From bea4d78c8ad50dbdbe55ba9071f461df46b27230 Mon Sep 17 00:00:00 2001 From: Jocelyne Date: Thu, 4 Jul 2024 12:36:29 +0200 Subject: [PATCH] fix: EXPOSED-432 CurrentDate default is generated as null in MariaDB As of [MySQL 8.0.13](https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html#:~:text=Expression%20Evaluation%E2%80%9D.-,Explicit%20Default%20Handling%20Prior%20to%20MySQL%208.0.13,-With%20one%20exception) and [MariaDB 10.2.1](https://mariadb.com/kb/en/create-table/#default-column-option), the default value specified in a DEFAULT clause can be a function or an expression. --- .../org/jetbrains/exposed/sql/SchemaUtils.kt | 24 ++++++++++++++----- .../exposed/sql/vendors/MysqlDialect.kt | 19 ++++++++++++--- .../exposed/sql/javatime/JavaDateFunctions.kt | 2 ++ .../org/jetbrains/exposed/DefaultsTest.kt | 22 ++--------------- .../org/jetbrains/exposed/JavaTimeTests.kt | 12 ++++++++++ .../exposed/sql/jodatime/DateFunctions.kt | 2 ++ .../jetbrains/exposed/JodaTimeDefaultsTest.kt | 10 ++------ .../kotlin/datetime/KotlinDateFunctions.kt | 4 +++- .../sql/kotlin/datetime/DefaultsTest.kt | 22 ++--------------- .../sql/kotlin/datetime/KotlinTimeTests.kt | 12 ++++++++++ 10 files changed, 71 insertions(+), 58 deletions(-) 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 becbeb229c..1719205e74 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 @@ -252,10 +252,17 @@ object SchemaUtils { is Function<*> -> { var processed = processForDefaultValue(exp) - if (exp.columnType is IDateColumnType && (processed.startsWith("CURRENT_TIMESTAMP") || processed == "GETDATE()")) { - when (currentDialect) { - is SQLServerDialect -> processed = "getdate" - is MariaDBDialect -> processed = processed.lowercase() + if (exp.columnType is IDateColumnType) { + if (processed.startsWith("CURRENT_TIMESTAMP") || processed == "GETDATE()") { + when (currentDialect) { + is SQLServerDialect -> processed = "getdate" + is MariaDBDialect -> processed = processed.lowercase() + } + } + if (processed.trim('(').startsWith("CURRENT_DATE")) { + when (currentDialect) { + is MysqlDialect -> processed = "curdate()" + } } } processed @@ -313,7 +320,12 @@ object SchemaUtils { val dataTypeProvider = currentDialect.dataTypeProvider val redoColumns = existingTableColumns.mapValues { (col, existingCol) -> val columnType = col.columnType - val incorrectNullability = existingCol.nullable != columnType.nullable + val colNullable = if (col.dbDefaultValue?.let { currentDialect.isAllowedAsColumnDefault(it) } == false) { + true // Treat a disallowed default value as null because that is what Exposed does with it + } else { + columnType.nullable + } + val incorrectNullability = existingCol.nullable != colNullable // Exposed doesn't support changing sequences on columns val incorrectAutoInc = existingCol.autoIncrement != columnType.isAutoInc && col.autoIncColumnType?.autoincSeq == null @@ -350,7 +362,7 @@ object SchemaUtils { */ private fun isIncorrectDefault(dataTypeProvider: DataTypeProvider, columnMeta: ColumnMetadata, column: Column<*>): Boolean { val isExistingColumnDefaultNull = columnMeta.defaultDbValue == null - val isDefinedColumnDefaultNull = column.dbDefaultValue == null || + val isDefinedColumnDefaultNull = column.dbDefaultValue?.takeIf { currentDialect.isAllowedAsColumnDefault(it) } == null || (column.dbDefaultValue is LiteralOp<*> && (column.dbDefaultValue as? LiteralOp<*>)?.value == null) return when { diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/MysqlDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/MysqlDialect.kt index cbd7109ed6..070c2b17d5 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/MysqlDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/MysqlDialect.kt @@ -3,6 +3,7 @@ package org.jetbrains.exposed.sql.vendors import org.jetbrains.exposed.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.exceptions.throwUnsupportedException import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.Function import org.jetbrains.exposed.sql.transactions.TransactionManager import java.math.BigDecimal @@ -57,13 +58,22 @@ internal object MysqlDataTypeProvider : DataTypeProvider() { override fun processForDefaultValue(e: Expression<*>): String = when { e is LiteralOp<*> && e.columnType is JsonColumnMarker -> when { currentDialect is MariaDBDialect -> super.processForDefaultValue(e) - (currentDialect as? MysqlDialect)?.isMysql8 == true -> "(${super.processForDefaultValue(e)})" + ((currentDialect as? MysqlDialect)?.fullVersion ?: "0") >= "8.0.13" -> "(${super.processForDefaultValue(e)})" else -> throw UnsupportedByDialectException( "MySQL versions prior to 8.0.13 do not accept default values on JSON columns", currentDialect ) } - + currentDialect is MariaDBDialect -> super.processForDefaultValue(e) + // The default value specified in a DEFAULT clause can be a literal constant or an expression. With one + // exception, enclose expression default values within parentheses to distinguish them from literal constant + // default values. The exception is that, for TIMESTAMP and DATETIME columns, you can specify the + // CURRENT_TIMESTAMP function as the default, without enclosing parentheses. + // https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html#data-type-defaults-explicit + e is Function<*> && e.columnType is IDateColumnType && e.toString().startsWith("CURRENT_TIMESTAMP") -> + super.processForDefaultValue(e) + e is Function<*> && ((currentDialect as? MysqlDialect)?.fullVersion ?: "0") >= "8.0.13" -> + "(${super.processForDefaultValue(e)})" else -> super.processForDefaultValue(e) } @@ -308,7 +318,10 @@ open class MysqlDialect : VendorDialect(dialectName, MysqlDataTypeProvider, Mysq override fun isAllowedAsColumnDefault(e: Expression<*>): Boolean { if (super.isAllowedAsColumnDefault(e)) return true - val acceptableDefaults = arrayOf("CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", "NOW()", "CURRENT_TIMESTAMP(6)", "NOW(6)") + if ((currentDialect is MariaDBDialect && fullVersion >= "10.2.1") || (currentDialect !is MariaDBDialect && fullVersion >= "8.0.13")) { + return true + } + val acceptableDefaults = mutableListOf("CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", "NOW()", "CURRENT_TIMESTAMP(6)", "NOW(6)") return e.toString().trim() in acceptableDefaults && isFractionDateTimeSupported() } diff --git a/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt b/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt index bed3ef38d0..84ab596ae1 100644 --- a/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt +++ b/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt @@ -3,6 +3,7 @@ package org.jetbrains.exposed.sql.javatime import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.Function import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.MariaDBDialect import org.jetbrains.exposed.sql.vendors.MysqlDialect import org.jetbrains.exposed.sql.vendors.SQLServerDialect import org.jetbrains.exposed.sql.vendors.currentDialect @@ -55,6 +56,7 @@ sealed class CurrentTimestampBase(columnType: IColumnType) : Functio object CurrentDate : Function(JavaLocalDateColumnType.INSTANCE) { override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { +when (currentDialect) { + is MariaDBDialect -> "curdate()" is MysqlDialect -> "CURRENT_DATE()" is SQLServerDialect -> "GETDATE()" else -> "CURRENT_DATE" diff --git a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt index ac52d6859d..dde6bf3da1 100644 --- a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt +++ b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt @@ -292,17 +292,10 @@ class DefaultsTest : DatabaseTestsBase() { @Test fun testDefaultExpressions01() { - fun abs(value: Int) = object : ExpressionWithColumnType() { - override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { append("ABS($value)") } - - override val columnType: IColumnType = IntegerColumnType() - } - val foo = object : IntIdTable("foo") { val name = text("name") val defaultDateTime = datetime("defaultDateTime").defaultExpression(CurrentDateTime) val defaultDate = date("defaultDate").defaultExpression(CurrentDate) - val defaultInt = integer("defaultInteger").defaultExpression(abs(-100)) } withTables(foo) { @@ -313,7 +306,6 @@ class DefaultsTest : DatabaseTestsBase() { assertEquals(today, result[foo.defaultDateTime].toLocalDate()) assertEquals(today, result[foo.defaultDate]) - assertEquals(100, result[foo.defaultInt]) } } @@ -368,8 +360,7 @@ class DefaultsTest : DatabaseTestsBase() { val foo = object : IntIdTable("foo") { val name = text("name") val defaultDate = date("default_date").defaultExpression(CurrentDate) - val defaultDateTime1 = datetime("default_date_time_1").defaultExpression(CurrentDateTime) - val defaultDateTime2 = datetime("default_date_time_2").defaultExpression(CurrentDateTime) + val defaultDateTime = datetime("default_date_time").defaultExpression(CurrentDateTime) val defaultTimeStamp = timestamp("default_time_stamp").defaultExpression(CurrentTimestamp) } @@ -379,16 +370,7 @@ class DefaultsTest : DatabaseTestsBase() { val actual = SchemaUtils.statementsRequiredToActualizeScheme(foo) - if (currentDialectTest is MysqlDialect) { - // MySQL and MariaDB do not support CURRENT_DATE as default - // so the column is created with a NULL marker, which correctly triggers 1 alter statement - val tableName = foo.nameInDatabaseCase() - val dateColumnName = foo.defaultDate.nameInDatabaseCase() - val alter = "ALTER TABLE $tableName MODIFY COLUMN $dateColumnName DATE NULL" - assertEquals(alter, actual.single()) - } else { - assertTrue(actual.isEmpty()) - } + assertTrue(actual.isEmpty()) } finally { SchemaUtils.drop(foo) } diff --git a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt index 2f3f96f488..6b701fba90 100644 --- a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt +++ b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.between @@ -593,6 +594,17 @@ class JavaTimeTests : DatabaseTestsBase() { ) } } + + @Test + fun testCurrentDateAsDefaultExpression() { + val testTable = object : LongIdTable("test_table") { + val date: Column = date("date").index().defaultExpression(CurrentDate) + } + withTables(testTable) { + val statements = SchemaUtils.statementsRequiredForDatabaseMigration(testTable) + assertTrue(statements.isEmpty()) + } + } } fun assertEqualDateTime(d1: T?, d2: T?) { diff --git a/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt b/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt index e5c84a524e..f95fa781ca 100644 --- a/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt +++ b/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt @@ -3,6 +3,7 @@ package org.jetbrains.exposed.sql.jodatime import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.Function import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.MariaDBDialect import org.jetbrains.exposed.sql.vendors.MysqlDialect import org.jetbrains.exposed.sql.vendors.SQLServerDialect import org.jetbrains.exposed.sql.vendors.currentDialect @@ -38,6 +39,7 @@ object CurrentDateTime : Function(DateColumnType(true)) { object CurrentDate : Function(DateColumnType(false)) { override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { +when (currentDialect) { + is MariaDBDialect -> "curdate()" is MysqlDialect -> "CURRENT_DATE()" is SQLServerDialect -> "GETDATE()" else -> "CURRENT_DATE" diff --git a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt index 52c08c41da..aff9a45997 100644 --- a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt +++ b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt @@ -224,17 +224,10 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { @Test fun testDefaultExpressions01() { - fun abs(value: Int) = object : ExpressionWithColumnType() { - override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { append("ABS($value)") } - - override val columnType: IColumnType = IntegerColumnType() - } - val foo = object : IntIdTable("foo") { val name = text("name") val defaultDateTime = datetime("defaultDateTime").defaultExpression(CurrentDateTime) val defaultDate = date("defaultDate").defaultExpression(CurrentDate) - val defaultInt = integer("defaultInteger").defaultExpression(abs(-100)) } withTables(foo) { @@ -245,7 +238,6 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { assertEquals(today, result[foo.defaultDateTime].withTimeAtStartOfDay()) assertEquals(today, result[foo.defaultDate]) - assertEquals(100, result[foo.defaultInt]) } } @@ -428,6 +420,7 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { fun testConsistentSchemeWithFunctionAsDefaultExpression() { val foo = object : IntIdTable("foo") { val name = text("name") + val defaultDate = date("default_date").defaultExpression(CurrentDate) val defaultDateTime = datetime("defaultDateTime").defaultExpression(CurrentDateTime) } @@ -436,6 +429,7 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { SchemaUtils.create(foo) val actual = SchemaUtils.statementsRequiredToActualizeScheme(foo) + assertTrue(actual.isEmpty()) } finally { SchemaUtils.drop(foo) diff --git a/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt b/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt index 35629fdcaa..c6ac4ed29b 100644 --- a/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt +++ b/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt @@ -9,6 +9,7 @@ import kotlinx.datetime.LocalTime import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.Function import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.MariaDBDialect import org.jetbrains.exposed.sql.vendors.MysqlDialect import org.jetbrains.exposed.sql.vendors.SQLServerDialect import org.jetbrains.exposed.sql.vendors.currentDialect @@ -107,7 +108,8 @@ object CurrentTimestampWithTimeZone : CurrentTimestampBase(Kotli object CurrentDate : Function(KotlinLocalDateColumnType.INSTANCE) { override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { +when (currentDialect) { - is MysqlDialect -> "CURRENT_DATE()" + is MariaDBDialect -> "curdate()" + is MysqlDialect -> "CURRENT_DATE" is SQLServerDialect -> "GETDATE()" else -> "CURRENT_DATE" } diff --git a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt index 95c8a87832..2e1402a2a2 100644 --- a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt +++ b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt @@ -290,17 +290,10 @@ class DefaultsTest : DatabaseTestsBase() { @Test fun testDefaultExpressions01() { - fun abs(value: Int) = object : ExpressionWithColumnType() { - override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { append("ABS($value)") } - - override val columnType: IColumnType = IntegerColumnType() - } - val foo = object : IntIdTable("foo") { val name = text("name") val defaultDateTime = datetime("defaultDateTime").defaultExpression(CurrentDateTime) val defaultDate = date("defaultDate").defaultExpression(CurrentDate) - val defaultInt = integer("defaultInteger").defaultExpression(abs(-100)) } withTables(foo) { @@ -311,7 +304,6 @@ class DefaultsTest : DatabaseTestsBase() { assertEquals(today, result[foo.defaultDateTime].date) assertEquals(today, result[foo.defaultDate]) - assertEquals(100, result[foo.defaultInt]) } } @@ -372,8 +364,7 @@ class DefaultsTest : DatabaseTestsBase() { val foo = object : IntIdTable("foo") { val name = text("name") val defaultDate = date("default_date").defaultExpression(CurrentDate) - val defaultDateTime1 = datetime("default_date_time_1").defaultExpression(CurrentDateTime) - val defaultDateTime2 = datetime("default_date_time_2").defaultExpression(CurrentDateTime) + val defaultDateTime = datetime("default_date_time").defaultExpression(CurrentDateTime) val defaultTimeStamp = timestamp("default_time_stamp").defaultExpression(CurrentTimestamp) } @@ -383,16 +374,7 @@ class DefaultsTest : DatabaseTestsBase() { val actual = SchemaUtils.statementsRequiredToActualizeScheme(foo) - if (currentDialectTest is MysqlDialect) { - // MySQL and MariaDB do not support CURRENT_DATE as default - // so the column is created with a NULL marker, which correctly triggers 1 alter statement - val tableName = foo.nameInDatabaseCase() - val dateColumnName = foo.defaultDate.nameInDatabaseCase() - val alter = "ALTER TABLE $tableName MODIFY COLUMN $dateColumnName DATE NULL" - assertEquals(alter, actual.single()) - } else { - assertTrue(actual.isEmpty()) - } + assertTrue(actual.isEmpty()) } finally { SchemaUtils.drop(foo) } diff --git a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt index a503a05dd1..d3479c882b 100644 --- a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt +++ b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.between @@ -610,6 +611,17 @@ class KotlinTimeTests : DatabaseTestsBase() { ) } } + + @Test + fun testCurrentDateAsDefaultExpression() { + val testTable = object : LongIdTable("test_table") { + val date: Column = date("date").index().defaultExpression(CurrentDate) + } + withTables(testTable) { + val statements = SchemaUtils.statementsRequiredForDatabaseMigration(testTable) + assertTrue(statements.isEmpty()) + } + } } fun assertEqualDateTime(d1: T?, d2: T?) {