sql-glider 0.1.20__py3-none-any.whl → 0.1.21__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.21
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=QwRysHnO-cJwIFSvXReHDj93DEPhlibOTtnnb25J9-c,706
3
+ sqlglider/cli.py,sha256=jQisnNRvRR5AeBnGF7POtcndMR-7cen7yfp7WDShgO4,68523
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.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,,
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.21'
32
+ __version_tuple__ = version_tuple = (0, 1, 21)
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,10 @@ 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 ["text", "json", "csv", "mermaid", "mermaid-markdown", "dot"]:
1682
1682
  err_console.print(
1683
1683
  f"[red]Error:[/red] Invalid output format '{output_format}'. "
1684
- "Use 'text', 'json', or 'csv'."
1684
+ "Use 'text', 'json', 'csv', 'mermaid', 'mermaid-markdown', or 'dot'."
1685
1685
  )
1686
1686
  raise typer.Exit(1)
1687
1687
 
@@ -1699,8 +1699,20 @@ def graph_query(
1699
1699
  _format_query_result_text(result)
1700
1700
  elif output_format == "json":
1701
1701
  _format_query_result_json(result)
1702
- else: # csv
1702
+ elif output_format == "csv":
1703
1703
  _format_query_result_csv(result)
1704
+ elif output_format == "mermaid":
1705
+ from sqlglider.graph.diagram_formatters import MermaidFormatter
1706
+
1707
+ print(MermaidFormatter.format_query_result(result))
1708
+ elif output_format == "mermaid-markdown":
1709
+ from sqlglider.graph.diagram_formatters import MermaidMarkdownFormatter
1710
+
1711
+ print(MermaidMarkdownFormatter.format_query_result(result))
1712
+ elif output_format == "dot":
1713
+ from sqlglider.graph.diagram_formatters import DotFormatter
1714
+
1715
+ print(DotFormatter.format_query_result(result))
1704
1716
 
1705
1717
  except FileNotFoundError as e:
1706
1718
  err_console.print(f"[red]Error:[/red] {e}")
@@ -1795,6 +1807,91 @@ def _format_query_result_csv(result) -> None:
1795
1807
  )
1796
1808
 
1797
1809
 
1810
+ @graph_app.command("visualize")
1811
+ def graph_visualize(
1812
+ graph_file: Path = typer.Argument(
1813
+ ...,
1814
+ exists=True,
1815
+ help="Path to graph JSON file",
1816
+ ),
1817
+ output_format: str = typer.Option(
1818
+ "mermaid",
1819
+ "--output-format",
1820
+ "-f",
1821
+ help="Diagram format: 'mermaid', 'mermaid-markdown', or 'dot'",
1822
+ ),
1823
+ output_file: Optional[Path] = typer.Option(
1824
+ None,
1825
+ "--output-file",
1826
+ "-o",
1827
+ help="Write diagram to file instead of stdout",
1828
+ ),
1829
+ ) -> None:
1830
+ """
1831
+ Visualize the entire lineage graph as a diagram.
1832
+
1833
+ Generates Mermaid or DOT (Graphviz) diagrams showing all nodes and edges
1834
+ in the graph for visualization tools.
1835
+
1836
+ Examples:
1837
+
1838
+ # Generate Mermaid diagram
1839
+ sqlglider graph visualize graph.json
1840
+
1841
+ # Generate DOT diagram for Graphviz
1842
+ sqlglider graph visualize graph.json -f dot
1843
+
1844
+ # Save to file
1845
+ sqlglider graph visualize graph.json -o lineage.mmd
1846
+
1847
+ # Render DOT to PNG with Graphviz
1848
+ sqlglider graph visualize graph.json -f dot -o lineage.dot
1849
+ """
1850
+ from sqlglider.graph.diagram_formatters import (
1851
+ DotFormatter,
1852
+ MermaidFormatter,
1853
+ MermaidMarkdownFormatter,
1854
+ )
1855
+ from sqlglider.graph.serialization import load_graph
1856
+
1857
+ if output_format not in ["mermaid", "mermaid-markdown", "dot"]:
1858
+ err_console.print(
1859
+ f"[red]Error:[/red] Invalid output format '{output_format}'. "
1860
+ "Use 'mermaid', 'mermaid-markdown', or 'dot'."
1861
+ )
1862
+ raise typer.Exit(1)
1863
+
1864
+ try:
1865
+ graph = load_graph(graph_file)
1866
+
1867
+ if output_format == "mermaid":
1868
+ diagram = MermaidFormatter.format_full_graph(graph)
1869
+ elif output_format == "mermaid-markdown":
1870
+ diagram = MermaidMarkdownFormatter.format_full_graph(graph)
1871
+ else:
1872
+ diagram = DotFormatter.format_full_graph(graph)
1873
+
1874
+ if output_file:
1875
+ output_file.write_text(diagram, encoding="utf-8")
1876
+ console.print(
1877
+ f"[green]Success:[/green] Diagram written to {output_file}"
1878
+ )
1879
+ else:
1880
+ print(diagram)
1881
+
1882
+ except FileNotFoundError as e:
1883
+ err_console.print(f"[red]Error:[/red] {e}")
1884
+ raise typer.Exit(1)
1885
+
1886
+ except ValueError as e:
1887
+ err_console.print(f"[red]Error:[/red] {e}")
1888
+ raise typer.Exit(1)
1889
+
1890
+ except Exception as e:
1891
+ err_console.print(f"[red]Error:[/red] Unexpected error: {e}")
1892
+ raise typer.Exit(1)
1893
+
1894
+
1798
1895
  @app.command()
1799
1896
  def dissect(
1800
1897
  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)