@tabularis/explain 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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"]),
@@ -724,7 +726,10 @@ function parsePostgresText(raw) {
724
726
  let planningTime = null;
725
727
  let executionTime = null;
726
728
  let hasAnalyzeData = false;
729
+ let hasBuffers = false;
727
730
  const ids = new NodeIdAllocator();
731
+ let rootIndent = null;
732
+ let inTrailer = false;
728
733
  const stack = [];
729
734
  let root = null;
730
735
  const promoteOrAttach = (node) => {
@@ -766,12 +771,16 @@ function parsePostgresText(raw) {
766
771
  if (node.actual_rows !== null || node.actual_time_ms !== null) {
767
772
  hasAnalyzeData = true;
768
773
  }
774
+ rootIndent ??= indent;
775
+ inTrailer = false;
769
776
  popUntilShallower(indent);
770
777
  stack.push({ indent, node });
771
778
  } else {
779
+ if (rootIndent !== null && indent <= rootIndent) inTrailer = true;
780
+ if (inTrailer) continue;
772
781
  const top = stack[stack.length - 1];
773
- if (top !== void 0) {
774
- applyTextAttribute(top.node, trimmed);
782
+ if (top !== void 0 && applyTextAttribute(top.node, trimmed)) {
783
+ hasBuffers = true;
775
784
  }
776
785
  }
777
786
  }
@@ -779,6 +788,7 @@ function parsePostgresText(raw) {
779
788
  if (root === null) {
780
789
  throw new Error("No plan nodes found in EXPLAIN text output");
781
790
  }
791
+ if (hasBuffers) fillMissingBuffers(root);
782
792
  return {
783
793
  root,
784
794
  planning_time_ms: planningTime,
@@ -882,7 +892,7 @@ function parseCostFields(inner) {
882
892
  function parseActualFields(inner) {
883
893
  const trimmed = inner.trim();
884
894
  if (trimmed.toLowerCase() === "never executed") {
885
- return [null, null, null];
895
+ return [0, 0, 0];
886
896
  }
887
897
  const rest = trimmed.startsWith("actual ") ? trimmed.slice(7) : trimmed;
888
898
  let totalTime = null;
@@ -909,6 +919,10 @@ function parseFiniteNumber(text) {
909
919
  return Number.isFinite(value) ? value : null;
910
920
  }
911
921
  function applyTextAttribute(node, content) {
922
+ if (content.startsWith("Buffers:")) {
923
+ applyBuffers(node, content.slice("Buffers:".length));
924
+ return true;
925
+ }
912
926
  if (content.startsWith("Filter:")) {
913
927
  node.filter = content.slice("Filter:".length).trim();
914
928
  } else if (content.startsWith("Index Cond:")) {
@@ -923,6 +937,32 @@ function applyTextAttribute(node, content) {
923
937
  node.extra[content.slice(0, sep).trim()] = content.slice(sep + 1).trim();
924
938
  }
925
939
  }
940
+ return false;
941
+ }
942
+ function applyBuffers(node, content) {
943
+ node.buffers_hit = 0;
944
+ node.buffers_read = 0;
945
+ for (const group of content.split(",")) {
946
+ const [scope, ...counters] = group.trim().split(/\s+/);
947
+ if (scope === void 0 || scope === "") continue;
948
+ const scopeLabel = scope.charAt(0).toUpperCase() + scope.slice(1).toLowerCase();
949
+ for (const counter of counters) {
950
+ const eq = counter.indexOf("=");
951
+ if (eq === -1) continue;
952
+ const name = counter.slice(0, eq).toLowerCase();
953
+ const value = parseFiniteNumber(counter.slice(eq + 1));
954
+ if (value === null) continue;
955
+ if (scopeLabel === "Shared" && name === "hit") node.buffers_hit = value;
956
+ else if (scopeLabel === "Shared" && name === "read") node.buffers_read = value;
957
+ else
958
+ node.extra[`${scopeLabel} ${name.charAt(0).toUpperCase()}${name.slice(1)} Blocks`] = value;
959
+ }
960
+ }
961
+ }
962
+ function fillMissingBuffers(node) {
963
+ node.buffers_hit ??= 0;
964
+ node.buffers_read ??= 0;
965
+ for (const child of node.children) fillMissingBuffers(child);
926
966
  }
927
967
 
928
968
  // src/parsers/sqlite.ts
@@ -1053,16 +1093,12 @@ var BUILTIN_EXPLAIN_PARSERS = Object.freeze([
1053
1093
  Object.freeze({
1054
1094
  engine: "mysql",
1055
1095
  format: "mysql-tabular-rows",
1056
- parse: (payload) => parseMysqlTabularRows(
1057
- parseJsonRows(payload, "mysql-tabular-rows")
1058
- )
1096
+ parse: (payload) => parseMysqlTabularRows(parseJsonRows(payload, "mysql-tabular-rows"))
1059
1097
  }),
1060
1098
  Object.freeze({
1061
1099
  engine: "sqlite",
1062
1100
  format: "sqlite-eqp-rows",
1063
- parse: (payload) => parseSqliteEqpRows(
1064
- parseJsonRows(payload, "sqlite-eqp-rows")
1065
- )
1101
+ parse: (payload) => parseSqliteEqpRows(parseJsonRows(payload, "sqlite-eqp-rows"))
1066
1102
  })
1067
1103
  ]);
1068
1104
 
@@ -1144,19 +1180,19 @@ function explainEngineFromDriverName(name) {
1144
1180
  );
1145
1181
  return parser?.engine ?? null;
1146
1182
  }
1147
- function detectFormat(raw) {
1148
- return detectFormatFor(raw, null);
1183
+ async function detectFormat(raw) {
1184
+ return await detectFormatFor(raw, null);
1149
1185
  }
1150
- function detectFormatFor(raw, engine) {
1186
+ async function detectFormatFor(raw, engine) {
1151
1187
  switch (engine) {
1152
1188
  case "postgres":
1153
- return detectPostgresFormat(raw);
1189
+ return Promise.resolve(detectPostgresFormat(raw));
1154
1190
  case "mysql":
1155
- if (looksLikeJson(raw)) return "mysql-json";
1191
+ if (looksLikeJson(raw)) return Promise.resolve("mysql-json");
1156
1192
  if (raw.trim() === "") {
1157
1193
  throw new Error("Unsupported EXPLAIN file format: input is empty");
1158
1194
  }
1159
- return "mysql-text";
1195
+ return Promise.resolve("mysql-text");
1160
1196
  case "sqlite":
1161
1197
  throw new Error(
1162
1198
  "SQLite EXPLAIN QUERY PLAN has no text form here: pass its (id, parent, detail) rows to buildSqliteTree"
@@ -1164,14 +1200,14 @@ function detectFormatFor(raw, engine) {
1164
1200
  case null: {
1165
1201
  const builtinFormat = detectPostgresFormatOrNull(raw);
1166
1202
  if (builtinFormat !== null) return builtinFormat;
1167
- const customFormat = sniffRegisteredFormat(raw, null);
1203
+ const customFormat = await sniffRegisteredFormat(raw, null);
1168
1204
  if (customFormat !== null) return customFormat;
1169
1205
  throw new Error(
1170
1206
  "Unsupported EXPLAIN file format: expected Postgres JSON or text output"
1171
1207
  );
1172
1208
  }
1173
1209
  default: {
1174
- const format = sniffRegisteredFormat(raw, engine);
1210
+ const format = await sniffRegisteredFormat(raw, engine);
1175
1211
  if (format !== null) return format;
1176
1212
  throw new Error(`Unsupported EXPLAIN file format for engine '${engine}'`);
1177
1213
  }
@@ -1180,16 +1216,14 @@ function detectFormatFor(raw, engine) {
1180
1216
  function detectPostgresFormat(raw) {
1181
1217
  const format = detectPostgresFormatOrNull(raw);
1182
1218
  if (format !== null) return format;
1183
- throw new Error(
1184
- "Unsupported EXPLAIN file format: expected Postgres JSON or text output"
1185
- );
1219
+ throw new Error("Unsupported EXPLAIN file format: expected Postgres JSON or text output");
1186
1220
  }
1187
1221
  function detectPostgresFormatOrNull(raw) {
1188
1222
  if (looksLikeJson(raw)) return "postgres-json";
1189
1223
  if (looksLikePostgresText(raw)) return "postgres-text";
1190
1224
  return null;
1191
1225
  }
1192
- function sniffRegisteredFormat(raw, engine) {
1226
+ async function sniffRegisteredFormat(raw, engine) {
1193
1227
  const normalizedEngine = engine?.trim().toLowerCase() ?? null;
1194
1228
  for (const parser of listExplainParsers()) {
1195
1229
  if (normalizedEngine === null && BUILTIN_SOURCE_FORMATS.has(parser.format)) {
@@ -1200,7 +1234,7 @@ function sniffRegisteredFormat(raw, engine) {
1200
1234
  }
1201
1235
  if (parser.sniff === void 0) continue;
1202
1236
  try {
1203
- if (parser.sniff(raw)) return parser.format;
1237
+ if (await parser.sniff(raw)) return parser.format;
1204
1238
  } catch {
1205
1239
  }
1206
1240
  }
@@ -1216,8 +1250,9 @@ function looksLikePostgresText(raw) {
1216
1250
  function parseExplain(raw) {
1217
1251
  return parseExplainFor(raw, null);
1218
1252
  }
1219
- function parseExplainFor(raw, engine) {
1220
- return parserFor(detectFormatFor(raw, engine)).parse(raw);
1253
+ async function parseExplainFor(raw, engine) {
1254
+ const format = await detectFormatFor(raw, engine);
1255
+ return await parserFor(format).parse(raw);
1221
1256
  }
1222
1257
  function withSourceLabel(plan, name) {
1223
1258
  if (plan.original_query === "") {
@@ -1227,14 +1262,14 @@ function withSourceLabel(plan, name) {
1227
1262
  }
1228
1263
 
1229
1264
  // src/raw.ts
1230
- function parseRawExplain(raw) {
1265
+ async function parseRawExplain(raw) {
1231
1266
  const parser = getExplainParser(raw.format);
1232
1267
  if (parser === null) {
1233
1268
  throw new Error(
1234
1269
  `No EXPLAIN parser registered for format '${raw.format}' (engine '${raw.engine}'). Import the parser package for '${raw.engine}' before parsing.`
1235
1270
  );
1236
1271
  }
1237
- const plan = parser.parse(raw.payload);
1272
+ const plan = await parser.parse(raw.payload);
1238
1273
  return {
1239
1274
  ...plan,
1240
1275
  driver: raw.engine,
@@ -1242,7 +1277,7 @@ function parseRawExplain(raw) {
1242
1277
  };
1243
1278
  }
1244
1279
  function resolveExplainOutput(output) {
1245
- return output.kind === "plan" ? output.plan : parseRawExplain(output.raw);
1280
+ return output.kind === "plan" ? Promise.resolve(output.plan) : parseRawExplain(output.raw);
1246
1281
  }
1247
1282
 
1248
1283
  // src/metrics.ts
@@ -1323,16 +1358,10 @@ function computeExplainMetrics(plan) {
1323
1358
  let maxExclusiveBuffers = 0;
1324
1359
  let totalExclusiveTimeMs = 0;
1325
1360
  for (const metrics of order) {
1326
- maxExclusiveTimeMs = Math.max(
1327
- maxExclusiveTimeMs,
1328
- metrics.exclusiveTimeMs ?? 0
1329
- );
1361
+ maxExclusiveTimeMs = Math.max(maxExclusiveTimeMs, metrics.exclusiveTimeMs ?? 0);
1330
1362
  maxExclusiveCost = Math.max(maxExclusiveCost, metrics.exclusiveCost ?? 0);
1331
1363
  maxTotalRows = Math.max(maxTotalRows, metrics.totalRows ?? 0);
1332
- maxExclusiveBuffers = Math.max(
1333
- maxExclusiveBuffers,
1334
- metrics.exclusiveBuffers ?? 0
1335
- );
1364
+ maxExclusiveBuffers = Math.max(maxExclusiveBuffers, metrics.exclusiveBuffers ?? 0);
1336
1365
  totalExclusiveTimeMs += metrics.exclusiveTimeMs ?? 0;
1337
1366
  }
1338
1367
  if (totalExclusiveTimeMs > 0) {
@@ -1481,7 +1510,7 @@ function getRowEstimateRatio(node) {
1481
1510
  }
1482
1511
  return node.actual_rows / node.plan_rows;
1483
1512
  }
1484
- function getMismatchMagnitude(node) {
1513
+ function getRowEstimateGap(node) {
1485
1514
  const ratio = getRowEstimateRatio(node);
1486
1515
  if (ratio == null) {
1487
1516
  return null;
@@ -1527,7 +1556,7 @@ function getExplainPlanSummary(plan, metrics) {
1527
1556
  };
1528
1557
  }
1529
1558
  const ratio = getRowEstimateRatio(node);
1530
- const magnitude = getMismatchMagnitude(node);
1559
+ const magnitude = getRowEstimateGap(node);
1531
1560
  if (ratio != null && magnitude != null && (largestRowMismatchNode == null || magnitude > largestRowMismatchNode.value)) {
1532
1561
  largestRowMismatchNode = {
1533
1562
  nodeId: node.id,
@@ -1571,14 +1600,33 @@ function getExplainDriverLegend(plan) {
1571
1600
  "editor.visualExplain.mysqlEstimateLegend2"
1572
1601
  ];
1573
1602
  case "sqlite":
1574
- return [
1575
- "editor.visualExplain.sqliteLegend1",
1576
- "editor.visualExplain.sqliteLegend2"
1577
- ];
1603
+ return ["editor.visualExplain.sqliteLegend1", "editor.visualExplain.sqliteLegend2"];
1578
1604
  default:
1579
1605
  return [];
1580
1606
  }
1581
1607
  }
1608
+ function getExplainTableRow(node, hasAnalyzeData, nodeMetrics, fallbackIndex = 1) {
1609
+ return {
1610
+ index: nodeMetrics?.index ?? fallbackIndex,
1611
+ depth: nodeMetrics?.depth ?? 0,
1612
+ nodeType: node.node_type,
1613
+ relation: node.relation ?? null,
1614
+ startupCost: node.startup_cost ?? null,
1615
+ totalCost: node.total_cost ?? null,
1616
+ planRows: node.plan_rows ?? null,
1617
+ actualRows: hasAnalyzeData ? node.actual_rows ?? null : null,
1618
+ actualLoops: hasAnalyzeData ? node.actual_loops ?? null : null,
1619
+ exclusiveTimeMs: hasAnalyzeData ? nodeMetrics?.exclusiveTimeMs ?? null : null,
1620
+ estimateGap: getRowEstimateGap(node),
1621
+ filter: node.filter ?? null
1622
+ };
1623
+ }
1624
+ function getExplainTableRows(plan, metrics) {
1625
+ const planMetrics = metrics ?? computeExplainMetrics(plan);
1626
+ return flattenExplainNodes(plan.root).map(
1627
+ (node, i) => getExplainTableRow(node, plan.has_analyze_data, planMetrics.byId.get(node.id), i + 1)
1628
+ );
1629
+ }
1582
1630
 
1583
1631
  // src/diagnostics.ts
1584
1632
  var EXPLAIN_DIAGNOSTIC_THRESHOLDS = {
@@ -1764,9 +1812,7 @@ function getNodeDiagnostics(node, metrics) {
1764
1812
  value: formatRows(node.buffers_read)
1765
1813
  });
1766
1814
  }
1767
- return diagnostics.sort(
1768
- (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
1769
- );
1815
+ return diagnostics.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
1770
1816
  }
1771
1817
  function getPlanDiagnostics(plan, metrics) {
1772
1818
  const result = /* @__PURE__ */ new Map();
@@ -1910,4 +1956,478 @@ function compareByTimeThenCount(a, b) {
1910
1956
  return (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.count - a.count || a.nodeType.localeCompare(b.nodeType);
1911
1957
  }
1912
1958
 
1913
- export { EXPLAIN_DIAGNOSTIC_THRESHOLDS, EXPLAIN_METRIC_KINDS, NodeIdAllocator, buildSqliteTree, computeExplainMetrics, countDiagnosticsBySeverity, detectFormat, detectFormatFor, explainEngineFromDriverName, findExplainNode, flattenExplainNodes, formatCost, formatRatio, formatRows, formatTime, getAvailableMetricKinds, getDefaultMetricKind, getExplainDriverLegend, getExplainParser, getExplainPlanStats, getExplainPlanSummary, getHeatBarClass, getMaxCost, getMaxTime, getMetricMax, getMetricValue, getNodeCostStyle, getNodeDiagnostics, getNodeMetrics, getPlanDiagnostics, getRowEstimateRatio, getWorstSeverity, hasAnalyzeDataRecursive, isMetricAvailable, listExplainParsers, parseExplain, parseExplainFor, parseMysqlJson, parseMysqlTabularRows, parseMysqlText, parsePostgresJson, parsePostgresText, parseRawExplain, parseSqliteEqpRows, registerExplainParser, resolveExplainOutput, unregisterExplainParser, withSourceLabel };
1959
+ // src/tableColumns.ts
1960
+ var EXPLAIN_TABLE_COLUMNS = [
1961
+ "operation",
1962
+ "relation",
1963
+ "cost",
1964
+ "estRows",
1965
+ "actualRows",
1966
+ "loops",
1967
+ "selfTime",
1968
+ "estimateGap",
1969
+ "filter"
1970
+ ];
1971
+ var EXPLAIN_TABLE_ANALYZE_COLUMNS = /* @__PURE__ */ new Set([
1972
+ "actualRows",
1973
+ "loops"
1974
+ ]);
1975
+ function isExplainTableColumnId(value) {
1976
+ return typeof value === "string" && EXPLAIN_TABLE_COLUMNS.includes(value);
1977
+ }
1978
+ function normalizeExplainTableColumnOrder(saved) {
1979
+ const order = [];
1980
+ if (Array.isArray(saved)) {
1981
+ for (const id of saved) {
1982
+ if (isExplainTableColumnId(id) && !order.includes(id)) order.push(id);
1983
+ }
1984
+ }
1985
+ EXPLAIN_TABLE_COLUMNS.forEach((id, defaultIndex) => {
1986
+ if (order.includes(id)) return;
1987
+ const previous = EXPLAIN_TABLE_COLUMNS.slice(0, defaultIndex).reverse();
1988
+ const anchor = previous.find((candidate) => order.includes(candidate));
1989
+ order.splice(anchor ? order.indexOf(anchor) + 1 : 0, 0, id);
1990
+ });
1991
+ return order;
1992
+ }
1993
+ function moveExplainTableColumn(order, column, target) {
1994
+ const from = order.indexOf(column);
1995
+ const to = order.indexOf(target);
1996
+ if (from === -1 || to === -1 || from === to) return order;
1997
+ const next = order.filter((id) => id !== column);
1998
+ next.splice(to, 0, column);
1999
+ return next;
2000
+ }
2001
+ function shiftExplainTableColumn(order, column, direction, visible = order) {
2002
+ const shown = order.filter((id) => visible.includes(id));
2003
+ const at = shown.indexOf(column);
2004
+ const neighbour = at === -1 ? void 0 : shown[at + direction];
2005
+ return neighbour ? moveExplainTableColumn(order, column, neighbour) : order;
2006
+ }
2007
+ function isDefaultExplainTableColumnOrder(order) {
2008
+ return order.length === EXPLAIN_TABLE_COLUMNS.length && order.every((id, index) => id === EXPLAIN_TABLE_COLUMNS[index]);
2009
+ }
2010
+
2011
+ // src/nodeDetails.ts
2012
+ var DEFAULT_LABELS = {
2013
+ "editor.visualExplain.nodeType": "Operation",
2014
+ "editor.visualExplain.relation": "Table",
2015
+ "editor.visualExplain.cost": "Cost",
2016
+ "editor.visualExplain.selfCost": "Self Cost",
2017
+ "editor.visualExplain.estRows": "Est. Rows",
2018
+ "editor.visualExplain.filter": "Filter",
2019
+ "editor.visualExplain.indexCondition": "Index Cond.",
2020
+ "editor.visualExplain.joinType": "Join Type",
2021
+ "editor.visualExplain.hashCondition": "Hash Cond.",
2022
+ "editor.visualExplain.actualRows": "Actual Rows",
2023
+ "editor.visualExplain.rowsAllLoops": "Rows (All Loops)",
2024
+ "editor.visualExplain.selfTime": "Self Time",
2025
+ "editor.visualExplain.totalTime": "Total Time",
2026
+ "editor.visualExplain.timePerLoop": "Time per Loop",
2027
+ "editor.visualExplain.loops": "Loops",
2028
+ "editor.visualExplain.buffersHit": "Buffers Hit",
2029
+ "editor.visualExplain.buffersRead": "Buffers Read",
2030
+ "editor.visualExplain.general": "General",
2031
+ "editor.visualExplain.analyzeData": "Analyze Data",
2032
+ "editor.visualExplain.extraDetails": "Extra",
2033
+ "editor.visualExplain.diagnostics.title": "Diagnostics"
2034
+ };
2035
+ function getExplainNodeDetailSections(node, options = {}) {
2036
+ const { hasAnalyzeData = false, metrics = null, diagnostics = [], t } = options;
2037
+ const tr = (key, fallback) => {
2038
+ if (t) {
2039
+ return t(key, fallback ?? DEFAULT_LABELS[key] ?? key);
2040
+ }
2041
+ return fallback ?? DEFAULT_LABELS[key] ?? key;
2042
+ };
2043
+ const generalEntries = [
2044
+ { label: tr("editor.visualExplain.nodeType", "Operation"), value: node.node_type },
2045
+ ...node.relation ? [{ label: tr("editor.visualExplain.relation", "Table"), value: node.relation }] : [],
2046
+ ...node.startup_cost != null && node.total_cost != null ? [
2047
+ {
2048
+ label: tr("editor.visualExplain.cost", "Cost"),
2049
+ value: `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}`
2050
+ }
2051
+ ] : node.total_cost != null ? [
2052
+ {
2053
+ label: tr("editor.visualExplain.cost", "Cost"),
2054
+ value: formatCost(node.total_cost)
2055
+ }
2056
+ ] : [],
2057
+ ...metrics?.exclusiveCost != null ? [
2058
+ {
2059
+ label: tr("editor.visualExplain.selfCost", "Self Cost"),
2060
+ value: formatCost(metrics.exclusiveCost)
2061
+ }
2062
+ ] : [],
2063
+ ...node.plan_rows != null ? [
2064
+ {
2065
+ label: tr("editor.visualExplain.estRows", "Est. Rows"),
2066
+ value: formatRows(node.plan_rows)
2067
+ }
2068
+ ] : [],
2069
+ ...node.filter ? [{ label: tr("editor.visualExplain.filter", "Filter"), value: node.filter }] : [],
2070
+ ...node.index_condition ? [
2071
+ {
2072
+ label: tr("editor.visualExplain.indexCondition", "Index Cond."),
2073
+ value: node.index_condition
2074
+ }
2075
+ ] : [],
2076
+ ...node.join_type ? [{ label: tr("editor.visualExplain.joinType", "Join Type"), value: node.join_type }] : [],
2077
+ ...node.hash_condition ? [
2078
+ {
2079
+ label: tr("editor.visualExplain.hashCondition", "Hash Cond."),
2080
+ value: node.hash_condition
2081
+ }
2082
+ ] : []
2083
+ ];
2084
+ const analyzeEntries = hasAnalyzeData ? [
2085
+ ...node.actual_rows != null ? [
2086
+ {
2087
+ label: tr("editor.visualExplain.actualRows", "Actual Rows"),
2088
+ value: formatRows(node.actual_rows)
2089
+ }
2090
+ ] : [],
2091
+ ...metrics?.totalRows != null && node.actual_loops != null && node.actual_loops > 1 ? [
2092
+ {
2093
+ label: tr("editor.visualExplain.rowsAllLoops", "Rows (All Loops)"),
2094
+ value: formatRows(metrics.totalRows)
2095
+ }
2096
+ ] : [],
2097
+ ...metrics?.exclusiveTimeMs != null ? [
2098
+ {
2099
+ label: tr("editor.visualExplain.selfTime", "Self Time"),
2100
+ value: metrics.timeShare != null ? `${formatTime(metrics.exclusiveTimeMs)} (${Math.round(metrics.timeShare * 100)}%)` : formatTime(metrics.exclusiveTimeMs)
2101
+ }
2102
+ ] : [],
2103
+ ...metrics?.inclusiveTimeMs != null ? [
2104
+ {
2105
+ label: tr("editor.visualExplain.totalTime", "Total Time"),
2106
+ value: formatTime(metrics.inclusiveTimeMs)
2107
+ }
2108
+ ] : [],
2109
+ ...node.actual_time_ms != null ? [
2110
+ {
2111
+ label: tr("editor.visualExplain.timePerLoop", "Time per Loop"),
2112
+ value: formatTime(node.actual_time_ms)
2113
+ }
2114
+ ] : [],
2115
+ ...node.actual_loops != null ? [
2116
+ {
2117
+ label: tr("editor.visualExplain.loops", "Loops"),
2118
+ value: String(node.actual_loops)
2119
+ }
2120
+ ] : [],
2121
+ ...node.buffers_hit != null ? [
2122
+ {
2123
+ label: tr("editor.visualExplain.buffersHit", "Buffers Hit"),
2124
+ value: String(node.buffers_hit)
2125
+ }
2126
+ ] : [],
2127
+ ...node.buffers_read != null ? [
2128
+ {
2129
+ label: tr("editor.visualExplain.buffersRead", "Buffers Read"),
2130
+ value: String(node.buffers_read)
2131
+ }
2132
+ ] : []
2133
+ ] : [];
2134
+ const extraEntries = Object.entries(node.extra ?? {}).map(
2135
+ ([key, value]) => ({
2136
+ label: key,
2137
+ value: typeof value === "string" ? value : JSON.stringify(value)
2138
+ })
2139
+ );
2140
+ const sections = [];
2141
+ if (generalEntries.length > 0) {
2142
+ sections.push({
2143
+ title: tr("editor.visualExplain.general", "General"),
2144
+ titleKey: "editor.visualExplain.general",
2145
+ entries: generalEntries
2146
+ });
2147
+ }
2148
+ if (analyzeEntries.length > 0) {
2149
+ sections.push({
2150
+ title: tr("editor.visualExplain.analyzeData", "Analyze Data"),
2151
+ titleKey: "editor.visualExplain.analyzeData",
2152
+ entries: analyzeEntries
2153
+ });
2154
+ }
2155
+ if (extraEntries.length > 0) {
2156
+ sections.push({
2157
+ title: tr("editor.visualExplain.extraDetails", "Extra"),
2158
+ titleKey: "editor.visualExplain.extraDetails",
2159
+ entries: extraEntries
2160
+ });
2161
+ }
2162
+ if (diagnostics && diagnostics.length > 0) {
2163
+ const diagEntries = diagnostics.map((d) => ({
2164
+ label: tr(d.labelKey, d.kind),
2165
+ value: d.value ? `${tr(d.descriptionKey, d.kind)} (${d.value})` : tr(d.descriptionKey, d.kind)
2166
+ }));
2167
+ sections.push({
2168
+ title: tr("editor.visualExplain.diagnostics.title", "Diagnostics"),
2169
+ titleKey: "editor.visualExplain.diagnostics.title",
2170
+ entries: diagEntries
2171
+ });
2172
+ }
2173
+ return sections;
2174
+ }
2175
+ function formatExplainNodeDetails(node, options = {}) {
2176
+ const sections = getExplainNodeDetailSections(node, options);
2177
+ const parts = [];
2178
+ for (const section of sections) {
2179
+ if (section.entries.length === 0) continue;
2180
+ const lines = section.entries.map((entry) => `${entry.label}: ${entry.value}`);
2181
+ if (options.includeSectionHeaders || section.titleKey !== "editor.visualExplain.general") {
2182
+ parts.push(`${section.title}:
2183
+ ${lines.join("\n")}`);
2184
+ } else {
2185
+ parts.push(lines.join("\n"));
2186
+ }
2187
+ }
2188
+ return parts.join("\n\n");
2189
+ }
2190
+
2191
+ // src/planComparison.ts
2192
+ function checkEngineCompatibility(baselineEngine, currentEngine) {
2193
+ const b = (baselineEngine || "").toLowerCase().trim();
2194
+ const c = (currentEngine || "").toLowerCase().trim();
2195
+ if (b && c && b !== c) {
2196
+ return {
2197
+ compatible: false,
2198
+ reason: `Engines differ (${b} vs ${c}). Explain metrics and costs cannot be compared across different database engines.`
2199
+ };
2200
+ }
2201
+ return { compatible: true };
2202
+ }
2203
+ function calculateMetricDelta(baselineValue, currentValue) {
2204
+ const b = baselineValue ?? null;
2205
+ const c = currentValue ?? null;
2206
+ if (b == null || c == null) {
2207
+ return {
2208
+ baselineValue: b,
2209
+ currentValue: c,
2210
+ delta: null,
2211
+ ratio: null
2212
+ };
2213
+ }
2214
+ const delta = c - b;
2215
+ const ratio = b !== 0 ? c / b : null;
2216
+ return {
2217
+ baselineValue: b,
2218
+ currentValue: c,
2219
+ delta,
2220
+ ratio
2221
+ };
2222
+ }
2223
+ function flattenTree(root, prefix = "0", depth = 0) {
2224
+ const result = [{ node: root, path: prefix, depth }];
2225
+ root.children.forEach((child, i) => {
2226
+ result.push(...flattenTree(child, `${prefix}.${i}`, depth + 1));
2227
+ });
2228
+ return result;
2229
+ }
2230
+ function alignPlanNodes(baselineRoot, currentRoot, baselineMetrics, currentMetrics) {
2231
+ if (!baselineRoot && !currentRoot) return [];
2232
+ if (!baselineRoot && currentRoot) {
2233
+ const currentFlat2 = flattenTree(currentRoot);
2234
+ return currentFlat2.map(({ node }) => {
2235
+ const m = currentMetrics?.get(node.id);
2236
+ return {
2237
+ baselineNodeId: null,
2238
+ currentNodeId: node.id,
2239
+ operatorChanged: false,
2240
+ baselineNodeType: null,
2241
+ currentNodeType: node.node_type,
2242
+ relation: node.relation,
2243
+ status: "added",
2244
+ costDelta: calculateMetricDelta(null, m?.totalCost ?? node.total_cost),
2245
+ timeDelta: calculateMetricDelta(null, m?.inclusiveTimeMs ?? node.actual_time_ms),
2246
+ rowsDelta: calculateMetricDelta(
2247
+ null,
2248
+ m?.totalRows ?? node.actual_rows ?? node.plan_rows
2249
+ )
2250
+ };
2251
+ });
2252
+ }
2253
+ if (baselineRoot && !currentRoot) {
2254
+ const baselineFlat2 = flattenTree(baselineRoot);
2255
+ return baselineFlat2.map(({ node }) => {
2256
+ const m = baselineMetrics?.get(node.id);
2257
+ return {
2258
+ baselineNodeId: node.id,
2259
+ currentNodeId: null,
2260
+ operatorChanged: false,
2261
+ baselineNodeType: node.node_type,
2262
+ currentNodeType: null,
2263
+ relation: node.relation,
2264
+ status: "removed",
2265
+ costDelta: calculateMetricDelta(m?.totalCost ?? node.total_cost, null),
2266
+ timeDelta: calculateMetricDelta(m?.inclusiveTimeMs ?? node.actual_time_ms, null),
2267
+ rowsDelta: calculateMetricDelta(
2268
+ m?.totalRows ?? node.actual_rows ?? node.plan_rows,
2269
+ null
2270
+ )
2271
+ };
2272
+ });
2273
+ }
2274
+ const baselineFlat = flattenTree(baselineRoot);
2275
+ const currentFlat = flattenTree(currentRoot);
2276
+ const matchedCurrent = /* @__PURE__ */ new Set();
2277
+ const matchedBaseline = /* @__PURE__ */ new Set();
2278
+ const comparisons = [];
2279
+ const currentByPath = new Map(currentFlat.map((item) => [item.path, item]));
2280
+ for (const bItem of baselineFlat) {
2281
+ const cItem = currentByPath.get(bItem.path);
2282
+ if (cItem && !matchedCurrent.has(cItem.node.id)) {
2283
+ const typeMatch = bItem.node.node_type === cItem.node.node_type;
2284
+ const relMatch = bItem.node.relation === cItem.node.relation;
2285
+ if (typeMatch || bItem.node.relation && relMatch) {
2286
+ matchedBaseline.add(bItem.node.id);
2287
+ matchedCurrent.add(cItem.node.id);
2288
+ const bM = baselineMetrics?.get(bItem.node.id);
2289
+ const cM = currentMetrics?.get(cItem.node.id);
2290
+ comparisons.push({
2291
+ baselineNodeId: bItem.node.id,
2292
+ currentNodeId: cItem.node.id,
2293
+ operatorChanged: !typeMatch,
2294
+ baselineNodeType: bItem.node.node_type,
2295
+ currentNodeType: cItem.node.node_type,
2296
+ relation: cItem.node.relation ?? bItem.node.relation,
2297
+ status: "matched",
2298
+ costDelta: calculateMetricDelta(
2299
+ bM?.totalCost ?? bItem.node.total_cost,
2300
+ cM?.totalCost ?? cItem.node.total_cost
2301
+ ),
2302
+ timeDelta: calculateMetricDelta(
2303
+ bM?.inclusiveTimeMs ?? bItem.node.actual_time_ms,
2304
+ cM?.inclusiveTimeMs ?? cItem.node.actual_time_ms
2305
+ ),
2306
+ rowsDelta: calculateMetricDelta(
2307
+ bM?.totalRows ?? bItem.node.actual_rows ?? bItem.node.plan_rows,
2308
+ cM?.totalRows ?? cItem.node.actual_rows ?? cItem.node.plan_rows
2309
+ )
2310
+ });
2311
+ }
2312
+ }
2313
+ }
2314
+ for (const bItem of baselineFlat) {
2315
+ if (matchedBaseline.has(bItem.node.id)) continue;
2316
+ const candidate = currentFlat.find(
2317
+ (cItem) => !matchedCurrent.has(cItem.node.id) && bItem.node.relation && bItem.node.relation === cItem.node.relation
2318
+ );
2319
+ if (candidate) {
2320
+ matchedBaseline.add(bItem.node.id);
2321
+ matchedCurrent.add(candidate.node.id);
2322
+ const bM = baselineMetrics?.get(bItem.node.id);
2323
+ const cM = currentMetrics?.get(candidate.node.id);
2324
+ const typeMatch = bItem.node.node_type === candidate.node.node_type;
2325
+ comparisons.push({
2326
+ baselineNodeId: bItem.node.id,
2327
+ currentNodeId: candidate.node.id,
2328
+ operatorChanged: !typeMatch,
2329
+ baselineNodeType: bItem.node.node_type,
2330
+ currentNodeType: candidate.node.node_type,
2331
+ relation: candidate.node.relation,
2332
+ status: "matched",
2333
+ isAmbiguous: true,
2334
+ costDelta: calculateMetricDelta(
2335
+ bM?.totalCost ?? bItem.node.total_cost,
2336
+ cM?.totalCost ?? candidate.node.total_cost
2337
+ ),
2338
+ timeDelta: calculateMetricDelta(
2339
+ bM?.inclusiveTimeMs ?? bItem.node.actual_time_ms,
2340
+ cM?.inclusiveTimeMs ?? candidate.node.actual_time_ms
2341
+ ),
2342
+ rowsDelta: calculateMetricDelta(
2343
+ bM?.totalRows ?? bItem.node.actual_rows ?? bItem.node.plan_rows,
2344
+ cM?.totalRows ?? candidate.node.actual_rows ?? candidate.node.plan_rows
2345
+ )
2346
+ });
2347
+ }
2348
+ }
2349
+ for (const bItem of baselineFlat) {
2350
+ if (!matchedBaseline.has(bItem.node.id)) {
2351
+ const bM = baselineMetrics?.get(bItem.node.id);
2352
+ comparisons.push({
2353
+ baselineNodeId: bItem.node.id,
2354
+ currentNodeId: null,
2355
+ operatorChanged: false,
2356
+ baselineNodeType: bItem.node.node_type,
2357
+ currentNodeType: null,
2358
+ relation: bItem.node.relation,
2359
+ status: "removed",
2360
+ costDelta: calculateMetricDelta(bM?.totalCost ?? bItem.node.total_cost, null),
2361
+ timeDelta: calculateMetricDelta(
2362
+ bM?.inclusiveTimeMs ?? bItem.node.actual_time_ms,
2363
+ null
2364
+ ),
2365
+ rowsDelta: calculateMetricDelta(
2366
+ bM?.totalRows ?? bItem.node.actual_rows ?? bItem.node.plan_rows,
2367
+ null
2368
+ )
2369
+ });
2370
+ }
2371
+ }
2372
+ for (const cItem of currentFlat) {
2373
+ if (!matchedCurrent.has(cItem.node.id)) {
2374
+ const cM = currentMetrics?.get(cItem.node.id);
2375
+ comparisons.push({
2376
+ baselineNodeId: null,
2377
+ currentNodeId: cItem.node.id,
2378
+ operatorChanged: false,
2379
+ baselineNodeType: null,
2380
+ currentNodeType: cItem.node.node_type,
2381
+ relation: cItem.node.relation,
2382
+ status: "added",
2383
+ costDelta: calculateMetricDelta(null, cM?.totalCost ?? cItem.node.total_cost),
2384
+ timeDelta: calculateMetricDelta(
2385
+ null,
2386
+ cM?.inclusiveTimeMs ?? cItem.node.actual_time_ms
2387
+ ),
2388
+ rowsDelta: calculateMetricDelta(
2389
+ null,
2390
+ cM?.totalRows ?? cItem.node.actual_rows ?? cItem.node.plan_rows
2391
+ )
2392
+ });
2393
+ }
2394
+ }
2395
+ return comparisons;
2396
+ }
2397
+ function comparePlanSnapshots(baseline, current, baselineMetrics, currentMetrics) {
2398
+ const engineCheck = checkEngineCompatibility(baseline.context.engine, current.context.engine);
2399
+ const hasAnalyzeMismatch = baseline.hasAnalyzeData !== current.hasAnalyzeData;
2400
+ const nodeComparisons = engineCheck.compatible ? alignPlanNodes(baseline.plan.root, current.plan.root, baselineMetrics, currentMetrics) : [];
2401
+ let nodesAdded = 0;
2402
+ let nodesRemoved = 0;
2403
+ let operatorsChanged = 0;
2404
+ for (const cmp of nodeComparisons) {
2405
+ if (cmp.status === "added") nodesAdded++;
2406
+ if (cmp.status === "removed") nodesRemoved++;
2407
+ if (cmp.status === "matched" && cmp.operatorChanged) operatorsChanged++;
2408
+ }
2409
+ return {
2410
+ isCompatible: engineCheck.compatible,
2411
+ incompatibilityReason: engineCheck.reason,
2412
+ hasAnalyzeMismatch,
2413
+ baselineSnapshotId: baseline.id,
2414
+ currentSnapshotId: current.id,
2415
+ summary: {
2416
+ totalCostDelta: calculateMetricDelta(baseline.totalCost, current.totalCost),
2417
+ executionTimeDelta: calculateMetricDelta(
2418
+ baseline.executionTimeMs,
2419
+ current.executionTimeMs
2420
+ ),
2421
+ planningTimeDelta: calculateMetricDelta(
2422
+ baseline.planningTimeMs,
2423
+ current.planningTimeMs
2424
+ ),
2425
+ nodesAdded,
2426
+ nodesRemoved,
2427
+ operatorsChanged
2428
+ },
2429
+ nodeComparisons
2430
+ };
2431
+ }
2432
+
2433
+ 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 };