sql-code-graph 1.36.1__py3-none-any.whl → 1.37.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-code-graph
3
- Version: 1.36.1
3
+ Version: 1.37.0
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,4 +1,4 @@
1
- sqlcg/__init__.py,sha256=1pTarvYKeyADzesOkchbID_Tn8DRtdOiq2qMb535jXQ,116
1
+ sqlcg/__init__.py,sha256=YtrisBBDkZ4Cv_qnAQurX9ewAam7ZI-16EPYK2z4TIQ,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
@@ -44,7 +44,7 @@ sqlcg/lineage/schema_resolver.py,sha256=iXt6LYF6UVWsGUpcfbmjmGn9wCgXl721lTGf_8Aa
44
44
  sqlcg/metrics/__init__.py,sha256=hLJ6wm4St8qqYwKh3o9QG7lcEt1BEYM31ccqO9tGpIg,133
45
45
  sqlcg/metrics/store.py,sha256=KuDtxvyAgug9_KtiSCpvgKM2VZM7VSaI3D11uMLjJJk,10604
46
46
  sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
47
- sqlcg/parsers/ansi_parser.py,sha256=RX6eVj7gt1qmsHNJLAF_a4jyW3RCI5W2oF4rd53cKNg,39336
47
+ sqlcg/parsers/ansi_parser.py,sha256=7pudMxij87m419OcCzmaus124x2vHJ62bIGhBTBlZDw,40348
48
48
  sqlcg/parsers/base.py,sha256=d5s5_LSv96jrww9vx52GujjrLHwpxy_UOhmIlWcKglw,106489
49
49
  sqlcg/parsers/bigquery_parser.py,sha256=g0B6aIpMyxLMVQ3ohAAjzR4nEmMh-WGkFcYLMiKdLxs,3177
50
50
  sqlcg/parsers/dynamic_name.py,sha256=q0QAa9iAcmRW4e_0G2b2j-xTbI3VR1-Wwa-nJRLtrQw,6836
@@ -72,8 +72,8 @@ sqlcg/viz/data.py,sha256=deLWOZBgewM1x-kk1NhUnt0mswyt0nzqSevHzBCOHHc,5899
72
72
  sqlcg/viz/render.py,sha256=BINkGbJbbb_iqhrkN795RaQsdg8nqCiJtsEFF1yo22Y,2737
73
73
  sqlcg/viz/tags.py,sha256=6zRnGlHjuGmEeB6yN1uhzm8rqL7ZGoyL1Ki7jI5oM6A,5368
74
74
  sqlcg/viz/assets/force-graph.min.js,sha256=jNdYdDdrYiUdUlElxRkolPBt30rstQk2q15Q32VVdzc,177272
75
- sqlcg/viz/assets/template.html,sha256=9_j-mvo1ZxwgiJPDdVrNmca37dTrTjjYVd3977u-DxE,12294
76
- sql_code_graph-1.36.1.dist-info/METADATA,sha256=NR-127QkvaJ20LcGYPpd2HRAhZPDXHAPUdjNc0kmp6M,19208
77
- sql_code_graph-1.36.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
78
- sql_code_graph-1.36.1.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
79
- sql_code_graph-1.36.1.dist-info/RECORD,,
75
+ sqlcg/viz/assets/template.html,sha256=48ACoI2EjhITfvxDpSo_Js4OVG3L2BGq56eiwJfBupA,17404
76
+ sql_code_graph-1.37.0.dist-info/METADATA,sha256=_Y3q4uZhPPWEoagUbyK_NMRZrbx5hL1ZeB-mIh-hczg,19208
77
+ sql_code_graph-1.37.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
78
+ sql_code_graph-1.37.0.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
79
+ sql_code_graph-1.37.0.dist-info/RECORD,,
sqlcg/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """SQL Code Graph - SQL lineage and dependency analysis tool."""
2
2
 
3
- __version__ = "1.36.1"
3
+ __version__ = "1.37.0"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -564,12 +564,26 @@ class AnsiParser(SqlParser):
564
564
  # DML (a separate node the gate never touches), so gating PROCEDURE is
565
565
  # edge-neutral. This single gate feeds all three
566
566
  # `referenced_tables.extend(query_node.sources)` sites
567
- # (ansi_parser.py:246, snowflake_parser.py:784 / :944).
567
+ # (ansi_parser.py:246, snowflake_parser.py:894 / :1054).
568
568
  is_non_table_create = isinstance(stmt, exp.Create) and stmt.kind not in ("TABLE", "VIEW")
569
569
 
