From 061bc535e2ee8f23378b032f935810413fbafc20 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 20:38:44 +0200 Subject: [PATCH] test: cover PreparedStatementTest and run --- .../kotlin/kestrel/PreparedStatementTest.kt | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/test/kotlin/kestrel/PreparedStatementTest.kt diff --git a/src/test/kotlin/kestrel/PreparedStatementTest.kt b/src/test/kotlin/kestrel/PreparedStatementTest.kt new file mode 100644 index 0000000..eb044e0 --- /dev/null +++ b/src/test/kotlin/kestrel/PreparedStatementTest.kt @@ -0,0 +1,156 @@ +package kestrel + +import kestrel.catalog.Catalog +import kestrel.engine.DeallocateResult +import kestrel.engine.DeleteResult +import kestrel.engine.Executor +import kestrel.engine.InsertResult +import kestrel.engine.PrepareResult +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.engine.UpdateResult +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 + +/** + * End-to-end tests for `PREPARE`/`EXECUTE`/`DEALLOCATE` on the in-memory engine: `$1`, `$2`, ... + * positional parameters resolved by [kestrel.engine.ParameterSubstitution], driven through the same + * [Lexer]/[Parser]/[Executor] pipeline real SQL text goes through, not the AST directly. + */ +class PreparedStatementTest { + 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 seedAccounts() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("INSERT INTO accounts VALUES (1, 'ada', 100), (2, 'linus', 200), (3, 'grace', 300)") + } + + @Test + fun prepareReturnsItsNameAndExecuteRunsAParameterizedSelect() { + seedAccounts() + assertEquals(PrepareResult("byId"), run("PREPARE byId AS SELECT name, balance FROM accounts WHERE id = \$1").single()) + val result = select("EXECUTE byId(2)") + assertEquals(listOf("name", "balance"), result.columns) + assertEquals(listOf(listOf(Value.TextValue("linus"), Value.IntegerValue(200))), result.rows) + } + + @Test + fun theSamePreparedStatementCanBeExecutedRepeatedlyWithDifferentArguments() { + seedAccounts() + run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1") + assertEquals(listOf(listOf(Value.TextValue("ada"))), select("EXECUTE byId(1)").rows) + assertEquals(listOf(listOf(Value.TextValue("grace"))), select("EXECUTE byId(3)").rows) + } + + @Test + fun executeSubstitutesMultipleParametersPositionally() { + seedAccounts() + run("PREPARE inRange AS SELECT name FROM accounts WHERE balance >= \$1 AND balance <= \$2 ORDER BY balance") + assertEquals( + listOf(listOf(Value.TextValue("linus")), listOf(Value.TextValue("grace"))), + select("EXECUTE inRange(150, 350)").rows + ) + } + + @Test + fun prepareCanWrapInsertAndExecuteInsertsARealRow() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("PREPARE addAccount AS INSERT INTO accounts VALUES (\$1, \$2, \$3)") + assertEquals(InsertResult(1), run("EXECUTE addAccount(1, 'ada', 100)").single()) + assertEquals( + listOf(listOf(Value.IntegerValue(1), Value.TextValue("ada"), Value.IntegerValue(100))), + select("SELECT * FROM accounts").rows + ) + assertEquals(InsertResult(1), run("EXECUTE addAccount(2, 'linus', 200)").single()) + assertEquals(2, select("SELECT * FROM accounts").rows.size) + } + + @Test + fun prepareCanWrapUpdate() { + seedAccounts() + run("PREPARE credit AS UPDATE accounts SET balance = balance + \$1 WHERE id = \$2") + assertEquals(UpdateResult(1), run("EXECUTE credit(50, 1)").single()) + assertEquals(listOf(listOf(Value.IntegerValue(150))), select("SELECT balance FROM accounts WHERE id = 1").rows) + } + + @Test + fun prepareCanWrapDelete() { + seedAccounts() + run("PREPARE removeAccount AS DELETE FROM accounts WHERE id = \$1") + assertEquals(DeleteResult(1), run("EXECUTE removeAccount(2)").single()) + assertEquals(2, select("SELECT * FROM accounts").rows.size) + } + + @Test + fun prepareCanWrapMergeAndAParameterReachesTheMatchedUpdateAssignment() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("INSERT INTO accounts VALUES (1, 'ada', 100)") + run("INSERT INTO staging VALUES (1, 'ada', 500)") + run( + "PREPARE topUp AS MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN UPDATE SET balance = staging.balance + \$1" + ) + run("EXECUTE topUp(10)") + assertEquals(listOf(listOf(Value.IntegerValue(510))), select("SELECT balance FROM accounts WHERE id = 1").rows) + // Executed a second time with a DIFFERENT argument to prove substitution recomputes fresh + // every EXECUTE rather than baking in whatever value the first call used. + run("EXECUTE topUp(20)") + assertEquals(listOf(listOf(Value.IntegerValue(520))), select("SELECT balance FROM accounts WHERE id = 1").rows) + } + + @Test + fun prepareCanWrapMergeAndAParameterReachesTheNotMatchedInsertValues() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("INSERT INTO staging VALUES (1, 'ada', 500)") + run( + "PREPARE seedFromStaging AS MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN NOT MATCHED THEN INSERT VALUES (staging.id, staging.name, staging.balance + \$1)" + ) + run("EXECUTE seedFromStaging(10)") + assertEquals( + listOf(listOf(Value.IntegerValue(1), Value.TextValue("ada"), Value.IntegerValue(510))), + select("SELECT * FROM accounts").rows + ) + } + + @Test + fun prepareCanWrapMergeAndAParameterInTheOnConditionControlsWhetherARowMatches() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)") + run("INSERT INTO accounts VALUES (1, 'ada', 100)") + run("INSERT INTO staging VALUES (1, 'ada', 500)") + // Only a WHEN MATCHED clause - a source row the ON condition rejects has no NOT MATCHED + // clause to fall into, so it is a true no-op rather than an insert attempt. + run( + "PREPARE conditionalTopUp AS MERGE INTO accounts USING staging " + + "ON accounts.id = staging.id AND staging.balance > \$1 " + + "WHEN MATCHED THEN UPDATE SET balance = staging.balance" + ) + run("EXECUTE conditionalTopUp(1000)") + assertEquals(listOf(listOf(Value.IntegerValue(100))), select("SELECT balance FROM accounts WHERE id = 1").rows) + run("EXECUTE conditionalTopUp(10)") + assertEquals(listOf(listOf(Value.IntegerValue(500))), select("SELECT balance FROM accounts WHERE id = 1").rows) + } + + @Test + fun executeWithTheWrongNumberOfArgumentsFailsWithAClearMessage() { + seedAccounts() + run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1") + val tooMany = assertFailsWith { run("EXECUTE byId(1, 2)") } + assertTrue(tooMany.message!!.contains("expects 1 parameter"), tooMany.message) + assertFailsWith { run("EXECUTE byId()") } + } +