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.
Files changed (63) hide show
  1. execsql/cli/__init__.py +17 -586
  2. execsql/cli/application.py +309 -0
  3. execsql/cli/commands/__init__.py +13 -0
  4. execsql/cli/commands/config.py +205 -0
  5. execsql/cli/commands/format.py +100 -0
  6. execsql/cli/commands/init.py +135 -0
  7. execsql/cli/commands/lint.py +233 -0
  8. execsql/cli/commands/listing.py +81 -0
  9. execsql/cli/commands/run.py +451 -0
  10. execsql/cli/dispatch.py +66 -0
  11. execsql/cli/help.py +175 -24
  12. execsql/cli/lint.py +513 -76
  13. execsql/cli/options.py +108 -0
  14. execsql/cli/run.py +41 -14
  15. execsql/config.py +251 -154
  16. execsql/config_validation.py +282 -0
  17. execsql/data/execsql.conf.template +24 -1
  18. execsql/data/script_header.sql +23 -0
  19. execsql/db/access.py +22 -6
  20. execsql/db/base.py +105 -1
  21. execsql/db/dsn.py +2 -0
  22. execsql/db/duckdb.py +95 -2
  23. execsql/db/factory.py +2 -2
  24. execsql/db/mysql.py +11 -0
  25. execsql/db/oracle.py +12 -4
  26. execsql/db/sqlite.py +16 -0
  27. execsql/debug/repl.py +51 -193
  28. execsql/exceptions.py +9 -0
  29. execsql/format.py +87 -75
  30. execsql/interactive.py +232 -0
  31. execsql/manifest.py +189 -0
  32. execsql/metacommands/conditions.py +1 -1
  33. execsql/metacommands/control.py +2 -3
  34. execsql/metacommands/effects.py +124 -0
  35. execsql/script/__init__.py +0 -9
  36. execsql/script/ast.py +1 -1
  37. execsql/script/engine.py +6 -93
  38. execsql/script/executor.py +153 -34
  39. execsql/state.py +11 -2
  40. execsql/state.pyi +3 -0
  41. execsql/utils/color.py +18 -0
  42. execsql/utils/errors.py +11 -1
  43. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  44. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  45. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
  46. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  47. execsql2-2.23.0.dist-info/entry_points.txt +0 -3
  48. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  49. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  50. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  51. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  52. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  53. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  54. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  55. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  56. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  57. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  58. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  59. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  60. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  61. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  62. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  63. {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-config > execsql.conf
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(self, sqlcmd: Any, paramlist: list | None = None) -> None:
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
- else:
279
- exec1(sqlcmd, paramlist)
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",