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