sql-glider 0.1.20__py3-none-any.whl → 0.1.23__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.20
3
+ Version: 0.1.23
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/
@@ -32,8 +32,12 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  # SQL Glider
34
34
 
35
+ ![SQL Glider Logo](./docs/docs/static/sqlglider-logo-transparent.png)
36
+
35
37
  SQL Utility Toolkit for better understanding, use, and governance of your queries in a native environment.
36
38
 
39
+ **[Read the docs](https://rycowhi.github.io/sql-glider/)**
40
+
37
41
  ## Overview
38
42
 
39
43
  SQL Glider provides powerful column-level and table-level lineage analysis for SQL queries using SQLGlot. It operates on standalone SQL files without requiring a full project setup, making it perfect for ad-hoc analysis, data governance, and understanding query dependencies.
@@ -409,6 +413,17 @@ uv run sqlglider graph query graph.json --upstream orders.customer_id
409
413
 
410
414
  # Query downstream dependencies (find all columns affected by a source)
411
415
  uv run sqlglider graph query graph.json --downstream customers.id
416
+
417
+ # Query with Mermaid diagram output
418
+ uv run sqlglider graph query graph.json --upstream orders.customer_id -f mermaid
419
+
420
+ # Query with DOT (Graphviz) diagram output
421
+ uv run sqlglider graph query graph.json --downstream customers.id -f dot
422
+
423
+ # Visualize entire graph as a diagram
424
+ uv run sqlglider graph visualize graph.json # Mermaid (default)
425
+ uv run sqlglider graph visualize graph.json -f dot # DOT/Graphviz
426
+ uv run sqlglider graph visualize graph.json -o lineage.mmd # Save to file
412
427
  ```
413
428
 
414
429
  **Example Upstream Query Output:**
@@ -796,6 +811,31 @@ src/sqlglider/
796
811
  └── file_utils.py # File I/O utilities
797
812
  ```
798
813
 
814
+ ## Documentation
815
+
816
+ Project documentation is built with [Zensical](https://zensical.org/) and lives in the `docs/` directory:
817
+
818
+ ```
819
+ docs/
820
+ ├── zensical.toml # Site configuration (name, theme, features)
821
+ └── docs/ # Markdown content
822
+ └── index.md # Landing page
823
+ ```
824
+
825
+ To preview docs locally:
826
+
827
+ ```bash
828
+ cd docs && uv run zensical serve
829
+ ```
830
+
831
+ To build the static site:
832
+
833
+ ```bash
834
+ cd docs && uv run zensical build
835
+ ```
836
+
837
+ The built site outputs to `docs/site/` (git-ignored). Documentation is automatically deployed to GitHub Pages on pushes to `main` via the `docs.yml` workflow.
838
+
799
839
  ## Publishing
800
840
 
801
841
  SQL Glider is configured for publishing to both TestPyPI and PyPI using `uv`.
@@ -1,6 +1,6 @@
1
1
  sqlglider/__init__.py,sha256=gDf7s52dMcX7JuCZ1SLawcB1vb3U0yJCohu9RQAATBY,125
2
- sqlglider/_version.py,sha256=P88Jfo9OvOr8LB0vHFqLUwFR7A0eE281KxEewbcCSBc,706
3
- sqlglider/cli.py,sha256=9zNMaw3rgcqb6uG05VJTYbLUXmZzdX87gAOJ4Zg3xjY,65319
2
+ sqlglider/_version.py,sha256=eZj6tqY-zTtH5r_8y6a4Vovz6LQ_hDHSesTIiwuyahQ,706
3
+ sqlglider/cli.py,sha256=yvCkwU75aCSwExXcu0xHpMMI2G5X6L-DViP44nUgs5A,68548
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,6 +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
16
  sqlglider/graph/formatters.py,sha256=p85-WN9oPmEETsAtWSo1sIQELF36w85QoFEJyfBZGoM,4800
16
17
  sqlglider/graph/merge.py,sha256=uUZlm4BN3S9gRL66Cc2mzhbtuh4SVAv2n4cN4eUEQBU,4077
17
18
  sqlglider/graph/models.py,sha256=EYmjv_WzDSNp_WfhJ6H-qBIOkAcoNKS7GRUryfKrHuY,9330
@@ -31,8 +32,8 @@ sqlglider/utils/__init__.py,sha256=KGp9-UzKz_OFBOTFoSy-g-NXDZsvyWXG_9-1zcC6ePE,2
31
32
  sqlglider/utils/config.py,sha256=qx5zE9pjLCCzHQDFVPLVd7LgJ-lghxUa2x-aZOAHByY,4962
32
33
  sqlglider/utils/file_utils.py,sha256=5_ff28E0r1R7emZzsOnRuHd-7zIX6873eyr1SuPEr4E,1093
33
34
  sqlglider/utils/schema.py,sha256=LiWrYDunXKJdoSlpKmIaIQ2hLSaIN1iQHqkXjMpGzRE,1883
34
- sql_glider-0.1.20.dist-info/METADATA,sha256=tC3YZY7IuxfVzi7GvYEw_2JAD20rPwfWecg2YHMS3Pg,28446
35
- sql_glider-0.1.20.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
36
- sql_glider-0.1.20.dist-info/entry_points.txt,sha256=HDuakHqHS5C0HFKsMIxMYmDU7-BLBGrnIJcYaVRu-s0,251
37
- sql_glider-0.1.20.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
38
- sql_glider-0.1.20.dist-info/RECORD,,
35
+ sql_glider-0.1.23.dist-info/METADATA,sha256=kus6plflZU9p594ooQRsVkeYgEPyW8V_hbvCyMUGid8,29695
36
+ sql_glider-0.1.23.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
37
+ sql_glider-0.1.23.dist-info/entry_points.txt,sha256=HDuakHqHS5C0HFKsMIxMYmDU7-BLBGrnIJcYaVRu-s0,251
38
+ sql_glider-0.1.23.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
+ sql_glider-0.1.23.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.20'
32
- __version_tuple__ = version_tuple = (0, 1, 20)
31
+ __version__ = version = '0.1.23'
32
+ __version_tuple__ = version_tuple = (0, 1, 23)
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', or 'csv'",
1645
+ help="Output format: 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'",
1646
1646
  ),
1647
1647
  ) -> None:
