From 074f8aabbec65d762f276ca62866415e9450d594 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 13:58:22 +0200 Subject: [PATCH] test: cover dumpAndReplayPreservesForeignKeyAndCheckConstraints --- src/test/kotlin/kestrel/SqlWriterTest.kt | 161 +++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/test/kotlin/kestrel/SqlWriterTest.kt b/src/test/kotlin/kestrel/SqlWriterTest.kt index 97bc3ac..5eb5290 100644 --- a/src/test/kotlin/kestrel/SqlWriterTest.kt +++ b/src/test/kotlin/kestrel/SqlWriterTest.kt @@ -482,3 +482,164 @@ class SqlWriterTest { assertTrue(select("SELECT * FROM employees").rows.isEmpty()) } + @Test + fun dumpAndReplayPreservesForeignKeyAndCheckConstraints() { + run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + run( + "CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id) " + + "ON DELETE CASCADE, age INTEGER CHECK (age > 0))" + ) + run("INSERT INTO departments VALUES (1, 'eng')") + run("INSERT INTO employees VALUES (1, 1, 30)") + val dump = SqlWriter.dump(catalog) + + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) } + + Parser(Lexer("DELETE FROM departments WHERE id = 1").tokenize()).parseProgram() + .forEach { freshExecutor.execute(it) } + val remaining = freshExecutor.execute( + Parser(Lexer("SELECT * FROM employees").tokenize()).parseProgram().single() + ) as SelectResult + assertTrue(remaining.rows.isEmpty()) + } + + @Test + fun createTableWithUniqueConstraintsRoundTripsThroughSqlWriter() { + run( + SqlWriter.write( + parseOne( + "CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT UNIQUE, a INTEGER, b INTEGER, UNIQUE (a, b))" + ) + ) + ) + run("INSERT INTO t VALUES (1, 'ada@example.com', 1, 1)") + assertFailsWith { run("INSERT INTO t VALUES (2, 'ada@example.com', 2, 2)") } + assertFailsWith { run("INSERT INTO t VALUES (3, 'grace@example.com', 1, 1)") } + val result = exec("INSERT INTO t VALUES (4, 'grace@example.com', 1, 2)") + assertEquals(InsertResult(1), result) + } + + @Test + fun dumpAndReplayPreservesUniqueConstraints() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + run("INSERT INTO t VALUES (1, 'ada@example.com')") + val dump = SqlWriter.dump(catalog) + + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) } + + assertFailsWith { + Parser(Lexer("INSERT INTO t VALUES (2, 'ada@example.com')").tokenize()).parseProgram() + .forEach { freshExecutor.execute(it) } + } + } + + @Test + fun createUniqueIndexRoundTripsThroughSqlWriter() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT)") + writeThenReplay("CREATE UNIQUE INDEX idx_email ON t (email)") + run("INSERT INTO t VALUES (1, 'ada@example.com')") + assertFailsWith { run("INSERT INTO t VALUES (2, 'ada@example.com')") } + } + + @Test + fun dumpAndReplayPreservesAUniqueIndex() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT)") + run("CREATE UNIQUE INDEX idx_email ON t (email)") + run("INSERT INTO t VALUES (1, 'ada@example.com')") + val dump = SqlWriter.dump(catalog) + assertTrue(dump.contains("CREATE UNIQUE INDEX idx_email ON t (email);")) + + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) } + + assertFailsWith { + Parser(Lexer("INSERT INTO t VALUES (2, 'ada@example.com')").tokenize()).parseProgram() + .forEach { freshExecutor.execute(it) } + } + } + + @Test + fun alterTableAddColumnWithDefaultRoundTripsThroughSqlWriter() { + seed() + writeThenReplay("ALTER TABLE t ADD COLUMN status TEXT NOT NULL DEFAULT 'new'") + assertEquals(listOf(text("new"), text("new"), text("new")), select("SELECT status FROM t").rows.map { it.single() }) + run("INSERT INTO t (id) VALUES (4)") + assertEquals(listOf(text("new")), select("SELECT status FROM t WHERE id = 4").rows.single()) + } + + @Test + fun alterTableDropColumnRoundTripsThroughSqlWriter() { + seed() + writeThenReplay("ALTER TABLE t DROP COLUMN bio") + val columns = (select("SELECT * FROM t WHERE id = 1")).columns + assertTrue(columns.none { it.equals("bio", ignoreCase = true) }) + } + + @Test + fun alterTableRenameColumnRoundTripsThroughSqlWriter() { + seed() + writeThenReplay("ALTER TABLE t RENAME COLUMN bio TO biography") + assertEquals(listOf(text("mathematician")), select("SELECT biography FROM t WHERE id = 1").rows.single()) + } + + @Test + fun alterTableRenameToRoundTripsThroughSqlWriter() { + seed() + writeThenReplay("ALTER TABLE t RENAME TO renamed") + assertEquals(listOf(text("mathematician")), select("SELECT bio FROM renamed WHERE id = 1").rows.single()) + } + + @Test + fun dumpAndReplayOfARenamedTableWithADependentViewPreservesTheRewrittenReference() { + run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO departments VALUES (1, 'eng')") + run("CREATE VIEW v AS SELECT id, name FROM departments") + run("ALTER TABLE departments RENAME TO divisions") + val dump = SqlWriter.dump(catalog) + assertTrue(dump.contains("FROM divisions"), dump) + assertTrue(!dump.contains("FROM departments"), dump) + + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) } + val result = Parser(Lexer("SELECT * FROM v").tokenize()).parseProgram().single() + .let { freshExecutor.execute(it) } as SelectResult + assertEquals(listOf(text("eng")), listOf(result.rows.single()[1])) + } + + @Test + fun dumpAndReplayPreservesColumnDefaultValue() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'pending')") + run("INSERT INTO t (id) VALUES (1)") + val dump = SqlWriter.dump(catalog) + + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) } + freshExecutor.execute(Parser(Lexer("INSERT INTO t (id) VALUES (2)").tokenize()).parseProgram().single()) + + val result = freshExecutor.execute( + Parser(Lexer("SELECT status FROM t ORDER BY id ASC").tokenize()).parseProgram().single() + ) as SelectResult + assertEquals(listOf(listOf(text("pending")), listOf(text("pending"))), result.rows) + } + + @Test + fun dumpAndReplayPreservesAGeneratedColumnsExpressionNotJustItsAlreadyComputedValue() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a) VALUES (5)") + val dump = SqlWriter.dump(catalog) + assertTrue(dump.contains("GENERATED ALWAYS AS"), dump) + + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) } + // A row inserted only AFTER replay proves the GENERATED expression itself round-tripped + // (not just the value 10 already sitting in the dump's INSERT statement). + freshExecutor.execute(Parser(Lexer("INSERT INTO t (a) VALUES (7)").tokenize()).parseProgram().single()) +