@tabularis/explain 0.1.0 → 0.3.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 CHANGED
@@ -304,13 +304,17 @@ function parseMysqlQueryBlock(value, ids) {
304
304
  if (item["table"] !== void 0) {
305
305
  children.push(parseMysqlQueryBlock(item, ids));
306
306
  } else if (item["read_sorted_file"] !== void 0) {
307
- children.push(parseMariadbWrapper(item["read_sorted_file"], "Read Sorted File", ids));
307
+ children.push(
308
+ parseMariadbWrapper(item["read_sorted_file"], "Read Sorted File", ids)
309
+ );
308
310
  } else if (asObject(item["filesort"]) !== null) {
309
311
  children.push(parseMariadbFilesort(item["filesort"], ids));
310
312
  } else if (item["temporary_table"] !== void 0) {
311
313
  children.push(parseMariadbTemporaryTable(item["temporary_table"], ids));
312
314
  } else if (item["materialized"] !== void 0) {
313
- children.push(parseMariadbWrapper(item["materialized"], "Materialized Subquery", ids));
315
+ children.push(
316
+ parseMariadbWrapper(item["materialized"], "Materialized Subquery", ids)
317
+ );
314
318
  } else if (item["buffer_result"] !== void 0) {
315
319
  children.push(parseMariadbWrapper(item["buffer_result"], "Buffer Result", ids));
316
320
  }
@@ -698,9 +702,7 @@ function parsePgPlanNode(node, ids) {
698
702
  extra[key] = value;
699
703
  }
700
704
  }
