From 519f0986fa7b5f2b5b1942a5441b93545899a1ae Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Fri, 28 Jun 2024 13:31:51 +0200 Subject: [PATCH] Review issues: fix binary colum default values and ddl generation --- exposed-core/api/exposed-core.api | 1 + .../org/jetbrains/exposed/sql/ColumnType.kt | 16 +++- .../kotlin/org/jetbrains/exposed/sql/Op.kt | 3 + .../exposed/sql/SQLExpressionBuilder.kt | 2 +- .../org/jetbrains/exposed/sql/SchemaUtils.kt | 13 +++- .../jdbc/JdbcDatabaseMetadataImpl.kt | 16 +++- .../tests/shared/ddl/ColumnDefinitionTests.kt | 71 ------------------ .../ddl/CreateMissingTablesAndColumnsTests.kt | 75 +++++++++++++++++++ .../shared/types/BinaryColumnTypeTests.kt | 41 ++++++++++ 9 files changed, 160 insertions(+), 78 deletions(-) create mode 100644 exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/BinaryColumnTypeTests.kt diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index e96fa28049..363adbce12 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -1647,6 +1647,7 @@ public final class org/jetbrains/exposed/sql/OpKt { public static final fun andIfNotNull (Lorg/jetbrains/exposed/sql/Op;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/Op; public static final fun andIfNotNull (Lorg/jetbrains/exposed/sql/Op;Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/Op; public static final fun andNot (Lorg/jetbrains/exposed/sql/Expression;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/Op; + public static final fun binaryLiteral ([B)Lorg/jetbrains/exposed/sql/LiteralOp; public static final fun blobParam (Lorg/jetbrains/exposed/sql/statements/api/ExposedBlob;Z)Lorg/jetbrains/exposed/sql/Expression; public static synthetic fun blobParam$default (Lorg/jetbrains/exposed/sql/statements/api/ExposedBlob;ZILjava/lang/Object;)Lorg/jetbrains/exposed/sql/Expression; public static final fun booleanLiteral (Z)Lorg/jetbrains/exposed/sql/LiteralOp; diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt index 3144706cea..78e487c213 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt @@ -739,7 +739,21 @@ open class BasicBinaryColumnType : ColumnType() { else -> error("Unexpected value $value of type ${value::class.qualifiedName}") } - override fun nonNullValueToString(value: ByteArray): String = value.toString(Charsets.UTF_8) + override fun nonNullValueToString(value: ByteArray): String { + return when { + currentDialect is H2Dialect && currentDialect.h2Mode == null -> "X'${value.toHexString().lowercase()}'" + currentDialect is MariaDBDialect -> toTextString(value) + currentDialect is OracleDialect -> "HEXTORAW('${value.toHexString()}')" + currentDialect is SQLServerDialect || currentDialect is MysqlDialect -> "0x${value.toHexString()}" + currentDialect is PostgreSQLDialect -> "'\\x${value.toHexString().lowercase()}'::bytea" + else -> toTextString(value) + } + } + + private fun toTextString(value: ByteArray) = TextColumnType().nonNullValueToString(value.toString(Charsets.UTF_8)) + + @Suppress("MagicNumber") + private fun ByteArray.toHexString(): String = joinToString("") { it.toString(16).uppercase().padStart(2, '0') } } /** diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt index 91cd5fe161..1838ee1d16 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt @@ -678,6 +678,9 @@ fun stringLiteral(value: String): LiteralOp = LiteralOp(TextColumnType() /** Returns the specified [value] as a decimal literal. */ fun decimalLiteral(value: BigDecimal): LiteralOp = LiteralOp(DecimalColumnType(value.precision(), value.scale()), value) +/** Returns the specified [value] as a binary literal. */ +fun binaryLiteral(value: ByteArray): LiteralOp = LiteralOp(BasicBinaryColumnType(), value) + /** * Returns the specified [value] as an array literal, with elements parsed by the [delegateType] if provided. * diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt index 720843eb4d..4076c75963 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt @@ -927,7 +927,7 @@ interface ISqlExpressionBuilder { /** Returns the specified [value] as a literal of type [T]. */ @Suppress("UNCHECKED_CAST", "ComplexMethod") fun ExpressionWithColumnType.asLiteral(value: T): LiteralOp = when { - value is ByteArray && columnType is BasicBinaryColumnType -> stringLiteral(value.toString(Charsets.UTF_8)) + value is ByteArray && columnType is BasicBinaryColumnType -> binaryLiteral(value) else -> LiteralOp(columnType as IColumnType, value) } as LiteralOp 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 c966bb94e7..d1e7fb1e2e 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 @@ -199,6 +199,17 @@ object SchemaUtils { else -> processForDefaultValue(exp) } + is ByteArray -> when { + dialect is MariaDBDialect -> value.toString(Charsets.UTF_8) + dialect is MysqlDialect && !dialect.isMysql8 -> value.toString(Charsets.UTF_8) + dialect is MysqlDialect || + dialect is SQLServerDialect || + dialect is OracleDialect || + dialect is PostgreSQLDialect -> BasicBinaryColumnType().nonNullValueToString(value) + dialect is H2Dialect && dialect.h2Mode == null -> BasicBinaryColumnType().nonNullValueToString(value) + else -> value.toString(Charsets.UTF_8) + } + else -> { when { column.columnType is JsonColumnMarker -> { @@ -354,7 +365,7 @@ object SchemaUtils { (column.dbDefaultValue is LiteralOp<*> && (column.dbDefaultValue as? LiteralOp<*>)?.value == null) return when { - // Bot values are null-like, no DDL update is needed + // Both values are null-like, no DDL update is needed isExistingColumnDefaultNull && isDefinedColumnDefaultNull -> false // Only one of the values is null-like, DDL update is needed isExistingColumnDefaultNull != isDefinedColumnDefaultNull -> true 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 1b1bf95e32..66e691a5d5 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 @@ -5,6 +5,7 @@ import org.jetbrains.exposed.sql.statements.api.ExposedDatabaseMetadata import org.jetbrains.exposed.sql.statements.api.IdentifierManagerApi import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.vendors.* +import org.jetbrains.exposed.sql.vendors.H2Dialect.H2CompatibilityMode import java.math.BigDecimal import java.sql.DatabaseMetaData import java.sql.ResultSet @@ -213,14 +214,21 @@ class JdbcDatabaseMetadataImpl(database: String, val metadata: DatabaseMetaData) */ private fun sanitizedDefault(defaultValue: String): String? { val dialect = currentDialect + val h2Mode = dialect.h2Mode return when { - dialect is SQLServerDialect -> defaultValue.trim('(', ')').extractNullAndStringFromDefaultValue() - dialect is MariaDBDialect -> when (defaultValue) { - "NULL" -> null + // Check for MariaDB must be before MySql because MariaDBDialect as a class inherits MysqlDialect + dialect is MariaDBDialect || h2Mode == H2CompatibilityMode.MariaDB -> when { + defaultValue.startsWith("b'") -> defaultValue.substringAfter("b'").trim('\'') + else -> defaultValue.extractNullAndStringFromDefaultValue() + } + // It's the special case, because MySql returns default string "NULL" as string "NULL", but other DBs return it as "'NULL'" + dialect is MysqlDialect && defaultValue == "NULL" -> defaultValue + dialect is MysqlDialect || h2Mode == H2CompatibilityMode.MySQL -> when { + defaultValue.startsWith("b'") -> defaultValue.substringAfter("b'").trim('\'') else -> defaultValue.extractNullAndStringFromDefaultValue() } + dialect is SQLServerDialect -> defaultValue.trim('(', ')').extractNullAndStringFromDefaultValue() dialect is OracleDialect -> defaultValue.trim().extractNullAndStringFromDefaultValue() - dialect is MysqlDialect -> defaultValue.substringAfter("b'").trim('\'') else -> defaultValue.extractNullAndStringFromDefaultValue() } } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/ColumnDefinitionTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/ColumnDefinitionTests.kt index 3bed501097..54ec1b72b1 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/ColumnDefinitionTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/ColumnDefinitionTests.kt @@ -186,75 +186,4 @@ class ColumnDefinitionTests : DatabaseTestsBase() { result2.close() } } - - @Test - fun testNoChangesOnCreateMissingNullableColumns() { - val testerWithDefaults = object : Table("tester") { - val defaultNullNumber = integer("default_null_number").nullable().default(null) - val defaultNullWord = varchar("default_null_word", 8).nullable().default(null) - val nullNumber = integer("null_number").nullable() - val nullWord = varchar("null_word", 8).nullable() - val defaultNumber = integer("default_number").default(999).nullable() - val defaultWord = varchar("default_word", 8).default("Hello").nullable() - } - - val testerWithoutDefaults = object : Table("tester") { - val defaultNullNumber = integer("default_null_number").nullable() - val defaultNullWord = varchar("default_null_word", 8).nullable() - val nullNumber = integer("null_number").nullable() - val nullWord = varchar("null_word", 8).nullable() - val defaultNumber = integer("default_number").default(999).nullable() - val defaultWord = varchar("default_word", 8).default("Hello").nullable() - } - - listOf( - testerWithDefaults to testerWithDefaults, - testerWithDefaults to testerWithoutDefaults, - testerWithoutDefaults to testerWithDefaults, - testerWithoutDefaults to testerWithoutDefaults - ).forEach { (existingTable, definedTable) -> - withTables(existingTable) { - SchemaUtils.statementsRequiredToActualizeScheme(definedTable).also { - assertTrue(it.isEmpty()) - } - } - } - } - - @Test - fun testChangesOnCreateMissingNullableColumns() { - val testerWithDefaults = object : Table("tester") { - val defaultNullString = varchar("default_null_string", 8).nullable().default("NULL") - val defaultNumber = integer("default_number").default(999).nullable() - val defaultWord = varchar("default_word", 8).default("Hello").nullable() - } - - val testerWithoutDefaults = object : Table("tester") { - val defaultNullString = varchar("default_null_string", 8).nullable() - val defaultNumber = integer("default_number").nullable() - val defaultWord = varchar("default_word", 8).nullable() - } - - listOf( - testerWithDefaults to testerWithoutDefaults, - testerWithoutDefaults to testerWithDefaults, - ).forEach { (existingTable, definedTable) -> - withTables(excludeSettings = listOf(TestDB.SQLITE), existingTable) { - SchemaUtils.statementsRequiredToActualizeScheme(definedTable).also { - assertTrue(it.isNotEmpty()) - } - } - } - - listOf( - testerWithDefaults to testerWithDefaults, - testerWithoutDefaults to testerWithoutDefaults - ).forEach { (existingTable, definedTable) -> - withTables(excludeSettings = listOf(TestDB.SQLITE), existingTable) { - SchemaUtils.statementsRequiredToActualizeScheme(definedTable).also { - assertTrue(it.isEmpty()) - } - } - } - } } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/CreateMissingTablesAndColumnsTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/CreateMissingTablesAndColumnsTests.kt index 9fbcfb6e0e..11c936d571 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/CreateMissingTablesAndColumnsTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/CreateMissingTablesAndColumnsTests.kt @@ -726,4 +726,79 @@ class CreateMissingTablesAndColumnsTests : DatabaseTestsBase() { } } } + + @Test + fun testNoChangesOnCreateMissingNullableColumns() { + val testerWithDefaults = object : Table("tester") { + val defaultNullNumber = integer("default_null_number").nullable().default(null) + val defaultNullWord = varchar("default_null_word", 8).nullable().default(null) + val nullNumber = integer("null_number").nullable() + val nullWord = varchar("null_word", 8).nullable() + val defaultNumber = integer("default_number").default(999).nullable() + val defaultWord = varchar("default_word", 8).default("Hello").nullable() + val defaultBinary = binary("default_binary", 256).nullable().default(null) + } + + val testerWithoutDefaults = object : Table("tester") { + val defaultNullNumber = integer("default_null_number").nullable() + val defaultNullWord = varchar("default_null_word", 8).nullable() + val nullNumber = integer("null_number").nullable() + val nullWord = varchar("null_word", 8).nullable() + val defaultNumber = integer("default_number").default(999).nullable() + val defaultWord = varchar("default_word", 8).default("Hello").nullable() + val defaultBinary = binary("default_binary", 256).nullable() + } + + listOf( + testerWithDefaults to testerWithDefaults, + testerWithDefaults to testerWithoutDefaults, + testerWithoutDefaults to testerWithDefaults, + testerWithoutDefaults to testerWithoutDefaults + ).forEach { (existingTable, definedTable) -> + withTables(existingTable) { + SchemaUtils.statementsRequiredToActualizeScheme(definedTable).also { + assertTrue(it.isEmpty()) + } + } + } + } + + @Test + fun testChangesOnCreateMissingNullableColumns() { + val testerWithDefaults = object : Table("tester") { + val defaultNullString = varchar("default_null_string", 8).nullable().default("NULL") + val defaultNumber = integer("default_number").default(999).nullable() + val defaultWord = varchar("default_word", 8).default("Hello").nullable() + val defaultBinary = binary("default_binary", 256).nullable().default("default-binary".toByteArray()) + } + + val testerWithoutDefaults = object : Table("tester") { + val defaultNullString = varchar("default_null_string", 8).nullable() + val defaultNumber = integer("default_number").nullable() + val defaultWord = varchar("default_word", 8).nullable() + val defaultBinary = binary("default_binary", 256).nullable() + } + + listOf( + testerWithDefaults to testerWithoutDefaults, + testerWithoutDefaults to testerWithDefaults, + ).forEach { (existingTable, definedTable) -> + withTables(excludeSettings = listOf(TestDB.SQLITE), existingTable) { + SchemaUtils.statementsRequiredToActualizeScheme(definedTable).also { + assertTrue(it.isNotEmpty()) + } + } + } + + listOf( + testerWithDefaults to testerWithDefaults, + testerWithoutDefaults to testerWithoutDefaults + ).forEach { (existingTable, definedTable) -> + withTables(excludeSettings = listOf(TestDB.SQLITE), existingTable) { + SchemaUtils.statementsRequiredToActualizeScheme(definedTable).also { + assertTrue(it.isEmpty()) + } + } + } + } } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/BinaryColumnTypeTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/BinaryColumnTypeTests.kt new file mode 100644 index 0000000000..e04c6559c4 --- /dev/null +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/BinaryColumnTypeTests.kt @@ -0,0 +1,41 @@ +package org.jetbrains.exposed.sql.tests.shared.types + +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.binaryLiteral +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.tests.DatabaseTestsBase +import org.jetbrains.exposed.sql.tests.shared.assertEqualLists +import org.junit.Test + +class BinaryColumnTypeTests : DatabaseTestsBase() { + @Test + fun testBinaryColumn() { + val defaultValue = "default-value".toByteArray() + val tester = object : IntIdTable("testBinaryColumn") { + val bin = binary("bin", 256).default(defaultValue) + } + + withTables(tester) { + assertEqualLists(emptyList(), SchemaUtils.statementsRequiredToActualizeScheme(tester)) + + val id1 = tester.insertAndGetId { } + val row1 = tester.selectAll().where { tester.id eq id1 }.first() + val binValue1 = row1[tester.bin] + assertEqualLists(defaultValue.toList(), binValue1.toList()) + + val literalValue = "literal-value".toByteArray() + val id2 = tester.insertAndGetId { it[tester.bin] = binaryLiteral(literalValue) } + val row2 = tester.selectAll().where { tester.id eq id2 }.first() + val binValue2 = row2[tester.bin] + assertEqualLists(literalValue.toList(), binValue2.toList()) + + val nonLiteralValue = "non-literal-value".toByteArray() + val id3 = tester.insertAndGetId { it[tester.bin] = nonLiteralValue } + val row3 = tester.selectAll().where { tester.id eq id3 }.first() + val binValue3 = row3[tester.bin] + assertEqualLists(nonLiteralValue.toList(), binValue3.toList()) + } + } +}