@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/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 };