570
+ # Phantom source-node gate part 2 (#161 part 2,
571
+ # plan/sprints/fix_drop_alter_phantom_source_node.md): DROP / ALTER /
572
+ # TRUNCATE reference their TARGET object, not a data-flow source. No SELECT
573
+ # body is structurally possible, so build_scope() returns None for them and
574
+ # _fallback_table_scan below would otherwise scoop the target name into
575
+ # `sources`, minting a phantom degree-0 island SqlTable node + a phantom
576
+ # SELECTS_FROM edge (measured: 169 sole-DROP/ALTER/TRUNCATE-origin nodes,
577
+ # 1,308 phantom SELECTS_FROM edges on the DWH corpus). Keyed off the AST
578
+ # type (independent of _classify/QueryKind), and PER-STATEMENT: a table
579
+ # legitimately created/read by a *different* statement keeps its node + real
580
+ # edges; only the source the DROP/ALTER/TRUNCATE statement would have
581
+ # contributed is suppressed.
582
+ is_non_flow_ddl = isinstance(stmt, (exp.Drop, exp.Alter, exp.TruncateTable))
583
+
570
584
  # Try to extract table references using scope analysis
571
585
  try:
572
- if is_non_table_create:
586
+ if is_non_table_create or is_non_flow_ddl:
573
587
  sources = []
574
588
  else:
575
589
  # Use pre-built scope if provided, otherwise build it here (fallback)
@@ -29,14 +29,14 @@
29
29
  <div class="facet" id="kind-facet"><b>kind</b></div>
30
30
  <div class="facet" id="tag-facet"><b>tag</b></div>
31
31
  <select id="jobsel"><option value="">all jobs</option></select>
32
- <label>min deg <input id="mindeg" type="number" value="0" min="0" style="width:3.2em"></label>
32
+ <label>min deg <input id="mindeg" type="number" value="1" min="0" style="width:3.2em"></label>
33
33
  <label><input id="egomode" type="checkbox"> ego</label>
34
34
  <label><input id="labelmode" type="checkbox" checked> labels</label>
35
35
  <label><input id="tagcolor" type="checkbox"> color by tag</label>
36
36
  <span id="meta"></span>
37
37
  </div>
38
38
  <div id="graph"></div>
39
- <div id="info">click a node · scroll to zoom · drag to pan · click a chip to toggle, alt/right-click to solo</div>
39
+ <div id="info">hover/click a node to trace its 1-hop lineage (Esc or background clears) · scroll to zoom · drag 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__*/;
@@ -57,6 +57,22 @@ for (const l of DATA.links) {
57
57
  adj.get(s).add(t); adj.get(t).add(s);
58
58
  }
59
59
 
60
+ // ── Neighborhood highlight (headline) ───────────────────────────────────────
61
+ // Pure, side-effect-free: given the adjacency map and a focus id, return the
62
+ // 1-hop highlight set = focus ∪ direct neighbors. Returns an EMPTY Set when id
63
+ // is null (no highlight active). Factored out as a standalone fn so it can be
64
+ // asserted directly in a Node harness (no DOM, no force-graph) — this is the
65
+ // AC2 binding guard that the highlight is wired to the baked adjacency, not an
66
+ // orphaned reference. (Amendment 1.)
67
+ function computeHighlightSet(adjMap, id) {
68
+ const out = new Set();
69
+ if (id == null) return out;
70
+ out.add(id);
71
+ const nbrs = adjMap.get(id);
72
+ if (nbrs) for (const n of nbrs) out.add(n);
73
+ return out;
74
+ }
75
+
60
76
  // ── Schema palette + colors ──────────────────────────────────────────────
61
77
  const SCHEMA_PALETTE = SCHEMA_CONFIG.palette;
62
78
  const SCHEMA_OTHER_COLOR = SCHEMA_CONFIG.other;
