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.
Files changed (23) hide show
  1. {sql_dag_flow-0.2.0/src/sql_dag_flow.egg-info → sql_dag_flow-0.3.0}/PKG-INFO +15 -6
  2. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/README.md +14 -5
  3. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/pyproject.toml +1 -1
  4. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/main.py +46 -0
  5. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/parser.py +226 -46
  6. sql_dag_flow-0.3.0/src/sql_dag_flow/static/assets/index-B6fMDnZ3.js +84 -0
  7. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/static/index.html +1 -1
  8. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0/src/sql_dag_flow.egg-info}/PKG-INFO +15 -6
  9. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/SOURCES.txt +1 -1
  10. sql_dag_flow-0.2.0/src/sql_dag_flow/static/assets/index-DzAemHSo.js +0 -61
  11. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/LICENSE +0 -0
  12. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/MANIFEST.in +0 -0
  13. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/setup.cfg +0 -0
  14. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/__init__.py +0 -0
  15. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/static/assets/index-h7gxR8yA.css +0 -0
  16. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/static/vite.svg +0 -0
  17. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/test_api_endpoints.py +0 -0
  18. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/test_parser.py +0 -0
  19. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow/verify_counts.py +0 -0
  20. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/dependency_links.txt +0 -0
  21. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/entry_points.txt +0 -0
  22. {sql_dag_flow-0.2.0 → sql_dag_flow-0.3.0}/src/sql_dag_flow.egg-info/requires.txt +0 -0
  23. {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.2.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 usage counts and click-to-center navigation.
74
- * **Details Panel**: View formatted SQL code, schema info, and configure node settings.
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.2.0**):
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 usage counts and click-to-center navigation.
49
- * **Details Panel**: View formatted SQL code, schema info, and configure node settings.
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.2.0**):
99
+ To update to the latest version (**v0.3.0**):
91
100
 
92
101
  ```bash
93
102
  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.2.0"
7
+ version = "0.3.0"
8
8
  description = "A sophisticated SQL lineage visualization tool for Medallion Architectures."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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 = set()
161
+ dependencies = {} # dep_name -> dep_type
158
162
 
159
- # 1. Identify CTEs defined in the query to exclude them from dependencies
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
- if cte.alias_or_name:
163
- # Extract the full CTE definition as SQL string
164
- # We can use cte.sql() or just the inner query
165
- # User likely wants the full "name AS ( ... )" or just the inner query
166
- # Let's give the full CTE expression for context
167
- defined_ctes[cte.alias_or_name] = cte.sql(dialect=dialect, pretty=True)
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
- # Add strictly as a CTE dependency so we can visualize it if desired
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
- dependencies.add(full_name)
193
- # REMOVED: partial match addition to prevent double counting in visual metadata
194
- # matches are now handled in build_graph via fuzzy lookup
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": list(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
- for dep in data["dependencies"]:
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
- # Discovery Mode: Handle missing dependencies OR CTEs
282
- if discovery_mode:
283
-
284
- # 1. Handle CTEs
285
- if dep.startswith("cte:"):
286
- # Format: cte:filename_base:cte_name
287
- parts = dep.split(":")
288
- if len(parts) >= 3:
289
- # Reconstruct in case name had colons (unlikely but safe)
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", # Special layer for CTEs
306
- "type": "cte", # Special type for CTEs
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
- # Edge from CTE to Table
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
- "style": {"stroke": "#E91E63", "strokeDasharray": "2,2"} # Pink dashed for CTEs
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
- continue
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
- # 2. Handle missing external nodes
328
- if not target_id:
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