execsql2 2.4.4__py3-none-any.whl → 2.5.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.
- execsql/cli/__init__.py +14 -0
- execsql/cli/dsn.py +2 -0
- execsql/cli/help.py +2 -0
- execsql/cli/run.py +4 -2
- execsql/constants.py +11 -0
- execsql/db/access.py +20 -0
- execsql/db/base.py +4 -0
- execsql/db/dsn.py +11 -8
- execsql/db/duckdb.py +12 -8
- execsql/db/firebird.py +17 -8
- execsql/db/mysql.py +13 -8
- execsql/db/oracle.py +22 -8
- execsql/db/postgres.py +21 -9
- execsql/db/sqlite.py +18 -8
- execsql/db/sqlserver.py +14 -8
- execsql/exporters/__init__.py +6 -1
- execsql/exporters/base.py +2 -0
- execsql/exporters/delimited.py +10 -0
- execsql/exporters/protocol.py +128 -0
- execsql/exporters/xls.py +8 -0
- execsql/format.py +3 -1
- execsql/gui/__init__.py +2 -0
- execsql/gui/base.py +2 -0
- execsql/gui/console.py +2 -0
- execsql/gui/desktop.py +1 -0
- execsql/gui/tui.py +2 -0
- execsql/importers/base.py +1 -0
- execsql/importers/csv.py +2 -0
- execsql/importers/feather.py +2 -0
- execsql/importers/ods.py +1 -0
- execsql/importers/xls.py +1 -0
- execsql/metacommands/__init__.py +206 -0
- execsql/metacommands/dispatch.py +93 -2
- execsql/metacommands/io.py +41 -0
- execsql/models.py +17 -0
- execsql/parser.py +41 -0
- execsql/script/control.py +2 -0
- execsql/script/engine.py +19 -0
- execsql/script/variables.py +9 -5
- execsql/state.py +52 -0
- execsql/types.py +46 -0
- {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/METADATA +31 -4
- {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/RECORD +62 -61
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/WHEEL +0 -0
- {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/licenses/NOTICE +0 -0
execsql/parser.py
CHANGED
|
@@ -39,20 +39,26 @@ __all__ = [
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class SourceString:
|
|
42
|
+
"""Cursor-based scanner over a raw string for token matching."""
|
|
43
|
+
|
|
42
44
|
def __init__(self, source_string: str) -> None:
|
|
45
|
+
"""Initialise the scanner at position zero of the given string."""
|
|
43
46
|
self.str = source_string
|
|
44
47
|
self.currpos = 0
|
|
45
48
|
|
|
46
49
|
def eoi(self) -> bool:
|
|
50
|
+
"""Return True if the entire source string has been consumed."""
|
|
47
51
|
# Returns True or False indicating whether or not there is any of
|
|
48
52
|
# the source string left to be consumed.
|
|
49
53
|
return self.currpos >= len(self.str)
|
|
50
54
|
|
|
51
55
|
def eat_whitespace(self) -> None:
|
|
56
|
+
"""Advance the cursor past any whitespace at the current position."""
|
|
52
57
|
while not self.eoi() and self.str[self.currpos] in [" ", "\t", "\n"]:
|
|
53
58
|
self.currpos += 1
|
|
54
59
|
|
|
55
60
|
def match_str(self, str: str) -> str | None:
|
|
61
|
+
"""Match a string case-insensitively at the current position and advance."""
|
|
56
62
|
# Tries to match the 'str' argument at the current position in the
|
|
57
63
|
# source string. Matching is case-insensitive. If matching succeeds,
|
|
58
64
|
# the matched string is returned and the internal pointer is incremented.
|
|
@@ -70,6 +76,7 @@ class SourceString:
|
|
|
70
76
|
return None
|
|
71
77
|
|
|
72
78
|
def match_regex(self, regex: Any) -> dict | None:
|
|
79
|
+
"""Match a compiled regex at the current position and return named groups."""
|
|
73
80
|
# Tries to match the 'regex' argument at the current position in the
|
|
74
81
|
# source string. If it succeeds, a dictionary of all of the named
|
|
75
82
|
# groups is returned, and the internal pointer is incremented.
|
|
@@ -85,6 +92,7 @@ class SourceString:
|
|
|
85
92
|
return None
|
|
86
93
|
|
|
87
94
|
def match_metacommand(self, commandlist: Any) -> tuple | None:
|
|
95
|
+
"""Match a metacommand from the command list at the current position."""
|
|
88
96
|
# Tries to match text at the current position to any metacommand
|
|
89
97
|
# in the specified commandlist.
|
|
90
98
|
# If it succeeds, the return value is a tuple of the MetaCommand object
|
|
@@ -102,6 +110,7 @@ class SourceString:
|
|
|
102
110
|
return None
|
|
103
111
|
|
|
104
112
|
def remainder(self) -> str:
|
|
113
|
+
"""Return the unconsumed portion of the source string."""
|
|
105
114
|
return self.str[self.currpos :]
|
|
106
115
|
|
|
107
116
|
|
|
@@ -109,10 +118,14 @@ class SourceString:
|
|
|
109
118
|
|
|
110
119
|
|
|
111
120
|
class CondTokens:
|
|
121
|
+
"""Integer constants for conditional-expression AST node types."""
|
|
122
|
+
|
|
112
123
|
AND, OR, NOT, CONDITIONAL = range(4)
|
|
113
124
|
|
|
114
125
|
|
|
115
126
|
class NumTokens:
|
|
127
|
+
"""Integer constants for numeric-expression AST node types."""
|
|
128
|
+
|
|
116
129
|
MUL, DIV, ADD, SUB, NUMBER = range(5)
|
|
117
130
|
|
|
118
131
|
|
|
@@ -120,7 +133,10 @@ class NumTokens:
|
|
|
120
133
|
|
|
121
134
|
|
|
122
135
|
class CondAstNode(CondTokens):
|
|
136
|
+
"""AST node for boolean expressions supporting AND, OR, NOT, and leaf conditionals."""
|
|
137
|
+
|
|
123
138
|
def __init__(self, type: int, cond1: Any, cond2: Any) -> None:
|
|
139
|
+
"""Create a conditional AST node with the given operator type and children."""
|
|
124
140
|
# 'type' should be one of the constants AND, OR, NOT, CONDITIONAL.
|
|
125
141
|
# For AND and OR types, 'cond1' and 'cond2' should be a subtree (a CondAstNode)
|
|
126
142
|
# For NOT type, 'cond1' should be a CondAstNode and 'cond2' should be None
|
|
@@ -134,6 +150,7 @@ class CondAstNode(CondTokens):
|
|
|
134
150
|
self.right = None
|
|
135
151
|
|
|
136
152
|
def eval(self) -> bool:
|
|
153
|
+
"""Evaluate this subtree and return a boolean result."""
|
|
137
154
|
# Evaluates the subtrees and/or conditional value for this node,
|
|
138
155
|
# returning True or False.
|
|
139
156
|
if self.type == self.CONDITIONAL:
|
|
@@ -157,7 +174,10 @@ class CondAstNode(CondTokens):
|
|
|
157
174
|
|
|
158
175
|
|
|
159
176
|
class NumericAstNode(NumTokens):
|
|
177
|
+
"""AST node for arithmetic expressions supporting MUL, DIV, ADD, SUB, and NUMBER."""
|
|
178
|
+
|
|
160
179
|
def __init__(self, type: int, value1: Any, value2: Any) -> None:
|
|
180
|
+
"""Create a numeric AST node with the given operator type and operands."""
|
|
161
181
|
# 'type' should be one of the constants MUL, DIV, ADD, SUB, OR NUMBER.
|
|
162
182
|
# 'value1' and 'value2' should each be either a subtree (a
|
|
163
183
|
# NumericAstNode) or (only 'value1' should be) a number.
|
|
@@ -169,6 +189,7 @@ class NumericAstNode(NumTokens):
|
|
|
169
189
|
self.right = None
|
|
170
190
|
|
|
171
191
|
def eval(self) -> Any:
|
|
192
|
+
"""Evaluate this subtree and return a numeric result."""
|
|
172
193
|
# Evaluates the subtrees and/or numeric value for this node,
|
|
173
194
|
# returning a numeric value.
|
|
174
195
|
if self.type == self.NUMBER:
|
|
@@ -190,12 +211,16 @@ class NumericAstNode(NumTokens):
|
|
|
190
211
|
|
|
191
212
|
|
|
192
213
|
class CondParser(CondTokens):
|
|
214
|
+
"""Recursive-descent parser for boolean conditional expressions."""
|
|
215
|
+
|
|
193
216
|
# Takes a conditional expression string.
|
|
194
217
|
def __init__(self, condexpr: str) -> None:
|
|
218
|
+
"""Initialise the parser with the conditional expression string."""
|
|
195
219
|
self.condexpr = condexpr
|
|
196
220
|
self.cond_expr = SourceString(condexpr)
|
|
197
221
|
|
|
198
222
|
def match_not(self) -> int | None:
|
|
223
|
+
"""Match a NOT operator and return its token type, or None."""
|
|
199
224
|
# Try to match 'NOT' operator. If not found, return None
|
|
200
225
|
m1 = self.cond_expr.match_str("NOT")
|
|
201
226
|
if m1 is not None:
|
|
@@ -203,6 +228,7 @@ class CondParser(CondTokens):
|
|
|
203
228
|
return None
|
|
204
229
|
|
|
205
230
|
def match_andop(self) -> int | None:
|
|
231
|
+
"""Match an AND operator and return its token type, or None."""
|
|
206
232
|
# Try to match 'AND' operator. If not found, return None
|
|
207
233
|
m1 = self.cond_expr.match_str("AND")
|
|
208
234
|
if m1 is not None:
|
|
@@ -210,6 +236,7 @@ class CondParser(CondTokens):
|
|
|
210
236
|
return None
|
|
211
237
|
|
|
212
238
|
def match_orop(self) -> int | None:
|
|
239
|
+
"""Match an OR operator and return its token type, or None."""
|
|
213
240
|
# Try to match 'OR' operator. If not found, return None
|
|
214
241
|
m1 = self.cond_expr.match_str("OR")
|
|
215
242
|
if m1 is not None:
|
|
@@ -217,6 +244,7 @@ class CondParser(CondTokens):
|
|
|
217
244
|
return None
|
|
218
245
|
|
|
219
246
|
def factor(self) -> Any:
|
|
247
|
+
"""Parse a factor: NOT, a parenthesised expression, or a metacommand leaf."""
|
|
220
248
|
m1 = self.match_not()
|
|
221
249
|
if m1 is not None:
|
|
222
250
|
m1 = self.factor()
|
|
@@ -244,6 +272,7 @@ class CondParser(CondTokens):
|
|
|
244
272
|
)
|
|
245
273
|
|
|
246
274
|
def term(self) -> Any:
|
|
275
|
+
"""Parse a term: a factor optionally followed by AND and another term."""
|
|
247
276
|
m1 = self.factor()
|
|
248
277
|
andop = self.match_andop()
|
|
249
278
|
if andop is not None:
|
|
@@ -253,6 +282,7 @@ class CondParser(CondTokens):
|
|
|
253
282
|
return m1
|
|
254
283
|
|
|
255
284
|
def expression(self) -> Any:
|
|
285
|
+
"""Parse an expression: a term optionally followed by OR and another expression."""
|
|
256
286
|
e1 = self.term()
|
|
257
287
|
orop = self.match_orop()
|
|
258
288
|
if orop is not None:
|
|
@@ -262,6 +292,7 @@ class CondParser(CondTokens):
|
|
|
262
292
|
return e1
|
|
263
293
|
|
|
264
294
|
def parse(self) -> Any:
|
|
295
|
+
"""Parse the entire conditional expression and return the AST root."""
|
|
265
296
|
exp = self.expression()
|
|
266
297
|
if not self.cond_expr.eoi():
|
|
267
298
|
raise CondParserError(
|
|
@@ -274,13 +305,17 @@ class CondParser(CondTokens):
|
|
|
274
305
|
|
|
275
306
|
|
|
276
307
|
class NumericParser(NumTokens):
|
|
308
|
+
"""Recursive-descent parser for arithmetic numeric expressions."""
|
|
309
|
+
|
|
277
310
|
# Takes a numeric expression string
|
|
278
311
|
def __init__(self, numexpr: str) -> None:
|
|
312
|
+
"""Initialise the parser with the numeric expression string."""
|
|
279
313
|
self.num_expr = SourceString(numexpr)
|
|
280
314
|
self.rxint = re.compile(r"(?P<int_num>[+-]?[0-9]+)")
|
|
281
315
|
self.rxfloat = re.compile(r"(?P<float_num>[+-]?(?:(?:[0-9]*\.[0-9]+)|(?:[0-9]+\.[0-9]*)))")
|
|
282
316
|
|
|
283
317
|
def match_number(self) -> Any | None:
|
|
318
|
+
"""Match a float or integer literal and return its numeric value, or None."""
|
|
284
319
|
# Try to match a number in the source string.
|
|
285
320
|
# Return it if matched, return None if unmatched.
|
|
286
321
|
m1 = self.num_expr.match_regex(self.rxfloat)
|
|
@@ -293,6 +328,7 @@ class NumericParser(NumTokens):
|
|
|
293
328
|
return None
|
|
294
329
|
|
|
295
330
|
def match_mulop(self) -> int | None:
|
|
331
|
+
"""Match a multiplication or division operator and return its token type, or None."""
|
|
296
332
|
# Try to match a multiplication or division operator in the source string.
|
|
297
333
|
# if found, return the matching operator type. If not found, return None.
|
|
298
334
|
m1 = self.num_expr.match_str("*")
|
|
@@ -305,6 +341,7 @@ class NumericParser(NumTokens):
|
|
|
305
341
|
return None
|
|
306
342
|
|
|
307
343
|
def match_addop(self) -> int | None:
|
|
344
|
+
"""Match an addition or subtraction operator and return its token type, or None."""
|
|
308
345
|
# Try to match an addition or subtraction operator in the source string.
|
|
309
346
|
# if found, return the matching operator type. If not found, return None.
|
|
310
347
|
m1 = self.num_expr.match_str("+")
|
|
@@ -317,6 +354,7 @@ class NumericParser(NumTokens):
|
|
|
317
354
|
return None
|
|
318
355
|
|
|
319
356
|
def factor(self) -> Any:
|
|
357
|
+
"""Parse a numeric factor: a number literal or a parenthesised expression."""
|
|
320
358
|
# Parses a factor out of the source string and returns the
|
|
321
359
|
# AST node that is created.
|
|
322
360
|
m1 = self.match_number()
|
|
@@ -338,6 +376,7 @@ class NumericParser(NumTokens):
|
|
|
338
376
|
)
|
|
339
377
|
|
|
340
378
|
def term(self) -> Any:
|
|
379
|
+
"""Parse a term: a factor optionally followed by MUL/DIV and another term."""
|
|
341
380
|
# Parses a term out of the source string and returns the
|
|
342
381
|
# AST node that is created.
|
|
343
382
|
m1 = self.factor()
|
|
@@ -349,6 +388,7 @@ class NumericParser(NumTokens):
|
|
|
349
388
|
return m1
|
|
350
389
|
|
|
351
390
|
def expression(self) -> Any:
|
|
391
|
+
"""Parse an expression: a term optionally followed by ADD/SUB and another expression."""
|
|
352
392
|
# Parses an expression out of the source string and returns the
|
|
353
393
|
# AST node that is created.
|
|
354
394
|
e1 = self.term()
|
|
@@ -362,6 +402,7 @@ class NumericParser(NumTokens):
|
|
|
362
402
|
return e1
|
|
363
403
|
|
|
364
404
|
def parse(self) -> Any:
|
|
405
|
+
"""Parse the entire numeric expression and return the AST root."""
|
|
365
406
|
exp = self.expression()
|
|
366
407
|
if not self.num_expr.eoi():
|
|
367
408
|
raise NumericParserError(
|
execsql/script/control.py
CHANGED
execsql/script/engine.py
CHANGED
|
@@ -40,6 +40,25 @@ from execsql.script.variables import LocalSubVarSet, ScriptArgSubVarSet, SubVarS
|
|
|
40
40
|
from execsql.utils.errors import exception_desc
|
|
41
41
|
from execsql.utils.fileio import EncodedFile
|
|
42
42
|
|
|
43
|
+
__all__ = [
|
|
44
|
+
"MetaCommand",
|
|
45
|
+
"MetaCommandList",
|
|
46
|
+
"SqlStmt",
|
|
47
|
+
"MetacommandStmt",
|
|
48
|
+
"ScriptCmd",
|
|
49
|
+
"CommandList",
|
|
50
|
+
"CommandListWhileLoop",
|
|
51
|
+
"CommandListUntilLoop",
|
|
52
|
+
"ScriptFile",
|
|
53
|
+
"ScriptExecSpec",
|
|
54
|
+
"set_system_vars",
|
|
55
|
+
"substitute_vars",
|
|
56
|
+
"runscripts",
|
|
57
|
+
"current_script_line",
|
|
58
|
+
"read_sqlfile",
|
|
59
|
+
"read_sqlstring",
|
|
60
|
+
]
|
|
61
|
+
|
|
43
62
|
|
|
44
63
|
# ---------------------------------------------------------------------------
|
|
45
64
|
# MetaCommand / MetaCommandList
|
execsql/script/variables.py
CHANGED
|
@@ -15,6 +15,8 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
from execsql.exceptions import ErrInfo
|
|
17
17
|
|
|
18
|
+
__all__ = ["CounterVars", "SubVarSet", "LocalSubVarSet", "ScriptArgSubVarSet"]
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
# ---------------------------------------------------------------------------
|
|
20
22
|
# CounterVars
|
|
@@ -184,15 +186,17 @@ class SubVarSet:
|
|
|
184
186
|
return template_str.lower() in self._subs_dict
|
|
185
187
|
|
|
186
188
|
def merge(self, other_subvars: SubVarSet | None) -> SubVarSet:
|
|
187
|
-
"""Return a new SubVarSet with this object's variables merged with other_subvars.
|
|
189
|
+
"""Return a new SubVarSet with this object's variables merged with other_subvars.
|
|
190
|
+
|
|
191
|
+
Copies dictionaries and pre-compiled patterns directly instead of
|
|
192
|
+
re-adding variables one at a time, avoiding O(V) regex recompilation.
|
|
193
|
+
"""
|
|
188
194
|
if other_subvars is not None:
|
|
189
195
|
newsubs = SubVarSet()
|
|
190
|
-
newsubs._subs_dict =
|
|
191
|
-
newsubs._compiled_patterns =
|
|
196
|
+
newsubs._subs_dict = {**self._subs_dict, **other_subvars._subs_dict}
|
|
197
|
+
newsubs._compiled_patterns = {**self._compiled_patterns, **other_subvars._compiled_patterns}
|
|
192
198
|
newsubs.prefix_list = list(set(self.prefix_list + other_subvars.prefix_list))
|
|
193
199
|
newsubs.compile_var_rx()
|
|
194
|
-
for varname, value in other_subvars._subs_dict.items():
|
|
195
|
-
newsubs.add_substitution(varname, value)
|
|
196
200
|
return newsubs
|
|
197
201
|
return self
|
|
198
202
|
|
execsql/state.py
CHANGED
|
@@ -55,6 +55,58 @@ if TYPE_CHECKING:
|
|
|
55
55
|
from execsql.utils.mail import MailSpec
|
|
56
56
|
from execsql.utils.timer import Timer
|
|
57
57
|
|
|
58
|
+
__all__ = [
|
|
59
|
+
# Configuration / encoding
|
|
60
|
+
"conf",
|
|
61
|
+
"logfile_encoding",
|
|
62
|
+
# Runtime state
|
|
63
|
+
"last_command",
|
|
64
|
+
"upass",
|
|
65
|
+
"varlike",
|
|
66
|
+
"err_halt_writespec",
|
|
67
|
+
"err_halt_email",
|
|
68
|
+
"err_halt_exec",
|
|
69
|
+
"cancel_halt_writespec",
|
|
70
|
+
"cancel_halt_mailspec",
|
|
71
|
+
"cancel_halt_exec",
|
|
72
|
+
"commandliststack",
|
|
73
|
+
"savedscripts",
|
|
74
|
+
"loopcommandstack",
|
|
75
|
+
"compiling_loop",
|
|
76
|
+
"endloop_rx",
|
|
77
|
+
"loop_rx",
|
|
78
|
+
"loop_nest_level",
|
|
79
|
+
"cmds_run",
|
|
80
|
+
"defer_rx",
|
|
81
|
+
"stringtypes",
|
|
82
|
+
"exec_log",
|
|
83
|
+
"subvars",
|
|
84
|
+
"status",
|
|
85
|
+
# Lazy singletons
|
|
86
|
+
"if_stack",
|
|
87
|
+
"counters",
|
|
88
|
+
"timer",
|
|
89
|
+
"output",
|
|
90
|
+
"dbs",
|
|
91
|
+
"tempfiles",
|
|
92
|
+
"export_metadata",
|
|
93
|
+
"metacommandlist",
|
|
94
|
+
"conditionallist",
|
|
95
|
+
"filewriter",
|
|
96
|
+
"gui_console",
|
|
97
|
+
"gui_manager_queue",
|
|
98
|
+
"gui_manager_thread",
|
|
99
|
+
# Version
|
|
100
|
+
"primary_vno",
|
|
101
|
+
"secondary_vno",
|
|
102
|
+
"tertiary_vno",
|
|
103
|
+
# Functions
|
|
104
|
+
"xcmd_test",
|
|
105
|
+
"endloop",
|
|
106
|
+
"reset",
|
|
107
|
+
"initialize",
|
|
108
|
+
]
|
|
109
|
+
|
|
58
110
|
# ---------------------------------------------------------------------------
|
|
59
111
|
# Configuration / encoding
|
|
60
112
|
# ---------------------------------------------------------------------------
|
execsql/types.py
CHANGED
|
@@ -70,6 +70,8 @@ __all__ = [
|
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
class DataType:
|
|
73
|
+
"""Abstract base class for all data-type matchers used during column inference."""
|
|
74
|
+
|
|
73
75
|
data_type_name = None
|
|
74
76
|
data_type = None
|
|
75
77
|
lenspec = False # Is a length specification required for a (SQL) declaration of this data type?
|
|
@@ -83,9 +85,11 @@ class DataType:
|
|
|
83
85
|
return f"DataType({self.data_type_name!r}, {self.data_type!r})"
|
|
84
86
|
|
|
85
87
|
def is_null(self, data: object) -> bool:
|
|
88
|
+
"""Return True if the data value is None."""
|
|
86
89
|
return data is None
|
|
87
90
|
|
|
88
91
|
def matches(self, data: object) -> bool:
|
|
92
|
+
"""Return True if the non-null data value could be of this data type."""
|
|
89
93
|
# Returns T/F indicating whether the given data value could be of this data type.
|
|
90
94
|
# The data value should be non-null.
|
|
91
95
|
if self.is_null(data):
|
|
@@ -93,6 +97,7 @@ class DataType:
|
|
|
93
97
|
return self._is_match(data)
|
|
94
98
|
|
|
95
99
|
def from_data(self, data: object) -> object:
|
|
100
|
+
"""Coerce the data value to this type or raise DataTypeError."""
|
|
96
101
|
# Returns the data value coerced to this type, or raises a DataTypeError exception.
|
|
97
102
|
# The data value should be non-null.
|
|
98
103
|
if self.is_null(data):
|
|
@@ -123,16 +128,22 @@ class DataType:
|
|
|
123
128
|
|
|
124
129
|
|
|
125
130
|
class Tz(datetime.tzinfo):
|
|
131
|
+
"""Fixed-offset timezone implementation for timestamp-with-timezone parsing."""
|
|
132
|
+
|
|
126
133
|
def __init__(self, sign: int, hr: int, min: int) -> None:
|
|
134
|
+
"""Store the UTC offset as sign, hours, and minutes."""
|
|
127
135
|
self.sign = sign
|
|
128
136
|
self.hr = hr
|
|
129
137
|
self.min = min
|
|
130
138
|
|
|
131
139
|
def utcoffset(self, dt: object) -> datetime.timedelta:
|
|
140
|
+
"""Return the UTC offset as a timedelta."""
|
|
132
141
|
return self.sign * datetime.timedelta(hours=self.hr, minutes=self.min)
|
|
133
142
|
|
|
134
143
|
|
|
135
144
|
class DT_TimestampTZ(DataType):
|
|
145
|
+
"""Timezone-aware timestamp data type."""
|
|
146
|
+
|
|
136
147
|
data_type_name = "timestamptz"
|
|
137
148
|
data_type = datetime.datetime
|
|
138
149
|
# There is no distinct Python type corresponding to a timestamptz, so the data_type
|
|
@@ -166,6 +177,8 @@ class DT_TimestampTZ(DataType):
|
|
|
166
177
|
|
|
167
178
|
|
|
168
179
|
class DT_Timestamp(DataType):
|
|
180
|
+
"""Naive timestamp (datetime without timezone) data type."""
|
|
181
|
+
|
|
169
182
|
data_type_name = "timestamp"
|
|
170
183
|
data_type = datetime.datetime
|
|
171
184
|
|
|
@@ -207,10 +220,13 @@ date_fmts = collections.deque(
|
|
|
207
220
|
|
|
208
221
|
|
|
209
222
|
class DT_Date(DataType):
|
|
223
|
+
"""Calendar date data type with multiple format recognition."""
|
|
224
|
+
|
|
210
225
|
data_type_name = "date"
|
|
211
226
|
data_type = datetime.date
|
|
212
227
|
|
|
213
228
|
def __init__(self) -> None:
|
|
229
|
+
"""Initialise the date format deque for adaptive format matching."""
|
|
214
230
|
self._date_fmts = collections.deque(date_fmts)
|
|
215
231
|
|
|
216
232
|
def __repr__(self) -> str:
|
|
@@ -239,6 +255,8 @@ class DT_Date(DataType):
|
|
|
239
255
|
|
|
240
256
|
|
|
241
257
|
class DT_Time(DataType):
|
|
258
|
+
"""Time-of-day data type with multiple format recognition."""
|
|
259
|
+
|
|
242
260
|
data_type_name = "time"
|
|
243
261
|
data_type = datetime.time
|
|
244
262
|
time_fmts = (
|
|
@@ -288,6 +306,8 @@ class DT_Time_Oracle(DT_Time):
|
|
|
288
306
|
|
|
289
307
|
|
|
290
308
|
class DT_Boolean(DataType):
|
|
309
|
+
"""Boolean data type recognising word and integer true/false forms."""
|
|
310
|
+
|
|
291
311
|
data_type_name = "boolean"
|
|
292
312
|
data_type = bool
|
|
293
313
|
|
|
@@ -295,6 +315,7 @@ class DT_Boolean(DataType):
|
|
|
295
315
|
return "DT_Boolean()"
|
|
296
316
|
|
|
297
317
|
def set_bool_matches(self) -> None:
|
|
318
|
+
"""Populate the true/false match tuples from the current configuration."""
|
|
298
319
|
import execsql.state as _state
|
|
299
320
|
|
|
300
321
|
conf = _state.conf
|
|
@@ -342,6 +363,8 @@ class DT_Boolean(DataType):
|
|
|
342
363
|
|
|
343
364
|
|
|
344
365
|
class DT_Integer(DataType):
|
|
366
|
+
"""Small integer data type bounded by the configured max_int."""
|
|
367
|
+
|
|
345
368
|
data_type_name = "integer"
|
|
346
369
|
data_type = int
|
|
347
370
|
|
|
@@ -391,6 +414,8 @@ class DT_Integer(DataType):
|
|
|
391
414
|
|
|
392
415
|
|
|
393
416
|
class DT_Long(DataType):
|
|
417
|
+
"""Large integer (bigint) data type using Python int."""
|
|
418
|
+
|
|
394
419
|
data_type_name = "long"
|
|
395
420
|
data_type = int # In Python 3, long is just int
|
|
396
421
|
|
|
@@ -425,6 +450,8 @@ class DT_Long(DataType):
|
|
|
425
450
|
|
|
426
451
|
|
|
427
452
|
class DT_Float(DataType):
|
|
453
|
+
"""IEEE double-precision floating-point data type."""
|
|
454
|
+
|
|
428
455
|
data_type_name = "float"
|
|
429
456
|
data_type = float
|
|
430
457
|
|
|
@@ -463,6 +490,8 @@ class DT_Float(DataType):
|
|
|
463
490
|
|
|
464
491
|
|
|
465
492
|
class DT_Decimal(DataType):
|
|
493
|
+
"""Exact decimal data type tracking precision and scale."""
|
|
494
|
+
|
|
466
495
|
data_type_name = "decimal"
|
|
467
496
|
data_type = Decimal
|
|
468
497
|
precspec = True
|
|
@@ -471,6 +500,7 @@ class DT_Decimal(DataType):
|
|
|
471
500
|
return "DT_Decimal()"
|
|
472
501
|
|
|
473
502
|
def set_scale_prec(self, dec: Decimal) -> None:
|
|
503
|
+
"""Compute and store the precision and scale from a Decimal value."""
|
|
474
504
|
# 'dec' should be Decimal.
|
|
475
505
|
x = dec.as_tuple()
|
|
476
506
|
digits = len(x.digits)
|
|
@@ -501,6 +531,8 @@ class DT_Decimal(DataType):
|
|
|
501
531
|
|
|
502
532
|
|
|
503
533
|
class DT_Character(DataType):
|
|
534
|
+
"""Fixed-length string data type (up to 255 characters)."""
|
|
535
|
+
|
|
504
536
|
data_type_name = "character"
|
|
505
537
|
lenspec = True
|
|
506
538
|
|
|
@@ -530,6 +562,8 @@ class DT_Character(DataType):
|
|
|
530
562
|
|
|
531
563
|
|
|
532
564
|
class DT_Varchar(DataType):
|
|
565
|
+
"""Variable-length string data type (up to 255 characters)."""
|
|
566
|
+
|
|
533
567
|
data_type_name = "varchar"
|
|
534
568
|
lenspec = True
|
|
535
569
|
varlen = True
|
|
@@ -558,6 +592,8 @@ class DT_Varchar(DataType):
|
|
|
558
592
|
|
|
559
593
|
|
|
560
594
|
class DT_Text(DataType):
|
|
595
|
+
"""Unbounded text string data type."""
|
|
596
|
+
|
|
561
597
|
data_type_name = "character"
|
|
562
598
|
|
|
563
599
|
def __repr__(self) -> str:
|
|
@@ -581,6 +617,8 @@ class DT_Text(DataType):
|
|
|
581
617
|
|
|
582
618
|
|
|
583
619
|
class DT_Binary(DataType):
|
|
620
|
+
"""Binary byte-array data type."""
|
|
621
|
+
|
|
584
622
|
data_type_name = "binary"
|
|
585
623
|
data_type = bytearray
|
|
586
624
|
|
|
@@ -589,7 +627,10 @@ class DT_Binary(DataType):
|
|
|
589
627
|
|
|
590
628
|
|
|
591
629
|
class DbType:
|
|
630
|
+
"""Map Python DataType subclasses to DBMS-specific SQL type names."""
|
|
631
|
+
|
|
592
632
|
def __init__(self, DBMS_id: str, db_obj_quotes: str = '""') -> None:
|
|
633
|
+
"""Initialise a DBMS dialect with its identifier and quoting characters."""
|
|
593
634
|
# The DBMS_id is the name by which this DBMS is identified.
|
|
594
635
|
# db_obj_quotechars is a string of two characters that are the opening and closing quotes
|
|
595
636
|
# for identifiers (schema, table, and column names) that need to be quoted.
|
|
@@ -621,6 +662,7 @@ class DbType:
|
|
|
621
662
|
precision: object = None,
|
|
622
663
|
scale: object = None,
|
|
623
664
|
) -> None:
|
|
665
|
+
"""Register a DBMS-specific SQL type name for a DataType class."""
|
|
624
666
|
# data_type is a DataType class object.
|
|
625
667
|
# dbms_name is the DBMS-specific name for this data type.
|
|
626
668
|
# length_required indicates whether length information is required.
|
|
@@ -631,6 +673,7 @@ class DbType:
|
|
|
631
673
|
self.dialect[data_type] = (dbms_name, length_required, casting_name, conv_mod_fn, precision, scale)
|
|
632
674
|
|
|
633
675
|
def datatype_name(self, data_type: object) -> str:
|
|
676
|
+
"""Return the DBMS-specific SQL type name for the given DataType class."""
|
|
634
677
|
# A convenience function to simplify access to data type names.
|
|
635
678
|
try:
|
|
636
679
|
return self.dialect[data_type][0]
|
|
@@ -642,6 +685,7 @@ class DbType:
|
|
|
642
685
|
) from e
|
|
643
686
|
|
|
644
687
|
def quoted(self, dbms_object: str) -> str:
|
|
688
|
+
"""Quote a database identifier if it contains non-word characters."""
|
|
645
689
|
if re.search(r"\W", dbms_object):
|
|
646
690
|
if self.quotechars[0] == self.quotechars[1] and self.quotechars[0] in dbms_object:
|
|
647
691
|
dbms_object = dbms_object.replace(self.quotechars[0], self.quotechars[0] + self.quotechars[0])
|
|
@@ -649,6 +693,7 @@ class DbType:
|
|
|
649
693
|
return dbms_object
|
|
650
694
|
|
|
651
695
|
def spec_type(self, data_type: object) -> object:
|
|
696
|
+
"""Return the translated data type, or the original if no translation exists."""
|
|
652
697
|
# Returns a translated data type or the original if there is no translation.
|
|
653
698
|
if data_type in self.dt_xlate:
|
|
654
699
|
return self.dt_xlate[data_type]
|
|
@@ -663,6 +708,7 @@ class DbType:
|
|
|
663
708
|
precision: object = None,
|
|
664
709
|
scale: object = None,
|
|
665
710
|
) -> str:
|
|
711
|
+
"""Return a column specification string suitable for a CREATE TABLE statement."""
|
|
666
712
|
# Returns a column specification as it would be used in a CREATE TABLE statement.
|
|
667
713
|
data_type = self.spec_type(data_type)
|
|
668
714
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: execsql2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
|
|
5
5
|
Project-URL: Repository, https://github.com/geocoug/execsql
|
|
6
6
|
Project-URL: Issues, https://github.com/geocoug/execsql/issues
|
|
@@ -107,7 +107,7 @@ Description-Content-Type: text/markdown
|
|
|
107
107
|
|
|
108
108
|
> [!NOTE]
|
|
109
109
|
> **This is a maintained fork of [execsql](https://execsql.readthedocs.io/).**
|
|
110
|
-
> The original monolith has been fully refactored into a modular package
|
|
110
|
+
> The original monolith has been fully refactored into a modular package.
|
|
111
111
|
> The CLI and configuration are backwards-compatible with upstream v1.130.1.
|
|
112
112
|
> Report issues at [github.com/geocoug/execsql/issues](https://github.com/geocoug/execsql/issues).
|
|
113
113
|
|
|
@@ -215,8 +215,12 @@ execsql script.sql # read connection from config file
|
|
|
215
215
|
| `-m` | List metacommands and exit |
|
|
216
216
|
| `-n` | Create a new SQLite or PostgreSQL database if it does not exist |
|
|
217
217
|
| `-v {0,1,2,3}` | GUI level (0=none, 1=password, 2=selection, 3=full) |
|
|
218
|
-
| `--gui-framework {tkinter,textual}` | GUI framework for interactive prompts |
|
|
219
218
|
| `-w` | Skip password prompt when a username is supplied |
|
|
219
|
+
| `--dsn URL` | Connection string (e.g. `postgresql://user:pass@host/db`) |
|
|
220
|
+
| `--dry-run` | Parse the script and report commands without executing |
|
|
221
|
+
| `--progress` | Show a progress bar for long-running IMPORT operations |
|
|
222
|
+
| `--dump-keywords` | Print metacommand keywords as JSON and exit |
|
|
223
|
+
| `--gui-framework {tkinter,textual}` | GUI framework for interactive prompts |
|
|
220
224
|
|
|
221
225
|
Run `execsql --help` for the full option list, or `execsql -m` to list all metacommands.
|
|
222
226
|
|
|
@@ -283,7 +287,30 @@ execsql-format --in-place scripts/
|
|
|
283
287
|
execsql-format --check scripts/
|
|
284
288
|
```
|
|
285
289
|
|
|
286
|
-
|
|
290
|
+
`execsql-format` is also available as a [pre-commit](https://pre-commit.com/) hook:
|
|
291
|
+
|
|
292
|
+
```yaml
|
|
293
|
+
repos:
|
|
294
|
+
- repo: https://github.com/geocoug/execsql
|
|
295
|
+
rev: v2.4.4
|
|
296
|
+
hooks:
|
|
297
|
+
- id: execsql-format
|
|
298
|
+
args: [--in-place]
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
See the [formatter documentation](https://execsql2.readthedocs.io/en/latest/guides/formatter/) for all options.
|
|
302
|
+
|
|
303
|
+
# VS Code Syntax Highlighting
|
|
304
|
+
|
|
305
|
+
A VS Code extension for execsql syntax highlighting is included in [`extras/vscode-execsql`](extras/vscode-execsql). It injects a TextMate grammar into `.sql` files, adding highlighting for `-- !x!` metacommand markers, keywords (control flow, block, action, directive), variable substitutions (`!!var!!`, `!{var}!`), built-in functions, export formats, and config options — all layered on top of standard SQL highlighting.
|
|
306
|
+
|
|
307
|
+
To install, symlink the extension folder into your VS Code extensions directory:
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
ln -s /path/to/execsql/extras/vscode-execsql ~/.vscode/extensions/execsql-syntax
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
See the [extension README](extras/vscode-execsql/README.md) for Windows instructions, color customization, and troubleshooting.
|
|
287
314
|
|
|
288
315
|
# Templates
|
|
289
316
|
|