@mosaic-code/pg-index-check 0.0.0-stage → 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/LICENSE +207 -0
- package/README.md +176 -2
- package/dist/index.cjs +397 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +97 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +385 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a statement was flagged
|
|
3
|
+
*/
|
|
4
|
+
declare const FindingKind: {
|
|
5
|
+
/** A sequential scan applies a filter that no index can serve */
|
|
6
|
+
readonly SEQ_SCAN_FILTER: "SEQ_SCAN_FILTER";
|
|
7
|
+
/** A whole index is walked to apply a single-column equality filter */
|
|
8
|
+
readonly FULL_INDEX_SCAN: "FULL_INDEX_SCAN";
|
|
9
|
+
/** An index condition skips the index's leading column (PostgreSQL 16/17 only) */
|
|
10
|
+
readonly TRAILING_INDEX_COLUMN: "TRAILING_INDEX_COLUMN";
|
|
11
|
+
/** PostgreSQL could not produce a plan for the statement */
|
|
12
|
+
readonly UNPLANNABLE: "UNPLANNABLE";
|
|
13
|
+
};
|
|
14
|
+
type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];
|
|
15
|
+
/**
|
|
16
|
+
* What to do with statements PostgreSQL cannot EXPLAIN
|
|
17
|
+
*/
|
|
18
|
+
declare const UnplannableMode: {
|
|
19
|
+
/** Treat them as findings, failing assertIndexes() */
|
|
20
|
+
readonly FAIL: "fail";
|
|
21
|
+
/** List them in the report without failing */
|
|
22
|
+
readonly REPORT: "report";
|
|
23
|
+
};
|
|
24
|
+
type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];
|
|
25
|
+
interface Finding {
|
|
26
|
+
kind: FindingKind;
|
|
27
|
+
/** Statement text as recorded by pg_stat_statements */
|
|
28
|
+
statement: string;
|
|
29
|
+
/** `path:line` from a pg-source-comments comment in the statement, if present */
|
|
30
|
+
sourceLocation?: string;
|
|
31
|
+
/** Schema-qualified table, e.g. `public.orders` */
|
|
32
|
+
table?: string;
|
|
33
|
+
/** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */
|
|
34
|
+
condition?: string;
|
|
35
|
+
/** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */
|
|
36
|
+
indexName?: string;
|
|
37
|
+
/** Suggested fix, when it can be derived from the condition */
|
|
38
|
+
hint?: string;
|
|
39
|
+
/** PostgreSQL error message, for UNPLANNABLE */
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
interface IgnoreRule {
|
|
43
|
+
/** Schema-qualified table (`public.orders`); a string must match exactly */
|
|
44
|
+
table?: string | RegExp;
|
|
45
|
+
/** Statement text; a string matches if the statement contains it */
|
|
46
|
+
statement?: string | RegExp;
|
|
47
|
+
/** Why this is acceptable. Required so the allowlist stays auditable. */
|
|
48
|
+
reason: string;
|
|
49
|
+
}
|
|
50
|
+
interface CaptureOptions {
|
|
51
|
+
connectionString: string;
|
|
52
|
+
}
|
|
53
|
+
interface IndexCheckOptions extends CaptureOptions {
|
|
54
|
+
/** Findings to accept, each with a reason */
|
|
55
|
+
ignore?: IgnoreRule[];
|
|
56
|
+
/** @default 'fail' */
|
|
57
|
+
unplannable?: UnplannableMode;
|
|
58
|
+
}
|
|
59
|
+
interface IndexCheckResult {
|
|
60
|
+
/** Number of captured statements that were checked */
|
|
61
|
+
statementCount: number;
|
|
62
|
+
/** Findings that fail the check */
|
|
63
|
+
findings: Finding[];
|
|
64
|
+
/** Unplannable statements listed without failing (unplannable: 'report') */
|
|
65
|
+
reported: Finding[];
|
|
66
|
+
/** Ignore rules that matched no finding */
|
|
67
|
+
staleIgnoreRules: IgnoreRule[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Thrown by assertIndexes() when statements have no usable index
|
|
72
|
+
*/
|
|
73
|
+
declare class IndexCheckError extends Error {
|
|
74
|
+
readonly result: IndexCheckResult;
|
|
75
|
+
constructor(result: IndexCheckResult);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).
|
|
79
|
+
*/
|
|
80
|
+
declare function resetCapture(options: CaptureOptions): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Plans every statement recorded since resetCapture() with sequential scans
|
|
83
|
+
* disabled, and returns those no index can serve
|
|
84
|
+
*/
|
|
85
|
+
declare function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult>;
|
|
86
|
+
/**
|
|
87
|
+
* Like checkIndexes(), but throws an IndexCheckError listing the findings.
|
|
88
|
+
* Call after the test suite runs (e.g. in vitest globalSetup's teardown).
|
|
89
|
+
*/
|
|
90
|
+
declare function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Formats a check result for humans
|
|
94
|
+
*/
|
|
95
|
+
declare function formatReport(result: IndexCheckResult): string;
|
|
96
|
+
|
|
97
|
+
export { type CaptureOptions, type Finding, FindingKind, type IgnoreRule, IndexCheckError, type IndexCheckOptions, type IndexCheckResult, UnplannableMode, assertIndexes, checkIndexes, formatReport, resetCapture };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
|
|
3
|
+
// src/check.ts
|
|
4
|
+
|
|
5
|
+
// src/statement-text.ts
|
|
6
|
+
var CHECKED_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH", "UPDATE", "DELETE"]);
|
|
7
|
+
function firstKeyword(statement) {
|
|
8
|
+
let index = 0;
|
|
9
|
+
while (index < statement.length) {
|
|
10
|
+
if (/\s/.test(statement[index]) || statement[index] === "(") {
|
|
11
|
+
index += 1;
|
|
12
|
+
} else if (statement.startsWith("/*", index)) {
|
|
13
|
+
const commentEnd = statement.indexOf("*/", index + 2);
|
|
14
|
+
if (commentEnd === -1) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
index = commentEnd + 2;
|
|
18
|
+
} else if (statement.startsWith("--", index)) {
|
|
19
|
+
const lineEnd = statement.indexOf("\n", index + 2);
|
|
20
|
+
if (lineEnd === -1) {
|
|
21
|
+
return void 0;
|
|
22
|
+
}
|
|
23
|
+
index = lineEnd + 1;
|
|
24
|
+
} else {
|
|
25
|
+
const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));
|
|
26
|
+
return wordMatch?.[0].toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
function isCheckableStatement(statement) {
|
|
32
|
+
const keyword = firstKeyword(statement);
|
|
33
|
+
if (keyword === void 0 || !CHECKED_KEYWORDS.has(keyword)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return !/pg_stat_statements|pg_catalog\.|information_schema\./i.test(statement);
|
|
37
|
+
}
|
|
38
|
+
function parseSourceLocation(statement) {
|
|
39
|
+
const match = /\/\*\s*(\S+:\d+(?::\d+)?)(?:\s+in\s+\S+)?\s*\*\//.exec(statement);
|
|
40
|
+
return match?.[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/capture.ts
|
|
44
|
+
var SETUP_HELP = "Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) and call resetCapture() before the test suite runs.";
|
|
45
|
+
async function resetCapturedStatements(client) {
|
|
46
|
+
await client.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements");
|
|
47
|
+
await client.query("SELECT pg_stat_statements_reset()");
|
|
48
|
+
}
|
|
49
|
+
async function readCapturedStatements(client) {
|
|
50
|
+
const installed = await client.query(
|
|
51
|
+
"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'"
|
|
52
|
+
);
|
|
53
|
+
if (installed.rowCount === 0) {
|
|
54
|
+
throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);
|
|
55
|
+
}
|
|
56
|
+
const captured = await client.query(`
|
|
57
|
+
SELECT s.query
|
|
58
|
+
FROM pg_stat_statements s
|
|
59
|
+
JOIN pg_catalog.pg_database d ON d.oid = s.dbid
|
|
60
|
+
WHERE d.datname = current_database() AND s.toplevel
|
|
61
|
+
`);
|
|
62
|
+
const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);
|
|
63
|
+
if (statements.length === 0) {
|
|
64
|
+
throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);
|
|
65
|
+
}
|
|
66
|
+
return statements;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/explain.ts
|
|
70
|
+
async function preparePlanningSession(client) {
|
|
71
|
+
await client.query("SET enable_seqscan = off");
|
|
72
|
+
}
|
|
73
|
+
async function explainStatement(client, statement) {
|
|
74
|
+
try {
|
|
75
|
+
const result = await client.query(
|
|
76
|
+
`EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`
|
|
77
|
+
);
|
|
78
|
+
return { plan: result.rows[0]["QUERY PLAN"][0].Plan };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function serverMajorVersion(client) {
|
|
84
|
+
const result = await client.query("SHOW server_version_num");
|
|
85
|
+
return Math.floor(Number(result.rows[0].server_version_num) / 1e4);
|
|
86
|
+
}
|
|
87
|
+
async function leadingIndexColumns(client, indexKeys) {
|
|
88
|
+
if (indexKeys.length === 0) {
|
|
89
|
+
return /* @__PURE__ */ new Map();
|
|
90
|
+
}
|
|
91
|
+
const result = await client.query(
|
|
92
|
+
`
|
|
93
|
+
SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name
|
|
94
|
+
FROM pg_catalog.pg_index i
|
|
95
|
+
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
|
|
96
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
97
|
+
JOIN pg_catalog.pg_am am ON am.oid = c.relam
|
|
98
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
99
|
+
WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)
|
|
100
|
+
`,
|
|
101
|
+
[indexKeys]
|
|
102
|
+
);
|
|
103
|
+
return new Map(result.rows.map((row) => [row.index_key, row.column_name]));
|
|
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
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/ignore.ts
|
|
132
|
+
function applyIgnoreRules(findings, rules) {
|
|
133
|
+
for (const rule of rules) {
|
|
134
|
+
validateRule(rule);
|
|
135
|
+
}
|
|
136
|
+
const usedRules = /* @__PURE__ */ new Set();
|
|
137
|
+
const kept = findings.filter((finding) => {
|
|
138
|
+
const matchingRule = rules.find((rule) => ruleMatches(rule, finding));
|
|
139
|
+
if (matchingRule) {
|
|
140
|
+
usedRules.add(matchingRule);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };
|
|
146
|
+
}
|
|
147
|
+
function validateRule(rule) {
|
|
148
|
+
if (rule.table === void 0 && rule.statement === void 0) {
|
|
149
|
+
throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);
|
|
150
|
+
}
|
|
151
|
+
if (!rule.reason?.trim()) {
|
|
152
|
+
throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function ruleMatches(rule, finding) {
|
|
156
|
+
const tableMatches = rule.table === void 0 || finding.table !== void 0 && (typeof rule.table === "string" ? rule.table === finding.table : rule.table.test(finding.table));
|
|
157
|
+
const statementMatches = rule.statement === void 0 || (typeof rule.statement === "string" ? finding.statement.includes(rule.statement) : rule.statement.test(finding.statement));
|
|
158
|
+
return tableMatches && statementMatches;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/types.ts
|
|
162
|
+
var FindingKind = {
|
|
163
|
+
/** A sequential scan applies a filter that no index can serve */
|
|
164
|
+
SEQ_SCAN_FILTER: "SEQ_SCAN_FILTER",
|
|
165
|
+
/** A whole index is walked to apply a single-column equality filter */
|
|
166
|
+
FULL_INDEX_SCAN: "FULL_INDEX_SCAN",
|
|
167
|
+
/** An index condition skips the index's leading column (PostgreSQL 16/17 only) */
|
|
168
|
+
TRAILING_INDEX_COLUMN: "TRAILING_INDEX_COLUMN",
|
|
169
|
+
/** PostgreSQL could not produce a plan for the statement */
|
|
170
|
+
UNPLANNABLE: "UNPLANNABLE"
|
|
171
|
+
};
|
|
172
|
+
var UnplannableMode = {
|
|
173
|
+
/** Treat them as findings, failing assertIndexes() */
|
|
174
|
+
FAIL: "fail",
|
|
175
|
+
/** List them in the report without failing */
|
|
176
|
+
REPORT: "report"
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/plan-rules.ts
|
|
180
|
+
var INDEX_SCAN_TYPES = /* @__PURE__ */ new Set(["Index Scan", "Index Only Scan"]);
|
|
181
|
+
var INDEX_COND_SCAN_TYPES = /* @__PURE__ */ new Set(["Index Scan", "Index Only Scan", "Bitmap Index Scan"]);
|
|
182
|
+
function flattenPlan(plan, bitmapRelation) {
|
|
183
|
+
const node = bitmapRelation && plan["Relation Name"] === void 0 ? { ...plan, ...bitmapRelation } : plan;
|
|
184
|
+
const childRelation = plan["Node Type"] === "Bitmap Heap Scan" ? { Schema: plan.Schema, "Relation Name": plan["Relation Name"] } : bitmapRelation;
|
|
185
|
+
return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];
|
|
186
|
+
}
|
|
187
|
+
function indexesWithConditions(plan) {
|
|
188
|
+
return flattenPlan(plan).filter((node) => INDEX_COND_SCAN_TYPES.has(node["Node Type"]) && node["Index Cond"]).map((node) => indexKey(node));
|
|
189
|
+
}
|
|
190
|
+
function evaluatePlan(plan, context) {
|
|
191
|
+
return flattenPlan(plan).flatMap((node) => {
|
|
192
|
+
const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);
|
|
193
|
+
return finding ? [finding] : [];
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function planTables(plan) {
|
|
197
|
+
return [...new Set(flattenPlan(plan).map(tableName).filter((name) => name !== void 0))];
|
|
198
|
+
}
|
|
199
|
+
function checkSeqScanFilter(node) {
|
|
200
|
+
const filter = node.Filter;
|
|
201
|
+
if (node["Node Type"] !== "Seq Scan" || !filter || isNegationOnly(filter)) {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
kind: FindingKind.SEQ_SCAN_FILTER,
|
|
206
|
+
table: tableName(node),
|
|
207
|
+
condition: filter,
|
|
208
|
+
hint: indexHint(node, filter)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function checkFullIndexScan(node, context) {
|
|
212
|
+
const filter = node.Filter;
|
|
213
|
+
if (!INDEX_SCAN_TYPES.has(node["Node Type"]) || !filter || node["Index Cond"]) {
|
|
214
|
+
return void 0;
|
|
215
|
+
}
|
|
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)) {
|
|
222
|
+
return void 0;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
kind: FindingKind.FULL_INDEX_SCAN,
|
|
226
|
+
table,
|
|
227
|
+
condition: filter,
|
|
228
|
+
indexName: node["Index Name"],
|
|
229
|
+
hint: indexHint(node, filter)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function checkTrailingIndexColumn(node, context) {
|
|
233
|
+
const indexCondition = node["Index Cond"];
|
|
234
|
+
if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node["Node Type"]) || !indexCondition) {
|
|
235
|
+
return void 0;
|
|
236
|
+
}
|
|
237
|
+
const leadingColumn = context.leadingColumns.get(indexKey(node));
|
|
238
|
+
if (leadingColumn === void 0 || !indexCondition.includes(" = ")) {
|
|
239
|
+
return void 0;
|
|
240
|
+
}
|
|
241
|
+
if (new RegExp(`\\b"?${escapeRegExp(leadingColumn)}"?\\b`).test(indexCondition)) {
|
|
242
|
+
return void 0;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
kind: FindingKind.TRAILING_INDEX_COLUMN,
|
|
246
|
+
table: tableName(node),
|
|
247
|
+
condition: indexCondition,
|
|
248
|
+
indexName: node["Index Name"],
|
|
249
|
+
hint: `condition does not use the index's leading column "${leadingColumn}"`
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function isNegationOnly(filter) {
|
|
253
|
+
return filter.includes("<>") && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);
|
|
254
|
+
}
|
|
255
|
+
function equalityColumn(filter) {
|
|
256
|
+
if (/ AND | OR /.test(filter) || filter.split(" = ").length !== 2) {
|
|
257
|
+
return void 0;
|
|
258
|
+
}
|
|
259
|
+
const match = /^\((?:\w+\.)?"?(\w+)"? = /.exec(filter);
|
|
260
|
+
return match?.[1];
|
|
261
|
+
}
|
|
262
|
+
function indexHint(node, filter) {
|
|
263
|
+
const column = equalityColumn(filter);
|
|
264
|
+
return column === void 0 ? void 0 : `CREATE INDEX ON ${tableName(node)} (${column})`;
|
|
265
|
+
}
|
|
266
|
+
function tableName(node) {
|
|
267
|
+
const relation = node["Relation Name"];
|
|
268
|
+
if (relation === void 0) {
|
|
269
|
+
return void 0;
|
|
270
|
+
}
|
|
271
|
+
return node.Schema ? `${node.Schema}.${relation}` : relation;
|
|
272
|
+
}
|
|
273
|
+
function indexKey(node) {
|
|
274
|
+
return `${node.Schema}.${node["Index Name"]}`;
|
|
275
|
+
}
|
|
276
|
+
function escapeRegExp(text) {
|
|
277
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/report.ts
|
|
281
|
+
function formatReport(result) {
|
|
282
|
+
const sections = [
|
|
283
|
+
`pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`
|
|
284
|
+
];
|
|
285
|
+
if (result.findings.length > 0) {
|
|
286
|
+
sections.push(result.findings.map(formatFinding).join("\n\n"));
|
|
287
|
+
}
|
|
288
|
+
if (result.reported.length > 0) {
|
|
289
|
+
sections.push(`Unplannable statements (not failing):
|
|
290
|
+
|
|
291
|
+
${result.reported.map(formatFinding).join("\n\n")}`);
|
|
292
|
+
}
|
|
293
|
+
if (result.staleIgnoreRules.length > 0) {
|
|
294
|
+
const rules = result.staleIgnoreRules.map(
|
|
295
|
+
(rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`
|
|
296
|
+
);
|
|
297
|
+
sections.push(`Ignore rules that matched nothing:
|
|
298
|
+
${rules.join("\n")}`);
|
|
299
|
+
}
|
|
300
|
+
return sections.join("\n\n");
|
|
301
|
+
}
|
|
302
|
+
function formatFinding(finding) {
|
|
303
|
+
const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`].filter(Boolean).join(" ");
|
|
304
|
+
const details = [
|
|
305
|
+
finding.condition && `condition: ${finding.condition}`,
|
|
306
|
+
finding.indexName && `index: ${finding.indexName}`,
|
|
307
|
+
finding.error && `error: ${finding.error}`,
|
|
308
|
+
finding.hint && `fix: ${finding.hint}`,
|
|
309
|
+
`statement: ${finding.statement}`
|
|
310
|
+
].filter(Boolean);
|
|
311
|
+
return [heading, ...details.map((line) => ` ${line}`)].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/check.ts
|
|
315
|
+
var SKIP_SCAN_MAJOR_VERSION = 18;
|
|
316
|
+
var MINIMUM_MAJOR_VERSION = 16;
|
|
317
|
+
var IndexCheckError = class extends Error {
|
|
318
|
+
result;
|
|
319
|
+
constructor(result) {
|
|
320
|
+
super(formatReport(result));
|
|
321
|
+
this.name = "IndexCheckError";
|
|
322
|
+
this.result = result;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
async function resetCapture(options) {
|
|
326
|
+
await withClient(options.connectionString, resetCapturedStatements);
|
|
327
|
+
}
|
|
328
|
+
async function checkIndexes(options) {
|
|
329
|
+
const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;
|
|
330
|
+
return withClient(options.connectionString, async (client) => {
|
|
331
|
+
const statements = await readCapturedStatements(client);
|
|
332
|
+
const majorVersion = await serverMajorVersion(client);
|
|
333
|
+
if (majorVersion < MINIMUM_MAJOR_VERSION) {
|
|
334
|
+
throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);
|
|
335
|
+
}
|
|
336
|
+
await preparePlanningSession(client);
|
|
337
|
+
const allFindings = [];
|
|
338
|
+
for (const statement of statements) {
|
|
339
|
+
allFindings.push(...await checkStatement(client, statement, majorVersion));
|
|
340
|
+
}
|
|
341
|
+
const isReportedOnly = (finding) => finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;
|
|
342
|
+
const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);
|
|
343
|
+
return {
|
|
344
|
+
statementCount: statements.length,
|
|
345
|
+
findings: kept.filter((finding) => !isReportedOnly(finding)),
|
|
346
|
+
reported: kept.filter(isReportedOnly),
|
|
347
|
+
staleIgnoreRules
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async function assertIndexes(options) {
|
|
352
|
+
const result = await checkIndexes(options);
|
|
353
|
+
if (result.findings.length > 0) {
|
|
354
|
+
throw new IndexCheckError(result);
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
async function checkStatement(client, statement, majorVersion) {
|
|
359
|
+
const sourceLocation = parseSourceLocation(statement);
|
|
360
|
+
const outcome = await explainStatement(client, statement);
|
|
361
|
+
if ("error" in outcome) {
|
|
362
|
+
return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];
|
|
363
|
+
}
|
|
364
|
+
const findings = await evaluate(client, outcome.plan, majorVersion);
|
|
365
|
+
return findings.map((finding) => ({ ...finding, statement, sourceLocation }));
|
|
366
|
+
}
|
|
367
|
+
async function evaluate(client, plan, majorVersion) {
|
|
368
|
+
const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;
|
|
369
|
+
const leadingColumns = checkTrailingIndexColumns ? await leadingIndexColumns(client, indexesWithConditions(plan)) : /* @__PURE__ */ new Map();
|
|
370
|
+
const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));
|
|
371
|
+
return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });
|
|
372
|
+
}
|
|
373
|
+
async function withClient(connectionString, work) {
|
|
374
|
+
const client = new pg.Client({ connectionString });
|
|
375
|
+
await client.connect();
|
|
376
|
+
try {
|
|
377
|
+
return await work(client);
|
|
378
|
+
} finally {
|
|
379
|
+
await client.end();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export { FindingKind, IndexCheckError, UnplannableMode, assertIndexes, checkIndexes, formatReport, resetCapture };
|
|
384
|
+
//# sourceMappingURL=index.js.map
|
|
385
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":";;;;;AAAA,IAAM,gBAAA,uBAAuB,GAAA,CAAI,CAAC,UAAU,MAAA,EAAQ,QAAA,EAAU,QAAQ,CAAC,CAAA;AAMhE,SAAS,aAAa,SAAA,EAAuC;AAClE,EAAA,IAAI,KAAA,GAAQ,CAAA;AAEZ,EAAA,OAAO,KAAA,GAAQ,UAAU,MAAA,EAAQ;AAC/B,IAAA,IAAI,IAAA,CAAK,KAAK,SAAA,CAAU,KAAK,CAAC,CAAA,IAAK,SAAA,CAAU,KAAK,CAAA,KAAM,GAAA,EAAK;AAC3D,MAAA,KAAA,IAAS,CAAA;AAAA,IACX,CAAA,MAAA,IAAW,SAAA,CAAU,UAAA,CAAW,IAAA,EAAM,KAAK,CAAA,EAAG;AAC5C,MAAA,MAAM,UAAA,GAAa,SAAA,CAAU,OAAA,CAAQ,IAAA,EAAM,QAAQ,CAAC,CAAA;AACpD,MAAA,IAAI,eAAe,EAAA,EAAI;AACrB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,KAAA,GAAQ,UAAA,GAAa,CAAA;AAAA,IACvB,CAAA,MAAA,IAAW,SAAA,CAAU,UAAA,CAAW,IAAA,EAAM,KAAK,CAAA,EAAG;AAC5C,MAAA,MAAM,OAAA,GAAU,SAAA,CAAU,OAAA,CAAQ,IAAA,EAAM,QAAQ,CAAC,CAAA;AACjD,MAAA,IAAI,YAAY,EAAA,EAAI;AAClB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,KAAA,GAAQ,OAAA,GAAU,CAAA;AAAA,IACpB,CAAA,MAAO;AACL,MAAA,MAAM,YAAY,aAAA,CAAc,IAAA,CAAK,SAAA,CAAU,KAAA,CAAM,KAAK,CAAC,CAAA;AAC3D,MAAA,OAAO,SAAA,GAAY,CAAC,CAAA,CAAE,WAAA,EAAY;AAAA,IACpC;AAAA,EACF;AAEA,EAAA,OAAO,MAAA;AACT;AAMO,SAAS,qBAAqB,SAAA,EAA4B;AAC/D,EAAA,MAAM,OAAA,GAAU,aAAa,SAAS,CAAA;AACtC,EAAA,IAAI,YAAY,MAAA,IAAa,CAAC,gBAAA,CAAiB,GAAA,CAAI,OAAO,CAAA,EAAG;AAC3D,IAAA,OAAO,KAAA;AAAA,EACT;AAEA,EAAA,OAAO,CAAC,uDAAA,CAAwD,IAAA,CAAK,SAAS,CAAA;AAChF;AAMO,SAAS,oBAAoB,SAAA,EAAuC;AACzE,EAAA,MAAM,KAAA,GAAQ,kDAAA,CAAmD,IAAA,CAAK,SAAS,CAAA;AAC/E,EAAA,OAAO,QAAQ,CAAC,CAAA;AAClB;;;AClDA,IAAM,UAAA,GACJ,yKAAA;AAOF,eAAsB,wBAAwB,MAAA,EAA+B;AAC3E,EAAA,MAAM,MAAA,CAAO,MAAM,mDAAmD,CAAA;AACtE,EAAA,MAAM,MAAA,CAAO,MAAM,mCAAmC,CAAA;AACxD;AAKA,eAAsB,uBAAuB,MAAA,EAAmC;AAC9E,EAAA,MAAM,SAAA,GAAY,MAAM,MAAA,CAAO,KAAA;AAAA,IAC7B;AAAA,GACF;AACA,EAAA,IAAI,SAAA,CAAU,aAAa,CAAA,EAAG;AAC5B,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mEAAA,EAAsE,UAAU,CAAA,CAAE,CAAA;AAAA,EACpG;AAEA,EAAA,MAAM,QAAA,GAAW,MAAM,MAAA,CAAO,KAAA,CAAyB;AAAA;AAAA;AAAA;AAAA;AAAA,EAAA,CAKtD,CAAA;AAED,EAAA,MAAM,UAAA,GAAa,QAAA,CAAS,IAAA,CAAK,GAAA,CAAI,CAAC,QAAQ,GAAA,CAAI,KAAK,CAAA,CAAE,MAAA,CAAO,oBAAoB,CAAA;AACpF,EAAA,IAAI,UAAA,CAAW,WAAW,CAAA,EAAG;AAC3B,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,8DAAA,EAAiE,UAAU,CAAA,CAAE,CAAA;AAAA,EAC/F;AAEA,EAAA,OAAO,UAAA;AACT;;;AC/BA,eAAsB,uBAAuB,MAAA,EAA+B;AAC1E,EAAA,MAAM,MAAA,CAAO,MAAM,0BAA0B,CAAA;AAC/C;AAMA,eAAsB,gBAAA,CAAiB,QAAgB,SAAA,EAA4C;AACjG,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA;AAAA,MAC1B,gDAAgD,SAAS,CAAA;AAAA,KAC3D;AACA,IAAA,OAAO,EAAE,IAAA,EAAM,MAAA,CAAO,IAAA,CAAK,CAAC,EAAE,YAAY,CAAA,CAAE,CAAC,CAAA,CAAE,IAAA,EAAK;AAAA,EACtD,SAAS,KAAA,EAAO;AAEd,IAAA,OAAO,EAAE,OAAO,KAAA,YAAiB,KAAA,GAAQ,MAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAA,EAAE;AAAA,EACzE;AACF;AAKA,eAAsB,mBAAmB,MAAA,EAAiC;AACxE,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA,CAAsC,yBAAyB,CAAA;AAC3F,EAAA,OAAO,IAAA,CAAK,MAAM,MAAA,CAAO,MAAA,CAAO,KAAK,CAAC,CAAA,CAAE,kBAAkB,CAAA,GAAI,GAAK,CAAA;AACrE;AAMA,eAAsB,mBAAA,CAAoB,QAAgB,SAAA,EAAmD;AAC3G,EAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAC1B,IAAA,2BAAW,GAAA,EAAI;AAAA,EACjB;AAEA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA;AAAA,IAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAAA,CAAA;AAAA,IASA,CAAC,SAAS;AAAA,GACZ;AAEA,EAAA,OAAO,IAAI,GAAA,CAAI,MAAA,CAAO,IAAA,CAAK,GAAA,CAAI,CAAC,GAAA,KAAQ,CAAC,GAAA,CAAI,SAAA,EAAW,GAAA,CAAI,WAAW,CAAC,CAAC,CAAA;AAC3E;AAOA,eAAsB,mBAAA,CAAoB,QAAgB,MAAA,EAAqD;AAC7G,EAAA,IAAI,MAAA,CAAO,WAAW,CAAA,EAAG;AACvB,IAAA,2BAAW,GAAA,EAAI;AAAA,EACjB;AAEA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA;AAAA,IAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAAA,CAAA;AAAA,IAUA,CAAC,MAAM;AAAA,GACT;AAEA,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAyB;AAC7C,EAAA,KAAA,MAAW,GAAA,IAAO,OAAO,IAAA,EAAM;AAC7B,IAAA,MAAM,UAAU,OAAA,CAAQ,GAAA,CAAI,IAAI,SAAS,CAAA,wBAAS,GAAA,EAAY;AAC9D,IAAA,OAAA,CAAQ,GAAA,CAAI,IAAI,WAAW,CAAA;AAC3B,IAAA,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,SAAA,EAAW,OAAO,CAAA;AAAA,EACpC;AACA,EAAA,OAAO,OAAA;AACT;;;ACxFO,SAAS,gBAAA,CACd,UACA,KAAA,EACqD;AACrD,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,YAAA,CAAa,IAAI,CAAA;AAAA,EACnB;AAEA,EAAA,MAAM,SAAA,uBAAgB,GAAA,EAAgB;AACtC,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,MAAA,CAAO,CAAC,OAAA,KAAY;AACxC,IAAA,MAAM,YAAA,GAAe,MAAM,IAAA,CAAK,CAAC,SAAS,WAAA,CAAY,IAAA,EAAM,OAAO,CAAC,CAAA;AACpE,IAAA,IAAI,YAAA,EAAc;AAChB,MAAA,SAAA,CAAU,IAAI,YAAY,CAAA;AAC1B,MAAA,OAAO,KAAA;AAAA,IACT;AACA,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AAED,EAAA,OAAO,EAAE,IAAA,EAAM,gBAAA,EAAkB,KAAA,CAAM,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,SAAA,CAAU,GAAA,CAAI,IAAI,CAAC,CAAA,EAAE;AAChF;AAEA,SAAS,aAAa,IAAA,EAAwB;AAC5C,EAAA,IAAI,IAAA,CAAK,KAAA,KAAU,MAAA,IAAa,IAAA,CAAK,cAAc,MAAA,EAAW;AAC5D,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,wDAAA,EAA2D,KAAK,SAAA,CAAU,IAAI,CAAC,CAAA,CAAE,CAAA;AAAA,EACnG;AACA,EAAA,IAAI,CAAC,IAAA,CAAK,MAAA,EAAQ,IAAA,EAAK,EAAG;AACxB,IAAA,MAAM,IAAI,MAAM,CAAA,4CAAA,EAA+C,MAAA,CAAO,KAAK,KAAA,IAAS,IAAA,CAAK,SAAS,CAAC,CAAA,CAAE,CAAA;AAAA,EACvG;AACF;AAEA,SAAS,WAAA,CAAY,MAAkB,OAAA,EAA2B;AAChE,EAAA,MAAM,eACJ,IAAA,CAAK,KAAA,KAAU,UACd,OAAA,CAAQ,KAAA,KAAU,WAChB,OAAO,IAAA,CAAK,UAAU,QAAA,GAAW,IAAA,CAAK,UAAU,OAAA,CAAQ,KAAA,GAAQ,KAAK,KAAA,CAAM,IAAA,CAAK,QAAQ,KAAK,CAAA,CAAA;AAElG,EAAA,MAAM,mBACJ,IAAA,CAAK,SAAA,KAAc,WAClB,OAAO,IAAA,CAAK,cAAc,QAAA,GACvB,OAAA,CAAQ,SAAA,CAAU,QAAA,CAAS,KAAK,SAAS,CAAA,GACzC,KAAK,SAAA,CAAU,IAAA,CAAK,QAAQ,SAAS,CAAA,CAAA;AAE3C,EAAA,OAAO,YAAA,IAAgB,gBAAA;AACzB;;;AC7CO,IAAM,WAAA,GAAc;AAAA;AAAA,EAEzB,eAAA,EAAiB,iBAAA;AAAA;AAAA,EAEjB,eAAA,EAAiB,iBAAA;AAAA;AAAA,EAEjB,qBAAA,EAAuB,uBAAA;AAAA;AAAA,EAEvB,WAAA,EAAa;AACf;AAOO,IAAM,eAAA,GAAkB;AAAA;AAAA,EAE7B,IAAA,EAAM,MAAA;AAAA;AAAA,EAEN,MAAA,EAAQ;AACV;;;ACrBA,IAAM,mCAAmB,IAAI,GAAA,CAAI,CAAC,YAAA,EAAc,iBAAiB,CAAC,CAAA;AAClE,IAAM,wCAAwB,IAAI,GAAA,CAAI,CAAC,YAAA,EAAc,iBAAA,EAAmB,mBAAmB,CAAC,CAAA;AAwBrF,SAAS,WAAA,CAAY,MAAgB,cAAA,EAAyE;AACnH,EAAA,MAAM,IAAA,GAAO,cAAA,IAAkB,IAAA,CAAK,eAAe,CAAA,KAAM,MAAA,GAAY,EAAE,GAAG,IAAA,EAAM,GAAG,cAAA,EAAe,GAAI,IAAA;AACtG,EAAA,MAAM,aAAA,GACJ,IAAA,CAAK,WAAW,CAAA,KAAM,kBAAA,GAClB,EAAE,MAAA,EAAQ,IAAA,CAAK,MAAA,EAAQ,eAAA,EAAiB,IAAA,CAAK,eAAe,GAAE,GAC9D,cAAA;AAEN,EAAA,OAAO,CAAC,IAAA,EAAM,GAAA,CAAI,IAAA,CAAK,SAAS,EAAC,EAAG,OAAA,CAAQ,CAAC,KAAA,KAAU,WAAA,CAAY,KAAA,EAAO,aAAa,CAAC,CAAC,CAAA;AAC3F;AAMO,SAAS,sBAAsB,IAAA,EAA0B;AAC9D,EAAA,OAAO,WAAA,CAAY,IAAI,CAAA,CACpB,MAAA,CAAO,CAAC,IAAA,KAAS,qBAAA,CAAsB,IAAI,IAAA,CAAK,WAAW,CAAC,CAAA,IAAK,IAAA,CAAK,YAAY,CAAC,CAAA,CACnF,IAAI,CAAC,IAAA,KAAS,QAAA,CAAS,IAAI,CAAC,CAAA;AACjC;AAKO,SAAS,YAAA,CAAa,MAAgB,OAAA,EAAyC;AACpF,EAAA,OAAO,WAAA,CAAY,IAAI,CAAA,CAAE,OAAA,CAAQ,CAAC,IAAA,KAAS;AACzC,IAAA,MAAM,OAAA,GACJ,kBAAA,CAAmB,IAAI,CAAA,IAAK,kBAAA,CAAmB,MAAM,OAAO,CAAA,IAAK,wBAAA,CAAyB,IAAA,EAAM,OAAO,CAAA;AACzG,IAAA,OAAO,OAAA,GAAU,CAAC,OAAO,CAAA,GAAI,EAAC;AAAA,EAChC,CAAC,CAAA;AACH;AAKO,SAAS,WAAW,IAAA,EAA0B;AACnD,EAAA,OAAO,CAAC,GAAG,IAAI,GAAA,CAAI,WAAA,CAAY,IAAI,CAAA,CAAE,GAAA,CAAI,SAAS,CAAA,CAAE,OAAO,CAAC,IAAA,KAAyB,IAAA,KAAS,MAAS,CAAC,CAAC,CAAA;AAC3G;AAKA,SAAS,mBAAmB,IAAA,EAAyC;AACnE,EAAA,MAAM,SAAS,IAAA,CAAK,MAAA;AACpB,EAAA,IAAI,IAAA,CAAK,WAAW,CAAA,KAAM,UAAA,IAAc,CAAC,MAAA,IAAU,cAAA,CAAe,MAAM,CAAA,EAAG;AACzE,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,WAAA,CAAY,eAAA;AAAA,IAClB,KAAA,EAAO,UAAU,IAAI,CAAA;AAAA,IACrB,SAAA,EAAW,MAAA;AAAA,IACX,IAAA,EAAM,SAAA,CAAU,IAAA,EAAM,MAAM;AAAA,GAC9B;AACF;AAOA,SAAS,kBAAA,CAAmB,MAAgB,OAAA,EAAmD;AAC7F,EAAA,MAAM,SAAS,IAAA,CAAK,MAAA;AACpB,EAAA,IAAI,CAAC,gBAAA,CAAiB,GAAA,CAAI,IAAA,CAAK,WAAW,CAAC,CAAA,IAAK,CAAC,MAAA,IAAU,IAAA,CAAK,YAAY,CAAA,EAAG;AAC7E,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,MAAM,MAAA,GAAS,eAAe,MAAM,CAAA;AACpC,EAAA,IAAI,WAAW,MAAA,EAAW;AACxB,IAAA,OAAO,MAAA;AAAA,EACT;AAMA,EAAA,MAAM,KAAA,GAAQ,UAAU,IAAI,CAAA;AAC5B,EAAA,IAAI,KAAA,KAAU,UAAa,OAAA,CAAQ,qBAAA,CAAsB,IAAI,KAAK,CAAA,EAAG,GAAA,CAAI,MAAM,CAAA,EAAG;AAChF,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,WAAA,CAAY,eAAA;AAAA,IAClB,KAAA;AAAA,IACA,SAAA,EAAW,MAAA;AAAA,IACX,SAAA,EAAW,KAAK,YAAY,CAAA;AAAA,IAC5B,IAAA,EAAM,SAAA,CAAU,IAAA,EAAM,MAAM;AAAA,GAC9B;AACF;AAMA,SAAS,wBAAA,CAAyB,MAAgB,OAAA,EAAmD;AACnG,EAAA,MAAM,cAAA,GAAiB,KAAK,YAAY,CAAA;AACxC,EAAA,IAAI,CAAC,OAAA,CAAQ,yBAAA,IAA6B,CAAC,qBAAA,CAAsB,GAAA,CAAI,IAAA,CAAK,WAAW,CAAC,CAAA,IAAK,CAAC,cAAA,EAAgB;AAC1G,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,MAAM,gBAAgB,OAAA,CAAQ,cAAA,CAAe,GAAA,CAAI,QAAA,CAAS,IAAI,CAAC,CAAA;AAC/D,EAAA,IAAI,kBAAkB,MAAA,IAAa,CAAC,cAAA,CAAe,QAAA,CAAS,KAAK,CAAA,EAAG;AAClE,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,IAAI,IAAI,MAAA,CAAO,CAAA,KAAA,EAAQ,YAAA,CAAa,aAAa,CAAC,CAAA,KAAA,CAAO,CAAA,CAAE,IAAA,CAAK,cAAc,CAAA,EAAG;AAC/E,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,WAAA,CAAY,qBAAA;AAAA,IAClB,KAAA,EAAO,UAAU,IAAI,CAAA;AAAA,IACrB,SAAA,EAAW,cAAA;AAAA,IACX,SAAA,EAAW,KAAK,YAAY,CAAA;AAAA,IAC5B,IAAA,EAAM,sDAAsD,aAAa,CAAA,CAAA;AAAA,GAC3E;AACF;AAKA,SAAS,eAAe,MAAA,EAAyB;AAC/C,EAAA,OAAO,OAAO,QAAA,CAAS,IAAI,KAAK,CAAC,mCAAA,CAAoC,KAAK,MAAM,CAAA;AAClF;AAKA,SAAS,eAAe,MAAA,EAAoC;AAC1D,EAAA,IAAI,YAAA,CAAa,KAAK,MAAM,CAAA,IAAK,OAAO,KAAA,CAAM,KAAK,CAAA,CAAE,MAAA,KAAW,CAAA,EAAG;AACjE,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,MAAM,KAAA,GAAQ,2BAAA,CAA4B,IAAA,CAAK,MAAM,CAAA;AACrD,EAAA,OAAO,QAAQ,CAAC,CAAA;AAClB;AAEA,SAAS,SAAA,CAAU,MAAgB,MAAA,EAAoC;AACrE,EAAA,MAAM,MAAA,GAAS,eAAe,MAAM,CAAA;AACpC,EAAA,OAAO,MAAA,KAAW,SAAY,MAAA,GAAY,CAAA,gBAAA,EAAmB,UAAU,IAAI,CAAC,KAAK,MAAM,CAAA,CAAA,CAAA;AACzF;AAEA,SAAS,UAAU,IAAA,EAAoC;AACrD,EAAA,MAAM,QAAA,GAAW,KAAK,eAAe,CAAA;AACrC,EAAA,IAAI,aAAa,MAAA,EAAW;AAC1B,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,OAAO,KAAK,MAAA,GAAS,CAAA,EAAG,KAAK,MAAM,CAAA,CAAA,EAAI,QAAQ,CAAA,CAAA,GAAK,QAAA;AACtD;AAEA,SAAS,SAAS,IAAA,EAAwB;AACxC,EAAA,OAAO,GAAG,IAAA,CAAK,MAAM,CAAA,CAAA,EAAI,IAAA,CAAK,YAAY,CAAC,CAAA,CAAA;AAC7C;AAEA,SAAS,aAAa,IAAA,EAAsB;AAC1C,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,qBAAA,EAAuB,MAAM,CAAA;AACnD;;;AC/KO,SAAS,aAAa,MAAA,EAAkC;AAC7D,EAAA,MAAM,QAAA,GAAW;AAAA,IACf,mBAAmB,MAAA,CAAO,QAAA,CAAS,MAAM,CAAA,mBAAA,EAAsB,OAAO,cAAc,CAAA,aAAA;AAAA,GACtF;AAEA,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG;AAC9B,IAAA,QAAA,CAAS,IAAA,CAAK,OAAO,QAAA,CAAS,GAAA,CAAI,aAAa,CAAA,CAAE,IAAA,CAAK,MAAM,CAAC,CAAA;AAAA,EAC/D;AACA,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG;AAC9B,IAAA,QAAA,CAAS,IAAA,CAAK,CAAA;;AAAA,EAA4C,MAAA,CAAO,SAAS,GAAA,CAAI,aAAa,EAAE,IAAA,CAAK,MAAM,CAAC,CAAA,CAAE,CAAA;AAAA,EAC7G;AACA,EAAA,IAAI,MAAA,CAAO,gBAAA,CAAiB,MAAA,GAAS,CAAA,EAAG;AACtC,IAAA,MAAM,KAAA,GAAQ,OAAO,gBAAA,CAAiB,GAAA;AAAA,MACpC,CAAC,IAAA,KAAS,CAAA,IAAA,EAAO,MAAA,CAAO,IAAA,CAAK,KAAA,IAAS,IAAA,CAAK,SAAS,CAAC,CAAA,EAAA,EAAK,IAAA,CAAK,MAAM,CAAA,CAAA;AAAA,KACvE;AACA,IAAA,QAAA,CAAS,IAAA,CAAK,CAAA;AAAA,EAAuC,KAAA,CAAM,IAAA,CAAK,IAAI,CAAC,CAAA,CAAE,CAAA;AAAA,EACzE;AAEA,EAAA,OAAO,QAAA,CAAS,KAAK,MAAM,CAAA;AAC7B;AAEA,SAAS,cAAc,OAAA,EAA0B;AAC/C,EAAA,MAAM,UAAU,CAAC,OAAA,CAAQ,IAAA,EAAM,OAAA,CAAQ,OAAO,OAAA,CAAQ,cAAA,IAAkB,CAAA,GAAA,EAAM,OAAA,CAAQ,cAAc,CAAA,CAAE,CAAA,CACnG,OAAO,OAAO,CAAA,CACd,KAAK,GAAG,CAAA;AAEX,EAAA,MAAM,OAAA,GAAU;AAAA,IACd,OAAA,CAAQ,SAAA,IAAa,CAAA,WAAA,EAAc,OAAA,CAAQ,SAAS,CAAA,CAAA;AAAA,IACpD,OAAA,CAAQ,SAAA,IAAa,CAAA,OAAA,EAAU,OAAA,CAAQ,SAAS,CAAA,CAAA;AAAA,IAChD,OAAA,CAAQ,KAAA,IAAS,CAAA,OAAA,EAAU,OAAA,CAAQ,KAAK,CAAA,CAAA;AAAA,IACxC,OAAA,CAAQ,IAAA,IAAQ,CAAA,KAAA,EAAQ,OAAA,CAAQ,IAAI,CAAA,CAAA;AAAA,IACpC,CAAA,WAAA,EAAc,QAAQ,SAAS,CAAA;AAAA,GACjC,CAAE,OAAO,OAAO,CAAA;AAEhB,EAAA,OAAO,CAAC,OAAA,EAAS,GAAG,OAAA,CAAQ,GAAA,CAAI,CAAC,IAAA,KAAS,CAAA,EAAA,EAAK,IAAI,CAAA,CAAE,CAAC,CAAA,CAAE,KAAK,IAAI,CAAA;AACnE;;;ACvBA,IAAM,uBAAA,GAA0B,EAAA;AAEhC,IAAM,qBAAA,GAAwB,EAAA;AAKvB,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA,EAChC,MAAA;AAAA,EAET,YAAY,MAAA,EAA0B;AACpC,IAAA,KAAA,CAAM,YAAA,CAAa,MAAM,CAAC,CAAA;AAC1B,IAAA,IAAA,CAAK,IAAA,GAAO,iBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF;AAKA,eAAsB,aAAa,OAAA,EAAwC;AACzE,EAAA,MAAM,UAAA,CAAW,OAAA,CAAQ,gBAAA,EAAkB,uBAAuB,CAAA;AACpE;AAMA,eAAsB,aAAa,OAAA,EAAuD;AACxF,EAAA,MAAM,eAAA,GAAkB,OAAA,CAAQ,WAAA,IAAe,eAAA,CAAgB,IAAA;AAE/D,EAAA,OAAO,UAAA,CAAW,OAAA,CAAQ,gBAAA,EAAkB,OAAO,MAAA,KAAW;AAE5D,IAAA,MAAM,UAAA,GAAa,MAAM,sBAAA,CAAuB,MAAM,CAAA;AAEtD,IAAA,MAAM,YAAA,GAAe,MAAM,kBAAA,CAAmB,MAAM,CAAA;AACpD,IAAA,IAAI,eAAe,qBAAA,EAAuB;AACxC,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,2BAAA,EAA8B,qBAAqB,CAAA,qBAAA,EAAwB,YAAY,CAAA,CAAE,CAAA;AAAA,IAC3G;AACA,IAAA,MAAM,uBAAuB,MAAM,CAAA;AAEnC,IAAA,MAAM,cAAyB,EAAC;AAChC,IAAA,KAAA,MAAW,aAAa,UAAA,EAAY;AAClC,MAAA,WAAA,CAAY,KAAK,GAAI,MAAM,eAAe,MAAA,EAAQ,SAAA,EAAW,YAAY,CAAE,CAAA;AAAA,IAC7E;AAEA,IAAA,MAAM,cAAA,GAAiB,CAAC,OAAA,KACtB,OAAA,CAAQ,SAAS,WAAA,CAAY,WAAA,IAAe,oBAAoB,eAAA,CAAgB,MAAA;AAClF,IAAA,MAAM,EAAE,MAAM,gBAAA,EAAiB,GAAI,iBAAiB,WAAA,EAAa,OAAA,CAAQ,MAAA,IAAU,EAAE,CAAA;AAErF,IAAA,OAAO;AAAA,MACL,gBAAgB,UAAA,CAAW,MAAA;AAAA,MAC3B,QAAA,EAAU,KAAK,MAAA,CAAO,CAAC,YAAY,CAAC,cAAA,CAAe,OAAO,CAAC,CAAA;AAAA,MAC3D,QAAA,EAAU,IAAA,CAAK,MAAA,CAAO,cAAc,CAAA;AAAA,MACpC;AAAA,KACF;AAAA,EACF,CAAC,CAAA;AACH;AAMA,eAAsB,cAAc,OAAA,EAAuD;AACzF,EAAA,MAAM,MAAA,GAAS,MAAM,YAAA,CAAa,OAAO,CAAA;AACzC,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG;AAC9B,IAAA,MAAM,IAAI,gBAAgB,MAAM,CAAA;AAAA,EAClC;AACA,EAAA,OAAO,MAAA;AACT;AAEA,eAAe,cAAA,CAAe,MAAA,EAAmB,SAAA,EAAmB,YAAA,EAA0C;AAC5G,EAAA,MAAM,cAAA,GAAiB,oBAAoB,SAAS,CAAA;AACpD,EAAA,MAAM,OAAA,GAAU,MAAM,gBAAA,CAAiB,MAAA,EAAQ,SAAS,CAAA;AAExD,EAAA,IAAI,WAAW,OAAA,EAAS;AACtB,IAAA,OAAO,CAAC,EAAE,IAAA,EAAM,WAAA,CAAY,WAAA,EAAa,WAAW,cAAA,EAAgB,KAAA,EAAO,OAAA,CAAQ,KAAA,EAAO,CAAA;AAAA,EAC5F;AAEA,EAAA,MAAM,WAAW,MAAM,QAAA,CAAS,MAAA,EAAQ,OAAA,CAAQ,MAAM,YAAY,CAAA;AAClE,EAAA,OAAO,QAAA,CAAS,IAAI,CAAC,OAAA,MAAa,EAAE,GAAG,OAAA,EAAS,SAAA,EAAW,cAAA,EAAe,CAAE,CAAA;AAC9E;AAEA,eAAe,QAAA,CAAS,MAAA,EAAmB,IAAA,EAAgB,YAAA,EAAsB;AAC/E,EAAA,MAAM,4BAA4B,YAAA,GAAe,uBAAA;AACjD,EAAA,MAAM,cAAA,GAAiB,yBAAA,GACnB,MAAM,mBAAA,CAAoB,MAAA,EAAQ,sBAAsB,IAAI,CAAC,CAAA,mBAC7D,IAAI,GAAA,EAAoB;AAC5B,EAAA,MAAM,wBAAwB,MAAM,mBAAA,CAAoB,MAAA,EAAQ,UAAA,CAAW,IAAI,CAAC,CAAA;AAEhF,EAAA,OAAO,aAAa,IAAA,EAAM,EAAE,cAAA,EAAgB,qBAAA,EAAuB,2BAA2B,CAAA;AAChG;AAEA,eAAe,UAAA,CAAc,kBAA0B,IAAA,EAAqD;AAE1G,EAAA,MAAM,SAAS,IAAI,EAAA,CAAG,MAAA,CAAO,EAAE,kBAAkB,CAAA;AACjD,EAAA,MAAM,OAAO,OAAA,EAAQ;AACrB,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,KAAK,MAAM,CAAA;AAAA,EAC1B,CAAA,SAAE;AACA,IAAA,MAAM,OAAO,GAAA,EAAI;AAAA,EACnB;AACF","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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosaic-code/pg-index-check",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Fail your test suite when PostgreSQL queries have no usable index",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.cjs",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"require": {
|
|
19
|
+
"types": "./dist/index.d.cts",
|
|
20
|
+
"default": "./dist/index.cjs"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"clean": "rm -rf dist",
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:integration": "prisma generate && vitest run -c vitest.integration.config.ts",
|
|
35
|
+
"test:integration:setup": "bash scripts/integration-test-setup.sh",
|
|
36
|
+
"prepublishOnly": "npm run build",
|
|
37
|
+
"release:patch": "npm version patch && git push --follow-tags && npm run build && npm publish",
|
|
38
|
+
"release:minor": "npm version minor && git push --follow-tags && npm run build && npm publish",
|
|
39
|
+
"release:major": "npm version major && git push --follow-tags && npm run build && npm publish",
|
|
40
|
+
"release:dry": "conventional-changelog -p conventionalcommits -u",
|
|
41
|
+
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"pg",
|
|
45
|
+
"postgres",
|
|
46
|
+
"postgresql",
|
|
47
|
+
"index",
|
|
48
|
+
"explain",
|
|
49
|
+
"query-plan",
|
|
50
|
+
"testing",
|
|
51
|
+
"pg_stat_statements",
|
|
52
|
+
"prisma"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22.9.0",
|
|
56
|
+
"npm": ">=11.14.0"
|
|
57
|
+
},
|
|
58
|
+
"license": "NoHarm",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/mosaic-code-coop/pg-index-check.git"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/mosaic-code-coop/pg-index-check#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/mosaic-code-coop/pg-index-check/issues"
|
|
66
|
+
},
|
|
67
|
+
"author": "Chris Jensen <2920476+chrisjensen@users.noreply.github.com> (https://github.com/chrisjensen)",
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"pg": ">=8.0.0"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@mosaic-code/pg-source-comments": "^0.4.0",
|
|
73
|
+
"@prisma/adapter-pg": "^7.10.0",
|
|
74
|
+
"@prisma/client": "^7.10.0",
|
|
75
|
+
"@types/node": "^22.20.5",
|
|
76
|
+
"@types/pg": "^8.23.1",
|
|
77
|
+
"conventional-changelog-cli": "^5.0.0",
|
|
78
|
+
"pg": "^8.23.1",
|
|
79
|
+
"prisma": "^7.10.0",
|
|
80
|
+
"tsup": "^8.5.1",
|
|
81
|
+
"typescript": "^5.9.3",
|
|
82
|
+
"vitest": "^5.0.3"
|
|
83
|
+
}
|
|
84
|
+
}
|