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
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bqsqlparse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pure-Python BigQuery (GoogleSQL) parser with column-level lineage extraction: structs, arrays, UNNEST, PIVOT, QUALIFY, windows, MERGE and more.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vamsikapa/bqsqlparse
|
|
6
|
+
Project-URL: Documentation, https://github.com/vamsikapa/bqsqlparse#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/vamsikapa/bqsqlparse/issues
|
|
8
|
+
Author: Vamsi Kapa
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Vamsi Kapa
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: ast,bigquery,column-lineage,data-lineage,googlesql,lineage,parser,sql
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Programming Language :: SQL
|
|
43
|
+
Classifier: Topic :: Database
|
|
44
|
+
Classifier: Topic :: Software Development :: Compilers
|
|
45
|
+
Classifier: Typing :: Typed
|
|
46
|
+
Requires-Python: >=3.9
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: build; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: twine; extra == 'dev'
|
|
51
|
+
Description-Content-Type: text/markdown
|
|
52
|
+
|
|
53
|
+
# bqsqlparse
|
|
54
|
+
|
|
55
|
+
A **pure-Python BigQuery (GoogleSQL) SQL parser** with built-in
|
|
56
|
+
**column (attribute) level lineage** extraction. No runtime dependencies.
|
|
57
|
+
|
|
58
|
+
- Tokenizer, recursive-descent parser, typed AST, and SQL re-generator for
|
|
59
|
+
the BigQuery dialect
|
|
60
|
+
- Column-level lineage: trace every output attribute back to physical
|
|
61
|
+
source table columns — through CTEs, subqueries, joins, set operations,
|
|
62
|
+
`UNNEST`, struct field access, star expansion, `PIVOT`/`UNPIVOT`, and DML
|
|
63
|
+
- Usage report: for every source column, which clauses reference it
|
|
64
|
+
(`SELECT`, `JOIN`, `WHERE`, `GROUP_BY`, `HAVING`, `QUALIFY`, `ORDER_BY`, ...)
|
|
65
|
+
- Supports Python 3.9+
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install bqsqlparse
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Contents
|
|
74
|
+
|
|
75
|
+
- [Supported syntax](#supported-syntax)
|
|
76
|
+
- [Tokenizing](#tokenizing)
|
|
77
|
+
- [Parsing](#parsing) — AST inspection, rewriting SQL, multi-statement scripts, error reporting
|
|
78
|
+
- [Parsing feature tour](#parsing-feature-tour) — structs, arrays, UNNEST, windows, PIVOT, GROUP BY variants, typed literals, DML, scripting, routines
|
|
79
|
+
- [Column-level lineage](#column-level-lineage) — usage report, star expansion, set ops, subqueries, DML, scripts, serialization
|
|
80
|
+
- [Function registry](#function-registry)
|
|
81
|
+
- [Error types](#error-types)
|
|
82
|
+
- [API summary](#api-summary)
|
|
83
|
+
|
|
84
|
+
## Supported syntax
|
|
85
|
+
|
|
86
|
+
| Area | Constructs |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| Queries | `SELECT` (incl. `AS STRUCT` / `AS VALUE`), `WITH` / `WITH RECURSIVE`, `UNION`/`INTERSECT`/`EXCEPT` `ALL|DISTINCT`, `ORDER BY`, `LIMIT`/`OFFSET`, trailing commas |
|
|
89
|
+
| Select list | `* EXCEPT(...)`, `* REPLACE(...)`, `table.*`, implicit + explicit aliases |
|
|
90
|
+
| FROM | joins (`INNER`/`LEFT`/`RIGHT`/`FULL`/`CROSS`, `USING`), `UNNEST(...) [WITH OFFSET]`, subqueries, `PIVOT`, `UNPIVOT`, `TABLESAMPLE`, `FOR SYSTEM_TIME AS OF`, table-valued functions, `` `project.dataset.table` `` paths |
|
|
91
|
+
| Filtering | `WHERE`, `GROUP BY` (exprs, `ALL`, `ROLLUP`, `CUBE`, `GROUPING SETS`), `HAVING`, `QUALIFY`, named `WINDOW`s |
|
|
92
|
+
| Expressions | full operator precedence (`OR`/`AND`/`NOT`, comparisons, `|`, `^`, `&`, `<<`/`>>`, `+`/`-`, `*`/`/`/`||`, unary), `BETWEEN`, `IN` (list / subquery / `UNNEST`), `LIKE [ANY|ALL]`, `IS [NOT] NULL/TRUE/FALSE`, `IS [NOT] DISTINCT FROM`, `CASE`, `EXISTS` |
|
|
93
|
+
| Complex types | `STRUCT(...)` / `STRUCT<...>(...)`, `ARRAY[...]` / `ARRAY<T>[...]` / `ARRAY(subquery)`, nested `ARRAY<STRUCT<...>>` types, struct field access `a.b.c`, array subscripts `[OFFSET(i)]` / `[ORDINAL(i)]` / `[SAFE_OFFSET(i)]` / `[SAFE_ORDINAL(i)]` / JSON `j['key']` |
|
|
94
|
+
| Functions | any function call incl. dotted paths (`SAFE.`, `NET.`, `HLL_COUNT.`, UDFs), `COUNT(*)`, `DISTINCT`, `IGNORE|RESPECT NULLS`, `ORDER BY ... LIMIT` inside aggregates, `HAVING MAX/MIN`, named arguments (`param => value`), analytic `OVER (...)` with frames, `CAST`/`SAFE_CAST` (+ `FORMAT`), `EXTRACT`, `INTERVAL` |
|
|
95
|
+
| Literals | strings (raw / bytes / triple-quoted, escapes), numbers (hex, exponent), typed literals (`DATE '...'`, `TIMESTAMP '...'`, `NUMERIC '...'`, `JSON '...'`), parameters (`@name`, `@@system`, `?`) |
|
|
96
|
+
| Identifiers | backtick quoting throughout — reserved keywords as column names (`` SELECT `limit`, t.`from` ``) parse and regenerate correctly; non-reserved words (`value`, `date`, `offset`, `key`, ...) work unquoted |
|
|
97
|
+
| Statements | `CREATE [OR REPLACE] [TEMP] TABLE / VIEW / MATERIALIZED VIEW ... AS SELECT` (with `PARTITION BY`, `CLUSTER BY`, `OPTIONS`), `INSERT`, `UPDATE`, `DELETE`, `MERGE` (all `WHEN` variants), `TRUNCATE TABLE`, `DROP` |
|
|
98
|
+
| Scripting | `DECLARE`, `SET` (incl. `(a, b) = ...` and `@@system` vars), `BEGIN ... EXCEPTION WHEN ERROR THEN ... END`, `IF/ELSEIF/ELSE`, `LOOP`, `WHILE`, `REPEAT ... UNTIL`, `FOR ... IN (query) DO`, labels, `BREAK`/`LEAVE`/`CONTINUE`/`ITERATE`, `CALL`, `RETURN`, `RAISE [USING MESSAGE]`, `EXECUTE IMMEDIATE ... INTO ... USING`, `ASSERT`, `BEGIN/COMMIT/ROLLBACK TRANSACTION` |
|
|
99
|
+
| Routines | `CREATE [OR REPLACE] PROCEDURE` (with `IN`/`OUT`/`INOUT` params), `CREATE [TEMP] [AGGREGATE] FUNCTION` (SQL and `LANGUAGE js` bodies, `ANY TYPE`, `DETERMINISTIC`), `CREATE TABLE FUNCTION ... RETURNS TABLE<...>` |
|
|
100
|
+
|
|
101
|
+
## Tokenizing
|
|
102
|
+
|
|
103
|
+
`tokenize()` returns the raw token stream with exact line/column positions —
|
|
104
|
+
useful for linters, formatters and syntax highlighters:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from bqsqlparse import tokenize
|
|
108
|
+
|
|
109
|
+
for tok in tokenize("SELECT `q col` FROM t WHERE x >= @min"):
|
|
110
|
+
print(tok)
|
|
111
|
+
# Token(KEYWORD, 'SELECT', L1:C1)
|
|
112
|
+
# Token(QIDENT, 'q col', L1:C8)
|
|
113
|
+
# Token(KEYWORD, 'FROM', L1:C16)
|
|
114
|
+
# Token(IDENT, 't', L1:C21)
|
|
115
|
+
# Token(KEYWORD, 'WHERE', L1:C23)
|
|
116
|
+
# Token(IDENT, 'x', L1:C29)
|
|
117
|
+
# Token(OP, '>=', L1:C31)
|
|
118
|
+
# Token(PARAM, '@min', L1:C34)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Token types: `KEYWORD` (reserved words, uppercased), `IDENT`, `QIDENT`
|
|
122
|
+
(backtick-quoted), `STRING`, `BYTES`, `NUMBER`, `PARAM`, `OP`, `EOF`.
|
|
123
|
+
String tokens carry the *decoded* value (escapes processed, unless the
|
|
124
|
+
literal used a raw `r'...'` prefix).
|
|
125
|
+
|
|
126
|
+
## Parsing
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import bqsqlparse
|
|
130
|
+
|
|
131
|
+
ast = bqsqlparse.parse_one("""
|
|
132
|
+
SELECT s.customer.id, ARRAY_AGG(item.sku IGNORE NULLS ORDER BY item.price LIMIT 3) top_skus
|
|
133
|
+
FROM `proj.ds.sales` s, UNNEST(s.line_items) AS item
|
|
134
|
+
GROUP BY 1
|
|
135
|
+
QUALIFY ROW_NUMBER() OVER (PARTITION BY s.customer.id ORDER BY s.ts DESC) = 1
|
|
136
|
+
""")
|
|
137
|
+
|
|
138
|
+
# Typed AST
|
|
139
|
+
print(type(ast).__name__) # Query
|
|
140
|
+
print(ast.body.items[1].alias) # top_skus
|
|
141
|
+
|
|
142
|
+
# Walk the tree
|
|
143
|
+
from bqsqlparse import nodes
|
|
144
|
+
for func in ast.find_all(nodes.FuncCall):
|
|
145
|
+
print(func.name_str)
|
|
146
|
+
|
|
147
|
+
# Regenerate SQL
|
|
148
|
+
print(ast.sql())
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Inspecting the AST
|
|
152
|
+
|
|
153
|
+
Every node is a typed dataclass with `children()`, `walk()`, `find_all()`
|
|
154
|
+
and `sql()`:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from bqsqlparse import parse_one, nodes
|
|
158
|
+
|
|
159
|
+
ast = parse_one("SELECT a, SUM(b) AS total FROM ds.t GROUP BY a HAVING SUM(b) > 0")
|
|
160
|
+
|
|
161
|
+
sel = ast.body # nodes.Select
|
|
162
|
+
print(sel.items[1].alias) # total
|
|
163
|
+
print(sel.group_by.exprs[0].path) # ['a']
|
|
164
|
+
print(sel.having.sql()) # SUM(b) > 0
|
|
165
|
+
|
|
166
|
+
# find every table and every function used anywhere in the statement
|
|
167
|
+
print([t.full_name for t in ast.find_all(nodes.TableRef)]) # ['ds.t']
|
|
168
|
+
print([f.name_str for f in ast.find_all(nodes.FuncCall)]) # ['SUM', 'SUM']
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Rewriting SQL programmatically
|
|
172
|
+
|
|
173
|
+
The AST is mutable — edit nodes, then regenerate. Example: repoint a query
|
|
174
|
+
from staging to prod:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
ast = parse_one("SELECT id, amount FROM proj.staging.orders WHERE amount > 100")
|
|
178
|
+
for tref in ast.find_all(nodes.TableRef):
|
|
179
|
+
tref.path = ["proj", "prod", "orders"]
|
|
180
|
+
print(ast.sql())
|
|
181
|
+
# SELECT id, amount FROM proj.prod.orders WHERE amount > 100
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Multi-statement scripts
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from bqsqlparse import parse
|
|
188
|
+
|
|
189
|
+
stmts = parse("CREATE TEMP TABLE x AS SELECT 1 a; SELECT * FROM x;")
|
|
190
|
+
print([type(s).__name__ for s in stmts])
|
|
191
|
+
# ['CreateTableAsSelect', 'Query']
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Error reporting
|
|
195
|
+
|
|
196
|
+
All errors carry the offending line and column:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from bqsqlparse import parse_one, ParseError
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
parse_one("SELECT a FROM WHERE x = 1")
|
|
203
|
+
except ParseError as e:
|
|
204
|
+
print(e) # Expected identifier (got 'WHERE') at line 1, column 15
|
|
205
|
+
print(e.line, e.col) # 1 15
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Parsing feature tour
|
|
209
|
+
|
|
210
|
+
### Structs, arrays and nested types
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
ast = parse_one("""
|
|
214
|
+
SELECT
|
|
215
|
+
STRUCT(1 AS a, 'x' AS b) AS s1,
|
|
216
|
+
STRUCT<a INT64, b STRING>(1, 'x') AS s2,
|
|
217
|
+
ARRAY[1, 2, 3] AS a1,
|
|
218
|
+
ARRAY<FLOAT64>[1.0, 2.5] AS a2,
|
|
219
|
+
[4, 5] AS a3,
|
|
220
|
+
ARRAY(SELECT v FROM ds.vals) AS a4,
|
|
221
|
+
CAST(x AS ARRAY<STRUCT<k STRING, v ARRAY<INT64>>>) AS deep
|
|
222
|
+
FROM t
|
|
223
|
+
""")
|
|
224
|
+
deep = ast.body.items[6].expr.to_type
|
|
225
|
+
print(deep.name, deep.element.fields_[0].name) # ARRAY k
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### UNNEST, struct access and array subscripts
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
parse_one("""
|
|
232
|
+
SELECT
|
|
233
|
+
item.sku,
|
|
234
|
+
order_.payload.customer.address.city,
|
|
235
|
+
tags[OFFSET(0)], tags[SAFE_ORDINAL(2)],
|
|
236
|
+
json_col['results'][0]['id']
|
|
237
|
+
FROM ds.orders AS order_
|
|
238
|
+
CROSS JOIN UNNEST(order_.items) AS item WITH OFFSET AS pos
|
|
239
|
+
""")
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Window functions, QUALIFY and named windows
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
ast = parse_one("""
|
|
246
|
+
SELECT
|
|
247
|
+
ROW_NUMBER() OVER (PARTITION BY uid ORDER BY ts DESC) AS rn,
|
|
248
|
+
SUM(amt) OVER (ORDER BY ts ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS wk,
|
|
249
|
+
LAG(amt, 1) OVER w AS prev_amt
|
|
250
|
+
FROM ds.txns
|
|
251
|
+
QUALIFY rn = 1
|
|
252
|
+
WINDOW w AS (PARTITION BY uid ORDER BY ts)
|
|
253
|
+
""")
|
|
254
|
+
frame = ast.body.items[1].expr.over.frame
|
|
255
|
+
print(frame.unit, frame.start.kind) # ROWS PRECEDING
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Aggregate modifiers
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
parse_one("""
|
|
262
|
+
SELECT
|
|
263
|
+
COUNT(*), COUNT(DISTINCT uid),
|
|
264
|
+
ARRAY_AGG(sku IGNORE NULLS ORDER BY price DESC LIMIT 10),
|
|
265
|
+
STRING_AGG(name, ', ' ORDER BY name),
|
|
266
|
+
ANY_VALUE(payload HAVING MAX version)
|
|
267
|
+
FROM t
|
|
268
|
+
""")
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### GROUP BY variants
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
parse_one("SELECT a, SUM(b) FROM t GROUP BY ALL")
|
|
275
|
+
parse_one("SELECT a, b, SUM(c) FROM t GROUP BY ROLLUP(a, b)")
|
|
276
|
+
parse_one("SELECT a, b, SUM(c) FROM t GROUP BY CUBE(a, b)")
|
|
277
|
+
parse_one("SELECT a, b, SUM(c) FROM t GROUP BY GROUPING SETS ((a, b), a, ())")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### PIVOT, UNPIVOT, TABLESAMPLE and time travel
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
parse_one("""
|
|
284
|
+
SELECT * FROM sales
|
|
285
|
+
PIVOT(SUM(amount) AS total FOR quarter IN ('Q1' AS q1, 'Q2' AS q2)) p
|
|
286
|
+
""")
|
|
287
|
+
parse_one("SELECT * FROM wide UNPIVOT(value FOR quarter IN (q1, q2, q3))")
|
|
288
|
+
parse_one("SELECT * FROM big.t TABLESAMPLE SYSTEM (1 PERCENT)")
|
|
289
|
+
parse_one("SELECT * FROM ds.t FOR SYSTEM_TIME AS OF TIMESTAMP '2024-01-01'")
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Typed literals, parameters and special expressions
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
parse_one("""
|
|
296
|
+
SELECT
|
|
297
|
+
DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00+00', JSON '{"a": 1}',
|
|
298
|
+
NUMERIC '9.99', b'\\xDE\\xAD', r'raw\\d+', '''multi
|
|
299
|
+
line''',
|
|
300
|
+
ts + INTERVAL 90 MINUTE, INTERVAL '1-2' YEAR TO MONTH,
|
|
301
|
+
SAFE_CAST(v AS BIGNUMERIC), CAST(d AS STRING FORMAT 'YYYY-MM'),
|
|
302
|
+
EXTRACT(WEEK(MONDAY) FROM dt), EXTRACT(HOUR FROM ts AT TIME ZONE 'UTC'),
|
|
303
|
+
x IS DISTINCT FROM y, s LIKE ANY ('a%', 'b%'), v IN UNNEST(arr),
|
|
304
|
+
IF(a > 0, 'pos', 'neg'), fn(mode => 'strict', max_rows => 10)
|
|
305
|
+
FROM t
|
|
306
|
+
WHERE uid = @user_id AND shard = ? AND region = @@region
|
|
307
|
+
""")
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Reserved keywords as identifiers
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
ast = parse_one("SELECT `limit`, t.`from`, `order` AS o FROM ds.t AS t")
|
|
314
|
+
print(ast.sql()) # SELECT `limit`, t.`from`, `order` AS o FROM ds.t AS t
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### DDL and DML
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
parse_one("""
|
|
321
|
+
CREATE OR REPLACE TABLE ds.out
|
|
322
|
+
PARTITION BY DATE(ts) CLUSTER BY region
|
|
323
|
+
OPTIONS(description = 'daily rollup')
|
|
324
|
+
AS SELECT * FROM ds.src
|
|
325
|
+
""")
|
|
326
|
+
parse_one("INSERT INTO ds.t (a, b) VALUES (1, 'x'), (2, 'y')")
|
|
327
|
+
parse_one("UPDATE ds.t SET meta.updated = CURRENT_TIMESTAMP WHERE id = 1")
|
|
328
|
+
parse_one("DELETE FROM ds.t WHERE dt < '2020-01-01'")
|
|
329
|
+
parse_one("""
|
|
330
|
+
MERGE ds.tgt t USING ds.src s ON t.id = s.id
|
|
331
|
+
WHEN MATCHED AND s.deleted THEN DELETE
|
|
332
|
+
WHEN MATCHED THEN UPDATE SET name = s.name
|
|
333
|
+
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)
|
|
334
|
+
WHEN NOT MATCHED BY SOURCE THEN DELETE
|
|
335
|
+
""")
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Scripts: loops, nested IFs, exception handling
|
|
339
|
+
|
|
340
|
+
The full BigQuery procedural language parses into typed nodes
|
|
341
|
+
(`ScriptBlock`, `IfStmt`, `LoopStmt`, `WhileStmt`, `RepeatStmt`,
|
|
342
|
+
`ForInStmt`, ...):
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
script = """
|
|
346
|
+
DECLARE retries INT64 DEFAULT 0;
|
|
347
|
+
DECLARE done BOOL DEFAULT FALSE;
|
|
348
|
+
outer_loop: LOOP
|
|
349
|
+
BEGIN
|
|
350
|
+
IF retries > 3 THEN
|
|
351
|
+
LEAVE outer_loop;
|
|
352
|
+
ELSEIF done THEN
|
|
353
|
+
BREAK;
|
|
354
|
+
ELSE
|
|
355
|
+
MERGE ds.tgt t USING ds.src s ON t.id = s.id
|
|
356
|
+
WHEN MATCHED THEN UPDATE SET v = s.v;
|
|
357
|
+
SET done = TRUE;
|
|
358
|
+
END IF;
|
|
359
|
+
EXCEPTION WHEN ERROR THEN
|
|
360
|
+
SET retries = retries + 1;
|
|
361
|
+
END;
|
|
362
|
+
END LOOP outer_loop;
|
|
363
|
+
"""
|
|
364
|
+
stmts = parse(script)
|
|
365
|
+
loop = stmts[2]
|
|
366
|
+
print(type(loop).__name__, loop.label) # LoopStmt outer_loop
|
|
367
|
+
block = loop.statements[0]
|
|
368
|
+
print(block.has_exception_handler) # True
|
|
369
|
+
nested_if = block.statements[0]
|
|
370
|
+
print(len(nested_if.branches)) # 2 (IF + ELSEIF)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Also covered: `WHILE ... DO ... END WHILE`, `REPEAT ... UNTIL ... END REPEAT`,
|
|
374
|
+
`FOR rec IN (SELECT ...) DO ... END FOR`, `CALL`, `RETURN`,
|
|
375
|
+
`RAISE USING MESSAGE = ...`, `EXECUTE IMMEDIATE ... INTO ... USING`,
|
|
376
|
+
`ASSERT ... AS '...'`, transactions, `TRUNCATE TABLE`, `DROP ...`.
|
|
377
|
+
|
|
378
|
+
### Routines
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
proc = parse_one("""
|
|
382
|
+
CREATE OR REPLACE PROCEDURE ds.upsert_users(IN batch_date DATE, OUT rows_added INT64)
|
|
383
|
+
BEGIN
|
|
384
|
+
MERGE ds.users u USING ds.staging s ON u.id = s.id
|
|
385
|
+
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name);
|
|
386
|
+
SET rows_added = @@row_count;
|
|
387
|
+
END
|
|
388
|
+
""")
|
|
389
|
+
print([(p.mode, p.name) for p in proc.params]) # [('IN', 'batch_date'), ('OUT', 'rows_added')]
|
|
390
|
+
|
|
391
|
+
parse_one("CREATE TEMP FUNCTION add_tax(price FLOAT64) RETURNS FLOAT64 AS (price * 1.1)")
|
|
392
|
+
parse_one('CREATE FUNCTION ds.greet(name STRING) RETURNS STRING LANGUAGE js AS "return name;"')
|
|
393
|
+
parse_one("CREATE TEMP FUNCTION dbl(x ANY TYPE) AS (x * 2)")
|
|
394
|
+
parse_one("""
|
|
395
|
+
CREATE TABLE FUNCTION ds.recent_orders(cutoff DATE)
|
|
396
|
+
RETURNS TABLE<id INT64, amount NUMERIC>
|
|
397
|
+
AS (SELECT id, amount FROM ds.orders WHERE dt >= cutoff)
|
|
398
|
+
""")
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
## Column-level lineage
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from bqsqlparse import extract_lineage
|
|
405
|
+
|
|
406
|
+
result = extract_lineage("""
|
|
407
|
+
CREATE OR REPLACE TABLE proj.mart.customer_totals AS
|
|
408
|
+
WITH base AS (
|
|
409
|
+
SELECT o.customer_id, o.amount, c.region
|
|
410
|
+
FROM proj.ds.orders o
|
|
411
|
+
JOIN proj.ds.customers c ON o.customer_id = c.id
|
|
412
|
+
)
|
|
413
|
+
SELECT customer_id, region, SUM(amount) AS total
|
|
414
|
+
FROM base
|
|
415
|
+
GROUP BY 1, 2
|
|
416
|
+
""", include_indirect=True)
|
|
417
|
+
|
|
418
|
+
print(result.target) # proj.mart.customer_totals
|
|
419
|
+
print(result.tables) # ['proj.ds.customers', 'proj.ds.orders'] (physical tables only)
|
|
420
|
+
print(result.ctes) # ['base'] (CTE names, kept separate)
|
|
421
|
+
|
|
422
|
+
for col in result.columns:
|
|
423
|
+
print(col.name, col.transformation,
|
|
424
|
+
[str(s) for s in col.sources],
|
|
425
|
+
[str(s) for s in col.indirect_sources])
|
|
426
|
+
# customer_id IDENTITY ['proj.ds.orders.customer_id'] [...]
|
|
427
|
+
# region IDENTITY ['proj.ds.customers.region'] [...]
|
|
428
|
+
# total AGGREGATION ['proj.ds.orders.amount'] [...]
|
|
429
|
+
|
|
430
|
+
# Graph edges: (source, target) pairs, ready for networkx / OpenLineage
|
|
431
|
+
print(result.edges)
|
|
432
|
+
# [('proj.ds.orders.customer_id', 'proj.mart.customer_totals.customer_id'), ...]
|
|
433
|
+
|
|
434
|
+
print(result.to_json())
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Clause usage report
|
|
438
|
+
|
|
439
|
+
`result.usage` tells you *where* each physical source column is referenced,
|
|
440
|
+
across every level of the statement (CTEs and subqueries included):
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
r = extract_lineage("""
|
|
444
|
+
SELECT o.amount, d.region
|
|
445
|
+
FROM ds.orders o JOIN ds.dim d ON o.k = d.k
|
|
446
|
+
WHERE o.status = 'paid'
|
|
447
|
+
GROUP BY d.region, o.amount
|
|
448
|
+
ORDER BY o.ts
|
|
449
|
+
""")
|
|
450
|
+
for u in r.usage:
|
|
451
|
+
print(f"{u.table}.{u.column}: {u.contexts}")
|
|
452
|
+
# ds.dim.k: ['JOIN']
|
|
453
|
+
# ds.dim.region: ['GROUP_BY', 'SELECT']
|
|
454
|
+
# ds.orders.amount:['GROUP_BY', 'SELECT']
|
|
455
|
+
# ds.orders.k: ['JOIN']
|
|
456
|
+
# ds.orders.status:['WHERE']
|
|
457
|
+
# ds.orders.ts: ['ORDER_BY']
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Contexts: `SELECT`, `JOIN`, `WHERE`, `GROUP_BY`, `HAVING`, `QUALIFY`,
|
|
461
|
+
`ORDER_BY`, `UNNEST`, `PIVOT`, `TABLE_FUNCTION`, and for DML: `SET`,
|
|
462
|
+
`INSERT` (MERGE `ON` reports as `JOIN`, `WHEN ... AND` conditions as
|
|
463
|
+
`WHERE`). `ORDER BY` on a select-list alias is resolved back to the
|
|
464
|
+
underlying source column.
|
|
465
|
+
|
|
466
|
+
### Aliases vs. tables vs. CTEs
|
|
467
|
+
|
|
468
|
+
Table aliases and CTE names are fully resolved during analysis and never
|
|
469
|
+
leak into results: `result.tables` contains only physical tables,
|
|
470
|
+
`result.ctes` lists the CTE names encountered, and all `SourceColumn.table`
|
|
471
|
+
values are physical, fully-qualified names.
|
|
472
|
+
|
|
473
|
+
### Structs, arrays and UNNEST
|
|
474
|
+
|
|
475
|
+
Struct field paths are preserved end-to-end:
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
r = extract_lineage("""
|
|
479
|
+
SELECT item.sku, e.payload.user.id AS uid
|
|
480
|
+
FROM ds.events e, UNNEST(e.items) AS item
|
|
481
|
+
""")
|
|
482
|
+
# sku <- ds.events.items.sku
|
|
483
|
+
# uid <- ds.events.payload.user.id
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Star expansion with a schema
|
|
487
|
+
|
|
488
|
+
Without table schemas, `SELECT *` is reported as a `table.*` pass-through
|
|
489
|
+
edge. Provide schemas to fully expand stars and disambiguate unqualified
|
|
490
|
+
columns across joins:
|
|
491
|
+
|
|
492
|
+
```python
|
|
493
|
+
r = extract_lineage(
|
|
494
|
+
"SELECT * EXCEPT (pii) FROM ds.users",
|
|
495
|
+
schema={"ds.users": ["id", "email", "pii"]},
|
|
496
|
+
)
|
|
497
|
+
# columns: id <- ds.users.id, email <- ds.users.email
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Stars still resolve *through* subqueries and CTEs without a schema:
|
|
501
|
+
|
|
502
|
+
```python
|
|
503
|
+
r = extract_lineage("SELECT x.col1 FROM (SELECT * FROM ds.t) x")
|
|
504
|
+
# col1 <- ds.t.col1
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Set operations
|
|
508
|
+
|
|
509
|
+
UNION/INTERSECT/EXCEPT branches are merged positionally — one output column
|
|
510
|
+
collects sources from every branch:
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
r = extract_lineage(
|
|
514
|
+
"SELECT id, amount FROM ds.au_sales UNION ALL SELECT id, amt FROM ds.nz_sales"
|
|
515
|
+
)
|
|
516
|
+
# id <- ds.au_sales.id, ds.nz_sales.id
|
|
517
|
+
# amount <- ds.au_sales.amount, ds.nz_sales.amt
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### Correlated and scalar subqueries
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
r = extract_lineage("""
|
|
524
|
+
SELECT name,
|
|
525
|
+
(SELECT MAX(score) FROM ds.scores s WHERE s.uid = u.id) AS best
|
|
526
|
+
FROM ds.users u
|
|
527
|
+
""")
|
|
528
|
+
# name <- ds.users.name
|
|
529
|
+
# best <- ds.scores.score
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Transformation classification
|
|
533
|
+
|
|
534
|
+
Each output column carries one of: `IDENTITY`, `EXPRESSION`, `AGGREGATION`,
|
|
535
|
+
`WINDOW`, `CONSTANT`, `STAR`, `OFFSET`. Classification describes the final
|
|
536
|
+
hop that produced the column.
|
|
537
|
+
|
|
538
|
+
### DML lineage
|
|
539
|
+
|
|
540
|
+
`INSERT ... SELECT`, `UPDATE ... FROM`, `MERGE` and `CREATE TABLE AS SELECT`
|
|
541
|
+
all produce lineage against their target table:
|
|
542
|
+
|
|
543
|
+
```python
|
|
544
|
+
r = extract_lineage("""
|
|
545
|
+
MERGE ds.tgt t USING ds.src s ON t.id = s.id
|
|
546
|
+
WHEN MATCHED THEN UPDATE SET name = s.name
|
|
547
|
+
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)
|
|
548
|
+
""")
|
|
549
|
+
# name <- ds.src.name, id <- ds.src.id
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
`INSERT` column lists rename query outputs positionally:
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
r = extract_lineage(
|
|
556
|
+
"INSERT INTO ds.tgt (cid, total) SELECT customer_id, SUM(amt) FROM ds.src GROUP BY 1"
|
|
557
|
+
)
|
|
558
|
+
# cid <- ds.src.customer_id, total <- ds.src.amt (AGGREGATION)
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
`UPDATE ... FROM` resolves assignments against both target and joined
|
|
562
|
+
sources, and `DELETE` reports the filter columns via the usage report:
|
|
563
|
+
|
|
564
|
+
```python
|
|
565
|
+
r = extract_lineage(
|
|
566
|
+
"UPDATE ds.users u SET tier = s.tier, updated = CURRENT_TIMESTAMP "
|
|
567
|
+
"FROM ds.scores s WHERE u.id = s.uid"
|
|
568
|
+
)
|
|
569
|
+
# tier IDENTITY <- ds.scores.tier
|
|
570
|
+
# updated CONSTANT <- (none)
|
|
571
|
+
# usage: ds.scores.tier [SET], ds.scores.uid [WHERE], ds.users.id [WHERE]
|
|
572
|
+
|
|
573
|
+
r = extract_lineage("DELETE FROM ds.events WHERE dt < '2020-01-01'")
|
|
574
|
+
# target: ds.events, usage: ds.events.dt [WHERE]
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Indirect lineage
|
|
578
|
+
|
|
579
|
+
With `include_indirect=True`, each output column also lists the columns that
|
|
580
|
+
influenced *which rows* it contains (WHERE / JOIN ON / GROUP BY / HAVING /
|
|
581
|
+
QUALIFY):
|
|
582
|
+
|
|
583
|
+
```python
|
|
584
|
+
r = extract_lineage(
|
|
585
|
+
"SELECT o.amount FROM ds.orders o JOIN ds.dim d ON o.k = d.k "
|
|
586
|
+
"WHERE d.region = 'AU'",
|
|
587
|
+
include_indirect=True,
|
|
588
|
+
)
|
|
589
|
+
print([str(s) for s in r.columns[0].indirect_sources])
|
|
590
|
+
# ['ds.dim.k', 'ds.dim.region', 'ds.orders.k']
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Serialization and graph export
|
|
594
|
+
|
|
595
|
+
```python
|
|
596
|
+
r = extract_lineage("CREATE TABLE ds.out AS SELECT a AS x, b + 1 AS y FROM ds.t")
|
|
597
|
+
|
|
598
|
+
r.edges # [('ds.t.a', 'ds.out.x'), ('ds.t.b', 'ds.out.y')]
|
|
599
|
+
r.to_dict() # nested dict: target, tables, ctes, columns, usage
|
|
600
|
+
r.to_json() # pretty JSON of the same
|
|
601
|
+
|
|
602
|
+
# feed straight into networkx
|
|
603
|
+
import networkx as nx
|
|
604
|
+
g = nx.DiGraph(r.edges)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### Script and procedure lineage
|
|
608
|
+
|
|
609
|
+
`extract_script_lineage()` walks every control-flow branch (BEGIN blocks,
|
|
610
|
+
IF/ELSEIF/ELSE, all loop bodies, exception handlers, procedure bodies) and
|
|
611
|
+
returns one `LineageResult` per lineage-bearing statement. Script variables
|
|
612
|
+
(`DECLARE`, `FOR` loop vars, `EXECUTE IMMEDIATE ... INTO` targets) are
|
|
613
|
+
automatically excluded from column resolution so they are never mistaken
|
|
614
|
+
for table columns:
|
|
615
|
+
|
|
616
|
+
```python
|
|
617
|
+
from bqsqlparse import extract_script_lineage
|
|
618
|
+
|
|
619
|
+
results = extract_script_lineage("""
|
|
620
|
+
DECLARE min_amt INT64 DEFAULT 100;
|
|
621
|
+
IF EXTRACT(DAYOFWEEK FROM CURRENT_DATE) = 1 THEN
|
|
622
|
+
INSERT INTO ds.weekly (uid, total)
|
|
623
|
+
SELECT user_id, SUM(amt) FROM ds.sales WHERE amt > min_amt GROUP BY 1;
|
|
624
|
+
ELSE
|
|
625
|
+
INSERT INTO ds.daily (uid, total)
|
|
626
|
+
SELECT user_id, SUM(amt) FROM ds.sales WHERE amt > min_amt GROUP BY 1;
|
|
627
|
+
END IF;
|
|
628
|
+
""")
|
|
629
|
+
print([r.target for r in results]) # ['ds.weekly', 'ds.daily']
|
|
630
|
+
# min_amt never appears as a source column — it is a script variable
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Works for stored procedures and table functions too:
|
|
634
|
+
|
|
635
|
+
```python
|
|
636
|
+
(r,) = extract_script_lineage("""
|
|
637
|
+
CREATE PROCEDURE ds.sync()
|
|
638
|
+
BEGIN
|
|
639
|
+
MERGE ds.tgt t USING ds.src s ON t.id = s.id
|
|
640
|
+
WHEN MATCHED THEN UPDATE SET v = s.v;
|
|
641
|
+
END
|
|
642
|
+
""")
|
|
643
|
+
print(r.target) # ds.tgt
|
|
644
|
+
|
|
645
|
+
r = extract_lineage("""
|
|
646
|
+
CREATE TABLE FUNCTION ds.recent(cutoff DATE)
|
|
647
|
+
RETURNS TABLE<id INT64>
|
|
648
|
+
AS (SELECT id FROM ds.orders WHERE dt >= cutoff)
|
|
649
|
+
""")
|
|
650
|
+
print(r.target) # ds.recent (cutoff is treated as a parameter, not a column)
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`extract_lineage(...)` also accepts a `variables=[...]` list to exclude
|
|
654
|
+
known script variables when analyzing a single statement, and works
|
|
655
|
+
directly on a script if it contains exactly one lineage-bearing statement.
|
|
656
|
+
|
|
657
|
+
## Function registry
|
|
658
|
+
|
|
659
|
+
A registry of ~350 BigQuery built-ins powers transformation classification
|
|
660
|
+
and is exposed for your own tooling:
|
|
661
|
+
|
|
662
|
+
```python
|
|
663
|
+
import bqsqlparse
|
|
664
|
+
|
|
665
|
+
bqsqlparse.is_aggregate("ARRAY_AGG") # True
|
|
666
|
+
bqsqlparse.is_navigation("LAG") # True (window-only functions)
|
|
667
|
+
bqsqlparse.is_known_function("ST_DISTANCE") # True
|
|
668
|
+
|
|
669
|
+
sorted(bqsqlparse.SCALAR_FUNCTIONS)
|
|
670
|
+
# ['array', 'conditional', 'conversion', 'date_time', 'geography',
|
|
671
|
+
# 'hash_crypto', 'interval', 'json', 'math', 'net', 'range', 'search',
|
|
672
|
+
# 'string', 'utility']
|
|
673
|
+
bqsqlparse.AGGREGATE_FUNCTIONS # frozenset of aggregate names
|
|
674
|
+
bqsqlparse.ALL_FUNCTIONS # every registered built-in
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
## Error types
|
|
678
|
+
|
|
679
|
+
All exceptions derive from `BQSQLError` and carry `.line` / `.col`:
|
|
680
|
+
|
|
681
|
+
| Exception | Raised when |
|
|
682
|
+
| --- | --- |
|
|
683
|
+
| `LexError` | invalid character, unterminated string/comment |
|
|
684
|
+
| `ParseError` | unexpected token / invalid syntax |
|
|
685
|
+
| `UnsupportedStatementError` | statement type outside the supported set (e.g. `GRANT`, `DECLARE`) |
|
|
686
|
+
| `LineageError` | lineage requested for something without a query (e.g. `CREATE TABLE` with no `AS SELECT`) |
|
|
687
|
+
|
|
688
|
+
## API summary
|
|
689
|
+
|
|
690
|
+
| Function | Description |
|
|
691
|
+
| --- | --- |
|
|
692
|
+
| `parse(sql)` | Parse a script into a list of statement ASTs |
|
|
693
|
+
| `parse_one(sql)` | Parse exactly one statement |
|
|
694
|
+
| `tokenize(sql)` | Token stream (`Token` objects with line/col) |
|
|
695
|
+
| `to_sql(node)` / `node.sql()` | Regenerate SQL from an AST |
|
|
696
|
+
| `extract_lineage(sql, schema=None, include_indirect=False, variables=None)` | Column-level lineage (`LineageResult`) |
|
|
697
|
+
| `extract_script_lineage(sql, ...)` | One `LineageResult` per DML/query inside a script or procedure |
|
|
698
|
+
| `LineageResult.usage` | Per source column: clauses where it is used |
|
|
699
|
+
| `LineageResult.ctes` / `.tables` | CTE names vs. physical source tables |
|
|
700
|
+
| `is_aggregate / is_navigation / is_known_function` | BigQuery function registry lookups |
|
|
701
|
+
|
|
702
|
+
## Limitations
|
|
703
|
+
|
|
704
|
+
- Unquoted dash-separated project names (`my-project.ds.t`) must be
|
|
705
|
+
backtick-quoted (`` `my-project.ds.t` ``)
|
|
706
|
+
- A few administrative statements (`GRANT`, `EXPORT DATA`, `LOAD DATA`,
|
|
707
|
+
`ALTER ...`) raise `UnsupportedStatementError`
|
|
708
|
+
- `EXECUTE IMMEDIATE` with dynamic SQL strings cannot be traced statically
|
|
709
|
+
- Lineage classification reports the final transformation hop per column
|
|
710
|
+
|
|
711
|
+
## Development
|
|
712
|
+
|
|
713
|
+
```bash
|
|
714
|
+
pip install -e ".[dev]"
|
|
715
|
+
pytest
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
See [DEPLOYMENT.md](DEPLOYMENT.md) for publishing to PyPI.
|
|
719
|
+
|
|
720
|
+
## License
|
|
721
|
+
|
|
722
|
+
MIT
|