diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorColumnCollationTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorColumnCollationTest.kt index 6058c8b..005d192 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorColumnCollationTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorColumnCollationTest.kt @@ -308,3 +308,157 @@ class DiskExecutorColumnCollationTest { assertFailsWith { db.run("CREATE UNIQUE INDEX idxU2 ON u2 (name)") } } + @Test + fun aDeclaredColumnCollationKeepsComparingTheSameWayAfterARealFileCloseAndReopen() { + val db = newDatabase() + db.seedNames() + assertEquals( + listOf(int(1), int(2)), + db.select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows.map { it.single() } + ) + db.reopen() + assertEquals( + listOf(int(1), int(2)), + db.select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows.map { it.single() } + ) + } + + /** + * [DiskIndexAccessPlanner]'s equivalent of [kestrel.ColumnCollationTest.indexedEqualityHonorsTheColumnsDeclaredCollation]: + * [DiskTable.findBySecondary] used to encode both the inserted row's key AND the search + * literal straight to bytes via [kestrel.store.codec.KeyCodec], with no regard for the + * column's declared `COLLATE` - so once `CREATE INDEX` made the planner choose an index scan + * over the full scan the un-indexed [bareEqualityHonorsTheColumnsDeclaredCollationWithoutAnExplicitClause] + * exercises, the case-variant row silently dropped out of the result instead of raising an + * error, since [DiskExecutor] only re-checks WHERE against candidates the chosen access plan + * already returned. + */ + @Test + fun indexedEqualityHonorsTheColumnsDeclaredCollation() { + val db = newDatabase() + db.seedNames() + val scanned = db.select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows + db.run("CREATE INDEX idxName ON people (name)") + val plan = db.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 = db.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() { + val db = newDatabase() + db.run("CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT COLLATE RTRIM)") + db.run("INSERT INTO tags VALUES (1, 'a'), (2, 'a '), (3, 'b')") + db.run("CREATE INDEX idxTag ON tags (tag)") + val plan = db.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))), db.select("SELECT id FROM tags WHERE tag = 'a' ORDER BY id").rows) + } + + /** + * Mirrors [kestrel.ColumnCollationTest.compositeIndexEqualityHonorsPerColumnDeclaredCollationIndependently]: + * a composite secondary index's key is `encodeKey(first) ++ encodeKey(last) ++ rid` - each + * column position must fold through its OWN declared collation independently (`first` is + * NOCASE, `last` stays BINARY) rather than, say, folding the whole composite key uniformly or + * not at all. + */ + @Test + fun compositeIndexEqualityHonorsPerColumnDeclaredCollationIndependently() { + val db = newDatabase() + db.run("CREATE TABLE contacts (id INTEGER PRIMARY KEY, first TEXT COLLATE NOCASE, last TEXT)") + db.run("INSERT INTO contacts VALUES (1, 'Bob', 'Smith'), (2, 'bob', 'SMITH')") + db.run("CREATE INDEX idxContact ON contacts (first, last)") + val plan = db.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))), + db.select("SELECT id FROM contacts WHERE first = 'BOB' AND last = 'Smith'").rows + ) + } + + /** + * Safety net mirroring [kestrel.ColumnCollationTest.explicitCollateOverrideOnAnIndexedColumnFallsBackToASeqScanInsteadOfMisusingTheIndex]: + * [DiskIndexAccessPlanner.asColumnPredicate] only recognizes a bare [kestrel.sql.ast.ColumnExpression], + * so an explicit `COLLATE`-wrapped operand is excluded from pushdown entirely instead of being + * (wrongly) served by an index whose physical bytes are folded for the column's OWN collation. + */ + @Test + fun explicitCollateOverrideOnAnIndexedColumnFallsBackToASeqScanInsteadOfMisusingTheIndex() { + val db = newDatabase() + db.seedNames() + db.run("CREATE INDEX idxName ON people (name)") + val plan = db.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(), db.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() { + val db = newDatabase() + db.run("CREATE TABLE plain3 (id INTEGER PRIMARY KEY, name TEXT)") + db.run("INSERT INTO plain3 VALUES (1, 'Bob'), (2, 'bob')") + db.run("CREATE INDEX idxPlain3 ON plain3 (name)") + val plan = db.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))), db.select("SELECT id FROM plain3 WHERE name = 'Bob'").rows) + } + + /** + * [DiskTable.findByPrimaryKey]'s point-lookup counterpart of [indexedEqualityHonorsTheColumnsDeclaredCollation]: + * a single-column, inline `PRIMARY KEY` is backed by its own B+Tree exactly like a secondary + * index, so `WHERE name = 'BOB'` against a `NOCASE`-declared `PRIMARY KEY` column must be + * planned as [DiskAccessPlan.PrimaryKeyPoint] and still find the case-variant row. + */ + @Test + fun primaryKeyPointLookupHonorsTheColumnsDeclaredCollation() { + val db = newDatabase() + db.run("CREATE TABLE pk1 (name TEXT COLLATE NOCASE PRIMARY KEY, hits INTEGER)") + db.run("INSERT INTO pk1 VALUES ('Bob', 10)") + val plan = db.explain("EXPLAIN SELECT hits FROM pk1 WHERE name = 'BOB'") + assertTrue(plan.any { it.contains("Primary Key Scan") }, "expected primary key point lookup, got $plan") + assertEquals(listOf(listOf(int(10))), db.select("SELECT hits FROM pk1 WHERE name = 'BOB'").rows) + } + + /** + * [encodePrimaryKey]'s composite counterpart of [primaryKeyPointLookupHonorsTheColumnsDeclaredCollation]: + * a table-level composite `PRIMARY KEY (region, code)` folds each position through ITS OWN + * declared collation independently ([DiskAccessPlan.CompositePrimaryKeyPoint] mirrors + * [compositeIndexEqualityHonorsPerColumnDeclaredCollationIndependently] but for the PK's own + * B+Tree rather than a secondary index) - `region` is NOCASE (matches both rows) but `code` + * stays plain BINARY (only an exact-case match counts), so only row 1 survives both conjuncts. + */ + @Test + fun compositePrimaryKeyPointLookupHonorsPerColumnDeclaredCollationIndependently() { + val db = newDatabase() + db.run("CREATE TABLE zones (region TEXT COLLATE NOCASE, code TEXT, hits INTEGER, PRIMARY KEY (region, code))") + db.run("INSERT INTO zones VALUES ('EU', 'west', 1), ('eu', 'WEST', 2)") + val plan = db.explain("EXPLAIN SELECT hits FROM zones WHERE region = 'eu' AND code = 'west'") + assertTrue(plan.any { it.contains("Primary Key Scan") }, "expected composite primary key point lookup, got $plan") + assertEquals(listOf(listOf(int(1))), db.select("SELECT hits FROM zones WHERE region = 'eu' AND code = 'west'").rows) + } + + /** + * [DiskTable.rangeByPrimaryKey]'s composite counterpart of [rangeQueryOnAPrimaryKeyHonorsTheColumnsDeclaredCollation]: + * a table-level composite `PRIMARY KEY (order_id, sku)` ranged on its LAST column (`sku`, the + * only shape [DiskAccessPlan.CompositePrimaryKeyRange] plans - see its own doc) after an + * equality prefix on `order_id` (plain BINARY, irrelevant here) still folds the bound through + * `sku`'s own declared `NOCASE` collation, same as the single-column case - `'Mango'` only + * survives `sku > 'lemon'` once both sides are canonicalized to `'MANGO' > 'LEMON'`. + */ + @Test + fun rangeQueryOnTheLastColumnOfACompositePrimaryKeyAfterAnEqualityPrefixHonorsTheColumnsDeclaredCollation() { + val db = newDatabase() + db.run("CREATE TABLE order_lines4 (order_id INTEGER, sku TEXT COLLATE NOCASE, PRIMARY KEY (order_id, sku))") + db.run("INSERT INTO order_lines4 VALUES (1, 'Apple'), (1, 'Mango'), (1, 'kiwi'), (2, 'Zebra')") + val plan = db.explain("EXPLAIN SELECT sku FROM order_lines4 WHERE order_id = 1 AND sku > 'lemon'") + assertTrue(plan.any { it.contains("Primary Key Range Scan") }, "expected primary key range scan, got $plan") + assertEquals( + listOf(listOf(Value.TextValue("Mango"))), + db.select("SELECT sku FROM order_lines4 WHERE order_id = 1 AND sku > 'lemon'").rows + ) + } +