From 965cc2974d917137d099a11dc7bc987ca230b3ad Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 19:07:52 +0200 Subject: [PATCH] test: cover renameTablePropagatesIntoAViewsDistinctOnSubquery --- src/test/kotlin/kestrel/PersistenceTest.kt | 173 +++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/test/kotlin/kestrel/PersistenceTest.kt b/src/test/kotlin/kestrel/PersistenceTest.kt index 47806c8..3066154 100644 --- a/src/test/kotlin/kestrel/PersistenceTest.kt +++ b/src/test/kotlin/kestrel/PersistenceTest.kt @@ -718,4 +718,177 @@ class PersistenceTest { CREATE TABLE sales (dept TEXT, amount INTEGER); INSERT INTO sales VALUES ('eng', 100), ('eng', 200), ('ops', 50); CREATE VIEW top_per_dept AS SELECT DISTINCT ON (dept) dept, amount FROM sales ORDER BY dept, amount DESC; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT dept, amount FROM top_per_dept ORDER BY dept;\n.exit\n") + assertContains(reopened, "2 rows") + assertContains(reopened, "200") + assertTrue(!reopened.contains("100"), "amount=100 lost the DISTINCT ON tie-break and must not survive") + } + + /** + * Mirrors [renameTablePropagatesIntoAViewsStringAggSeparatorSubquery] for the newly added + * [kestrel.sql.ast.SelectStatement.distinctOn] field - [kestrel.catalog.RelationRename] must walk + * into it exactly like it already does for `groupBy`/`having`, or a subquery hiding inside + * `DISTINCT ON (...)` would silently keep pointing at a table name that no longer exists after + * reopen (the rewritten AST, not the original text, is what SqlWriter dumps on checkpoint). + */ + @Test + fun renameTablePropagatesIntoAViewsDistinctOnSubquery() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE items (dept TEXT, amount INTEGER); + INSERT INTO items VALUES ('eng', 100), ('eng', 200), ('ops', 50); + CREATE TABLE config (id INTEGER PRIMARY KEY, threshold INTEGER); + INSERT INTO config VALUES (1, 0); + CREATE VIEW top_per_dept AS SELECT DISTINCT ON (dept, (SELECT threshold FROM config WHERE id = 1)) dept, amount FROM items ORDER BY dept, (SELECT threshold FROM config WHERE id = 1), amount DESC; + ALTER TABLE config RENAME TO settings; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT dept, amount FROM top_per_dept ORDER BY dept;\n.exit\n") + assertContains(reopened, "2 rows") + assertContains(reopened, "200") + assertContains(reopened, "50") + } + + /** + * A trigger created before its table's data would refire on every reload (SqlWriter.dump + * intentionally writes CREATE TRIGGER only after every table's rows) - this proves reopening + * does NOT duplicate the audit row the trigger already produced during the first session, + * while a genuinely new INSERT after reopening still fires it normally. + */ + @Test + fun triggerCreationIsDumpedAfterDataSoReloadDoesNotRefireForAlreadyPersistedRows() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER); + CREATE TABLE audit (order_id INTEGER); + CREATE TRIGGER log_insert AFTER INSERT ON orders FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END; + INSERT INTO orders VALUES (1, 100); + .exit + """.trimIndent() + ) + val afterFirstReopen = runScript(dir, "SELECT order_id FROM audit;\n.exit\n") + assertContains(afterFirstReopen, "1 row") + val afterSecondReopen = runScript( + dir, + "INSERT INTO orders VALUES (2, 200);\nSELECT order_id FROM audit ORDER BY order_id;\n.exit\n" + ) + assertContains(afterSecondReopen, "2 rows") + } + + /** + * The dumped `CREATE SEQUENCE` must bake in the sequence's CURRENT position (via `START WITH`), + * not the one it was originally created with - otherwise a checkpoint reopen would reissue + * already-handed-out values, breaking the one guarantee a sequence exists to provide. + */ + @Test + fun sequencePositionSurvivesReopenAfterCheckpointInsteadOfRestarting() { + val dir = tempDir() + runScript( + dir, + """ + CREATE SEQUENCE seq; + CREATE TABLE t (id INTEGER); + INSERT INTO t VALUES (NEXTVAL('seq')), (NEXTVAL('seq')); + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "INSERT INTO t VALUES (NEXTVAL('seq'));\nSELECT id FROM t WHERE id = 3;\n.exit\n") + // A restart-from-scratch bug would reissue 1 as the third value (1, 2, 1) instead of + // continuing to 3 - this query would then find zero rows instead of one. + assertContains(reopened, "1 row") + } + + @Test + fun sequenceCreationAndNextvalDefaultSurviveReopenWithoutACheckpointViaWalReplay() { + val dir = tempDir() + val persistence = Persistence(dir) + persistence.appendWal( + listOf( + "CREATE SEQUENCE order_ids;", + "CREATE TABLE orders (id INTEGER DEFAULT NEXTVAL('order_ids'), item TEXT);", + "INSERT INTO orders (item) VALUES ('a');" + ) + ) + val output = StringBuilder() + Repl(output, output, Persistence(dir)).run( + "INSERT INTO orders (item) VALUES ('b');\nSELECT item FROM orders WHERE id = 1;\nSELECT item FROM orders WHERE id = 2;\n.exit\n" + .reader().buffered() + ) + val text = output.toString() + assertContains(text, "a") + assertContains(text, "b") + assertEquals(2, text.lines().count { it.trim() == "1 row" }) + } + + @Test + fun dropSequenceSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE SEQUENCE seq; + DROP SEQUENCE seq; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, ".sequences\n.exit\n") + assertContains(reopened, "no sequences") + } + + /** + * PREPARE/EXECUTE themselves are session-scoped and never written to the WAL (see + * PrepareStatement's own doc) - but an INSERT run THROUGH EXECUTE is real data and must survive + * a reopen exactly like one typed literally would. This is the whole reason + * [kestrel.cli.Repl.executeBuffer] persists EXECUTE's RESOLVED statement instead of its literal + * `EXECUTE name(...)` text: that literal text would be meaningless to replay against a fresh + * session that never re-ran the PREPARE. + */ + @Test + fun executeOfAPreparedInsertPersistsTheResolvedRowAcrossReopen() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER); + PREPARE addUser AS INSERT INTO users VALUES ($1, $2, $3); + EXECUTE addUser(1, 'ada', 36); + EXECUTE addUser(2, 'linus', 54); + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT name, age FROM users ORDER BY id ASC;\n.exit\n") + assertContains(reopened, "ada") + assertContains(reopened, "linus") + } + + @Test + fun preparedStatementsDoNotSurviveReopen() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + PREPARE addUser AS INSERT INTO users VALUES ($1, $2); + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "EXECUTE addUser(1, 'ada');\n.exit\n") + assertContains(reopened, "Error: no such prepared statement: addUser") + } + + @Test + fun temporaryTableDoesNotSurviveReopenAfterCheckpointButARegularTableAlongsideItDoes() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE regular (id INTEGER PRIMARY KEY, note TEXT); }