fix(sqlite): keep triggers whose declaration spells the table in another case (#2807)
* fix(sqlite): keep triggers whose declaration spells the table in another case SQLite stores a trigger's target in sqlite_schema.tbl_name exactly as written in CREATE TRIGGER and resolves it by ASCII case folding, while an index stores the table's own name. The selected_table scope filtered tbl_name with COLLATE BINARY, so a trigger declared ON users for table Users was omitted while the schema still reported complete. Match dependents with COLLATE NOCASE, and report each trigger's table as the stored table or view name so the selected-table contract still holds. * fix(sqlite): refuse an inexact table before the case-folded schema scan With the NOCASE expansion, a selection such as users for table Users expanded the stored table's declarations before readRows reported the missing exact name, so large trigger definitions could surface as an output-limit failure instead of the selection error. Check the exact table name against PRAGMA table_list first and share the selection failure with readRows. * fix(sqlite): fold dependent names only for a row-selectable table An exact view, virtual or possible shadow table passed the exact-name guard, so the NOCASE scan could still expand a differently cased trigger past the reply budget before readRows refused the selection. Fold case only when the selection is an ordinary table that is not a possible shadow table, and keep the exact filter otherwise so every refused selection fails as it did before the case-folding change. * fix(sqlite): resolve trigger tables without folding unrelated names The stored-name lookup case-folded every PRAGMA table_list name up front, so a selected-table inspection of a tiny table paid for large unrelated identifiers. A selected-table scan only returns the selected table's dependents, so report the selected name directly; other scopes use an exact stored name when one matches and fold the stored names once, only when a trigger spells its target differently. * perf(sqlite): fold each table name once when matching shadow parents The extracted shadow-parent helper folded the table name inside the find callback, once per unresolved virtual table, and the selected table was checked both before and during the schema scan. Fold the name once per call as before, and reuse the selected table's result in the scan.
This commit is contained in:
@@ -109,6 +109,11 @@ class OutputBudget {
|
||||
}
|
||||
const quoteIdentifier = (name: string): string =>
|
||||
`"${name.replaceAll('"', '""')}"`;
|
||||
const missingSelectedTable = (name: string): SqliteInspectionFailure =>
|
||||
new SqliteInspectionFailure(
|
||||
"selection",
|
||||
`Selected ordinary table does not exist with that exact name: ${name}`,
|
||||
);
|
||||
// SQLite folds only ASCII letters when matching virtual/shadow table names.
|
||||
const sqliteIdentifierKey = (name: string): string =>
|
||||
name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
||||
@@ -236,12 +241,49 @@ const readSchema = (
|
||||
metadata.set(value.name, value);
|
||||
}
|
||||
}
|
||||
// A trigger stores its target as written in CREATE TRIGGER, which SQLite
|
||||
// resolves by ASCII case folding; report the stored table or view name.
|
||||
// A selected-table scan returns only the selected table's dependents, and
|
||||
// other scopes fold stored names once, only when a spelling differs.
|
||||
let foldedNames: Map<string, string> | undefined;
|
||||
const triggerTable = (written: string): string => {
|
||||
if (selectedName !== undefined) return selectedName;
|
||||
if (metadata.has(written)) return written;
|
||||
foldedNames ??= new Map(
|
||||
[...metadata.keys()].map((name) => [sqliteIdentifierKey(name), name]),
|
||||
);
|
||||
return foldedNames.get(sqliteIdentifierKey(written)) ?? written;
|
||||
};
|
||||
const unresolvedVirtualTables = [...metadata.values()]
|
||||
.filter((value) => value.type === "virtual" && value.ncol === 0)
|
||||
.map((value) => ({
|
||||
name: value.name,
|
||||
prefix: sqliteIdentifierKey(`${value.name}_`),
|
||||
}));
|
||||
const shadowParentOf = (
|
||||
name: string,
|
||||
listed: z.output<typeof tableRow>,
|
||||
): (typeof unresolvedVirtualTables)[number] | undefined => {
|
||||
if (listed.type !== "table") return undefined;
|
||||
const identifier = sqliteIdentifierKey(name);
|
||||
return unresolvedVirtualTables.find((parent) =>
|
||||
identifier.startsWith(parent.prefix),
|
||||
);
|
||||
};
|
||||
const selected =
|
||||
selectedName === undefined ? undefined : metadata.get(selectedName);
|
||||
if (selectedName !== undefined && selected === undefined)
|
||||
throw missingSelectedTable(selectedName);
|
||||
const selectedShadowParent =
|
||||
selectedName === undefined || selected === undefined
|
||||
? undefined
|
||||
: shadowParentOf(selectedName, selected);
|
||||
// NOCASE folds only ASCII letters, as SQLite does when it resolves the
|
||||
// table named by an index or trigger. Fold only for an ordinary table whose
|
||||
// rows can be selected; a selection refused later keeps the exact filter,
|
||||
// so its failure does not depend on declarations under another spelling.
|
||||
const foldDependents =
|
||||
selected?.type === "table" && selectedShadowParent === undefined;
|
||||
const schema: SqliteDatabase["schema"] = {
|
||||
scope,
|
||||
completeness: "complete",
|
||||
@@ -254,7 +296,9 @@ const readSchema = (
|
||||
// duplicate large names or definitions beyond SQLite's native record limit.
|
||||
const declaration = scope === "inventory" ? "" : ", CAST(sql AS BLOB) AS sql";
|
||||
const selection =
|
||||
scope === "selected_table" ? " WHERE tbl_name = ? COLLATE BINARY" : "";
|
||||
scope === "selected_table"
|
||||
? ` WHERE tbl_name = ? COLLATE ${foldDependents ? "NOCASE" : "BINARY"}`
|
||||
: "";
|
||||
const statement = db.prepare(
|
||||
`SELECT type, CAST(name AS BLOB) AS name, CAST(tbl_name AS BLOB) AS tbl_name, rootpage${declaration} FROM main.sqlite_schema${selection}`,
|
||||
);
|
||||
@@ -280,13 +324,10 @@ const readSchema = (
|
||||
"format",
|
||||
`SQLite schema/table inventory mismatch: ${value.name}`,
|
||||
);
|
||||
const identifier = sqliteIdentifierKey(value.name);
|
||||
const possibleShadowParent =
|
||||
listed.type === "table"
|
||||
? unresolvedVirtualTables.find((parent) =>
|
||||
identifier.startsWith(parent.prefix),
|
||||
)
|
||||
: undefined;
|
||||
value.name === selectedName
|
||||
? selectedShadowParent
|
||||
: shadowParentOf(value.name, listed);
|
||||
const table: SqliteDatabase["schema"]["tables"][number] = {
|
||||
name: value.name,
|
||||
...(scope === "inventory" ? {} : { sql: value.sql }),
|
||||
@@ -349,7 +390,7 @@ const readSchema = (
|
||||
} else {
|
||||
const trigger = {
|
||||
name: value.name,
|
||||
table: value.tbl_name,
|
||||
table: triggerTable(value.tbl_name),
|
||||
...(scope === "inventory" ? {} : { sql: value.sql }),
|
||||
};
|
||||
budget.retain(trigger);
|
||||
@@ -370,11 +411,7 @@ const readRows = (
|
||||
): SqliteDatabase["rows"] => {
|
||||
const { encoding, budget } = context;
|
||||
if (input.table === undefined) return null;
|
||||
if (table === undefined)
|
||||
throw new SqliteInspectionFailure(
|
||||
"selection",
|
||||
`Selected ordinary table does not exist with that exact name: ${input.table}`,
|
||||
);
|
||||
if (table === undefined) throw missingSelectedTable(input.table);
|
||||
if (table.kind === "unknown")
|
||||
throw new SqliteInspectionFailure(
|
||||
"selection",
|
||||
|
||||
@@ -130,6 +130,71 @@ it("accounts for JSON escape expansion of a stored declaration in the reply limi
|
||||
]);
|
||||
});
|
||||
|
||||
it("attributes triggers declared with another ASCII case to the stored table name", async () => {
|
||||
const path = await createDatabase(`
|
||||
CREATE TABLE Users(id INTEGER PRIMARY KEY, email TEXT);
|
||||
CREATE INDEX users_email ON users(email);
|
||||
CREATE TRIGGER audit_insert AFTER INSERT ON users BEGIN SELECT 1; END;
|
||||
CREATE TRIGGER audit_delete AFTER DELETE ON USERS BEGIN SELECT 1; END;
|
||||
CREATE VIEW recent AS SELECT 1;
|
||||
CREATE TRIGGER recent_insert INSTEAD OF INSERT ON Recent BEGIN SELECT 1; END;
|
||||
`);
|
||||
const selected = inspectSqliteDatabaseSnapshot(path, {
|
||||
path,
|
||||
table: "Users",
|
||||
});
|
||||
expect(selected.schema).toMatchObject({
|
||||
scope: "selected_table",
|
||||
completeness: "complete",
|
||||
tables: [{ name: "Users" }],
|
||||
indexes: [{ name: "users_email", table: "Users" }],
|
||||
views: [],
|
||||
});
|
||||
expect(
|
||||
selected.schema.triggers.map(({ name, table }) => ({ name, table })),
|
||||
).toEqual([
|
||||
{ name: "audit_insert", table: "Users" },
|
||||
{ name: "audit_delete", table: "Users" },
|
||||
]);
|
||||
const inventory = inspectSqliteDatabaseSnapshot(path, { path });
|
||||
expect(inventory.schema.triggers).toEqual([
|
||||
{ name: "audit_insert", table: "Users" },
|
||||
{ name: "audit_delete", table: "Users" },
|
||||
{ name: "recent_insert", table: "recent" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
selection: "a differently cased table",
|
||||
schema: "CREATE TABLE Users(id INTEGER PRIMARY KEY)",
|
||||
trigger: "AFTER INSERT ON Users",
|
||||
table: "users",
|
||||
},
|
||||
{
|
||||
selection: "an exact view",
|
||||
schema: "CREATE VIEW Recent AS SELECT 1 AS id",
|
||||
trigger: "INSTEAD OF INSERT ON recent",
|
||||
table: "Recent",
|
||||
},
|
||||
])(
|
||||
"refuses $selection before expanding declarations under another spelling",
|
||||
async ({ schema, trigger, table }) => {
|
||||
const path = await createDatabase(`
|
||||
${schema};
|
||||
CREATE TRIGGER expanded ${trigger} BEGIN /*${"\u0001".repeat(3 * 1024 * 1024)}*/ SELECT 1; END;
|
||||
`);
|
||||
expect(() =>
|
||||
inspectSqliteDatabaseSnapshot(path, { path, table }),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
reason: "selection",
|
||||
message: `Selected ordinary table does not exist with that exact name: ${table}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["UTF-8", "UTF-16le", "UTF-16be"])(
|
||||
"preserves exact %s schema text when decoding native bytes",
|
||||
async (encoding) => {
|
||||
|
||||
Reference in New Issue
Block a user