sql-dag-flow 0.2.0__tar.gz → 0.3.0__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.2.0/src/sql_dag_flow.egg-info → sql_dag_flow-0.3.0}/PKG-INFO +15 -6
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/README.md +14 -5
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/pyproject.toml +1 -1
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/main.py +46 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/parser.py +226 -46
- sql_dag_flow-0.3.0/src/sql_dag_flow/static/assets/index-B6fMDnZ3.js +84 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/static/index.html +1 -1
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0/src/sql_dag_flow.egg-info}/PKG-INFO +15 -6
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/SOURCES.txt +1 -1
- sql_dag_flow-0.2.0/src/sql_dag_flow/static/assets/index-DzAemHSo.js +0 -61
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/LICENSE +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/MANIFEST.in +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/setup.cfg +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/__init__.py +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/static/assets/index-h7gxR8yA.css +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/static/vite.svg +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/test_api_endpoints.py +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/test_parser.py +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/verify_counts.py +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/dependency_links.txt +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/entry_points.txt +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/requires.txt +0 -0
- {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/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.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -46,8 +46,6 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
46
46
|
* **1. Legacy Code Audit & Refactoring**:
|
|
47
47
|
* *The Problem*: You join a new project with 200+ undocumented SQL scripts. Nobody knows what breaks what.
|
|
48
48
|
* *The Solution*: Run `sql-dag-flow` to instantly map the "spaghetti" dependencies. Identify orphan tables, circular dependencies, and the impact of changing a Silver layer table.
|
|
49
|
-
* **2. Automated Architecture Documentation**:
|
|
50
|
-
* *The Problem*: Architecture diagrams in Lucidchart or Visio are always outdated.
|
|
51
49
|
* *The Solution*: Generate interactive pipeline visualizations (ETL/ELT) to include in your Pull Requests, Wikis, or client deliverables.
|
|
52
50
|
* **3. Medallion Architecture Validation**:
|
|
53
51
|
* *The Problem*: It's hard to verify if the logical separation of layers (Bronze → Silver → Gold) is being respected.
|
|
@@ -63,6 +61,10 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
63
61
|
* **Medallion Architecture Support**: Automatically categorizes and colors nodes based on folder structure (Bronze, Silver, Gold).
|
|
64
62
|
* **Discovery Mode**: Visualize "Ghost Nodes" (missing files or external tables) and create them with a click.
|
|
65
63
|
* **CTE Visualization**: Detects internal Common Table Expressions and displays them as distinct Pink nodes.
|
|
64
|
+
* **Smart Layout (New 🧠)**:
|
|
65
|
+
* Powered by **ELK (Eclipse Layout Kernel)**.
|
|
66
|
+
* Minimizes edge crossings and optimizes flow direction.
|
|
67
|
+
* Intelligent "Port" handling for cleaner connections.
|
|
66
68
|
|
|
67
69
|
### 🎮 Interactive Graph
|
|
68
70
|
* **Smart Context Menu**:
|
|
@@ -70,8 +72,9 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
70
72
|
* **Select Tree**: One-click selection of an entire dependency chain for easy movement.
|
|
71
73
|
* **Hide/Show**: Toggle visibility of individual nodes or full branches.
|
|
72
74
|
* **Advanced Navigation**:
|
|
73
|
-
* **Sidebar**: Grouped list of nodes with
|
|
74
|
-
* **
|
|
75
|
+
* **Sidebar**: Grouped list of nodes with toggle between **By Layer** and **By Project/Dataset** views.
|
|
76
|
+
* **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.
|
|
75
78
|
|
|
76
79
|
### 📝 Notes & Annotations
|
|
77
80
|
* **Center Placement**: New notes spawn exactly in the center of your view.
|
|
@@ -80,6 +83,12 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
80
83
|
* **Transparent & Borderless**:Create clean, floating text labels without boxes.
|
|
81
84
|
* **Groups**: Create visual containers to group related nodes.
|
|
82
85
|
|
|
86
|
+
### 📊 Discovery & Analysis Tools (New ✨)
|
|
87
|
+
* **Business Rule Extraction**: Automatically detects and displays WHERE filters, CASE logic, HAVING clauses, and aggregations from each SQL model.
|
|
88
|
+
* **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.
|
|
90
|
+
* **Schema Preview**: Extracts columns from DDL, CTAS (`CREATE TABLE AS SELECT`), and `CREATE VIEW` statements.
|
|
91
|
+
|
|
83
92
|
### ⚙️ Customization
|
|
84
93
|
* **Premium UI**:
|
|
85
94
|
* **Themes**: Toggle between Light and Dark modes.
|
|
@@ -112,7 +121,7 @@ Install easily via `pip`:
|
|
|
112
121
|
pip install sql-dag-flow
|
|
113
122
|
```
|
|
114
123
|
|
|
115
|
-
To update to the latest version (**v0.
|
|
124
|
+
To update to the latest version (**v0.3.0**):
|
|
116
125
|
|
|
117
126
|
```bash
|
|
118
127
|
pip install --upgrade sql-dag-flow
|
|
@@ -21,8 +21,6 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
21
21
|
* **1. Legacy Code Audit & Refactoring**:
|
|
22
22
|
* *The Problem*: You join a new project with 200+ undocumented SQL scripts. Nobody knows what breaks what.
|
|
23
23
|
* *The Solution*: Run `sql-dag-flow` to instantly map the "spaghetti" dependencies. Identify orphan tables, circular dependencies, and the impact of changing a Silver layer table.
|
|
24
|
-
* **2. Automated Architecture Documentation**:
|
|
25
|
-
* *The Problem*: Architecture diagrams in Lucidchart or Visio are always outdated.
|
|
26
24
|
* *The Solution*: Generate interactive pipeline visualizations (ETL/ELT) to include in your Pull Requests, Wikis, or client deliverables.
|
|
27
25
|
* **3. Medallion Architecture Validation**:
|
|
28
26
|
* *The Problem*: It's hard to verify if the logical separation of layers (Bronze → Silver → Gold) is being respected.
|
|
@@ -38,6 +36,10 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
38
36
|
* **Medallion Architecture Support**: Automatically categorizes and colors nodes based on folder structure (Bronze, Silver, Gold).
|
|
39
37
|
* **Discovery Mode**: Visualize "Ghost Nodes" (missing files or external tables) and create them with a click.
|
|
40
38
|
* **CTE Visualization**: Detects internal Common Table Expressions and displays them as distinct Pink nodes.
|
|
39
|
+
* **Smart Layout (New 🧠)**:
|
|
40
|
+
* Powered by **ELK (Eclipse Layout Kernel)**.
|
|
41
|
+
* Minimizes edge crossings and optimizes flow direction.
|
|
42
|
+
* Intelligent "Port" handling for cleaner connections.
|
|
41
43
|
|
|
42
44
|
### 🎮 Interactive Graph
|
|
43
45
|
* **Smart Context Menu**:
|
|
@@ -45,8 +47,9 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
45
47
|
* **Select Tree**: One-click selection of an entire dependency chain for easy movement.
|
|
46
48
|
* **Hide/Show**: Toggle visibility of individual nodes or full branches.
|
|
47
49
|
* **Advanced Navigation**:
|
|
48
|
-
* **Sidebar**: Grouped list of nodes with
|
|
49
|
-
* **
|
|
50
|
+
* **Sidebar**: Grouped list of nodes with toggle between **By Layer** and **By Project/Dataset** views.
|
|
51
|
+
* **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.
|
|
50
53
|
|
|
51
54
|
### 📝 Notes & Annotations
|
|
52
55
|
* **Center Placement**: New notes spawn exactly in the center of your view.
|
|
@@ -55,6 +58,12 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
|
|
|
55
58
|
* **Transparent & Borderless**:Create clean, floating text labels without boxes.
|
|
56
59
|
* **Groups**: Create visual containers to group related nodes.
|
|
57
60
|
|
|
61
|
+
### 📊 Discovery & Analysis Tools (New ✨)
|
|
62
|
+
* **Business Rule Extraction**: Automatically detects and displays WHERE filters, CASE logic, HAVING clauses, and aggregations from each SQL model.
|
|
63
|
+
* **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.
|
|
65
|
+
* **Schema Preview**: Extracts columns from DDL, CTAS (`CREATE TABLE AS SELECT`), and `CREATE VIEW` statements.
|
|
66
|
+
|
|
58
67
|
### ⚙️ Customization
|
|
59
68
|
* **Premium UI**:
|
|
60
69
|
* **Themes**: Toggle between Light and Dark modes.
|
|
@@ -87,7 +96,7 @@ Install easily via `pip`:
|
|
|
87
96
|
pip install sql-dag-flow
|
|
88
97
|
```
|
|
89
98
|
|
|
90
|
-
To update to the latest version (**v0.
|
|
99
|
+
To update to the latest version (**v0.3.0**):
|
|
91
100
|
|
|
92
101
|
```bash
|
|
93
102
|
pip install --upgrade sql-dag-flow
|
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import webbrowser
|
|
11
11
|
import threading
|
|
12
12
|
import time
|
|
13
|
+
import shutil
|
|
13
14
|
from .parser import parse_sql_files, build_graph
|
|
14
15
|
|
|
15
16
|
app = FastAPI()
|
|
@@ -193,6 +194,51 @@ def create_file(request: CreateFileRequest):
|
|
|
193
194
|
except Exception as e:
|
|
194
195
|
raise HTTPException(status_code=500, detail=str(e))
|
|
195
196
|
|
|
197
|
+
class MoveFileRequest(BaseModel):
|
|
198
|
+
current_path: str
|
|
199
|
+
target_layer: str
|
|
200
|
+
|
|
201
|
+
@app.post("/files/move")
|
|
202
|
+
def move_file(request: MoveFileRequest):
|
|
203
|
+
try:
|
|
204
|
+
target_layer = request.target_layer.strip()
|
|
205
|
+
if ".." in request.current_path:
|
|
206
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
207
|
+
|
|
208
|
+
current_path = os.path.join(CURRENT_DIRECTORY, request.current_path)
|
|
209
|
+
if not os.path.exists(current_path):
|
|
210
|
+
raise HTTPException(status_code=404, detail="Original file not found")
|
|
211
|
+
|
|
212
|
+
filename = os.path.basename(current_path)
|
|
213
|
+
dir_name = os.path.dirname(current_path)
|
|
214
|
+
|
|
215
|
+
parent_dir_name = os.path.basename(dir_name).lower()
|
|
216
|
+
if parent_dir_name in ["bronze", "bronce", "silver", "gold", "other"]:
|
|
217
|
+
new_dir = os.path.dirname(dir_name)
|
|
218
|
+
new_dir = os.path.join(new_dir, target_layer)
|
|
219
|
+
else:
|
|
220
|
+
new_dir = os.path.join(dir_name, target_layer)
|
|
221
|
+
|
|
222
|
+
new_full_path = os.path.join(new_dir, filename)
|
|
223
|
+
|
|
224
|
+
if new_full_path == current_path:
|
|
225
|
+
return {"message": "File already in target layer", "path": request.current_path}
|
|
226
|
+
|
|
227
|
+
if os.path.exists(new_full_path):
|
|
228
|
+
raise HTTPException(status_code=400, detail="A file with this name already exists in the target layer folder")
|
|
229
|
+
|
|
230
|
+
os.makedirs(new_dir, exist_ok=True)
|
|
231
|
+
shutil.move(current_path, new_full_path)
|
|
232
|
+
|
|
233
|
+
# Normalize response relative path
|
|
234
|
+
rel_new_path = os.path.relpath(new_full_path, CURRENT_DIRECTORY).replace(os.sep, '/')
|
|
235
|
+
return {"message": f"File moved to {target_layer}", "path": rel_new_path}
|
|
236
|
+
|
|
237
|
+
except HTTPException as he:
|
|
238
|
+
raise he
|
|
239
|
+
except Exception as e:
|
|
240
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
241
|
+
|
|
196
242
|
# Serve Static Files (Frontend)
|
|
197
243
|
if os.path.exists(STATIC_DIR):
|
|
198
244
|
app.mount("/assets", StaticFiles(directory=os.path.join(STATIC_DIR, "assets")), name="assets")
|
|
@@ -130,6 +130,10 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
|
|
|
130
130
|
if create_node and create_node.this:
|
|
131
131
|
# sqlglot represents the target as an exp.Table or exp.Schema
|
|
132
132
|
target_exp = create_node.this
|
|
133
|
+
# Handle Schema wrapping (DDL with column definitions)
|
|
134
|
+
# e.g. CREATE TABLE project.dataset.table (id INT64, name STRING)
|
|
135
|
+
if isinstance(target_exp, exp.Schema):
|
|
136
|
+
target_exp = target_exp.this
|
|
133
137
|
if isinstance(target_exp, exp.Table):
|
|
134
138
|
target_table_name = target_exp.name
|
|
135
139
|
dataset = target_exp.db or "default"
|
|
@@ -154,17 +158,56 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
|
|
|
154
158
|
if parent_dir.lower() not in ["bronze", "bronce", "silver", "gold", "other"] and dataset == "default":
|
|
155
159
|
dataset = parent_dir
|
|
156
160
|
|
|
157
|
-
dependencies =
|
|
161
|
+
dependencies = {} # dep_name -> dep_type
|
|
158
162
|
|
|
159
|
-
# 1. Identify CTEs defined in the query
|
|
163
|
+
# 1. Identify CTEs defined in the query and their internal dependencies
|
|
160
164
|
defined_ctes = {}
|
|
165
|
+
cte_deps = {} # cte_name -> {full_dep_name: dep_type}
|
|
166
|
+
|
|
161
167
|
for cte in parsed.find_all(exp.CTE):
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
cte_name = cte.alias_or_name
|
|
169
|
+
if not cte_name:
|
|
170
|
+
continue
|
|
171
|
+
defined_ctes[cte_name] = cte.sql(dialect=dialect, pretty=True)
|
|
172
|
+
|
|
173
|
+
# Extract tables referenced INSIDE this CTE definition
|
|
174
|
+
cte_internal_deps = {}
|
|
175
|
+
cte_join_tables = set()
|
|
176
|
+
for j in cte.find_all(exp.Join):
|
|
177
|
+
jt = j.find(exp.Table)
|
|
178
|
+
if jt:
|
|
179
|
+
cte_join_tables.add(jt.name)
|
|
180
|
+
|
|
181
|
+
for t in cte.find_all(exp.Table):
|
|
182
|
+
t_name = t.name
|
|
183
|
+
# Skip self-references and references to other CTEs in the same query
|
|
184
|
+
if t_name == target_table_name or t_name in defined_ctes:
|
|
185
|
+
continue
|
|
186
|
+
t_full = t_name
|
|
187
|
+
if t.db:
|
|
188
|
+
t_full = f"{t.db}.{t_name}"
|
|
189
|
+
if t.catalog:
|
|
190
|
+
t_full = f"{t.catalog}.{t.db}.{t_name}"
|
|
191
|
+
cte_internal_deps[t_full] = "JOIN" if t_name in cte_join_tables else "FROM"
|
|
192
|
+
|
|
193
|
+
cte_deps[cte_name] = cte_internal_deps
|
|
194
|
+
|
|
195
|
+
# Collect all table names that appear inside CTE definitions
|
|
196
|
+
# These should NOT be direct dependencies of the parent model
|
|
197
|
+
tables_inside_ctes = set()
|
|
198
|
+
for cte_name, ct in cte_deps.items():
|
|
199
|
+
for dep_key in ct:
|
|
200
|
+
tables_inside_ctes.add(dep_key.split(".")[-1])
|
|
201
|
+
|
|
202
|
+
# Detect JOIN tables for labeling (only at the top-level query, not inside CTEs)
|
|
203
|
+
join_tables = set()
|
|
204
|
+
for join_node in parsed.find_all(exp.Join):
|
|
205
|
+
# Check this join is not inside a CTE
|
|
206
|
+
parent_cte = join_node.find_ancestor(exp.CTE)
|
|
207
|
+
if parent_cte is None:
|
|
208
|
+
join_table = join_node.find(exp.Table)
|
|
209
|
+
if join_table:
|
|
210
|
+
join_tables.add(join_table.name)
|
|
168
211
|
|
|
169
212
|
# Find all tables referenced in the query
|
|
170
213
|
for table in parsed.find_all(exp.Table):
|
|
@@ -180,22 +223,97 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
|
|
|
180
223
|
if dep_name == target_table_name:
|
|
181
224
|
continue
|
|
182
225
|
|
|
183
|
-
# Internal CTE references
|
|
226
|
+
# Internal CTE references (main query references a CTE)
|
|
184
227
|
if dep_name in defined_ctes:
|
|
185
|
-
|
|
186
|
-
dependencies.add(f"cte:{filename_base}:{dep_name}")
|
|
228
|
+
dependencies[f"cte:{filename_base}:{dep_name}"] = "CTE"
|
|
187
229
|
continue
|
|
188
|
-
|
|
189
|
-
# If we haven't found a CREATE statement, this might just be a SELECT
|
|
190
|
-
# and we treat the filename as the target.
|
|
191
230
|
|
|
192
|
-
|
|
193
|
-
#
|
|
194
|
-
|
|
231
|
+
# Skip tables that belong inside a CTE definition
|
|
232
|
+
# These will be wired as CTE_node deps in build_graph
|
|
233
|
+
if dep_name in tables_inside_ctes:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Regular external dependency at the main query level
|
|
237
|
+
dep_type = "JOIN" if dep_name in join_tables else "FROM"
|
|
238
|
+
dependencies[full_name] = dep_type
|
|
239
|
+
|
|
240
|
+
# ===== Business Rule Extraction =====
|
|
241
|
+
business_rules = {
|
|
242
|
+
"filters": [], # WHERE conditions
|
|
243
|
+
"case_logic": [], # CASE statements
|
|
244
|
+
"having": [], # HAVING conditions
|
|
245
|
+
"aggregations": [], # Aggregate functions
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Extract WHERE clauses
|
|
249
|
+
for where_node in parsed.find_all(exp.Where):
|
|
250
|
+
try:
|
|
251
|
+
where_sql = where_node.this.sql(dialect=dialect, pretty=False)
|
|
252
|
+
business_rules["filters"].append(where_sql)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# Extract CASE statements
|
|
257
|
+
for case_node in parsed.find_all(exp.Case):
|
|
258
|
+
try:
|
|
259
|
+
case_sql = case_node.sql(dialect=dialect, pretty=False)
|
|
260
|
+
# Try to get the alias if available
|
|
261
|
+
parent = case_node.parent
|
|
262
|
+
alias = ""
|
|
263
|
+
if hasattr(parent, 'alias') and parent.alias:
|
|
264
|
+
alias = parent.alias
|
|
265
|
+
label = f"{alias}: {case_sql}" if alias else case_sql
|
|
266
|
+
business_rules["case_logic"].append(label)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# Extract HAVING clauses
|
|
271
|
+
for having_node in parsed.find_all(exp.Having):
|
|
272
|
+
try:
|
|
273
|
+
having_sql = having_node.this.sql(dialect=dialect, pretty=False)
|
|
274
|
+
business_rules["having"].append(having_sql)
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
# Extract aggregate functions
|
|
279
|
+
agg_types = (exp.Count, exp.Sum, exp.Avg, exp.Min, exp.Max)
|
|
280
|
+
for agg_node in parsed.find_all(*agg_types):
|
|
281
|
+
try:
|
|
282
|
+
agg_sql = agg_node.sql(dialect=dialect, pretty=False)
|
|
283
|
+
parent = agg_node.parent
|
|
284
|
+
alias = ""
|
|
285
|
+
if hasattr(parent, 'alias') and parent.alias:
|
|
286
|
+
alias = parent.alias
|
|
287
|
+
label = f"{alias}: {agg_sql}" if alias else agg_sql
|
|
288
|
+
business_rules["aggregations"].append(label)
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
# ===== Complexity Score =====
|
|
293
|
+
# Weights: JOIN=3, CTE=2, Subquery=3, WHERE=1, CASE=2, Aggregation=1, UNION=2
|
|
294
|
+
complexity_breakdown = {
|
|
295
|
+
"joins": len(list(parsed.find_all(exp.Join))),
|
|
296
|
+
"ctes": len(defined_ctes),
|
|
297
|
+
"subqueries": len(list(parsed.find_all(exp.Subquery))),
|
|
298
|
+
"filters": len(business_rules["filters"]),
|
|
299
|
+
"case_statements": len(business_rules["case_logic"]),
|
|
300
|
+
"aggregations": len(business_rules["aggregations"]),
|
|
301
|
+
"unions": len(list(parsed.find_all(exp.Union))),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
complexity_score = (
|
|
305
|
+
complexity_breakdown["joins"] * 3 +
|
|
306
|
+
complexity_breakdown["ctes"] * 2 +
|
|
307
|
+
complexity_breakdown["subqueries"] * 3 +
|
|
308
|
+
complexity_breakdown["filters"] * 1 +
|
|
309
|
+
complexity_breakdown["case_statements"] * 2 +
|
|
310
|
+
complexity_breakdown["aggregations"] * 1 +
|
|
311
|
+
complexity_breakdown["unions"] * 2
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
complexity_breakdown["score"] = complexity_score
|
|
195
315
|
|
|
196
316
|
tables[filename_base] = {
|
|
197
|
-
# Use filename_base as unique ID for the graph to avoid ambiguity
|
|
198
|
-
# Visual label can be the actual table name
|
|
199
317
|
"id": filename_base,
|
|
200
318
|
"label": target_table_name,
|
|
201
319
|
"layer": layer,
|
|
@@ -203,9 +321,12 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
|
|
|
203
321
|
"project": project,
|
|
204
322
|
"dataset": dataset,
|
|
205
323
|
"path": filepath,
|
|
206
|
-
"dependencies":
|
|
324
|
+
"dependencies": dependencies,
|
|
207
325
|
"content": sql_content,
|
|
208
|
-
"ctes": defined_ctes
|
|
326
|
+
"ctes": defined_ctes,
|
|
327
|
+
"cte_deps": cte_deps,
|
|
328
|
+
"business_rules": business_rules,
|
|
329
|
+
"complexity": complexity_breakdown
|
|
209
330
|
}
|
|
210
331
|
except Exception as e:
|
|
211
332
|
print(f"Error parsing {filepath}: {e}")
|
|
@@ -217,7 +338,7 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
|
|
|
217
338
|
"project": "n/a",
|
|
218
339
|
"dataset": "n/a",
|
|
219
340
|
"path": filepath,
|
|
220
|
-
"dependencies":
|
|
341
|
+
"dependencies": {},
|
|
221
342
|
"error": str(e),
|
|
222
343
|
"content": sql_content
|
|
223
344
|
}
|
|
@@ -260,7 +381,10 @@ def build_graph(tables, discovery_mode=False):
|
|
|
260
381
|
|
|
261
382
|
# Create edges first (conceptually) to count dependencies
|
|
262
383
|
for source_id, data in tables.items():
|
|
263
|
-
|
|
384
|
+
deps = data["dependencies"]
|
|
385
|
+
# Support both dict (name->type) and list (legacy) formats
|
|
386
|
+
dep_items = deps.items() if isinstance(deps, dict) else [(d, "FROM") for d in deps]
|
|
387
|
+
for dep, dep_type in dep_items:
|
|
264
388
|
target_id = lookup.get(dep)
|
|
265
389
|
|
|
266
390
|
# Fuzzy lookup: if exact match fails, try splitting by dot and matching last part (table name)
|
|
@@ -274,26 +398,23 @@ def build_graph(tables, discovery_mode=False):
|
|
|
274
398
|
"source": target_id,
|
|
275
399
|
"target": source_id,
|
|
276
400
|
"animated": True,
|
|
401
|
+
"label": dep_type,
|
|
277
402
|
"style": {"stroke": "#b1b1b7"}
|
|
278
403
|
})
|
|
279
404
|
incoming_edges_count[source_id] = incoming_edges_count.get(source_id, 0) + 1
|
|
280
405
|
else:
|
|
281
|
-
#
|
|
282
|
-
if
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if
|
|
289
|
-
#
|
|
290
|
-
cte_name = ":".join(parts[2:])
|
|
291
|
-
|
|
292
|
-
# CTE ID is the dependency string itself to be unique per file
|
|
406
|
+
# Handle CTE dependency references
|
|
407
|
+
if dep.startswith("cte:"):
|
|
408
|
+
parts = dep.split(":")
|
|
409
|
+
if len(parts) >= 3:
|
|
410
|
+
cte_name = ":".join(parts[2:])
|
|
411
|
+
cte_internal_deps = tables[source_id].get("cte_deps", {}).get(cte_name, {})
|
|
412
|
+
|
|
413
|
+
if discovery_mode:
|
|
414
|
+
# Discovery Mode: Create CTE ghost node with incoming edges
|
|
293
415
|
cte_id = dep
|
|
294
416
|
|
|
295
417
|
if cte_id not in missing_nodes:
|
|
296
|
-
# Retrieve SQL content if available
|
|
297
418
|
cte_content = f"-- CTE: {cte_name}"
|
|
298
419
|
if source_id in tables and "ctes" in tables[source_id]:
|
|
299
420
|
if cte_name in tables[source_id]["ctes"]:
|
|
@@ -302,30 +423,89 @@ def build_graph(tables, discovery_mode=False):
|
|
|
302
423
|
missing_nodes[cte_id] = {
|
|
303
424
|
"id": cte_id,
|
|
304
425
|
"label": cte_name,
|
|
305
|
-
"layer": "cte",
|
|
306
|
-
"type": "cte",
|
|
426
|
+
"layer": "cte",
|
|
427
|
+
"type": "cte",
|
|
307
428
|
"project": "internal",
|
|
308
429
|
"dataset": "cte",
|
|
309
430
|
"path": "internal",
|
|
310
|
-
"dependencies":
|
|
431
|
+
"dependencies": {},
|
|
311
432
|
"content": cte_content
|
|
312
433
|
}
|
|
313
434
|
|
|
314
|
-
|
|
435
|
+
# Wire CTE's internal dependencies as incoming edges
|
|
436
|
+
for inner_dep, inner_type in cte_internal_deps.items():
|
|
437
|
+
inner_target = lookup.get(inner_dep)
|
|
438
|
+
if not inner_target and "." in inner_dep:
|
|
439
|
+
inner_target = lookup.get(inner_dep.split(".")[-1])
|
|
440
|
+
|
|
441
|
+
if inner_target:
|
|
442
|
+
edges.append({
|
|
443
|
+
"id": f"{inner_target}-{cte_id}",
|
|
444
|
+
"source": inner_target,
|
|
445
|
+
"target": cte_id,
|
|
446
|
+
"animated": True,
|
|
447
|
+
"label": inner_type,
|
|
448
|
+
"style": {"stroke": "#E91E63"}
|
|
449
|
+
})
|
|
450
|
+
incoming_edges_count[cte_id] = incoming_edges_count.get(cte_id, 0) + 1
|
|
451
|
+
else:
|
|
452
|
+
# Create ghost node for missing CTE dep
|
|
453
|
+
ghost_id = inner_dep
|
|
454
|
+
if ghost_id not in missing_nodes:
|
|
455
|
+
dep_parts = ghost_id.split('.')
|
|
456
|
+
ghost_project, ghost_dataset, ghost_table = "default", "default", ghost_id
|
|
457
|
+
if len(dep_parts) == 3:
|
|
458
|
+
ghost_project, ghost_dataset, ghost_table = dep_parts
|
|
459
|
+
elif len(dep_parts) == 2:
|
|
460
|
+
ghost_dataset, ghost_table = dep_parts
|
|
461
|
+
missing_nodes[ghost_id] = {
|
|
462
|
+
"id": ghost_id, "label": ghost_table,
|
|
463
|
+
"layer": "external", "type": "table",
|
|
464
|
+
"project": ghost_project, "dataset": ghost_dataset,
|
|
465
|
+
"path": "discovered", "dependencies": {},
|
|
466
|
+
"content": "-- Discovered dependency (via CTE)"
|
|
467
|
+
}
|
|
468
|
+
edges.append({
|
|
469
|
+
"id": f"{ghost_id}-{cte_id}",
|
|
470
|
+
"source": ghost_id, "target": cte_id,
|
|
471
|
+
"animated": True, "label": inner_type,
|
|
472
|
+
"style": {"stroke": "#ff9f1c", "strokeDasharray": "5,5"}
|
|
473
|
+
})
|
|
474
|
+
incoming_edges_count[cte_id] = incoming_edges_count.get(cte_id, 0) + 1
|
|
475
|
+
|
|
476
|
+
# Edge from CTE to parent Table
|
|
315
477
|
edges.append({
|
|
316
478
|
"id": f"{cte_id}-{source_id}",
|
|
317
479
|
"source": cte_id,
|
|
318
480
|
"target": source_id,
|
|
319
481
|
"animated": True,
|
|
320
|
-
"
|
|
482
|
+
"label": "CTE",
|
|
483
|
+
"style": {"stroke": "#E91E63", "strokeDasharray": "2,2"}
|
|
321
484
|
})
|
|
322
|
-
# CTEs generally don't have incoming edges in this parser implementation yet
|
|
323
|
-
# but we count for the target
|
|
324
485
|
incoming_edges_count[source_id] = incoming_edges_count.get(source_id, 0) + 1
|
|
325
|
-
|
|
486
|
+
else:
|
|
487
|
+
# Non-discovery: Flatten CTE deps as direct edges to parent
|
|
488
|
+
for inner_dep, inner_type in cte_internal_deps.items():
|
|
489
|
+
inner_target = lookup.get(inner_dep)
|
|
490
|
+
if not inner_target and "." in inner_dep:
|
|
491
|
+
inner_target = lookup.get(inner_dep.split(".")[-1])
|
|
492
|
+
if inner_target and inner_target != source_id:
|
|
493
|
+
edge_id = f"{inner_target}-{source_id}"
|
|
494
|
+
# Avoid duplicate edges
|
|
495
|
+
if not any(e["id"] == edge_id for e in edges):
|
|
496
|
+
edges.append({
|
|
497
|
+
"id": edge_id,
|
|
498
|
+
"source": inner_target,
|
|
499
|
+
"target": source_id,
|
|
500
|
+
"animated": True,
|
|
501
|
+
"label": inner_type,
|
|
502
|
+
"style": {"stroke": "#b1b1b7"}
|
|
503
|
+
})
|
|
504
|
+
incoming_edges_count[source_id] = incoming_edges_count.get(source_id, 0) + 1
|
|
505
|
+
continue
|
|
326
506
|
|
|
327
|
-
|
|
328
|
-
|
|
507
|
+
# Handle missing external nodes (discovery mode only)
|
|
508
|
+
if discovery_mode and not target_id:
|
|
329
509
|
# Create a unique ID for the missing node
|
|
330
510
|
# Use the full dependency name as the ID
|
|
331
511
|
ghost_id = dep
|