1 /*
2  * Copyright 2008-2019 by Emeric Vernat
3  *
4  *     This file is part of Java Melody.
5  *
6  * Licensed under the Apache License, Version 2.0 (the "License");
7  * you may not use this file except in compliance with the License.
8  * You may obtain a copy of the License at
9  *
10  *     http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing, software
13  * distributed under the License is distributed on an "AS IS" BASIS,
14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15  * See the License for the specific language governing permissions and
16  * limitations under the License.
17  */

18 package net.bull.javamelody.internal.model;
19
20 import java.io.Serializable;
21 import java.sql.Connection;
22 import java.sql.DatabaseMetaData;
23 import java.sql.DriverManager;
24 import java.sql.PreparedStatement;
25 import java.sql.ResultSet;
26 import java.sql.ResultSetMetaData;
27 import java.sql.SQLException;
28 import java.sql.Statement;
29 import java.util.ArrayList;
30 import java.util.Arrays;
31 import java.util.Collection;
32 import java.util.Collections;
33 import java.util.List;
34 import java.util.Locale;
35 import java.util.ResourceBundle;
36
37 import javax.naming.NamingException;
38 import javax.sql.DataSource;
39
40 import net.bull.javamelody.JdbcWrapper;
41 import net.bull.javamelody.Parameter;
42 import net.bull.javamelody.internal.common.I18N;
43 import net.bull.javamelody.internal.common.Parameters;
44
45 /**
46  * Informations sur la base de données.
47  * @author Emeric Vernat
48  */

