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/lexer.py ADDED
@@ -0,0 +1,494 @@
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
+ """A precise, linear-time lexer for GoogleSQL (BigQuery Standard SQL).
8
+
9
+ This module is the correctness foundation of sql2sqlx: every later stage
10
+ (statement splitting, classification, reference rewriting) operates on the
11
+ token stream produced here, so semicolons, keywords and table names that
12
+ appear *inside* strings or comments can never be misinterpreted.
13
+
14
+ The lexer recognizes, per the GoogleSQL lexical specification:
15
+
16
+ * line comments (``-- ...`` and ``# ...``);
17
+ * block comments (``/* ... */``, **non-nested**, exactly as GoogleSQL
18
+ defines them - the comment ends at the first ``*/``);
19
+ * single- and double-quoted string literals with backslash escape
20
+ sequences (``'it\\'s'``);
21
+ * triple-quoted string literals (three single or double quotes) which
22
+ may span lines and contain quotes;
23
+ * raw and bytes literal prefixes in any valid combination and case
24
+ (``r'...'``, ``B\"...\"``, ``rb'''...'''``, ``bR\"...\"``, ...). In raw
25
+ literals escape sequences are not *interpreted* (the backslash stays in
26
+ the value), but a backslash still consumes the character after it, so
27
+ ``r'a\\'b'`` is one literal and a raw literal can never end in an odd
28
+ number of backslashes - exactly GoogleSQL's rules;
29
+ * backtick-quoted identifiers, including escape sequences and embedded
30
+ dots (``project.dataset.table`` wrapped in backticks);
31
+ * numeric literals (integers, decimals, exponents);
32
+ * named and positional query parameters (``@param``, ``?``) and system
33
+ variables (``@@var``);
34
+ * identifiers/keywords and all GoogleSQL operators and punctuation.
35
+
36
+ Design notes
37
+ ------------
38
+ The scanner is a single compiled alternation executed by CPython's C
39
+ regex engine, which gives 20-60 MB/s throughput while remaining exactly
40
+ character-accurate. Every alternative in the pattern is written so that
41
+ matching is strictly linear (no ambiguous nested quantifiers, hence no
42
+ catastrophic backtracking). Anything the master pattern cannot match is
43
+ diagnosed by a small fallback that raises :class:`~sql2sqlx.errors.LexError`
44
+ with an exact line/column.
45
+
46
+ Tokens store ``(kind, text, start, end)`` where ``start``/``end`` are
47
+ character offsets into the original text; downstream stages rewrite SQL
48
+ by *span edits* on the original string, guaranteeing that untouched SQL
49
+ is preserved character-for-character after decoding.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import bisect
55
+ import re
56
+ from typing import List, Optional, Tuple
57
+
58
+ from sql2sqlx.errors import LexError
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Token kinds (module-level string constants; cheap identity comparisons).
62
+ # ---------------------------------------------------------------------------
63
+
64
+ IDENT = "IDENT" #: unquoted identifier or keyword
65
+ BACKTICK = "BACKTICK" #: backtick-quoted identifier, text includes backticks
66
+ STRING = "STRING" #: any string/bytes literal, text includes quotes/prefix
67
+ NUMBER = "NUMBER" #: numeric literal
68
+ PARAM = "PARAM" #: @named, @@system or ? positional parameter
69
+ OP = "OP" #: operator or punctuation (one token per symbol)
70
+ COMMENT = "COMMENT" #: line or block comment (excluded from significant stream)
71
+ EOF = "EOF" #: synthetic end-of-input marker
72
+
73
+
74
+ class Token:
75
+ """A single lexical token.
76
+
77
+ Attributes:
78
+ kind: One of :data:`IDENT`, :data:`BACKTICK`, :data:`STRING`,
79
+ :data:`NUMBER`, :data:`PARAM`, :data:`OP`, :data:`COMMENT`,
80
+ :data:`EOF`.
81
+ text: The exact source text of the token (including quotes,
82
+ prefixes and backticks where applicable).
83
+ start: Character offset of the first character in the source.
84
+ end: Character offset one past the last character in the source.
85
+ """
86
+
87
+ __slots__ = ("kind", "text", "start", "end")
88
+
89
+ def __init__(self, kind: str, text: str, start: int, end: int) -> None:
90
+ """Initialize a token; see class attribute docs for parameters."""
91
+ self.kind = kind
92
+ self.text = text
93
+ self.start = start
94
+ self.end = end
95
+
96
+ @property
97
+ def upper(self) -> str:
98
+ """Uppercased token text, used for case-insensitive keyword tests."""
99
+ return self.text.upper()
100
+
101
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
102
+ return f"Token({self.kind}, {self.text!r}, {self.start}:{self.end})"
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Master pattern.
107
+ #
108
+ # Order matters:
109
+ # * comments before operators (so `--` is not two minus tokens);
110
+ # * string literals (with optional r/b prefixes) before IDENT (so the
111
+ # prefix letter is consumed as part of the literal);
112
+ # * triple-quoted before single-quoted (longest match);
113
+ # * NUMBER before OP (so `.5` is one number, and `1.5e-3` is one token).
114
+ #
115
+ # Escape pairs are recognized broadly by the master scanner, then validated
116
+ # against GoogleSQL's exact escape table. This produces precise errors for
117
+ # forbidden backslash-newline pairs and incomplete numeric escapes. Raw
118
+ # literal bodies also consume backslash+character pairs - the escape is
119
+ # never *interpreted* (the backslash stays in the value), but per GoogleSQL
120
+ # it still cannot terminate the literal, so r'a\'b' is one literal and
121
+ # r'a\' is unterminated.
122
+ # ---------------------------------------------------------------------------
123
+
124
+ _RAW_PREFIX = r"(?:[rR][bB]?|[bB][rR])" # any prefix combination containing r/R
125
+ _BYTES_ONLY = r"[bB]?" # optional b/B (escapes still active)
126
+
127
+ _TOKEN_RE = re.compile(
128
+ r"""
129
+ (?P<WS>\s+)
130
+ | (?P<COMMENT>--[^\r\n]* | \#[^\r\n]* | /\*(?:[^*]|\*(?!/))*\*/)
131
+ | (?P<STRING>
132
+ # raw (and raw-bytes) triple-quoted: escapes stay uninterpreted
133
+ # but still consume the next character
134
+ {raw}(?:'''(?:[^'\\]|\\[\s\S]|'(?!''))*''' | \"\"\"(?:[^"\\]|\\[\s\S]|"(?!""))*\"\"\")
135
+ # raw (and raw-bytes) single-line quoted
136
+ | {raw}(?:'(?:[^'\\\r\n]|\\[^\r\n])*' | "(?:[^"\\\r\n]|\\[^\r\n])*")
137
+ # (bytes-)triple-quoted with escapes
138
+ | {b}(?:'''(?:[^'\\]|\\[\s\S]|'(?!''))*''' | \"\"\"(?:[^"\\]|\\[\s\S]|"(?!""))*\"\"\")
139
+ # (bytes-)single-line quoted with escapes
140
+ | {b}(?:'(?:[^'\\\r\n]|\\[\s\S])*' | "(?:[^"\\\r\n]|\\[\s\S])*")
141
+ )
142
+ | (?P<BACKTICK>`(?:[^`\\\r\n]|\\[\s\S])*`)
143
+ | (?P<IDENT>[A-Za-z_][A-Za-z_0-9]*)
144
+ | (?P<NUMBER>0[xX][0-9A-Fa-f]+ |
145
+ \d+\.(?:\d+(?:[eE][+-]?\d+)? | [eE][+-]?\d+ |
146
+ (?![A-Za-z_])) |
147
+ \.\d+(?:[eE][+-]?\d+)? |
148
+ \d+(?:[eE][+-]?\d+)?)
149
+ | (?P<PARAM>@@[A-Za-z_][A-Za-z_0-9.]* |
150
+ @`(?:[^`\\\r\n]|\\[\s\S])*` |
151
+ @[A-Za-z_][A-Za-z_0-9]* | \?)
152
+ | (?P<OP>\|> | <=> | <> | <= | >= | != | \|\| | << | >> | => | ->>? |
153
+ [+\-*/%,;()\[\]{{}}<>=.:|&^~@])
154
+ """.format(raw=_RAW_PREFIX, b=_BYTES_ONLY),
155
+ re.VERBOSE,
156
+ )
157
+
158
+ # Group index lookup computed once: groupindex maps name -> group number.
159
+ _GROUP_KIND: List[str] = [""] * (_TOKEN_RE.groups + 1)
160
+ for _name, _num in _TOKEN_RE.groupindex.items():
161
+ _GROUP_KIND[_num] = _name
162
+
163
+
164
+ _NEWLINE_RE = re.compile(r"\r\n?|\n")
165
+
166
+
167
+ def _line_col(text: str, pos: int) -> Tuple[int, int]:
168
+ """Compute the 1-based ``(line, column)`` of a character offset.
169
+
170
+ Args:
171
+ text: The full source text.
172
+ pos: Character offset into ``text``.
173
+
174
+ Returns:
175
+ Tuple of 1-based line and column numbers.
176
+ """
177
+ line = 1
178
+ line_start = 0
179
+ for match in _NEWLINE_RE.finditer(text, 0, pos):
180
+ line += 1
181
+ line_start = match.end()
182
+ return line, pos - line_start + 1
183
+
184
+
185
+ _LITERAL_HEAD_RE = re.compile(r"([rRbB]{0,2})('''|\"\"\"|'|\")")
186
+ _SIMPLE_ESCAPES = {
187
+ "a": "\a",
188
+ "b": "\b",
189
+ "f": "\f",
190
+ "n": "\n",
191
+ "r": "\r",
192
+ "t": "\t",
193
+ "v": "\v",
194
+ "\\": "\\",
195
+ "?": "?",
196
+ '"': '"',
197
+ "'": "'",
198
+ "`": "`",
199
+ }
200
+
201
+
202
+ def _escape_value(body: str, i: int, bytes_literal: bool = False) -> Tuple[str, int]:
203
+ """Decode one GoogleSQL escape beginning at ``body[i]``.
204
+
205
+ Returns the decoded character and the first index after the escape.
206
+ ``ValueError`` means the escape is lexically invalid.
207
+ """
208
+ if i + 1 >= len(body) or body[i] != "\\":
209
+ raise ValueError("trailing backslash")
210
+ nxt = body[i + 1]
211
+ if nxt in "\r\n":
212
+ raise ValueError("a backslash immediately before a newline is not allowed")
213
+ if nxt in _SIMPLE_ESCAPES:
214
+ return _SIMPLE_ESCAPES[nxt], i + 2
215
+ if nxt in "01234567":
216
+ digits = body[i + 1 : i + 4]
217
+ if len(digits) != 3 or any(ch not in "01234567" for ch in digits):
218
+ raise ValueError("octal escapes require exactly three digits")
219
+ value = int(digits, 8)
220
+ if bytes_literal and value > 0xFF:
221
+ raise ValueError("a bytes escape must be in the range 0x00-0xFF")
222
+ return chr(value), i + 4
223
+ if nxt in "xX":
224
+ digits = body[i + 2 : i + 4]
225
+ if len(digits) != 2 or any(ch not in "0123456789abcdefABCDEF" for ch in digits):
226
+ raise ValueError("hex escapes require exactly two digits")
227
+ return chr(int(digits, 16)), i + 4
228
+ if nxt in "uU":
229
+ if bytes_literal:
230
+ raise ValueError("Unicode escapes are not allowed in bytes literals")
231
+ width = 4 if nxt == "u" else 8
232
+ digits = body[i + 2 : i + 2 + width]
233
+ if len(digits) != width or any(ch not in "0123456789abcdefABCDEF" for ch in digits):
234
+ raise ValueError(f"Unicode escapes require exactly {width} hex digits")
235
+ value = int(digits, 16)
236
+ if 0xD800 <= value <= 0xDFFF or value > 0x10FFFF:
237
+ raise ValueError("Unicode escape is not a valid scalar value")
238
+ return chr(value), i + 2 + width
239
+ raise ValueError(f"invalid escape sequence \\{nxt}")
240
+
241
+
242
+ def _validate_escaped_body(
243
+ body: str, source: str, body_start: int, bytes_literal: bool = False
244
+ ) -> None:
245
+ """Validate every escape in a non-raw literal or quoted identifier."""
246
+ i = 0
247
+ while i < len(body):
248
+ if body[i] != "\\":
249
+ i += 1
250
+ continue
251
+ try:
252
+ _, i = _escape_value(body, i, bytes_literal)
253
+ except ValueError as exc:
254
+ line, col = _line_col(source, body_start + i)
255
+ raise LexError(str(exc), line, col) from None
256
+
257
+
258
+ def _validate_string_literal(literal: str, source: str, start: int) -> None:
259
+ """Enforce GoogleSQL's literal-prefix and backslash restrictions."""
260
+ m = _LITERAL_HEAD_RE.match(literal)
261
+ if m is None: # The master pattern guarantees this shape.
262
+ return
263
+ prefix = m.group(1).lower()
264
+ quote = m.group(2)
265
+ body = literal[m.end() : -len(quote)]
266
+ body_start = start + m.end()
267
+ if "r" in prefix:
268
+ trailing = len(body) - len(body.rstrip("\\"))
269
+ if trailing % 2:
270
+ line, col = _line_col(source, body_start + len(body) - trailing)
271
+ raise LexError(
272
+ "Raw string cannot end with an odd number of backslashes",
273
+ line,
274
+ col,
275
+ )
276
+ return
277
+ _validate_escaped_body(body, source, body_start, bytes_literal="b" in prefix)
278
+
279
+
280
+ def _validate_backtick_identifier(token_text: str, source: str, start: int) -> None:
281
+ """Validate escape sequences in a backtick-quoted identifier."""
282
+ body = token_text[1:-1]
283
+ if not body:
284
+ line, col = _line_col(source, start)
285
+ raise LexError("Quoted identifier cannot be empty", line, col)
286
+ _validate_escaped_body(body, source, start + 1)
287
+
288
+
289
+ def _diagnose(text: str, pos: int) -> "LexError":
290
+ """Build a precise :class:`LexError` for an unmatchable position.
291
+
292
+ The master pattern fails only for a handful of well-defined reasons;
293
+ this helper distinguishes them so users get an actionable message.
294
+
295
+ Args:
296
+ text: The full source text.
297
+ pos: Offset at which the master pattern failed to match.
298
+
299
+ Returns:
300
+ A ready-to-raise :class:`LexError`.
301
+ """
302
+ line, col = _line_col(text, pos)
303
+ ch = text[pos]
304
+ rest = text[pos : pos + 2]
305
+ if rest == "/*":
306
+ return LexError("Unterminated block comment", line, col)
307
+ if ch == "`":
308
+ return LexError("Unterminated backtick-quoted identifier", line, col)
309
+ if ch in "'\"" or (ch in "rRbB" and re.match(r"[rRbB]{1,2}['\"]", text[pos : pos + 3] or "")):
310
+ return LexError("Unterminated string literal", line, col)
311
+ return LexError(f"Unexpected character {ch!r}", line, col)
312
+
313
+
314
+ def tokenize(
315
+ text: str,
316
+ keep_comments: bool = False,
317
+ comment_spans_out: Optional[List[Tuple[int, int]]] = None,
318
+ ) -> List[Token]:
319
+ """Tokenize GoogleSQL text into a list of :class:`Token` objects.
320
+
321
+ Whitespace is always dropped. Comments are dropped unless
322
+ ``keep_comments`` is true (statement splitting only needs significant
323
+ tokens); their spans can be captured in the same pass via
324
+ ``comment_spans_out``, which the conversion pipeline uses so files
325
+ are lexed exactly once.
326
+
327
+ Args:
328
+ text: SQL source text.
329
+ keep_comments: If ``True``, ``COMMENT`` tokens are included in the
330
+ returned stream (in source order).
331
+ comment_spans_out: Optional list that receives every comment's
332
+ ``(start, end)`` span, regardless of ``keep_comments``.
333
+
334
+ Returns:
335
+ List of significant tokens in source order, terminated by a
336
+ synthetic :data:`EOF` token whose span is ``(len(text), len(text))``.
337
+
338
+ Raises:
339
+ LexError: If the text contains an unterminated string, unterminated
340
+ backtick identifier, unterminated block comment, or a character
341
+ that is not valid in GoogleSQL.
342
+
343
+ Example:
344
+ >>> [t.text for t in tokenize("SELECT 'a;b' -- c\\n;")][:-1]
345
+ ["SELECT", "'a;b'", ';']
346
+ """
347
+ tokens: List[Token] = []
348
+ append = tokens.append
349
+ match = _TOKEN_RE.match
350
+ pos = 0
351
+ n = len(text)
352
+ kinds = _GROUP_KIND
353
+ while pos < n:
354
+ m = match(text, pos)
355
+ if m is None:
356
+ raise _diagnose(text, pos)
357
+ # A lone "/" where "/*" begins means the COMMENT alternative could
358
+ # not match, i.e. the block comment is unterminated.
359
+ if m.group() == "/" and text.startswith("/*", pos):
360
+ raise _diagnose(text, pos)
361
+ end = m.end()
362
+ kind = kinds[m.lastindex] # type: ignore[index]
363
+ if kind == "WS":
364
+ pos = end
365
+ continue
366
+ if kind == "COMMENT":
367
+ if comment_spans_out is not None:
368
+ comment_spans_out.append((pos, end))
369
+ if not keep_comments:
370
+ pos = end
371
+ continue
372
+ elif kind == "STRING":
373
+ _validate_string_literal(m.group(), text, pos)
374
+ elif kind == "BACKTICK":
375
+ _validate_backtick_identifier(m.group(), text, pos)
376
+ elif kind == "PARAM" and m.group().startswith("@`"):
377
+ _validate_backtick_identifier(m.group()[1:], text, pos + 1)
378
+ append(Token(kind, m.group(), pos, end))
379
+ pos = end
380
+ append(Token(EOF, "", n, n))
381
+ return tokens
382
+
383
+
384
+ def comment_spans(text: str) -> List[Tuple[int, int]]:
385
+ """Return the ``(start, end)`` spans of every comment in ``text``.
386
+
387
+ Used by the emitter to carry a statement's *leading* comments into the
388
+ generated ``.sqlx`` file. Runs the same master pattern, so a ``--``
389
+ inside a string literal is never mistaken for a comment.
390
+
391
+ Args:
392
+ text: SQL source text (must be lexable).
393
+
394
+ Returns:
395
+ List of half-open character spans, in source order.
396
+
397
+ Raises:
398
+ LexError: Propagated from :func:`tokenize` failure conditions.
399
+ """
400
+ spans: List[Tuple[int, int]] = []
401
+ match = _TOKEN_RE.match
402
+ pos = 0
403
+ n = len(text)
404
+ kinds = _GROUP_KIND
405
+ while pos < n:
406
+ m = match(text, pos)
407
+ if m is None:
408
+ raise _diagnose(text, pos)
409
+ if m.group() == "/" and text.startswith("/*", pos):
410
+ raise _diagnose(text, pos)
411
+ kind = kinds[m.lastindex] # type: ignore[index]
412
+ if kind == "STRING":
413
+ _validate_string_literal(m.group(), text, pos)
414
+ elif kind == "BACKTICK":
415
+ _validate_backtick_identifier(m.group(), text, pos)
416
+ elif kind == "PARAM" and m.group().startswith("@`"):
417
+ _validate_backtick_identifier(m.group()[1:], text, pos + 1)
418
+ if kind == "COMMENT":
419
+ spans.append((pos, m.end()))
420
+ pos = m.end()
421
+ return spans
422
+
423
+
424
+ class LineIndex:
425
+ """Fast character-offset to ``(line, column)`` mapping for one text.
426
+
427
+ Builds the newline offset table once (O(n)) and answers each query in
428
+ O(log n) via binary search. Used to attach accurate source locations
429
+ to report warnings without rescanning the file per warning.
430
+ """
431
+
432
+ __slots__ = ("_starts",)
433
+
434
+ def __init__(self, text: str) -> None:
435
+ """Index ``text``.
436
+
437
+ Args:
438
+ text: The source text to index.
439
+ """
440
+ self._starts = [0] + [match.end() for match in _NEWLINE_RE.finditer(text)]
441
+
442
+ def locate(self, pos: int) -> Tuple[int, int]:
443
+ """Return the 1-based ``(line, column)`` for character offset ``pos``.
444
+
445
+ Args:
446
+ pos: Character offset into the indexed text.
447
+
448
+ Returns:
449
+ Tuple of 1-based line and column numbers.
450
+ """
451
+ i = bisect.bisect_right(self._starts, pos) - 1
452
+ return i + 1, pos - self._starts[i] + 1
453
+
454
+
455
+ def unquote_identifier(text: str) -> str:
456
+ """Return the logical name of an identifier token's text.
457
+
458
+ For backtick-quoted identifiers the surrounding backticks are removed
459
+ and GoogleSQL escape sequences are decoded (``\\``` -> `` ` ``,
460
+ ``\\\\`` -> ``\\``, plus the standard C-style escapes). Unquoted
461
+ identifiers are returned unchanged.
462
+
463
+ Args:
464
+ text: Raw token text, e.g. ``"`my table`"`` or ``"orders"``.
465
+
466
+ Returns:
467
+ The decoded identifier, e.g. ``"my table"`` or ``"orders"``.
468
+ """
469
+ if not (len(text) >= 2 and text[0] == "`" and text[-1] == "`"):
470
+ return text
471
+ body = text[1:-1]
472
+ if "\\" not in body:
473
+ return body
474
+ out: List[str] = []
475
+ i = 0
476
+ while i < len(body):
477
+ if body[i] != "\\":
478
+ out.append(body[i])
479
+ i += 1
480
+ continue
481
+ try:
482
+ decoded, i = _escape_value(body, i)
483
+ out.append(decoded)
484
+ except ValueError:
485
+ # ``unquote_identifier`` is also a public convenience helper;
486
+ # keep it total for arbitrary caller-provided text. Tokens
487
+ # produced by ``tokenize`` have already been strictly validated.
488
+ if i + 1 < len(body):
489
+ out.append(body[i + 1])
490
+ i += 2
491
+ else:
492
+ out.append("\\")
493
+ i += 1
494
+ return "".join(out)