sql-code-graph 1.37.1__py3-none-any.whl → 1.38.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql_code_graph-1.37.1.dist-info → sql_code_graph-1.38.1.dist-info}/METADATA +1 -1
- {sql_code_graph-1.37.1.dist-info → sql_code_graph-1.38.1.dist-info}/RECORD +8 -7
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +42 -0
- sqlcg/lineage/temp_collapse.py +252 -0
- sqlcg/viz/assets/template.html +9 -7
- {sql_code_graph-1.37.1.dist-info → sql_code_graph-1.38.1.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.37.1.dist-info → sql_code_graph-1.38.1.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-code-graph
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.38.1
|
|
4
4
|
Summary: SQL code graph analyzer and lineage tracer
|
|
5
5
|
Project-URL: Homepage, https://github.com/Warhorze/sql-code-graph
|
|
6
6
|
Project-URL: Repository, https://github.com/Warhorze/sql-code-graph
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
sqlcg/__init__.py,sha256=
|
|
1
|
+
sqlcg/__init__.py,sha256=LeCnfE_7dohzbUW1dqEPREc3JI5lPwcCI0noKJlTJvo,116
|
|
2
2
|
sqlcg/__main__.py,sha256=1YoFLcqEgTwYq1J3TbUwpkdG0zeeLIf2fJvwWI-CLFU,109
|
|
3
3
|
sqlcg/cli/__init__.py,sha256=W8fD0LpMq2xm_5WKGNMvJh2WBL1ho5E8hUeAqXQYT1g,28
|
|
4
4
|
sqlcg/cli/coverage.py,sha256=Xm9ITzZDHv2mJ70Q5jCacVuhDStVrE3gq12_-Ypvtd8,43823
|
|
5
5
|
sqlcg/cli/main.py,sha256=K7CDIJ1tz7kxySGLeuKGZoTinucUrhINCxs_zieMmsg,2526
|
|
6
6
|
sqlcg/cli/commands/__init__.py,sha256=oSHtr6VD-jNubOjuCQyZj2tBppjMEpQDh-IGQ8of9eA,30
|
|
7
|
-
sqlcg/cli/commands/analyze.py,sha256=
|
|
7
|
+
sqlcg/cli/commands/analyze.py,sha256=I7TlBbfTqHLnG8Sj3gtN9pgThDHDHOuwliBVZ_NBD8I,33017
|
|
8
8
|
sqlcg/cli/commands/catalog.py,sha256=xCIWp_9xcDPHIMUB0xrZR3-G0WMvKbfq4yMwe2sDT2g,12670
|
|
9
9
|
sqlcg/cli/commands/db.py,sha256=QEDPs0boeFe8ETE8BWe7I2JLSXROUI571SHXMTUqxh8,7982
|
|
10
10
|
sqlcg/cli/commands/find.py,sha256=uMdG08bU-AvB9RqsTKPfdVnJet7b3Ebj0xbsjqTclYA,2678
|
|
@@ -41,6 +41,7 @@ sqlcg/indexer/watcher.py,sha256=mJQq1LASRLKKwhz0WhCUWPLLqyPR2_-FD_8efYU6gE8,8442
|
|
|
41
41
|
sqlcg/lineage/__init__.py,sha256=Da1DlYwtK13WHv_RnHjAtNkHTOuFbhxqCjT1Le7DsWM,46
|
|
42
42
|
sqlcg/lineage/aggregator.py,sha256=zR9h0I14GtPQYT9bTs_UbVyjaUVgpEhAynKBNc4VPnA,13325
|
|
43
43
|
sqlcg/lineage/schema_resolver.py,sha256=iXt6LYF6UVWsGUpcfbmjmGn9wCgXl721lTGf_8AaWcc,7320
|
|
44
|
+
sqlcg/lineage/temp_collapse.py,sha256=OVVQxeKIHl0k5V85z7OH-wngBH29z-e5KvQXp58ykR4,11350
|
|
44
45
|
sqlcg/metrics/__init__.py,sha256=hLJ6wm4St8qqYwKh3o9QG7lcEt1BEYM31ccqO9tGpIg,133
|
|
45
46
|
sqlcg/metrics/store.py,sha256=KuDtxvyAgug9_KtiSCpvgKM2VZM7VSaI3D11uMLjJJk,10604
|
|
46
47
|
sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
|
|
@@ -72,8 +73,8 @@ sqlcg/viz/data.py,sha256=deLWOZBgewM1x-kk1NhUnt0mswyt0nzqSevHzBCOHHc,5899
|
|
|
72
73
|
sqlcg/viz/render.py,sha256=BINkGbJbbb_iqhrkN795RaQsdg8nqCiJtsEFF1yo22Y,2737
|
|
73
74
|
sqlcg/viz/tags.py,sha256=6zRnGlHjuGmEeB6yN1uhzm8rqL7ZGoyL1Ki7jI5oM6A,5368
|
|
74
75
|
sqlcg/viz/assets/force-graph.min.js,sha256=jNdYdDdrYiUdUlElxRkolPBt30rstQk2q15Q32VVdzc,177272
|
|
75
|
-
sqlcg/viz/assets/template.html,sha256=
|
|
76
|
-
sql_code_graph-1.
|
|
77
|
-
sql_code_graph-1.
|
|
78
|
-
sql_code_graph-1.
|
|
79
|
-
sql_code_graph-1.
|
|
76
|
+
sqlcg/viz/assets/template.html,sha256=XoJHOSLn1E3k9BP8ti_y1T1gBVqvKW-8J-dqIHtDy-w,17595
|
|
77
|
+
sql_code_graph-1.38.1.dist-info/METADATA,sha256=foTbbQqQGDtq6uGqsPSFumKMhjWNAmMxbQkqrD8o7Kw,19208
|
|
78
|
+
sql_code_graph-1.38.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
79
|
+
sql_code_graph-1.38.1.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
|
|
80
|
+
sql_code_graph-1.38.1.dist-info/RECORD,,
|
sqlcg/__init__.py
CHANGED
sqlcg/cli/commands/analyze.py
CHANGED
|
@@ -314,6 +314,15 @@ def downstream( # noqa: B008
|
|
|
314
314
|
def impact( # noqa: B008
|
|
315
315
|
table: str = typer.Argument(..., help="Table name to analyze"), # noqa: B008
|
|
316
316
|
raw: bool = typer.Option(False, "--raw", help="Disable noise filtering on results"), # noqa: B008
|
|
317
|
+
through_temps: bool = typer.Option( # noqa: B008
|
|
318
|
+
False,
|
|
319
|
+
"--through-temps",
|
|
320
|
+
help=(
|
|
321
|
+
"Also report consumers reachable only THROUGH a temp/cte/derived "
|
|
322
|
+
"intermediate (table-grain temp-collapsed closure). Off by default — "
|
|
323
|
+
"the default impact stays one-hop and does not thread temps."
|
|
324
|
+
),
|
|
325
|
+
),
|
|
317
326
|
) -> None:
|
|
318
327
|
"""Show all queries impacted by a table.
|
|
319
328
|
|
|
@@ -323,6 +332,13 @@ def impact( # noqa: B008
|
|
|
323
332
|
``ba.all_rows_in_selection``). Bug #6 fix: brings ``impact`` to the same
|
|
324
333
|
one-hop consumer completeness ``find_table_usages`` already has. Stays
|
|
325
334
|
one-hop (NOT transitive — that is ``downstream``'s job).
|
|
335
|
+
|
|
336
|
+
With ``--through-temps`` the consumer set is augmented with the downstream
|
|
337
|
+
**temp-collapsed** closure: physical tables reachable from this table only
|
|
338
|
+
by threading through ``kind IN ('cte','derived','temp')`` intermediates,
|
|
339
|
+
computed from the structural COLUMN_LINEAGE hops (read-only; it never
|
|
340
|
+
mutates the graph). This recovers consumers — e.g. the star-into-temp
|
|
341
|
+
final target — that the one-hop default and TEMP_INLINE do not surface.
|
|
326
342
|
"""
|
|
327
343
|
direct = run_read_routed(
|
|
328
344
|
"SELECT DISTINCT q.id AS id, q.kind AS kind, q.target_table AS target"
|
|
@@ -341,6 +357,32 @@ def impact( # noqa: B008
|
|
|
341
357
|
)
|
|
342
358
|
seen = {r["id"] for r in direct}
|
|
343
359
|
results = direct + [r for r in via if r["id"] not in seen]
|
|
360
|
+
|
|
361
|
+
# --through-temps: augment with the temp-collapsed downstream closure. These
|
|
362
|
+
# are physical final tables reached only by threading through a temp/cte/
|
|
363
|
+
# derived node (the one-hop default + the queries above miss them). Rendered
|
|
364
|
+
# as synthetic "[collapsed] <final>" rows so they are visibly distinct from
|
|
365
|
+
# the direct/via consumer-query rows above.
|
|
366
|
+
if through_temps:
|
|
367
|
+
from sqlcg.lineage.temp_collapse import collapse_through_temps
|
|
368
|
+
|
|
369
|
+
collapsed = collapse_through_temps(table.lower(), direction="downstream")
|
|
370
|
+
seen_finals = {r.get("target", "") for r in results}
|
|
371
|
+
for edge in collapsed:
|
|
372
|
+
if not edge.via_temp:
|
|
373
|
+
# reached by a single direct hop; already covered above
|
|
374
|
+
continue
|
|
375
|
+
if edge.final_table in seen_finals:
|
|
376
|
+
continue
|
|
377
|
+
seen_finals.add(edge.final_table)
|
|
378
|
+
results.append(
|
|
379
|
+
{
|
|
380
|
+
"id": f"[collapsed] {edge.final_table}",
|
|
381
|
+
"kind": f"via-temp (hops={edge.hop_count})",
|
|
382
|
+
"target": edge.final_table,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
344
386
|
# Preserve the existing 100-row bound on the merged consumer set.
|
|
345
387
|
results = results[:100]
|
|
346
388
|
if not raw:
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Non-destructive, read-only temp-collapse primitive (PR-2).
|
|
2
|
+
|
|
3
|
+
Given a physical start table, :func:`collapse_through_temps` returns the
|
|
4
|
+
**temp-collapsed transitive closure** — physical ``(src, final)`` table pairs
|
|
5
|
+
threaded *through* intermediate nodes whose ``SqlTable.kind IN
|
|
6
|
+
('cte','derived','temp')`` — computed from the stored **structural**
|
|
7
|
+
COLUMN_LINEAGE hops (``transform IS DISTINCT FROM 'TEMP_INLINE'``), NOT from
|
|
8
|
+
TEMP_INLINE presence.
|
|
9
|
+
|
|
10
|
+
Why structural-only + node-keyed matters: the star-into-temp gap is precisely
|
|
11
|
+
"TEMP_INLINE did not fire" — but the per-hop structural edges
|
|
12
|
+
(``src -> <ns>::temp`` and ``<ns>::temp -> final``) always exist. Threading
|
|
13
|
+
through the temp *node* (table_qualified, namespace-stripped to its table grain)
|
|
14
|
+
recovers the collapsed ``src -> final`` edge that TEMP_INLINE does not emit.
|
|
15
|
+
|
|
16
|
+
This module performs **read-only** traversal only: every query is a ``SELECT``
|
|
17
|
+
issued through :func:`run_read_routed`. It never deletes, replaces, or mutates
|
|
18
|
+
any stored ``COLUMN_LINEAGE`` row (the HARD non-destructive invariant — guarded
|
|
19
|
+
behaviorally in the test suite). It lives outside ``base.py`` / ``indexer.py``
|
|
20
|
+
so it adds nothing to the parse/qualify hot loop (CLAUDE.md perf invariants).
|
|
21
|
+
|
|
22
|
+
Guards PR-2 of the lineage gold-set + temp-collapse plan
|
|
23
|
+
([plan doc](../../../plan/sprints/lineage_gold_regression_and_temp_collapse.md)).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from sqlcg.server.read_client import run_read_routed
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Authoritative constant sets — mirror the existing read surface, do NOT invent.
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
#: Intermediate node kinds the collapse threads *through* (never an endpoint).
|
|
38
|
+
#: Mirrors ``coverage.py``'s ``kind IN ('cte','derived','temp')`` usage and the
|
|
39
|
+
#: cross-batch downgrade guard in ``duckdb_backend.py``. Define once here.
|
|
40
|
+
_TEMP_KIND_VALUES: tuple[str, ...] = ("cte", "derived", "temp")
|
|
41
|
+
|
|
42
|
+
#: COLUMN_LINEAGE.transform value emitted for inlined temp tables (E8
|
|
43
|
+
#: dual-emission). Mirrors ``analyze.py:_TEMP_INLINE_TRANSFORM``. The collapse
|
|
44
|
+
#: is computed from STRUCTURAL hops only, so these edges are filtered out.
|
|
45
|
+
_TEMP_INLINE_TRANSFORM = "TEMP_INLINE"
|
|
46
|
+
|
|
47
|
+
#: Default depth cap for the recursive traversal. Bounds runaway closures on a
|
|
48
|
+
#: dense graph; the visited-path guard already breaks cycles. Mirrors the
|
|
49
|
+
#: depth-bound convention in ``analyze.py`` (1..100).
|
|
50
|
+
_DEFAULT_MAX_DEPTH = 50
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CollapsedEdge:
|
|
55
|
+
"""One temp-collapsed table-grain edge.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
src_table: The physical (table/external/unknown) start endpoint.
|
|
59
|
+
final_table: The physical endpoint reached by threading through temps.
|
|
60
|
+
via_temp: True when the path passed through at least one
|
|
61
|
+
cte/derived/temp node (a genuine collapse), False for a direct
|
|
62
|
+
physical->physical structural hop reached at depth 1.
|
|
63
|
+
hop_count: Number of structural COLUMN_LINEAGE hops on the path.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
src_table: str
|
|
67
|
+
final_table: str
|
|
68
|
+
via_temp: bool
|
|
69
|
+
hop_count: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _temp_kind_in_list_sql() -> str:
|
|
73
|
+
"""SQL literal list for the intermediate-kind IN (...) clause."""
|
|
74
|
+
return ", ".join(f"'{v}'" for v in _TEMP_KIND_VALUES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _strip_namespace(key: str) -> str:
|
|
78
|
+
"""Drop the ``<file>::`` namespace prefix from a temp/cte/derived key.
|
|
79
|
+
|
|
80
|
+
Temp/CTE/derived nodes are keyed ``<repo-rel-file>::<db>.<name>`` in the
|
|
81
|
+
graph (see ``base.py:97-98``). The collapse threads through them but reports
|
|
82
|
+
endpoints at their bare ``<db>.<name>`` table grain — the same convention
|
|
83
|
+
the PR-1 gold-set rollup uses.
|
|
84
|
+
"""
|
|
85
|
+
return key.split("::", 1)[1] if "::" in key else key
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collapse_sql(*, direction: str, max_depth: int) -> str:
|
|
89
|
+
"""Build the table-grain temp-collapse recursive-CTE SQL.
|
|
90
|
+
|
|
91
|
+
The traversal rolls COLUMN_LINEAGE to ``SqlColumn.table_qualified`` and
|
|
92
|
+
threads through nodes whose ``SqlTable.kind IN ('cte','derived','temp')``,
|
|
93
|
+
on STRUCTURAL hops only (``transform IS DISTINCT FROM 'TEMP_INLINE'``).
|
|
94
|
+
Every emitted row is a ``(start_physical, reached_physical)`` pair whose
|
|
95
|
+
intermediate hops are all temp/cte/derived and whose endpoint is physical
|
|
96
|
+
(``table``/``external``) or unknown (NULL kind).
|
|
97
|
+
|
|
98
|
+
Cycle-safe: the visited ``path`` of table-qualified node keys carries the
|
|
99
|
+
``NOT ... = ANY(reach.path)`` guard (mirrors ``analyze.py:144/191``).
|
|
100
|
+
Depth-bounded: ``reach.depth < {max_depth}``.
|
|
101
|
+
|
|
102
|
+
Single ``?`` placeholder: the (lowercased) start table-qualified name.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
direction: ``"downstream"`` (src -> final) or ``"upstream"``
|
|
106
|
+
(final -> src). ``analyze impact --through-temps`` uses downstream.
|
|
107
|
+
max_depth: Maximum number of structural hops to traverse.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A parameterised SQL string with one ``?`` placeholder.
|
|
111
|
+
"""
|
|
112
|
+
if direction == "downstream":
|
|
113
|
+
from_col, to_col = "src", "dst"
|
|
114
|
+
elif direction == "upstream":
|
|
115
|
+
from_col, to_col = "dst", "src"
|
|
116
|
+
else: # pragma: no cover - guarded by the public function
|
|
117
|
+
raise ValueError(f"direction must be 'downstream' or 'upstream', got {direction!r}")
|
|
118
|
+
|
|
119
|
+
temp_kinds = _temp_kind_in_list_sql()
|
|
120
|
+
structural_base = f"cl.transform IS DISTINCT FROM '{_TEMP_INLINE_TRANSFORM}'"
|
|
121
|
+
structural_rec = f"cl2.transform IS DISTINCT FROM '{_TEMP_INLINE_TRANSFORM}'"
|
|
122
|
+
|
|
123
|
+
# What counts as a temp/cte/derived INTERMEDIATE node to thread *through*:
|
|
124
|
+
# a node whose ``SqlTable.kind IN ('cte','derived','temp')`` AND whose key is
|
|
125
|
+
# NAMESPACED (``<file>::<db>.<name>`` — see base.py:97-98). Namespacing is the
|
|
126
|
+
# load-bearing discriminator: CTE/temp nodes are always namespaced, whereas an
|
|
127
|
+
# INSERT/CTAS *target* table (e.g. ``mart.dim_person``) is also stamped
|
|
128
|
+
# ``kind='derived'`` but is NOT namespaced and is a genuine physical endpoint.
|
|
129
|
+
# Keying on kind alone would thread through final targets and never terminate;
|
|
130
|
+
# requiring the ``::`` namespace keeps endpoints (the gold-set finals) terminal.
|
|
131
|
+
# (See PR-2 Deviation 1 in the plan.)
|
|
132
|
+
def _is_intermediate(kind_alias: str, node_alias: str) -> str:
|
|
133
|
+
return f"({kind_alias}.kind IN ({temp_kinds}) AND {node_alias} LIKE '%::%')"
|
|
134
|
+
|
|
135
|
+
# ``reach`` walks the table-grain structural graph. Each row is a node
|
|
136
|
+
# reached from the start, carrying:
|
|
137
|
+
# node — the table_qualified of the reached endpoint
|
|
138
|
+
# depth — number of structural hops taken
|
|
139
|
+
# via_temp— whether any intermediate node so far was a temp/cte/derived
|
|
140
|
+
# path — visited table_qualified keys (cycle guard)
|
|
141
|
+
# The recursive step only CONTINUES through an intermediate node; a physical
|
|
142
|
+
# endpoint (non-namespaced, or kind not temp/cte/derived) is terminal and is
|
|
143
|
+
# not re-expanded.
|
|
144
|
+
seed_via_temp = _is_intermediate("kt", "n.table_qualified")
|
|
145
|
+
rec_via_temp = _is_intermediate("kt2", "n2.table_qualified")
|
|
146
|
+
cur_is_intermediate = _is_intermediate("kcur", "reach.node")
|
|
147
|
+
return f"""
|
|
148
|
+
WITH RECURSIVE reach(node, depth, via_temp, path) AS (
|
|
149
|
+
SELECT
|
|
150
|
+
n.table_qualified AS node,
|
|
151
|
+
1 AS depth,
|
|
152
|
+
{seed_via_temp} AS via_temp,
|
|
153
|
+
ARRAY[s.table_qualified, n.table_qualified] AS path
|
|
154
|
+
FROM "COLUMN_LINEAGE" cl
|
|
155
|
+
JOIN "SqlColumn" s ON s.id = cl.{from_col}_key
|
|
156
|
+
JOIN "SqlColumn" n ON n.id = cl.{to_col}_key
|
|
157
|
+
LEFT JOIN "SqlTable" kt ON kt.qualified = n.table_qualified
|
|
158
|
+
WHERE s.table_qualified = ?
|
|
159
|
+
AND {structural_base}
|
|
160
|
+
AND n.table_qualified IS DISTINCT FROM s.table_qualified
|
|
161
|
+
UNION ALL
|
|
162
|
+
SELECT
|
|
163
|
+
n2.table_qualified,
|
|
164
|
+
reach.depth + 1,
|
|
165
|
+
reach.via_temp OR {rec_via_temp},
|
|
166
|
+
array_append(reach.path, n2.table_qualified)
|
|
167
|
+
FROM reach
|
|
168
|
+
JOIN "SqlColumn" cur ON cur.table_qualified = reach.node
|
|
169
|
+
JOIN "COLUMN_LINEAGE" cl2 ON cl2.{from_col}_key = cur.id
|
|
170
|
+
JOIN "SqlColumn" n2 ON n2.id = cl2.{to_col}_key
|
|
171
|
+
LEFT JOIN "SqlTable" kcur ON kcur.qualified = reach.node
|
|
172
|
+
LEFT JOIN "SqlTable" kt2 ON kt2.qualified = n2.table_qualified
|
|
173
|
+
WHERE reach.depth < {max_depth}
|
|
174
|
+
AND {structural_rec}
|
|
175
|
+
-- only thread THROUGH a temp/cte/derived intermediate (namespaced) node
|
|
176
|
+
AND {cur_is_intermediate}
|
|
177
|
+
AND n2.table_qualified IS DISTINCT FROM reach.node
|
|
178
|
+
AND NOT n2.table_qualified = ANY(reach.path)
|
|
179
|
+
)
|
|
180
|
+
SELECT
|
|
181
|
+
reach.node AS final_node,
|
|
182
|
+
min(reach.depth) AS hop_count,
|
|
183
|
+
bool_or(reach.via_temp) AS via_temp
|
|
184
|
+
FROM reach
|
|
185
|
+
LEFT JOIN "SqlTable" fk ON fk.qualified = reach.node
|
|
186
|
+
-- emit only endpoints, never a bare temp/cte intermediate node
|
|
187
|
+
WHERE NOT {_is_intermediate("fk", "reach.node")}
|
|
188
|
+
GROUP BY reach.node
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def collapse_through_temps(
|
|
193
|
+
start_table: str,
|
|
194
|
+
direction: str = "downstream",
|
|
195
|
+
max_depth: int = _DEFAULT_MAX_DEPTH,
|
|
196
|
+
db_path: Path | None = None,
|
|
197
|
+
) -> list[CollapsedEdge]:
|
|
198
|
+
"""Return the temp-collapsed transitive closure from ``start_table``.
|
|
199
|
+
|
|
200
|
+
Read-only, NODE-keyed, structural-hop-only table-grain BFS. Threads through
|
|
201
|
+
``kind IN ('cte','derived','temp')`` intermediates and emits one
|
|
202
|
+
:class:`CollapsedEdge` per physical ``(start, reached)`` pair. Recovers the
|
|
203
|
+
star-into-temp ``src -> final`` edge that TEMP_INLINE does not emit (the
|
|
204
|
+
closure is built from the always-present structural hops, not from
|
|
205
|
+
TEMP_INLINE rows).
|
|
206
|
+
|
|
207
|
+
Non-destructive: issues only ``SELECT`` queries via
|
|
208
|
+
:func:`run_read_routed`. It performs no DELETE/INSERT/UPDATE/upsert and
|
|
209
|
+
never touches any write path.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
start_table: The qualified start table name (case-insensitive — graph
|
|
213
|
+
keys are lowercased at index time, C2 normalization).
|
|
214
|
+
direction: ``"downstream"`` (default; consumers reached from the table)
|
|
215
|
+
or ``"upstream"`` (sources the table is derived from).
|
|
216
|
+
max_depth: Maximum number of structural hops (1..100). Bounds the
|
|
217
|
+
closure; the visited-path guard breaks cycles independently.
|
|
218
|
+
db_path: Explicit database path; defaults to the routed read target.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
A list of :class:`CollapsedEdge`, one per distinct physical endpoint
|
|
222
|
+
reached. ``src_table`` is the (namespace-stripped) start table;
|
|
223
|
+
``final_table`` the (namespace-stripped) reached physical table.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: ``direction`` is not ``"downstream"``/``"upstream"`` or
|
|
227
|
+
``max_depth`` is outside 1..100.
|
|
228
|
+
"""
|
|
229
|
+
if direction not in ("downstream", "upstream"):
|
|
230
|
+
raise ValueError(f"direction must be 'downstream' or 'upstream', got {direction!r}")
|
|
231
|
+
if max_depth < 1 or max_depth > 100:
|
|
232
|
+
raise ValueError(f"max_depth must be between 1 and 100, got {max_depth}")
|
|
233
|
+
|
|
234
|
+
start = start_table.lower() # graph keys are lowercased at index time
|
|
235
|
+
sql = _collapse_sql(direction=direction, max_depth=max_depth)
|
|
236
|
+
rows = run_read_routed(sql, {"start": start}, db_path=db_path)
|
|
237
|
+
|
|
238
|
+
src_bare = _strip_namespace(start)
|
|
239
|
+
edges: list[CollapsedEdge] = []
|
|
240
|
+
for r in rows:
|
|
241
|
+
final_bare = _strip_namespace(r["final_node"])
|
|
242
|
+
if final_bare == src_bare:
|
|
243
|
+
continue # never report the start as its own endpoint
|
|
244
|
+
edges.append(
|
|
245
|
+
CollapsedEdge(
|
|
246
|
+
src_table=src_bare,
|
|
247
|
+
final_table=final_bare,
|
|
248
|
+
via_temp=bool(r["via_temp"]),
|
|
249
|
+
hop_count=int(r["hop_count"]),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return edges
|
sqlcg/viz/assets/template.html
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
<span id="meta"></span>
|
|
37
37
|
</div>
|
|
38
38
|
<div id="graph"></div>
|
|
39
|
-
<div id="info">
|
|
39
|
+
<div id="info">click a node to trace its 1-hop lineage (click again, background, or Esc clears) · drag a node to move it · scroll to zoom · drag background to pan · click a chip to toggle, alt/right-click to solo · set min deg 0 to show isolated tables</div>
|
|
40
40
|
<script>
|
|
41
41
|
const DATA = /*__DATA__*/;
|
|
42
42
|
const TAGS = /*__TAGS__*/;
|
|
@@ -345,21 +345,23 @@ const Graph = ForceGraph()(document.getElementById('graph'))
|
|
|
345
345
|
// highlightSet, so updating state is enough; ego-mode keeps focusNode separate.
|
|
346
346
|
.onNodeClick(n => {
|
|
347
347
|
pinnedNode = (pinnedNode === n.id) ? null : n.id; // toggle pin
|
|
348
|
-
setHighlight(pinnedNode
|
|
348
|
+
setHighlight(pinnedNode); // CLICK-ONLY highlight (null when unpinned)
|
|
349
349
|
focusNode = n.id; // ego-mode focus (orthogonal; only used when ego is on)
|
|
350
350
|
})
|
|
351
351
|
.onNodeHover(n => {
|
|
352
|
+
// Hover only tracks the cursor target for the on-hover LABEL (drawNodeCanvas);
|
|
353
|
+
// it does NOT drive the edge highlight — highlighting is click-only so moving
|
|
354
|
+
// the mouse over the graph never flickers the lineage edges.
|
|
352
355
|
hoveredNode = n ? n.id : null;
|
|
353
|
-
if (pinnedNode === null) setHighlight(hoveredNode); // pin wins over hover
|
|
354
356
|
})
|
|
355
|
-
.onBackgroundClick(() => { // click empty space clears the
|
|
357
|
+
.onBackgroundClick(() => { // click empty space clears the highlight
|
|
356
358
|
pinnedNode = null;
|
|
357
|
-
setHighlight(
|
|
359
|
+
setHighlight(null);
|
|
358
360
|
});
|
|
359
361
|
|
|
360
|
-
// Escape clears the
|
|
362
|
+
// Escape clears the click-pinned highlight.
|
|
361
363
|
document.addEventListener('keydown', e => {
|
|
362
|
-
if (e.key === 'Escape') { pinnedNode = null; setHighlight(
|
|
364
|
+
if (e.key === 'Escape') { pinnedNode = null; setHighlight(null); }
|
|
363
365
|
});
|
|
364
366
|
|
|
365
367
|
// refresh() rebuilds graphData → re-seeds layout. Reserved for FILTER changes
|
|
File without changes
|
|
File without changes
|