Skip to content

Commit 78b4626

Browse files
authored
fix(lineage): expand each shared node once when rendering lineage trees (#870)
1 parent 51354fe commit 78b4626

4 files changed

Lines changed: 173 additions & 43 deletions

File tree

‎src/sqlbuild/compiler/lineage/_helpers/tree_render.py‎

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,30 +27,29 @@ def render_dependency_branch[Node: Hashable](
2727
branch_style: Callable[[str], str],
2828
already_shown: Callable[[], str],
2929
) -> list[str]:
30-
"""Render a box-drawing dependency branch for opaque node values."""
30+
"""Render a box-drawing dependency branch, expanding each node once across the tree."""
3131

3232
lines: list[str] = []
33-
children: list[Node] = sorted(deps.get(node, ()), key=sort_key)
34-
index: int
35-
child: Node
36-
for index, child in enumerate(children):
37-
is_last: bool = index == len(children) - 1
33+
shown: set[Node] = set(seen)
34+
pending: list[tuple[Node, str, bool]] = _pending_children(
35+
children=sorted(deps.get(node, ()), key=sort_key), prefix=prefix
36+
)
37+
while pending:
38+
child: Node
39+
child_prefix: str
40+
is_last: bool
41+
child, child_prefix, is_last = pending.pop()
3842
branch: str = _BRANCH_LAST if is_last else _BRANCH_MID
39-
continuation: str = _CONTINUATION_LAST if is_last else _CONTINUATION_MID
40-
suffix: str = already_shown() if child in seen else ""
41-
lines.append(f"{branch_style(prefix + branch)}{format_node(child)}{suffix}")
42-
if child in seen:
43+
suffix: str = already_shown() if child in shown else ""
44+
lines.append(f"{branch_style(child_prefix + branch)}{format_node(child)}{suffix}")
45+
if child in shown:
4346
continue
44-
lines.extend(
45-
render_dependency_branch(
46-
node=child,
47-
deps=deps,
48-
prefix=prefix + continuation,
49-
seen=seen | {child},
50-
format_node=format_node,
51-
sort_key=sort_key,
52-
branch_style=branch_style,
53-
already_shown=already_shown,
47+
shown.add(child)
48+
continuation: str = _CONTINUATION_LAST if is_last else _CONTINUATION_MID
49+
pending.extend(
50+
_pending_children(
51+
children=sorted(deps.get(child, ()), key=sort_key),
52+
prefix=child_prefix + continuation,
5453
)
5554
)
5655
return lines
@@ -68,41 +67,59 @@ def render_column_trace_branch[Column, Edge](
6867
branch_style: Callable[[str], str],
6968
already_shown: Callable[[], str],
7069
) -> list[str]:
71-
"""Render a box-drawing column-trace branch for opaque column/edge values."""
70+
"""Render a box-drawing column-trace branch, expanding each column once across the tree."""
7271

7372
lines: list[str] = []
74-
edges: list[Edge] = sorted(
75-
deps.get(column_id(column), ()),
76-
key=lambda edge: column_id(related_column(edge)),
73+
shown: set[str] = set(seen)
74+
pending: list[tuple[Edge, str, bool]] = _pending_children(
75+
children=_sorted_edges(
76+
edges=deps.get(column_id(column), ()),
77+
column_id=column_id,
78+
related_column=related_column,
79+
),
80+
prefix=prefix,
7781
)
78-
index: int
79-
edge: Edge
80-
for index, edge in enumerate(edges):
81-
is_last: bool = index == len(edges) - 1
82+
while pending:
83+
edge: Edge
84+
edge_prefix: str
85+
is_last: bool
86+
edge, edge_prefix, is_last = pending.pop()
8287
branch: str = _BRANCH_LAST if is_last else _BRANCH_MID
83-
continuation: str = _CONTINUATION_LAST if is_last else _CONTINUATION_MID
8488
related: Column = related_column(edge)
8589
related_id: str = column_id(related)
86-
suffix: str = already_shown() if related_id in seen else ""
87-
lines.append(f"{prefix}{branch_style(branch)}{format_related(edge)}{suffix}")
88-
if related_id in seen:
90+
suffix: str = already_shown() if related_id in shown else ""
91+
lines.append(f"{edge_prefix}{branch_style(branch)}{format_related(edge)}{suffix}")
92+
if related_id in shown:
8993
continue
90-
lines.extend(
91-
render_column_trace_branch(
92-
column=related,
93-
deps=deps,
94-
prefix=prefix + continuation,
95-
seen=seen | {related_id},
96-
column_id=column_id,
97-
related_column=related_column,
98-
format_related=format_related,
99-
branch_style=branch_style,
100-
already_shown=already_shown,
94+
shown.add(related_id)
95+
continuation: str = _CONTINUATION_LAST if is_last else _CONTINUATION_MID
96+
pending.extend(
97+
_pending_children(
98+
children=_sorted_edges(
99+
edges=deps.get(related_id, ()),
100+
column_id=column_id,
101+
related_column=related_column,
102+
),
103+
prefix=edge_prefix + continuation,
101104
)
102105
)
103106
return lines
104107

105108

109+
def _pending_children[Item](*, children: list[Item], prefix: str) -> list[tuple[Item, str, bool]]:
110+
last: int = len(children) - 1
111+
return [(child, prefix, index == last) for index, child in reversed(list(enumerate(children)))]
112+
113+
114+
def _sorted_edges[Column, Edge](
115+
*,
116+
edges: list[Edge] | tuple[()],
117+
column_id: Callable[[Column], str],
118+
related_column: Callable[[Edge], Column],
119+
) -> list[Edge]:
120+
return sorted(edges, key=lambda edge: column_id(related_column(edge)))
121+
122+
106123
def render_column_trace_limit_note(
107124
*,
108125
total: int,

‎tests/e2e/src/sqlbuild/cli/commands/main/lineage/_test_types.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,11 @@ class LineageErrorCliTestCase:
3131
description: str
3232
command: tuple[str, ...]
3333
expected_fragments: tuple[str, ...]
34+
35+
36+
@dataclass(frozen=True)
37+
class DiamondLineageTreeCliTestCase:
38+
description: str
39+
command: tuple[str, ...]
40+
expected_expanded_names: tuple[str, ...]
41+
expected_max_lines: int

‎tests/e2e/src/sqlbuild/cli/commands/main/lineage/helpers.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,40 @@ def prepare_lineage_cache_project(*, tmp_path: Path) -> Path:
2828
def lineage_node_ids(*, payload: dict[str, object]) -> tuple[str, ...]:
2929
nodes: list[dict[str, object]] = cast(list[dict[str, object]], payload["nodes"])
3030
return tuple(str(node["id"]) for node in nodes)
31+
32+
33+
DIAMOND_LAYERS: int = 14
34+
35+
36+
def diamond_model_names() -> tuple[str, ...]:
37+
"""Return every model in the layered diamond project, in layer order."""
38+
39+
layer_names: tuple[tuple[str, str, str], ...] = tuple(
40+
(f"orders_left_{layer}", f"orders_right_{layer}", f"orders_{layer}")
41+
for layer in range(1, DIAMOND_LAYERS + 1)
42+
)
43+
return ("orders_0", *sum(layer_names, ()))
44+
45+
46+
def prepare_diamond_lineage_project(*, tmp_path: Path) -> Path:
47+
"""Write a project whose layers each fork into two models and join again."""
48+
49+
files: dict[str, str] = {
50+
"sqlbuild_project.toml": 'name = "diamond_lineage"\nadapter = "duckdb"\n',
51+
"models/orders_0.sql": "MODEL (materialized view);\n\nSELECT 1 AS order_id, 10 AS amount\n",
52+
}
53+
for layer in range(1, DIAMOND_LAYERS + 1):
54+
previous: str = f"orders_{layer - 1}"
55+
for side in ("left", "right"):
56+
files[f"models/orders_{side}_{layer}.sql"] = (
57+
f'MODEL (materialized view);\n\nSELECT order_id, amount FROM __ref("{previous}")\n'
58+
)
59+
files[f"models/orders_{layer}.sql"] = (
60+
"MODEL (materialized view);\n\n"
61+
"SELECT l.order_id, l.amount + r.amount AS amount\n"
62+
f'FROM __ref("orders_left_{layer}") AS l\n'
63+
f'JOIN __ref("orders_right_{layer}") AS r ON l.order_id = r.order_id\n'
64+
)
65+
return prepare_inline_project(
66+
tmp_path=tmp_path, project_name="diamond_lineage", repo_files=files
67+
)

‎tests/e2e/src/sqlbuild/cli/commands/main/lineage/test_lineage.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45
import subprocess
56
from pathlib import Path
67

78
import pytest
89

910
from tests.e2e.src.sqlbuild.cli.commands.main.lineage._test_types import (
1011
ColumnLineageCacheCliTestCase,
12+
DiamondLineageTreeCliTestCase,
1113
LineageCacheCliTestCase,
1214
LineageCliTestCase,
1315
LineageErrorCliTestCase,
1416
)
1517
from tests.e2e.src.sqlbuild.cli.commands.main.lineage.helpers import (
18+
DIAMOND_LAYERS,
19+
diamond_model_names,
1620
lineage_node_ids,
21+
prepare_diamond_lineage_project,
1722
prepare_lineage_cache_project,
1823
)
1924
from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import (
2025
prepare_waffle_shop,
2126
run_sqb,
2227
)
2328

29+
_TREE_LINE: re.Pattern[str] = re.compile(r"^[│ ]*[├└]── .*$", re.MULTILINE)
30+
_EXPANDED_DIAMOND_NODE: re.Pattern[str] = re.compile(
31+
r"^[│ ]*[├└]── (?![^\n]*\(already shown\))[^\n]*?(orders_(?:left_|right_)?\d+(?:\.amount)?)",
32+
re.MULTILINE,
33+
)
2434
_LINEAGE_CACHE_RELATIVE_PATH: Path = Path("target/cache/lineage/v1/structural-graph.sqlite3")
2535

2636

@@ -439,3 +449,61 @@ def test_given_invalid_lineage_target_when_running_then_explains_the_error(
439449
assert result.returncode == 1, result.stdout + result.stderr
440450
for fragment in test_case.expected_fragments:
441451
assert fragment in result.stderr, result.stderr
452+
453+
454+
@pytest.mark.parametrize(
455+
"test_case",
456+
(
457+
DiamondLineageTreeCliTestCase(
458+
description="downstream tree expands each shared model once",
459+
command=("--no-color", "lineage", "orders_0", "--direction", "downstream"),
460+
expected_expanded_names=diamond_model_names()[1:],
461+
expected_max_lines=4 * len(diamond_model_names()),
462+
),
463+
DiamondLineageTreeCliTestCase(
464+
description="upstream tree expands each shared model once",
465+
command=(
466+
"--no-color",
467+
"lineage",
468+
f"orders_{DIAMOND_LAYERS}",
469+
"--direction",
470+
"upstream",
471+
),
472+
expected_expanded_names=diamond_model_names()[:-1],
473+
expected_max_lines=4 * len(diamond_model_names()),
474+
),
475+
DiamondLineageTreeCliTestCase(
476+
description="column trace expands each shared column once",
477+
command=(
478+
"--no-color",
479+
"lineage",
480+
f"orders_{DIAMOND_LAYERS}.amount",
481+
"--direction",
482+
"upstream",
483+
),
484+
expected_expanded_names=(
485+
f"orders_left_{DIAMOND_LAYERS}.amount",
486+
f"orders_right_{DIAMOND_LAYERS}.amount",
487+
f"orders_{DIAMOND_LAYERS - 1}.amount",
488+
),
489+
expected_max_lines=4 * len(diamond_model_names()),
490+
),
491+
),
492+
ids=lambda case: case.description,
493+
)
494+
def test_given_diamond_dependencies_when_rendering_lineage_tree_then_output_stays_linear(
495+
test_case: DiamondLineageTreeCliTestCase,
496+
tmp_path: Path,
497+
) -> None:
498+
project_dir: Path = prepare_diamond_lineage_project(tmp_path=tmp_path)
499+
500+
result: subprocess.CompletedProcess[str] = run_sqb(
501+
command=test_case.command, project_dir=project_dir
502+
)
503+
504+
assert result.returncode == 0, result.stdout + result.stderr
505+
tree_lines: list[str] = _TREE_LINE.findall(result.stdout)
506+
expanded_nodes: list[str] = _EXPANDED_DIAMOND_NODE.findall(result.stdout)
507+
assert len(tree_lines) <= test_case.expected_max_lines
508+
assert len(expanded_nodes) == len(set(expanded_nodes))
509+
assert set(test_case.expected_expanded_names) <= set(expanded_nodes)

0 commit comments

Comments
 (0)