49 public class DatabaseInformations implements Serializable {
50     private static final long serialVersionUID = -6105478981257689782L;
51
52     enum Database {
53         // base de données connues avec les noms retournés par connection.getMetaData().getDatabaseProductName()
54         // (inspirés par Hibernate)
55         POSTGRESQL("PostgreSQL"),
56         MYSQL("MySQL"),
57         MYSQL4("MySQL"),
58         MARIADB("MariaDB"),
59         ORACLE("Oracle"),
60         DB2("DB2 UDB for AS/400""DB2/"),
61         H2("H2"),
62         HSQLDB("HSQL Database Engine"),
63         SQLSERVER("Microsoft SQL Server"),
64         SYBASE("Sybase SQL Server""Adaptive Server Enterprise"),
65         INFORMIX("Informix Dynamic Server"),
66         SQLITE("SQLite");
67
68         // RESOURCE_BUNDLE_BASE_NAME vaut "net.bull.javamelody.resource.databaseInformations"
69         // ce qui charge net.bull.javamelody.resource.databaseInformations.properties
70         // (Parameters.getResourcePath("databaseInformations") seul ne fonctionne pas si on est dans un jar/war)
71         private static final String RESOURCE_BUNDLE_BASE_NAME = Parameters
72                 .getResourcePath("databaseInformations").replace('/', '.').substring(1);
73
74         private final List<String> databaseNames;
75
76         Database(String... databaseNames) {
77             this.databaseNames = Arrays.asList(databaseNames);
78         }
79
80         // CHECKSTYLE:OFF
81         List<String> getRequestNames() {
82             // CHECKSTYLE:ON
83             final List<String> tmp;
84             switch (this) {
85             case POSTGRESQL:
86                 tmp = Arrays.asList("pg_stat_activity""pg_locks""pg_database""pg_tablespace",
87                         "pg_stat_database""pg_stat_user_tables""pg_stat_user_indexes",
88                         "pg_statio_user_tables""pg_statio_user_indexes",
89                         "pg_statio_user_sequences""pg_settings");
90                 break;
91             case MYSQL:
92             case MARIADB:
93                 tmp = Arrays.asList("processlist""databases""variables""global_status",
94                         "innodb_status""unusedIndexes""longRunning""tableStats",
95                         "eventsWaits""tableIoWaits""indexIoWaits""tableLockWaits",
96                         "tablesWithoutPk""perfDigests""memory");
97                 break;
98             case MYSQL4:
99                 // les noms des premières requêtes sont les mêmes, mais la requête SQL correspondant à "innodb_status"
100                 // n'est pas identique entre MYSQL 5+ et MYSQL 4 (issue 195)
101                 tmp = Arrays.asList("processlist""databases""variables""global_status",
102                         "innodb_status");
103                 break;
104             case ORACLE:
105                 tmp = Arrays.asList("sessions""locks""sqlTimes""foreignKeysWithoutIndexes",
106                         "invalidObjects""disabledConstraints""tableStats""instance",
107                         "database""nlsParameters""tablespaceFreespace""datafileIo",
108                         "tablespaceExtents""ratios""parameters""rollbackSegmentStatistics",
109                         "statistics""events");
110                 break;
111             case DB2:
112                 tmp = Arrays.asList("mon_current_sql""mon_db_summary""mon_lockwaits",
113                         "mon_service_subclass_summary""mon_current_uow""mon_workload_summary",
114                         "mon_get_connection""current_queries");
115                 break;
116             case H2:
117                 tmp = Arrays.asList("memory""sessions""locks""settings");
118                 break;
119             case HSQLDB:
120                 tmp = Arrays.asList("system_sessions""system_cacheinfo""system_properties",
121                         "system_schemas");
122                 break;
123             case SQLSERVER:
124                 tmp = Arrays.asList("version""connections");
125                 break;
126             case SYBASE:
127                 tmp = Arrays.asList("sp_who""connections""sp_lock""lock",
128                         "running_stored_procedure""used_temporary_tables""used_tables",
129                         "sp_version");
130                 break;
131             case INFORMIX:
132                 tmp = Arrays.asList("version""sessions""resources_by_user""current_queries",
133                         "config");
134                 break;
135             case SQLITE:
136                 tmp = Arrays.asList("version""database_list");
137                 break;
138             default:
139                 throw new IllegalStateException();
140             }
141             return addPrefix(tmp);
142         }
143
144         private List<String> addPrefix(List<String> requests) {
145             final List<String> list = new ArrayList<>(requests.size());
146             final String prefix = this.toString().toLowerCase(Locale.ENGLISH) + '.';
147             for (final String requestName : requests) {
148                 list.add(prefix + requestName);
149             }
150             return list;
151         }
152
153         String getUrlIdentifier() {
154             if (this == MYSQL4) {
155                 return MYSQL.toString().toLowerCase(Locale.ENGLISH);
156             }
157             return this.toString().toLowerCase(Locale.ENGLISH);
158         }
159
160         String getRequestByName(String requestName) {
161             return ResourceBundle.getBundle(RESOURCE_BUNDLE_BASE_NAME).getString(requestName);
162         }
163
164         List<String> getDatabaseNames() {
165             return databaseNames;
166         }
167
168         private boolean isRecognized(String databaseName, String url) {
169             for (final String name : getDatabaseNames()) {
170                 if (databaseName.startsWith(name)) {
171                     return true;
172                 }
173             }
174             return url != null && url.contains(getUrlIdentifier());
175         }
176
177         static Database getDatabaseForConnection(Connection connection) throws SQLException {
178             final DatabaseMetaData metaData = connection.getMetaData();
179             final String databaseName = metaData.getDatabaseProductName();
180             final String url = metaData.getURL();
181             for (final Database database : values()) {
182                 if (database.isRecognized(databaseName, url)) {
183                     if (database == MYSQL && metaData.getDatabaseMajorVersion() <= 4) {
184                         // si mysql et version 4 alors c'est MYSQL4 et non MYSQL
185                         return MYSQL4;
186                     }
187                     return database;
188                 }
189             }
190             throw new IllegalArgumentException(
191                     I18N.getFormattedString("type_base_de_donnees_inconnu", databaseName));
192         }
193     }
194
195     private final Database database;
196     @SuppressWarnings("all")
197     private final List<String> requestNames;
198     private final int selectedRequestIndex;
199     private final String[][] result;
200
201     public DatabaseInformations(int selectedRequestIndex) throws SQLException, NamingException {
202         super();
203         this.selectedRequestIndex = selectedRequestIndex;
204         final Connection connection = getConnection();
205         assert connection != null;
206         try {
207             database = Database.getDatabaseForConnection(connection);
208             requestNames = database.getRequestNames();
209             final String request = database
210                     .getRequestByName(requestNames.get(selectedRequestIndex));
211             result = executeRequest(connection, request, null);
212         } finally {
213             connection.close();
214         }
215     }
216
217     public static int parseRequestIndex(String requestIndex) {
218         if (requestIndex != null) {
219             return Integer.parseInt(requestIndex);
220         }
221         return 0;
222     }
223
224     public int getNbColumns() {
225         final String selectedRequestName = getSelectedRequestName();
226         if ("oracle.statistics".equals(selectedRequestName)) {
227             return 2;
228         } else if ("oracle.events".equals(selectedRequestName)) {
229             return 2;
230         } else if ("mysql.variables".equals(selectedRequestName)) {
231             return 2;
232         } else if ("mysql.global_status".equals(selectedRequestName)) {
233             return 4;
234         } else if ("h2.settings".equals(selectedRequestName)) {
235             return 2;
236         }
237         return 1;
238     }
239
240     public int getSelectedRequestIndex() {
241         return selectedRequestIndex;
242     }
243
244     public String getSelectedRequestName() {
245         return requestNames.get(getSelectedRequestIndex());
246     }
247
248     public String[][] getResult() {
249         return result; // NOPMD
250     }
251
252     public List<String> getRequestNames() {
253         return requestNames;
254     }
255
256     private static String[][] executeRequest(Connection connection, String request,
257             List<?> parametersValues) throws SQLException {
258         try (PreparedStatement statement = connection.prepareStatement(request)) {
259             if (parametersValues != null) {
260                 int i = 1;
261                 for (final Object parameterValue : parametersValues) {
262                     statement.setObject(i, parameterValue);
263                     i++;
264                 }
265             }
266             return executeQuery(statement);
267         } catch (final SQLException e) {
268             if (e.getErrorCode() == 942 && e.getMessage() != null
269                     && e.getMessage().startsWith("ORA-")) {
270                 final String userName = connection.getMetaData().getUserName();
271                 final String message = I18N.getFormattedString("oracle.grantSelectAnyDictionnary",
272                         userName);
273                 throw new SQLException(message, e);
274             }
275             throw e;
276         }
277     }
278
279     private static String[][] executeQuery(PreparedStatement statement) throws SQLException {
280         try (ResultSet resultSet = statement.executeQuery()) {
281             final ResultSetMetaData metaData = resultSet.getMetaData();
282             final int columnCount = metaData.getColumnCount();
283             final List<String[]> list = new ArrayList<>();
284             String[] values = new String[columnCount];
285             for (int i = 1; i <= columnCount; i++) {
286                 values[i - 1] = metaData.getColumnName(i) + '\n' + metaData.getColumnTypeName(i)
287                         + '(' + metaData.getColumnDisplaySize(i) + ')';
288             }
289             list.add(values);
290
291             while (resultSet.next()) {
292                 values = new String[columnCount];
293                 for (int i = 1; i <= columnCount; i++) {
294                     values[i - 1] = resultSet.getString(i);
295                 }
296                 list.add(values);
297             }
298             return list.toArray(new String[0][]);
299         }
300     }
301
302     private static Connection getConnection() throws SQLException, NamingException {
303         // on commence par voir si le driver jdbc a été utilisé
304         // car s'il n'y a pas de datasource une exception est déclenchée
305         if (Parameters.getLastConnectUrl() != null) {
306             final Connection connection = DriverManager
307                     .getConnection(Parameters.getLastConnectUrl(), Parameters.getLastConnectInfo());
308             connection.setAutoCommit(false);
309             return connection;
310         }
311
312         // on cherche une datasource avec InitialContext
313         // (le nom de la dataSource recherchée dans JNDI est du genre jdbc/Xxx qui est le nom standard d'une DataSource)
314         final Collection<DataSource> dataSources = JdbcWrapper.getJndiAndSpringDataSources()
315                 .values();
316         for (final DataSource dataSource : dataSources) {
317             try {
318                 return dataSource.getConnection();
319                 // on ne doit pas changer autoCommit pour la connection d'une DataSource
320                 // (ou alors il faudrait remettre l'autoCommit après, issue 189)
321                 // connection.setAutoCommit(false);
322             } catch (final Exception e) {
323                 // si cette dataSource ne fonctionne pas, on suppose que la bonne dataSource est une des suivantes
324                 // (par exemple, sur GlassFish il y a des dataSources par défaut qui ne fonctionne pas forcément)
325                 continue;
326             }
327         }
328         if (!dataSources.isEmpty()) {
329             // this will probably throw an exception like above
330             return dataSources.iterator().next().getConnection();
331         }
332         return null;
333     }
334
335     public static String explainPlanFor(String sqlRequest) throws SQLException, NamingException {
336         final Connection connection = getConnection();
337         if (connection != null) {
338             try {
339                 final Database database = Database.getDatabaseForConnection(connection);
340                 if (database == Database.ORACLE) {
341                     // Si oracle, on demande le plan d'exécution avec la table PLAN_TABLE par défaut
342                     // avec "explain plan set statement_id = <statement_id> for ..."
343                     // (si mysql ou postgresql on pourrait faire "explain ...",
344                     // sauf que les paramètres bindés ne seraient pas acceptés
345                     // et les requêtes update/insert/delete non plus).
346                     // (si db2, la syntaxe serait "explain plan for ...")
347
348                     // Si mysql il suffit de lire le ResultSet de executeQuery("explain ...")
349                     // qui pourrait être affiché en tableau à partir de String[][],
350                     // mais en oracle il faut aller lire la table plan_table
351                     // (http://www.java2s.com/Open-Source/Java-Document/Database-Client/squirrel-sql-2.6.5a/net/sourceforge/squirrel_sql/plugins/oracle/explainplan/ExplainPlanExecuter.java.htm)
352                     // le hashCode est une clé suffisamment unique car il y a peu de plans d'exécution
353                     // affichés simultanément, et en tout cas CounterRequest.getId() est trop long
354                     // pour la table oracle par défaut (SYS.PLAN_TABLE$.STATEMENT_ID a une longueur de 30)
355                     final String statementId = String.valueOf(sqlRequest.hashCode());
356                     final String explainRequest = buildExplainRequest(sqlRequest, statementId);
357                     // exécution de la demande
358                     try (Statement statement = connection.createStatement()) {
359                         statement.execute(explainRequest);
360                     }
361
362                     // récupération du résultat
363                     return getPlanOutput(connection, statementId);
364                 }
365             } finally {
366                 if (!connection.getAutoCommit()) {
367                     connection.rollback();
368                 }
369                 connection.close();
370             }
371         }
372         return null;
373     }
374
375     private static String buildExplainRequest(String sqlRequest, String statementId) {
376         // rq : il semble qu'une requête explain plan ne puisse avoir la requête en paramètre bindé
377         // (donc les requêtes "explain ..." seront ignorées dans JdbcWrapper)
378         int i = 1;
379         String request = sqlRequest;
380         if (Parameter.SQL_TRANSFORM_PATTERN.getValue() != null) {
381             // si les requêtes SQL peuvent avoir été transformées par SQL_TRANSFORM_PATTERN,
382             // alors on remplace le '$' par '?' en espérant avec un plan d'exécution même simplifié
383             // (sinon, il serait impossible d'avoir un plan d'exécution pour certaines requêtes SQL
384             // transformées par SQL_TRANSFORM_PATTERN)
385             request = request.replace(Counter.TRANSFORM_REPLACEMENT_CHAR, '?');
386         }
387         // utilisation de la table PLAN_TABLE par défaut
388         // (il faut que cette table soit créée auparavant dans oracle
389         // et elle peut être créée par : @$ORACLE_HOME/rdbms/admin/catplan.sql
390         // ou par @$ORACLE_HOME/rdbms/admin/utlxplan.sql si oracle 9g ou avant)
391         String explainRequest = "explain plan set statement_id = '" + statementId + "' for "
392                 + request;
393
394         // dans le cas où la requête contient ';' (requêtes multiples), je ne sais pas si explain
395         // plan considère que cela fait partie de la requête à analyser où si certaines versions
396         // d'oracle considèrent que cela vient après l'explain plan; par sécurité on interdit cela
397         if (explainRequest.indexOf(';') != -1) {
398             explainRequest = explainRequest.substring(0, explainRequest.indexOf(';'));
399         }
400
401         // on remplace les paramètres bindés "?" par ":n"
402         int index = explainRequest.indexOf('?');
403         while (index != -1) {
404             explainRequest = explainRequest.substring(0, index) + ':' + i
405                     + explainRequest.substring(index + 1);
406             i++;
407             index = explainRequest.indexOf('?');
408         }
409         return explainRequest;
410     }
411
412     private static String getPlanOutput(Connection connection, String statementId)
413             throws SQLException {
414         // table PLAN_TABLE par défaut et format par défaut
415         final String planTableRequest = "select * from table(dbms_xplan.display(null,?, null))";
416         final String[][] planTableOutput = executeRequest(connection, planTableRequest,
417                 Collections.singletonList(statementId));
418         final StringBuilder sb = new StringBuilder();
419         for (final String[] row : planTableOutput) {
420             for (final String value : row) {
421                 sb.append(value);
422             }
423             sb.append('\n');
424         }
425         if (sb.indexOf("-") != -1) {
426             sb.delete(0, sb.indexOf("-"));
427         }
428         return sb.toString();
429     }
430
431     /** {@inheritDoc} */
432     @Override
433     public String toString() {
434         return getClass().getSimpleName() + "[database=" + database + ']';
435     }
436 }
437