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/__init__.py +62 -0
- bqsqlparse/errors.py +30 -0
- bqsqlparse/functions.py +159 -0
- bqsqlparse/lexer.py +242 -0
- bqsqlparse/lineage.py +902 -0
- bqsqlparse/nodes.py +728 -0
- bqsqlparse/parser.py +1715 -0
- bqsqlparse/py.typed +0 -0
- bqsqlparse/unparse.py +766 -0
- bqsqlparse-0.1.0.dist-info/METADATA +722 -0
- bqsqlparse-0.1.0.dist-info/RECORD +13 -0
- bqsqlparse-0.1.0.dist-info/WHEEL +4 -0
- bqsqlparse-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|