diff --git a/src/test/kotlin/kestrel/PersistenceTest.kt b/src/test/kotlin/kestrel/PersistenceTest.kt index 2c521d9..e4df4a3 100644 --- a/src/test/kotlin/kestrel/PersistenceTest.kt +++ b/src/test/kotlin/kestrel/PersistenceTest.kt @@ -372,4 +372,177 @@ class PersistenceTest { CREATE UNIQUE INDEX idx_email ON users (email); .exit """.trimIndent() + ) + val afterReopen = runScript( + dir, + """ + INSERT INTO users VALUES (2, 'ada@example.com'); + EXPLAIN SELECT id FROM users WHERE email = 'ada@example.com'; + .schema users + .exit + """.trimIndent() + ) + assertContains(afterReopen, "Error") + assertContains(afterReopen, "UNIQUE constraint") + assertContains(afterReopen, "Index Scan using idx_email") + assertContains(afterReopen, "CREATE UNIQUE INDEX idx_email ON users (email);") + } + + @Test + fun compositeUniqueIndexSurvivesReopenAfterCheckpointAndKeepsEnforcingAndAccelerating() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, seq INTEGER); + INSERT INTO orders VALUES (1, 'EU', 1); + CREATE UNIQUE INDEX idx_region_seq ON orders (region, seq); + .exit + """.trimIndent() + ) + val afterReopen = runScript( + dir, + """ + INSERT INTO orders VALUES (2, 'EU', 1); + INSERT INTO orders VALUES (3, 'US', 1); + EXPLAIN SELECT id FROM orders WHERE region = 'EU' AND seq = 1; + .schema orders + .exit + """.trimIndent() + ) + assertContains(afterReopen, "Error") + assertContains(afterReopen, "UNIQUE constraint") + assertContains(afterReopen, "Index Scan using idx_region_seq") + assertContains(afterReopen, "CREATE UNIQUE INDEX idx_region_seq ON orders (region, seq);") + } + + @Test + fun compositePrimaryKeyAndForeignKeySurviveReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE orders (region TEXT, order_no INTEGER, PRIMARY KEY (region, order_no)); + CREATE TABLE line_items (region TEXT, order_no INTEGER, sku TEXT, FOREIGN KEY (region, order_no) REFERENCES orders (region, order_no) ON DELETE CASCADE); + INSERT INTO orders VALUES ('eu', 1); + INSERT INTO line_items VALUES ('eu', 1, 'SKU1'); + .exit + """.trimIndent() + ) + val duplicateRejected = runScript(dir, "INSERT INTO orders VALUES ('eu', 1);\n.exit\n") + assertContains(duplicateRejected, "Error") + assertContains(duplicateRejected, "UNIQUE constraint") + + val afterCascade = runScript( + dir, + """ + DELETE FROM orders WHERE region = 'eu'; + SELECT * FROM line_items; + .exit + """.trimIndent() + ) + assertTrue(!afterCascade.contains("SKU1"), "cascade delete across a composite FK must survive reopen") + } + + @Test + fun alterTableAddColumnSurvivesReopenAfterCheckpointAndBackfillsAndDefaultsFutureInserts() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + INSERT INTO users VALUES (1, 'ada'), (2, 'linus'); + ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + INSERT INTO users (id, name) VALUES (3, 'grace'); + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT name, status FROM users ORDER BY id ASC;\n.exit\n") + assertContains(reopened, "ada") + assertContains(reopened, "active") + val statusCount = Regex("active").findAll(reopened).count() + assertEquals(3, statusCount, "all three rows (backfilled and freshly inserted) must carry the DEFAULT") + } + + @Test + fun generatedColumnSurvivesReopenAfterCheckpointAndKeepsComputingFutureInserts() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED); + INSERT INTO t (a) VALUES (5); + .exit + """.trimIndent() + ) + runScript(dir, "INSERT INTO t (a) VALUES (7);\n.exit\n") + val reopened = runScript(dir, "SELECT b FROM t ORDER BY a ASC;\n.exit\n") + assertContains(reopened, "10") + assertContains(reopened, "14") + } + + @Test + fun generatedColumnSurvivesReopenWithoutACheckpointViaWalReplay() { + val dir = tempDir() + val persistence = Persistence(dir) + persistence.appendWal( + listOf( + "CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED);", + "INSERT INTO t (a) VALUES (5);" + ) + ) + val output = StringBuilder() + Repl(output, output, Persistence(dir)).run("SELECT b FROM t WHERE a = 5;\n.exit\n".reader().buffered()) + assertContains(output.toString(), "10") + } + + @Test + fun alterTableRenameAndDropColumnSurviveReopenWithoutACheckpointViaWalReplay() { + val dir = tempDir() + val persistence = Persistence(dir) + persistence.appendWal( + listOf( + "CREATE TABLE t (id INTEGER PRIMARY KEY, bio TEXT, scratch TEXT);", + "INSERT INTO t VALUES (1, 'mathematician', 'temp');", + "ALTER TABLE t RENAME COLUMN bio TO biography;", + "ALTER TABLE t DROP COLUMN scratch;" + ) + ) + val output = StringBuilder() + Repl(output, output, Persistence(dir)).run("SELECT biography FROM t WHERE id = 1;\n.exit\n".reader().buffered()) + assertContains(output.toString(), "mathematician") + } + + @Test + fun alterTableRenameToSurvivesReopenWithoutACheckpointViaWalReplay() { + val dir = tempDir() + val persistence = Persistence(dir) + persistence.appendWal( + listOf( + "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);", + "INSERT INTO t VALUES (1, 'ada');", + "ALTER TABLE t RENAME TO people;" + ) + ) + val output = StringBuilder() + Repl(output, output, Persistence(dir)).run("SELECT name FROM people WHERE id = 1;\n.tables\n.exit\n".reader().buffered()) + val text = output.toString() + assertContains(text, "ada") + val tableLines = text.lines().map { it.trim() } + assertTrue(tableLines.contains("people"), "renamed table must appear in .tables") + assertTrue(tableLines.none { it.equals("t", ignoreCase = true) }, "old table name must not linger in .tables") + } + + @Test + fun alterTableRenameToPropagatesIntoADependentViewAcrossACheckpointReopen() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO departments VALUES (1, 'eng'); + CREATE VIEW v AS SELECT id, name FROM departments; + ALTER TABLE departments RENAME TO divisions; + .exit + """.trimIndent() }