From 5ead4b7d84b51f2f214ecb962d0e5b3a83582afd Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 19:49:09 +0200 Subject: [PATCH] test: cover createTableColumnCollationRoundTripsThroughSqlWriter --- .../kotlin/kestrel/ColumnCollationTest.kt | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/test/kotlin/kestrel/ColumnCollationTest.kt b/src/test/kotlin/kestrel/ColumnCollationTest.kt index 7002494..96355b8 100644 --- a/src/test/kotlin/kestrel/ColumnCollationTest.kt +++ b/src/test/kotlin/kestrel/ColumnCollationTest.kt @@ -482,3 +482,158 @@ class ColumnCollationTest { assertEquals(listOf(listOf(text("Bob")), listOf(text("Carol"))), result.rows) } + @Test + fun createTableColumnCollationRoundTripsThroughSqlWriter() { + val createStatement = parseOne("CREATE TABLE people2 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") as CreateTableStatement + val rendered = SqlWriter.write(createStatement) + check("COLLATE NOCASE" in rendered) { "expected COLLATE NOCASE in rendered CREATE TABLE: $rendered" } + executor.execute(parseOne(rendered)) + run("INSERT INTO people2 VALUES (1, 'Bob')") + assertEquals(bool(true), select("SELECT name = 'BOB' FROM people2").rows.single().single()) + } + + /** + * A real correctness bug, not a documented scope-cut: [kestrel.catalog.SortedIndex] used to + * sort/compare its keys with plain byte order regardless of the indexed column's own declared + * `COLLATE` (see [kestrel.catalog.Catalog.collationsFor]/[kestrel.catalog.IndexKeyOrdering]), + * so once [kestrel.engine.IndexAccessPlanner] chose to serve `WHERE name = 'BOB'` from an index + * on a `NOCASE`-declared `name` column, the lookup silently missed a stored `'bob'` row - unlike + * every other collation gap in this codebase (all documented scope-cuts, never wrong answers), + * this one returned fewer rows than a full scan of the exact same table/predicate would, because + * [kestrel.engine.SelectRunner.materialize]'s post-scan `WHERE` filter only re-checks candidates + * the chosen access path already produced - it cannot recover a row the index never handed back. + */ + @Test + fun indexedEqualityHonorsTheColumnsDeclaredCollation() { + seedNames() + val scanned = select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows + run("CREATE INDEX idxName ON people (name)") + val plan = explain("EXPLAIN SELECT id FROM people WHERE name = 'BOB'") + assertTrue(plan.any { it.contains("Index Scan using idxName") }, "expected index scan, got $plan") + val indexed = select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows + assertEquals(scanned, indexed) + assertEquals(listOf(listOf(int(1)), listOf(int(2))), indexed) + } + + /** Mirrors [indexedEqualityHonorsTheColumnsDeclaredCollation] for a trailing-space collation instead of case. */ + @Test + fun indexedEqualityHonorsAnRtrimDeclaredCollation() { + run("CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT COLLATE RTRIM)") + run("INSERT INTO tags VALUES (1, 'a'), (2, 'a '), (3, 'b')") + run("CREATE INDEX idxTag ON tags (tag)") + val plan = explain("EXPLAIN SELECT id FROM tags WHERE tag = 'a'") + assertTrue(plan.any { it.contains("Index Scan using idxTag") }, "expected index scan, got $plan") + assertEquals(listOf(listOf(int(1)), listOf(int(2))), select("SELECT id FROM tags WHERE tag = 'a' ORDER BY id").rows) + } + + /** + * A composite index's [kestrel.catalog.IndexKeyOrdering] carries one [kestrel.storage.Collation] + * per column position - `first` is `NOCASE` (matches both rows) but `last` stays plain `BINARY` + * (only `'Smith'` exactly matches), so only row 1 should survive both conjuncts. This only passes + * if each column position is folded through its OWN collation independently rather than, say, the + * whole composite key sharing a single collation. + */ + @Test + fun compositeIndexEqualityHonorsPerColumnDeclaredCollationIndependently() { + run("CREATE TABLE contacts (id INTEGER PRIMARY KEY, first TEXT COLLATE NOCASE, last TEXT)") + run("INSERT INTO contacts VALUES (1, 'Bob', 'Smith'), (2, 'bob', 'SMITH')") + run("CREATE INDEX idxContact ON contacts (first, last)") + val plan = explain("EXPLAIN SELECT id FROM contacts WHERE first = 'BOB' AND last = 'Smith'") + assertTrue(plan.any { it.contains("Index Scan using idxContact") }, "got $plan") + assertEquals( + listOf(listOf(int(1))), + select("SELECT id FROM contacts WHERE first = 'BOB' AND last = 'Smith'").rows + ) + } + + /** + * An explicit `COLLATE` at the query level overrides the column's own declared collation (see + * [kestrel.engine.pairCollation]) - [kestrel.engine.IndexAccessPlanner.asColumnPredicate] only + * recognizes a bare [kestrel.sql.ast.ColumnExpression], so a `COLLATE`-wrapped operand is simply + * excluded from pushdown rather than being (wrongly) served by an index built for the column's + * OWN collation. Proves that safety net: falling back to `Seq Scan` here, not silently pushing + * the query's `BINARY` override through a `NOCASE`-sorted index. + */ + @Test + fun explicitCollateOverrideOnAnIndexedColumnFallsBackToASeqScanInsteadOfMisusingTheIndex() { + seedNames() + run("CREATE INDEX idxName ON people (name)") + val plan = explain("EXPLAIN SELECT id FROM people WHERE name COLLATE BINARY = 'BOB'") + assertTrue(plan.any { it.contains("Seq Scan") }, "expected seq scan fallback, got $plan") + assertEquals(emptyList(), select("SELECT id FROM people WHERE name COLLATE BINARY = 'BOB'").rows) + } + + /** Control negative: an index over a column with no declared `COLLATE` behaves exactly as before this session. */ + @Test + fun indexedEqualityWithNoDeclaredCollationStaysBinary() { + run("CREATE TABLE plain3 (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO plain3 VALUES (1, 'Bob'), (2, 'bob')") + run("CREATE INDEX idxPlain3 ON plain3 (name)") + val plan = explain("EXPLAIN SELECT id FROM plain3 WHERE name = 'Bob'") + assertTrue(plan.any { it.contains("Index Scan using idxPlain3") }, "got $plan") + assertEquals(listOf(listOf(int(1))), select("SELECT id FROM plain3 WHERE name = 'Bob'").rows) + } + + /** + * PRIMARY KEY uniqueness ([kestrel.engine.Executor.enforcePrimaryKeyUniqueness]) folded through + * [kestrel.engine.collationFoldedUniquenessKey] - 'Bob'/'bob' now collide under a NOCASE-declared + * PRIMARY KEY exactly like they already do for a plain equality comparison. + */ + @Test + fun primaryKeyUniquenessHonorsTheColumnsDeclaredCollation() { + run("CREATE TABLE pk1 (name TEXT COLLATE NOCASE PRIMARY KEY)") + run("INSERT INTO pk1 VALUES ('Bob')") + assertFailsWith { run("INSERT INTO pk1 VALUES ('bob')") } + } + + /** [kestrel.engine.Executor.enforceUniqueConstraints], the declarative `UNIQUE` counterpart of the PK test above. */ + @Test + fun uniqueConstraintHonorsTheColumnsDeclaredCollation() { + run("CREATE TABLE u1 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE UNIQUE)") + run("INSERT INTO u1 VALUES (1, 'Bob')") + assertFailsWith { run("INSERT INTO u1 VALUES (2, 'bob')") } + } + + /** + * `CREATE UNIQUE INDEX` ([kestrel.engine.Executor.enforceUniqueIndexes]) checked against + * pre-existing rows before the index is registered - two rows already differing only by case + * under a `NOCASE`-declared column must now be rejected, matching what inserting either of them + * fresh under an already-`UNIQUE`-constrained column would do. + */ + @Test + fun createUniqueIndexHonorsTheColumnsDeclaredCollationAgainstPreexistingRows() { + run("CREATE TABLE u2 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("INSERT INTO u2 VALUES (1, 'Bob'), (2, 'bob')") + assertFailsWith { run("CREATE UNIQUE INDEX idxU2 ON u2 (name)") } + } + + /** + * [kestrel.engine.Executor.enforceUniqueIndexes] guarding an ordinary `INSERT` against an + * ALREADY-REGISTERED `CREATE UNIQUE INDEX` - distinct from + * [createUniqueIndexHonorsTheColumnsDeclaredCollationAgainstPreexistingRows]'s backfill-time + * check: here the index already exists and a fresh case-variant row is what must be rejected, + * BEFORE the row is appended to the table (see `enforceUniqueIndexes`'s own doc comment on why + * it does not just rely on [kestrel.catalog.SortedIndex.add]'s own, likewise collation-aware, + * uniqueness check to catch this instead). + */ + @Test + fun uniqueIndexHonorsTheColumnsDeclaredCollationOnInsertAfterCreation() { + run("CREATE TABLE u5 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") + run("CREATE UNIQUE INDEX idxU5 ON u5 (name)") + run("INSERT INTO u5 VALUES (1, 'Bob')") + assertFailsWith { run("INSERT INTO u5 VALUES (2, 'bob')") } + // Proves enforceUniqueIndexes itself rejected the row BEFORE table.rows.addAll ran, not + // merely that some exception surfaced - u5 has no trigger, so withStatementAtomicity takes + // no catalog snapshot here and could not have rolled back a row that got appended first. + assertEquals(listOf(listOf(int(1))), select("SELECT id FROM u5").rows) + } + + /** Control negative: a `UNIQUE` column with no declared `COLLATE` behaves exactly as before this session. */ + @Test + fun uniqueConstraintWithNoDeclaredCollationStaysBinary() { + run("CREATE TABLE u3 (id INTEGER PRIMARY KEY, name TEXT UNIQUE)") + run("INSERT INTO u3 VALUES (1, 'Bob')") + run("INSERT INTO u3 VALUES (2, 'bob')") + assertEquals(2, select("SELECT id FROM u3").rows.size) + } +}