test(sql): cover onConflictDoUpdateFiresUpdateTriggersNotInsertTriggers

This commit is contained in:
dualfroz committed 2026-09-13 19:16:22 +02:00
1 parent e4195113f1
commit bacc234c9f
1 file changed
+122
@@ -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<KestrelException> {
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<KestrelException> {
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<KestrelException> { 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<KestrelException> { 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)
}
}