701
- const children = (asArray(obj["Plans"]) ?? []).map(
702
- (child) => parsePgPlanNode(child, ids)
703
- );
705
+ const children = (asArray(obj["Plans"]) ?? []).map((child) => parsePgPlanNode(child, ids));
704
706
  return createExplainNode(id, {
705
707
  node_type: asString(obj["Node Type"]) ?? "Unknown",
706
708
  relation: asString(obj["Relation Name"]),
@@ -925,80 +927,6 @@ function applyTextAttribute(node, content) {
925
927
  }
926
928
  }
927
929
 
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
930
  // src/parsers/sqlite.ts
1003
931
  function buildSqliteTree(entries, parentId, ids) {
1004
932
  const rootEntry = entries.find(
@@ -1085,44 +1013,234 @@ function parseSqliteDetail(detail) {
1085
1013
  return [detail, null, null];
1086
1014
  }
1087
1015
 
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) {
1016
+ // src/parsers/builtins.ts
1017
+ function parseJsonRows(payload, format) {
1115
1018
  let value;
1116
1019
  try {
1117
- value = JSON.parse(raw.payload);
1020
+ value = JSON.parse(payload);
1118
1021
  } catch (err) {
1119
1022
  throw new Error(`Failed to parse EXPLAIN rows: ${String(err)}`);
1120
1023
  }
1121
1024
  if (!Array.isArray(value)) {
1122
- throw new Error(`EXPLAIN rows payload for '${raw.format}' must be a JSON array`);
1025
+ throw new Error(`EXPLAIN rows payload for '${format}' must be a JSON array`);
1123
1026
  }
1124
1027
  return value;
1125
1028
  }
1029
+ var BUILTIN_EXPLAIN_PARSERS = Object.freeze([
1030
+ Object.freeze({
1031
+ engine: "postgres",
1032
+ format: "postgres-json",
1033
+ parse: parsePostgresJson
1034
+ }),
1035
+ Object.freeze({
1036
+ engine: "postgres",
1037
+ format: "postgres-text",
1038
+ parse: parsePostgresText
1039
+ }),
1040
+ Object.freeze({
1041
+ engine: "mysql",
1042
+ format: "mysql-json",
1043
+ parse: parseMysqlJson
1044
+ }),
1045
+ Object.freeze({
1046
+ engine: "mysql",
1047
+ format: "mysql-text",
1048
+ parse: parseMysqlText
1049
+ }),
1050
+ Object.freeze({
1051
+ engine: "mysql",
1052
+ format: "mysql-analyze-text",
1053
+ parse: parseMysqlText
1054
+ }),
1055
+ Object.freeze({
1056
+ engine: "mysql",
1057
+ format: "mysql-tabular-rows",
1058
+ parse: (payload) => parseMysqlTabularRows(parseJsonRows(payload, "mysql-tabular-rows"))
1059
+ }),
1060
+ Object.freeze({
1061
+ engine: "sqlite",
1062
+ format: "sqlite-eqp-rows",
1063
+ parse: (payload) => parseSqliteEqpRows(parseJsonRows(payload, "sqlite-eqp-rows"))
1064
+ })
1065
+ ]);
1066
+
1067
+ // src/registry.ts
1068
+ var builtinParsersByFormat = new Map(
1069
+ BUILTIN_EXPLAIN_PARSERS.map((parser) => [parser.format, parser])
1070
+ );
1071
+ var parserOverlays = /* @__PURE__ */ new Map();
1072
+ var customFormatOrder = [];
1073
+ function registerExplainParser(parser) {
1074
+ validateParser(parser);
1075
+ const replacing = getExplainParser(parser.format) !== null;
1076
+ if (replacing) {
1077
+ console.warn(
1078
+ `EXPLAIN parser format '${parser.format}' is already registered; replacing it.`
1079
+ );
1080
+ } else {
1081
+ customFormatOrder.push(parser.format);
1082
+ }
1083
+ parserOverlays.set(parser.format, parser);
1084
+ }
1085
+ function unregisterExplainParser(format) {
1086
+ if (!parserOverlays.delete(format)) return;
1087
+ if (builtinParsersByFormat.has(format)) return;
1088
+ const index = customFormatOrder.indexOf(format);
1089
+ if (index !== -1) customFormatOrder.splice(index, 1);
1090
+ }
1091
+ function getExplainParser(format) {
1092
+ return parserOverlays.get(format) ?? builtinParsersByFormat.get(format) ?? null;
1093
+ }
1094
+ function listExplainParsers() {
1095
+ const parsers = BUILTIN_EXPLAIN_PARSERS.map(
1096
+ (parser) => parserOverlays.get(parser.format) ?? parser
1097
+ );
1098
+ for (const format of customFormatOrder) {
1099
+ const parser = parserOverlays.get(format);
1100
+ if (parser !== void 0) parsers.push(parser);
1101
+ }
1102
+ return Object.freeze(parsers);
1103
+ }
1104
+ function validateParser(parser) {
1105
+ if (typeof parser !== "object" || parser === null || typeof parser.engine !== "string" || parser.engine.trim() === "" || typeof parser.format !== "string" || parser.format.trim() === "" || typeof parser.parse !== "function") {
1106
+ throw new TypeError(
1107
+ "EXPLAIN parser registration requires non-empty engine and format strings and a parse function"
1108
+ );
1109
+ }
1110
+ }
1111
+
1112
+ // src/parsers/source.ts
1113
+ var BUILTIN_SOURCE_FORMATS = /* @__PURE__ */ new Set([
1114
+ "postgres-json",
1115
+ "postgres-text",
1116
+ "mysql-json",
1117
+ "mysql-text"
1118
+ ]);
1119
+ function parserFor(format) {
1120
+ const parser = getExplainParser(format);
1121
+ if (parser === null) {
1122
+ throw new Error(`No parser registered for format '${format}'`);
1123
+ }
1124
+ return parser;
1125
+ }
1126
+ function explainEngineFromDriverName(name) {
1127
+ const normalizedName = name.trim().toLowerCase();
1128
+ switch (normalizedName) {
1129
+ case "postgres":
1130
+ case "postgresql":
1131
+ case "pg":
1132
+ return "postgres";
1133
+ case "mysql":
1134
+ case "mariadb":
1135
+ return "mysql";
1136
+ case "sqlite":
1137
+ case "sqlite3":
1138
+ return "sqlite";
1139
+ }
1140
+ const parser = listExplainParsers().find(
1141
+ (candidate) => candidate.engine.trim().toLowerCase() === normalizedName
1142
+ );
1143
+ return parser?.engine ?? null;
1144
+ }
1145
+ async function detectFormat(raw) {
1146
+ return await detectFormatFor(raw, null);
1147
+ }
1148
+ async function detectFormatFor(raw, engine) {
1149
+ switch (engine) {
1150
+ case "postgres":
1151
+ return Promise.resolve(detectPostgresFormat(raw));
1152
+ case "mysql":
1153
+ if (looksLikeJson(raw)) return Promise.resolve("mysql-json");
1154
+ if (raw.trim() === "") {
1155
+ throw new Error("Unsupported EXPLAIN file format: input is empty");
1156
+ }
1157
+ return Promise.resolve("mysql-text");
1158
+ case "sqlite":
1159
+ throw new Error(
1160
+ "SQLite EXPLAIN QUERY PLAN has no text form here: pass its (id, parent, detail) rows to buildSqliteTree"
1161
+ );
1162
+ case null: {
1163
+ const builtinFormat = detectPostgresFormatOrNull(raw);
1164
+ if (builtinFormat !== null) return builtinFormat;
1165
+ const customFormat = await sniffRegisteredFormat(raw, null);
1166
+ if (customFormat !== null) return customFormat;
1167
+ throw new Error(
1168
+ "Unsupported EXPLAIN file format: expected Postgres JSON or text output"
1169
+ );
1170
+ }
1171
+ default: {
1172
+ const format = await sniffRegisteredFormat(raw, engine);
1173
+ if (format !== null) return format;
1174
+ throw new Error(`Unsupported EXPLAIN file format for engine '${engine}'`);
1175
+ }
1176
+ }
1177
+ }
1178
+ function detectPostgresFormat(raw) {
1179
+ const format = detectPostgresFormatOrNull(raw);
1180
+ if (format !== null) return format;
1181
+ throw new Error("Unsupported EXPLAIN file format: expected Postgres JSON or text output");
1182
+ }
1183
+ function detectPostgresFormatOrNull(raw) {
1184
+ if (looksLikeJson(raw)) return "postgres-json";
1185
+ if (looksLikePostgresText(raw)) return "postgres-text";
1186
+ return null;
1187
+ }
1188
+ async function sniffRegisteredFormat(raw, engine) {
1189
+ const normalizedEngine = engine?.trim().toLowerCase() ?? null;
1190
+ for (const parser of listExplainParsers()) {
1191
+ if (normalizedEngine === null && BUILTIN_SOURCE_FORMATS.has(parser.format)) {
1192
+ continue;
1193
+ }
1194
+ if (normalizedEngine !== null && parser.engine.trim().toLowerCase() !== normalizedEngine) {
1195
+ continue;
1196
+ }
1197
+ if (parser.sniff === void 0) continue;
1198
+ try {
1199
+ if (await parser.sniff(raw)) return parser.format;
1200
+ } catch {
1201
+ }
1202
+ }
1203
+ return null;
1204
+ }
1205
+ function looksLikeJson(raw) {
1206
+ const trimmed = raw.trimStart();
1207
+ return trimmed.startsWith("[") || trimmed.startsWith("{");
1208
+ }
1209
+ function looksLikePostgresText(raw) {
1210
+ return raw.split("\n").some((line) => line.includes("(cost=") && line.includes("width="));
1211
+ }
1212
+ function parseExplain(raw) {
1213
+ return parseExplainFor(raw, null);
1214
+ }
1215
+ async function parseExplainFor(raw, engine) {
1216
+ const format = await detectFormatFor(raw, engine);
1217
+ return await parserFor(format).parse(raw);
1218
+ }
1219
+ function withSourceLabel(plan, name) {
1220
+ if (plan.original_query === "") {
1221
+ return { ...plan, original_query: `-- loaded from ${name}` };
1222
+ }
1223
+ return plan;
1224
+ }
1225
+
1226
+ // src/raw.ts
1227
+ async function parseRawExplain(raw) {
1228
+ const parser = getExplainParser(raw.format);
1229
+ if (parser === null) {
1230
+ throw new Error(
1231
+ `No EXPLAIN parser registered for format '${raw.format}' (engine '${raw.engine}'). Import the parser package for '${raw.engine}' before parsing.`
1232
+ );
1233
+ }
1234
+ const plan = await parser.parse(raw.payload);
1235
+ return {
1236
+ ...plan,
1237
+ driver: raw.engine,
1238
+ original_query: raw.original_query
1239
+ };
1240
+ }
1241
+ function resolveExplainOutput(output) {
1242
+ return output.kind === "plan" ? Promise.resolve(output.plan) : parseRawExplain(output.raw);
1243
+ }
1126
1244
 
1127
1245
  // src/metrics.ts
1128
1246
  var EXPLAIN_METRIC_KINDS = [
@@ -1202,16 +1320,10 @@ function computeExplainMetrics(plan) {
1202
1320
  let maxExclusiveBuffers = 0;
1203
1321
  let totalExclusiveTimeMs = 0;
1204
1322
  for (const metrics of order) {
1205
- maxExclusiveTimeMs = Math.max(
1206
- maxExclusiveTimeMs,
1207
- metrics.exclusiveTimeMs ?? 0
1208
- );
1323
+ maxExclusiveTimeMs = Math.max(maxExclusiveTimeMs, metrics.exclusiveTimeMs ?? 0);
1209
1324
  maxExclusiveCost = Math.max(maxExclusiveCost, metrics.exclusiveCost ?? 0);
1210
1325
  maxTotalRows = Math.max(maxTotalRows, metrics.totalRows ?? 0);
1211
- maxExclusiveBuffers = Math.max(
1212
- maxExclusiveBuffers,
1213
- metrics.exclusiveBuffers ?? 0
1214
- );
1326
+ maxExclusiveBuffers = Math.max(maxExclusiveBuffers, metrics.exclusiveBuffers ?? 0);
1215
1327
  totalExclusiveTimeMs += metrics.exclusiveTimeMs ?? 0;
1216
1328
  }
1217
1329
  if (totalExclusiveTimeMs > 0) {
@@ -1360,7 +1472,7 @@ function getRowEstimateRatio(node) {
1360
1472
  }
1361
1473
  return node.actual_rows / node.plan_rows;
1362
1474
  }
1363
- function getMismatchMagnitude(node) {
1475
+ function getRowEstimateGap(node) {
1364
1476
  const ratio = getRowEstimateRatio(node);
1365
1477
  if (ratio == null) {
1366
1478
  return null;
@@ -1406,7 +1518,7 @@ function getExplainPlanSummary(plan, metrics) {
1406
1518
  };
1407
1519
  }
1408
1520
  const ratio = getRowEstimateRatio(node);
1409
- const magnitude = getMismatchMagnitude(node);
1521
+ const magnitude = getRowEstimateGap(node);
1410
1522
  if (ratio != null && magnitude != null && (largestRowMismatchNode == null || magnitude > largestRowMismatchNode.value)) {
1411
1523
  largestRowMismatchNode = {
1412
1524
  nodeId: node.id,
@@ -1450,14 +1562,33 @@ function getExplainDriverLegend(plan) {
1450
1562
  "editor.visualExplain.mysqlEstimateLegend2"
1451
1563
  ];
1452
1564
  case "sqlite":
1453
- return [
1454
- "editor.visualExplain.sqliteLegend1",
1455
- "editor.visualExplain.sqliteLegend2"
1456
- ];
1565
+ return ["editor.visualExplain.sqliteLegend1", "editor.visualExplain.sqliteLegend2"];
1457
1566
  default:
1458
1567
  return [];
1459
1568
  }
1460
1569
  }
1570
+ function getExplainTableRow(node, hasAnalyzeData, nodeMetrics, fallbackIndex = 1) {
1571
+ return {
1572
+ index: nodeMetrics?.index ?? fallbackIndex,
1573
+ depth: nodeMetrics?.depth ?? 0,
1574
+ nodeType: node.node_type,
1575
+ relation: node.relation ?? null,
1576
+ startupCost: node.startup_cost ?? null,
1577
+ totalCost: node.total_cost ?? null,
1578
+ planRows: node.plan_rows ?? null,
1579
+ actualRows: hasAnalyzeData ? node.actual_rows ?? null : null,
1580
+ actualLoops: hasAnalyzeData ? node.actual_loops ?? null : null,
1581
+ exclusiveTimeMs: hasAnalyzeData ? nodeMetrics?.exclusiveTimeMs ?? null : null,
1582
+ estimateGap: getRowEstimateGap(node),
1583
+ filter: node.filter ?? null
1584
+ };
1585
+ }
1586
+ function getExplainTableRows(plan, metrics) {
1587
+ const planMetrics = metrics ?? computeExplainMetrics(plan);
1588
+ return flattenExplainNodes(plan.root).map(
1589
+ (node, i) => getExplainTableRow(node, plan.has_analyze_data, planMetrics.byId.get(node.id), i + 1)
1590
+ );
1591
+ }
1461
1592
 
1462
1593
  // src/diagnostics.ts
1463
1594
  var EXPLAIN_DIAGNOSTIC_THRESHOLDS = {
@@ -1643,9 +1774,7 @@ function getNodeDiagnostics(node, metrics) {
1643
1774
  value: formatRows(node.buffers_read)
1644
1775
  });
1645
1776
  }
1646
- return diagnostics.sort(
1647
- (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
1648
- );
1777
+ return diagnostics.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
1649
1778
  }
1650
1779
  function getPlanDiagnostics(plan, metrics) {
1651
1780
  const result = /* @__PURE__ */ new Map();
@@ -1789,4 +1918,478 @@ function compareByTimeThenCount(a, b) {
1789
1918
  return (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.count - a.count || a.nodeType.localeCompare(b.nodeType);
1790
1919
  }
1791
1920
 
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 };
1921
+ // src/tableColumns.ts
1922
+ var EXPLAIN_TABLE_COLUMNS = [
1923
+ "operation",
1924
+ "relation",
1925
+ "cost",
1926
+ "estRows",
1927
+ "actualRows",
1928
+ "loops",
1929
+ "selfTime",
1930
+ "estimateGap",
1931
+ "filter"
1932
+ ];
1933
+ var EXPLAIN_TABLE_ANALYZE_COLUMNS = /* @__PURE__ */ new Set([
1934
+ "actualRows",
1935
+ "loops"
1936
+ ]);
1937
+ function isExplainTableColumnId(value) {
1938
+ return typeof value === "string" && EXPLAIN_TABLE_COLUMNS.includes(value);
1939
+ }
1940
+ function normalizeExplainTableColumnOrder(saved) {
1941
+ const order = [];
1942
+ if (Array.isArray(saved)) {
1943
+ for (const id of saved) {
1944
+ if (isExplainTableColumnId(id) && !order.includes(id)) order.push(id);
1945
+ }
1946
+ }
1947
+ EXPLAIN_TABLE_COLUMNS.forEach((id, defaultIndex) => {
1948
+ if (order.includes(id)) return;
1949
+ const previous = EXPLAIN_TABLE_COLUMNS.slice(0, defaultIndex).reverse();
1950
+ const anchor = previous.find((candidate) => order.includes(candidate));
1951
+ order.splice(anchor ? order.indexOf(anchor) + 1 : 0, 0, id);
1952
+ });
1953
+ return order;
1954
+ }
1955
+ function moveExplainTableColumn(order, column, target) {
1956
+ const from = order.indexOf(column);
1957
+ const to = order.indexOf(target);
1958
+ if (from === -1 || to === -1 || from === to) return order;
1959
+ const next = order.filter((id) => id !== column);
1960
+ next.splice(to, 0, column);
1961
+ return next;
1962
+ }
1963
+ function shiftExplainTableColumn(order, column, direction, visible = order) {
1964
+ const shown = order.filter((id) => visible.includes(id));
1965
+ const at = shown.indexOf(column);
1966
+ const neighbour = at === -1 ? void 0 : shown[at + direction];
1967
+ return neighbour ? moveExplainTableColumn(order, column, neighbour) : order;
1968
+ }
1969
+ function isDefaultExplainTableColumnOrder(order) {
1970
+ return order.length === EXPLAIN_TABLE_COLUMNS.length && order.every((id, index) => id === EXPLAIN_TABLE_COLUMNS[index]);
1971
+ }
1972
+
1973
+ // src/nodeDetails.ts
1974
+ var DEFAULT_LABELS = {
1975
+ "editor.visualExplain.nodeType": "Operation",
1976
+ "editor.visualExplain.relation": "Table",
1977
+ "editor.visualExplain.cost": "Cost",
1978
+ "editor.visualExplain.selfCost": "Self Cost",
1979
+ "editor.visualExplain.estRows": "Est. Rows",
1980
+ "editor.visualExplain.filter": "Filter",
1981
+ "editor.visualExplain.indexCondition": "Index Cond.",
1982
+ "editor.visualExplain.joinType": "Join Type",
1983
+ "editor.visualExplain.hashCondition": "Hash Cond.",
1984
+ "editor.visualExplain.actualRows": "Actual Rows",
1985
+ "editor.visualExplain.rowsAllLoops": "Rows (All Loops)",
1986
+ "editor.visualExplain.selfTime": "Self Time",
1987
+ "editor.visualExplain.totalTime": "Total Time",
1988
+ "editor.visualExplain.timePerLoop": "Time per Loop",
1989
+ "editor.visualExplain.loops": "Loops",
1990
+ "editor.visualExplain.buffersHit": "Buffers Hit",
1991
+ "editor.visualExplain.buffersRead": "Buffers Read",
1992
+ "editor.visualExplain.general": "General",
1993
+ "editor.visualExplain.analyzeData": "Analyze Data",
1994
+ "editor.visualExplain.extraDetails": "Extra",
1995
+ "editor.visualExplain.diagnostics.title": "Diagnostics"
1996
+ };
1997
+ function getExplainNodeDetailSections(node, options = {}) {
1998
+ const { hasAnalyzeData = false, metrics = null, diagnostics = [], t } = options;
1999
+ const tr = (key, fallback) => {
2000
+ if (t) {
2001
+ return t(key, fallback ?? DEFAULT_LABELS[key] ?? key);
2002
+ }
2003
+ return fallback ?? DEFAULT_LABELS[key] ?? key;
2004
+ };
2005
+ const generalEntries = [
2006
+ { label: tr("editor.visualExplain.nodeType", "Operation"), value: node.node_type },
2007
+ ...node.relation ? [{ label: tr("editor.visualExplain.relation", "Table"), value: node.relation }] : [],
2008
+ ...node.startup_cost != null && node.total_cost != null ? [
2009
+ {
2010
+ label: tr("editor.visualExplain.cost", "Cost"),
2011
+ value: `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}`
2012
+ }
2013
+ ] : node.total_cost != null ? [
2014
+ {
2015
+ label: tr("editor.visualExplain.cost", "Cost"),
2016
+ value: formatCost(node.total_cost)
2017
+ }
2018
+ ] : [],
2019
+ ...metrics?.exclusiveCost != null ? [
2020
+ {
2021
+ label: tr("editor.visualExplain.selfCost", "Self Cost"),
2022
+ value: formatCost(metrics.exclusiveCost)
2023
+ }
2024
+ ] : [],
2025
+ ...node.plan_rows != null ? [
2026
+ {
2027
+ label: tr("editor.visualExplain.estRows", "Est. Rows"),
2028
+ value: formatRows(node.plan_rows)
2029
+ }
2030
+ ] : [],
2031
+ ...node.filter ? [{ label: tr("editor.visualExplain.filter", "Filter"), value: node.filter }] : [],
2032
+ ...node.index_condition ? [
2033
+ {
2034
+ label: tr("editor.visualExplain.indexCondition", "Index Cond."),
2035
+ value: node.index_condition
2036
+ }
2037
+ ] : [],
2038
+ ...node.join_type ? [{ label: tr("editor.visualExplain.joinType", "Join Type"), value: node.join_type }] : [],
2039
+ ...node.hash_condition ? [
2040
+ {
2041
+ label: tr("editor.visualExplain.hashCondition", "Hash Cond."),
2042
+ value: node.hash_condition
2043
+ }
2044
+ ] : []
2045
+ ];
2046
+ const analyzeEntries = hasAnalyzeData ? [
2047
+ ...node.actual_rows != null ? [
2048
+ {
2049
+ label: tr("editor.visualExplain.actualRows", "Actual Rows"),
2050
+ value: formatRows(node.actual_rows)
2051
+ }
2052
+ ] : [],
2053
+ ...metrics?.totalRows != null && node.actual_loops != null && node.actual_loops > 1 ? [
2054
+ {
2055
+ label: tr("editor.visualExplain.rowsAllLoops", "Rows (All Loops)"),
2056
+ value: formatRows(metrics.totalRows)
2057
+ }
2058
+ ] : [],
2059
+ ...metrics?.exclusiveTimeMs != null ? [
2060
+ {
2061
+ label: tr("editor.visualExplain.selfTime", "Self Time"),
2062
+ value: metrics.timeShare != null ? `${formatTime(metrics.exclusiveTimeMs)} (${Math.round(metrics.timeShare * 100)}%)` : formatTime(metrics.exclusiveTimeMs)
2063
+ }
2064
+ ] : [],
2065
+ ...metrics?.inclusiveTimeMs != null ? [
2066
+ {
2067
+ label: tr("editor.visualExplain.totalTime", "Total Time"),
2068
+ value: formatTime(metrics.inclusiveTimeMs)
2069
+ }
2070
+ ] : [],
2071
+ ...node.actual_time_ms != null ? [
2072
+ {
2073
+ label: tr("editor.visualExplain.timePerLoop", "Time per Loop"),
2074
+ value: formatTime(node.actual_time_ms)
2075
+ }
2076
+ ] : [],
2077
+ ...node.actual_loops != null ? [
2078
+ {
2079
+ label: tr("editor.visualExplain.loops", "Loops"),
2080
+ value: String(node.actual_loops)
2081
+ }
2082
+ ] : [],
2083
+ ...node.buffers_hit != null ? [
2084
+ {
2085
+ label: tr("editor.visualExplain.buffersHit", "Buffers Hit"),
2086
+ value: String(node.buffers_hit)
2087
+ }
2088
+ ] : [],
2089
+ ...node.buffers_read != null ? [
2090
+ {
2091
+ label: tr("editor.visualExplain.buffersRead", "Buffers Read"),
2092
+ value: String(node.buffers_read)
2093
+ }
2094
+ ] : []
2095
+ ] : [];
2096
+ const extraEntries = Object.entries(node.extra ?? {}).map(
2097
+ ([key, value]) => ({
2098
+ label: key,
2099
+ value: typeof value === "string" ? value : JSON.stringify(value)
2100
+ })
2101
+ );
2102
+ const sections = [];
2103
+ if (generalEntries.length > 0) {
2104
+ sections.push({
2105
+ title: tr("editor.visualExplain.general", "General"),
2106
+ titleKey: "editor.visualExplain.general",
2107
+ entries: generalEntries
2108
+ });
2109
+ }
2110
+ if (analyzeEntries.length > 0) {
2111
+ sections.push({
2112
+ title: tr("editor.visualExplain.analyzeData", "Analyze Data"),
2113
+ titleKey: "editor.visualExplain.analyzeData",
2114
+ entries: analyzeEntries
2115
+ });
2116
+ }
2117
+ if (extraEntries.length > 0) {
2118
+ sections.push({
2119
+ title: tr("editor.visualExplain.extraDetails", "Extra"),
2120
+ titleKey: "editor.visualExplain.extraDetails",
2121
+ entries: extraEntries
2122
+ });
2123
+ }
2124
+ if (diagnostics && diagnostics.length > 0) {
2125
+ const diagEntries = diagnostics.map((d) => ({
2126
+ label: tr(d.labelKey, d.kind),
2127
+ value: d.value ? `${tr(d.descriptionKey, d.kind)} (${d.value})` : tr(d.descriptionKey, d.kind)
2128
+ }));
2129
+ sections.push({
2130
+ title: tr("editor.visualExplain.diagnostics.title", "Diagnostics"),
2131
+ titleKey: "editor.visualExplain.diagnostics.title",
2132
+ entries: diagEntries
2133
+ });
2134
+ }
2135
+ return sections;
2136
+ }
2137
+ function formatExplainNodeDetails(node, options = {}) {
2138
+ const sections = getExplainNodeDetailSections(node, options);
2139
+ const parts = [];
2140
+ for (const section of sections) {
2141
+ if (section.entries.length === 0) continue;
2142
+ const lines = section.entries.map((entry) => `${entry.label}: ${entry.value}`);
2143
+ if (options.includeSectionHeaders || section.titleKey !== "editor.visualExplain.general") {
2144
+ parts.push(`${section.title}:
2145
+ ${lines.join("\n")}`);
2146
+ } else {
2147
+ parts.push(lines.join("\n"));
2148
+ }
2149
+ }
2150
+ return parts.join("\n\n");
2151
+ }
2152
+
2153
+ // src/planComparison.ts
2154
+ function checkEngineCompatibility(baselineEngine, currentEngine) {
2155
+ const b = (baselineEngine || "").toLowerCase().trim();
2156
+ const c = (currentEngine || "").toLowerCase().trim();
2157
+ if (b && c && b !== c) {
2158
+ return {
2159
+ compatible: false,
2160
+ reason: `Engines differ (${b} vs ${c}). Explain metrics and costs cannot be compared across different database engines.`
2161
+ };
2162
+ }
2163
+ return { compatible: true };
2164
+ }
2165
+ function calculateMetricDelta(baselineValue, currentValue) {
2166
+ const b = baselineValue ?? null;
2167
+ const c = currentValue ?? null;
2168
+ if (b == null || c == null) {
2169
+ return {
2170
+ baselineValue: b,
2171
+ currentValue: c,
2172
+ delta: null,
2173
+ ratio: null
2174
+ };
2175
+ }
2176
+ const delta = c - b;
2177
+ const ratio = b !== 0 ? c / b : null;
2178
+ return {
2179
+ baselineValue: b,
2180
+ currentValue: c,
2181
+ delta,
2182
+ ratio
2183
+ };
2184
+ }
2185
+ function flattenTree(root, prefix = "0", depth = 0) {
2186
+ const result = [{ node: root, path: prefix, depth }];
2187
+ root.children.forEach((child, i) => {
2188
+ result.push(...flattenTree(child, `${prefix}.${i}`, depth + 1));
2189
+ });
2190
+ return result;
2191
+ }
2192
+ function alignPlanNodes(baselineRoot, currentRoot, baselineMetrics, currentMetrics) {
2193
+ if (!baselineRoot && !currentRoot) return [];
2194
+ if (!baselineRoot && currentRoot) {
2195
+ const currentFlat2 = flattenTree(currentRoot);
2196
+ return currentFlat2.map(({ node }) => {
2197
+ const m = currentMetrics?.get(node.id);
2198
+ return {
2199
+ baselineNodeId: null,
2200
+ currentNodeId: node.id,
2201
+ operatorChanged: false,
2202
+ baselineNodeType: null,
2203
+ currentNodeType: node.node_type,
2204
+ relation: node.relation,
2205
+ status: "added",
2206
+ costDelta: calculateMetricDelta(null, m?.totalCost ?? node.total_cost),
2207
+ timeDelta: calculateMetricDelta(null, m?.inclusiveTimeMs ?? node.actual_time_ms),
2208
+ rowsDelta: calculateMetricDelta(
2209
+ null,
2210
+ m?.totalRows ?? node.actual_rows ?? node.plan_rows
2211
+ )
2212
+ };
2213
+ });
2214
+ }
2215
+ if (baselineRoot && !currentRoot) {
2216
+ const baselineFlat2 = flattenTree(baselineRoot);
2217
+ return baselineFlat2.map(({ node }) => {
2218
+ const m = baselineMetrics?.get(node.id);
2219
+ return {
2220
+ baselineNodeId: node.id,
2221
+ currentNodeId: null,
2222
+ operatorChanged: false,
2223
+ baselineNodeType: node.node_type,
2224
+ currentNodeType: null,
2225
+ relation: node.relation,
2226
+ status: "removed",
2227
+ costDelta: calculateMetricDelta(m?.totalCost ?? node.total_cost, null),
2228
+ timeDelta: calculateMetricDelta(m?.inclusiveTimeMs ?? node.actual_time_ms, null),
2229
+ rowsDelta: calculateMetricDelta(
2230
+ m?.totalRows ?? node.actual_rows ?? node.plan_rows,
2231
+ null
2232
+ )
2233
+ };
2234
+ });
2235
+ }
2236
+ const baselineFlat = flattenTree(baselineRoot);
2237
+ const currentFlat = flattenTree(currentRoot);
2238
+ const matchedCurrent = /* @__PURE__ */ new Set();
2239
+ const matchedBaseline = /* @__PURE__ */ new Set();
2240
+ const comparisons = [];
2241
+ const currentByPath = new Map(currentFlat.map((item) => [item.path, item]));
2242
+ for (const bItem of baselineFlat) {
2243
+ const cItem = currentByPath.get(bItem.path);
2244
+ if (cItem && !matchedCurrent.has(cItem.node.id)) {
2245
+ const typeMatch = bItem.node.node_type === cItem.node.node_type;
2246
+ const relMatch = bItem.node.relation === cItem.node.relation;
2247
+ if (typeMatch || bItem.node.relation && relMatch) {
2248
+ matchedBaseline.add(bItem.node.id);
2249
+ matchedCurrent.add(cItem.node.id);
2250
+ const bM = baselineMetrics?.get(bItem.node.id);
2251
+ const cM = currentMetrics?.get(cItem.node.id);
2252
+ comparisons.push({
2253
+ baselineNodeId: bItem.node.id,
2254
+ currentNodeId: cItem.node.id,
2255
+ operatorChanged: !typeMatch,
2256
+ baselineNodeType: bItem.node.node_type,
2257
+ currentNodeType: cItem.node.node_type,
2258
+ relation: cItem.node.relation ?? bItem.node.relation,
2259
+ status: "matched",
2260
+ costDelta: calculateMetricDelta(
2261
+ bM?.totalCost ?? bItem.node.total_cost,
2262
+ cM?.totalCost ?? cItem.node.total_cost
2263
+ ),
2264
+ timeDelta: calculateMetricDelta(
2265
+ bM?.inclusiveTimeMs ?? bItem.node.actual_time_ms,
2266
+ cM?.inclusiveTimeMs ?? cItem.node.actual_time_ms
2267
+ ),
2268
+ rowsDelta: calculateMetricDelta(
2269
+ bM?.totalRows ?? bItem.node.actual_rows ?? bItem.node.plan_rows,
2270
+ cM?.totalRows ?? cItem.node.actual_rows ?? cItem.node.plan_rows
2271
+ )
2272
+ });
2273
+ }
2274
+ }
2275
+ }
2276
+ for (const bItem of baselineFlat) {
2277
+ if (matchedBaseline.has(bItem.node.id)) continue;
2278
+ const candidate = currentFlat.find(
2279
+ (cItem) => !matchedCurrent.has(cItem.node.id) && bItem.node.relation && bItem.node.relation === cItem.node.relation
2280
+ );
2281
+ if (candidate) {
2282
+ matchedBaseline.add(bItem.node.id);
2283
+ matchedCurrent.add(candidate.node.id);
2284
+ const bM = baselineMetrics?.get(bItem.node.id);
2285
+ const cM = currentMetrics?.get(candidate.node.id);
2286
+ const typeMatch = bItem.node.node_type === candidate.node.node_type;
2287
+ comparisons.push({
2288
+ baselineNodeId: bItem.node.id,
2289
+ currentNodeId: candidate.node.id,
2290
+ operatorChanged: !typeMatch,
2291
+ baselineNodeType: bItem.node.node_type,
2292
+ currentNodeType: candidate.node.node_type,
2293
+ relation: candidate.node.relation,
2294
+ status: "matched",
2295
+ isAmbiguous: true,
2296
+ costDelta: calculateMetricDelta(
2297
+ bM?.totalCost ?? bItem.node.total_cost,
2298
+ cM?.totalCost ?? candidate.node.total_cost
2299
+ ),
2300
+ timeDelta: calculateMetricDelta(
2301
+ bM?.inclusiveTimeMs ?? bItem.node.actual_time_ms,
2302
+ cM?.inclusiveTimeMs ?? candidate.node.actual_time_ms
2303
+ ),
2304
+ rowsDelta: calculateMetricDelta(
2305
+ bM?.totalRows ?? bItem.node.actual_rows ?? bItem.node.plan_rows,
2306
+ cM?.totalRows ?? candidate.node.actual_rows ?? candidate.node.plan_rows
2307
+ )
2308
+ });
2309
+ }
2310
+ }
2311
+ for (const bItem of baselineFlat) {
2312
+ if (!matchedBaseline.has(bItem.node.id)) {
2313
+ const bM = baselineMetrics?.get(bItem.node.id);
2314
+ comparisons.push({
2315
+ baselineNodeId: bItem.node.id,
2316
+ currentNodeId: null,
2317
+ operatorChanged: false,
2318
+ baselineNodeType: bItem.node.node_type,
2319
+ currentNodeType: null,
2320
+ relation: bItem.node.relation,
2321
+ status: "removed",
2322
+ costDelta: calculateMetricDelta(bM?.totalCost ?? bItem.node.total_cost, null),
2323
+ timeDelta: calculateMetricDelta(
2324
+ bM?.inclusiveTimeMs ?? bItem.node.actual_time_ms,
2325
+ null
2326
+ ),
2327
+ rowsDelta: calculateMetricDelta(
2328
+ bM?.totalRows ?? bItem.node.actual_rows ?? bItem.node.plan_rows,
2329
+ null
2330
+ )
2331
+ });
2332
+ }
2333
+ }
2334
+ for (const cItem of currentFlat) {
2335
+ if (!matchedCurrent.has(cItem.node.id)) {
2336
+ const cM = currentMetrics?.get(cItem.node.id);
2337
+ comparisons.push({
2338
+ baselineNodeId: null,
2339
+ currentNodeId: cItem.node.id,
2340
+ operatorChanged: false,
2341
+ baselineNodeType: null,
2342
+ currentNodeType: cItem.node.node_type,
2343
+ relation: cItem.node.relation,
2344
+ status: "added",
2345
+ costDelta: calculateMetricDelta(null, cM?.totalCost ?? cItem.node.total_cost),
2346
+ timeDelta: calculateMetricDelta(
2347
+ null,
2348
+ cM?.inclusiveTimeMs ?? cItem.node.actual_time_ms
2349
+ ),
2350
+ rowsDelta: calculateMetricDelta(
2351
+ null,
2352
+ cM?.totalRows ?? cItem.node.actual_rows ?? cItem.node.plan_rows
2353
+ )
2354
+ });
2355
+ }
2356
+ }
2357
+ return comparisons;
2358
+ }
2359
+ function comparePlanSnapshots(baseline, current, baselineMetrics, currentMetrics) {
2360
+ const engineCheck = checkEngineCompatibility(baseline.context.engine, current.context.engine);
2361
+ const hasAnalyzeMismatch = baseline.hasAnalyzeData !== current.hasAnalyzeData;
2362
+ const nodeComparisons = engineCheck.compatible ? alignPlanNodes(baseline.plan.root, current.plan.root, baselineMetrics, currentMetrics) : [];
2363
+ let nodesAdded = 0;
2364
+ let nodesRemoved = 0;
2365
+ let operatorsChanged = 0;
2366
+ for (const cmp of nodeComparisons) {
2367
+ if (cmp.status === "added") nodesAdded++;
2368
+ if (cmp.status === "removed") nodesRemoved++;
2369
+ if (cmp.status === "matched" && cmp.operatorChanged) operatorsChanged++;
2370
+ }
2371
+ return {
2372
+ isCompatible: engineCheck.compatible,
2373
+ incompatibilityReason: engineCheck.reason,
2374
+ hasAnalyzeMismatch,
2375
+ baselineSnapshotId: baseline.id,
2376
+ currentSnapshotId: current.id,
2377
+ summary: {
2378
+ totalCostDelta: calculateMetricDelta(baseline.totalCost, current.totalCost),
2379
+ executionTimeDelta: calculateMetricDelta(
2380
+ baseline.executionTimeMs,
2381
+ current.executionTimeMs
2382
+ ),
2383
+ planningTimeDelta: calculateMetricDelta(
2384
+ baseline.planningTimeMs,
2385
+ current.planningTimeMs
2386
+ ),
2387
+ nodesAdded,
2388
+ nodesRemoved,
2389
+ operatorsChanged
2390
+ },
2391
+ nodeComparisons
2392
+ };
2393
+ }
2394
+
2395
+ export { EXPLAIN_DIAGNOSTIC_THRESHOLDS, EXPLAIN_METRIC_KINDS, EXPLAIN_TABLE_ANALYZE_COLUMNS, EXPLAIN_TABLE_COLUMNS, NodeIdAllocator, alignPlanNodes, buildSqliteTree, calculateMetricDelta, checkEngineCompatibility, comparePlanSnapshots, computeExplainMetrics, countDiagnosticsBySeverity, detectFormat, detectFormatFor, explainEngineFromDriverName, findExplainNode, flattenExplainNodes, formatCost, formatExplainNodeDetails, formatRatio, formatRows, formatTime, getAvailableMetricKinds, getDefaultMetricKind, getExplainDriverLegend, getExplainNodeDetailSections, getExplainParser, getExplainPlanStats, getExplainPlanSummary, getExplainTableRows, getHeatBarClass, getMaxCost, getMaxTime, getMetricMax, getMetricValue, getNodeCostStyle, getNodeDiagnostics, getNodeMetrics, getPlanDiagnostics, getRowEstimateRatio, getWorstSeverity, hasAnalyzeDataRecursive, isDefaultExplainTableColumnOrder, isMetricAvailable, listExplainParsers, moveExplainTableColumn, normalizeExplainTableColumnOrder, parseExplain, parseExplainFor, parseMysqlJson, parseMysqlTabularRows, parseMysqlText, parsePostgresJson, parsePostgresText, parseRawExplain, parseSqliteEqpRows, registerExplainParser, resolveExplainOutput, shiftExplainTableColumn, unregisterExplainParser, withSourceLabel };