test(server): stop counting a filtered partial index as covering a foreign key

The foreign key index check took every index from PRAGMA index_list as a
usable prefix, partial ones included. An index like
CREATE INDEX ix ON c(p_id) WHERE state = 'open' would satisfy FKIDX-001,
yet the cascade lookup WHERE p_id = ? still scans the table, which is the
regression the check exists to catch. A partial index now counts only when
its predicate is IS NOT NULL on the key's own columns, the one form SQLite
can prove from p_id = ?. Nothing in today's schema depends on a partial
index alone, so the result on the migrated snapshot is unchanged.
This commit is contained in:
Maurice
2026-10-08 12:52:00 +02:00
parent 5a5349b817
commit a198dde5d2
2 changed files with 72 additions and 10 deletions
@@ -29,6 +29,7 @@ interface ForeignKeyRow {
interface IndexListRow {
name: string;
partial: number;
}
interface IndexInfoRow {
@@ -62,10 +63,43 @@ function foreignKeys(db: Database.Database, table: string): string[][] {
return [...byId.values()].map((group) => group.sort((a, b) => a.seq - b.seq).map((r) => r.from));
}
/** The leading columns of an index, and the columns its WHERE clause requires to be non-null. */
interface IndexPrefix {
cols: string[];
notNull: string[];
}
/**
* The columns a partial index's predicate requires to be non-null, or null when
* the predicate says anything else. SQLite proves `col = ?` implies `col IS NOT
* NULL`, so only such an index serves the parent's cascade lookup; one filtered
* on another predicate (`WHERE state = 'open'`) leaves the lookup scanning.
*/
function notNullPredicate(sql: string | null): string[] | null {
const where = /\bWHERE\b([\s\S]*)$/i.exec(sql ?? '');
if (!where) return null;
const cols: string[] = [];
for (const term of where[1].trim().split(/\s+AND\s+/i)) {
const m = /^\(?\s*["`[]?(\w+)["`\]]?\s+IS\s+NOT\s+NULL\s*\)?$/i.exec(term.trim());
if (!m) return null;
cols.push(m[1]);
}
return cols;
}
/** Every index of a table, and its primary key, as ordered column lists. */
function indexedPrefixes(db: Database.Database, table: string): string[][] {
const lists: string[][] = [];
for (const { name } of db.prepare(`PRAGMA index_list("${table}")`).all() as IndexListRow[]) {
function indexedPrefixes(db: Database.Database, table: string): IndexPrefix[] {
const lists: IndexPrefix[] = [];
for (const { name, partial } of db.prepare(`PRAGMA index_list("${table}")`).all() as IndexListRow[]) {
let notNull: string[] = [];
if (partial) {
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`).get(name) as
{ sql: string | null } | undefined;
const required = notNullPredicate(row?.sql ?? null);
// A predicate other than IS NOT NULL on key columns hides rows from the lookup.
if (required === null) continue;
notNull = required;
}
const cols = (db.prepare(`PRAGMA index_info("${name}")`).all() as IndexInfoRow[])
.sort((a, b) => a.seqno - b.seqno)
// An expression column has no name and ends the usable prefix.
@@ -75,20 +109,28 @@ function indexedPrefixes(db: Database.Database, table: string): string[][] {
if (c === null) break;
usable.push(c);
}
lists.push(usable);
lists.push({ cols: usable, notNull });
}
const pk = (db.prepare(`PRAGMA table_info("${table}")`).all() as TableInfoRow[])
.filter((c) => c.pk > 0)
.sort((a, b) => a.pk - b.pk)
.map((c) => c.name);
if (pk.length) lists.push(pk);
if (pk.length) lists.push({ cols: pk, notNull: [] });
return lists;
}
/** True when the foreign key's columns, in any order, are the leading columns of one index. */
function isLedByIndex(fk: string[], indexes: string[][]): boolean {
/**
* True when the foreign key's columns, in any order, are the leading columns of
* one index, and a partial index filters only on those columns being non-null.
*/
function isLedByIndex(fk: string[], indexes: IndexPrefix[]): boolean {
const want = [...fk].sort().join(',');
return indexes.some((cols) => cols.length >= fk.length && [...cols.slice(0, fk.length)].sort().join(',') === want);
return indexes.some(
({ cols, notNull }) =>
cols.length >= fk.length &&
[...cols.slice(0, fk.length)].sort().join(',') === want &&
notNull.every((c) => fk.includes(c)),
);
}
function unindexed(db: Database.Database): string[] {
@@ -130,11 +172,31 @@ describe('foreign key indexes', () => {
db.exec('CREATE INDEX idx_child_note_parent ON child(note, parent_id)');
expect(unindexed(db)).toContain('child.parent_id');
db.exec('CREATE INDEX idx_child_parent ON child(parent_id)');
// A partial index on another predicate leaves the cascade lookup scanning.
db.exec(`CREATE INDEX idx_child_parent_open ON child(parent_id) WHERE note = 'open'`);
expect(db.prepare('EXPLAIN QUERY PLAN SELECT 1 FROM child WHERE parent_id = ?').get(1)).toMatchObject({
detail: 'SCAN child',
});
expect(unindexed(db)).toContain('child.parent_id');
// One filtered only on the key being non-null still serves `parent_id = ?`.
db.exec('CREATE INDEX idx_child_parent ON child(parent_id) WHERE parent_id IS NOT NULL');
expect(
(db.prepare('EXPLAIN QUERY PLAN SELECT 1 FROM child WHERE parent_id = ?').get(1) as { detail: string }).detail,
).toContain('idx_child_parent');
db.exec('CREATE INDEX idx_pair_yx ON pair(y, x, z)');
expect(unindexed(db)).toEqual([]);
} finally {
db.close();
}
});
it('FKIDX-004: a partial index counts only when its predicate is IS NOT NULL on its columns', () => {
expect(notNullPredicate('CREATE INDEX i ON c(p_id) WHERE p_id IS NOT NULL')).toEqual(['p_id']);
expect(notNullPredicate('CREATE INDEX i ON c(a, b) WHERE "a" IS NOT NULL AND (b IS NOT NULL)')).toEqual(['a', 'b']);
expect(notNullPredicate("CREATE INDEX i ON c(p_id) WHERE state = 'open'")).toBeNull();
expect(notNullPredicate('CREATE INDEX i ON c(p_id) WHERE p_id IS NOT NULL OR x = 1')).toBeNull();
expect(notNullPredicate('CREATE INDEX i ON c(p_id) WHERE p_id IS NULL')).toBeNull();
expect(notNullPredicate(null)).toBeNull();
});
});