@mosaic-code/pg-index-check 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +39 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -108,6 +108,31 @@ async function leadingIndexColumns(client, indexKeys) {
|
|
|
108
108
|
);
|
|
109
109
|
return new Map(result.rows.map((row) => [row.index_key, row.column_name]));
|
|
110
110
|
}
|
|
111
|
+
async function tableLeadingColumns(client, tables) {
|
|
112
|
+
if (tables.length === 0) {
|
|
113
|
+
return /* @__PURE__ */ new Map();
|
|
114
|
+
}
|
|
115
|
+
const result = await client.query(
|
|
116
|
+
`
|
|
117
|
+
SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name
|
|
118
|
+
FROM pg_catalog.pg_index i
|
|
119
|
+
JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
|
|
120
|
+
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
|
|
121
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
|
|
122
|
+
JOIN pg_catalog.pg_am am ON am.oid = c.relam
|
|
123
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
124
|
+
WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)
|
|
125
|
+
`,
|
|
126
|
+
[tables]
|
|
127
|
+
);
|
|
128
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
129
|
+
for (const row of result.rows) {
|
|
130
|
+
const columns = byTable.get(row.table_key) ?? /* @__PURE__ */ new Set();
|
|
131
|
+
columns.add(row.column_name);
|
|
132
|
+
byTable.set(row.table_key, columns);
|
|
133
|
+
}
|
|
134
|
+
return byTable;
|
|
135
|
+
}
|
|
111
136
|
|
|
112
137
|
// src/ignore.ts
|
|
113
138
|
function applyIgnoreRules(findings, rules) {
|
|
@@ -170,10 +195,13 @@ function indexesWithConditions(plan) {
|
|
|
170
195
|
}
|
|
171
196
|
function evaluatePlan(plan, context) {
|
|
172
197
|
return flattenPlan(plan).flatMap((node) => {
|
|
173
|
-
const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);
|
|
198
|
+
const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);
|
|
174
199
|
return finding ? [finding] : [];
|
|
175
200
|
});
|
|
176
201
|
}
|
|
202
|
+
function planTables(plan) {
|
|
203
|
+
return [...new Set(flattenPlan(plan).map(tableName).filter((name) => name !== void 0))];
|
|
204
|
+
}
|
|
177
205
|
function checkSeqScanFilter(node) {
|
|
178
206
|
const filter = node.Filter;
|
|
179
207
|
if (node["Node Type"] !== "Seq Scan" || !filter || isNegationOnly(filter)) {
|
|
@@ -186,17 +214,22 @@ function checkSeqScanFilter(node) {
|
|
|
186
214
|
hint: indexHint(node, filter)
|
|
187
215
|
};
|
|
188
216
|
}
|
|
189
|
-
function checkFullIndexScan(node) {
|
|
217
|
+
function checkFullIndexScan(node, context) {
|
|
190
218
|
const filter = node.Filter;
|
|
191
219
|
if (!INDEX_SCAN_TYPES.has(node["Node Type"]) || !filter || node["Index Cond"]) {
|
|
192
220
|
return void 0;
|
|
193
221
|
}
|
|
194
|
-
|
|
222
|
+
const column = equalityColumn(filter);
|
|
223
|
+
if (column === void 0) {
|
|
224
|
+
return void 0;
|
|
225
|
+
}
|
|
226
|
+
const table = tableName(node);
|
|
227
|
+
if (table !== void 0 && context.leadingColumnsByTable.get(table)?.has(column)) {
|
|
195
228
|
return void 0;
|
|
196
229
|
}
|
|
197
230
|
return {
|
|
198
231
|
kind: FindingKind.FULL_INDEX_SCAN,
|
|
199
|
-
table
|
|
232
|
+
table,
|
|
200
233
|
condition: filter,
|
|
201
234
|
indexName: node["Index Name"],
|
|
202
235
|
hint: indexHint(node, filter)
|
|
@@ -340,7 +373,8 @@ async function checkStatement(client, statement, majorVersion) {
|
|
|
340
373
|
async function evaluate(client, plan, majorVersion) {
|
|
341
374
|
const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;
|
|
342
375
|
const leadingColumns = checkTrailingIndexColumns ? await leadingIndexColumns(client, indexesWithConditions(plan)) : /* @__PURE__ */ new Map();
|
|
343
|
-
|
|
376
|
+
const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));
|
|
377
|
+
return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });
|
|
344
378
|
}
|
|
345
379
|
async function withClient(connectionString, work) {
|
|
346
380
|
const client = new pg__default.default.Client({ connectionString });
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":["pg"],"mappings":"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","file":"index.cjs","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n if (equalityColumn(filter) === undefined) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table: tableName(node),\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n\n return evaluatePlan(plan, { leadingColumns, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":["pg"],"mappings":"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","file":"index.cjs","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n\n/**\n * Columns that lead some valid btree index, per table, keyed by `schema.table`.\n * A query filtering such a column by equality has a usable index even if the\n * generic planner chose a different one, so it is not a missing-index finding.\n */\nexport async function tableLeadingColumns(client: Client, tables: string[]): Promise<Map<string, Set<string>>> {\n if (tables.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ table_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class t ON t.oid = i.indrelid\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)\n `,\n [tables]\n );\n\n const byTable = new Map<string, Set<string>>();\n for (const row of result.rows) {\n const columns = byTable.get(row.table_key) ?? new Set<string>();\n columns.add(row.column_name);\n byTable.set(row.table_key, columns);\n }\n return byTable;\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /**\n * Columns that lead some btree index, per table (`schema.table`). Used to\n * suppress a FULL_INDEX_SCAN when a usable index for the filtered column\n * exists — the generic planner just picked a different one.\n */\n leadingColumnsByTable: Map<string, Set<string>>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * Tables (`schema.table`) a plan reads, for looking up their index leading columns\n */\nexport function planTables(plan: PlanNode): string[] {\n return [...new Set(flattenPlan(plan).map(tableName).filter((name): name is string => name !== undefined))];\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n const column = equalityColumn(filter);\n if (column === undefined) {\n return undefined;\n }\n\n // A usable index for this equality filter already exists (its leading column\n // is the filtered one); the generic planner just chose an ordering index\n // instead. With real selectivity production uses the usable one, so this is\n // not a missing index. The guard only guarantees at least one usable index.\n const table = tableName(node);\n if (table !== undefined && context.leadingColumnsByTable.get(table)?.has(column)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table,\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n tableLeadingColumns,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions, planTables } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));\n\n return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
|
package/dist/index.js
CHANGED
|
@@ -102,6 +102,31 @@ async function leadingIndexColumns(client, indexKeys) {
|
|
|
102
102
|
);
|
|
103
103
|
return new Map(result.rows.map((row) => [row.index_key, row.column_name]));
|
|
104
104
|
}
|
|
105
|
+
async function tableLeadingColumns(client, tables) {
|
|
106
|
+
if (tables.length === 0) {
|
|
107
|
+
return /* @__PURE__ */ new Map();
|
|
108
|
+
}
|
|
109
|
+
const result = await client.query(
|
|
110
|
+
`
|
|
111
|
+
SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name
|
|
112
|
+
FROM pg_catalog.pg_index i
|
|
113
|
+
JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
|
|
114
|
+
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
|
|
115
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
|
|
116
|
+
JOIN pg_catalog.pg_am am ON am.oid = c.relam
|
|
117
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
118
|
+
WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)
|
|
119
|
+
`,
|
|
120
|
+
[tables]
|
|
121
|
+
);
|
|
122
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
123
|
+
for (const row of result.rows) {
|
|
124
|
+
const columns = byTable.get(row.table_key) ?? /* @__PURE__ */ new Set();
|
|
125
|
+
columns.add(row.column_name);
|
|
126
|
+
byTable.set(row.table_key, columns);
|
|
127
|
+
}
|
|
128
|
+
return byTable;
|
|
129
|
+
}
|
|
105
130
|
|
|
106
131
|
// src/ignore.ts
|
|
107
132
|
function applyIgnoreRules(findings, rules) {
|
|
@@ -164,10 +189,13 @@ function indexesWithConditions(plan) {
|
|
|
164
189
|
}
|
|
165
190
|
function evaluatePlan(plan, context) {
|
|
166
191
|
return flattenPlan(plan).flatMap((node) => {
|
|
167
|
-
const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);
|
|
192
|
+
const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);
|
|
168
193
|
return finding ? [finding] : [];
|
|
169
194
|
});
|
|
170
195
|
}
|
|
196
|
+
function planTables(plan) {
|
|
197
|
+
return [...new Set(flattenPlan(plan).map(tableName).filter((name) => name !== void 0))];
|
|
198
|
+
}
|
|
171
199
|
function checkSeqScanFilter(node) {
|
|
172
200
|
const filter = node.Filter;
|
|
173
201
|
if (node["Node Type"] !== "Seq Scan" || !filter || isNegationOnly(filter)) {
|
|
@@ -180,17 +208,22 @@ function checkSeqScanFilter(node) {
|
|
|
180
208
|
hint: indexHint(node, filter)
|
|
181
209
|
};
|
|
182
210
|
}
|
|
183
|
-
function checkFullIndexScan(node) {
|
|
211
|
+
function checkFullIndexScan(node, context) {
|
|
184
212
|
const filter = node.Filter;
|
|
185
213
|
if (!INDEX_SCAN_TYPES.has(node["Node Type"]) || !filter || node["Index Cond"]) {
|
|
186
214
|
return void 0;
|
|
187
215
|
}
|
|
188
|
-
|
|
216
|
+
const column = equalityColumn(filter);
|
|
217
|
+
if (column === void 0) {
|
|
218
|
+
return void 0;
|
|
219
|
+
}
|
|
220
|
+
const table = tableName(node);
|
|
221
|
+
if (table !== void 0 && context.leadingColumnsByTable.get(table)?.has(column)) {
|
|
189
222
|
return void 0;
|
|
190
223
|
}
|
|
191
224
|
return {
|
|
192
225
|
kind: FindingKind.FULL_INDEX_SCAN,
|
|
193
|
-
table
|
|
226
|
+
table,
|
|
194
227
|
condition: filter,
|
|
195
228
|
indexName: node["Index Name"],
|
|
196
229
|
hint: indexHint(node, filter)
|
|
@@ -334,7 +367,8 @@ async function checkStatement(client, statement, majorVersion) {
|
|
|
334
367
|
async function evaluate(client, plan, majorVersion) {
|
|
335
368
|
const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;
|
|
336
369
|
const leadingColumns = checkTrailingIndexColumns ? await leadingIndexColumns(client, indexesWithConditions(plan)) : /* @__PURE__ */ new Map();
|
|
337
|
-
|
|
370
|
+
const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));
|
|
371
|
+
return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });
|
|
338
372
|
}
|
|
339
373
|
async function withClient(connectionString, work) {
|
|
340
374
|
const client = new pg.Client({ connectionString });
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n if (equalityColumn(filter) === undefined) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table: tableName(node),\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n\n return evaluatePlan(plan, { leadingColumns, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n\n/**\n * Columns that lead some valid btree index, per table, keyed by `schema.table`.\n * A query filtering such a column by equality has a usable index even if the\n * generic planner chose a different one, so it is not a missing-index finding.\n */\nexport async function tableLeadingColumns(client: Client, tables: string[]): Promise<Map<string, Set<string>>> {\n if (tables.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ table_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class t ON t.oid = i.indrelid\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)\n `,\n [tables]\n );\n\n const byTable = new Map<string, Set<string>>();\n for (const row of result.rows) {\n const columns = byTable.get(row.table_key) ?? new Set<string>();\n columns.add(row.column_name);\n byTable.set(row.table_key, columns);\n }\n return byTable;\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /**\n * Columns that lead some btree index, per table (`schema.table`). Used to\n * suppress a FULL_INDEX_SCAN when a usable index for the filtered column\n * exists — the generic planner just picked a different one.\n */\n leadingColumnsByTable: Map<string, Set<string>>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * Tables (`schema.table`) a plan reads, for looking up their index leading columns\n */\nexport function planTables(plan: PlanNode): string[] {\n return [...new Set(flattenPlan(plan).map(tableName).filter((name): name is string => name !== undefined))];\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n const column = equalityColumn(filter);\n if (column === undefined) {\n return undefined;\n }\n\n // A usable index for this equality filter already exists (its leading column\n // is the filtered one); the generic planner just chose an ordering index\n // instead. With real selectivity production uses the usable one, so this is\n // not a missing index. The guard only guarantees at least one usable index.\n const table = tableName(node);\n if (table !== undefined && context.leadingColumnsByTable.get(table)?.has(column)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table,\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n tableLeadingColumns,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions, planTables } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));\n\n return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
|