execsql2 2.23.0__py3-none-any.whl → 2.24.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 +17 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +233 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +451 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +513 -76
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +41 -14
- execsql/config.py +251 -154
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +24 -1
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +22 -6
- execsql/db/base.py +105 -1
- execsql/db/dsn.py +2 -0
- execsql/db/duckdb.py +95 -2
- execsql/db/factory.py +2 -2
- execsql/db/mysql.py +11 -0
- execsql/db/oracle.py +12 -4
- execsql/db/sqlite.py +16 -0
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/format.py +87 -75
- execsql/interactive.py +232 -0
- execsql/manifest.py +189 -0
- execsql/metacommands/conditions.py +1 -1
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/effects.py +124 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +6 -93
- execsql/script/executor.py +153 -34
- execsql/state.py +11 -2
- execsql/state.pyi +3 -0
- execsql/utils/color.py +18 -0
- execsql/utils/errors.py +11 -1
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.0.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Check execsql.conf files and report every problem with its file and line.
|
|
2
|
+
|
|
3
|
+
A run reads config files with :class:`~execsql.config.ConfigData`, which stops
|
|
4
|
+
at the first invalid value and silently ignores anything it does not
|
|
5
|
+
recognize — a misspelled key or section simply does nothing. This module
|
|
6
|
+
backs ``execsql config --validate``: it reads the same files in the same order
|
|
7
|
+
and reports all of it.
|
|
8
|
+
|
|
9
|
+
Values are checked by :meth:`ConfigData._apply_file`, the method a run uses,
|
|
10
|
+
applied to one option at a time on a fresh instance. Validation therefore
|
|
11
|
+
cannot accept a value a run rejects, or reject one a run accepts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import configparser
|
|
17
|
+
import difflib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import warnings
|
|
22
|
+
from collections import deque
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from execsql.config import ConfigData, describe_parse_error
|
|
29
|
+
from execsql.exceptions import ConfigError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from execsql.config import _VariablePool
|
|
33
|
+
|
|
34
|
+
__all__ = ["ConfigProblem", "known_options", "validate_config"]
|
|
35
|
+
|
|
36
|
+
#: Sections whose keys are free-form: variable names, and numbered include files.
|
|
37
|
+
_FREE_SECTIONS = frozenset({"variables", "include_required", "include_optional", configparser.DEFAULTSECT})
|
|
38
|
+
|
|
39
|
+
#: Keys that chain to another config file, and the platform each applies on.
|
|
40
|
+
_CHAIN_KEYS = {
|
|
41
|
+
"config_file": None,
|
|
42
|
+
"linux_config_file": "linux",
|
|
43
|
+
"macos_config_file": "darwin",
|
|
44
|
+
"win_config_file": "win32",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ConfigProblem:
|
|
50
|
+
"""One problem in one config file.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
file: The config file.
|
|
54
|
+
line: 1-based line number, or ``0`` when the problem has no single line.
|
|
55
|
+
severity: ``"error"`` — a run fails on it — or ``"warning"`` — a run
|
|
56
|
+
silently ignores it.
|
|
57
|
+
message: What is wrong, and the likely fix when there is one.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
file: str
|
|
61
|
+
line: int
|
|
62
|
+
severity: str
|
|
63
|
+
message: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def known_options() -> dict[str, set[str]]:
|
|
67
|
+
"""Every section execsql reads, and the keys it reads in each.
|
|
68
|
+
|
|
69
|
+
Free-form sections (``[variables]`` and the include sections) map to an
|
|
70
|
+
empty set: any key is accepted there.
|
|
71
|
+
"""
|
|
72
|
+
probe = ConfigData.__new__(ConfigData)
|
|
73
|
+
probe._set_defaults()
|
|
74
|
+
probe._read_known_options(configparser.ConfigParser()) # fills ConfigData._option_keys
|
|
75
|
+
known: dict[str, set[str]] = {}
|
|
76
|
+
for section, key, _attr in ConfigData._option_keys:
|
|
77
|
+
known.setdefault(section, set()).add(key)
|
|
78
|
+
known.setdefault(ConfigData._CONFIG_SECTION, set()).update(_CHAIN_KEYS)
|
|
79
|
+
for section in _FREE_SECTIONS - {configparser.DEFAULTSECT}:
|
|
80
|
+
known[section] = set()
|
|
81
|
+
return known
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _line_numbers(text: str) -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
|
85
|
+
"""Where each section header and each key is, by 1-based line number.
|
|
86
|
+
|
|
87
|
+
Keys are lower-cased, as :class:`configparser.ConfigParser` does.
|
|
88
|
+
Continuation lines (indented) and comments are skipped.
|
|
89
|
+
"""
|
|
90
|
+
sections: dict[str, int] = {}
|
|
91
|
+
keys: dict[tuple[str, str], int] = {}
|
|
92
|
+
current: str | None = None
|
|
93
|
+
for number, raw in enumerate(text.splitlines(), 1):
|
|
94
|
+
line = raw.strip()
|
|
95
|
+
if not line or line[0] in "#;":
|
|
96
|
+
continue
|
|
97
|
+
header = re.fullmatch(r"\[(.+)\]", line)
|
|
98
|
+
if header:
|
|
99
|
+
current = header.group(1)
|
|
100
|
+
sections.setdefault(current, number)
|
|
101
|
+
continue
|
|
102
|
+
if raw[0].isspace() or current is None:
|
|
103
|
+
continue
|
|
104
|
+
option = re.match(r"([^=:]+?)\s*[=:]", line)
|
|
105
|
+
if option:
|
|
106
|
+
keys[(current, option.group(1).strip().lower())] = number
|
|
107
|
+
return sections, keys
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _suggest(word: str, candidates: Iterable[str]) -> str | None:
|
|
111
|
+
match = difflib.get_close_matches(word, sorted(candidates), n=1, cutoff=0.7)
|
|
112
|
+
return match[0] if match else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_value(
|
|
116
|
+
section: str,
|
|
117
|
+
key: str,
|
|
118
|
+
value: str,
|
|
119
|
+
variable_pool: _VariablePool,
|
|
120
|
+
current_script: str,
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Apply one option the way a run would; return why it fails, or ``None``."""
|
|
123
|
+
single = configparser.ConfigParser()
|
|
124
|
+
single.read_dict({section: {key: value.replace("%", "%%")}})
|
|
125
|
+
probe = ConfigData.__new__(ConfigData)
|
|
126
|
+
probe._set_defaults()
|
|
127
|
+
try:
|
|
128
|
+
with warnings.catch_warnings():
|
|
129
|
+
warnings.simplefilter("ignore") # enc_password's deprecation notice
|
|
130
|
+
probe._apply_file(single, variable_pool, current_script)
|
|
131
|
+
except ConfigError as exc:
|
|
132
|
+
return str(exc)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
if section in ("include_required", "include_optional"):
|
|
135
|
+
return f"keys in [{section}] must be numbers, not {key!r}"
|
|
136
|
+
return str(exc)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return f"{type(exc).__name__}: {exc}"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_rule_codes(value: str) -> str | None:
|
|
143
|
+
"""Rule codes in ``[lint] select`` / ``ignore``, checked as ``execsql lint`` checks them."""
|
|
144
|
+
from execsql.cli.lint import resolve_selectors
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
resolve_selectors([value])
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
return str(exc)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_secret(key: str) -> bool:
|
|
154
|
+
"""Whether *key* holds a password (``password``, ``enc_password``, ``passwd``)."""
|
|
155
|
+
return "passw" in key.lower()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_file(
|
|
159
|
+
path: str,
|
|
160
|
+
known: dict[str, set[str]],
|
|
161
|
+
variable_pool: _VariablePool,
|
|
162
|
+
current_script: str,
|
|
163
|
+
) -> tuple[list[ConfigProblem], list[str]]:
|
|
164
|
+
"""Every problem in one file, and the config files it chains to (in queue order)."""
|
|
165
|
+
problems: list[ConfigProblem] = []
|
|
166
|
+
|
|
167
|
+
def problem(line: int, severity: str, message: str) -> None:
|
|
168
|
+
problems.append(ConfigProblem(path, line, severity, message))
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
with open(path) as f: # the encoding ConfigParser.read uses
|
|
172
|
+
text = f.read()
|
|
173
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
174
|
+
problem(0, "error", f"cannot read: {exc}")
|
|
175
|
+
return problems, []
|
|
176
|
+
|
|
177
|
+
cp = configparser.ConfigParser()
|
|
178
|
+
try:
|
|
179
|
+
cp.read_string(text, source=path)
|
|
180
|
+
except configparser.ParsingError as exc:
|
|
181
|
+
if isinstance(exc, configparser.MissingSectionHeaderError):
|
|
182
|
+
line, text = describe_parse_error(exc)
|
|
183
|
+
problem(line, "error", text)
|
|
184
|
+
else: # every unparsable line, not only the first
|
|
185
|
+
for err_line, err_text in exc.errors:
|
|
186
|
+
problem(err_line, "error", f"cannot parse line: {err_text.strip()}")
|
|
187
|
+
return problems, []
|
|
188
|
+
except configparser.Error as exc:
|
|
189
|
+
line, text = describe_parse_error(exc)
|
|
190
|
+
problem(line, "error", text)
|
|
191
|
+
return problems, []
|
|
192
|
+
|
|
193
|
+
section_lines, key_lines = _line_numbers(text)
|
|
194
|
+
|
|
195
|
+
for section in cp.sections():
|
|
196
|
+
if section not in known:
|
|
197
|
+
hint = _suggest(section, known)
|
|
198
|
+
problem(
|
|
199
|
+
section_lines.get(section, 0),
|
|
200
|
+
"warning",
|
|
201
|
+
f"unknown section [{section}]" + (f"; did you mean [{hint}]?" if hint else "; a run ignores it"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
chained: list[str] = []
|
|
205
|
+
for (section, key), line in key_lines.items():
|
|
206
|
+
if section not in known and section != configparser.DEFAULTSECT:
|
|
207
|
+
continue # reported once, as an unknown section
|
|
208
|
+
if section not in _FREE_SECTIONS and key not in known[section]:
|
|
209
|
+
elsewhere = sorted(s for s, keys in known.items() if key in keys)
|
|
210
|
+
if elsewhere:
|
|
211
|
+
message = f"{key} belongs in [{elsewhere[0]}], not [{section}]"
|
|
212
|
+
else:
|
|
213
|
+
hint = _suggest(key, known[section])
|
|
214
|
+
message = f"unknown key {key!r} in [{section}]" + (
|
|
215
|
+
f"; did you mean {hint!r}?" if hint else "; a run ignores it"
|
|
216
|
+
)
|
|
217
|
+
problem(line, "warning", message)
|
|
218
|
+
continue
|
|
219
|
+
if section == configparser.DEFAULTSECT:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
value = cp.get(section, key)
|
|
223
|
+
except configparser.Error as exc:
|
|
224
|
+
problem(line, "error", describe_parse_error(exc)[1])
|
|
225
|
+
continue
|
|
226
|
+
if key in _CHAIN_KEYS:
|
|
227
|
+
platform = _CHAIN_KEYS[key]
|
|
228
|
+
if platform and not sys.platform.startswith(platform):
|
|
229
|
+
continue # another platform's file; a run here never reads it
|
|
230
|
+
expand_home = os.name == "posix" if key == "config_file" else True
|
|
231
|
+
target = ConfigData._chain_target(value, variable_pool, expand_home=expand_home)
|
|
232
|
+
if Path(target).is_file():
|
|
233
|
+
chained.append(target)
|
|
234
|
+
else:
|
|
235
|
+
problem(line, "warning", f"{key} = {value}: no such file; a run skips it")
|
|
236
|
+
continue
|
|
237
|
+
failure = _check_value(section, key, value, variable_pool, current_script)
|
|
238
|
+
if failure is None and section == ConfigData._LINT_SECTION:
|
|
239
|
+
failure = _check_rule_codes(value)
|
|
240
|
+
if failure:
|
|
241
|
+
if _is_secret(key):
|
|
242
|
+
# Never echo a password, even an obfuscated one, into logs.
|
|
243
|
+
failure = failure.replace(value, "***") if value else failure
|
|
244
|
+
value = "***"
|
|
245
|
+
problem(line, "error", f"{key} = {value}: {failure}")
|
|
246
|
+
|
|
247
|
+
return problems, chained
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def validate_config(
|
|
251
|
+
script_path: str,
|
|
252
|
+
variable_pool: _VariablePool,
|
|
253
|
+
config_file: str | None = None,
|
|
254
|
+
) -> tuple[list[str], list[ConfigProblem]]:
|
|
255
|
+
"""Check every config file a run from *script_path* would read.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
script_path: Directory of the script a run would execute; its
|
|
259
|
+
``execsql.conf`` is included, as for a run.
|
|
260
|
+
variable_pool: Substitution variables for expanding chained file paths
|
|
261
|
+
and checking ``[variables]`` names.
|
|
262
|
+
config_file: The file ``--config`` names, read last.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The files checked, in the order a run reads them, and every problem
|
|
266
|
+
found, in file order and then line order.
|
|
267
|
+
"""
|
|
268
|
+
known = known_options()
|
|
269
|
+
current_script = str(Path(sys.argv[0]).resolve())
|
|
270
|
+
queue: deque[str] = deque(ConfigData._search_paths(script_path, config_file))
|
|
271
|
+
checked: list[str] = []
|
|
272
|
+
problems: list[ConfigProblem] = []
|
|
273
|
+
while queue and len(checked) < ConfigData._MAX_CONFIG_CHAIN:
|
|
274
|
+
path = queue.popleft()
|
|
275
|
+
if path in checked or not Path(path).is_file():
|
|
276
|
+
continue
|
|
277
|
+
checked.append(path)
|
|
278
|
+
found, chained = _check_file(path, known, variable_pool, current_script)
|
|
279
|
+
problems.extend(sorted(found, key=lambda p: p.line))
|
|
280
|
+
for target in chained:
|
|
281
|
+
queue.appendleft(target)
|
|
282
|
+
return checked, problems
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# 4. Working directory: current directory
|
|
13
13
|
#
|
|
14
14
|
# Later files override earlier ones. CLI arguments override everything.
|
|
15
|
-
# Generate this template with: execsql --init
|
|
15
|
+
# Generate this template with: execsql config --init > execsql.conf
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
[connect]
|
|
@@ -349,6 +349,29 @@
|
|
|
349
349
|
#win_config_file=
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
[format]
|
|
353
|
+
# Layout for `execsql format`. A flag on the command line wins over these.
|
|
354
|
+
# A run ignores this section.
|
|
355
|
+
|
|
356
|
+
# Spaces per indent level, for metacommand blocks and SQL.
|
|
357
|
+
#indent=4
|
|
358
|
+
# Put commas at the start of lines instead of the end.
|
|
359
|
+
#leading_comma=No
|
|
360
|
+
# Reformat SQL with sqlglot (needs the [formatter] extra). No = metacommands only.
|
|
361
|
+
#sql=Yes
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
[lint]
|
|
365
|
+
# Rules for `execsql lint`: codes or prefixes, comma-separated (e.g. V001,F).
|
|
366
|
+
# --select or --ignore on the command line replaces the value here.
|
|
367
|
+
# A run ignores this section.
|
|
368
|
+
|
|
369
|
+
#select=
|
|
370
|
+
#ignore=
|
|
371
|
+
# Yes = exit 1 on warnings too, not only on errors (--strict / --no-strict).
|
|
372
|
+
#strict=No
|
|
373
|
+
|
|
374
|
+
|
|
352
375
|
[variables]
|
|
353
376
|
# Substitution variables defined at startup. No pre-defined keys.
|
|
354
377
|
# Example:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- {name}
|
|
2
|
+
--
|
|
3
|
+
-- PURPOSE
|
|
4
|
+
--
|
|
5
|
+
--
|
|
6
|
+
-- NOTES
|
|
7
|
+
-- 1.
|
|
8
|
+
--
|
|
9
|
+
-- PROJECT
|
|
10
|
+
--
|
|
11
|
+
--
|
|
12
|
+
-- COPYRIGHT
|
|
13
|
+
-- Copyright (c) {year},
|
|
14
|
+
--
|
|
15
|
+
-- AUTHORS
|
|
16
|
+
--
|
|
17
|
+
--
|
|
18
|
+
-- HISTORY
|
|
19
|
+
-- Date Remarks
|
|
20
|
+
-- ---------- -----------------------------------------------------------------
|
|
21
|
+
-- {date} Created.
|
|
22
|
+
--
|
|
23
|
+
-- ==============================================================================
|
execsql/db/access.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any, cast
|
|
16
16
|
|
|
17
|
-
from execsql.db.base import Database
|
|
17
|
+
from execsql.db.base import Database, _fetched
|
|
18
18
|
from execsql.db.tiers import SupportTier
|
|
19
19
|
from execsql.exceptions import ErrInfo
|
|
20
20
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
@@ -231,7 +231,13 @@ class AccessDatabase(Database):
|
|
|
231
231
|
if time.time() - self.last_dao_time < 5.0:
|
|
232
232
|
time.sleep(5 - (time.time() - self.last_dao_time))
|
|
233
233
|
|
|
234
|
-
def execute(
|
|
234
|
+
def execute(
|
|
235
|
+
self,
|
|
236
|
+
sqlcmd: Any,
|
|
237
|
+
paramlist: list | None = None,
|
|
238
|
+
*,
|
|
239
|
+
fetch: bool = False,
|
|
240
|
+
) -> tuple[list[str], list] | None:
|
|
235
241
|
"""Execute a SQL command, handling encoding, DAO flush, and temporary queries."""
|
|
236
242
|
|
|
237
243
|
# A shortcut to self.cursor().execute() that handles encoding and that
|
|
@@ -239,9 +245,15 @@ class AccessDatabase(Database):
|
|
|
239
245
|
# to allow Jet's read buffer to be flushed (see https://support.microsoft.com/en-us/kb/225048).
|
|
240
246
|
# This also handles the 'CREATE TEMPORARY QUERY' extension to Access.
|
|
241
247
|
# For Access, commands in a tuple (batch) are executed singly.
|
|
242
|
-
def exec1(sql: str, paramlist: list | None) -> None:
|
|
248
|
+
def exec1(sql: str, paramlist: list | None) -> tuple[list[str], list] | None:
|
|
243
249
|
tqd = self.temp_rx.match(sql)
|
|
244
250
|
if tqd:
|
|
251
|
+
if self.conn is not None and self.holding():
|
|
252
|
+
self.warn_once(
|
|
253
|
+
"temporary query",
|
|
254
|
+
"CREATE TEMPORARY QUERY reopens the Access connection, which rolls back statements "
|
|
255
|
+
"not yet committed under AUTOCOMMIT OFF or BEGIN BATCH.",
|
|
256
|
+
)
|
|
245
257
|
qn = tqd.group(3)
|
|
246
258
|
qsql = sql[tqd.end() :]
|
|
247
259
|
if self.dao_conn is None:
|
|
@@ -258,7 +270,9 @@ class AccessDatabase(Database):
|
|
|
258
270
|
self.conn = None
|
|
259
271
|
if tqd.group(1) and tqd.group(1).strip().lower()[:4] == "temp" and qn not in self.temp_query_names:
|
|
260
272
|
self.temp_query_names.append(qn)
|
|
273
|
+
return None
|
|
261
274
|
else:
|
|
275
|
+
self.before_statement(sql)
|
|
262
276
|
self.dao_flush_check()
|
|
263
277
|
with self._cursor() as curs:
|
|
264
278
|
encoded_sql: Any
|
|
@@ -271,12 +285,14 @@ class AccessDatabase(Database):
|
|
|
271
285
|
else:
|
|
272
286
|
curs.execute(encoded_sql, paramlist)
|
|
273
287
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
288
|
+
return _fetched(curs) if fetch else None
|
|
274
289
|
|
|
275
290
|
if type(sqlcmd) in (list, tuple):
|
|
291
|
+
result = None
|
|
276
292
|
for sql in sqlcmd:
|
|
277
|
-
exec1(sql, paramlist)
|
|
278
|
-
|
|
279
|
-
|
|
293
|
+
result = exec1(sql, paramlist)
|
|
294
|
+
return result
|
|
295
|
+
return exec1(sqlcmd, paramlist)
|
|
280
296
|
|
|
281
297
|
def exec_cmd(self, querycommand: str) -> None:
|
|
282
298
|
"""Execute a stored query command via DAO."""
|
execsql/db/base.py
CHANGED
|
@@ -29,6 +29,21 @@ import execsql.state as _state
|
|
|
29
29
|
|
|
30
30
|
__all__ = ["Database", "DatabasePool"]
|
|
31
31
|
|
|
32
|
+
# Leading whitespace and comments, skipped to find a statement's first words.
|
|
33
|
+
_LEADING_NOISE_RX = re.compile(r"(?:\s+|--[^\n]*(?:\n|$)|/\*.*?\*/)*", re.S)
|
|
34
|
+
|
|
35
|
+
# Statements that manage a transaction themselves; one is never opened in front of them.
|
|
36
|
+
_TRANSACTION_CONTROL_RX = re.compile(
|
|
37
|
+
r"(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|ABORT|SAVEPOINT|RELEASE)\b",
|
|
38
|
+
re.I,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def statement_start(sql: str) -> str:
|
|
43
|
+
"""*sql* from its first keyword on: leading whitespace and comments removed."""
|
|
44
|
+
match = _LEADING_NOISE_RX.match(sql)
|
|
45
|
+
return sql[match.end() :] if match else sql
|
|
46
|
+
|
|
32
47
|
|
|
33
48
|
def _default_dt_cast() -> dict[type, Callable]:
|
|
34
49
|
"""Build the default type-cast mapping used by all database backends."""
|
|
@@ -46,6 +61,13 @@ def _default_dt_cast() -> dict[type, Callable]:
|
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
|
|
64
|
+
def _fetched(curs: Any) -> tuple[list[str], list] | None:
|
|
65
|
+
"""The rows a just-executed cursor produced, as ``(column_names, rows)``; ``None`` if none."""
|
|
66
|
+
if curs.description is None:
|
|
67
|
+
return None
|
|
68
|
+
return [d[0] for d in curs.description], curs.fetchall()
|
|
69
|
+
|
|
70
|
+
|
|
49
71
|
class Database(ABC):
|
|
50
72
|
"""Abstract base class for every DBMS adapter.
|
|
51
73
|
|
|
@@ -215,13 +237,17 @@ class Database(ABC):
|
|
|
215
237
|
"""Return a comma-separated string of *paramcount* parameter placeholders."""
|
|
216
238
|
return ",".join((self.paramstr,) * paramcount)
|
|
217
239
|
|
|
218
|
-
def execute(self, sql: Any, paramlist: list | None = None) -> None:
|
|
240
|
+
def execute(self, sql: Any, paramlist: list | None = None, *, fetch: bool = False) -> tuple[list[str], list] | None:
|
|
219
241
|
"""Execute *sql* (optionally with *paramlist*), updating ``$LAST_ROWCOUNT``.
|
|
220
242
|
|
|
243
|
+
With *fetch*, return ``(column_names, rows)`` when the statement
|
|
244
|
+
produces rows (a prompt shows them), else ``None``.
|
|
245
|
+
|
|
221
246
|
Rolls back the current transaction and re-raises on any driver error.
|
|
222
247
|
"""
|
|
223
248
|
if type(sql) in (tuple, list):
|
|
224
249
|
sql = " ".join(sql)
|
|
250
|
+
self.before_statement(sql)
|
|
225
251
|
try:
|
|
226
252
|
with self._cursor() as curs:
|
|
227
253
|
if paramlist is None:
|
|
@@ -232,6 +258,7 @@ class Database(ABC):
|
|
|
232
258
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
233
259
|
except Exception:
|
|
234
260
|
pass # Non-critical: some drivers lack rowcount support.
|
|
261
|
+
return _fetched(curs) if fetch else None
|
|
235
262
|
except Exception:
|
|
236
263
|
try:
|
|
237
264
|
self.rollback()
|
|
@@ -244,6 +271,81 @@ class Database(ABC):
|
|
|
244
271
|
"""Execute a stored procedure or function by name."""
|
|
245
272
|
...
|
|
246
273
|
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
# Holding statements until commit (AUTOCOMMIT OFF, BEGIN BATCH)
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
#: Statements the DBMS commits on its own, with everything run before
|
|
279
|
+
#: them, even inside a transaction (MySQL and Oracle DDL). Matched against
|
|
280
|
+
#: the start of the statement; a warning names each one run while holding.
|
|
281
|
+
implicit_commit_rx: ClassVar[re.Pattern[str] | None] = None
|
|
282
|
+
|
|
283
|
+
#: Statements that cannot run inside a transaction on this DBMS; one is
|
|
284
|
+
#: not opened in front of them.
|
|
285
|
+
no_transaction_rx: ClassVar[re.Pattern[str] | None] = None
|
|
286
|
+
|
|
287
|
+
#: Set on a connection whose driver could only connect in autocommit mode
|
|
288
|
+
#: (an ODBC DSN without transactions): nothing can be held back.
|
|
289
|
+
driver_autocommits: bool = False
|
|
290
|
+
|
|
291
|
+
def holding(self) -> bool:
|
|
292
|
+
"""Whether statements on this connection wait for a commit.
|
|
293
|
+
|
|
294
|
+
True while AUTOCOMMIT is OFF for this database, or inside BEGIN BATCH
|
|
295
|
+
(which holds every database).
|
|
296
|
+
"""
|
|
297
|
+
if not self.autocommit:
|
|
298
|
+
return True
|
|
299
|
+
status = _state.status
|
|
300
|
+
return status is not None and status.batch.in_batch()
|
|
301
|
+
|
|
302
|
+
def before_statement(self, sql: str) -> None:
|
|
303
|
+
"""Make sure *sql* is held until commit when execsql is holding statements.
|
|
304
|
+
|
|
305
|
+
Most drivers already hold every statement: they open a transaction
|
|
306
|
+
before the first one and keep it open until commit or rollback. An
|
|
307
|
+
adapter whose driver does not (SQLite for DDL, DuckDB for everything)
|
|
308
|
+
overrides :meth:`begin_transaction`. Where the DBMS itself commits a
|
|
309
|
+
statement regardless, a warning says so, since the work cannot be
|
|
310
|
+
rolled back.
|
|
311
|
+
"""
|
|
312
|
+
if not self.holding():
|
|
313
|
+
return
|
|
314
|
+
if self.driver_autocommits:
|
|
315
|
+
self.warn_once(
|
|
316
|
+
"driver",
|
|
317
|
+
f"{self.name()} cannot hold statements until COMMIT: its driver commits each one as it runs. "
|
|
318
|
+
"AUTOCOMMIT OFF and BEGIN BATCH have no effect on it.",
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
start = statement_start(sql)
|
|
322
|
+
if self.implicit_commit_rx is not None and (m := self.implicit_commit_rx.match(start)):
|
|
323
|
+
from execsql.utils.errors import write_warning
|
|
324
|
+
|
|
325
|
+
write_warning(
|
|
326
|
+
f"{self.type.dbms_id} commits {' '.join(m.group(0).upper().split())} immediately, together with "
|
|
327
|
+
"everything run before it; it cannot be rolled back.",
|
|
328
|
+
always=True,
|
|
329
|
+
)
|
|
330
|
+
if _TRANSACTION_CONTROL_RX.match(start):
|
|
331
|
+
return
|
|
332
|
+
if self.no_transaction_rx is not None and self.no_transaction_rx.match(start):
|
|
333
|
+
return
|
|
334
|
+
self.begin_transaction()
|
|
335
|
+
|
|
336
|
+
def begin_transaction(self) -> None:
|
|
337
|
+
"""Open a transaction if none is open. Most drivers do this themselves; a no-op here."""
|
|
338
|
+
|
|
339
|
+
def warn_once(self, key: str, message: str) -> None:
|
|
340
|
+
"""Write *message* as a warning the first time *key* comes up on this connection."""
|
|
341
|
+
warned: set[str] = self.__dict__.setdefault("_warned", set())
|
|
342
|
+
if key in warned:
|
|
343
|
+
return
|
|
344
|
+
warned.add(key)
|
|
345
|
+
from execsql.utils.errors import write_warning
|
|
346
|
+
|
|
347
|
+
write_warning(message, always=True)
|
|
348
|
+
|
|
247
349
|
def autocommit_on(self) -> None:
|
|
248
350
|
"""Enable autocommit mode so each statement is committed immediately."""
|
|
249
351
|
self.autocommit = True
|
|
@@ -562,6 +664,7 @@ class Database(ABC):
|
|
|
562
664
|
rows = iter(rowsource)
|
|
563
665
|
eof = False
|
|
564
666
|
total_rows = 0
|
|
667
|
+
self.before_statement(sql)
|
|
565
668
|
|
|
566
669
|
# Optional rich progress bar for long-running imports.
|
|
567
670
|
use_progress = getattr(_state.conf, "show_progress", False)
|
|
@@ -745,6 +848,7 @@ class Database(ABC):
|
|
|
745
848
|
sq_name = self.schema_qualified_table_name(schema_name, table_name)
|
|
746
849
|
quoted_col = self.quote_identifier(column_name)
|
|
747
850
|
sql = f"insert into {sq_name} ({quoted_col}) values ({self.paramsubs(1)});"
|
|
851
|
+
self.before_statement(sql)
|
|
748
852
|
with self._cursor() as curs:
|
|
749
853
|
curs.execute(sql, (filedata,))
|
|
750
854
|
|
execsql/db/dsn.py
CHANGED
|
@@ -109,6 +109,8 @@ class DsnDatabase(Database):
|
|
|
109
109
|
if "Optional feature not implemented" in excdesc:
|
|
110
110
|
try:
|
|
111
111
|
_dsn_connect(autocommit=True)
|
|
112
|
+
# This ODBC driver has no transactions: nothing can be held.
|
|
113
|
+
self.driver_autocommits = True
|
|
112
114
|
except Exception as e:
|
|
113
115
|
raise ErrInfo(
|
|
114
116
|
type="exception",
|