sql-dag-flow 0.4.7__tar.gz → 0.4.9__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 (22) hide show
  1. {sql_dag_flow-0.4.7/src/sql_dag_flow.egg-info → sql_dag_flow-0.4.9}/PKG-INFO +9 -4
  2. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/README.md +8 -3
  3. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/pyproject.toml +1 -1
  4. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/main.py +47 -3
  5. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/parser.py +277 -0
  6. sql_dag_flow-0.4.7/src/sql_dag_flow/static/assets/index-BhG0Vsh6.js → sql_dag_flow-0.4.9/src/sql_dag_flow/static/assets/index-ZylJfG6u.js +53 -53
  7. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/static/index.html +1 -1
  8. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9/src/sql_dag_flow.egg-info}/PKG-INFO +9 -4
  9. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow.egg-info/SOURCES.txt +1 -1
  10. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/LICENSE +0 -0
  11. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/MANIFEST.in +0 -0
  12. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/setup.cfg +0 -0
  13. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/__init__.py +0 -0
  14. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/static/assets/index-BM6GPh_j.css +0 -0
  15. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/static/vite.svg +0 -0
  16. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/test_api_endpoints.py +0 -0
  17. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/test_parser.py +0 -0
  18. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow/verify_counts.py +0 -0
  19. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow.egg-info/dependency_links.txt +0 -0
  20. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow.egg-info/entry_points.txt +0 -0
  21. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/src/sql_dag_flow.egg-info/requires.txt +0 -0
  22. {sql_dag_flow-0.4.7 → sql_dag_flow-0.4.9}/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.7
3
+ Version: 0.4.9
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
@@ -89,12 +89,17 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
89
89
  ### 📊 Discovery & Analysis Tools
90
90
  * **Impact Analysis**: Visualize blast radius before making changes. Highlights downstream models, column usage, and risk levels.
91
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.
92
+ * **Column Usage Tracking (Improved in v0.4.9 🔧)**: Schema Preview shows which specific columns are used by downstream consumers. Uses `sqlglot.optimizer.qualify_columns` for precise resolution of unqualified column references.
93
93
  * **Staleness Detection**: Automatically flags inactive models (`Last Modified > 90d` = Stale) to help clean up legacy pipelines.
94
94
  * **Business Rule Extraction**: Automatically detects and displays WHERE filters, CASE logic, HAVING clauses, and aggregations from each SQL model.
95
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.
96
+ * **Node Comparison**: Select exactly 2 nodes and compare them side-by-side. Highlights differences across metadata, schema columns (shared vs unique), dependencies (Venn-style), complexity scores (with delta indicators), business rules, and SQL content (synced scroll).
96
97
  * **Statistics Panel**: Centered popup with layer distribution bars, edge/source/sink/orphan counts, project/dataset tree, and architecture health validation (now detects **circular dependencies**).
97
- * **Schema Preview**: Extracts columns from DDL, CTAS (`CREATE TABLE AS SELECT`), and `CREATE VIEW` statements.
98
+ * **Schema Extraction**: Backend AST-based extraction via `sqlglot` handles DDL, CTAS, `CREATE VIEW AS`, CTEs, window functions, CASE expressions, and `SELECT *`.
99
+ * **Column-Level Lineage (New in v0.4.9 🆕)**: Traces how each output column derives from source columns. Shows transformation chain (e.g., `order_timestamp ← orders_raw.order_date via CAST(... AS DATETIME)`).
100
+ * **SQL Syntax Validation (New in v0.4.9 🆕)**: Detects SQL parse errors and displays structured warnings with line/column references. Shows ⚠️ badge on nodes with syntax issues.
101
+ * **Batch Hide from Toolbar**: Select multiple nodes → click "Hide" in the selection toolbar to hide them all at once.
102
+ * **Discovery Mode Fix**: Ghost nodes from Discovery Mode are now hidden when their connected source nodes are hidden, preventing orphan ghost nodes.
98
103
 
99
104
  ### 🎨 Linear-Inspired UI (New in v0.4.6 ✨)
100
105
  * **Design Token System**: ~80 CSS custom properties for consistent theming across all components.
