From bacc234c9f5db0e780ce9a5f9699d27e48c83d30 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 19:16:22 +0200 Subject: [PATCH] test(sql): cover onConflictDoUpdateFiresUpdateTriggersNotInsertTriggers --- .../store/sql/DiskExecutorTriggerTest.kt | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt index fe6668e..4cda550 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt @@ -312,3 +312,125 @@ class DiskExecutorTriggerTest { assertEquals(0, db.select("SELECT * FROM audit").rows.size) } + @Test + fun onConflictDoUpdateFiresUpdateTriggersNotInsertTriggers() { + val db = newDatabase() + db.run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("CREATE TABLE inserts (id INTEGER)") + db.run("CREATE TABLE updates (id INTEGER, old_balance INTEGER, new_balance INTEGER)") + db.run("CREATE TRIGGER log_insert AFTER INSERT ON accounts FOR EACH ROW BEGIN INSERT INTO inserts VALUES (NEW.id); END") + db.run( + "CREATE TRIGGER log_update AFTER UPDATE ON accounts FOR EACH ROW " + + "BEGIN INSERT INTO updates VALUES (NEW.id, OLD.balance, NEW.balance); END" + ) + db.run("INSERT INTO accounts VALUES (1, 999) ON CONFLICT (id) DO UPDATE SET balance = excluded.balance") + assertEquals(0, db.select("SELECT * FROM inserts").rows.size) + assertEquals(listOf(listOf(int(1), int(100), int(999))), db.select("SELECT * FROM updates").rows) + } + + @Test + fun onConflictBeforeUpdateTriggerCanRejectTheWholeStatement() { + val db = newDatabase() + db.run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("CREATE TABLE guard (x INTEGER NOT NULL)") + db.run("CREATE TRIGGER reject BEFORE UPDATE ON accounts FOR EACH ROW BEGIN INSERT INTO guard VALUES (NULL); END") + assertFailsWith { + db.run("INSERT INTO accounts VALUES (1, 999) ON CONFLICT (id) DO UPDATE SET balance = excluded.balance") + } + assertEquals(int(100), db.select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + } + + /** Disk-specific: the trigger cascade and the ON CONFLICT statement itself share one savepoint, so a BEFORE trigger's rejection survives a real close/reopen as if the whole statement never ran. */ + @Test + fun onConflictBeforeUpdateTriggerRejectionSurvivesAReopen() { + val db = newDatabase() + db.run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("CREATE TABLE guard (x INTEGER NOT NULL)") + db.run("CREATE TRIGGER reject BEFORE UPDATE ON accounts FOR EACH ROW BEGIN INSERT INTO guard VALUES (NULL); END") + assertFailsWith { + db.run("INSERT INTO accounts VALUES (1, 999) ON CONFLICT (id) DO UPDATE SET balance = excluded.balance") + } + db.reopen() + assertEquals(int(100), db.select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals(0, db.select("SELECT * FROM guard").rows.size) + } + + @Test + fun onConflictMultiRowStatementFiresTheRightTriggerPerRow() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, hits INTEGER)") + db.run("INSERT INTO t VALUES (1, 10)") + db.run("CREATE TABLE inserts (id INTEGER)") + db.run("CREATE TABLE updates (id INTEGER)") + db.run("CREATE TRIGGER log_insert AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO inserts VALUES (NEW.id); END") + db.run("CREATE TRIGGER log_update AFTER UPDATE ON t FOR EACH ROW BEGIN INSERT INTO updates VALUES (NEW.id); END") + db.run( + """ + INSERT INTO t VALUES (1, 1), (2, 2) + ON CONFLICT (id) DO UPDATE SET hits = t.hits + excluded.hits + """.trimIndent() + ) + assertEquals(listOf(listOf(int(2))), db.select("SELECT * FROM inserts").rows) + assertEquals(listOf(listOf(int(1))), db.select("SELECT * FROM updates").rows) + } + + @Test + fun bodyStatementInAnInsertTriggerCanOnlySeeNewNotOld() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER)") + db.run("CREATE TABLE audit (id INTEGER)") + db.run("CREATE TRIGGER log AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END") + db.run("INSERT INTO t VALUES (5)") + assertEquals(listOf(listOf(int(5))), db.select("SELECT * FROM audit").rows) + // OLD is simply not bound for an INSERT trigger, so an OLD.id reference in the body falls + // through exactly like any other unresolvable column in an INSERT's VALUES list would. + db.run("CREATE TRIGGER bad AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (OLD.id); END") + assertFailsWith { db.run("INSERT INTO t VALUES (6)") } + } + + /** + * Disk-specific: proves a fired trigger's writes and the firing statement's own writes share the + * user's explicit `BEGIN`, not a savepoint-scoped transaction of their own - a `ROLLBACK` after + * both undoes everything, same as [DiskExecutorOnConflictTest.onConflictInsideAnExplicitTransactionParticipatesInItsCommit]. + */ + @Test + fun triggerFiringInsideAnExplicitTransactionParticipatesInItsCommit() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER)") + db.run("CREATE TABLE audit (id INTEGER)") + db.run("CREATE TRIGGER log AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END") + db.run("BEGIN") + db.run("INSERT INTO t VALUES (1)") + db.run("ROLLBACK") + assertEquals(0, db.select("SELECT * FROM t").rows.size) + assertEquals(0, db.select("SELECT * FROM audit").rows.size) + } + + /** + * Durability proof unique to the disk-backed executor: a trigger cascade's rollback-on-failure + * (via the internal storage-engine savepoint [DiskExecutor.withTriggerAtomicity] takes, not a + * whole-catalog snapshot) must leave nothing behind even after a real close/reopen, and a + * trigger's own definition must itself survive to keep firing after reopen - mirrors the reopen + * tests already established for ON CONFLICT ([DiskExecutorOnConflictTest.conflictResolutionSurvivesAReopen]). + */ + @Test + fun triggerCascadeAtomicitySurvivesAReopen() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE audit (order_id INTEGER NOT NULL)") + db.run("CREATE TRIGGER log_fail AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (NULL); END") + assertFailsWith { db.run("INSERT INTO t VALUES (1)") } + db.reopen() + assertEquals(0, db.select("SELECT * FROM t").rows.size) + assertEquals(0, db.select("SELECT * FROM audit").rows.size) + db.run("DROP TRIGGER log_fail") + db.run("CREATE TABLE audit2 (id INTEGER)") + db.run("CREATE TRIGGER log_ok AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit2 VALUES (NEW.id); END") + db.reopen() + db.run("INSERT INTO t VALUES (9)") + assertEquals(listOf(listOf(int(9))), db.select("SELECT * FROM audit2").rows) + } +}