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/splitter.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
"""Split a token stream into top-level GoogleSQL statements.
|
|
8
|
+
|
|
9
|
+
Splitting SQL on semicolons is only correct if three things are tracked:
|
|
10
|
+
|
|
11
|
+
1. **Strings and comments** - handled upstream: the splitter operates on
|
|
12
|
+
the significant token stream from :mod:`sql2sqlx.lexer`, so a ``;``
|
|
13
|
+
inside ``'a;b'`` or ``/* ; */`` simply never appears here.
|
|
14
|
+
2. **Parenthesis depth** - a ``;`` can only end a statement at paren
|
|
15
|
+
depth 0 (defensive; valid GoogleSQL has no parenthesized semicolons).
|
|
16
|
+
3. **Procedural blocks** - BigQuery scripting statements contain inner
|
|
17
|
+
semicolons that must *not* split::
|
|
18
|
+
|
|
19
|
+
BEGIN ... END; IF c THEN ...; END IF; LOOP ...; END LOOP;
|
|
20
|
+
WHILE c DO ...; END WHILE; REPEAT ...; UNTIL c END REPEAT;
|
|
21
|
+
FOR x IN (q) DO ...; END FOR; CASE WHEN c THEN ...; END CASE;
|
|
22
|
+
|
|
23
|
+
The block tracker maintains a frame stack. Openers are recognized in
|
|
24
|
+
*statement position* (start of input, after ``;``, after ``BEGIN``,
|
|
25
|
+
``LOOP``, ``REPEAT``, or after the ``THEN``/``ELSE``/``DO`` of a
|
|
26
|
+
*scripting* frame), after a block label, or at the body of a
|
|
27
|
+
``CREATE PROCEDURE``. Statement position distinguishes the scripting
|
|
28
|
+
``IF c THEN`` statement from the ``IF(a, b, c)`` expression function and
|
|
29
|
+
the ``LOOP``/``WHILE``/``FOR`` statements from the same words used as
|
|
30
|
+
(quoted-elsewhere) identifiers. ``CASE`` opens a frame in *any* position
|
|
31
|
+
because both the expression form (``CASE ... END``) and the scripting
|
|
32
|
+
form (``CASE ... END CASE``) are symmetric push/pop pairs; the two are
|
|
33
|
+
told apart by whether the ``CASE`` itself sits in statement position.
|
|
34
|
+
Inside an *expression* ``CASE``, ``THEN``/``ELSE`` are followed by
|
|
35
|
+
expressions, so they must not grant statement position - otherwise a
|
|
36
|
+
column named ``loop`` or ``begin`` right after them would open a bogus
|
|
37
|
+
frame, desynchronize ``END`` matching and glue unrelated statements
|
|
38
|
+
together. Likewise ``ELSEIF`` is followed by a *condition*; only its own
|
|
39
|
+
``THEN`` starts the branch statements.
|
|
40
|
+
|
|
41
|
+
For the one genuinely ambiguous opener - ``IF`` in statement position can
|
|
42
|
+
still be the expression function when it follows ``THEN``/``ELSE`` of a
|
|
43
|
+
*CASE expression* - a bounded, nesting-aware lookahead searches for the
|
|
44
|
+
scripting condition's top-level ``THEN`` without confusing a nested CASE.
|
|
45
|
+
|
|
46
|
+
``BEGIN`` immediately followed by ``;`` or ``TRANSACTION`` is the
|
|
47
|
+
transaction-control statement, not a block.
|
|
48
|
+
|
|
49
|
+
``END`` closers are matched leniently (an ``END X`` pops the innermost
|
|
50
|
+
frame even on a type mismatch) because the splitter's only job is to find
|
|
51
|
+
top-level semicolons; leniency degrades gracefully into "statements kept
|
|
52
|
+
together", which downstream classification handles safely as an
|
|
53
|
+
``operations`` action, whereas strictness would reject convertible files.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
from typing import List, Optional, Set
|
|
59
|
+
|
|
60
|
+
from sql2sqlx.lexer import BACKTICK, EOF, IDENT, OP, Token
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RawStatement:
|
|
64
|
+
"""One top-level statement as a slice of the token stream.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
tokens: The significant tokens of the statement, **excluding** the
|
|
68
|
+
terminating semicolon.
|
|
69
|
+
start: Character offset of the first token.
|
|
70
|
+
end: Character offset one past the last token (semicolon excluded).
|
|
71
|
+
terminator_end: Offset immediately after the terminating semicolon,
|
|
72
|
+
or ``end`` when the final statement was unterminated.
|
|
73
|
+
terminated: Whether the statement was followed by ``;`` in source.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
__slots__ = ("tokens", "start", "end", "terminator_end", "terminated")
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
tokens: List[Token],
|
|
81
|
+
terminated: bool,
|
|
82
|
+
end: Optional[int] = None,
|
|
83
|
+
terminator_end: Optional[int] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Build a statement from a non-empty token slice.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
tokens: Significant tokens (no semicolon terminator).
|
|
89
|
+
terminated: True if a ``;`` followed in the source.
|
|
90
|
+
end: End of the statement body, excluding the semicolon but
|
|
91
|
+
including any whitespace/comments immediately before it.
|
|
92
|
+
terminator_end: End of the semicolon when present.
|
|
93
|
+
"""
|
|
94
|
+
self.tokens = tokens
|
|
95
|
+
self.start = tokens[0].start
|
|
96
|
+
self.end = tokens[-1].end if end is None else end
|
|
97
|
+
self.terminator_end = self.end if terminator_end is None else terminator_end
|
|
98
|
+
self.terminated = terminated
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _if_is_scripting(tokens: List[Token], i: int, limit: int = 4096) -> bool:
|
|
102
|
+
"""Decide whether ``tokens[i]`` (an ``IF``) starts a scripting IF.
|
|
103
|
+
|
|
104
|
+
Grammar facts used:
|
|
105
|
+
|
|
106
|
+
* The expression form is ``IF(<expr>, <expr>, <expr>)`` - the token
|
|
107
|
+
after ``IF`` is **always** ``(`` and the token after the balanced
|
|
108
|
+
group is **never** ``THEN``.
|
|
109
|
+
* The scripting form is ``IF <condition> THEN``. Conditions may begin
|
|
110
|
+
with a parenthesized term and continue (for example ``IF (a) AND b
|
|
111
|
+
THEN``), so the scanner searches for a top-level ``THEN`` while
|
|
112
|
+
tracking parentheses and nested CASE expressions.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
tokens: Full token list.
|
|
116
|
+
i: Index of the ``IF`` token.
|
|
117
|
+
limit: Maximum lookahead in tokens (defensive bound).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if this ``IF`` opens a scripting block frame.
|
|
121
|
+
"""
|
|
122
|
+
j = i + 1
|
|
123
|
+
if j >= len(tokens) or tokens[j].kind == EOF:
|
|
124
|
+
return False
|
|
125
|
+
if not (tokens[j].kind == OP and tokens[j].text == "("):
|
|
126
|
+
# `IF NOT EXISTS` never reaches here (handled inside CREATE/DROP,
|
|
127
|
+
# which are not statement-position IF); `IF cond THEN` it is.
|
|
128
|
+
return True
|
|
129
|
+
depth = 0
|
|
130
|
+
case_depth = 0
|
|
131
|
+
end = min(len(tokens), j + limit)
|
|
132
|
+
while j < end:
|
|
133
|
+
t = tokens[j]
|
|
134
|
+
if t.kind == EOF:
|
|
135
|
+
return False
|
|
136
|
+
if t.kind == OP:
|
|
137
|
+
if t.text == "(":
|
|
138
|
+
depth += 1
|
|
139
|
+
elif t.text == ")":
|
|
140
|
+
depth = max(0, depth - 1)
|
|
141
|
+
elif t.text == ";" and depth == 0:
|
|
142
|
+
return False
|
|
143
|
+
elif t.kind == IDENT and depth == 0:
|
|
144
|
+
up = t.upper
|
|
145
|
+
if up == "CASE":
|
|
146
|
+
case_depth += 1
|
|
147
|
+
elif up == "END" and case_depth:
|
|
148
|
+
case_depth -= 1
|
|
149
|
+
elif up == "THEN" and case_depth == 0:
|
|
150
|
+
return True
|
|
151
|
+
elif up in ("ELSE", "ELSEIF") and case_depth == 0:
|
|
152
|
+
return False
|
|
153
|
+
j += 1
|
|
154
|
+
# A very large/unfinished condition is kept together rather than split
|
|
155
|
+
# into independently executable actions.
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Two-token END closers.
|
|
160
|
+
_END_CLOSERS = frozenset({"IF", "LOOP", "WHILE", "REPEAT", "FOR", "CASE"})
|
|
161
|
+
# Statement-position block openers besides BEGIN/IF/CASE.
|
|
162
|
+
_SIMPLE_OPENERS = frozenset({"LOOP", "WHILE", "REPEAT", "FOR"})
|
|
163
|
+
# Openers whose statement body starts immediately after the keyword
|
|
164
|
+
# (WHILE/FOR bodies start at DO, IF/CASE bodies at THEN/ELSE).
|
|
165
|
+
_IMMEDIATE_BODY_OPENERS = frozenset({"LOOP", "REPEAT"})
|
|
166
|
+
# Frame marker for a CASE *expression* (``CASE ... END``); its THEN/ELSE
|
|
167
|
+
# introduce expressions, never statements.
|
|
168
|
+
_CASE_EXPR = "CASE_EXPR"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _starts_create_procedure(tokens: List[Token]) -> bool:
|
|
172
|
+
"""Whether the current statement prefix is ``CREATE ... PROCEDURE``."""
|
|
173
|
+
words = [token.upper for token in tokens if token.kind == IDENT]
|
|
174
|
+
if not words or words[0] != "CREATE":
|
|
175
|
+
return False
|
|
176
|
+
i = 1
|
|
177
|
+
if words[i : i + 2] == ["OR", "REPLACE"]:
|
|
178
|
+
i += 2
|
|
179
|
+
return i < len(words) and words[i] == "PROCEDURE"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def split_statements(
|
|
183
|
+
tokens: List[Token],
|
|
184
|
+
statement_starts_out: Optional[Set[int]] = None,
|
|
185
|
+
) -> List[RawStatement]:
|
|
186
|
+
"""Split a significant-token stream into top-level statements.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
tokens: Token stream from :func:`sql2sqlx.lexer.tokenize`
|
|
190
|
+
(must end with the synthetic :data:`~sql2sqlx.lexer.EOF`).
|
|
191
|
+
statement_starts_out: Optional set that receives the source offset
|
|
192
|
+
of every top-level or nested statement head. The offsets are
|
|
193
|
+
derived from the same block-aware state machine used for
|
|
194
|
+
splitting, so a keyword after a scripting ``THEN`` is included
|
|
195
|
+
while the same keyword after a ``CASE`` *expression* is not.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List of :class:`RawStatement`, in source order. Empty statements
|
|
199
|
+
(e.g. from ``;;`` or a trailing ``;``) are omitted.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> from sql2sqlx.lexer import tokenize
|
|
203
|
+
>>> stmts = split_statements(tokenize(
|
|
204
|
+
... "CREATE TABLE d.a AS SELECT 1; BEGIN DELETE d.a WHERE true; END;"))
|
|
205
|
+
>>> len(stmts)
|
|
206
|
+
2
|
|
207
|
+
"""
|
|
208
|
+
statements: List[RawStatement] = []
|
|
209
|
+
current: List[Token] = []
|
|
210
|
+
frames: List[str] = [] # open block frames, innermost last
|
|
211
|
+
paren_depth = 0
|
|
212
|
+
stmt_position = True # next token may begin a (sub)statement
|
|
213
|
+
|
|
214
|
+
def flush(
|
|
215
|
+
terminated: bool, end: Optional[int] = None, terminator_end: Optional[int] = None
|
|
216
|
+
) -> None:
|
|
217
|
+
if current:
|
|
218
|
+
statements.append(RawStatement(list(current), terminated, end, terminator_end))
|
|
219
|
+
current.clear()
|
|
220
|
+
|
|
221
|
+
i = 0
|
|
222
|
+
n = len(tokens)
|
|
223
|
+
while i < n:
|
|
224
|
+
tok = tokens[i]
|
|
225
|
+
if tok.kind == EOF:
|
|
226
|
+
break
|
|
227
|
+
if stmt_position and statement_starts_out is not None:
|
|
228
|
+
statement_starts_out.add(tok.start)
|
|
229
|
+
kind = tok.kind
|
|
230
|
+
if (
|
|
231
|
+
stmt_position
|
|
232
|
+
and kind in (IDENT, BACKTICK)
|
|
233
|
+
and i + 2 < n
|
|
234
|
+
and tokens[i + 1].kind == OP
|
|
235
|
+
and tokens[i + 1].text == ":"
|
|
236
|
+
and tokens[i + 2].kind == IDENT
|
|
237
|
+
and tokens[i + 2].upper in (_SIMPLE_OPENERS | {"BEGIN"})
|
|
238
|
+
):
|
|
239
|
+
# GoogleSQL labels belong to the following block/loop and do not
|
|
240
|
+
# consume statement position: ``label: BEGIN ... END label``.
|
|
241
|
+
current.append(tok)
|
|
242
|
+
current.append(tokens[i + 1])
|
|
243
|
+
i += 2
|
|
244
|
+
continue
|
|
245
|
+
if kind == OP:
|
|
246
|
+
text = tok.text
|
|
247
|
+
if text == ";":
|
|
248
|
+
if paren_depth == 0 and not frames:
|
|
249
|
+
flush(True, tok.start, tok.end)
|
|
250
|
+
stmt_position = True
|
|
251
|
+
i += 1
|
|
252
|
+
continue
|
|
253
|
+
current.append(tok)
|
|
254
|
+
stmt_position = True # statement boundary inside a block
|
|
255
|
+
i += 1
|
|
256
|
+
continue
|
|
257
|
+
if text == "(":
|
|
258
|
+
paren_depth += 1
|
|
259
|
+
elif text == ")":
|
|
260
|
+
paren_depth = max(0, paren_depth - 1)
|
|
261
|
+
current.append(tok)
|
|
262
|
+
stmt_position = False
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
if kind == IDENT:
|
|
267
|
+
up = tok.upper
|
|
268
|
+
if up == "END":
|
|
269
|
+
nxt: Optional[Token] = tokens[i + 1] if i + 1 < n else None
|
|
270
|
+
if (
|
|
271
|
+
nxt is not None
|
|
272
|
+
and nxt.kind == IDENT
|
|
273
|
+
and nxt.upper in _END_CLOSERS
|
|
274
|
+
and frames
|
|
275
|
+
and frames[-1] == nxt.upper
|
|
276
|
+
):
|
|
277
|
+
frames.pop()
|
|
278
|
+
current.append(tok)
|
|
279
|
+
current.append(nxt)
|
|
280
|
+
stmt_position = False
|
|
281
|
+
i += 2
|
|
282
|
+
continue
|
|
283
|
+
if frames:
|
|
284
|
+
# Plain END (or lenient mismatch): pop innermost.
|
|
285
|
+
frames.pop()
|
|
286
|
+
current.append(tok)
|
|
287
|
+
stmt_position = False
|
|
288
|
+
i += 1
|
|
289
|
+
continue
|
|
290
|
+
if up == "CASE":
|
|
291
|
+
# A CASE beginning a (sub)statement is the scripting form
|
|
292
|
+
# (``CASE ... END CASE``); anywhere else it is an expression.
|
|
293
|
+
frames.append("CASE" if stmt_position else _CASE_EXPR)
|
|
294
|
+
current.append(tok)
|
|
295
|
+
stmt_position = False
|
|
296
|
+
i += 1
|
|
297
|
+
continue
|
|
298
|
+
if stmt_position:
|
|
299
|
+
if up == "BEGIN":
|
|
300
|
+
nxt = tokens[i + 1] if i + 1 < n else None
|
|
301
|
+
is_txn = (
|
|
302
|
+
nxt is None
|
|
303
|
+
or nxt.kind == EOF
|
|
304
|
+
or (nxt.kind == OP and nxt.text == ";")
|
|
305
|
+
or (nxt.kind == IDENT and nxt.upper == "TRANSACTION")
|
|
306
|
+
)
|
|
307
|
+
if not is_txn:
|
|
308
|
+
frames.append("BEGIN")
|
|
309
|
+
current.append(tok)
|
|
310
|
+
stmt_position = True # first inner statement follows
|
|
311
|
+
i += 1
|
|
312
|
+
continue
|
|
313
|
+
elif up == "IF" and _if_is_scripting(tokens, i):
|
|
314
|
+
frames.append("IF")
|
|
315
|
+
current.append(tok)
|
|
316
|
+
stmt_position = False
|
|
317
|
+
i += 1
|
|
318
|
+
continue
|
|
319
|
+
elif up in _SIMPLE_OPENERS:
|
|
320
|
+
frames.append(up)
|
|
321
|
+
current.append(tok)
|
|
322
|
+
# LOOP and REPEAT bodies start immediately after the
|
|
323
|
+
# keyword; WHILE/FOR bodies start at their DO.
|
|
324
|
+
stmt_position = up in _IMMEDIATE_BODY_OPENERS
|
|
325
|
+
i += 1
|
|
326
|
+
continue
|
|
327
|
+
elif up == "BEGIN" and not frames and _starts_create_procedure(current):
|
|
328
|
+
# A SQL stored-procedure body begins after its signature, so
|
|
329
|
+
# BEGIN is not in ordinary statement position.
|
|
330
|
+
frames.append("BEGIN")
|
|
331
|
+
current.append(tok)
|
|
332
|
+
stmt_position = True
|
|
333
|
+
i += 1
|
|
334
|
+
continue
|
|
335
|
+
if up in ("THEN", "ELSE", "DO"):
|
|
336
|
+
# Statements follow only inside a scripting frame (IF,
|
|
337
|
+
# scripting CASE, WHILE/FOR, BEGIN exception handlers). In
|
|
338
|
+
# a CASE *expression* - or with no frame open at all, as in
|
|
339
|
+
# ``MERGE ... WHEN MATCHED THEN UPDATE`` - an expression or
|
|
340
|
+
# clause keyword follows instead, and granting statement
|
|
341
|
+
# position would let a column named ``loop``/``begin`` open
|
|
342
|
+
# a bogus block frame. ``ELSEIF`` is followed by a
|
|
343
|
+
# condition, so it grants nothing; its own THEN does.
|
|
344
|
+
current.append(tok)
|
|
345
|
+
stmt_position = bool(frames) and frames[-1] != _CASE_EXPR
|
|
346
|
+
i += 1
|
|
347
|
+
continue
|
|
348
|
+
current.append(tok)
|
|
349
|
+
stmt_position = False
|
|
350
|
+
i += 1
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# STRING / NUMBER / PARAM / BACKTICK
|
|
354
|
+
current.append(tok)
|
|
355
|
+
stmt_position = False
|
|
356
|
+
i += 1
|
|
357
|
+
|
|
358
|
+
eof_pos = tokens[-1].start if tokens and tokens[-1].kind == EOF else None
|
|
359
|
+
flush(False, eof_pos, eof_pos)
|
|
360
|
+
return statements
|
sql2sqlx/version.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
"""Single source of truth for the package version.
|
|
8
|
+
|
|
9
|
+
Kept in its own module so every other module (including
|
|
10
|
+
:mod:`sql2sqlx.converter`, which stamps generated files) can import it
|
|
11
|
+
without circular-import risk, and so packaging tools can read it
|
|
12
|
+
statically.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|