diff --git a/src/test/kotlin/kestrel/PersistenceTest.kt b/src/test/kotlin/kestrel/PersistenceTest.kt index e4df4a3..47806c8 100644 --- a/src/test/kotlin/kestrel/PersistenceTest.kt +++ b/src/test/kotlin/kestrel/PersistenceTest.kt @@ -545,4 +545,177 @@ class PersistenceTest { ALTER TABLE departments RENAME TO divisions; .exit """.trimIndent() + ) + val reopened = runScript(dir, "SELECT * FROM v;\n.exit\n") + assertContains(reopened, "eng") + } + + @Test + fun stringAggViewSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE tags (item TEXT, label TEXT); + INSERT INTO tags VALUES ('widget', 'blue'), ('widget', 'small'), ('gadget', 'red'); + CREATE VIEW item_labels AS SELECT item, STRING_AGG(label, ', ' ORDER BY label) AS labels FROM tags GROUP BY item; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT item, labels FROM item_labels ORDER BY item;\n.exit\n") + assertContains(reopened, "blue, small") + assertContains(reopened, "red") + } + + /** + * `COUNT(DISTINCT ...)`'s `distinct` flag is a field on [kestrel.sql.ast.FunctionExpression] just + * like STRING_AGG's `separator`/`orderBy` above - this proves [kestrel.persistence.SqlWriter] + * actually dumps the `DISTINCT` keyword back out (not just the wrapped argument), so a view built + * on it does not silently turn into a plain, non-deduplicating `COUNT` after reopen. + */ + @Test + fun countDistinctViewSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE sales (dept TEXT, amount INTEGER); + INSERT INTO sales VALUES ('eng', 100), ('eng', 100), ('eng', 200), ('ops', 100), ('ops', 100); + CREATE VIEW dept_distinct_amounts AS SELECT dept, COUNT(DISTINCT amount) AS n FROM sales GROUP BY dept; + .exit + """.trimIndent() + ) + val reopened = runScript( + dir, + "SELECT n FROM dept_distinct_amounts WHERE dept = 'eng';\n" + + "SELECT n FROM dept_distinct_amounts WHERE dept = 'ops';\n.exit\n" + ) + assertContains(reopened, "| 2 |") + assertContains(reopened, "| 1 |") + } + + /** + * `FILTER (WHERE ...)`'s predicate is a field on [kestrel.sql.ast.FunctionExpression] just like + * STRING_AGG's `separator`/`orderBy` and COUNT's `distinct` above - this proves + * [kestrel.persistence.SqlWriter] actually dumps the `FILTER (WHERE ...)` clause back out, so a + * view built on it does not silently turn into an unfiltered aggregate after reopen. + */ + @Test + fun filteredAggregateViewSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE sales (dept TEXT, amount INTEGER); + INSERT INTO sales VALUES ('eng', 100), ('eng', 200), ('ops', 100), ('ops', 100); + CREATE VIEW dept_high_value_counts AS SELECT dept, COUNT(*) FILTER (WHERE amount > 100) AS n FROM sales GROUP BY dept; + .exit + """.trimIndent() + ) + val reopened = runScript( + dir, + "SELECT n FROM dept_high_value_counts WHERE dept = 'eng';\n" + + "SELECT n FROM dept_high_value_counts WHERE dept = 'ops';\n.exit\n" + ) + assertContains(reopened, "| 1 |") + assertContains(reopened, "| 0 |") + } + + /** + * [kestrel.catalog.RelationRename] rewrites a renamed table's name everywhere it could appear in a + * dependent view's stored query, including inside a nested subquery - this proves that recursion + * now also reaches a subquery sitting inside an aggregate's `FILTER (WHERE ...)` predicate + * specifically (a [kestrel.sql.ast.FunctionExpression] field distinct from `argument`, easy to miss + * when only `argument` gets rewritten), not just a subquery in WHERE/projection. + */ + @Test + fun renameTablePropagatesIntoAViewsAggregateFilterSubquery() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE items (id INTEGER PRIMARY KEY, amount INTEGER); + INSERT INTO items VALUES (1, 100), (2, 200); + CREATE TABLE config (id INTEGER PRIMARY KEY, threshold INTEGER); + INSERT INTO config VALUES (1, 150); + CREATE VIEW joined AS SELECT COUNT(*) FILTER (WHERE amount > (SELECT threshold FROM config WHERE id = 1)) AS n FROM items; + ALTER TABLE config RENAME TO settings; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT n FROM joined;\n.exit\n") + assertContains(reopened, "| 1 |") + } + + /** + * [kestrel.catalog.RelationRename] rewrites a renamed table's name everywhere it could appear in a + * dependent view's stored query, including inside a nested subquery - this proves that recursion + * now also reaches a subquery sitting inside STRING_AGG's `separator` argument specifically (a + * [kestrel.sql.ast.FunctionExpression] field distinct from `argument`, easy to miss when only + * `argument` gets rewritten), not just a subquery in WHERE/projection. + */ + @Test + fun renameTablePropagatesIntoAViewsStringAggSeparatorSubquery() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT); + INSERT INTO items VALUES (1, 'b'), (2, 'a'); + CREATE TABLE config (id INTEGER PRIMARY KEY, sep TEXT); + INSERT INTO config VALUES (1, ','); + CREATE VIEW joined AS SELECT STRING_AGG(label, (SELECT sep FROM config WHERE id = 1) ORDER BY label) AS labels FROM items; + ALTER TABLE config RENAME TO settings; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT labels FROM joined;\n.exit\n") + assertContains(reopened, "a,b") + } + + @Test + fun distinctViewSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE sales (dept TEXT); + INSERT INTO sales VALUES ('eng'), ('eng'), ('ops'); + CREATE VIEW depts AS SELECT DISTINCT dept FROM sales; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT dept FROM depts ORDER BY dept;\n.exit\n") + assertContains(reopened, "2 rows") + assertContains(reopened, "eng") + assertContains(reopened, "ops") + } + + @Test + fun limitAndOffsetViewSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE sales (dept TEXT, amount INTEGER); + INSERT INTO sales VALUES ('eng', 300), ('eng', 200), ('ops', 100); + CREATE VIEW second_highest AS SELECT dept, amount FROM sales ORDER BY amount DESC LIMIT 1 OFFSET 1; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT dept, amount FROM second_highest;\n.exit\n") + assertContains(reopened, "1 row") + assertContains(reopened, "200") + assertTrue(!reopened.contains("300"), "amount=300 was skipped by OFFSET and must not survive") + } + + @Test + fun distinctOnViewSurvivesReopenAfterCheckpoint() { + val dir = tempDir() + runScript( + dir, + """ + 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; }