execsql2 2.24.0__py3-none-any.whl → 2.25.1__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/api.py +169 -160
- execsql/cli/__init__.py +2 -0
- execsql/cli/commands/lint.py +2 -10
- execsql/cli/commands/run.py +35 -27
- execsql/cli/dsn.py +11 -4
- execsql/cli/lint.py +351 -48
- execsql/cli/options.py +1 -1
- execsql/cli/run.py +56 -19
- execsql/config.py +5 -19
- execsql/data/execsql.conf.template +14 -4
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +11 -2
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +29 -2
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/manifest.py +33 -22
- execsql/metacommands/conditions.py +6 -7
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/engine.py +40 -11
- execsql/script/executor.py +9 -5
- execsql/script/parser.py +167 -118
- execsql/state.py +23 -9
- execsql/state.pyi +8 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +71 -9
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/NOTICE +0 -0
execsql/cli/lint.py
CHANGED
|
@@ -7,10 +7,10 @@ required.
|
|
|
7
7
|
|
|
8
8
|
Checks performed:
|
|
9
9
|
|
|
10
|
-
1. **Parse errors** —
|
|
11
|
-
|
|
12
|
-
``
|
|
13
|
-
|
|
10
|
+
1. **Parse errors** — :func:`lint_source` parses with error recovery, so
|
|
11
|
+
every unmatched or unclosed IF / LOOP / BATCH / SCRIPT / SQL block is a
|
|
12
|
+
``P001`` at its own line and the remaining checks run on the recovered
|
|
13
|
+
tree.
|
|
14
14
|
2. **Empty scripts** — warns when no nodes were parsed.
|
|
15
15
|
3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
|
|
16
16
|
with no preceding ``SUB``-family definition, ignoring built-in
|
|
@@ -22,6 +22,12 @@ Checks performed:
|
|
|
22
22
|
5. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
23
23
|
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
24
|
when ``IF EXISTS`` is present).
|
|
25
|
+
6. **Unknown metacommands** — reports a metacommand that no entry in the
|
|
26
|
+
dispatch table ``execsql run`` uses (plus installed plugins) matches,
|
|
27
|
+
skipping commands that contain a substitution variable.
|
|
28
|
+
7. **Unknown conditions** — parses (never evaluates) every IF, ELSEIF,
|
|
29
|
+
ANDIF, ORIF, LOOP and EXECUTE SCRIPT ... WHILE/UNTIL condition, and the
|
|
30
|
+
condition in ASSERT and WAIT_UNTIL, against the conditional-test table.
|
|
25
31
|
|
|
26
32
|
Every check has a rule code (:data:`RULES`), so a caller can select or
|
|
27
33
|
ignore rules and machine-readable output can name them.
|
|
@@ -31,6 +37,8 @@ Public surface:
|
|
|
31
37
|
- :func:`lint` — entry point; returns a list of :class:`Issue`.
|
|
32
38
|
- :data:`RULES` / :class:`Rule` — the rule registry: code, name, severity.
|
|
33
39
|
- :func:`parse_error` — the :class:`Issue` for a script that fails to parse.
|
|
40
|
+
- :func:`lint_source` — parse script text and lint it: every block-structure
|
|
41
|
+
error as a ``P001``, plus every other rule on the recovered tree.
|
|
34
42
|
- :func:`resolve_selectors` / :func:`filter_issues` — ``--select`` and
|
|
35
43
|
``--ignore`` handling.
|
|
36
44
|
- :func:`print_text`, :func:`print_concise`, :func:`print_statistics`,
|
|
@@ -45,7 +53,7 @@ import re
|
|
|
45
53
|
from collections import Counter
|
|
46
54
|
from collections.abc import Iterable
|
|
47
55
|
from pathlib import Path
|
|
48
|
-
from typing import NamedTuple
|
|
56
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
49
57
|
|
|
50
58
|
from execsql.exceptions import ErrInfo
|
|
51
59
|
from execsql.script.ast import (
|
|
@@ -62,6 +70,10 @@ from execsql.script.ast import (
|
|
|
62
70
|
SqlStatement,
|
|
63
71
|
)
|
|
64
72
|
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from execsql.script.engine import MetaCommandList
|
|
75
|
+
from execsql.script.ast import ConditionModifier
|
|
76
|
+
|
|
65
77
|
__all__ = [
|
|
66
78
|
"RULES",
|
|
67
79
|
"Issue",
|
|
@@ -69,6 +81,7 @@ __all__ = [
|
|
|
69
81
|
"exit_code",
|
|
70
82
|
"filter_issues",
|
|
71
83
|
"lint",
|
|
84
|
+
"lint_source",
|
|
72
85
|
"parse_error",
|
|
73
86
|
"print_concise",
|
|
74
87
|
"print_statistics",
|
|
@@ -109,6 +122,24 @@ RULES: dict[str, Rule] = {
|
|
|
109
122
|
"error",
|
|
110
123
|
"The script cannot be parsed, for example an IF, LOOP, BATCH or SCRIPT block that is never closed.",
|
|
111
124
|
),
|
|
125
|
+
Rule(
|
|
126
|
+
"P002",
|
|
127
|
+
"split-dollar-quote",
|
|
128
|
+
"error",
|
|
129
|
+
"A dollar-quoted body ($$ ... $$) is split into separate statements at a line ending in ';'.",
|
|
130
|
+
),
|
|
131
|
+
Rule(
|
|
132
|
+
"P003",
|
|
133
|
+
"unknown-metacommand",
|
|
134
|
+
"error",
|
|
135
|
+
"A metacommand is misspelled or malformed: no form of any metacommand matches it.",
|
|
136
|
+
),
|
|
137
|
+
Rule(
|
|
138
|
+
"P004",
|
|
139
|
+
"unknown-condition",
|
|
140
|
+
"error",
|
|
141
|
+
"A condition (IF, ELSEIF, ANDIF, ORIF, LOOP, ASSERT, ...) uses an unknown test or cannot be parsed.",
|
|
142
|
+
),
|
|
112
143
|
Rule("S001", "empty-script", "warning", "The script contains no statements."),
|
|
113
144
|
Rule(
|
|
114
145
|
"V001",
|
|
@@ -194,12 +225,30 @@ _RX_SUB_INI = re.compile(
|
|
|
194
225
|
)
|
|
195
226
|
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
196
227
|
_RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
|
|
197
|
-
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name
|
|
198
|
-
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name
|
|
199
|
-
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name
|
|
200
|
-
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<
|
|
228
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
229
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
230
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
231
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<qstr>.+?)\s*$", re.I)
|
|
232
|
+
# The prompts that store the answer in a variable.
|
|
233
|
+
_RX_ASK_SUB = re.compile(r"""^\s*(?:PROMPT\s+)?ASK\s+(?:'.*'|".*")\s+SUB\s+(?P<name>~?\w+)(?:\s|$)""", re.I)
|
|
234
|
+
_RX_PROMPT_ENTER_SUB = re.compile(r"^\s*PROMPT\s+ENTER_SUB\s+(?P<name>~?\w+)\s", re.I)
|
|
235
|
+
_RX_PROMPT_DIRECTORY = re.compile(r"^\s*PROMPT\s+DIRECTORY\s+SUB\s+(?P<name>~?\w+)(?:\s|$)", re.I)
|
|
236
|
+
_RX_PROMPT_FILE = re.compile(
|
|
237
|
+
r"^\s*PROMPT\s+(?:OPENFILE|SAVEFILE)\s+SUB\s+(?P<names>~?\w+(?:\s+~?\w+){0,4})(?=\s+FROM\s|\s*$)",
|
|
238
|
+
re.I,
|
|
239
|
+
)
|
|
240
|
+
_RX_PROMPT_CREDENTIALS = re.compile(
|
|
241
|
+
r'^\s*PROMPT(?:\s+MESSAGE)?(?:\s+"[^"]*")?\s+CREDENTIALS\s+(?P<user>\w+)\s+(?P<pw>\w+)\s*$',
|
|
242
|
+
re.I,
|
|
243
|
+
)
|
|
201
244
|
|
|
202
245
|
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
246
|
+
# Every candidate reference, including one nested in another's name
|
|
247
|
+
# (``!!N_!!GROUP!!_CHECKS!!``), which _RX_VAR_REF's left-to-right scan skips.
|
|
248
|
+
_RX_VAR_REF_ANY = re.compile(r"(?=!!([$@&~#+]?\w+)!!)")
|
|
249
|
+
# Stands for a substituted value in _unresolved_var_refs: a word character
|
|
250
|
+
# (so an enclosing name still matches) that no script name contains.
|
|
251
|
+
_SUBSTITUTED = "ª"
|
|
203
252
|
|
|
204
253
|
|
|
205
254
|
# ---------------------------------------------------------------------------
|
|
@@ -245,6 +294,39 @@ def _get_builtin_vars() -> frozenset[str]:
|
|
|
245
294
|
return _BUILTIN_VARS
|
|
246
295
|
|
|
247
296
|
|
|
297
|
+
_DISPATCH_TABLE: MetaCommandList | None = None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _dispatch_table() -> MetaCommandList:
|
|
301
|
+
"""Return the metacommand table ``execsql run`` dispatches from, built on first call.
|
|
302
|
+
|
|
303
|
+
A private copy, with installed metacommand plugins registered, so that
|
|
304
|
+
linting never adds entries to the table a run in this process uses.
|
|
305
|
+
"""
|
|
306
|
+
global _DISPATCH_TABLE
|
|
307
|
+
if _DISPATCH_TABLE is None:
|
|
308
|
+
from execsql.metacommands.dispatch import build_dispatch_table
|
|
309
|
+
from execsql.plugins import discover_metacommand_plugins
|
|
310
|
+
|
|
311
|
+
table = build_dispatch_table()
|
|
312
|
+
discover_metacommand_plugins(table)
|
|
313
|
+
_DISPATCH_TABLE = table
|
|
314
|
+
return _DISPATCH_TABLE
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
_CONDITIONAL_TABLE: MetaCommandList | None = None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _conditional_table() -> MetaCommandList:
|
|
321
|
+
"""Return the table of conditional tests ``execsql run`` evaluates with, built on first call."""
|
|
322
|
+
global _CONDITIONAL_TABLE
|
|
323
|
+
if _CONDITIONAL_TABLE is None:
|
|
324
|
+
from execsql.metacommands.conditions import build_conditional_table
|
|
325
|
+
|
|
326
|
+
_CONDITIONAL_TABLE = build_conditional_table()
|
|
327
|
+
return _CONDITIONAL_TABLE
|
|
328
|
+
|
|
329
|
+
|
|
248
330
|
# ---------------------------------------------------------------------------
|
|
249
331
|
# AST walker helpers
|
|
250
332
|
# ---------------------------------------------------------------------------
|
|
@@ -302,7 +384,7 @@ def _extract_var_definition(
|
|
|
302
384
|
script_dir: Path | None,
|
|
303
385
|
defined: set[str],
|
|
304
386
|
) -> None:
|
|
305
|
-
"""
|
|
387
|
+
"""Add the variable(s) a metacommand defines to *defined*: the SUB family and the prompts that store an answer."""
|
|
306
388
|
for rx in (
|
|
307
389
|
_RX_SUB,
|
|
308
390
|
_RX_SUB_EMPTY,
|
|
@@ -313,13 +395,32 @@ def _extract_var_definition(
|
|
|
313
395
|
_RX_SUB_TEMPFILE,
|
|
314
396
|
_RX_SUB_DECRYPT,
|
|
315
397
|
_RX_SUB_ENCRYPT,
|
|
316
|
-
|
|
398
|
+
_RX_ASK_SUB,
|
|
399
|
+
_RX_PROMPT_ENTER_SUB,
|
|
400
|
+
_RX_PROMPT_DIRECTORY,
|
|
317
401
|
):
|
|
318
402
|
m = rx.match(command)
|
|
319
403
|
if m:
|
|
320
404
|
defined.add(m.group("name").lstrip("+~").upper())
|
|
321
405
|
return
|
|
322
406
|
|
|
407
|
+
m = _RX_PROMPT_FILE.match(command)
|
|
408
|
+
if m:
|
|
409
|
+
defined.update(name.lstrip("~").upper() for name in m.group("names").split())
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
m = _RX_PROMPT_CREDENTIALS.match(command)
|
|
413
|
+
if m:
|
|
414
|
+
defined.update((m.group("user").upper(), m.group("pw").upper()))
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
m = _RX_SUB_QUERYSTRING.match(command)
|
|
418
|
+
if m:
|
|
419
|
+
from urllib.parse import parse_qsl
|
|
420
|
+
|
|
421
|
+
defined.update(key.upper() for key, _value in parse_qsl(m.group("qstr")))
|
|
422
|
+
return
|
|
423
|
+
|
|
323
424
|
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
324
425
|
ini_m = _RX_SUB_INI.match(command)
|
|
325
426
|
if ini_m:
|
|
@@ -329,29 +430,99 @@ def _extract_var_definition(
|
|
|
329
430
|
_read_ini_vars(ini_file, ini_section, script_dir, defined)
|
|
330
431
|
|
|
331
432
|
|
|
433
|
+
# Larger than any real SUB_INI file; lint reads no more than this.
|
|
434
|
+
_MAX_INI_BYTES = 1024 * 1024
|
|
435
|
+
|
|
436
|
+
|
|
332
437
|
def _read_ini_vars(
|
|
333
438
|
ini_file: str,
|
|
334
439
|
section: str,
|
|
335
440
|
script_dir: Path | None,
|
|
336
441
|
defined_vars: set[str],
|
|
337
442
|
) -> None:
|
|
338
|
-
"""Read an INI file and register its section keys as defined variables.
|
|
443
|
+
"""Read an INI file and register its section keys as defined variables.
|
|
444
|
+
|
|
445
|
+
Only a regular file up to :data:`_MAX_INI_BYTES` is read, so a script
|
|
446
|
+
naming a FIFO or a device (``/dev/zero``) cannot hang lint or the
|
|
447
|
+
language server; a file that is unreadable or not valid INI defines
|
|
448
|
+
nothing.
|
|
449
|
+
"""
|
|
339
450
|
from configparser import ConfigParser
|
|
451
|
+
from configparser import Error as ConfigError
|
|
340
452
|
|
|
341
453
|
p = Path(ini_file)
|
|
342
454
|
if not p.is_absolute() and script_dir is not None:
|
|
343
455
|
p = script_dir / p
|
|
344
456
|
|
|
345
|
-
if not p.exists():
|
|
346
|
-
return
|
|
347
|
-
|
|
348
457
|
cp = ConfigParser()
|
|
349
|
-
|
|
458
|
+
try:
|
|
459
|
+
if not p.is_file() or p.stat().st_size > _MAX_INI_BYTES:
|
|
460
|
+
return
|
|
461
|
+
cp.read_string(p.read_text(encoding="utf-8"), source=str(p))
|
|
462
|
+
except (OSError, UnicodeDecodeError, ConfigError):
|
|
463
|
+
return
|
|
350
464
|
if cp.has_section(section):
|
|
351
465
|
for key, _value in cp.items(section):
|
|
352
466
|
defined_vars.add(key.upper())
|
|
353
467
|
|
|
354
468
|
|
|
469
|
+
def _is_known_var(raw_name: str, defined_vars: set[str]) -> bool:
|
|
470
|
+
"""Whether execsql may substitute *raw_name* (as written, with its sigil) at run time."""
|
|
471
|
+
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
472
|
+
name = raw_name[len(sigil) :]
|
|
473
|
+
|
|
474
|
+
# Skip non-$ sigil prefixes — resolved at runtime
|
|
475
|
+
if sigil in ("@", "&", "~", "#", "+"):
|
|
476
|
+
return True
|
|
477
|
+
|
|
478
|
+
if sigil == "$":
|
|
479
|
+
# System variables: $ARG_N is set with -a/--assign-arg, $COUNTER_N by
|
|
480
|
+
# CounterVars, the rest by execsql itself. A SUB cannot define one.
|
|
481
|
+
return bool(re.match(r"^(?:ARG|COUNTER)_\d+$", name, re.I)) or name.upper() in _get_builtin_vars()
|
|
482
|
+
# User variables, defined by a SUB-family metacommand or a prompt.
|
|
483
|
+
return name.upper() in defined_vars
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _unresolved_var_refs(text: str, defined_vars: set[str]) -> list[str]:
|
|
487
|
+
"""The variable names in *text* that execsql would leave unsubstituted, as written.
|
|
488
|
+
|
|
489
|
+
execsql substitutes a known variable wherever ``!!name!!`` appears and
|
|
490
|
+
repeats until nothing changes, so a variable written inside another's
|
|
491
|
+
name (``!!N_!!GROUP!!_CHECKS!!``) is replaced first, and the outer name
|
|
492
|
+
is only known at run time: it is not checked. Here each known variable
|
|
493
|
+
is replaced by a placeholder character, a name holding one counts as
|
|
494
|
+
known, and that repeats until nothing is replaced.
|
|
495
|
+
|
|
496
|
+
In a run of unknown candidates that share delimiters, the inner ones
|
|
497
|
+
(every second, from the second) are reported: with ``GROUP`` undefined,
|
|
498
|
+
``!!N_!!GROUP!!_CHECKS!!`` reports ``GROUP``, not the fragments ``N_``
|
|
499
|
+
and ``_CHECKS``.
|
|
500
|
+
"""
|
|
501
|
+
while True:
|
|
502
|
+
parts: list[str] = []
|
|
503
|
+
end = 0
|
|
504
|
+
for m in _RX_VAR_REF_ANY.finditer(text):
|
|
505
|
+
name = m.group(1)
|
|
506
|
+
if m.start() >= end and (_SUBSTITUTED in name or _is_known_var(name, defined_vars)):
|
|
507
|
+
parts += [text[end : m.start()], _SUBSTITUTED]
|
|
508
|
+
end = m.start() + len(name) + 4
|
|
509
|
+
if not parts:
|
|
510
|
+
break
|
|
511
|
+
text = "".join([*parts, text[end:]])
|
|
512
|
+
|
|
513
|
+
unresolved: list[str] = []
|
|
514
|
+
run: list[str] = []
|
|
515
|
+
end = -1
|
|
516
|
+
for m in _RX_VAR_REF_ANY.finditer(text):
|
|
517
|
+
if run and m.start() != end - 2: # not sharing the previous candidate's closing !!
|
|
518
|
+
unresolved.extend(run if len(run) == 1 else run[1::2])
|
|
519
|
+
run = []
|
|
520
|
+
run.append(m.group(1))
|
|
521
|
+
end = m.start() + len(m.group(1)) + 4
|
|
522
|
+
unresolved.extend(run if len(run) == 1 else run[1::2])
|
|
523
|
+
return unresolved
|
|
524
|
+
|
|
525
|
+
|
|
355
526
|
def _check_var_ref(
|
|
356
527
|
raw_name: str,
|
|
357
528
|
source: str,
|
|
@@ -360,30 +531,7 @@ def _check_var_ref(
|
|
|
360
531
|
issues: list[_Issue],
|
|
361
532
|
) -> None:
|
|
362
533
|
"""Emit a warning if *raw_name* looks like an undefined user variable."""
|
|
363
|
-
if not raw_name:
|
|
364
|
-
return
|
|
365
|
-
|
|
366
|
-
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
367
|
-
name = raw_name[len(sigil) :]
|
|
368
|
-
|
|
369
|
-
# Skip non-$ sigil prefixes — resolved at runtime
|
|
370
|
-
if sigil in ("@", "&", "~", "#", "+"):
|
|
371
|
-
return
|
|
372
|
-
|
|
373
|
-
# $ARG_N is set via -a/--assign-arg at invocation time
|
|
374
|
-
if re.match(r"^ARG_\d+$", name, re.I):
|
|
375
|
-
return
|
|
376
|
-
|
|
377
|
-
# $COUNTER_N is managed by CounterVars
|
|
378
|
-
if re.match(r"^COUNTER_\d+$", name, re.I):
|
|
379
|
-
return
|
|
380
|
-
|
|
381
|
-
# Built-in system variables
|
|
382
|
-
if name.upper() in _get_builtin_vars():
|
|
383
|
-
return
|
|
384
|
-
|
|
385
|
-
# User-defined via SUB
|
|
386
|
-
if name.upper() in defined_vars:
|
|
534
|
+
if not raw_name or _is_known_var(raw_name, defined_vars):
|
|
387
535
|
return
|
|
388
536
|
|
|
389
537
|
issues.append(
|
|
@@ -427,8 +575,8 @@ def _check_include_path(
|
|
|
427
575
|
#: Conditions whose value is fixed at parse time. ``IF(True)`` is a real and
|
|
428
576
|
#: reasonable thing to write while developing; leaving it in is what makes
|
|
429
577
|
#: every other branch dead code.
|
|
430
|
-
_RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|
|
|
431
|
-
_RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|
|
|
578
|
+
_RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|yes)\s*\)?\s*$", re.I)
|
|
579
|
+
_RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|no)\s*\)?\s*$", re.I)
|
|
432
580
|
|
|
433
581
|
#: Metacommands after which nothing in the same block can run.
|
|
434
582
|
_RX_TERMINAL = re.compile(r"^\s*HALT\b(?!\s+DISPLAY)", re.I)
|
|
@@ -482,6 +630,138 @@ def _check_constant_condition(node: IfBlock, issues: list[_Issue]) -> None:
|
|
|
482
630
|
)
|
|
483
631
|
|
|
484
632
|
|
|
633
|
+
def _check_split_dollar_quotes(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
634
|
+
"""Report a dollar-quoted body that the script splits into several statements.
|
|
635
|
+
|
|
636
|
+
A statement ends at any line ending in ``;``, even inside ``$$ ... $$``,
|
|
637
|
+
so a function body is sent to the database in pieces: the first piece
|
|
638
|
+
fails, and with ERROR_HALT OFF the statements inside the body then run
|
|
639
|
+
on their own. One issue is reported where the body opens; the pieces
|
|
640
|
+
that follow until it closes are part of the same problem.
|
|
641
|
+
"""
|
|
642
|
+
from execsql.format import open_dollar_quote
|
|
643
|
+
|
|
644
|
+
open_tag: str | None = None
|
|
645
|
+
for node in nodes:
|
|
646
|
+
if not isinstance(node, SqlStatement):
|
|
647
|
+
continue
|
|
648
|
+
was_open = open_tag
|
|
649
|
+
open_tag = open_dollar_quote(node.text, open_tag)
|
|
650
|
+
if was_open is None and open_tag is not None:
|
|
651
|
+
issues.append(
|
|
652
|
+
_issue(
|
|
653
|
+
"P002",
|
|
654
|
+
node.span.file,
|
|
655
|
+
node.span.start_line,
|
|
656
|
+
f"dollar-quoted body {open_tag} is split at the first line ending in ';'; "
|
|
657
|
+
"put the statement between BEGIN SQL and END SQL",
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# A command holding a variable (!!v!!, !'!v!'!, !"!v!"! or deferred !{v}!) is
|
|
663
|
+
# only known after substitution at run time.
|
|
664
|
+
_RX_ANY_VAR = re.compile(r"!(['\"]?)![$&@~#+]?\w+!\1!|!\{[$&@~#+]?\w+\}!")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
#: Values a variable is tried as: a boolean (a whole condition), a number
|
|
668
|
+
#: (``ROW_COUNT_EQ(t, !!n!!)``) and a name (a table or column). Between them
|
|
669
|
+
#: they fit wherever a variable can stand in a condition or a metacommand.
|
|
670
|
+
_PLACEHOLDERS = ("TRUE", "1", "x")
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _with_placeholders(text: str, value: str = "TRUE") -> str:
|
|
674
|
+
"""Replace each substitution variable with the literal *value*."""
|
|
675
|
+
return _RX_ANY_VAR.sub(value, text)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _placeholder_forms(text: str) -> list[str]:
|
|
679
|
+
"""*text* with its variables replaced by each of :data:`_PLACEHOLDERS` in turn.
|
|
680
|
+
|
|
681
|
+
What none of them makes valid is not valid with any value, such as
|
|
682
|
+
``!!flag!! = TRUE`` (there is no ``=`` comparison).
|
|
683
|
+
"""
|
|
684
|
+
return [_with_placeholders(text, value) for value in _PLACEHOLDERS]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# Named groups that capture a condition in a metacommand's pattern: ASSERT,
|
|
688
|
+
# WAIT_UNTIL, and the WHILE / UNTIL of ON ... EXECUTE SCRIPT.
|
|
689
|
+
_CONDITION_GROUPS = ("condtest", "condition", "loopcond")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _check_metacommand(node: MetaCommandStatement, issues: list[_Issue]) -> None:
|
|
693
|
+
"""Report a metacommand that no dispatch-table entry matches, or whose condition does not parse.
|
|
694
|
+
|
|
695
|
+
``execsql run`` halts on such a command with "Unknown metacommand",
|
|
696
|
+
whether the keyword is misspelled or the arguments don't fit any form.
|
|
697
|
+
"""
|
|
698
|
+
match = _dispatch_table().get_match(node.command)
|
|
699
|
+
if match is None and _RX_ANY_VAR.search(node.command):
|
|
700
|
+
# A variable can stand for a keyword or a whole clause: try the
|
|
701
|
+
# command with placeholders, and judge it only if one matches.
|
|
702
|
+
match = next(
|
|
703
|
+
(m for form in _placeholder_forms(node.command) if (m := _dispatch_table().get_match(form)) is not None),
|
|
704
|
+
None,
|
|
705
|
+
)
|
|
706
|
+
if match is None:
|
|
707
|
+
return
|
|
708
|
+
if match is None:
|
|
709
|
+
issues.append(
|
|
710
|
+
_issue(
|
|
711
|
+
"P003",
|
|
712
|
+
node.span.file,
|
|
713
|
+
node.span.start_line,
|
|
714
|
+
f"unknown or malformed metacommand: {node.command.strip()}",
|
|
715
|
+
),
|
|
716
|
+
)
|
|
717
|
+
return
|
|
718
|
+
groups = match[1].groupdict()
|
|
719
|
+
for name in _CONDITION_GROUPS:
|
|
720
|
+
if groups.get(name):
|
|
721
|
+
_check_condition(groups[name], node.span.file, node.span.start_line, issues)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _check_condition(condition: str, source: str, line_no: int, issues: list[_Issue]) -> None:
|
|
725
|
+
"""Report a condition that the conditional-expression parser rejects.
|
|
726
|
+
|
|
727
|
+
Only parses: no test is evaluated, so nothing touches a database. A
|
|
728
|
+
condition with variables is reported only when it parses with none of
|
|
729
|
+
the placeholder values (:func:`_placeholder_forms`).
|
|
730
|
+
"""
|
|
731
|
+
from execsql.exceptions import CondParserError
|
|
732
|
+
from execsql.parser import CondParser
|
|
733
|
+
|
|
734
|
+
forms = _placeholder_forms(condition) if _RX_ANY_VAR.search(condition) else [condition]
|
|
735
|
+
for form in forms:
|
|
736
|
+
try:
|
|
737
|
+
CondParser(form, _conditional_table()).parse()
|
|
738
|
+
except (CondParserError, RecursionError): # RecursionError: nested past the parser's depth
|
|
739
|
+
continue
|
|
740
|
+
return
|
|
741
|
+
issues.append(
|
|
742
|
+
_issue("P004", source, line_no, f"unknown or malformed condition: {condition.strip()}"),
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _check_modifiers(modifiers: list[ConditionModifier], issues: list[_Issue]) -> None:
|
|
747
|
+
for mod in modifiers:
|
|
748
|
+
_check_condition(mod.condition, mod.span.file, mod.span.start_line, issues)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _check_block_conditions(node: Node, issues: list[_Issue]) -> None:
|
|
752
|
+
"""Check the conditions an IF (with its ANDIF / ORIF / ELSEIF), LOOP or EXECUTE SCRIPT carries."""
|
|
753
|
+
if isinstance(node, IfBlock):
|
|
754
|
+
_check_condition(node.condition, node.span.file, node.span.start_line, issues)
|
|
755
|
+
_check_modifiers(node.condition_modifiers, issues)
|
|
756
|
+
for clause in node.elseif_clauses:
|
|
757
|
+
_check_condition(clause.condition, clause.span.file, clause.span.start_line, issues)
|
|
758
|
+
_check_modifiers(clause.condition_modifiers, issues)
|
|
759
|
+
elif isinstance(node, LoopBlock):
|
|
760
|
+
_check_condition(node.condition, node.span.file, node.span.start_line, issues)
|
|
761
|
+
elif isinstance(node, IncludeDirective) and node.loop_condition:
|
|
762
|
+
_check_condition(node.loop_condition, node.span.file, node.span.start_line, issues)
|
|
763
|
+
|
|
764
|
+
|
|
485
765
|
def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
486
766
|
"""Report statements that follow an unconditional HALT in the same block.
|
|
487
767
|
|
|
@@ -507,11 +787,17 @@ def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> No
|
|
|
507
787
|
return
|
|
508
788
|
|
|
509
789
|
|
|
790
|
+
# The conditional tests that read a variable by its bare name.
|
|
791
|
+
_RX_VAR_TEST = re.compile(r"\bSUB_(?:DEFINED|EMPTY)\s*\(\s*([$&@~#]?\w+)\s*\)", re.I)
|
|
792
|
+
|
|
793
|
+
|
|
510
794
|
def _collect_var_references(nodes: list[Node], seen: set[str]) -> None:
|
|
511
|
-
"""Record every
|
|
795
|
+
"""Record every variable read anywhere beneath *nodes*: ``!!var!!``, or ``sub_defined(var)`` / ``sub_empty(var)``."""
|
|
512
796
|
for node in nodes:
|
|
513
797
|
for text in _referencing_text(node):
|
|
514
|
-
for m in
|
|
798
|
+
for m in _RX_VAR_REF_ANY.finditer(text):
|
|
799
|
+
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
800
|
+
for m in _RX_VAR_TEST.finditer(text):
|
|
515
801
|
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
516
802
|
for child in node.children():
|
|
517
803
|
_collect_var_references([child], seen)
|
|
@@ -591,20 +877,23 @@ def _lint_nodes(
|
|
|
591
877
|
visited_scripts = set()
|
|
592
878
|
|
|
593
879
|
_check_unreachable_after_halt(nodes, issues)
|
|
880
|
+
_check_split_dollar_quotes(nodes, issues)
|
|
594
881
|
|
|
595
882
|
for node in nodes:
|
|
596
883
|
src = node.span.file
|
|
597
884
|
lno = node.span.start_line
|
|
885
|
+
_check_block_conditions(node, issues)
|
|
598
886
|
|
|
599
887
|
# -- Variable references in SQL --
|
|
600
888
|
if isinstance(node, SqlStatement):
|
|
601
|
-
for
|
|
602
|
-
_check_var_ref(
|
|
889
|
+
for name in _unresolved_var_refs(node.text, defined_vars):
|
|
890
|
+
_check_var_ref(name, src, lno, defined_vars, issues)
|
|
603
891
|
|
|
604
892
|
# -- Metacommand checks --
|
|
605
893
|
elif isinstance(node, MetaCommandStatement):
|
|
606
|
-
|
|
607
|
-
|
|
894
|
+
_check_metacommand(node, issues)
|
|
895
|
+
for name in _unresolved_var_refs(node.command, defined_vars):
|
|
896
|
+
_check_var_ref(name, src, lno, defined_vars, issues)
|
|
608
897
|
|
|
609
898
|
# -- IncludeDirective checks --
|
|
610
899
|
elif isinstance(node, IncludeDirective):
|
|
@@ -711,6 +1000,20 @@ def lint(
|
|
|
711
1000
|
return issues
|
|
712
1001
|
|
|
713
1002
|
|
|
1003
|
+
def lint_source(source: str, label: str, script_path: str | None = None) -> list[_Issue]:
|
|
1004
|
+
"""Parse *source* and lint it, unfiltered.
|
|
1005
|
+
|
|
1006
|
+
Each block-structure error (an IF without ENDIF, a stray END LOOP, ...)
|
|
1007
|
+
is a ``P001`` at its own line. The parser recovers from them, so every
|
|
1008
|
+
other rule still runs on the rest of the script.
|
|
1009
|
+
"""
|
|
1010
|
+
from execsql.script.parser import parse_string
|
|
1011
|
+
|
|
1012
|
+
errors: list[ErrInfo] = []
|
|
1013
|
+
tree = parse_string(source, label, errors=errors)
|
|
1014
|
+
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path)
|
|
1015
|
+
|
|
1016
|
+
|
|
714
1017
|
# ---------------------------------------------------------------------------
|
|
715
1018
|
# Selecting rules
|
|
716
1019
|
# ---------------------------------------------------------------------------
|
execsql/cli/options.py
CHANGED
|
@@ -50,7 +50,7 @@ DsnOpt = Annotated[
|
|
|
50
50
|
metavar="URL",
|
|
51
51
|
help=(
|
|
52
52
|
"Database connection URL, e.g. postgresql://user:pass@host:5432/db. "
|
|
53
|
-
"Supported schemes: postgresql, mysql, mssql, oracle, firebird, sqlite, duckdb. "
|
|
53
|
+
"Supported schemes: postgresql (postgres), mysql (mariadb), mssql (sqlserver), oracle, firebird, sqlite, duckdb. "
|
|
54
54
|
"Overrides -t/-u/-p and positional server/db args."
|
|
55
55
|
),
|
|
56
56
|
),
|