diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index 472fbc0e4d..f54f4b824d 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -414,6 +414,7 @@ public final class org/jetbrains/exposed/sql/CheckConstraint : org/jetbrains/exp } public final class org/jetbrains/exposed/sql/CheckConstraint$Companion { + public final fun from (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Lorg/jetbrains/exposed/sql/Op;)Lorg/jetbrains/exposed/sql/CheckConstraint; } public final class org/jetbrains/exposed/sql/Coalesce : org/jetbrains/exposed/sql/Function { @@ -454,20 +455,22 @@ public final class org/jetbrains/exposed/sql/Column : org/jetbrains/exposed/sql/ public final class org/jetbrains/exposed/sql/ColumnDiff { public static final field Companion Lorg/jetbrains/exposed/sql/ColumnDiff$Companion; - public fun (ZZZZZ)V + public fun (ZZZZZZ)V public final fun component1 ()Z public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Z public final fun component5 ()Z - public final fun copy (ZZZZZ)Lorg/jetbrains/exposed/sql/ColumnDiff; - public static synthetic fun copy$default (Lorg/jetbrains/exposed/sql/ColumnDiff;ZZZZZILjava/lang/Object;)Lorg/jetbrains/exposed/sql/ColumnDiff; + public final fun component6 ()Z + public final fun copy (ZZZZZZ)Lorg/jetbrains/exposed/sql/ColumnDiff; + public static synthetic fun copy$default (Lorg/jetbrains/exposed/sql/ColumnDiff;ZZZZZZILjava/lang/Object;)Lorg/jetbrains/exposed/sql/ColumnDiff; public fun equals (Ljava/lang/Object;)Z public final fun getAutoInc ()Z public final fun getCaseSensitiveName ()Z public final fun getDefaults ()Z public final fun getNullability ()Z public final fun getSizeAndScale ()Z + public final fun getType ()Z public final fun hasDifferences ()Z public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -2585,6 +2588,7 @@ public class org/jetbrains/exposed/sql/Table : org/jetbrains/exposed/sql/ColumnS public final fun check (Lorg/jetbrains/exposed/sql/Column;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lorg/jetbrains/exposed/sql/Column; public static synthetic fun check$default (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static synthetic fun check$default (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/sql/Column;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/Column; + public final fun checkConstraints ()Ljava/util/List; public final fun clientDefault (Lorg/jetbrains/exposed/sql/Column;Lkotlin/jvm/functions/Function0;)Lorg/jetbrains/exposed/sql/Column; public fun createStatement ()Ljava/util/List; public fun crossJoin (Lorg/jetbrains/exposed/sql/ColumnSet;)Lorg/jetbrains/exposed/sql/Join; @@ -3810,24 +3814,26 @@ public final class org/jetbrains/exposed/sql/transactions/experimental/Suspended } public final class org/jetbrains/exposed/sql/vendors/ColumnMetadata { - public fun (Ljava/lang/String;IZLjava/lang/Integer;Ljava/lang/Integer;ZLjava/lang/String;)V + public fun (Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Integer;Ljava/lang/Integer;ZLjava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I - public final fun component3 ()Z - public final fun component4 ()Ljava/lang/Integer; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z public final fun component5 ()Ljava/lang/Integer; - public final fun component6 ()Z - public final fun component7 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;IZLjava/lang/Integer;Ljava/lang/Integer;ZLjava/lang/String;)Lorg/jetbrains/exposed/sql/vendors/ColumnMetadata; - public static synthetic fun copy$default (Lorg/jetbrains/exposed/sql/vendors/ColumnMetadata;Ljava/lang/String;IZLjava/lang/Integer;Ljava/lang/Integer;ZLjava/lang/String;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/vendors/ColumnMetadata; + public final fun component6 ()Ljava/lang/Integer; + public final fun component7 ()Z + public final fun component8 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Integer;Ljava/lang/Integer;ZLjava/lang/String;)Lorg/jetbrains/exposed/sql/vendors/ColumnMetadata; + public static synthetic fun copy$default (Lorg/jetbrains/exposed/sql/vendors/ColumnMetadata;Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Integer;Ljava/lang/Integer;ZLjava/lang/String;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/vendors/ColumnMetadata; public fun equals (Ljava/lang/Object;)Z public final fun getAutoIncrement ()Z public final fun getDefaultDbValue ()Ljava/lang/String; + public final fun getJdbcType ()I public final fun getName ()Ljava/lang/String; public final fun getNullable ()Z public final fun getScale ()Ljava/lang/Integer; public final fun getSize ()Ljava/lang/Integer; - public final fun getType ()I + public final fun getSqlType ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -3877,6 +3883,7 @@ public abstract interface class org/jetbrains/exposed/sql/vendors/DatabaseDialec public abstract fun addPrimaryKey (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Column;)Ljava/lang/String; public abstract fun allTablesNames ()Ljava/util/List; public abstract fun allTablesNamesInAllSchemas ()Ljava/util/List; + public abstract fun areEquivalentColumnTypes (Ljava/lang/String;ILjava/lang/String;)Z public abstract fun catalog (Lorg/jetbrains/exposed/sql/Transaction;)Ljava/lang/String; public abstract fun checkTableMapping (Lorg/jetbrains/exposed/sql/Table;)Z public abstract fun columnConstraints ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; @@ -3886,9 +3893,13 @@ public abstract interface class org/jetbrains/exposed/sql/vendors/DatabaseDialec public abstract fun dropDatabase (Ljava/lang/String;)Ljava/lang/String; public abstract fun dropIndex (Ljava/lang/String;Ljava/lang/String;ZZ)Ljava/lang/String; public abstract fun dropSchema (Lorg/jetbrains/exposed/sql/Schema;Z)Ljava/lang/String; + public abstract fun equivalentColumnTypesPairs ()Ljava/util/Set; + public abstract fun existingCheckConstraints ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public abstract fun existingIndices ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public abstract fun existingPrimaryKeys ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public abstract fun existingSequences ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; + public abstract fun fetchAllColumnTypes (Ljava/lang/String;)Ljava/util/concurrent/ConcurrentHashMap; + public abstract fun getColumnType (Ljava/sql/ResultSet;Ljava/util/concurrent/ConcurrentHashMap;)Ljava/lang/String; public abstract fun getDataTypeProvider ()Lorg/jetbrains/exposed/sql/vendors/DataTypeProvider; public abstract fun getDatabase ()Ljava/lang/String; public abstract fun getDefaultReferenceOption ()Lorg/jetbrains/exposed/sql/ReferenceOption; @@ -3899,6 +3910,7 @@ public abstract interface class org/jetbrains/exposed/sql/vendors/DatabaseDialec public abstract fun getNeedsSequenceToAutoInc ()Z public abstract fun getRequiresAutoCommitOnCreateDrop ()Z public abstract fun getSequenceMaxValue ()J + public abstract fun getSupportsColumnTypeChange ()Z public abstract fun getSupportsCreateSchema ()Z public abstract fun getSupportsCreateSequence ()Z public abstract fun getSupportsDualTableConcept ()Z @@ -3933,6 +3945,7 @@ public final class org/jetbrains/exposed/sql/vendors/DatabaseDialect$Companion { } public final class org/jetbrains/exposed/sql/vendors/DatabaseDialect$DefaultImpls { + public static fun areEquivalentColumnTypes (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Ljava/lang/String;ILjava/lang/String;)Z public static fun catalog (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Lorg/jetbrains/exposed/sql/Transaction;)Ljava/lang/String; public static fun checkTableMapping (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Lorg/jetbrains/exposed/sql/Table;)Z public static fun columnConstraints (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;[Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; @@ -3940,15 +3953,21 @@ public final class org/jetbrains/exposed/sql/vendors/DatabaseDialect$DefaultImpl public static fun createSchema (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Lorg/jetbrains/exposed/sql/Schema;)Ljava/lang/String; public static fun dropDatabase (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Ljava/lang/String;)Ljava/lang/String; public static fun dropSchema (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Lorg/jetbrains/exposed/sql/Schema;Z)Ljava/lang/String; + public static fun equivalentColumnTypesPairs (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Ljava/util/Set; + public static fun existingCheckConstraints (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;[Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public static fun existingIndices (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;[Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public static fun existingPrimaryKeys (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;[Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public static fun existingSequences (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;[Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; + public static fun fetchAllColumnTypes (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Ljava/lang/String;)Ljava/util/concurrent/ConcurrentHashMap; + public static fun getColumnType (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Ljava/sql/ResultSet;Ljava/util/concurrent/ConcurrentHashMap;)Ljava/lang/String; + public static synthetic fun getColumnType$default (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;Ljava/sql/ResultSet;Ljava/util/concurrent/ConcurrentHashMap;ILjava/lang/Object;)Ljava/lang/String; public static fun getDefaultReferenceOption (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Lorg/jetbrains/exposed/sql/ReferenceOption; public static fun getLikePatternSpecialChars (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Ljava/util/Map; public static fun getNeedsQuotesWhenSymbolsInNames (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z public static fun getNeedsSequenceToAutoInc (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z public static fun getRequiresAutoCommitOnCreateDrop (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z public static fun getSequenceMaxValue (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)J + public static fun getSupportsColumnTypeChange (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z public static fun getSupportsCreateSchema (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z public static fun getSupportsCreateSequence (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z public static fun getSupportsDualTableConcept (Lorg/jetbrains/exposed/sql/vendors/DatabaseDialect;)Z @@ -4139,10 +4158,15 @@ public abstract interface class org/jetbrains/exposed/sql/vendors/FunctionProvid public class org/jetbrains/exposed/sql/vendors/H2Dialect : org/jetbrains/exposed/sql/vendors/VendorDialect { public static final field Companion Lorg/jetbrains/exposed/sql/vendors/H2Dialect$Companion; public fun ()V + public fun areEquivalentColumnTypes (Ljava/lang/String;ILjava/lang/String;)Z public fun createDatabase (Ljava/lang/String;)Ljava/lang/String; public fun createIndex (Lorg/jetbrains/exposed/sql/Index;)Ljava/lang/String; public fun dropDatabase (Ljava/lang/String;)Ljava/lang/String; + public fun equivalentColumnTypesPairs ()Ljava/util/Set; + public fun existingCheckConstraints ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public fun existingIndices ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; + public fun fetchAllColumnTypes (Ljava/lang/String;)Ljava/util/concurrent/ConcurrentHashMap; + public fun getColumnType (Ljava/sql/ResultSet;Ljava/util/concurrent/ConcurrentHashMap;)Ljava/lang/String; public fun getDataTypeProvider ()Lorg/jetbrains/exposed/sql/vendors/DataTypeProvider; public fun getDefaultReferenceOption ()Lorg/jetbrains/exposed/sql/ReferenceOption; public final fun getDelegatedDialectNameProvider ()Lorg/jetbrains/exposed/sql/vendors/VendorDialect$DialectNameProvider; @@ -4153,6 +4177,7 @@ public class org/jetbrains/exposed/sql/vendors/H2Dialect : org/jetbrains/exposed public fun getNeedsSequenceToAutoInc ()Z public final fun getOriginalDataTypeProvider ()Lorg/jetbrains/exposed/sql/vendors/DataTypeProvider; public final fun getOriginalFunctionProvider ()Lorg/jetbrains/exposed/sql/vendors/FunctionProvider; + public fun getSupportsColumnTypeChange ()Z public fun getSupportsCreateSchema ()Z public fun getSupportsCreateSequence ()Z public fun getSupportsDualTableConcept ()Z @@ -4383,6 +4408,7 @@ public abstract class org/jetbrains/exposed/sql/vendors/VendorDialect : org/jetb public fun addPrimaryKey (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Column;)Ljava/lang/String; public fun allTablesNames ()Ljava/util/List; public fun allTablesNamesInAllSchemas ()Ljava/util/List; + public fun areEquivalentColumnTypes (Ljava/lang/String;ILjava/lang/String;)Z public fun catalog (Lorg/jetbrains/exposed/sql/Transaction;)Ljava/lang/String; public fun checkTableMapping (Lorg/jetbrains/exposed/sql/Table;)Z public fun columnConstraints ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; @@ -4393,14 +4419,18 @@ public abstract class org/jetbrains/exposed/sql/vendors/VendorDialect : org/jetb public fun dropDatabase (Ljava/lang/String;)Ljava/lang/String; public fun dropIndex (Ljava/lang/String;Ljava/lang/String;ZZ)Ljava/lang/String; public fun dropSchema (Lorg/jetbrains/exposed/sql/Schema;Z)Ljava/lang/String; + public fun equivalentColumnTypesPairs ()Ljava/util/Set; + public fun existingCheckConstraints ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public fun existingIndices ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public fun existingPrimaryKeys ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; public fun existingSequences ([Lorg/jetbrains/exposed/sql/Table;)Ljava/util/Map; + public fun fetchAllColumnTypes (Ljava/lang/String;)Ljava/util/concurrent/ConcurrentHashMap; protected fun fillConstraintCacheForTables (Ljava/util/List;)V public final fun filterCondition (Lorg/jetbrains/exposed/sql/Index;)Ljava/lang/String; protected final fun getAllTableNamesCache ()Ljava/util/Map; public final fun getAllTablesNames ()Ljava/util/List; protected final fun getColumnConstraintsCache ()Ljava/util/Map; + public fun getColumnType (Ljava/sql/ResultSet;Ljava/util/concurrent/ConcurrentHashMap;)Ljava/lang/String; public fun getDataTypeProvider ()Lorg/jetbrains/exposed/sql/vendors/DataTypeProvider; public fun getDatabase ()Ljava/lang/String; public fun getDefaultReferenceOption ()Lorg/jetbrains/exposed/sql/ReferenceOption; @@ -4412,6 +4442,7 @@ public abstract class org/jetbrains/exposed/sql/vendors/VendorDialect : org/jetb public fun getNeedsSequenceToAutoInc ()Z public fun getRequiresAutoCommitOnCreateDrop ()Z public fun getSequenceMaxValue ()J + public fun getSupportsColumnTypeChange ()Z public fun getSupportsCreateSchema ()Z public fun getSupportsCreateSequence ()Z public fun getSupportsDualTableConcept ()Z diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnDiff.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnDiff.kt index 5b7f01c7f2..ca8d7d1931 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnDiff.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnDiff.kt @@ -6,6 +6,8 @@ package org.jetbrains.exposed.sql data class ColumnDiff( /** Whether there is a mismatch between nullability of the existing column and the defined column. */ val nullability: Boolean, + /** Whether there is a mismatch between type of the existing column and the defined column. */ + val type: Boolean, /** Whether there is a mismatch between auto-increment status of the existing column and the defined column. */ val autoInc: Boolean, /** Whether the default value of the existing column matches that of the defined column. */ @@ -22,6 +24,7 @@ data class ColumnDiff( /** A [ColumnDiff] with no differences. */ val NoneChanged = ColumnDiff( nullability = false, + type = false, autoInc = false, defaults = false, caseSensitiveName = false, @@ -31,6 +34,7 @@ data class ColumnDiff( /** A [ColumnDiff] with differences for every matched property. */ val AllChanged = ColumnDiff( nullability = true, + type = true, autoInc = true, defaults = true, caseSensitiveName = true, diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Constraints.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Constraints.kt index 73447e72da..9619dd4a15 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Constraints.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Constraints.kt @@ -219,7 +219,7 @@ data class CheckConstraint( } companion object { - internal fun from(table: Table, name: String, op: Op): CheckConstraint { + fun from(table: Table, name: String, op: Op): CheckConstraint { require(name.isNotBlank()) { "Check constraint name cannot be blank" } val tr = TransactionManager.current() val identifierManager = tr.db.identifierManager diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt index dc1bccad71..2f688ee443 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt @@ -365,6 +365,9 @@ object SchemaUtils { } else { columnType.nullable } + + val incorrectType = if (currentDialect.supportsColumnTypeChange) isIncorrectType(existingCol, col) else false + val incorrectNullability = existingCol.nullable != colNullable val incorrectAutoInc = isIncorrectAutoInc(existingCol, col) @@ -373,9 +376,9 @@ object SchemaUtils { val incorrectCaseSensitiveName = existingCol.name.inProperCase() != col.nameUnquoted().inProperCase() - val incorrectSizeOrScale = isIncorrectSizeOrScale(existingCol, columnType) + val incorrectSizeOrScale = if (incorrectType) false else isIncorrectSizeOrScale(existingCol, columnType) - ColumnDiff(incorrectNullability, incorrectAutoInc, incorrectDefaults, incorrectCaseSensitiveName, incorrectSizeOrScale) + ColumnDiff(incorrectNullability, incorrectType, incorrectAutoInc, incorrectDefaults, incorrectCaseSensitiveName, incorrectSizeOrScale) }.filterValues { it.hasDifferences() } redoColumns.flatMapTo(statements) { (col, changedState) -> col.modifyStatements(changedState) } @@ -398,6 +401,10 @@ object SchemaUtils { return statements } + private fun isIncorrectType(columnMetadata: ColumnMetadata, column: Column<*>): Boolean { + return !currentDialect.areEquivalentColumnTypes(columnMetadata.sqlType, columnMetadata.jdbcType, column.columnType.sqlType()) + } + private fun isIncorrectAutoInc(columnMetadata: ColumnMetadata, column: Column<*>): Boolean = when { !columnMetadata.autoIncrement && column.columnType.isAutoInc && column.autoIncColumnType?.sequence == null -> true diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt index e76b51adc4..f28556ae9c 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt @@ -529,6 +529,57 @@ open class Table(name: String = "") : ColumnSet(), DdlAware { private val generatedSignedCheckPrefix get() = "chk_${tableNameWithSchemaSanitized}_signed_" + private fun filteredCheckConstraints(): List { + val filteredChecks = when (val dialect = currentDialect) { + is MysqlDialect -> checkConstraints.filterNot { (name, _) -> + name.startsWith(generatedUnsignedCheckPrefix) || + name.startsWith(generatedSignedCheckPrefix) + } + is SQLServerDialect -> checkConstraints.filterNot { (name, _) -> + name.startsWith("${generatedUnsignedCheckPrefix}byte_") || + name.startsWith("${generatedSignedCheckPrefix}short") + } + is PostgreSQLDialect -> checkConstraints.filterNot { (name, _) -> + name.startsWith("${generatedSignedCheckPrefix}short") + } + is H2Dialect -> { + when (dialect.h2Mode) { + H2Dialect.H2CompatibilityMode.PostgreSQL -> checkConstraints.filterNot { (name, _) -> + name.startsWith("${generatedSignedCheckPrefix}short") + } + + else -> checkConstraints.filterNot { (name, _) -> + name.startsWith(generatedSignedCheckPrefix) + } + } + } + else -> checkConstraints + }.let { + if (currentDialect !is SQLiteDialect && currentDialect !is OracleDialect) { + it.filterNot { (name, _) -> + name.startsWith("${generatedSignedCheckPrefix}integer") + } + } else { + it + } + }.let { + if (currentDialect !is OracleDialect) { + it.filterNot { (name, _) -> + name.startsWith("${generatedSignedCheckPrefix}long") + } + } else { + it + } + } + return filteredChecks.mapIndexed { index, (name, op) -> + val resolvedName = name.ifBlank { "check_${tableNameWithSchemaSanitized}_$index" } + CheckConstraint.from(this@Table, resolvedName, op) + } + } + + /** Returns the list of CHECK constraints in this table. */ + fun checkConstraints(): List = filteredCheckConstraints() + /** * Returns the table name in proper case. * Should be called within transaction or default [tableName] will be returned. @@ -1723,51 +1774,7 @@ open class Table(name: String = "") : ColumnSet(), DdlAware { } if (checkConstraints.isNotEmpty()) { - val filteredChecks = when (val dialect = currentDialect) { - is MysqlDialect -> checkConstraints.filterNot { (name, _) -> - name.startsWith(generatedUnsignedCheckPrefix) || - name.startsWith(generatedSignedCheckPrefix) - } - is SQLServerDialect -> checkConstraints.filterNot { (name, _) -> - name.startsWith("${generatedUnsignedCheckPrefix}byte_") || - name.startsWith("${generatedSignedCheckPrefix}short") - } - is PostgreSQLDialect -> checkConstraints.filterNot { (name, _) -> - name.startsWith("${generatedSignedCheckPrefix}short") - } - is H2Dialect -> { - when (dialect.h2Mode) { - H2Dialect.H2CompatibilityMode.PostgreSQL -> checkConstraints.filterNot { (name, _) -> - name.startsWith("${generatedSignedCheckPrefix}short") - } - - else -> checkConstraints.filterNot { (name, _) -> - name.startsWith(generatedSignedCheckPrefix) - } - } - } - else -> checkConstraints - }.let { - if (currentDialect !is SQLiteDialect && currentDialect !is OracleDialect) { - it.filterNot { (name, _) -> - name.startsWith("${generatedSignedCheckPrefix}integer") - } - } else { - it - } - }.let { - if (currentDialect !is OracleDialect) { - it.filterNot { (name, _) -> - name.startsWith("${generatedSignedCheckPrefix}long") - } - } else { - it - } - }.ifEmpty { null } - filteredChecks?.mapIndexed { index, (name, op) -> - val resolvedName = name.ifBlank { "check_${tableNameWithSchemaSanitized}_$index" } - CheckConstraint.from(this@Table, resolvedName, op).checkPart - }?.joinTo(this, prefix = ", ") + filteredCheckConstraints().map { it.checkPart }.ifEmpty { null }?.joinTo(this, prefix = ", ") } append(")") diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/ColumnMetadata.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/ColumnMetadata.kt index 13971b5888..66dfa23710 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/ColumnMetadata.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/ColumnMetadata.kt @@ -7,11 +7,13 @@ data class ColumnMetadata( /** Name of the column. */ val name: String, /** - * Type of the column. + * JDBC type of the column. * * @see java.sql.Types */ - val type: Int, + val jdbcType: Int, + /** SQL type of the column. */ + val sqlType: String, /** Whether the column is nullable or not. */ val nullable: Boolean, /** Optional size of the column. */ diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/DatabaseDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/DatabaseDialect.kt index 7cc7cef5fa..7f6c03eb98 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/DatabaseDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/DatabaseDialect.kt @@ -2,6 +2,8 @@ package org.jetbrains.exposed.sql.vendors import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.TransactionManager +import java.sql.ResultSet +import java.util.concurrent.ConcurrentHashMap /** * Common interface for all database dialects. @@ -77,6 +79,9 @@ interface DatabaseDialect { /** Returns the allowed maximum sequence value for a dialect, as a [Long]. */ val sequenceMaxValue: Long get() = Long.MAX_VALUE + /** Returns whether Exposed currently supports column type change in migrations for this dialect. */ + val supportsColumnTypeChange: Boolean get() = false + /** Returns `true` if the database supports the `LIMIT` clause with update and delete statements. */ fun supportsLimitWithUpdateOrDelete(): Boolean @@ -136,6 +141,9 @@ interface DatabaseDialect { /** Returns a list of the names of all sequences in the database. */ fun sequences(): List + /** Returns a map with the CHECK constraints in each of the specified [tables] in the database. */ + fun existingCheckConstraints(vararg tables: Table): Map> = emptyMap() + /** Returns `true` if the dialect supports `SELECT FOR UPDATE` statements, `false` otherwise. */ fun supportsSelectForUpdate(): Boolean @@ -202,6 +210,26 @@ interface DatabaseDialect { return TransactionManager.current().db.metadata { resolveReferenceOption(refOption.toString())!! } } + /** Returns a map of all the columns' names mapped to their type. */ + fun fetchAllColumnTypes(tableName: String): ConcurrentHashMap = ConcurrentHashMap() + + /** Returns the SQL type of the column in [resultSet]. If available, [prefetchedColumnTypes] is used to get the column type. */ + fun getColumnType(resultSet: ResultSet, prefetchedColumnTypes: ConcurrentHashMap = ConcurrentHashMap()): String = "" + + /** Returns whether the [columnMetadataSqlType] type and the [columnType] are equivalent. + * + * [columnMetadataJdbcType], the value of which comes from [java.sql.Types], is taken into consideration if needed by a specific database. + * @see [H2Dialect.areEquivalentColumnTypes] */ + fun areEquivalentColumnTypes(columnMetadataSqlType: String, columnMetadataJdbcType: Int, columnType: String): Boolean = + columnMetadataSqlType.equals(columnType, ignoreCase = true) || + equivalentColumnTypesPairs().any { pair -> + columnType.equals(pair.first, ignoreCase = true) && columnMetadataSqlType.equals(pair.second, ignoreCase = true) || + columnType.equals(pair.second, ignoreCase = true) && columnMetadataSqlType.equals(pair.first, ignoreCase = true) + } + + /** Returns the pairs of column type values that are equivalent to one another. */ + fun equivalentColumnTypesPairs(): Set> = setOf() + companion object { private val defaultLikePatternSpecialChars = mapOf('%' to null, '_' to null) } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt index 9644cb50c0..b5b98226ec 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt @@ -4,7 +4,10 @@ import org.jetbrains.exposed.exceptions.throwUnsupportedException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.statements.StatementType import org.jetbrains.exposed.sql.transactions.TransactionManager +import java.sql.ResultSet +import java.sql.Types import java.util.* +import java.util.concurrent.ConcurrentHashMap internal object H2DataTypeProvider : DataTypeProvider() { override fun binaryType(): String = "VARBINARY" @@ -298,12 +301,42 @@ open class H2Dialect : VendorDialect(dialectName, H2DataTypeProvider, H2Function override val supportsDualTableConcept: Boolean by lazy { resolveDelegatedDialect()?.supportsDualTableConcept ?: super.supportsDualTableConcept } override val supportsOrderByNullsFirstLast: Boolean by lazy { resolveDelegatedDialect()?.supportsOrderByNullsFirstLast ?: super.supportsOrderByNullsFirstLast } override val supportsWindowFrameGroupsMode: Boolean by lazy { resolveDelegatedDialect()?.supportsWindowFrameGroupsMode ?: super.supportsWindowFrameGroupsMode } -// override val likePatternSpecialChars: Map by lazy { resolveDelegatedDialect()?.likePatternSpecialChars ?: super.likePatternSpecialChars } + override val supportsColumnTypeChange: Boolean get() = isSecondVersion override fun existingIndices(vararg tables: Table): Map> = super.existingIndices(*tables).mapValues { entry -> entry.value.filterNot { it.indexName.startsWith("PRIMARY_KEY_") } } .filterValues { it.isNotEmpty() } + override fun existingCheckConstraints(vararg tables: Table): Map> { + val result = mutableMapOf>() + tables.forEach { table -> + val transaction = TransactionManager.current() + val checkConstraints = mutableListOf() + transaction.exec( + """ + SELECT tc.CONSTRAINT_NAME, cc.CHECK_CLAUSE + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + JOIN INFORMATION_SCHEMA.CHECK_CONSTRAINTS cc + ON tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME + WHERE tc.CONSTRAINT_TYPE = 'CHECK' + AND tc.TABLE_NAME = '${table.nameInDatabaseCaseUnquoted()}'; + """.trimIndent() + ) { rs -> + while (rs.next()) { + checkConstraints.add( + CheckConstraint( + tableName = transaction.identity(table), + checkName = rs.getString(1), + checkOp = rs.getString(2) + ) + ) + } + } + result[table] = checkConstraints + } + return result + } + override fun isAllowedAsColumnDefault(e: Expression<*>): Boolean = true override fun createIndex(index: Index): String { @@ -338,6 +371,135 @@ open class H2Dialect : VendorDialect(dialectName, H2DataTypeProvider, H2Function override fun dropDatabase(name: String) = "DROP SCHEMA IF EXISTS ${name.inProperCase()}" + override fun fetchAllColumnTypes(tableName: String): ConcurrentHashMap { + val map = ConcurrentHashMap() + TransactionManager.current().exec("SHOW COLUMNS FROM $tableName") { rs -> + while (rs.next()) { + val field = rs.getString("FIELD") + val type = rs.getString("TYPE").uppercase() + map[field] = type + } + } + return map + } + + // All H2 V1 databases are excluded because Exposed will be dropping support for it soon + override fun getColumnType(resultSet: ResultSet, prefetchedColumnTypes: ConcurrentHashMap): String { + val columnName = resultSet.getString("COLUMN_NAME") + val columnType = prefetchedColumnTypes[columnName] ?: resultSet.getString("TYPE_NAME").uppercase() + val dataType = resultSet.getInt("DATA_TYPE") + return if (dataType == Types.ARRAY) { + val baseType = columnType.substringBefore(" ARRAY") + normalizedColumnType(baseType) + columnType.replaceBefore(" ARRAY", "") + } else { + normalizedColumnType(columnType) + } + } + + /** Returns the normalized column type. */ + private fun normalizedColumnType(columnType: String): String = + when { + columnType.matches(Regex("CHARACTER VARYING(?:\\(\\d+\\))?")) -> when (h2Mode) { + H2CompatibilityMode.Oracle -> columnType.replace("CHARACTER VARYING", "VARCHAR2") + else -> columnType.replace("CHARACTER VARYING", "VARCHAR") + } + columnType.matches(Regex("CHARACTER(?:\\(\\d+\\))?")) -> columnType.replace("CHARACTER", "CHAR") + columnType.matches(Regex("BINARY VARYING(?:\\(\\d+\\))?")) -> when (h2Mode) { + H2CompatibilityMode.PostgreSQL -> "bytea" + H2CompatibilityMode.Oracle -> columnType.replace("BINARY VARYING", "RAW") + else -> columnType.replace("BINARY VARYING", "VARBINARY") + } + columnType == "BOOLEAN" -> when (h2Mode) { + H2CompatibilityMode.SQLServer -> "BIT" + else -> columnType + } + columnType == "BINARY LARGE OBJECT" -> "BLOB" + columnType == "CHARACTER LARGE OBJECT" -> "CLOB" + columnType == "INTEGER" && h2Mode != H2CompatibilityMode.Oracle -> "INT" + else -> columnType + } + + override fun areEquivalentColumnTypes(columnMetadataSqlType: String, columnMetadataJdbcType: Int, columnType: String): Boolean { + if (super.areEquivalentColumnTypes(columnMetadataSqlType, columnMetadataJdbcType, columnType)) { + return true + } + if (columnMetadataJdbcType == Types.ARRAY) { + val baseType = columnMetadataSqlType.substringBefore(" ARRAY") + return areEquivalentColumnTypes(baseType, Types.OTHER, columnType.substringBefore(" ARRAY")) && + areEquivalentColumnTypes(columnMetadataSqlType.replaceBefore("ARRAY", ""), Types.OTHER, columnType.replaceBefore("ARRAY", "")) + } + if (listOf(columnMetadataSqlType, columnType).all { it.matches(Regex("VARCHAR(?:\\((?:MAX|\\d+)\\))?")) }) { + return true + } + if (listOf(columnMetadataSqlType, columnType).all { it.matches(Regex("VARBINARY(?:\\((?:MAX|\\d+)\\))?")) }) { + return true + } + return when (h2Mode) { + H2CompatibilityMode.Oracle -> { + when { + // Unlike Oracle, H2 Oracle mode does not distinguish between VARCHAR2(4000) and VARCHAR2(4000 CHAR). + // It treats the length as a character count and does not enforce a separate byte limit. + listOf(columnMetadataSqlType, columnType).all { it.matches(Regex("VARCHAR2(?:\\((?:MAX|\\d+)(?:\\s+CHAR)?\\))?")) } -> true + else -> { + // H2 maps NUMBER to NUMERIC + val numberRegex = Regex("NUMBER(?:\\((\\d+)(?:,\\s?(\\d+))?\\))?") + val numericRegex = Regex("NUMERIC(?:\\((\\d+)(?:,\\s?(\\d+))?\\))?") + val numberMatch = numberRegex.find(columnType.uppercase()) + val numericMatch = numericRegex.find(columnMetadataSqlType.uppercase()) + if (numberMatch != null && numericMatch != null) { + numberMatch.groupValues[1] == numericMatch.groupValues[1] // compare precision + } else { + false + } + } + } + } + H2CompatibilityMode.SQLServer -> + when { + // Auto-increment difference is dealt with elsewhere + columnType.contains(" IDENTITY") -> + areEquivalentColumnTypes(columnMetadataSqlType, columnMetadataJdbcType, columnType.substringBefore(" IDENTITY")) + // H2 maps DATETIME2 to TIMESTAMP + columnType.uppercase().matches(Regex("DATETIME2(?:\\(\\d+\\))?")) && + columnMetadataSqlType.uppercase().matches(Regex("TIMESTAMP(?:\\(\\d+\\))?")) -> true + // H2 maps NVARCHAR to VARCHAR + columnType.uppercase().matches(Regex("NVARCHAR(?:\\((\\d+|MAX)\\))?")) && + columnMetadataSqlType.uppercase().matches(Regex("VARCHAR(?:\\((\\d+|MAX)\\))?")) -> true + else -> false + } + null, H2CompatibilityMode.MySQL, H2CompatibilityMode.MariaDB -> + when { + // Auto-increment difference is dealt with elsewhere + columnType.contains(" AUTO_INCREMENT") -> + areEquivalentColumnTypes(columnMetadataSqlType, columnMetadataJdbcType, columnType.substringBefore(" AUTO_INCREMENT")) + // H2 maps DATETIME to TIMESTAMP + columnType.uppercase().matches(Regex("DATETIME(?:\\(\\d+\\))?")) && + columnMetadataSqlType.uppercase().matches(Regex("TIMESTAMP(?:\\(\\d+\\))?")) -> true + else -> false + } + else -> false + } + } + + override fun equivalentColumnTypesPairs(): Set> = hashSetOf( + "TEXT" to "VARCHAR" + ).apply { + when (h2Mode) { + H2CompatibilityMode.Oracle -> this.add("DATE" to "TIMESTAMP(0)") + H2CompatibilityMode.SQLServer -> this.add("uniqueidentifier" to "UUID") + H2CompatibilityMode.PostgreSQL -> this.addAll( + listOf( + // Auto-increment difference is dealt with elsewhere + "SERIAL" to "INT", + "BIGSERIAL" to "BIGINT" + ) + ) + else -> { + /* Do nothing */ + } + } + } + companion object : DialectNameProvider("H2") } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt index c5d9428984..275fd1bc16 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt @@ -17,18 +17,21 @@ internal object OracleDataTypeProvider : DataTypeProvider() { } else { "NUMBER(3)" } + override fun ubyteType(): String = "NUMBER(3)" override fun shortType(): String = if (currentDialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) { "SMALLINT" } else { "NUMBER(5)" } + override fun ushortType(): String = "NUMBER(5)" override fun integerType(): String = if (currentDialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) { "INTEGER" } else { "NUMBER(10)" } + override fun integerAutoincType(): String = integerType() override fun uintegerType(): String = "NUMBER(10)" override fun uintegerAutoincType(): String = "NUMBER(10)" @@ -37,6 +40,7 @@ internal object OracleDataTypeProvider : DataTypeProvider() { } else { "NUMBER(19)" } + override fun longAutoincType(): String = longType() override fun ulongType(): String = "NUMBER(20)" override fun ulongAutoincType(): String = "NUMBER(20)" @@ -52,14 +56,14 @@ internal object OracleDataTypeProvider : DataTypeProvider() { override fun binaryType(length: Int): String { @Suppress("MagicNumber") - return if (length < 2000) "RAW ($length)" else binaryType() + return if (length < 2000) "RAW($length)" else binaryType() } override fun uuidType(): String { return if ((currentDialect as? H2Dialect)?.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) { "UUID" } else { - return "RAW(16)" + "RAW(16)" } } diff --git a/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcDatabaseMetadataImpl.kt b/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcDatabaseMetadataImpl.kt index 2c11c44704..e7720252b2 100644 --- a/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcDatabaseMetadataImpl.kt +++ b/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcDatabaseMetadataImpl.kt @@ -187,10 +187,11 @@ class JdbcDatabaseMetadataImpl(database: String, val metadata: DatabaseMetaData) return SchemaMetadata(currentSchema!!, tablesInSchema) } - private fun ResultSet.extractColumns(): List { + private fun ResultSet.extractColumns(tableName: String): List { + val prefetchedColumnTypes = currentDialect.fetchAllColumnTypes(tableName) val result = mutableListOf() while (next()) { - result.add(asColumnMetadata()) + result.add(asColumnMetadata(prefetchedColumnTypes)) } return result } @@ -204,7 +205,7 @@ class JdbcDatabaseMetadataImpl(database: String, val metadata: DatabaseMetaData) for (table in schemaTables) { val catalog = if (!useSchemaInsteadOfDatabase || schema == currentSchema!!) databaseName else schema val rs = metadata.getColumns(catalog, schema, table.nameInDatabaseCaseUnquoted(), "%") - val columns = rs.extractColumns() + val columns = rs.extractColumns(tableName = table.nameInDatabaseCase()) check(columns.isNotEmpty()) result[table] = columns rs.close() @@ -214,7 +215,7 @@ class JdbcDatabaseMetadataImpl(database: String, val metadata: DatabaseMetaData) return result } - private fun ResultSet.asColumnMetadata(): ColumnMetadata { + private fun ResultSet.asColumnMetadata(prefetchedColumnTypes: ConcurrentHashMap = ConcurrentHashMap()): ColumnMetadata { val defaultDbValue = getString("COLUMN_DEF")?.let { sanitizedDefault(it) } val autoIncrement = getString("IS_AUTOINCREMENT") == "YES" val type = getInt("DATA_TYPE") @@ -222,8 +223,9 @@ class JdbcDatabaseMetadataImpl(database: String, val metadata: DatabaseMetaData) val nullable = getBoolean("NULLABLE") val size = getInt("COLUMN_SIZE").takeIf { it != 0 } val scale = getInt("DECIMAL_DIGITS").takeIf { it != 0 } + val sqlType = currentDialect.getColumnType(this, prefetchedColumnTypes) - return ColumnMetadata(name, type, nullable, size, scale, autoIncrement, defaultDbValue?.takeIf { !autoIncrement }) + return ColumnMetadata(name, type, sqlType, nullable, size, scale, autoIncrement, defaultDbValue?.takeIf { !autoIncrement }) } /** diff --git a/exposed-migration/src/main/kotlin/MigrationUtils.kt b/exposed-migration/src/main/kotlin/MigrationUtils.kt index e1cdd677d8..60f0d27987 100644 --- a/exposed-migration/src/main/kotlin/MigrationUtils.kt +++ b/exposed-migration/src/main/kotlin/MigrationUtils.kt @@ -1,14 +1,9 @@ -import org.jetbrains.exposed.sql.ExperimentalDatabaseMigrationApi -import org.jetbrains.exposed.sql.Index +import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SchemaUtils.addMissingColumnsStatements import org.jetbrains.exposed.sql.SchemaUtils.checkExcessiveForeignKeyConstraints import org.jetbrains.exposed.sql.SchemaUtils.checkExcessiveIndices import org.jetbrains.exposed.sql.SchemaUtils.checkMappingConsistence import org.jetbrains.exposed.sql.SchemaUtils.createStatements -import org.jetbrains.exposed.sql.Sequence -import org.jetbrains.exposed.sql.Table -import org.jetbrains.exposed.sql.exists -import org.jetbrains.exposed.sql.exposedLogger import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.vendors.H2Dialect import org.jetbrains.exposed.sql.vendors.MysqlDialect @@ -159,7 +154,9 @@ object MigrationUtils { checkUnmappedIndices(tables = tables, withLogs).flatMap { it.dropStatement() } + checkExcessiveForeignKeyConstraints(tables = tables, withLogs).flatMap { it.dropStatement() } + checkExcessiveIndices(tables = tables, withLogs).flatMap { it.dropStatement() } + - checkUnmappedSequences(tables = tables, withLogs).flatMap { it.dropStatement() } + checkUnmappedSequences(tables = tables, withLogs).flatMap { it.dropStatement() } + + checkMissingCheckConstraints(tables = tables, withLogs).flatMap { it.createStatement() } + + checkUnmappedCheckConstraints(tables = tables, withLogs).flatMap { it.dropStatement() } } /** @@ -352,6 +349,63 @@ object MigrationUtils { return unmappedSequences.toList() } + /** + * Checks all [tables] for any that have CHECK constraints that are missing in the database but are defined in the code. + * If found, this function also logs the CHECK constraints that will be created. + * + * @return List of CHECK constraints that are missing and can be created. + */ + private fun checkMissingCheckConstraints(vararg tables: Table, withLogs: Boolean): List { + fun Collection.log(mainMessage: String) { + if (withLogs && isNotEmpty()) { + exposedLogger.warn(joinToString(prefix = "$mainMessage\n\t\t", separator = "\n\t\t")) + } + } + + if (!currentDialect.supportsColumnTypeChange) { + return emptyList() + } + + val missingCheckConstraints = mutableListOf() + tables.forEach { table -> + val mappedCheckConstraints = table.checkConstraints() + val existingCheckConstraints = currentDialect.existingCheckConstraints(*tables)[table].orEmpty() + val existingCheckConstraintsNames = existingCheckConstraints.map { it.checkName.uppercase() }.toSet() + missingCheckConstraints.addAll(mappedCheckConstraints.filterNot { it.checkName.uppercase() in existingCheckConstraintsNames }) + } + missingCheckConstraints.log("CHECK constraints missed from database (will be created):") + return missingCheckConstraints + } + + /** + * Checks all [tables] for any that have CHECK constraints that exist in the database but are not mapped in the code. + * If found, this function also logs the CHECK constraints that will be dropped. + * + * @return List of CHECK constraints that are unmapped and can be dropped. + */ + private fun checkUnmappedCheckConstraints(vararg tables: Table, withLogs: Boolean): List { + fun Collection.log(mainMessage: String) { + if (withLogs && isNotEmpty()) { + exposedLogger.warn(joinToString(prefix = "$mainMessage\n\t\t", separator = "\n\t\t")) + } + } + + if (!currentDialect.supportsColumnTypeChange) { + return emptyList() + } + + val unmappedCheckConstraints = mutableListOf() + tables.forEach { table -> + val existingCheckConstraints = currentDialect.existingCheckConstraints(*tables)[table].orEmpty() + val mappedCheckConstraints = table.checkConstraints() + val mappedCheckConstraintsNames = mappedCheckConstraints.map { it.checkName.uppercase() }.toSet() + + unmappedCheckConstraints.addAll(existingCheckConstraints.filterNot { it.checkName.uppercase() in mappedCheckConstraintsNames }) + } + unmappedCheckConstraints.log("CHECK constraints exist in database and not mapped in code:") + return unmappedCheckConstraints + } + private inline fun logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R { return if (withLogs) { val start = System.currentTimeMillis() diff --git a/exposed-tests/build.gradle.kts b/exposed-tests/build.gradle.kts index 2d2af52eb7..f8acb08fb6 100644 --- a/exposed-tests/build.gradle.kts +++ b/exposed-tests/build.gradle.kts @@ -22,7 +22,9 @@ dependencies { implementation(project(":exposed-core")) implementation(project(":exposed-jdbc")) implementation(project(":exposed-dao")) + implementation(project(":exposed-json")) implementation(project(":exposed-kotlin-datetime")) + implementation(project(":exposed-money")) implementation(project(":exposed-migration")) implementation(libs.slf4j) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ConnectionTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ConnectionTests.kt index fe5baa2168..0651371161 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ConnectionTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ConnectionTests.kt @@ -18,24 +18,24 @@ class ConnectionTests : DatabaseTestsBase() { @Test fun testGettingColumnMetadata() { - withTables(excludeSettings = TestDB.ALL - TestDB.H2_V2, People) { + withTables(excludeSettings = TestDB.ALL - TestDB.ALL_H2_V2, People) { val columnMetadata = connection.metadata { requireNotNull(columns(People)[People]) }.toSet() - val expected = when ((db.dialect as H2Dialect).isSecondVersion) { - false -> setOf( - ColumnMetadata("ID", Types.BIGINT, false, 19, null, true, null), - ColumnMetadata("FIRSTNAME", Types.VARCHAR, true, 80, null, false, null), - ColumnMetadata("LASTNAME", Types.VARCHAR, false, 42, null, false, "Doe"), - ColumnMetadata("AGE", Types.INTEGER, false, 10, null, false, "18"), - ) - true -> setOf( - ColumnMetadata("ID", Types.BIGINT, false, 64, null, true, null), - ColumnMetadata("FIRSTNAME", Types.VARCHAR, true, 80, null, false, null), - ColumnMetadata("LASTNAME", Types.VARCHAR, false, 42, null, false, "Doe"), - ColumnMetadata("AGE", Types.INTEGER, false, 32, null, false, "18"), - ) - } + + val h2Dialect = (db.dialect as H2Dialect) + val idType = "BIGINT" + val firstNameType = if (h2Dialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) "VARCHAR2(80)" else "VARCHAR(80)" + val lastNameType = if (h2Dialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) "VARCHAR2(42)" else "VARCHAR(42)" + val ageType = if (h2Dialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) "INTEGER" else "INT" + + val expected = setOf( + ColumnMetadata(People.id.nameInDatabaseCase(), Types.BIGINT, idType, false, 64, null, h2Dialect.h2Mode != H2Dialect.H2CompatibilityMode.Oracle, null), + ColumnMetadata(People.firstName.nameInDatabaseCase(), Types.VARCHAR, firstNameType, true, 80, null, false, null), + ColumnMetadata(People.lastName.nameInDatabaseCase(), Types.VARCHAR, lastNameType, false, 42, null, false, "Doe"), + ColumnMetadata(People.age.nameInDatabaseCase(), Types.INTEGER, ageType, false, 32, null, false, "18"), + ) + assertEquals(expected, columnMetadata) } } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt index dbc5c42e36..337a9e7a5c 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt @@ -1,10 +1,18 @@ package org.jetbrains.exposed.sql.tests.shared.ddl -import MigrationUtils +import kotlinx.serialization.json.Json import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.dao.id.UIntIdTable +import org.jetbrains.exposed.dao.id.ULongIdTable import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.json.json +import org.jetbrains.exposed.sql.json.jsonb +import org.jetbrains.exposed.sql.kotlin.datetime.* +import org.jetbrains.exposed.sql.money.CurrencyColumnType +import org.jetbrains.exposed.sql.money.currency import org.jetbrains.exposed.sql.tests.DatabaseTestsBase import org.jetbrains.exposed.sql.tests.TestDB import org.jetbrains.exposed.sql.tests.currentDialectTest @@ -14,18 +22,26 @@ import org.jetbrains.exposed.sql.tests.shared.assertEqualLists import org.jetbrains.exposed.sql.tests.shared.assertEquals import org.jetbrains.exposed.sql.tests.shared.assertFalse import org.jetbrains.exposed.sql.tests.shared.assertTrue +import org.jetbrains.exposed.sql.tests.shared.ddl.EnumerationTests.Foo +import org.jetbrains.exposed.sql.tests.shared.ddl.EnumerationTests.PGEnum import org.jetbrains.exposed.sql.tests.shared.expectException +import org.jetbrains.exposed.sql.vendors.H2Dialect import org.jetbrains.exposed.sql.vendors.MysqlDialect +import org.jetbrains.exposed.sql.vendors.PostgreSQLDialect import org.jetbrains.exposed.sql.vendors.PrimaryKeyMetadata import org.junit.Before import org.junit.Test import java.io.File +import java.util.* import kotlin.properties.Delegates import kotlin.test.assertNull @OptIn(ExperimentalDatabaseMigrationApi::class) +@Suppress("LargeClass") class DatabaseMigrationTests : DatabaseTestsBase() { + private val columnTypeChangeUnsupportedDb = TestDB.ALL - TestDB.ALL_H2_V2 + private lateinit var sequence: Sequence @Before @@ -775,6 +791,236 @@ class DatabaseMigrationTests : DatabaseTestsBase() { } } + @Test + fun testNoColumnTypeChangeStatementsGenerated() { + withDb(excludeSettings = columnTypeChangeUnsupportedDb) { + try { + SchemaUtils.create(columnTypesTester) + + val columns = columnTypesTester.columns.sortedBy { it.name.uppercase() } + val columnsMetadata = connection.metadata { + requireNotNull(columns(columnTypesTester)[columnTypesTester]) + }.toSet().sortedBy { it.name.uppercase() } + columnsMetadata.forEachIndexed { index, columnMetadataItem -> + val columnType = columns[index].columnType.sqlType() + val columnMetadataSqlType = columnMetadataItem.sqlType + assertTrue(currentDialectTest.areEquivalentColumnTypes(columnMetadataSqlType, columnMetadataItem.jdbcType, columnType)) + } + + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(columnTypesTester, withLogs = false) + assertTrue(statements.isEmpty()) + } finally { + SchemaUtils.drop(columnTypesTester) + } + } + } + + @Test + fun testCorrectColumnTypeChangeStatementsGenerated() { + withDb(excludeSettings = columnTypeChangeUnsupportedDb) { + val columns = columnTypesTester.columns.sortedBy { it.name.uppercase() } + + columns.forEach { oldColumn -> + val oldColumnWithModifiedName = Column(table = oldColumn.table, name = "tester_col", columnType = oldColumn.columnType as IColumnType) + val oldTable = object : Table("tester") { + override val columns: List> + get() = listOf(oldColumnWithModifiedName) + } + + withTables(oldTable) { + val columnsMetadata = connection.metadata { + requireNotNull(columns(oldTable)[oldTable]) + }.toSet() + val oldColumnMetadataItem = columnsMetadata.single() + + for (newColumn in columns) { + if (currentDialectTest.areEquivalentColumnTypes(oldColumnMetadataItem.sqlType, oldColumnMetadataItem.jdbcType, newColumn.columnType.sqlType())) { + continue + } + + val newColumnWithModifiedName = Column(table = newColumn.table, name = "tester_col", columnType = newColumn.columnType as IColumnType) + val newTable = object : Table("tester") { + override val columns: List> + get() = listOf(newColumnWithModifiedName) + } + + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(newTable, withLogs = false) + assertEquals(1, statements.size) + } + } + } + } + } + + @Test + fun testNoColumnTypeChangeStatementsGeneratedForArrayColumnType() { + withTables(TestDB.ALL - setOf(TestDB.H2_V2, TestDB.H2_V2_PSQL), arraysTester) { + val columnMetadata = connection.metadata { + requireNotNull(columns(arraysTester)[arraysTester]) + }.toSet().sortedBy { it.name.uppercase() } + val columns = arraysTester.columns.sortedBy { it.name.uppercase() } + columnMetadata.forEachIndexed { index, columnMetadataItem -> + val columnType = columns[index].columnType.sqlType() + val columnMetadataSqlType = columnMetadataItem.sqlType + assertTrue(currentDialectTest.areEquivalentColumnTypes(columnMetadataSqlType, columnMetadataItem.jdbcType, columnType)) + } + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(arraysTester, withLogs = false) + assertTrue(statements.isEmpty()) + } + } + + @Test + fun testCorrectColumnTypeChangeStatementsGeneratedForArrayColumnType() { + withDb(excludeSettings = TestDB.ALL - setOf(TestDB.H2_V2, TestDB.H2_V2_PSQL)) { + val columns = arraysTester.columns.sortedBy { it.name.uppercase() } + + columns.forEach { oldColumn -> + val oldColumnWithModifiedName = Column(table = oldColumn.table, name = "tester_col", columnType = oldColumn.columnType as IColumnType) + val oldTable = object : Table("tester") { + override val columns: List> + get() = listOf(oldColumnWithModifiedName) + } + + withTables(oldTable) { + val columnsMetadata = connection.metadata { + requireNotNull(columns(oldTable)[oldTable]) + }.toSet() + val oldColumnMetadataItem = columnsMetadata.single() + + for (newColumn in columns) { + if (currentDialectTest.areEquivalentColumnTypes(oldColumnMetadataItem.sqlType, oldColumnMetadataItem.jdbcType, newColumn.columnType.sqlType())) { + continue + } + + val newColumnWithModifiedName = Column(table = newColumn.table, name = "tester_col", columnType = newColumn.columnType as IColumnType) + val newTable = object : Table("tester") { + override val columns: List> + get() = listOf(newColumnWithModifiedName) + } + + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(newTable, withLogs = false) + assertEquals(1, statements.size) + } + } + } + } + } + + @Test + fun testDifferentCheckConstraintForSameUnderlyingColumnType() { + val oldTable = object : Table("tester") { + val tester_col = byte("tester_col") + } + val newTable = object : Table("tester") { + val tester_col = ubyte("tester_col") + } + + // For H2 PostgreSQL, both `byte` and `ubyte` have the same column type of SMALLINT + withTables(excludeSettings = TestDB.ALL - TestDB.H2_V2_PSQL, oldTable) { + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(newTable, withLogs = false) + assertEquals(2, statements.size) + assertEquals( + newTable.checkConstraints().single().createStatement().single(), + statements[0] + ) + assertEquals( + oldTable.checkConstraints().single().dropStatement().single(), + statements[1] + ) + statements.forEach(::exec) + newTable.insert { + it[tester_col] = UByte.MAX_VALUE + } + } + } + + @Test + fun testAddMissingCheckConstraint() { + val oldTable = object : Table("tester") { + val tester_col = text("tester_col") + } + val newTable = object : Table("tester") { + val tester_col = byte("tester_col") + } + + withTables(excludeSettings = TestDB.ALL - TestDB.H2_V2_PSQL, oldTable) { + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(newTable, withLogs = false) + assertEquals(2, statements.size) + assertEquals( + newTable.checkConstraints().single().createStatement().single(), + statements[1] + ) + statements.forEach(::exec) + newTable.insert { + it[tester_col] = Byte.MAX_VALUE + } + } + } + + @Test + fun testDropUnmappedCheckConstraint() { + val oldTable = object : Table("tester") { + val tester_col = byte("tester_col") + } + val newTable = object : Table("tester") { + val tester_col = text("tester_col") + } + + withTables(excludeSettings = TestDB.ALL - TestDB.H2_V2_PSQL, oldTable) { + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(newTable, withLogs = false) + assertEquals(2, statements.size) + assertEquals( + oldTable.checkConstraints().single().dropStatement().single(), + statements[1] + ) + statements.forEach(::exec) + newTable.insert { + it[tester_col] = "Testing text" + } + } + } + + @Test + fun testChangingIdTableType() { + val intIdTable = object : IntIdTable("tester") {} + val uintIdTable = object : UIntIdTable("tester") {} + val longIdTable = object : LongIdTable("tester") {} + val ulongIdTable = object : ULongIdTable("tester") {} + + val tables = listOf>(intIdTable, uintIdTable, longIdTable, ulongIdTable) + + withDb(excludeSettings = columnTypeChangeUnsupportedDb) { + tables.forEach { oldTable -> + for (newTable in tables) { + val oldIdColumn = (oldTable.id.columnType as EntityIDColumnType<*>).idColumn + val newIdColumn = (newTable.id.columnType as EntityIDColumnType<*>).idColumn + + if (oldIdColumn.columnType == newIdColumn.columnType) { + continue + } + + withTables(oldTable) { + assertTrue(MigrationUtils.statementsRequiredForDatabaseMigration(oldTable, withLogs = false).isEmpty()) + + val statements = MigrationUtils.statementsRequiredForDatabaseMigration(newTable, withLogs = false) + + var expectedSize = 0 + if (oldTable.ddl.any { it.contains("CHECK") } || newTable.ddl.any { it.contains("CHECK") }) { + expectedSize++ // Statement for adding or dropping the CHECK constraint + } + if (oldIdColumn.columnType.sqlType() != newIdColumn.columnType.sqlType()) { + expectedSize++ // Statement for changing the column type + } + assertEquals(expectedSize, statements.size) + + statements.forEach(::exec) + newTable.insert {} + } + } + } + } + } + private fun expectedCreateSequenceStatement(sequenceName: String) = "CREATE SEQUENCE${" IF NOT EXISTS".takeIf { currentDialectTest.supportsIfNotExists } ?: ""} " + "$sequenceName START WITH 1 MINVALUE 1 MAXVALUE ${currentDialectTest.sequenceMaxValue}" @@ -807,4 +1053,104 @@ class DatabaseMigrationTests : DatabaseTestsBase() { override val id: Column> = long("id").autoIncrement(sequenceName).entityId() } } + + private enum class TestEnum { A, B, C } + + private val sqlType by lazy { + when (currentDialectTest) { + is H2Dialect, is MysqlDialect -> "ENUM('Bar', 'Baz')" + is PostgreSQLDialect -> "RefEnum" + else -> error("Unsupported case") + } + } + + private val columnTypesTester by lazy { + object : Table("tester") { + val byte = byte("byte_col") + val ubyte = ubyte("ubyte_col") + val short = short("short_col") + val ushort = ushort("ushort_col") + val integer = integer("integer_col") + val uinteger = uinteger("uinteger_col") + val long = long("long_col") + val ulong = ulong("ulong_col") + val float = float("float_col") + val double = double("double_col") + val decimal = decimal("decimal_col", 6, 2) + val decimal2 = decimal("decimal_col_2", 3, 2) + val char = char("char_col") + val letter = char("letter_col", 1) + val char2 = char("char_col_2", 2) + val varchar = varchar("varchar_col", 14) + val varchar2 = varchar("varchar_col_2", 28) + val text = text("text_col") + val mediumText = mediumText("mediumText_col") + val largeText = largeText("largeText_col") + + val binary = binary("binary_col", 123) + val binary2 = binary("binary_col_2", 456) + val blob = blob("blob_col") + val uuid = uuid("uuid_col") + val bool = bool("boolean_col") + val enum1 = enumeration("enum_col_1", TestEnum::class) + val enum2 = enumeration("enum_col_2") + val enum3 = enumerationByName("enum_col_3", 25, TestEnum::class) + val enum4 = enumerationByName("enum_col_4", 64, TestEnum::class) + val enum5 = enumerationByName("enum_col_5", 16) + val enum6 = enumerationByName("enum_col_6", 32) + val customEnum = customEnumeration( + "custom_enum_col", + sqlType, + { value -> Foo.valueOf(value as String) }, + { value -> + when (currentDialectTest) { + is PostgreSQLDialect -> PGEnum(sqlType, value) + else -> value.name + } + } + ) + val currency = currency("currency_col") + val date = date("date_col") + val datetime = datetime("datetime_col") + val time = time("time_col") + val timestamp = timestamp("timestamp_col") + val timestampWithTimeZone = timestampWithTimeZone("timestampWithTimeZone_col") + val duration = duration("duration_col") + val intArrayJson = json("json_col", Json.Default) + val intArrayJsonb = jsonb("jsonb_col", Json.Default) + } + } + + private val arraysTester by lazy { + object : Table("tester") { + val byteArray = array("byteArray", ByteColumnType()) + val ubyteArray = array("ubyteArray", UByteColumnType()) + val shortArray = array("shortArray", ShortColumnType(), 10) + val ushortArray = array("ushortArray", UShortColumnType(), 10) + val intArray = array("intArray", 20) + val uintArray = array("uintArray", 20) + val longArray = array("longArray", 30) + val ulongArray = array("ulongArray", 30) + val floatArray = array("floatArray", 40) + val doubleArray = array("doubleArray", 50) + val decimalArray = array("decimalArray", DecimalColumnType(6, 3), 60) + val charArray = array("charArray", CharacterColumnType(), 70) + val initialsArray = array("initialsArray", CharColumnType(2), 45) + val varcharArray = array("varcharArray", VarCharColumnType(), 80) + val textArray = array("textArray", TextColumnType(), 90) + val mediumTextArray = array("mediumTextArray", MediumTextColumnType(), 100) + val largeTextArray = array("largeTextArray", LargeTextColumnType(), 110) + val binaryArray = array("binaryArray", BinaryColumnType(123), 120) + val blobArray = array("blobArray", BlobColumnType(), 130) + val uuidArray = array("uuidArray", 140) + val booleanArray = array("booleanArray", 150) + val currencyArray = array("currencyArray", CurrencyColumnType(), 25) + val dateArray = array("dateArray", KotlinLocalDateColumnType(), 366) + val datetimeArray = array("datetimeArray", KotlinLocalDateTimeColumnType(), 10) + val timeArray = array("timeArray", KotlinLocalTimeColumnType(), 14) + val timestampArray = array("timestampArray", KotlinInstantColumnType(), 10) + val timestampWithTimeZoneArray = array("timestampWithTimeZoneArray", KotlinOffsetDateTimeColumnType(), 10) + val durationArray = array("durationArray", KotlinDurationColumnType(), 7) + } + } }