@@ -139,7 +144,7 @@ Install easily via `pip`:
139
144
  pip install sql-dag-flow
140
145
  ```
141
146
 
142
- To update to the latest version (**v0.4.6**):
147
+ To update to the latest version (**v0.4.9**):
143
148
 
144
149
  ```bash
145
150
  pip install --upgrade sql-dag-flow
@@ -64,12 +64,17 @@ Specially optimized for the **Medallion Architecture** (Bronze, Silver, Gold) an
64
64
  ### 📊 Discovery & Analysis Tools
65
65
  * **Impact Analysis**: Visualize blast radius before making changes. Highlights downstream models, column usage, and risk levels.
66
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.
67
+ * **Column Usage Tracking (Improved in v0.4.9 🔧)**: Schema Preview shows which specific columns are used by downstream consumers. Uses `sqlglot.optimizer.qualify_columns` for precise resolution of unqualified column references.
68
68
  * **Staleness Detection**: Automatically flags inactive models (`Last Modified > 90d` = Stale) to help clean up legacy pipelines.
69
69
  * **Business Rule Extraction**: Automatically detects and displays WHERE filters, CASE logic, HAVING clauses, and aggregations from each SQL model.
70
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.
71
+ * **Node Comparison**: Select exactly 2 nodes and compare them side-by-side. Highlights differences across metadata, schema columns (shared vs unique), dependencies (Venn-style), complexity scores (with delta indicators), business rules, and SQL content (synced scroll).
71
72
  * **Statistics Panel**: Centered popup with layer distribution bars, edge/source/sink/orphan counts, project/dataset tree, and architecture health validation (now detects **circular dependencies**).
72
- * **Schema Preview**: Extracts columns from DDL, CTAS (`CREATE TABLE AS SELECT`), and `CREATE VIEW` statements.
73
+ * **Schema Extraction**: Backend AST-based extraction via `sqlglot` handles DDL, CTAS, `CREATE VIEW AS`, CTEs, window functions, CASE expressions, and `SELECT *`.
74
+ * **Column-Level Lineage (New in v0.4.9 🆕)**: Traces how each output column derives from source columns. Shows transformation chain (e.g., `order_timestamp ← orders_raw.order_date via CAST(... AS DATETIME)`).
75
+ * **SQL Syntax Validation (New in v0.4.9 🆕)**: Detects SQL parse errors and displays structured warnings with line/column references. Shows ⚠️ badge on nodes with syntax issues.
76
+ * **Batch Hide from Toolbar**: Select multiple nodes → click "Hide" in the selection toolbar to hide them all at once.
77
+ * **Discovery Mode Fix**: Ghost nodes from Discovery Mode are now hidden when their connected source nodes are hidden, preventing orphan ghost nodes.
73
78
 
74
79
  ### 🎨 Linear-Inspired UI (New in v0.4.6 ✨)
75
80
  * **Design Token System**: ~80 CSS custom properties for consistent theming across all components.
@@ -114,7 +119,7 @@ Install easily via `pip`:
114
119
  pip install sql-dag-flow
115
120
  ```
116
121
 
117
- To update to the latest version (**v0.4.6**):
122
+ To update to the latest version (**v0.4.9**):
118
123
 
119
124
  ```bash
120
125
  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.7"
7
+ version = "0.4.9"
8
8
  description = "A sophisticated SQL lineage visualization tool for Medallion Architectures."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -107,15 +107,24 @@ def get_filtered_graph(data: dict = Body(...)):
107
107
  def get_path():
108
108
  return {"path": CURRENT_DIRECTORY}
109
109
 
110
- @app.get("/export")
111
- def export_data_dictionary(dialect: str = "bigquery"):
112
- """Generates a Markdown data dictionary report."""
110
+ @app.post("/export")
111
+ def export_data_dictionary(data: dict = Body(...)):
112
+ """Generates a Markdown data dictionary report for visible nodes only."""
113
+ dialect = data.get("dialect", "bigquery")
114
+ visible_node_ids = data.get("visible_node_ids", None) # None = export all
115
+
113
116
  if not os.path.exists(CURRENT_DIRECTORY):
114
117
  raise HTTPException(status_code=400, detail="Directory not found")
