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.
- {sql_dag_flow-0.4.2/src/sql_dag_flow.egg-info → sql_dag_flow-0.4.7}/PKG-INFO +16 -8
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/README.md +15 -7
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/pyproject.toml +1 -1
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/main.py +171 -15
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/parser.py +133 -17
- sql_dag_flow-0.4.7/src/sql_dag_flow/static/assets/index-BhG0Vsh6.js +89 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/static/index.html +1 -1
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7/src/sql_dag_flow.egg-info}/PKG-INFO +16 -8
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/SOURCES.txt +1 -1
- sql_dag_flow-0.4.2/src/sql_dag_flow/static/assets/index-CVmTuJjC.js +0 -84
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/LICENSE +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/MANIFEST.in +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/setup.cfg +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/__init__.py +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/static/assets/index-BM6GPh_j.css +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/static/vite.svg +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/test_api_endpoints.py +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/test_parser.py +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow/verify_counts.py +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/dependency_links.txt +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/entry_points.txt +0 -0
- {sql_dag_flow-0.4.2 → sql_dag_flow-0.4.7}/src/sql_dag_flow.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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.
|
|
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** (
|
|
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**:
|
|
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.
|
|
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
|
|
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.
|
|
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** (
|
|
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**:
|
|
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.
|
|
117
|
+
To update to the latest version (**v0.4.6**):
|
|
110
118
|
|
|
111
119
|
```bash
|
|
112
120
|
pip install --upgrade sql-dag-flow
|
|
@@ -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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 '{
|
|
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:
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|