test: cover createTableColumnCollationRoundTripsThroughSqlWriter

This commit is contained in:
dualfroz committed 2026-09-08 19:49:09 +02:00
1 parent b870a00b06
commit 5ead4b7d84
1 file changed
+155
@@ -482,3 +482,158 @@ class ColumnCollationTest {
assertEquals(listOf(listOf(text("Bob")), listOf(text("Carol"))), result.rows) 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<KestrelException> { 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<KestrelException> { 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<KestrelException> { 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<KestrelException> { 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)
}
}