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( """