@query-doctor/core 0.10.4 → 0.10.5
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/findings/query-findings.cjs +709 -0
- package/dist/findings/query-findings.cjs.map +1 -0
- package/dist/findings/query-findings.d.cts +59 -0
- package/dist/findings/query-findings.d.cts.map +1 -0
- package/dist/findings/query-findings.d.mts +59 -0
- package/dist/findings/query-findings.d.mts.map +1 -0
- package/dist/findings/query-findings.mjs +709 -0
- package/dist/findings/query-findings.mjs.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +2 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/optimizer/statistics.cjs +17 -11
- package/dist/optimizer/statistics.cjs.map +1 -1
- package/dist/optimizer/statistics.d.cts +2 -0
- package/dist/optimizer/statistics.d.cts.map +1 -1
- package/dist/optimizer/statistics.d.mts +2 -0
- package/dist/optimizer/statistics.d.mts.map +1 -1
- package/dist/optimizer/statistics.mjs +17 -11
- package/dist/optimizer/statistics.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
//#region src/findings/query-findings.ts
|
|
3
|
+
const MIN_QUERY_COST = 100;
|
|
4
|
+
const INDEX_SERVED_NODES = new Set([
|
|
5
|
+
"Index Only Scan",
|
|
6
|
+
"Index Scan",
|
|
7
|
+
"Bitmap Heap Scan",
|
|
8
|
+
"Bitmap Index Scan"
|
|
9
|
+
]);
|
|
10
|
+
const COST_CONCENTRATION_SHARE = .5;
|
|
11
|
+
const COST_CONCENTRATION_WARN_SHARE = .8;
|
|
12
|
+
const SEQ_SCAN_COST_SHARE = .3;
|
|
13
|
+
const SEQ_SCAN_ROWS = 5e4;
|
|
14
|
+
const LOOP_RATIO = 10;
|
|
15
|
+
const LOOP_SHARE = .25;
|
|
16
|
+
const MAX_LOOP_FINDINGS = 3;
|
|
17
|
+
const SORT_COST_SHARE = .2;
|
|
18
|
+
const SORT_WARN_SHARE = .4;
|
|
19
|
+
const WIDE_RESULT_BYTES = 1e5;
|
|
20
|
+
const WIDE_RESULT_WARN_BYTES = 256e3;
|
|
21
|
+
const COST_DRIVER_SHARE = .4;
|
|
22
|
+
const SELECTIVE_SCAN_MIN_SCANNED = 1e4;
|
|
23
|
+
const SELECTIVE_SCAN_MAX_RATIO = .05;
|
|
24
|
+
function asNode(plan) {
|
|
25
|
+
return plan;
|
|
26
|
+
}
|
|
27
|
+
function num(node, key) {
|
|
28
|
+
const value = node[key];
|
|
29
|
+
return typeof value === "number" ? value : void 0;
|
|
30
|
+
}
|
|
31
|
+
function str(node, key) {
|
|
32
|
+
const value = node[key];
|
|
33
|
+
return typeof value === "string" ? value : void 0;
|
|
34
|
+
}
|
|
35
|
+
function childNodes(node) {
|
|
36
|
+
return Array.isArray(node.Plans) ? node.Plans : [];
|
|
37
|
+
}
|
|
38
|
+
function walk(node, visit) {
|
|
39
|
+
visit(node);
|
|
40
|
+
for (const child of childNodes(node)) walk(child, visit);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Postgres reports cumulative Total Cost (a node's cost includes its children).
|
|
44
|
+
* A node's own contribution is therefore its Total Cost minus its children's —
|
|
45
|
+
* which telescopes so the self-costs across the tree sum back to the root total.
|
|
46
|
+
*/
|
|
47
|
+
function selfCost(node) {
|
|
48
|
+
const total = num(node, "Total Cost") ?? 0;
|
|
49
|
+
const childTotal = childNodes(node).reduce((sum, child) => sum + (num(child, "Total Cost") ?? 0), 0);
|
|
50
|
+
return Math.max(0, total - childTotal);
|
|
51
|
+
}
|
|
52
|
+
function nodeLabel(node) {
|
|
53
|
+
const type = str(node, "Node Type") ?? "node";
|
|
54
|
+
const relation = str(node, "Relation Name") ?? str(node, "Alias");
|
|
55
|
+
return relation ? `${type} on ${relation}` : type;
|
|
56
|
+
}
|
|
57
|
+
function formatBytes(bytes) {
|
|
58
|
+
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
59
|
+
if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} KB`;
|
|
60
|
+
return `${Math.round(bytes)} B`;
|
|
61
|
+
}
|
|
62
|
+
function costBreakdown(node) {
|
|
63
|
+
const raw = node["Cost Breakdown"];
|
|
64
|
+
if (!Array.isArray(raw)) return [];
|
|
65
|
+
const components = [];
|
|
66
|
+
for (const entry of raw) {
|
|
67
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
68
|
+
const record = entry;
|
|
69
|
+
const cost = record["Cost"];
|
|
70
|
+
const reason = record["Reason"];
|
|
71
|
+
if (typeof cost !== "number" || typeof reason !== "string") continue;
|
|
72
|
+
const variables = typeof record["Variables"] === "object" && record["Variables"] !== null ? record["Variables"] : {};
|
|
73
|
+
components.push({
|
|
74
|
+
cost,
|
|
75
|
+
reason,
|
|
76
|
+
variables
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return components;
|
|
80
|
+
}
|
|
81
|
+
/** Plain-language name for a cost component, citing its load-bearing cardinality
|
|
82
|
+
* (the planner's assumed relpages / reltuples). Maps only the Reasons QD's
|
|
83
|
+
* planner actually emits; an unmapped one falls back to a generic phrase so a new
|
|
84
|
+
* component never breaks the sentence. The distinction that matters to a reader
|
|
85
|
+
* is disk I/O (the table or index is big on disk — fewer pages or a covering
|
|
86
|
+
* index helps) vs per-row CPU (many rows flow through — better selectivity helps). */
|
|
87
|
+
function costComponentPhrase(component) {
|
|
88
|
+
const cardinality = (key) => {
|
|
89
|
+
const value = component.variables[key];
|
|
90
|
+
return typeof value === "number" ? value.toLocaleString("en-US") : void 0;
|
|
91
|
+
};
|
|
92
|
+
switch (component.reason) {
|
|
93
|
+
case "RUNTIME:DISK_IO": {
|
|
94
|
+
const pages = cardinality("relpages");
|
|
95
|
+
return pages ? `reading the table off disk (~${pages} pages)` : "sequential disk reads";
|
|
96
|
+
}
|
|
97
|
+
case "RUNTIME:DISK_ACCESS": {
|
|
98
|
+
const pages = cardinality("index_pages_fetched");
|
|
99
|
+
return pages ? `random index-page reads (~${pages} pages)` : "random index-page reads";
|
|
100
|
+
}
|
|
101
|
+
case "RUNTIME:WORST_CASE_IO": {
|
|
102
|
+
const pages = cardinality("pages_fetched");
|
|
103
|
+
return pages ? `worst-case random heap reads (~${pages} pages, assuming low page visibility)` : "worst-case random heap reads";
|
|
104
|
+
}
|
|
105
|
+
case "RUNTIME:HEAP_FETCH_AND_FILTER": {
|
|
106
|
+
const rows = cardinality("reltuples");
|
|
107
|
+
return rows ? `fetching and filtering ~${rows} rows from the heap` : "per-row heap fetch and filter";
|
|
108
|
+
}
|
|
109
|
+
case "RUNTIME:HEAP_FILTER": {
|
|
110
|
+
const rows = cardinality("reltuples");
|
|
111
|
+
return rows ? `filtering ~${rows} rows` : "per-row filtering";
|
|
112
|
+
}
|
|
113
|
+
case "RUNTIME:INDEX_FILTER": return "scanning index entries";
|
|
114
|
+
case "RUNTIME:BITMAP": return "building the row bitmap";
|
|
115
|
+
default: return "its main cost component";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** A one-sentence "where this node's cost goes", derived from its Cost Breakdown:
|
|
119
|
+
* the single largest positive component, in plain language. Discounts (negative
|
|
120
|
+
* parallel-worker / I/O-correlation adjustments) and ~zero startup terms aren't
|
|
121
|
+
* where the cost lands, so they're dropped. Returns "" when the node has no
|
|
122
|
+
* breakdown (vanilla plan) or no component clearly dominates — callers append it
|
|
123
|
+
* unconditionally and get nothing when there's nothing certain to add. */
|
|
124
|
+
function whyExpensive(node) {
|
|
125
|
+
const positive = costBreakdown(node).filter((c) => c.cost > 0);
|
|
126
|
+
if (positive.length === 0) return "";
|
|
127
|
+
const total = positive.reduce((sum, c) => sum + c.cost, 0);
|
|
128
|
+
const dominant = positive.reduce((a, b) => b.cost > a.cost ? b : a);
|
|
129
|
+
if (total <= 0 || dominant.cost / total < COST_DRIVER_SHARE) return "";
|
|
130
|
+
return ` Most of that node's cost is ${costComponentPhrase(dominant)}.`;
|
|
131
|
+
}
|
|
132
|
+
/** Rows a scan node reads, from its Cost Breakdown's `reltuples` (the planner's
|
|
133
|
+
* table-size estimate) — distinct from Plan Rows, which is the *post-filter*
|
|
134
|
+
* output. Undefined when the node carries no breakdown cardinality (vanilla plan). */
|
|
135
|
+
function rowsScanned(node) {
|
|
136
|
+
let max;
|
|
137
|
+
for (const component of costBreakdown(node)) {
|
|
138
|
+
const value = component.variables["reltuples"];
|
|
139
|
+
if (typeof value === "number" && (max === void 0 || value > max)) max = value;
|
|
140
|
+
}
|
|
141
|
+
return max;
|
|
142
|
+
}
|
|
143
|
+
/** A diagnosis sentence for a scan that reads far more rows than it returns: the
|
|
144
|
+
* filter throws away nearly everything it touched, which is exactly the work an
|
|
145
|
+
* index avoids. Pure diagnosis — it states the waste, not a fix; the optimizer's
|
|
146
|
+
* index verdict (handled by the caller) decides whether an index is the answer.
|
|
147
|
+
* "" when the node isn't a selective scan or has no breakdown to measure it from. */
|
|
148
|
+
function selectivityClause(node) {
|
|
149
|
+
const scanned = rowsScanned(node);
|
|
150
|
+
const returned = num(node, "Plan Rows");
|
|
151
|
+
if (scanned === void 0 || returned === void 0) return "";
|
|
152
|
+
if (scanned < SELECTIVE_SCAN_MIN_SCANNED) return "";
|
|
153
|
+
const ratio = scanned > 0 ? returned / scanned : 1;
|
|
154
|
+
if (ratio >= SELECTIVE_SCAN_MAX_RATIO) return "";
|
|
155
|
+
const pct = ratio < 1e-4 ? "<0.01%" : `~${(ratio * 100).toFixed(2)}%`;
|
|
156
|
+
return ` It scans ~${scanned.toLocaleString("en-US")} rows and returns ~${returned.toLocaleString("en-US")} (${pct}), so nearly all of that work is rows the filter discards.`;
|
|
157
|
+
}
|
|
158
|
+
/** A cost as a fraction of the query total, clamped to [0,1]. Parallel plans
|
|
159
|
+
* (Gather / Gather Merge) report a child node's cost as the per-worker figure —
|
|
160
|
+
* which can exceed the gathered root total, since the Gather divides the work.
|
|
161
|
+
* Left unclamped that yields a nonsensical >100% share. */
|
|
162
|
+
function clampShare(cost, total) {
|
|
163
|
+
if (total <= 0) return 0;
|
|
164
|
+
return Math.min(1, cost / total);
|
|
165
|
+
}
|
|
166
|
+
/** A node that reads an entire relation with nothing to narrow it: a Seq Scan or
|
|
167
|
+
* full Index Only Scan carrying no Filter / Index Cond / Recheck Cond. The cost
|
|
168
|
+
* is the whole table by definition — and since there's no predicate, no index can
|
|
169
|
+
* avoid the pass (which is why the optimizer returns no_improvement_found). */
|
|
170
|
+
function isUnfilteredFullRead(node) {
|
|
171
|
+
const type = str(node, "Node Type");
|
|
172
|
+
if (type !== "Seq Scan" && type !== "Index Only Scan") return false;
|
|
173
|
+
return node["Filter"] == null && node["Index Cond"] == null && node["Recheck Cond"] == null;
|
|
174
|
+
}
|
|
175
|
+
/** Whether the plan aggregates, and how — a scalar aggregate (count(*) over the
|
|
176
|
+
* whole table, no grouping) can be approximated or maintained out of band, a
|
|
177
|
+
* grouped one (GROUP BY / HAVING) genuinely needs every row, and neither rules
|
|
178
|
+
* the full read. Grouped wins when both appear (e.g. `count(*)` over a grouped
|
|
179
|
+
* subquery), since the grouping is what forces the whole-table pass. */
|
|
180
|
+
function aggregateShape(root) {
|
|
181
|
+
let scalar = false;
|
|
182
|
+
let grouped = false;
|
|
183
|
+
walk(root, (node) => {
|
|
184
|
+
if (str(node, "Node Type") !== "Aggregate") return;
|
|
185
|
+
const groupKey = node["Group Key"];
|
|
186
|
+
if (Array.isArray(groupKey) && groupKey.length > 0) grouped = true;
|
|
187
|
+
else scalar = true;
|
|
188
|
+
});
|
|
189
|
+
return grouped ? "grouped" : scalar ? "scalar" : "none";
|
|
190
|
+
}
|
|
191
|
+
/** FULL_TABLE_SCAN: an unfiltered whole-relation read that dominates the cost
|
|
192
|
+
* while the optimizer found no index that helps — because there's no predicate to
|
|
193
|
+
* index. Without this the query shows *zero* findings (the dominant node is
|
|
194
|
+
* index-served, so the concentration pass suppresses it), which reads as "QD
|
|
195
|
+
* didn't look". This fills that silence with the reason and an honest lead. */
|
|
196
|
+
function buildFullTableReadFinding(node, share, shape) {
|
|
197
|
+
const pct = Math.round(share * 100);
|
|
198
|
+
const relation = relationOf(node);
|
|
199
|
+
const target = relation ? `\`${relation}\`` : "The table";
|
|
200
|
+
let detail;
|
|
201
|
+
let lead;
|
|
202
|
+
if (shape === "scalar") {
|
|
203
|
+
detail = `${target} is read end to end to compute the aggregate — there's no filter to narrow it, so no index avoids the full pass (the optimizer confirmed none helps).`;
|
|
204
|
+
lead = `If this runs often, an approximate count (\`pg_class.reltuples\`) or a maintained tally avoids re-reading the whole table each call.`;
|
|
205
|
+
} else if (shape === "grouped") detail = `${target} is read end to end to group every row — there's no filter to narrow it, so the whole-table pass is inherent to the query.`;
|
|
206
|
+
else {
|
|
207
|
+
detail = `${target} is read end to end — there's no filter to narrow it, so no index avoids the full pass (the optimizer confirmed none helps).`;
|
|
208
|
+
lead = `If you don't need every row, a WHERE filter or LIMIT lets an index read only part of the table.`;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
code: "FULL_TABLE_SCAN",
|
|
212
|
+
severity: "info",
|
|
213
|
+
impact: share,
|
|
214
|
+
title: relation ? `Whole-table read of ${relation}` : "Whole-table read",
|
|
215
|
+
detail: `${detail}${whyExpensive(node)}`,
|
|
216
|
+
...lead ? { lead } : {},
|
|
217
|
+
evidence: {
|
|
218
|
+
...relation ? { relation } : {},
|
|
219
|
+
costShare: `${pct}%`
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Sort nodes a LIMIT bounds directly (top-N) — `Limit → Sort`, possibly through
|
|
224
|
+
* pass-through nodes (Result / Gather Merge / Gather). For these the win from a
|
|
225
|
+
* matching index is bigger than skipping the sort: the LIMIT also lets the scan
|
|
226
|
+
* stop after the first n rows instead of sorting the whole set. A Limit above an
|
|
227
|
+
* Aggregate or a Join doesn't bound the sort below it, so the descent stops at
|
|
228
|
+
* any non-pass-through node. */
|
|
229
|
+
const LIMIT_PASSTHROUGH = new Set([
|
|
230
|
+
"Result",
|
|
231
|
+
"Gather",
|
|
232
|
+
"Gather Merge",
|
|
233
|
+
"LockRows"
|
|
234
|
+
]);
|
|
235
|
+
function sortsBoundedByLimit(root) {
|
|
236
|
+
const bounded = /* @__PURE__ */ new Set();
|
|
237
|
+
walk(root, (node) => {
|
|
238
|
+
if (str(node, "Node Type") !== "Limit") return;
|
|
239
|
+
let cursor = node;
|
|
240
|
+
const seen = /* @__PURE__ */ new Set();
|
|
241
|
+
while (cursor && !seen.has(cursor)) {
|
|
242
|
+
seen.add(cursor);
|
|
243
|
+
const child = childNodes(cursor)[0];
|
|
244
|
+
if (!child) break;
|
|
245
|
+
const type = str(child, "Node Type");
|
|
246
|
+
if (type === "Sort") {
|
|
247
|
+
bounded.add(child);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
if (!LIMIT_PASSTHROUGH.has(type ?? "")) break;
|
|
251
|
+
cursor = child;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return bounded;
|
|
255
|
+
}
|
|
256
|
+
/** Advice for a dominant Sort node. Seq Scan index advice keys off the
|
|
257
|
+
* optimizer's verdict (handled inline); a Nested Loop is a multiplication (its
|
|
258
|
+
* own finding); everything else has no generic hint — hence Sort-only here. */
|
|
259
|
+
function sortHint(nodeType) {
|
|
260
|
+
return nodeType === "Sort" ? " It's an in-memory sort — an index matching the ORDER BY could avoid it." : "";
|
|
261
|
+
}
|
|
262
|
+
function relationOf(node) {
|
|
263
|
+
return str(node, "Relation Name") ?? str(node, "Alias");
|
|
264
|
+
}
|
|
265
|
+
/** A nested loop's two inputs: the outer side (driven once, its row count is the
|
|
266
|
+
* loop count) and the inner side (re-run once per outer row). Postgres tags them
|
|
267
|
+
* via Parent Relationship; fall back to child order when the tag is absent. */
|
|
268
|
+
function loopSides(node) {
|
|
269
|
+
const kids = childNodes(node);
|
|
270
|
+
return {
|
|
271
|
+
outer: kids.find((k) => str(k, "Parent Relationship") === "Outer") ?? kids[0],
|
|
272
|
+
inner: kids.find((k) => str(k, "Parent Relationship") === "Inner") ?? kids[1]
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function setBasedLead(relation) {
|
|
276
|
+
return `This subquery might fold into one set-based pass: aggregate or join ${relation ? `\`${relation}\`` : "the subquery's table"} once, then filter. Often much faster, but it depends on the data, so check before committing.`;
|
|
277
|
+
}
|
|
278
|
+
/** A node's SubPlan children — correlated subqueries (EXISTS, scalar) run once
|
|
279
|
+
* per row, whether in the Filter (EXISTS) or the SELECT list (a scalar COUNT).
|
|
280
|
+
* Parent Relationship "SubPlan" is the reliable tell; an InitPlan (also carries
|
|
281
|
+
* a Subplan Name) runs once, so it's deliberately excluded. */
|
|
282
|
+
function subplanChildren(node) {
|
|
283
|
+
return childNodes(node).filter(isSubplanChild);
|
|
284
|
+
}
|
|
285
|
+
/** REPEATED_INNER_LOOP for the SubPlan-on-a-node shape: a Seq/Index Scan (or
|
|
286
|
+
* other node) that evaluates correlated subqueries once per row. The planner
|
|
287
|
+
* folds that multiplied cost into the node's own total, so a naive reading
|
|
288
|
+
* blames the scan and says "add an index" — but the cost is the per-row
|
|
289
|
+
* repetition, not the scan. Covers both the d2armory count(*) (EXISTS subplans
|
|
290
|
+
* in the Filter) and the migration backfill (scalar subplans in the SELECT). */
|
|
291
|
+
function buildSubplanFinding(node, self, rootTotal) {
|
|
292
|
+
const share = clampShare(self, rootTotal);
|
|
293
|
+
const pct = Math.round(share * 100);
|
|
294
|
+
const relation = relationOf(node);
|
|
295
|
+
const nodeType = str(node, "Node Type") ?? "scan";
|
|
296
|
+
const subplans = subplanChildren(node);
|
|
297
|
+
const loops = num(node, "Plan Rows");
|
|
298
|
+
const loopsText = loops !== void 0 ? ` (~${loops.toLocaleString("en-US")} rows)` : "";
|
|
299
|
+
const count = subplans.length;
|
|
300
|
+
const noun = count === 1 ? "a correlated subquery" : `${count} correlated subqueries`;
|
|
301
|
+
const indexClause = relation ? ` An index on \`${relation}\` can shrink the scan but not the per-row subqueries.` : ``;
|
|
302
|
+
let subqueryRelation;
|
|
303
|
+
for (const subplan of subplans) {
|
|
304
|
+
const driver = heaviestDriver(subplan);
|
|
305
|
+
const rel = driver ? relationOf(driver) : void 0;
|
|
306
|
+
if (rel) {
|
|
307
|
+
subqueryRelation = rel;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
code: "REPEATED_INNER_LOOP",
|
|
313
|
+
severity: share >= COST_CONCENTRATION_WARN_SHARE ? "warning" : "info",
|
|
314
|
+
impact: share,
|
|
315
|
+
title: "Correlated subquery runs once per row",
|
|
316
|
+
detail: `This ${nodeType}${relation ? ` over \`${relation}\`` : ""} runs ${noun} once per row${loopsText}, so about ${pct}% of the cost is that repetition, not the scan.${indexClause}`,
|
|
317
|
+
lead: setBasedLead(subqueryRelation),
|
|
318
|
+
evidence: {
|
|
319
|
+
...relation ? { relation } : {},
|
|
320
|
+
...loops !== void 0 ? { loops } : {},
|
|
321
|
+
subqueries: count,
|
|
322
|
+
costShare: `${pct}%`
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** The inner side is a correlated subquery (re-evaluated per outer row) rather
|
|
327
|
+
* than a plain table probe — the tell is a Memoize/Subquery/SubPlan in its
|
|
328
|
+
* subtree. Postgres only inserts Memoize to cache a repeatedly-run inner, so its
|
|
329
|
+
* presence is itself the signal that work is being multiplied per row. */
|
|
330
|
+
function innerIsCorrelatedSubquery(inner) {
|
|
331
|
+
let found = false;
|
|
332
|
+
walk(inner, (n) => {
|
|
333
|
+
const type = str(n, "Node Type");
|
|
334
|
+
if (type === "Memoize" || type === "Subquery Scan") found = true;
|
|
335
|
+
if (str(n, "Parent Relationship") === "SubPlan") found = true;
|
|
336
|
+
if (typeof n["Subplan Name"] === "string") found = true;
|
|
337
|
+
});
|
|
338
|
+
return found;
|
|
339
|
+
}
|
|
340
|
+
/** The single heaviest node within a subtree (by own cost), used to name what
|
|
341
|
+
* actually drives an inner side's per-loop cost. */
|
|
342
|
+
function heaviestDriver(subtree) {
|
|
343
|
+
let best;
|
|
344
|
+
let bestSelf = -1;
|
|
345
|
+
walk(subtree, (n) => {
|
|
346
|
+
const s = selfCost(n);
|
|
347
|
+
if (s > bestSelf) {
|
|
348
|
+
bestSelf = s;
|
|
349
|
+
best = n;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return best;
|
|
353
|
+
}
|
|
354
|
+
/** True when a child node is a correlated SubPlan (e.g. a scalar COUNT subquery
|
|
355
|
+
* in the SELECT list), which re-runs per row of its parent rather than once. */
|
|
356
|
+
function isSubplanChild(node) {
|
|
357
|
+
return str(node, "Parent Relationship") === "SubPlan";
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The node whose *multiplied* cost (own cost × how many times it runs) is
|
|
361
|
+
* largest within a loop's per-row work — i.e. where the repeated cost truly
|
|
362
|
+
* lands. A node cheap per run (a 631-cost heap fetch, an 8-cost COUNT) can
|
|
363
|
+
* dominate once run thousands of times; a plain "heaviest node" reading misses
|
|
364
|
+
* it. Per-row work hangs off the loop two ways: the inner side (run per outer
|
|
365
|
+
* row) and correlated SubPlan children (run per *output* row, e.g. scalar
|
|
366
|
+
* subqueries in the SELECT). Both are walked. No-EXPLAIN-ANALYZE: executions
|
|
367
|
+
* come from Plan Rows × nested-loop / subplan multipliers, never real loops.
|
|
368
|
+
*/
|
|
369
|
+
function multipliedDriver(loopNode, baseLoops) {
|
|
370
|
+
const { inner } = loopSides(loopNode);
|
|
371
|
+
const subplans = subplanChildren(loopNode);
|
|
372
|
+
const outputRows = num(loopNode, "Plan Rows") ?? baseLoops;
|
|
373
|
+
let best;
|
|
374
|
+
let bestCost = -1;
|
|
375
|
+
const visit = (node, executions) => {
|
|
376
|
+
const cost = selfCost(node) * executions;
|
|
377
|
+
if (cost > bestCost) {
|
|
378
|
+
bestCost = cost;
|
|
379
|
+
best = node;
|
|
380
|
+
}
|
|
381
|
+
let innerChild;
|
|
382
|
+
let outerRows = 1;
|
|
383
|
+
if (str(node, "Node Type") === "Nested Loop") {
|
|
384
|
+
const sides = loopSides(node);
|
|
385
|
+
innerChild = sides.inner;
|
|
386
|
+
outerRows = sides.outer ? num(sides.outer, "Plan Rows") ?? 1 : 1;
|
|
387
|
+
}
|
|
388
|
+
const rows = num(node, "Plan Rows") ?? 1;
|
|
389
|
+
for (const child of childNodes(node)) visit(child, child === innerChild ? executions * outerRows : isSubplanChild(child) ? executions * rows : executions);
|
|
390
|
+
};
|
|
391
|
+
if (inner) visit(inner, baseLoops);
|
|
392
|
+
for (const subplan of subplans) visit(subplan, outputRows);
|
|
393
|
+
return best ? {
|
|
394
|
+
node: best,
|
|
395
|
+
multiplied: bestCost
|
|
396
|
+
} : void 0;
|
|
397
|
+
}
|
|
398
|
+
/** Ratio of a node's Total Cost to its children's combined single-pass cost.
|
|
399
|
+
* >LOOP_RATIO means the node re-runs a child per row (loop multiplication). */
|
|
400
|
+
function loopRatio(node) {
|
|
401
|
+
const childTotal = childNodes(node).reduce((sum, child) => sum + (num(child, "Total Cost") ?? 0), 0);
|
|
402
|
+
const total = num(node, "Total Cost") ?? 0;
|
|
403
|
+
return childTotal > 0 ? total / childTotal : 0;
|
|
404
|
+
}
|
|
405
|
+
/** Identity of what a nested loop repeats, so twin loops across subplans (same
|
|
406
|
+
* correlated inner) collapse to one finding instead of several. */
|
|
407
|
+
function loopSignature(node) {
|
|
408
|
+
const { inner } = loopSides(node);
|
|
409
|
+
const driver = inner ? heaviestDriver(inner) : void 0;
|
|
410
|
+
const driverRel = driver ? relationOf(driver) : void 0;
|
|
411
|
+
const correlated = inner ? innerIsCorrelatedSubquery(inner) : false;
|
|
412
|
+
return `${driverRel ?? "?"}|${correlated}`;
|
|
413
|
+
}
|
|
414
|
+
/** Build the REPEATED_INNER_LOOP verdict for a nested loop. Diagnose, don't
|
|
415
|
+
* prescribe: decompose the cost into loops × per-loop, name what drives the
|
|
416
|
+
* per-loop cost, and — when the inner is a correlated subquery — state the
|
|
417
|
+
* load-bearing fact that an index can't remove the per-row repetition. No
|
|
418
|
+
* rewrite advice: whether a rewrite helps is conditional and a human's call. */
|
|
419
|
+
function buildLoopFinding(node, self, rootTotal) {
|
|
420
|
+
const share = clampShare(self, rootTotal);
|
|
421
|
+
const pct = Math.round(share * 100);
|
|
422
|
+
const { outer, inner } = loopSides(node);
|
|
423
|
+
const loops = outer ? num(outer, "Plan Rows") : void 0;
|
|
424
|
+
const loopsText = loops !== void 0 ? `~${loops.toLocaleString("en-US")}` : "many";
|
|
425
|
+
const correlated = (inner ? innerIsCorrelatedSubquery(inner) : false) || childNodes(node).some(isSubplanChild);
|
|
426
|
+
const driver = loops !== void 0 ? multipliedDriver(node, loops) : void 0;
|
|
427
|
+
const driverNode = driver?.node;
|
|
428
|
+
const driverRel = driverNode ? relationOf(driverNode) : void 0;
|
|
429
|
+
const driverType = driverNode ? str(driverNode, "Node Type") : void 0;
|
|
430
|
+
const driverShare = driver ? clampShare(driver.multiplied, rootTotal) : void 0;
|
|
431
|
+
const driverPct = driverShare !== void 0 ? Math.round(driverShare * 100) : void 0;
|
|
432
|
+
const driverText = driverRel && driverType && driverPct !== void 0 ? ` Most of it is the ${driverType} on \`${driverRel}\`, run ${loopsText} times (about ${driverPct}% of the query).` : ``;
|
|
433
|
+
return {
|
|
434
|
+
code: "REPEATED_INNER_LOOP",
|
|
435
|
+
severity: share >= COST_CONCENTRATION_WARN_SHARE ? "warning" : "info",
|
|
436
|
+
impact: share,
|
|
437
|
+
title: correlated ? "Correlated subquery runs once per row" : "Inner side re-runs once per row",
|
|
438
|
+
detail: `A nested loop re-runs its inner side once per row (${loopsText} rows), so about ${pct}% of the cost is that repetition, not one node.${driverText}${correlated ? ` It's a correlated subquery, so an index can speed each run but won't cut the repeats.` : ``}`,
|
|
439
|
+
...correlated ? { lead: setBasedLead(driverRel) } : {},
|
|
440
|
+
evidence: {
|
|
441
|
+
...loops !== void 0 ? { loops } : {},
|
|
442
|
+
...correlated ? { innerKind: "correlated subquery" } : {},
|
|
443
|
+
...driverRel ? { driver: driverRel } : {},
|
|
444
|
+
...driverPct !== void 0 ? { driverShare: `${driverPct}%` } : {},
|
|
445
|
+
costShare: `${pct}%`
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function escapeRegExp(value) {
|
|
450
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
451
|
+
}
|
|
452
|
+
const CONDITION_KEYS = [
|
|
453
|
+
"Hash Cond",
|
|
454
|
+
"Join Filter",
|
|
455
|
+
"Filter",
|
|
456
|
+
"Recheck Cond",
|
|
457
|
+
"Merge Cond",
|
|
458
|
+
"Index Cond"
|
|
459
|
+
];
|
|
460
|
+
/**
|
|
461
|
+
* Relations whose columns are wrapped in a function (or cast) inside some plan
|
|
462
|
+
* condition — i.e. the relations a function-on-column actually blocks an index
|
|
463
|
+
* on. The function-on-column nudge is AST-only and doesn't know which table is
|
|
464
|
+
* affected; reading it off the plan's condition strings (e.g. `lower(cfg.repo)`,
|
|
465
|
+
* mapping the alias back to its relation) is what stops us from blaming an
|
|
466
|
+
* unrelated table that just happens to be sequentially scanned.
|
|
467
|
+
*/
|
|
468
|
+
function relationsWrappedInFunction(root) {
|
|
469
|
+
const conditions = [];
|
|
470
|
+
const aliasToRelation = /* @__PURE__ */ new Map();
|
|
471
|
+
walk(root, (node) => {
|
|
472
|
+
for (const key of CONDITION_KEYS) {
|
|
473
|
+
const value = node[key];
|
|
474
|
+
if (typeof value === "string") conditions.push(value);
|
|
475
|
+
}
|
|
476
|
+
const relation = str(node, "Relation Name");
|
|
477
|
+
if (relation) {
|
|
478
|
+
aliasToRelation.set(relation, relation);
|
|
479
|
+
const alias = str(node, "Alias");
|
|
480
|
+
if (alias) aliasToRelation.set(alias, relation);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
const wrapped = /* @__PURE__ */ new Set();
|
|
484
|
+
for (const [aliasOrName, relation] of aliasToRelation) {
|
|
485
|
+
const ref = escapeRegExp(aliasOrName);
|
|
486
|
+
const inFunction = new RegExp(`[a-z_][a-z0-9_]*\\([^()]*\\b${ref}\\.`, "i");
|
|
487
|
+
const inCast = new RegExp(`\\b${ref}\\.[a-z_][a-z0-9_]*\\s*::`, "i");
|
|
488
|
+
if (conditions.some((c) => inFunction.test(c) || inCast.test(c))) wrapped.add(relation);
|
|
489
|
+
}
|
|
490
|
+
return wrapped;
|
|
491
|
+
}
|
|
492
|
+
function planOf(optimization) {
|
|
493
|
+
if (!optimization) return void 0;
|
|
494
|
+
if (optimization.state === "improvements_available" || optimization.state === "no_improvement_found") return optimization.explainPlan;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Compute the plan-aware findings for one query from its stored optimization and
|
|
498
|
+
* nudges. Returns [] when there's no plan to reason over (waiting, optimizing,
|
|
499
|
+
* not_supported, timeout, error) — the syntactic nudges still stand on their own.
|
|
500
|
+
*/
|
|
501
|
+
function analyzeQueryFindings(optimization, nudges = []) {
|
|
502
|
+
const plan = planOf(optimization);
|
|
503
|
+
if (!plan) return [];
|
|
504
|
+
const root = asNode(plan);
|
|
505
|
+
const rootTotal = num(root, "Total Cost") ?? 0;
|
|
506
|
+
if (rootTotal < MIN_QUERY_COST) return [];
|
|
507
|
+
const recommendedDefs = optimization?.state === "improvements_available" ? optimization.indexRecommendations.map((rec) => rec.definition).filter((def) => typeof def === "string" && def !== "") : [];
|
|
508
|
+
const recommendedClause = recommendedDefs.length > 0 ? ` The recommended index ${recommendedDefs.map((def) => `\`${def}\``).join(" / ")} would help this.` : "";
|
|
509
|
+
const scanIndexClause = recommendedClause || (optimization?.state === "no_improvement_found" ? " The optimizer checked for an index and found none that helps here." : " An index on the filtered or joined columns would likely remove it.");
|
|
510
|
+
const nodes = [];
|
|
511
|
+
walk(root, (node) => nodes.push({
|
|
512
|
+
node,
|
|
513
|
+
self: selfCost(node)
|
|
514
|
+
}));
|
|
515
|
+
const executions = /* @__PURE__ */ new Map();
|
|
516
|
+
const countExecutions = (node, runs) => {
|
|
517
|
+
executions.set(node, runs);
|
|
518
|
+
let innerChild;
|
|
519
|
+
let outerRows = 1;
|
|
520
|
+
if (str(node, "Node Type") === "Nested Loop") {
|
|
521
|
+
const sides = loopSides(node);
|
|
522
|
+
innerChild = sides.inner;
|
|
523
|
+
outerRows = sides.outer ? num(sides.outer, "Plan Rows") ?? 1 : 1;
|
|
524
|
+
}
|
|
525
|
+
for (const child of childNodes(node)) countExecutions(child, child === innerChild ? runs * outerRows : runs);
|
|
526
|
+
};
|
|
527
|
+
countExecutions(root, 1);
|
|
528
|
+
const multipliedCost = (node) => selfCost(node) * (executions.get(node) ?? 1);
|
|
529
|
+
const findings = [];
|
|
530
|
+
const reported = /* @__PURE__ */ new Set();
|
|
531
|
+
if (rootTotal > 0) {
|
|
532
|
+
const loopNodes = nodes.filter(({ node, self }) => str(node, "Node Type") === "Nested Loop" && clampShare(self, rootTotal) >= LOOP_SHARE && loopRatio(node) > LOOP_RATIO).sort((a, b) => b.self - a.self);
|
|
533
|
+
const seenSignatures = /* @__PURE__ */ new Set();
|
|
534
|
+
for (const { node, self } of loopNodes) {
|
|
535
|
+
if (reported.size >= MAX_LOOP_FINDINGS) break;
|
|
536
|
+
const sig = loopSignature(node);
|
|
537
|
+
if (seenSignatures.has(sig)) continue;
|
|
538
|
+
seenSignatures.add(sig);
|
|
539
|
+
findings.push(buildLoopFinding(node, self, rootTotal));
|
|
540
|
+
reported.add(node);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (rootTotal > 0 && nodes.length > 1) {
|
|
544
|
+
const top = nodes.reduce((best, candidate) => candidate.self > best.self ? candidate : best);
|
|
545
|
+
const share = clampShare(top.self, rootTotal);
|
|
546
|
+
if (share >= COST_CONCENTRATION_SHARE && !reported.has(top.node)) {
|
|
547
|
+
const nodeType = str(top.node, "Node Type");
|
|
548
|
+
if (nodeType === "Nested Loop") findings.push(buildLoopFinding(top.node, top.self, rootTotal));
|
|
549
|
+
else if (subplanChildren(top.node).length > 0) findings.push(buildSubplanFinding(top.node, top.self, rootTotal));
|
|
550
|
+
else if (INDEX_SERVED_NODES.has(nodeType ?? "") && optimization?.state === "no_improvement_found") {
|
|
551
|
+
if (isUnfilteredFullRead(top.node)) findings.push(buildFullTableReadFinding(top.node, share, aggregateShape(root)));
|
|
552
|
+
} else {
|
|
553
|
+
const pct = Math.round(share * 100);
|
|
554
|
+
findings.push({
|
|
555
|
+
code: "COST_CONCENTRATION",
|
|
556
|
+
severity: share >= COST_CONCENTRATION_WARN_SHARE ? "warning" : "info",
|
|
557
|
+
impact: share,
|
|
558
|
+
title: `${pct}% of the cost is one node`,
|
|
559
|
+
detail: `About ${pct}% of this query's estimated cost is a single ${nodeLabel(top.node)}. ${share >= COST_CONCENTRATION_WARN_SHARE ? "It's the one thing worth tuning here." : "It's the biggest single contributor."}${whyExpensive(top.node)}${nodeType === "Seq Scan" ? selectivityClause(top.node) + scanIndexClause : sortHint(nodeType) + recommendedClause}`,
|
|
560
|
+
evidence: {
|
|
561
|
+
node: nodeLabel(top.node),
|
|
562
|
+
costShare: `${pct}%`,
|
|
563
|
+
nodeCost: Math.round(top.self),
|
|
564
|
+
totalCost: Math.round(rootTotal)
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
reported.add(top.node);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const seqScansByRelation = /* @__PURE__ */ new Map();
|
|
572
|
+
for (const { node, self } of nodes) {
|
|
573
|
+
if (str(node, "Node Type") !== "Seq Scan") continue;
|
|
574
|
+
if (reported.has(node)) continue;
|
|
575
|
+
const relation = str(node, "Relation Name") ?? str(node, "Alias") ?? "?";
|
|
576
|
+
const rows = num(node, "Plan Rows");
|
|
577
|
+
const entry = seqScansByRelation.get(relation) ?? {
|
|
578
|
+
self: 0,
|
|
579
|
+
rows: void 0,
|
|
580
|
+
topNode: node,
|
|
581
|
+
topSelf: -1
|
|
582
|
+
};
|
|
583
|
+
entry.self += self;
|
|
584
|
+
if (self > entry.topSelf) {
|
|
585
|
+
entry.topSelf = self;
|
|
586
|
+
entry.topNode = node;
|
|
587
|
+
}
|
|
588
|
+
if (rows !== void 0) entry.rows = Math.max(entry.rows ?? 0, rows);
|
|
589
|
+
seqScansByRelation.set(relation, entry);
|
|
590
|
+
}
|
|
591
|
+
for (const [relation, { self, rows, topNode }] of seqScansByRelation) {
|
|
592
|
+
const share = clampShare(self, rootTotal);
|
|
593
|
+
if (!(share >= SEQ_SCAN_COST_SHARE) && !(rows !== void 0 && rows >= SEQ_SCAN_ROWS)) continue;
|
|
594
|
+
const pct = Math.round(share * 100);
|
|
595
|
+
const named = relation !== "?";
|
|
596
|
+
const selectivity = selectivityClause(topNode);
|
|
597
|
+
const rowsText = !selectivity && rows !== void 0 ? ` (~${rows.toLocaleString("en-US")} rows)` : "";
|
|
598
|
+
const scanned = selectivity ? rowsScanned(topNode) : void 0;
|
|
599
|
+
findings.push({
|
|
600
|
+
code: "EXPENSIVE_SEQ_SCAN",
|
|
601
|
+
severity: "warning",
|
|
602
|
+
impact: share,
|
|
603
|
+
title: `Sequential scan${named ? ` on ${relation}` : ""}`,
|
|
604
|
+
detail: `${named ? `\`${relation}\`` : "A table"} is read with a full sequential scan${rowsText}, about ${pct}% of the query's cost.${whyExpensive(topNode)}${selectivity}${scanIndexClause}`,
|
|
605
|
+
evidence: {
|
|
606
|
+
...named ? { relation } : {},
|
|
607
|
+
...rows !== void 0 ? { rows } : {},
|
|
608
|
+
...scanned !== void 0 ? { scanned } : {},
|
|
609
|
+
costShare: `${pct}%`
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (nudges.some((nudge) => nudge.kind === "MISSING_LIMIT_CLAUSE")) {
|
|
614
|
+
const rows = num(root, "Plan Rows");
|
|
615
|
+
const width = num(root, "Plan Width");
|
|
616
|
+
if (rows !== void 0 && width !== void 0) {
|
|
617
|
+
const bytes = rows * width;
|
|
618
|
+
if (bytes >= WIDE_RESULT_BYTES) findings.push({
|
|
619
|
+
code: "WIDE_RESULT_NO_LIMIT",
|
|
620
|
+
severity: bytes >= WIDE_RESULT_WARN_BYTES ? "warning" : "info",
|
|
621
|
+
impact: Math.min(.9, bytes / 2e6),
|
|
622
|
+
title: "Unbounded result set",
|
|
623
|
+
detail: `No LIMIT, and the query returns ~${rows.toLocaleString("en-US")} rows of ~${width} bytes (~${formatBytes(bytes)} total). That width is a planner estimate and undercounts jsonb and large-text columns, so the real payload is likely bigger. To shrink it, drop columns from the SELECT or bound the rows.`,
|
|
624
|
+
evidence: {
|
|
625
|
+
rows,
|
|
626
|
+
rowWidth: width,
|
|
627
|
+
estimatedPayload: formatBytes(bytes)
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (nudges.some((nudge) => nudge.kind === "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE")) {
|
|
633
|
+
const wrapped = relationsWrappedInFunction(root);
|
|
634
|
+
const blocked = [...new Set(nodes.filter(({ node }) => str(node, "Node Type") === "Seq Scan").map(({ node }) => str(node, "Relation Name")).filter((relation) => relation !== void 0))].filter((relation) => wrapped.has(relation));
|
|
635
|
+
if (blocked.length > 0) {
|
|
636
|
+
const relationList = blocked.map((r) => `\`${r}\``).join(", ");
|
|
637
|
+
const blockedCost = nodes.filter(({ node }) => str(node, "Node Type") === "Seq Scan" && blocked.includes(str(node, "Relation Name") ?? "")).reduce((sum, { node }) => sum + multipliedCost(node), 0);
|
|
638
|
+
findings.push({
|
|
639
|
+
code: "FUNCTION_ON_COLUMN_BLOCKS_INDEX",
|
|
640
|
+
severity: "warning",
|
|
641
|
+
impact: clampShare(blockedCost, rootTotal),
|
|
642
|
+
title: "A function on a column blocks an index",
|
|
643
|
+
detail: `A condition wraps ${relationList}'s column in a function (e.g. \`lower(col)\`), so Postgres scans the table instead of using an index. Compare the bare column, or add an expression index for the function.${recommendedClause}`,
|
|
644
|
+
evidence: { sequentialScans: relationList }
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const reportedSortKeys = /* @__PURE__ */ new Set();
|
|
649
|
+
const topNSorts = sortsBoundedByLimit(root);
|
|
650
|
+
for (const { node, self } of nodes) {
|
|
651
|
+
if (str(node, "Node Type") !== "Sort") continue;
|
|
652
|
+
if (reported.has(node)) continue;
|
|
653
|
+
const share = clampShare(self, rootTotal);
|
|
654
|
+
if (share < SORT_COST_SHARE) continue;
|
|
655
|
+
const sortKey = sortKeyText(node);
|
|
656
|
+
const dedupeKey = sortKey ?? "";
|
|
657
|
+
if (reportedSortKeys.has(dedupeKey)) continue;
|
|
658
|
+
reportedSortKeys.add(dedupeKey);
|
|
659
|
+
const pct = Math.round(share * 100);
|
|
660
|
+
const advice = sortKeyIsIndexable(sortKey) ? topNSorts.has(node) ? "An index in that order would skip the sort and, with the LIMIT, let Postgres stop after the first rows instead of ordering the whole set." : "An index in that order would let Postgres skip the sort." : "The sort key is computed at runtime (an aggregate or subquery result), so no index can pre-sort it.";
|
|
661
|
+
findings.push({
|
|
662
|
+
code: "SORT_WITHOUT_INDEX",
|
|
663
|
+
severity: share >= SORT_WARN_SHARE ? "warning" : "info",
|
|
664
|
+
impact: share,
|
|
665
|
+
title: "In-memory sort",
|
|
666
|
+
detail: `Rows are sorted in memory${sortKey ? ` by \`${sortKey}\`` : ""}, about ${pct}% of the query's cost. ${advice}`,
|
|
667
|
+
evidence: {
|
|
668
|
+
...sortKey ? { sortKey } : {},
|
|
669
|
+
costShare: `${pct}%`
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
const explained = /* @__PURE__ */ new Set();
|
|
674
|
+
for (const finding of findings) {
|
|
675
|
+
if (finding.code === "FUNCTION_ON_COLUMN_BLOCKS_INDEX") {
|
|
676
|
+
const list = String(finding.evidence?.sequentialScans ?? "");
|
|
677
|
+
for (const match of list.matchAll(/`([^`]+)`/g)) explained.add(match[1]);
|
|
678
|
+
}
|
|
679
|
+
if (finding.code === "REPEATED_INNER_LOOP" && finding.evidence?.driver) explained.add(String(finding.evidence.driver));
|
|
680
|
+
}
|
|
681
|
+
const deduped = findings.filter((finding) => !(finding.code === "EXPENSIVE_SEQ_SCAN" && explained.has(String(finding.evidence?.relation ?? ""))));
|
|
682
|
+
deduped.sort((a, b) => b.impact - a.impact);
|
|
683
|
+
return deduped;
|
|
684
|
+
}
|
|
685
|
+
/** Whether an index could pre-order this sort. A plain column (or a function of
|
|
686
|
+
* one) can be served by an index; a sort on an aggregate, a subquery result, or
|
|
687
|
+
* a computed comparison is produced at runtime, so no index can pre-order it —
|
|
688
|
+
* promising one would be false advice. Unknown keys keep the generic advice. */
|
|
689
|
+
function sortKeyIsIndexable(sortKey) {
|
|
690
|
+
if (!sortKey) return true;
|
|
691
|
+
if (/\bSubPlan\b/i.test(sortKey)) return false;
|
|
692
|
+
if (/\b(count|sum|avg|min|max|jsonb_agg|array_agg|string_agg|bool_or|bool_and|row_number|rank|dense_rank|ntile)\s*\(/i.test(sortKey)) return false;
|
|
693
|
+
if (/\s(=|<|>|<=|>=|<>)\s/.test(sortKey)) return false;
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
/** The Sort node's ORDER BY, as a compact string. `Sort Key` is a string array
|
|
697
|
+
* (e.g. ["created_at DESC", "id DESC"]); join it and trim runaway length. */
|
|
698
|
+
function sortKeyText(node) {
|
|
699
|
+
const raw = node["Sort Key"];
|
|
700
|
+
if (!Array.isArray(raw)) return void 0;
|
|
701
|
+
const keys = raw.filter((k) => typeof k === "string");
|
|
702
|
+
if (keys.length === 0) return void 0;
|
|
703
|
+
const joined = keys.join(", ");
|
|
704
|
+
return joined.length > 120 ? `${joined.slice(0, 117)}…` : joined;
|
|
705
|
+
}
|
|
706
|
+
//#endregion
|
|
707
|
+
export { analyzeQueryFindings };
|
|
708
|
+
|
|
709
|
+
//# sourceMappingURL=query-findings.mjs.map
|