115
118
 
116
119
  tables = parse_sql_files(CURRENT_DIRECTORY, dialect=dialect)
117
120
  nodes, edges, cycles = build_graph(tables, discovery_mode=False)
118
121
 
122
+ # Filter to only visible nodes if list provided
123
+ if visible_node_ids is not None:
124
+ visible_set = set(visible_node_ids)
125
+ nodes = [n for n in nodes if n['id'] in visible_set]
126
+ edges = [e for e in edges if e['source'] in visible_set and e['target'] in visible_set]
127
+
119
128
  lines = []
120
129
  lines.append(f"# Data Dictionary")
121
130
  lines.append(f"")
@@ -195,6 +204,17 @@ def export_data_dictionary(dialect: str = "bigquery"):
195
204
  lines.extend(rules_items[:6])
196
205
  lines.append(f"")
197
206
 
207
+ # Schema columns
208
+ schema = d.get('schema', [])
209
+ if schema:
210
+ lines.append(f"**Schema ({len(schema)} columns):**")
211
+ lines.append(f"")
212
+ lines.append(f"| Column | Type |")
213
+ lines.append(f"|--------|------|")
214
+ for col in schema:
215
+ lines.append(f"| `{col.get('name', '?')}` | {col.get('type', 'UNKNOWN')} |")
216
+ lines.append(f"")
217
+
198
218
  # Column consumers
199
219
  col_consumers = d.get('column_consumers', {})
200
220
  if col_consumers:
@@ -207,6 +227,30 @@ def export_data_dictionary(dialect: str = "bigquery"):
207
227
  lines.append(f"| `{col}` | {consumer_labels} |")
208
228
  lines.append(f"")
209
229
 
230
+ # Column lineage
231
+ col_lineage = d.get('column_lineage', {})
232
+ if col_lineage:
233
+ lines.append(f"**Column Lineage:**")
234
+ lines.append(f"")
235
+ lines.append(f"| Output Column | Source | Transform |")
236
+ lines.append(f"|---------------|--------|-----------|")
237
+ for col, sources in sorted(col_lineage.items()):
238
+ for src in sources:
239
+ src_ref = f"{src.get('source_table', '')}.{src.get('source_column', '')}" if src.get('source_table') else src.get('source_column', '')
240
+ transform = src.get('transform', '') or '—'
241
+ lines.append(f"| `{col}` | `{src_ref}` | {transform} |")
242
+ lines.append(f"")
243
+
244
+ # Syntax warnings
245
+ syntax_warnings = d.get('syntax_warnings', [])
246
+ if syntax_warnings:
247
+ lines.append(f"**⚠️ Syntax Issues ({len(syntax_warnings)}):**")
248
+ lines.append(f"")
249
+ for w in syntax_warnings:
250
+ loc = f"Line {w.get('line', '?')}, Col {w.get('col', '?')}" if w.get('line') else ""
251
+ lines.append(f"- {w.get('description', 'Unknown error')} {f'({loc})' if loc else ''}")
252
+ lines.append(f"")
253
+
210
254
  lines.append(f"---")
211
255
  lines.append(f"")
212
256
 
@@ -3,8 +3,86 @@ import re
3
3
  import time
4
4
  import sqlglot
5
5
  from sqlglot import exp
6
+ from sqlglot.optimizer.qualify_columns import qualify_columns as sqlglot_qualify_columns
6
7
  import networkx as nx
7
8
 
