Public: anyone can read this; the crew can change it.
Indexes no prepared query uses are dropped, with the list and the bytes saved in the pull #12023
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12011-indexes-no-prepared-query-uses-are-dropp → main 2 commits fork
5 files
changed +550
−0
scripts/index-use.ts added +386
| @@ -0,0 +1,386 @@ | ||
| 1 | /** | |
| 2 | * Index use (#12011): which index each statement the forge prepares walks. | |
| 3 | * | |
| 4 | * bun scripts/index-use.ts every index, with the statements that use it | |
| 5 | * bun scripts/index-use.ts --unused only the indexes nothing uses and nothing keeps | |
| 6 | * bun scripts/index-use.ts --bytes …and what each held on a seeded database | |
| 7 | * bun scripts/index-use.ts --log <file> add the statements a test run collected | |
| 8 | * | |
| 9 | * The statements come from the source: every string or template literal | |
| 10 | * under `src/` (migrations aside) that reads as SQL, with `${…}` standing | |
| 11 | * in as a bound parameter, plus the statements inside each trigger. A | |
| 12 | * test run adds what it really prepared when `FORGE_SQL_LOG=<file>` is set | |
| 13 | * (`test/helpers/preload.ts` appends each distinct text): | |
| 14 | * | |
| 15 | * FORGE_SQL_LOG=$TMPDIR/sql.log bun test test/a.test.ts … | |
| 16 | * bun scripts/index-use.ts --log $TMPDIR/sql.log | |
| 17 | * | |
| 18 | * Each is planned with EXPLAIN QUERY PLAN on a freshly migrated database. | |
| 19 | * The forge never runs ANALYZE, so production plans without statistics too, | |
| 20 | * and the plan seen here is the plan it gets. | |
| 21 | * | |
| 22 | * An index is never offered for dropping when it is UNIQUE (a constraint), | |
| 23 | * partial (a bound value can satisfy its WHERE at run time, which a plan | |
| 24 | * without values cannot show), the lookup a foreign key's checks and | |
| 25 | * cascades walk (those plans are not in EXPLAIN), or named by any file in | |
| 26 | * `src/` or `test/` outside the migrations (a test of a plan, an INDEXED BY). | |
| 27 | */ | |
| 28 | ||
| 29 | import type { Database } from "bun:sqlite"; | |
| 30 | import { appendFileSync, readFileSync } from "node:fs"; | |
| 31 | import { join } from "node:path"; | |
| 32 | ||
| 33 | /** What makes a literal SQL: its first word. */ | |
| 34 | const SQL_START = /^(?:SELECT|WITH|INSERT|UPDATE|DELETE|REPLACE)\b/i; | |
| 35 | ||
| 36 | /** Where a `${…}` stood in a template literal, before a stand-in is chosen. */ | |
| 37 | const HOLE = "\u0000"; | |
| 38 | ||
| 39 | /** | |
| 40 | * The string and template literals of one TypeScript file, with literals | |
| 41 | * joined by `+` read as one, and each `${…}` left as {@link HOLE}. | |
| 42 | */ | |
| 43 | export function literals(text: string): string[] { | |
| 44 | const found: { start: number; end: number; value: string }[] = []; | |
| 45 | let i = 0; | |
| 46 | // The last significant character, to tell a regular expression from a division. | |
| 47 | let last = ""; | |
| 48 | const n = text.length; | |
| 49 | ||
| 50 | const quoted = (q: string): string => { | |
| 51 | let out = ""; | |
| 52 | i++; | |
| 53 | while (i < n && text[i] !== q) { | |
| 54 | if (text[i] === "\\") { | |
| 55 | out += unescape(text[i + 1] ?? ""); | |
| 56 | i += 2; | |
| 57 | continue; | |
| 58 | } | |
| 59 | if (text[i] === "\n") break; | |
| 60 | out += text[i++]; | |
| 61 | } | |
| 62 | i++; | |
| 63 | return out; | |
| 64 | }; | |
| 65 | ||
| 66 | const template = (): string => { | |
| 67 | let out = ""; | |
| 68 | i++; | |
| 69 | while (i < n && text[i] !== "`") { | |
| 70 | if (text[i] === "\\") { | |
| 71 | out += unescape(text[i + 1] ?? ""); | |
| 72 | i += 2; | |
| 73 | } else if (text[i] === "$" && text[i + 1] === "{") { | |
| 74 | i += 2; | |
| 75 | skipCode("}"); | |
| 76 | i++; | |
| 77 | out += HOLE; | |
| 78 | } else out += text[i++]; | |
| 79 | } | |
| 80 | i++; | |
| 81 | return out; | |
| 82 | }; | |
| 83 | ||
| 84 | // Walks code until `close` at depth zero; literals inside a `${…}` are still found. | |
| 85 | const skipCode = (close: string | null): void => { | |
| 86 | let depth = 0; | |
| 87 | while (i < n) { | |
| 88 | const c = text[i]!; | |
| 89 | if (close !== null && c === close && depth === 0) return; | |
| 90 | if (c === "{") depth++; | |
| 91 | else if (c === "}") depth--; | |
| 92 | if (c === "/" && text[i + 1] === "/") { | |
| 93 | while (i < n && text[i] !== "\n") i++; | |
| 94 | continue; | |
| 95 | } | |
| 96 | if (c === "/" && text[i + 1] === "*") { | |
| 97 | const end = text.indexOf("*/", i + 2); | |
| 98 | i = end < 0 ? n : end + 2; | |
| 99 | continue; | |
| 100 | } | |
| 101 | if (c === '"' || c === "'" || c === "`") { | |
| 102 | const start = i; | |
| 103 | const value = c === "`" ? template() : quoted(c); | |
| 104 | found.push({ start, end: i, value }); | |
| 105 | last = c; | |
| 106 | continue; | |
| 107 | } | |
| 108 | if (c === "/" && (last === "" || "(,=:[!&|?{};+-*%<>~^".includes(last) || /\b(?:return|typeof|case)$/.test(text.slice(Math.max(0, i - 7), i).trimEnd()))) { | |
| 109 | i++; | |
| 110 | let inClass = false; | |
| 111 | while (i < n && text[i] !== "\n") { | |
| 112 | if (text[i] === "\\") i++; | |
| 113 | else if (text[i] === "[") inClass = true; | |
| 114 | else if (text[i] === "]") inClass = false; | |
| 115 | else if (text[i] === "/" && !inClass) break; | |
| 116 | i++; | |
| 117 | } | |
| 118 | i++; | |
| 119 | last = "/"; | |
| 120 | continue; | |
| 121 | } | |
| 122 | if (!/\s/.test(c)) last = c; | |
| 123 | i++; | |
| 124 | } | |
| 125 | }; | |
| 126 | ||
| 127 | skipCode(null); | |
| 128 | found.sort((a, b) => a.start - b.start); | |
| 129 | const joined: string[] = []; | |
| 130 | let prev: { end: number } | null = null; | |
| 131 | for (const lit of found) { | |
| 132 | if (prev !== null && joined.length > 0 && /^\s*\+\s*$/.test(text.slice(prev.end, lit.start))) | |
| 133 | joined[joined.length - 1] += lit.value; | |
| 134 | else joined.push(lit.value); | |
| 135 | prev = lit; | |
| 136 | } | |
| 137 | return joined; | |
| 138 | } | |
| 139 | ||
| 140 | function unescape(c: string): string { | |
| 141 | if (c === "n" || c === "t" || c === "r") return " "; | |
| 142 | if (c === "\n") return ""; | |
| 143 | return c; | |
| 144 | } | |
| 145 | ||
| 146 | /** The literals of `text` that read as one SQL statement, leading comments dropped. */ | |
| 147 | export function sqlLiterals(text: string): string[] { | |
| 148 | const out: string[] = []; | |
| 149 | for (const lit of literals(text)) { | |
| 150 | const sql = lit.replace(/^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/, "").trim(); | |
| 151 | if (SQL_START.test(sql) && /\s/.test(sql)) out.push(sql); | |
| 152 | } | |
| 153 | return out; | |
| 154 | } | |
| 155 | ||
| 156 | const scan = (root: string, glob: string) => [...new Bun.Glob(glob).scanSync({ cwd: root })].sort(); | |
| 157 | ||
| 158 | /** Every SQL literal under `root`/src outside the migrations, distinct. */ | |
| 159 | export function sourceStatements(root: string): string[] { | |
| 160 | const all = new Set<string>(); | |
| 161 | for (const f of scan(root, "src/**/*.ts")) { | |
| 162 | if (f.startsWith("src/core/migrations/") || f.endsWith(".test.ts")) continue; | |
| 163 | for (const sql of sqlLiterals(readFileSync(join(root, f), "utf8"))) all.add(sql); | |
| 164 | } | |
| 165 | return [...all]; | |
| 166 | } | |
| 167 | ||
| 168 | /** The statements inside each trigger, with `NEW.x` and `OLD.x` bound. */ | |
| 169 | export function triggerStatements(db: Database): string[] { | |
| 170 | const out: string[] = []; | |
| 171 | const rows = db | |
| 172 | .query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND sql IS NOT NULL") | |
| 173 | .all(); | |
| 174 | for (const { sql } of rows) { | |
| 175 | const body = /\bBEGIN\b([\s\S]*)\bEND\s*;?\s*$/i.exec(sql)?.[1]; | |
| 176 | if (body === undefined) continue; | |
| 177 | for (const part of body.split(";")) { | |
| 178 | const s = part.replace(/\b(?:NEW|OLD)\.["`]?\w+["`]?/gi, "?").trim(); | |
| 179 | if (SQL_START.test(s)) out.push(s); | |
| 180 | } | |
| 181 | } | |
| 182 | return out; | |
| 183 | } | |
| 184 | ||
| 185 | /** The forms a `${…}` is tried as, in order, until the statement prepares. */ | |
| 186 | const STAND_INS = ["?", "1", "", "id"]; | |
| 187 | ||
| 188 | /** The index names one statement's plan walks, or null when it prepares in no form. */ | |
| 189 | export function plannedIndexes(db: Database, sql: string): string[] | null { | |
| 190 | for (const stand of STAND_INS) { | |
| 191 | const text = sql.split(HOLE).join(stand); | |
| 192 | let plan: { detail: string }[]; | |
| 193 | try { | |
| 194 | plan = db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${text}`).all(); | |
| 195 | } catch { | |
| 196 | continue; | |
| 197 | } | |
| 198 | const names = new Set<string>(); | |
| 199 | for (const { detail } of plan) | |
| 200 | for (const m of detail.matchAll(/USING (?:COVERING )?INDEX (\S+)/g)) names.add(m[1]!); | |
| 201 | return [...names]; | |
| 202 | } | |
| 203 | return null; | |
| 204 | } | |
| 205 | ||
| 206 | export interface IndexRow { | |
| 207 | name: string; | |
| 208 | table: string; | |
| 209 | sql: string; | |
| 210 | /** Why it may never be dropped; empty when nothing keeps it but use. */ | |
| 211 | kept: string[]; | |
| 212 | /** The statements whose plan walks it. */ | |
| 213 | usedBy: string[]; | |
| 214 | } | |
| 215 | ||
| 216 | export interface IndexUse { | |
| 217 | indexes: IndexRow[]; | |
| 218 | /** Statements that prepared in no form (dynamic SQL); counted, not planned. */ | |
| 219 | unplanned: string[]; | |
| 220 | } | |
| 221 | ||
| 222 | /** Each explicit index of `db`, what uses it and what keeps it. */ | |
| 223 | export function indexUse(db: Database, statements: readonly string[], named: ReadonlySet<string>): IndexUse { | |
| 224 | const rows = db | |
| 225 | .query<{ name: string; tbl_name: string; sql: string }, []>( | |
| 226 | "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY tbl_name, name", | |
| 227 | ) | |
| 228 | .all(); | |
| 229 | const byName = new Map<string, IndexRow>(); | |
| 230 | for (const r of rows) | |
| 231 | byName.set(r.name, { name: r.name, table: r.tbl_name, sql: r.sql, kept: keptBecause(db, r, named), usedBy: [] }); | |
| 232 | const unplanned: string[] = []; | |
| 233 | for (const sql of statements) { | |
| 234 | const used = plannedIndexes(db, sql); | |
| 235 | if (used === null) { | |
| 236 | unplanned.push(sql); | |
| 237 | continue; | |
| 238 | } | |
| 239 | for (const name of used) byName.get(name)?.usedBy.push(sql); | |
| 240 | } | |
| 241 | return { indexes: [...byName.values()], unplanned }; | |
| 242 | } | |
| 243 | ||
| 244 | function keptBecause(db: Database, r: { name: string; tbl_name: string; sql: string }, named: ReadonlySet<string>): string[] { | |
| 245 | const why: string[] = []; | |
| 246 | if (/^\s*CREATE\s+UNIQUE\b/i.test(r.sql)) why.push("unique"); | |
| 247 | if (/\)\s*WHERE\b/i.test(r.sql)) why.push("partial"); | |
| 248 | const cols = db | |
| 249 | .query<{ name: string | null }, [string]>("SELECT name FROM pragma_index_info(?1) ORDER BY seqno") | |
| 250 | .all(r.name) | |
| 251 | .map((c) => c.name); | |
| 252 | const fks = new Map<number, string[]>(); | |
| 253 | for (const fk of db | |
| 254 | .query<{ id: number; from: string }, [string]>('SELECT id, "from" FROM pragma_foreign_key_list(?1)') | |
| 255 | .all(r.tbl_name)) | |
| 256 | fks.set(fk.id, [...(fks.get(fk.id) ?? []), fk.from]); | |
| 257 | for (const from of fks.values()) { | |
| 258 | const lead = new Set(cols.slice(0, from.length)); | |
| 259 | if (from.every((c) => lead.has(c))) { | |
| 260 | why.push("foreign key"); | |
| 261 | break; | |
| 262 | } | |
| 263 | } | |
| 264 | if (named.has(r.name)) why.push("named in code"); | |
| 265 | return why; | |
| 266 | } | |
| 267 | ||
| 268 | /** The index names `src/` and `test/` mention outside the migrations. */ | |
| 269 | export function namedIndexes(root: string, names: readonly string[]): Set<string> { | |
| 270 | const files = [...scan(root, "src/**/*.ts"), ...scan(root, "test/**/*.ts")].filter( | |
| 271 | (f) => !f.startsWith("src/core/migrations/"), | |
| 272 | ); | |
| 273 | const text = files.map((f) => readFileSync(join(root, f), "utf8")).join("\n"); | |
| 274 | const words = new Set(text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []); | |
| 275 | return new Set(names.filter((n) => words.has(n))); | |
| 276 | } | |
| 277 | ||
| 278 | /** The indexes nothing plans through and nothing keeps. */ | |
| 279 | export function unusedIndexes(use: IndexUse): IndexRow[] { | |
| 280 | return use.indexes.filter((r) => r.usedBy.length === 0 && r.kept.length === 0); | |
| 281 | } | |
| 282 | ||
| 283 | /** | |
| 284 | * A copy of `db` with `rows` rows in every table, values made from each | |
| 285 | * column's declared type, so an index holds what it would hold in use. | |
| 286 | * Checks, foreign keys and triggers are off in the copy: shape, not truth. | |
| 287 | */ | |
| 288 | export function seeded(db: Database, rows: number): Database { | |
| 289 | const { Database: Db } = require("bun:sqlite") as typeof import("bun:sqlite"); | |
| 290 | const copy = Db.deserialize(db.serialize()); | |
| 291 | copy.exec("PRAGMA foreign_keys = OFF; PRAGMA ignore_check_constraints = ON; PRAGMA journal_mode = MEMORY;"); | |
| 292 | for (const { name } of copy.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'trigger'").all()) | |
| 293 | copy.exec(`DROP TRIGGER "${name}"`); | |
| 294 | const tables = copy | |
| 295 | .query<{ name: string }, []>( | |
| 296 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND sql NOT LIKE 'CREATE VIRTUAL%'", | |
| 297 | ) | |
| 298 | .all(); | |
| 299 | copy.exec("BEGIN"); | |
| 300 | for (const { name } of tables) { | |
| 301 | const cols = copy | |
| 302 | .query<{ name: string; type: string; hidden: number }, [string]>( | |
| 303 | "SELECT name, type, hidden FROM pragma_table_xinfo(?1) WHERE hidden = 0", | |
| 304 | ) | |
| 305 | .all(name); | |
| 306 | // A text is a JSON string, so a generated column that reads JSON reads it. | |
| 307 | try { | |
| 308 | const insert = copy.prepare( | |
| 309 | `INSERT OR IGNORE INTO "${name}" (${cols.map((c) => `"${c.name}"`).join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`, | |
| 310 | ); | |
| 311 | for (let i = 0; i < rows; i++) | |
| 312 | insert.run(...cols.map((c, k) => (/INT|REAL|NUM/i.test(c.type) ? 1_700_000_000_000 + i * 7919 + k : `"${c.name}-${i}-${(i * 2654435761) % 1_000_003}"`))); | |
| 313 | } catch { | |
| 314 | // A table this cannot fill (a full-text shadow, a generated column it cannot satisfy) | |
| 315 | // stays as full as it got; its indexes count what it holds. | |
| 316 | } | |
| 317 | } | |
| 318 | copy.exec("COMMIT"); | |
| 319 | return copy; | |
| 320 | } | |
| 321 | ||
| 322 | /** The bytes the file of `db` loses when `names` are dropped, after a VACUUM. */ | |
| 323 | export function bytesHeld(db: Database, names: readonly string[]): number { | |
| 324 | const { Database: Db } = require("bun:sqlite") as typeof import("bun:sqlite"); | |
| 325 | const size = (d: Database) => { | |
| 326 | d.exec("VACUUM"); | |
| 327 | const { n } = d.query<{ n: number }, []>("SELECT page_count * page_size AS n FROM pragma_page_count, pragma_page_size").get()!; | |
| 328 | return n; | |
| 329 | }; | |
| 330 | const without = Db.deserialize(db.serialize()); | |
| 331 | for (const name of names) without.exec(`DROP INDEX "${name}"`); | |
| 332 | const before = size(Db.deserialize(db.serialize())); | |
| 333 | return before - size(without); | |
| 334 | } | |
| 335 | ||
| 336 | /** Appends `sql` to the file `FORGE_SQL_LOG` names, once per process per text (the test run's collector). */ | |
| 337 | export function collectInto(file: string): (sql: string) => void { | |
| 338 | const seen = new Set<string>(); | |
| 339 | return (sql) => { | |
| 340 | if (seen.has(sql)) return; | |
| 341 | seen.add(sql); | |
| 342 | appendFileSync(file, `${JSON.stringify(sql)}\n`); | |
| 343 | }; | |
| 344 | } | |
| 345 | ||
| 346 | /** The statements a collector wrote, one JSON string per line. */ | |
| 347 | export function readLog(file: string): string[] { | |
| 348 | return readFileSync(file, "utf8") | |
| 349 | .split("\n") | |
| 350 | .filter((l) => l.trim() !== "") | |
| 351 | .map((l) => JSON.parse(l) as string) | |
| 352 | .filter((s) => SQL_START.test(s.trim())); | |
| 353 | } | |
| 354 | ||
| 355 | if (import.meta.main) { | |
| 356 | const { openDb } = await import("../src/kernel/db.ts"); | |
| 357 | const { migrations } = await import("../src/core/schema.ts"); | |
| 358 | const args = process.argv.slice(2); | |
| 359 | const root = join(import.meta.dir, ".."); | |
| 360 | const db = openDb(":memory:", migrations); | |
| 361 | const statements = new Set([...sourceStatements(root), ...triggerStatements(db)]); | |
| 362 | for (let k = 0; k < args.length; k++) if (args[k] === "--log") for (const s of readLog(args[++k]!)) statements.add(s); | |
| 363 | const names = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL").all().map((r) => r.name); | |
| 364 | const use = indexUse(db, [...statements], namedIndexes(root, names)); | |
| 365 | const unused = unusedIndexes(use); | |
| 366 | const one = (s: string) => s.replace(/\s+/g, " ").replaceAll(HOLE, "${…}").slice(0, 160); | |
| 367 | // CLI answer for the person at the terminal. | |
| 368 | if (args.includes("--unused") || args.includes("--bytes")) { | |
| 369 | const seed = args.includes("--bytes") ? seeded(db, 1000) : null; | |
| 370 | let total = 0; | |
| 371 | for (const r of unused) { | |
| 372 | const bytes = seed === null ? null : bytesHeld(seed, [r.name]); | |
| 373 | total += bytes ?? 0; | |
| 374 | console.log(bytes === null ? `${r.name}\t${r.table}` : `${r.name}\t${r.table}\t${bytes}`); | |
| 375 | } | |
| 376 | if (seed !== null) console.log(`total\t${unused.length} indexes\t${bytesHeld(seed, unused.map((r) => r.name))} bytes (sum of each alone ${total}) at 1000 rows a table`); | |
| 377 | } else { | |
| 378 | for (const r of use.indexes) { | |
| 379 | console.log(`${r.name} on ${r.table}${r.kept.length > 0 ? ` (kept: ${r.kept.join(", ")})` : ""}: ${r.usedBy.length} statements`); | |
| 380 | for (const s of r.usedBy) console.log(` ${one(s)}`); | |
| 381 | } | |
| 382 | } | |
| 383 | console.error( | |
| 384 | `index-use: ${statements.size} statements, ${use.unplanned.length} would not prepare, ${use.indexes.length} indexes, ${unused.length} unused`, | |
| 385 | ); | |
| 386 | } |
src/core/migration-pins/20261113115530-drop-unused-indexes.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 63971e93a95da2f67d77bca205bc754e49ccd8031efc1293403e79c2fcebb1e6 |
src/core/migrations/20261113115530-drop-unused-indexes.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | // Seven indexes no statement the forge prepares plans through (#12011, part of #11973: | |
| 2 | // indexes are most of forge.db). `bun scripts/index-use.ts --unused` finds them: every SQL | |
| 3 | // text under src/ and in the triggers, EXPLAIN QUERY PLAN on a fresh schema; the forge never | |
| 4 | // runs ANALYZE, so production plans the same way. None is UNIQUE, partial, a foreign key's | |
| 5 | // lookup or named by code. Each table's reads already walk another index or the key: | |
| 6 | // session by session_user, storage by storage_at, webhook_delivery by delivery_state_id, | |
| 7 | // note_shipped by its key, the rest read whole or by id. Writes stop paying for them. | |
| 8 | // Additive: an old process plans the same statements without them. | |
| 9 | export default ` | |
| 10 | DROP INDEX IF EXISTS flow_valve_turn_at; | |
| 11 | DROP INDEX IF EXISTS gift_redeem_code; | |
| 12 | DROP INDEX IF EXISTS note_shipped_pull; | |
| 13 | DROP INDEX IF EXISTS session_seen; | |
| 14 | DROP INDEX IF EXISTS storage_repo; | |
| 15 | DROP INDEX IF EXISTS telemetry_read_at; | |
| 16 | DROP INDEX IF EXISTS delivery_due; | |
| 17 | `; |
test/helpers/preload.ts +16
| @@ -23,3 +23,19 @@ process.env.FORGE_DEV = "1"; | ||
| 23 | 23 | |
| 24 | 24 | // A suite has no public network to prove a child through (`spawn/reach.ts`); a test of reach injects its own fetch. |
| 25 | 25 | useReachByDefault({ off: true }); |
| 26 | ||
| 27 | // `FORGE_SQL_LOG=<file>`: each distinct statement the run prepares, for | |
| 28 | // `scripts/index-use.ts --log` (#12011). Off unless asked for. | |
| 29 | if (process.env.FORGE_SQL_LOG) { | |
| 30 | const { Database } = await import("bun:sqlite"); | |
| 31 | const { collectInto } = await import("../../scripts/index-use.ts"); | |
| 32 | const note = collectInto(process.env.FORGE_SQL_LOG); | |
| 33 | const proto = Database.prototype as unknown as Record<"query" | "prepare", (sql: string, ...rest: unknown[]) => unknown>; | |
| 34 | for (const method of ["query", "prepare"] as const) { | |
| 35 | const original = proto[method]; | |
| 36 | proto[method] = function (this: unknown, sql: string, ...rest: unknown[]) { | |
| 37 | if (typeof sql === "string") note(sql); | |
| 38 | return original.call(this, sql, ...rest); | |
| 39 | }; | |
| 40 | } | |
| 41 | } |
test/index-use.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * `scripts/index-use.ts` (#12011): which index each prepared statement walks, | |
| 3 | * and which indexes nothing uses and nothing keeps. The migration that drops | |
| 4 | * the seven it found leaves a fresh database without them. | |
| 5 | */ | |
| 6 | ||
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { migrations } from "../src/core/schema.ts"; | |
| 13 | import { openDb } from "../src/kernel/db.ts"; | |
| 14 | import { | |
| 15 | bytesHeld, | |
| 16 | collectInto, | |
| 17 | indexUse, | |
| 18 | plannedIndexes, | |
| 19 | readLog, | |
| 20 | seeded, | |
| 21 | sqlLiterals, | |
| 22 | triggerStatements, | |
| 23 | unusedIndexes, | |
| 24 | } from "../scripts/index-use.ts"; | |
| 25 | ||
| 26 | describe("SQL in source", () => { | |
| 27 | test("string, template and joined literals that read as SQL, with holes for `${…}`", () => { | |
| 28 | const text = [ | |
| 29 | 'const a = db.query("SELECT id FROM run WHERE state = ?1");', | |
| 30 | "const b = db.query(`", | |
| 31 | " -- the queued ones", | |
| 32 | " SELECT id FROM run WHERE id IN (${marks.map(() => `?`).join(',')})`);", | |
| 33 | 'const c = "SELECT x " +', | |
| 34 | ' "FROM t";', | |
| 35 | "const re = /it's \"not\" a string/;", | |
| 36 | 'const d = "not sql at all";', | |
| 37 | 'const e = "SELECTED";', | |
| 38 | ].join("\n"); | |
| 39 | expect(sqlLiterals(text)).toEqual([ | |
| 40 | "SELECT id FROM run WHERE state = ?1", | |
| 41 | "SELECT id FROM run WHERE id IN (\u0000)", | |
| 42 | "SELECT x FROM t", | |
| 43 | ]); | |
| 44 | }); | |
| 45 | }); | |
| 46 | ||
| 47 | function small(): Database { | |
| 48 | const db = new Database(":memory:"); | |
| 49 | db.exec(` | |
| 50 | CREATE TABLE parent (id INTEGER PRIMARY KEY); | |
| 51 | CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT, state TEXT, parent_id INTEGER REFERENCES parent(id)); | |
| 52 | CREATE INDEX t_a ON t(a); | |
| 53 | CREATE INDEX t_b ON t(b); | |
| 54 | CREATE UNIQUE INDEX t_ab ON t(a, b); | |
| 55 | CREATE INDEX t_live ON t(a) WHERE state = 'live'; | |
| 56 | CREATE INDEX t_parent ON t(parent_id, a); | |
| 57 | CREATE INDEX t_named ON t(state, b); | |
| 58 | CREATE TABLE log (id INTEGER PRIMARY KEY, b TEXT); | |
| 59 | CREATE TRIGGER t_log AFTER INSERT ON t BEGIN INSERT INTO log (b) SELECT b FROM t WHERE b = NEW.b; END; | |
| 60 | `); | |
| 61 | return db; | |
| 62 | } | |
| 63 | ||
| 64 | describe("index use", () => { | |
| 65 | test("each index lists the statements whose plan walks it", () => { | |
| 66 | const db = small(); | |
| 67 | expect(plannedIndexes(db, "SELECT id FROM t WHERE b = ?1")).toEqual(["t_b"]); | |
| 68 | expect(plannedIndexes(db, "SELECT id FROM t WHERE id = ?1")).toEqual([]); | |
| 69 | expect(plannedIndexes(db, "SELECT id FROM \u0000 WHERE b = ?1")).toBeNull(); | |
| 70 | const use = indexUse(db, ["SELECT id FROM t WHERE b = ?1", "SELECT nonsense FROM nowhere"], new Set()); | |
| 71 | expect(use.indexes.find((r) => r.name === "t_b")!.usedBy).toEqual(["SELECT id FROM t WHERE b = ?1"]); | |
| 72 | expect(use.unplanned).toEqual(["SELECT nonsense FROM nowhere"]); | |
| 73 | }); | |
| 74 | ||
| 75 | test("unique, partial, a foreign key's lookup and a name code says are kept; the rest unused", () => { | |
| 76 | const db = small(); | |
| 77 | const use = indexUse(db, [], new Set(["t_named"])); | |
| 78 | const kept = Object.fromEntries(use.indexes.map((r) => [r.name, r.kept])); | |
| 79 | expect(kept).toEqual({ | |
| 80 | t_a: [], | |
| 81 | t_ab: ["unique"], | |
| 82 | t_b: [], | |
| 83 | t_live: ["partial"], | |
| 84 | t_named: ["named in code"], | |
| 85 | t_parent: ["foreign key"], | |
| 86 | }); | |
| 87 | expect(unusedIndexes(use).map((r) => r.name)).toEqual(["t_a", "t_b"]); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a trigger's statements are planned, NEW and OLD bound", () => { | |
| 91 | const db = small(); | |
| 92 | const inside = triggerStatements(db); | |
| 93 | expect(inside).toEqual(["INSERT INTO log (b) SELECT b FROM t WHERE b = ?"]); | |
| 94 | const use = indexUse(db, inside, new Set()); | |
| 95 | expect(unusedIndexes(use).map((r) => r.name)).toEqual(["t_a", "t_named"]); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a seeded copy counts the bytes an index holds", () => { | |
| 99 | const db = small(); | |
| 100 | const copy = seeded(db, 2000); | |
| 101 | expect(copy.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM t").get()!.n).toBe(2000); | |
| 102 | expect(bytesHeld(copy, ["t_b"])).toBeGreaterThan(4096); | |
| 103 | expect(db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM t").get()!.n).toBe(0); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a test run's collector writes each statement once and the script reads them back", () => { | |
| 107 | const dir = mkdtempSync(join(tmpdir(), "index-use-")); | |
| 108 | try { | |
| 109 | const file = join(dir, "sql.log"); | |
| 110 | const note = collectInto(file); | |
| 111 | note("SELECT 1 FROM t"); | |
| 112 | note("SELECT 1 FROM t"); | |
| 113 | note("PRAGMA foreign_keys = ON"); | |
| 114 | note("UPDATE t SET a = 'x\ny'"); | |
| 115 | expect(readLog(file)).toEqual(["SELECT 1 FROM t", "UPDATE t SET a = 'x\ny'"]); | |
| 116 | } finally { | |
| 117 | rmSync(dir, { recursive: true, force: true }); | |
| 118 | } | |
| 119 | }); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a fresh forge database has none of the seven indexes nothing used", () => { | |
| 123 | const db = openDb(":memory:", migrations); | |
| 124 | const gone = ["flow_valve_turn_at", "gift_redeem_code", "note_shipped_pull", "session_seen", "storage_repo", "telemetry_read_at", "delivery_due"]; | |
| 125 | const left = db | |
| 126 | .query<{ name: string }, []>(`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (${gone.map((n) => `'${n}'`).join(", ")})`) | |
| 127 | .all(); | |
| 128 | expect(left).toEqual([]); | |
| 129 | db.close(); | |
| 130 | }); |
new above
offline