diff --git a/src/test/kotlin/kestrel/ColumnCollationTest.kt b/src/test/kotlin/kestrel/ColumnCollationTest.kt index 6c17c2d..7002494 100644 --- a/src/test/kotlin/kestrel/ColumnCollationTest.kt +++ b/src/test/kotlin/kestrel/ColumnCollationTest.kt @@ -317,3 +317,168 @@ class ColumnCollationTest { assertEquals(int(1), select("SELECT hits FROM accounts WHERE id = 1").rows.single().single()) } + @Test + fun declaredCollationAppliesInATriggerWhenClauseOnNew() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("CREATE TABLE hits (id INTEGER PRIMARY KEY, count INTEGER)") + run("INSERT INTO t VALUES (1, 'Bob')") + run("INSERT INTO hits VALUES (1, 0)") + run( + "CREATE TRIGGER bump AFTER UPDATE ON t FOR EACH ROW WHEN NEW.name = 'BOB' " + + "BEGIN UPDATE hits SET count = count + 100 WHERE id = NEW.id; END" + ) + run("UPDATE t SET name = 'bob' WHERE id = 1") + assertEquals(int(100), select("SELECT count FROM hits WHERE id = 1").rows.single().single()) + } + + /** + * `UNION`/`INTERSECT`/`EXCEPT`'s own `ORDER BY` ([kestrel.engine.SelectRunner. + * declaredSetOperationCollation]) - the last named gap left by #66 ("multiKeyComparator-owa + * sciezka UNION/INTERSECT/EXCEPT ORDER BY dalej binarna"). A `SELECT *` branch's declared + * collation is traced back to the real table each output column comes from. + */ + @Test + fun declaredCollationAppliesToASetOperationsOrderByThroughAStarProjection() { + seedNames() + val result = select( + "SELECT * FROM people WHERE id <= 2 UNION ALL SELECT * FROM people WHERE id > 2 ORDER BY name" + ) + // Combined pre-sort order is (1,Bob),(2,bob),(3,Alice),(4,Zoe) - NOCASE ranks Bob/bob equal, + // so the stable sort keeps Bob (scanned first) ahead of bob. + assertEquals( + listOf(int(3) to "Alice", int(1) to "Bob", int(2) to "bob", int(4) to "Zoe").map { (id, name) -> listOf(id, text(name)) }, + result.rows + ) + } + + @Test + fun declaredCollationAppliesToASetOperationsOrderByThroughAnAliasedProjection() { + seedNames() + val result = select( + "SELECT name AS n FROM people WHERE id <= 2 UNION ALL SELECT name AS n FROM people WHERE id > 2 ORDER BY n" + ) + assertEquals(listOf("Alice", "Bob", "bob", "Zoe").map { listOf(text(it)) }, result.rows) + } + + @Test + fun explicitCollateInASetOperationsOrderByOverridesTheColumnsDeclaredCollation() { + seedNames() + val result = select( + "SELECT * FROM people WHERE id <= 2 UNION ALL SELECT * FROM people WHERE id > 2 ORDER BY name COLLATE BINARY" + ) + assertEquals(listOf("Alice", "Bob", "Zoe", "bob"), result.rows.map { (it[1] as Value.TextValue).value }) + } + + /** + * The declared collation of an output column joined from a second `FROM` source is found past + * the first source's own column count - proves `declaredSetOperationCollation`'s per-source + * offset walk, not just the single-table case above. + */ + @Test + fun declaredCollationAppliesToASetOperationsOrderByAcrossAJoinedSecondTable() { + run("CREATE TABLE ids (id INTEGER PRIMARY KEY)") + run("CREATE TABLE names (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("INSERT INTO ids VALUES (1), (2), (3), (4)") + run("INSERT INTO names VALUES (1, 'Bob'), (2, 'bob'), (3, 'Alice'), (4, 'Zoe')") + val result = select( + "SELECT * FROM ids JOIN names ON ids.id = names.id WHERE ids.id <= 2 " + + "UNION ALL SELECT * FROM ids JOIN names ON ids.id = names.id WHERE ids.id > 2 " + + "ORDER BY name" + ) + assertEquals(listOf("Alice", "Bob", "bob", "Zoe"), result.rows.map { (it[2] as Value.TextValue).value }) + } + + /** + * Still-open scope cut (see [kestrel.engine.SelectRunner.declaredSetOperationCollation]'s own + * doc comment): a set operation's left branch reading through a derived table rather than a + * real table directly reports no declared collation, so its ORDER BY stays binary even though + * the underlying table declares NOCASE - symmetric with the pre-existing view/CTE scope cut for + * a plain SELECT's own ORDER BY. + */ + @Test + fun declaredCollationDoesNotReachThroughADerivedTableInASetOperationsLeftBranch() { + seedNames() + val result = select( + "SELECT * FROM (SELECT * FROM people) sub WHERE id <= 2 " + + "UNION ALL SELECT * FROM (SELECT * FROM people) sub2 WHERE id > 2 " + + "ORDER BY name" + ) + assertEquals(listOf("Alice", "Bob", "Zoe", "bob"), result.rows.map { (it[1] as Value.TextValue).value }) + } + + /** + * `UNION`/`INTERSECT`/`EXCEPT`'s own multiset dedup ([kestrel.engine.SelectRunner. + * runSetOperation]) - the largest piece of the scope-cut #67/#68 left open ("UNION/CTE dedup ... + * dalej uzywaja bajtowej rownosci"). Reuses [kestrel.engine.SelectRunner.declaredSetOperationCollation], + * the same resolver the ORDER BY tests above already exercise, but now folded into the dedup key + * itself rather than only the comparator. + */ + @Test + fun plainUnionDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT name FROM people WHERE id = 1 UNION SELECT name FROM people WHERE id = 2") + assertEquals(listOf(listOf(text("Bob"))), result.rows) + } + + @Test + fun plainUnionWithNoDeclaredCollationKeepsCaseVariantsDistinct() { + run("CREATE TABLE plain2 (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO plain2 VALUES (1, 'Bob'), (2, 'bob')") + val result = select("SELECT name FROM plain2 WHERE id = 1 UNION SELECT name FROM plain2 WHERE id = 2") + assertEquals(listOf(listOf(text("Bob")), listOf(text("bob"))), result.rows) + } + + @Test + fun intersectMatchesCaseVariantsThroughTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT name FROM people WHERE id = 1 INTERSECT SELECT name FROM people WHERE id = 2") + assertEquals(listOf(listOf(text("Bob"))), result.rows) + } + + @Test + fun exceptExcludesCaseVariantsThroughTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT name FROM people WHERE id IN (1, 4) EXCEPT SELECT name FROM people WHERE id = 2") + assertEquals(listOf(listOf(text("Zoe"))), result.rows) + } + + /** + * `INTERSECT ALL`/`EXCEPT ALL` preserve duplicate counts through [kestrel.engine.SelectRunner. + * multisetFilter], which grouped its right-hand side by raw `Value` equality before this session + * - proving the count itself (not just presence/absence) is now grouped through the declared + * collation: 3 case-variant 'Bob' rows on the left, 2 on the right, min(3,2)=2 kept. + */ + @Test + fun intersectAllMatchesCaseVariantsThroughTheColumnsDeclaredCollationAndPreservesDuplicateCounts() { + run("CREATE TABLE dup (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("INSERT INTO dup VALUES (1, 'Bob'), (2, 'Bob'), (3, 'bob'), (4, 'Zoe')") + val result = select( + "SELECT name FROM dup WHERE id IN (1, 2, 3) INTERSECT ALL SELECT name FROM dup WHERE id IN (2, 3)" + ) + assertEquals(listOf(listOf(text("Bob")), listOf(text("Bob"))), result.rows) + } + + /** + * A recursive CTE's own fixed-point dedup ([kestrel.engine.SelectRunner.buildCteScope]), folded + * through [kestrel.engine.SelectRunner.declaredRecursiveCteCollation] resolved against the + * anchor (base) term - mirrors [declaredSetOperationCollation] but for the base/recursive-term + * split instead of a set operation's left/right. `steps` chains 'Bob' -> 'Carol' -> 'bob'; without + * collation-aware dedup 'bob' would be treated as a new row (it differs from 'Bob' byte-for-byte) + * and the walk would keep going instead of recognizing it as the already-visited start node. + */ + @Test + fun recursiveCteWithUnionDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + run("CREATE TABLE origin (name TEXT COLLATE NOCASE)") + run("INSERT INTO origin VALUES ('Bob')") + run("CREATE TABLE steps (name TEXT COLLATE NOCASE, next_name TEXT COLLATE NOCASE)") + run("INSERT INTO steps VALUES ('Bob', 'Carol'), ('Carol', 'bob')") + val result = select( + "WITH RECURSIVE walk(name) AS (" + + "SELECT name FROM origin " + + "UNION " + + "SELECT steps.next_name FROM steps JOIN walk ON steps.name = walk.name" + + ") SELECT name FROM walk ORDER BY name" + ) + assertEquals(listOf(listOf(text("Bob")), listOf(text("Carol"))), result.rows) + } +