bqsqlparse 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.
- bqsqlparse/__init__.py +62 -0
- bqsqlparse/errors.py +30 -0
- bqsqlparse/functions.py +159 -0
- bqsqlparse/lexer.py +242 -0
- bqsqlparse/lineage.py +902 -0
- bqsqlparse/nodes.py +728 -0
- bqsqlparse/parser.py +1715 -0
- bqsqlparse/py.typed +0 -0
- bqsqlparse/unparse.py +766 -0
- bqsqlparse-0.1.0.dist-info/METADATA +722 -0
- bqsqlparse-0.1.0.dist-info/RECORD +13 -0
- bqsqlparse-0.1.0.dist-info/WHEEL +4 -0
- bqsqlparse-0.1.0.dist-info/licenses/LICENSE +21 -0
bqsqlparse/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""bqsqlparse — a pure-Python BigQuery (GoogleSQL) parser with
|
|
2
|
+
column-level lineage extraction.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
import bqsqlparse
|
|
7
|
+
|
|
8
|
+
ast = bqsqlparse.parse_one("SELECT a, SUM(b) AS total FROM ds.t GROUP BY a")
|
|
9
|
+
print(ast.sql())
|
|
10
|
+
|
|
11
|
+
lineage = bqsqlparse.extract_lineage(
|
|
12
|
+
"CREATE TABLE ds.out AS SELECT o.id, SUM(o.amount) t FROM ds.orders o GROUP BY 1"
|
|
13
|
+
)
|
|
14
|
+
print(lineage.to_json())
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from . import nodes
|
|
18
|
+
from .errors import (
|
|
19
|
+
BQSQLError, LexError, LineageError, ParseError, UnsupportedStatementError,
|
|
20
|
+
)
|
|
21
|
+
from .functions import (
|
|
22
|
+
AGGREGATE_FUNCTIONS, ALL_FUNCTIONS, NAVIGATION_FUNCTIONS,
|
|
23
|
+
SCALAR_FUNCTIONS, is_aggregate, is_known_function, is_navigation,
|
|
24
|
+
)
|
|
25
|
+
from .lexer import Token, tokenize
|
|
26
|
+
from .lineage import (
|
|
27
|
+
ColumnLineage, ColumnUsage, LineageResult, SourceColumn, extract_lineage,
|
|
28
|
+
extract_script_lineage,
|
|
29
|
+
)
|
|
30
|
+
from .parser import Parser, parse, parse_one
|
|
31
|
+
from .unparse import to_sql
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"parse",
|
|
37
|
+
"parse_one",
|
|
38
|
+
"Parser",
|
|
39
|
+
"tokenize",
|
|
40
|
+
"Token",
|
|
41
|
+
"to_sql",
|
|
42
|
+
"nodes",
|
|
43
|
+
"extract_lineage",
|
|
44
|
+
"extract_script_lineage",
|
|
45
|
+
"LineageResult",
|
|
46
|
+
"ColumnLineage",
|
|
47
|
+
"ColumnUsage",
|
|
48
|
+
"SourceColumn",
|
|
49
|
+
"BQSQLError",
|
|
50
|
+
"LexError",
|
|
51
|
+
"ParseError",
|
|
52
|
+
"UnsupportedStatementError",
|
|
53
|
+
"LineageError",
|
|
54
|
+
"AGGREGATE_FUNCTIONS",
|
|
55
|
+
"NAVIGATION_FUNCTIONS",
|
|
56
|
+
"SCALAR_FUNCTIONS",
|
|
57
|
+
"ALL_FUNCTIONS",
|
|
58
|
+
"is_aggregate",
|
|
59
|
+
"is_navigation",
|
|
60
|
+
"is_known_function",
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
bqsqlparse/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exception types for bqsqlparse."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BQSQLError(Exception):
|
|
7
|
+
"""Base class for all bqsqlparse errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, line: Optional[int] = None, col: Optional[int] = None):
|
|
10
|
+
self.message = message
|
|
11
|
+
self.line = line
|
|
12
|
+
self.col = col
|
|
13
|
+
loc = f" at line {line}, column {col}" if line is not None else ""
|
|
14
|
+
super().__init__(f"{message}{loc}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LexError(BQSQLError):
|
|
18
|
+
"""Raised when the tokenizer encounters invalid input."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ParseError(BQSQLError):
|
|
22
|
+
"""Raised when the parser encounters invalid or unexpected syntax."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnsupportedStatementError(ParseError):
|
|
26
|
+
"""Raised for statement types the parser does not support."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LineageError(BQSQLError):
|
|
30
|
+
"""Raised when lineage extraction cannot proceed."""
|
bqsqlparse/functions.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Registry of BigQuery (GoogleSQL) built-in functions.
|
|
2
|
+
|
|
3
|
+
Used by the lineage engine to classify transformations (aggregation vs.
|
|
4
|
+
window vs. scalar expression) and exposed for consumers who want to check
|
|
5
|
+
whether a function name is a known BigQuery built-in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, FrozenSet
|
|
9
|
+
|
|
10
|
+
AGGREGATE_FUNCTIONS: FrozenSet[str] = frozenset({
|
|
11
|
+
"ANY_VALUE", "ARRAY_AGG", "ARRAY_CONCAT_AGG", "AVG", "BIT_AND", "BIT_OR",
|
|
12
|
+
"BIT_XOR", "COUNT", "COUNTIF", "GROUPING", "LOGICAL_AND", "LOGICAL_OR",
|
|
13
|
+
"MAX", "MAX_BY", "MIN", "MIN_BY", "STRING_AGG", "SUM", "CORR",
|
|
14
|
+
"COVAR_POP", "COVAR_SAMP", "STDDEV", "STDDEV_POP", "STDDEV_SAMP",
|
|
15
|
+
"VAR_POP", "VAR_SAMP", "VARIANCE",
|
|
16
|
+
"APPROX_COUNT_DISTINCT", "APPROX_QUANTILES", "APPROX_TOP_COUNT",
|
|
17
|
+
"APPROX_TOP_SUM",
|
|
18
|
+
"HLL_COUNT.INIT", "HLL_COUNT.MERGE", "HLL_COUNT.MERGE_PARTIAL",
|
|
19
|
+
"HLL_COUNT.EXTRACT",
|
|
20
|
+
"KLL_QUANTILES.INIT_INT64", "KLL_QUANTILES.INIT_FLOAT64",
|
|
21
|
+
"KLL_QUANTILES.MERGE_PARTIAL", "KLL_QUANTILES.MERGE",
|
|
22
|
+
"KLL_QUANTILES.MERGE_POINT", "KLL_QUANTILES.EXTRACT",
|
|
23
|
+
"KLL_QUANTILES.EXTRACT_POINT",
|
|
24
|
+
"PERCENTILE_CONT", "PERCENTILE_DISC", "ST_EXTENT", "ST_UNION_AGG",
|
|
25
|
+
"ST_CENTROID_AGG",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
NAVIGATION_FUNCTIONS: FrozenSet[str] = frozenset({
|
|
29
|
+
"RANK", "DENSE_RANK", "ROW_NUMBER", "NTILE", "CUME_DIST", "PERCENT_RANK",
|
|
30
|
+
"LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTH_VALUE",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
SCALAR_FUNCTIONS: Dict[str, FrozenSet[str]] = {
|
|
34
|
+
"string": frozenset({
|
|
35
|
+
"ASCII", "BYTE_LENGTH", "CHAR_LENGTH", "CHARACTER_LENGTH", "CHR",
|
|
36
|
+
"CODE_POINTS_TO_BYTES", "CODE_POINTS_TO_STRING", "COLLATE", "CONCAT",
|
|
37
|
+
"CONTAINS_SUBSTR", "EDIT_DISTANCE", "ENDS_WITH", "FORMAT",
|
|
38
|
+
"FROM_BASE32", "FROM_BASE64", "FROM_HEX", "INITCAP", "INSTR", "LEFT",
|
|
39
|
+
"LENGTH", "LOWER", "LPAD", "LTRIM", "NORMALIZE",
|
|
40
|
+
"NORMALIZE_AND_CASEFOLD", "OCTET_LENGTH", "REGEXP_CONTAINS",
|
|
41
|
+
"REGEXP_EXTRACT", "REGEXP_EXTRACT_ALL", "REGEXP_INSTR",
|
|
42
|
+
"REGEXP_REPLACE", "REGEXP_SUBSTR", "REPEAT", "REPLACE", "REVERSE",
|
|
43
|
+
"RIGHT", "RPAD", "RTRIM", "SAFE_CONVERT_BYTES_TO_STRING", "SOUNDEX",
|
|
44
|
+
"SPLIT", "STARTS_WITH", "STRPOS", "SUBSTR", "SUBSTRING", "TO_BASE32",
|
|
45
|
+
"TO_BASE64", "TO_CODE_POINTS", "TO_HEX", "TRANSLATE", "TRIM",
|
|
46
|
+
"UNICODE", "UPPER",
|
|
47
|
+
}),
|
|
48
|
+
"math": frozenset({
|
|
49
|
+
"ABS", "ACOS", "ACOSH", "ASIN", "ASINH", "ATAN", "ATAN2", "ATANH",
|
|
50
|
+
"CBRT", "CEIL", "CEILING", "COS", "COSH", "COSINE_DISTANCE", "COT",
|
|
51
|
+
"COTH", "CSC", "CSCH", "DIV", "EUCLIDEAN_DISTANCE", "EXP", "FLOOR",
|
|
52
|
+
"GREATEST", "IEEE_DIVIDE", "IS_INF", "IS_NAN", "LEAST", "LN", "LOG",
|
|
53
|
+
"LOG10", "MOD", "POW", "POWER", "RAND", "RANGE_BUCKET", "ROUND",
|
|
54
|
+
"SAFE_ADD", "SAFE_DIVIDE", "SAFE_MULTIPLY", "SAFE_NEGATE",
|
|
55
|
+
"SAFE_SUBTRACT", "SEC", "SECH", "SIGN", "SIN", "SINH", "SQRT", "TAN",
|
|
56
|
+
"TANH", "TRUNC",
|
|
57
|
+
}),
|
|
58
|
+
"date_time": frozenset({
|
|
59
|
+
"CURRENT_DATE", "CURRENT_DATETIME", "CURRENT_TIME",
|
|
60
|
+
"CURRENT_TIMESTAMP", "DATE", "DATE_ADD", "DATE_BUCKET", "DATE_DIFF",
|
|
61
|
+
"DATE_FROM_UNIX_DATE", "DATE_SUB", "DATE_TRUNC", "DATETIME",
|
|
62
|
+
"DATETIME_ADD", "DATETIME_BUCKET", "DATETIME_DIFF", "DATETIME_SUB",
|
|
63
|
+
"DATETIME_TRUNC", "FORMAT_DATE", "FORMAT_DATETIME", "FORMAT_TIME",
|
|
64
|
+
"FORMAT_TIMESTAMP", "GENERATE_DATE_ARRAY", "GENERATE_TIMESTAMP_ARRAY",
|
|
65
|
+
"JUSTIFY_DAYS", "JUSTIFY_HOURS", "JUSTIFY_INTERVAL", "LAST_DAY",
|
|
66
|
+
"MAKE_INTERVAL", "PARSE_DATE", "PARSE_DATETIME", "PARSE_TIME",
|
|
67
|
+
"PARSE_TIMESTAMP", "TIME", "TIME_ADD", "TIME_DIFF", "TIME_SUB",
|
|
68
|
+
"TIME_TRUNC", "TIMESTAMP", "TIMESTAMP_ADD", "TIMESTAMP_BUCKET",
|
|
69
|
+
"TIMESTAMP_DIFF", "TIMESTAMP_MICROS", "TIMESTAMP_MILLIS",
|
|
70
|
+
"TIMESTAMP_SECONDS", "TIMESTAMP_SUB", "TIMESTAMP_TRUNC",
|
|
71
|
+
"UNIX_DATE", "UNIX_MICROS", "UNIX_MILLIS", "UNIX_SECONDS", "EXTRACT",
|
|
72
|
+
}),
|
|
73
|
+
"array": frozenset({
|
|
74
|
+
"ARRAY", "ARRAY_CONCAT", "ARRAY_FIRST", "ARRAY_LAST", "ARRAY_LENGTH",
|
|
75
|
+
"ARRAY_REVERSE", "ARRAY_SLICE", "ARRAY_TO_STRING", "GENERATE_ARRAY",
|
|
76
|
+
"ARRAY_ZIP", "FLATTEN",
|
|
77
|
+
}),
|
|
78
|
+
"json": frozenset({
|
|
79
|
+
"BOOL", "FLOAT64", "INT64", "JSON_ARRAY", "JSON_ARRAY_APPEND",
|
|
80
|
+
"JSON_ARRAY_INSERT", "JSON_EXTRACT", "JSON_EXTRACT_ARRAY",
|
|
81
|
+
"JSON_EXTRACT_SCALAR", "JSON_EXTRACT_STRING_ARRAY", "JSON_KEYS",
|
|
82
|
+
"JSON_OBJECT", "JSON_QUERY", "JSON_QUERY_ARRAY", "JSON_REMOVE",
|
|
83
|
+
"JSON_SET", "JSON_STRIP_NULLS", "JSON_TYPE", "JSON_VALUE",
|
|
84
|
+
"JSON_VALUE_ARRAY", "LAX_BOOL", "LAX_FLOAT64", "LAX_INT64",
|
|
85
|
+
"LAX_STRING", "PARSE_JSON", "STRING", "TO_JSON", "TO_JSON_STRING",
|
|
86
|
+
}),
|
|
87
|
+
"conditional": frozenset({
|
|
88
|
+
"COALESCE", "IF", "IFNULL", "NULLIF", "NULLIFZERO", "ZEROIFNULL",
|
|
89
|
+
}),
|
|
90
|
+
"conversion": frozenset({
|
|
91
|
+
"CAST", "SAFE_CAST", "PARSE_BIGNUMERIC", "PARSE_NUMERIC",
|
|
92
|
+
}),
|
|
93
|
+
"hash_crypto": frozenset({
|
|
94
|
+
"FARM_FINGERPRINT", "MD5", "SHA1", "SHA256", "SHA512",
|
|
95
|
+
"KEYS.NEW_KEYSET", "KEYS.ADD_KEY_FROM_RAW_BYTES",
|
|
96
|
+
"AEAD.DECRYPT_BYTES", "AEAD.DECRYPT_STRING", "AEAD.ENCRYPT",
|
|
97
|
+
"DETERMINISTIC_DECRYPT_BYTES", "DETERMINISTIC_DECRYPT_STRING",
|
|
98
|
+
"DETERMINISTIC_ENCRYPT", "KEYS.KEYSET_CHAIN", "KEYS.KEYSET_FROM_JSON",
|
|
99
|
+
"KEYS.KEYSET_TO_JSON", "KEYS.ROTATE_KEYSET", "KEYS.KEYSET_LENGTH",
|
|
100
|
+
}),
|
|
101
|
+
"net": frozenset({
|
|
102
|
+
"NET.HOST", "NET.IP_FROM_STRING", "NET.IP_NET_MASK", "NET.IP_TO_STRING",
|
|
103
|
+
"NET.IP_TRUNC", "NET.IPV4_FROM_INT64", "NET.IPV4_TO_INT64",
|
|
104
|
+
"NET.PUBLIC_SUFFIX", "NET.REG_DOMAIN", "NET.SAFE_IP_FROM_STRING",
|
|
105
|
+
}),
|
|
106
|
+
"geography": frozenset({
|
|
107
|
+
"ST_AREA", "ST_ASBINARY", "ST_ASGEOJSON", "ST_ASTEXT", "ST_ANGLE",
|
|
108
|
+
"ST_AZIMUTH", "ST_BOUNDARY", "ST_BOUNDINGBOX", "ST_BUFFER",
|
|
109
|
+
"ST_BUFFERWITHTOLERANCE", "ST_CENTROID", "ST_CLOSESTPOINT",
|
|
110
|
+
"ST_CLUSTERDBSCAN", "ST_CONTAINS", "ST_CONVEXHULL", "ST_COVEREDBY",
|
|
111
|
+
"ST_COVERS", "ST_DIFFERENCE", "ST_DIMENSION", "ST_DISJOINT",
|
|
112
|
+
"ST_DISTANCE", "ST_DUMP", "ST_DWITHIN", "ST_ENDPOINT", "ST_EQUALS",
|
|
113
|
+
"ST_EXTERIORRING", "ST_GEOGFROM", "ST_GEOGFROMGEOJSON",
|
|
114
|
+
"ST_GEOGFROMTEXT", "ST_GEOGFROMWKB", "ST_GEOGPOINT",
|
|
115
|
+
"ST_GEOGPOINTFROMGEOHASH", "ST_GEOHASH", "ST_GEOMETRYTYPE",
|
|
116
|
+
"ST_HAUSDORFFDISTANCE", "ST_INTERIORRINGS", "ST_INTERSECTION",
|
|
117
|
+
"ST_INTERSECTS", "ST_INTERSECTSBOX", "ST_ISCLOSED", "ST_ISCOLLECTION",
|
|
118
|
+
"ST_ISEMPTY", "ST_ISRING", "ST_LENGTH", "ST_LINEINTERPOLATEPOINT",
|
|
119
|
+
"ST_LINELOCATEPOINT", "ST_LINESUBSTRING", "ST_MAKELINE",
|
|
120
|
+
"ST_MAKEPOLYGON", "ST_MAKEPOLYGONORIENTED", "ST_MAXDISTANCE",
|
|
121
|
+
"ST_NPOINTS", "ST_NUMGEOMETRIES", "ST_NUMPOINTS", "ST_PERIMETER",
|
|
122
|
+
"ST_POINTN", "ST_SIMPLIFY", "ST_SNAPTOGRID", "ST_STARTPOINT",
|
|
123
|
+
"ST_TOUCHES", "ST_UNION", "ST_WITHIN", "ST_X", "ST_Y",
|
|
124
|
+
}),
|
|
125
|
+
"range": frozenset({
|
|
126
|
+
"RANGE", "RANGE_CONTAINS", "RANGE_END", "RANGE_INTERSECT",
|
|
127
|
+
"RANGE_OVERLAPS", "RANGE_SESSIONIZE", "RANGE_START",
|
|
128
|
+
}),
|
|
129
|
+
"utility": frozenset({
|
|
130
|
+
"GENERATE_UUID", "SESSION_USER", "ERROR", "BIT_COUNT",
|
|
131
|
+
"TYPEOF", "VECTOR_SEARCH",
|
|
132
|
+
}),
|
|
133
|
+
"search": frozenset({
|
|
134
|
+
"SEARCH", "TEXT_ANALYZE",
|
|
135
|
+
}),
|
|
136
|
+
"interval": frozenset({
|
|
137
|
+
"JUSTIFY_DAYS", "JUSTIFY_HOURS", "JUSTIFY_INTERVAL", "MAKE_INTERVAL",
|
|
138
|
+
}),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_all = set(AGGREGATE_FUNCTIONS) | set(NAVIGATION_FUNCTIONS)
|
|
142
|
+
for _names in SCALAR_FUNCTIONS.values():
|
|
143
|
+
_all |= set(_names)
|
|
144
|
+
ALL_FUNCTIONS: FrozenSet[str] = frozenset(_all)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_aggregate(name: str) -> bool:
|
|
148
|
+
"""True if *name* (case-insensitive, dotted allowed) is an aggregate."""
|
|
149
|
+
return name.upper() in AGGREGATE_FUNCTIONS
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_navigation(name: str) -> bool:
|
|
153
|
+
"""True if *name* is a numbering/navigation (window-only) function."""
|
|
154
|
+
return name.upper() in NAVIGATION_FUNCTIONS
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_known_function(name: str) -> bool:
|
|
158
|
+
"""True if *name* is a known BigQuery built-in function."""
|
|
159
|
+
return name.upper() in ALL_FUNCTIONS
|
bqsqlparse/lexer.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Tokenizer for the BigQuery (GoogleSQL) dialect.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- Reserved keywords vs. contextual identifiers
|
|
5
|
+
- Backtick-quoted identifiers (including dotted paths inside backticks)
|
|
6
|
+
- String / bytes literals with r/b prefixes, single, double and triple quotes
|
|
7
|
+
- Escape sequences (\\n, \\xHH, \\uXXXX, \\UXXXXXXXX, octal, ...)
|
|
8
|
+
- Numeric literals (int, float, exponent, hex)
|
|
9
|
+
- Named (@param), system (@@var) and positional (?) parameters
|
|
10
|
+
- Comments: ``--``, ``#`` and ``/* ... */``
|
|
11
|
+
- All GoogleSQL operators including ``||``, ``<<``, ``>>``, ``=>``
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from bisect import bisect_right
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import List
|
|
20
|
+
|
|
21
|
+
from .errors import LexError
|
|
22
|
+
|
|
23
|
+
# Official GoogleSQL reserved keywords.
|
|
24
|
+
RESERVED = frozenset(
|
|
25
|
+
"""
|
|
26
|
+
ALL AND ANY ARRAY AS ASC ASSERT_ROWS_MODIFIED AT BETWEEN BY CASE CAST
|
|
27
|
+
COLLATE CONTAINS CREATE CROSS CUBE CURRENT DEFAULT DEFINE DESC DISTINCT
|
|
28
|
+
ELSE END ENUM ESCAPE EXCEPT EXCLUDE EXISTS EXTRACT FALSE FETCH FOLLOWING
|
|
29
|
+
FOR FROM FULL GROUP GROUPING GROUPS HASH HAVING IF IGNORE IN INNER
|
|
30
|
+
INTERSECT INTERVAL INTO IS JOIN LATERAL LEFT LIKE LIMIT LOOKUP MERGE
|
|
31
|
+
NATURAL NEW NO NOT NULL NULLS OF ON OR ORDER OUTER OVER PARTITION
|
|
32
|
+
PRECEDING PROTO QUALIFY RANGE RECURSIVE RESPECT RIGHT ROLLUP ROWS SELECT
|
|
33
|
+
SET SOME STRUCT TABLESAMPLE THEN TO TREAT TRUE UNBOUNDED UNION UNNEST
|
|
34
|
+
USING WHEN WHERE WINDOW WITH WITHIN
|
|
35
|
+
""".split()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Token types
|
|
39
|
+
KEYWORD = "KEYWORD"
|
|
40
|
+
IDENT = "IDENT"
|
|
41
|
+
QIDENT = "QIDENT" # backtick-quoted identifier
|
|
42
|
+
STRING = "STRING"
|
|
43
|
+
BYTES = "BYTES"
|
|
44
|
+
NUMBER = "NUMBER"
|
|
45
|
+
PARAM = "PARAM"
|
|
46
|
+
OP = "OP"
|
|
47
|
+
EOF = "EOF"
|
|
48
|
+
|
|
49
|
+
_TWO_CHAR_OPS = ("<<", ">>", "<=", ">=", "!=", "<>", "||", "=>")
|
|
50
|
+
_SINGLE_CHAR_OPS = set("+-*/=<>()[]{},.;&|^~:")
|
|
51
|
+
|
|
52
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
53
|
+
_NUMBER_RE = re.compile(r"0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
|
54
|
+
|
|
55
|
+
_ESCAPES = {
|
|
56
|
+
"a": "\a", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t",
|
|
57
|
+
"v": "\v", "\\": "\\", "'": "'", '"': '"', "`": "`", "?": "?", "/": "/",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Token:
|
|
63
|
+
type: str
|
|
64
|
+
value: str
|
|
65
|
+
pos: int = 0
|
|
66
|
+
line: int = 0
|
|
67
|
+
col: int = 0
|
|
68
|
+
flags: str = field(default="", compare=False)
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
71
|
+
return f"Token({self.type}, {self.value!r}, L{self.line}:C{self.col})"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Lexer:
|
|
75
|
+
def __init__(self, sql: str):
|
|
76
|
+
self.sql = sql
|
|
77
|
+
self.n = len(sql)
|
|
78
|
+
self.i = 0
|
|
79
|
+
self.tokens: List[Token] = []
|
|
80
|
+
self.line_starts = [0]
|
|
81
|
+
for idx, ch in enumerate(sql):
|
|
82
|
+
if ch == "\n":
|
|
83
|
+
self.line_starts.append(idx + 1)
|
|
84
|
+
|
|
85
|
+
def _linecol(self, pos: int):
|
|
86
|
+
li = bisect_right(self.line_starts, pos) - 1
|
|
87
|
+
return li + 1, pos - self.line_starts[li] + 1
|
|
88
|
+
|
|
89
|
+
def _err(self, msg: str, pos: int):
|
|
90
|
+
line, col = self._linecol(pos)
|
|
91
|
+
raise LexError(msg, line, col)
|
|
92
|
+
|
|
93
|
+
def _add(self, type_: str, value: str, pos: int, flags: str = ""):
|
|
94
|
+
line, col = self._linecol(pos)
|
|
95
|
+
self.tokens.append(Token(type_, value, pos, line, col, flags))
|
|
96
|
+
|
|
97
|
+
def run(self) -> List[Token]:
|
|
98
|
+
sql, n = self.sql, self.n
|
|
99
|
+
while self.i < n:
|
|
100
|
+
ch = sql[self.i]
|
|
101
|
+
if ch in " \t\r\n":
|
|
102
|
+
self.i += 1
|
|
103
|
+
elif ch == "-" and self.i + 1 < n and sql[self.i + 1] == "-":
|
|
104
|
+
self._skip_line()
|
|
105
|
+
elif ch == "#":
|
|
106
|
+
self._skip_line()
|
|
107
|
+
elif ch == "/" and self.i + 1 < n and sql[self.i + 1] == "*":
|
|
108
|
+
end = sql.find("*/", self.i + 2)
|
|
109
|
+
if end == -1:
|
|
110
|
+
self._err("Unterminated block comment", self.i)
|
|
111
|
+
self.i = end + 2
|
|
112
|
+
elif ch == "`":
|
|
113
|
+
self._read_qident()
|
|
114
|
+
elif ch in "'\"":
|
|
115
|
+
self._read_string(self.i, "")
|
|
116
|
+
elif ch.isalpha() or ch == "_":
|
|
117
|
+
m = _IDENT_RE.match(sql, self.i)
|
|
118
|
+
word = m.group(0)
|
|
119
|
+
nxt = self.i + len(word)
|
|
120
|
+
if word.lower() in ("r", "b", "rb", "br") and nxt < n and sql[nxt] in "'\"":
|
|
121
|
+
start = self.i
|
|
122
|
+
self.i = nxt
|
|
123
|
+
self._read_string(start, word.lower())
|
|
124
|
+
else:
|
|
125
|
+
upper = word.upper()
|
|
126
|
+
if upper in RESERVED:
|
|
127
|
+
self._add(KEYWORD, upper, self.i)
|
|
128
|
+
else:
|
|
129
|
+
self._add(IDENT, word, self.i)
|
|
130
|
+
self.i = nxt
|
|
131
|
+
elif ch.isdigit() or (ch == "." and self.i + 1 < n and sql[self.i + 1].isdigit()):
|
|
132
|
+
m = _NUMBER_RE.match(sql, self.i)
|
|
133
|
+
self._add(NUMBER, m.group(0), self.i)
|
|
134
|
+
self.i = m.end()
|
|
135
|
+
elif ch == "@":
|
|
136
|
+
start = self.i
|
|
137
|
+
self.i += 1
|
|
138
|
+
prefix = "@"
|
|
139
|
+
if self.i < n and sql[self.i] == "@":
|
|
140
|
+
prefix = "@@"
|
|
141
|
+
self.i += 1
|
|
142
|
+
m = _IDENT_RE.match(sql, self.i)
|
|
143
|
+
if not m:
|
|
144
|
+
self._err("Invalid query parameter", start)
|
|
145
|
+
self._add(PARAM, prefix + m.group(0), start)
|
|
146
|
+
self.i = m.end()
|
|
147
|
+
elif ch == "?":
|
|
148
|
+
self._add(PARAM, "?", self.i)
|
|
149
|
+
self.i += 1
|
|
150
|
+
else:
|
|
151
|
+
two = sql[self.i : self.i + 2]
|
|
152
|
+
if two in _TWO_CHAR_OPS:
|
|
153
|
+
self._add(OP, two, self.i)
|
|
154
|
+
self.i += 2
|
|
155
|
+
elif ch in _SINGLE_CHAR_OPS:
|
|
156
|
+
self._add(OP, ch, self.i)
|
|
157
|
+
self.i += 1
|
|
158
|
+
else:
|
|
159
|
+
self._err(f"Unexpected character {ch!r}", self.i)
|
|
160
|
+
self._add(EOF, "", n)
|
|
161
|
+
return self.tokens
|
|
162
|
+
|
|
163
|
+
def _skip_line(self):
|
|
164
|
+
end = self.sql.find("\n", self.i)
|
|
165
|
+
self.i = self.n if end == -1 else end + 1
|
|
166
|
+
|
|
167
|
+
def _read_qident(self):
|
|
168
|
+
start = self.i
|
|
169
|
+
self.i += 1
|
|
170
|
+
chars = []
|
|
171
|
+
while self.i < self.n:
|
|
172
|
+
ch = self.sql[self.i]
|
|
173
|
+
if ch == "\\" and self.i + 1 < self.n:
|
|
174
|
+
chars.append(self.sql[self.i + 1])
|
|
175
|
+
self.i += 2
|
|
176
|
+
elif ch == "`":
|
|
177
|
+
self.i += 1
|
|
178
|
+
self._add(QIDENT, "".join(chars), start)
|
|
179
|
+
return
|
|
180
|
+
else:
|
|
181
|
+
chars.append(ch)
|
|
182
|
+
self.i += 1
|
|
183
|
+
self._err("Unterminated quoted identifier", start)
|
|
184
|
+
|
|
185
|
+
def _read_string(self, start: int, flags: str):
|
|
186
|
+
sql, n = self.sql, self.n
|
|
187
|
+
quote = sql[self.i]
|
|
188
|
+
raw = "r" in flags
|
|
189
|
+
is_bytes = "b" in flags
|
|
190
|
+
triple = sql[self.i : self.i + 3] == quote * 3
|
|
191
|
+
self.i += 3 if triple else 1
|
|
192
|
+
terminator = quote * 3 if triple else quote
|
|
193
|
+
chars = []
|
|
194
|
+
while self.i < n:
|
|
195
|
+
if sql.startswith(terminator, self.i):
|
|
196
|
+
self.i += len(terminator)
|
|
197
|
+
self._add(BYTES if is_bytes else STRING, "".join(chars), start, flags)
|
|
198
|
+
return
|
|
199
|
+
ch = sql[self.i]
|
|
200
|
+
if not triple and ch == "\n":
|
|
201
|
+
self._err("Unterminated string literal", start)
|
|
202
|
+
if ch == "\\" and self.i + 1 < n:
|
|
203
|
+
if raw:
|
|
204
|
+
chars.append(ch)
|
|
205
|
+
chars.append(sql[self.i + 1])
|
|
206
|
+
self.i += 2
|
|
207
|
+
else:
|
|
208
|
+
self.i += 1
|
|
209
|
+
self._read_escape(chars)
|
|
210
|
+
else:
|
|
211
|
+
chars.append(ch)
|
|
212
|
+
self.i += 1
|
|
213
|
+
self._err("Unterminated string literal", start)
|
|
214
|
+
|
|
215
|
+
def _read_escape(self, chars):
|
|
216
|
+
sql = self.sql
|
|
217
|
+
e = sql[self.i]
|
|
218
|
+
if e in _ESCAPES:
|
|
219
|
+
chars.append(_ESCAPES[e])
|
|
220
|
+
self.i += 1
|
|
221
|
+
elif e == "x" or e == "X":
|
|
222
|
+
hexs = sql[self.i + 1 : self.i + 3]
|
|
223
|
+
chars.append(chr(int(hexs, 16)))
|
|
224
|
+
self.i += 3
|
|
225
|
+
elif e == "u":
|
|
226
|
+
chars.append(chr(int(sql[self.i + 1 : self.i + 5], 16)))
|
|
227
|
+
self.i += 5
|
|
228
|
+
elif e == "U":
|
|
229
|
+
chars.append(chr(int(sql[self.i + 1 : self.i + 9], 16)))
|
|
230
|
+
self.i += 9
|
|
231
|
+
elif e.isdigit():
|
|
232
|
+
m = re.match(r"[0-7]{1,3}", sql[self.i :])
|
|
233
|
+
chars.append(chr(int(m.group(0), 8)))
|
|
234
|
+
self.i += len(m.group(0))
|
|
235
|
+
else:
|
|
236
|
+
chars.append(e)
|
|
237
|
+
self.i += 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def tokenize(sql: str) -> List[Token]:
|
|
241
|
+
"""Tokenize a GoogleSQL string into a list of :class:`Token`."""
|
|
242
|
+
return _Lexer(sql).run()
|