sql-dag-flow 0.4.2__tar.gz → 0.4.7__tar.gz

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.
Files changed (23) hide show
  1. {sql_dag_flow-0.4.2/src/sql_dag_flow.egg-info → sql_dag_flow-0.4.7}/PKG-INFO +16 -8
  2. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/README.md +15 -7
  3. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/pyproject.toml +1 -1
  4. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/main.py +171 -15
  5. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/parser.py +133 -17
  6. sql_dag_flow-0.4.7/src/sql_dag_flow/static/assets/index-BhG0Vsh6.js +89 -0
  7. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/static/index.html +1 -1
  8. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7/src/sql_dag_flow.egg-info}/PKG-INFO +16 -8
  9. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/SOURCES.txt +1 -1
  10. sql_dag_flow-0.4.2/src/sql_dag_flow/static/assets/index-CVmTuJjC.js +0 -84
  11. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/LICENSE +0 -0
  12. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/MANIFEST.in +0 -0
  13. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/setup.cfg +0 -0
  14. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/__init__.py +0 -0
  15. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/static/assets/index-BM6GPh_j.css +0 -0
  16. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/static/vite.svg +0 -0
  17. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/test_api_endpoints.py +0 -0
  18. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/test_parser.py +0 -0
  19. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/verify_counts.py +0 -0
  20. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/dependency_links.txt +0 -0
  21. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/entry_points.txt +0 -0
  22. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/requires.txt +0 -0
  23. {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dag-flow
3
- Version: 0.4.2
3
+ Version: 0.4.7
4
4
  Summary: A sophisticated SQL lineage visualization tool for Medallion Architectures.
5
5
  Author-email: Flavio Sandoval <dsandovalflavio@gmail.com>
6
6
  License: MIT
@@ -73,8 +73,11 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
73
73
  * **Hide/Show**: Toggle visibility of individual nodes or full branches.
74
74
  * **Advanced Navigation**:
75
75
  * **Sidebar**: Grouped list of nodes with toggle between **By Layer** and **By Project/Dataset** views.
76
+ * **Command Palette (Cmd+P)**: Instantly search and navigate to nodes across large projects.
77
+ * **Keyboard Arrow Navigation**: Rapidly explore lineage by moving ← (upstream) and → (downstream) between connected nodes.
78
+ * **Breadcrumb Trail**: Maintain context while drilling down with a visual history of visited nodes.
76
79
  * **SQL Content Search**: Search inside SQL file content across all nodes — find WHERE clauses, JOINs, or any keyword.
77
- * **Details Panel**: View formatted SQL code, schema preview (DDL, CTAS, Views), and node configuration.
80
+ * **Details Panel**: View formatted SQL code, schema preview (DDL, CTAS, Views), node configuration, and add **custom descriptions** to document models.
78
81
 
79
82
  ### 📝 Notes & Annotations
80
83
  * **Center Placement**: New notes spawn exactly in the center of your view.
@@ -84,12 +87,16 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
84
87
  * **Groups**: Create visual containers to group related nodes.
85
88
 
86
89
  ### 📊 Discovery & Analysis Tools
90
+ * **Impact Analysis**: Visualize blast radius before making changes. Highlights downstream models, column usage, and risk levels.
91
+ * **Diff View on Refresh**: Automatically summarizes added, removed, and modified nodes/edges after code changes.
92
+ * **Column Usage Tracking**: Schema Preview shows which specific columns are used by downstream consumers.
93
+ * **Staleness Detection**: Automatically flags inactive models (`Last Modified > 90d` = Stale) to help clean up legacy pipelines.
87
94
  * **Business Rule Extraction**: Automatically detects and displays WHERE filters, CASE logic, HAVING clauses, and aggregations from each SQL model.
88
95
  * **Complexity Scoring**: Weighted metric per node (JOINs×3, CTEs×2, Subqueries×3, Filters×1, CASE×2, Aggregations×1, UNIONs×2) with color-coded badges (🟢 Low, 🟡 Medium, 🟠 High, 🔴 Very High). Toggleable via ⚡ button.
89
- * **Statistics Panel**: Centered popup with layer distribution bars, edge/source/sink/orphan counts, project/dataset tree, and architecture health validation.
96
+ * **Statistics Panel**: Centered popup with layer distribution bars, edge/source/sink/orphan counts, project/dataset tree, and architecture health validation (now detects **circular dependencies**).
90
97
  * **Schema Preview**: Extracts columns from DDL, CTAS (`CREATE TABLE AS SELECT`), and `CREATE VIEW` statements.
91
98
 
92
- ### 🎨 Linear-Inspired UI (New in v0.4.0 ✨)
99
+ ### 🎨 Linear-Inspired UI (New in v0.4.6 ✨)
93
100
  * **Design Token System**: ~80 CSS custom properties for consistent theming across all components.
94
101
  * **Premium Dark Theme**: Deep `#0d0d0d` canvas with warm white text (`#e8e8e6`), never pure white.
95
102
  * **Refined Light Theme**: Warm off-white `#f7f6f3` backgrounds — never harsh pure white.
@@ -99,12 +106,13 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
99
106
  * **Custom Scrollbars**: Subtle, styled scrollbars matching the theme.
100
107
  * **Focus Rings**: Accessible focus indicators using the accent color.
101
108
 
102
- ### ⚙️ Customization
109
+ ### ⚙️ Customization & Export
103
110
  * **Premium UI**:
104
111
  * **Themes**: Toggle between Light and Dark modes.
105
- * **Palettes**: Choose from **Standard**, **Vivid**, **Pastel**, or **Linear** (new — muted, LCH-inspired tones) color schemes.
112
+ * **Palettes**: Choose from **Standard**, **Vivid**, **Pastel**, or **Linear** (LCH-inspired tones) color schemes.
106
113
  * **Styles**: Switch between "Full" (colored body) and "Minimal" (colored border) node styles.
107
- * **Export**: Save high-resolution **PNG** or vector **SVG** diagrams for documentation.
114
+ * **Export Dictionary**: Generate and download a comprehensive Markdown Data Dictionary report of your entire DAG.
115
+ * **Export Graph**: Save high-resolution **PNG** or vector **SVG** diagrams for documentation.
108
116
 
109
117
  ---
110
118
 
@@ -131,7 +139,7 @@ Install easily via `pip`:
131
139
  pip install sql-dag-flow
132
140
  ```
133
141
 
134
- To update to the latest version (**v0.4.0**):
142
+ To update to the latest version (**v0.4.6**):
135
143
 
136
144
  ```bash
137
145
  pip install --upgrade sql-dag-flow
@@ -48,8 +48,11 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
48
48
  * **Hide/Show**: Toggle visibility of individual nodes or full branches.
49
49
  * **Advanced Navigation**:
50
50
  * **Sidebar**: Grouped list of nodes with toggle between **By Layer** and **By Project/Dataset** views.
51
+ * **Command Palette (Cmd+P)**: Instantly search and navigate to nodes across large projects.
52
+ * **Keyboard Arrow Navigation**: Rapidly explore lineage by moving ← (upstream) and → (downstream) between connected nodes.
53
+ * **Breadcrumb Trail**: Maintain context while drilling down with a visual history of visited nodes.
51
54
  * **SQL Content Search**: Search inside SQL file content across all nodes — find WHERE clauses, JOINs, or any keyword.
52
- * **Details Panel**: View formatted SQL code, schema preview (DDL, CTAS, Views), and node configuration.
55
+ * **Details Panel**: View formatted SQL code, schema preview (DDL, CTAS, Views), node configuration, and add **custom descriptions** to document models.
53
56
 
54
57
  ### 📝 Notes & Annotations
55
58
  * **Center Placement**: New notes spawn exactly in the center of your view.
@@ -59,12 +62,16 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
59
62
  * **Groups**: Create visual containers to group related nodes.
60
63
 
61
64
  ### 📊 Discovery & Analysis Tools
65
+ * **Impact Analysis**: Visualize blast radius before making changes. Highlights downstream models, column usage, and risk levels.
66
+ * **Diff View on Refresh**: Automatically summarizes added, removed, and modified nodes/edges after code changes.
67
+ * **Column Usage Tracking**: Schema Preview shows which specific columns are used by downstream consumers.
68
+ * **Staleness Detection**: Automatically flags inactive models (`Last Modified > 90d` = Stale) to help clean up legacy pipelines.
62
69
  * **Business Rule Extraction**: Automatically detects and displays WHERE filters, CASE logic, HAVING clauses, and aggregations from each SQL model.
63
70
  * **Complexity Scoring**: Weighted metric per node (JOINs×3, CTEs×2, Subqueries×3, Filters×1, CASE×2, Aggregations×1, UNIONs×2) with color-coded badges (🟢 Low, 🟡 Medium, 🟠 High, 🔴 Very High). Toggleable via ⚡ button.
64
- * **Statistics Panel**: Centered popup with layer distribution bars, edge/source/sink/orphan counts, project/dataset tree, and architecture health validation.
71
+ * **Statistics Panel**: Centered popup with layer distribution bars, edge/source/sink/orphan counts, project/dataset tree, and architecture health validation (now detects **circular dependencies**).
65
72
  * **Schema Preview**: Extracts columns from DDL, CTAS (`CREATE TABLE AS SELECT`), and `CREATE VIEW` statements.
66
73
 
67
- ### 🎨 Linear-Inspired UI (New in v0.4.0 ✨)
74
+ ### 🎨 Linear-Inspired UI (New in v0.4.6 ✨)
68
75
  * **Design Token System**: ~80 CSS custom properties for consistent theming across all components.
69
76
  * **Premium Dark Theme**: Deep `#0d0d0d` canvas with warm white text (`#e8e8e6`), never pure white.
70
77
  * **Refined Light Theme**: Warm off-white `#f7f6f3` backgrounds — never harsh pure white.
@@ -74,12 +81,13 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
74
81
  * **Custom Scrollbars**: Subtle, styled scrollbars matching the theme.
75
82
  * **Focus Rings**: Accessible focus indicators using the accent color.
76
83
 
77
- ### ⚙️ Customization
84
+ ### ⚙️ Customization & Export
78
85
  * **Premium UI**:
79
86
  * **Themes**: Toggle between Light and Dark modes.
80
- * **Palettes**: Choose from **Standard**, **Vivid**, **Pastel**, or **Linear** (new — muted, LCH-inspired tones) color schemes.
87
+ * **Palettes**: Choose from **Standard**, **Vivid**, **Pastel**, or **Linear** (LCH-inspired tones) color schemes.
81
88
  * **Styles**: Switch between "Full" (colored body) and "Minimal" (colored border) node styles.
82
- * **Export**: Save high-resolution **PNG** or vector **SVG** diagrams for documentation.
89
+ * **Export Dictionary**: Generate and download a comprehensive Markdown Data Dictionary report of your entire DAG.
90
+ * **Export Graph**: Save high-resolution **PNG** or vector **SVG** diagrams for documentation.
83
91
 
84
92
  ---
85
93
 
@@ -106,7 +114,7 @@ Install easily via `pip`:
106
114
  pip install sql-dag-flow
107
115
  ```
108
116
 
109
- To update to the latest version (**v0.4.0**):
117
+ To update to the latest version (**v0.4.6**):
110
118
 
111
119
  ```bash
112
120
  pip install --upgrade sql-dag-flow
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sql-dag-flow"
7
- version = "0.4.2"
7
+ version = "0.4.7"
8
8
  description = "A sophisticated SQL lineage visualization tool for Medallion Architectures."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -1,4 +1,5 @@
1
1
  from fastapi import FastAPI, HTTPException, Body
2
+ from fastapi.responses import FileResponse, Response
2
3
  from pydantic import BaseModel
3
4
  from fastapi.middleware.cors import CORSMiddleware
4
5
  from fastapi.staticfiles import StaticFiles
@@ -10,6 +11,8 @@ import json
10
11
  import webbrowser
11
12
  import threading
12
13
  import time
14
+ import socket
15
+ import argparse
13
16
  import shutil
14
17
  from .parser import parse_sql_files, build_graph
15
18
 
@@ -34,14 +37,15 @@ CURRENT_DIRECTORY = os.getcwd() # Default, updated by start()
34
37
  DIAGRAM_FILE = "sql_diagram.json"
35
38
 
36
39
  @app.get("/graph")
37
- def get_graph(dialect: str = "bigquery", discovery: bool = False):
40
+ def get_graph(dialect: str = "bigquery", discovery: bool = False, expanded_nodes: str = ""):
38
41
  """Parses SQL files in the current directory and returns graph data."""
39
42
  if not os.path.exists(CURRENT_DIRECTORY):
40
- return {"nodes": [], "edges": [], "error": "Directory not found"}
43
+ return {"nodes": [], "edges": [], "cycles": [], "error": "Directory not found"}
41
44
 
45
+ exp_nodes_list = [n.strip() for n in expanded_nodes.split(",")] if expanded_nodes else []
42
46
  tables = parse_sql_files(CURRENT_DIRECTORY, dialect=dialect)
43
- nodes, edges = build_graph(tables, discovery_mode=discovery)
44
- return {"nodes": nodes, "edges": edges}
47
+ nodes, edges, cycles = build_graph(tables, discovery_mode=discovery, expanded_nodes=exp_nodes_list)
48
+ return {"nodes": nodes, "edges": edges, "cycles": cycles}
45
49
 
46
50
  @app.post("/config/path")
47
51
  def set_path(path_data: dict = Body(...)):
@@ -93,15 +97,126 @@ def get_filtered_graph(data: dict = Body(...)):
93
97
  subfolders = data.get("subfolders") # List of strings or None
94
98
  dialect = data.get("dialect", "bigquery")
95
99
  discovery = data.get("discovery", False)
100
+ expanded_nodes = data.get("expanded_nodes", [])
96
101
 
97
102
  tables = parse_sql_files(CURRENT_DIRECTORY, allowed_subfolders=subfolders, dialect=dialect)
98
- nodes, edges = build_graph(tables, discovery_mode=discovery)
99
- return {"nodes": nodes, "edges": edges}
103
+ nodes, edges, cycles = build_graph(tables, discovery_mode=discovery, expanded_nodes=expanded_nodes)
104
+ return {"nodes": nodes, "edges": edges, "cycles": cycles}
100
105
 
101
106
  @app.get("/config/path")
102
107
  def get_path():
103
108
  return {"path": CURRENT_DIRECTORY}
104
109
 
110
+ @app.get("/export")
111
+ def export_data_dictionary(dialect: str = "bigquery"):
112
+ """Generates a Markdown data dictionary report."""
113
+ if not os.path.exists(CURRENT_DIRECTORY):
114
+ raise HTTPException(status_code=400, detail="Directory not found")
115
+
116
+ tables = parse_sql_files(CURRENT_DIRECTORY, dialect=dialect)
117
+ nodes, edges, cycles = build_graph(tables, discovery_mode=False)
118
+
119
+ lines = []
120
+ lines.append(f"# Data Dictionary")
121
+ lines.append(f"")
122
+ lines.append(f"**Project Path:** `{CURRENT_DIRECTORY}` ")
123
+ lines.append(f"**Total Models:** {len(nodes)} ")
124
+ lines.append(f"**Total Dependencies:** {len(edges)} ")
125
+ lines.append(f"")
126
+
127
+ if cycles:
128
+ lines.append(f"## ⚠️ Circular Dependencies ({len(cycles)})")
129
+ lines.append(f"")
130
+ for i, cycle in enumerate(cycles, 1):
131
+ cycle_str = " → ".join([n['label'] for n in cycle])
132
+ lines.append(f"{i}. {cycle_str} → {cycle[0]['label']}")
133
+ lines.append(f"")
134
+
135
+ # Group by layer
136
+ layer_order = ['bronze', 'silver', 'gold', 'external', 'cte', 'other']
137
+ node_by_layer = {}
138
+ for n in nodes:
139
+ layer = n['data'].get('layer', 'other')
140
+ if layer not in node_by_layer:
141
+ node_by_layer[layer] = []
142
+ node_by_layer[layer].append(n)
143
+
144
+ for layer in layer_order:
145
+ layer_nodes = node_by_layer.get(layer, [])
146
+ if not layer_nodes:
147
+ continue
148
+ lines.append(f"## {layer.capitalize()} Layer ({len(layer_nodes)} models)")
149
+ lines.append(f"")
150
+
151
+ for n in sorted(layer_nodes, key=lambda x: x['data']['label']):
152
+ d = n['data'].get('details', {})
153
+ label = n['data']['label']
154
+ lines.append(f"### {label}")
155
+ lines.append(f"")
156
+
157
+ # Metadata table
158
+ lines.append(f"| Property | Value |")
159
+ lines.append(f"|----------|-------|")
160
+ lines.append(f"| **Project** | {d.get('project', '-')} |")
161
+ lines.append(f"| **Dataset** | {d.get('dataset', '-')} |")
162
+ lines.append(f"| **Type** | {d.get('type', 'table')} |")
163
+ lines.append(f"| **Layer** | {layer} |")
164
+
165
+ complexity = d.get('complexity', {})
166
+ if complexity:
167
+ lines.append(f"| **Complexity** | {complexity.get('score', 0)} |")
168
+
169
+ header_meta = d.get('header_meta', {})
170
+ if header_meta.get('description'):
171
+ lines.append(f"| **Description** | {header_meta['description']} |")
172
+ if header_meta.get('author'):
173
+ lines.append(f"| **Author** | {header_meta['author']} |")
174
+
175
+ incoming = n['data'].get('incomingCount', 0)
176
+ downstream = n['data'].get('downstreamCount', 0)
177
+ lines.append(f"| **Dependencies** | {incoming} incoming, {downstream} downstream |")
178
+ lines.append(f"")
179
+
180
+ # Dependencies
181
+ deps = d.get('dependencies', {})
182
+ if deps:
183
+ lines.append(f"**Dependencies:** {', '.join(f'`{k}` ({v})' for k, v in deps.items()) if isinstance(deps, dict) else ', '.join(f'`{x}`' for x in deps)}")
184
+ lines.append(f"")
185
+
186
+ # Business Rules
187
+ br = d.get('business_rules', {})
188
+ rules_items = []
189
+ for cat, items in br.items():
190
+ if items:
191
+ rules_items.extend([f"- **{cat}**: `{r}`" for r in items[:3]])
192
+ if rules_items:
193
+ lines.append(f"**Business Rules:**")
194
+ lines.append(f"")
195
+ lines.extend(rules_items[:6])
196
+ lines.append(f"")
197
+
198
+ # Column consumers
199
+ col_consumers = d.get('column_consumers', {})
200
+ if col_consumers:
201
+ lines.append(f"**Column Usage (downstream):**")
202
+ lines.append(f"")
203
+ lines.append(f"| Column | Consumers |")
204
+ lines.append(f"|--------|-----------|")
205
+ for col, consumers in sorted(col_consumers.items()):
206
+ consumer_labels = ', '.join([c['label'] for c in consumers])
207
+ lines.append(f"| `{col}` | {consumer_labels} |")
208
+ lines.append(f"")
209
+
210
+ lines.append(f"---")
211
+ lines.append(f"")
212
+
213
+ md_content = "\n".join(lines)
214
+ return Response(
215
+ content=md_content,
216
+ media_type="text/markdown",
217
+ headers={"Content-Disposition": "attachment; filename=data_dictionary.md"}
218
+ )
219
+
105
220
  class SaveRequest(BaseModel):
106
221
  nodes: list
107
222
  edges: list
@@ -257,31 +372,72 @@ if os.path.exists(STATIC_DIR):
257
372
  response.headers["Expires"] = "0"
258
373
  return response
259
374
 
375
+ def _is_port_available(port):
376
+ """Check if a port is available by attempting to bind to it."""
377
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
378
+ try:
379
+ s.bind(('127.0.0.1', port))
380
+ return True
381
+ except OSError:
382
+ return False
383
+
384
+ def _find_available_port(start_port, max_attempts=10):
385
+ """Find an available port starting from start_port."""
386
+ for offset in range(max_attempts):
387
+ port = start_port + offset
388
+ if _is_port_available(port):
389
+ return port
390
+ return None
391
+
260
392
  def start():
261
393
  """Entry point for the CLI tool."""
262
394
  global CURRENT_DIRECTORY
263
395
 
264
- # CLI Argument Parsing
265
- if len(sys.argv) > 1:
266
- path_arg = sys.argv[1]
267
- if os.path.exists(path_arg):
268
- CURRENT_DIRECTORY = os.path.abspath(path_arg)
396
+ # CLI Argument Parsing with argparse
397
+ parser = argparse.ArgumentParser(
398
+ prog='sql-dag-flow',
399
+ description='SQL DAG Flow - Medallion Architecture Visualizer'
400
+ )
401
+ parser.add_argument('path', nargs='?', default=None, help='Path to SQL project folder')
402
+ parser.add_argument('--port', '-p', type=int, default=8000, help='Port to run the server on (default: 8000)')
403
+
404
+ # Use parse_known_args to be tolerant of unexpected args
405
+ args, unknown = parser.parse_known_args()
406
+
407
+ if args.path:
408
+ # Normalize the path (handles backslashes on Windows, trailing separators, etc.)
409
+ normalized_path = os.path.normpath(os.path.abspath(args.path))
410
+ if os.path.exists(normalized_path) and os.path.isdir(normalized_path):
411
+ CURRENT_DIRECTORY = normalized_path
269
412
  print(f"Setting project path from CLI: {CURRENT_DIRECTORY}")
270
413
  else:
271
- print(f"Warning: Path '{path_arg}' does not exist. Using defaults.")
414
+ print(f"Warning: Path '{args.path}' does not exist or is not a directory. Using current directory.")
415
+ CURRENT_DIRECTORY = os.getcwd()
272
416
  else:
273
417
  CURRENT_DIRECTORY = os.getcwd()
274
418
  print(f"Using current directory: {CURRENT_DIRECTORY}")
275
419
 
420
+ # Find an available port
421
+ requested_port = args.port
422
+ port = _find_available_port(requested_port)
423
+
424
+ if port is None:
425
+ print(f"Error: Could not find an available port (tried {requested_port}-{requested_port + 9}).")
426
+ sys.exit(1)
427
+
428
+ if port != requested_port:
429
+ print(f"Port {requested_port} is in use. Using port {port} instead.")
430
+ else:
431
+ print(f"Starting server on port {port}")
432
+
276
433
  def open_browser():
277
434
  time.sleep(1.5)
278
- webbrowser.open("http://localhost:8000")
435
+ webbrowser.open(f"http://localhost:{port}")
279
436
 
280
437
  threading.Thread(target=open_browser, daemon=True).start()
281
438
 
282
439
  # Run uvicorn programmatically
283
- # Note: When running programmatically, reload=True is not supported easily without other hacks
284
- uvicorn.run(app, host="127.0.0.1", port=8000)
440
+ uvicorn.run(app, host="127.0.0.1", port=port)
285
441
 
286
442
  if __name__ == "__main__":
287
443
  start()
@@ -1,14 +1,10 @@
1
1
  import os
2
+ import re
3
+ import time
2
4
  import sqlglot
3
5
  from sqlglot import exp
4
6
  import networkx as nx
5
7
 
6
- import os
7
- import sqlglot
8
- from sqlglot import exp
9
- import networkx as nx
10
- import re
11
-
12
8
  def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
13
9
  """
14
10
  Recursively scans a directory for .sql files and parses them.
@@ -312,6 +308,74 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
312
308
  )
313
309
 
314
310
  complexity_breakdown["score"] = complexity_score
311
+
312
+ # ===== Column Reference Extraction =====
313
+ # Extract which columns this model references from each source table
314
+ # This enables downstream impact analysis
315
+ column_references = {} # source_table -> [columns]
316
+
317
+ # Build alias -> table name mapping for the query
318
+ alias_map = {} # alias -> full_table_name
319
+ for t in parsed.find_all(exp.Table):
320
+ t_name = t.name
321
+ t_full = t_name
322
+ if t.db:
323
+ t_full = f"{t.db}.{t_name}"
324
+ if t.catalog:
325
+ t_full = f"{t.catalog}.{t.db}.{t_name}"
326
+ if t.alias:
327
+ alias_map[t.alias] = t_full
328
+ alias_map[t_name] = t_full
329
+
330
+ # Extract column references with their table qualifier
331
+ for col in parsed.find_all(exp.Column):
332
+ col_name = col.name
333
+ col_table = col.table # The table qualifier (alias or name)
334
+ if col_table and col_table in alias_map:
335
+ source = alias_map[col_table]
336
+ if source not in column_references:
337
+ column_references[source] = set()
338
+ column_references[source].add(col_name)
339
+
340
+ # Convert sets to sorted lists for JSON serialization
341
+ column_references = {k: sorted(list(v)) for k, v in column_references.items()}
342
+
343
+ # ===== Header Comment Extraction =====
344
+ # Extract metadata from SQL header comments:
345
+ # -- @description: ...
346
+ # -- @author: ...
347
+ # -- @modified: ...
348
+ # -- Description: ... (first block comment)
349
+ header_meta = {}
350
+ header_lines = []
351
+ for line in sql_content.split('\n'):
352
+ stripped = line.strip()
353
+ if stripped.startswith('--'):
354
+ header_lines.append(stripped[2:].strip())
355
+ elif stripped == '' and not header_lines:
356
+ continue # skip leading blank lines
357
+ else:
358
+ break # stop at first non-comment line
359
+
360
+ for hline in header_lines:
361
+ # Match @key: value patterns
362
+ meta_match = re.match(r'^@(\w+)[:\s]+(.+)$', hline, re.IGNORECASE)
363
+ if meta_match:
364
+ key = meta_match.group(1).lower()
365
+ header_meta[key] = meta_match.group(2).strip()
366
+
367
+ # If no @description, use first non-@ comment lines as description
368
+ if 'description' not in header_meta:
369
+ desc_lines = [l for l in header_lines if not l.startswith('@') and l.strip()]
370
+ if desc_lines:
371
+ header_meta['description'] = ' '.join(desc_lines[:3])
372
+ # ===== File Modification Timestamp =====
373
+ try:
374
+ mtime = os.path.getmtime(filepath)
375
+ days_ago = int((time.time() - mtime) / 86400)
376
+ except Exception:
377
+ mtime = None
378
+ days_ago = None
315
379
 
316
380
  tables[filename_base] = {
317
381
  "id": filename_base,
@@ -326,7 +390,10 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
326
390
  "ctes": defined_ctes,
327
391
  "cte_deps": cte_deps,
328
392
  "business_rules": business_rules,
329
- "complexity": complexity_breakdown
393
+ "complexity": complexity_breakdown,
394
+ "column_references": column_references,
395
+ "header_meta": header_meta,
396
+ "last_modified_days": days_ago
330
397
  }
331
398
  except Exception as e:
332
399
  print(f"Error parsing {filepath}: {e}")
@@ -346,11 +413,12 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
346
413
  return tables
347
414
 
348
415
 
349
- def build_graph(tables, discovery_mode=False):
416
+ def build_graph(tables, discovery_mode=False, expanded_nodes=None):
350
417
  """
351
418
  Constructs nodes and edges for React Flow.
352
419
  If discovery_mode is True, creates 'ghost' nodes for dependencies
353
420
  that are not found in the parsed tables.
421
+ Also creates ghost nodes for any node whose ID is in expanded_nodes list.
354
422
  """
355
423
  nodes = []
356
424
  edges = []
@@ -410,8 +478,8 @@ def build_graph(tables, discovery_mode=False):
410
478
  cte_name = ":".join(parts[2:])
411
479
  cte_internal_deps = tables[source_id].get("cte_deps", {}).get(cte_name, {})
412
480
 
413
- if discovery_mode:
414
- # Discovery Mode: Create CTE ghost node with incoming edges
481
+ if discovery_mode or (expanded_nodes and source_id in expanded_nodes):
482
+ # Discovery Mode or Expanded: Create CTE ghost node with incoming edges
415
483
  cte_id = dep
416
484
 
417
485
  if cte_id not in missing_nodes:
@@ -504,8 +572,8 @@ def build_graph(tables, discovery_mode=False):
504
572
  incoming_edges_count[source_id] = incoming_edges_count.get(source_id, 0) + 1
505
573
  continue
506
574
 
507
- # Handle missing external nodes (discovery mode only)
508
- if discovery_mode and not target_id:
575
+ # Handle missing external nodes (discovery mode or expanded node only)
576
+ if (discovery_mode or (expanded_nodes and source_id in expanded_nodes)) and not target_id:
509
577
  # Create a unique ID for the missing node
510
578
  # Use the full dependency name as the ID
511
579
  ghost_id = dep
@@ -555,27 +623,75 @@ def build_graph(tables, discovery_mode=False):
555
623
  for edge in edges:
556
624
  G.add_edge(edge["source"], edge["target"])
557
625
 
626
+ # ===== Compute Column Downstream Consumers =====
627
+ # For each node, figure out which downstream models reference its columns
628
+ # Result: column_consumers[node_id] = { "col_name": [{"node": consumer_id, "label": consumer_label}] }
629
+ column_consumers = {}
630
+
631
+ for consumer_id, consumer_data in all_nodes_data.items():
632
+ col_refs = consumer_data.get("column_references", {})
633
+ for source_ref, columns in col_refs.items():
634
+ # Resolve source_ref to a node_id using lookup
635
+ source_node_id = lookup.get(source_ref)
636
+ if not source_node_id and "." in source_ref:
637
+ source_node_id = lookup.get(source_ref.split(".")[-1])
638
+
639
+ if source_node_id and source_node_id in all_nodes_data:
640
+ if source_node_id not in column_consumers:
641
+ column_consumers[source_node_id] = {}
642
+ for col in columns:
643
+ if col not in column_consumers[source_node_id]:
644
+ column_consumers[source_node_id][col] = []
645
+ column_consumers[source_node_id][col].append({
646
+ "node": consumer_id,
647
+ "label": consumer_data.get("label", consumer_id)
648
+ })
649
+
558
650
  for table_name, data in all_nodes_data.items():
559
651
  # Calculate nested dependencies (all ancestors in the dependency graph)
560
652
  nested_count = 0
561
653
  if G.has_node(table_name):
562
654
  try:
563
- # ancestors() returns all nodes u such that there is a path from u to table_name
564
655
  nested_count = len(nx.ancestors(G, table_name))
565
656
  except Exception:
566
- pass # distinct graph parts or cycles? cycles shouldn't exist in DAG but safety first
657
+ pass
658
+
659
+ # Get downstream impact count
660
+ downstream_count = 0
661
+ if G.has_node(table_name):
662
+ try:
663
+ downstream_count = len(nx.descendants(G, table_name))
664
+ except Exception:
665
+ pass
567
666
 
568
667
  nodes.append({
569
668
  "id": table_name,
570
669
  "data": {
571
670
  "label": data["label"],
572
671
  "layer": data["layer"],
573
- "details": data,
672
+ "details": {
673
+ **data,
674
+ "column_consumers": column_consumers.get(table_name, {})
675
+ },
574
676
  "incomingCount": incoming_edges_count.get(table_name, 0),
575
- "nestedCount": nested_count
677
+ "nestedCount": nested_count,
678
+ "downstreamCount": downstream_count
576
679
  },
577
680
  "position": {"x": 0, "y": 0},
578
681
  "type": "custom",
579
682
  })
580
683
 
581
- return nodes, edges
684
+ # ===== Cycle Detection =====
685
+ cycles = []
686
+ try:
687
+ raw_cycles = list(nx.simple_cycles(G))
688
+ for cycle in raw_cycles:
689
+ cycle_labels = []
690
+ for nid in cycle:
691
+ label = all_nodes_data.get(nid, {}).get("label", nid)
692
+ cycle_labels.append({"id": nid, "label": label})
693
+ cycles.append(cycle_labels)
694
+ except Exception:
695
+ pass
696
+
697
+ return nodes, edges, cycles