sql2sqlx 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.
sql2sqlx/refs.py ADDED
@@ -0,0 +1,902 @@
1
+ # Copyright (c) Soumyadip Sarkar.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the Apache-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Table-path parsing and reference-site discovery.
8
+
9
+ The reference scanner finds every position in a statement where a *table*
10
+ is being read - the places that can safely be rewritten to Dataform
11
+ ``${ref(...)}`` calls. Doing this on raw text would be hopeless; doing it
12
+ on the token stream with a small amount of context tracking is exact for
13
+ the constructs that matter and *conservative* everywhere else (when in
14
+ doubt, a reference is left untouched, which always yields valid SQLX).
15
+
16
+ What is captured
17
+ ----------------
18
+ A candidate reference site is a table path appearing:
19
+
20
+ * after ``FROM`` (except a ``FROM`` inside ``EXTRACT( ... )``);
21
+ * after any ``JOIN``;
22
+ * after a ``,`` inside an active ``FROM`` table list (comma joins);
23
+ * after ``USING`` of a top-level ``MERGE`` statement.
24
+
25
+ What is excluded
26
+ ----------------
27
+ * ``UNNEST(...)`` and table-valued function calls (``name(`` directly
28
+ after the table position);
29
+ * subqueries and parenthesized join trees (recursed into instead);
30
+ * references to a CTE in the CTE's actual visibility range;
31
+ * paths whose first segment is a visible range variable (FROM aliases are
32
+ tracked per query block, including correlated scalar subqueries, without
33
+ leaking aliases out of nested or set-operation query blocks);
34
+ * paths inside caller-supplied *skip spans* (e.g. the target of
35
+ ``DELETE FROM target``);
36
+ * paths longer than three segments (correlated array references such as
37
+ ``FROM t, t.array_col`` at four+ parts).
38
+
39
+ Path syntax handled
40
+ -------------------
41
+ ``dataset.table``, ``project.dataset.table``, any mix of backticked
42
+ segments (`` `p.d.t` ``, `` `p`.`d`.`t` ``, `` `p.d`.t ``), whitespace
43
+ around dots, and BigQuery *dashed* project names (``my-project-123.d.t``)
44
+ - dash joining requires character adjacency, exactly like BigQuery's own
45
+ lexer, so ``a - b`` in an expression is never mistaken for a path.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from typing import Dict, List, Optional, Sequence, Set, Tuple
51
+
52
+ from sql2sqlx.keywords import FROM_CLAUSE_ENDERS, RESERVED
53
+ from sql2sqlx.lexer import BACKTICK, EOF, IDENT, NUMBER, OP, Token, unquote_identifier
54
+ from sql2sqlx.model import RefSite, TableName
55
+
56
+
57
+ class PathMatch:
58
+ """A parsed table path within the token stream.
59
+
60
+ Attributes:
61
+ parts: Decoded path segments (backticks removed, escapes decoded,
62
+ dotted backtick contents split).
63
+ start: Character offset of the first path character.
64
+ end: Character offset one past the last path character.
65
+ next_index: Token index immediately after the path.
66
+ """
67
+
68
+ __slots__ = ("parts", "start", "end", "next_index")
69
+
70
+ def __init__(self, parts: List[str], start: int, end: int, next_index: int) -> None:
71
+ """Store the parse result; see class docstring for fields."""
72
+ self.parts = parts
73
+ self.start = start
74
+ self.end = end
75
+ self.next_index = next_index
76
+
77
+
78
+ def _read_segment(tokens: Sequence[Token], k: int) -> Optional[Tuple[str, int, int]]:
79
+ """Read one path segment starting at token index ``k``.
80
+
81
+ Handles backticked segments and BigQuery dashed identifiers
82
+ (``proj-name-123``), where every ``-`` and its continuation must be
83
+ *character-adjacent* to the preceding token.
84
+
85
+ Args:
86
+ tokens: The token stream.
87
+ k: Index of the candidate segment's first token.
88
+
89
+ Returns:
90
+ ``(decoded_text, end_offset, next_index)`` or ``None`` if no
91
+ segment starts at ``k``.
92
+ """
93
+ tok = tokens[k]
94
+ if tok.kind == BACKTICK:
95
+ return unquote_identifier(tok.text), tok.end, k + 1
96
+ if tok.kind != IDENT:
97
+ return None
98
+ text = tok.text
99
+ e = tok.end
100
+ k += 1
101
+ n = len(tokens)
102
+ while (
103
+ k + 1 < n
104
+ and tokens[k].kind == OP
105
+ and tokens[k].text == "-"
106
+ and tokens[k].start == e
107
+ and tokens[k + 1].kind in (IDENT, NUMBER)
108
+ and tokens[k + 1].start == tokens[k].end
109
+ ):
110
+ seg = tokens[k + 1].text
111
+ e = tokens[k + 1].end
112
+ was_number = tokens[k + 1].kind == NUMBER
113
+ k += 2
114
+ # `proj-123abc` lexes as NUMBER `123` + adjacent IDENT `abc`.
115
+ if was_number and k < n and tokens[k].kind == IDENT and tokens[k].start == e:
116
+ seg += tokens[k].text
117
+ e = tokens[k].end
118
+ k += 1
119
+ text += "-" + seg
120
+ return text, e, k
121
+
122
+
123
+ def parse_table_path(tokens: Sequence[Token], i: int) -> Optional[PathMatch]:
124
+ """Parse a (possibly qualified) table path at token index ``i``.
125
+
126
+ Args:
127
+ tokens: The token stream (must be EOF-terminated).
128
+ i: Index of the first token of the candidate path.
129
+
130
+ Returns:
131
+ A :class:`PathMatch`, or ``None`` when ``tokens[i]`` cannot begin
132
+ a path (wrong kind, an unquoted reserved keyword, or an index at
133
+ or past the end of the token list - script statement slices are
134
+ not EOF-terminated, so a truncated ``DELETE``/``RENAME TO``/...
135
+ can legitimately ask for the position after the last token).
136
+
137
+ Example:
138
+ >>> from sql2sqlx.lexer import tokenize
139
+ >>> pm = parse_table_path(tokenize("`my-proj`.sales . orders x"), 0)
140
+ >>> pm.parts
141
+ ['my-proj', 'sales', 'orders']
142
+ """
143
+ if not (0 <= i < len(tokens)):
144
+ return None
145
+ first = tokens[i]
146
+ if first.kind == IDENT:
147
+ if first.upper in RESERVED:
148
+ return None
149
+ elif first.kind != BACKTICK:
150
+ return None
151
+
152
+ seg = _read_segment(tokens, i)
153
+ if seg is None:
154
+ return None
155
+ text0, end, j = seg
156
+ parts: List[str] = text0.split(".") if first.kind == BACKTICK and "." in text0 else [text0]
157
+ start = first.start
158
+ n = len(tokens)
159
+ while j < n and tokens[j].kind == OP and tokens[j].text == ".":
160
+ if j + 1 >= n:
161
+ break
162
+ was_backtick = tokens[j + 1].kind == BACKTICK
163
+ nxt = _read_segment(tokens, j + 1)
164
+ if nxt is None:
165
+ break
166
+ text_n, end_n, j2 = nxt
167
+ if was_backtick and "." in text_n:
168
+ parts.extend(text_n.split("."))
169
+ else:
170
+ parts.append(text_n)
171
+ end = end_n
172
+ j = j2
173
+ return PathMatch(parts, start, end, j)
174
+
175
+
176
+ #: Unreserved keywords that directly follow a table expression but are
177
+ #: never aliases (consuming them as aliases would be harmless for the
178
+ #: exclusion set, but skipping their token would desynchronize scanning).
179
+ _NON_ALIAS_FOLLOWERS = frozenset({"PIVOT", "UNPIVOT"})
180
+
181
+
182
+ class _QueryScope:
183
+ """Reference-name visibility for one query expression or script unit."""
184
+
185
+ __slots__ = (
186
+ "start",
187
+ "end",
188
+ "branches",
189
+ "outer_aliases",
190
+ "aliases",
191
+ "outer_ctes",
192
+ "ctes",
193
+ )
194
+
195
+ def __init__(
196
+ self,
197
+ start: int,
198
+ end: int,
199
+ outer_aliases: Set[str],
200
+ outer_ctes: Set[str],
201
+ ) -> None:
202
+ self.start = start
203
+ self.end = end
204
+ self.branches: Optional[List[Tuple[int, int]]] = None
205
+ self.outer_aliases = outer_aliases
206
+ self.aliases: Set[str] = set()
207
+ self.outer_ctes = outer_ctes
208
+ self.ctes: Set[str] = set()
209
+
210
+
211
+ def _paren_pairs(tokens: Sequence[Token]) -> Dict[int, int]:
212
+ """Build opening-to-closing parenthesis indexes in one linear pass."""
213
+ stack: List[int] = []
214
+ pairs: Dict[int, int] = {}
215
+ for i, token in enumerate(tokens):
216
+ if token.kind != OP:
217
+ continue
218
+ if token.text == "(":
219
+ stack.append(i)
220
+ elif token.text == ")" and stack:
221
+ pairs[stack.pop()] = i
222
+ return pairs
223
+
224
+
225
+ def _matching_paren(
226
+ tokens: Sequence[Token],
227
+ opening: int,
228
+ end: int,
229
+ pairs: Optional[Dict[int, int]] = None,
230
+ ) -> Optional[int]:
231
+ """Return the matching ``)`` token index for ``tokens[opening]``."""
232
+ if pairs is not None:
233
+ closing = pairs.get(opening)
234
+ return closing if closing is not None and closing < end else None
235
+ depth = 0
236
+ for i in range(opening, end):
237
+ token = tokens[i]
238
+ if token.kind != OP:
239
+ continue
240
+ if token.text == "(":
241
+ depth += 1
242
+ elif token.text == ")":
243
+ depth -= 1
244
+ if depth == 0:
245
+ return i
246
+ return None
247
+
248
+
249
+ def _segment_end(tokens: Sequence[Token], start: int) -> int:
250
+ """Return the exclusive token index of the current script statement."""
251
+ for i in range(start, len(tokens)):
252
+ token = tokens[i]
253
+ if token.kind == EOF or (token.kind == OP and token.text == ";"):
254
+ return i
255
+ return len(tokens)
256
+
257
+
258
+ def _begins_query(tokens: Sequence[Token], opening: int, closing: int) -> bool:
259
+ """Whether a parenthesized expression contains a query expression."""
260
+ i = opening + 1
261
+ while i < closing and tokens[i].kind == OP and tokens[i].text == "(":
262
+ i += 1
263
+ return i < closing and tokens[i].kind == IDENT and tokens[i].upper in ("SELECT", "WITH", "FROM")
264
+
265
+
266
+ def _cte_definitions(
267
+ tokens: Sequence[Token],
268
+ with_index: int,
269
+ end: int,
270
+ paren_pairs: Dict[int, int],
271
+ ) -> Optional[Tuple[bool, List[Tuple[str, int, int]]]]:
272
+ """Parse the top-level definitions of one ``WITH`` clause.
273
+
274
+ Returns ``(recursive, [(name, body_open, body_close), ...])``. The
275
+ strict shape check prevents unrelated uses of the unreserved word
276
+ ``with`` from changing reference visibility.
277
+ """
278
+ i = with_index + 1
279
+ recursive = False
280
+ if i < end and tokens[i].kind == IDENT and tokens[i].upper == "RECURSIVE":
281
+ recursive = True
282
+ i += 1
283
+ definitions: List[Tuple[str, int, int]] = []
284
+ while i < end:
285
+ token = tokens[i]
286
+ if token.kind == BACKTICK:
287
+ name = unquote_identifier(token.text)
288
+ elif token.kind == IDENT and token.upper not in RESERVED:
289
+ name = token.text
290
+ else:
291
+ return None
292
+ i += 1
293
+ # Accept the standard optional CTE column-name list.
294
+ if i < end and tokens[i].kind == OP and tokens[i].text == "(":
295
+ column_close = _matching_paren(tokens, i, end, paren_pairs)
296
+ if column_close is None:
297
+ return None
298
+ i = column_close + 1
299
+ if not (i < end and tokens[i].kind == IDENT and tokens[i].upper == "AS"):
300
+ return None
301
+ i += 1
302
+ if not (i < end and tokens[i].kind == OP and tokens[i].text == "("):
303
+ return None
304
+ body_open = i
305
+ body_close = _matching_paren(tokens, body_open, end, paren_pairs)
306
+ if body_close is None:
307
+ return None
308
+ definitions.append((name.upper(), body_open, body_close))
309
+ i = body_close + 1
310
+ if not (i < end and tokens[i].kind == OP and tokens[i].text == ","):
311
+ break
312
+ # A comma belongs to the WITH list only when another ``name AS (``
313
+ # definition follows. Let the next iteration validate that shape.
314
+ i += 1
315
+ return (recursive, definitions) if definitions else None
316
+
317
+
318
+ def _maybe_alias(tokens: Sequence[Token], i: int, excluded: Set[str]) -> int:
319
+ """Consume an optional ``[AS] alias`` at index ``i``.
320
+
321
+ The alias (decoded, upper-cased) is added to ``excluded`` so later
322
+ dotted paths starting with it (``alias.column``) are never treated as
323
+ table references.
324
+
325
+ Args:
326
+ tokens: The token stream.
327
+ i: Index where an alias may start.
328
+ excluded: Mutable set of excluded first segments.
329
+
330
+ Returns:
331
+ The index of the first token after the alias (or ``i`` unchanged
332
+ when no alias is present).
333
+ """
334
+ n = len(tokens)
335
+ if i < n and tokens[i].kind == IDENT and tokens[i].upper == "AS":
336
+ j = i + 1
337
+ if j < n and tokens[j].kind in (IDENT, BACKTICK):
338
+ t = tokens[j]
339
+ if t.kind == BACKTICK or t.upper not in RESERVED:
340
+ excluded.add(unquote_identifier(t.text).upper())
341
+ return j + 1
342
+ return i
343
+ if i < n:
344
+ t = tokens[i]
345
+ if t.kind == BACKTICK:
346
+ excluded.add(unquote_identifier(t.text).upper())
347
+ return i + 1
348
+ if t.kind == IDENT and t.upper not in RESERVED and t.upper not in _NON_ALIAS_FOLLOWERS:
349
+ excluded.add(t.upper)
350
+ return i + 1
351
+ return i
352
+
353
+
354
+ def _set_operator(tokens: Sequence[Token], i: int) -> bool:
355
+ """Whether ``tokens[i]`` is a top-level set operator."""
356
+ token = tokens[i]
357
+ if token.kind != IDENT or token.upper not in ("UNION", "INTERSECT", "EXCEPT"):
358
+ return False
359
+ # ``SELECT * EXCEPT (column)`` is a projection modifier, not a set op.
360
+ return not (
361
+ token.upper == "EXCEPT"
362
+ and i + 1 < len(tokens)
363
+ and tokens[i + 1].kind == OP
364
+ and tokens[i + 1].text == "("
365
+ )
366
+
367
+
368
+ def _branch_ranges(
369
+ tokens: Sequence[Token],
370
+ start: int,
371
+ end: int,
372
+ ) -> List[Tuple[int, int]]:
373
+ """Split a query expression into its top-level set-operation branches."""
374
+ branches: List[Tuple[int, int]] = []
375
+ branch_start = start
376
+ depth = 0
377
+ bracket_depth = 0
378
+ for i in range(start, end):
379
+ token = tokens[i]
380
+ if token.kind == OP:
381
+ if token.text == "(":
382
+ depth += 1
383
+ elif token.text == ")" and depth:
384
+ depth -= 1
385
+ elif token.text == "[":
386
+ bracket_depth += 1
387
+ elif token.text == "]" and bracket_depth:
388
+ bracket_depth -= 1
389
+ continue
390
+ if depth == 0 and bracket_depth == 0 and _set_operator(tokens, i):
391
+ branches.append((branch_start, i))
392
+ branch_start = i + 1
393
+ branches.append((branch_start, end))
394
+ return branches
395
+
396
+
397
+ def _collect_range_aliases(
398
+ tokens: Sequence[Token],
399
+ start: int,
400
+ end: int,
401
+ *,
402
+ from_active: bool = False,
403
+ cte_names: Optional[Set[str]] = None,
404
+ paren_pairs: Optional[Dict[int, int]] = None,
405
+ ) -> Set[str]:
406
+ """Collect range variables declared in one query/set-operation block."""
407
+ aliases: Set[str] = set()
408
+ expecting = from_active
409
+ i = start
410
+ while i < end:
411
+ token = tokens[i]
412
+ if token.kind == OP:
413
+ if token.text == "(":
414
+ closing = _matching_paren(tokens, i, end, paren_pairs)
415
+ if closing is None:
416
+ return aliases
417
+ if expecting:
418
+ is_query = _begins_query(tokens, i, closing)
419
+ nested: Set[str] = set()
420
+ if not is_query:
421
+ nested = _collect_range_aliases(
422
+ tokens,
423
+ i + 1,
424
+ closing,
425
+ from_active=True,
426
+ cte_names=cte_names,
427
+ paren_pairs=paren_pairs,
428
+ )
429
+ alias_end = _maybe_alias(tokens, closing + 1, aliases)
430
+ if alias_end == closing + 1:
431
+ aliases.update(nested)
432
+ i = alias_end
433
+ expecting = False
434
+ continue
435
+ i = closing + 1
436
+ continue
437
+ if token.text == "," and from_active:
438
+ expecting = True
439
+ i += 1
440
+ continue
441
+ if token.kind not in (IDENT, BACKTICK):
442
+ i += 1
443
+ continue
444
+ upper = token.upper if token.kind == IDENT else ""
445
+ if token.kind == IDENT and upper == "FROM":
446
+ from_active = True
447
+ expecting = True
448
+ i += 1
449
+ continue
450
+ if token.kind == IDENT and upper == "JOIN":
451
+ from_active = True
452
+ expecting = True
453
+ i += 1
454
+ continue
455
+ if token.kind == IDENT and upper in FROM_CLAUSE_ENDERS:
456
+ from_active = False
457
+ expecting = False
458
+ i += 1
459
+ continue
460
+ if not expecting:
461
+ i += 1
462
+ continue
463
+ if token.kind == IDENT and upper == "LATERAL":
464
+ i += 1
465
+ continue
466
+ if token.kind == IDENT and upper == "UNNEST":
467
+ if i + 1 < end and tokens[i + 1].kind == OP and tokens[i + 1].text == "(":
468
+ closing = _matching_paren(tokens, i + 1, end, paren_pairs)
469
+ if closing is None:
470
+ return aliases
471
+ alias_end = _maybe_alias(tokens, closing + 1, aliases)
472
+ if alias_end == closing + 1 and i + 2 < closing:
473
+ path = parse_table_path(tokens, i + 2)
474
+ if path is not None and path.next_index == closing:
475
+ aliases.add(path.parts[-1].upper())
476
+ i = alias_end
477
+ else:
478
+ i += 1
479
+ expecting = False
480
+ continue
481
+ if token.kind == IDENT and upper in RESERVED:
482
+ expecting = False
483
+ i += 1
484
+ continue
485
+ path = parse_table_path(tokens, i)
486
+ if path is None:
487
+ expecting = False
488
+ i += 1
489
+ continue
490
+ after = path.next_index
491
+ if after < end and tokens[after].kind == OP and tokens[after].text == "(":
492
+ closing = _matching_paren(tokens, after, end, paren_pairs)
493
+ if closing is None:
494
+ return aliases
495
+ after = closing + 1
496
+ alias_end = _maybe_alias(tokens, after, aliases)
497
+ if alias_end == after and not (
498
+ path.next_index < end
499
+ and tokens[path.next_index].kind == OP
500
+ and tokens[path.next_index].text == "("
501
+ ):
502
+ lexical = unquote_identifier(tokens[i].text).upper()
503
+ is_cte = cte_names is not None and path.next_index == i + 1 and lexical in cte_names
504
+ aliases.add(lexical if is_cte else path.parts[-1].upper())
505
+ i = alias_end
506
+ expecting = False
507
+ return aliases
508
+
509
+
510
+ def _branch_aliases(
511
+ tokens: Sequence[Token],
512
+ scope: _QueryScope,
513
+ position: int,
514
+ cache: Dict[Tuple[int, int, Tuple[str, ...]], Set[str]],
515
+ paren_pairs: Dict[int, int],
516
+ ) -> Set[str]:
517
+ """Return every range alias in ``scope``'s branch at ``position``."""
518
+ if scope.branches is None:
519
+ scope.branches = _branch_ranges(tokens, scope.start, scope.end)
520
+ start, end = scope.start, scope.end
521
+ for branch_start, branch_end in scope.branches:
522
+ if branch_start <= position < branch_end:
523
+ start, end = branch_start, branch_end
524
+ break
525
+ cte_names = scope.outer_ctes | scope.ctes
526
+ key = (start, end, tuple(sorted(cte_names)))
527
+ if key not in cache:
528
+ cache[key] = _collect_range_aliases(
529
+ tokens,
530
+ start,
531
+ end,
532
+ cte_names=cte_names,
533
+ paren_pairs=paren_pairs,
534
+ )
535
+ return cache[key]
536
+
537
+
538
+ def _exclude_write_target_alias(
539
+ tokens: Sequence[Token],
540
+ i: int,
541
+ excluded: Set[str],
542
+ ) -> None:
543
+ """Register the range variable of an UPDATE/DELETE/MERGE target.
544
+
545
+ Unlike a ``DELETE FROM`` target, UPDATE and MERGE targets do not pass
546
+ through the ordinary FROM-item scanner. Their explicit (or implicit)
547
+ aliases are nevertheless visible later in the statement and must shield
548
+ correlated paths such as ``target_alias.repeated_field`` from physical
549
+ table-reference rewriting.
550
+ """
551
+ head = tokens[i].upper
552
+ j = i + 1
553
+ if (
554
+ head == "MERGE"
555
+ and j < len(tokens)
556
+ and (tokens[j].kind == IDENT and tokens[j].upper == "INTO")
557
+ ):
558
+ j += 1
559
+ elif (
560
+ head == "DELETE"
561
+ and j < len(tokens)
562
+ and (tokens[j].kind == IDENT and tokens[j].upper == "FROM")
563
+ ):
564
+ j += 1
565
+ match = parse_table_path(tokens, j)
566
+ if match is None or not match.parts:
567
+ return
568
+ j = match.next_index
569
+ if j < len(tokens) and tokens[j].kind == IDENT and tokens[j].upper == "AS":
570
+ j += 1
571
+ if j < len(tokens) and tokens[j].kind in (IDENT, BACKTICK):
572
+ alias = tokens[j]
573
+ if alias.kind == BACKTICK or alias.upper not in RESERVED:
574
+ excluded.add(unquote_identifier(alias.text).upper())
575
+ return
576
+ elif j < len(tokens):
577
+ alias = tokens[j]
578
+ if alias.kind == BACKTICK or (alias.kind == IDENT and alias.upper not in RESERVED):
579
+ excluded.add(unquote_identifier(alias.text).upper())
580
+ return
581
+ excluded.add(match.parts[-1].upper())
582
+
583
+
584
+ def scan_ref_sites(
585
+ tokens: Sequence[Token],
586
+ stmt_kind: str,
587
+ skip_spans: Sequence[Tuple[int, int]] = (),
588
+ statement_start_offsets: Optional[Set[int]] = None,
589
+ ) -> List[RefSite]:
590
+ """Scan one statement's tokens for rewritable table references.
591
+
592
+ Args:
593
+ tokens: Significant tokens of the statement (EOF terminator is
594
+ tolerated but not required).
595
+ stmt_kind: Uppercase leading keyword of the statement (``"MERGE"``
596
+ enables ``USING`` as a table introducer at depth 0).
597
+ skip_spans: Character spans (absolute offsets) whose contained
598
+ paths must not be reported - typically the statement's own
599
+ write target.
600
+ statement_start_offsets: Optional block-aware source offsets for
601
+ nested statement heads. Supplying these lets a whole-script
602
+ scan recognize nested ``MERGE ... USING`` statements without
603
+ treating an arbitrary ``MERGE`` token as a statement.
604
+
605
+ Returns:
606
+ Filtered list of :class:`~sql2sqlx.model.RefSite` in source order.
607
+ CTE references and paths rooted at a visible range variable are
608
+ removed.
609
+ """
610
+ resolved: List[RefSite] = []
611
+ n = len(tokens)
612
+ paren_pairs = _paren_pairs(tokens)
613
+ scopes: List[_QueryScope] = [_QueryScope(0, _segment_end(tokens, 0), set(), set())]
614
+ # close-index -> owning scope/name for non-recursive CTE visibility.
615
+ cte_close_events: Dict[int, List[Tuple[_QueryScope, str]]] = {}
616
+ cte_body_opens: Set[int] = set()
617
+ alias_cache: Dict[Tuple[int, int, Tuple[str, ...]], Set[str]] = {}
618
+ implicit_alias_at_close: Dict[int, str] = {}
619
+ # One context per open paren/bracket (plus root):
620
+ # [from_active, is_extract, opened_query_scope].
621
+ ctx: List[List[bool]] = [[False, False, False]]
622
+ expecting = False
623
+ prev_upper = ""
624
+ merge_active = stmt_kind == "MERGE"
625
+ i = 0
626
+ while i < n:
627
+ tok = tokens[i]
628
+ kind = tok.kind
629
+ if kind == OP:
630
+ t = tok.text
631
+ if t == ";":
632
+ # A procedural draft can contain several inner statements.
633
+ # FROM state and alias/CTE exclusions are statement-scoped;
634
+ # carrying either across `;` can invent references or hide
635
+ # real ones in the next statement.
636
+ scopes = [_QueryScope(i + 1, _segment_end(tokens, i + 1), set(), set())]
637
+ ctx = [[False, False, False]]
638
+ merge_active = False
639
+ expecting = False
640
+ prev_upper = ""
641
+ i += 1
642
+ continue
643
+ if t == "(":
644
+ closing = _matching_paren(tokens, i, n, paren_pairs)
645
+ begins_query = closing is not None and (
646
+ _begins_query(tokens, i, closing) or i in cte_body_opens
647
+ )
648
+ if begins_query and closing is not None:
649
+ parent = scopes[-1]
650
+ outer_ctes = parent.outer_ctes | parent.ctes
651
+ outer_aliases: Set[str]
652
+ if i in cte_body_opens:
653
+ # BigQuery explicitly disallows a CTE body from
654
+ # referencing correlated columns in an outer query.
655
+ outer_aliases = set()
656
+ elif expecting:
657
+ # BigQuery FROM subqueries are not lateral. UNNEST
658
+ # and TVFs are handled separately and can consume
659
+ # preceding range variables in their arguments.
660
+ outer_aliases = set()
661
+ else:
662
+ outer_aliases = (
663
+ parent.outer_aliases
664
+ | parent.aliases
665
+ | _branch_aliases(
666
+ tokens,
667
+ parent,
668
+ i,
669
+ alias_cache,
670
+ paren_pairs,
671
+ )
672
+ )
673
+ scopes.append(
674
+ _QueryScope(
675
+ i + 1,
676
+ closing,
677
+ set(outer_aliases),
678
+ set(outer_ctes),
679
+ )
680
+ )
681
+ ctx.append(
682
+ [
683
+ expecting and not begins_query,
684
+ prev_upper == "EXTRACT",
685
+ begins_query,
686
+ ]
687
+ )
688
+ if begins_query:
689
+ expecting = False
690
+ prev_upper = ""
691
+ i += 1
692
+ continue
693
+ if t == "[":
694
+ # Array literals/subscripts can contain commas but never a
695
+ # table list directly.
696
+ ctx.append([False, False, False])
697
+ expecting = False
698
+ prev_upper = ""
699
+ i += 1
700
+ continue
701
+ if t == ")":
702
+ if len(ctx) > 1:
703
+ closed = ctx.pop()
704
+ if closed[2] and len(scopes) > 1:
705
+ scopes.pop()
706
+ for owner, name in cte_close_events.get(i, []):
707
+ owner.ctes.add(name)
708
+ if ctx[-1][0]:
709
+ alias_end = _maybe_alias(
710
+ tokens,
711
+ i + 1,
712
+ scopes[-1].aliases,
713
+ )
714
+ if alias_end == i + 1 and i in implicit_alias_at_close:
715
+ scopes[-1].aliases.add(implicit_alias_at_close[i])
716
+ i = alias_end
717
+ else:
718
+ i += 1
719
+ expecting = False
720
+ prev_upper = ""
721
+ continue
722
+ if t == "]":
723
+ if len(ctx) > 1:
724
+ ctx.pop()
725
+ expecting = False
726
+ prev_upper = ""
727
+ i += 1
728
+ continue
729
+ if t == "|>":
730
+ # The preceding FROM table list ends at a pipe operator.
731
+ # A later pipe JOIN will explicitly reactivate table mode.
732
+ ctx[-1][0] = False
733
+ expecting = False
734
+ prev_upper = ""
735
+ i += 1
736
+ continue
737
+ if t == "," and ctx[-1][0]:
738
+ expecting = True
739
+ prev_upper = ""
740
+ i += 1
741
+ continue
742
+ expecting = False
743
+ prev_upper = ""
744
+ i += 1
745
+ continue
746
+
747
+ if kind == IDENT or kind == BACKTICK:
748
+ up = tok.upper if kind == IDENT else ""
749
+ if kind == IDENT:
750
+ is_statement_head = (
751
+ tok.start in statement_start_offsets
752
+ if statement_start_offsets is not None
753
+ else i == 0
754
+ )
755
+ if is_statement_head and up in ("UPDATE", "DELETE", "MERGE"):
756
+ _exclude_write_target_alias(tokens, i, scopes[-1].aliases)
757
+ if (
758
+ up == "MERGE"
759
+ and statement_start_offsets is not None
760
+ and tok.start in statement_start_offsets
761
+ ):
762
+ merge_active = True
763
+ if up == "WITH":
764
+ definitions = _cte_definitions(
765
+ tokens,
766
+ i,
767
+ scopes[-1].end,
768
+ paren_pairs,
769
+ )
770
+ if definitions is not None:
771
+ recursive, ctes = definitions
772
+ cte_body_opens.update(opening for _, opening, _ in ctes)
773
+ if recursive:
774
+ scopes[-1].ctes.update(name for name, _, _ in ctes)
775
+ else:
776
+ owner = scopes[-1]
777
+ for name, _, closing in ctes:
778
+ cte_close_events.setdefault(closing, []).append((owner, name))
779
+ if up == "FROM":
780
+ if ctx[-1][1]: # EXTRACT(... FROM ...)
781
+ expecting = False
782
+ else:
783
+ ctx[-1][0] = True
784
+ expecting = True
785
+ prev_upper = up
786
+ i += 1
787
+ continue
788
+ if up == "JOIN":
789
+ ctx[-1][0] = True
790
+ expecting = True
791
+ prev_upper = up
792
+ i += 1
793
+ continue
794
+ if up == "USING" and merge_active and len(ctx) == 1:
795
+ # Only the MERGE statement's first USING introduces its
796
+ # source. A later JOIN ... USING(column_list) is a join
797
+ # condition, not another table position.
798
+ merge_active = False
799
+ expecting = True
800
+ prev_upper = up
801
+ i += 1
802
+ continue
803
+ if _set_operator(tokens, i):
804
+ scopes[-1].aliases.clear()
805
+ if up in FROM_CLAUSE_ENDERS:
806
+ ctx[-1][0] = False
807
+ expecting = False
808
+ prev_upper = up
809
+ i += 1
810
+ continue
811
+ if expecting:
812
+ if kind == IDENT:
813
+ if up == "LATERAL":
814
+ prev_upper = up
815
+ i += 1
816
+ continue
817
+ if (
818
+ up == "UNNEST"
819
+ and i + 1 < n
820
+ and (tokens[i + 1].kind == OP and tokens[i + 1].text == "(")
821
+ ):
822
+ closing = _matching_paren(tokens, i + 1, n, paren_pairs)
823
+ if closing is None:
824
+ expecting = False
825
+ i += 1
826
+ continue
827
+ if i + 2 < closing:
828
+ path = parse_table_path(tokens, i + 2)
829
+ if path is not None and path.next_index == closing:
830
+ implicit_alias_at_close[closing] = path.parts[-1].upper()
831
+ expecting = False
832
+ prev_upper = ""
833
+ # Continue through the arguments: ARRAY/scalar
834
+ # subqueries inside UNNEST can contain physical table
835
+ # reads and can correlate to the enclosing query.
836
+ i += 1
837
+ continue
838
+ if up in RESERVED: # UNNEST, SELECT, ...
839
+ expecting = False
840
+ prev_upper = up
841
+ i += 1
842
+ continue
843
+ pm = parse_table_path(tokens, i)
844
+ if pm is None:
845
+ expecting = False
846
+ prev_upper = up
847
+ i += 1
848
+ continue
849
+ expecting = False
850
+ nxt = tokens[pm.next_index] if pm.next_index < n else None
851
+ if nxt is not None and nxt.kind == OP and nxt.text == "(":
852
+ # table-valued function call - not a plain table
853
+ # Scan its arguments because they may contain query
854
+ # expressions with ordinary physical table reads.
855
+ prev_upper = ""
856
+ i = pm.next_index
857
+ continue
858
+ in_skip = any(pm.start >= a and pm.end <= b for a, b in skip_spans)
859
+ lexical_root = unquote_identifier(tokens[i].text).upper()
860
+ roots = {pm.parts[0].upper(), lexical_root}
861
+ scope = scopes[-1]
862
+ alias_names = scope.outer_aliases | scope.aliases
863
+ cte_names = scope.outer_ctes | scope.ctes
864
+ is_cte_ref = pm.next_index == i + 1 and lexical_root in cte_names
865
+ if (
866
+ not in_skip
867
+ and 1 <= len(pm.parts) <= 3
868
+ and all(pm.parts)
869
+ and roots.isdisjoint(alias_names)
870
+ and not is_cte_ref
871
+ ):
872
+ resolved.append(
873
+ RefSite(
874
+ pm.start,
875
+ pm.end,
876
+ TableName.from_parts(list(pm.parts)),
877
+ )
878
+ )
879
+ alias_end = _maybe_alias(
880
+ tokens,
881
+ pm.next_index,
882
+ scopes[-1].aliases,
883
+ )
884
+ if alias_end == pm.next_index:
885
+ # A plain table path has the implicit alias of its last
886
+ # identifier. It is visible to subsequent FROM items,
887
+ # e.g. ``FROM d.parent, parent.children``.
888
+ implicit = lexical_root if is_cte_ref else pm.parts[-1].upper()
889
+ scopes[-1].aliases.add(implicit)
890
+ i = alias_end
891
+ prev_upper = ""
892
+ continue
893
+ prev_upper = up
894
+ i += 1
895
+ continue
896
+
897
+ # STRING / NUMBER / PARAM / EOF
898
+ expecting = False
899
+ prev_upper = ""
900
+ i += 1
901
+
902
+ return resolved