001package ca.uhn.fhir.jpa.migrate;
002
003/*-
004 * #%L
005 * HAPI FHIR Server - SQL Migration
006 * %%
007 * Copyright (C) 2014 - 2022 Smile CDR, Inc.
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 *
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 *
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.jpa.migrate.taskdef.ColumnTypeEnum;
025import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
026import org.apache.commons.lang3.builder.EqualsBuilder;
027import org.apache.commons.lang3.builder.HashCodeBuilder;
028import org.apache.commons.lang3.builder.ToStringBuilder;
029import org.hibernate.boot.model.naming.Identifier;
030import org.hibernate.dialect.Dialect;
031import org.hibernate.engine.jdbc.dialect.internal.StandardDialectResolver;
032import org.hibernate.engine.jdbc.dialect.spi.DatabaseMetaDataDialectResolutionInfoAdapter;
033import org.hibernate.engine.jdbc.dialect.spi.DialectResolver;
034import org.hibernate.engine.jdbc.env.internal.NormalizingIdentifierHelperImpl;
035import org.hibernate.engine.jdbc.env.spi.ExtractedDatabaseMetaData;
036import org.hibernate.engine.jdbc.env.spi.IdentifierHelper;
037import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment;
038import org.hibernate.engine.jdbc.env.spi.LobCreatorBuilder;
039import org.hibernate.engine.jdbc.env.spi.NameQualifierSupport;
040import org.hibernate.engine.jdbc.env.spi.QualifiedObjectNameFormatter;
041import org.hibernate.engine.jdbc.spi.SqlExceptionHelper;
042import org.hibernate.engine.jdbc.spi.TypeInfo;
043import org.hibernate.service.ServiceRegistry;
044import org.hibernate.tool.schema.extract.spi.ExtractionContext;
045import org.hibernate.tool.schema.extract.spi.SequenceInformation;
046import org.hibernate.tool.schema.extract.spi.SequenceInformationExtractor;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049import org.springframework.jdbc.core.ColumnMapRowMapper;
050import org.springframework.transaction.support.TransactionTemplate;
051
052import javax.annotation.Nullable;
053import javax.sql.DataSource;
054import java.sql.Connection;
055import java.sql.DatabaseMetaData;
056import java.sql.ResultSet;
057import java.sql.SQLException;
058import java.sql.Types;
059import java.util.ArrayList;
060import java.util.Collections;
061import java.util.HashSet;
062import java.util.List;
063import java.util.Locale;
064import java.util.Objects;
065import java.util.Set;
066import java.util.stream.Collectors;
067
068public class JdbcUtils {
069        private static final Logger ourLog = LoggerFactory.getLogger(JdbcUtils.class);
070
071        /**
072         * Retrieve all index names
073         */
074        public static Set<String> getIndexNames(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theTableName) throws SQLException {
075
076                if (!getTableNames(theConnectionProperties).contains(theTableName)) {
077                        return Collections.emptySet();
078                }
079
080                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
081                try (Connection connection = dataSource.getConnection()) {
082                        return theConnectionProperties.getTxTemplate().execute(t -> {
083                                DatabaseMetaData metadata;
084                                try {
085                                        metadata = connection.getMetaData();
086
087                                        ResultSet indexes = getIndexInfo(theTableName, connection, metadata, false);
088                                        Set<String> indexNames = new HashSet<>();
089                                        while (indexes.next()) {
090                                                ourLog.debug("*** Next index: {}", new ColumnMapRowMapper().mapRow(indexes, 0));
091                                                String indexName = indexes.getString("INDEX_NAME");
092                                                indexNames.add(indexName);
093                                        }
094
095                                        indexes = getIndexInfo(theTableName, connection, metadata, true);
096                                        while (indexes.next()) {
097                                                ourLog.debug("*** Next index: {}", new ColumnMapRowMapper().mapRow(indexes, 0));
098                                                String indexName = indexes.getString("INDEX_NAME");
099                                                indexNames.add(indexName);
100                                        }
101
102                                        indexNames = indexNames
103                                                .stream()
104                                                .filter(Objects::nonNull)       // filter out the nulls first
105                                                .map(s -> s.toUpperCase(Locale.US)) // then convert the non-null entries to upper case
106                                                .collect(Collectors.toSet());
107
108                                        return indexNames;
109
110                                } catch (SQLException e) {
111                                        throw new InternalErrorException(Msg.code(29) + e);
112                                }
113                        });
114                }
115        }
116
117        @SuppressWarnings("ConstantConditions")
118        public static boolean isIndexUnique(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theTableName, String theIndexName) throws SQLException {
119                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
120                try (Connection connection = dataSource.getConnection()) {
121                        return theConnectionProperties.getTxTemplate().execute(t -> {
122                                DatabaseMetaData metadata;
123                                try {
124                                        metadata = connection.getMetaData();
125                                        ResultSet indexes = getIndexInfo(theTableName, connection, metadata, false);
126
127                                        while (indexes.next()) {
128                                                String indexName = indexes.getString("INDEX_NAME");
129                                                if (theIndexName.equalsIgnoreCase(indexName)) {
130                                                        boolean nonUnique = indexes.getBoolean("NON_UNIQUE");
131                                                        return !nonUnique;
132                                                }
133                                        }
134
135                                } catch (SQLException e) {
136                                        throw new InternalErrorException(Msg.code(30) + e);
137                                }
138
139                                throw new InternalErrorException(Msg.code(31) + "Can't find index: " + theIndexName + " on table " + theTableName);
140                        });
141                }
142        }
143
144        private static ResultSet getIndexInfo(String theTableName, Connection theConnection, DatabaseMetaData theMetadata, boolean theUnique) throws SQLException {
145                // FYI Using approximate=false causes a very slow table scan on Oracle
146                boolean approximate = true;
147                return theMetadata.getIndexInfo(theConnection.getCatalog(), theConnection.getSchema(), massageIdentifier(theMetadata, theTableName), theUnique, approximate);
148        }
149
150        /**
151         * Retrieve all index names
152         */
153        public static ColumnType getColumnType(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theTableName, String theColumnName) throws SQLException {
154                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
155                try (Connection connection = dataSource.getConnection()) {
156                        return theConnectionProperties.getTxTemplate().execute(t -> {
157                                DatabaseMetaData metadata;
158                                try {
159                                        metadata = connection.getMetaData();
160                                        String catalog = connection.getCatalog();
161                                        String schema = connection.getSchema();
162                                        ResultSet indexes = metadata.getColumns(catalog, schema, massageIdentifier(metadata, theTableName), null);
163
164                                        while (indexes.next()) {
165
166                                                String tableName = indexes.getString("TABLE_NAME").toUpperCase(Locale.US);
167                                                if (!theTableName.equalsIgnoreCase(tableName)) {
168                                                        continue;
169                                                }
170                                                String columnName = indexes.getString("COLUMN_NAME").toUpperCase(Locale.US);
171                                                if (!theColumnName.equalsIgnoreCase(columnName)) {
172                                                        continue;
173                                                }
174
175                                                int dataType = indexes.getInt("DATA_TYPE");
176                                                Long length = indexes.getLong("COLUMN_SIZE");
177                                                switch (dataType) {
178                                                        case Types.BIT:
179                                                        case Types.BOOLEAN:
180                                                                return new ColumnType(ColumnTypeEnum.BOOLEAN, length);
181                                                        case Types.VARCHAR:
182                                                                return new ColumnType(ColumnTypeEnum.STRING, length);
183                                                        case Types.NUMERIC:
184                                                        case Types.BIGINT:
185                                                        case Types.DECIMAL:
186                                                                return new ColumnType(ColumnTypeEnum.LONG, length);
187                                                        case Types.INTEGER:
188                                                                return new ColumnType(ColumnTypeEnum.INT, length);
189                                                        case Types.TIMESTAMP:
190                                                        case Types.TIMESTAMP_WITH_TIMEZONE:
191                                                                return new ColumnType(ColumnTypeEnum.DATE_TIMESTAMP, length);
192                                                        case Types.BLOB:
193                                                                return new ColumnType(ColumnTypeEnum.BLOB, length);
194                                                        case Types.LONGVARBINARY:
195                                                                if (DriverTypeEnum.MYSQL_5_7.equals(theConnectionProperties.getDriverType())) {
196                                                                        //See git
197                                                                        return new ColumnType(ColumnTypeEnum.BLOB, length);
198                                                                } else {
199                                                                        throw new IllegalArgumentException(Msg.code(32) + "Don't know how to handle datatype " + dataType + " for column " + theColumnName + " on table " + theTableName);
200                                                                }
201                                                        case Types.VARBINARY:
202                                                                if (DriverTypeEnum.MSSQL_2012.equals(theConnectionProperties.getDriverType())) {
203                                                                        // MS SQLServer seems to be mapping BLOB to VARBINARY under the covers, so we need to reverse that mapping
204                                                                        return new ColumnType(ColumnTypeEnum.BLOB, length);
205
206                                                                } else {
207                                                                        throw new IllegalArgumentException(Msg.code(33) + "Don't know how to handle datatype " + dataType + " for column " + theColumnName + " on table " + theTableName);
208                                                                }
209                                                        case Types.CLOB:
210                                                                return new ColumnType(ColumnTypeEnum.CLOB, length);
211                                                        case Types.DOUBLE:
212                                                                return new ColumnType(ColumnTypeEnum.DOUBLE, length);
213                                                        case Types.FLOAT:
214                                                                return new ColumnType(ColumnTypeEnum.FLOAT, length);
215                                                        default:
216                                                                throw new IllegalArgumentException(Msg.code(34) + "Don't know how to handle datatype " + dataType + " for column " + theColumnName + " on table " + theTableName);
217                                                }
218
219                                        }
220
221                                        ourLog.debug("Unable to find column {} in table {}.", theColumnName, theTableName);
222                                        return null;
223
224                                } catch (SQLException e) {
225                                        throw new InternalErrorException(Msg.code(35) + e);
226                                }
227
228                        });
229                }
230        }
231
232        /**
233         * Retrieve all index names
234         */
235        public static Set<String> getForeignKeys(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theTableName, @Nullable String theForeignTable) throws SQLException {
236                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
237
238                try (Connection connection = dataSource.getConnection()) {
239                        TransactionTemplate txTemplate = theConnectionProperties.getTxTemplate();
240                        return txTemplate.execute(t -> {
241                                DatabaseMetaData metadata;
242                                try {
243                                        metadata = connection.getMetaData();
244                                        String catalog = connection.getCatalog();
245                                        String schema = connection.getSchema();
246
247
248                                        List<String> parentTables = new ArrayList<>();
249                                        if (theTableName != null) {
250                                                parentTables.add(massageIdentifier(metadata, theTableName));
251                                        } else {
252                                                // If no foreign table is specified, we'll try all of them
253                                                parentTables.addAll(JdbcUtils.getTableNames(theConnectionProperties));
254                                        }
255
256                                        String foreignTable = massageIdentifier(metadata, theForeignTable);
257
258                                        Set<String> fkNames = new HashSet<>();
259                                        for (String nextParentTable : parentTables) {
260                                                ResultSet indexes = metadata.getCrossReference(catalog, schema, nextParentTable, catalog, schema, foreignTable);
261
262                                                while (indexes.next()) {
263                                                        String fkName = indexes.getString("FK_NAME");
264                                                        fkName = fkName.toUpperCase(Locale.US);
265                                                        fkNames.add(fkName);
266                                                }
267                                        }
268
269                                        return fkNames;
270                                } catch (SQLException e) {
271                                        throw new InternalErrorException(Msg.code(36) + e);
272                                }
273                        });
274                }
275        }
276
277        /**
278         * Retrieve names of foreign keys that reference a specified foreign key column.
279         */
280        public static Set<String> getForeignKeysForColumn(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theForeignKeyColumn, String theForeignTable) throws SQLException {
281                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
282
283                try (Connection connection = dataSource.getConnection()) {
284                        return theConnectionProperties.getTxTemplate().execute(t -> {
285                                DatabaseMetaData metadata;
286                                try {
287                                        metadata = connection.getMetaData();
288                                        String catalog = connection.getCatalog();
289                                        String schema = connection.getSchema();
290
291
292                                        List<String> parentTables = new ArrayList<>();
293                                        parentTables.addAll(JdbcUtils.getTableNames(theConnectionProperties));
294
295                                        String foreignTable = massageIdentifier(metadata, theForeignTable);
296
297                                        Set<String> fkNames = new HashSet<>();
298                                        for (String nextParentTable : parentTables) {
299                                                ResultSet indexes = metadata.getCrossReference(catalog, schema, nextParentTable, catalog, schema, foreignTable);
300
301                                                while (indexes.next()) {
302                                                        if (theForeignKeyColumn.equals(indexes.getString("FKCOLUMN_NAME"))) {
303                                                                String fkName = indexes.getString("FK_NAME");
304                                                                fkName = fkName.toUpperCase(Locale.US);
305                                                                fkNames.add(fkName);
306                                                        }
307                                                }
308                                        }
309
310                                        return fkNames;
311                                } catch (SQLException e) {
312                                        throw new InternalErrorException(Msg.code(37) + e);
313                                }
314                        });
315                }
316        }
317
318        /**
319         * Retrieve all index names
320         */
321        public static Set<String> getColumnNames(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theTableName) throws SQLException {
322                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
323                try (Connection connection = dataSource.getConnection()) {
324                        return theConnectionProperties.getTxTemplate().execute(t -> {
325                                DatabaseMetaData metadata;
326                                try {
327                                        metadata = connection.getMetaData();
328                                        ResultSet indexes = metadata.getColumns(connection.getCatalog(), connection.getSchema(), massageIdentifier(metadata, theTableName), null);
329
330                                        Set<String> columnNames = new HashSet<>();
331                                        while (indexes.next()) {
332                                                String tableName = indexes.getString("TABLE_NAME").toUpperCase(Locale.US);
333                                                if (!theTableName.equalsIgnoreCase(tableName)) {
334                                                        continue;
335                                                }
336
337                                                String columnName = indexes.getString("COLUMN_NAME");
338                                                columnName = columnName.toUpperCase(Locale.US);
339                                                columnNames.add(columnName);
340                                        }
341
342                                        return columnNames;
343                                } catch (SQLException e) {
344                                        throw new InternalErrorException(Msg.code(38) + e);
345                                }
346                        });
347                }
348        }
349
350        public static Set<String> getSequenceNames(DriverTypeEnum.ConnectionProperties theConnectionProperties) throws SQLException {
351                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
352                try (Connection connection = dataSource.getConnection()) {
353                        return theConnectionProperties.getTxTemplate().execute(t -> {
354                                try {
355                                        DialectResolver dialectResolver = new StandardDialectResolver();
356                                        Dialect dialect = dialectResolver.resolveDialect(new DatabaseMetaDataDialectResolutionInfoAdapter(connection.getMetaData()));
357
358                                        Set<String> sequenceNames = new HashSet<>();
359                                        if (dialect.supportsSequences()) {
360
361                                                // Use Hibernate to get a list of current sequences
362                                                SequenceInformationExtractor sequenceInformationExtractor = dialect.getSequenceInformationExtractor();
363                                                ExtractionContext extractionContext = new ExtractionContext.EmptyExtractionContext() {
364                                                        @Override
365                                                        public Connection getJdbcConnection() {
366                                                                return connection;
367                                                        }
368
369                                                        @Override
370                                                        public ServiceRegistry getServiceRegistry() {
371                                                                return super.getServiceRegistry();
372                                                        }
373
374                                                        @Override
375                                                        public JdbcEnvironment getJdbcEnvironment() {
376                                                                return new JdbcEnvironment() {
377                                                                        @Override
378                                                                        public Dialect getDialect() {
379                                                                                return dialect;
380                                                                        }
381
382                                                                        @Override
383                                                                        public ExtractedDatabaseMetaData getExtractedDatabaseMetaData() {
384                                                                                return null;
385                                                                        }
386
387                                                                        @Override
388                                                                        public Identifier getCurrentCatalog() {
389                                                                                return null;
390                                                                        }
391
392                                                                        @Override
393                                                                        public Identifier getCurrentSchema() {
394                                                                                return null;
395                                                                        }
396
397                                                                        @Override
398                                                                        public QualifiedObjectNameFormatter getQualifiedObjectNameFormatter() {
399                                                                                return null;
400                                                                        }
401
402                                                                        @Override
403                                                                        public IdentifierHelper getIdentifierHelper() {
404                                                                                return new NormalizingIdentifierHelperImpl(this, null, true, true, true, null, null, null);
405                                                                        }
406
407                                                                        @Override
408                                                                        public NameQualifierSupport getNameQualifierSupport() {
409                                                                                return null;
410                                                                        }
411
412                                                                        @Override
413                                                                        public SqlExceptionHelper getSqlExceptionHelper() {
414                                                                                return null;
415                                                                        }
416
417                                                                        @Override
418                                                                        public LobCreatorBuilder getLobCreatorBuilder() {
419                                                                                return null;
420                                                                        }
421
422                                                                        @Override
423                                                                        public TypeInfo getTypeInfoForJdbcCode(int jdbcTypeCode) {
424                                                                                return null;
425                                                                        }
426                                                                };
427                                                        }
428                                                };
429                                                Iterable<SequenceInformation> sequences = sequenceInformationExtractor.extractMetadata(extractionContext);
430                                                for (SequenceInformation next : sequences) {
431                                                        sequenceNames.add(next.getSequenceName().getSequenceName().getText());
432                                                }
433
434                                        }
435                                        return sequenceNames;
436                                } catch (SQLException e) {
437                                        throw new InternalErrorException(Msg.code(39) + e);
438                                }
439                        });
440                }
441        }
442
443        public static Set<String> getTableNames(DriverTypeEnum.ConnectionProperties theConnectionProperties) throws SQLException {
444                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
445                try (Connection connection = dataSource.getConnection()) {
446                        return theConnectionProperties.getTxTemplate().execute(t -> {
447                                DatabaseMetaData metadata;
448                                try {
449                                        metadata = connection.getMetaData();
450                                        ResultSet tables = metadata.getTables(connection.getCatalog(), connection.getSchema(), null, null);
451
452                                        Set<String> columnNames = new HashSet<>();
453                                        while (tables.next()) {
454                                                String tableName = tables.getString("TABLE_NAME");
455                                                tableName = tableName.toUpperCase(Locale.US);
456
457                                                String tableType = tables.getString("TABLE_TYPE");
458                                                if ("SYSTEM TABLE".equalsIgnoreCase(tableType)) {
459                                                        continue;
460                                                }
461                                                if (SchemaMigrator.HAPI_FHIR_MIGRATION_TABLENAME.equalsIgnoreCase(tableName)) {
462                                                        continue;
463                                                }
464
465                                                columnNames.add(tableName);
466                                        }
467
468                                        return columnNames;
469                                } catch (SQLException e) {
470                                        throw new InternalErrorException(Msg.code(40) + e);
471                                }
472                        });
473                }
474        }
475
476        public static boolean isColumnNullable(DriverTypeEnum.ConnectionProperties theConnectionProperties, String theTableName, String theColumnName) throws SQLException {
477                DataSource dataSource = Objects.requireNonNull(theConnectionProperties.getDataSource());
478                try (Connection connection = dataSource.getConnection()) {
479                        //noinspection ConstantConditions
480                        return theConnectionProperties.getTxTemplate().execute(t -> {
481                                DatabaseMetaData metadata;
482                                try {
483                                        metadata = connection.getMetaData();
484                                        ResultSet tables = metadata.getColumns(connection.getCatalog(), connection.getSchema(), massageIdentifier(metadata, theTableName), null);
485
486                                        while (tables.next()) {
487                                                String tableName = tables.getString("TABLE_NAME").toUpperCase(Locale.US);
488                                                if (!theTableName.equalsIgnoreCase(tableName)) {
489                                                        continue;
490                                                }
491
492                                                if (theColumnName.equalsIgnoreCase(tables.getString("COLUMN_NAME"))) {
493                                                        String nullable = tables.getString("IS_NULLABLE");
494                                                        if ("YES".equalsIgnoreCase(nullable)) {
495                                                                return true;
496                                                        } else if ("NO".equalsIgnoreCase(nullable)) {
497                                                                return false;
498                                                        } else {
499                                                                throw new IllegalStateException(Msg.code(41) + "Unknown nullable: " + nullable);
500                                                        }
501                                                }
502                                        }
503
504                                        throw new IllegalStateException(Msg.code(42) + "Did not find column " + theColumnName);
505                                } catch (SQLException e) {
506                                        throw new InternalErrorException(Msg.code(43) + e);
507                                }
508                        });
509                }
510        }
511
512        private static String massageIdentifier(DatabaseMetaData theMetadata, String theCatalog) throws SQLException {
513                String retVal = theCatalog;
514                if (theCatalog == null) {
515                        return null;
516                } else if (theMetadata.storesLowerCaseIdentifiers()) {
517                        retVal = retVal.toLowerCase();
518                } else {
519                        retVal = retVal.toUpperCase();
520                }
521                return retVal;
522        }
523
524        public static class ColumnType {
525                private final ColumnTypeEnum myColumnTypeEnum;
526                private final Long myLength;
527
528                public ColumnType(ColumnTypeEnum theColumnType, Long theLength) {
529                        myColumnTypeEnum = theColumnType;
530                        myLength = theLength;
531                }
532
533                public ColumnType(ColumnTypeEnum theColumnType, int theLength) {
534                        this(theColumnType, (long) theLength);
535                }
536
537                public ColumnType(ColumnTypeEnum theColumnType) {
538                        this(theColumnType, null);
539                }
540
541                @Override
542                public boolean equals(Object theO) {
543                        if (this == theO) {
544                                return true;
545                        }
546
547                        if (theO == null || getClass() != theO.getClass()) {
548                                return false;
549                        }
550
551                        ColumnType that = (ColumnType) theO;
552
553                        return new EqualsBuilder()
554                                .append(myColumnTypeEnum, that.myColumnTypeEnum)
555                                .append(myLength, that.myLength)
556                                .isEquals();
557                }
558
559                @Override
560                public int hashCode() {
561                        return new HashCodeBuilder(17, 37)
562                                .append(myColumnTypeEnum)
563                                .append(myLength)
564                                .toHashCode();
565                }
566
567                @Override
568                public String toString() {
569                        ToStringBuilder b = new ToStringBuilder(this);
570                        b.append("type", myColumnTypeEnum);
571                        if (myLength != null) {
572                                b.append("length", myLength);
573                        }
574                        return b.toString();
575                }
576
577                public ColumnTypeEnum getColumnTypeEnum() {
578                        return myColumnTypeEnum;
579                }
580
581                public Long getLength() {
582                        return myLength;
583                }
584
585                public boolean equals(ColumnTypeEnum theTaskColumnType, Long theTaskColumnLength) {
586                        ourLog.debug("Comparing existing {} {} to new {} {}", myColumnTypeEnum, myLength, theTaskColumnType, theTaskColumnLength);
587                        return myColumnTypeEnum == theTaskColumnType && (theTaskColumnLength == null || theTaskColumnLength.equals(myLength));
588                }
589        }
590}