diff --git a/src/test/kotlin/kestrel/ColumnCollationTest.kt b/src/test/kotlin/kestrel/ColumnCollationTest.kt index b22234a..6c17c2d 100644 --- a/src/test/kotlin/kestrel/ColumnCollationTest.kt +++ b/src/test/kotlin/kestrel/ColumnCollationTest.kt @@ -160,3 +160,160 @@ class ColumnCollationTest { assertEquals(listOf("Bob" to 1L, "bob" to 1L).map { (name, count) -> listOf(text(name), int(count)) }, result.rows) } + @Test + fun explicitGroupByCollateOverridesTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT name COLLATE BINARY, COUNT(*) FROM people GROUP BY name COLLATE BINARY ORDER BY name COLLATE BINARY") + assertEquals( + listOf("Alice" to 1L, "Bob" to 1L, "Zoe" to 1L, "bob" to 1L).map { (name, count) -> listOf(text(name), int(count)) }, + result.rows + ) + } + + @Test + fun selectDistinctDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT DISTINCT name FROM people ORDER BY name") + assertEquals(listOf("Alice", "Bob", "Zoe").map { listOf(text(it)) }, result.rows) + } + + @Test + fun selectDistinctStarDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + // A single-column table so DISTINCT *'s whole-row key is driven entirely by name's own + // declared NOCASE - with more columns involved, any other differing column (like people's + // own id) would already make every row distinct regardless of collation. + run("CREATE TABLE tags (label TEXT COLLATE NOCASE)") + run("INSERT INTO tags VALUES ('Bob'), ('bob'), ('Alice')") + val result = select("SELECT DISTINCT * FROM tags ORDER BY label") + assertEquals(listOf("Alice", "Bob").map { listOf(text(it)) }, result.rows) + } + + @Test + fun selectDistinctWithNoDeclaredCollationStaysBinary() { + run("CREATE TABLE plain (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO plain VALUES (1, 'Bob'), (2, 'bob')") + val result = select("SELECT DISTINCT name FROM plain ORDER BY name") + assertEquals(listOf("Bob", "bob").map { listOf(text(it)) }, result.rows) + } + + @Test + fun selectDistinctOnDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + run("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, hits INTEGER)") + run("INSERT INTO people VALUES (1, 'Bob', 10), (2, 'bob', 20), (3, 'Alice', 5)") + val result = select("SELECT DISTINCT ON (name) name, hits FROM people ORDER BY name, id") + assertEquals( + listOf("Alice" to 5L, "Bob" to 10L).map { (name, hits) -> listOf(text(name), int(hits)) }, + result.rows + ) + } + + @Test + fun countDistinctDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + seedNames() + assertEquals(int(3), select("SELECT COUNT(DISTINCT name) FROM people").rows.single().single()) + } + + /** + * `<>` forces [kestrel.engine.SelectRunner]'s nested-loop join path (only a `BinaryExpression` + * `=` condition can hash-join at all), which reuses the same collation-aware + * [kestrel.engine.Evaluator] as `WHERE`. See the `...InAnEqualityHashJoin` tests below for the + * `=` counterpart, which takes the hash-join fast path instead. + */ + @Test + fun joiningOnColumnsWhereOnlyTheLeftSideDeclaresCollationHonorsItInANonEqualityJoinCondition() { + run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO left_t VALUES (1, 'Bob'), (2, 'Zoe')") + run("INSERT INTO right_t VALUES (1, 'BOB')") + assertEquals( + listOf(listOf(int(2))), + select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name <> right_t.name").rows + ) + } + + @Test + fun joiningOnColumnsWhereOnlyTheRightSideDeclaresCollationFallsBackToTheRightWhenTheLeftHasNone() { + run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("INSERT INTO left_t VALUES (1, 'Bob'), (2, 'Zoe')") + run("INSERT INTO right_t VALUES (1, 'BOB')") + assertEquals( + listOf(listOf(int(2))), + select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name <> right_t.name").rows + ) + } + + /** + * `=` takes [kestrel.engine.SelectRunner]'s hash-join fast path (`hashJoinKeys`/`executeJoin`), + * which must bucket [Value.TextValue] keys through the exact same [kestrel.engine.pairCollation] + * the nested-loop `<>` tests above resolve through [kestrel.engine.Evaluator] - otherwise the + * "optimization" would silently change which rows match. `right_t`'s undeclared `name` still + * only matches its own literal case ('bob'), so this also proves the fast path is not simply + * ignoring case altogether. + */ + @Test + fun joiningOnColumnsWhereOnlyTheLeftSideDeclaresCollationHonorsItInAnEqualityHashJoin() { + run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO left_t VALUES (1, 'Bob'), (2, 'Zoe')") + run("INSERT INTO right_t VALUES (1, 'BOB'), (2, 'bob')") + assertEquals( + listOf(listOf(int(1), int(1)), listOf(int(1), int(2))), + select("SELECT left_t.id, right_t.id FROM left_t JOIN right_t ON left_t.name = right_t.name ORDER BY right_t.id").rows + ) + } + + @Test + fun joiningOnColumnsWhereOnlyTheRightSideDeclaresCollationHonorsItInAnEqualityHashJoin() { + run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("INSERT INTO left_t VALUES (1, 'BOB'), (2, 'Zoe')") + run("INSERT INTO right_t VALUES (1, 'Bob')") + assertEquals( + listOf(listOf(int(1))), + select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name = right_t.name").rows + ) + } + + @Test + fun explicitCollateOnAnEqualityHashJoinConditionOverridesTheColumnsDeclaredCollation() { + run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO left_t VALUES (1, 'Bob')") + run("INSERT INTO right_t VALUES (1, 'BOB')") + assertEquals( + emptyList(), + select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name COLLATE BINARY = right_t.name").rows + ) + } + + @Test + fun equalityHashJoinWithNoDeclaredCollationOnEitherSideStaysBinary() { + run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO left_t VALUES (1, 'Bob')") + run("INSERT INTO right_t VALUES (1, 'BOB')") + assertEquals( + emptyList(), + select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name = right_t.name").rows + ) + } + + @Test + fun declaredCollationAppliesInsideAnOnConflictDoUpdateAssignment() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, was_bob BOOLEAN)") + run("INSERT INTO t VALUES (1, 'Bob', FALSE)") + run("INSERT INTO t (id, name) VALUES (1, 'x') ON CONFLICT (id) DO UPDATE SET was_bob = (name = 'BOB')") + assertEquals(bool(true), select("SELECT was_bob FROM t WHERE id = 1").rows.single().single()) + } + + @Test + fun declaredCollationAppliesInAMergeOnCondition() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, hits INTEGER)") + run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO accounts VALUES (1, 'Bob', 0)") + run("INSERT INTO staging VALUES (2, 'BOB')") + run("MERGE INTO accounts USING staging ON accounts.name = staging.name WHEN MATCHED THEN UPDATE SET hits = hits + 1") + assertEquals(int(1), select("SELECT hits FROM accounts WHERE id = 1").rows.single().single()) + } +