sql-glider 0.1.21__py3-none-any.whl → 0.1.24__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-glider
3
- Version: 0.1.21
3
+ Version: 0.1.24
4
4
  Summary: SQL Utility Toolkit for better understanding, use, and governance of your queries in a native environment.
5
5
  Project-URL: Homepage, https://github.com/rycowhi/sql-glider/
6
6
  Project-URL: Repository, https://github.com/rycowhi/sql-glider/
@@ -28,6 +28,8 @@ Requires-Dist: sqlglot[rs]>=25.0.0
28
28
  Requires-Dist: typer>=0.9.0
29
29
  Provides-Extra: databricks
30
30
  Requires-Dist: databricks-sdk>=0.20.0; extra == 'databricks'
31
+ Provides-Extra: plotly
32
+ Requires-Dist: plotly>=5.0.0; extra == 'plotly'
31
33
  Description-Content-Type: text/markdown
32
34
 
33
35
  # SQL Glider
@@ -420,12 +422,33 @@ uv run sqlglider graph query graph.json --upstream orders.customer_id -f mermaid
420
422
  # Query with DOT (Graphviz) diagram output
421
423
  uv run sqlglider graph query graph.json --downstream customers.id -f dot
422
424
 
425
+ # Query with Plotly JSON output (for interactive visualization)
426
+ uv run sqlglider graph query graph.json --upstream orders.customer_id -f plotly
427
+
423
428
  # Visualize entire graph as a diagram
424
429
  uv run sqlglider graph visualize graph.json # Mermaid (default)
425
430
  uv run sqlglider graph visualize graph.json -f dot # DOT/Graphviz
431
+ uv run sqlglider graph visualize graph.json -f plotly # Plotly JSON
426
432
  uv run sqlglider graph visualize graph.json -o lineage.mmd # Save to file
427
433
  ```
428
434
 
435
+ > **Note:** Plotly output requires an optional dependency. Install with: `pip install sql-glider[plotly]`
436
+
437
+ The Plotly JSON output can be loaded into Plotly/Dash applications for interactive visualization:
438
+
439
+ ```python
440
+ import plotly.io as pio
441
+ from dash import Dash, dcc, html
442
+
443
+ # Load the JSON output
444
+ with open("lineage.json") as f:
445
+ fig = pio.from_json(f.read())
446
+
447
+ # Use in a Dash app
448
+ app = Dash(__name__)
449
+ app.layout = html.Div([dcc.Graph(figure=fig)])
450
+ ```
451
+
429
452
  **Example Upstream Query Output:**
430
453
  ```
431
454
  Sources for 'order_totals.total'
@@ -711,12 +734,24 @@ Arguments:
711
734
  Options:
712
735
  --upstream, -u Find source columns for this column [optional]
713
736
  --downstream, -d Find affected columns for this source [optional]
