singlestoredb 0.4.0__py3-none-any.whl → 1.0.4__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.
Potentially problematic release.
This version of singlestoredb might be problematic. Click here for more details.
- singlestoredb/__init__.py +33 -1
- singlestoredb/alchemy/__init__.py +90 -0
- singlestoredb/auth.py +5 -1
- singlestoredb/config.py +116 -14
- singlestoredb/connection.py +483 -516
- singlestoredb/converters.py +238 -135
- singlestoredb/exceptions.py +30 -2
- singlestoredb/functions/__init__.py +1 -0
- singlestoredb/functions/decorator.py +142 -0
- singlestoredb/functions/dtypes.py +1639 -0
- singlestoredb/functions/ext/__init__.py +2 -0
- singlestoredb/functions/ext/arrow.py +375 -0
- singlestoredb/functions/ext/asgi.py +661 -0
- singlestoredb/functions/ext/json.py +427 -0
- singlestoredb/functions/ext/mmap.py +306 -0
- singlestoredb/functions/ext/rowdat_1.py +744 -0
- singlestoredb/functions/signature.py +673 -0
- singlestoredb/fusion/__init__.py +11 -0
- singlestoredb/fusion/graphql.py +213 -0
- singlestoredb/fusion/handler.py +621 -0
- singlestoredb/fusion/handlers/stage.py +257 -0
- singlestoredb/fusion/handlers/utils.py +162 -0
- singlestoredb/fusion/handlers/workspace.py +412 -0
- singlestoredb/fusion/registry.py +164 -0
- singlestoredb/fusion/result.py +399 -0
- singlestoredb/http/__init__.py +27 -0
- singlestoredb/{http.py → http/connection.py} +555 -154
- singlestoredb/management/__init__.py +3 -0
- singlestoredb/management/billing_usage.py +148 -0
- singlestoredb/management/cluster.py +14 -6
- singlestoredb/management/manager.py +100 -38
- singlestoredb/management/organization.py +188 -0
- singlestoredb/management/region.py +5 -5
- singlestoredb/management/utils.py +281 -2
- singlestoredb/management/workspace.py +1344 -49
- singlestoredb/{clients/pymysqlsv → mysql}/__init__.py +16 -21
- singlestoredb/{clients/pymysqlsv → mysql}/_auth.py +39 -8
- singlestoredb/{clients/pymysqlsv → mysql}/charset.py +26 -23
- singlestoredb/{clients/pymysqlsv/connections.py → mysql/connection.py} +532 -165
- singlestoredb/{clients/pymysqlsv → mysql}/constants/CLIENT.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/COMMAND.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/CR.py +0 -2
- singlestoredb/{clients/pymysqlsv → mysql}/constants/ER.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/FIELD_TYPE.py +1 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/FLAG.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/SERVER_STATUS.py +0 -1
- singlestoredb/mysql/converters.py +271 -0
- singlestoredb/{clients/pymysqlsv → mysql}/cursors.py +228 -112
- singlestoredb/mysql/err.py +92 -0
- singlestoredb/{clients/pymysqlsv → mysql}/optionfile.py +5 -4
- singlestoredb/{clients/pymysqlsv → mysql}/protocol.py +49 -20
- singlestoredb/mysql/tests/__init__.py +19 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/base.py +32 -12
- singlestoredb/mysql/tests/conftest.py +37 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_DictCursor.py +11 -7
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_SSCursor.py +17 -12
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_basic.py +32 -24
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_connection.py +130 -119
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_converters.py +9 -7
- singlestoredb/mysql/tests/test_cursor.py +141 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_err.py +3 -2
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_issues.py +35 -27
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_load_local.py +13 -11
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_nextset.py +7 -3
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_optionfile.py +2 -1
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/__init__.py +1 -1
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/capabilities.py +19 -17
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/dbapi20.py +31 -22
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +3 -4
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +24 -20
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +4 -4
- singlestoredb/{clients/pymysqlsv → mysql}/times.py +3 -4
- singlestoredb/pytest.py +283 -0
- singlestoredb/tests/empty.sql +0 -0
- singlestoredb/tests/ext_funcs/__init__.py +385 -0
- singlestoredb/tests/test.sql +210 -0
- singlestoredb/tests/test2.sql +1 -0
- singlestoredb/tests/test_basics.py +482 -115
- singlestoredb/tests/test_config.py +13 -13
- singlestoredb/tests/test_connection.py +241 -305
- singlestoredb/tests/test_dbapi.py +27 -0
- singlestoredb/tests/test_ext_func.py +1193 -0
- singlestoredb/tests/test_ext_func_data.py +1101 -0
- singlestoredb/tests/test_fusion.py +465 -0
- singlestoredb/tests/test_http.py +32 -26
- singlestoredb/tests/test_management.py +588 -8
- singlestoredb/tests/test_plugin.py +33 -0
- singlestoredb/tests/test_results.py +11 -12
- singlestoredb/tests/test_udf.py +687 -0
- singlestoredb/tests/utils.py +3 -2
- singlestoredb/utils/config.py +58 -0
- singlestoredb/utils/debug.py +13 -0
- singlestoredb/utils/mogrify.py +151 -0
- singlestoredb/utils/results.py +4 -1
- singlestoredb-1.0.4.dist-info/METADATA +139 -0
- singlestoredb-1.0.4.dist-info/RECORD +112 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/WHEEL +1 -1
- singlestoredb-1.0.4.dist-info/entry_points.txt +2 -0
- singlestoredb/clients/pymysqlsv/converters.py +0 -365
- singlestoredb/clients/pymysqlsv/err.py +0 -144
- singlestoredb/clients/pymysqlsv/tests/__init__.py +0 -19
- singlestoredb/clients/pymysqlsv/tests/test_cursor.py +0 -133
- singlestoredb/clients/pymysqlsv/tests/thirdparty/test_MySQLdb/__init__.py +0 -9
- singlestoredb/drivers/__init__.py +0 -45
- singlestoredb/drivers/base.py +0 -198
- singlestoredb/drivers/cymysql.py +0 -38
- singlestoredb/drivers/http.py +0 -47
- singlestoredb/drivers/mariadb.py +0 -40
- singlestoredb/drivers/mysqlconnector.py +0 -49
- singlestoredb/drivers/mysqldb.py +0 -60
- singlestoredb/drivers/pymysql.py +0 -37
- singlestoredb/drivers/pymysqlsv.py +0 -35
- singlestoredb/drivers/pyodbc.py +0 -65
- singlestoredb-0.4.0.dist-info/METADATA +0 -111
- singlestoredb-0.4.0.dist-info/RECORD +0 -86
- /singlestoredb/{clients → fusion/handlers}/__init__.py +0 -0
- /singlestoredb/{clients/pymysqlsv → mysql}/constants/__init__.py +0 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/LICENSE +0 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import abc
|
|
3
|
+
import functools
|
|
4
|
+
import re
|
|
5
|
+
import textwrap
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import Callable
|
|
8
|
+
from typing import Dict
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
from typing import List
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from typing import Tuple
|
|
13
|
+
|
|
14
|
+
from parsimonious import Grammar
|
|
15
|
+
from parsimonious import ParseError
|
|
16
|
+
from parsimonious.nodes import Node
|
|
17
|
+
from parsimonious.nodes import NodeVisitor
|
|
18
|
+
|
|
19
|
+
from . import result
|
|
20
|
+
from ..connection import Connection
|
|
21
|
+
|
|
22
|
+
CORE_GRAMMAR = r'''
|
|
23
|
+
ws = ~r"(\s+|(\s*/\*.*\*/\s*)+)"
|
|
24
|
+
qs = ~r"\"([^\"]*)\"|'([^\']*)'|`([^\`]*)`|([A-Za-z0-9_\-\.]+)"
|
|
25
|
+
number = ~r"[-+]?(\d*\.)?\d+(e[-+]?\d+)?"i
|
|
26
|
+
integer = ~r"-?\d+"
|
|
27
|
+
comma = ws* "," ws*
|
|
28
|
+
eq = ws* "=" ws*
|
|
29
|
+
open_paren = ws* "(" ws*
|
|
30
|
+
close_paren = ws* ")" ws*
|
|
31
|
+
select = ~r"SELECT"i ws+ ~r".+" ws*
|
|
32
|
+
'''
|
|
33
|
+
|
|
34
|
+
BUILTINS = {
|
|
35
|
+
'<order-by>': r'''
|
|
36
|
+
order_by = ORDER BY order_by_key_,...
|
|
37
|
+
order_by_key_ = '<key>' [ ASC | DESC ]
|
|
38
|
+
''',
|
|
39
|
+
'<like>': r'''
|
|
40
|
+
like = LIKE '<pattern>'
|
|
41
|
+
''',
|
|
42
|
+
'<extended>': r'''
|
|
43
|
+
extended = EXTENDED
|
|
44
|
+
''',
|
|
45
|
+
'<limit>': r'''
|
|
46
|
+
limit = LIMIT <integer>
|
|
47
|
+
''',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
BUILTIN_DEFAULTS = { # type: ignore
|
|
51
|
+
'order_by': {'by': []},
|
|
52
|
+
'like': None,
|
|
53
|
+
'extended': False,
|
|
54
|
+
'limit': None,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_keywords(grammar: str) -> Tuple[str, ...]:
|
|
59
|
+
"""Return all all-caps words from the beginning of the line."""
|
|
60
|
+
m = re.match(r'^\s*((?:[A-Z0-9_]+)(\s+|$|;))+', grammar)
|
|
61
|
+
if not m:
|
|
62
|
+
return tuple()
|
|
63
|
+
return tuple(re.split(r'\s+', m.group(0).replace(';', '').strip()))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_bool(grammar: str) -> bool:
|
|
67
|
+
"""Determine if the rule is a boolean."""
|
|
68
|
+
return bool(re.match(r'^[A-Z0-9_\s*]+$', grammar))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def process_optional(m: Any) -> str:
|
|
72
|
+
"""Create options or groups of options."""
|
|
73
|
+
sql = m.group(1).strip()
|
|
74
|
+
if '|' in sql:
|
|
75
|
+
return f'( {sql} )*'
|
|
76
|
+
return f'( {sql} )?'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def process_alternates(m: Any) -> str:
|
|
80
|
+
"""Make alternates mandatory groups."""
|
|
81
|
+
sql = m.group(1).strip()
|
|
82
|
+
if '|' in sql:
|
|
83
|
+
return f'( {sql} )'
|
|
84
|
+
raise ValueError(f'alternates must contain "|": {sql}')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def process_repeats(m: Any) -> str:
|
|
88
|
+
"""Add repeated patterns."""
|
|
89
|
+
sql = m.group(1).strip()
|
|
90
|
+
return f'open_paren? {sql} ws* ( comma {sql} ws* )* close_paren?'
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def lower_and_regex(m: Any) -> str:
|
|
94
|
+
"""Lowercase and convert literal to regex."""
|
|
95
|
+
sql = m.group(1)
|
|
96
|
+
return f'~"{sql.lower()}"i'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def split_unions(grammar: str) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Convert grammar in the form '[ x ] [ y ]' to '[ x | y ]'.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
grammar : str
|
|
106
|
+
SQL grammar
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
str
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
in_alternate = False
|
|
114
|
+
out = []
|
|
115
|
+
for c in grammar:
|
|
116
|
+
if c == '{':
|
|
117
|
+
in_alternate = True
|
|
118
|
+
out.append(c)
|
|
119
|
+
elif c == '}':
|
|
120
|
+
in_alternate = False
|
|
121
|
+
out.append(c)
|
|
122
|
+
elif not in_alternate and c == '|':
|
|
123
|
+
out.append(']')
|
|
124
|
+
out.append(' ')
|
|
125
|
+
out.append('[')
|
|
126
|
+
else:
|
|
127
|
+
out.append(c)
|
|
128
|
+
return ''.join(out)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def expand_rules(rules: Dict[str, str], m: Any) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Return expanded grammar syntax for given rule.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
ops : Dict[str, str]
|
|
138
|
+
Dictionary of rules in grammar
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
str
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
txt = m.group(1)
|
|
146
|
+
if txt in rules:
|
|
147
|
+
return f' {rules[txt]} '
|
|
148
|
+
return f' <{txt}> '
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_cmd(grammar: str) -> str:
|
|
152
|
+
"""Pre-process grammar to construct top-level command."""
|
|
153
|
+
if ';' not in grammar:
|
|
154
|
+
raise ValueError('a semi-colon exist at the end of the primary rule')
|
|
155
|
+
|
|
156
|
+
# Pre-space
|
|
157
|
+
m = re.match(r'^\s*', grammar)
|
|
158
|
+
space = m.group(0) if m else ''
|
|
159
|
+
|
|
160
|
+
# Split on ';' on a line by itself
|
|
161
|
+
begin, end = grammar.split(';', 1)
|
|
162
|
+
|
|
163
|
+
# Get statement keywords
|
|
164
|
+
keywords = get_keywords(begin)
|
|
165
|
+
cmd = '_'.join(x.lower() for x in keywords) + '_cmd'
|
|
166
|
+
|
|
167
|
+
# Collapse multi-line to one
|
|
168
|
+
begin = re.sub(r'\s+', r' ', begin)
|
|
169
|
+
|
|
170
|
+
return f'{space}{cmd} ={begin}\n{end}'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_help(grammar: str) -> str:
|
|
174
|
+
"""Construct full help syntax."""
|
|
175
|
+
if ';' not in grammar:
|
|
176
|
+
raise ValueError('a semi-colon exist at the end of the primary rule')
|
|
177
|
+
|
|
178
|
+
# Split on ';' on a line by itself
|
|
179
|
+
cmd, end = grammar.split(';', 1)
|
|
180
|
+
|
|
181
|
+
rules = {}
|
|
182
|
+
for line in end.split('\n'):
|
|
183
|
+
line = line.strip()
|
|
184
|
+
if not line:
|
|
185
|
+
continue
|
|
186
|
+
name, value = line.split('=', 1)
|
|
187
|
+
name = name.strip()
|
|
188
|
+
value = value.strip()
|
|
189
|
+
rules[name] = value
|
|
190
|
+
|
|
191
|
+
while re.search(r' [a-z0-9_]+\b', cmd):
|
|
192
|
+
cmd = re.sub(r' ([a-z0-9_]+)\b', functools.partial(expand_rules, rules), cmd)
|
|
193
|
+
|
|
194
|
+
cmd = textwrap.dedent(cmd).rstrip() + ';'
|
|
195
|
+
cmd = re.sub(r' +', ' ', cmd)
|
|
196
|
+
cmd = re.sub(r'^ ', ' ', cmd, flags=re.M)
|
|
197
|
+
cmd = re.sub(r'\s+,\.\.\.', ',...', cmd)
|
|
198
|
+
|
|
199
|
+
return cmd
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def strip_comments(grammar: str) -> str:
|
|
203
|
+
"""Strip comments from grammar."""
|
|
204
|
+
return re.sub(r'^\s*#.*$', r'', grammar, flags=re.M)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_rule_info(grammar: str) -> Dict[str, Any]:
|
|
208
|
+
"""Compute metadata about rule used in coallescing parsed output."""
|
|
209
|
+
return dict(
|
|
210
|
+
n_keywords=len(get_keywords(grammar)),
|
|
211
|
+
repeats=',...' in grammar,
|
|
212
|
+
default=False if is_bool(grammar) else [] if ',...' in grammar else None,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def inject_builtins(grammar: str) -> str:
|
|
217
|
+
"""Inject complex builtin rules."""
|
|
218
|
+
for k, v in BUILTINS.items():
|
|
219
|
+
if re.search(k, grammar):
|
|
220
|
+
grammar = re.sub(
|
|
221
|
+
k,
|
|
222
|
+
k.replace('<', '').replace('>', '').replace('-', '_'),
|
|
223
|
+
grammar,
|
|
224
|
+
)
|
|
225
|
+
grammar += v
|
|
226
|
+
return grammar
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def process_grammar(grammar: str) -> Tuple[Grammar, Tuple[str, ...], Dict[str, Any], str]:
|
|
230
|
+
"""
|
|
231
|
+
Convert SQL grammar to a Parsimonious grammar.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
grammar : str
|
|
236
|
+
The SQL grammar
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
(Grammar, Tuple[str, ...], Dict[str, Any], str) - Grammar is the parsimonious
|
|
241
|
+
grammar object. The tuple is a series of the keywords that start the command.
|
|
242
|
+
The dictionary is a set of metadata about each rule. The final string is
|
|
243
|
+
a human-readable version of the grammar for documentation and errors.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
out = []
|
|
247
|
+
rules = {}
|
|
248
|
+
rule_info = {}
|
|
249
|
+
|
|
250
|
+
grammar = strip_comments(grammar)
|
|
251
|
+
grammar = inject_builtins(grammar)
|
|
252
|
+
command_key = get_keywords(grammar)
|
|
253
|
+
help_txt = build_help(grammar)
|
|
254
|
+
grammar = build_cmd(grammar)
|
|
255
|
+
|
|
256
|
+
# Make sure grouping characters all have whitespace around them
|
|
257
|
+
grammar = re.sub(r' *(\[|\{|\||\}|\]) *', r' \1 ', grammar)
|
|
258
|
+
|
|
259
|
+
for line in grammar.split('\n'):
|
|
260
|
+
if not line.strip():
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
op, sql = line.split('=', 1)
|
|
264
|
+
op = op.strip()
|
|
265
|
+
sql = sql.strip()
|
|
266
|
+
sql = split_unions(sql)
|
|
267
|
+
|
|
268
|
+
rules[op] = sql
|
|
269
|
+
rule_info[op] = get_rule_info(sql)
|
|
270
|
+
|
|
271
|
+
# Convert consecutive optionals to a union
|
|
272
|
+
sql = re.sub(r'\]\s+\[', r' | ', sql)
|
|
273
|
+
|
|
274
|
+
# Lower-case keywords and make them case-insensitive
|
|
275
|
+
sql = re.sub(r'\b([A-Z0-9]+)\b', lower_and_regex, sql)
|
|
276
|
+
|
|
277
|
+
# Convert literal strings to 'qs'
|
|
278
|
+
sql = re.sub(r"'[^']+'", r'qs', sql)
|
|
279
|
+
|
|
280
|
+
# Convert special characters to literal tokens
|
|
281
|
+
sql = re.sub(r'([=]) ', r' eq ', sql)
|
|
282
|
+
|
|
283
|
+
# Convert [...] groups to (...)*
|
|
284
|
+
sql = re.sub(r'\[([^\]]+)\]', process_optional, sql)
|
|
285
|
+
|
|
286
|
+
# Convert {...} groups to (...)
|
|
287
|
+
sql = re.sub(r'\{([^\}]+)\}', process_alternates, sql)
|
|
288
|
+
|
|
289
|
+
# Convert <...> to ... (<...> is the form for core types)
|
|
290
|
+
sql = re.sub(r'<([a-z0-9_]+)>', r'\1', sql)
|
|
291
|
+
|
|
292
|
+
# Insert ws between every token to allow for whitespace and comments
|
|
293
|
+
sql = ' ws '.join(re.split(r'\s+', sql)) + ' ws'
|
|
294
|
+
|
|
295
|
+
# Remove ws in optional groupings
|
|
296
|
+
sql = sql.replace('( ws', '(')
|
|
297
|
+
sql = sql.replace('| ws', '|')
|
|
298
|
+
|
|
299
|
+
# Convert | to /
|
|
300
|
+
sql = sql.replace('|', '/')
|
|
301
|
+
|
|
302
|
+
# Remove ws after operation names, all operations contain ws at the end
|
|
303
|
+
sql = re.sub(r'(\s+[a-z0-9_]+)\s+ws\b', r'\1', sql)
|
|
304
|
+
|
|
305
|
+
# Convert foo,... to foo ("," foo)*
|
|
306
|
+
sql = re.sub(r'(\S+),...', process_repeats, sql)
|
|
307
|
+
|
|
308
|
+
# Remove ws before / and )
|
|
309
|
+
sql = re.sub(r'(\s*\S+\s+)ws\s+/', r'\1/', sql)
|
|
310
|
+
sql = re.sub(r'(\s*\S+\s+)ws\s+\)', r'\1)', sql)
|
|
311
|
+
|
|
312
|
+
# Make sure every operation ends with ws
|
|
313
|
+
sql = re.sub(r'\s+ws\s+ws$', r' ws', sql + ' ws')
|
|
314
|
+
sql = re.sub(r'(\s+ws)*\s+ws\*$', r' ws*', sql)
|
|
315
|
+
sql = re.sub(r'\s+ws$', r' ws*', sql)
|
|
316
|
+
sql = re.sub(r'\s+ws\s+\(', r' ws* (', sql)
|
|
317
|
+
sql = re.sub(r'\)\s+ws\s+', r') ws* ', sql)
|
|
318
|
+
sql = re.sub(r'\s+ws\s+', r' ws+ ', sql)
|
|
319
|
+
sql = re.sub(r'\?\s+ws\+', r'? ws*', sql)
|
|
320
|
+
|
|
321
|
+
# Remove extra ws around eq
|
|
322
|
+
sql = re.sub(r'ws\+\s*eq\b', r'eq', sql)
|
|
323
|
+
|
|
324
|
+
out.append(f'{op} = {sql}')
|
|
325
|
+
|
|
326
|
+
for k, v in list(rules.items()):
|
|
327
|
+
while re.search(r' ([a-z0-9_]+) ', v):
|
|
328
|
+
v = re.sub(r' ([a-z0-9_]+) ', functools.partial(expand_rules, rules), v)
|
|
329
|
+
rules[k] = v
|
|
330
|
+
|
|
331
|
+
for k, v in list(rules.items()):
|
|
332
|
+
while re.search(r' <([a-z0-9_]+)> ', v):
|
|
333
|
+
v = re.sub(r' <([a-z0-9_]+)> ', r' \1 ', v)
|
|
334
|
+
rules[k] = v
|
|
335
|
+
|
|
336
|
+
cmds = ' / '.join(x for x in rules if x.endswith('_cmd'))
|
|
337
|
+
cmds = f'init = ws* ( {cmds} ) ws* ";"? ws*\n'
|
|
338
|
+
|
|
339
|
+
return Grammar(cmds + CORE_GRAMMAR + '\n'.join(out)), command_key, rule_info, help_txt
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def flatten(items: Iterable[Any]) -> List[Any]:
|
|
343
|
+
"""Flatten a list of iterables."""
|
|
344
|
+
out = []
|
|
345
|
+
for x in items:
|
|
346
|
+
if isinstance(x, (str, bytes, dict)):
|
|
347
|
+
out.append(x)
|
|
348
|
+
elif isinstance(x, Iterable):
|
|
349
|
+
for sub_x in flatten(x):
|
|
350
|
+
if sub_x is not None:
|
|
351
|
+
out.append(sub_x)
|
|
352
|
+
elif x is not None:
|
|
353
|
+
out.append(x)
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def merge_dicts(items: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
358
|
+
"""Merge list of dictionaries together."""
|
|
359
|
+
out: Dict[str, Any] = {}
|
|
360
|
+
for x in items:
|
|
361
|
+
if isinstance(x, dict):
|
|
362
|
+
same = list(set(x.keys()).intersection(set(out.keys())))
|
|
363
|
+
if same:
|
|
364
|
+
raise ValueError(f"found duplicate rules for '{same[0]}'")
|
|
365
|
+
out.update(x)
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class SQLHandler(NodeVisitor):
|
|
370
|
+
"""Base class for all SQL handler classes."""
|
|
371
|
+
|
|
372
|
+
#: Parsimonious grammar object
|
|
373
|
+
grammar: Grammar = Grammar(CORE_GRAMMAR)
|
|
374
|
+
|
|
375
|
+
#: SQL keywords that start the command
|
|
376
|
+
command_key: Tuple[str, ...] = ()
|
|
377
|
+
|
|
378
|
+
#: Metadata about the parse rules
|
|
379
|
+
rule_info: Dict[str, Any] = {}
|
|
380
|
+
|
|
381
|
+
#: Help string for use in error messages
|
|
382
|
+
help: str = ''
|
|
383
|
+
|
|
384
|
+
#: Rule validation functions
|
|
385
|
+
validators: Dict[str, Callable[..., Any]] = {}
|
|
386
|
+
|
|
387
|
+
_grammar: str = CORE_GRAMMAR
|
|
388
|
+
_is_compiled: bool = False
|
|
389
|
+
|
|
390
|
+
def __init__(self, connection: Connection):
|
|
391
|
+
self.connection = connection
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def compile(cls, grammar: str = '') -> None:
|
|
395
|
+
"""
|
|
396
|
+
Compile the grammar held in the docstring.
|
|
397
|
+
|
|
398
|
+
This method modifies attributes on the class: ``grammar``,
|
|
399
|
+
``command_key``, ``rule_info``, and ``help``.
|
|
400
|
+
|
|
401
|
+
Parameters
|
|
402
|
+
----------
|
|
403
|
+
grammar : str, optional
|
|
404
|
+
Grammar to use instead of docstring
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
if cls._is_compiled:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
cls.grammar, cls.command_key, cls.rule_info, cls.help = \
|
|
411
|
+
process_grammar(grammar or cls.__doc__ or '')
|
|
412
|
+
|
|
413
|
+
cls._grammar = grammar or cls.__doc__ or ''
|
|
414
|
+
cls._is_compiled = True
|
|
415
|
+
|
|
416
|
+
@classmethod
|
|
417
|
+
def register(cls, overwrite: bool = False) -> None:
|
|
418
|
+
"""
|
|
419
|
+
Register the handler class.
|
|
420
|
+
|
|
421
|
+
Paraemeters
|
|
422
|
+
-----------
|
|
423
|
+
overwrite : bool, optional
|
|
424
|
+
Overwrite an existing command with the same name?
|
|
425
|
+
|
|
426
|
+
"""
|
|
427
|
+
from . import registry
|
|
428
|
+
cls.compile()
|
|
429
|
+
registry.register_handler(cls, overwrite=overwrite)
|
|
430
|
+
|
|
431
|
+
def execute(self, sql: str) -> result.FusionSQLResult:
|
|
432
|
+
"""
|
|
433
|
+
Parse the SQL and invoke the handler method.
|
|
434
|
+
|
|
435
|
+
Parameters
|
|
436
|
+
----------
|
|
437
|
+
sql : str
|
|
438
|
+
SQL statement to execute
|
|
439
|
+
|
|
440
|
+
Returns
|
|
441
|
+
-------
|
|
442
|
+
DummySQLResult
|
|
443
|
+
|
|
444
|
+
"""
|
|
445
|
+
import warnings
|
|
446
|
+
warnings.warn(
|
|
447
|
+
'Fusion SQL is currently a preview feature. '
|
|
448
|
+
'Run `SHOW FUSION COMMANDS` to see all commands.',
|
|
449
|
+
RuntimeWarning,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
type(self).compile()
|
|
453
|
+
try:
|
|
454
|
+
params = self.visit(type(self).grammar.parse(sql))
|
|
455
|
+
for k, v in params.items():
|
|
456
|
+
params[k] = self.validate_rule(k, v)
|
|
457
|
+
|
|
458
|
+
res = self.run(params)
|
|
459
|
+
if res is not None:
|
|
460
|
+
res.format_results(self.connection)
|
|
461
|
+
return res
|
|
462
|
+
|
|
463
|
+
res = result.FusionSQLResult()
|
|
464
|
+
res.set_rows([])
|
|
465
|
+
res.format_results(self.connection)
|
|
466
|
+
return res
|
|
467
|
+
|
|
468
|
+
except ParseError as exc:
|
|
469
|
+
s = str(exc)
|
|
470
|
+
msg = ''
|
|
471
|
+
m = re.search(r'(The non-matching portion.*$)', s)
|
|
472
|
+
if m:
|
|
473
|
+
msg = ' ' + m.group(1)
|
|
474
|
+
m = re.search(r"(Rule) '.+?'( didn't match at.*$)", s)
|
|
475
|
+
if m:
|
|
476
|
+
msg = ' ' + m.group(1) + m.group(2)
|
|
477
|
+
raise ValueError(
|
|
478
|
+
f'Could not parse statement.{msg} '
|
|
479
|
+
'Expecting:\n' + textwrap.indent(type(self).help, ' '),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@abc.abstractmethod
|
|
483
|
+
def run(self, params: Dict[str, Any]) -> Optional[result.FusionSQLResult]:
|
|
484
|
+
"""
|
|
485
|
+
Run the handler command.
|
|
486
|
+
|
|
487
|
+
Parameters
|
|
488
|
+
----------
|
|
489
|
+
params : Dict[str, Any]
|
|
490
|
+
Values parsed from the SQL query. Each rule in the grammar
|
|
491
|
+
results in a key/value pair in the ``params` dictionary.
|
|
492
|
+
|
|
493
|
+
Returns
|
|
494
|
+
-------
|
|
495
|
+
SQLResult - tuple containing the column definitions and
|
|
496
|
+
rows of data in the result
|
|
497
|
+
|
|
498
|
+
"""
|
|
499
|
+
raise NotImplementedError
|
|
500
|
+
|
|
501
|
+
def visit_qs(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
502
|
+
"""Quoted strings."""
|
|
503
|
+
if node is None:
|
|
504
|
+
return None
|
|
505
|
+
return node.match.group(1) or node.match.group(2) or \
|
|
506
|
+
node.match.group(3) or node.match.group(4)
|
|
507
|
+
|
|
508
|
+
def visit_number(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
509
|
+
"""Numeric value."""
|
|
510
|
+
return float(node.match.group(0))
|
|
511
|
+
|
|
512
|
+
def visit_integer(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
513
|
+
"""Integer value."""
|
|
514
|
+
return int(node.match.group(0))
|
|
515
|
+
|
|
516
|
+
def visit_ws(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
517
|
+
"""Whitespace and comments."""
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
def visit_eq(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
521
|
+
"""Equals sign."""
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
def visit_comma(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
525
|
+
"""Single comma."""
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
def visit_open_paren(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
529
|
+
"""Open parenthesis."""
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
def visit_close_paren(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
533
|
+
"""Close parenthesis."""
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
def visit_init(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
537
|
+
"""Entry point of the grammar."""
|
|
538
|
+
_, out, *_ = visited_children
|
|
539
|
+
return out
|
|
540
|
+
|
|
541
|
+
def visit_select(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
542
|
+
out = ' '.join(flatten(visited_children))
|
|
543
|
+
return {'select': out}
|
|
544
|
+
|
|
545
|
+
def visit_order_by(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
546
|
+
"""Handle ORDER BY."""
|
|
547
|
+
by = []
|
|
548
|
+
ascending = []
|
|
549
|
+
data = [x for x in flatten(visited_children)[2:] if x]
|
|
550
|
+
for item in data:
|
|
551
|
+
value = item.popitem()[-1]
|
|
552
|
+
if not isinstance(value, list):
|
|
553
|
+
value = [value]
|
|
554
|
+
value.append('A')
|
|
555
|
+
by.append(value[0])
|
|
556
|
+
ascending.append(value[1].upper().startswith('A'))
|
|
557
|
+
return {'order_by': {'by': by, 'ascending': ascending}}
|
|
558
|
+
|
|
559
|
+
def generic_visit(self, node: Node, visited_children: Iterable[Any]) -> Any:
|
|
560
|
+
"""
|
|
561
|
+
Handle all undefined rules.
|
|
562
|
+
|
|
563
|
+
This method processes all user-defined rules. Each rule results in
|
|
564
|
+
a dictionary with a single key corresponding to the rule name, with
|
|
565
|
+
a value corresponding to the data value following the rule keywords.
|
|
566
|
+
|
|
567
|
+
If no value exists, the value True is used. If the rule is not a
|
|
568
|
+
rule with possible repeated values, a single value is used. If the
|
|
569
|
+
rule can have repeated values, a list of values is returned.
|
|
570
|
+
|
|
571
|
+
"""
|
|
572
|
+
# Call a grammar rule
|
|
573
|
+
if node.expr_name in type(self).rule_info:
|
|
574
|
+
n_keywords = type(self).rule_info[node.expr_name]['n_keywords']
|
|
575
|
+
repeats = type(self).rule_info[node.expr_name]['repeats']
|
|
576
|
+
|
|
577
|
+
# If this is the top-level command, create the final result
|
|
578
|
+
if node.expr_name.endswith('_cmd'):
|
|
579
|
+
final = merge_dicts(flatten(visited_children)[n_keywords:])
|
|
580
|
+
for k, v in type(self).rule_info.items():
|
|
581
|
+
if k.endswith('_cmd') or k.endswith('_'):
|
|
582
|
+
continue
|
|
583
|
+
if k not in final:
|
|
584
|
+
final[k] = BUILTIN_DEFAULTS.get(k, v['default'])
|
|
585
|
+
return final
|
|
586
|
+
|
|
587
|
+
# Filter out stray empty strings
|
|
588
|
+
out = [x for x in flatten(visited_children)[n_keywords:] if x]
|
|
589
|
+
|
|
590
|
+
if repeats or len(out) > 1:
|
|
591
|
+
return {node.expr_name: out}
|
|
592
|
+
|
|
593
|
+
return {node.expr_name: out[0] if out else True}
|
|
594
|
+
|
|
595
|
+
if hasattr(node, 'match'):
|
|
596
|
+
if not visited_children and not node.match.groups():
|
|
597
|
+
return node.text
|
|
598
|
+
return visited_children or list(node.match.groups())
|
|
599
|
+
|
|
600
|
+
return visited_children or node.text
|
|
601
|
+
|
|
602
|
+
def validate_rule(self, rule: str, value: Any) -> Any:
|
|
603
|
+
"""
|
|
604
|
+
Validate the value of the given rule.
|
|
605
|
+
|
|
606
|
+
Paraemeters
|
|
607
|
+
-----------
|
|
608
|
+
rule : str
|
|
609
|
+
Name of the grammar rule the value belongs to
|
|
610
|
+
value : Any
|
|
611
|
+
Value parsed from the query
|
|
612
|
+
|
|
613
|
+
Returns
|
|
614
|
+
-------
|
|
615
|
+
Any - result of the validator function
|
|
616
|
+
|
|
617
|
+
"""
|
|
618
|
+
validator = type(self).validators.get(rule)
|
|
619
|
+
if validator is not None:
|
|
620
|
+
return validator(value)
|
|
621
|
+
return value
|