From 1024cd60b0101d5ba82dc5540892da571151f68e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 19:43:51 +0200 Subject: [PATCH] test: cover ColumnCollationTest and run --- .../kotlin/kestrel/ColumnCollationTest.kt | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/test/kotlin/kestrel/ColumnCollationTest.kt diff --git a/src/test/kotlin/kestrel/ColumnCollationTest.kt b/src/test/kotlin/kestrel/ColumnCollationTest.kt new file mode 100644 index 0000000..b22234a --- /dev/null +++ b/src/test/kotlin/kestrel/ColumnCollationTest.kt @@ -0,0 +1,162 @@ +package kestrel + +import kestrel.catalog.Catalog +import kestrel.engine.ExplainResult +import kestrel.engine.Executor +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.persistence.SqlWriter +import kestrel.sql.ast.CreateTableStatement +import kestrel.sql.ast.Statement +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * `col TYPE COLLATE name` - a column's own declared collating sequence (see + * [kestrel.catalog.Column.collation]), which a *bare* reference to it compares with whenever the + * query itself writes no explicit `COLLATE` - see [kestrel.CollateTest] for that explicit, + * expression-level form, which always overrides this one. Threaded through + * [kestrel.engine.ColumnResolver.declaredCollation] into every resolver built directly over a + * table's own schema: plain `WHERE`/`SELECT`/`HAVING` (via [kestrel.engine.SelectRunner]'s + * per-source lookup, including across a JOIN), `INSERT ... ON CONFLICT DO UPDATE` (EXCLUDED-aware), + * `MERGE`'s `ON`/`WHEN` clauses, and a trigger's `NEW`/`OLD`. [kestrel.store.sql. + * DiskExecutorColumnCollationTest] mirrors the core cases against the disk engine, including a + * real file close/reopen (the declared collation is persisted in [kestrel.store.sql. + * DirectoryEntryCodec], not just this engine's text dump). + */ +class ColumnCollationTest { + private val catalog = Catalog() + private val executor = Executor(catalog) + + private fun run(sql: String): List = + Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + + private fun parseOne(sql: String): Statement = Parser(Lexer(sql).tokenize()).parseProgram().single() + + private fun select(sql: String): SelectResult = run(sql).single() as SelectResult + + private fun explain(sql: String): List = (run(sql).single() as ExplainResult).plan + + private fun int(value: Long) = Value.IntegerValue(value) + private fun text(value: String) = Value.TextValue(value) + private fun bool(value: Boolean) = Value.BooleanValue(value) + + private fun seedNames() { + run("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("INSERT INTO people VALUES (1, 'Bob'), (2, 'bob'), (3, 'Alice'), (4, 'Zoe')") + } + + @Test + fun bareEqualityHonorsTheColumnsDeclaredCollationWithoutAnExplicitClause() { + seedNames() + assertEquals(bool(true), select("SELECT name = 'BOB' FROM people WHERE id = 1").rows.single().single()) + assertEquals( + listOf(int(1), int(2)), + select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows.map { it.single() } + ) + } + + @Test + fun explicitCollateInTheQueryOverridesTheColumnsDeclaredCollation() { + seedNames() + assertEquals(bool(false), select("SELECT name COLLATE BINARY = 'BOB' FROM people WHERE id = 1").rows.single().single()) + } + + @Test + fun explicitCollateOnTheOtherOperandStillOverridesADeclaredCollationOnThisOne() { + seedNames() + // 'BOB' COLLATE BINARY on the right wins over people.name's own declared NOCASE on the left. + assertEquals(bool(false), select("SELECT name = 'BOB' COLLATE BINARY FROM people WHERE id = 1").rows.single().single()) + } + + @Test + fun columnWithNoDeclaredCollationStaysBinaryByDefault() { + run("CREATE TABLE plain (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO plain VALUES (1, 'Bob')") + assertEquals(bool(false), select("SELECT name = 'BOB' FROM plain WHERE id = 1").rows.single().single()) + } + + @Test + fun declaredCollationAppliesInAnInList() { + seedNames() + assertEquals(bool(true), select("SELECT name IN ('BOB', 'other') FROM people WHERE id = 1").rows.single().single()) + } + + @Test + fun declaredCollationAppliesInBetween() { + run("CREATE TABLE words (id INTEGER PRIMARY KEY, w TEXT COLLATE NOCASE)") + run("INSERT INTO words VALUES (1, 'm')") + assertEquals(bool(true), select("SELECT w BETWEEN 'A' AND 'Z' FROM words").rows.single().single()) + } + + @Test + fun declaredCollationAppliesToASearchedCaseOperandMatch() { + run("CREATE TABLE words (id INTEGER PRIMARY KEY, w TEXT COLLATE NOCASE)") + run("INSERT INTO words VALUES (1, 'YES')") + assertEquals( + listOf(listOf(text("matched"))), + select("SELECT CASE w WHEN 'yes' THEN 'matched' ELSE 'no' END FROM words").rows + ) + } + + @Test + fun declaredCollationAppliesToDefaultOrderBySortOrder() { + seedNames() + // No explicit `ORDER BY name COLLATE NOCASE` needed - the column's own declared NOCASE + // already interleaves case. 'Bob' (id 1) and 'bob' (id 2) compare equal under NOCASE, so + // the sort (stable) keeps them in their original scan-order relative position. + val result = select("SELECT name FROM people ORDER BY name") + assertEquals(listOf("Alice", "Bob", "bob", "Zoe").map { listOf(text(it)) }, result.rows) + } + + @Test + fun explicitCollateInOrderByOverridesTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT name FROM people ORDER BY name COLLATE BINARY") + assertEquals(listOf("Alice", "Bob", "Zoe", "bob").map { listOf(text(it)) }, result.rows) + } + + @Test + fun declaredCollationAppliesInAWindowFunctionsOrderBy() { + seedNames() + val result = select("SELECT name, ROW_NUMBER() OVER (ORDER BY name) AS rn FROM people ORDER BY rn") + assertEquals( + listOf("Alice" to 1L, "Bob" to 2L, "bob" to 3L, "Zoe" to 4L).map { (name, rn) -> listOf(text(name), int(rn)) }, + result.rows + ) + } + + @Test + fun declaredCollationAppliesInGroupBysOwnOrderBy() { + seedNames() + // GROUP BY itself now also dedups through name's declared NOCASE (closing the scope cut this + // test used to document), so 'Bob' and 'bob' collapse into a single group - the group keeps + // whichever row it saw first in scan order ('Bob', id 1). Only three groups remain, and the + // ordering of those groups (GroupAggregation's own ORDER BY handling) is still under test. + val result = select("SELECT name FROM people GROUP BY name ORDER BY name") + assertEquals(listOf("Alice", "Bob", "Zoe").map { listOf(text(it)) }, result.rows) + } + + @Test + fun groupByDedupsCaseVariantsThroughTheColumnsDeclaredCollation() { + seedNames() + val result = select("SELECT name, COUNT(*) FROM people GROUP BY name ORDER BY name") + assertEquals( + listOf("Alice" to 1L, "Bob" to 2L, "Zoe" to 1L).map { (name, count) -> listOf(text(name), int(count)) }, + result.rows + ) + } + + @Test + fun groupByWithNoDeclaredCollationStillDedupsByBinaryEquality() { + run("CREATE TABLE plain (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO plain VALUES (1, 'Bob'), (2, 'bob')") + val result = select("SELECT name, COUNT(*) FROM plain GROUP BY name ORDER BY name") + assertEquals(listOf("Bob" to 1L, "bob" to 1L).map { (name, count) -> listOf(text(name), int(count)) }, result.rows) + } +