714
- --output-format, -f Output format: 'text', 'json', or 'csv' [default: text]
737
+ --output-format, -f Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', 'dot', or 'plotly' [default: text]
738
+ ```
739
+
740
+ ```
741
+ sqlglider graph visualize <graph_file> [OPTIONS]
742
+
743
+ Arguments:
744
+ graph_file Path to graph JSON file [required]
745
+
746
+ Options:
747
+ --output-format, -f Diagram format: 'mermaid', 'mermaid-markdown', 'dot', or 'plotly' [default: mermaid]
748
+ --output-file, -o Write diagram to file instead of stdout [optional]
715
749
  ```
716
750
 
717
751
  **Notes:**
718
752
  - `--upstream` and `--downstream` are mutually exclusive. Use one or the other.
719
753
  - Graph queries are case-insensitive for column matching.
754
+ - Plotly output requires optional dependency: `pip install sql-glider[plotly]`
720
755
 
721
756
  ## Output Formats
722
757
 
@@ -921,6 +956,21 @@ UV_PUBLISH_TOKEN=pypi-...
921
956
  - **pydantic:** Data validation and serialization
922
957
  - **rustworkx:** High-performance graph library for cross-file lineage analysis
923
958
 
959
+ ### Optional Dependencies
960
+
961
+ Install optional features with extras:
962
+
963
+ ```bash
964
+ # Databricks catalog integration
965
+ pip install sql-glider[databricks]
966
+
967
+ # Plotly interactive visualization
968
+ pip install sql-glider[plotly]
969
+
970
+ # Install multiple extras
971
+ pip install sql-glider[databricks,plotly]
972
+ ```
973
+
924
974
  ## References
925
975
 
926
976
  - [SQLGlot Documentation](https://sqlglot.com/)
@@ -1,6 +1,6 @@
1
1
  sqlglider/__init__.py,sha256=gDf7s52dMcX7JuCZ1SLawcB1vb3U0yJCohu9RQAATBY,125
2
- sqlglider/_version.py,sha256=QwRysHnO-cJwIFSvXReHDj93DEPhlibOTtnnb25J9-c,706
3
- sqlglider/cli.py,sha256=jQisnNRvRR5AeBnGF7POtcndMR-7cen7yfp7WDShgO4,68523
2
+ sqlglider/_version.py,sha256=IV4a2R7tlzuACf6FAyPEbprLKNroeE-n_UPSKi1QJSc,706
3
+ sqlglider/cli.py,sha256=oML-M3PW8Cjddii4_Tx_tfYAxYh6Q-inU4eehx0gzC0,69570
4
4
  sqlglider/global_models.py,sha256=2vyJXAuXOsXQpE-D3F0ejj7eR9z0nDWFjTkielhzM8k,356
5
5
  sqlglider/catalog/__init__.py,sha256=2PqFPyzFXJ14FpSUcBmVK2L-a_ypWQHAbHFHxLDk_LE,814
6
6
  sqlglider/catalog/base.py,sha256=R7htHC43InpH4uRjYk33dMYYji6oylHns7Ye_mgfjJE,3116
@@ -12,7 +12,7 @@ sqlglider/dissection/formatters.py,sha256=M7gsmTNljRIeLIRv4D0vHvqJVrTqWSpsg7vem8
12
12
  sqlglider/dissection/models.py,sha256=RRD3RIteqbUBY6e-74skKDvMH3qeAUaqA2sFcrjP5GQ,3618
13
13
  sqlglider/graph/__init__.py,sha256=4DDdrPM75CmeQWt7wHdBsjCm1s70BHGLYdijIbaUEKY,871
14
14
  sqlglider/graph/builder.py,sha256=VNBdsDlkiaId3JGvr2G4h6OIFek_9zPsGMIYL9GpJlk,15796
15
- sqlglider/graph/diagram_formatters.py,sha256=loCpT343Awuj1mue8pv2fNTfwrLfK8n9adlfpPI_OQw,10825
15
+ sqlglider/graph/diagram_formatters.py,sha256=6EdNwU23k4CL3hSBtM1lgZ5Gyc95o7vUMRdQa-a1Fko,22021
16
16
  sqlglider/graph/formatters.py,sha256=p85-WN9oPmEETsAtWSo1sIQELF36w85QoFEJyfBZGoM,4800
17
17
  sqlglider/graph/merge.py,sha256=uUZlm4BN3S9gRL66Cc2mzhbtuh4SVAv2n4cN4eUEQBU,4077
18
18
  sqlglider/graph/models.py,sha256=EYmjv_WzDSNp_WfhJ6H-qBIOkAcoNKS7GRUryfKrHuY,9330
@@ -32,8 +32,8 @@ sqlglider/utils/__init__.py,sha256=KGp9-UzKz_OFBOTFoSy-g-NXDZsvyWXG_9-1zcC6ePE,2
32
32
  sqlglider/utils/config.py,sha256=qx5zE9pjLCCzHQDFVPLVd7LgJ-lghxUa2x-aZOAHByY,4962
33
33
  sqlglider/utils/file_utils.py,sha256=5_ff28E0r1R7emZzsOnRuHd-7zIX6873eyr1SuPEr4E,1093
34
34
  sqlglider/utils/schema.py,sha256=LiWrYDunXKJdoSlpKmIaIQ2hLSaIN1iQHqkXjMpGzRE,1883
35
- sql_glider-0.1.21.dist-info/METADATA,sha256=tceZxbt-9yvvLlQn52RcmixUiPs62HhswbJFiGMksmA,29695
36
- sql_glider-0.1.21.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
37
- sql_glider-0.1.21.dist-info/entry_points.txt,sha256=HDuakHqHS5C0HFKsMIxMYmDU7-BLBGrnIJcYaVRu-s0,251
38
- sql_glider-0.1.21.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
- sql_glider-0.1.21.dist-info/RECORD,,
35
+ sql_glider-0.1.24.dist-info/METADATA,sha256=5giBXU7edyyoMBfbFVficYatA60-To2D_P3eMc6PdQQ,31197
36
+ sql_glider-0.1.24.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
37
+ sql_glider-0.1.24.dist-info/entry_points.txt,sha256=HDuakHqHS5C0HFKsMIxMYmDU7-BLBGrnIJcYaVRu-s0,251
38
+ sql_glider-0.1.24.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
+ sql_glider-0.1.24.dist-info/RECORD,,
sqlglider/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.21'
32
- __version_tuple__ = version_tuple = (0, 1, 21)
31
+ __version__ = version = '0.1.24'
32
+ __version_tuple__ = version_tuple = (0, 1, 24)
33
33
 
34
34
  __commit_id__ = commit_id = None
sqlglider/cli.py CHANGED
@@ -1642,7 +1642,7 @@ def graph_query(
1642
1642
  "text",
1643
1643
  "--output-format",
1644
1644
  "-f",
1645
- help="Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'",
1645
+ help="Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'",
1646
1646
  ),
1647
1647
  ) -> None:
1648
1648
  """
@@ -1661,6 +1661,9 @@ def graph_query(
1661
1661
 
1662
1662
  # CSV output
1663
1663
  sqlglider graph query graph.json --downstream orders.order_id -f csv
1664
+
1665
+ # Plotly JSON output (for Dash/Plotly visualization)
1666
+ sqlglider graph query graph.json --upstream orders.total -f plotly
1664
1667
  """
1665
1668
  from sqlglider.graph.query import GraphQuerier
1666
1669
 
@@ -1678,10 +1681,18 @@ def graph_query(
1678
1681
  )
1679
1682
  raise typer.Exit(1)
1680
1683
 
1681
- if output_format not in ["text", "json", "csv", "mermaid", "mermaid-markdown", "dot"]:
1684
+ if output_format not in [
1685
+ "text",
1686
+ "json",
1687
+ "csv",
1688
+ "mermaid",
1689
+ "mermaid-markdown",
1690
+ "dot",
1691
+ "plotly",
1692
+ ]:
1682
1693
  err_console.print(
1683
1694
  f"[red]Error:[/red] Invalid output format '{output_format}'. "
1684
- "Use 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'."
1695
+ "Use 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'."
1685
1696
  )
1686
1697
  raise typer.Exit(1)
1687
1698
 
@@ -1713,6 +1724,14 @@ def graph_query(
1713
1724
  from sqlglider.graph.diagram_formatters import DotFormatter
1714
1725
 
1715
1726
  print(DotFormatter.format_query_result(result))
1727
+ elif output_format == "plotly":
1728
+ from sqlglider.graph.diagram_formatters import PlotlyFormatter
1729
+
1730
+ print(PlotlyFormatter.format_query_result(result))
1731
+
1732
+ except ImportError as e:
1733
+ err_console.print(f"[red]Error:[/red] {e}")
1734
+ raise typer.Exit(1)
1716
1735
 
1717
1736
  except FileNotFoundError as e:
1718
1737
  err_console.print(f"[red]Error:[/red] {e}")
@@ -1818,7 +1837,7 @@ def graph_visualize(
1818
1837
  "mermaid",
1819
1838
  "--output-format",
1820
1839
  "-f",
1821
- help="Diagram format: 'mermaid', 'mermaid-markdown', or 'dot'",
1840
+ help="Diagram format: 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'",
1822
1841
  ),
1823
1842
  output_file: Optional[Path] = typer.Option(
1824
1843
  None,
@@ -1830,8 +1849,8 @@ def graph_visualize(
1830
1849
  """
1831
1850
  Visualize the entire lineage graph as a diagram.
1832
1851
 
1833
- Generates Mermaid or DOT (Graphviz) diagrams showing all nodes and edges
1834
- in the graph for visualization tools.
1852
+ Generates Mermaid, DOT (Graphviz), or Plotly JSON diagrams showing all
1853
+ nodes and edges in the graph for visualization tools.
1835
1854
 
1836
1855
  Examples:
1837
1856
 
@@ -1846,18 +1865,16 @@ def graph_visualize(
1846
1865
 
1847
1866
  # Render DOT to PNG with Graphviz
1848
1867
  sqlglider graph visualize graph.json -f dot -o lineage.dot
1868
+
1869
+ # Generate Plotly JSON for Dash/Plotly apps
1870
+ sqlglider graph visualize graph.json -f plotly -o lineage.json
1849
1871
  """
1850
- from sqlglider.graph.diagram_formatters import (
1851
- DotFormatter,
1852
- MermaidFormatter,
1853
- MermaidMarkdownFormatter,
1854
- )
1855
1872
  from sqlglider.graph.serialization import load_graph
1856
1873
 
1857
- if output_format not in ["mermaid", "mermaid-markdown", "dot"]:
1874
+ if output_format not in ["mermaid", "mermaid-markdown", "dot", "plotly"]:
1858
1875
  err_console.print(
1859
1876
  f"[red]Error:[/red] Invalid output format '{output_format}'. "
1860
- "Use 'mermaid', 'mermaid-markdown', or 'dot'."
1877
+ "Use 'mermaid', 'mermaid-markdown', 'dot', or 'plotly'."
1861
1878
  )
1862
1879
  raise typer.Exit(1)
1863
1880
 
@@ -1865,20 +1882,32 @@ def graph_visualize(
1865
1882
  graph = load_graph(graph_file)
1866
1883
 
1867
1884
  if output_format == "mermaid":
1885
+ from sqlglider.graph.diagram_formatters import MermaidFormatter
1886
+
1868
1887
  diagram = MermaidFormatter.format_full_graph(graph)
1869
1888
  elif output_format == "mermaid-markdown":
1889
+ from sqlglider.graph.diagram_formatters import MermaidMarkdownFormatter
1890
+
1870
1891
  diagram = MermaidMarkdownFormatter.format_full_graph(graph)
1871
- else:
1892
+ elif output_format == "dot":
1893
+ from sqlglider.graph.diagram_formatters import DotFormatter
1894
+
1872
1895
  diagram = DotFormatter.format_full_graph(graph)
1896
+ else: # plotly
1897
+ from sqlglider.graph.diagram_formatters import PlotlyFormatter
1898
+
1899
+ diagram = PlotlyFormatter.format_full_graph(graph)
1873
1900
 
1874
1901
  if output_file:
1875
1902
  output_file.write_text(diagram, encoding="utf-8")
1876
- console.print(
1877
- f"[green]Success:[/green] Diagram written to {output_file}"
1878
- )
1903
+ console.print(f"[green]Success:[/green] Diagram written to {output_file}")
1879
1904
  else:
1880
1905
  print(diagram)
1881
1906
 
1907
+ except ImportError as e:
1908
+ err_console.print(f"[red]Error:[/red] {e}")
1909
+ raise typer.Exit(1)
1910
+
1882
1911
  except FileNotFoundError as e:
1883
1912
  err_console.print(f"[red]Error:[/red] {e}")
1884
1913
  raise typer.Exit(1)
@@ -1,7 +1,9 @@
1
- """Diagram formatters for lineage graphs (Mermaid and DOT/Graphviz)."""
1
+ """Diagram formatters for lineage graphs (Mermaid, DOT/Graphviz, and Plotly)."""
2
2
 
3
+ import json
3
4
  import re
4
- from typing import Set
5
+ from collections import defaultdict
6
+ from typing import Any, Set
5
7
 
6
8
  from sqlglider.graph.models import LineageGraph
7
9
  from sqlglider.graph.query import LineageQueryResult
@@ -328,3 +330,329 @@ class DotFormatter:
328
330
 
329
331
  lines.append("}")
330
332
  return "\n".join(lines)
333
+
334
+
335
+ def _compute_layered_layout(
336
+ nodes: list[str],
337
+ edges: list[tuple[str, str]],
338
+ ) -> dict[str, tuple[float, float]]:
339
+ """Compute layered layout positions for nodes using topological ordering.
340
+
341
+ Positions nodes in layers from left to right based on their dependencies.
342
+ Nodes with no incoming edges are placed in layer 0, their dependents in
343
+ layer 1, etc.
344
+
345
+ Args:
346
+ nodes: List of node identifiers
347
+ edges: List of (source, target) edge tuples
348
+
349
+ Returns:
350
+ Dictionary mapping node identifiers to (x, y) positions
351
+ """
352
+ if not nodes:
353
+ return {}
354
+
355
+ # Build adjacency structures
356
+ incoming: dict[str, set[str]] = defaultdict(set)
357
+ outgoing: dict[str, set[str]] = defaultdict(set)
358
+ for src, tgt in edges:
359
+ outgoing[src].add(tgt)
360
+ incoming[tgt].add(src)
361
+
362
+ # Assign layers via modified Kahn's algorithm
363
+ layers: dict[str, int] = {}
364
+ node_set = set(nodes)
365
+
366
+ # Start with nodes that have no incoming edges (roots)
367
+ current_layer = [n for n in nodes if not incoming[n]]
368
+ if not current_layer:
369
+ # Cycle detected or all nodes have incoming edges, use first node
370
+ current_layer = [nodes[0]]
371
+
372
+ layer_num = 0
373
+ while current_layer:
374
+ next_layer = []
375
+ for node in current_layer:
376
+ if node not in layers:
377
+ layers[node] = layer_num
378
+ for child in outgoing[node]:
379
+ if child in node_set and child not in layers:
380
+ # Check if all parents are assigned
381
+ if all(p in layers for p in incoming[child]):
382
+ next_layer.append(child)
383
+ layer_num += 1
384
+ current_layer = next_layer
385
+
386
+ # Assign any remaining unvisited nodes to the last layer
387
+ for node in nodes:
388
+ if node not in layers:
389
+ layers[node] = layer_num
390
+
391
+ # Group nodes by layer for vertical positioning
392
+ layer_groups: dict[int, list[str]] = defaultdict(list)
393
+ for node, layer in layers.items():
394
+ layer_groups[layer].append(node)
395
+
396
+ # Compute positions: x based on layer, y spread vertically within layer
397
+ positions: dict[str, tuple[float, float]] = {}
398
+ max_layer = max(layers.values()) if layers else 0
399
+ x_spacing = 1.0 if max_layer == 0 else 1.0
400
+
401
+ for layer, layer_nodes in layer_groups.items():
402
+ x = layer * x_spacing
403
+ n = len(layer_nodes)
404
+ for i, node in enumerate(sorted(layer_nodes)):
405
+ # Center nodes vertically, spread them out
406
+ y = (i - (n - 1) / 2) * 0.5
407
+ positions[node] = (x, y)
408
+
409
+ return positions
410
+
411
+
412
+ class PlotlyFormatter:
413
+ """Format lineage graphs as Plotly JSON figure specifications.
414
+
415
+ Generates JSON that can be loaded into Plotly/Dash applications using
416
+ plotly.io.from_json() or directly into dcc.Graph components.
417
+
418
+ Requires the 'plotly' optional dependency: pip install sql-glider[plotly]
419
+ """
420
+
421
+ @staticmethod
422
+ def _check_plotly_available() -> None:
423
+ """Check if plotly is installed, raise ImportError if not."""
424
+ try:
425
+ import plotly # noqa: F401
426
+ except ImportError:
427
+ raise ImportError(
428
+ "Plotly is required for this output format. "
429
+ "Install it with: pip install sql-glider[plotly]"
430
+ )
431
+
432
+ @staticmethod
433
+ def format_full_graph(graph: LineageGraph) -> str:
434
+ """Format complete lineage graph as a Plotly JSON figure.
435
+
436
+ Args:
437
+ graph: LineageGraph with all nodes and edges
438
+
439
+ Returns:
440
+ JSON string representing a Plotly figure specification
441
+ """
442
+ PlotlyFormatter._check_plotly_available()
443
+
444
+ node_ids = [n.identifier for n in graph.nodes]
445
+ edge_tuples = [(e.source_node, e.target_node) for e in graph.edges]
446
+
447
+ if not node_ids:
448
+ # Empty graph
449
+ figure: dict[str, Any] = {
450
+ "data": [],
451
+ "layout": {
452
+ "title": {"text": "Lineage Graph"},
453
+ "showlegend": False,
454
+ "xaxis": {"visible": False},
455
+ "yaxis": {"visible": False},
456
+ },
457
+ }
458
+ return json.dumps(figure, indent=2)
459
+
460
+ positions = _compute_layered_layout(node_ids, edge_tuples)
461
+
462
+ # Build edge traces (one trace per edge for simplicity)
463
+ edge_traces: list[dict[str, Any]] = []
464
+ for src, tgt in edge_tuples:
465
+ if src in positions and tgt in positions:
466
+ x0, y0 = positions[src]
467
+ x1, y1 = positions[tgt]
468
+ edge_traces.append(
469
+ {
470
+ "type": "scatter",
471
+ "x": [x0, x1, None],
472
+ "y": [y0, y1, None],
473
+ "mode": "lines",
474
+ "line": {"width": 1, "color": "#888"},
475
+ "hoverinfo": "none",
476
+ "showlegend": False,
477
+ }
478
+ )
479
+
480
+ # Build node trace
481
+ node_x = [positions[n][0] for n in node_ids if n in positions]
482
+ node_y = [positions[n][1] for n in node_ids if n in positions]
483
+ node_text = [n for n in node_ids if n in positions]
484
+
485
+ node_trace: dict[str, Any] = {
486
+ "type": "scatter",
487
+ "x": node_x,
488
+ "y": node_y,
489
+ "mode": "markers+text",
490
+ "text": node_text,
491
+ "textposition": "top center",
492
+ "hoverinfo": "text",
493
+ "marker": {
494
+ "size": 20,
495
+ "color": "#6495ED",
496
+ "line": {"width": 2, "color": "#4169E1"},
497
+ },
498
+ "showlegend": False,
499
+ }
500
+
501
+ figure = {
502
+ "data": edge_traces + [node_trace],
503
+ "layout": {
504
+ "title": {"text": "Lineage Graph"},
505
+ "showlegend": False,
506
+ "hovermode": "closest",
507
+ "xaxis": {"visible": False},
508
+ "yaxis": {"visible": False},
509
+ "margin": {"l": 40, "r": 40, "t": 60, "b": 40},
510
+ },
511
+ }
512
+
513
+ return json.dumps(figure, indent=2)
514
+
515
+ @staticmethod
516
+ def format_query_result(result: LineageQueryResult) -> str:
517
+ """Format query result as a Plotly JSON figure with styling.
518
+
519
+ The queried column is highlighted in amber, root nodes in teal,
520
+ and leaf nodes in violet, matching the Mermaid/DOT color scheme.
521
+
522
+ Args:
523
+ result: LineageQueryResult from upstream/downstream query
524
+
525
+ Returns:
526
+ JSON string representing a Plotly figure specification
527
+ """
528
+ PlotlyFormatter._check_plotly_available()
529
+
530
+ all_nodes = _collect_query_nodes(result)
531
+ edges = _collect_query_edges(result)
532
+
533
+ if not all_nodes:
534
+ # Should not happen, but handle gracefully
535
+ figure: dict[str, Any] = {
536
+ "data": [],
537
+ "layout": {
538
+ "title": {"text": f"Lineage: {result.query_column}"},
539
+ "showlegend": False,
540
+ "xaxis": {"visible": False},
541
+ "yaxis": {"visible": False},
542
+ },
543
+ }
544
+ return json.dumps(figure, indent=2)
545
+
546
+ node_list = sorted(all_nodes)
547
+ edge_list = list(edges)
548
+ positions = _compute_layered_layout(node_list, edge_list)
549
+
550
+ # Build styling lookup
551
+ root_ids: set[str] = set()
552
+ leaf_ids: set[str] = set()
553
+ for node in result.related_columns:
554
+ if node.is_root:
555
+ root_ids.add(node.identifier)
556
+ if node.is_leaf:
557
+ leaf_ids.add(node.identifier)
558
+
559
+ # Determine node colors
560
+ node_colors: list[str] = []
561
+ for node in node_list:
562
+ if node == result.query_column:
563
+ node_colors.append(QUERIED_FILL)
564
+ elif node in root_ids:
565
+ node_colors.append(ROOT_FILL)
566
+ elif node in leaf_ids:
567
+ node_colors.append(LEAF_FILL)
568
+ else:
569
+ node_colors.append("#6495ED") # Default blue
570
+
571
+ # Build edge traces
572
+ edge_traces: list[dict[str, Any]] = []
573
+ for src, tgt in sorted(edges):
574
+ if src in positions and tgt in positions:
575
+ x0, y0 = positions[src]
576
+ x1, y1 = positions[tgt]
577
+ edge_traces.append(
578
+ {
579
+ "type": "scatter",
580
+ "x": [x0, x1, None],
581
+ "y": [y0, y1, None],
582
+ "mode": "lines",
583
+ "line": {"width": 1.5, "color": "#888"},
584
+ "hoverinfo": "none",
585
+ "showlegend": False,
586
+ }
587
+ )
588
+
589
+ # Build node trace
590
+ node_x = [positions[n][0] for n in node_list if n in positions]
591
+ node_y = [positions[n][1] for n in node_list if n in positions]
592
+
593
+ node_trace: dict[str, Any] = {
594
+ "type": "scatter",
595
+ "x": node_x,
596
+ "y": node_y,
597
+ "mode": "markers+text",
598
+ "text": node_list,
599
+ "textposition": "top center",
600
+ "hoverinfo": "text",
601
+ "marker": {
602
+ "size": 20,
603
+ "color": node_colors,
604
+ "line": {"width": 2, "color": "#333"},
605
+ },
606
+ "showlegend": False,
607
+ }
608
+
609
+ # Build legend traces (invisible markers for legend display)
610
+ legend_traces: list[dict[str, Any]] = [
611
+ {
612
+ "type": "scatter",
613
+ "x": [None],
614
+ "y": [None],
615
+ "mode": "markers",
616
+ "marker": {"size": 12, "color": QUERIED_FILL},
617
+ "name": "Queried Column",
618
+ "showlegend": True,
619
+ },
620
+ {
621
+ "type": "scatter",
622
+ "x": [None],
623
+ "y": [None],
624
+ "mode": "markers",
625
+ "marker": {"size": 12, "color": ROOT_FILL},
626
+ "name": "Root (no upstream)",
627
+ "showlegend": True,
628
+ },
629
+ {
630
+ "type": "scatter",
631
+ "x": [None],
632
+ "y": [None],
633
+ "mode": "markers",
634
+ "marker": {"size": 12, "color": LEAF_FILL},
635
+ "name": "Leaf (no downstream)",
636
+ "showlegend": True,
637
+ },
638
+ ]
639
+
640
+ direction_label = (
641
+ "Upstream" if result.direction == "upstream" else "Downstream"
642
+ )
643
+ title = f"{direction_label} Lineage: {result.query_column}"
644
+
645
+ figure = {
646
+ "data": edge_traces + [node_trace] + legend_traces,
647
+ "layout": {
648
+ "title": {"text": title},
649
+ "showlegend": True,
650
+ "legend": {"x": 1, "y": 1, "xanchor": "right"},
651
+ "hovermode": "closest",
652
+ "xaxis": {"visible": False},
653
+ "yaxis": {"visible": False},
654
+ "margin": {"l": 40, "r": 40, "t": 60, "b": 40},
655
+ },
656
+ }
657
+
658
+ return json.dumps(figure, indent=2)