@tabularis/explain 0.1.0
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 +201 -0
- package/README.md +104 -0
- package/dist/diagnostics-DPSgsjFk.d.ts +157 -0
- package/dist/flow.d.ts +40 -0
- package/dist/flow.js +171 -0
- package/dist/index.d.ts +325 -0
- package/dist/index.js +1792 -0
- package/dist/react.d.ts +91 -0
- package/dist/react.js +1617 -0
- package/package.json +95 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1792 @@
|
|
|
1
|
+
// src/parsers/node.ts
|
|
2
|
+
var NodeIdAllocator = class {
|
|
3
|
+
counter = 0;
|
|
4
|
+
next() {
|
|
5
|
+
const id = `node_${this.counter}`;
|
|
6
|
+
this.counter += 1;
|
|
7
|
+
return id;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function createExplainNode(id, overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
node_type: "Unknown",
|
|
14
|
+
relation: null,
|
|
15
|
+
startup_cost: null,
|
|
16
|
+
total_cost: null,
|
|
17
|
+
plan_rows: null,
|
|
18
|
+
actual_rows: null,
|
|
19
|
+
actual_time_ms: null,
|
|
20
|
+
actual_loops: null,
|
|
21
|
+
buffers_hit: null,
|
|
22
|
+
buffers_read: null,
|
|
23
|
+
filter: null,
|
|
24
|
+
index_condition: null,
|
|
25
|
+
join_type: null,
|
|
26
|
+
hash_condition: null,
|
|
27
|
+
extra: {},
|
|
28
|
+
children: [],
|
|
29
|
+
...overrides
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function hasAnalyzeDataRecursive(node) {
|
|
33
|
+
if (node.actual_rows !== null || node.actual_time_ms !== null) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return node.children.some(hasAnalyzeDataRecursive);
|
|
37
|
+
}
|
|
38
|
+
function asObject(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
40
|
+
}
|
|
41
|
+
function asArray(value) {
|
|
42
|
+
return Array.isArray(value) ? value : null;
|
|
43
|
+
}
|
|
44
|
+
function asString(value) {
|
|
45
|
+
return typeof value === "string" ? value : null;
|
|
46
|
+
}
|
|
47
|
+
function asNumber(value) {
|
|
48
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
49
|
+
}
|
|
50
|
+
function asLooseNumber(value) {
|
|
51
|
+
const direct = asNumber(value);
|
|
52
|
+
if (direct !== null) return direct;
|
|
53
|
+
const text = asString(value);
|
|
54
|
+
if (text === null || text.trim() === "") return null;
|
|
55
|
+
const parsed = Number(text);
|
|
56
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
57
|
+
}
|
|
58
|
+
function asLoopCount(value) {
|
|
59
|
+
const num = asNumber(value);
|
|
60
|
+
return num === null ? null : Math.trunc(num);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/parsers/mysql.ts
|
|
64
|
+
function parseMysqlJson(raw) {
|
|
65
|
+
let value;
|
|
66
|
+
try {
|
|
67
|
+
value = JSON.parse(raw);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new Error(`Failed to parse EXPLAIN JSON: ${String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
const doc = asObject(value);
|
|
72
|
+
const queryBlock = doc?.["query_block"];
|
|
73
|
+
if (queryBlock === void 0) {
|
|
74
|
+
throw new Error("EXPLAIN JSON missing 'query_block' key");
|
|
75
|
+
}
|
|
76
|
+
const ids = new NodeIdAllocator();
|
|
77
|
+
const root = parseMysqlQueryBlock(queryBlock, ids);
|
|
78
|
+
const planningTime = asNumber(asObject(doc?.["query_optimization"])?.["r_total_time_ms"]);
|
|
79
|
+
return {
|
|
80
|
+
root,
|
|
81
|
+
planning_time_ms: planningTime,
|
|
82
|
+
execution_time_ms: null,
|
|
83
|
+
original_query: "",
|
|
84
|
+
driver: "mysql",
|
|
85
|
+
has_analyze_data: hasAnalyzeDataRecursive(root),
|
|
86
|
+
raw_output: raw
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function parseMysqlText(raw) {
|
|
90
|
+
if (raw.trim() === "") {
|
|
91
|
+
throw new Error("EXPLAIN ANALYZE returned no output");
|
|
92
|
+
}
|
|
93
|
+
const ids = new NodeIdAllocator();
|
|
94
|
+
const root = parseMysqlAnalyzeText(raw, ids);
|
|
95
|
+
return {
|
|
96
|
+
root,
|
|
97
|
+
planning_time_ms: null,
|
|
98
|
+
execution_time_ms: null,
|
|
99
|
+
original_query: "",
|
|
100
|
+
driver: "mysql",
|
|
101
|
+
has_analyze_data: hasAnalyzeDataRecursive(root),
|
|
102
|
+
raw_output: raw
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function parseMariadbFilesort(value, ids) {
|
|
106
|
+
const id = ids.next();
|
|
107
|
+
const filesort = asObject(value) ?? {};
|
|
108
|
+
const extra = {};
|
|
109
|
+
for (const key of [
|
|
110
|
+
"sort_key",
|
|
111
|
+
"r_sort_mode",
|
|
112
|
+
"r_buffer_size",
|
|
113
|
+
"r_limit",
|
|
114
|
+
"r_used_priority_queue"
|
|
115
|
+
]) {
|
|
116
|
+
if (filesort[key] !== void 0) {
|
|
117
|
+
extra[key] = filesort[key];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const children = [];
|
|
121
|
+
if (filesort["temporary_table"] !== void 0) {
|
|
122
|
+
children.push(parseMariadbTemporaryTable(filesort["temporary_table"], ids));
|
|
123
|
+
}
|
|
124
|
+
const nestedLoop = asArray(filesort["nested_loop"]);
|
|
125
|
+
if (nestedLoop !== null) {
|
|
126
|
+
for (const item of nestedLoop) {
|
|
127
|
+
children.push(parseMysqlQueryBlock(item, ids));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (filesort["ordering_operation"] !== void 0) {
|
|
131
|
+
children.push(parseMysqlQueryBlock(filesort["ordering_operation"], ids));
|
|
132
|
+
}
|
|
133
|
+
if (filesort["table"] !== void 0 && filesort["nested_loop"] === void 0 && filesort["temporary_table"] === void 0) {
|
|
134
|
+
children.push(parseMysqlQueryBlock(filesort, ids));
|
|
135
|
+
}
|
|
136
|
+
return createExplainNode(id, {
|
|
137
|
+
node_type: "Filesort",
|
|
138
|
+
actual_rows: asNumber(filesort["r_output_rows"]),
|
|
139
|
+
actual_time_ms: asNumber(filesort["r_total_time_ms"]),
|
|
140
|
+
actual_loops: asLoopCount(filesort["r_loops"]),
|
|
141
|
+
extra,
|
|
142
|
+
children
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function parseMariadbTemporaryTable(value, ids) {
|
|
146
|
+
const id = ids.next();
|
|
147
|
+
const tmpTbl = asObject(value) ?? {};
|
|
148
|
+
const children = [];
|
|
149
|
+
const nestedLoop = asArray(tmpTbl["nested_loop"]);
|
|
150
|
+
if (nestedLoop !== null) {
|
|
151
|
+
for (const item of nestedLoop) {
|
|
152
|
+
children.push(parseMysqlQueryBlock(item, ids));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (tmpTbl["filesort"] !== void 0) {
|
|
156
|
+
children.push(parseMariadbFilesort(tmpTbl["filesort"], ids));
|
|
157
|
+
}
|
|
158
|
+
return createExplainNode(id, { node_type: "Temporary Table", children });
|
|
159
|
+
}
|
|
160
|
+
function parseMariadbSubqueryCache(value, ids) {
|
|
161
|
+
const id = ids.next();
|
|
162
|
+
const cache = asObject(value) ?? {};
|
|
163
|
+
const extra = {};
|
|
164
|
+
if (cache["r_hit_ratio"] !== void 0) {
|
|
165
|
+
extra["r_hit_ratio"] = cache["r_hit_ratio"];
|
|
166
|
+
}
|
|
167
|
+
const children = [];
|
|
168
|
+
if (cache["query_block"] !== void 0) {
|
|
169
|
+
children.push(parseMysqlQueryBlock(cache["query_block"], ids));
|
|
170
|
+
}
|
|
171
|
+
return createExplainNode(id, {
|
|
172
|
+
node_type: "Subquery Cache",
|
|
173
|
+
actual_loops: asLoopCount(cache["r_loops"]),
|
|
174
|
+
extra,
|
|
175
|
+
children
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function parseMariadbWrapper(value, label, ids) {
|
|
179
|
+
const id = ids.next();
|
|
180
|
+
const obj = asObject(value) ?? {};
|
|
181
|
+
const children = [];
|
|
182
|
+
if (obj["table"] !== void 0) {
|
|
183
|
+
children.push(parseMysqlQueryBlock(obj, ids));
|
|
184
|
+
}
|
|
185
|
+
const nestedLoop = asArray(obj["nested_loop"]);
|
|
186
|
+
if (nestedLoop !== null) {
|
|
187
|
+
for (const item of nestedLoop) {
|
|
188
|
+
children.push(parseMysqlQueryBlock(item, ids));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (asObject(obj["filesort"]) !== null) {
|
|
192
|
+
children.push(parseMariadbFilesort(obj["filesort"], ids));
|
|
193
|
+
}
|
|
194
|
+
if (obj["temporary_table"] !== void 0) {
|
|
195
|
+
children.push(parseMariadbTemporaryTable(obj["temporary_table"], ids));
|
|
196
|
+
}
|
|
197
|
+
if (obj["query_block"] !== void 0) {
|
|
198
|
+
children.push(parseMysqlQueryBlock(obj["query_block"], ids));
|
|
199
|
+
}
|
|
200
|
+
const specs = asArray(obj["query_specifications"]);
|
|
201
|
+
if (specs !== null) {
|
|
202
|
+
for (const spec of specs) {
|
|
203
|
+
children.push(parseMysqlQueryBlock(spec, ids));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (obj["ordering_operation"] !== void 0) {
|
|
207
|
+
children.push(parseMysqlQueryBlock(obj["ordering_operation"], ids));
|
|
208
|
+
}
|
|
209
|
+
if (obj["grouping_operation"] !== void 0) {
|
|
210
|
+
children.push(parseMysqlQueryBlock(obj["grouping_operation"], ids));
|
|
211
|
+
}
|
|
212
|
+
const subs = asArray(obj["attached_subqueries"]);
|
|
213
|
+
if (subs !== null) {
|
|
214
|
+
for (const sq of subs) {
|
|
215
|
+
children.push(parseMysqlQueryBlock(sq, ids));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return createExplainNode(id, {
|
|
219
|
+
node_type: label,
|
|
220
|
+
total_cost: asLooseNumber(obj["cost"]),
|
|
221
|
+
actual_rows: asNumber(obj["r_rows"]) ?? asNumber(obj["r_output_rows"]),
|
|
222
|
+
actual_time_ms: asNumber(obj["r_total_time_ms"]),
|
|
223
|
+
actual_loops: asLoopCount(obj["r_loops"]),
|
|
224
|
+
children
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
var MYSQL_ACCESS_TYPES = {
|
|
228
|
+
ALL: "Full Table Scan",
|
|
229
|
+
index: "Index Scan",
|
|
230
|
+
range: "Range Scan",
|
|
231
|
+
ref: "Index Lookup",
|
|
232
|
+
eq_ref: "Unique Index Lookup",
|
|
233
|
+
const: "Const Lookup",
|
|
234
|
+
system: "Const Lookup",
|
|
235
|
+
fulltext: "Fulltext Search"
|
|
236
|
+
};
|
|
237
|
+
function parseMysqlQueryBlock(value, ids) {
|
|
238
|
+
const id = ids.next();
|
|
239
|
+
const block = asObject(value) ?? {};
|
|
240
|
+
let nodeType;
|
|
241
|
+
let relation = null;
|
|
242
|
+
let planRows = null;
|
|
243
|
+
let startupCost = null;
|
|
244
|
+
let totalCost = null;
|
|
245
|
+
let filter = null;
|
|
246
|
+
const table = asObject(block["table"]);
|
|
247
|
+
if (table !== null) {
|
|
248
|
+
const access = asString(table["access_type"]) ?? "ALL";
|
|
249
|
+
nodeType = MYSQL_ACCESS_TYPES[access] ?? access;
|
|
250
|
+
relation = asString(table["table_name"]);
|
|
251
|
+
planRows = asNumber(table["rows_examined_per_scan"]) ?? asNumber(table["rows"]);
|
|
252
|
+
const costInfo = asObject(table["cost_info"]);
|
|
253
|
+
startupCost = asLooseNumber(costInfo?.["read_cost"]);
|
|
254
|
+
totalCost = asLooseNumber(costInfo?.["prefix_cost"]) ?? startupCost ?? asLooseNumber(table["cost"]);
|
|
255
|
+
filter = asString(table["attached_condition"]);
|
|
256
|
+
} else {
|
|
257
|
+
if (asObject(block)?.["using_filesort"] === true) {
|
|
258
|
+
nodeType = "Filesort";
|
|
259
|
+
} else if (block["grouping_operation"] !== void 0) {
|
|
260
|
+
nodeType = "Group";
|
|
261
|
+
} else if (block["duplicates_removal"] !== void 0) {
|
|
262
|
+
nodeType = "Duplicate Removal";
|
|
263
|
+
} else if (block["having_condition"] !== void 0) {
|
|
264
|
+
nodeType = "Having Filter";
|
|
265
|
+
} else if (block["window_functions_computation"] !== void 0) {
|
|
266
|
+
nodeType = "Window Functions";
|
|
267
|
+
} else {
|
|
268
|
+
nodeType = "Query Block";
|
|
269
|
+
}
|
|
270
|
+
const costInfo = asObject(block["cost_info"]);
|
|
271
|
+
totalCost = asLooseNumber(
|
|
272
|
+
costInfo?.["query_cost"] ?? costInfo?.["sort_cost"] ?? costInfo?.["prefix_cost"]
|
|
273
|
+
) ?? asLooseNumber(block["cost"]);
|
|
274
|
+
filter = asString(block["having_condition"]);
|
|
275
|
+
}
|
|
276
|
+
const knownKeys = [
|
|
277
|
+
"access_type",
|
|
278
|
+
"table_name",
|
|
279
|
+
"rows_examined_per_scan",
|
|
280
|
+
"rows",
|
|
281
|
+
"cost_info",
|
|
282
|
+
"attached_condition",
|
|
283
|
+
"key",
|
|
284
|
+
"possible_keys",
|
|
285
|
+
"used_key_parts"
|
|
286
|
+
];
|
|
287
|
+
const extra = {};
|
|
288
|
+
if (table !== null) {
|
|
289
|
+
for (const [k, v] of Object.entries(table)) {
|
|
290
|
+
if (!knownKeys.includes(k)) {
|
|
291
|
+
extra[k] = v;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const key = asString(table["key"]);
|
|
295
|
+
if (key !== null) {
|
|
296
|
+
extra["key"] = key;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const children = [];
|
|
300
|
+
const nestedLoop = asArray(block["nested_loop"]);
|
|
301
|
+
if (nestedLoop !== null) {
|
|
302
|
+
for (const rawItem of nestedLoop) {
|
|
303
|
+
const item = asObject(rawItem) ?? {};
|
|
304
|
+
if (item["table"] !== void 0) {
|
|
305
|
+
children.push(parseMysqlQueryBlock(item, ids));
|
|
306
|
+
} else if (item["read_sorted_file"] !== void 0) {
|
|
307
|
+
children.push(parseMariadbWrapper(item["read_sorted_file"], "Read Sorted File", ids));
|
|
308
|
+
} else if (asObject(item["filesort"]) !== null) {
|
|
309
|
+
children.push(parseMariadbFilesort(item["filesort"], ids));
|
|
310
|
+
} else if (item["temporary_table"] !== void 0) {
|
|
311
|
+
children.push(parseMariadbTemporaryTable(item["temporary_table"], ids));
|
|
312
|
+
} else if (item["materialized"] !== void 0) {
|
|
313
|
+
children.push(parseMariadbWrapper(item["materialized"], "Materialized Subquery", ids));
|
|
314
|
+
} else if (item["buffer_result"] !== void 0) {
|
|
315
|
+
children.push(parseMariadbWrapper(item["buffer_result"], "Buffer Result", ids));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (block["ordering_operation"] !== void 0) {
|
|
320
|
+
children.push(parseMysqlQueryBlock(block["ordering_operation"], ids));
|
|
321
|
+
}
|
|
322
|
+
if (block["grouping_operation"] !== void 0) {
|
|
323
|
+
children.push(parseMysqlQueryBlock(block["grouping_operation"], ids));
|
|
324
|
+
}
|
|
325
|
+
if (block["duplicates_removal"] !== void 0) {
|
|
326
|
+
children.push(parseMysqlQueryBlock(block["duplicates_removal"], ids));
|
|
327
|
+
}
|
|
328
|
+
for (const listKey of ["optimized_away_subqueries", "attached_subqueries"]) {
|
|
329
|
+
const list = asArray(block[listKey]);
|
|
330
|
+
if (list !== null) {
|
|
331
|
+
for (const sq of list) {
|
|
332
|
+
children.push(parseMysqlQueryBlock(sq, ids));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const subqueries = asArray(block["subqueries"]);
|
|
337
|
+
if (subqueries !== null) {
|
|
338
|
+
for (const rawSq of subqueries) {
|
|
339
|
+
const sq = asObject(rawSq) ?? {};
|
|
340
|
+
if (sq["subquery_cache"] !== void 0) {
|
|
341
|
+
children.push(parseMariadbSubqueryCache(sq["subquery_cache"], ids));
|
|
342
|
+
} else if (sq["query_block"] !== void 0 || sq["table"] !== void 0) {
|
|
343
|
+
children.push(parseMysqlQueryBlock(sq, ids));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (asObject(block["filesort"]) !== null) {
|
|
348
|
+
children.push(parseMariadbFilesort(block["filesort"], ids));
|
|
349
|
+
}
|
|
350
|
+
if (block["temporary_table"] !== void 0) {
|
|
351
|
+
children.push(parseMariadbTemporaryTable(block["temporary_table"], ids));
|
|
352
|
+
}
|
|
353
|
+
if (block["materialized"] !== void 0) {
|
|
354
|
+
children.push(parseMariadbWrapper(block["materialized"], "Materialized Subquery", ids));
|
|
355
|
+
}
|
|
356
|
+
if (block["union_result"] !== void 0) {
|
|
357
|
+
children.push(parseMariadbWrapper(block["union_result"], "Union Result", ids));
|
|
358
|
+
}
|
|
359
|
+
if (block["buffer_result"] !== void 0) {
|
|
360
|
+
children.push(parseMariadbWrapper(block["buffer_result"], "Buffer Result", ids));
|
|
361
|
+
}
|
|
362
|
+
if (block["window_functions_computation"] !== void 0) {
|
|
363
|
+
children.push(
|
|
364
|
+
parseMariadbWrapper(block["window_functions_computation"], "Window Functions", ids)
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (block["expression_cache"] !== void 0) {
|
|
368
|
+
children.push(parseMariadbWrapper(block["expression_cache"], "Expression Cache", ids));
|
|
369
|
+
}
|
|
370
|
+
if (block["read_sorted_file"] !== void 0) {
|
|
371
|
+
children.push(parseMariadbWrapper(block["read_sorted_file"], "Read Sorted File", ids));
|
|
372
|
+
}
|
|
373
|
+
const specs = asArray(block["query_specifications"]);
|
|
374
|
+
if (specs !== null) {
|
|
375
|
+
for (const spec of specs) {
|
|
376
|
+
children.push(parseMysqlQueryBlock(spec, ids));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const indexCondition = table !== null ? asString(table["key"]) : null;
|
|
380
|
+
let actualTimeMs = asNumber(table?.["r_total_time_ms"]);
|
|
381
|
+
if (actualTimeMs === null && table !== null) {
|
|
382
|
+
const tableMs = asNumber(table["r_table_time_ms"]);
|
|
383
|
+
const otherMs = asNumber(table["r_other_time_ms"]);
|
|
384
|
+
if (tableMs !== null) {
|
|
385
|
+
actualTimeMs = otherMs !== null ? tableMs + otherMs : tableMs;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (actualTimeMs === null) {
|
|
389
|
+
actualTimeMs = asNumber(block["r_total_time_ms"]);
|
|
390
|
+
}
|
|
391
|
+
const actualRows = asNumber(table?.["r_rows"]) ?? asNumber(block["r_rows"]);
|
|
392
|
+
const actualLoops = asLoopCount(table?.["r_loops"]) ?? asLoopCount(block["r_loops"]);
|
|
393
|
+
return createExplainNode(id, {
|
|
394
|
+
node_type: nodeType,
|
|
395
|
+
relation,
|
|
396
|
+
startup_cost: startupCost,
|
|
397
|
+
total_cost: totalCost,
|
|
398
|
+
plan_rows: planRows,
|
|
399
|
+
actual_rows: actualRows,
|
|
400
|
+
actual_time_ms: actualTimeMs,
|
|
401
|
+
actual_loops: actualLoops,
|
|
402
|
+
filter,
|
|
403
|
+
index_condition: indexCondition,
|
|
404
|
+
extra,
|
|
405
|
+
children
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
function parseMysqlAnalyzeText(text, ids) {
|
|
409
|
+
const parsedLines = [];
|
|
410
|
+
for (const line of text.split("\n")) {
|
|
411
|
+
const trimmed = line.replace(/\s+$/, "");
|
|
412
|
+
if (trimmed === "") continue;
|
|
413
|
+
const leading = trimmed.length - trimmed.trimStart().length;
|
|
414
|
+
const depth = Math.floor(leading / 4);
|
|
415
|
+
const content = trimmed.trimStart();
|
|
416
|
+
if (!content.startsWith("-> ")) continue;
|
|
417
|
+
const descRest = content.slice(3);
|
|
418
|
+
const descEnd = descRest.indexOf(" (");
|
|
419
|
+
const description = descEnd === -1 ? descRest : descRest.slice(0, descEnd);
|
|
420
|
+
const metrics = descEnd === -1 ? "" : descRest.slice(descEnd);
|
|
421
|
+
const [estCost, estRows] = parseAnalyzeEstimated(metrics);
|
|
422
|
+
const [actualTimeMs, actualRows, actualLoops] = parseAnalyzeActual(metrics);
|
|
423
|
+
const [nodeType, relation, filter, indexCondition, joinType] = mapAnalyzeDescription(description);
|
|
424
|
+
parsedLines.push({
|
|
425
|
+
depth,
|
|
426
|
+
node_type: nodeType,
|
|
427
|
+
relation,
|
|
428
|
+
filter,
|
|
429
|
+
index_condition: indexCondition,
|
|
430
|
+
join_type: joinType,
|
|
431
|
+
est_cost: estCost,
|
|
432
|
+
est_rows: estRows,
|
|
433
|
+
actual_time_ms: actualTimeMs,
|
|
434
|
+
actual_rows: actualRows,
|
|
435
|
+
actual_loops: actualLoops
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (parsedLines.length === 0) {
|
|
439
|
+
return createExplainNode(ids.next(), { node_type: "Query" });
|
|
440
|
+
}
|
|
441
|
+
const [roots] = buildAnalyzeTree(parsedLines, 0, -1, ids);
|
|
442
|
+
if (roots.length === 1) {
|
|
443
|
+
return roots[0];
|
|
444
|
+
}
|
|
445
|
+
return createExplainNode(ids.next(), { node_type: "Query", children: roots });
|
|
446
|
+
}
|
|
447
|
+
function buildAnalyzeTree(lines, start, parentDepth, ids) {
|
|
448
|
+
const children = [];
|
|
449
|
+
let i = start;
|
|
450
|
+
while (i < lines.length) {
|
|
451
|
+
const line = lines[i];
|
|
452
|
+
if (line.depth <= parentDepth) break;
|
|
453
|
+
const id = ids.next();
|
|
454
|
+
const [grandchildren, nextI] = buildAnalyzeTree(lines, i + 1, line.depth, ids);
|
|
455
|
+
children.push(
|
|
456
|
+
createExplainNode(id, {
|
|
457
|
+
node_type: line.node_type,
|
|
458
|
+
relation: line.relation,
|
|
459
|
+
total_cost: line.est_cost,
|
|
460
|
+
plan_rows: line.est_rows,
|
|
461
|
+
actual_rows: line.actual_rows,
|
|
462
|
+
actual_time_ms: line.actual_time_ms,
|
|
463
|
+
actual_loops: line.actual_loops,
|
|
464
|
+
filter: line.filter,
|
|
465
|
+
index_condition: line.index_condition,
|
|
466
|
+
join_type: line.join_type,
|
|
467
|
+
children: grandchildren
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
i = nextI;
|
|
471
|
+
}
|
|
472
|
+
return [children, i];
|
|
473
|
+
}
|
|
474
|
+
function parseAnalyzeEstimated(s) {
|
|
475
|
+
const idx = s.indexOf("(cost=");
|
|
476
|
+
if (idx === -1) return [null, null];
|
|
477
|
+
const section = s.slice(idx);
|
|
478
|
+
const end = section.indexOf(")");
|
|
479
|
+
if (end === -1) return [null, null];
|
|
480
|
+
const inner = section.slice(1, end);
|
|
481
|
+
let cost = null;
|
|
482
|
+
let rows = null;
|
|
483
|
+
for (const part of inner.split(/\s+/)) {
|
|
484
|
+
if (part.startsWith("cost=")) {
|
|
485
|
+
cost = parseAnalyzeNumber(part.slice(5));
|
|
486
|
+
} else if (part.startsWith("rows=")) {
|
|
487
|
+
rows = parseAnalyzeNumber(part.slice(5));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return [cost, rows];
|
|
491
|
+
}
|
|
492
|
+
function parseAnalyzeActual(s) {
|
|
493
|
+
const idx = s.indexOf("(actual time=");
|
|
494
|
+
if (idx === -1) return [null, null, null];
|
|
495
|
+
const section = s.slice(idx);
|
|
496
|
+
const end = section.indexOf(")");
|
|
497
|
+
if (end === -1) return [null, null, null];
|
|
498
|
+
const inner = section.slice(1, end);
|
|
499
|
+
let perLoopTimeMs = null;
|
|
500
|
+
let rows = null;
|
|
501
|
+
let loops = null;
|
|
502
|
+
for (const part of inner.split(/\s+/)) {
|
|
503
|
+
if (part.startsWith("time=")) {
|
|
504
|
+
const val = part.slice(5);
|
|
505
|
+
const dotPos = val.indexOf("..");
|
|
506
|
+
perLoopTimeMs = parseAnalyzeNumber(dotPos === -1 ? val : val.slice(dotPos + 2));
|
|
507
|
+
} else if (part.startsWith("rows=")) {
|
|
508
|
+
rows = parseAnalyzeNumber(part.slice(5));
|
|
509
|
+
} else if (part.startsWith("loops=")) {
|
|
510
|
+
loops = parseAnalyzeNumber(part.slice(6));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const timeMs = perLoopTimeMs !== null && loops !== null ? perLoopTimeMs * loops : perLoopTimeMs;
|
|
514
|
+
return [timeMs, rows, loops];
|
|
515
|
+
}
|
|
516
|
+
function parseAnalyzeNumber(text) {
|
|
517
|
+
if (text.trim() === "") return null;
|
|
518
|
+
const value = Number(text);
|
|
519
|
+
return Number.isFinite(value) ? value : null;
|
|
520
|
+
}
|
|
521
|
+
function mapAnalyzeDescription(desc) {
|
|
522
|
+
const lower = desc.toLowerCase();
|
|
523
|
+
const relation = extractOnRelation(desc);
|
|
524
|
+
const indexCond = extractUsingIndex(desc);
|
|
525
|
+
let nodeType;
|
|
526
|
+
let filter = null;
|
|
527
|
+
let joinType = null;
|
|
528
|
+
if (lower.startsWith("table scan")) {
|
|
529
|
+
nodeType = "Full Table Scan";
|
|
530
|
+
} else if (lower.startsWith("covering index scan")) {
|
|
531
|
+
nodeType = "Index Only Scan";
|
|
532
|
+
} else if (lower.startsWith("index range scan") || lower.startsWith("range scan")) {
|
|
533
|
+
nodeType = "Range Scan";
|
|
534
|
+
} else if (lower.startsWith("index scan")) {
|
|
535
|
+
nodeType = "Index Scan";
|
|
536
|
+
} else if (lower.startsWith("single-row index lookup")) {
|
|
537
|
+
nodeType = "Unique Index Lookup";
|
|
538
|
+
} else if (lower.startsWith("index lookup") || lower.startsWith("multi-range index lookup")) {
|
|
539
|
+
nodeType = "Index Lookup";
|
|
540
|
+
} else if (lower.startsWith("constant row")) {
|
|
541
|
+
nodeType = "Const Lookup";
|
|
542
|
+
} else if (lower.startsWith("nested loop")) {
|
|
543
|
+
nodeType = "Nested Loop";
|
|
544
|
+
if (lower.includes("inner")) joinType = "Inner";
|
|
545
|
+
else if (lower.includes("left")) joinType = "Left";
|
|
546
|
+
else if (lower.includes("semijoin")) joinType = "Semi";
|
|
547
|
+
else if (lower.includes("antijoin") || lower.includes("anti")) joinType = "Anti";
|
|
548
|
+
} else if (lower.includes("hash join")) {
|
|
549
|
+
nodeType = "Hash Join";
|
|
550
|
+
joinType = lower.includes("left") ? "Left" : "Inner";
|
|
551
|
+
} else if (lower.startsWith("filter:")) {
|
|
552
|
+
nodeType = "Filter";
|
|
553
|
+
const filt = desc.slice(7).trim();
|
|
554
|
+
filter = filt === "" ? null : filt;
|
|
555
|
+
} else if (lower.startsWith("sort:") || lower.startsWith("sort row")) {
|
|
556
|
+
nodeType = "Sort";
|
|
557
|
+
} else if (lower.startsWith("limit:") || lower.startsWith("limit ")) {
|
|
558
|
+
nodeType = "Limit";
|
|
559
|
+
} else if (lower.startsWith("group aggregate") || lower.startsWith("aggregate")) {
|
|
560
|
+
nodeType = "Aggregate";
|
|
561
|
+
} else if (lower.startsWith("temporary table") || lower.startsWith("materialize")) {
|
|
562
|
+
nodeType = "Materialize";
|
|
563
|
+
} else if (lower.startsWith("stream results") || lower.startsWith("stream")) {
|
|
564
|
+
nodeType = "Stream";
|
|
565
|
+
} else if (lower.startsWith("window aggregate") || lower.startsWith("window")) {
|
|
566
|
+
nodeType = "Window";
|
|
567
|
+
} else {
|
|
568
|
+
nodeType = desc;
|
|
569
|
+
}
|
|
570
|
+
return [nodeType, relation, filter, indexCond, joinType];
|
|
571
|
+
}
|
|
572
|
+
function extractOnRelation(desc) {
|
|
573
|
+
const pos = desc.indexOf(" on ");
|
|
574
|
+
if (pos === -1) return null;
|
|
575
|
+
const after = desc.slice(pos + 4);
|
|
576
|
+
const endMatch = after.search(/[ (]/);
|
|
577
|
+
const name = (endMatch === -1 ? after : after.slice(0, endMatch)).trim();
|
|
578
|
+
return name === "" ? null : name;
|
|
579
|
+
}
|
|
580
|
+
function extractUsingIndex(desc) {
|
|
581
|
+
const pos = desc.toLowerCase().indexOf(" using ");
|
|
582
|
+
if (pos === -1) return null;
|
|
583
|
+
const after = desc.slice(pos + 7);
|
|
584
|
+
const endMatch = after.search(/[ (]/);
|
|
585
|
+
const name = (endMatch === -1 ? after : after.slice(0, endMatch)).trim();
|
|
586
|
+
return name === "" ? null : name;
|
|
587
|
+
}
|
|
588
|
+
function parseMysqlTabularRows(rows) {
|
|
589
|
+
const ids = new NodeIdAllocator();
|
|
590
|
+
const rootId = ids.next();
|
|
591
|
+
const rawLines = [];
|
|
592
|
+
const children = [];
|
|
593
|
+
for (const row of rows) {
|
|
594
|
+
const accessType = row.access_type ?? "";
|
|
595
|
+
const nodeType = accessType === "" ? "Unknown" : MYSQL_ACCESS_TYPES[accessType] ?? accessType;
|
|
596
|
+
rawLines.push(
|
|
597
|
+
[
|
|
598
|
+
row.select_type,
|
|
599
|
+
row.table ?? "",
|
|
600
|
+
accessType,
|
|
601
|
+
row.key ?? "-",
|
|
602
|
+
row.rows ?? 0,
|
|
603
|
+
row.extra ?? ""
|
|
604
|
+
].join(" ")
|
|
605
|
+
);
|
|
606
|
+
const extra = { select_type: row.select_type };
|
|
607
|
+
if (row.possible_keys !== null) extra["possible_keys"] = row.possible_keys;
|
|
608
|
+
if (row.filtered !== null) extra["filtered"] = row.filtered;
|
|
609
|
+
if (row.extra !== null) extra["extra"] = row.extra;
|
|
610
|
+
children.push(
|
|
611
|
+
createExplainNode(ids.next(), {
|
|
612
|
+
node_type: nodeType,
|
|
613
|
+
relation: row.table === null || row.table === "" ? null : row.table,
|
|
614
|
+
plan_rows: row.rows,
|
|
615
|
+
filter: row.extra,
|
|
616
|
+
index_condition: row.key,
|
|
617
|
+
extra
|
|
618
|
+
})
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const root = createExplainNode(rootId, { node_type: "Query", children });
|
|
622
|
+
return {
|
|
623
|
+
root,
|
|
624
|
+
planning_time_ms: null,
|
|
625
|
+
execution_time_ms: null,
|
|
626
|
+
original_query: "",
|
|
627
|
+
driver: "mysql",
|
|
628
|
+
has_analyze_data: false,
|
|
629
|
+
raw_output: rawLines.join("\n")
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/parsers/postgres.ts
|
|
634
|
+
function parsePostgresJson(raw) {
|
|
635
|
+
let value;
|
|
636
|
+
try {
|
|
637
|
+
value = JSON.parse(raw);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
throw new Error(`Failed to parse EXPLAIN JSON: ${String(err)}`);
|
|
640
|
+
}
|
|
641
|
+
const top = firstStatement(value);
|
|
642
|
+
const planObj = top["Plan"];
|
|
643
|
+
if (planObj === void 0) {
|
|
644
|
+
throw new Error("EXPLAIN JSON missing 'Plan' key");
|
|
645
|
+
}
|
|
646
|
+
const ids = new NodeIdAllocator();
|
|
647
|
+
const root = parsePgPlanNode(planObj, ids);
|
|
648
|
+
const planningTime = asNumber(top["Planning Time"]);
|
|
649
|
+
const executionTime = asNumber(top["Execution Time"]);
|
|
650
|
+
const hasAnalyzeData = root.actual_rows !== null || root.actual_time_ms !== null;
|
|
651
|
+
return {
|
|
652
|
+
root,
|
|
653
|
+
planning_time_ms: planningTime,
|
|
654
|
+
execution_time_ms: executionTime,
|
|
655
|
+
original_query: "",
|
|
656
|
+
driver: "postgres",
|
|
657
|
+
has_analyze_data: hasAnalyzeData,
|
|
658
|
+
raw_output: raw
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function firstStatement(value) {
|
|
662
|
+
if (Array.isArray(value)) {
|
|
663
|
+
const first = asObject(value[0]);
|
|
664
|
+
if (first === null) {
|
|
665
|
+
throw new Error("EXPLAIN JSON array is empty");
|
|
666
|
+
}
|
|
667
|
+
return first;
|
|
668
|
+
}
|
|
669
|
+
const obj = asObject(value);
|
|
670
|
+
if (obj !== null) {
|
|
671
|
+
return obj;
|
|
672
|
+
}
|
|
673
|
+
throw new Error("EXPLAIN JSON must be an array or object");
|
|
674
|
+
}
|
|
675
|
+
var PG_KNOWN_KEYS = [
|
|
676
|
+
"Node Type",
|
|
677
|
+
"Relation Name",
|
|
678
|
+
"Startup Cost",
|
|
679
|
+
"Total Cost",
|
|
680
|
+
"Plan Rows",
|
|
681
|
+
"Actual Rows",
|
|
682
|
+
"Actual Total Time",
|
|
683
|
+
"Actual Loops",
|
|
684
|
+
"Shared Hit Blocks",
|
|
685
|
+
"Shared Read Blocks",
|
|
686
|
+
"Filter",
|
|
687
|
+
"Index Cond",
|
|
688
|
+
"Join Type",
|
|
689
|
+
"Hash Cond",
|
|
690
|
+
"Plans"
|
|
691
|
+
];
|
|
692
|
+
function parsePgPlanNode(node, ids) {
|
|
693
|
+
const id = ids.next();
|
|
694
|
+
const obj = asObject(node) ?? {};
|
|
695
|
+
const extra = {};
|
|
696
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
697
|
+
if (!PG_KNOWN_KEYS.includes(key)) {
|
|
698
|
+
extra[key] = value;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const children = (asArray(obj["Plans"]) ?? []).map(
|
|
702
|
+
(child) => parsePgPlanNode(child, ids)
|
|
703
|
+
);
|
|
704
|
+
return createExplainNode(id, {
|
|
705
|
+
node_type: asString(obj["Node Type"]) ?? "Unknown",
|
|
706
|
+
relation: asString(obj["Relation Name"]),
|
|
707
|
+
startup_cost: asNumber(obj["Startup Cost"]),
|
|
708
|
+
total_cost: asNumber(obj["Total Cost"]),
|
|
709
|
+
plan_rows: asNumber(obj["Plan Rows"]),
|
|
710
|
+
actual_rows: asNumber(obj["Actual Rows"]),
|
|
711
|
+
actual_time_ms: asNumber(obj["Actual Total Time"]),
|
|
712
|
+
actual_loops: asLoopCount(obj["Actual Loops"]),
|
|
713
|
+
buffers_hit: asLoopCount(obj["Shared Hit Blocks"]),
|
|
714
|
+
buffers_read: asLoopCount(obj["Shared Read Blocks"]),
|
|
715
|
+
filter: asString(obj["Filter"]),
|
|
716
|
+
index_condition: asString(obj["Index Cond"]),
|
|
717
|
+
join_type: asString(obj["Join Type"]),
|
|
718
|
+
hash_condition: asString(obj["Hash Cond"]),
|
|
719
|
+
extra,
|
|
720
|
+
children
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
function parsePostgresText(raw) {
|
|
724
|
+
let planningTime = null;
|
|
725
|
+
let executionTime = null;
|
|
726
|
+
let hasAnalyzeData = false;
|
|
727
|
+
const ids = new NodeIdAllocator();
|
|
728
|
+
const stack = [];
|
|
729
|
+
let root = null;
|
|
730
|
+
const promoteOrAttach = (node) => {
|
|
731
|
+
const parent = stack[stack.length - 1];
|
|
732
|
+
if (parent !== void 0) {
|
|
733
|
+
parent.node.children.push(node);
|
|
734
|
+
} else if (root === null) {
|
|
735
|
+
root = node;
|
|
736
|
+
} else {
|
|
737
|
+
root.children.push(node);
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
const popUntilShallower = (cutoff) => {
|
|
741
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= cutoff) {
|
|
742
|
+
const frame = stack.pop();
|
|
743
|
+
if (frame !== void 0) {
|
|
744
|
+
promoteOrAttach(frame.node);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
for (const line of raw.split("\n")) {
|
|
749
|
+
const rawLine = line.replace(/\s+$/, "");
|
|
750
|
+
if (rawLine.trim() === "") continue;
|
|
751
|
+
if (isTextMetadataLine(rawLine)) continue;
|
|
752
|
+
const indent = leadingWidth(rawLine);
|
|
753
|
+
const trimmed = rawLine.trimStart();
|
|
754
|
+
const planning = stripMsSuffix(trimmed, "Planning Time:");
|
|
755
|
+
if (planning !== null) {
|
|
756
|
+
planningTime = planning;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const execution = stripMsSuffix(trimmed, "Execution Time:");
|
|
760
|
+
if (execution !== null) {
|
|
761
|
+
executionTime = execution;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const node = parseTextNodeHeader(trimmed, ids);
|
|
765
|
+
if (node !== null) {
|
|
766
|
+
if (node.actual_rows !== null || node.actual_time_ms !== null) {
|
|
767
|
+
hasAnalyzeData = true;
|
|
768
|
+
}
|
|
769
|
+
popUntilShallower(indent);
|
|
770
|
+
stack.push({ indent, node });
|
|
771
|
+
} else {
|
|
772
|
+
const top = stack[stack.length - 1];
|
|
773
|
+
if (top !== void 0) {
|
|
774
|
+
applyTextAttribute(top.node, trimmed);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
popUntilShallower(0);
|
|
779
|
+
if (root === null) {
|
|
780
|
+
throw new Error("No plan nodes found in EXPLAIN text output");
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
root,
|
|
784
|
+
planning_time_ms: planningTime,
|
|
785
|
+
execution_time_ms: executionTime,
|
|
786
|
+
original_query: "",
|
|
787
|
+
driver: "postgres",
|
|
788
|
+
has_analyze_data: hasAnalyzeData,
|
|
789
|
+
raw_output: raw
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function isTextMetadataLine(line) {
|
|
793
|
+
const t = line.trim();
|
|
794
|
+
if (t.toUpperCase() === "QUERY PLAN") return true;
|
|
795
|
+
if (t !== "" && [...t].every((c) => c === "-")) return true;
|
|
796
|
+
if (t.startsWith("(") && (t.endsWith("rows)") || t.endsWith("row)"))) return true;
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
function leadingWidth(line) {
|
|
800
|
+
let count = 0;
|
|
801
|
+
for (const ch of line) {
|
|
802
|
+
if (/\s/.test(ch)) count += 1;
|
|
803
|
+
else break;
|
|
804
|
+
}
|
|
805
|
+
return count;
|
|
806
|
+
}
|
|
807
|
+
function stripMsSuffix(line, prefix) {
|
|
808
|
+
if (!line.startsWith(prefix)) return null;
|
|
809
|
+
let rest = line.slice(prefix.length).trim();
|
|
810
|
+
if (rest.endsWith("ms")) rest = rest.slice(0, -2).trim();
|
|
811
|
+
const value = Number(rest);
|
|
812
|
+
return rest !== "" && Number.isFinite(value) ? value : null;
|
|
813
|
+
}
|
|
814
|
+
function parseTextNodeHeader(content, ids) {
|
|
815
|
+
const body = content.startsWith("->") ? content.slice(2).trimStart() : content;
|
|
816
|
+
const costPos = body.indexOf("(cost=");
|
|
817
|
+
if (costPos === -1) return null;
|
|
818
|
+
const header = body.slice(0, costPos).trim();
|
|
819
|
+
const tail = body.slice(costPos);
|
|
820
|
+
const costGroup = extractParens(tail);
|
|
821
|
+
if (costGroup === null) return null;
|
|
822
|
+
const [startupCost, totalCost, planRows] = parseCostFields(costGroup.inner);
|
|
823
|
+
const actualSection = costGroup.rest.trimStart();
|
|
824
|
+
const actualGroup = extractParens(actualSection);
|
|
825
|
+
const [actualTimeMs, actualRows, actualLoops] = actualGroup !== null ? parseActualFields(actualGroup.inner) : [null, null, null];
|
|
826
|
+
const [nodeType, relation] = splitNodeTypeAndRelation(header);
|
|
827
|
+
return createExplainNode(ids.next(), {
|
|
828
|
+
node_type: nodeType,
|
|
829
|
+
relation,
|
|
830
|
+
startup_cost: startupCost,
|
|
831
|
+
total_cost: totalCost,
|
|
832
|
+
plan_rows: planRows,
|
|
833
|
+
actual_rows: actualRows,
|
|
834
|
+
actual_time_ms: actualTimeMs,
|
|
835
|
+
actual_loops: actualLoops
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function splitNodeTypeAndRelation(header) {
|
|
839
|
+
const idx = header.lastIndexOf(" on ");
|
|
840
|
+
if (idx === -1) return [header.trim(), null];
|
|
841
|
+
const nodeType = header.slice(0, idx).trim();
|
|
842
|
+
const relation = header.slice(idx + 4).trim();
|
|
843
|
+
return relation === "" ? [nodeType, null] : [nodeType, relation];
|
|
844
|
+
}
|
|
845
|
+
function extractParens(input) {
|
|
846
|
+
if (!input.startsWith("(")) return null;
|
|
847
|
+
let depth = 0;
|
|
848
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
849
|
+
const ch = input[i];
|
|
850
|
+
if (ch === "(") depth += 1;
|
|
851
|
+
else if (ch === ")") {
|
|
852
|
+
depth -= 1;
|
|
853
|
+
if (depth === 0) {
|
|
854
|
+
return { inner: input.slice(1, i), rest: input.slice(i + 1) };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
function parseCostFields(inner) {
|
|
861
|
+
let startup = null;
|
|
862
|
+
let total = null;
|
|
863
|
+
let rows = null;
|
|
864
|
+
if (inner.startsWith("cost=")) {
|
|
865
|
+
const parts = inner.slice(5).split(/\s+/);
|
|
866
|
+
const costExpr = parts[0];
|
|
867
|
+
if (costExpr !== void 0) {
|
|
868
|
+
const sep = costExpr.indexOf("..");
|
|
869
|
+
if (sep !== -1) {
|
|
870
|
+
startup = parseFiniteNumber(costExpr.slice(0, sep));
|
|
871
|
+
total = parseFiniteNumber(costExpr.slice(sep + 2));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const part of parts.slice(1)) {
|
|
875
|
+
if (part.startsWith("rows=")) {
|
|
876
|
+
rows = parseFiniteNumber(part.slice(5));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return [startup, total, rows];
|
|
881
|
+
}
|
|
882
|
+
function parseActualFields(inner) {
|
|
883
|
+
const trimmed = inner.trim();
|
|
884
|
+
if (trimmed.toLowerCase() === "never executed") {
|
|
885
|
+
return [null, null, null];
|
|
886
|
+
}
|
|
887
|
+
const rest = trimmed.startsWith("actual ") ? trimmed.slice(7) : trimmed;
|
|
888
|
+
let totalTime = null;
|
|
889
|
+
let rows = null;
|
|
890
|
+
let loops = null;
|
|
891
|
+
for (const part of rest.split(/\s+/)) {
|
|
892
|
+
if (part.startsWith("time=")) {
|
|
893
|
+
const expr = part.slice(5);
|
|
894
|
+
const sep = expr.indexOf("..");
|
|
895
|
+
if (sep !== -1) {
|
|
896
|
+
totalTime = parseFiniteNumber(expr.slice(sep + 2));
|
|
897
|
+
}
|
|
898
|
+
} else if (part.startsWith("rows=")) {
|
|
899
|
+
rows = parseFiniteNumber(part.slice(5));
|
|
900
|
+
} else if (part.startsWith("loops=")) {
|
|
901
|
+
loops = parseFiniteNumber(part.slice(6));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return [totalTime, rows, loops];
|
|
905
|
+
}
|
|
906
|
+
function parseFiniteNumber(text) {
|
|
907
|
+
if (text.trim() === "") return null;
|
|
908
|
+
const value = Number(text);
|
|
909
|
+
return Number.isFinite(value) ? value : null;
|
|
910
|
+
}
|
|
911
|
+
function applyTextAttribute(node, content) {
|
|
912
|
+
if (content.startsWith("Filter:")) {
|
|
913
|
+
node.filter = content.slice("Filter:".length).trim();
|
|
914
|
+
} else if (content.startsWith("Index Cond:")) {
|
|
915
|
+
node.index_condition = content.slice("Index Cond:".length).trim();
|
|
916
|
+
} else if (content.startsWith("Hash Cond:")) {
|
|
917
|
+
node.hash_condition = content.slice("Hash Cond:".length).trim();
|
|
918
|
+
} else if (content.startsWith("Join Type:")) {
|
|
919
|
+
node.join_type = content.slice("Join Type:".length).trim();
|
|
920
|
+
} else {
|
|
921
|
+
const sep = content.indexOf(":");
|
|
922
|
+
if (sep !== -1) {
|
|
923
|
+
node.extra[content.slice(0, sep).trim()] = content.slice(sep + 1).trim();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/parsers/source.ts
|
|
929
|
+
var SOURCE_PARSERS = [
|
|
930
|
+
{ engine: "postgres", format: "postgres-json", parse: parsePostgresJson },
|
|
931
|
+
{ engine: "postgres", format: "postgres-text", parse: parsePostgresText },
|
|
932
|
+
{ engine: "mysql", format: "mysql-json", parse: parseMysqlJson },
|
|
933
|
+
{ engine: "mysql", format: "mysql-text", parse: parseMysqlText }
|
|
934
|
+
];
|
|
935
|
+
function parserFor(format) {
|
|
936
|
+
const parser = SOURCE_PARSERS.find((candidate) => candidate.format === format);
|
|
937
|
+
if (parser === void 0) {
|
|
938
|
+
throw new Error(`No parser registered for format '${format}'`);
|
|
939
|
+
}
|
|
940
|
+
return parser;
|
|
941
|
+
}
|
|
942
|
+
function explainEngineFromDriverName(name) {
|
|
943
|
+
switch (name.trim().toLowerCase()) {
|
|
944
|
+
case "postgres":
|
|
945
|
+
case "postgresql":
|
|
946
|
+
case "pg":
|
|
947
|
+
return "postgres";
|
|
948
|
+
case "mysql":
|
|
949
|
+
case "mariadb":
|
|
950
|
+
return "mysql";
|
|
951
|
+
case "sqlite":
|
|
952
|
+
case "sqlite3":
|
|
953
|
+
return "sqlite";
|
|
954
|
+
default:
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function detectFormat(raw) {
|
|
959
|
+
return detectFormatFor(raw, null);
|
|
960
|
+
}
|
|
961
|
+
function detectFormatFor(raw, engine) {
|
|
962
|
+
switch (engine) {
|
|
963
|
+
case "postgres":
|
|
964
|
+
case null:
|
|
965
|
+
if (looksLikeJson(raw)) return "postgres-json";
|
|
966
|
+
if (looksLikePostgresText(raw)) return "postgres-text";
|
|
967
|
+
throw new Error(
|
|
968
|
+
"Unsupported EXPLAIN file format: expected Postgres JSON or text output"
|
|
969
|
+
);
|
|
970
|
+
case "mysql":
|
|
971
|
+
if (looksLikeJson(raw)) return "mysql-json";
|
|
972
|
+
if (raw.trim() === "") {
|
|
973
|
+
throw new Error("Unsupported EXPLAIN file format: input is empty");
|
|
974
|
+
}
|
|
975
|
+
return "mysql-text";
|
|
976
|
+
case "sqlite":
|
|
977
|
+
throw new Error(
|
|
978
|
+
"SQLite EXPLAIN QUERY PLAN has no text form here: pass its (id, parent, detail) rows to buildSqliteTree"
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function looksLikeJson(raw) {
|
|
983
|
+
const trimmed = raw.trimStart();
|
|
984
|
+
return trimmed.startsWith("[") || trimmed.startsWith("{");
|
|
985
|
+
}
|
|
986
|
+
function looksLikePostgresText(raw) {
|
|
987
|
+
return raw.split("\n").some((line) => line.includes("(cost=") && line.includes("width="));
|
|
988
|
+
}
|
|
989
|
+
function parseExplain(raw) {
|
|
990
|
+
return parseExplainFor(raw, null);
|
|
991
|
+
}
|
|
992
|
+
function parseExplainFor(raw, engine) {
|
|
993
|
+
return parserFor(detectFormatFor(raw, engine)).parse(raw);
|
|
994
|
+
}
|
|
995
|
+
function withSourceLabel(plan, name) {
|
|
996
|
+
if (plan.original_query === "") {
|
|
997
|
+
return { ...plan, original_query: `-- loaded from ${name}` };
|
|
998
|
+
}
|
|
999
|
+
return plan;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/parsers/sqlite.ts
|
|
1003
|
+
function buildSqliteTree(entries, parentId, ids) {
|
|
1004
|
+
const rootEntry = entries.find(
|
|
1005
|
+
(entry) => parentId === 0 ? entry.parent === 0 && entry.id === 0 : entry.id === parentId
|
|
1006
|
+
);
|
|
1007
|
+
const [nodeType, relation, indexCondition] = rootEntry !== void 0 ? parseSqliteDetail(rootEntry.detail) : ["Query Plan", null, null];
|
|
1008
|
+
const id = ids.next();
|
|
1009
|
+
const rootId = rootEntry?.id ?? 0;
|
|
1010
|
+
const childIds = entries.filter((entry) => entry.parent === rootId && entry.id !== rootId).map((entry) => entry.id);
|
|
1011
|
+
const children = childIds.map((childId) => {
|
|
1012
|
+
const childEntry = entries.find((entry) => entry.id === childId);
|
|
1013
|
+
const [ct, cr, ci] = childEntry !== void 0 ? parseSqliteDetail(childEntry.detail) : ["Unknown", null, null];
|
|
1014
|
+
const childNodeId = ids.next();
|
|
1015
|
+
const grandchildren = entries.filter((entry) => entry.parent === childId).map((entry) => buildSqliteTree(entries, entry.id, ids));
|
|
1016
|
+
return createExplainNode(childNodeId, {
|
|
1017
|
+
node_type: ct,
|
|
1018
|
+
relation: cr,
|
|
1019
|
+
index_condition: ci,
|
|
1020
|
+
children: grandchildren
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
return createExplainNode(id, {
|
|
1024
|
+
node_type: nodeType,
|
|
1025
|
+
relation,
|
|
1026
|
+
index_condition: indexCondition,
|
|
1027
|
+
children
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
function parseSqliteEqpRows(entries) {
|
|
1031
|
+
if (entries.length === 0) {
|
|
1032
|
+
throw new Error("EXPLAIN QUERY PLAN returned no output");
|
|
1033
|
+
}
|
|
1034
|
+
const ids = new NodeIdAllocator();
|
|
1035
|
+
const root = buildSqliteTree(entries, 0, ids);
|
|
1036
|
+
const rawOutput = entries.map((entry) => `${entry.id}|${entry.parent}|${entry.detail}`).join("\n");
|
|
1037
|
+
return {
|
|
1038
|
+
root,
|
|
1039
|
+
planning_time_ms: null,
|
|
1040
|
+
execution_time_ms: null,
|
|
1041
|
+
original_query: "",
|
|
1042
|
+
driver: "sqlite",
|
|
1043
|
+
has_analyze_data: false,
|
|
1044
|
+
raw_output: rawOutput
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
function parseSqliteDetail(detail) {
|
|
1048
|
+
const detailUpper = detail.toUpperCase();
|
|
1049
|
+
if (detailUpper.startsWith("SCAN")) {
|
|
1050
|
+
const parts = detail.split(" ");
|
|
1051
|
+
const relation = parts[1] ?? null;
|
|
1052
|
+
let index = null;
|
|
1053
|
+
if (detailUpper.includes("USING INDEX")) {
|
|
1054
|
+
index = detail.slice(detail.indexOf("USING INDEX") + 12).trim();
|
|
1055
|
+
} else if (detailUpper.includes("USING COVERING INDEX")) {
|
|
1056
|
+
index = detail.slice(detail.indexOf("USING COVERING INDEX") + 21).trim();
|
|
1057
|
+
}
|
|
1058
|
+
return ["Scan", relation, index];
|
|
1059
|
+
}
|
|
1060
|
+
if (detailUpper.startsWith("SEARCH")) {
|
|
1061
|
+
const parts = detail.split(" ");
|
|
1062
|
+
const relation = parts[1] ?? null;
|
|
1063
|
+
let index = null;
|
|
1064
|
+
if (detailUpper.includes("USING INDEX")) {
|
|
1065
|
+
index = detail.slice(detail.indexOf("USING INDEX") + 12).trim();
|
|
1066
|
+
} else if (detailUpper.includes("USING INTEGER PRIMARY KEY")) {
|
|
1067
|
+
index = "PRIMARY KEY";
|
|
1068
|
+
} else if (detailUpper.includes("USING COVERING INDEX")) {
|
|
1069
|
+
index = detail.slice(detail.indexOf("USING COVERING INDEX") + 21).trim();
|
|
1070
|
+
}
|
|
1071
|
+
return ["Search", relation, index];
|
|
1072
|
+
}
|
|
1073
|
+
if (detailUpper.includes("TEMP B-TREE")) {
|
|
1074
|
+
return ["Sort", null, null];
|
|
1075
|
+
}
|
|
1076
|
+
if (detailUpper.startsWith("CO-ROUTINE")) {
|
|
1077
|
+
return ["Co-routine", null, null];
|
|
1078
|
+
}
|
|
1079
|
+
if (detailUpper.startsWith("COMPOUND SUBQUERIES")) {
|
|
1080
|
+
return ["Compound Subquery", null, null];
|
|
1081
|
+
}
|
|
1082
|
+
if (detailUpper.startsWith("MATERIALIZE")) {
|
|
1083
|
+
return ["Materialize", null, null];
|
|
1084
|
+
}
|
|
1085
|
+
return [detail, null, null];
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// src/raw.ts
|
|
1089
|
+
function parseRawExplain(raw) {
|
|
1090
|
+
const plan = parseRawPayload(raw);
|
|
1091
|
+
return {
|
|
1092
|
+
...plan,
|
|
1093
|
+
driver: raw.engine,
|
|
1094
|
+
original_query: raw.original_query
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function resolveExplainOutput(output) {
|
|
1098
|
+
return output.kind === "plan" ? output.plan : parseRawExplain(output.raw);
|
|
1099
|
+
}
|
|
1100
|
+
function parseRawPayload(raw) {
|
|
1101
|
+
switch (raw.format) {
|
|
1102
|
+
case "postgres-json":
|
|
1103
|
+
return parsePostgresJson(raw.payload);
|
|
1104
|
+
case "mysql-json":
|
|
1105
|
+
return parseMysqlJson(raw.payload);
|
|
1106
|
+
case "mysql-analyze-text":
|
|
1107
|
+
return parseMysqlText(raw.payload);
|
|
1108
|
+
case "mysql-tabular-rows":
|
|
1109
|
+
return parseMysqlTabularRows(parseJsonRows(raw));
|
|
1110
|
+
case "sqlite-eqp-rows":
|
|
1111
|
+
return parseSqliteEqpRows(parseJsonRows(raw));
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function parseJsonRows(raw) {
|
|
1115
|
+
let value;
|
|
1116
|
+
try {
|
|
1117
|
+
value = JSON.parse(raw.payload);
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
throw new Error(`Failed to parse EXPLAIN rows: ${String(err)}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (!Array.isArray(value)) {
|
|
1122
|
+
throw new Error(`EXPLAIN rows payload for '${raw.format}' must be a JSON array`);
|
|
1123
|
+
}
|
|
1124
|
+
return value;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// src/metrics.ts
|
|
1128
|
+
var EXPLAIN_METRIC_KINDS = [
|
|
1129
|
+
"time",
|
|
1130
|
+
"rows",
|
|
1131
|
+
"cost",
|
|
1132
|
+
"buffers"
|
|
1133
|
+
];
|
|
1134
|
+
function loopCount(node) {
|
|
1135
|
+
return node.actual_loops != null && node.actual_loops > 0 ? node.actual_loops : 1;
|
|
1136
|
+
}
|
|
1137
|
+
function isSubplanChild(node) {
|
|
1138
|
+
const relationship = node.extra["Parent Relationship"];
|
|
1139
|
+
return relationship === "InitPlan" || relationship === "SubPlan";
|
|
1140
|
+
}
|
|
1141
|
+
function bufferCount(node) {
|
|
1142
|
+
if (node.buffers_hit == null && node.buffers_read == null) {
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
return (node.buffers_hit ?? 0) + (node.buffers_read ?? 0);
|
|
1146
|
+
}
|
|
1147
|
+
function selfValue(total, childSum) {
|
|
1148
|
+
if (total == null) {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
return Math.max(0, total - childSum);
|
|
1152
|
+
}
|
|
1153
|
+
function computeExplainMetrics(plan) {
|
|
1154
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1155
|
+
const order = [];
|
|
1156
|
+
let nextIndex = 1;
|
|
1157
|
+
function walk(node, depth) {
|
|
1158
|
+
const metrics = {
|
|
1159
|
+
nodeId: node.id,
|
|
1160
|
+
index: nextIndex,
|
|
1161
|
+
depth,
|
|
1162
|
+
inclusiveTimeMs: null,
|
|
1163
|
+
exclusiveTimeMs: null,
|
|
1164
|
+
totalCost: node.total_cost,
|
|
1165
|
+
exclusiveCost: null,
|
|
1166
|
+
totalRows: null,
|
|
1167
|
+
inclusiveBuffers: bufferCount(node),
|
|
1168
|
+
exclusiveBuffers: null,
|
|
1169
|
+
timeShare: null,
|
|
1170
|
+
neverExecuted: node.actual_loops === 0
|
|
1171
|
+
};
|
|
1172
|
+
nextIndex += 1;
|
|
1173
|
+
byId.set(node.id, metrics);
|
|
1174
|
+
order.push(metrics);
|
|
1175
|
+
let childTimeMs = 0;
|
|
1176
|
+
let childCost = 0;
|
|
1177
|
+
let childBuffers = 0;
|
|
1178
|
+
for (const child of node.children) {
|
|
1179
|
+
const contribution = walk(child, depth + 1);
|
|
1180
|
+
childCost += contribution.cost;
|
|
1181
|
+
childBuffers += contribution.buffers;
|
|
1182
|
+
if (!isSubplanChild(child)) {
|
|
1183
|
+
childTimeMs += contribution.timeMs;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
const loops = loopCount(node);
|
|
1187
|
+
metrics.inclusiveTimeMs = node.actual_time_ms != null ? node.actual_time_ms * loops : null;
|
|
1188
|
+
metrics.exclusiveTimeMs = selfValue(metrics.inclusiveTimeMs, childTimeMs);
|
|
1189
|
+
metrics.exclusiveCost = selfValue(metrics.totalCost, childCost);
|
|
1190
|
+
metrics.exclusiveBuffers = selfValue(metrics.inclusiveBuffers, childBuffers);
|
|
1191
|
+
metrics.totalRows = node.actual_rows != null ? node.actual_rows * loops : null;
|
|
1192
|
+
return {
|
|
1193
|
+
timeMs: metrics.inclusiveTimeMs ?? 0,
|
|
1194
|
+
cost: metrics.totalCost ?? 0,
|
|
1195
|
+
buffers: metrics.inclusiveBuffers ?? 0
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
walk(plan.root, 0);
|
|
1199
|
+
let maxExclusiveTimeMs = 0;
|
|
1200
|
+
let maxExclusiveCost = 0;
|
|
1201
|
+
let maxTotalRows = 0;
|
|
1202
|
+
let maxExclusiveBuffers = 0;
|
|
1203
|
+
let totalExclusiveTimeMs = 0;
|
|
1204
|
+
for (const metrics of order) {
|
|
1205
|
+
maxExclusiveTimeMs = Math.max(
|
|
1206
|
+
maxExclusiveTimeMs,
|
|
1207
|
+
metrics.exclusiveTimeMs ?? 0
|
|
1208
|
+
);
|
|
1209
|
+
maxExclusiveCost = Math.max(maxExclusiveCost, metrics.exclusiveCost ?? 0);
|
|
1210
|
+
maxTotalRows = Math.max(maxTotalRows, metrics.totalRows ?? 0);
|
|
1211
|
+
maxExclusiveBuffers = Math.max(
|
|
1212
|
+
maxExclusiveBuffers,
|
|
1213
|
+
metrics.exclusiveBuffers ?? 0
|
|
1214
|
+
);
|
|
1215
|
+
totalExclusiveTimeMs += metrics.exclusiveTimeMs ?? 0;
|
|
1216
|
+
}
|
|
1217
|
+
if (totalExclusiveTimeMs > 0) {
|
|
1218
|
+
for (const metrics of order) {
|
|
1219
|
+
if (metrics.exclusiveTimeMs != null) {
|
|
1220
|
+
metrics.timeShare = metrics.exclusiveTimeMs / totalExclusiveTimeMs;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
byId,
|
|
1226
|
+
order,
|
|
1227
|
+
maxExclusiveTimeMs,
|
|
1228
|
+
maxExclusiveCost,
|
|
1229
|
+
maxTotalRows,
|
|
1230
|
+
maxExclusiveBuffers,
|
|
1231
|
+
totalExclusiveTimeMs
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
function getNodeMetrics(metrics, nodeId) {
|
|
1235
|
+
if (!nodeId) {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
return metrics.byId.get(nodeId) ?? null;
|
|
1239
|
+
}
|
|
1240
|
+
function getMetricValue(metrics, kind) {
|
|
1241
|
+
switch (kind) {
|
|
1242
|
+
case "time":
|
|
1243
|
+
return metrics.exclusiveTimeMs;
|
|
1244
|
+
case "rows":
|
|
1245
|
+
return metrics.totalRows;
|
|
1246
|
+
case "cost":
|
|
1247
|
+
return metrics.exclusiveCost;
|
|
1248
|
+
case "buffers":
|
|
1249
|
+
return metrics.exclusiveBuffers;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
function getMetricMax(metrics, kind) {
|
|
1253
|
+
switch (kind) {
|
|
1254
|
+
case "time":
|
|
1255
|
+
return metrics.maxExclusiveTimeMs;
|
|
1256
|
+
case "rows":
|
|
1257
|
+
return metrics.maxTotalRows;
|
|
1258
|
+
case "cost":
|
|
1259
|
+
return metrics.maxExclusiveCost;
|
|
1260
|
+
case "buffers":
|
|
1261
|
+
return metrics.maxExclusiveBuffers;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
function isMetricAvailable(metrics, kind) {
|
|
1265
|
+
return metrics.order.some((node) => getMetricValue(node, kind) != null);
|
|
1266
|
+
}
|
|
1267
|
+
function getAvailableMetricKinds(metrics) {
|
|
1268
|
+
return EXPLAIN_METRIC_KINDS.filter((kind) => isMetricAvailable(metrics, kind));
|
|
1269
|
+
}
|
|
1270
|
+
function getDefaultMetricKind(metrics) {
|
|
1271
|
+
if (metrics.maxExclusiveTimeMs > 0) {
|
|
1272
|
+
return "time";
|
|
1273
|
+
}
|
|
1274
|
+
const available = getAvailableMetricKinds(metrics);
|
|
1275
|
+
return available.includes("cost") ? "cost" : available[0] ?? null;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/plan.ts
|
|
1279
|
+
function getNodeCostStyle(cost, maxCost) {
|
|
1280
|
+
if (maxCost <= 0) return { border: "border-l-green-500", headerBg: "bg-green-950/30" };
|
|
1281
|
+
const ratio = cost / maxCost;
|
|
1282
|
+
if (ratio < 0.2) return { border: "border-l-green-500", headerBg: "bg-green-950/30" };
|
|
1283
|
+
if (ratio < 0.6) return { border: "border-l-yellow-500", headerBg: "bg-yellow-950/30" };
|
|
1284
|
+
return { border: "border-l-red-500", headerBg: "bg-red-950/30" };
|
|
1285
|
+
}
|
|
1286
|
+
function getHeatBarClass(value, max) {
|
|
1287
|
+
if (max <= 0) return "bg-green-500/70";
|
|
1288
|
+
const ratio = value / max;
|
|
1289
|
+
if (ratio < 0.2) return "bg-green-500/70";
|
|
1290
|
+
if (ratio < 0.6) return "bg-yellow-500/70";
|
|
1291
|
+
return "bg-red-500/70";
|
|
1292
|
+
}
|
|
1293
|
+
function formatCost(n) {
|
|
1294
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
1295
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
1296
|
+
if (n >= 100) return n.toFixed(0);
|
|
1297
|
+
if (n >= 1) return n.toFixed(1);
|
|
1298
|
+
return n.toFixed(2);
|
|
1299
|
+
}
|
|
1300
|
+
function formatTime(ms) {
|
|
1301
|
+
if (ms >= 1e3) return `${(ms / 1e3).toFixed(2)} s`;
|
|
1302
|
+
if (ms >= 1) return `${ms.toFixed(2)} ms`;
|
|
1303
|
+
return `${(ms * 1e3).toFixed(0)} us`;
|
|
1304
|
+
}
|
|
1305
|
+
function formatRows(n) {
|
|
1306
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
1307
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
1308
|
+
return n.toFixed(0);
|
|
1309
|
+
}
|
|
1310
|
+
function formatRatio(n) {
|
|
1311
|
+
if (n >= 100) return `${n.toFixed(0)}x`;
|
|
1312
|
+
if (n >= 10) return `${n.toFixed(1)}x`;
|
|
1313
|
+
return `${n.toFixed(2)}x`;
|
|
1314
|
+
}
|
|
1315
|
+
function getMaxCost(node) {
|
|
1316
|
+
let max = node.total_cost ?? 0;
|
|
1317
|
+
for (const child of node.children) {
|
|
1318
|
+
const childMax = getMaxCost(child);
|
|
1319
|
+
if (childMax > max) max = childMax;
|
|
1320
|
+
}
|
|
1321
|
+
return max;
|
|
1322
|
+
}
|
|
1323
|
+
function getMaxTime(node) {
|
|
1324
|
+
let max = node.actual_time_ms ?? 0;
|
|
1325
|
+
for (const child of node.children) {
|
|
1326
|
+
const childMax = getMaxTime(child);
|
|
1327
|
+
if (childMax > max) max = childMax;
|
|
1328
|
+
}
|
|
1329
|
+
return max;
|
|
1330
|
+
}
|
|
1331
|
+
function flattenExplainNodes(root) {
|
|
1332
|
+
const nodes = [];
|
|
1333
|
+
function walk(node) {
|
|
1334
|
+
nodes.push(node);
|
|
1335
|
+
for (const child of node.children) {
|
|
1336
|
+
walk(child);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
walk(root);
|
|
1340
|
+
return nodes;
|
|
1341
|
+
}
|
|
1342
|
+
function findExplainNode(root, nodeId) {
|
|
1343
|
+
if (!nodeId) {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
if (root.id === nodeId) {
|
|
1347
|
+
return root;
|
|
1348
|
+
}
|
|
1349
|
+
for (const child of root.children) {
|
|
1350
|
+
const found = findExplainNode(child, nodeId);
|
|
1351
|
+
if (found) {
|
|
1352
|
+
return found;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return null;
|
|
1356
|
+
}
|
|
1357
|
+
function getRowEstimateRatio(node) {
|
|
1358
|
+
if (node.plan_rows == null || node.actual_rows == null || node.plan_rows <= 0 || node.actual_rows <= 0) {
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
return node.actual_rows / node.plan_rows;
|
|
1362
|
+
}
|
|
1363
|
+
function getMismatchMagnitude(node) {
|
|
1364
|
+
const ratio = getRowEstimateRatio(node);
|
|
1365
|
+
if (ratio == null) {
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
return ratio >= 1 ? ratio : 1 / ratio;
|
|
1369
|
+
}
|
|
1370
|
+
function isSequentialScan(node) {
|
|
1371
|
+
const normalizedType = node.node_type.toLowerCase();
|
|
1372
|
+
const accessType = typeof node.extra.access_type === "string" ? node.extra.access_type.toLowerCase() : "";
|
|
1373
|
+
return normalizedType.includes("seq scan") || normalizedType.includes("table scan") || normalizedType.includes("full scan") || accessType === "all";
|
|
1374
|
+
}
|
|
1375
|
+
function isTempOperation(node) {
|
|
1376
|
+
const normalizedType = node.node_type.toLowerCase();
|
|
1377
|
+
const extraText = Object.values(node.extra).filter((value) => typeof value === "string").join(" ").toLowerCase();
|
|
1378
|
+
return normalizedType.includes("sort") || normalizedType.includes("filesort") || normalizedType.includes("temporary") || extraText.includes("using temporary") || extraText.includes("using filesort");
|
|
1379
|
+
}
|
|
1380
|
+
function getExplainPlanSummary(plan, metrics) {
|
|
1381
|
+
const nodes = flattenExplainNodes(plan.root);
|
|
1382
|
+
const planMetrics = metrics ?? computeExplainMetrics(plan);
|
|
1383
|
+
let highestCostNode = null;
|
|
1384
|
+
let slowestNode = null;
|
|
1385
|
+
let largestRowMismatchNode = null;
|
|
1386
|
+
let sequentialScans = 0;
|
|
1387
|
+
let tempOperations = 0;
|
|
1388
|
+
for (const node of nodes) {
|
|
1389
|
+
const nodeMetrics = planMetrics.byId.get(node.id);
|
|
1390
|
+
const exclusiveCost = nodeMetrics?.exclusiveCost ?? null;
|
|
1391
|
+
const exclusiveTimeMs = nodeMetrics?.exclusiveTimeMs ?? null;
|
|
1392
|
+
if (exclusiveCost != null && (highestCostNode == null || exclusiveCost > highestCostNode.value)) {
|
|
1393
|
+
highestCostNode = {
|
|
1394
|
+
nodeId: node.id,
|
|
1395
|
+
nodeType: node.node_type,
|
|
1396
|
+
relation: node.relation,
|
|
1397
|
+
value: exclusiveCost
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
if (exclusiveTimeMs != null && (slowestNode == null || exclusiveTimeMs > slowestNode.value)) {
|
|
1401
|
+
slowestNode = {
|
|
1402
|
+
nodeId: node.id,
|
|
1403
|
+
nodeType: node.node_type,
|
|
1404
|
+
relation: node.relation,
|
|
1405
|
+
value: exclusiveTimeMs
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
const ratio = getRowEstimateRatio(node);
|
|
1409
|
+
const magnitude = getMismatchMagnitude(node);
|
|
1410
|
+
if (ratio != null && magnitude != null && (largestRowMismatchNode == null || magnitude > largestRowMismatchNode.value)) {
|
|
1411
|
+
largestRowMismatchNode = {
|
|
1412
|
+
nodeId: node.id,
|
|
1413
|
+
nodeType: node.node_type,
|
|
1414
|
+
relation: node.relation,
|
|
1415
|
+
value: magnitude,
|
|
1416
|
+
ratio
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
if (isSequentialScan(node)) {
|
|
1420
|
+
sequentialScans += 1;
|
|
1421
|
+
}
|
|
1422
|
+
if (isTempOperation(node)) {
|
|
1423
|
+
tempOperations += 1;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return {
|
|
1427
|
+
highestCostNode,
|
|
1428
|
+
slowestNode,
|
|
1429
|
+
largestRowMismatchNode,
|
|
1430
|
+
sequentialScans,
|
|
1431
|
+
tempOperations
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function getExplainDriverLegend(plan) {
|
|
1435
|
+
switch (plan.driver) {
|
|
1436
|
+
case "postgres":
|
|
1437
|
+
return plan.has_analyze_data ? [
|
|
1438
|
+
"editor.visualExplain.postgresAnalyzeLegend1",
|
|
1439
|
+
"editor.visualExplain.postgresAnalyzeLegend2"
|
|
1440
|
+
] : [
|
|
1441
|
+
"editor.visualExplain.postgresEstimateLegend1",
|
|
1442
|
+
"editor.visualExplain.postgresEstimateLegend2"
|
|
1443
|
+
];
|
|
1444
|
+
case "mysql":
|
|
1445
|
+
return plan.has_analyze_data ? [
|
|
1446
|
+
"editor.visualExplain.mysqlAnalyzeLegend1",
|
|
1447
|
+
"editor.visualExplain.mysqlAnalyzeLegend2"
|
|
1448
|
+
] : [
|
|
1449
|
+
"editor.visualExplain.mysqlEstimateLegend1",
|
|
1450
|
+
"editor.visualExplain.mysqlEstimateLegend2"
|
|
1451
|
+
];
|
|
1452
|
+
case "sqlite":
|
|
1453
|
+
return [
|
|
1454
|
+
"editor.visualExplain.sqliteLegend1",
|
|
1455
|
+
"editor.visualExplain.sqliteLegend2"
|
|
1456
|
+
];
|
|
1457
|
+
default:
|
|
1458
|
+
return [];
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/diagnostics.ts
|
|
1463
|
+
var EXPLAIN_DIAGNOSTIC_THRESHOLDS = {
|
|
1464
|
+
/** Share of total plan time above which a node is called a hotspot. */
|
|
1465
|
+
hotspotTimeShare: 0.25,
|
|
1466
|
+
/** Nodes faster than this never count as a hotspot, however large the share. */
|
|
1467
|
+
hotspotMinTimeMs: 1,
|
|
1468
|
+
/** Row estimate off by this factor is a warning. */
|
|
1469
|
+
estimateWarnFactor: 4,
|
|
1470
|
+
/** Row estimate off by this factor is critical. */
|
|
1471
|
+
estimateCriticalFactor: 10,
|
|
1472
|
+
/** Estimate checks are skipped below this many actual rows, to cut noise. */
|
|
1473
|
+
estimateMinRows: 10,
|
|
1474
|
+
/** Fraction of scanned rows dropped by a filter that counts as wasted work. */
|
|
1475
|
+
filterLossRatio: 0.9,
|
|
1476
|
+
/** Filter checks are skipped below this many discarded rows. */
|
|
1477
|
+
filterLossMinRows: 1e3,
|
|
1478
|
+
/** Sequential scans below this many rows are not reported. */
|
|
1479
|
+
seqScanMinRows: 1e4,
|
|
1480
|
+
/** Heap fetches above this count suggest the visibility map is stale. */
|
|
1481
|
+
heapFetchesMinCount: 1e3,
|
|
1482
|
+
/** Loop count above which repeated execution is worth pointing out. */
|
|
1483
|
+
highLoopsMinCount: 1e3,
|
|
1484
|
+
/** Fraction of buffer accesses that missed shared buffers. */
|
|
1485
|
+
cacheMissRatio: 0.5,
|
|
1486
|
+
/** Cache checks are skipped below this many blocks read from disk. */
|
|
1487
|
+
cacheMissMinBlocks: 1e3
|
|
1488
|
+
};
|
|
1489
|
+
var SEVERITY_RANK = {
|
|
1490
|
+
critical: 0,
|
|
1491
|
+
warning: 1,
|
|
1492
|
+
info: 2
|
|
1493
|
+
};
|
|
1494
|
+
function extraString(node, key) {
|
|
1495
|
+
const value = node.extra[key];
|
|
1496
|
+
return typeof value === "string" ? value : null;
|
|
1497
|
+
}
|
|
1498
|
+
function extraNumber(node, key) {
|
|
1499
|
+
const value = node.extra[key];
|
|
1500
|
+
if (typeof value === "number") {
|
|
1501
|
+
return value;
|
|
1502
|
+
}
|
|
1503
|
+
if (typeof value === "string") {
|
|
1504
|
+
const parsed = Number(value);
|
|
1505
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1506
|
+
}
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
function isSequentialScan2(node) {
|
|
1510
|
+
const nodeType = node.node_type.toLowerCase();
|
|
1511
|
+
const accessType = extraString(node, "access_type")?.toLowerCase() ?? "";
|
|
1512
|
+
return nodeType.includes("seq scan") || nodeType.includes("table scan") || nodeType.includes("full scan") || accessType === "all";
|
|
1513
|
+
}
|
|
1514
|
+
function usesDiskWorkspace(node) {
|
|
1515
|
+
const sortMethod = extraString(node, "Sort Method")?.toLowerCase() ?? "";
|
|
1516
|
+
const sortSpace = extraString(node, "Sort Space Type")?.toLowerCase() ?? "";
|
|
1517
|
+
if (sortMethod.includes("disk") || sortSpace === "disk") {
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
return (extraNumber(node, "Temp Written Blocks") ?? 0) > 0;
|
|
1521
|
+
}
|
|
1522
|
+
function estimateDiagnostic(node) {
|
|
1523
|
+
const { estimateMinRows, estimateWarnFactor, estimateCriticalFactor } = EXPLAIN_DIAGNOSTIC_THRESHOLDS;
|
|
1524
|
+
if (node.plan_rows == null || node.actual_rows == null || node.plan_rows <= 0 || node.actual_rows < estimateMinRows) {
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
const ratio = node.actual_rows / node.plan_rows;
|
|
1528
|
+
const factor = ratio >= 1 ? ratio : 1 / ratio;
|
|
1529
|
+
if (factor < estimateWarnFactor) {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
const severity = factor >= estimateCriticalFactor ? "critical" : "warning";
|
|
1533
|
+
return ratio >= 1 ? {
|
|
1534
|
+
kind: "over-estimate",
|
|
1535
|
+
severity,
|
|
1536
|
+
labelKey: "editor.visualExplain.diagnostics.overEstimate",
|
|
1537
|
+
descriptionKey: "editor.visualExplain.diagnostics.overEstimateHint",
|
|
1538
|
+
value: formatRatio(factor)
|
|
1539
|
+
} : {
|
|
1540
|
+
kind: "under-estimate",
|
|
1541
|
+
severity,
|
|
1542
|
+
labelKey: "editor.visualExplain.diagnostics.underEstimate",
|
|
1543
|
+
descriptionKey: "editor.visualExplain.diagnostics.underEstimateHint",
|
|
1544
|
+
value: formatRatio(factor)
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
function getNodeDiagnostics(node, metrics) {
|
|
1548
|
+
const t = EXPLAIN_DIAGNOSTIC_THRESHOLDS;
|
|
1549
|
+
const diagnostics = [];
|
|
1550
|
+
if (metrics?.neverExecuted) {
|
|
1551
|
+
diagnostics.push({
|
|
1552
|
+
kind: "never-executed",
|
|
1553
|
+
severity: "info",
|
|
1554
|
+
labelKey: "editor.visualExplain.diagnostics.neverExecuted",
|
|
1555
|
+
descriptionKey: "editor.visualExplain.diagnostics.neverExecutedHint"
|
|
1556
|
+
});
|
|
1557
|
+
return diagnostics;
|
|
1558
|
+
}
|
|
1559
|
+
if (metrics?.timeShare != null && metrics.timeShare >= t.hotspotTimeShare && (metrics.exclusiveTimeMs ?? 0) >= t.hotspotMinTimeMs) {
|
|
1560
|
+
diagnostics.push({
|
|
1561
|
+
kind: "hotspot",
|
|
1562
|
+
severity: "critical",
|
|
1563
|
+
labelKey: "editor.visualExplain.diagnostics.hotspot",
|
|
1564
|
+
descriptionKey: "editor.visualExplain.diagnostics.hotspotHint",
|
|
1565
|
+
value: `${Math.round(metrics.timeShare * 100)}%`
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
const estimate = estimateDiagnostic(node);
|
|
1569
|
+
if (estimate) {
|
|
1570
|
+
diagnostics.push(estimate);
|
|
1571
|
+
}
|
|
1572
|
+
if (usesDiskWorkspace(node)) {
|
|
1573
|
+
diagnostics.push({
|
|
1574
|
+
kind: "disk-sort",
|
|
1575
|
+
severity: "warning",
|
|
1576
|
+
labelKey: "editor.visualExplain.diagnostics.diskSort",
|
|
1577
|
+
descriptionKey: "editor.visualExplain.diagnostics.diskSortHint",
|
|
1578
|
+
value: extraString(node, "Sort Method") ?? void 0
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
const rowsRemoved = extraNumber(node, "Rows Removed by Filter");
|
|
1582
|
+
if (rowsRemoved != null && rowsRemoved >= t.filterLossMinRows) {
|
|
1583
|
+
const kept = node.actual_rows ?? 0;
|
|
1584
|
+
const scanned = kept + rowsRemoved;
|
|
1585
|
+
if (scanned > 0 && rowsRemoved / scanned >= t.filterLossRatio) {
|
|
1586
|
+
diagnostics.push({
|
|
1587
|
+
kind: "filter-loss",
|
|
1588
|
+
severity: "warning",
|
|
1589
|
+
labelKey: "editor.visualExplain.diagnostics.filterLoss",
|
|
1590
|
+
descriptionKey: "editor.visualExplain.diagnostics.filterLossHint",
|
|
1591
|
+
value: formatRows(rowsRemoved)
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (isSequentialScan2(node)) {
|
|
1596
|
+
const rows = node.actual_rows ?? node.plan_rows;
|
|
1597
|
+
if (rows != null && rows >= t.seqScanMinRows) {
|
|
1598
|
+
diagnostics.push({
|
|
1599
|
+
kind: "large-seq-scan",
|
|
1600
|
+
severity: "warning",
|
|
1601
|
+
labelKey: "editor.visualExplain.diagnostics.largeSeqScan",
|
|
1602
|
+
descriptionKey: "editor.visualExplain.diagnostics.largeSeqScanHint",
|
|
1603
|
+
value: formatRows(rows)
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
const heapFetches = extraNumber(node, "Heap Fetches");
|
|
1608
|
+
if (heapFetches != null && heapFetches >= t.heapFetchesMinCount) {
|
|
1609
|
+
diagnostics.push({
|
|
1610
|
+
kind: "heap-fetches",
|
|
1611
|
+
severity: "warning",
|
|
1612
|
+
labelKey: "editor.visualExplain.diagnostics.heapFetches",
|
|
1613
|
+
descriptionKey: "editor.visualExplain.diagnostics.heapFetchesHint",
|
|
1614
|
+
value: formatRows(heapFetches)
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
const workersPlanned = extraNumber(node, "Workers Planned");
|
|
1618
|
+
const workersLaunched = extraNumber(node, "Workers Launched");
|
|
1619
|
+
if (workersPlanned != null && workersLaunched != null && workersLaunched < workersPlanned) {
|
|
1620
|
+
diagnostics.push({
|
|
1621
|
+
kind: "workers-underused",
|
|
1622
|
+
severity: "warning",
|
|
1623
|
+
labelKey: "editor.visualExplain.diagnostics.workersUnderused",
|
|
1624
|
+
descriptionKey: "editor.visualExplain.diagnostics.workersUnderusedHint",
|
|
1625
|
+
value: `${workersLaunched}/${workersPlanned}`
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
if (node.actual_loops != null && node.actual_loops >= t.highLoopsMinCount) {
|
|
1629
|
+
diagnostics.push({
|
|
1630
|
+
kind: "high-loops",
|
|
1631
|
+
severity: "info",
|
|
1632
|
+
labelKey: "editor.visualExplain.diagnostics.highLoops",
|
|
1633
|
+
descriptionKey: "editor.visualExplain.diagnostics.highLoopsHint",
|
|
1634
|
+
value: formatRows(node.actual_loops)
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
if (node.buffers_read != null && node.buffers_read >= t.cacheMissMinBlocks && node.buffers_read / (node.buffers_read + (node.buffers_hit ?? 0)) >= t.cacheMissRatio) {
|
|
1638
|
+
diagnostics.push({
|
|
1639
|
+
kind: "cache-miss",
|
|
1640
|
+
severity: "info",
|
|
1641
|
+
labelKey: "editor.visualExplain.diagnostics.cacheMiss",
|
|
1642
|
+
descriptionKey: "editor.visualExplain.diagnostics.cacheMissHint",
|
|
1643
|
+
value: formatRows(node.buffers_read)
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
return diagnostics.sort(
|
|
1647
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
function getPlanDiagnostics(plan, metrics) {
|
|
1651
|
+
const result = /* @__PURE__ */ new Map();
|
|
1652
|
+
for (const node of flattenExplainNodes(plan.root)) {
|
|
1653
|
+
const diagnostics = getNodeDiagnostics(node, metrics.byId.get(node.id));
|
|
1654
|
+
if (diagnostics.length > 0) {
|
|
1655
|
+
result.set(node.id, diagnostics);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return result;
|
|
1659
|
+
}
|
|
1660
|
+
function getWorstSeverity(diagnostics) {
|
|
1661
|
+
let worst = null;
|
|
1662
|
+
for (const diagnostic of diagnostics) {
|
|
1663
|
+
if (worst == null || SEVERITY_RANK[diagnostic.severity] < SEVERITY_RANK[worst]) {
|
|
1664
|
+
worst = diagnostic.severity;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return worst;
|
|
1668
|
+
}
|
|
1669
|
+
function countDiagnosticsBySeverity(diagnostics) {
|
|
1670
|
+
const counts = {
|
|
1671
|
+
critical: 0,
|
|
1672
|
+
warning: 0,
|
|
1673
|
+
info: 0
|
|
1674
|
+
};
|
|
1675
|
+
for (const list of diagnostics.values()) {
|
|
1676
|
+
for (const diagnostic of list) {
|
|
1677
|
+
counts[diagnostic.severity] += 1;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
return counts;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// src/stats.ts
|
|
1684
|
+
function addTo(sum, value) {
|
|
1685
|
+
if (value != null) {
|
|
1686
|
+
sum.total += value;
|
|
1687
|
+
sum.hasData = true;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
function resolve(sum) {
|
|
1691
|
+
return sum.hasData ? sum.total : null;
|
|
1692
|
+
}
|
|
1693
|
+
function newSum() {
|
|
1694
|
+
return { total: 0, hasData: false };
|
|
1695
|
+
}
|
|
1696
|
+
function indexNameOf(node) {
|
|
1697
|
+
const value = node.extra["Index Name"];
|
|
1698
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1699
|
+
}
|
|
1700
|
+
function getExplainPlanStats(plan, metrics) {
|
|
1701
|
+
const nodes = flattenExplainNodes(plan.root);
|
|
1702
|
+
const byNodeType = /* @__PURE__ */ new Map();
|
|
1703
|
+
const byRelation = /* @__PURE__ */ new Map();
|
|
1704
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
1705
|
+
let maxDepth = 0;
|
|
1706
|
+
let neverExecutedCount = 0;
|
|
1707
|
+
const totalTime = newSum();
|
|
1708
|
+
for (const node of nodes) {
|
|
1709
|
+
const nodeMetrics = metrics.byId.get(node.id);
|
|
1710
|
+
const exclusiveTimeMs = nodeMetrics?.exclusiveTimeMs ?? null;
|
|
1711
|
+
maxDepth = Math.max(maxDepth, (nodeMetrics?.depth ?? 0) + 1);
|
|
1712
|
+
if (nodeMetrics?.neverExecuted) {
|
|
1713
|
+
neverExecutedCount += 1;
|
|
1714
|
+
}
|
|
1715
|
+
addTo(totalTime, exclusiveTimeMs);
|
|
1716
|
+
const typeStat = byNodeType.get(node.node_type) ?? {
|
|
1717
|
+
count: 0,
|
|
1718
|
+
time: newSum()
|
|
1719
|
+
};
|
|
1720
|
+
typeStat.count += 1;
|
|
1721
|
+
addTo(typeStat.time, exclusiveTimeMs);
|
|
1722
|
+
byNodeType.set(node.node_type, typeStat);
|
|
1723
|
+
if (node.relation) {
|
|
1724
|
+
const relationStat = byRelation.get(node.relation) ?? {
|
|
1725
|
+
accessCount: 0,
|
|
1726
|
+
nodeTypes: [],
|
|
1727
|
+
time: newSum(),
|
|
1728
|
+
rows: newSum()
|
|
1729
|
+
};
|
|
1730
|
+
relationStat.accessCount += 1;
|
|
1731
|
+
if (!relationStat.nodeTypes.includes(node.node_type)) {
|
|
1732
|
+
relationStat.nodeTypes.push(node.node_type);
|
|
1733
|
+
}
|
|
1734
|
+
addTo(relationStat.time, exclusiveTimeMs);
|
|
1735
|
+
addTo(relationStat.rows, nodeMetrics?.totalRows ?? null);
|
|
1736
|
+
byRelation.set(node.relation, relationStat);
|
|
1737
|
+
}
|
|
1738
|
+
const indexName = indexNameOf(node);
|
|
1739
|
+
if (indexName) {
|
|
1740
|
+
const indexStat = byIndex.get(indexName) ?? {
|
|
1741
|
+
relation: node.relation,
|
|
1742
|
+
scanCount: 0,
|
|
1743
|
+
time: newSum()
|
|
1744
|
+
};
|
|
1745
|
+
indexStat.scanCount += 1;
|
|
1746
|
+
indexStat.relation = indexStat.relation ?? node.relation;
|
|
1747
|
+
addTo(indexStat.time, exclusiveTimeMs);
|
|
1748
|
+
byIndex.set(indexName, indexStat);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const planTotalTime = resolve(totalTime);
|
|
1752
|
+
const nodeTypes = Array.from(byNodeType.entries()).map(([nodeType, stat]) => {
|
|
1753
|
+
const exclusiveTimeMs = resolve(stat.time);
|
|
1754
|
+
return {
|
|
1755
|
+
nodeType,
|
|
1756
|
+
count: stat.count,
|
|
1757
|
+
exclusiveTimeMs,
|
|
1758
|
+
timeShare: exclusiveTimeMs != null && planTotalTime != null && planTotalTime > 0 ? exclusiveTimeMs / planTotalTime : null
|
|
1759
|
+
};
|
|
1760
|
+
}).sort(compareByTimeThenCount);
|
|
1761
|
+
const relations = Array.from(byRelation.entries()).map(([relation, stat]) => ({
|
|
1762
|
+
relation,
|
|
1763
|
+
accessCount: stat.accessCount,
|
|
1764
|
+
nodeTypes: stat.nodeTypes,
|
|
1765
|
+
exclusiveTimeMs: resolve(stat.time),
|
|
1766
|
+
totalRows: resolve(stat.rows)
|
|
1767
|
+
})).sort(
|
|
1768
|
+
(a, b) => (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.accessCount - a.accessCount || a.relation.localeCompare(b.relation)
|
|
1769
|
+
);
|
|
1770
|
+
const indexes = Array.from(byIndex.entries()).map(([indexName, stat]) => ({
|
|
1771
|
+
indexName,
|
|
1772
|
+
relation: stat.relation,
|
|
1773
|
+
scanCount: stat.scanCount,
|
|
1774
|
+
exclusiveTimeMs: resolve(stat.time)
|
|
1775
|
+
})).sort(
|
|
1776
|
+
(a, b) => (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.scanCount - a.scanCount || a.indexName.localeCompare(b.indexName)
|
|
1777
|
+
);
|
|
1778
|
+
return {
|
|
1779
|
+
nodeCount: nodes.length,
|
|
1780
|
+
maxDepth,
|
|
1781
|
+
neverExecutedCount,
|
|
1782
|
+
totalExclusiveTimeMs: planTotalTime,
|
|
1783
|
+
nodeTypes,
|
|
1784
|
+
relations,
|
|
1785
|
+
indexes
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
function compareByTimeThenCount(a, b) {
|
|
1789
|
+
return (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.count - a.count || a.nodeType.localeCompare(b.nodeType);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
export { EXPLAIN_DIAGNOSTIC_THRESHOLDS, EXPLAIN_METRIC_KINDS, NodeIdAllocator, buildSqliteTree, computeExplainMetrics, countDiagnosticsBySeverity, detectFormat, detectFormatFor, explainEngineFromDriverName, findExplainNode, flattenExplainNodes, formatCost, formatRatio, formatRows, formatTime, getAvailableMetricKinds, getDefaultMetricKind, getExplainDriverLegend, getExplainPlanStats, getExplainPlanSummary, getHeatBarClass, getMaxCost, getMaxTime, getMetricMax, getMetricValue, getNodeCostStyle, getNodeDiagnostics, getNodeMetrics, getPlanDiagnostics, getRowEstimateRatio, getWorstSeverity, hasAnalyzeDataRecursive, isMetricAvailable, parseExplain, parseExplainFor, parseMysqlJson, parseMysqlTabularRows, parseMysqlText, parsePostgresJson, parsePostgresText, parseRawExplain, parseSqliteEqpRows, resolveExplainOutput, withSourceLabel };
|