test: cover createTableColumnCollationRoundTripsThroughSqlWriter
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user