@@ -69,10 +85,12 @@ curatedSchemas.forEach((s, i) => schemaColour.set(s, SCHEMA_PALETTE[i % SCHEMA_P
69
85
  dataSchemas.forEach(s => { if (!schemaColour.has(s)) schemaColour.set(s, SCHEMA_OTHER_COLOR); });
70
86
  function schemaFill(n) { return schemaColour.get(n.schema) || SCHEMA_OTHER_COLOR; }
71
87
 
72
- // ── Kind vocabulary (B2): table/view/temp default ON; cte/derived default OFF.
73
- // "external" is never emitted. Unknown kinds bucket into "table". ─────────
88
+ // ── Kind vocabulary: table/view default ON; temp/cte/derived default OFF.
89
+ // "external" is never emitted. Unknown kinds bucket into "table".
90
+ // temp stays in KNOWN_KINDS so it remains a toggleable chip (804 temp nodes
91
+ // at live scale are clutter by default; one chip click reveals them). ──────
74
92
  const KNOWN_KINDS = ['table', 'view', 'temp', 'cte', 'derived'];
75
- const KIND_DEFAULT_ON = new Set(['table', 'view', 'temp']);
93
+ const KIND_DEFAULT_ON = new Set(['table', 'view']);
76
94
  function kindBucket(k) { return KNOWN_KINDS.includes(k) ? k : 'table'; }
77
95
 
78
96
  // ── Facet selection state. Empty selected-set for a facet = "do not filter". ─
@@ -83,8 +101,20 @@ const sel = {
83
101
  };
84
102
  const soloBackup = { schema: null, kind: null, tag: null };
85
103
 
86
- let focusNode = null;
87
- let hoveredNode = null;
104
+ let focusNode = null; // ego-mode focus (orthogonal to highlight)
105
+ let hoveredNode = null; // transient hover target
106
+ let pinnedNode = null; // click-pinned highlight focus (survives cursor leave)
107
+
108
+ // highlightId: pin wins over hover. highlightSet: focus ∪ 1-hop neighbors,
109
+ // recomputed only when the focus changes (cheap; not per frame). These are read
110
+ // by the styling accessors below — repaint is styling-only, NEVER a graphData()
111
+ // rebuild (Amendment 2: a rebuild re-seeds the force layout → the graph jumps).
112
+ let highlightId = null;
113
+ let highlightSet = new Set();
114
+ function setHighlight(id) {
115
+ highlightId = id;
116
+ highlightSet = computeHighlightSet(adj, id);
117
+ }
88
118
 
89
119
  function buildFacet(facetId, key, items, colorOf, defaultOn) {
90
120
  const root = document.getElementById(facetId);
@@ -145,9 +175,15 @@ buildFacet('schema-facet', 'schema', curatedSchemas,
145
175
  s => schemaColour.get(s) || SCHEMA_OTHER_COLOR, () => false);
146
176
  // kind facet
147
177
  buildFacet('kind-facet', 'kind', KNOWN_KINDS, null, k => KIND_DEFAULT_ON.has(k));
148
- // tag facet (legend swatches double as filter controls)
149
- buildFacet('tag-facet', 'tag', TAGS.labels || [],
150
- t => (TAGS.colors || {})[t] || SCHEMA_OTHER_COLOR, () => false);
178
+ // tag facet (legend swatches double as filter controls). When no --tags were
179
+ // passed the label list is empty; hide the whole facet container so a lonely
180
+ // "TAG" header is not shown (Phase 4 empty-facet polish). No JS error either way.
181
+ if ((TAGS.labels || []).length) {
182
+ buildFacet('tag-facet', 'tag', TAGS.labels,
183
+ t => (TAGS.colors || {})[t] || SCHEMA_OTHER_COLOR, () => false);
184
+ } else {
185
+ document.getElementById('tag-facet').style.display = 'none';
186
+ }
151
187
 
152
188
  // ── Job facet → dropdown (filter only). ──────────────────────────────────
153
189
  const jobSel = document.getElementById('jobsel');
@@ -227,24 +263,67 @@ function tagRing(n) {
227
263
 
228
264
  function drawNodeCanvas(node, ctx, globalScale) {
229
265
  const r = Math.max(1, Math.sqrt(node.deg || 1));
266
+ // Highlight-aware alpha: when a highlight is active, nodes outside the 1-hop
267
+ // set dim; nodes inside paint full. No highlight → everything full.
268
+ const inSet = highlightSet.has(node.id);
269
+ const dim = highlightId !== null && !inSet;
270
+ ctx.globalAlpha = dim ? 0.12 : 1;
230
271
  ctx.beginPath();
231
272
  ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
232
273
  ctx.fillStyle = nodeFill(node);
233
274
  ctx.fill();
234
275
  const ring = tagRing(node);
235
- if (ring && !node._hit) {
276
+ if (ring && !node._hit && !dim) {
236
277
  ctx.lineWidth = 1.5 / globalScale;
237
278
  ctx.strokeStyle = ring;
238
279
  ctx.beginPath();
239
280
  ctx.arc(node.x, node.y, r + 1.5 / globalScale, 0, 2 * Math.PI);
240
281
  ctx.stroke();
241
282
  }
283
+ // Focus ring on the highlightId node itself (white, distinct from tag ring).
284
+ if (node.id === highlightId) {
285
+ ctx.lineWidth = 2 / globalScale;
286
+ ctx.strokeStyle = '#ffffff';
287
+ ctx.beginPath();
288
+ ctx.arc(node.x, node.y, r + 3 / globalScale, 0, 2 * Math.PI);
289
+ ctx.stroke();
290
+ }
242
291
  const showLabels = document.getElementById('labelmode').checked;
243
- if (showLabels && (r * globalScale > 6 || node._hit || node.id === hoveredNode)) {
292
+ if (showLabels && !dim
293
+ && (r * globalScale > 6 || node._hit || inSet || node.id === hoveredNode)) {
244
294
  ctx.font = `${10 / globalScale}px system-ui`;
245
295
  ctx.fillStyle = '#c9d1d9';
246
296
  ctx.fillText(node.id, node.x + r + 1, node.y + 3 / globalScale);
247
297
  }
298
+ ctx.globalAlpha = 1;
299
+ }
300
+
301
+ // ── Edge styling (Item 2): legible base edges on #0d1117; dim/bright tiers and
302
+ // directional arrows driven by highlightId/highlightSet. A link is "incident"
303
+ // to the highlight iff BOTH endpoints are in highlightSet (highlightSet is
304
+ // focus + its neighbors, so this is exactly the focus's own edges). ─────────
305
+ function linkEnds(l) {
306
+ return [l.source.id ?? l.source, l.target.id ?? l.target];
307
+ }
308
+ function linkIncident(l) {
309
+ if (highlightId === null) return false;
310
+ const [s, t] = linkEnds(l);
311
+ return highlightSet.has(s) && highlightSet.has(t);
312
+ }
313
+ function linkColor(l) {
314
+ if (highlightId === null) return '#3b4350'; // legible base on dark bg
315
+ if (linkIncident(l)) return '#79c0ff'; // bright (traced lineage)
316
+ return 'rgba(59,67,80,0.08)'; // dimmed away from focus
317
+ }
318
+ function linkWidth(l) {
319
+ if (highlightId !== null && linkIncident(l)) return 2;
320
+ return 1;
321
+ }
322
+ // Directional arrows on HIGHLIGHTED edges ONLY (D3): base state stays clean.
323
+ // DATA.links carry source->target = producer->consumer (data.py), so the arrow
324
+ // points producer→consumer with no data change.
325
+ function linkArrowLen(l) {
326
+ return (highlightId !== null && linkIncident(l)) ? 4 : 0;
248
327
  }
249
328
 
250
329
  // ── Graph init ────────────────────────────────────────────────────────────
@@ -252,12 +331,39 @@ const Graph = ForceGraph()(document.getElementById('graph'))
252
331
  .graphData(currentData())
253
332
  .nodeId('id')
254
333
  .nodeCanvasObject(drawNodeCanvas)
334
+ .linkColor(linkColor)
335
+ .linkWidth(linkWidth)
336
+ .linkDirectionalArrowLength(linkArrowLen)
337
+ .linkDirectionalArrowRelPos(1)
255
338
  .nodeLabel(n => `${n.id}\nschema: ${n.schema} · kind: ${n.kind} · degree: ${n.deg}`
256
339
  + ((n.tags || []).length ? `\ntags: ${n.tags.join(', ')}` : '')
257
340
  + ((n.jobs || []).length ? `\njobs: ${n.jobs.join(', ')}` : ''))
258
- .onNodeClick(n => { focusNode = n.id; refresh(); })
259
- .onNodeHover(n => { hoveredNode = n ? n.id : null; });
341
+ // Highlight handlers mutate ONLY hover/pin state, then recompute highlightSet.
342
+ // They DO NOT call refresh()/graphData() that would re-seed the force layout
343
+ // and make the graph jump at 3069/6671 scale (Amendment 2). force-graph
344
+ // repaints every animation frame, re-reading the closure-visible highlightId/
345
+ // highlightSet, so updating state is enough; ego-mode keeps focusNode separate.
346
+ .onNodeClick(n => {
347
+ pinnedNode = (pinnedNode === n.id) ? null : n.id; // toggle pin
348
+ setHighlight(pinnedNode ?? hoveredNode);
349
+ focusNode = n.id; // ego-mode focus (orthogonal; only used when ego is on)
350
+ })
351
+ .onNodeHover(n => {
352
+ hoveredNode = n ? n.id : null;
353
+ if (pinnedNode === null) setHighlight(hoveredNode); // pin wins over hover
354
+ })
355
+ .onBackgroundClick(() => { // click empty space clears the pin
356
+ pinnedNode = null;
357
+ setHighlight(hoveredNode);
358
+ });
359
+
360
+ // Escape clears the pin (transient hover clears on its own via onNodeHover).
361
+ document.addEventListener('keydown', e => {
362
+ if (e.key === 'Escape') { pinnedNode = null; setHighlight(hoveredNode); }
363
+ });
260
364
 
365
+ // refresh() rebuilds graphData → re-seeds layout. Reserved for FILTER changes
366
+ // (facets / mindeg / search / ego) ONLY, never for highlight (Amendment 2).
261
367
  function refresh() { Graph.graphData(currentData()); }
262
368
 
263
369
  for (const id of ['search', 'mindeg', 'egomode', 'labelmode', 'tagcolor']) {