diff --git a/query-graphs/src/loaders/highlight-rules.ts b/query-graphs/src/loaders/highlight-rules.ts new file mode 100644 index 00000000..c11f6078 --- /dev/null +++ b/query-graphs/src/loaders/highlight-rules.ts @@ -0,0 +1,535 @@ +// Highlight rules — the plan-highlighting heuristics used by the loader. +// +// These are loader-side policy: the loader applies them at load time to bake each node's highlight +// (costly / high-volume scan, cardinality misestimate, runtime / memory hotspot) plus its human- +// readable reason and proportional heat shade. The layout and rendering stages just display the +// baked result, keeping them database-agnostic. This module is pure (no React, no DOM). +// +// The thresholds are adjustable at render time: a loader packages these heuristics into a generic +// `PlanInsights` capability (see `createInsightsCapability`) that the UI drives — the renderer draws a +// slider per threshold and calls the capability's `rehighlight` to re-bake the tree with new values. +// Because the recompute lives here (loader-side) and is exposed only through that opaque capability, +// the rendering stage stays database-agnostic even while the highlights are live-tunable. + +import type {TreeNode, InsightsThreshold, InsightsRule, PlanInsights, PropertyStyle} from "../tree-description"; +import {visitTreeNodes, allChildren} from "../tree-description"; +import {formatMetric, formatBytes} from "./loader-utils"; + +// The numeric thresholds behind the highlight rules, in natural units (rows, a ratio, a percentage). +// Seeded from `DEFAULT_THRESHOLDS`; the UI can override any of them live (see `createInsightsCapability`). +export interface HighlightThresholds { + // Costly scan: a scan must read at least this many rows before its selectivity is even considered. + costlyScanMinProcessed: number; + // Costly scan: processed rows per matched row at or above which the scan is flagged (a zero-match + // scan is always costly, independent of this ratio). + costlyScanSelectivityRatio: number; + // High-volume scan: a scan reads at least this many rows, regardless of selectivity. Catches the + // "massive but efficient" reads a costly scan misses (all rows matched, but the sheer volume is + // itself worth calling out as an optimization target). + highVolumeScanMinProcessed: number; + // Cardinality misestimate: the larger of estimate/actual must clear this floor to highlight. + cardinalityFloor: number; + // Cardinality misestimate: estimate and actual are a "mismatch" when they differ by this factor. + cardinalityRatio: number; + // Runtime hotspot: an operator using at least this percentage of the plan's total CPU cycles. + runtimeHotspotPercent: number; + // Memory hotspot: an operator holding at least this percentage of the plan's total peak memory. + memoryHotspotPercent: number; +} + +export const DEFAULT_THRESHOLDS: HighlightThresholds = { + costlyScanMinProcessed: 1_000_000, + costlyScanSelectivityRatio: 100, + // A scan reading 100M+ rows is "high volume" regardless of how many it kept — big enough to be + // worth a look on its own. + highVolumeScanMinProcessed: 100_000_000, + cardinalityFloor: 100_000, + cardinalityRatio: 10, + runtimeHotspotPercent: 5, + // Memory concentrates in fewer operators than CPU does, so use a higher default share before + // flagging — otherwise nearly every operator on a small plan would light up. + memoryHotspotPercent: 20, +}; + +// Whether a cardinality estimate and its measured actual "mismatch": they differ by more than the +// configured ratio AND the larger side clears the absolute floor. The floor keeps a 36-vs-0 miss +// from highlighting like a 540M-vs-0 one (a >ratio difference is trivially true whenever actual is 0). +export function isCardinalityMismatch(estimate: number, actual: number, t: HighlightThresholds): boolean { + return ( + Math.max(estimate, actual) >= t.cardinalityFloor && + (estimate > actual * t.cardinalityRatio || actual > estimate * t.cardinalityRatio) + ); +} + +// Whether a scan is "costly": it reads a meaningful volume and keeps few of those rows. A zero-match +// scan (read everything, kept nothing) is the extreme case and always counts once the volume floor +// is met. +export function isCostlyScan(processedRows: number, rowsMatching: number, t: HighlightThresholds): boolean { + return ( + processedRows >= t.costlyScanMinProcessed && + (rowsMatching === 0 || processedRows >= rowsMatching * t.costlyScanSelectivityRatio) + ); +} + +// Whether a scan is "high volume": it read at least the configured number of rows, regardless of how +// selective it was. Orthogonal to `isCostlyScan` (which is about selectivity waste) — a scan can be +// high volume without being costly (all rows matched) and vice versa. +export function isHighVolumeScan(processedRows: number, t: HighlightThresholds): boolean { + return processedRows >= t.highVolumeScanMinProcessed; +} + +// The hover-tooltip reason for a high-volume scan, baked onto the node by the loader. +export function highVolumeScanReason(processedRows: number): string { + return ( + `High-volume scan: read ${formatMetric(processedRows)} rows. Even efficient, a read this large ` + + `dominates the plan's cost — worth checking whether it can be filtered earlier or narrowed.` + ); +} + +// The hover-tooltip reason for a costly (inefficient) scan. `processed-rows` is billed at row-group +// granularity, so a low match-per-processed ratio means many row groups were read to return few rows. +export function costlyScanReason(processedRows: number, rowsMatching: number): string { + return ( + `Inefficient scan: billed ${formatMetric(processedRows)} rows (whole row groups), ` + + `only ${formatMetric(rowsMatching)} matched the restrictions — few matches per row group read.` + ); +} + +// The hover-tooltip reason for a runtime (CPU) hotspot; `pct` is the operator's rounded share of the +// plan's total CPU cycles. +export function runtimeHotspotReason(cpuCycles: number, pct: number): string { + return `Runtime CPU hotspot: used ${formatMetric(cpuCycles)} CPU cycles — ${pct}% of the plan's total runtime.`; +} + +// The hover-tooltip reason for a memory hotspot; `pct` is the operator's rounded share of the plan's +// total peak memory. +export function memoryHotspotReason(bytes: number, pct: number): string { + return `Memory hotspot: held ${formatBytes(bytes)} — ${pct}% of the plan's total peak memory.`; +} + +// A proportional "heatmap" shade: interpolate lightness from `lightStart` (a negligible share) down to +// `lightEnd` (essentially the whole plan) by `ratio`, at a fixed hue/saturation. The costly-scan, +// runtime, and memory heatmaps differ only in their hue and lightness endpoints, so they share this. +function heatShade(hue: number, saturation: number, lightStart: number, lightEnd: number, ratio: number): string { + const l = (lightStart + (lightEnd - lightStart) * ratio).toFixed(3); + return `hsl(${hue}, ${saturation}%, ${l}%)`; +} + +// The proportional red shade for a costly scan, darker the larger this scan's share of all rows read by +// the plan's scans. Lightness runs from 98% (a negligible share — barely tinted) down to 82% (the scan +// that read essentially everything); both ends are kept light so even the heaviest reads as a soft +// heatmap red, not a saturated error color. `processedTotal` is the summed processed-rows across every +// scan; when it is non-positive (no runtime stats) there is nothing to scale against, so fall back to +// the lightest shade. +export function costlyScanShade(processedRows: number, processedTotal: number): string { + const ratio = processedTotal > 0 ? processedRows / processedTotal : 0; + return heatShade(0, 100, 98, 82, ratio); +} + +// The proportional violet shade for a runtime (CPU) hotspot, darker the larger this operator's share of +// the plan's total CPU cycles. A distinct hue from the magenta cardinality-misestimate edge highlight, +// so the two rules never read alike. Baked by the loader's `colorRelativeExecutionTime`. +export function runtimeHotspotShade(ratio: number): string { + return heatShade(265, 70, 95, 72, ratio); +} + +// The proportional orange shade for a memory hotspot, darker the larger this operator's share of the +// plan's total peak memory (the memory analog of the violet CPU heatmap). Orange (hue 28) is the one +// open "warning heat" slot in the palette, disambiguated by the legend, the tinted `memory-bytes` row, +// and the hover tooltip. Baked by the loader's `colorRelativeMemory`. +export function memoryHotspotShade(ratio: number): string { + return heatShade(28, 90, 95, 72, ratio); +} + +// The hover-tooltip reason for a node whose output projects a duplicate column name. +export function duplicateColumnsReason(names: string[]): string { + return `Duplicate output column name${names.length > 1 ? "s" : ""}: ${names.join(", ")}.`; +} + +// One editable numeric input in the rules footer, bound to a single `HighlightThresholds` field. +interface ThresholdField { + key: keyof HighlightThresholds; + label: string; + // Suffix shown after the input (e.g. "×", "%"). Omitted for a plain row count. + unit?: string; + min: number; + step: number; +} + +// Metadata describing a highlight rule for the footer legend/editor: its label, the swatch class +// mirroring its node/edge color, a one-line explanation, and any editable thresholds. Rules with no +// `fields` are boolean facts (an index exists / was used), not tunable heuristics. `legend` / `summary` +// control where the category surfaces in the panel (see the matching `InsightsRule` fields), so the +// renderer draws the legend and summary generically without knowing the taxonomy. +interface HighlightRule { + key: string; + label: string; + swatchClass: string; + description: string; + fields: ThresholdField[]; + // Appears in the top legend (countable / drill-into) vs. footer-only (edge- or list-level). + legend: boolean; + // Singular / plural nouns for the one-line summary header; omitted → not counted in the summary. + summary?: {singular: string; plural: string}; +} + +// Order matters: the panel renders the legend and the summary by filtering this list in place, so the +// order here is the display order for both. Node categories (shown in the legend) come first in +// precedence order, then the list-only hotspots, then the edge-level cardinality rule. +const HIGHLIGHT_RULES: HighlightRule[] = [ + { + key: "costly-scan", + label: "Inefficient scan", + swatchClass: "qg-swatch-costly-scan", + description: + "A scan billed far more rows than matched its restrictions — whole row groups were read " + + "to return few rows (low selectivity).", + fields: [ + {key: "costlyScanMinProcessed", label: "Min processed", min: 0, step: 100_000}, + {key: "costlyScanSelectivityRatio", label: "Processed ↔ matched", unit: "×", min: 1, step: 5}, + ], + legend: true, + summary: {singular: "inefficient scan", plural: "inefficient scans"}, + }, + { + key: "high-volume-scan", + label: "High-volume scan", + swatchClass: "qg-swatch-high-volume-scan", + description: + "A scan read a very large number of rows — even if it kept most of them, the sheer volume " + + "dominates the plan's cost and is worth a look (can it be filtered earlier or read less?).", + fields: [{key: "highVolumeScanMinProcessed", label: "Min processed", min: 0, step: 10_000_000}], + legend: true, + summary: {singular: "high-volume scan", plural: "high-volume scans"}, + }, + { + key: "index-rec", + label: "Index recommendation", + swatchClass: "qg-swatch-index-rec", + description: + "The optimizer flagged a column as an index-recommendation candidate — analyze the query " + + "traffic patterns before building the index; it's a candidate, not a directive.", + fields: [], + legend: true, + summary: {singular: "index recommendation", plural: "index recommendations"}, + }, + { + key: "duplicate-columns", + label: "Duplicate output columns", + swatchClass: "qg-swatch-duplicate-columns", + description: + "This operator's output projects the same column name more than once — often a sign of an " + + "over-broad or accidentally repeated projection worth double-checking.", + fields: [], + legend: true, + summary: {singular: "duplicate-column node", plural: "duplicate-column nodes"}, + }, + { + key: "index-used", + label: "Index used", + swatchClass: "qg-swatch-index-used", + description: "The scan used an existing index — informational, not a guarantee the plan is optimal.", + fields: [], + legend: true, + }, + { + key: "runtime-hotspot", + label: "Runtime CPU hotspot", + swatchClass: "qg-swatch-runtime-hotspot", + description: "An operator consumed a large share of the plan's total CPU cycles.", + fields: [{key: "runtimeHotspotPercent", label: "Runtime share", unit: "%", min: 0, step: 1}], + legend: false, + summary: {singular: "CPU hotspot", plural: "CPU hotspots"}, + }, + { + key: "memory-hotspot", + label: "Memory hotspot", + swatchClass: "qg-swatch-memory-hotspot", + description: "An operator held a large share of the plan's total peak memory.", + fields: [{key: "memoryHotspotPercent", label: "Memory share", unit: "%", min: 0, step: 1}], + legend: false, + summary: {singular: "memory hotspot", plural: "memory hotspots"}, + }, + { + key: "cardinality", + label: "Cardinality misestimate", + swatchClass: "qg-swatch-cardinality", + description: "The optimizer's row estimate diverged sharply from the actual row count on an edge.", + fields: [ + {key: "cardinalityFloor", label: "Min rows", min: 0, step: 10_000}, + {key: "cardinalityRatio", label: "Estimate ↔ actual", unit: "×", min: 1, step: 1}, + ], + legend: false, + }, +]; + +// The subset of `TreeNode` fields recomputed from thresholds. Assigned back onto the node by `rehighlight`. +interface NodeDisplay { + highlightNode?: "costly-scan" | "high-volume-scan" | "index-rec" | "index-used"; + highlightReason?: string; + costlyScan?: boolean; + costlyScanColor?: string; + highVolumeScan?: boolean; + nodeColor?: string; + memoryColor?: string; + edgeClass?: string; + edgeReason?: string; + // Generic category membership + issue flag, so the layout/render stages never encode the taxonomy. + insightCategories?: string[]; + isIssue?: boolean; + // Per-property presentation hints for the renderer (see `TreeNode.propertyStyles`), plus two static + // display fields lifted out of the raw property map so the panel needn't read property names. + propertyStyles?: Map; + operatorId?: string; + scanTableName?: string; +} + +// Recompute a node's threshold-dependent display from its raw signals. Reproduces exactly what the +// loader bakes at load time, but from the given (possibly UI-adjusted) thresholds, so a threshold edit +// re-highlights without reloading the plan. +// +// Precedence for the node color is costly-scan > high-volume-scan > index-rec > index-used; the +// runtime-hotspot tint is orthogonal (it colors the node label / cpu-cycles row) and its explanation is +// appended to any existing reason. On the edge, a costly scan's reason overrides a cardinality +// misestimate's. +function deriveNodeDisplay( + node: TreeNode, + t: HighlightThresholds, + planCpuTotal: number, + planProcessedTotal: number, + planMemoryTotal: number, +): NodeDisplay { + const display: NodeDisplay = {}; + + // Costly scan (top-precedence node color). + const costly = + typeof node.scanProcessedRows === "number" && + typeof node.scanRowsMatching === "number" && + isCostlyScan(node.scanProcessedRows, node.scanRowsMatching, t); + let costlyReason: string | undefined; + if (costly) { + costlyReason = costlyScanReason(node.scanProcessedRows!, node.scanRowsMatching!); + display.highlightNode = "costly-scan"; + display.highlightReason = costlyReason; + display.costlyScan = true; + // Shade the node box proportionally to this scan's share of all rows the plan's scans read, + // the same heatmap the loader bakes (see `shadeCostlyScans` in hyper.ts). + display.costlyScanColor = costlyScanShade(node.scanProcessedRows!, planProcessedTotal); + } else if (typeof node.scanProcessedRows === "number" && isHighVolumeScan(node.scanProcessedRows, t)) { + // High-volume scan: less severe than a costly scan (so it only claims the node when the scan + // isn't costly), but more prominent than the index categories, so it wins the fill over them. + // An index recommendation on the same scan still shows on the border (see `qg-node-index-rec-border`). + display.highlightNode = "high-volume-scan"; + display.highlightReason = highVolumeScanReason(node.scanProcessedRows); + display.highVolumeScan = true; + } else if (node.baseHighlight) { + // Fall back to the non-threshold node category (index recommendation or index used). + display.highlightNode = node.baseHighlight; + display.highlightReason = node.baseHighlightReason; + } + + // Cardinality on the incoming edge. The edge label shows only the bare "actual/estimate" numbers, + // so always spell them out as a hover tooltip; when they diverge sharply, highlight the edge and + // append the misestimate explanation below the counts. + if (typeof node.cardEstimate === "number" && typeof node.cardActual === "number") { + const rowsTip = `Actual rows: ${formatMetric(node.cardActual)}, Est. rows: ${formatMetric(node.cardEstimate)}`; + display.edgeReason = rowsTip; + if (isCardinalityMismatch(node.cardEstimate, node.cardActual, t)) { + display.edgeClass = "qg-label-highlighted"; + const dir = node.cardEstimate > node.cardActual ? "over-estimated" : "under-estimated"; + // Scans report the matched-restrictions count as their "actual"; generic operators report + // measured output. Word the two cases accordingly. + const tail = node.cardIsScan + ? `estimated ${formatMetric(node.cardEstimate)} rows, ${formatMetric(node.cardActual)} matched the restrictions.` + : `estimated ${formatMetric(node.cardEstimate)} rows, actual ${formatMetric(node.cardActual)}.`; + const subject = node.cardIsScan ? "this scan's output" : "this operator's output"; + display.edgeReason = `${rowsTip}\nCardinality misestimate: the optimizer ${dir} ${subject} — ${tail}`; + } + } + // A costly scan always highlights the edge; append its reason below any row-count/misestimate text. + if (costly) { + display.edgeClass = "qg-label-highlighted"; + display.edgeReason = display.edgeReason ? `${display.edgeReason}\n${costlyReason}` : costlyReason; + } + + // Duplicate output column names — a static per-plan fact (not threshold-dependent), baked by the + // loader. Appended before the CPU / memory reasons so the tooltip reads scan → duplicate → cpu → mem. + if (node.duplicateColumns && node.duplicateColumns.length > 0) { + const dupReason = duplicateColumnsReason(node.duplicateColumns); + display.highlightReason = display.highlightReason ? `${display.highlightReason}\n${dupReason}` : dupReason; + } + + // Runtime hotspot (orthogonal violet tint on the node label / cpu-cycles row). A distinct hue + // from the magenta cardinality-misestimate edge highlight, so the two rules never read alike. + if (typeof node.cpuTime === "number" && planCpuTotal > 0) { + const ratio = node.cpuTime / planCpuTotal; + if (ratio >= t.runtimeHotspotPercent / 100) { + display.nodeColor = runtimeHotspotShade(ratio); + const pct = Math.round(ratio * 100); + const cpuReason = runtimeHotspotReason(node.cpuTime, pct); + // Each reason on its own line so multiple findings on one node stay legible. + display.highlightReason = display.highlightReason ? `${display.highlightReason}\n${cpuReason}` : cpuReason; + // Only append to a *highlighted* edge (mismatch / costly), not to the plain row-count + // tooltip that every cardinality edge now carries — otherwise the CPU note would leak onto + // ordinary edges. Gate on `edgeClass` rather than the (now always-set) `edgeReason`. + if (display.edgeClass) display.edgeReason = `${display.edgeReason}\n${cpuReason}`; + } + } + + // Memory hotspot (orthogonal orange tint on the node label / memory-bytes row). Independent of the + // CPU hotspot — an operator can be both — so it gets its own `memoryColor`; the label shows the CPU + // tint first (see QueryNode.tsx) but the memory-bytes row always carries this color. Keep the shade + // and reason string in sync with `colorRelativeMemory` in hyper.ts. + if (typeof node.memoryBytes === "number" && planMemoryTotal > 0) { + const ratio = node.memoryBytes / planMemoryTotal; + if (ratio >= t.memoryHotspotPercent / 100) { + display.memoryColor = memoryHotspotShade(ratio); + const pct = Math.round(ratio * 100); + const memReason = memoryHotspotReason(node.memoryBytes, pct); + display.highlightReason = display.highlightReason ? `${display.highlightReason}\n${memReason}` : memReason; + if (display.edgeClass) display.edgeReason = `${display.edgeReason}\n${memReason}`; + } + } + + // Generic category membership + issue flag (see `TreeNode.insightCategories` / `isIssue`). Keys match + // `HIGHLIGHT_RULES[].key`, so the panel counts / drills and the layout stage dims focus without ever + // naming a category. A CPU / memory hotspot is identified by the tint this pass just assigned. + const categories: string[] = []; + if (display.costlyScan) categories.push("costly-scan"); + if (display.highVolumeScan) categories.push("high-volume-scan"); + if (node.hasIndexRec) categories.push("index-rec"); + if (node.duplicateColumns && node.duplicateColumns.length > 0) categories.push("duplicate-columns"); + if (node.hasIndexUsed) categories.push("index-used"); + if (display.nodeColor) categories.push("runtime-hotspot"); + if (display.memoryColor) categories.push("memory-hotspot"); + display.insightCategories = categories.length > 0 ? categories : undefined; + // "Issue" = worth drawing attention to. Excludes the merely-informational used index; a runtime error + // always qualifies. Drives focus-mode dimming and issue navigation. + display.isIssue = Boolean( + display.costlyScan || + display.highVolumeScan || + node.hasIndexRec || + (node.duplicateColumns && node.duplicateColumns.length > 0) || + display.nodeColor || + display.memoryColor || + node.errorMessage, + ); + + // Per-property presentation hints (see `PropertyStyle`): the loader owns the property-name vocabulary, + // so it bakes each row's tint / shade / grouping / annotation here and the renderer just applies them. + // Rebuilt every pass because several tints depend on the (tunable) scan / hotspot verdicts above. + const propertyStyles = new Map(); + const styleRow = (key: string, patch: PropertyStyle) => propertyStyles.set(key, {...propertyStyles.get(key), ...patch}); + for (const [key, value] of node.properties ?? []) { + // `table-metadata` renders as a grouped header + indented sub-items (the loader packs the sub-items + // as newline-separated `label: value` lines). + if (key === "table-metadata") styleRow(key, {grouped: true}); + // An index recommendation gets an amber emphasis; a *used* index gets informational blue, but only + // when one was actually used (value != "no"). + else if (key === "index-rec") styleRow(key, {className: "qg-prop-emphasized"}); + else if (key === "index-used" && value !== "no") styleRow(key, {className: "qg-prop-index-used"}); + // The loader-added duplicate-columns list carries the node's rose warning tint. + else if (key === "duplicate-columns") styleRow(key, {className: "qg-prop-duplicate-columns"}); + // A hybrid / vector search node tints its `function` row teal to match the insights legend accent. + else if (node.vectorSearch && key === "function") styleRow(key, {className: "qg-prop-vector-search"}); + // The benign "(likely early probe)" annotation on a 0-row scan is tinted green, split from the "0". + else if (key === "processed-rows" && value.includes("(likely early probe)")) + styleRow(key, {annotation: "(likely early probe)"}); + } + // Tint the cpu-cycles / memory-bytes rows with the same runtime-violet / memory-orange heat as the node + // label, so an expanded node matches its collapsed shade. + if (node.properties?.has("cpu-cycles") && display.nodeColor) styleRow("cpu-cycles", {background: display.nodeColor}); + if (node.properties?.has("memory-bytes") && display.memoryColor) styleRow("memory-bytes", {background: display.memoryColor}); + // On a costly scan, flag the processed-rows / rows-matching rows in proportional red; on a high-volume + // (but not costly) scan, tint processed-rows indigo instead. + if (display.costlyScan) { + for (const key of ["processed-rows", "rows-matching"]) { + if (node.properties?.has(key)) styleRow(key, {className: "qg-prop-costly-scan", background: display.costlyScanColor}); + } + } else if (display.highVolumeScan && node.properties?.has("processed-rows")) { + styleRow("processed-rows", {className: "qg-prop-high-volume-scan"}); + } + display.propertyStyles = propertyStyles.size > 0 ? propertyStyles : undefined; + + // Static display fields the panel needs, lifted out of the raw property map so the render stage never + // reads a database-specific property name. + display.operatorId = node.properties?.get("operator-id"); + display.scanTableName = node.properties?.get("table-name"); + + return display; +} + +// The footer documentation, associating each rule with the threshold keys that tune it (empty for the +// boolean-fact rules like "index used"). Shared by the adjustable and static capabilities. +function insightsRules(): InsightsRule[] { + return HIGHLIGHT_RULES.map((rule) => ({ + key: rule.key, + label: rule.label, + swatchClass: rule.swatchClass, + description: rule.description, + thresholdKeys: rule.fields.map((f) => f.key), + legend: rule.legend, + summary: rule.summary, + })); +} + +// A docs-only insights capability: it surfaces the panel (legend + per-operator lists) but exposes no +// adjustable thresholds and a no-op `rehighlight`. Used for trees whose highlights can't be meaningfully +// re-derived from a single set of plan-wide totals — e.g. an optimizer-steps tree that stitches several +// independent sub-plans, each baked against its own totals. +export function staticInsightsCapability(): PlanInsights { + return {thresholds: [], rules: insightsRules(), rehighlight: () => {}}; +} + +// Package the highlight heuristics into the generic `PlanInsights` capability a loader attaches to its +// `TreeDescription`. The renderer stays database-agnostic: it reads `thresholds` to draw the sliders and +// `rules` for the footer legend, and calls `rehighlight` (which owns all the database-specific logic +// here) whenever the user tunes a knob. `planCpuTotal` / `planProcessedTotal` / `planMemoryTotal` are the +// plan-wide totals the recompute needs; the loader passes the values it measured during conversion. +export function createInsightsCapability( + root: TreeNode, + planCpuTotal: number, + planProcessedTotal: number, + planMemoryTotal: number, +): PlanInsights { + // Flatten the tunable fields into generic slider descriptors, seeded with the default values. + const thresholds: InsightsThreshold[] = HIGHLIGHT_RULES.flatMap((rule) => + rule.fields.map((f) => ({ + key: f.key, + label: f.label, + value: DEFAULT_THRESHOLDS[f.key], + min: f.min, + step: f.step, + unit: f.unit, + })), + ); + const rules = insightsRules(); + const rehighlight = (values: Record) => { + // The generic values map is keyed by our threshold field names, so merging over the defaults + // yields a full `HighlightThresholds` (any knob the UI omits keeps its default). + const t: HighlightThresholds = {...DEFAULT_THRESHOLDS, ...values}; + visitTreeNodes( + root, + (node) => { + const d = deriveNodeDisplay(node, t, planCpuTotal, planProcessedTotal, planMemoryTotal); + // Assign every field (even when undefined) so a highlight that no longer applies under + // the new thresholds is cleared, not left stale from a previous pass. + node.highlightNode = d.highlightNode; + node.highlightReason = d.highlightReason; + node.costlyScan = d.costlyScan; + node.costlyScanColor = d.costlyScanColor; + node.nodeColor = d.nodeColor; + node.memoryColor = d.memoryColor; + node.edgeClass = d.edgeClass; + node.edgeReason = d.edgeReason; + node.insightCategories = d.insightCategories; + node.isIssue = d.isIssue; + node.propertyStyles = d.propertyStyles; + node.operatorId = d.operatorId; + node.scanTableName = d.scanTableName; + }, + allChildren, + ); + }; + return {thresholds, rules, rehighlight}; +} diff --git a/query-graphs/src/loaders/hyper.ts b/query-graphs/src/loaders/hyper.ts index e36e4703..db05df0f 100644 --- a/query-graphs/src/loaders/hyper.ts +++ b/query-graphs/src/loaders/hyper.ts @@ -1,37 +1,10 @@ -/* - -Hyper JSON Transformations --------------------------- - -We transform a Hyper JSON tree into a query-graphs tree using the following heuristics: - -1. Convert the overall tree - * traverse the tree recursively, converting from JSON to our internal representation - * detect the type of a node based on its `operator` or `expression` key. - For other keys, decide based on their value: a plain value (string, number, ...) becomes - part of the tooltip; anything else becomes part of the tree. A few pre-defined keys - (e.g., `statistics`) are always rendered in the tooltip, though. - * look up a type-specific config which configures the icon, display name etc. - * render children in a logically meaningful order, i.e. render "left" before "right" etc. - * collapse the tree by default: - * for operators: collapse all children which are not operators - * for expressions: don't collapse anything -2. Add additional details in a 2nd pass: edge widths, highlighting particularly long-running operators, ... - -*/ - import type {TreeNode, TreeDescription, Crosslink, IconName} from "../tree-description"; import {allChildren} from "../tree-description"; import type {Json, JsonObject} from "./loader-utils"; -import {forceToString, tryToString, formatMetric, hasOwnProperty, tryGetPropertyPath} from "./loader-utils"; +import {forceToString, tryToString, formatMetric, formatBytes, hasOwnProperty, tryGetPropertyPath} from "./loader-utils"; +import {createInsightsCapability, staticInsightsCapability} from "./highlight-rules"; -// A categorical color palette for execution pipelines (the Tableau 20 colors). -// The ten saturated base hues come first, then their lighter companions, so -// that adjacent pipelines never get near-identical shades (e.g. light-blue does -// not follow blue). Colors are assigned to pipelines left-to-right and rotate -// (index % length) once exhausted. const PIPELINE_PALETTE = [ - // Base hues. "#4e79a7", // blue "#f28e2b", // orange "#59a14f", // green @@ -42,7 +15,6 @@ const PIPELINE_PALETTE = [ "#d37295", // pink "#b07aa1", // purple "#9d7660", // brown - // Lighter companions (only reached by wide plans). "#a0cbe8", // light blue "#ffbe7d", // light orange "#8cd17d", // light green @@ -64,17 +36,981 @@ interface UnresolvedCrosslink { targetOpId: string; } -// Temporary state which we hold during converting from JSON to internal graph representation +// Only these tags populate scan-only stats (`processed-rows`, `rows-matching-restrictions`) and estimated/matching-rows edges. +const SCAN_OPERATORS = new Set([ + // Newer plans emit a generic `scan` (with `type`, e.g. `data-lake-object`) instead of per-format tags; treat as scan. + "scan", + "tablescan", + "arrowscan", + "binaryscan", + "csvscan", + "cloudtablescan", + "cursorscan", + "icebergscan", + "parquetscan", + "tdescan", +]); + +// Join operators carry a `condition` predicate; surface it inline like filters, not just in the collapsed subtree. +const JOIN_OPERATORS = new Set([ + "join", + "leftouterjoin", + "rightouterjoin", + "fullouterjoin", + "leftantijoin", + "rightantijoin", + "leftsemijoin", + "rightsemijoin", + "leftsinglejoin", + "rightsinglejoin", + "leftmarkjoin", + "rightmarkjoin", +]); + +// FORMAT JSON rework (W-22563058) renamed runtime block `analyze` -> `statistics` (`tuple-count` -> `output-rows`); try new then legacy. +function getStatistic(rawNode: Json, key: string): Json | undefined { + return tryGetPropertyPath(rawNode, ["statistics", key]) ?? tryGetPropertyPath(rawNode, ["analyze", key]); +} + +// Reads a failed plan's operator error from runtime-statistics: one-line, translated message preferred, SQLSTATE-prefixed. +function getErrorMessage(rawNode: Json): string | undefined { + const error = getStatistic(rawNode, "error"); + if (typeof error !== "object" || error === null || Array.isArray(error)) { + // Plain-string error (older shapes) used as-is; anything else (incl. null) has none. + return typeof error === "string" && error.length > 0 ? error : undefined; + } + const messageNode = tryGetPropertyPath(error, ["message"]); + const message = + typeof messageNode === "string" + ? messageNode + : [tryGetPropertyPath(error, ["message", "translation"]), tryGetPropertyPath(error, ["message", "original"])].find( + (m): m is string => typeof m === "string", + ); + if (message === undefined || message.length === 0) return undefined; + const code = tryGetPropertyPath(error, ["code"]); + return typeof code === "string" && code.length > 0 ? `[${code}] ${message}` : message; +} + +// Runtime metrics an operator emits into `analyze`/`statistics`, mapped to its surfaced property; only on runtime plans. +const RUNTIME_METRIC_PROPS: {key: string; prop: string; format: (v: number) => string}[] = [ + {key: "execution-time", prop: "execution-time", format: (v) => formatMetric(v)}, + {key: "memory-bytes", prop: "memory-bytes", format: (v) => formatBytes(v)}, + {key: "pipeline", prop: "pipeline", format: (v) => v.toString()}, +]; + +// `statistics` is overloaded: usually the renamed runtime block, but on a scan it's table metadata; distinguish via a runtime key. +const RUNTIME_STATISTIC_KEYS = ["cpu-cycles", "tuple-count", "output-rows", "processed-rows", "running", "pipeline"]; +function isRuntimeStatistics(stats: Json | undefined): boolean { + if (typeof stats !== "object" || stats === null || Array.isArray(stats)) return false; + return RUNTIME_STATISTIC_KEYS.some((k) => k in stats); +} + +// Optimizer estimate `cardinality` -> `estimated-rows` (W-22563058), new first; external plans carry only `statistics.estimated-rows`. +function getEstimatedRows(rawNode: Json): Json | undefined { + return ( + tryGetPropertyPath(rawNode, ["estimated-rows"]) ?? + tryGetPropertyPath(rawNode, ["statistics", "estimated-rows"]) ?? + tryGetPropertyPath(rawNode, ["cardinality"]) + ); +} + +// The generic property loop already copied this under `estimated-rows`/`cardinality`; drop both before setting the formatted value. +function setFormattedEstimatedRows(properties: Map, estRows: number) { + properties.delete("estimated-rows"); + properties.delete("cardinality"); + properties.set("estimated-rows", formatMetric(estRows)); +} + +function getActualRows(rawNode: Json): Json | undefined { + const outputRows = getStatistic(rawNode, "output-rows"); + return outputRows === undefined ? tryGetPropertyPath(rawNode, ["analyze", "tuple-count"]) : outputRows; +} + +// A `udtablefunction` UDF's details live under `args[i].variant.language-specific-metadata.properties..value`. +function findUdfMetadataProperties(rawNode: Json): JsonObject | undefined { + const args = tryGetPropertyPath(rawNode, ["args"]); + if (!Array.isArray(args)) return undefined; + for (const arg of args) { + const props = tryGetPropertyPath(arg, ["variant", "language-specific-metadata", "properties"]); + if (typeof props === "object" && props !== null && !Array.isArray(props)) { + return props as JsonObject; + } + } + return undefined; +} + +// Read a `{classification, value}`-wrapped metadata entry as a plain string. +function getUdfMetadataString(props: JsonObject, key: string): string | undefined { + const value = tryGetPropertyPath(props, [key, "value"]); + return typeof value === "string" ? value : undefined; +} + +// A search UDF's first `tableref` arg's `table-name` carries `{database, schema, table}`; return the bare table name. +function findUdfTableName(rawNode: Json): string | undefined { + const args = tryGetPropertyPath(rawNode, ["args"]); + if (!Array.isArray(args)) return undefined; + for (const arg of args) { + const table = tryGetPropertyPath(arg, ["variant", "tableref", "table-name", "table"]); + if (typeof table === "string") return table; + } + return undefined; +} + +// Source tables behind a search UDF's index/view come from `language-specific-metadata.leafTables[].tableName.table`. +function findUdfLeafTables(rawNode: Json): string[] { + const args = tryGetPropertyPath(rawNode, ["args"]); + if (!Array.isArray(args)) return []; + const tables = args.flatMap((arg) => { + const leaves = tryGetPropertyPath(arg, ["variant", "language-specific-metadata", "leafTables"]); + return Array.isArray(leaves) ? leaves.map((leaf) => tryGetPropertyPath(leaf, ["tableName", "table"])) : []; + }); + return [...new Set(tables.filter((t): t is string => typeof t === "string"))]; +} + +// Relevance-score columns (`vector_score__c`/`keyword_score__c`/`hybrid_score__c`) come from `output-columns[].name` or `ius[i]`. +function findUdfScoreColumns(rawNode: Json): string[] { + const names: string[] = []; + const seen = new Set(); + const add = (raw: string | undefined) => { + if (raw === undefined) return; + const bare = raw.slice(raw.lastIndexOf(".") + 1); + const m = /^(.+)_score__c$/.exec(bare); + if (m === null) return; + const short = m[1]; + if (!seen.has(short)) { + seen.add(short); + names.push(short); + } + }; + const outputColumns = tryGetPropertyPath(rawNode, ["output-columns"]); + if (Array.isArray(outputColumns)) { + for (const col of outputColumns) add(tryToString(tryGetPropertyPath(col, ["name"]))); + } + // Falls back to `ius` (older plans) only when `output-columns` is empty; names there are truncated, so matching is best-effort. + if (names.length === 0) { + const ius = tryGetPropertyPath(rawNode, ["ius"]); + if (Array.isArray(ius)) { + for (const entry of ius) add(Array.isArray(entry) ? tryToString(entry[0]) : undefined); + } + } + const rank = (s: string) => (s === "hybrid" ? 0 : s === "vector" ? 1 : s === "keyword" ? 2 : 3); + return names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); +} + +// Reads a field from a metadata entry whose `value` is JSON-encoded; undefined if missing, invalid JSON, or lacking the field. +function getUdfMetadataJsonField(props: JsonObject, key: string, field: string): string | undefined { + const raw = getUdfMetadataString(props, key); + if (raw === undefined) return undefined; + let parsed: Json; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + // Narrows on `typeof === "string"`, not `tryToString`, which turns a missing field into "undefined", falsely flagging a search. + const value = tryGetPropertyPath(parsed, [field]); + return typeof value === "string" ? value : undefined; +} + +const EXPRESSION_OPERATORS: Record = { + add: "+", + sub: "-", + mul: "*", + div: "/", + mod: "%", + and: "AND", + or: "OR", +}; + +// A `Double`/`Float` const's `value` is a raw 64-bit IEEE-754 bit pattern; reinterpret via two 32-bit words (no BigInt). +function reinterpretDoubleBits(bits: number): number | undefined { + if (!Number.isFinite(bits) || bits < 0 || Math.floor(bits) !== bits) { + return undefined; + } + const high = Math.floor(bits / 0x100000000); + const low = bits - high * 0x100000000; + if (high > 0xffffffff) return undefined; + const dv = new DataView(new ArrayBuffer(8)); + dv.setUint32(0, high); + dv.setUint32(4, low >>> 0); + const value = dv.getFloat64(0); + // JSON.parse's precision loss corrupts the lowest ~3 decimal digits; trim to 13 sig figs for display. + return Number(value.toPrecision(13)); +} + +// Quotes strings so `x = 'PROMO'` reads unambiguous vs `x = 3`; const types differ: plain value, IEEE-754 bits, or scaled int. +function stringifyConst(expr: JsonObject): string | undefined { + const value = tryGetPropertyPath(expr, ["value", "value"]); + const type = tryGetPropertyPath(expr, ["value", "type"]); + const typeName = Array.isArray(type) ? tryToString(type[0]) : undefined; + // A null literal carries `null: true` instead of `value`; render `NULL`, or one null constant collapses the whole expression. + if (tryGetPropertyPath(expr, ["value", "null"]) === true) return "NULL"; + // An `Interval` literal has no scalar `value`, only `months`/`days`/`time` (µs); render non-zero parts, not the subtree. + if (typeName === "Interval") { + const months = tryGetPropertyPath(expr, ["value", "months"]); + const days = tryGetPropertyPath(expr, ["value", "days"]); + const time = tryGetPropertyPath(expr, ["value", "time"]); + const parts: string[] = []; + if (typeof months === "number" && months !== 0) parts.push(`${months} month${Math.abs(months) === 1 ? "" : "s"}`); + if (typeof days === "number" && days !== 0) parts.push(`${days} day${Math.abs(days) === 1 ? "" : "s"}`); + if (typeof time === "number" && time !== 0) parts.push(`${time}µs`); + return `INTERVAL ${parts.length > 0 ? parts.join(" ") : "0"}`; + } + if ((typeName === "Double" || typeName === "Float") && typeof value === "number") { + const asDouble = reinterpretDoubleBits(value); + if (asDouble !== undefined) return asDouble.toString(); + } + // `Numeric`/`BigNumeric` are fixed-point: unscaled int / 10^scale; `Numeric` uses `value`, `BigNumeric` splits `low`/`high`. + if ((typeName === "Numeric" || typeName === "BigNumeric") && Array.isArray(type)) { + const scale = typeof type[2] === "number" ? type[2] : 0; + let unscaled: number | undefined; + if (typeof value === "number") { + unscaled = value; + } else { + const low = tryGetPropertyPath(expr, ["value", "low"]); + const high = tryGetPropertyPath(expr, ["value", "high"]); + // high * 2^64 + low: exact when high === 0 (common case), an approximation otherwise. + if (typeof low === "number" && typeof high === "number") { + unscaled = high * 4294967296 * 4294967296 + low; + } + } + if (unscaled !== undefined) { + return (scale > 0 ? unscaled / Math.pow(10, scale) : unscaled).toString(); + } + } + // Any other type must carry a plain scalar `value`; without one, bail to the subtree instead of literal string "undefined". + if (value === undefined) return undefined; + const str = tryToString(value); + if (str === undefined) return undefined; + const isText = typeName === "Varchar" || typeName === "Char" || typeName === "Text"; + return isText ? `'${str}'` : str; +} + +// Hyper's "IU" names: scan columns are `scan_`; operator-produced ones are `` (e.g. `union82`). +const IU_ORIGIN_LABELS: Record = { + union: "union", + groupbykey: "group key", + map: "computed", + tableconstruction: "literal rows", + setresult: "set result", + window: "window", + unnest: "unnest", +}; +function humanizeIuName(name: string | undefined): string | undefined { + if (name === undefined || name.length === 0) return name; + + // A qualified reference (`c.relkind`, `orders.o_orderkey`) already carries a real column name. + if (name.includes(".")) return name; + if (name.startsWith("scan_") && name.length > "scan_".length) { + return name.slice("scan_".length); + } + const match = /^([A-Za-z_][A-Za-z_]*?)(\d+)$/.exec(name); + const base = match ? match[1] : name; + const counter = match ? match[2] : undefined; + const label = IU_ORIGIN_LABELS[base.toLowerCase()]; + if (label !== undefined) { + return counter !== undefined ? `⟨${label} #${counter}⟩` : `⟨${label}⟩`; + } + // Aggregates (`sum`, `avg`, `count2`) are already readable; return verbatim to match the `output columns` name. + return name; +} + +// Plan-scoped map: Hyper IU name -> real column name, recovered from scan `attributes` and the plan's projected output. +let iuDisplayNames = new Map(); + +// Plan-scoped map: IU -> user-facing alias when the query renamed the column (`AS "Account Name"`); preferred over the base name. +let iuAliases = new Map(); + +// Plan-scoped set of every IU referenced by an `iu-ref` anywhere in the plan; used to decide which columns a preview shows. +let referencedIus = new Set(); + +// Plan-scoped memo: operator node -> IUs it references in its OWN expressions (not a child's); orders `output columns`. +let directRefsCache = new WeakMap>(); + +// Plan-scoped memo for `computeOutputIus`, keyed by node identity — avoids O(n^2) re-derivation down a deep operator chain. +let outputIuCache = new WeakMap(); + +// Plan-scoped map: a set-op's input node -> that set op's own output columns; authoritative over `computeOutputIus`'s derivation. +let setOpInputColumns = new WeakMap(); + +// While a join's `condition` renders, maps an IU to which input it's from (`"L"`/`"R"`/`""`); undefined outside a join render. +let iuSideTag: ((iu: string) => "L" | "R" | "") | undefined; + +// Walks the plan once since a node can reference an IU named deeper; `underPassthrough` suppresses refs under `output`/`mapping`. +function collectIuInfo( + node: Json | undefined, + names: Map, + refs: Set, + mappingLinks: {target: string; source: string}[], + underPassthrough = false, +): void { + if (Array.isArray(node)) { + for (const child of node) collectIuInfo(child, names, refs, mappingLinks, underPassthrough); + return; + } + if (typeof node !== "object" || node === null) return; + + // Scan attributes: `iu` (a `[name, type]` pair) -> the source column `name`. + const attributes = node["attributes"]; + if (Array.isArray(attributes)) { + for (const attr of attributes) { + const rawName = iuName(tryGetPropertyPath(attr, ["iu"])); + const colName = tryGetPropertyPath(attr, ["name"]); + if (typeof rawName === "string" && typeof colName === "string") { + names.set(rawName, colName); + } + } + } + + // Projected output columns: `output[i].iu` -> `output-names[i]` (the user-facing result names). + const output = node["output"]; + const outputNames = node["output-names"] ?? node["outputNames"]; + if (Array.isArray(output) && Array.isArray(outputNames)) { + for (let i = 0; i < output.length && i < outputNames.length; i++) { + const rawName = iuName(tryGetPropertyPath(output[i], ["iu"])); + const name = outputNames[i]; + if (typeof rawName === "string" && typeof name === "string") { + names.set(rawName, name); + } + } + } + + // `explicit-scan`/`temp` mapping's source may be defined later in this walk, so record it as a link resolved afterward. + const mapping = node["mapping"]; + if (Array.isArray(mapping)) { + for (const m of mapping) { + const targetIu = iuName(tryGetPropertyPath(m, ["target"])); + const source = tryGetPropertyPath(m, ["source"]); + linkIfIuRef(targetIu, source, mappingLinks); + } + } + + // A `group-by` key's fresh `GroupByKeyN` IU lacks a real name; link it to the source IU named via `expression.value`. + const keyExprs = node["key-expressions"] ?? node["keyExpressions"]; + if (Array.isArray(keyExprs)) { + for (const k of keyExprs) { + const targetIu = iuName(tryGetPropertyPath(k, ["iu"])); + // Newer plans wrap the key under `expression.value`; legacy plans put it directly under `value` — accept both. + const source = tryGetPropertyPath(k, ["expression", "value"]) ?? tryGetPropertyPath(k, ["value"]); + linkIfIuRef(targetIu, source, mappingLinks); + } + } + + // An `iu-ref`'s `iu` (bare name or `[name, type]`) is a genuine use unless inside a passthrough construct. + const kind = tryToString(node["expression"])?.replace(/-/g, ""); + if (kind === "iuref" && !underPassthrough) { + const raw = iuName(node["iu"]); + if (raw !== undefined) refs.add(raw); + } + + for (const key of Object.getOwnPropertyNames(node)) { + collectIuInfo(node[key], names, refs, mappingLinks, underPassthrough || key === "output" || key === "mapping"); + } +} + +// Unlike `collectIuInfo` (walks the whole plan), stops at any nested child operator boundary. +function collectDirectRefs(node: Json | undefined, refs: Set, underPassthrough: boolean, isChild: boolean): void { + if (Array.isArray(node)) { + for (const child of node) collectDirectRefs(child, refs, underPassthrough, isChild); + return; + } + if (typeof node !== "object" || node === null) return; + // Boundary: a nested operator owns its own reference scope — do not descend into it. + if (isChild && node.hasOwnProperty("operator")) return; + const kind = tryToString(node["expression"])?.replace(/-/g, ""); + if (kind === "iuref" && !underPassthrough) { + const raw = iuName(node["iu"]); + if (raw !== undefined) refs.add(raw); + } + for (const key of Object.getOwnPropertyNames(node)) { + collectDirectRefs(node[key], refs, underPassthrough || key === "output" || key === "mapping", true); + } +} + +function directRefsOf(node: object): Set { + let refs = directRefsCache.get(node); + if (refs === undefined) { + refs = new Set(); + collectDirectRefs(node as Json, refs, false, false); + directRefsCache.set(node, refs); + } + return refs; +} + +// Alias flood-fill inputs: an undirected "same logical column" graph plus aliased-output seeds not already in `mappingLinks`. +function collectAliasInfo( + node: Json | undefined, + links: {a: string; b: string}[], + seeds: Map, + computed: Map, +): void { + if (Array.isArray(node)) { + for (const child of node) collectAliasInfo(child, links, seeds, computed); + return; + } + if (typeof node !== "object" || node === null) return; + + // Alias seeds: a projection's `output[i].iu` takes the user-facing `output-names[i]`. + const output = node["output"]; + const outputNames = node["output-names"] ?? node["outputNames"]; + if (Array.isArray(output) && Array.isArray(outputNames)) { + for (let i = 0; i < output.length && i < outputNames.length; i++) { + const iu = iuName(tryGetPropertyPath(output[i], ["iu"])); + const name = outputNames[i]; + if (iu !== undefined && typeof name === "string" && !seeds.has(iu)) seeds.set(iu, name); + } + } + + const ius = node["ius"]; + const values = node["values"]; + if (Array.isArray(ius) && Array.isArray(values)) { + // Set operation: `ius[i]` is output column i; `values[k][i]` is input branch k's iu-ref for it. + for (const branch of values) { + if (!Array.isArray(branch)) continue; + for (let i = 0; i < branch.length && i < ius.length; i++) { + const kind = tryToString(tryGetPropertyPath(branch[i], ["expression"]))?.replace(/-/g, ""); + if (kind !== "iuref") continue; + const outIu = iuName(ius[i]); + const srcIu = iuName(tryGetPropertyPath(branch[i], ["iu"])); + if (outIu !== undefined && srcIu !== undefined) links.push({a: outIu, b: srcIu}); + } + } + } else if (Array.isArray(values)) { + // A `map`'s `values[j] = {iu, value}`; link produced IU to passthrough source(s), else stash iu-ref leaves in `computed`. + for (const entry of values) { + const targetIu = iuName(tryGetPropertyPath(entry, ["iu"])); + if (targetIu === undefined) continue; + const value = tryGetPropertyPath(entry, ["value"]); + const pass = passthroughSourceIus(value); + if (pass.length > 0) { + for (const srcIu of pass) links.push({a: targetIu, b: srcIu}); + } else if (!computed.has(targetIu)) { + computed.set(targetIu, iuRefLeaves(value)); + } + } + } + + for (const key of Object.getOwnPropertyNames(node)) collectAliasInfo(node[key], links, seeds, computed); +} + +// Every `iu-ref` leaf IU an expression reads (depth-bounded). Resolves a single-source union branch's name. +function iuRefLeaves(expr: Json | undefined, depth = 0): string[] { + if (depth > 8 || expr === undefined || expr === null || typeof expr !== "object") return []; + if (Array.isArray(expr)) { + return expr.flatMap((child) => iuRefLeaves(child, depth + 1)); + } + const kind = tryToString(expr["expression"])?.replace(/-/g, ""); + if (kind === "iuref") { + const iu = iuName(tryGetPropertyPath(expr, ["iu"])); + return iu === undefined ? [] : [iu]; + } + return Object.getOwnPropertyNames(expr) + .filter((key) => key !== "expression" && key !== "type") + .flatMap((key) => iuRefLeaves(expr[key], depth + 1)); +} + +// A `map` rename target: set ops insert a `map` that only re-types for branch unification (`setCastN = cast(col)`), same column. +function renameSourceIus(value: Json | undefined): string[] { + const pass = passthroughSourceIus(value); + if (pass.length > 0) return pass; + if (value === undefined) return []; + const kind = tryToString(tryGetPropertyPath(value, ["expression"]))?.replace(/-/g, ""); + if (kind === "cast") { + // Only a cast of a SINGLE column preserves identity — check the DIRECT operand, not just one `iu-ref` leaf in the subtree. + return passthroughSourceIus(tryGetPropertyPath(value, ["value"])); + } + return []; +} + +// `map` rename links so a `setCastN` inherits its source's real name; unlike the alias flood, contributes ONLY a display name. +function collectMapRenameLinks(node: Json | undefined, out: {target: string; source: string}[]): void { + if (Array.isArray(node)) { + for (const child of node) collectMapRenameLinks(child, out); + return; + } + if (node === null || typeof node !== "object") return; + const values = node["values"]; + if (Array.isArray(values) && !Array.isArray(node["ius"])) { + for (const entry of values) { + const target = iuName(tryGetPropertyPath(entry, ["iu"])); + if (target === undefined) continue; + const value = tryGetPropertyPath(entry, ["value"]); + for (const source of renameSourceIus(value)) out.push({target, source}); + } + } + for (const key of Object.getOwnPropertyNames(node)) collectMapRenameLinks(node[key], out); +} + +// A `map` value passes through: `iu-ref` yields its IU; `coalesce` yields its direct `iu-ref` children's IUs (outer-join merge). +function passthroughSourceIus(value: Json | undefined): string[] { + if (value === undefined) return []; + const kind = tryToString(tryGetPropertyPath(value, ["expression"]))?.replace(/-/g, ""); + if (kind === "iuref") { + const iu = iuName(tryGetPropertyPath(value, ["iu"])); + return iu === undefined ? [] : [iu]; + } + if (kind === "coalesce") { + // Only merges two full-outer-join sides of the SAME column; `COALESCE(a, b)` over different columns must NOT be fused. + const args = [tryGetPropertyPath(value, ["value"]), tryGetPropertyPath(value, ["arguments"])].find(Array.isArray); + if (!Array.isArray(args)) return []; + const ius: string[] = []; + for (const arg of args) { + const argKind = tryToString(tryGetPropertyPath(arg, ["expression"]))?.replace(/-/g, ""); + if (argKind !== "iuref") return []; // a computed operand -> not a plain column merge + const iu = iuName(tryGetPropertyPath(arg, ["iu"])); + if (iu === undefined) return []; + ius.push(iu); + } + const baseNames = new Set(ius.map((iu) => iuDisplayNames.get(iu))); + if (baseNames.size !== 1 || baseNames.has(undefined)) return []; + return ius; + } + return []; +} + +// Order columns relevant-first (parent-read, used-downstream, rest); full list the UI progressively reveals (QueryNode.tsx). +function orderColumnsRelevantFirst(cols: {name: string; iu: string | undefined}[], parentRefs?: Set): string[] { + const usedByParent = (c: {iu: string | undefined}) => c.iu !== undefined && parentRefs !== undefined && parentRefs.has(c.iu); + const usedElsewhere = (c: {iu: string | undefined}) => c.iu !== undefined && referencedIus.has(c.iu); + return [ + ...cols.filter((c) => usedByParent(c)), + ...cols.filter((c) => !usedByParent(c) && usedElsewhere(c)), + ...cols.filter((c) => !usedByParent(c) && !usedElsewhere(c)), + ].map((c) => c.name); +} + +// Columns shown before eliding into `... [remaining]`; the UI reveals this many more per click (QueryNode.tsx). +const COLUMN_PREVIEW_COUNT = 2; + +// Static column-preview fallback; must match the UI's initial state (see QueryNode.tsx). +function formatColumnPreview(names: string[]): string | undefined { + if (names.length === 0) return undefined; + if (names.length <= COLUMN_PREVIEW_COUNT) return names.join(", "); + return `${names.slice(0, COLUMN_PREVIEW_COUNT).join(", ")} ... [${names.length - COLUMN_PREVIEW_COUNT}]`; +} + +interface OutputColumn { + name: string; + iu: string | undefined; +} + +function findDuplicateNames(names: string[]): string[] { + const counts = new Map(); + for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1); + return [...new Set(names)].filter((n) => (counts.get(n) ?? 0) > 1); +} + +function dedupOutputColumns(cols: OutputColumn[]): OutputColumn[] { + const seen = new Set(); + return cols.filter((c) => c.iu === undefined || (!seen.has(c.iu) && seen.add(c.iu))); +} + +function outputColumnName(iu: string): string { + return iuAliases.get(iu) ?? iuDisplayNames.get(iu) ?? iu; +} + +// Pull the IU name out of Hyper's `[name, type]` pair (or a bare name). +function iuName(iuPair: Json | undefined): string | undefined { + const raw = Array.isArray(iuPair) ? iuPair[0] : iuPair; + return typeof raw === "string" ? raw : undefined; +} + +function linkIfIuRef( + targetIu: string | undefined, + source: Json | undefined, + mappingLinks: {target: string; source: string}[], +): void { + if (targetIu === undefined || source === undefined) return; + const sourceKind = tryToString(tryGetPropertyPath(source, ["expression"]))?.replace(/-/g, ""); + if (sourceKind !== "iuref") return; + const sourceIu = iuName(tryGetPropertyPath(source, ["iu"])); + if (sourceIu !== undefined) mappingLinks.push({target: targetIu, source: sourceIu}); +} + +// Row inputs: newer plans nest them in `inputs`; older ones use `input`/`left`/`right` (scalar-subquery fields excluded). +function rowInputs(node: JsonObject): Json[] { + const rawInputs = node["inputs"]; + return Array.isArray(rawInputs) + ? (rawInputs as Json[]) + : ([node["input"], node["left"], node["right"]].filter((c) => c !== undefined && c !== null) as Json[]); +} + +function pushToList(map: Map, key: K, value: V): void { + const list = map.get(key) ?? []; + list.push(value); + map.set(key, list); +} + +// Hyper type tuple `[Name, ...args]`: trailing numbers become length/precision/scale (`Numeric(18, 2)`); string modifiers like `nullable` dropped. +function formatTypeName(type: Json | undefined): string | undefined { + if (!Array.isArray(type) || type.length === 0) return undefined; + const name = tryToString(type[0]); + if (name === undefined) return undefined; + const params = type.slice(1).filter((t): t is number => typeof t === "number"); + return params.length > 0 ? `${name}(${params.join(", ")})` : name; +} + +// Operators without their own column list: reconstruct output schema bottom-up from children's outputs + IUs defined/dropped, deduped. Memoized. +function computeOutputIus(node: Json | undefined, depth = 0): OutputColumn[] { + if (depth > 40 || typeof node !== "object" || node === null || Array.isArray(node)) return []; + const cached = outputIuCache.get(node); + if (cached !== undefined) return cached; + const result = deriveOutputIus(node, depth); + outputIuCache.set(node, result); + return result; +} + +function deriveOutputIus(node: JsonObject, depth: number): OutputColumn[] { + const children = rowInputs(node); + const childColumns = (idx?: number): OutputColumn[] => { + const picked = idx === undefined ? children : children[idx] !== undefined ? [children[idx]] : []; + return dedupOutputColumns(picked.flatMap((c) => computeOutputIus(c, depth + 1))); + }; + + // Concatenated-lowercase tag so kebab (`group-by`) and legacy (`groupby`) spellings hit the same branch. + const tag = (tryToString(node["operator"]) ?? "").replace(/-/g, "").toLowerCase(); + + // Scan family / virtual table: `attributes[].iu` -> the source column `name`. + const attributes = node["attributes"]; + if (Array.isArray(attributes) && (tag.endsWith("scan") || tag === "virtualtable")) { + return dedupOutputColumns( + attributes + .map((a): OutputColumn | undefined => { + const iu = iuName(tryGetPropertyPath(a, ["iu"])); + const nm = tryGetPropertyPath(a, ["name"]); + if (iu === undefined && typeof nm !== "string") return undefined; + // Prefer query alias, then recovered display name — must match the scan annotation below. + const display = iu !== undefined ? (iuAliases.get(iu) ?? iuDisplayNames.get(iu)) : undefined; + return {name: display ?? (typeof nm === "string" ? nm : iu!), iu}; + }) + .filter((c): c is OutputColumn => c !== undefined), + ); + } + // explicit-scan / temp: re-projects a materialized result via `mapping` (source -> renamed target). + if (tag === "explicitscan" || tag === "temp") { + const mapping = node["mapping"]; + if (!Array.isArray(mapping)) return childColumns(); + return dedupOutputColumns( + mapping + .map((m): OutputColumn | undefined => { + const name = stringifyExpression(tryGetPropertyPath(m, ["source"])); + const iu = iuName(tryGetPropertyPath(m, ["target"])); + if (name === undefined || name.length === 0) return undefined; + return {name, iu}; + }) + .filter((c): c is OutputColumn => c !== undefined), + ); + } + // Set operations (union-all / except-all / intersect-all) expose their result IUs in `ius`. + if (Array.isArray(node["ius"])) { + const ius = node["ius"] as Json[]; + // `values[k]` is branch k's per-position iu-refs, positionally aligned to `ius`. + const branches = node["values"]; + return dedupOutputColumns( + ius + .map((e, i): OutputColumn | undefined => { + const iu = iuName(e); + if (iu === undefined) return undefined; + const known = iuAliases.get(iu) ?? iuDisplayNames.get(iu); + if (known !== undefined) return {name: known, iu}; + // Branches disagree on/lack a source name — annotate the IU with the distinct real source names (`union195 (uniqueid__c / UID__c)`). + const sources: string[] = []; + if (Array.isArray(branches)) { + for (const branch of branches) { + const entry = Array.isArray(branch) ? branch[i] : undefined; + const srcIu = entry === undefined ? undefined : iuName(tryGetPropertyPath(entry, ["iu"])); + const srcName = srcIu !== undefined ? (iuAliases.get(srcIu) ?? iuDisplayNames.get(srcIu)) : undefined; + if (srcName !== undefined && !sources.includes(srcName)) sources.push(srcName); + } + } + const name = sources.length > 0 ? `${iu} (${sources.join(" / ")})` : (humanizeIuName(iu) ?? iu); + return {name, iu}; + }) + .filter((c): c is OutputColumn => c !== undefined), + ); + } + // `tableconstruction` lists its result IUs in `output` (each an `[iu, type]` pair). + if (tag === "tableconstruction" && Array.isArray(node["output"])) { + return dedupOutputColumns( + (node["output"] as Json[]) + .map((e): OutputColumn | undefined => { + const iu = iuName(e); + return iu === undefined ? undefined : {name: outputColumnName(iu), iu}; + }) + .filter((c): c is OutputColumn => c !== undefined), + ); + } + + // `map` appends computed columns (`values[].iu`) to child output; name by the computed expression when short, else the raw IU. + if (tag === "map" && Array.isArray(node["values"])) { + const computed = (node["values"] as Json[]) + .map((v): OutputColumn | undefined => { + const iu = iuName(tryGetPropertyPath(v, ["iu"])); + if (iu === undefined) return undefined; + const expr = stringifyExpression(tryGetPropertyPath(v, ["value"])); + const name = expr !== undefined && expr.length > 0 && expr.length <= 30 ? expr : outputColumnName(iu); + return {name, iu}; + }) + .filter((c): c is OutputColumn => c !== undefined); + return dedupOutputColumns([...childColumns(), ...computed]); + } + // `group-by` drops input columns, emits only grouping keys + aggregates; name a key by its expression, aggregates by IU. + if (tag === "groupby") { + // Accept both kebab (`key-expressions`) and legacy camelCase (`keyExpressions`) spellings. + const keyExprs = node["key-expressions"] ?? node["keyExpressions"]; + const keys = (Array.isArray(keyExprs) ? (keyExprs as Json[]) : []) + .map((k): OutputColumn | undefined => { + const iu = iuName(tryGetPropertyPath(k, ["iu"])); + if (iu === undefined) return undefined; + const expr = stringifyExpression(tryGetPropertyPath(k, ["expression", "value"])); + const name = expr !== undefined && expr.length > 0 && expr.length <= 30 ? expr : outputColumnName(iu); + return {name, iu}; + }) + .filter((c): c is OutputColumn => c !== undefined); + const aggs = (Array.isArray(node["aggregates"]) ? (node["aggregates"] as Json[]) : []) + .map((a): OutputColumn | undefined => { + const iu = iuName(tryGetPropertyPath(a, ["iu"])); + return iu === undefined ? undefined : {name: outputColumnName(iu), iu}; + }) + .filter((c): c is OutputColumn => c !== undefined); + return dedupOutputColumns([...keys, ...aggs]); + } + if (tag === "window" && Array.isArray(node["window-infos"])) { + const windowIus: OutputColumn[] = []; + for (const info of node["window-infos"] as Json[]) { + const directIu = iuName(tryGetPropertyPath(info, ["iu"])); + if (directIu !== undefined) windowIus.push({name: outputColumnName(directIu), iu: directIu}); + const aggs = tryGetPropertyPath(info, ["aggregation", "aggregates"]); + if (Array.isArray(aggs)) { + for (const a of aggs) { + const iu = iuName(tryGetPropertyPath(a, ["iu"])); + if (iu !== undefined) windowIus.push({name: outputColumnName(iu), iu}); + } + } + } + return dedupOutputColumns([...childColumns(), ...windowIus]); + } + + // Semi/anti joins keep the probed side; mark joins keep one side + marker IU. `inputs[0]`=left, `inputs[1]`=right. + if (tag === "leftsemijoin" || tag === "leftantijoin") return childColumns(0); + if (tag === "rightsemijoin" || tag === "rightantijoin") return childColumns(1); + if (tag === "leftmarkjoin" || tag === "rightmarkjoin") { + const base = childColumns(tag === "leftmarkjoin" ? 0 : 1); + const markerIu = iuName(node["marker"]); + return markerIu === undefined ? base : dedupOutputColumns([...base, {name: outputColumnName(markerIu), iu: markerIu}]); + } + + return childColumns(); +} + +// Push set-op column names onto inputs' aligned columns, top-down (nested first); only fill unnamed IUs, never overwrite. +function propagateSetOpNames(node: Json | undefined, depth = 0): void { + if (depth > 40 || typeof node !== "object" || node === null) return; + if (Array.isArray(node)) { + for (const child of node) propagateSetOpNames(child, depth + 1); + return; + } + // A set operation is signalled by an `ius` array (the same marker `deriveOutputIus` keys on). + if (Array.isArray(node["ius"])) { + const outIus = (node["ius"] as Json[]).map(iuName); + // The set op's output columns, resolved to display names now (top-down order means already named). + const outCols: OutputColumn[] = outIus + .filter((iu): iu is string => iu !== undefined) + .map((iu) => ({name: outputColumnName(iu), iu})); + const inputs = rowInputs(node); + // Each `values[k]` is input k's positional iu-refs for the set op's `ius` (authoritatively aligned). + const branches = node["values"]; + inputs.forEach((input, k) => { + // Record to overwrite this input's `output columns` at display time; a first/outermost set op wins. + if (typeof input !== "object" || input === null || setOpInputColumns.has(input)) return; + // Prefer the input's own positional source IU over the set op's output IU — real names, not `union2`/`union4`. + const branch = Array.isArray(branches) ? branches[k] : undefined; + const cols: OutputColumn[] = outCols.map((c, i) => { + const entry = Array.isArray(branch) ? branch[i] : undefined; + if (entry === undefined) return c; + const kind = tryToString(tryGetPropertyPath(entry, ["expression"]))?.replace(/-/g, ""); + const srcIu = kind === "iuref" ? iuName(tryGetPropertyPath(entry, ["iu"])) : undefined; + return srcIu !== undefined ? {name: outputColumnName(srcIu), iu: srcIu} : c; + }); + setOpInputColumns.set(input, cols); + // Deliberately not pushing set-op names by position — order can misalign `ius`; flood-fill uses `values` links. + }); + } + for (const key of Object.getOwnPropertyNames(node)) { + propagateSetOpNames(node[key], depth + 1); + } +} + +// Renders a Hyper expression as a compact string; returns real `undefined` (never the string) so callers fall back to the subtree. +function stringifyExpression(expr: Json | undefined, depth = 0): string | undefined { + if (depth > 6) return "…"; + // A missing/null operand returns real `undefined`, not `tryToString`'s string, else it leaks into e.g. `NOT (undefined)`. + if (expr === undefined || expr === null) { + return undefined; + } + if (typeof expr !== "object" || Array.isArray(expr)) { + return tryToString(expr); + } + // Newer plans kebab-case the tag (`iu-ref` vs `iuref`); `kind` drops hyphens, `kindRaw` keeps the original for the fallback. + const kindRaw = tryToString(expr["expression"]); + const kind = kindRaw?.replace(/-/g, ""); + switch (kind) { + case "iuref": { + // `iu` is a plain column name or `[name, type]`; a malformed iuref falls back to the subtree, not "undefined". + const raw = iuName(expr["iu"]); + if (raw === undefined) return undefined; + const name = iuAliases.get(raw) ?? iuDisplayNames.get(raw) ?? humanizeIuName(raw); + + // Within a join condition, frame which side the column comes from: ⟨L⟩ prefix / ⟨R⟩ suffix. + if (iuSideTag === undefined) return name; + const side = iuSideTag(raw); + if (side === "L") return `⟨L⟩ ${name}`; + if (side === "R") return `${name} ⟨R⟩`; + return name; + } + case "const": + return stringifyConst(expr); + case "comparison": { + const mode = tryToString(expr["mode"]) ?? "?"; + // Do NOT reorder operands to force `⟨L⟩ … ⟨R⟩` — optimizer order is meaningful; per-operand tags show each side. + const left = stringifyExpression(expr["left"], depth + 1); + const right = stringifyExpression(expr["right"], depth + 1); + if (left === undefined || right === undefined) return undefined; + return `${left} ${mode} ${right}`; + } + case "between": { + const args = expr["arguments"]; + if (!Array.isArray(args) || args.length < 3) return undefined; + const value = stringifyExpression(args[0], depth + 1); + const lo = stringifyExpression(args[1], depth + 1); + const hi = stringifyExpression(args[2], depth + 1); + if (value === undefined || lo === undefined || hi === undefined) return undefined; + return `${value} BETWEEN ${lo} AND ${hi}`; + } + case "like": { + // `[value, pattern, escape?]`; render `value LIKE pattern`, dropping the escape char. + const args = expr["arguments"]; + if (!Array.isArray(args) || args.length < 2) return undefined; + const value = stringifyExpression(args[0], depth + 1); + const pattern = stringifyExpression(args[1], depth + 1); + if (value === undefined || pattern === undefined) return undefined; + return `${value} LIKE ${pattern}`; + } + case "not": { + // Negation carries its operand as `input` or a single-element `arguments`. + const inner = + stringifyExpression(expr["input"], depth + 1) ?? + (Array.isArray(expr["arguments"]) ? stringifyExpression(expr["arguments"][0], depth + 1) : undefined); + return inner === undefined ? undefined : `NOT (${inner})`; + } + case "isnull": + case "isnotnull": { + const inner = + stringifyExpression(expr["input"], depth + 1) ?? + (Array.isArray(expr["arguments"]) ? stringifyExpression(expr["arguments"][0], depth + 1) : undefined); + if (inner === undefined) return undefined; + return `${inner} ${kind === "isnull" ? "IS NULL" : "IS NOT NULL"}`; + } + case "in": { + const args = expr["arguments"]; + if (!Array.isArray(args) || args.length < 2) return undefined; + const value = stringifyExpression(args[0], depth + 1); + const set = args.slice(1).map((a) => stringifyExpression(a, depth + 1)); + if (value === undefined || set.some((s) => s === undefined)) return undefined; + return `${value} IN (${set.join(", ")})`; + } + case "cast": { + // Casts are noise in a predicate; render the inner value, but increment `depth` for the recursion guard. + return stringifyExpression(expr["value"], depth + 1); + } + case "case": { + // Searched CASE: `cases:[{case,value},...]` + optional `else`; bails to undefined if a branch can't be stringified. + const cases = expr["cases"]; + if (!Array.isArray(cases) || cases.length === 0) return undefined; + const parts: string[] = []; + for (const c of cases) { + const cond = c !== null && typeof c === "object" ? c : {}; + const when = stringifyExpression((cond as JsonObject)["case"], depth + 1); + const then = stringifyExpression((cond as JsonObject)["value"], depth + 1); + if (when === undefined || then === undefined) return undefined; + parts.push(`WHEN ${when} THEN ${then}`); + } + const elseStr = stringifyExpression(expr["else"], depth + 1); + return `CASE ${parts.join(" ")}${elseStr !== undefined ? ` ELSE ${elseStr}` : ""} END`; + } + case "simplecase": { + // Simple CASE has two shapes: (A) `{value,cases:[{cases,value}],else}` vs (B) `{input,cases:[{value,result}],else}`. + const scrutinee = stringifyExpression(expr["input"] ?? expr["value"], depth + 1); + const cases = expr["cases"]; + if (scrutinee === undefined || !Array.isArray(cases) || cases.length === 0) return undefined; + const parts: string[] = []; + for (const c of cases) { + const branch = (c !== null && typeof c === "object" ? c : {}) as JsonObject; + const shapeA = Array.isArray(branch["cases"]); + const matchExprs = shapeA ? (branch["cases"] as Json[]) : [branch["value"]]; + const matches = matchExprs.map((m) => stringifyExpression(m, depth + 1)); + const result = stringifyExpression(shapeA ? branch["value"] : branch["result"], depth + 1); + if (result === undefined || matches.some((m) => m === undefined)) return undefined; + parts.push(`WHEN ${matches.join(", ")} THEN ${result}`); + } + const elseStr = stringifyExpression(expr["else"], depth + 1); + return `CASE ${scrutinee} ${parts.join(" ")}${elseStr !== undefined ? ` ELSE ${elseStr}` : ""} END`; + } + default: + break; + } + if (kind === undefined) return undefined; + if (kind in EXPRESSION_OPERATORS) { + const op = EXPRESSION_OPERATORS[kind]; + const args = expr["arguments"]; + if (Array.isArray(args)) { + const parts = args.map((a) => stringifyExpression(a, depth + 1)); + if (parts.some((p) => p === undefined)) return undefined; + return parts.join(` ${op} `); + } + const left = stringifyExpression(expr["left"], depth + 1); + const right = stringifyExpression(expr["right"], depth + 1); + if (left !== undefined && right !== undefined) return `${left} ${op} ${right}`; + } + // Generic fallback: renders unrecognized expr as `kind(args)`; unary uses `input`, binary `left`/`right`, else `arguments`. + const fnArgs = Array.isArray(expr["arguments"]) + ? expr["arguments"] + : expr.hasOwnProperty("input") + ? [expr["input"]] + : expr.hasOwnProperty("left") && expr.hasOwnProperty("right") + ? [expr["left"], expr["right"]] + : undefined; + if (fnArgs !== undefined) { + const parts = fnArgs.map((a) => stringifyExpression(a, depth + 1)); + if (!parts.some((p) => p === undefined)) { + return `${kindRaw}(${parts.join(", ")})`; + } + } + return undefined; +} + +function setCardinalityEdge(node: TreeNode, conversionState: ConversionState, estimate: number, actual: number, isScan: boolean) { + // Width follows actual row count so edges share one scale (`setEdgeWidths` normalizes); an estimate would skew it. + conversionState.edgeWidths.push({node, width: actual}); + // Label reads actual/estimate (actual first), matching postgres.ts's actual/estimated order. + node.edgeLabel = formatMetric(actual) + "/" + formatMetric(estimate); + // Raw estimate/actual; edge highlight class and tooltip are derived from these by `deriveNodeDisplay`. + node.cardEstimate = estimate; + node.cardActual = actual; + node.cardIsScan = isScan; +} + interface ConversionState { operatorsById: Map; crosslinks: UnresolvedCrosslink[]; edgeWidths: {node: TreeNode; width: number}[]; runtimes: {node: TreeNode; time: number}[]; + memories: {node: TreeNode; bytes: number}[]; + // Every scan's processed-rows volume, used to total scan work and shade costly scans proportionally. + scanProcessed: {node: TreeNode; processed: number}[]; metadata: Map; } -// Customization points for rendering the various different -// operator and expression types interface NodeRenderingConfig { displayNameKey?: string; crosslinkSourceKey?: string; @@ -87,7 +1023,6 @@ const nodeRenderingConfig: Record = { "op:filter": {icon: "filter-symbol"}, "op:sort": {icon: "sort-symbol"}, "op:group-by": {icon: "groupby-symbol"}, - // Joins "op:join": {displayNameKey: "type", icon: "inner-join-symbol", crosslinkSourceKey: "magic"}, "op:join:inner": {displayNameKey: "type", icon: "inner-join-symbol", crosslinkSourceKey: "magic"}, "op:join:left-outer": {displayNameKey: "type", icon: "left-join-symbol", crosslinkSourceKey: "magic"}, @@ -113,7 +1048,6 @@ const nodeRenderingConfig: Record = { "op:left-mark-join": {crosslinkSourceKey: "magic"}, "op:right-mark-join": {crosslinkSourceKey: "magic"}, "op:early-probe": {icon: "filter-symbol", crosslinkSourceKey: "builder"}, - // Various scans "op:scan": {displayNameKey: "type", icon: "table-symbol"}, "op:scan:virtual-table": {displayNameKey: "type", icon: "virtual-table-symbol"}, "op:table-scan": {icon: "table-symbol"}, @@ -125,16 +1059,14 @@ const nodeRenderingConfig: Record = { "op:iceberg-scan": {icon: "table-symbol"}, "op:parquet-scan": {icon: "table-symbol"}, "op:tde-scan": {icon: "table-symbol"}, - // Other tables + // Table-valued UDF (e.g. Data Cloud `hybrid_search`); `name` holds the function name. + "op:udtablefunction": {icon: "virtual-table-symbol", displayNameKey: "name"}, "op:table-construction": {icon: "const-table-symbol"}, "op:virtual-table": {icon: "virtual-table-symbol"}, - // Temp & Explicit scan "op:explicit-scan": {icon: "temp-table-symbol", crosslinkSourceKey: "input"}, "op:temp": {icon: "temp-table-symbol"}, "op:iteration-increment": {crosslinkSourceKey: "source"}, - // Inserts "op:insert": {displayNameKey: "type"}, - // Expressions "exp:comparison": {displayNameKey: "mode"}, "exp:iu-ref": {displayNameKey: "iu"}, "exp:reference": {displayNameKey: "id"}, @@ -173,39 +1105,105 @@ const legacyNodeTags: Record = { "exp:iuref": "exp:iu-ref", }; -// Should the entry `key` from `node` always be expanded? function isAlwaysExpanded(node: JsonObject, key: string): boolean { - const child = node[key]; - if (node.hasOwnProperty("operator")) { - // There might be arrays of operators. Also detect those... - let unwrapped = child; - while (Array.isArray(unwrapped) && unwrapped.length) { - unwrapped = unwrapped[0]; + if (!node.hasOwnProperty("operator")) return false; + let unwrapped = node[key]; + while (Array.isArray(unwrapped) && unwrapped.length) { + unwrapped = unwrapped[0]; + } + if (typeof unwrapped === "object" && !Array.isArray(unwrapped) && unwrapped !== null) { + return unwrapped.hasOwnProperty("operator"); + } + return false; +} + +function reorderProperties(properties: Map, order: string[]): void { + const reordered = new Map(); + for (const key of order) { + const value = properties.get(key); + if (value !== undefined) { + reordered.set(key, value); } - // Subobjects which are also operators themself should be displayed - if (typeof unwrapped === "object" && !Array.isArray(unwrapped) && unwrapped !== null) { - return unwrapped.hasOwnProperty("operator"); + } + for (const [key, value] of properties) { + if (!reordered.has(key)) { + reordered.set(key, value); } - // All other children should be hidden - return false; } - return false; + properties.clear(); + for (const [key, value] of reordered) { + properties.set(key, value); + } } -// Convert Hyper JSON to a D3 tree -function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionState): TreeNode | TreeNode[] { +// `agg.operation.aggregate` is the fn; `agg.source` indexes `aggExprs` (missing ⇒ nullary); shared by group-by/window. +function formatAggregateCalls(aggregates: Json[], aggExprs: Json | undefined, overSuffix = ""): string[] { + const out: string[] = []; + for (const agg of aggregates) { + const fnRaw = tryGetPropertyPath(agg, ["operation", "aggregate"]); + if (typeof fnRaw !== "string") continue; + const source = tryGetPropertyPath(agg, ["source"]); + let arg = "*"; + if (typeof source === "number" && Array.isArray(aggExprs) && source >= 0 && source < aggExprs.length) { + const argExpr = tryGetPropertyPath(aggExprs[source], ["value"]) ?? aggExprs[source]; + arg = stringifyExpression(argExpr) ?? "…"; + } + const call = `${fnRaw}(${arg})${overSuffix}`; + const iu = iuName(tryGetPropertyPath(agg, ["iu"])); + const alias = iu !== undefined ? outputColumnName(iu) : undefined; + out.push(alias !== undefined && alias.length > 0 ? `${alias} = ${call}` : call); + } + return out; +} + +function renderConditionAndConjuncts(rawNode: Json, properties: Map): void { + const conditionStr = stringifyExpression(tryGetPropertyPath(rawNode, ["condition"])); + if (conditionStr !== undefined && conditionStr.length > 0) { + properties.set("condition", conditionStr); + } + const conditionArgs = tryGetPropertyPath(rawNode, ["condition", "arguments"]); + const conjunctKind = tryToString(tryGetPropertyPath(rawNode, ["condition", "expression"])); + if (conjunctKind === "and" && Array.isArray(conditionArgs) && conditionArgs.length > 1) { + properties.set("predicates", conditionArgs.length.toString()); + conditionArgs.forEach((conjunct, i) => { + const conjunctStr = stringifyExpression(conjunct); + if (conjunctStr !== undefined && conjunctStr.length > 0) { + properties.set(`predicate ${i + 1}`, conjunctStr); + } + }); + } +} + +function convertHyperNode( + rawNode: Json, + parentKey, + conversionState: ConversionState, + parentOperator?: object, +): TreeNode | TreeNode[] { if (tryToString(rawNode) !== undefined) { return { name: tryToString(rawNode), }; } else if (typeof rawNode === "object" && !Array.isArray(rawNode) && rawNode !== null) { - // "Object" nodes const expandedChildren = [] as TreeNode[]; const collapsedChildren = [] as TreeNode[]; const properties = new Map(); + // IUs the parent directly consumes, so this node's output columns lead with them. + const parentRefs = parentOperator !== undefined ? directRefsOf(parentOperator) : undefined; + // Full relevant-first column lists for truncated previews, carried on the node so the UI can reveal them on click. + const columnLists = new Map(); + // Duplicate output-column names (empty if none); recomputed per preview, so a set-op overwrite's last write wins. + let duplicateColumns: string[] = []; + // Set a column-preview property and, when truncated, stash the full ordered list for the UI. + const setColumnPreview = (key: string, cols: OutputColumn[]) => { + const ordered = orderColumnsRelevantFirst(cols, parentRefs); + const preview = formatColumnPreview(ordered); + if (preview === undefined) return; + properties.set(key, preview); + if (ordered.length > COLUMN_PREVIEW_COUNT) columnLists.set(key, ordered); + if (key === "output columns") duplicateColumns = findDuplicateNames(ordered); + }; - // Figure out if this is an operator or an expression and - // retrieve the operator-specific customizations let nodeType: "operator" | "expression" | undefined; let nodeTag: string | undefined; let renderingConfig: NodeRenderingConfig = {}; @@ -232,53 +1230,59 @@ function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionS } } - // Display these properties always as properties, even if they are more complex. - const propertyKeys = ["debug-name", "statistics", "sqlpos"]; + // Always displayed; `debugName` is the pre-kebab-case spelling of `debug-name` — accept both. + const propertyKeys = ["debug-name", "debugName"]; for (const key of propertyKeys) { if (!rawNode.hasOwnProperty(key)) { continue; } + // Table name is a sensitivity-wrapped string `{classification, value}`; surface `.value` as `table-name`. + if (key === "debug-name" || key === "debugName") { + const value = tryGetPropertyPath(rawNode, [key, "value"]); + if (typeof value === "string") { + properties.set("table-name", value); + continue; + } + } properties.set(key, forceToString(rawNode[key])); } - // Determine the order in which other keys are displayed. - // For some keys, we enforce a specific order here (e.g., "left" comes before "right"). - // For all other keys, we use alphabetic order. + // Display order for remaining keys: `fixedChildOrder` keys first (in this order), then the rest alphabetically. const fixedChildOrder = ["inputs", "input", "left", "right", "value", "value-for-comparison"]; const orderedKeys = Object.getOwnPropertyNames(rawNode) .filter((k) => { - // `propertyKeys` and `operator`/`expression` were already handled + // Drop the runtime `statistics`/legacy `analyze` block (already surfaced); a metadata block must still show. + if ((k === "statistics" || k === "analyze") && isRuntimeStatistics(rawNode[k])) return false; + // `sqlpos` is a raw source-offset span into the original SQL text — visualizer noise. + if (k === "sqlpos") return false; + + // `propertyKeys` and `operator`/`expression` (via `nodeType`) were already handled above. return k != nodeType && propertyKeys.indexOf(k) === -1; }) .sort((a, b) => { - const idx1 = fixedChildOrder.indexOf(a); - const idx2 = fixedChildOrder.indexOf(b); - if (idx1 != -1 || idx2 != -1) { - const fixed1 = idx1 == -1 ? Infinity : idx1; - const fixed2 = idx2 == -1 ? Infinity : idx2; - return fixed1 - fixed2; - } else { - if (a < b) return -1; - if (a > b) return 1; - return 0; + const fixed1 = fixedChildOrder.indexOf(a); + const fixed2 = fixedChildOrder.indexOf(b); + if (fixed1 != -1 || fixed2 != -1) { + return (fixed1 == -1 ? Infinity : fixed1) - (fixed2 == -1 ? Infinity : fixed2); } + return a < b ? -1 : a > b ? 1 : 0; }); - // Display all other properties adaptively: simple expressions are displayed as properties, all others as part of the tree + // Display remaining properties adaptively: simple expressions become properties, everything else becomes child nodes. for (const key of orderedKeys) { - // Try to display as string property const str = tryToString(rawNode[key]); if (str !== undefined) { properties.set(key, str); continue; } - // Display as part of the tree + // Child's parent operator: this node if an operator, else the enclosing one (drives child column ordering). const children = isAlwaysExpanded(rawNode, key) ? expandedChildren : collapsedChildren; - const innerNodes = convertHyperNode(rawNode[key], key, conversionState); + const childParentOperator = nodeType === "operator" ? rawNode : parentOperator; + const innerNodes = convertHyperNode(rawNode[key], key, conversionState, childParentOperator); if (fixedChildOrder.indexOf(key) != -1) { + // Flatten the array, in case it's one of the `fixedChildOrder` keys. if (Array.isArray(innerNodes)) { - // Flatten the array, in case it's one of the "fixedChildOrder" keys Array.prototype.push.apply(children, innerNodes); } else { // The `key` itself is not inserted as an intermediate node. @@ -288,25 +1292,20 @@ function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionS children.push(innerNodes); } } else if (Array.isArray(innerNodes)) { - // Array-valued children are collapsed by default, to avoid displaying too many properties all at once. children.push({name: key, collapsedChildren: innerNodes}); } else if (!innerNodes.name) { - // Single node without a name? Set the name and as a child. innerNodes.name = key; children.push(innerNodes); } else { - // Single node which already has a name? Add as a nested node. children.push({name: key, children: [innerNodes]}); } } - // Figure out the display name const specificDisplayName = renderingConfig.displayNameKey ? properties.get(renderingConfig.displayNameKey) : undefined; const debugNameNode = tryGetPropertyPath(rawNode, ["debug-name", "value"]); const debugName = typeof debugNameNode === "string" ? debugNameNode : undefined; const displayName = debugName ?? specificDisplayName ?? properties?.get("name") ?? nodeTag ?? ""; - // Build the converted node const convertedNode = { name: displayName, icon: renderingConfig.icon, @@ -316,38 +1315,726 @@ function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionS expandedByDefault: nodeType != "operator" && expandedChildren.length == 0, } as TreeNode; - // Highlight the node which errored out, in case the query failed - const errored = conversionState.metadata.has("Error") && tryGetPropertyPath(rawNode, ["statistics", "running"]) === true; + // Raising operator carries `error`; the executing one is flagged `running: true` (often different nodes). + const errorMessage = getErrorMessage(rawNode); + if (errorMessage !== undefined) { + properties.set("error", errorMessage); + convertedNode.iconColor = "red"; + convertedNode.errorMessage = errorMessage; + } + const errored = conversionState.metadata.has("Error") && getStatistic(rawNode, "running") === true; if (errored) { convertedNode.iconColor = "red"; } - // Information on the execution time - const execTime = tryGetPropertyPath(rawNode, ["statistics", "cpu-cycles"]); + const execTime = getStatistic(rawNode, "cpu-cycles"); if (typeof execTime === "number") { conversionState.runtimes.push({node: convertedNode, time: execTime}); + // Keep the raw figure on the node for the panel's "Top operators by CPU" list. + convertedNode.cpuTime = execTime; + // Surfaces measured CPU cycles on the node so it's visible without expanding the collapsed "statistics" subtree. + properties.set("cpu-cycles", formatMetric(execTime)); } - // Display the cardinality on the links between the nodes - const internalEstimate = rawNode["estimated-rows"]; - const externalEstimate = tryGetPropertyPath(rawNode, ["statistics", "estimated-rows"]); - const estimatedCard = typeof internalEstimate === "number" ? internalEstimate : externalEstimate; - if (typeof estimatedCard === "number") { - const actualCard = tryGetPropertyPath(rawNode, ["statistics", "output-rows"]); + // Surfaces peak memory like cpu-cycles: record for the hotspot pass, keep raw figure, set readable row. Runtime plans only. + const memoryBytes = getStatistic(rawNode, "memory-bytes"); + if (typeof memoryBytes === "number") { + conversionState.memories.push({node: convertedNode, bytes: memoryBytes}); + convertedNode.memoryBytes = memoryBytes; + properties.set("memory-bytes", formatBytes(memoryBytes)); + } + + // Scan operators emit their own edge below; skip the generic cardinality block here to avoid a double push. + const isScanOperator = nodeType == "operator" && nodeTag !== undefined && SCAN_OPERATORS.has(nodeTag.replace(/-/g, "")); + + // Renamed in the FORMAT JSON rework: `cardinality`→`estimated-rows`, `analyze.tuple-count`→`statistics.output-rows`. + const estimatedCardRaw = getEstimatedRows(rawNode); + if (typeof estimatedCardRaw === "number" && !isScanOperator) { + const actualCard = getActualRows(rawNode); if (typeof actualCard === "number") { - conversionState.edgeWidths.push({node: convertedNode, width: actualCard}); - convertedNode.edgeLabel = formatMetric(actualCard) + "/" + formatMetric(estimatedCard); - // Highlight significant differences between planned and actual rows - if (estimatedCard > actualCard * 10 || actualCard > estimatedCard * 10) { - convertedNode.edgeClass = "qg-label-highlighted"; - } + setCardinalityEdge(convertedNode, conversionState, estimatedCardRaw, actualCard, false); } else { - conversionState.edgeWidths.push({node: convertedNode, width: estimatedCard}); - convertedNode.edgeLabel = formatMetric(estimatedCard); + conversionState.edgeWidths.push({node: convertedNode, width: estimatedCardRaw}); + convertedNode.edgeLabel = formatMetric(estimatedCardRaw); + } + } + + // Surfaces key scan stats without expanding "statistics" (`getStatistic` also reads legacy "analyze"). + if (isScanOperator) { + // Scan source type: the generic `scan` carries it in `type`, older operators in the tag. + const rawScanType = rawNode["type"]; + const scanTypeField = nodeTag === "scan" ? (typeof rawScanType === "string" ? rawScanType : undefined) : nodeTag; + if (typeof scanTypeField === "string" && scanTypeField.length > 0) { + convertedNode.scanType = scanTypeField; + } + // `est-rows` is the top-level estimate (`estimated-rows`, formerly `cardinality`), not in statistics. + const estRows = estimatedCardRaw; + if (typeof estRows === "number") { + setFormattedEstimatedRows(properties, estRows); + } + // Surfaces measured output, matching the edge's fallback to `output-rows` when there's no `rows-matching-restrictions`. + const scanOutputRows = getActualRows(rawNode); + if (typeof scanOutputRows === "number") { + properties.set("output-rows", formatMetric(scanOutputRows)); + } + const scanStatMetrics: [string, string][] = [ + ["processed-rows", "processed-rows"], + ["rows-matching-restrictions", "rows-matching"], + ]; + for (const [jsonKey, label] of scanStatMetrics) { + const value = getStatistic(rawNode, jsonKey); + if (typeof value === "number") { + properties.set(label, formatMetric(value)); + } + } + // Low selectivity (processed >> matching) is the signal Hyper's index recommender keys off. + const processedRows = getStatistic(rawNode, "processed-rows"); + const rowsMatching = getStatistic(rawNode, "rows-matching-restrictions"); + if (typeof rowsMatching === "number" && typeof estRows === "number") { + // `isScan` = true selects the "matched the restrictions" wording; rows-matching is the scan's actual output. + setCardinalityEdge(convertedNode, conversionState, estRows, rowsMatching, true); + } else if (typeof estRows === "number") { + // Legacy `analyze` plan without `rows-matching-restrictions`: use measured output; `isScan` = false. + const actualCard = getActualRows(rawNode); + if (typeof actualCard === "number") { + setCardinalityEdge(convertedNode, conversionState, estRows, actualCard, false); + } else { + conversionState.edgeWidths.push({node: convertedNode, width: estRows}); + convertedNode.edgeLabel = formatMetric(estRows); + } + } + // Record raw signals only; verdicts/highlights are baked later by `deriveNodeDisplay`. + if (typeof processedRows === "number") { + // Remember the raw scan volume so plan-insights can total it and rank scans in the "top offenders" list. + convertedNode.scanProcessedRows = processedRows; + + // Collect it for the plan-wide processed-rows total, which shades costly scans. + conversionState.scanProcessed.push({node: convertedNode, processed: processedRows}); + } + if (typeof rowsMatching === "number") { + convertedNode.scanRowsMatching = rowsMatching; + } + // 0 processed rows + `early-probes` ⇒ likely probe-skipped; require estRows > 0, else a zero estimate is expected. + const earlyProbes = tryGetPropertyPath(rawNode, ["early-probes"]); + const hasEarlyProbe = Array.isArray(earlyProbes) && earlyProbes.length > 0; + if (processedRows === 0 && hasEarlyProbe && typeof estRows === "number" && estRows > 0) { + properties.set("processed-rows", `${formatMetric(0)} (likely early probe)`); + } + + // `index-recommendation-candidate` appears only when flagged; `should-recommend-candidate` is its build verdict. + const idxRecColumn = tryGetPropertyPath(rawNode, ["index-recommendation-candidate", "column"]); + if (typeof idxRecColumn === "string") { + const shouldRecommend = tryGetPropertyPath(rawNode, [ + "statistics", + "index-recommender", + "should-recommend-candidate", + ]); + const suffix = shouldRecommend === true ? " (recommended)" : shouldRecommend === false ? " (not recommended)" : ""; + properties.set("index-rec", idxRecColumn + suffix); + // Record membership regardless of which highlight color wins below (a costly scan may also carry a rec). + convertedNode.hasIndexRec = true; + // `baseHighlight` is an intermediate; final `highlightNode` is picked by precedence below. + const verdict = + shouldRecommend === true + ? " Hyper recommends building it." + : shouldRecommend === false + ? " Hyper does not recommend building it." + : ""; + convertedNode.baseHighlight = "index-rec"; + convertedNode.baseHighlightReason = `Index-recommendation candidate on column "${idxRecColumn}".${verdict}`; + // Color follows precedence (costly scan outranks the rec, shown via `qg-node-index-rec-border`). + } + + // `used-index` present only when an index was used; `available-indexes` counts existing ones. + const usedIndexName = tryGetPropertyPath(rawNode, ["used-index", "name"]); + const availableIndexes = tryGetPropertyPath(rawNode, ["available-indexes"]); + if (typeof availableIndexes === "number") { + // Delete the generic-loop copy and re-set so it appears once as a count, not as an index named "3". + properties.delete("available-indexes"); + properties.set("available-indexes", formatMetric(availableIndexes)); + } + if (typeof usedIndexName === "string") { + const covered = tryGetPropertyPath(rawNode, ["used-index", "covered"]); + const suffix = covered === true ? " (covered)" : covered === false ? " (seek)" : ""; + properties.set("index-used", usedIndexName + suffix); + convertedNode.hasIndexUsed = true; + // Only claim baseHighlight if a higher-precedence base (index rec / costly scan) hasn't already. + if (convertedNode.baseHighlight === undefined) { + const how = covered === true ? "a covering scan" : covered === false ? "an index seek" : "an index"; + convertedNode.baseHighlight = "index-used"; + convertedNode.baseHighlightReason = `Used index "${usedIndexName}" (${how}).`; + } + } else if (typeof availableIndexes === "number" && availableIndexes > 0) { + properties.set("index-used", "no"); + } + + // Each `attributes` entry pairs an internal IU with its source column `name`. + const scanAttributes = rawNode["attributes"]; + if (Array.isArray(scanAttributes)) { + const cols = scanAttributes + .map((attr) => { + const name = tryGetPropertyPath(attr, ["name"]); + if (typeof name !== "string") return undefined; + const iuKey = iuName(tryGetPropertyPath(attr, ["iu"])); + // Prefer the recovered display name for one consistent name per IU (incl. set-op-propagated). + const display = iuKey !== undefined ? iuDisplayNames.get(iuKey) : undefined; + const base = display ?? name; + // Annotate `base → alias`; never replace the base name here. + const alias = iuKey !== undefined ? iuAliases.get(iuKey) : undefined; + return {name: alias !== undefined && alias !== base ? `${base} → ${alias}` : base, iu: iuKey}; + }) + .filter((c): c is {name: string; iu: string | undefined} => c !== undefined); + setColumnPreview("output columns", cols); + } + + // Iceberg/CDP-v2 lakehouse scans carry `table-metadata` (identifier cols, partitioning, sort order). + const tableMetadata = rawNode["table-metadata"]; + if (tableMetadata !== null && typeof tableMetadata === "object" && !Array.isArray(tableMetadata)) { + // Render a column transform: `identity` is bare, anything else wraps it (`bucket[16](Id__c)`). + const withTransform = (transform: Json | undefined, column: Json | undefined): string | undefined => { + if (typeof column !== "string") return undefined; + return typeof transform !== "string" || transform === "identity" ? column : `${transform}(${column})`; + }; + + // One `table-metadata` property of `label: value` lines; the QueryNode renderer splits on newlines. + const metaLines: string[] = []; + + const identifierFields = tableMetadata["identifier-fields"]; + if (Array.isArray(identifierFields) && identifierFields.length > 0) { + const cols = identifierFields + .map((f) => tryGetPropertyPath(f, ["column"])) + .filter((c): c is string => typeof c === "string"); + if (cols.length > 0) { + metaLines.push(`identifier: ${cols.join(", ")}`); + } + } + + const partitionTransforms = tableMetadata["partition-transforms"]; + if (Array.isArray(partitionTransforms) && partitionTransforms.length > 0) { + const parts = partitionTransforms + .map((p) => + withTransform(tryGetPropertyPath(p, ["transform"]), tryGetPropertyPath(p, ["source", "column"])), + ) + .filter((p): p is string => p !== undefined); + if (parts.length > 0) { + metaLines.push(`partitioned-by: ${parts.join(", ")}`); + } + } + + // Sort order shown verbatim as the plan spells direction/null-order, not reformatted. + const sortKeys = tryGetPropertyPath(tableMetadata, ["sort-order", "sort-keys"]); + if (Array.isArray(sortKeys) && sortKeys.length > 0) { + const keys = sortKeys + .map((k) => { + const col = withTransform( + tryGetPropertyPath(k, ["transform"]), + tryGetPropertyPath(k, ["source", "column"]), + ); + if (col === undefined) return undefined; + const dir = tryGetPropertyPath(k, ["direction"]); + const nulls = tryGetPropertyPath(k, ["null-order"]); + return [col, dir, nulls].filter((x): x is string => typeof x === "string").join(" "); + }) + .filter((k): k is string => k !== undefined); + if (keys.length > 0) { + metaLines.push(`sort-order: ${keys.join(", ")}`); + } + } + + if (metaLines.length > 0) { + properties.set("table-metadata", metaLines.join("\n")); + } + } + + reorderProperties(properties, [ + "table-name", + "output columns", + "index-rec", + "estimated-rows", + "processed-rows", + "rows-matching", + "output-rows", + "available-indexes", + "index-used", + "table-metadata", + ]); + } else if (nodeType == "operator") { + // Non-scan operators surface actual output alongside estimate (`rows-matching` excluded); reuses `estimatedCardRaw`. + const estRows = estimatedCardRaw; + if (typeof estRows === "number") { + setFormattedEstimatedRows(properties, estRows); + } + // `getActualRows` so old ANALYZE'd plans fall back to `analyze.tuple-count`, matching the edge. + const outputRows = getActualRows(rawNode); + if (typeof outputRows === "number") { + properties.set("output-rows", formatMetric(outputRows)); + } + + // Set when an operator block has its own lead, so the `output columns` fallback appends instead of fronting. + let hasSemanticLead = false; + + const sortedIndexedKeys = (prefix: string) => + [...properties.keys()] + .filter((k) => new RegExp(`^${prefix} \\d+$`).test(k)) + .sort((a, b) => Number(a.slice(prefix.length + 1)) - Number(b.slice(prefix.length + 1))); + + // Hyper names this operator `select` (legacy) or `filter` (newer); same shape, handled together. + if (nodeTag === "select" || nodeTag === "filter") { + renderConditionAndConjuncts(rawNode, properties); + // Selectivity = output/input rows (actuals, else "(est)"); child is `input` (legacy) or `inputs[0]` (newer). + const inputs = rawNode["inputs"]; + const input = rawNode["input"] ?? (Array.isArray(inputs) ? inputs[0] : null); + const inputActual = getActualRows(input); + const inputEst = getEstimatedRows(input); + let inputRows: number | undefined; + let filterOut: number | undefined; + let estimated = false; + if (typeof inputActual === "number" && typeof outputRows === "number") { + inputRows = inputActual; + filterOut = outputRows; + } else if (typeof inputEst === "number" && typeof estRows === "number") { + inputRows = inputEst; + filterOut = estRows; + estimated = true; + } + if (typeof inputRows === "number" && typeof filterOut === "number" && inputRows > 0) { + const pct = (filterOut / inputRows) * 100; + const pctStr = pct < 10 ? pct.toFixed(1) : pct.toFixed(0); + properties.set( + "selectivity", + `${pctStr}% pass (${formatMetric(filterOut)} of ${formatMetric(inputRows)})${estimated ? " (est)" : ""}`, + ); + } + + reorderProperties(properties, [ + "condition", + "predicates", + ...sortedIndexedKeys("predicate"), + "estimated-rows", + "output-rows", + "selectivity", + ]); + hasSemanticLead = true; + } + + // `JOIN_OPERATORS` holds concatenated spellings; strip hyphens to match kebab-case. + if (nodeTag !== undefined && JOIN_OPERATORS.has(nodeTag.replace(/-/g, ""))) { + // Tags each predicate column by join side (left/right) via the two inputs' IUs; sets a module-level hook, cleared after rendering. + const rawInputs = rawNode["inputs"]; + const leftChild = Array.isArray(rawInputs) ? rawInputs[0] : (rawNode["left"] ?? rawNode["input"]); + const rightChild = Array.isArray(rawInputs) ? rawInputs[1] : rawNode["right"]; + const iusOf = (child: Json | undefined) => + new Set( + computeOutputIus(child) + .map((c) => c.iu) + .filter((iu): iu is string => iu !== undefined), + ); + const leftIus = iusOf(leftChild); + const rightIus = iusOf(rightChild); + iuSideTag = (iu) => (leftIus.has(iu) ? "L" : rightIus.has(iu) ? "R" : ""); + + renderConditionAndConjuncts(rawNode, properties); + // Clear the side-tag hook so no later predicate render (a filter, another node) is tagged. + iuSideTag = undefined; + + reorderProperties(properties, [ + "condition", + "predicates", + ...sortedIndexedKeys("predicate"), + "method", + "estimated-rows", + "output-rows", + ]); + hasSemanticLead = true; + } + + if (nodeTag === "group-by" || nodeTag === "groupby") { + // Surface what the group-by does: which columns it groups on ("group by") and what it aggregates. + // Each key wraps its expression under `expression.value` (newer) or `value` directly. + const keyExprs = rawNode["key-expressions"] ?? rawNode["keyExpressions"]; + const keyStrs: string[] = []; + if (Array.isArray(keyExprs)) { + for (const entry of keyExprs) { + const keyExpr = tryGetPropertyPath(entry, ["expression", "value"]) ?? tryGetPropertyPath(entry, ["value"]); + const s = stringifyExpression(keyExpr); + if (s !== undefined && s.length > 0) keyStrs.push(s); + } + } + if (keyStrs.length > 0) { + properties.set("group by", keyStrs.join(", ")); + } else if (Array.isArray(keyExprs) && keyExprs.length > 0) { + // Keys present but none rendered: don't mislabel as a keyless aggregate — point at the subtree. + properties.set("group by", `${keyExprs.length} key expression${keyExprs.length > 1 ? "s" : ""} (see subtree)`); + } else { + properties.set("group by", "(global aggregate — no keys)"); + } + + // Multiple grouping sets => ROLLUP/CUBE/GROUPING SETS: report the count. + const groupingSets = rawNode["grouping-sets"] ?? rawNode["groupingSets"]; + if (Array.isArray(groupingSets) && groupingSets.length > 1) { + properties.set("grouping sets", groupingSets.length.toString()); + } + + // Rendered as `alias = fn(arg)` via `formatAggregateCalls`; `window` renders the same with an `OVER (...)` suffix. + const aggregates = rawNode["aggregates"]; + const aggExprs = rawNode["agg-expressions"] ?? rawNode["aggExpressions"]; + const aggStrs = Array.isArray(aggregates) ? formatAggregateCalls(aggregates, aggExprs) : []; + if (aggStrs.length > 0) { + properties.set("aggregates", aggStrs.join(", ")); + } + + reorderProperties(properties, ["group by", "grouping sets", "aggregates", "estimated-rows", "output-rows"]); + hasSemanticLead = true; + } + + // Each `criterion` entry carries its key expression under `value`. + if (nodeTag === "sort") { + const criterion = rawNode["criterion"]; + const keyStrs: string[] = []; + if (Array.isArray(criterion)) { + for (const c of criterion) { + const keyStr = stringifyExpression(tryGetPropertyPath(c, ["value"])); + if (keyStr === undefined || keyStr.length === 0) continue; + const descending = tryGetPropertyPath(c, ["descending"]) === true; + // `null-first` (kebab) or legacy `nullFirst`; annotate only when present (real boolean). + const nullFirst = tryGetPropertyPath(c, ["null-first"]) ?? tryGetPropertyPath(c, ["nullFirst"]); + const dir = descending ? "desc" : "asc"; + const nulls = nullFirst === true ? " nulls-first" : nullFirst === false ? " nulls-last" : ""; + keyStrs.push(`${keyStr} ${dir}${nulls}`); + } + } + if (keyStrs.length > 0) { + properties.set("sort by", keyStrs.join(", ")); + } else if ((!Array.isArray(criterion) || criterion.length === 0) && rawNode["limit"] !== undefined) { + // Empty `criterion` + a `limit` is how Hyper encodes a bare LIMIT (no ORDER BY); relabel "sort" -> "limit" and swap the icon. + convertedNode.name = "limit"; + convertedNode.icon = "limit-symbol"; + } + reorderProperties(properties, ["sort by", "limit", "estimated-rows", "output-rows"]); + hasSemanticLead = true; + } + + // Each `map` `values` entry carries the output IU under `iu` and the expression under `value`. + if (nodeTag === "map") { + const values = rawNode["values"]; + const colStrs: string[] = []; + if (Array.isArray(values)) { + for (const v of values) { + const rawName = iuName(tryGetPropertyPath(v, ["iu"])); + // Prefer recovered alias, then base name, so `column N` matches `output columns`; else humanize the IU. + const name = + typeof rawName === "string" + ? (iuAliases.get(rawName) ?? iuDisplayNames.get(rawName) ?? humanizeIuName(rawName)) + : undefined; + const valueExpr = tryGetPropertyPath(v, ["value"]); + const exprStr = stringifyExpression(valueExpr); + if (name === undefined || exprStr === undefined || exprStr.length === 0) continue; + if (name === exprStr) { + // Pure rename/cast collapses to the column name; tag transparent cast/coalesce (`col (cast)`). + const opKind = + valueExpr === undefined + ? undefined + : tryToString(tryGetPropertyPath(valueExpr, ["expression"]))?.replace(/-/g, ""); + let marker = ""; + if (opKind === "cast") { + const castType = + valueExpr === undefined ? undefined : formatTypeName(tryGetPropertyPath(valueExpr, ["type"])); + marker = castType !== undefined ? ` (cast as ${castType})` : " (cast)"; + } else if (opKind === "coalesce") { + marker = " (coalesce)"; + } + colStrs.push(name + marker); + } else { + colStrs.push(`${name} = ${exprStr}`); + } + } + } + if (colStrs.length > 0) { + properties.set("computes", colStrs.length.toString()); + colStrs.forEach((s, i) => properties.set(`column ${i + 1}`, s)); + } + reorderProperties(properties, ["computes", ...sortedIndexedKeys("column"), "estimated-rows", "output-rows"]); + hasSemanticLead = true; + } + + // Each `window-infos` entry is a window aggregate or ranking/value function, surfaced as `alias = fn(arg) OVER (...)`. + if (nodeTag === "window") { + const windowInfos = rawNode["window-infos"] ?? rawNode["windowInfos"]; + // Frame bound: unbounded / current-row / an N-row offset (sign selects preceding vs following), matching SQL frame syntax. + const frameBound = (info: Json, modeKey: string, expKey: string): string | undefined => { + const mode = tryGetPropertyPath(info, [modeKey]); + if (mode === "unbounded-preceding" || mode === "unboundedPreceding") return "unbounded preceding"; + if (mode === "unbounded-following" || mode === "unboundedFollowing") return "unbounded following"; + if (mode === "current-row" || mode === "currentRow") return "current row"; + if (mode === "value") { + const v = tryGetPropertyPath(info, [expKey, "value", "value"]); + if (typeof v === "number") return v === 0 ? "current row" : v < 0 ? `${-v} preceding` : `${v} following`; + } + return undefined; + }; + const overClause = (info: Json): string => { + const parts: string[] = []; + const partitionBy = tryGetPropertyPath(info, ["partition-by"]) ?? tryGetPropertyPath(info, ["partitionBy"]); + if (Array.isArray(partitionBy) && partitionBy.length > 0) { + const cols = partitionBy + .map((p) => stringifyExpression(p)) + .filter((s): s is string => s !== undefined && s.length > 0); + if (cols.length > 0) parts.push(`partition by ${cols.join(", ")}`); + } + const orderBy = tryGetPropertyPath(info, ["frame-order-by"]) ?? tryGetPropertyPath(info, ["frameOrderBy"]); + if (Array.isArray(orderBy) && orderBy.length > 0) { + const keys = orderBy + .map((c) => { + const keyStr = stringifyExpression(tryGetPropertyPath(c, ["value"])); + if (keyStr === undefined || keyStr.length === 0) return undefined; + const descending = tryGetPropertyPath(c, ["descending"]) === true; + return `${keyStr} ${descending ? "desc" : "asc"}`; + }) + .filter((s): s is string => s !== undefined); + if (keys.length > 0) parts.push(`order by ${keys.join(", ")}`); + } + // Only report a frame when both bounds resolve; `exclude: no_others` is the default, so it's intentionally not shown. + const mode = + tryGetPropertyPath(info, ["rowsmode"]) === true + ? "rows" + : tryGetPropertyPath(info, ["rangemode"]) === true + ? "range" + : undefined; + if (mode !== undefined) { + const start = frameBound(info, "start-mode", "start-exp"); + const end = frameBound(info, "end-mode", "end-exp"); + if (start !== undefined && end !== undefined) parts.push(`${mode} between ${start} and ${end}`); + } + return ` OVER (${parts.join(" ")})`; + }; + const fnStrs: string[] = []; + if (Array.isArray(windowInfos)) { + for (const info of windowInfos) { + const over = overClause(info); + const operation = tryGetPropertyPath(info, ["operation"]); + if (operation === "aggregate") { + // A window aggregate encodes aggregates like a `group-by`, under nested `aggregation`, sharing this `OVER (…)`. + const aggregation = tryGetPropertyPath(info, ["aggregation"]); + const aggregates = + aggregation === undefined ? undefined : tryGetPropertyPath(aggregation, ["aggregates"]); + const aggExprs = + aggregation === undefined + ? undefined + : (tryGetPropertyPath(aggregation, ["agg-expressions"]) ?? + tryGetPropertyPath(aggregation, ["aggExpressions"])); + if (Array.isArray(aggregates)) { + fnStrs.push(...formatAggregateCalls(aggregates, aggExprs, over)); + } + } else if (typeof operation === "string") { + // Ranking/value fns (row_number, rank, lead, lag): `operation` is the fn name, `iu` its result column. + const iu = iuName(tryGetPropertyPath(info, ["iu"])); + const alias = iu !== undefined ? outputColumnName(iu) : undefined; + const call = `${operation}()${over}`; + fnStrs.push(alias !== undefined && alias.length > 0 ? `${alias} = ${call}` : call); + } + } + } + if (fnStrs.length > 0) { + properties.set("window functions", fnStrs.length.toString()); + fnStrs.forEach((s, i) => properties.set(`function ${i + 1}`, s)); + } + reorderProperties(properties, [ + "window functions", + ...sortedIndexedKeys("function"), + "estimated-rows", + "output-rows", + ]); + hasSemanticLead = true; + } + + // `explicitscan`/`temp` re-reads a shared temp result, re-projecting columns via `mapping` (`source` -> renamed `target` IU). + if (nodeTag !== undefined && (nodeTag.replace(/-/g, "") === "explicitscan" || nodeTag === "temp")) { + const mapping = rawNode["mapping"]; + if (Array.isArray(mapping)) { + const cols = mapping + .map((m) => { + // `name` = source column's display name (via `iuDisplayNames`); `iu` = renamed target IU downstream operators reference. + const name = stringifyExpression(tryGetPropertyPath(m, ["source"])); + if (name === undefined || name.length === 0) return undefined; + return {name, iu: iuName(tryGetPropertyPath(m, ["target"]))}; + }) + .filter((c): c is {name: string; iu: string | undefined} => c !== undefined); + setColumnPreview("output columns", cols); + } + reorderProperties(properties, ["output columns", "estimated-rows", "output-rows"]); + } + + // `execution-target` (plan root): final output columns; accept kebab and legacy concatenated spellings. + if (nodeTag === "execution-target" || nodeTag === "executiontarget") { + const outputNames = rawNode["output-names"] ?? rawNode["outputNames"]; + if (Array.isArray(outputNames)) { + // Pair each result name with its IU from the parallel `output` array for relevant-first preview ordering. + const output = rawNode["output"]; + const cols = outputNames + .map((name, i): OutputColumn | undefined => { + if (typeof name !== "string") return undefined; + const iu = Array.isArray(output) ? iuName(tryGetPropertyPath(output[i], ["iu"])) : undefined; + return {name, iu}; + }) + .filter((c): c is OutputColumn => c !== undefined); + setColumnPreview("output columns", cols); + } + reorderProperties(properties, ["output columns", "estimated-rows", "output-rows"]); + } + + // `udtablefunction` (e.g. `hybrid_search`) buries index/vector-DB/embedding metadata in the UDF arg; surfaced as properties below. + if (nodeTag === "udtablefunction") { + // Require real strings: `tryToString` yields the literal "undefined" for a missing field, showing `function: undefined`. + const fnName = rawNode["name"]; + if (typeof fnName === "string") { + properties.set("function", fnName); + } + const volatility = rawNode["volatility"]; + if (typeof volatility === "string") { + properties.set("volatility", volatility); + } + // The view/model the search targets (UDF's `tableref` argument), e.g. `..._index__dlm`. + const udfTableName = findUdfTableName(rawNode); + if (udfTableName !== undefined) { + properties.set("table-name", udfTableName); + } + // Physical source table(s) behind that view (the `..._chunk__dll` data-lake table); shown only when distinct from `table-name`. + const leafTables = findUdfLeafTables(rawNode).filter((t) => t !== udfTableName); + if (leafTables.length > 0) { + properties.set("source-table", leafTables.join(", ")); + } + + // Relevance-score columns the search projects; keyword + vector score both present is the authoritative "hybrid search" signal. + const scoreColumns = findUdfScoreColumns(rawNode); + if (scoreColumns.length > 0) { + properties.set("scores", scoreColumns.join(", ")); + } + const scoresHybrid = scoreColumns.includes("keyword") && scoreColumns.includes("vector"); + + // Declared here so the runtime-telemetry block below can read corpus size even if metadata parsing fails. + let totalRecords: string | undefined; + const meta = findUdfMetadataProperties(rawNode); + if (meta !== undefined) { + // Human-readable index name; total-records is the corpus size (context for row estimates). + const developerName = getUdfMetadataString(meta, "developer-name"); + if (developerName !== undefined) { + properties.set("index", developerName); + } + totalRecords = getUdfMetadataString(meta, "total-records"); + if (totalRecords !== undefined) { + const asNum = Number(totalRecords); + properties.set("total-records", Number.isFinite(asNum) ? formatMetric(asNum) : totalRecords); + } + const vectorDb = getUdfMetadataJsonField(meta, "vectorDbConnectionDetails", "vectorDBName"); + if (vectorDb !== undefined) { + properties.set("vector-db", vectorDb); + } + const indexType = getUdfMetadataJsonField(meta, "vectorAccessProperties", "indexType"); + if (indexType !== undefined) { + properties.set("vector-index", indexType); + } + const metricType = getUdfMetadataJsonField(meta, "vectorAccessProperties", "metricType"); + if (metricType !== undefined) { + properties.set("similarity-metric", metricType); + } + const embeddingModel = getUdfMetadataJsonField(meta, "embeddingModelDetails", "model"); + if (embeddingModel !== undefined) { + properties.set("embedding-model", embeddingModel); + } + const embeddingDim = getUdfMetadataJsonField(meta, "embeddingModelDetails", "dimension"); + if (embeddingDim !== undefined) { + properties.set("embedding-dim", embeddingDim); + } + // A keyword-index entry means a lexical (BM25-style) leg runs alongside the vector one — the "hybrid" signal. + const keywordIndex = getUdfMetadataString(meta, "keywordIndexConnectionDetails"); + if (keywordIndex !== undefined) { + properties.set("keyword-search", "yes"); + } + + // Only mark vector/hybrid search when a vector DB or embedding model is present. + if (vectorDb !== undefined || embeddingModel !== undefined) { + convertedNode.vectorSearch = { + function: properties.get("function"), + index: developerName, + vectorDb, + embeddingModel, + // Prefer authoritative score-column evidence; fall back to keyword-index metadata. + hybrid: scoresHybrid || keywordIndex !== undefined, + }; + } + } + + // Runtime telemetry lives on the `analyze`/`statistics` block, not the UDF arg metadata parsed above. + + // No processed/matching ratio here — `output-rows` IS the matched count; `total-records` is context, not a denominator. + const searchOutputRows = getActualRows(rawNode); + if (typeof searchOutputRows === "number") { + const totalRecordsNum = totalRecords !== undefined ? Number(totalRecords) : NaN; + const corpus = Number.isFinite(totalRecordsNum) ? ` (index holds ${formatMetric(totalRecordsNum)})` : ""; + properties.set("matched-records", `${formatMetric(searchOutputRows)} matched${corpus}`); + } + + // Other runtime telemetry; `cpu-cycles` is already surfaced earlier for every operator. + for (const {key, prop, format} of RUNTIME_METRIC_PROPS) { + const value = getStatistic(rawNode, key); + if (typeof value === "number") { + properties.set(prop, format(value)); + } + } + + // Drop low-signal raw properties: internal catalog index and source-text span. + properties.delete("function-id"); + properties.delete("sqlpos"); + + reorderProperties(properties, [ + "function", + "table-name", + "source-table", + "index", + "total-records", + "estimated-rows", + "output-rows", + "matched-records", + "vector-db", + "vector-index", + "similarity-metric", + "embedding-model", + "embedding-dim", + "keyword-search", + "scores", + "volatility", + "cpu-cycles", + "execution-time", + "memory-bytes", + "pipeline", + ]); + hasSemanticLead = true; + } + + // Operators with no column list of their own get an output schema derived bottom-up, shown used-first like scans. + if ( + !properties.has("output columns") && + nodeTag !== "execution-target" && + nodeTag !== "executiontarget" && + nodeTag !== "insert" + ) { + setColumnPreview("output columns", computeOutputIus(rawNode)); + // Front `output columns` only when the operator has no lead of its own, so a semantic lead stays visible. + if (!hasSemanticLead && properties.has("output columns")) { + reorderProperties(properties, ["output columns"]); + } + } + + // A set-op input's output schema is authoritatively the set op's columns — overwrite the bottom-up derivation to match. + const setOpCols = setOpInputColumns.get(rawNode); + if (setOpCols !== undefined && setOpCols.length > 0) { + columnLists.delete("output columns"); + // Re-resolve names from stored IUs now (after the alias flood filled `iuAliases`), else a later-aliased column mismatches. + const resolved = setOpCols.map((c) => (c.iu !== undefined ? {name: outputColumnName(c.iu), iu: c.iu} : c)); + setColumnPreview("output columns", resolved); } } - // Add to `operator-id` map if applicable. if (nodeType == "operator") { const operatorId = properties?.get("operator-id"); if (operatorId !== undefined) { @@ -355,7 +2042,6 @@ function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionS } } - // Add cross links if (renderingConfig.crosslinkSourceKey) { const sourceId = properties?.get(renderingConfig.crosslinkSourceKey); if (sourceId !== undefined) { @@ -366,14 +2052,42 @@ function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionS } } + // Keep `operator-id` last (bookkeeping, not semantics); delete+re-set moves it to the Map's end. + for (const key of ["operator-id", "operatorId"]) { + const value = properties.get(key); + if (value !== undefined) { + properties.delete(key); + properties.set(key, value); + } + } + + // Flag a projection emitting the same output name twice; carry names for insights and show a `duplicate-columns` row. + if (duplicateColumns.length > 0) { + convertedNode.duplicateColumns = duplicateColumns; + const dupPreview = formatColumnPreview(duplicateColumns)!; + if (duplicateColumns.length > COLUMN_PREVIEW_COUNT) columnLists.set("duplicate-columns", duplicateColumns); + // Insert right after `output columns` without disturbing other order. + const rebuilt = new Map(); + for (const [k, v] of properties) { + rebuilt.set(k, v); + if (k === "output columns") rebuilt.set("duplicate-columns", dupPreview); + } + properties.clear(); + for (const [k, v] of rebuilt) properties.set(k, v); + } + + // Carry full column lists (only set when a preview was truncated) so the UI can expand elided columns. + if (columnLists.size > 0) { + convertedNode.columnLists = columnLists; + } + return convertedNode; } else if (Array.isArray(rawNode)) { - // "Array" nodes const listOfObjects = [] as TreeNode[]; for (let index = 0; index < rawNode.length; ++index) { const value = rawNode[index]; const name = `${parentKey}.${index}`; - let innerNode = convertHyperNode(value, name, conversionState); + let innerNode = convertHyperNode(value, name, conversionState, parentOperator); if (Array.isArray(innerNode)) { innerNode = {children: innerNode}; } @@ -385,7 +2099,6 @@ function convertHyperNode(rawNode: Json, parentKey, conversionState: ConversionS throw new Error("Invalid Hyper query plan"); } -// Resolve all pending crosslinks function resolveCrosslinks(state: ConversionState): Crosslink[] { const crosslinks = [] as Crosslink[]; for (const link of state.crosslinks) { @@ -397,14 +2110,9 @@ function resolveCrosslinks(state: ConversionState): Crosslink[] { return crosslinks; } -// Sets the edge widths, relative to the number of output tuples -function colorRelativeExecutionTime(state: ConversionState) { - const totalTime = state.runtimes.reduce((p, v) => p + v.time, 0); - for (const op of state.runtimes) { - const relativeExecutionRatio = op.time / totalTime; - const l = (95 + (72 - 95) * relativeExecutionRatio).toFixed(3); - op.node.nodeColor = relativeExecutionRatio >= 0.05 ? `hsl(309, 84%, ${l}%)` : undefined; - } +// Total a numeric field across a list, so insights can size each operator's share against a threshold. +function sumBy(items: T[], pick: (item: T) => number): number { + return items.reduce((total, item) => total + pick(item), 0); } // Sets the edge widths, relative to the number of output tuples @@ -424,7 +2132,22 @@ interface RawPipeline { operatorIds: number[]; } -// Parse and validate the `pipelines` array of the plan. +// Propagates display names along `target`<-`source` links to a fixpoint, resolving rename chains; writes `iuDisplayNames`. +function propagateDisplayNames(links: {target: string; source: string}[]): void { + let changed = true; + let passes = 0; + while (changed && passes++ < links.length) { + changed = false; + for (const {target, source} of links) { + const sourceName = iuDisplayNames.get(source); + if (sourceName !== undefined && !iuDisplayNames.has(target)) { + iuDisplayNames.set(target, sourceName); + changed = true; + } + } + } +} + function parsePipelines(pipelinesJson: Json): RawPipeline[] { if (!Array.isArray(pipelinesJson)) { return []; @@ -441,15 +2164,14 @@ function parsePipelines(pipelinesJson: Json): RawPipeline[] { return pipelines; } -// Color the per-node bars, edges and icons for the merged execution pipelines in one pre-order DFS, coloring each pipeline on first appearance so colors track tree position, not pipeline ids. +// Colors pipeline bars/edges/icons in one pre-order DFS; each pipeline is colored on first sight so colors track tree position. function assignPipelineColors( root: TreeNode, operatorsById: Map, pipelines: RawPipeline[], crosslinks: Crosslink[], ): void { - // Resolve each pipeline to its tree nodes. `color` is filled lazily the first - // time the pipeline is seen during the walk (empty string = not yet seen). + // Resolve each pipeline to its tree nodes; `color` fills lazily on first sight (empty = not yet seen). interface ResolvedPipeline { id: number; nodes: TreeNode[]; @@ -461,41 +2183,23 @@ function assignPipelineColors( color: "", })); - // Record, per tree node, every pipeline it belongs to (kept local: the - // "pipeline" concept never leaks into the presentation model, which only - // ever sees colors). + // Per node, every pipeline it belongs to; kept local so "pipeline" never leaks into the presentation model, which sees only colors. const nodePipelines = new Map(); for (const p of resolved) { - for (const node of p.nodes) { - const list = nodePipelines.get(node) ?? []; - list.push(p); - nodePipelines.set(node, list); - } + for (const node of p.nodes) pushToList(nodePipelines, node, p); } - // A crosslink feeds data into its source like a child would (e.g. an explicit - // scan reading a shared operator, or a magic join reading its magic side), but - // it is not a tree child. Treat the crosslink target as an extra child so a - // reader still gets the below-bar for the pipeline it reads through the link. + // A crosslink feeds its source like a child but isn't a tree child; treat target as an extra child so the below-bar shows. const crosslinkChildren = new Map(); - for (const link of crosslinks) { - const list = crosslinkChildren.get(link.source) ?? []; - list.push(link.target); - crosslinkChildren.set(link.source, list); - } + for (const link of crosslinks) pushToList(crosslinkChildren, link.source, link.target); let nextColor = 0; const walk = (node: TreeNode, parent: TreeNode | undefined) => { const nodePs = nodePipelines.get(node); if (nodePs) { - // Color the pipelines appearing here for the first time. for (const p of nodePs) if (p.color === "") p.color = pipelineColor(nextColor++); - // Order segments left-to-right by the position of the first child - // that carries each pipeline, so the bars line up with the branches - // below. Ties (several pipelines entering through the same child, or - // pipelines with no child) keep their appearance order via the stable - // sort. + // Order segments by the first child carrying each pipeline so bars line up with branches below; ties keep order via stable sort. const childOrder = new Map(); const children = [...allChildren(node), ...(crosslinkChildren.get(node) ?? [])]; children.forEach((child, idx) => { @@ -506,8 +2210,7 @@ function assignPipelineColors( const ordered = (ps: ResolvedPipeline[]): ResolvedPipeline[] => [...ps].sort((a, b) => (childOrder.get(a.id) ?? Infinity) - (childOrder.get(b.id) ?? Infinity)); - // Outgoing (above): pipelines shared with the parent. The root has no - // parent, so it gets no bar above. + // Outgoing (above): pipelines shared with the parent; the root gets no bar above. let outgoing: ResolvedPipeline[] = []; if (parent) { const parentPs = nodePipelines.get(parent); @@ -517,14 +2220,11 @@ function assignPipelineColors( node.barsAbove = ordered(outgoing).map((p) => p.color); if (outgoing.length) node.edgeColors = node.barsAbove; - // Incoming (below): pipelines shared with an operator child. A leaf has - // no operator child, so it gets no bar below. + // Incoming (below): pipelines shared with an operator child; a leaf gets no bar below. const incoming = nodePs.filter((p) => childOrder.has(p.id)); node.barsBelow = ordered(incoming).map((p) => p.color); - // Tint the operator icon (and thereby the minimap) with the node's - // right-most pipeline color, unless already colored (e.g. the red - // error highlight, which takes precedence). + // Tint the icon (and minimap) with the right-most pipeline color, unless already colored (e.g. the red error highlight wins). if (!node.iconColor) { const all = ordered(nodePs); node.iconColor = all[all.length - 1].color; @@ -541,10 +2241,100 @@ function convertHyperPlan(node: Json, pipelines?: Json): TreeDescription { crosslinks: [], edgeWidths: [], runtimes: [], + memories: [], + scanProcessed: [], metadata: new Map(), } as ConversionState; - // Check if the query failed - const errorMsg = tryGetPropertyPath(node, ["statistics", "error", "message", "original"]); + // Pre-pass: recover internal-IU -> real-column-name map and the set of referenced IUs (so previews lead with used columns). Per plan. + iuDisplayNames = new Map(); + iuAliases = new Map(); + referencedIus = new Set(); + directRefsCache = new WeakMap>(); + outputIuCache = new WeakMap(); + setOpInputColumns = new WeakMap(); + const mappingLinks: {target: string; source: string}[] = []; + collectIuInfo(node, iuDisplayNames, referencedIus, mappingLinks); + // Propagates display names across `explicit-scan`/`temp` renames to a fixpoint, so a temp-of-a-temp chain resolves to the origin. + propagateDisplayNames(mappingLinks); + // Give each set-op `map` target (`setCastN = cast(col)`) its source column's real name instead of the opaque `setCast`. + const renameLinks: {target: string; source: string}[] = []; + collectMapRenameLinks(node, renameLinks); + propagateDisplayNames(renameLinks); + // Build the undirected "same logical column" graph; two flood passes below walk its components: base-name recovery, then alias fill. + const aliasLinks: {a: string; b: string}[] = []; + const aliasSeeds = new Map(); + const computedSources = new Map(); + collectAliasInfo(node, aliasLinks, aliasSeeds, computedSources); + const adjacency = new Map(); + const addEdge = (a: string, b: string) => { + pushToList(adjacency, a, b); + pushToList(adjacency, b, a); + }; + for (const {target, source} of mappingLinks) addEdge(target, source); + for (const {a, b} of aliasLinks) addEdge(a, b); + // A component contains only IUs provably the SAME logical column — edges are renames/passthroughs/set-op links, computations add none. + const components: string[][] = []; + const visited = new Set(); + for (const start of adjacency.keys()) { + if (visited.has(start)) continue; + const component: string[] = []; + const stack = [start]; + visited.add(start); + while (stack.length > 0) { + const cur = stack.pop() as string; + component.push(cur); + for (const nb of adjacency.get(cur) ?? []) { + if (!visited.has(nb)) { + visited.add(nb); + stack.push(nb); + } + } + } + components.push(component); + } + // Base-name recovery: adopt only if the component resolves to exactly ONE; `veto` blocks a computed member hiding disagreement. + for (const component of components) { + const baseNames = new Set(); + let veto = false; + for (const iu of component) { + const direct = iuDisplayNames.get(iu); + if (direct !== undefined) { + baseNames.add(direct); + continue; + } + const leaves = computedSources.get(iu); + if (leaves === undefined) continue; // a passthrough / set-op output IU still awaiting a name + // A single-source computed value (a cast) traces to its origin; anything else is a new column, not foldable into a neighbor. + const leafName = leaves.length === 1 ? iuDisplayNames.get(leaves[0]) : undefined; + if (leafName !== undefined) baseNames.add(leafName); + else veto = true; + } + if (veto || baseNames.size !== 1) continue; + const base = baseNames.values().next().value as string; + for (const iu of component) { + if (!iuDisplayNames.has(iu)) iuDisplayNames.set(iu, base); + } + } + // `propagateSetOpNames` pushes set-op names onto inputs; it fills `outputIuCache` with pre-propagation names, so discard after. + propagateSetOpNames(node); + outputIuCache = new WeakMap(); + // Alias flood-fill: walk each component from its aliased IU, recording aliases that differ from base name if seeds agree on one. + for (const component of components) { + const seededAliases = new Set(); + for (const iu of component) { + const a = aliasSeeds.get(iu); + if (a !== undefined) seededAliases.add(a); + } + if (seededAliases.size !== 1) continue; + const alias = seededAliases.values().next().value as string; + for (const iu of component) { + if (!iuAliases.has(iu) && iuDisplayNames.get(iu) !== alias) iuAliases.set(iu, alias); + } + } + // The runtime statistics block was renamed from `analyze` to `statistics` in the FORMAT JSON rework (W-22563058); read both. + const errorMsg = + tryGetPropertyPath(node, ["statistics", "error", "message", "original"]) ?? + tryGetPropertyPath(node, ["analyze", "error", "message", "original"]); if (errorMsg) { conversionState.metadata.set("Error", forceToString(errorMsg)); } @@ -553,29 +2343,39 @@ function convertHyperPlan(node: Json, pipelines?: Json): TreeDescription { if (Array.isArray(root)) { throw new Error("Invalid Hyper query plan"); } - colorRelativeExecutionTime(conversionState); + // Plan-wide totals the highlight heatmaps scale against; `deriveNodeDisplay` derives colors/tooltips from per-node signals plus these. + const planCpuTotal = sumBy(conversionState.runtimes, (v) => v.time); + const planMemoryTotal = sumBy(conversionState.memories, (v) => v.bytes); + const planProcessedTotal = sumBy(conversionState.scanProcessed, (v) => v.processed); setEdgeWidths(conversionState); const crosslinks = resolveCrosslinks(conversionState); if (pipelines !== undefined) { assignPipelineColors(root, conversionState.operatorsById, parsePipelines(pipelines), crosslinks); } - return {root, crosslinks, metadata: conversionState.metadata}; + // Packages highlight heuristics into an insights capability; opts the tree into the insights panel, keeps rendering db-agnostic. + const insights = createInsightsCapability(root, planCpuTotal, planProcessedTotal, planMemoryTotal); + // Initial highlight bake via the same code path a later slider edit takes, so load-time and re-highlighted state can't diverge. + insights.rehighlight({}); + return { + root, + crosslinks, + metadata: conversionState.metadata, + planSource: "hyper", + insights, + }; } function convertOptimizerSteps(node: Json): TreeDescription | undefined { - // Check if we have a top-level object with a single key "optimizersteps" containing an array if (typeof node !== "object" || Array.isArray(node) || node === null) return undefined; if (Object.getOwnPropertyNames(node).length != 1) return undefined; if (!node.hasOwnProperty("optimizersteps")) return undefined; const steps = node["optimizersteps"]; if (!Array.isArray(steps)) return undefined; - // Transform the optimizer steps const crosslinks: Crosslink[] = []; const children: TreeNode[] = []; const properties = new Map(); for (const step of steps) { - // Check that our step has two subproperties: "name" and "plan" if (typeof step !== "object" || Array.isArray(step) || step === null) return undefined; if (Object.getOwnPropertyNames(step).length != 2) return undefined; if (!step.hasOwnProperty("name")) return undefined; @@ -584,16 +2384,14 @@ function convertOptimizerSteps(node: Json): TreeDescription | undefined { const plan = step["plan"]; if (typeof name !== "string") return undefined; - // Add the child const {root: childRoot, crosslinks: newCrosslinks, metadata: newProperties} = convertHyperPlan(plan); crosslinks.push(...(newCrosslinks ?? [])); children.push({name: name, children: [childRoot]}); - for (const p of newProperties ?? new Map()) { - properties.set(p[0], p[1]); - } + for (const [k, v] of newProperties ?? []) properties.set(k, v); } const root = {name: "optimizersteps", children: children}; - return {root, crosslinks, metadata: properties}; + // Sub-plans carry baked highlights, but the stitched tree has no plan-wide totals, so thresholds aren't live-tunable (docs-only). + return {root, crosslinks, metadata: properties, planSource: "hyper", insights: staticInsightsCapability()}; } // Detect the `{tree, pipelines}` envelope emitted by `EXPLAIN (..., PIPELINES, ...)`. @@ -608,7 +2406,6 @@ function hasPipelineEnvelope(json: Json): json is JsonObject { ); } -// Loads a Hyper query plan export function loadHyperPlan(json: Json): TreeDescription { if (hasPipelineEnvelope(json)) { return convertHyperPlan(json["tree"], json["pipelines"]); @@ -617,16 +2414,13 @@ export function loadHyperPlan(json: Json): TreeDescription { } function tryStripPrefix(str, pre) { - if (str.startsWith(pre)) return str.substring(pre.length); - return str; + return str.startsWith(pre) ? str.substring(pre.length) : str; } -// Load a JSON tree from text export function loadHyperPlanFromText(graphString: string): TreeDescription { // Strip `plan` prefix if it exists. This is written by `sql_hyper` if output is forwarded using `\o` graphString = tryStripPrefix(graphString, "plan\n"); - // Parse the plan as JSON let json: Json; try { json = JSON.parse(graphString); diff --git a/query-graphs/src/loaders/json.ts b/query-graphs/src/loaders/json.ts index fa77298c..67be1fd9 100644 --- a/query-graphs/src/loaders/json.ts +++ b/query-graphs/src/loaders/json.ts @@ -49,7 +49,7 @@ function convertChildren(node: Json): TreeNode[] { // Load a JSON tree export function loadJson(json: Json): TreeDescription { const root = {name: "root", children: convertChildren(json)}; - return {root: root}; + return {root: root, planSource: "json"}; } // Load a JSON tree from text diff --git a/query-graphs/src/loaders/loader-utils.ts b/query-graphs/src/loaders/loader-utils.ts index ece7e07c..84a06b5e 100644 --- a/query-graphs/src/loaders/loader-utils.ts +++ b/query-graphs/src/loaders/loader-utils.ts @@ -84,3 +84,17 @@ export function formatMetric(x: number): string { } return x.toFixed(0) + sizes[idx]; } + +// Format a byte count with binary (1024-based) units. Memory figures read more naturally as +// KiB/MiB/GiB than as the decimal metric suffixes `formatMetric` uses for row/cycle counts (where +// e.g. a `memory-bytes` of 1,048,576 would misleadingly render as "1M" instead of "1.0 MiB"). +export function formatBytes(x: number): string { + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let idx = 0; + while (x >= 1024 && idx < units.length - 1) { + x /= 1024; + ++idx; + } + // Whole bytes show no decimal; scaled units keep one digit of precision (e.g. "1.5 MiB"). + return (idx === 0 ? x.toFixed(0) : x.toFixed(1)) + " " + units[idx]; +} diff --git a/query-graphs/src/loaders/postgres.ts b/query-graphs/src/loaders/postgres.ts index 313fcb62..979d0ce5 100644 --- a/query-graphs/src/loaders/postgres.ts +++ b/query-graphs/src/loaders/postgres.ts @@ -336,7 +336,7 @@ export function loadPostgresPlan(json: Json): TreeDescription { colorRelativeExecutionTime(root); setEdgeWidths(conversionState); const crosslinks = resolveCrosslinks(conversionState); - return {root: root, crosslinks: crosslinks}; + return {root: root, planSource: "postgres", crosslinks: crosslinks}; } // Load a JSON tree from text diff --git a/query-graphs/src/loaders/tableau.ts b/query-graphs/src/loaders/tableau.ts index ff27dc26..189ee8c0 100644 --- a/query-graphs/src/loaders/tableau.ts +++ b/query-graphs/src/loaders/tableau.ts @@ -160,5 +160,5 @@ function convertXML(xml: ParsedXML): TreeNode { export function loadTableauPlan(graphString: string): TreeDescription { const xml = typesafeXMLParse(graphString); const root = convertXML(xml); - return {root: root, crosslinks: undefined}; + return {root: root, planSource: "tableau", crosslinks: undefined}; } diff --git a/query-graphs/src/loaders/xml.ts b/query-graphs/src/loaders/xml.ts index 8b983d16..13c10b63 100644 --- a/query-graphs/src/loaders/xml.ts +++ b/query-graphs/src/loaders/xml.ts @@ -84,5 +84,5 @@ function convertXML(xml: ParsedXML): TreeNode { export function loadXml(graphString: string): TreeDescription { const xml = typesafeXMLParse(graphString); - return {root: convertXML(xml)}; + return {root: convertXML(xml), planSource: "xml"}; } diff --git a/query-graphs/src/tree-description.ts b/query-graphs/src/tree-description.ts index 0b6766b2..44df5d0f 100644 --- a/query-graphs/src/tree-description.ts +++ b/query-graphs/src/tree-description.ts @@ -3,6 +3,7 @@ export type IconName = | "filter-symbol" | "groupby-symbol" | "sort-symbol" + | "limit-symbol" | "inner-join-symbol" | "left-join-symbol" | "right-join-symbol" @@ -12,6 +13,24 @@ export type IconName = | "virtual-table-symbol" | "const-table-symbol"; +// A loader-supplied presentation hint for a single property row, keyed by property name in +// `TreeNode.propertyStyles`. Lets the renderer tint, shade, group, and annotate property rows without +// ever branching on database-specific property names or values — the loader (which owns that +// vocabulary) bakes the hint and the renderer applies whatever fields are present. Re-baked on tuning +// because some tints (costly / high-volume scan, CPU / memory heat) are threshold-dependent. +export interface PropertyStyle { + // Extra CSS class(es) for the row (an emphasis or heat tint). + className?: string; + // A literal CSS color for the row background (a baked proportional heat shade). + background?: string; + // Render the value as a header row followed by one indented sub-item per newline-separated + // `label: value` line, instead of a single value. + grouped?: boolean; + // A trailing substring of the value to render in its own "benign annotation" span, tinted apart + // from the rest of the value. + annotation?: string; +} + // Must be a `type` instead of the usual `interface`. // xyflow's `Node` requires NodeData to satisfy `Record` and // TypeScript only infers that implicit index signature for type aliases, not interfaces. @@ -28,6 +47,112 @@ export type TreeNode = { iconColor?: string; // Rendered in the tooltip properties?: Map; + // Full relevant-first column name lists for truncated column-preview properties (keyed by the same + // property name, e.g. `columns`, `outputs`). Present only when the preview was elided; lets the UI + // progressively reveal the hidden columns when the `... [n]` marker is clicked. + columnLists?: Map; + // Loader-supplied per-property presentation hints (keyed by property name; see `PropertyStyle`). + // Lets the renderer style property rows — emphasis/heat tints, heat-map backgrounds, grouped + // sub-lists, benign annotations — without knowing any database-specific property names. Baked by the + // loader's insights `rehighlight`; absent when the node has no styled rows. + propertyStyles?: Map; + // Colors the whole node based on its content so it can be spotted while collapsed, without + // expanding to see the properties. The category (chosen by precedence: costly-scan > index-rec > + // index-used) drives the highlight color, matching the corresponding property-row highlight. + // Baked by the loader; the rendering stage just displays it. + highlightNode?: "costly-scan" | "high-volume-scan" | "index-rec" | "index-used"; + // Human-readable explanation of why the node is highlighted (shown as a hover tooltip). + highlightReason?: string; + // Raw processed-rows count for a scan node (unformatted), used to total scan volume in the + // plan-insights summary and to rank the "top offenders" list. Undefined for non-scan nodes / + // plans without runtime statistics. + scanProcessedRows?: number; + // Raw rows-matching-restrictions count for a scan node (unformatted). Paired with + // scanProcessedRows to compute the processed-to-matching ratio in the offenders list. + scanRowsMatching?: number; + // The scan's source type, used for the plan-insights "Scan types" breakdown. For the newer generic + // `scan` operator this is its `type` field (e.g. `data-lake-object`); for the older per-format + // operators it is the operator tag itself (`tablescan`, `icebergscan`, `parquetscan`, …). Only set + // on scan nodes. + scanType?: string; + // A stable per-operator id from the plan (e.g. Hyper's `operator-id`), used by the insights panel to + // disambiguate two same-named operators in its CPU / memory / error lists. Baked by the loader so the + // panel needn't read raw property names. + operatorId?: string; + // The scanned relation's display name (e.g. Hyper's `table-name`), used as the label for a scan in the + // insights "top offenders" list. Baked by the loader; falls back to `name` when absent. + scanTableName?: string; + // Marks a costly scan: one whose processed-rows dwarf rows-matching (low selectivity). + // Used to highlight the costly scan's processed-rows / rows-matching property rows in light red. + costlyScan?: boolean; + // Proportional red shade for a costly scan's node box, darker the larger this scan's share of all + // rows the plan's scans read (mirrors `nodeColor`'s runtime heatmap). Only set on costly scans. + // Applied as a CSS custom property so the expanded/hover "white" state can still override it. + costlyScanColor?: string; + // Proportional orange shade for a memory hotspot, the memory analog of `nodeColor`'s violet CPU + // heatmap: darker the larger this operator's share of the plan's total memory. Tints the node label + // (when not already CPU-tinted) and the `memory-bytes` property row. Only set on memory hotspots. + memoryColor?: string; + + // --- Per-operator metrics surfaced by the plan-insights panel --- + // Unformatted measured figures the panel totals and ranks. Populated by the loader (currently Hyper). + // + // Measured CPU cycles for this operator (drives the runtime "Top operators by CPU" list). + cpuTime?: number; + // Measured peak memory (bytes) for this operator (drives the "Top operators by memory" list). + memoryBytes?: number; + // The optimizer's row estimate and the measured actual for the incoming edge (cardinality + // misestimate). `cardIsScan` distinguishes a scan's rows-matching "actual" from a generic + // operator's measured output, so the baked edge reason reads correctly. + cardEstimate?: number; + cardActual?: number; + cardIsScan?: boolean; + // The node category the loader determined for an index recommendation / index used, and its reason. + // A loader-internal intermediate used while baking `highlightNode`/`highlightReason` above. + baseHighlight?: "index-rec" | "index-used"; + baseHighlightReason?: string; + // Category membership flags, independent of `highlightNode` (which can only show one color by + // precedence). A single scan can belong to several categories at once — e.g. a costly scan + // that also has an index recommendation — so the plan-insights legend counts/drills off these. + hasIndexRec?: boolean; + hasIndexUsed?: boolean; + + // Generic highlight-category membership, baked by the loader's insights capability: the stable + // category keys this node belongs to (the same keys the loader lists in `PlanInsights.rules`). A node + // can belong to several at once. The plan-insights panel counts and drills off this list, so it never + // needs to know the database's highlight taxonomy. Re-baked whenever the thresholds change. + insightCategories?: string[]; + // Generic "worth attention" flag, baked by the loader: true when the node is a highlighted issue (a + // costly / high-volume scan, index recommendation, duplicate-column node, CPU / memory hotspot, or a + // runtime error — but not a merely-informational used index). Drives focus-mode dimming (layout stage) + // and issue navigation (insights panel) without either encoding the taxonomy. Re-baked on tuning. + isIssue?: boolean; + + // Set on a `udtablefunction` node that performs a (hybrid / vector) search, e.g. Data Cloud's + // `hybrid_search`. Carries the key metadata surfaced by the Hyper loader so the plan-insights panel + // can call out the search node(s) and let the user jump to them. `hybrid` is true when the search + // also runs a keyword (lexical) retrieval leg alongside the vector one. + vectorSearch?: { + function?: string; + index?: string; + vectorDb?: string; + embeddingModel?: string; + hybrid?: boolean; + }; + + // Set when this operator carries a runtime error (a failed / analyzed plan records the error on the + // operator that raised it — often the `execution-target` root). Carries the one-line message (with the + // SQLSTATE code prefix) so the plan-insights panel can surface it as a severe error and let the user + // jump to the node. A query failure is the single most important thing to see, so it outranks every + // other insight. + errorMessage?: string; + + // Set when this node's rendered "output columns" list emits the same column name more than once + // (e.g. an execution-target projection that references the same IU twice). Carries the duplicated + // names, in first-seen order, so the UI can flag the node (warning border + tinted `duplicate-columns` + // row + hover reason) and the plan-insights panel can count and jump to it. Static per plan, not + // threshold-dependent. + duplicateColumns?: string[]; // Colors of a bar drawn just above the node // (conceptually the "outgoing" side, toward the parent). @@ -42,6 +167,8 @@ export type TreeNode = { edgeClass?: string; // Label placed on the incoming edge edgeLabel?: string; + // Explanation shown as a hover tooltip on the incoming edge label (e.g. why it is highlighted). + edgeReason?: string; // Width of the incoming edge edgeWidth?: number; // Colors of the incoming edge. Several colors are drawn as a gradient, @@ -61,13 +188,77 @@ export interface Crosslink { target: TreeNode; } +/// One adjustable numeric knob a loader exposes for live highlight tuning, described generically so the +/// rendering stage can draw an input for it without knowing what it means. `key` is opaque to the UI: it +/// is echoed back verbatim in the `rehighlight` values map. +export interface InsightsThreshold { + key: string; + label: string; + /// Current value (seeded with the loader's default; overridden as the user edits the slider). + value: number; + min: number; + step: number; + /// Suffix shown after the input (e.g. "×", "%"). Omitted for a plain row count. + unit?: string; +} + +/// One highlight category, as the insights panel sees it: its legend swatch, label, one-line +/// description, which threshold keys (if any) tune it, and where it surfaces. Loader-supplied so the +/// rendering stage carries no database-specific vocabulary — it counts nodes by matching `key` against +/// `TreeNode.insightCategories`, and draws the legend / summary / footer entirely from these fields. +export interface InsightsRule { + /// Stable category identity, echoed on the member nodes' `TreeNode.insightCategories`. Opaque to the + /// renderer (used only as a lookup handle / React key). + key: string; + label: string; + swatchClass: string; + description: string; + thresholdKeys: string[]; + /// True when this category appears in the panel's top legend as a countable, drill-into-able row. + /// Edge-level or list-only categories (e.g. a cardinality misestimate, a CPU hotspot) set this false + /// and show only in the "How highlighting works" footer. + legend: boolean; + /// Singular / plural nouns for the one-line summary header (e.g. "inefficient scan" / "inefficient + /// scans"). Present only for categories the loader wants counted in the summary; omitted → footer-only. + summary?: {singular: string; plural: string}; +} + +/// A loader-supplied plan-insights capability (see `TreeDescription.insights`). The loader owns all the +/// highlight logic and vocabulary; the renderer only draws `thresholds` as sliders and `rules` as the +/// footer legend, and calls `rehighlight` when the user tunes a knob. This keeps the rendering stage +/// database-agnostic while the highlights stay live-tunable. +export interface PlanInsights { + /// The adjustable thresholds, in display order. Empty if the loader bakes fixed highlights. + thresholds: InsightsThreshold[]; + /// The highlight categories, for the footer legend/documentation. + rules: InsightsRule[]; + /// Re-run the loader's highlight pass over the tree with new threshold values (keyed by + /// `InsightsThreshold.key`), mutating the baked `TreeNode` highlight fields in place. The renderer + /// calls this on a slider edit, then re-lays-out from the freshly-baked fields. + rehighlight: (values: Record) => void; +} + +/// Which loader produced this tree. Lets the UI make source-specific decisions if it ever needs to, +/// without inferring the source from an incidental feature flag. Loaders that don't identify +/// themselves leave it unset. NB: the rendering stage must stay database-agnostic — gate optional UI +/// (e.g. the plan-insights panel) on a generic capability flag like `hasInsights`, not on this. +export type PlanSource = "hyper" | "postgres" | "tableau" | "json" | "xml"; + export interface TreeDescription { /// The tree root root: TreeNode; + /// The loader that produced this tree (see `PlanSource`). + planSource?: PlanSource; /// Metadata about the graph; displayed in the top-level tree label metadata?: Map; /// Additional links between indirectly related nodes crosslinks?: Crosslink[]; + /// Set by a loader when the tree carries plan-insights data — highlighted issues (costly / high- + /// volume scans, index recommendations, duplicate columns) and per-operator scan / CPU / memory + /// metrics — for the insights panel to surface. A generic capability object (see `PlanInsights`): + /// the panel mounts on its presence and drives it entirely through this interface, keeping the + /// rendering stage database-agnostic rather than branching on `planSource`. + insights?: PlanInsights; } // A recursive helper function for walking through all nodes diff --git a/query-graphs/src/ui/ColoredEdge.tsx b/query-graphs/src/ui/ColoredEdge.tsx deleted file mode 100644 index 97fb6dd9..00000000 --- a/query-graphs/src/ui/ColoredEdge.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type {Edge, EdgeProps} from "@xyflow/react"; -import {BaseEdge, getBezierPath} from "@xyflow/react"; - -// Must be a `type` instead of the usual `interface`. -// xyflow's `Node` requires NodeData to satisfy `Record` and -// TypeScript only infers that implicit index signature for type aliases, not interfaces. -// Also see official docs at https://reactflow.dev/learn/advanced-use/typescript#custom-nodes -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type ColoredEdgeData = { - // The colors of this edge. More than one is drawn as a contiguous - // color-band gradient (source -> target); a single color is a solid stroke. - colors?: string[]; -}; - -export type ColoredGraphEdge = Edge; - -// A tree edge that can carry multiple colors. When several colors are given the -// stroke is painted with a gradient of contiguous color bands (one per color, -// running source->target); a single color is drawn as a solid stroke. -export function ColoredEdge(props: EdgeProps) { - const {id, sourceX, sourceY, targetX, targetY, markerEnd, label, labelStyle, style} = props; - const [edgePath, labelX, labelY] = getBezierPath(props); - - const colors = props.data?.colors ?? []; - const multi = colors.length > 1; - - // Unique gradient id for this edge (only used in the multi-color case). - const gradientId = `qg-edge-grad-${id}`.replace(/[^a-zA-Z0-9_-]/g, "_"); - - if (multi) { - return ( - <> - - {/* Contiguous color bands along the edge (source -> target). */} - - {colors.flatMap((c, i) => [ - , - , - ])} - - - - - ); - } - - return ( - - ); -} diff --git a/query-graphs/src/ui/NodeIcon.tsx b/query-graphs/src/ui/NodeIcon.tsx index 43e5ed26..bf166ab8 100644 --- a/query-graphs/src/ui/NodeIcon.tsx +++ b/query-graphs/src/ui/NodeIcon.tsx @@ -59,6 +59,25 @@ function SortIcon(p: NodeIconProps) { ); } +function LimitIcon(p: NodeIconProps) { + // A bare LIMIT (no ORDER BY) keeps the top N rows and drops the rest. Two solid rows above a dashed + // cut line read as "kept"; two faded rows below read as "dropped" — distinct from both the sort arrow + // (this does no ordering) and the filter funnel (this truncates by position, not a predicate). + return ( + + + {/* kept rows (top N) */} + + + {/* the limit boundary */} + + {/* dropped rows (beyond N) */} + + + + ); +} + function FilterIcon(p: NodeIconProps) { return ( @@ -169,6 +188,7 @@ export function NodeIcon({icon, ...rest}: NodeIconProps) { "filter-symbol": FilterIcon, "groupby-symbol": GroupByIcon, "sort-symbol": SortIcon, + "limit-symbol": LimitIcon, "inner-join-symbol": InnerJoinIcon, "left-join-symbol": LeftJoinIcon, "right-join-symbol": RightJoinIcon, diff --git a/query-graphs/src/ui/PlanInsights.css b/query-graphs/src/ui/PlanInsights.css new file mode 100644 index 00000000..3112c014 --- /dev/null +++ b/query-graphs/src/ui/PlanInsights.css @@ -0,0 +1,545 @@ +.qg-insights { + background: white; + border: 1px solid hsl(0, 0%, 90%); + border-radius: 10px; + box-shadow: + 0 2px 6px hsl(0, 0%, 0%, 0.08), + 0 1px 2px hsl(0, 0%, 0%, 0.06); + font-size: 12px; + line-height: 1.4; + color: hsl(0, 0%, 20%); +} + +/* Centered summary pill on the top row. */ +.qg-insights-summary-panel { + padding: 9px 18px; +} +/* Tools box (legend + offenders + navigation) in the top-right corner. */ +.qg-insights-tools-panel { + padding: 10px 12px; + max-width: 24em; + max-height: 80vh; + overflow-y: auto; +} +/* When minimized, shrink to the header bar only. */ +.qg-insights-tools-panel.qg-insights-minimized { + padding: 6px 8px 6px 12px; +} + +/* Header row carrying the panel title and the minimize/expand toggle. */ +.qg-insights-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.qg-insights-tools-panel:not(.qg-insights-minimized) .qg-insights-tools-header { + margin-bottom: 8px; +} +.qg-insights-tools-title { + font-weight: 600; + color: hsl(0, 0%, 35%); +} +.qg-insights-minimize { + border: 1px solid hsl(0, 0%, 85%); + background: white; + border-radius: 5px; + width: 20px; + height: 20px; + line-height: 1; + padding: 0; + cursor: pointer; + color: hsl(0, 0%, 40%); + font-size: 15px; + display: flex; + align-items: center; + justify-content: center; + flex: none; +} +.qg-insights-minimize:hover { + background: hsl(0, 0%, 96%); + border-color: hsl(0, 0%, 75%); +} + +.qg-insights-summary { + font-weight: 600; + font-size: 17px; + letter-spacing: 0.01em; + white-space: nowrap; +} +.qg-insights-verdict-warn { + color: hsl(28, 80%, 45%); +} +.qg-insights-verdict-ok { + color: hsl(140, 45%, 38%); +} +/* A query failure is severe: a red, clickable banner that jumps to the failed operator. It replaces the + plain summary span with a
+
{title}
+ {rows.map((r, i) => ( + + ))} + {controls} +
+ ); +} + +// An overlay panel providing at-a-glance plan insights: a color legend, a one-line summary of +// the issues found, a "jump to next issue" navigator, and a focus toggle that dims all +// non-flagged nodes so the interesting ones stand out on large plans. +export function PlanInsights({treeDescription, nodeIdMapping}: PlanInsightsProps) { + const reactFlow = useReactFlow(); + + // The loader's insights capability: the highlight categories (for the footer legend) and the + // adjustable thresholds (rendered as sliders). The renderer stays database-agnostic — it never + // reads the meaning of a threshold key, only echoes it back through `setThreshold` / `rehighlight`. + const insights = treeDescription.insights; + + // Live threshold values from the store. Editing a slider updates the store; QueryGraph re-bakes the + // tree's highlight fields via `insights.rehighlight` before re-laying-out, and this walk re-runs + // (it depends on `highlightThresholds`) so the counts and ranked lists stay in sync. + const highlightThresholds = useGraphRenderingStore((s) => s.highlightThresholds); + const setThreshold = useGraphRenderingStore((s) => s.setThreshold); + const resetThresholds = useGraphRenderingStore((s) => s.resetThresholds); + // Look up a threshold descriptor by its opaque key, so a footer rule can render the inputs for the + // knobs it lists in `thresholdKeys`. + const thresholdByKey = useMemo(() => { + const m = new Map(); + for (const t of insights?.thresholds ?? []) m.set(t.key, t); + return m; + }, [insights]); + + // Walk the tree once, grouping node ids by highlight category and totaling the scan volume. Category + // membership is read from the generic `insightCategories` list the loader baked onto each node (the + // same category keys it lists in `insights.rules`), so the panel counts and drills without knowing + // the database's taxonomy. A node can belong to several categories at once — a costly scan may also + // carry an index recommendation and use an index — and they all count. `issueIds` collects the nodes + // the loader flagged as an actual issue (its baked `isIssue`), which drives the "Next issue" navigation. + const {byCategory, issueIds, totalProcessed, scans, searchNodes, scanTypes, cpus, totalCpu, mems, totalMemory, errors} = + useMemo(() => { + const byCategory: Record = {}; + const pushCategory = (key: string, id: string) => { + (byCategory[key] ??= []).push(id); + }; + const issueIds: string[] = []; + let totalProcessed = 0; + let totalCpu = 0; + let totalMemory = 0; + const scans: Offender[] = []; + const cpus: CpuOp[] = []; + const mems: MemoryOp[] = []; + const searchNodes: SearchNode[] = []; + const errors: PlanError[] = []; + // Group scan-node ids by their source type (`data-lake-object`, `tablescan`, …) for the + // "Scan types" breakdown; the count is the list length and the ids drive click-to-drill. + const scanTypeIds = new Map(); + visitTreeNodes( + treeDescription.root, + (n) => { + if (typeof n.scanProcessedRows === "number") { + totalProcessed += n.scanProcessedRows; + } + if (typeof n.cpuTime === "number") { + totalCpu += n.cpuTime; + } + if (typeof n.memoryBytes === "number") { + totalMemory += n.memoryBytes; + } + const id = nodeIdMapping.get(n); + if (id === undefined) return; + // Category membership + the generic issue flag are both loader-baked. Collecting the + // full tree (not just the visible top-N) keeps the legend counts and issue navigation + // complete. + for (const category of n.insightCategories ?? []) pushCategory(category, id); + if (n.isIssue) issueIds.push(id); + // Label an operator by its name, tagging the operator-id when present so two same-named + // operators stay distinguishable. Shared by the error / CPU / memory lists below. + const opId = n.operatorId; + const opLabel = opId ? `${n.name ?? "operator"} #${opId}` : (n.name ?? "operator"); + // A runtime error is the single most important finding: collect the errored operator(s) + // so the panel can call it out as a severe error and link straight to the node. + if (n.errorMessage) { + errors.push({id, label: opLabel, message: n.errorMessage}); + } + // Every operator with a measured CPU figure is a CPU-list candidate. `nodeColor` is the + // loader's baked runtime-hotspot verdict (violet tint), so a colored operator is "hot". + if (typeof n.cpuTime === "number") { + cpus.push({id, label: opLabel, cycles: n.cpuTime, hot: !!n.nodeColor}); + } + // Every operator with a measured peak-memory figure is a memory-list candidate. + // `memoryColor` is the loader's baked memory-hotspot verdict (orange tint). + if (typeof n.memoryBytes === "number") { + mems.push({id, label: opLabel, bytes: n.memoryBytes, hot: !!n.memoryColor}); + } + if (n.scanType) { + const ids = scanTypeIds.get(n.scanType); + if (ids) ids.push(id); + else scanTypeIds.set(n.scanType, [id]); + } + // Every scan with a measured processed-row volume is an offender candidate. + if (typeof n.scanProcessedRows === "number") { + const matching = n.scanRowsMatching; + scans.push({ + id, + label: n.scanTableName ?? n.name ?? "scan", + processed: n.scanProcessedRows, + matching, + costlyScan: !!n.costlyScan, + }); + } + // A vector / hybrid search node (Data Cloud `hybrid_search` etc.). + if (n.vectorSearch) { + const vs = n.vectorSearch; + // Prefer the index searched as the primary label; the vector DB + embedding model + // make the informative detail line. + const detailParts = [vs.vectorDb, vs.embeddingModel].filter((p): p is string => !!p); + searchNodes.push({ + id, + label: vs.index ?? vs.function ?? n.name ?? "search", + detail: detailParts.join(" · "), + hybrid: !!vs.hybrid, + }); + } + }, + allChildren, + ); + // Rank worst-first by raw processed volume — the rows Hyper actually had to read. The full + // sorted list is returned; the render shows the first few and reveals more on demand. + scans.sort((a, b) => b.processed - a.processed); + // Rank CPU / memory operators worst-first (cycles consumed / peak memory held). The full sorted + // lists are returned: the render shows the top-N, but the baked-hotspot flags/ids already cover + // every operator (which can extend past the top-N) for the summary and "Next issue" navigation. + cpus.sort((a, b) => b.cycles - a.cycles); + mems.sort((a, b) => b.bytes - a.bytes); + // Most-frequent type first; ties broken alphabetically for a stable order. + const scanTypes = [...scanTypeIds.entries()] + .map(([type, ids]) => ({type, ids})) + .sort((a, b) => b.ids.length - a.ids.length || a.type.localeCompare(b.type)); + return {byCategory, issueIds, totalProcessed, scans, searchNodes, scanTypes, cpus, totalCpu, mems, totalMemory, errors}; + // `highlightThresholds` is a dependency because the loader re-bakes the tree's highlight fields + // (via `insights.rehighlight` in QueryGraph) when a slider changes; re-running the walk keeps the + // category counts and issue list consistent with the freshly-baked node fields. exhaustive-deps + // can't see this (the walk reads the mutated nodes, not the values directly), so it's kept manually. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [treeDescription, nodeIdMapping, highlightThresholds]); + + // Node counts per highlight category, keyed by the loader's category keys. Missing keys read 0. + const counts: Record = {}; + for (const [key, ids] of Object.entries(byCategory)) counts[key] = ids.length; + + // Center a node in the viewport by react-flow id. + const centerOnNode = useCallback( + (id: string) => { + const target = reactFlow.getNode(id); + if (target) { + // The graph sets `nodeOrigin={[0.5, 0]}`, so a node's `position` anchor is its + // top-center: `position.x` is already the horizontal center (no +width/2), while + // `position.y` is the top edge, so add half the height to reach the vertical center. + const x = target.position.x; + const y = target.position.y + (target.measured?.height ?? target.height ?? 0) / 2; + reactFlow.setCenter(x, y, {zoom: 1, duration: 400}); + } + }, + [reactFlow], + ); + + // Each category keeps its own round-robin cursor, so repeatedly clicking a legend row (or the + // "Next issue" button) walks through that category's nodes one at a time. + const cursorsRef = useRef>({}); + const drillInto = useCallback( + (ids: string[], key: string) => { + if (ids.length === 0) return; + const next = ((cursorsRef.current[key] ?? -1) + 1) % ids.length; + cursorsRef.current[key] = next; + centerOnNode(ids[next]); + }, + [centerOnNode], + ); + + // "Jump to next issue" cycles through the nodes the loader flagged as an actual issue (its baked + // `isIssue`, collected into `issueIds` during the walk), centering each in the viewport. Which nodes + // count is loader policy — a used index, for instance, is informational, so it is counted in the + // legend but excluded from `isIssue` and thus from this navigation. `issueIds` already has one entry + // per node (the walk visits each once), so no further deduplication is needed. + const issues = issueIds; + const [cursor, setCursor] = useState(-1); + // Reset every navigation cursor when a different plan is loaded, so drill-down and "next issue" + // start fresh instead of resuming at a position that referred to the previous plan's node list. + // The `cursor` state uses React's render-phase "adjust state when a prop changes" pattern (tracking + // the previous plan) rather than a `setState` inside an effect; the `cursorsRef` map is a plain ref, + // so it is cleared in an effect (refs must not be written during render). + const [prevTree, setPrevTree] = useState(treeDescription); + // How many rows each "Top …" list currently reveals. Starts compact; the trailing "…" grows it. + const [scansShown, setScansShown] = useState(INITIAL_ROWS); + const [cpuShown, setCpuShown] = useState(INITIAL_ROWS); + const [memShown, setMemShown] = useState(INITIAL_ROWS); + if (prevTree !== treeDescription) { + setPrevTree(treeDescription); + setCursor(-1); + // A new plan has different lists; collapse each back to the compact initial size. + setScansShown(INITIAL_ROWS); + setCpuShown(INITIAL_ROWS); + setMemShown(INITIAL_ROWS); + } + const offenders = scans.slice(0, scansShown); + const cpuOps = cpus.slice(0, cpuShown); + const memoryOps = mems.slice(0, memShown); + + // The "… N more / … N less" controls for a ranked list: reveal the next `ROWS_STEP` entries, or + // collapse back toward the compact `INITIAL_ROWS`. Rendered only for the directions that apply, so a + // list capped at its full length shows just "less", and one at the initial size shows just "more". + const revealControls = (shown: number, setShown: (u: (s: number) => number) => void, total: number) => { + const more = Math.min(ROWS_STEP, total - shown); + const less = Math.min(ROWS_STEP, shown - INITIAL_ROWS); + if (more <= 0 && less <= 0) return null; + return ( +
+ {more > 0 ? ( + + ) : null} + {less > 0 ? ( + + ) : null} +
+ ); + }; + useEffect(() => { + cursorsRef.current = {}; + }, [treeDescription]); + const jumpToNext = useCallback(() => { + if (issues.length === 0) return; + const next = (cursor + 1) % issues.length; + setCursor(next); + centerOnNode(issues[next]); + }, [issues, cursor, centerOnNode]); + + // Focus mode dims every non-flagged node so the flagged ones pop on a large plan. The dimming + // is applied during layout (see tree-layout.ts), driven by this store flag. + const focus = useGraphRenderingStore((s) => s.focusIssues); + const setFocusIssues = useGraphRenderingStore((s) => s.setFocusIssues); + const toggleFocus = useCallback(() => setFocusIssues(!focus), [focus, setFocusIssues]); + + // The rules footer documents what each highlight means; it starts collapsed to keep the panel compact. + const [rulesOpen, setRulesOpen] = useState(false); + + // The whole tools panel can be minimized to a compact header bar, to get it out of the way on + // small viewports or when the user just wants to see the graph. Starts expanded. + const [minimized, setMinimized] = useState(false); + + // Single header line: the actionable findings plus the total scan volume. Which categories are + // "actionable" (counted here) and how they read in prose is loader policy — the summary is built by + // walking the loader's rules and taking each that declares `summary` nouns, in the loader's order. + // A category without `summary` nouns (e.g. "index used", which is informational) is deliberately kept + // out of the header even though it still shows in the legend and node colors. + const summaryParts: string[] = []; + let totalIssues = 0; + for (const rule of insights?.rules ?? []) { + if (!rule.summary) continue; + const count = counts[rule.key] ?? 0; + if (count === 0) continue; + totalIssues += count; + summaryParts.push(`${count} ${count > 1 ? rule.summary.plural : rule.summary.singular}`); + } + if (totalProcessed > 0) summaryParts.push(`${formatMetric(totalProcessed)} rows processed`); + // A hybrid/vector search node is a notable plan characteristic (not an issue), so it is mentioned + // in the summary but does not flip the verdict to "warn". + if (searchNodes.length) { + // Hybrid and pure-vector searches can coexist in one plan; label each group by its own count + // rather than calling the whole set "hybrid" whenever a single node is (which mislabels the + // pure-vector ones). Uses "search"/"searches" per group so "1 hybrid search" reads correctly. + const hybridCount = searchNodes.filter((s) => s.hybrid).length; + const vectorCount = searchNodes.length - hybridCount; + const label = (n: number, kind: string) => `${n} ${kind} search${n > 1 ? "es" : ""}`; + if (hybridCount) summaryParts.push(label(hybridCount, "hybrid")); + if (vectorCount) summaryParts.push(label(vectorCount, "vector")); + } + const summary = summaryParts.length ? summaryParts.join(", ") : "No issues detected"; + + return ( + <> + {/* Summary sits on the top row, centered (same line as the title box). A query failure + outranks every other finding, so it replaces the summary with a clickable severe banner + that jumps to the failed operator. */} + + {errors.length > 0 ? ( + + ) : ( + + {summary} + + )} + + {/* Legend + navigation stay in the top-right corner. */} + +
+ Plan insights + +
+ {minimized ? null : ( + <> + {errors.length > 0 ? ( +
+
Query error{errors.length > 1 ? "s" : ""}
+ {errors.map((e) => ( + + ))} +
+ ) : null} + {/* The legend lists the loader's legend-level categories that occur in this plan + (a zero-count row is just noise). The rows, labels, swatches and order all come + from the loader's `insights.rules`, so the rendering stage carries no database + vocabulary — it counts by matching each rule's `key` against the baked + `insightCategories`. */} +
+ {(insights?.rules ?? []) + .filter((r) => r.legend && (counts[r.key] ?? 0) > 0) + .map((r) => ( + + ))} +
+ {scans.length > 0 ? ( + ({ + id: o.id, + label: o.label, + metric: formatMetric(o.processed), + hot: o.costlyScan, + title: + `Scan of ${o.label}: processed ${formatMetric(o.processed)} rows` + + (typeof o.matching === "number" + ? `, ${formatMetric(o.matching)} matched restrictions` + : "") + + ". Click to jump.", + }))} + /> + ) : null} + {cpus.length > 0 ? ( + { + const share = totalCpu > 0 ? c.cycles / totalCpu : 0; + return { + id: c.id, + label: c.label, + metric: formatMetric(c.cycles), + // The loader's baked runtime-hotspot verdict, so the list agrees + // with the violet node tint. + hot: c.hot, + title: + `${c.label}: used ${formatMetric(c.cycles)} CPU cycles` + + (totalCpu > 0 ? ` — ${Math.round(share * 100)}% of the plan's total runtime` : "") + + ". Click to jump.", + }; + })} + /> + ) : null} + {mems.length > 0 ? ( + { + const share = totalMemory > 0 ? m.bytes / totalMemory : 0; + return { + id: m.id, + label: m.label, + metric: formatBytes(m.bytes), + // The loader's baked memory-hotspot verdict, so the list agrees + // with the orange node tint. + hot: m.hot, + title: + `${m.label}: held ${formatBytes(m.bytes)}` + + (totalMemory > 0 ? ` — ${Math.round(share * 100)}% of the plan's peak memory` : "") + + ". Click to jump.", + }; + })} + /> + ) : null} + {scanTypes.length > 0 ? ( +
+
Scan types
+ {scanTypes.map((s) => ( + + ))} +
+ ) : null} + {searchNodes.length > 0 ? ( +
+
+ {searchNodes.some((s) => s.hybrid) ? "Hybrid / vector search" : "Vector search"} +
+ {searchNodes.map((s) => ( + + ))} +
+ ) : null} + {issues.length > 0 ? ( +
+ + +
+ ) : null} + {/* Footer: what each highlight means, and (for plans that expose adjustable knobs) + editable thresholds. The categories, copy, and threshold knobs all come from the + loader's `insights` capability — the rendering stage carries no database-specific + vocabulary, it just draws the rules and echoes threshold edits back through + `setThreshold` (QueryGraph then re-highlights via `insights.rehighlight`). Starts + collapsed to keep the panel compact. */} +
+ + {rulesOpen ? ( +
+ {(insights?.rules ?? []).map((rule) => { + // The knobs this category exposes, resolved from the threshold list. + const fields = rule.thresholdKeys + .map((k) => thresholdByKey.get(k)) + .filter((t): t is InsightsThreshold => t !== undefined); + return ( +
+
+ + {rule.label} +
+
{rule.description}
+ {fields.length > 0 ? ( +
+ {fields.map((f) => { + const value = highlightThresholds[f.key] ?? f.value; + return ( + + ); + })} +
+ ) : null} +
+ ); + })} + {(insights?.thresholds.length ?? 0) > 0 ? ( + + ) : null} +
+ ) : null} +
+ + )} +
+ + ); +} diff --git a/query-graphs/src/ui/QueryEdge.css b/query-graphs/src/ui/QueryEdge.css new file mode 100644 index 00000000..13419b12 --- /dev/null +++ b/query-graphs/src/ui/QueryEdge.css @@ -0,0 +1,38 @@ +/* The row-count label for a tree edge, rendered in the edge-label layer (portaled above the nodes so + * an expanded node never covers it). Replicates the look of react-flow's default SVG edge label: + * a small, semi-transparent white pill with a thin grey outline. `EdgeLabelRenderer` absolutely + * positions its children and its container carries the viewport transform, so the font scales with + * zoom just as the old SVG text did. */ +.qg-edge-label { + position: absolute; + /* The edge-label layer is `pointer-events: none`; the label is non-interactive by default and + * only opts back in when it carries a hover reason (see below). */ + pointer-events: none; + font-size: 9px; + line-height: 1; + padding: 1px 3px; + border-radius: 2px; + white-space: nowrap; + color: #222; + /* Solid (not semi-transparent) so the pill fully masks whatever it sits over — at a joining layer + * the labels can land on a node icon, its color bar, or an adjacent sibling label. A subtle shadow + * plus a positive z-index lift each pill clearly above node chrome and above any pill it overlaps, + * so the top one always stays legible even when two labels stack. */ + background: #fff; + border: 1px solid #777; + box-shadow: 0 1px 2px hsl(0, 0%, 0%, 0.25); + z-index: 1; +} + +/* Cardinality-misestimate (or otherwise flagged) label: matches the magenta the SVG label used. */ +.qg-edge-label-highlighted { + color: hsl(309, 84%, 36%); + border-color: hsl(309, 84%, 36%); +} + +/* Edges carrying a "why highlighted" reason: make the label feel hoverable and let it receive the + * pointer so its native `title` tooltip shows. */ +.qg-edge-label-has-reason { + cursor: help; + pointer-events: all; +} diff --git a/query-graphs/src/ui/QueryEdge.tsx b/query-graphs/src/ui/QueryEdge.tsx new file mode 100644 index 00000000..a9a2bca7 --- /dev/null +++ b/query-graphs/src/ui/QueryEdge.tsx @@ -0,0 +1,88 @@ +import type {Edge, EdgeProps} from "@xyflow/react"; +import {BaseEdge, EdgeLabelRenderer, getBezierPath} from "@xyflow/react"; +import cc from "classcat"; +import "./QueryEdge.css"; + +// Must be a `type` instead of the usual `interface`. +// xyflow's `Edge` requires EdgeData to satisfy `Record` and +// TypeScript only infers that implicit index signature for type aliases, not interfaces. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type QueryEdgeData = { + // The colors of this edge. More than one is drawn as a contiguous + // color-band gradient (source -> target); a single color is a solid stroke. + colors?: string[]; + // Explanation shown as a hover tooltip on the edge label (e.g. why it is highlighted). + edgeReason?: string; + // Whether the row-count label is highlighted (e.g. a cardinality misestimate). Carried through + // `data` rather than the edge's `className` because the label is rendered in the edge-label layer + // (portaled out of the edge's `` by `EdgeLabelRenderer`), so a descendant CSS selector on the + // edge wrapper can no longer reach it. + labelHighlighted?: boolean; +}; + +export type QueryGraphEdge = Edge; + +// A tree edge that can carry multiple colors (drawn as a source->target gradient of contiguous +// color bands; a single color is a solid stroke) and, when the edge carries a "why highlighted" +// reason, shows it as a native tooltip on hover. +// +// The row-count label is rendered through `EdgeLabelRenderer` instead of the SVG `EdgeText`: that +// layer is portaled ABOVE the nodes, so an expanded node never paints over the label (the edges SVG +// otherwise sits below the nodes layer). The edge *path* still draws below the nodes via `BaseEdge`. +export function QueryEdge(props: EdgeProps) { + const {id, sourceX, sourceY, targetX, targetY, markerEnd, label, style} = props; + const [edgePath, labelX, labelY] = getBezierPath(props); + + const colors = props.data?.colors ?? []; + const reason = props.data?.edgeReason; + const highlighted = props.data?.labelHighlighted; + const multi = colors.length > 1; + + // Unique gradient id for this edge (only used in the multi-color case). + const gradientId = `qg-edge-grad-${id}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + + const pathStyle = multi ? {...style, stroke: `url(#${gradientId})`} : {...style, stroke: colors[0] ?? style?.stroke}; + + return ( + <> + {multi && ( + + {/* Contiguous color bands along the edge (source -> target). */} + + {colors.flatMap((c, i) => [ + , + , + ])} + + + )} + + {label !== undefined && label !== null ? ( + +
+ {label} +
+
+ ) : null} + + ); +} diff --git a/query-graphs/src/ui/QueryGraph.css b/query-graphs/src/ui/QueryGraph.css index 864652f2..5f1410f6 100644 --- a/query-graphs/src/ui/QueryGraph.css +++ b/query-graphs/src/ui/QueryGraph.css @@ -14,19 +14,9 @@ cursor: default; } - & .react-flow__edge-textbg { - opacity: .7; - fill: white; - stroke: #777; - } - - & .react-flow__edge-text { - font-size: .6em; - } - - & .react-flow__edge.qg-label-highlighted .react-flow__edge-text { - fill: hsl(309, 84%, 36%); - } + /* NB: the edge row-count label is no longer react-flow's SVG `.react-flow__edge-text(bg)` — QueryEdge + renders it as a portaled `.qg-edge-label` div, styled in QueryEdge.css. The old SVG label rules that + used to live here have been removed as dead code. */ & .react-flow__edge.qg-crosslink { & .react-flow__edge-path { diff --git a/query-graphs/src/ui/QueryGraph.tsx b/query-graphs/src/ui/QueryGraph.tsx index c5a50f31..90157afe 100644 --- a/query-graphs/src/ui/QueryGraph.tsx +++ b/query-graphs/src/ui/QueryGraph.tsx @@ -8,7 +8,8 @@ import type {ReactNode} from "react"; import {useMemo, useEffect, useRef} from "react"; import {QueryNode} from "./QueryNode"; import type {QueryGraphNode} from "./QueryNode"; -import {ColoredEdge} from "./ColoredEdge"; +import {QueryEdge} from "./QueryEdge"; +import {PlanInsights} from "./PlanInsights"; import {useGraphRenderingStore} from "./store"; import "./QueryGraph.css"; @@ -18,7 +19,11 @@ interface QueryGraphProps { } function minimapNodeColor(n: QueryGraphNode): string { + // A costly scan's proportional red takes precedence so heavy scans are spottable in the minimap; + // otherwise fall back to the runtime-hotspot tint, then the memory-hotspot tint, then the icon color. + if (n.data.highlightNode === "costly-scan" && n.data.costlyScanColor) return n.data.costlyScanColor; if (n.data.nodeColor) return n.data.nodeColor; + if (n.data.memoryColor) return n.data.memoryColor; if (n.data.iconColor) return n.data.iconColor; return "hsl(0, 0%, 72%)"; } @@ -28,7 +33,7 @@ const nodeTypes = { }; const edgeTypes = { - colored: ColoredEdge, + queryedge: QueryEdge, }; function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { @@ -59,7 +64,14 @@ function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { }, allChildren, ); - initGraphStore(expandedSubtrees); + // Seed the adjustable highlight thresholds from the loader's insights capability, so the + // insights panel's sliders start at the loader's defaults and "Reset to defaults" can restore + // them. Loaders without live-tunable insights supply an empty threshold list. + const highlightThresholds: Record = {}; + for (const t of treeDescription.insights?.thresholds ?? []) { + highlightThresholds[t.key] = t.value; + } + initGraphStore(expandedSubtrees, highlightThresholds); }, [treeDescription, initGraphStore, nodeIdMapping]); // Create a ResizeObserver to keep track of the sizes of the nodes @@ -81,10 +93,33 @@ function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions); const expandedNodes = useGraphRenderingStore((s) => s.expandedNodes); const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees); - const layout = useMemo( - () => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedNodes, expandedSubtrees, resizeObserver), - [treeDescription, nodeIdMapping, nodeDimensions, expandedNodes, expandedSubtrees, resizeObserver], - ); + const focusIssues = useGraphRenderingStore((s) => s.focusIssues); + const highlightThresholds = useGraphRenderingStore((s) => s.highlightThresholds); + const layout = useMemo(() => { + // Re-bake the tree's highlight fields for the current threshold values before laying out. The + // loader owns this logic (exposed opaquely via `insights.rehighlight`), keeping the layout stage + // database-agnostic; at the loader's defaults it reproduces the initial bake exactly. Runs before + // PlanInsights (a child) walks the tree, so its counts stay in sync with the re-highlighted nodes. + treeDescription.insights?.rehighlight(highlightThresholds); + return layoutTree( + treeDescription, + nodeIdMapping, + nodeDimensions, + expandedNodes, + expandedSubtrees, + resizeObserver, + focusIssues, + ); + }, [ + treeDescription, + nodeIdMapping, + nodeDimensions, + expandedNodes, + expandedSubtrees, + resizeObserver, + focusIssues, + highlightThresholds, + ]); return ( {...Array.isArray(children) ? children : [children]} + {/* The insights overlay (summary header + legend/tools panel) surfaces the plan-insights + data a loader bakes into the tree. It's gated on the generic `insights` capability + rather than a specific plan source, keeping the rendering stage database-agnostic: any + loader that populates the highlight categories opts in by supplying the capability. + Loaders that don't (e.g. Postgres) leave it unset and get no panel instead of an + always-empty one. */} + {treeDescription.insights ? : null} diff --git a/query-graphs/src/ui/QueryNode.css b/query-graphs/src/ui/QueryNode.css index 75413326..db8c3f0e 100644 --- a/query-graphs/src/ui/QueryNode.css +++ b/query-graphs/src/ui/QueryNode.css @@ -10,6 +10,58 @@ cursor: default; } + /* Node-level color reflecting the node's content, visible even while collapsed. Each category's + * hue matches its corresponding property-row highlight: costly scan (red), index recommendation + * (amber), index used (blue — informational, not "all good"). Precedence is decided in the loader; + * only one class is applied. */ + &.qg-node-costly-scan { + /* Proportional red set per node via `--qg-costly-scan-color` (darker = read more rows); falls + * back to the flat light red when the loader didn't compute a shade. */ + background: var(--qg-costly-scan-color, hsl(0, 100%, 95%)); + border-color: hsl(0, 75%, 60%); + box-shadow: 0 0 0 1px hsl(0, 75%, 60%); + } + /* High-volume scan: indigo (hue 245) — a neutral "big, not necessarily wrong" hue distinct from the + * costly-scan red, the index-used blue (205), and the runtime-hotspot violet (265). Flat fill (no + * proportional shade); precedence sits below costly scan and above the index categories. */ + &.qg-node-high-volume-scan { + background: hsl(245, 80%, 96%); + border-color: hsl(245, 50%, 60%); + box-shadow: 0 0 0 1px hsl(245, 50%, 60%); + } + &.qg-node-index-rec { + background: hsl(50, 100%, 92%); + border-color: hsl(45, 90%, 50%); + box-shadow: 0 0 0 1px hsl(45, 90%, 50%); + } + &.qg-node-index-used { + background: hsl(205, 85%, 93%); + border-color: hsl(205, 70%, 50%); + box-shadow: 0 0 0 1px hsl(205, 70%, 50%); + } + /* A node whose fill shows another category (e.g. a costly scan) but which also carries an index + * recommendation: outline it in the index-rec amber so both signals are visible at once. Listed + * after the category rules so it wins the border/box-shadow. */ + &.qg-node-index-rec-border { + border-color: hsl(45, 90%, 50%); + box-shadow: 0 0 0 1px hsl(45, 90%, 50%); + } + /* Hybrid / vector search node: teal border matching the plan-insights legend accent (hsl(182)). It is + * a plan characteristic, not an issue, so only the border is tinted — no fill — and it doesn't compete + * with the costly-scan / index category colors. */ + &.qg-node-vector-search { + border-color: hsl(182, 60%, 42%); + box-shadow: 0 0 0 1px hsl(182, 60%, 42%); + } + /* A node whose output projects a duplicate column name: rose border (hue 340 — a warning hue used + * nowhere else in the palette, so it never reads as costly-scan red or index-rec amber). Only the + * border is tinted — no fill — so it can coexist with a category fill (a costly scan can also emit a + * duplicate name). Listed after the category rules so it wins the border/box-shadow when both apply. */ + &.qg-node-duplicate-columns { + border-color: hsl(340, 80%, 55%); + box-shadow: 0 0 0 1px hsl(340, 80%, 55%); + } + &.qg-collapsed:hover, &.qg-expanded { cursor: pointer; @@ -17,6 +69,16 @@ border-color: hsl(0,0%, 80%); box-shadow: 1px 2px 5px hsl(0,0%,90%); } + + /* A node that raised a runtime error (e.g. an OOM or cancellation the execution-target root records + * when the query fails). This is the single most severe node state, so it gets a bold red border that + * outranks every category fill/border above — and, unlike those, it is listed after the hover/expand + * rule so it persists even while the node is hovered or expanded: a failed query is the first thing + * the user must see. */ + &.qg-node-error { + border-color: hsl(0, 72%, 50%); + box-shadow: 0 0 0 2px hsl(0, 72%, 50%); + } } .query-graph .react-flow__handle.qg-subtree-handle { @@ -102,7 +164,9 @@ .qg-graph-node.qg-expanded & { max-width: 30em; - max-height: 20em; + /* Show up to ~20 property rows, then scroll. Kept in sync with `.qg-graph-node-body` below: + * 20 rows * 1.5em line-height = 30em. */ + max-height: 30em; } } .qg-graph-node-body { @@ -110,7 +174,11 @@ height: max-content; overflow: auto; max-width: 30em; - max-height: 20em; + /* Fix the row line-height so the height cap maps to a predictable line count: at 1.5em/row a 30em + * cap shows 20 rows before the body starts scrolling (the wrapper carries `.nowheel`, so scrolling + * here never pans the canvas). */ + line-height: 1.5em; + max-height: 30em; .qg-graph-node.qg-expanded & { padding-bottom: 0.6em; @@ -129,3 +197,82 @@ -ms-user-select: text; user-select: text; } + +/* An expanded long value (via the `more` toggle) wraps onto multiple lines and breaks long + * unbroken tokens, so the full text is readable instead of forcing a wide horizontal scroll. */ +.qg-prop-value-expanded { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +/* The clickable `... [n]` marker on a truncated column list. Reads as an inline link (no button + * chrome) so it sits naturally at the end of the column names; clicking reveals more columns. */ +.qg-prop-more { + font: inherit; + background: none; + border: none; + padding: 0; + margin: 0; + color: hsl(210, 70%, 45%); + cursor: pointer; + white-space: nowrap; +} +.qg-prop-more:hover { + text-decoration: underline; +} +.qg-prop-more:focus-visible { + outline: none; + box-shadow: 0 0 0 2px hsl(210, 90%, 80%); + border-radius: 3px; +} + +.qg-prop-emphasized { + background: hsl(50, 100%, 80%); + border-radius: 3px; +} + +/* Informational blue highlight for the `index-used` row when an index was actually used — + * a used index is worth noticing, not a guarantee the plan is optimal. */ +.qg-prop-index-used { + background: hsl(205, 85%, 82%); + border-radius: 3px; +} + +/* Light-red highlight for a costly scan's processed-rows / rows-matching property rows. */ +.qg-prop-costly-scan { + background: hsl(0, 100%, 88%); + border-radius: 3px; +} + +/* Indigo highlight for a high-volume scan's processed-rows row, matching the node highlight (hsl(245)). */ +.qg-prop-high-volume-scan { + background: hsl(245, 75%, 90%); + border-radius: 3px; +} + +/* Teal highlight for a hybrid/vector search node's `function` row, matching the plan-insights legend + * accent (hsl(182)) and the node's teal border. */ +.qg-prop-vector-search { + background: hsl(182, 50%, 85%); + border-radius: 3px; +} + +/* Rose highlight for the `duplicate-columns` row, matching the node's rose warning border (hsl(340)). */ +.qg-prop-duplicate-columns { + background: hsl(340, 85%, 90%); + border-radius: 3px; +} + +/* Grouped property (e.g. `table-metadata`): a header row whose sub-items are rendered as flush + * `- label: value` bullet rows below it (the "- " prefix is the only indentation cue; see QueryNode.tsx). + * The header carries no value, so its name reads as a section label. */ +.qg-prop-group-header .qg-prop-name { + color: hsl(0, 0%, 40%); +} + +/* Green highlight for the "(likely early probe)" annotation on a 0-row processed-rows value: + * a 0-row scan pruned by an early probe is benign (work saved), so it reads as good, not a problem. */ +.qg-prop-early-probe { + color: hsl(140, 65%, 32%); +} diff --git a/query-graphs/src/ui/QueryNode.tsx b/query-graphs/src/ui/QueryNode.tsx index 4849da8d..07d093a1 100644 --- a/query-graphs/src/ui/QueryNode.tsx +++ b/query-graphs/src/ui/QueryNode.tsx @@ -1,5 +1,5 @@ -import type {ReactElement, MouseEvent, RefObject} from "react"; -import {memo, useCallback, useRef, useEffect} from "react"; +import type {ReactElement, MouseEvent, RefObject, CSSProperties} from "react"; +import {memo, useCallback, useRef, useState, useEffect} from "react"; import type {Node, NodeProps} from "@xyflow/react"; import {Handle, Position} from "@xyflow/react"; import cc from "classcat"; @@ -13,6 +13,60 @@ type NodeData = TreeNode & {resizeObserver: ResizeObserver}; export type QueryGraphNode = Node; +// How many more columns each click of the `...` marker reveals. Matches the loader's initial preview +// count (COLUMN_PREVIEW_COUNT in hyper.ts) so the first render lines up with the static fallback string. +const COLUMN_PREVIEW_STEP = 2; + +// A column-list property (`columns`, `outputs`) whose full list was truncated by the loader. Shows the +// first `COLUMN_PREVIEW_STEP` names, then a clickable `... [remaining]` marker that reveals that many +// more per click and updates the remaining count, until every column is shown. The click is stopped +// from bubbling so revealing columns never toggles the node's expand/collapse. +function ColumnPreview({names}: {names: string[]}): ReactElement { + const [shown, setShown] = useState(COLUMN_PREVIEW_STEP); + const onMore = useCallback((e: MouseEvent) => { + e.stopPropagation(); + setShown((n) => n + COLUMN_PREVIEW_STEP); + }, []); + const remaining = names.length - shown; + return ( + + {names.slice(0, shown).join(", ")} + {remaining > 0 && ( + <> + {" "} + + + )} + + ); +} + +// A long property value (e.g. a rendered `CASE … END` expression or a big predicate) is collapsed to +// its first `VALUE_PREVIEW_CHARS` characters with a trailing clickable `more` marker; clicking toggles +// between the truncated and full text. Short values render verbatim with no marker. The click is +// stopped from bubbling so expanding a value never toggles the node's expand/collapse. +const VALUE_PREVIEW_CHARS = 60; +function ValuePreview({text}: {text: string}): ReactElement { + const [expanded, setExpanded] = useState(false); + const onToggle = useCallback((e: MouseEvent) => { + e.stopPropagation(); + setExpanded((v) => !v); + }, []); + if (text.length <= VALUE_PREVIEW_CHARS) { + return {text}; + } + return ( + + {expanded ? text : text.slice(0, VALUE_PREVIEW_CHARS).trimEnd() + "…"}{" "} + + + ); +} + function useResizeObservedRef(resizeObserver: ResizeObserver): RefObject { const ref = useRef(null); useEffect(() => { @@ -56,10 +110,61 @@ function QueryNode({data, id}: NodeProps) { ); const children = [] as ReactElement[]; - for (const [key, value] of (data.properties || []).entries()) { + for (const [key, value] of (data.properties ?? new Map()).entries()) { + // Every per-property presentation decision (grouping, emphasis/heat tints, heat-map backgrounds, + // benign annotations) is baked by the loader onto `propertyStyles`; the renderer stays agnostic to + // what each property means and just applies whatever style is present. See `deriveNodeDisplay`. + const style = data.propertyStyles?.get(key); + // A grouped property renders as a header row followed by one indented sub-item per newline- + // separated `label: value` line (the loader packs the sub-items). + if (style?.grouped) { + children.push( +
+ {key}: +
, + ); + value.split("\n").forEach((line, i) => { + const sep = line.indexOf(": "); + const subKey = sep >= 0 ? line.slice(0, sep) : line; + const subVal = sep >= 0 ? line.slice(sep + 2) : ""; + children.push( +
+ - {subKey}: {subVal} +
, + ); + }); + continue; + } + // A truncated column list: render an interactive preview whose `... [n]` marker reveals more + // columns on click, instead of the static fallback string in `value`. Any row tint (e.g. the + // duplicate-columns warning) comes from the loader-supplied style class. + const columnList = data.columnLists?.get(key); + if (columnList) { + children.push( +
+ {key}: +
, + ); + continue; + } + const rowClassName = cc(["qg-prop", style?.className]); + const rowStyle: CSSProperties | undefined = style?.background ? {background: style.background} : undefined; + // A benign trailing annotation (e.g. "(likely early probe)") is split into its own tinted span so + // only the annotation is highlighted, not the value it trails. Any other value goes through + // `ValuePreview`, which truncates + adds a `more`/`less` toggle when long (and renders verbatim otherwise). + const annotationIdx = style?.annotation ? value.indexOf(style.annotation) : -1; + const valueEl = + annotationIdx >= 0 ? ( + + {value.slice(0, annotationIdx)} + {value.slice(annotationIdx)} + + ) : ( + + ); children.push( -
- {key}: {value} +
+ {key}: {valueEl}
, ); } @@ -70,6 +175,27 @@ function QueryNode({data, id}: NodeProps) { "qg-expanded": expanded, "qg-collapsed": hasProperties && !expanded, "qg-no-props": !hasProperties, + // Node color reflects the node's content (precedence set in the loader): + // costly scan (red) > high-volume scan (indigo) > index recommendation (amber) > index used (blue). + "qg-node-costly-scan": data.highlightNode === "costly-scan", + "qg-node-high-volume-scan": data.highlightNode === "high-volume-scan", + "qg-node-index-rec": data.highlightNode === "index-rec", + "qg-node-index-used": data.highlightNode === "index-used", + // A node can be a costly scan (red fill) AND carry an index recommendation. The fill can + // only show one category, so surface the index-rec signal on the border in its amber hue + // when another category won the fill — otherwise the recommendation would be invisible. + "qg-node-index-rec-border": data.hasIndexRec && data.highlightNode !== "index-rec", + // A hybrid / vector search node gets a teal border matching the plan-insights legend, so it + // is identifiable in the graph. It's a characteristic, not an issue, so it only borders the + // node (no fill) and never competes with the red/amber/blue category fills above. + "qg-node-vector-search": !!data.vectorSearch, + // A node whose output projects a duplicate column name: rose warning border (no fill, so it + // never competes with the category fills), matching the plan-insights legend accent. + "qg-node-duplicate-columns": !!data.duplicateColumns?.length, + // A node that raised a runtime error (typically the execution-target root of a failed plan): + // bold red border. The most severe state, so it outranks every category above (see the CSS, + // where it is ordered to win the border and persist through hover/expand). + "qg-node-error": !!data.errorMessage, }, ]); @@ -89,14 +215,33 @@ function QueryNode({data, id}: NodeProps) { "qg-collapsed": hasSubtree && !subtreeExpanded, }); + // A costly scan's node box is tinted proportionally to how many rows it read (heavier scans read + // as a deeper red), via a CSS custom property the `.qg-node-costly-scan` rule consumes. Left unset + // for other nodes, so the hover/expanded "white" state can still take over. + const nodeStyle = + data.highlightNode === "costly-scan" && data.costlyScanColor + ? ({"--qg-costly-scan-color": data.costlyScanColor} as CSSProperties) + : undefined; + + // A runtime error is the most severe thing a node can carry, so lead the hover tooltip with it + // (⚠-prefixed), then append any other highlight reason below. + const title = data.errorMessage + ? data.highlightReason + ? `⚠ ${data.errorMessage}\n${data.highlightReason}` + : `⚠ ${data.errorMessage}` + : data.highlightReason; + return ( <> -
+
{colorBar(data.barsAbove, "above")} -
+ {/* The label carries the runtime-hotspot violet (nodeColor); if the node is a memory + hotspot but not a CPU one, it shows the memory-hotspot orange instead. When it is + both, CPU wins the label and the memory signal still shows on the memory-bytes row. */} +
{data.name}
diff --git a/query-graphs/src/ui/store.ts b/query-graphs/src/ui/store.ts index 2286da29..d6796c14 100644 --- a/query-graphs/src/ui/store.ts +++ b/query-graphs/src/ui/store.ts @@ -10,7 +10,7 @@ export interface NodeDimensions { } interface GraphRenderingState { - init: (expandedSubtrees: Record) => void; + init: (expandedSubtrees: Record, highlightThresholds: Record) => void; // `expandedNodes` tracks which nodes show their property detail panel (toggled by a plain click). expandedNodes: Record; toggleExpandedNode: (nodeId: string) => void; @@ -20,6 +20,17 @@ interface GraphRenderingState { // Measured on-screen head/body sizes, reported by a ResizeObserver and fed back into layout. nodeDimensions: Record; updateNodeDimensions: (entries: ResizeObserverEntry[]) => unknown; + // When true, non-flagged nodes are dimmed so highlighted issues stand out (focus mode). + focusIssues: boolean; + setFocusIssues: (focus: boolean) => void; + // Current values of the plan's adjustable highlight thresholds, keyed by the opaque threshold key the + // loader's insights capability supplies. Editing one re-highlights the graph without reloading the + // plan (see QueryGraph.tsx). `defaultHighlightThresholds` is the loader's seed, used by reset. Both + // are re-seeded from the loaded tree's `insights.thresholds` on `init`. + highlightThresholds: Record; + defaultHighlightThresholds: Record; + setThreshold: (key: string, value: number) => void; + resetThresholds: () => void; } export const useGraphRenderingStore = create()( @@ -28,13 +39,31 @@ export const useGraphRenderingStore = create()( expandedNodes: {}, expandedSubtrees: {}, nodeDimensions: {}, - init: (expandedSubtrees) => { + focusIssues: false, + highlightThresholds: {}, + defaultHighlightThresholds: {}, + init: (expandedSubtrees, highlightThresholds) => { set((state) => { state.expandedNodes = {}; state.expandedSubtrees = expandedSubtrees; state.nodeDimensions = {}; + state.focusIssues = false; + state.highlightThresholds = {...highlightThresholds}; + state.defaultHighlightThresholds = {...highlightThresholds}; }); }, + setFocusIssues: (focus) => + set((state) => { + state.focusIssues = focus; + }), + setThreshold: (key, value) => + set((state) => { + state.highlightThresholds[key] = value; + }), + resetThresholds: () => + set((state) => { + state.highlightThresholds = {...state.defaultHighlightThresholds}; + }), toggleExpandedNode: (nodeId) => set((state) => { state.expandedNodes[nodeId] = !get().expandedNodes[nodeId]; diff --git a/query-graphs/src/ui/tree-layout.ts b/query-graphs/src/ui/tree-layout.ts index ecff5de2..fc6e929f 100644 --- a/query-graphs/src/ui/tree-layout.ts +++ b/query-graphs/src/ui/tree-layout.ts @@ -6,12 +6,12 @@ import type * as treeDescription from "../tree-description"; import type {TreeNode, TreeDescription} from "../tree-description"; import type {Edge} from "@xyflow/react"; import type {QueryGraphNode} from "./QueryNode"; -import type {ColoredGraphEdge} from "./ColoredEdge"; +import type {QueryGraphEdge as ColoredQueryGraphEdge} from "./QueryEdge"; import {assertNotNull} from "../assert"; import type {CSSProperties} from "react"; // Crosslinks have no `type`/`data` of their own, so they stay plain `Edge`s. -type QueryGraphEdge = ColoredGraphEdge | Edge; +type QueryGraphEdge = ColoredQueryGraphEdge | Edge; interface TreeLayout { nodes: QueryGraphNode[]; @@ -29,6 +29,7 @@ export function layoutTree( expandedNodes: Record, expandedSubtrees: Record, resizeObserver: ResizeObserver, + focusIssues: boolean, ): TreeLayout { const root = d3hierarchy.hierarchy(treeData.root, (d) => { if (expandedSubtrees[nodeIds.get(d)!] && d.collapsedChildren) { @@ -37,8 +38,12 @@ export function layoutTree( return d.children; }); - // Layout the tree - const heighOffset = 60; + // Layout the tree. + // This offset is added to every node's layout height, so it becomes the vertical gap between one + // level and the next. A collapsed node is short (head only, no body), so with a small offset its + // edge to the level below is short and the row-count label gets crammed against the child. Keep the + // gap generous so collapsed levels read as roomily as expanded ones and the edge labels have space. + const heighOffset = 100; const treelayout = d3flextree .flextree() .nodeSize((d) => { @@ -67,14 +72,26 @@ export function layoutTree( const d3edges = layout.links(); // Transform tree representation from d3 into reactflow + // Track which nodes end up dimmed in focus mode, so the edges leading into them can be dimmed to + // match — otherwise a highlighted (e.g. cardinality-misestimate) edge would point at a greyed-out + // node and read as a broken focus view. + const dimmedNodeIds = new Set(); const nodes: QueryGraphNode[] = d3nodes.map((n) => { const id = nodeIds.get(n.data); assertNotNull(id); + const data = {...n.data, resizeObserver}; + // In focus mode, dim every node that is not a flagged issue so the issues pop. Which nodes count + // as issues is loader policy, baked generically onto `TreeNode.isIssue` (see `deriveNodeDisplay`): + // the layout stage stays database-agnostic and never names a highlight category. The same flag + // drives the insights panel's "Next issue" navigation, so focus mode and the panel agree. + const dimmed = focusIssues && !data.isIssue; + if (dimmed) dimmedNodeIds.add(id); return { id, position: {x: n.x, y: n.y}, type: "querynode", - data: {...n.data, resizeObserver}, + data, + className: dimmed ? "qg-node-dimmed" : undefined, }; }); const edges: QueryGraphEdge[] = d3edges.map((e) => { @@ -87,15 +104,25 @@ export function layoutTree( const width = Math.max(1, 10 * Math.min(1, e.target.data.edgeWidth)); style.strokeWidth = `${width}px`; } + // The edge highlight (cardinality misestimate / costly-scan) is baked by the loader. + const edgeClass = e.target.data.edgeClass; + const edgeReason = e.target.data.edgeReason; + // Dim an edge whenever its target node is dimmed, so focus mode fades the edge and the node + // it points at together instead of leaving a colored edge crossing into a greyed-out node. + const edgeDimmed = dimmedNodeIds.has(targetId); return { id: `${sourceId}->${targetId}`, source: sourceId, target: targetId, - type: "colored", + type: "queryedge", label: e.target.data.edgeLabel, - className: e.target.data.edgeClass, + className: [edgeClass, edgeDimmed ? "qg-edge-dimmed" : undefined].filter(Boolean).join(" ") || undefined, style: style, - data: {colors: e.target.data.edgeColors}, + // Carried through to the custom edge so it can draw the color gradient, highlight the + // row-count label, and show a "why highlighted" hover tooltip. `labelHighlighted` is + // passed via data (not just the edge className) because the label renders in the + // edge-label layer, out of reach of a descendant selector on the edge wrapper. + data: {colors: e.target.data.edgeColors, edgeReason, labelHighlighted: edgeClass === "qg-label-highlighted"}, focusable: false, }; }); @@ -116,11 +143,15 @@ export function layoutTree( const targetId = nodeIds.get(targetNode.data); assertNotNull(sourceId); assertNotNull(targetId); + // Dim the crosslink when either endpoint is dimmed, so focus mode never leaves a fully-opaque + // crosslink pointing at (or from) a greyed-out node — matching how tree edges dim with their + // target. + const linkDimmed = dimmedNodeIds.has(sourceId) || dimmedNodeIds.has(targetId); crosslinks.push({ id: `${sourceId}->${targetId}`, source: sourceId, target: targetId, - className: "qg-crosslink", + className: ["qg-crosslink", linkDimmed ? "qg-edge-dimmed" : undefined].filter(Boolean).join(" "), focusable: true, }); }