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.
Files changed (72) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +2 -0
  3. execsql/cli/commands/lint.py +2 -10
  4. execsql/cli/commands/run.py +35 -27
  5. execsql/cli/dsn.py +11 -4
  6. execsql/cli/lint.py +351 -48
  7. execsql/cli/options.py +1 -1
  8. execsql/cli/run.py +56 -19
  9. execsql/config.py +5 -19
  10. execsql/data/execsql.conf.template +14 -4
  11. execsql/db/access.py +29 -8
  12. execsql/db/base.py +40 -8
  13. execsql/db/dsn.py +13 -4
  14. execsql/db/duckdb.py +3 -1
  15. execsql/db/factory.py +11 -2
  16. execsql/db/firebird.py +21 -7
  17. execsql/db/mysql.py +37 -16
  18. execsql/db/oracle.py +49 -16
  19. execsql/db/postgres.py +30 -6
  20. execsql/db/sqlite.py +7 -9
  21. execsql/db/sqlserver.py +25 -8
  22. execsql/exporters/delimited.py +87 -28
  23. execsql/exporters/xlsx.py +24 -9
  24. execsql/exporters/zip.py +1 -2
  25. execsql/format.py +29 -2
  26. execsql/gui/desktop.py +1 -3
  27. execsql/gui/tui.py +2 -2
  28. execsql/importers/base.py +35 -1
  29. execsql/importers/csv.py +3 -1
  30. execsql/manifest.py +33 -22
  31. execsql/metacommands/conditions.py +6 -7
  32. execsql/metacommands/data.py +4 -1
  33. execsql/metacommands/debug.py +2 -4
  34. execsql/metacommands/io_fileops.py +62 -37
  35. execsql/metacommands/io_import.py +0 -1
  36. execsql/metacommands/prompt.py +2 -0
  37. execsql/metacommands/upsert.py +5 -2
  38. execsql/models.py +3 -2
  39. execsql/parser.py +14 -5
  40. execsql/plugins.py +24 -0
  41. execsql/script/engine.py +40 -11
  42. execsql/script/executor.py +9 -5
  43. execsql/script/parser.py +167 -118
  44. execsql/state.py +23 -9
  45. execsql/state.pyi +8 -0
  46. execsql/types.py +22 -13
  47. execsql/utils/auth.py +169 -24
  48. execsql/utils/datetime.py +55 -17
  49. execsql/utils/errors.py +71 -9
  50. execsql/utils/fileio.py +404 -117
  51. execsql/utils/gui.py +3 -0
  52. execsql/utils/mail.py +37 -12
  53. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
  72. {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** — the AST parser rejects unmatched IF / LOOP /
11
- BATCH / SCRIPT blocks at parse time with precise source spans;
12
- ``cli/__init__.py`` reports any parse failure as a lint error before
13
- :func:`lint` is even called.
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>\w+)\s", re.I)
198
- _RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
199
- _RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
200
- _RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
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
- """Extract variable name from a SUB-family metacommand into *defined*."""
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
- _RX_SUB_QUERYSTRING,
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
- cp.read(p)
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|1\s*=\s*1|yes)\s*\)?\s*$", re.I)
431
- _RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|0\s*=\s*1|1\s*=\s*0|no)\s*\)?\s*$", re.I)
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 ``!!var!!`` reference anywhere beneath *nodes*."""
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 _RX_VAR_REF.finditer(text):
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 m in _RX_VAR_REF.finditer(node.text):
602
- _check_var_ref(m.group(1), src, lno, defined_vars, issues)
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
- for m in _RX_VAR_REF.finditer(node.command):
607
- _check_var_ref(m.group(1), src, lno, defined_vars, issues)
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
  ),