test(sql): cover DiskExecutorTriggerTest and tempFile

This commit is contained in:
dualfroz committed 2026-09-13 18:54:37 +02:00
1 parent 48693b00ab
commit ed8fc6576d
1 file changed
+156
@@ -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<StatementResult> = 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<KestrelException> { 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<KestrelException> { 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<KestrelException> { 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)
}