From 8fb902be1569703e9626d3e8796ddf1ce20e3f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?griff=20=D1=96=E2=8A=99?= <346896+griffio@users.noreply.github.com> Date: Mon, 21 Aug 2023 19:36:07 +0100 Subject: [PATCH 1/3] sqlite 3-35 On Conflict Allow multiple ON CONFLICT clauses that are evaluated in order, The final ON CONFLICT clause may omit the conflict target and yet still use DO UPDATE TODO - restrict final ON CONFLICT to have optional conflict target --- .../dialects/sqlite_3_35/grammar/sqlite.bnf | 4 ++-- .../multiple-on-conflict/1.s | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s diff --git a/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf index ed4832e4c97..cc3b654b419 100644 --- a/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf +++ b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf @@ -73,14 +73,14 @@ alter_table_drop_column ::= DROP COLUMN {column_name} { insert_stmt ::= [ {with_clause} ] ( INSERT OR REPLACE | REPLACE | INSERT OR ROLLBACK | INSERT OR ABORT | INSERT OR FAIL | INSERT OR IGNORE | INSERT ) INTO [ {database_name} DOT ] {table_name} [ AS {table_alias} ] - [ LP {column_name} ( COMMA {column_name} ) * RP ] {insert_stmt_values} [ upsert_clause ] [ returning_clause ] { + [ LP {column_name} ( COMMA {column_name} ) * RP ] {insert_stmt_values} [ ( upsert_clause ) * ] [ returning_clause ] { extends = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.impl.SqliteInsertStmtImpl" implements = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.SqliteInsertStmt" pin = 5 override = true } -upsert_clause ::= ON CONFLICT ( upsert_conflict_target DO UPDATE upsert_do_update | [ upsert_conflict_target ] DO NOTHING ) { +upsert_clause ::= ON CONFLICT ( [ upsert_conflict_target ] DO UPDATE upsert_do_update | [ upsert_conflict_target ] DO NOTHING ) { extends = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.impl.SqliteUpsertClauseImpl" implements = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.SqliteUpsertClause" } diff --git a/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s new file mode 100644 index 00000000000..7611cb01b38 --- /dev/null +++ b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s @@ -0,0 +1,20 @@ +CREATE TABLE apparel ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + size TEXT NOT NULL, + price TEXT NOT NULL, + quantity INTEGER, + CONSTRAINT apparel_name_size_key UNIQUE (name, size) +); + +INSERT INTO apparel (id, name, size, price, quantity) VALUES(31, 'Shirt', 'XL', 3.99, 3) +ON CONFLICT(name, size) DO UPDATE SET size = excluded.size, quantity = excluded.quantity +ON CONFLICT DO UPDATE SET name= excluded.name = excluded.size, quantity = excluded.quantity; + +INSERT INTO apparel (id, name, size, price, quantity) VALUES(31, 'Shirt', 'XL', 2.99, 2) +ON CONFLICT(name, size) DO UPDATE SET size = excluded.size, quantity = excluded.quantity +ON CONFLICT DO NOTHING; + + + + From 75260abcbecebff54fecaa3bcc82827900d585f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?griff=20=D1=96=E2=8A=99?= <346896+griffio@users.noreply.github.com> Date: Wed, 23 Aug 2023 12:13:13 +0100 Subject: [PATCH 2/3] Add InsertStmtMixin Multiple ON CONFLICT clauses are allowed in the grammar Only the final clause allows optional conflict target and annotated in the Mixin Add fixture test --- .../grammar/mixins/InsertStmtMixin.kt | 65 +++++++++++++++++++ .../dialects/sqlite_3_35/grammar/sqlite.bnf | 6 +- .../multiple-on-conflict/1.s | 20 ------ .../multiple-on-conflict/Test.s | 18 +++++ .../multiple-on-conflict/failure.txt | 1 + 5 files changed, 86 insertions(+), 24 deletions(-) create mode 100644 dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt delete mode 100644 dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s create mode 100644 dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/Test.s create mode 100644 dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/failure.txt 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 new file mode 100644 index 00000000000..f805d2c9ad2 --- /dev/null +++ b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt @@ -0,0 +1,65 @@ +package app.cash.sqldelight.dialects.sqlite_3_35.grammar.mixins + +import app.cash.sqldelight.dialects.sqlite_3_35.grammar.psi.SqliteInsertStmt +import com.alecstrong.sql.psi.core.SqlAnnotationHolder +import com.alecstrong.sql.psi.core.psi.SqlTypes +import com.alecstrong.sql.psi.core.psi.impl.SqlInsertStmtImpl +import com.intellij.lang.ASTNode + +internal abstract class InsertStmtMixin( + node: ASTNode, +) : SqlInsertStmtImpl(node), + SqliteInsertStmt { + override fun annotate(annotationHolder: SqlAnnotationHolder) { + super.annotate(annotationHolder) + val insertDefaultValues = insertStmtValues?.node?.findChildByType( + SqlTypes.DEFAULT, + ) != null + + upsertClauseList.forEachIndexed { index, upsert -> + + val upsertDoUpdate = upsert.upsertDoUpdate + if (insertDefaultValues && upsertDoUpdate != null) { + annotationHolder.createErrorAnnotation( + upsert, + "The upsert clause is not supported after DEFAULT VALUES", + ) + } + + val insertOr = node.findChildByType( + SqlTypes.INSERT, + )?.treeNext + val replace = node.findChildByType( + SqlTypes.REPLACE, + ) + val conflictResolution = when { + replace != null -> SqlTypes.REPLACE + insertOr != null && insertOr.elementType == SqlTypes.OR -> { + val type = insertOr.treeNext.elementType + check( + type == SqlTypes.ROLLBACK || type == SqlTypes.ABORT || + type == SqlTypes.FAIL || type == SqlTypes.IGNORE, + ) + type + } + + else -> null + } + + if (conflictResolution != null && upsertDoUpdate != null) { + annotationHolder.createErrorAnnotation( + upsertDoUpdate, + "Cannot use DO UPDATE while " + + "also specifying a conflict resolution algorithm ($conflictResolution)", + ) + } + + if (upsert.upsertConflictTarget == null && index != upsertClauseList.lastIndex) { + annotationHolder.createErrorAnnotation( + upsert, + "Only the final ON CONFLICT clause may omit the conflict target", + ) + } + } + } +} diff --git a/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf index cc3b654b419..bda4e96014c 100644 --- a/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf +++ b/dialects/sqlite-3-35/src/main/kotlin/app/cash/sqldelight/dialects/sqlite_3_35/grammar/sqlite.bnf @@ -74,15 +74,13 @@ insert_stmt ::= [ {with_clause} ] ( INSERT OR REPLACE | REPLACE | INSERT OR ROLLBACK | INSERT OR ABORT | INSERT OR FAIL | INSERT OR IGNORE | INSERT ) INTO [ {database_name} DOT ] {table_name} [ AS {table_alias} ] [ LP {column_name} ( COMMA {column_name} ) * RP ] {insert_stmt_values} [ ( upsert_clause ) * ] [ returning_clause ] { - extends = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.impl.SqliteInsertStmtImpl" - implements = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.SqliteInsertStmt" + mixin = "app.cash.sqldelight.dialects.sqlite_3_35.grammar.mixins.InsertStmtMixin" pin = 5 override = true } -upsert_clause ::= ON CONFLICT ( [ upsert_conflict_target ] DO UPDATE upsert_do_update | [ upsert_conflict_target ] DO NOTHING ) { +upsert_clause ::= ON CONFLICT [ upsert_conflict_target ] DO UPDATE upsert_do_update | ON CONFLICT [ upsert_conflict_target ] DO NOTHING { extends = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.impl.SqliteUpsertClauseImpl" - implements = "app.cash.sqldelight.dialects.sqlite_3_24.grammar.psi.SqliteUpsertClause" } upsert_conflict_target ::= LP {indexed_column} ( COMMA {indexed_column} ) * RP [ WHERE <> ] { diff --git a/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s deleted file mode 100644 index 7611cb01b38..00000000000 --- a/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/1.s +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE apparel ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - size TEXT NOT NULL, - price TEXT NOT NULL, - quantity INTEGER, - CONSTRAINT apparel_name_size_key UNIQUE (name, size) -); - -INSERT INTO apparel (id, name, size, price, quantity) VALUES(31, 'Shirt', 'XL', 3.99, 3) -ON CONFLICT(name, size) DO UPDATE SET size = excluded.size, quantity = excluded.quantity -ON CONFLICT DO UPDATE SET name= excluded.name = excluded.size, quantity = excluded.quantity; - -INSERT INTO apparel (id, name, size, price, quantity) VALUES(31, 'Shirt', 'XL', 2.99, 2) -ON CONFLICT(name, size) DO UPDATE SET size = excluded.size, quantity = excluded.quantity -ON CONFLICT DO NOTHING; - - - - diff --git a/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/Test.s b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/Test.s new file mode 100644 index 00000000000..673c1bc2903 --- /dev/null +++ b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/Test.s @@ -0,0 +1,18 @@ +CREATE TABLE test_conflict ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + size TEXT NOT NULL, + price TEXT NOT NULL, + quantity INTEGER, + CONSTRAINT test_name_size_key UNIQUE (name, size) +); + +-- succeeds with multiple ON CONFLICT clauses +INSERT INTO test_conflict (id, name, size, price, quantity) VALUES(31, 'Test', 'XL', 3.99, 3) +ON CONFLICT(name, size) DO UPDATE SET size = excluded.size, quantity = excluded.quantity +ON CONFLICT DO UPDATE SET name = excluded.name = excluded.size, quantity = excluded.quantity; + +-- fails with optional conflict target is only allowed with the final ON CONFLICT clause +INSERT INTO test_conflict (id, name, size, price, quantity) VALUES(31, 'Test', 'XL', 3.99, 3) +ON CONFLICT DO UPDATE SET name = excluded.name = excluded.size, quantity = excluded.quantity +ON CONFLICT(name, size) DO UPDATE SET size = excluded.size, quantity = excluded.quantity; diff --git a/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/failure.txt b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/failure.txt new file mode 100644 index 00000000000..c9d2b6880d3 --- /dev/null +++ b/dialects/sqlite-3-35/src/test/fixtures_sqlite_3_35/multiple-on-conflict/failure.txt @@ -0,0 +1 @@ +Test.s line 17:0 - Only the final ON CONFLICT clause may omit the conflict target From 2446c10be7a4cdd976dc14f0b3c5615b6697e999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?griff=20=D1=96=E2=8A=99?= <346896+griffio@users.noreply.github.com> Date: Wed, 23 Aug 2023 14:53:07 +0100 Subject: [PATCH 3/3] Add Integration Test Similar to sqlite-3-24 test extended for multiple ON CONFLICT clauses --- .../sqldelight/app/cash/sqldelight/integration/Person.sq | 5 +++++ .../app/cash/sqldelight/integration/IntegrationTests.kt | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/main/sqldelight/app/cash/sqldelight/integration/Person.sq b/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/main/sqldelight/app/cash/sqldelight/integration/Person.sq index 549d8f93098..f816b86349a 100644 --- a/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/main/sqldelight/app/cash/sqldelight/integration/Person.sq +++ b/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/main/sqldelight/app/cash/sqldelight/integration/Person.sq @@ -51,3 +51,8 @@ deleteAndReturnAll: DELETE FROM person WHERE last_name = ? RETURNING *; + +performUpsert: +INSERT INTO person (_id, first_name, last_name) VALUES (?, ?, ?) + ON CONFLICT(_id) DO UPDATE SET first_name=excluded.first_name, last_name=excluded.last_name + ON CONFLICT DO NOTHING; diff --git a/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/test/java/app/cash/sqldelight/integration/IntegrationTests.kt b/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/test/java/app/cash/sqldelight/integration/IntegrationTests.kt index 3c89911fc45..14dfc38108f 100644 --- a/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/test/java/app/cash/sqldelight/integration/IntegrationTests.kt +++ b/sqldelight-gradle-plugin/src/test/integration-sqlite-3-35/src/test/java/app/cash/sqldelight/integration/IntegrationTests.kt @@ -69,4 +69,11 @@ class IntegrationTests { assertThat(personQueries.deleteAndReturnAll("Strong").executeAsOne()) .isEqualTo(Person(1, "Alec", "Strong")) } + + @Test fun upsertWithMultipleConflict() { + personQueries.performUpsert(1, "First", "Last") + personQueries.performUpsert(1, "Alpha", "Omega") + assertThat(personQueries.deleteAndReturnAll("Omega").executeAsOne()) + .isEqualTo(Person(1, "Alpha", "Omega")) + } }