1648
1648
  """
@@ -1678,10 +1678,17 @@ def graph_query(
1678
1678
  )
1679
1679
  raise typer.Exit(1)
1680
1680
 
1681
- if output_format not in ["text", "json", "csv"]:
1681
+ if output_format not in [
1682
+ "text",
1683
+ "json",
1684
+ "csv",
1685
+ "mermaid",
1686
+ "mermaid-markdown",
1687
+ "dot",
1688
+ ]:
1682
1689
  err_console.print(
1683
1690
  f"[red]Error:[/red] Invalid output format '{output_format}'. "
1684
- "Use 'text', 'json', or 'csv'."
1691
+ "Use 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'."
1685
1692
  )
1686
1693
  raise typer.Exit(1)
1687
1694
 
@@ -1699,8 +1706,20 @@ def graph_query(
1699
1706
  _format_query_result_text(result)
1700
1707
  elif output_format == "json":
1701
1708
  _format_query_result_json(result)
1702
- else: # csv
1709
+ elif output_format == "csv":
1703
1710
  _format_query_result_csv(result)
1711
+ elif output_format == "mermaid":
1712
+ from sqlglider.graph.diagram_formatters import MermaidFormatter
1713
+
1714
+ print(MermaidFormatter.format_query_result(result))
1715
+ elif output_format == "mermaid-markdown":
1716
+ from sqlglider.graph.diagram_formatters import MermaidMarkdownFormatter
1717
+
1718
+ print(MermaidMarkdownFormatter.format_query_result(result))
1719
+ elif output_format == "dot":
1720
+ from sqlglider.graph.diagram_formatters import DotFormatter
1721
+
1722
+ print(DotFormatter.format_query_result(result))
1704
1723
 
1705
1724
  except FileNotFoundError as e:
1706
1725
  err_console.print(f"[red]Error:[/red] {e}")
@@ -1795,6 +1814,89 @@ def _format_query_result_csv(result) -> None:
1795
1814
  )
1796
1815
 
1797
1816
 
1817
+ @graph_app.command("visualize")
1818
+ def graph_visualize(
1819
+ graph_file: Path = typer.Argument(
1820
+ ...,
1821
+ exists=True,
1822
+ help="Path to graph JSON file",
1823
+ ),
1824
+ output_format: str = typer.Option(
1825
+ "mermaid",
1826
+ "--output-format",
1827
+ "-f",
1828
+ help="Diagram format: 'mermaid', 'mermaid-markdown', or 'dot'",
1829
+ ),
1830
+ output_file: Optional[Path] = typer.Option(
1831
+ None,
1832
+ "--output-file",
1833
+ "-o",
1834
+ help="Write diagram to file instead of stdout",
1835
+ ),
1836
+ ) -> None:
1837
+ """
1838
+ Visualize the entire lineage graph as a diagram.
1839
+
1840
+ Generates Mermaid or DOT (Graphviz) diagrams showing all nodes and edges
1841
+ in the graph for visualization tools.
1842
+
1843
+ Examples:
1844
+
1845
+ # Generate Mermaid diagram
1846
+ sqlglider graph visualize graph.json
1847
+
1848
+ # Generate DOT diagram for Graphviz
1849
+ sqlglider graph visualize graph.json -f dot
1850
+
1851
+ # Save to file
1852
+ sqlglider graph visualize graph.json -o lineage.mmd
1853
+
1854
+ # Render DOT to PNG with Graphviz
1855
+ sqlglider graph visualize graph.json -f dot -o lineage.dot
1856
+ """
1857
+ from sqlglider.graph.diagram_formatters import (
1858
+ DotFormatter,
1859
+ MermaidFormatter,
1860
+ MermaidMarkdownFormatter,
1861
+ )
1862
+ from sqlglider.graph.serialization import load_graph
1863
+
1864
+ if output_format not in ["mermaid", "mermaid-markdown", "dot"]:
1865
+ err_console.print(
1866
+ f"[red]Error:[/red] Invalid output format '{output_format}'. "
1867
+ "Use 'mermaid', 'mermaid-markdown', or 'dot'."
1868
+ )
1869
+ raise typer.Exit(1)
1870
+
1871
+ try:
1872
+ graph = load_graph(graph_file)
1873
+
1874
+ if output_format == "mermaid":
1875
+ diagram = MermaidFormatter.format_full_graph(graph)
1876
+ elif output_format == "mermaid-markdown":
1877
+ diagram = MermaidMarkdownFormatter.format_full_graph(graph)
1878
+ else:
1879
+ diagram = DotFormatter.format_full_graph(graph)
1880
+
1881
+ if output_file:
1882
+ output_file.write_text(diagram, encoding="utf-8")
1883
+ console.print(f"[green]Success:[/green] Diagram written to {output_file}")
1884
+ else:
1885
+ print(diagram)
1886
+
1887
+ except FileNotFoundError as e:
1888
+ err_console.print(f"[red]Error:[/red] {e}")
1889
+ raise typer.Exit(1)
1890
+
1891
+ except ValueError as e:
1892
+ err_console.print(f"[red]Error:[/red] {e}")
1893
+ raise typer.Exit(1)
1894
+
1895
+ except Exception as e:
1896
+ err_console.print(f"[red]Error:[/red] Unexpected error: {e}")
1897
+ raise typer.Exit(1)
1898
+
1899
+
1798
1900
  @app.command()
1799
1901
  def dissect(
1800
1902
  sql_file: Annotated[
@@ -0,0 +1,330 @@
1
+ """Diagram formatters for lineage graphs (Mermaid and DOT/Graphviz)."""
2
+
3
+ import re
4
+ from typing import Set
5
+
6
+ from sqlglider.graph.models import LineageGraph
7
+ from sqlglider.graph.query import LineageQueryResult
8
+
9
+ # Color palette (muted jewel tones for light/dark mode compatibility)
10
+ QUERIED_FILL = "#e6a843"
11
+ QUERIED_STROKE = "#b8860b"
12
+ ROOT_FILL = "#4ecdc4"
13
+ ROOT_STROKE = "#2b9e96"
14
+ LEAF_FILL = "#c084fc"
15
+ LEAF_STROKE = "#7c3aed"
16
+
17
+
18
+ def _sanitize_mermaid_id(identifier: str) -> str:
19
+ """Sanitize an identifier for use as a Mermaid node ID.
20
+
21
+ Replaces non-alphanumeric characters with underscores.
22
+
23
+ Args:
24
+ identifier: Raw node identifier (e.g., "schema.table.column")
25
+
26
+ Returns:
27
+ Sanitized ID safe for Mermaid syntax
28
+ """
29
+ return re.sub(r"[^a-zA-Z0-9_]", "_", identifier)
30
+
31
+
32
+ def _quote_dot_id(identifier: str) -> str:
33
+ """Quote an identifier for use in DOT syntax.
34
+
35
+ Args:
36
+ identifier: Raw node identifier
37
+
38
+ Returns:
39
+ Double-quoted identifier with internal quotes escaped
40
+ """
41
+ escaped = identifier.replace("\\", "\\\\").replace('"', '\\"')
42
+ return f'"{escaped}"'
43
+
44
+
45
+ def _collect_query_edges(result: LineageQueryResult) -> Set[tuple[str, str]]:
46
+ """Extract unique directed edges from all paths in a query result.
47
+
48
+ Args:
49
+ result: Query result containing paths
50
+
51
+ Returns:
52
+ Set of (source, target) identifier pairs
53
+ """
54
+ edges: Set[tuple[str, str]] = set()
55
+ for node in result.related_columns:
56
+ for path in node.paths:
57
+ for i in range(len(path.nodes) - 1):
58
+ edges.add((path.nodes[i], path.nodes[i + 1]))
59
+ return edges
60
+
61
+
62
+ def _collect_query_nodes(result: LineageQueryResult) -> Set[str]:
63
+ """Extract all unique node identifiers from query result paths.
64
+
65
+ Args:
66
+ result: Query result containing paths
67
+
68
+ Returns:
69
+ Set of node identifiers
70
+ """
71
+ nodes: Set[str] = set()
72
+ for node in result.related_columns:
73
+ for path in node.paths:
74
+ nodes.update(path.nodes)
75
+ # Always include the queried column itself
76
+ nodes.add(result.query_column)
77
+ return nodes
78
+
79
+
80
+ class MermaidFormatter:
81
+ """Format lineage graphs and query results as Mermaid diagrams."""
82
+
83
+ @staticmethod
84
+ def format_full_graph(graph: LineageGraph) -> str:
85
+ """Format complete lineage graph as a Mermaid flowchart.
86
+
87
+ Args:
88
+ graph: LineageGraph with all nodes and edges
89
+
90
+ Returns:
91
+ Mermaid diagram string (flowchart TD syntax)
92
+ """
93
+ lines = ["flowchart TD"]
94
+
95
+ if not graph.nodes and not graph.edges:
96
+ return "\n".join(lines)
97
+
98
+ # Declare nodes with labels
99
+ for node in graph.nodes:
100
+ node_id = _sanitize_mermaid_id(node.identifier)
101
+ lines.append(f' {node_id}["{node.identifier}"]')
102
+
103
+ # Add edges
104
+ for edge in graph.edges:
105
+ src = _sanitize_mermaid_id(edge.source_node)
106
+ tgt = _sanitize_mermaid_id(edge.target_node)
107
+ lines.append(f" {src} --> {tgt}")
108
+
109
+ return "\n".join(lines)
110
+
111
+ @staticmethod
112
+ def format_query_result(result: LineageQueryResult) -> str:
113
+ """Format query result as a Mermaid flowchart with styling.
114
+
115
+ The queried column is highlighted in amber, root nodes in teal,
116
+ and leaf nodes in violet. A legend subgraph is included.
117
+
118
+ Args:
119
+ result: LineageQueryResult from upstream/downstream query
120
+
121
+ Returns:
122
+ Mermaid diagram string with style directives and legend
123
+ """
124
+ lines = ["flowchart TD"]
125
+
126
+ if not result.related_columns:
127
+ # Show just the queried node
128
+ node_id = _sanitize_mermaid_id(result.query_column)
129
+ lines.append(f' {node_id}["{result.query_column}"]')
130
+ return "\n".join(lines)
131
+
132
+ all_nodes = _collect_query_nodes(result)
133
+ edges = _collect_query_edges(result)
134
+
135
+ # Declare nodes
136
+ for identifier in sorted(all_nodes):
137
+ node_id = _sanitize_mermaid_id(identifier)
138
+ lines.append(f' {node_id}["{identifier}"]')
139
+
140
+ # Add edges
141
+ for src, tgt in sorted(edges):
142
+ lines.append(
143
+ f" {_sanitize_mermaid_id(src)} --> {_sanitize_mermaid_id(tgt)}"
144
+ )
145
+
146
+ # Style directives
147
+ queried_id = _sanitize_mermaid_id(result.query_column)
148
+ lines.append(
149
+ f" style {queried_id} fill:{QUERIED_FILL},stroke:{QUERIED_STROKE},stroke-width:3px"
150
+ )
151
+
152
+ root_ids = set()
153
+ leaf_ids = set()
154
+ for node in result.related_columns:
155
+ if node.is_root:
156
+ root_ids.add(_sanitize_mermaid_id(node.identifier))
157
+ if node.is_leaf:
158
+ leaf_ids.add(_sanitize_mermaid_id(node.identifier))
159
+
160
+ for rid in sorted(root_ids):
161
+ if rid != queried_id:
162
+ lines.append(f" style {rid} fill:{ROOT_FILL},stroke:{ROOT_STROKE}")
163
+
164
+ for lid in sorted(leaf_ids):
165
+ if lid != queried_id and lid not in root_ids:
166
+ lines.append(f" style {lid} fill:{LEAF_FILL},stroke:{LEAF_STROKE}")
167
+
168
+ # Legend
169
+ lines.append("")
170
+ lines.append(" subgraph Legend")
171
+ lines.append(' legend_queried["Queried Column"]')
172
+ lines.append(' legend_root["Root (no upstream)"]')
173
+ lines.append(' legend_leaf["Leaf (no downstream)"]')
174
+ lines.append(" end")
175
+ lines.append(
176
+ f" style legend_queried fill:{QUERIED_FILL},stroke:{QUERIED_STROKE},stroke-width:3px"
177
+ )
178
+ lines.append(f" style legend_root fill:{ROOT_FILL},stroke:{ROOT_STROKE}")
179
+ lines.append(f" style legend_leaf fill:{LEAF_FILL},stroke:{LEAF_STROKE}")
180
+
181
+ return "\n".join(lines)
182
+
183
+
184
+ class MermaidMarkdownFormatter:
185
+ """Format lineage graphs and query results as Mermaid diagrams wrapped in markdown code fences."""
186
+
187
+ @staticmethod
188
+ def format_full_graph(graph: LineageGraph) -> str:
189
+ """Format complete lineage graph as a Mermaid diagram in a markdown code block.
190
+
191
+ Args:
192
+ graph: LineageGraph with all nodes and edges
193
+
194
+ Returns:
195
+ Markdown string with fenced Mermaid diagram
196
+ """
197
+ mermaid = MermaidFormatter.format_full_graph(graph)
198
+ return f"```mermaid\n{mermaid}\n```"
199
+
200
+ @staticmethod
201
+ def format_query_result(result: LineageQueryResult) -> str:
202
+ """Format query result as a Mermaid diagram in a markdown code block.
203
+
204
+ Args:
205
+ result: LineageQueryResult from upstream/downstream query
206
+
207
+ Returns:
208
+ Markdown string with fenced Mermaid diagram
209
+ """
210
+ mermaid = MermaidFormatter.format_query_result(result)
211
+ return f"```mermaid\n{mermaid}\n```"
212
+
213
+
214
+ class DotFormatter:
215
+ """Format lineage graphs and query results as DOT (Graphviz) diagrams."""
216
+
217
+ @staticmethod
218
+ def format_full_graph(graph: LineageGraph) -> str:
219
+ """Format complete lineage graph as a DOT digraph.
220
+
221
+ Args:
222
+ graph: LineageGraph with all nodes and edges
223
+
224
+ Returns:
225
+ DOT diagram string
226
+ """
227
+ lines = [
228
+ "digraph lineage {",
229
+ " rankdir=LR;",
230
+ " node [shape=box, style=rounded];",
231
+ ]
232
+
233
+ if not graph.nodes and not graph.edges:
234
+ lines.append("}")
235
+ return "\n".join(lines)
236
+
237
+ # Declare nodes
238
+ for node in graph.nodes:
239
+ lines.append(f" {_quote_dot_id(node.identifier)};")
240
+
241
+ # Add edges
242
+ for edge in graph.edges:
243
+ src = _quote_dot_id(edge.source_node)
244
+ tgt = _quote_dot_id(edge.target_node)
245
+ lines.append(f" {src} -> {tgt};")
246
+
247
+ lines.append("}")
248
+ return "\n".join(lines)
249
+
250
+ @staticmethod
251
+ def format_query_result(result: LineageQueryResult) -> str:
252
+ """Format query result as a DOT digraph with styling.
253
+
254
+ The queried column is highlighted in amber, root nodes in teal,
255
+ and leaf nodes in violet. A legend subgraph is included.
256
+
257
+ Args:
258
+ result: LineageQueryResult from upstream/downstream query
259
+
260
+ Returns:
261
+ DOT diagram string with node attributes and legend
262
+ """
263
+ lines = [
264
+ "digraph lineage {",
265
+ " rankdir=LR;",
266
+ " node [shape=box, style=rounded];",
267
+ ]
268
+
269
+ if not result.related_columns:
270
+ qid = _quote_dot_id(result.query_column)
271
+ lines.append(
272
+ f' {qid} [style="rounded,filled", fillcolor="{QUERIED_FILL}"];'
273
+ )
274
+ lines.append("}")
275
+ return "\n".join(lines)
276
+
277
+ all_nodes = _collect_query_nodes(result)
278
+ edges = _collect_query_edges(result)
279
+
280
+ # Build styling lookup
281
+ root_ids: Set[str] = set()
282
+ leaf_ids: Set[str] = set()
283
+ for node in result.related_columns:
284
+ if node.is_root:
285
+ root_ids.add(node.identifier)
286
+ if node.is_leaf:
287
+ leaf_ids.add(node.identifier)
288
+
289
+ # Declare nodes with styling
290
+ for identifier in sorted(all_nodes):
291
+ qid = _quote_dot_id(identifier)
292
+ if identifier == result.query_column:
293
+ lines.append(
294
+ f' {qid} [style="rounded,filled", fillcolor="{QUERIED_FILL}"];'
295
+ )
296
+ elif identifier in root_ids:
297
+ lines.append(
298
+ f' {qid} [style="rounded,filled", fillcolor="{ROOT_FILL}"];'
299
+ )
300
+ elif identifier in leaf_ids:
301
+ lines.append(
302
+ f' {qid} [style="rounded,filled", fillcolor="{LEAF_FILL}"];'
303
+ )
304
+ else:
305
+ lines.append(f" {qid};")
306
+
307
+ # Add edges
308
+ for src, tgt in sorted(edges):
309
+ lines.append(f" {_quote_dot_id(src)} -> {_quote_dot_id(tgt)};")
310
+
311
+ # Legend
312
+ lines.append("")
313
+ lines.append(" subgraph cluster_legend {")
314
+ lines.append(' label="Legend";')
315
+ lines.append(" style=dashed;")
316
+ lines.append(
317
+ f' legend_queried [label="Queried Column", style="rounded,filled", fillcolor="{QUERIED_FILL}"];'
318
+ )
319
+ lines.append(
320
+ f' legend_root [label="Root (no upstream)", style="rounded,filled", fillcolor="{ROOT_FILL}"];'
321
+ )
322
+ lines.append(
323
+ f' legend_leaf [label="Leaf (no downstream)", style="rounded,filled", fillcolor="{LEAF_FILL}"];'
324
+ )
325
+ lines.append(" legend_queried -> legend_root [style=invis];")
326
+ lines.append(" legend_root -> legend_leaf [style=invis];")
327
+ lines.append(" }")
328
+
329
+ lines.append("}")
330
+ return "\n".join(lines)