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/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"