From 865d9b82a1eaf0f9cbd8c6ec9c251690a0e13e11 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 3 Aug 2023 17:20:30 -0400 Subject: [PATCH] fix: EXPOSED-116 UUID conversion error with upsert in H2 Using a UUID column as a key constraint in H2's MERGE INTO -- USING statement was failing with: org.h2.jdbc.JdbcSQLDataException: Data conversion error converting "U&'\\fffdK\\fffdS\\001bJr\\fffdG%\\fffdE\\00100\\fffd' (TESTER: ""ID"" UUID NOT NULL)"; This error occurs because the statement uses a derived column list to merge ON (T.ID=S.ID), so the UUID as a ByteArray in T.ID needs to be converted internally to compare with the String in S.ID. This error goes away if a regular integer id column is used or if the key constraint is swapped with another. The error also goes away if the identical MERGE statement is placed in an exec() directly, indicating that the issue lies with how Exposed sends UUID values to the H2 database. Switching the sent value from a ByteArray to a String allows comparison without conversion. A String is still one of 3 acceptable ways to store a UUID in H2 and sending a String still results in a UUID being retrieved back from the database. --- .../org/jetbrains/exposed/sql/vendors/H2.kt | 2 + .../exposed/sql/vendors/OracleDialect.kt | 21 ++++++++-- .../sql/tests/shared/dml/UpsertTests.kt | 38 ++++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) 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 b26c613c3e..bd1f333f0a 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,6 +4,7 @@ import org.intellij.lang.annotations.Language import org.jetbrains.exposed.exceptions.throwUnsupportedException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.TransactionManager +import java.util.* internal object H2DataTypeProvider : DataTypeProvider() { override fun binaryType(): String { @@ -12,6 +13,7 @@ internal object H2DataTypeProvider : DataTypeProvider() { } override fun uuidType(): String = "UUID" + override fun uuidToDB(value: UUID): Any = value.toString() override fun dateTimeType(): String = "DATETIME(9)" override fun timestampWithTimeZoneType(): String = "TIMESTAMP(9) WITH TIME ZONE" 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 42b08283f2..82e1ef05fb 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 @@ -3,6 +3,7 @@ package org.jetbrains.exposed.sql.vendors import org.jetbrains.exposed.exceptions.throwUnsupportedException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.TransactionManager +import java.util.* internal object OracleDataTypeProvider : DataTypeProvider() { override fun byteType(): String = "SMALLINT" @@ -26,11 +27,25 @@ 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)" + } + } + + override fun uuidToDB(value: UUID): Any { + return if ((currentDialect as? H2Dialect)?.h2Mode == H2Dialect.H2CompatibilityMode.Oracle) { + H2DataTypeProvider.uuidToDB(value) + } else { + super.uuidToDB(value) + } } - override fun uuidType(): String = "RAW(16)" override fun dateTimeType(): String = "TIMESTAMP" override fun booleanType(): String = "CHAR(1)" override fun booleanToStatementString(bool: Boolean) = if (bool) "1" else "0" diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/dml/UpsertTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/dml/UpsertTests.kt index ba24fdbbf1..554f95a621 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/dml/UpsertTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/dml/UpsertTests.kt @@ -63,17 +63,17 @@ class UpsertTests : DatabaseTestsBase() { it[name] = "A" } - tester.upsert { // insert because only 1 constraint is equal + tester.upsert { // insert because only 1 constraint is equal it[idA] = 7 it[idB] = insertStmt get tester.idB it[name] = "B" } - tester.upsert { // insert because both constraints differ + tester.upsert { // insert because both constraints differ it[idA] = 99 it[idB] = 99 it[name] = "C" } - tester.upsert { // update because both constraints match + tester.upsert { // update because both constraints match it[idA] = insertStmt get tester.idA it[idB] = insertStmt get tester.idB it[name] = "D" @@ -157,6 +157,32 @@ class UpsertTests : DatabaseTestsBase() { } } + @Test + fun testUpsertWithUUIDKeyConflict() { + val tester = object : Table("tester") { + val id = uuid("id").autoGenerate() + val title = text("title") + + override val primaryKey = PrimaryKey(id) + } + + withTables(tester) { testDb -> + excludingH2Version1(testDb) { + val uuid1 = tester.upsert { + it[title] = "A" + } get tester.id + tester.upsert { + it[id] = uuid1 + it[title] = "B" + } + + val result = tester.selectAll().single() + assertEquals(uuid1, result[tester.id]) + assertEquals("B", result[tester.title]) + } + } + } + @Test fun testUpsertWithNoUniqueConstraints() { val tester = object : Table("tester") { @@ -254,18 +280,18 @@ class UpsertTests : DatabaseTestsBase() { withTables(tester) { testDb -> excludingH2Version1(testDb) { val testWord = "Test" - tester.upsert { // default expression in insert + tester.upsert { // default expression in insert it[word] = testWord } assertEquals("Phrase", tester.selectAll().single()[tester.phrase]) val phraseConcat = concat(" - ", listOf(tester.word, tester.phrase)) - tester.upsert(onUpdate = listOf(tester.phrase to phraseConcat)) { // expression in update + tester.upsert(onUpdate = listOf(tester.phrase to phraseConcat)) { // expression in update it[word] = testWord } assertEquals("$testWord - $defaultPhrase", tester.selectAll().single()[tester.phrase]) - tester.upsert { // provided expression in insert + tester.upsert { // provided expression in insert it[word] = "$testWord 2" it[phrase] = concat(stringLiteral("foo"), stringLiteral("bar")) }