9
+
10
+ def extract_output_columns(parsed, dialect="bigquery"):
11
+ """
12
+ Extract output column schema from a parsed SQL AST.
13
+ Returns a list of {"name": str, "type": str} dicts.
14
+
15
+ Handles:
16
+ - DDL with explicit column definitions (CREATE TABLE t (col TYPE))
17
+ - CTAS / CREATE VIEW AS SELECT
18
+ - WITH...SELECT (final SELECT after CTEs)
19
+ - Standalone SELECT
20
+ - Window functions, nested functions, CASE expressions
21
+ - SELECT * (returns [{"name": "*", "type": "ALL"}])
22
+ """
23
+ columns = []
24
+
25
+ # Case 1: DDL with explicit column definitions
26
+ if isinstance(parsed, exp.Create):
27
+ schema_node = parsed.this
28
+ if isinstance(schema_node, exp.Schema):
29
+ for col_def in schema_node.expressions:
30
+ if isinstance(col_def, exp.ColumnDef):
31
+ col_name = col_def.name
32
+ col_type_node = col_def.args.get("kind")
33
+ type_str = col_type_node.sql(dialect=dialect) if col_type_node else "UNKNOWN"
34
+ columns.append({"name": col_name, "type": type_str})
35
+ if columns:
36
+ return columns
37
+
38
+ # Case 2: Find the final SELECT statement
39
+ # For CTAS, CREATE VIEW AS, WITH...SELECT, standalone SELECT
40
+ # We want the outermost SELECT that isn't inside a CTE or subquery
41
+ select_node = None
42
+
43
+ if isinstance(parsed, exp.Create):
44
+ # For CTAS / CREATE VIEW AS: get the SELECT inside the CREATE
45
+ inner = parsed.expression
46
+ if inner:
47
+ if isinstance(inner, exp.Select):
48
+ select_node = inner
49
+ elif hasattr(inner, 'find'):
50
+ select_node = inner.find(exp.Select)
51
+
52
+ if not select_node:
53
+ # For standalone queries: find WITH wrapper or direct SELECT
54
+ # Walk to find the top-level Select (not nested in subquery)
55
+ if isinstance(parsed, exp.Select):
56
+ select_node = parsed
57
+ else:
58
+ # Could be a WITH or UNION — find the first select
59
+ select_node = parsed.find(exp.Select)
60
+
61
+ if select_node:
62
+ for expr in select_node.expressions:
63
+ try:
64
+ if isinstance(expr, exp.Star):
65
+ columns.append({"name": "*", "type": "ALL"})
66
+ elif isinstance(expr, exp.Alias):
67
+ alias_name = expr.alias
68
+ inner_expr = expr.this
69
+ type_str = inner_expr.sql(dialect=dialect, pretty=False)
70
+ # Truncate very long expressions for readability
71
+ if len(type_str) > 80:
72
+ type_str = type_str[:77] + "..."
73
+ columns.append({"name": alias_name, "type": type_str})
74
+ elif isinstance(expr, exp.Column):
75
+ columns.append({"name": expr.name, "type": "column"})
76
+ else:
77
+ # Computed expression without alias
78
+ expr_sql = expr.sql(dialect=dialect, pretty=False)
79
+ name = expr_sql[:40] + "..." if len(expr_sql) > 40 else expr_sql
80
+ columns.append({"name": name, "type": "expression"})
81
+ except Exception:
82
+ continue
83
+
84
+ return columns
85
+
8
86
  def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
