diff --git a/src/test/kotlin/kestrel/TriggerTest.kt b/src/test/kotlin/kestrel/TriggerTest.kt new file mode 100644 index 0000000..05f1cc9 --- /dev/null +++ b/src/test/kotlin/kestrel/TriggerTest.kt @@ -0,0 +1,159 @@ +package kestrel + +import kestrel.catalog.Catalog +import kestrel.engine.Executor +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class TriggerTest { + private val catalog = Catalog() + private val executor = Executor(catalog) + + private fun run(sql: String): List = + Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + + private fun select(sql: String): SelectResult = run(sql).single() as SelectResult + + private fun int(value: Long) = Value.IntegerValue(value) + private fun text(value: String) = Value.TextValue(value) + + @Test + fun afterInsertTriggerRunsBodyForEachInsertedRow() { + run("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)") + run("CREATE TABLE audit (order_id INTEGER)") + run( + "CREATE TRIGGER log_insert AFTER INSERT ON orders FOR EACH ROW " + + "BEGIN INSERT INTO audit VALUES (NEW.id); END" + ) + run("INSERT INTO orders VALUES (1, 100), (2, 200)") + assertEquals( + listOf(listOf(int(1)), listOf(int(2))), + select("SELECT order_id FROM audit ORDER BY order_id").rows + ) + } + + @Test + fun beforeInsertTriggerCanAbortTheWholeStatementBeforeThisEnginesOwnConstraintsRun() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + run("CREATE TABLE guard (x INTEGER NOT NULL)") + run("CREATE TRIGGER reject_all BEFORE INSERT ON t FOR EACH ROW BEGIN INSERT INTO guard VALUES (NULL); END") + // The row itself is perfectly valid for t's own PRIMARY KEY - only the trigger rejects it. + assertFailsWith { run("INSERT INTO t VALUES (1)") } + assertEquals(0, select("SELECT * FROM t").rows.size) + } + + @Test + fun beforeTriggerSideEffectsAreRolledBackWhenTheFiringStatementItselfFails() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY CHECK (id > 0))") + run("CREATE TABLE counter (n INTEGER)") + run("INSERT INTO counter VALUES (0)") + run("CREATE TRIGGER bump BEFORE INSERT ON t FOR EACH ROW BEGIN UPDATE counter SET n = n + 1; END") + assertFailsWith { run("INSERT INTO t VALUES (-1)") } + assertEquals(listOf(listOf(int(0))), select("SELECT n FROM counter").rows) + assertEquals(0, select("SELECT * FROM t").rows.size) + } + + @Test + fun afterInsertTriggerFailureRollsBackTheOriginalInsertToo() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + run("CREATE TABLE audit (order_id INTEGER NOT NULL)") + run("CREATE TRIGGER log_fail AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (NULL); END") + assertFailsWith { run("INSERT INTO t VALUES (1)") } + assertEquals(0, select("SELECT * FROM t").rows.size) + assertEquals(0, select("SELECT * FROM audit").rows.size) + } + + @Test + fun whenClauseGatesFiringPerRow() { + run("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)") + run("CREATE TABLE audit (order_id INTEGER)") + run( + "CREATE TRIGGER log_big AFTER INSERT ON orders FOR EACH ROW WHEN NEW.amount > 100 " + + "BEGIN INSERT INTO audit VALUES (NEW.id); END" + ) + run("INSERT INTO orders VALUES (1, 50), (2, 200)") + assertEquals(listOf(listOf(int(2))), select("SELECT order_id FROM audit").rows) + } + + @Test + fun updateTriggerSeesBothOldAndNew() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + run("CREATE TABLE audit (account_id INTEGER, old_balance INTEGER, new_balance INTEGER)") + run("INSERT INTO accounts VALUES (1, 100)") + run( + "CREATE TRIGGER log_balance_change AFTER UPDATE ON accounts FOR EACH ROW " + + "BEGIN INSERT INTO audit VALUES (NEW.id, OLD.balance, NEW.balance); END" + ) + run("UPDATE accounts SET balance = 150 WHERE id = 1") + assertEquals(listOf(listOf(int(1), int(100), int(150))), select("SELECT * FROM audit").rows) + } + + @Test + fun updateTriggerFiresForEveryMatchedRowEvenWhenTheValueIsUnchanged() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, x INTEGER)") + run("CREATE TABLE audit (n INTEGER)") + run("INSERT INTO t VALUES (1, 5)") + run("CREATE TRIGGER log AFTER UPDATE ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (1); END") + run("UPDATE t SET x = 5 WHERE id = 1") + assertEquals(1, select("SELECT * FROM audit").rows.size) + } + + @Test + fun deleteTriggerSeesOld() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + run("CREATE TABLE audit (label TEXT)") + run("INSERT INTO t VALUES (1, 'gone')") + run("CREATE TRIGGER log_delete AFTER DELETE ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (OLD.label); END") + run("DELETE FROM t WHERE id = 1") + assertEquals(listOf(listOf(text("gone"))), select("SELECT * FROM audit").rows) + } + + @Test + fun multipleTriggersOnSameEventFireInCreationOrder() { + run("CREATE TABLE t (id INTEGER)") + run("CREATE TABLE audit (step TEXT)") + run("CREATE TRIGGER first_trigger AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES ('first'); END") + run("CREATE TRIGGER second_trigger AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES ('second'); END") + run("INSERT INTO t VALUES (1)") + assertEquals(listOf(listOf(text("first")), listOf(text("second"))), select("SELECT * FROM audit").rows) + } + + @Test + fun cascadingTriggerFiresATriggerOnADifferentTable() { + run("CREATE TABLE a (id INTEGER)") + run("CREATE TABLE b (id INTEGER)") + run("CREATE TABLE c (id INTEGER)") + run("CREATE TRIGGER a_to_b AFTER INSERT ON a FOR EACH ROW BEGIN INSERT INTO b VALUES (NEW.id); END") + run("CREATE TRIGGER b_to_c AFTER INSERT ON b FOR EACH ROW BEGIN INSERT INTO c VALUES (NEW.id); END") + run("INSERT INTO a VALUES (7)") + assertEquals(listOf(listOf(int(7))), select("SELECT * FROM b").rows) + assertEquals(listOf(listOf(int(7))), select("SELECT * FROM c").rows) + } + + @Test + fun recursiveTriggerHitsTheDepthGuardInsteadOfLoopingForeverAndRollsBackCleanly() { + run("CREATE TABLE t (id INTEGER)") + run("CREATE TRIGGER loopy AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO t VALUES (NEW.id + 1); END") + val exception = assertFailsWith { run("INSERT INTO t VALUES (1)") } + assertTrue(exception.message!!.contains("recursion", ignoreCase = true), exception.message!!) + assertEquals(0, select("SELECT * FROM t").rows.size) + } + + @Test + fun dropTriggerStopsItFromFiringAndCannotBeDroppedTwice() { + run("CREATE TABLE t (id INTEGER)") + run("CREATE TABLE audit (id INTEGER)") + run("CREATE TRIGGER log AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END") + run("DROP TRIGGER log") + run("INSERT INTO t VALUES (1)") + assertEquals(0, select("SELECT * FROM audit").rows.size) + assertFailsWith { run("DROP TRIGGER log") } + } +