Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 139 additions & 37 deletions examples/explore/dag.html

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions features/explore_lineage_dag.feature
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,32 @@ Feature: explore's dag.html renders an interactive Cytoscape lineage with highli
Then the exit code is 0
And the lineage payload round-trips the name "evil</script><img src=x onerror=alert(1)>"
And dag.html carries no unescaped script-closing markup in the payload carrier

@no-baseline-usage-error
Scenario: A snapshot mid-chain renders as a typed node and keeps the lineage connected
Given the explore manifest declares the model "stg_patients"
And the explore manifest declares the snapshot "snp_patients" built from "stg_patients"
And the explore manifest declares the model "dim_patients"
And the explore model "dim_patients" depends on the snapshot "snp_patients"
When I run cute-dbt explore on the synthetic manifest
Then the exit code is 0
And the lineage payload types "snp_patients" as "snapshot"
And dag.html carries a lineage edge from "stg_patients" to "snp_patients"
And dag.html carries a lineage edge from "snp_patients" to "dim_patients"

@no-baseline-usage-error
Scenario: Seeds, sources and exposures render as typed lineage nodes
Given the explore manifest declares the model "stg_orders"
And the explore manifest declares the seed "raw_orders"
And the explore manifest declares the source "raw" table "orders"
And the explore model "stg_orders" depends on the seed "raw_orders"
And the explore model "stg_orders" depends on the source "raw" table "orders"
And the explore manifest declares the exposure "orders_dashboard" on "stg_orders"
When I run cute-dbt explore on the synthetic manifest
Then the exit code is 0
And the lineage payload types "raw_orders" as "seed"
And the lineage payload types "raw.orders" as "source"
And the lineage payload types "orders_dashboard" as "exposure"
And dag.html carries a lineage edge from "raw_orders" to "stg_orders"
And dag.html carries a lineage edge from "raw.orders" to "stg_orders"
And dag.html carries a lineage edge from "stg_orders" to "orders_dashboard"
721 changes: 665 additions & 56 deletions src/adapters/explore.rs

Large diffs are not rendered by default.

18 changes: 15 additions & 3 deletions templates/explore-cte.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,22 @@
});
}

// cute-dbt#253 — the CTE view is MODEL semantics (per-model CTE DAGs
// off the cte_dags map, built for models only): a highlighted
// snapshot/seed/source/exposure keeps the CTE arm locked rather than
// opening a misleading "no CTE structure" sparse state for a node the
// engine never parsed. Pre-#253 payloads carry no node_type and stay
// model-typed by default.
function modelTyped(id) {
var n = modelById[id];
return !!n && (!n.node_type || n.node_type === "model");
}

