bqsqlparse 0.1.0__py3-none-any.whl

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.
bqsqlparse/lineage.py ADDED
@@ -0,0 +1,902 @@
1
+ """Column (attribute) level lineage extraction for BigQuery SQL.
2
+
3
+ Given a statement AST (or SQL text), this module traces every output column
4
+ back to the physical source table columns it derives from — through CTEs,
5
+ subqueries, joins, set operations, UNNEST (including struct field access),
6
+ star expansion, PIVOT/UNPIVOT, and DML (INSERT / UPDATE / MERGE / CTAS).
7
+
8
+ Usage::
9
+
10
+ from bqsqlparse import extract_lineage
11
+
12
+ result = extract_lineage(sql, schema={"proj.ds.orders": ["id", "amount"]})
13
+ for col in result.columns:
14
+ print(col.name, "<-", [f"{s.table}.{s.column}" for s in col.sources])
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass, field as dc_field
21
+ from typing import Dict, List, Optional, Sequence, Set, Tuple, Union
22
+
23
+ from . import nodes as n
24
+ from .errors import LineageError
25
+ from .functions import is_aggregate, is_navigation
26
+ from .parser import parse, parse_one
27
+
28
+ # Transformation kinds
29
+ IDENTITY = "IDENTITY"
30
+ EXPRESSION = "EXPRESSION"
31
+ AGGREGATION = "AGGREGATION"
32
+ WINDOW = "WINDOW"
33
+ CONSTANT = "CONSTANT"
34
+ STAR = "STAR"
35
+ OFFSET = "OFFSET"
36
+
37
+
38
+ @dataclass(frozen=True, order=True)
39
+ class SourceColumn:
40
+ """A physical source attribute: ``table`` is the fully-qualified table
41
+ name; ``column`` may include struct field paths (``payload.user.id``) or
42
+ be ``*`` when a star could not be expanded without a schema."""
43
+
44
+ table: str
45
+ column: str
46
+
47
+ def __str__(self) -> str:
48
+ return f"{self.table}.{self.column}"
49
+
50
+
51
+ @dataclass
52
+ class ColumnUsage:
53
+ """Where a physical source column is used across the statement.
54
+
55
+ ``contexts`` is a sorted list drawn from: SELECT, JOIN, WHERE, GROUP_BY,
56
+ HAVING, QUALIFY, ORDER_BY, UNNEST, PIVOT, TABLE_FUNCTION, SET, INSERT.
57
+ """
58
+
59
+ table: str
60
+ column: str
61
+ contexts: List[str]
62
+
63
+ def to_dict(self) -> dict:
64
+ return {"table": self.table, "column": self.column, "contexts": self.contexts}
65
+
66
+
67
+ @dataclass
68
+ class ColumnLineage:
69
+ name: str
70
+ expression: str
71
+ transformation: str
72
+ sources: List[SourceColumn] = dc_field(default_factory=list)
73
+ indirect_sources: List[SourceColumn] = dc_field(default_factory=list)
74
+
75
+ def to_dict(self) -> dict:
76
+ return {
77
+ "name": self.name,
78
+ "expression": self.expression,
79
+ "transformation": self.transformation,
80
+ "sources": [{"table": s.table, "column": s.column} for s in self.sources],
81
+ "indirect_sources": [
82
+ {"table": s.table, "column": s.column} for s in self.indirect_sources
83
+ ],
84
+ }
85
+
86
+
87
+ @dataclass
88
+ class LineageResult:
89
+ target: Optional[str]
90
+ columns: List[ColumnLineage]
91
+ tables: List[str]
92
+ ctes: List[str] = dc_field(default_factory=list)
93
+ usage: List[ColumnUsage] = dc_field(default_factory=list)
94
+
95
+ @property
96
+ def edges(self) -> List[Tuple[str, str]]:
97
+ """(source ``table.column``, target ``target.column``) pairs."""
98
+ tgt = self.target or "<query>"
99
+ out = []
100
+ for col in self.columns:
101
+ for s in col.sources:
102
+ out.append((str(s), f"{tgt}.{col.name}"))
103
+ return out
104
+
105
+ def to_dict(self) -> dict:
106
+ return {
107
+ "target": self.target,
108
+ "tables": self.tables,
109
+ "ctes": self.ctes,
110
+ "columns": [c.to_dict() for c in self.columns],
111
+ "usage": [u.to_dict() for u in self.usage],
112
+ }
113
+
114
+ def to_json(self, **kwargs) -> str:
115
+ kwargs.setdefault("indent", 2)
116
+ return json.dumps(self.to_dict(), **kwargs)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Internal model
121
+ # ---------------------------------------------------------------------------
122
+
123
+ @dataclass
124
+ class _Out:
125
+ name: str
126
+ sources: Set[SourceColumn]
127
+ transformation: str
128
+ expression: str = ""
129
+ indirect: Set[SourceColumn] = dc_field(default_factory=set)
130
+
131
+
132
+ @dataclass
133
+ class _Derived:
134
+ """The resolved output of a query/subquery/CTE."""
135
+
136
+ outputs: "Dict[str, _Out]" # ordered; keys lowercased
137
+ order: List[str]
138
+ star_sources: List[SourceColumn] # unexpanded pass-through columns
139
+
140
+ def get(self, name: str) -> Optional[_Out]:
141
+ return self.outputs.get(name.lower())
142
+
143
+
144
+ @dataclass
145
+ class _Rel:
146
+ kind: str # 'table' | 'derived' | 'unnest'
147
+ alias: Optional[str] = None
148
+ table_name: Optional[str] = None
149
+ schema_cols: Optional[List[str]] = None
150
+ derived: Optional[_Derived] = None
151
+ unnest_sources: Set[SourceColumn] = dc_field(default_factory=set)
152
+ offset_alias: Optional[str] = None
153
+
154
+ def names(self) -> List[str]:
155
+ """Qualifier names this relation answers to (lowercased)."""
156
+ out = []
157
+ if self.alias:
158
+ out.append(self.alias.lower())
159
+ elif self.table_name:
160
+ out.append(self.table_name.lower())
161
+ last = self.table_name.split(".")[-1].lower()
162
+ if last != self.table_name.lower():
163
+ out.append(last)
164
+ return out
165
+
166
+ def has_column(self, name: str) -> Optional[bool]:
167
+ """True/False if known, None if unknown schema."""
168
+ name = name.lower()
169
+ if self.kind == "table":
170
+ if self.schema_cols is None:
171
+ return None
172
+ return name in (c.lower() for c in self.schema_cols)
173
+ if self.kind == "derived":
174
+ if self.derived.get(name) is not None:
175
+ return True
176
+ return None if self.derived.star_sources else False
177
+ if self.kind == "unnest":
178
+ return name in ((self.alias or "").lower(), (self.offset_alias or "").lower())
179
+ return None
180
+
181
+ def resolve(self, rest: Sequence[str]) -> Set[SourceColumn]:
182
+ """Resolve a column (+ optional struct field path) inside this rel."""
183
+ if self.kind == "table":
184
+ return {SourceColumn(self.table_name, ".".join(rest))}
185
+ if self.kind == "unnest":
186
+ if rest and self.offset_alias and rest[0].lower() == self.offset_alias.lower():
187
+ return set()
188
+ suffix = list(rest)
189
+ if suffix and self.alias and suffix[0].lower() == self.alias.lower():
190
+ suffix = suffix[1:]
191
+ if not suffix:
192
+ return set(self.unnest_sources)
193
+ return {
194
+ SourceColumn(s.table, f"{s.column}.{'.'.join(suffix)}" if s.column != "*" else ".".join(suffix))
195
+ for s in self.unnest_sources
196
+ }
197
+ # derived
198
+ out = self.derived.get(rest[0])
199
+ if out is not None:
200
+ if len(rest) == 1:
201
+ return set(out.sources)
202
+ suffix = ".".join(rest[1:])
203
+ return {
204
+ SourceColumn(s.table, f"{s.column}.{suffix}" if s.column != "*" else suffix)
205
+ for s in out.sources
206
+ }
207
+ # pass through unexpanded star
208
+ return {
209
+ SourceColumn(s.table, ".".join(rest))
210
+ for s in self.derived.star_sources
211
+ }
212
+
213
+
214
+ class _Scope:
215
+ def __init__(self, rels: List[_Rel], parent: Optional["_Scope"] = None,
216
+ variables: Optional[Set[str]] = None):
217
+ self.rels = rels
218
+ self.parent = parent
219
+ self.variables = variables if variables is not None else (
220
+ parent.variables if parent is not None else set())
221
+
222
+ def resolve_path(self, path: Sequence[str]) -> Set[SourceColumn]:
223
+ # 1. qualified: longest-prefix match against aliases / table names
224
+ for k in range(min(len(path) - 1, 3), 0, -1):
225
+ qual = ".".join(path[:k]).lower()
226
+ for rel in self.rels:
227
+ if qual in rel.names():
228
+ return rel.resolve(path[k:])
229
+ # unnest element referenced by its own alias with no remainder
230
+ for rel in self.rels:
231
+ if rel.kind == "unnest":
232
+ if rel.alias and path[0].lower() == rel.alias.lower():
233
+ return rel.resolve(path)
234
+ if rel.offset_alias and path[0].lower() == rel.offset_alias.lower():
235
+ return set()
236
+ # 2. unqualified
237
+ name = path[0]
238
+ claiming = [r for r in self.rels if r.has_column(name) is True]
239
+ if len(claiming) >= 1:
240
+ results: Set[SourceColumn] = set()
241
+ for rel in claiming:
242
+ results |= rel.resolve(path)
243
+ return results
244
+ # script variables shadow unknown-schema columns
245
+ if name.lower() in self.variables:
246
+ return set()
247
+ unknown = [r for r in self.rels if r.has_column(name) is None]
248
+ if unknown:
249
+ results = set()
250
+ for rel in unknown:
251
+ results |= rel.resolve(path)
252
+ return results
253
+ if self.parent is not None:
254
+ return self.parent.resolve_path(path)
255
+ return set()
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Analyzer
260
+ # ---------------------------------------------------------------------------
261
+
262
+ def _flatten_path(expr: n.Node) -> Optional[List[str]]:
263
+ if isinstance(expr, n.ColumnRef):
264
+ return list(expr.path)
265
+ if isinstance(expr, n.FieldAccess):
266
+ base = _flatten_path(expr.base)
267
+ if base is not None:
268
+ return base + [expr.field_name]
269
+ return None
270
+
271
+
272
+ class _Analyzer:
273
+ def __init__(self, schema: Optional[Dict[str, List[str]]] = None,
274
+ include_indirect: bool = False,
275
+ variables: Optional[Set[str]] = None):
276
+ self.schema = { (k.replace("`", "").lower()): v for k, v in (schema or {}).items() }
277
+ self.include_indirect = include_indirect
278
+ self.variables = {v.lower() for v in (variables or set())}
279
+ self.tables: Set[str] = set()
280
+ self.cte_names: Set[str] = set()
281
+ self.usage: Dict[SourceColumn, Set[str]] = {}
282
+
283
+ # -------------------------------------------------------------- resolve
284
+
285
+ def _collect(self, expr: n.Node, scope: _Scope, out: Set[SourceColumn]):
286
+ path = _flatten_path(expr)
287
+ if path is not None:
288
+ out |= scope.resolve_path(path)
289
+ return
290
+ if isinstance(expr, (n.ScalarSubquery, n.ArraySubquery, n.ExistsSubquery)):
291
+ derived = self.analyze_query(expr.query, {}, scope)
292
+ for o in derived.outputs.values():
293
+ out |= o.sources
294
+ for s in derived.star_sources:
295
+ out.add(s)
296
+ return
297
+ if isinstance(expr, n.Star):
298
+ return
299
+ if isinstance(expr, n.Node):
300
+ for child in expr.children():
301
+ self._collect(child, scope, out)
302
+
303
+ def _tag(self, srcs: Set[SourceColumn], context: str):
304
+ for s in srcs:
305
+ self.usage.setdefault(s, set()).add(context)
306
+
307
+ def _use(self, expr: n.Node, scope: _Scope, context: str) -> Set[SourceColumn]:
308
+ """Collect sources of *expr* and record which clause used them."""
309
+ srcs: Set[SourceColumn] = set()
310
+ self._collect(expr, scope, srcs)
311
+ self._tag(srcs, context)
312
+ return srcs
313
+
314
+ def _classify(self, expr: n.Node, sources: Set[SourceColumn]) -> str:
315
+ has_window = False
316
+ has_agg = False
317
+ for node in expr.walk():
318
+ if isinstance(node, n.FuncCall):
319
+ if node.over is not None:
320
+ has_window = True
321
+ if is_aggregate(node.name_str) or is_navigation(node.name_str):
322
+ if node.over is not None:
323
+ has_window = True
324
+ elif is_aggregate(node.name_str):
325
+ has_agg = True
326
+ if has_window:
327
+ return WINDOW
328
+ if has_agg:
329
+ return AGGREGATION
330
+ if _flatten_path(expr) is not None:
331
+ return IDENTITY
332
+ if not sources:
333
+ return CONSTANT
334
+ return EXPRESSION
335
+
336
+ # ---------------------------------------------------------------- query
337
+
338
+ def analyze_query(self, query: n.Query, env: Dict[str, _Derived],
339
+ parent: Optional[_Scope] = None) -> _Derived:
340
+ env = dict(env)
341
+ if query.recursive:
342
+ for cte in query.ctes:
343
+ env.setdefault(cte.name.lower(), _Derived({}, [], []))
344
+ for cte in query.ctes:
345
+ self.cte_names.add(cte.name)
346
+ env[cte.name.lower()] = self.analyze_query(cte.query, env, parent)
347
+ return self._analyze_body(query.body, env, parent, query.order_by)
348
+
349
+ def _tag_order_by_outputs(self, derived: _Derived,
350
+ order_by: Sequence[n.OrderItem]):
351
+ """For set ops / nested queries, ORDER BY refers to output names."""
352
+ for oi in order_by:
353
+ path = _flatten_path(oi.expr)
354
+ if path is not None and len(path) == 1:
355
+ out = derived.get(path[0])
356
+ if out is not None:
357
+ self._tag(out.sources, "ORDER_BY")
358
+
359
+ def _analyze_body(self, body: n.Node, env: Dict[str, _Derived],
360
+ parent: Optional[_Scope],
361
+ order_by: Sequence[n.OrderItem] = ()) -> _Derived:
362
+ if isinstance(body, n.Query):
363
+ derived = self.analyze_query(body, env, parent)
364
+ self._tag_order_by_outputs(derived, order_by)
365
+ return derived
366
+ if isinstance(body, n.SetOp):
367
+ left = self._analyze_body(body.left, env, parent)
368
+ right = self._analyze_body(body.right, env, parent)
369
+ outputs: Dict[str, _Out] = {}
370
+ order: List[str] = []
371
+ rnames = right.order
372
+ for idx, key in enumerate(left.order):
373
+ lo = left.outputs[key]
374
+ merged = _Out(lo.name, set(lo.sources), lo.transformation,
375
+ lo.expression, set(lo.indirect))
376
+ if idx < len(rnames):
377
+ ro = right.outputs[rnames[idx]]
378
+ merged.sources |= ro.sources
379
+ merged.indirect |= ro.indirect
380
+ outputs[key] = merged
381
+ order.append(key)
382
+ derived = _Derived(outputs, order,
383
+ left.star_sources + right.star_sources)
384
+ self._tag_order_by_outputs(derived, order_by)
385
+ return derived
386
+ if isinstance(body, n.Select):
387
+ return self._analyze_select(body, env, parent, order_by)
388
+ raise LineageError(f"Unexpected query body: {type(body).__name__}")
389
+
390
+ # --------------------------------------------------------------- select
391
+
392
+ def _build_scope(self, from_: Optional[n.Node], env: Dict[str, _Derived],
393
+ parent: Optional[_Scope],
394
+ indirect_exprs: List[Tuple[str, n.Node]]) -> _Scope:
395
+ rels: List[_Rel] = []
396
+ scope = _Scope(rels, parent, self.variables)
397
+
398
+ def add(item: Optional[n.Node]):
399
+ if item is None:
400
+ return
401
+ if isinstance(item, n.Join):
402
+ add(item.left)
403
+ add(item.right)
404
+ if item.on is not None:
405
+ indirect_exprs.append(("JOIN", item.on))
406
+ for col in item.using:
407
+ indirect_exprs.append(("JOIN", n.ColumnRef([col])))
408
+ return
409
+ if isinstance(item, n.TableRef):
410
+ name = item.full_name.replace("`", "")
411
+ key = name.lower()
412
+ if len(item.path) == 1 and key in env:
413
+ rels.append(_Rel("derived", alias=item.alias or item.path[0],
414
+ derived=env[key]))
415
+ else:
416
+ self.tables.add(name)
417
+ rels.append(_Rel("table", alias=item.alias, table_name=name,
418
+ schema_cols=self.schema.get(key)))
419
+ return
420
+ if isinstance(item, n.SubqueryRef):
421
+ derived = self.analyze_query(item.query, env, scope)
422
+ rels.append(_Rel("derived", alias=item.alias, derived=derived))
423
+ return
424
+ if isinstance(item, n.UnnestRef):
425
+ srcs = self._use(item.expr, scope, "UNNEST")
426
+ rels.append(_Rel("unnest", alias=item.alias,
427
+ unnest_sources=srcs,
428
+ offset_alias=item.offset_alias))
429
+ return
430
+ if isinstance(item, (n.PivotRef, n.UnpivotRef)):
431
+ inner_indirect: List[Tuple[str, n.Node]] = []
432
+ inner_scope = self._build_scope(item.input, env, parent, inner_indirect)
433
+ stars: List[SourceColumn] = []
434
+ for rel in inner_scope.rels:
435
+ if rel.kind == "table":
436
+ if rel.schema_cols:
437
+ stars.extend(SourceColumn(rel.table_name, c)
438
+ for c in rel.schema_cols)
439
+ else:
440
+ stars.append(SourceColumn(rel.table_name, "*"))
441
+ elif rel.kind == "derived":
442
+ stars.extend(rel.derived.star_sources)
443
+ for o in rel.derived.outputs.values():
444
+ stars.extend(o.sources)
445
+ rels.append(_Rel("derived", alias=item.alias,
446
+ derived=_Derived({}, [], stars)))
447
+ if isinstance(item, n.PivotRef):
448
+ indirect_exprs.append(("PIVOT", item.for_col))
449
+ return
450
+ if isinstance(item, n.TableFuncRef):
451
+ name = ".".join(item.name).replace("`", "")
452
+ self.tables.add(name + "()")
453
+ rels.append(_Rel("table", alias=item.alias,
454
+ table_name=name + "()"))
455
+ for a in item.args:
456
+ indirect_exprs.append(("TABLE_FUNCTION", a))
457
+ return
458
+ raise LineageError(f"Unexpected FROM item: {type(item).__name__}")
459
+
460
+ add(from_)
461
+ return scope
462
+
463
+ def _expand_rel(self, rel: _Rel, star: n.Star, add_out, star_sources: List[SourceColumn]):
464
+ except_ = {e.lower() for e in star.except_}
465
+ replace = {r.name.lower(): r for r in star.replace}
466
+ if rel.kind == "table":
467
+ if rel.schema_cols is not None:
468
+ for col in rel.schema_cols:
469
+ if col.lower() in except_:
470
+ continue
471
+ add_out(col, {SourceColumn(rel.table_name, col)}, IDENTITY, col)
472
+ else:
473
+ src = SourceColumn(rel.table_name, "*")
474
+ star_sources.append(src)
475
+ add_out("*", {src}, STAR, star.sql())
476
+ elif rel.kind == "derived":
477
+ for key in rel.derived.order:
478
+ o = rel.derived.outputs[key]
479
+ if key in except_:
480
+ continue
481
+ add_out(o.name, set(o.sources), IDENTITY, o.name, set(o.indirect))
482
+ for s in rel.derived.star_sources:
483
+ star_sources.append(s)
484
+ add_out("*", {s}, STAR, star.sql())
485
+ elif rel.kind == "unnest":
486
+ name = rel.alias or "f0_"
487
+ if name.lower() not in except_:
488
+ add_out(name, set(rel.unnest_sources), IDENTITY, name)
489
+ if rel.offset_alias and rel.offset_alias.lower() not in except_:
490
+ add_out(rel.offset_alias, set(), OFFSET, "OFFSET")
491
+ # apply REPLACE overrides afterwards (handled by caller via replace map)
492
+ return replace
493
+
494
+ def _analyze_select(self, select: n.Select, env: Dict[str, _Derived],
495
+ parent: Optional[_Scope],
496
+ order_by: Sequence[n.OrderItem] = ()) -> _Derived:
497
+ indirect_exprs: List[Tuple[str, n.Node]] = []
498
+ scope = self._build_scope(select.from_, env, parent, indirect_exprs)
499
+
500
+ for ctx, clause in (("WHERE", select.where), ("HAVING", select.having),
501
+ ("QUALIFY", select.qualify)):
502
+ if clause is not None:
503
+ indirect_exprs.append((ctx, clause))
504
+ if select.group_by is not None:
505
+ for e in select.group_by.exprs:
506
+ indirect_exprs.append(("GROUP_BY", e))
507
+ for s in select.group_by.sets:
508
+ for e in s:
509
+ indirect_exprs.append(("GROUP_BY", e))
510
+
511
+ indirect: Set[SourceColumn] = set()
512
+ for ctx, e in indirect_exprs:
513
+ srcs = self._use(e, scope, ctx)
514
+ if self.include_indirect:
515
+ indirect |= srcs
516
+
517
+ outputs: Dict[str, _Out] = {}
518
+ order: List[str] = []
519
+ star_sources: List[SourceColumn] = []
520
+ anon = [0]
521
+
522
+ def add_out(name: str, sources: Set[SourceColumn], transformation: str,
523
+ expression: str, extra_indirect: Optional[Set[SourceColumn]] = None):
524
+ key = name.lower()
525
+ if key in outputs and key != "*":
526
+ key = f"{key}#{len(order)}"
527
+ self._tag(sources, "SELECT")
528
+ o = _Out(name, sources, transformation, expression,
529
+ set(indirect) | (extra_indirect or set()))
530
+ outputs[key] = o
531
+ order.append(key)
532
+
533
+ for i, item in enumerate(select.items):
534
+ if isinstance(item, n.Star):
535
+ target_rels = scope.rels
536
+ if item.prefix:
537
+ qual = ".".join(item.prefix).lower()
538
+ target_rels = [r for r in scope.rels if qual in r.names()]
539
+ if not target_rels:
540
+ # t.struct_col.* — expand a struct column
541
+ srcs = scope.resolve_path(item.prefix)
542
+ add_out(item.prefix[-1], srcs, EXPRESSION, item.sql())
543
+ continue
544
+ replace_map = {r.name.lower(): r for r in item.replace}
545
+ for rel in target_rels:
546
+ self._expand_rel(rel, item, add_out, star_sources)
547
+ for rname, ritem in replace_map.items():
548
+ srcs: Set[SourceColumn] = set()
549
+ self._collect(ritem.expr, scope, srcs)
550
+ key = rname
551
+ if key in outputs:
552
+ o = outputs[key]
553
+ o.sources = srcs
554
+ o.transformation = self._classify(ritem.expr, srcs)
555
+ o.expression = ritem.expr.sql()
556
+ else:
557
+ add_out(ritem.name, srcs,
558
+ self._classify(ritem.expr, srcs), ritem.expr.sql())
559
+ continue
560
+
561
+ expr = item.expr
562
+ srcs = set()
563
+ self._collect(expr, scope, srcs)
564
+ name = item.alias
565
+ if not name:
566
+ path = _flatten_path(expr)
567
+ if path is not None:
568
+ name = path[-1]
569
+ elif isinstance(expr, n.FuncCall):
570
+ anon[0] += 1
571
+ name = f"f{anon[0] - 1}_"
572
+ else:
573
+ anon[0] += 1
574
+ name = f"f{anon[0] - 1}_"
575
+ add_out(name, srcs, self._classify(expr, srcs), expr.sql())
576
+
577
+ # ORDER BY: prefer select-list aliases, else resolve in FROM scope
578
+ for oi in order_by:
579
+ path = _flatten_path(oi.expr)
580
+ if path is not None and len(path) == 1 and path[0].lower() in outputs:
581
+ self._tag(outputs[path[0].lower()].sources, "ORDER_BY")
582
+ else:
583
+ self._use(oi.expr, scope, "ORDER_BY")
584
+
585
+ return _Derived(outputs, order, star_sources)
586
+
587
+ # ----------------------------------------------------------- statements
588
+
589
+ def analyze_statement(self, stmt: n.Node) -> LineageResult:
590
+ if isinstance(stmt, n.Query):
591
+ derived = self.analyze_query(stmt, {})
592
+ return self._result(None, derived)
593
+ if isinstance(stmt, n.CreateTableAsSelect):
594
+ if stmt.query is None:
595
+ raise LineageError("CREATE statement has no AS SELECT query")
596
+ derived = self.analyze_query(stmt.query, {})
597
+ declared = [c.name for c in stmt.columns]
598
+ if declared:
599
+ derived = self._rename_positionally(derived, declared)
600
+ return self._result(stmt.full_name, derived)
601
+ if isinstance(stmt, n.InsertStmt):
602
+ if stmt.query is not None:
603
+ derived = self.analyze_query(stmt.query, {})
604
+ else:
605
+ outputs: Dict[str, _Out] = {}
606
+ order: List[str] = []
607
+ for row in stmt.values[:1]:
608
+ for idx, v in enumerate(row):
609
+ name = stmt.columns[idx] if idx < len(stmt.columns) else f"f{idx}_"
610
+ srcs: Set[SourceColumn] = set()
611
+ outputs[name.lower()] = _Out(
612
+ name, srcs, CONSTANT, v.sql())
613
+ order.append(name.lower())
614
+ derived = _Derived(outputs, order, [])
615
+ if stmt.columns:
616
+ derived = self._rename_positionally(derived, stmt.columns)
617
+ return self._result(stmt.full_name, derived)
618
+ if isinstance(stmt, n.UpdateStmt):
619
+ return self._analyze_update(stmt)
620
+ if isinstance(stmt, n.MergeStmt):
621
+ return self._analyze_merge(stmt)
622
+ if isinstance(stmt, n.DeleteStmt):
623
+ name = stmt.full_name.replace("`", "")
624
+ self.tables.add(name)
625
+ if stmt.where is not None:
626
+ rel = _Rel("table", alias=stmt.alias, table_name=name,
627
+ schema_cols=self.schema.get(name.lower()))
628
+ self._use(stmt.where, _Scope([rel], variables=self.variables),
629
+ "WHERE")
630
+ return LineageResult(name, [], sorted(self.tables),
631
+ ctes=sorted(self.cte_names),
632
+ usage=self._usage_list())
633
+ if isinstance(stmt, n.CreateFunction):
634
+ return self._analyze_create_function(stmt)
635
+ if isinstance(stmt, _SCRIPT_NODES):
636
+ results = _script_results(
637
+ [stmt], self.schema, self.include_indirect, self.variables)
638
+ if len(results) == 1:
639
+ return results[0]
640
+ if not results:
641
+ return LineageResult(None, [], [])
642
+ raise LineageError(
643
+ f"Script contains {len(results)} lineage-bearing statements; "
644
+ "use extract_script_lineage() to get one result per statement")
645
+ if isinstance(stmt, _NO_LINEAGE_NODES):
646
+ return LineageResult(None, [], [])
647
+ raise LineageError(
648
+ f"Lineage not supported for {type(stmt).__name__}")
649
+
650
+ def _analyze_create_function(self, stmt: n.CreateFunction) -> LineageResult:
651
+ params = {p.name.lower() for p in stmt.params}
652
+ inner = _Analyzer(self.schema, self.include_indirect,
653
+ self.variables | params)
654
+ if stmt.body_query is not None:
655
+ derived = inner.analyze_query(stmt.body_query, {})
656
+ result = inner._result(stmt.full_name, derived)
657
+ return result
658
+ if stmt.body_expr is not None:
659
+ scope = _Scope([], variables=inner.variables)
660
+ srcs = inner._use(stmt.body_expr, scope, "SELECT")
661
+ col = ColumnLineage("<return>", stmt.body_expr.sql(),
662
+ inner._classify(stmt.body_expr, srcs),
663
+ sorted(srcs))
664
+ return LineageResult(stmt.full_name, [col], sorted(inner.tables),
665
+ usage=inner._usage_list())
666
+ return LineageResult(stmt.full_name, [], [])
667
+
668
+ def _analyze_update(self, stmt: n.UpdateStmt) -> LineageResult:
669
+ target = stmt.full_name.replace("`", "")
670
+ self.tables.add(target)
671
+ rels = [_Rel("table", alias=stmt.alias, table_name=target,
672
+ schema_cols=self.schema.get(target.lower()))]
673
+ indirect_exprs: List[Tuple[str, n.Node]] = []
674
+ if stmt.from_ is not None:
675
+ from_scope = self._build_scope(stmt.from_, {}, None, indirect_exprs)
676
+ rels.extend(from_scope.rels)
677
+ scope = _Scope(rels, variables=self.variables)
678
+ indirect: Set[SourceColumn] = set()
679
+ if stmt.where is not None:
680
+ indirect_exprs.append(("WHERE", stmt.where))
681
+ for ctx, e in indirect_exprs:
682
+ srcs = self._use(e, scope, ctx)
683
+ if self.include_indirect:
684
+ indirect |= srcs
685
+ cols: List[ColumnLineage] = []
686
+ for a in stmt.assignments:
687
+ srcs = self._use(a.value, scope, "SET")
688
+ cols.append(ColumnLineage(
689
+ ".".join(a.target), a.value.sql(),
690
+ self._classify(a.value, srcs),
691
+ sorted(srcs), sorted(indirect)))
692
+ return LineageResult(target, cols, sorted(self.tables),
693
+ ctes=sorted(self.cte_names),
694
+ usage=self._usage_list())
695
+
696
+ def _analyze_merge(self, stmt: n.MergeStmt) -> LineageResult:
697
+ target = stmt.full_name.replace("`", "")
698
+ self.tables.add(target)
699
+ target_rel = _Rel("table", alias=stmt.alias, table_name=target,
700
+ schema_cols=self.schema.get(target.lower()))
701
+ indirect_exprs: List[Tuple[str, n.Node]] = [("JOIN", stmt.on)]
702
+ src_scope = self._build_scope(stmt.source, {}, None, indirect_exprs)
703
+ scope = _Scope([target_rel] + src_scope.rels, variables=self.variables)
704
+ indirect: Set[SourceColumn] = set()
705
+ for ctx, e in indirect_exprs:
706
+ srcs = self._use(e, scope, ctx)
707
+ if self.include_indirect:
708
+ indirect |= srcs
709
+ cols: List[ColumnLineage] = []
710
+ for when in stmt.whens:
711
+ if when.condition is not None:
712
+ srcs = self._use(when.condition, scope, "WHERE")
713
+ if self.include_indirect:
714
+ indirect |= srcs
715
+ if when.action_kind == "UPDATE":
716
+ for a in when.assignments:
717
+ srcs = self._use(a.value, scope, "SET")
718
+ cols.append(ColumnLineage(
719
+ ".".join(a.target), a.value.sql(),
720
+ self._classify(a.value, srcs),
721
+ sorted(srcs), sorted(indirect)))
722
+ elif when.action_kind == "INSERT":
723
+ for idx, v in enumerate(when.values):
724
+ name = when.columns[idx] if idx < len(when.columns) else f"f{idx}_"
725
+ srcs = self._use(v, scope, "INSERT")
726
+ cols.append(ColumnLineage(
727
+ name, v.sql(), self._classify(v, srcs),
728
+ sorted(srcs), sorted(indirect)))
729
+ elif when.action_kind == "INSERT_ROW":
730
+ for rel in src_scope.rels:
731
+ if rel.kind == "table":
732
+ if rel.schema_cols:
733
+ for c in rel.schema_cols:
734
+ cols.append(ColumnLineage(
735
+ c, c, IDENTITY,
736
+ [SourceColumn(rel.table_name, c)],
737
+ sorted(indirect)))
738
+ else:
739
+ cols.append(ColumnLineage(
740
+ "*", "INSERT ROW", STAR,
741
+ [SourceColumn(rel.table_name, "*")],
742
+ sorted(indirect)))
743
+ elif rel.kind == "derived":
744
+ for key in rel.derived.order:
745
+ o = rel.derived.outputs[key]
746
+ cols.append(ColumnLineage(
747
+ o.name, o.expression, o.transformation,
748
+ sorted(o.sources), sorted(indirect)))
749
+ return LineageResult(target, cols, sorted(self.tables),
750
+ ctes=sorted(self.cte_names),
751
+ usage=self._usage_list())
752
+
753
+ # -------------------------------------------------------------- helpers
754
+
755
+ def _usage_list(self) -> List[ColumnUsage]:
756
+ return [
757
+ ColumnUsage(s.table, s.column, sorted(ctxs))
758
+ for s, ctxs in sorted(self.usage.items())
759
+ ]
760
+
761
+ @staticmethod
762
+ def _rename_positionally(derived: _Derived, names: List[str]) -> _Derived:
763
+ outputs: Dict[str, _Out] = {}
764
+ order: List[str] = []
765
+ for idx, key in enumerate(derived.order):
766
+ o = derived.outputs[key]
767
+ new_name = names[idx] if idx < len(names) else o.name
768
+ o.name = new_name
769
+ outputs[new_name.lower()] = o
770
+ order.append(new_name.lower())
771
+ return _Derived(outputs, order, derived.star_sources)
772
+
773
+ def _result(self, target: Optional[str], derived: _Derived) -> LineageResult:
774
+ cols = []
775
+ for key in derived.order:
776
+ o = derived.outputs[key]
777
+ cols.append(ColumnLineage(
778
+ o.name, o.expression, o.transformation,
779
+ sorted(o.sources), sorted(o.indirect)))
780
+ return LineageResult(
781
+ target.replace("`", "") if target else None,
782
+ cols, sorted(self.tables),
783
+ ctes=sorted(self.cte_names),
784
+ usage=self._usage_list())
785
+
786
+
787
+ def extract_lineage(
788
+ sql_or_ast: Union[str, n.Node],
789
+ schema: Optional[Dict[str, List[str]]] = None,
790
+ include_indirect: bool = False,
791
+ variables: Optional[Sequence[str]] = None,
792
+ ) -> LineageResult:
793
+ """Extract column-level lineage from a BigQuery SQL statement.
794
+
795
+ Args:
796
+ sql_or_ast: SQL text or an already-parsed statement AST.
797
+ schema: optional mapping of fully-qualified table name to its column
798
+ names. Enables ``SELECT *`` expansion and unqualified-column
799
+ disambiguation across joins.
800
+ include_indirect: also report columns that influence each output
801
+ indirectly (WHERE / JOIN ON / GROUP BY / HAVING / QUALIFY).
802
+ variables: script variable names to exclude from column resolution.
803
+
804
+ Returns:
805
+ A :class:`LineageResult` with one :class:`ColumnLineage` per output
806
+ attribute, each holding its :class:`SourceColumn` ancestors.
807
+ """
808
+ stmt = parse_one(sql_or_ast) if isinstance(sql_or_ast, str) else sql_or_ast
809
+ vars_ = set(variables or ())
810
+ return _Analyzer(schema, include_indirect, vars_).analyze_statement(stmt)
811
+
812
+
813
+ # ---------------------------------------------------------------------------
814
+ # Script (procedural language) lineage
815
+ # ---------------------------------------------------------------------------
816
+
817
+ _SCRIPT_NODES = (
818
+ n.ScriptBlock, n.IfStmt, n.LoopStmt, n.WhileStmt, n.RepeatStmt,
819
+ n.ForInStmt, n.CreateProcedure,
820
+ )
821
+ _NO_LINEAGE_NODES = (
822
+ n.DeclareStmt, n.SetStmt, n.BreakContinueStmt, n.CallStmt, n.ReturnStmt,
823
+ n.RaiseStmt, n.ExecuteImmediate, n.AssertStmt, n.TransactionStmt,
824
+ n.TruncateStmt, n.DropStmt,
825
+ )
826
+ _LINEAGE_NODES = (
827
+ n.Query, n.CreateTableAsSelect, n.InsertStmt, n.UpdateStmt, n.DeleteStmt,
828
+ n.MergeStmt, n.CreateFunction,
829
+ )
830
+
831
+
832
+ def _collect_script_variables(stmts: List[n.Node]) -> Set[str]:
833
+ variables: Set[str] = set()
834
+ for stmt in stmts:
835
+ for node in stmt.walk():
836
+ if isinstance(node, n.DeclareStmt):
837
+ variables |= {name.lower() for name in node.names}
838
+ elif isinstance(node, n.ForInStmt):
839
+ variables.add(node.var.lower())
840
+ elif isinstance(node, n.ExecuteImmediate):
841
+ variables |= {name.lower() for name in node.into}
842
+ return variables
843
+
844
+
845
+ def _script_results(stmts: List[n.Node], schema, include_indirect,
846
+ extra_vars: Set[str]) -> List[LineageResult]:
847
+ variables = _collect_script_variables(stmts) | set(extra_vars)
848
+ results: List[LineageResult] = []
849
+
850
+ def visit(stmt: n.Node):
851
+ if isinstance(stmt, _LINEAGE_NODES):
852
+ analyzer = _Analyzer(schema, include_indirect, variables)
853
+ results.append(analyzer.analyze_statement(stmt))
854
+ elif isinstance(stmt, n.ScriptBlock):
855
+ for s in stmt.statements:
856
+ visit(s)
857
+ for s in stmt.exception_statements:
858
+ visit(s)
859
+ elif isinstance(stmt, n.IfStmt):
860
+ for b in stmt.branches:
861
+ for s in b.statements:
862
+ visit(s)
863
+ for s in stmt.else_statements:
864
+ visit(s)
865
+ elif isinstance(stmt, (n.LoopStmt, n.WhileStmt, n.RepeatStmt)):
866
+ for s in stmt.statements:
867
+ visit(s)
868
+ elif isinstance(stmt, n.ForInStmt):
869
+ visit(stmt.query) # the driving query reads tables too
870
+ for s in stmt.statements:
871
+ visit(s)
872
+ elif isinstance(stmt, n.CreateProcedure):
873
+ visit(stmt.body)
874
+ # DECLARE/SET/CALL/etc. carry no static table lineage
875
+
876
+ for s in stmts:
877
+ visit(s)
878
+ return results
879
+
880
+
881
+ def extract_script_lineage(
882
+ sql_or_ast: Union[str, n.Node, List[n.Node]],
883
+ schema: Optional[Dict[str, List[str]]] = None,
884
+ include_indirect: bool = False,
885
+ ) -> List[LineageResult]:
886
+ """Extract lineage from a BigQuery script, procedure, or multi-statement
887
+ SQL string.
888
+
889
+ Walks BEGIN blocks, IF/ELSEIF branches, LOOP/WHILE/REPEAT/FOR bodies and
890
+ exception handlers, returning one :class:`LineageResult` per
891
+ lineage-bearing statement found (queries, CTAS, INSERT, UPDATE, DELETE,
892
+ MERGE, CREATE FUNCTION). ``DECLARE``d variables, ``FOR`` loop variables
893
+ and ``EXECUTE IMMEDIATE ... INTO`` targets are automatically excluded
894
+ from column resolution.
895
+ """
896
+ if isinstance(sql_or_ast, str):
897
+ stmts = parse(sql_or_ast)
898
+ elif isinstance(sql_or_ast, list):
899
+ stmts = sql_or_ast
900
+ else:
901
+ stmts = [sql_or_ast]
902
+ return _script_results(stmts, schema, include_indirect, set())