9
87
  """
10
88
  Recursively scans a directory for .sql files and parses them.
@@ -328,6 +406,9 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
328
406
  alias_map[t_name] = t_full
329
407
 
330
408
  # Extract column references with their table qualifier
409
+ # Also capture unqualified columns and assign them to source tables
410
+ unqualified_columns = set()
411
+
331
412
  for col in parsed.find_all(exp.Column):
332
413
  col_name = col.name
333
414
  col_table = col.table # The table qualifier (alias or name)
@@ -336,6 +417,36 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
336
417
  if source not in column_references:
337
418
  column_references[source] = set()
338
419
  column_references[source].add(col_name)
420
+ elif not col_table:
421
+ # Unqualified column — track separately
422
+ unqualified_columns.add(col_name)
423
+
424
+ # Assign unqualified columns to source tables
425
+ # Filter out CTEs and the target table itself (both short and full name)
426
+ source_tables = [
427
+ v for k, v in alias_map.items()
428
+ if v != target_table_name
429
+ and v.split('.')[-1] != target_table_name
430
+ and v not in defined_ctes
431
+ and v.split('.')[-1] not in defined_ctes
432
+ ]
433
+ # Deduplicate (aliases may point to same table)
434
+ source_tables = list(set(source_tables))
435
+
436
+ if unqualified_columns and source_tables:
437
+ if len(source_tables) == 1:
438
+ # Single source: assign all unqualified columns to it
439
+ src = source_tables[0]
440
+ if src not in column_references:
441
+ column_references[src] = set()
442
+ column_references[src].update(unqualified_columns)
443
+ else:
444
+ # Multiple sources: assign to ALL sources (best effort)
445
+ # The UI will show them as "used" which is better than missing
446
+ for src in source_tables:
447
+ if src not in column_references:
448
+ column_references[src] = set()
449
+ column_references[src].update(unqualified_columns)
339
450
 
340
451
  # Convert sets to sorted lists for JSON serialization
341
452
  column_references = {k: sorted(list(v)) for k, v in column_references.items()}
@@ -389,12 +500,37 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
389
500
  "content": sql_content,
390
501
  "ctes": defined_ctes,
391
502
  "cte_deps": cte_deps,
503
+ "schema": extract_output_columns(parsed, dialect),
392
504
  "business_rules": business_rules,
393
505
  "complexity": complexity_breakdown,
394
506
  "column_references": column_references,
395
507
  "header_meta": header_meta,
396
508
  "last_modified_days": days_ago
397
509
  }
510
+ except sqlglot.errors.ParseError as pe:
511
+ # Feature 4: Capture structured syntax errors
512
+ syntax_warnings = []
513
+ for err in pe.errors:
514
+ syntax_warnings.append({
515
+ "description": err.get("description", str(pe)),
516
+ "line": err.get("line"),
517
+ "col": err.get("col"),
518
+ "highlight": err.get("highlight", "")
519
+ })
520
+ print(f"Syntax error in {filepath}: {pe}")
521
+ tables[filename_base] = {
522
+ "id": filename_base,
523
+ "label": filename_base,
524
+ "layer": layer,
525
+ "type": "unknown",
526
+ "project": "n/a",
527
+ "dataset": "n/a",
528
+ "path": filepath,
529
+ "dependencies": {},
530
+ "error": str(pe),
531
+ "syntax_warnings": syntax_warnings,
532
+ "content": sql_content
533
+ }
398
534
  except Exception as e:
399
535
  print(f"Error parsing {filepath}: {e}")
400
536
  tables[filename_base] = {
@@ -409,6 +545,144 @@ def parse_sql_files(directory, allowed_subfolders=None, dialect="bigquery"):
409
545
  "error": str(e),
410
546
  "content": sql_content
411
547
  }
548
+ # ===== Second Pass: Qualify Columns & Column Lineage =====
549
+ # Build a global schema dict for qualify_columns and lineage
550
+ global_schema = {} # {dataset: {table: {col: type}}}
551
+ for tid, tdata in tables.items():
552
+ schema_cols = tdata.get("schema", [])
553
+ if not schema_cols:
554
+ continue
555
+ ds = tdata.get("dataset", "default")
556
+ label = tdata.get("label", tid)
557
+ if ds not in global_schema:
558
+ global_schema[ds] = {}
559
+ col_dict = {}
560
+ for c in schema_cols:
561
+ col_dict[c["name"]] = c.get("type", "UNKNOWN")
562
+ global_schema[ds][label] = col_dict
563
+ # Also add without dataset prefix for simpler lookups
564
+ if "default" not in global_schema:
565
+ global_schema["default"] = {}
566
+ global_schema["default"][label] = col_dict
567
+
568
+ # Re-extract column_references using qualify_columns for precision
569
+ for tid, tdata in tables.items():
570
+ if tdata.get("error") or not tdata.get("content"):
571
+ continue
572
+ try:
573
+ parsed = sqlglot.parse_one(tdata["content"], read=dialect)
574
+
575
+ # Try to qualify columns using the global schema
576
+ try:
577
+ qualified_ast = sqlglot_qualify_columns(parsed.copy(), schema=global_schema, dialect=dialect)
578
+ except Exception:
579
+ qualified_ast = parsed # Fallback to unqualified
580
+
581
+ # Re-extract column references from qualified AST
582
+ alias_map = {}
583
+ defined_ctes = set()
584
+ for cte in qualified_ast.find_all(exp.CTE):
585
+ cn = cte.alias_or_name
586
+ if cn:
587
+ defined_ctes.add(cn)
588
+
589
+ for t in qualified_ast.find_all(exp.Table):
590
+ t_name = t.name
591
+ t_full = t_name
592
+ if t.db:
593
+ t_full = f"{t.db}.{t_name}"
594
+ if t.catalog:
595
+ t_full = f"{t.catalog}.{t.db}.{t_name}"
596
+ if t.alias:
597
+ alias_map[t.alias] = t_full
598
+ alias_map[t_name] = t_full
599
+
600
+ column_references = {}
601
+ target_name = tdata.get("label", tid)
602
+ unqualified_columns = set()
603
+
604
+ for col in qualified_ast.find_all(exp.Column):
605
+ col_name = col.name
606
+ col_table = col.table
607
+ if col_table and col_table in alias_map:
608
+ source = alias_map[col_table]
609
+ if source not in column_references:
610
+ column_references[source] = set()
611
+ column_references[source].add(col_name)
612
+ elif not col_table:
613
+ unqualified_columns.add(col_name)
614
+
615
+ # Fallback for any remaining unqualified columns
616
+ if unqualified_columns:
617
+ source_tables = [
618
+ v for k, v in alias_map.items()
619
+ if v != target_name
620
+ and v.split('.')[-1] != target_name
621
+ and v not in defined_ctes
622
+ and v.split('.')[-1] not in defined_ctes
623
+ ]
624
+ source_tables = list(set(source_tables))
625
+ if source_tables:
626
+ for src in source_tables:
627
+ if src not in column_references:
628
+ column_references[src] = set()
629
+ column_references[src].update(unqualified_columns)
630
+
631
+ column_references = {k: sorted(list(v)) for k, v in column_references.items()}
632
+ tdata["column_references"] = column_references
633
+
634
+ except Exception:
635
+ pass # Keep original column_references
636
+
637
+ # ===== Column-Level Lineage =====
638
+ # For each table, trace how each output column derives from source columns
639
+ for tid, tdata in tables.items():
640
+ if tdata.get("error") or not tdata.get("content"):
641
+ continue
642
+ schema_cols = tdata.get("schema", [])
643
+ if not schema_cols:
644
+ continue
645
+
646
+ column_lineage = {} # col_name -> [{source_table, source_column, transform}]
647
+ sql_content = tdata["content"]
648
+
649
+ for col_info in schema_cols:
650
+ col_name = col_info["name"]
651
+ if col_name == "*":
652
+ continue
653
+ try:
654
+ from sqlglot.lineage import lineage as sqlglot_lineage
655
+ node = sqlglot_lineage(
656
+ col_name, sql_content,
657
+ schema=global_schema,
658
+ dialect=dialect
659
+ )
660
+ sources = []
661
+ for child in node.downstream:
662
+ source_col = child.name
663
+ # Extract the source table from the expression
664
+ source_table = ""
665
+ if child.source and isinstance(child.source, exp.Table):
666
+ source_table = child.source.name
667
+ if child.source.db:
668
+ source_table = f"{child.source.db}.{child.source.name}"
669
+
670
+ transform = ""
671
+ if child.expression and str(child.expression) != source_col:
672
+ transform = str(child.expression)
673
+
674
+ sources.append({
675
+ "source_table": source_table,
676
+ "source_column": source_col,
677
+ "transform": transform
678
+ })
679
+ if sources:
680
+ column_lineage[col_name] = sources
681
+ except Exception:
682
+ pass # Lineage not available for this column
683
+
684
+ if column_lineage:
685
+ tdata["column_lineage"] = column_lineage
412
686
 
413
687
  return tables
414
688
 
@@ -637,6 +911,9 @@ def build_graph(tables, discovery_mode=False, expanded_nodes=None):
637
911
  source_node_id = lookup.get(source_ref.split(".")[-1])
638
912
 
639
913
  if source_node_id and source_node_id in all_nodes_data:
914
+ # Skip self-references (a model shouldn't be its own consumer)
915
+ if source_node_id == consumer_id:
916
+ continue
640
917
  if source_node_id not in column_consumers:
641
918
  column_consumers[source_node_id] = {}
642
919
  for col in columns: