From 277ee5b19dbb2073c9b204cacf254787660eec0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Mon, 13 Jul 2026 15:31:47 +0300 Subject: [PATCH] Validate the optimistic lock in INSERT ON CONFLICT DO UPDATE The optimistic lock check only ran for UPDATE statements, so an INSERT with ON CONFLICT DO UPDATE skipped it. A table with an AS LOCK column would compile an upsert whose DO UPDATE SET never touches the lock. The same check now runs on the DO UPDATE SET clause in the sqlite-3-24 and sqlite-3-35 dialects. DO UPDATE also has to carry the WHERE clause a multirow UPDATE can leave out. Self incrementing the lock bumps every row an UPDATE matches, but DO UPDATE only ever touches the row that conflicted, so without WHERE version = :version the stored lock is never read. Upserts need one more guard, because excluded is a real query source there. SET version = excluded.version + 1 and WHERE excluded.version = :version both read back the caller's own incoming value, so neither counts as the stored lock. The table name and the table alias do. Refs #6232 --- CHANGELOG.md | 1 + .../grammar/mixins/InsertStmtMixin.kt | 97 ++++ .../grammar/mixins/InsertStmtMixin.kt | 97 ++++ .../core/tables/OptimisticLockTest.kt | 460 ++++++++++++++++++ 4 files changed, 655 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ef098bcda..6cd0de423d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ ### Fixed - [Compiler] Suppress Kotlin extra warnings in generated code (#6208 by @eyupcanakman) +- [SQLite Dialect] Validate optimistic lock column and WHERE clause in INSERT ... ON CONFLICT DO UPDATE (#6306 by @eyupcanakman) - [Compiler] Other columns in a non-grouped aggregate result set are always nullable - [PostgreSQL Dialect] Resolve nullability correctly for coalesce and ifnull - [PostgreSQL Dialect] Fixed IDE integration of the PostgreSQL dialect diff --git a/dialects/sqlite-3-24/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_24/grammar/mixins/InsertStmtMixin.kt b/dialects/sqlite-3-24/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_24/grammar/mixins/InsertStmtMixin.kt index 73ab5a77b17..bc421f6db24 100644 --- a/dialects/sqlite-3-24/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_24/grammar/mixins/InsertStmtMixin.kt +++ b/dialects/sqlite-3-24/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_24/grammar/mixins/InsertStmtMixin.kt @@ -1,10 +1,22 @@ package app.cash.sqldelight.dialects.sqlite_3_24.grammar.mixins import app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.SqliteInsertStmt +import app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.SqliteUpsertDoUpdate import com.alecstrong.sql.psi.core.SqlAnnotationHolder +import com.alecstrong.sql.psi.core.psi.SqlBinaryAddExpr +import com.alecstrong.sql.psi.core.psi.SqlBinaryEqualityExpr +import com.alecstrong.sql.psi.core.psi.SqlBinaryExpr +import com.alecstrong.sql.psi.core.psi.SqlBindExpr +import com.alecstrong.sql.psi.core.psi.SqlColumnExpr +import com.alecstrong.sql.psi.core.psi.SqlColumnName +import com.alecstrong.sql.psi.core.psi.SqlExpr +import com.alecstrong.sql.psi.core.psi.SqlSetterExpression import com.alecstrong.sql.psi.core.psi.SqlTypes +import com.alecstrong.sql.psi.core.psi.SqlUpdateStmtSubsequentSetter import com.alecstrong.sql.psi.core.psi.impl.SqlInsertStmtImpl +import com.alecstrong.sql.psi.core.psi.mixins.ColumnDefMixin import com.intellij.lang.ASTNode +import com.intellij.psi.util.PsiTreeUtil internal abstract class InsertStmtMixin( node: ASTNode, @@ -50,6 +62,91 @@ internal abstract class InsertStmtMixin( "also specifying a conflict resolution algorithm ($conflictResolution)", ) } + + if (upsertDoUpdate != null) { + validateOptimisticLock(upsertDoUpdate, annotationHolder) + } + } + } + + private fun validateOptimisticLock( + doUpdate: SqliteUpsertDoUpdate, + annotationHolder: SqlAnnotationHolder, + ) { + val table = tablesAvailable(this) + .firstOrNull { it.tableName.name == tableName.name } ?: return + val lock = table.query.columns + .mapNotNull { it.element.parent as? ColumnDefMixin } + .singleOrNull { col -> + col.columnType.node.getChildren(null).any { it.text == "LOCK" } + } ?: return + + val firstColumnName = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlColumnName::class.java).firstOrNull() + val firstSetterExpr = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlSetterExpression::class.java).firstOrNull() + val subsequentSetters = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlUpdateStmtSubsequentSetter::class.java) + + val columns = listOfNotNull(firstColumnName) + subsequentSetters.mapNotNull { it.columnName } + val setters = listOfNotNull(firstSetterExpr) + subsequentSetters.mapNotNull { it.setterExpression } + + val setter = columns.zip(setters) + .singleOrNull { (col, _) -> col.textMatches(lock.columnName.name) } + ?.second + + if (setter == null) { + annotationHolder.createErrorAnnotation( + doUpdate, + "This statement is missing the optimistic lock in its SET clause.", + ) + return + } + + // excluded holds the row being inserted, so only the target table carries the stored lock. + val storedNames = listOfNotNull(tableName.name, tableAlias?.name) + fun SqlColumnExpr.isStoredLock() = columnName.textMatches(lock.columnName.name) && + (tableName?.let { qualifier -> qualifier.name in storedNames } ?: true) + + val setterExpressions = (setter.expr as? SqlBinaryExpr)?.getExprList() + val selfIncrementsByOne = setter.expr is SqlBinaryAddExpr && + setter.expr.node.getChildren(null).any { it.text == "+" } && + setterExpressions?.getOrNull(1)?.textMatches("1") ?: false + val bindIncrement = selfIncrementsByOne && + setterExpressions.getOrNull(0) is SqlBindExpr + val selfIncrements = selfIncrementsByOne && + (setterExpressions.getOrNull(0) as? SqlColumnExpr)?.isStoredLock() == true + + // Confirm the statement has SET lock = :arg + 1 or SET lock = lock + 1 + if (!bindIncrement && !selfIncrements) { + annotationHolder.createErrorAnnotation( + doUpdate, + """The optimistic lock must be set exactly like "${lock.columnName.name} = :${lock.columnName.name} + 1".""", + ) + return + } + + // Unlike a multirow UPDATE, DO UPDATE only touches the row that conflicted, so it still needs the WHERE clause. + val whereExpr = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlExpr::class.java).firstOrNull() + if (whereExpr == null) { + annotationHolder.createErrorAnnotation( + doUpdate, + "This statement is missing a WHERE clause to check the optimistic lock.", + ) + return + } + + val equalityExprs = buildList { + if (whereExpr is SqlBinaryEqualityExpr) add(whereExpr) + addAll(PsiTreeUtil.findChildrenOfType(whereExpr, SqlBinaryEqualityExpr::class.java)) + } + if (equalityExprs.none { eq -> + eq.node.getChildren(null).any { it.text == "=" || it.text == "==" } && + (eq.getExprList()[0] as? SqlColumnExpr)?.isStoredLock() == true && + eq.getExprList()[1] is SqlBindExpr + } + ) { + annotationHolder.createErrorAnnotation( + doUpdate, + """The optimistic lock must be queried exactly like "${lock.columnName.name} == :${lock.columnName.name}".""", + ) } } } diff --git a/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt index e2a68e65910..815a18520f2 100644 --- a/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt +++ b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt @@ -1,10 +1,22 @@ package app.cash.sqldelight.dialects.sqlite_3_35.grammar.mixins import app.cash.sqldelight.dialects.sqlite_3_35.grammar.psi.SqliteInsertStmt +import app.cash.sqldelight.dialects.sqlite_3_35.grammar.psi.SqliteUpsertDoUpdate import com.alecstrong.sql.psi.core.SqlAnnotationHolder +import com.alecstrong.sql.psi.core.psi.SqlBinaryAddExpr +import com.alecstrong.sql.psi.core.psi.SqlBinaryEqualityExpr +import com.alecstrong.sql.psi.core.psi.SqlBinaryExpr +import com.alecstrong.sql.psi.core.psi.SqlBindExpr +import com.alecstrong.sql.psi.core.psi.SqlColumnExpr +import com.alecstrong.sql.psi.core.psi.SqlColumnName +import com.alecstrong.sql.psi.core.psi.SqlExpr +import com.alecstrong.sql.psi.core.psi.SqlSetterExpression import com.alecstrong.sql.psi.core.psi.SqlTypes +import com.alecstrong.sql.psi.core.psi.SqlUpdateStmtSubsequentSetter import com.alecstrong.sql.psi.core.psi.impl.SqlInsertStmtImpl +import com.alecstrong.sql.psi.core.psi.mixins.ColumnDefMixin import com.intellij.lang.ASTNode +import com.intellij.psi.util.PsiTreeUtil internal abstract class InsertStmtMixin( node: ASTNode, @@ -62,6 +74,91 @@ internal abstract class InsertStmtMixin( "Only the final ON CONFLICT clause may omit the conflict target", ) } + + if (upsertDoUpdate != null) { + validateOptimisticLock(upsertDoUpdate, annotationHolder) + } + } + } + + private fun validateOptimisticLock( + doUpdate: SqliteUpsertDoUpdate, + annotationHolder: SqlAnnotationHolder, + ) { + val table = tablesAvailable(this) + .firstOrNull { it.tableName.name == tableName.name } ?: return + val lock = table.query.columns + .mapNotNull { it.element.parent as? ColumnDefMixin } + .singleOrNull { col -> + col.columnType.node.getChildren(null).any { it.text == "LOCK" } + } ?: return + + val firstColumnName = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlColumnName::class.java).firstOrNull() + val firstSetterExpr = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlSetterExpression::class.java).firstOrNull() + val subsequentSetters = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlUpdateStmtSubsequentSetter::class.java) + + val columns = listOfNotNull(firstColumnName) + subsequentSetters.mapNotNull { it.columnName } + val setters = listOfNotNull(firstSetterExpr) + subsequentSetters.mapNotNull { it.setterExpression } + + val setter = columns.zip(setters) + .singleOrNull { (col, _) -> col.textMatches(lock.columnName.name) } + ?.second + + if (setter == null) { + annotationHolder.createErrorAnnotation( + doUpdate, + "This statement is missing the optimistic lock in its SET clause.", + ) + return + } + + // excluded holds the row being inserted, so only the target table carries the stored lock. + val storedNames = listOfNotNull(tableName.name, tableAlias?.name) + fun SqlColumnExpr.isStoredLock() = columnName.textMatches(lock.columnName.name) && + (tableName?.let { qualifier -> qualifier.name in storedNames } ?: true) + + val setterExpressions = (setter.expr as? SqlBinaryExpr)?.getExprList() + val selfIncrementsByOne = setter.expr is SqlBinaryAddExpr && + setter.expr.node.getChildren(null).any { it.text == "+" } && + setterExpressions?.getOrNull(1)?.textMatches("1") ?: false + val bindIncrement = selfIncrementsByOne && + setterExpressions.getOrNull(0) is SqlBindExpr + val selfIncrements = selfIncrementsByOne && + (setterExpressions.getOrNull(0) as? SqlColumnExpr)?.isStoredLock() == true + + // Confirm the statement has SET lock = :arg + 1 or SET lock = lock + 1 + if (!bindIncrement && !selfIncrements) { + annotationHolder.createErrorAnnotation( + doUpdate, + """The optimistic lock must be set exactly like "${lock.columnName.name} = :${lock.columnName.name} + 1".""", + ) + return + } + + // Unlike a multirow UPDATE, DO UPDATE only touches the row that conflicted, so it still needs the WHERE clause. + val whereExpr = PsiTreeUtil.getChildrenOfTypeAsList(doUpdate, SqlExpr::class.java).firstOrNull() + if (whereExpr == null) { + annotationHolder.createErrorAnnotation( + doUpdate, + "This statement is missing a WHERE clause to check the optimistic lock.", + ) + return + } + + val equalityExprs = buildList { + if (whereExpr is SqlBinaryEqualityExpr) add(whereExpr) + addAll(PsiTreeUtil.findChildrenOfType(whereExpr, SqlBinaryEqualityExpr::class.java)) + } + if (equalityExprs.none { eq -> + eq.node.getChildren(null).any { it.text == "=" || it.text == "==" } && + (eq.getExprList()[0] as? SqlColumnExpr)?.isStoredLock() == true && + eq.getExprList()[1] is SqlBindExpr + } + ) { + annotationHolder.createErrorAnnotation( + doUpdate, + """The optimistic lock must be queried exactly like "${lock.columnName.name} == :${lock.columnName.name}".""", + ) } } } diff --git a/sqldelight-compiler/src/test/kotlin/app/cash/sqldelight/core/tables/OptimisticLockTest.kt b/sqldelight-compiler/src/test/kotlin/app/cash/sqldelight/core/tables/OptimisticLockTest.kt index 590a8e21827..139763355f3 100644 --- a/sqldelight-compiler/src/test/kotlin/app/cash/sqldelight/core/tables/OptimisticLockTest.kt +++ b/sqldelight-compiler/src/test/kotlin/app/cash/sqldelight/core/tables/OptimisticLockTest.kt @@ -1,5 +1,6 @@ package app.cash.sqldelight.core.tables +import app.cash.sqldelight.core.TestDialect import app.cash.sqldelight.core.compiler.MutatorQueryGenerator import app.cash.sqldelight.dialects.postgresql.PostgreSqlDialect import app.cash.sqldelight.test.util.FixtureCompiler @@ -93,6 +94,465 @@ class OptimisticLockTest { ) } + @Test fun `upsert missing optimistic lock in DO UPDATE fails`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET text = excluded.text + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).containsExactly( + "Test.sq: (11, 0): This statement is missing the optimistic lock in its SET clause.", + ) + } + + @Test fun `upsert with correct optimistic lock is allowed`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = :version + 1 + |WHERE version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).isEmpty() + } + + @Test fun `upsert with a self incrementing optimistic lock is allowed`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = version + 1 + |WHERE version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).isEmpty() + } + + @Test fun `upsert self incrementing the optimistic lock without a WHERE clause fails`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = version + 1 + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).containsExactly( + "Test.sq: (10, 27): This statement is missing a WHERE clause to check the optimistic lock.", + ) + } + + @Test fun `upsert self incrementing the optimistic lock without checking it fails`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = version + 1 + |WHERE id = :id + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).containsExactly( + """Test.sq: (10, 27): The optimistic lock must be queried exactly like "version == :version".""", + ) + } + + @Test fun `upsert incrementing the excluded optimistic lock fails`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET version = excluded.version + 1 + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).containsExactly( + """Test.sq: (11, 0): The optimistic lock must be set exactly like "version = :version + 1".""", + ) + } + + @Test fun `upsert self incrementing an aliased optimistic lock is allowed`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test AS t (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = t.version + 1 + |WHERE t.version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).isEmpty() + } + + @Test fun `upsert checking the excluded optimistic lock fails`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET version = :version + 1 + |WHERE excluded.version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).containsExactly( + """Test.sq: (11, 0): The optimistic lock must be queried exactly like "version == :version".""", + ) + } + + @Test fun `upsert with an optimistic lock that is not incremented fails`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET version = :version + 2 + |WHERE version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).containsExactly( + """Test.sq: (11, 0): The optimistic lock must be set exactly like "version = :version + 1".""", + ) + } + + @Test fun `upsert missing optimistic lock in DO UPDATE fails on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET text = excluded.text + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).containsExactly( + "Test.sq: (11, 0): This statement is missing the optimistic lock in its SET clause.", + ) + } + + @Test fun `upsert incrementing the excluded optimistic lock fails on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET version = excluded.version + 1 + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).containsExactly( + """Test.sq: (11, 0): The optimistic lock must be set exactly like "version = :version + 1".""", + ) + } + + @Test fun `upsert self incrementing a quoted optimistic lock is allowed`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = [test].version + 1 + |WHERE version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_24.dialect, + ) + + assertThat(result.errors).isEmpty() + } + + @Test fun `upsert checks every conflict clause on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | other INTEGER NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, other, text, version) + |VALUES (?, ?, ?, ?) + |ON CONFLICT (id) DO UPDATE + |SET text = excluded.text + |ON CONFLICT (other) DO UPDATE + |SET text = excluded.text + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).containsExactly( + "Test.sq: (12, 0): This statement is missing the optimistic lock in its SET clause.", + "Test.sq: (14, 0): This statement is missing the optimistic lock in its SET clause.", + ) + } + + @Test fun `upsert with a lock check on every conflict clause is allowed on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | other INTEGER NOT NULL, + | version INTEGER AS LOCK NOT NULL DEFAULT 0, + | text TEXT NOT NULL, + | UNIQUE (id), + | UNIQUE (other) + |); + | + |upsertText: + |INSERT INTO test (id, other, text) + |VALUES (:id, :other, :text) + |ON CONFLICT (id) DO UPDATE + |SET text = :text, version = version + 1 + |WHERE version = :version + |ON CONFLICT (other) DO UPDATE + |SET text = :text, version = version + 1 + |WHERE version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).isEmpty() + } + + @Test fun `upsert self incrementing a quoted optimistic lock is allowed on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = [test].version + 1 + |WHERE version = :version + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).isEmpty() + } + + @Test fun `upsert self incrementing the optimistic lock without a WHERE clause fails on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = version + 1 + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).containsExactly( + "Test.sq: (10, 27): This statement is missing a WHERE clause to check the optimistic lock.", + ) + } + + @Test fun `upsert self incrementing the optimistic lock without checking it fails on sqlite 3_35`() { + val result = FixtureCompiler.compileSql( + """ + |CREATE TABLE test ( + | id INTEGER AS VALUE NOT NULL, + | version INTEGER AS LOCK NOT NULL, + | text TEXT NOT NULL + |); + | + |upsertText: + |INSERT INTO test (id, text, version) + |VALUES (?, ?, ?) + |ON CONFLICT (id) DO UPDATE SET + | text = excluded.text, + | version = version + 1 + |WHERE id = :id + |; + | + """.trimMargin(), + tempFolder, + overrideDialect = TestDialect.SQLITE_3_35.dialect, + ) + + assertThat(result.errors).containsExactly( + """Test.sq: (10, 27): The optimistic lock must be queried exactly like "version == :version".""", + ) + } + @Test fun `update query correctly generates`() { val file = FixtureCompiler.parseSql( """