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.
@@ -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