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/__init__.py +83 -0
- sql2sqlx/__main__.py +14 -0
- sql2sqlx/cli.py +323 -0
- sql2sqlx/converter.py +1241 -0
- sql2sqlx/emitter.py +275 -0
- sql2sqlx/errors.py +93 -0
- sql2sqlx/keywords.py +169 -0
- sql2sqlx/lexer.py +494 -0
- sql2sqlx/model.py +487 -0
- sql2sqlx/parser.py +2193 -0
- sql2sqlx/py.typed +2 -0
- sql2sqlx/refs.py +902 -0
- sql2sqlx/splitter.py +360 -0
- sql2sqlx/version.py +15 -0
- sql2sqlx-0.1.0.dist-info/METADATA +733 -0
- sql2sqlx-0.1.0.dist-info/RECORD +20 -0
- sql2sqlx-0.1.0.dist-info/WHEEL +5 -0
- sql2sqlx-0.1.0.dist-info/entry_points.txt +2 -0
- sql2sqlx-0.1.0.dist-info/licenses/LICENSE +202 -0
- sql2sqlx-0.1.0.dist-info/top_level.txt +1 -0
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
|