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)
|
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