diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt new file mode 100644 index 0000000..a3cd3fc --- /dev/null +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorTriggerTest.kt @@ -0,0 +1,156 @@ +package kestrel.store.sql + +import kestrel.KestrelException +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * `CREATE`/`DROP TRIGGER` `BEFORE`/`AFTER INSERT`/`UPDATE`/`DELETE` through [DiskExecutor] against a + * real [DiskCatalog] backed by temp files - see [DiskExecutorTest] for the general end-to-end pattern + * this reuses. Scenarios are ported 1:1 from [kestrel.TriggerTest] (the in-memory engine's equivalent) + * wherever they translate, since firing/WHEN/validation semantics are identical (both engines share + * [kestrel.engine.TriggerRowContext]); [triggerCascadeAtomicitySurvivesAReopen] and + * [triggerFiringInsideAnExplicitTransactionParticipatesInItsCommit] are new to this file, proving what + * is actually different on the disk-backed path: atomicity comes from an internal storage-engine + * savepoint (see [DiskExecutor.withTriggerAtomicity]) rather than a whole-catalog snapshot/restore. + */ +class DiskExecutorTriggerTest { + private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() } + + private fun int(value: Long) = Value.IntegerValue(value) + private fun text(value: String) = Value.TextValue(value) + + private class Database(val dataFile: File, val walFile: File) { + var catalog = DiskCatalog.open(dataFile, walFile) + private set + var executor = DiskExecutor(catalog) + private set + + fun run(sql: String): List = Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + fun select(sql: String): SelectResult = run(sql).single() as SelectResult + + fun reopen() { + catalog.close() + catalog = DiskCatalog.open(dataFile, walFile) + executor = DiskExecutor(catalog) + } + } + + private fun newDatabase(): Database = Database(tempFile("kestrel-disk-trigger-data"), tempFile("kestrel-disk-trigger-wal")) + + @Test + fun afterInsertTriggerRunsBodyForEachInsertedRow() { + val db = newDatabase() + db.run("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)") + db.run("CREATE TABLE audit (order_id INTEGER)") + db.run( + "CREATE TRIGGER log_insert AFTER INSERT ON orders FOR EACH ROW " + + "BEGIN INSERT INTO audit VALUES (NEW.id); END" + ) + db.run("INSERT INTO orders VALUES (1, 100), (2, 200)") + assertEquals(listOf(listOf(int(1)), listOf(int(2))), db.select("SELECT order_id FROM audit ORDER BY order_id").rows) + } + + @Test + fun beforeInsertTriggerCanAbortTheWholeStatementBeforeThisExecutorsOwnConstraintsRun() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE guard (x INTEGER NOT NULL)") + db.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 { db.run("INSERT INTO t VALUES (1)") } + assertEquals(0, db.select("SELECT * FROM t").rows.size) + } + + @Test + fun beforeTriggerSideEffectsAreRolledBackWhenTheFiringStatementItselfFails() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY CHECK (id > 0))") + db.run("CREATE TABLE counter (n INTEGER)") + db.run("INSERT INTO counter VALUES (0)") + db.run("CREATE TRIGGER bump BEFORE INSERT ON t FOR EACH ROW BEGIN UPDATE counter SET n = n + 1; END") + assertFailsWith { db.run("INSERT INTO t VALUES (-1)") } + assertEquals(listOf(listOf(int(0))), db.select("SELECT n FROM counter").rows) + assertEquals(0, db.select("SELECT * FROM t").rows.size) + } + + @Test + fun afterInsertTriggerFailureRollsBackTheOriginalInsertToo() { + 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)") } + assertEquals(0, db.select("SELECT * FROM t").rows.size) + assertEquals(0, db.select("SELECT * FROM audit").rows.size) + } + + @Test + fun whenClauseGatesFiringPerRow() { + val db = newDatabase() + db.run("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)") + db.run("CREATE TABLE audit (order_id INTEGER)") + db.run( + "CREATE TRIGGER log_big AFTER INSERT ON orders FOR EACH ROW WHEN NEW.amount > 100 " + + "BEGIN INSERT INTO audit VALUES (NEW.id); END" + ) + db.run("INSERT INTO orders VALUES (1, 50), (2, 200)") + assertEquals(listOf(listOf(int(2))), db.select("SELECT order_id FROM audit").rows) + } + + @Test + fun updateTriggerSeesBothOldAndNew() { + val db = newDatabase() + db.run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + db.run("CREATE TABLE audit (account_id INTEGER, old_balance INTEGER, new_balance INTEGER)") + db.run("INSERT INTO accounts VALUES (1, 100)") + db.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" + ) + db.run("UPDATE accounts SET balance = 150 WHERE id = 1") + assertEquals(listOf(listOf(int(1), int(100), int(150))), db.select("SELECT * FROM audit").rows) + } + + @Test + fun updateTriggerFiresForEveryMatchedRowEvenWhenTheValueIsUnchanged() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, x INTEGER)") + db.run("CREATE TABLE audit (n INTEGER)") + db.run("INSERT INTO t VALUES (1, 5)") + db.run("CREATE TRIGGER log AFTER UPDATE ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (1); END") + db.run("UPDATE t SET x = 5 WHERE id = 1") + assertEquals(1, db.select("SELECT * FROM audit").rows.size) + } + + @Test + fun deleteTriggerSeesOld() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + db.run("CREATE TABLE audit (label TEXT)") + db.run("INSERT INTO t VALUES (1, 'gone')") + db.run("CREATE TRIGGER log_delete AFTER DELETE ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES (OLD.label); END") + db.run("DELETE FROM t WHERE id = 1") + assertEquals(listOf(listOf(text("gone"))), db.select("SELECT * FROM audit").rows) + } + + @Test + fun multipleTriggersOnSameEventFireInCreationOrder() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER)") + db.run("CREATE TABLE audit (step TEXT)") + db.run("CREATE TRIGGER first_trigger AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES ('first'); END") + db.run("CREATE TRIGGER second_trigger AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO audit VALUES ('second'); END") + db.run("INSERT INTO t VALUES (1)") + assertEquals(listOf(listOf(text("first")), listOf(text("second"))), db.select("SELECT * FROM audit").rows) + } +