// ---- the view toggle (chrome + selection persist; same page) ------------
function setView(next) {
if (next === view) return;
if (next === "cte" && !highlightedId) return; // gated on a highlight
// gated on a MODEL highlight
if (next === "cte" && (!highlightedId || !modelTyped(highlightedId))) return;
view = next;
var isCte = view === "cte";
lineageView.hidden = isCte;
Expand All @@ -251,9 +263,9 @@
// and retargets an active CTE view in place.
document.addEventListener("cute-explore-highlight", function (ev) {
highlightedId = ev.detail ? ev.detail.id : null;
btnCte.disabled = !highlightedId;
btnCte.disabled = !highlightedId || !modelTyped(highlightedId);
if (view !== "cte") return;
if (!highlightedId) {
if (!highlightedId || !modelTyped(highlightedId)) {
setView("lineage");
return;
}
Expand Down
34 changes: 29 additions & 5 deletions templates/explore-dag.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@
.lineage-legend { color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem; }
.lineage-legend .legend-chip { display: inline-block; padding: 0 0.4rem; border: 1.5px solid var(--dag-import-stroke); border-radius: 4px; background: var(--dag-import-fill); color: var(--dag-node-text); }
.lineage-legend .legend-chip.notcompiled { border-color: #b07400; border-style: dashed; background: var(--dag-transform-fill); }
/* cute-dbt#253 — the typed-node vocabulary. Canvas-paired chips: these
exact fills/strokes are what explore-lineage.js draws per node type
(redundant shape+color coding on canvas; the chips mirror the colors
and approximate the shapes via border-radius). Shared by the legend
and the detail card's type chip. */
.legend-chip.type-model, .detail-type.type-model { border-color: #0072B2; background: #e8f1f8; }
.legend-chip.type-snapshot, .detail-type.type-snapshot { border-color: #7b5ea7; background: #f1ecf9; border-radius: 2px; }
.legend-chip.type-seed, .detail-type.type-seed { border-color: #00756d; background: #e6f4f2; border-radius: 10px; }
.legend-chip.type-source, .detail-type.type-source { border-color: #1f8a5b; background: #eef7ee; border-radius: 999px; }
.legend-chip.type-exposure, .detail-type.type-exposure { border-color: #6b6b76; background: #f4f4f5; border-radius: 4px 12px 12px 4px; }
/* cute-dbt#106 — the PR-diff change-context treatment: an amber halo
mirroring the canvas underlay glow. A separate visual channel from
the highlight vocabulary (border = selected, opacity = dim/trace)
Expand Down Expand Up @@ -110,6 +120,9 @@
/* canvas-paired status chips (fixed across themes; dark fixed text on the
fixed light fills stays readable on every theme by construction) */
.model-detail-card .detail-notcompiled { display: inline-block; padding: 0 0.4rem; border: 1.5px dashed #b07400; border-radius: 4px; background: var(--dag-transform-fill); color: var(--dag-node-text); font-size: 0.8rem; }
/* cute-dbt#253 — the detail card's node-type chip (non-model nodes only;
per-type colors above). */
.model-detail-card .detail-type { display: inline-block; margin-right: 0.35rem; padding: 0 0.4rem; border: 1.5px solid var(--dag-import-stroke); border-radius: 4px; color: var(--dag-node-text); font-size: 0.8rem; }
/* cute-dbt#106 — the detail card's "changed in this diff" chip (the
change-context twin of .detail-notcompiled; both can show at once). */
.model-detail-card .detail-changed { display: inline-block; margin-left: 0.35rem; padding: 0 0.4rem; border: 1.5px solid var(--dag-import-stroke); border-radius: 4px; background: var(--dag-import-fill); color: var(--dag-node-text); box-shadow: 0 0 0 2px rgba(230, 159, 0, 0.55); font-size: 0.8rem; }
Expand All @@ -125,7 +138,9 @@
<header class="explore-header">
<div>
<h1>Model lineage</h1>
<p class="explore-counts">full manifest — {{ model_count }} model{% if model_count != 1 %}s{% endif %}, {{ edge_count }} edge{% if edge_count != 1 %}s{% endif %}{% if not_compiled_count > 0 %}, {{ not_compiled_count }} not compiled{% endif %}{% if has_change_context %}, {{ changed_count }} changed in this diff{% endif %}</p>
{# cute-dbt#253 — typed counts: the non-model segments render only
when present, so a models-only manifest keeps the pre-#253 line. #}
<p class="explore-counts">full manifest — {{ model_count }} model{% if model_count != 1 %}s{% endif %}{% if snapshot_count > 0 %}, {{ snapshot_count }} snapshot{% if snapshot_count != 1 %}s{% endif %}{% endif %}{% if seed_count > 0 %}, {{ seed_count }} seed{% if seed_count != 1 %}s{% endif %}{% endif %}{% if source_count > 0 %}, {{ source_count }} source{% if source_count != 1 %}s{% endif %}{% endif %}{% if exposure_count > 0 %}, {{ exposure_count }} exposure{% if exposure_count != 1 %}s{% endif %}{% endif %}, {{ edge_count }} edge{% if edge_count != 1 %}s{% endif %}{% if not_compiled_count > 0 %}, {{ not_compiled_count }} not compiled{% endif %}{% if has_change_context %}, {{ changed_count }} changed in this diff{% endif %}</p>
</div>
<nav class="explore-nav" aria-label="Explore pages">
<a href="tests.html">Unit tests &rarr;</a>
Expand All @@ -135,11 +150,11 @@ <h1>Model lineage</h1>
<section class="lineage" aria-label="Model lineage DAG">
<div class="lineage-toolbar">
<div class="lineage-search">
<input type="search" class="lineage-search-input" placeholder="Search models&hellip;"
aria-label="Search models" role="combobox" aria-expanded="false"
<input type="search" class="lineage-search-input" placeholder="Search nodes&hellip;"
aria-label="Search lineage nodes" role="combobox" aria-expanded="false"
aria-controls="lineage-search-results" autocomplete="off" spellcheck="false">
<ul class="lineage-search-results" id="lineage-search-results" role="listbox"
aria-label="Matching models" hidden></ul>
aria-label="Matching nodes" hidden></ul>
</div>
{# cute-dbt#102 — the CTE ⇄ model view toggle. The CTE arm is gated
on a highlight (disabled at render time; explore-cte.js enables it
Expand All @@ -165,8 +180,17 @@ <h1>Model lineage</h1>
attribute. The engine fills it with createElement/textContent only. #}
<div class="lineage-tooltip" role="tooltip" aria-hidden="true" hidden></div>
<p class="lineage-empty" hidden>No models in this manifest.</p>
{# cute-dbt#253 — typed-node chips render only for types present in
this manifest (the model chip always); the chip colors are
canvas-paired with the engine's per-type styles. The template
SOURCE carries every wire key's chip — the node-vocab completeness
guard greps this file. #}
<div class="lineage-legend">
<span class="legend-chip">model</span>
<span class="legend-chip type-model">model</span>{% if snapshot_count > 0 %}
<span class="legend-chip type-snapshot">snapshot</span>{% endif %}{% if seed_count > 0 %}
<span class="legend-chip type-seed">seed</span>{% endif %}{% if source_count > 0 %}
<span class="legend-chip type-source">source</span>{% endif %}{% if exposure_count > 0 %}
<span class="legend-chip type-exposure">exposure</span>{% endif %}
<span class="legend-chip notcompiled">not compiled</span>{% if has_change_context %}
<span class="legend-chip changed">changed</span>{% endif %}
&mdash; a dashed node has no compiled SQL in this manifest (<code>dbt parse</code>); run <code>dbt compile</code> to fill it in. The explorer renders it anyway (fail-open).{% if has_change_context %} An amber-glow node&rsquo;s file changed in this PR diff &mdash; change context only highlights; the full graph always renders every model.{% endif %}
Expand Down
128 changes: 100 additions & 28 deletions templates/explore-lineage.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,17 @@
var MAX_TEXT_WIDTH = 274;
var measureCtx = document.createElement("canvas").getContext("2d");
measureCtx.font = "13px " + FONT_STACK;
function nodeWidth(name, badge) {
// cute-dbt#253 — per-type horizontal padding: non-rectangular shapes
// (ellipse / barrel / round-tag) inscribe less text than a rectangle,
// so their labels need wider boxes to stay un-clipped.
var TYPE_PAD = { model: 26, snapshot: 34, seed: 44, source: 44, exposure: 44 };
function nodeWidth(name, badge, type) {
var widest = Math.max(
measureCtx.measureText(name).width,
measureCtx.measureText(badge).width
);
return Math.min(300, Math.ceil(widest) + 26);
var pad = TYPE_PAD[type] || 26;
return Math.min(300, Math.ceil(widest) + pad);
}
// Manual single-line ellipsis (the label is two lines under
// `text-wrap: wrap` for the cute-dbt#103 badge, so Cytoscape's
Expand All @@ -178,6 +183,10 @@
// are manifest test-count facts, not check-engine output.
var name = ellipsize(String(n.name || n.id));
var badge = String(n.badge || "");
// cute-dbt#253 — the typed-node vocabulary (model / snapshot / seed
// / source / exposure), the style/shape hook below. Pre-#253
// payloads carried models only; default accordingly.
var type = String(n.node_type || "model");
elements.push({
group: "nodes",
// The element id is the full manifest node id. It is only ever
Expand All @@ -187,7 +196,8 @@
data: {
id: n.id,
label: badge ? name + "\n" + badge : name,
w: nodeWidth(name, badge),
w: nodeWidth(name, badge, type),
type: type,
notCompiled: n.not_compiled ? 1 : 0,
// cute-dbt#106 — PR-diff change context. The payload omits the
// key entirely on a no-context render, so `n.changed` is
Expand Down Expand Up @@ -226,6 +236,37 @@
"transition-property": "opacity",
"transition-duration": 120
}},
// cute-dbt#253 — the typed-node vocabulary. Redundant coding (shape
// AND canvas-paired color) so the typing never rides color alone;
// the dag.html legend chips mirror these exact fills/strokes (the
// report's fixed-DAG-palette posture — identical in every theme).
// The explicit model selector restates the base style so the
// node-vocab completeness guard greps one selector per wire key.
{ selector: 'node[type = "model"]', style: {
"shape": "round-rectangle",
"background-color": "#e8f1f8",
"border-color": "#0072B2"
}},
{ selector: 'node[type = "snapshot"]', style: {
"shape": "cut-rectangle",
"background-color": "#f1ecf9",
"border-color": "#7b5ea7"
}},
{ selector: 'node[type = "seed"]', style: {
"shape": "barrel",
"background-color": "#e6f4f2",
"border-color": "#00756d"
}},
{ selector: 'node[type = "source"]', style: {
"shape": "ellipse",
"background-color": "#eef7ee",
"border-color": "#1f8a5b"
}},
{ selector: 'node[type = "exposure"]', style: {
"shape": "round-tag",
"background-color": "#f4f4f5",
"border-color": "#6b6b76"
}},
{ selector: "node[notCompiled = 1]", style: {
"background-color": "#f4f4f5",
"border-color": "#b07400",
Expand Down Expand Up @@ -358,9 +399,17 @@
var n = nodeById[id];
if (!card || !n) return;
var d = n.detail;
// cute-dbt#253 — type-aware facts: code-bearing nodes-map types
// carry files/grain; sources/exposures honestly omit what they
// structurally cannot have.
var type = String(n.node_type || "model");
var codeBearing = type === "model" || type === "snapshot" || type === "seed";
while (card.firstChild) card.removeChild(card.firstChild);

card.appendChild(el("h2", null, n.name || n.id));
if (type !== "model") {
card.appendChild(el("span", "detail-type type-" + type, type));
}
if (n.not_compiled) {
card.appendChild(el("span", "detail-notcompiled", "not compiled"));
}
Expand All @@ -374,11 +423,17 @@
: el("p", "detail-description detail-empty", "no description"));

var facts = el("dl", "detail-facts", null);
fact(facts, "tests", document.createTextNode(n.badge || ""));
// The badge string is empty only on the cute-dbt#253 non-model
// types (models always carry the explicit 0/0 line).
fact(facts, "tests", n.badge
? document.createTextNode(n.badge)
: el("span", "detail-empty", "none"));
fact(facts, "materialized", d.materialized
? el("code", null, d.materialized)
: el("span", "detail-empty", "not set"));
fact(facts, "grain", grainValueNode(d.grain));
// Grain is SQL-model semantics — sources/exposures have no grain
// ladder to consult, so the row is omitted, not "unknown".
if (codeBearing) fact(facts, "grain", grainValueNode(d.grain));
var tagsNode = document.createElement("span");
if (d.tags.length) {
d.tags.forEach(function (tag) {
Expand All @@ -401,27 +456,32 @@

// cute-dbt#105 — read-only per-node file paths (the external-drive
// contract's NodePathsPayload, surfaced for humans too). All values
// are project-relative manifest facts; textContent only.
card.appendChild(el("p", "detail-section-title", "files"));
var pathFacts = el("dl", "detail-facts detail-paths", null);
fact(pathFacts, "sql", n.paths.sql
? el("code", null, n.paths.sql)
: el("span", "detail-empty", "not in manifest"));
fact(pathFacts, "schema yaml", n.paths.schema_yaml
? el("code", null, n.paths.schema_yaml)
: el("span", "detail-empty", "none"));
n.paths.unit_tests.forEach(function (t) {
var holder = document.createElement("span");
holder.appendChild(t.yaml
? el("code", null, t.yaml)
: el("span", "detail-empty", "yaml not in manifest"));
t.fixtures.forEach(function (fixture) {
holder.appendChild(document.createTextNode(" "));
holder.appendChild(el("code", "detail-path-fixtures", fixture));
// are project-relative manifest facts; textContent only. Code-bearing
// nodes-map types only (cute-dbt#253): a source/exposure carries no
// per-node file paths in the payload, so an all-empty section would
// be noise.
if (codeBearing) {
card.appendChild(el("p", "detail-section-title", "files"));
var pathFacts = el("dl", "detail-facts detail-paths", null);
fact(pathFacts, "sql", n.paths.sql
? el("code", null, n.paths.sql)
: el("span", "detail-empty", "not in manifest"));
fact(pathFacts, "schema yaml", n.paths.schema_yaml
? el("code", null, n.paths.schema_yaml)
: el("span", "detail-empty", "none"));
n.paths.unit_tests.forEach(function (t) {
var holder = document.createElement("span");
holder.appendChild(t.yaml
? el("code", null, t.yaml)
: el("span", "detail-empty", "yaml not in manifest"));
t.fixtures.forEach(function (fixture) {
holder.appendChild(document.createTextNode(" "));
holder.appendChild(el("code", "detail-path-fixtures", fixture));
});
fact(pathFacts, t.name, holder);
});
fact(pathFacts, t.name, holder);
});
card.appendChild(pathFacts);
card.appendChild(pathFacts);
}

card.appendChild(el("p", "detail-section-title", "columns"));
if (d.columns.length) {
Expand Down Expand Up @@ -461,13 +521,25 @@
function showTooltip(node) {
var n = nodeById[node.id()];
if (!tooltip || !n) return;
var type = String(n.node_type || "model");
while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild);
tooltip.appendChild(el("span", "tooltip-name", n.name || n.id));
tooltip.appendChild(el("span", "tooltip-fact", n.badge || ""));
// cute-dbt#253 — surface the non-model typing as a key fact; the
// badge line is skipped when empty (non-model types without tests).
if (type !== "model") {
tooltip.appendChild(el("span", "tooltip-fact", "type: " + type));
}
if (n.badge) {
tooltip.appendChild(el("span", "tooltip-fact", n.badge));
}
if (n.detail.materialized) {
tooltip.appendChild(el("span", "tooltip-fact", "materialized: " + n.detail.materialized));
}
tooltip.appendChild(el("span", "tooltip-fact", "grain: " + n.detail.grain.value));
// Grain is SQL-model semantics (omitted for sources/exposures, the
// detail-card rule).
if (type === "model" || type === "snapshot" || type === "seed") {
tooltip.appendChild(el("span", "tooltip-fact", "grain: " + n.detail.grain.value));
}
if (n.not_compiled) {
tooltip.appendChild(el("span", "tooltip-fact", "not compiled"));
}
Expand Down Expand Up @@ -609,7 +681,7 @@
if (!matches.length) {
var none = document.createElement("li");
none.className = "lineage-search-none";
none.textContent = "no matching model";
none.textContent = "no matching node";
list.appendChild(none);
}
matches.forEach(function (m, i) {
Expand Down
Loading
Loading