execsql2 2.16.18__py3-none-any.whl → 2.17.2__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 (74) hide show
  1. execsql/__init__.py +6 -2
  2. execsql/api.py +25 -6
  3. execsql/cli/__init__.py +5 -3
  4. execsql/cli/lint.py +30 -34
  5. execsql/cli/run.py +10 -0
  6. execsql/config.py +145 -92
  7. execsql/db/access.py +54 -40
  8. execsql/db/base.py +33 -6
  9. execsql/db/firebird.py +3 -1
  10. execsql/db/mysql.py +4 -3
  11. execsql/db/oracle.py +36 -14
  12. execsql/db/postgres.py +8 -6
  13. execsql/db/sqlite.py +5 -2
  14. execsql/db/sqlserver.py +8 -6
  15. execsql/debug/repl.py +59 -21
  16. execsql/exceptions.py +19 -4
  17. execsql/exporters/base.py +3 -2
  18. execsql/exporters/delimited.py +2 -3
  19. execsql/exporters/feather.py +3 -3
  20. execsql/exporters/ods.py +1 -1
  21. execsql/exporters/xls.py +12 -4
  22. execsql/exporters/xlsx.py +1 -1
  23. execsql/gui/desktop.py +129 -15
  24. execsql/importers/__init__.py +1 -1
  25. execsql/importers/ods.py +1 -1
  26. execsql/importers/xls.py +1 -1
  27. execsql/metacommands/__init__.py +34 -5
  28. execsql/metacommands/conditions.py +26 -14
  29. execsql/metacommands/connect.py +21 -14
  30. execsql/metacommands/control.py +55 -68
  31. execsql/metacommands/data.py +25 -9
  32. execsql/metacommands/debug.py +132 -77
  33. execsql/metacommands/io_export.py +14 -2
  34. execsql/metacommands/io_import.py +11 -2
  35. execsql/metacommands/io_write.py +113 -11
  36. execsql/metacommands/prompt.py +46 -32
  37. execsql/metacommands/script_ext.py +63 -34
  38. execsql/metacommands/system.py +4 -3
  39. execsql/metacommands/upsert.py +0 -29
  40. execsql/script/__init__.py +28 -37
  41. execsql/script/ast.py +7 -7
  42. execsql/script/control.py +4 -101
  43. execsql/script/engine.py +37 -251
  44. execsql/script/executor.py +193 -230
  45. execsql/script/parser.py +1 -3
  46. execsql/script/variables.py +8 -3
  47. execsql/state.py +125 -37
  48. execsql/utils/errors.py +0 -2
  49. execsql/utils/fileio.py +47 -3
  50. execsql/utils/mail.py +3 -2
  51. execsql/utils/strings.py +5 -5
  52. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/METADATA +42 -36
  53. execsql2-2.17.2.dist-info/RECORD +124 -0
  54. execsql2-2.17.2.dist-info/licenses/NOTICE +11 -0
  55. execsql2-2.16.18.dist-info/RECORD +0 -124
  56. execsql2-2.16.18.dist-info/licenses/NOTICE +0 -10
  57. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/README.md +0 -0
  58. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  59. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  60. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/execsql.conf +0 -0
  61. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  62. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  63. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  64. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  65. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  66. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  67. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  68. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/script_template.sql +0 -0
  69. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  70. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  71. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  72. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/WHEEL +0 -0
  73. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/entry_points.txt +0 -0
  74. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -1,22 +1,28 @@
1
1
  """AST-based script executor for execsql.
2
2
 
3
- Walks a :class:`~execsql.script.ast.Script` tree and executes each node,
4
- replacing the flat ``CommandList.run_next()`` loop for scripts parsed via
5
- the AST parser.
3
+ Walks a :class:`~execsql.script.ast.Script` tree and executes each node.
4
+ This is the only execution engine; the parser produces the tree, this
5
+ module runs it.
6
6
 
7
7
  Design:
8
- - **The executor owns control flow.** IF conditions, LOOP iteration,
9
- and BATCH boundaries are driven by the tree structure no
10
- ``if_stack`` or ``compiling_loop`` needed.
11
- - **SQL and metacommands delegate to the existing runtime.** SQL is
12
- executed via the current database connection; metacommands are
13
- dispatched through ``ctx.metacommandlist.eval()``. All 200+
14
- metacommand handlers work unchanged.
15
- - **Variable substitution** uses the existing ``substitute_vars()``.
16
- - **RuntimeContext is passed explicitly** as ``ctx`` — the first
17
- module migrated to instance-based context (Phase 2). The public
18
- ``execute()`` function defaults to ``get_context()`` if no ``ctx``
19
- is provided, so callers that haven't migrated yet work unchanged.
8
+ - **Control flow is tree-driven.** IF conditions, LOOP iteration, and
9
+ BATCH boundaries are resolved by walking nested nodes, not by
10
+ runtime state flags.
11
+ - **SQL and metacommands delegate to the existing runtime.** SQL
12
+ runs against the active database connection; metacommands are
13
+ dispatched through ``ctx.metacommandlist.eval()`` (the same
14
+ ~225-entry dispatch table the rest of the codebase uses).
15
+ - **Variable substitution** uses :func:`execsql.script.engine.substitute_vars`.
16
+ - **Context is explicit.** :func:`execute` takes a
17
+ :class:`~execsql.state.RuntimeContext` via the ``ctx`` keyword;
18
+ when omitted it falls back to :func:`~execsql.state.get_context`.
19
+ For tracking and error reporting the executor pushes
20
+ :class:`~execsql.state.ExecFrame` records onto
21
+ ``ctx.ast_exec_stack`` as it descends into IF / LOOP / BATCH /
22
+ INCLUDE'd files / ``EXECUTE SCRIPT`` calls. Scope frames
23
+ (``main``, ``script``) carry the active ``localvars`` and
24
+ ``paramvals``; block frames cache a ``scope_ref`` to the
25
+ enclosing scope for O(1) variable lookup.
20
26
 
21
27
  Usage::
22
28
 
@@ -24,12 +30,13 @@ Usage::
24
30
  from execsql.script.parser import parse_script
25
31
 
26
32
  tree = parse_script("pipeline.sql")
27
- execute(tree) # uses global context
33
+ execute(tree) # uses the thread-local context
28
34
 
29
35
  # Or with an explicit context:
30
- from execsql.state import RuntimeContext, get_context
31
- ctx = get_context()
32
- execute(tree, ctx=ctx)
36
+ from execsql.state import RuntimeContext, active_context
37
+ ctx = RuntimeContext()
38
+ with active_context(ctx):
39
+ execute(tree, ctx=ctx)
33
40
  """
34
41
 
35
42
  from __future__ import annotations
@@ -57,9 +64,9 @@ from execsql.script.ast import (
57
64
  SqlBlock,
58
65
  SqlStatement,
59
66
  )
60
- from execsql.script.engine import CommandList, set_dynamic_system_vars, set_static_system_vars, substitute_vars
67
+ from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
61
68
  from execsql.script.variables import SubVarSet
62
- from execsql.state import RuntimeContext, active_context, get_context, xcmd_test
69
+ from execsql.state import ExecFrame, RuntimeContext, active_context, get_context, xcmd_test
63
70
  from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
64
71
 
65
72
  __all__ = ["execute"]
@@ -81,18 +88,21 @@ _VARLIKE = re.compile(r"!![$@&~#]?\w+!!", re.I)
81
88
 
82
89
 
83
90
  def _stack_localvars(ctx: RuntimeContext) -> SubVarSet | None:
84
- """Build the merged local+param variable overlay from the current stack frame.
91
+ """Build the merged ``~`` local + ``#`` param overlay for the current scope.
85
92
 
86
- Returns ``frame.localvars.merge(frame.paramvals)`` for the top frame on
87
- ``ctx.commandliststack``, or ``None`` if the stack is empty. This mirrors
88
- the legacy ``CommandList.run_and_increment()`` pattern (line 517 of
89
- engine.py) so that both ``substitute_vars`` and ``get_subvarset`` (used by
90
- ``x_sub``, ``x_rm_sub``, etc.) operate on the same scope.
93
+ Returns ``localvars.merge(paramvals)`` for the innermost SCRIPT or
94
+ ``<main>`` scope frame, or ``None`` if the exec stack is empty. This is
95
+ the canonical "current variable scope" used by ``substitute_vars`` and
96
+ ``get_subvarset`` so that ``x_sub``, ``x_rm_sub``, condition predicates,
97
+ and prompt handlers all see the same overlay.
91
98
  """
92
- if ctx.commandliststack:
93
- frame = ctx.commandliststack[-1]
94
- return frame.localvars.merge(frame.paramvals)
95
- return None
99
+ scope = ctx.current_scope()
100
+ if scope is None:
101
+ return None
102
+ localvars = scope.localvars
103
+ if localvars is None:
104
+ return None
105
+ return localvars.merge(scope.paramvals)
96
106
 
97
107
 
98
108
  def _push_frame(
@@ -102,26 +112,43 @@ def _push_frame(
102
112
  line_no: int = 0,
103
113
  *,
104
114
  paramnames: list[str] | None = None,
105
- ) -> CommandList:
106
- """Push a synthetic CommandList frame onto the command-list stack.
107
-
108
- Creates a minimal frame with a sentinel :class:`ScriptCmd` so that
109
- ``current_script_line()`` and ``get_subvarset()`` work correctly.
110
- Returns the frame so the caller can set ``paramvals`` or pre-populate
111
- ``localvars``.
115
+ kind: str = "script",
116
+ ) -> ExecFrame:
117
+ """Push a scope frame onto the unified AST execution stack.
118
+
119
+ Creates an :class:`ExecFrame` of ``kind`` ``"main"`` or ``"script"`` with a
120
+ fresh :class:`LocalSubVarSet` and the declared ``paramnames``. Returns
121
+ the frame so the caller can set ``paramvals`` after parsing the call's
122
+ arguments.
112
123
  """
113
- from execsql.script.engine import MetacommandStmt, ScriptCmd
114
-
115
- sentinel = ScriptCmd(source, line_no, "cmd", MetacommandStmt(f"BEGIN SCRIPT {name}"))
116
- frame = CommandList([sentinel], name, paramnames)
117
- ctx.commandliststack.append(frame)
124
+ from execsql.script.variables import LocalSubVarSet
125
+
126
+ frame = ExecFrame(
127
+ kind=kind,
128
+ label=name,
129
+ source=source,
130
+ line=line_no or None,
131
+ localvars=LocalSubVarSet(),
132
+ paramnames=paramnames,
133
+ )
134
+ ctx.ast_exec_stack.append(frame)
118
135
  return frame
119
136
 
120
137
 
121
138
  def _pop_frame(ctx: RuntimeContext) -> None:
122
- """Pop the top frame from the command-list stack."""
123
- if ctx.commandliststack:
124
- ctx.commandliststack.pop()
139
+ """Pop the top scope frame from the AST execution stack."""
140
+ if ctx.ast_exec_stack:
141
+ ctx.ast_exec_stack.pop()
142
+
143
+
144
+ def _push_block_frame(ctx: RuntimeContext, frame: ExecFrame) -> None:
145
+ """Push a non-scope frame (if/loop/batch/include) onto the exec stack.
146
+
147
+ Caches a reference to the enclosing SCRIPT/main scope on the frame so
148
+ that ``current_localvars()`` stays O(1) regardless of nesting depth.
149
+ """
150
+ frame.scope_ref = ctx.current_scope()
151
+ ctx.ast_exec_stack.append(frame)
125
152
 
126
153
 
127
154
  def _convert_deferred_vars(text: str) -> str:
@@ -243,15 +270,17 @@ def _exec_sql(
243
270
 
244
271
  def _exec_metacommand(
245
272
  ctx: RuntimeContext,
246
- command: str,
273
+ cmd: str,
247
274
  source: str,
248
275
  line_no: int,
249
- localvars: SubVarSet | None = None,
250
276
  ) -> Any:
251
- """Dispatch a metacommand through the dispatch table."""
252
- # Build localvars from the command-list stack frame (see _exec_sql comment).
253
- effective_locals = _stack_localvars(ctx) or localvars
254
- cmd = substitute_vars(command, effective_locals, ctx=ctx)
277
+ """Dispatch a metacommand through the dispatch table.
278
+
279
+ *cmd* must already have ``!!$VAR!!`` substitution applied. The caller is
280
+ responsible for expansion so that side-effecting substitutions (counter
281
+ increments, ``$RANDOM``, ``$UUID``) are evaluated exactly once per
282
+ metacommand reference.
283
+ """
255
284
  if _VARLIKE.search(cmd):
256
285
  ctx.output.write(
257
286
  f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
@@ -355,13 +384,15 @@ def _execute_node(
355
384
  command = node.command
356
385
  if in_loop:
357
386
  command = _convert_deferred_vars(command)
358
- # Intercept BREAK before dispatch it controls loop flow
387
+ # Substitute once: the same expanded text is used for BREAK detection
388
+ # and dispatch. Calling substitute_vars twice would double-increment
389
+ # !!$COUNTER_N!! and re-roll !!$RANDOM!!/!!$UUID!! references.
359
390
  effective_locals = _stack_localvars(ctx) or localvars
360
391
  expanded = substitute_vars(command, effective_locals, ctx=ctx)
361
392
  if _BREAK_RX.match(expanded):
362
393
  raise _BreakLoop
363
394
  ctx.last_command = _FakeScriptCmd(node)
364
- _exec_metacommand(ctx, command, node.span.file, node.span.start_line, localvars)
395
+ _exec_metacommand(ctx, expanded, node.span.file, node.span.start_line)
365
396
 
366
397
  elif isinstance(node, IfBlock):
367
398
  ctx.last_command = _FakeScriptCmd(node)
@@ -372,12 +403,15 @@ def _execute_node(
372
403
  _execute_loop(ctx, node, localvars)
373
404
 
374
405
  elif isinstance(node, BatchBlock):
406
+ ctx.last_command = _FakeScriptCmd(node)
375
407
  _execute_batch(ctx, node, localvars, in_loop=in_loop)
376
408
 
377
409
  elif isinstance(node, ScriptBlock):
410
+ ctx.last_command = _FakeScriptCmd(node)
378
411
  _register_script_block(ctx, node)
379
412
 
380
413
  elif isinstance(node, SqlBlock):
414
+ ctx.last_command = _FakeScriptCmd(node)
381
415
  _execute_sql_block(ctx, node, localvars, in_loop=in_loop)
382
416
 
383
417
  elif isinstance(node, IncludeDirective):
@@ -403,20 +437,48 @@ def _execute_if(
403
437
  *,
404
438
  in_loop: bool = False,
405
439
  ) -> None:
406
- """Evaluate an IF block and execute the matching branch."""
440
+ """Evaluate an IF block and execute the matching branch.
441
+
442
+ Pushes an :class:`ExecFrame` onto ``ctx.ast_exec_stack`` for the active
443
+ branch while its body executes, so the debug REPL and
444
+ ``DEBUG WRITE COMMANDLISTSTACK`` see the current IF nesting.
445
+ """
446
+ from execsql.state import ExecFrame
447
+
407
448
  if _eval_condition(ctx, node.condition, node.condition_modifiers):
408
- _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
449
+ _push_block_frame(
450
+ ctx,
451
+ ExecFrame(kind="if", label=node.condition, source=node.span.file, line=node.span.start_line),
452
+ )
453
+ try:
454
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
455
+ finally:
456
+ ctx.ast_exec_stack.pop()
409
457
  return
410
458
 
411
459
  # Try ELSEIF clauses
412
460
  for clause in node.elseif_clauses:
413
461
  if _eval_condition(ctx, clause.condition, clause.condition_modifiers):
414
- _execute_nodes(ctx, clause.body, node.span.file, localvars, in_loop=in_loop)
462
+ _push_block_frame(
463
+ ctx,
464
+ ExecFrame(kind="elseif", label=clause.condition, source=node.span.file, line=node.span.start_line),
465
+ )
466
+ try:
467
+ _execute_nodes(ctx, clause.body, node.span.file, localvars, in_loop=in_loop)
468
+ finally:
469
+ ctx.ast_exec_stack.pop()
415
470
  return
416
471
 
417
472
  # ELSE branch
418
473
  if node.else_body:
419
- _execute_nodes(ctx, node.else_body, node.span.file, localvars, in_loop=in_loop)
474
+ _push_block_frame(
475
+ ctx,
476
+ ExecFrame(kind="else", label="", source=node.span.file, line=node.span.start_line),
477
+ )
478
+ try:
479
+ _execute_nodes(ctx, node.else_body, node.span.file, localvars, in_loop=in_loop)
480
+ finally:
481
+ ctx.ast_exec_stack.pop()
420
482
 
421
483
 
422
484
  def _execute_loop(
@@ -425,29 +487,39 @@ def _execute_loop(
425
487
  localvars: SubVarSet | None = None,
426
488
  ) -> None:
427
489
  """Execute a LOOP WHILE or LOOP UNTIL block."""
490
+ from execsql.state import ExecFrame
491
+
428
492
  # Convert deferred vars in the condition — they re-evaluate each iteration
429
493
  condition = _convert_deferred_vars(node.condition)
430
494
 
431
- if node.loop_type == "WHILE":
432
- while True:
433
- effective_locals = _stack_localvars(ctx)
434
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
435
- if not xcmd_test(expanded):
436
- break
437
- try:
438
- _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
439
- except _BreakLoop:
440
- break
441
- else: # UNTIL
442
- while True:
443
- try:
444
- _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
445
- except _BreakLoop:
446
- break
447
- effective_locals = _stack_localvars(ctx)
448
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
449
- if xcmd_test(expanded):
450
- break
495
+ kind = "loop_while" if node.loop_type == "WHILE" else "loop_until"
496
+ frame = ExecFrame(kind=kind, label=node.condition, source=node.span.file, line=node.span.start_line, iteration=0)
497
+ _push_block_frame(ctx, frame)
498
+ try:
499
+ if node.loop_type == "WHILE":
500
+ while True:
501
+ effective_locals = _stack_localvars(ctx)
502
+ expanded = substitute_vars(condition, effective_locals, ctx=ctx)
503
+ if not xcmd_test(expanded):
504
+ break
505
+ frame.iteration += 1
506
+ try:
507
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
508
+ except _BreakLoop:
509
+ break
510
+ else: # UNTIL
511
+ while True:
512
+ frame.iteration += 1
513
+ try:
514
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
515
+ except _BreakLoop:
516
+ break
517
+ effective_locals = _stack_localvars(ctx)
518
+ expanded = substitute_vars(condition, effective_locals, ctx=ctx)
519
+ if xcmd_test(expanded):
520
+ break
521
+ finally:
522
+ ctx.ast_exec_stack.pop()
451
523
 
452
524
 
453
525
  def _execute_batch(
@@ -458,10 +530,17 @@ def _execute_batch(
458
530
  in_loop: bool = False,
459
531
  ) -> None:
460
532
  """Execute a BEGIN BATCH / END BATCH block."""
533
+ from execsql.state import ExecFrame
534
+
461
535
  ctx.status.batch.new_batch()
536
+ _push_block_frame(
537
+ ctx,
538
+ ExecFrame(kind="batch", label="", source=node.span.file, line=node.span.start_line),
539
+ )
462
540
  try:
463
541
  _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
464
542
  finally:
543
+ ctx.ast_exec_stack.pop()
465
544
  if ctx.status.batch.in_batch():
466
545
  ctx.status.batch.end_batch()
467
546
 
@@ -494,135 +573,9 @@ def _pre_register_scripts(ctx: RuntimeContext, nodes: list[Node]) -> None:
494
573
 
495
574
 
496
575
  def _register_script_block(ctx: RuntimeContext, node: ScriptBlock) -> None:
497
- """Register a named SCRIPT block.
498
-
499
- Stores the AST node in ``ctx.ast_scripts`` for native execution, and also
500
- builds a legacy ``CommandList`` in ``ctx.savedscripts`` so that dispatch
501
- table handlers (e.g., ON ERROR_HALT EXECUTE SCRIPT) still work.
502
- """
503
- from execsql.script.engine import CommandList
504
-
505
- # AST-native registry
576
+ """Register a named SCRIPT block in the AST script registry."""
506
577
  ctx.ast_scripts[node.name] = node
507
578
 
508
- # Legacy compatibility — flatten to CommandList for dispatch table
509
- cmdlist = _flatten_for_legacy(node.body, node.span.file)
510
- cl = CommandList(cmdlist, node.name, node.param_names)
511
- ctx.savedscripts[node.name] = cl
512
-
513
-
514
- def _flatten_for_legacy(nodes: list[Node], source: str) -> list:
515
- """Convert AST nodes to flat ScriptCmd list for legacy compatibility."""
516
- from execsql.script.engine import MetacommandStmt, ScriptCmd, SqlStmt
517
-
518
- result = []
519
- for node in nodes:
520
- if isinstance(node, SqlStatement):
521
- text = re.sub(r"\s*;(\s*;\s*)+$", ";", node.text)
522
- result.append(
523
- ScriptCmd(node.span.file, node.span.start_line, "sql", SqlStmt(text)),
524
- )
525
- elif isinstance(node, MetaCommandStatement):
526
- result.append(
527
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(node.command)),
528
- )
529
- elif isinstance(node, IfBlock):
530
- result.append(
531
- ScriptCmd(
532
- node.span.file,
533
- node.span.start_line,
534
- "cmd",
535
- MetacommandStmt(f"IF ({node.condition})"),
536
- ),
537
- )
538
- # Emit ANDIF/ORIF condition modifiers after the IF
539
- for mod in node.condition_modifiers:
540
- keyword = "ANDIF" if mod.kind == "AND" else "ORIF"
541
- result.append(
542
- ScriptCmd(
543
- mod.span.file,
544
- mod.span.start_line,
545
- "cmd",
546
- MetacommandStmt(f"{keyword} ({mod.condition})"),
547
- ),
548
- )
549
- result.extend(_flatten_for_legacy(node.body, source))
550
- for clause in node.elseif_clauses:
551
- result.append(
552
- ScriptCmd(
553
- clause.span.file,
554
- clause.span.start_line,
555
- "cmd",
556
- MetacommandStmt(f"ELSEIF ({clause.condition})"),
557
- ),
558
- )
559
- result.extend(_flatten_for_legacy(clause.body, source))
560
- if node.else_body:
561
- result.append(
562
- ScriptCmd(
563
- node.span.file,
564
- node.else_span.start_line if node.else_span else node.span.start_line,
565
- "cmd",
566
- MetacommandStmt("ELSE"),
567
- ),
568
- )
569
- result.extend(_flatten_for_legacy(node.else_body, source))
570
- result.append(
571
- ScriptCmd(
572
- node.span.file,
573
- node.span.effective_end_line,
574
- "cmd",
575
- MetacommandStmt("ENDIF"),
576
- ),
577
- )
578
- elif isinstance(node, LoopBlock):
579
- result.append(
580
- ScriptCmd(
581
- node.span.file,
582
- node.span.start_line,
583
- "cmd",
584
- MetacommandStmt(f"LOOP {node.loop_type} ({node.condition})"),
585
- ),
586
- )
587
- result.extend(_flatten_for_legacy(node.body, source))
588
- result.append(
589
- ScriptCmd(
590
- node.span.file,
591
- node.span.effective_end_line,
592
- "cmd",
593
- MetacommandStmt("END LOOP"),
594
- ),
595
- )
596
- elif isinstance(node, BatchBlock):
597
- result.append(
598
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt("BEGIN BATCH")),
599
- )
600
- result.extend(_flatten_for_legacy(node.body, source))
601
- result.append(
602
- ScriptCmd(node.span.file, node.span.effective_end_line, "cmd", MetacommandStmt("END BATCH")),
603
- )
604
- elif isinstance(node, SqlBlock):
605
- result.extend(_flatten_for_legacy(node.body, source))
606
- elif isinstance(node, IncludeDirective):
607
- if node.is_execute_script:
608
- parts = ["EXECUTE SCRIPT"]
609
- if node.if_exists:
610
- parts.append("IF EXISTS")
611
- parts.append(node.target)
612
- if node.arguments:
613
- parts.append(f"WITH ARGS ({node.arguments})")
614
- if node.loop_type:
615
- parts.append(f"{node.loop_type} ({node.loop_condition})")
616
- result.append(
617
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(" ".join(parts))),
618
- )
619
- else:
620
- prefix = "INCLUDE IF EXISTS" if node.if_exists else "INCLUDE"
621
- result.append(
622
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(f"{prefix} {node.target}")),
623
- )
624
- return result
625
-
626
579
 
627
580
  def _execute_sql_block(
628
581
  ctx: RuntimeContext,
@@ -662,22 +615,12 @@ def _execute_include(
662
615
  _execute_script_native(ctx, node, ctx.ast_scripts[target], localvars)
663
616
  return
664
617
 
665
- # Target not in AST registry might be defined in an INCLUDE'd file
666
- # that hasn't been loaded yet. Fall through to legacy dispatch.
667
- if not node.if_exists and target not in ctx.savedscripts:
668
- raise ErrInfo(
669
- "cmd",
670
- other_msg=f"There is no SCRIPT named {node.target}.",
671
- )
672
- if node.if_exists and target not in ctx.savedscripts:
673
- return # IF EXISTS — skip silently
674
-
675
- # Target is in savedscripts but not ast_scripts — this shouldn't
676
- # happen when the AST executor is the only engine, but handle it
677
- # gracefully by raising an error.
618
+ # Target not registeredIF EXISTS silently skips; otherwise error.
619
+ if node.if_exists:
620
+ return
678
621
  raise ErrInfo(
679
622
  "cmd",
680
- other_msg=f"SCRIPT {node.target} is not registered in the AST executor.",
623
+ other_msg=f"There is no SCRIPT named {node.target}.",
681
624
  )
682
625
 
683
626
  # --- INCLUDE (file inclusion) — parse and execute natively ---
@@ -692,10 +635,11 @@ def _execute_script_native(
692
635
  ) -> None:
693
636
  """Execute a SCRIPT block natively through the AST executor.
694
637
 
695
- Pushes a :class:`CommandList` frame onto ``ctx.commandliststack`` so that
696
- legacy metacommand handlers (``x_sub``, ``x_rm_sub``, ``xf_sub_defined``,
697
- prompt handlers, etc.) can access ``~`` local variables and ``#`` script
698
- arguments via ``get_subvarset()`` / ``commandliststack[-1]``. The frame
638
+ Pushes a SCRIPT-kind :class:`~execsql.state.ExecFrame` onto
639
+ ``ctx.ast_exec_stack`` so that metacommand handlers (``x_sub``,
640
+ ``x_rm_sub``, ``xf_sub_defined``, prompt handlers, etc.) can access
641
+ ``~`` local variables and ``#`` script arguments via
642
+ ``ctx.current_localvars()`` / ``ctx.current_paramvals()``. The frame
699
643
  is popped on exit (including on error) via ``try/finally``.
700
644
  """
701
645
  from execsql.script.variables import ScriptArgSubVarSet
@@ -750,7 +694,7 @@ def _execute_script_native(
750
694
  if pdef.default is not None:
751
695
  paramvals.add_substitution(f"#{pdef.name}", pdef.default)
752
696
 
753
- # Push a CommandList frame onto the stack so that:
697
+ # Push a SCRIPT-kind ExecFrame onto ast_exec_stack so that:
754
698
  # - get_subvarset() can find ~local and +outer-scope variables
755
699
  # - xf_sub_defined/xf_sub_empty can check ~local and #param variables
756
700
  # - current_script_line() returns meaningful source location
@@ -761,9 +705,11 @@ def _execute_script_native(
761
705
  script_block.span.file,
762
706
  script_block.span.start_line,
763
707
  paramnames=script_block.param_names,
708
+ kind="script",
764
709
  )
765
710
  if paramvals is not None:
766
711
  frame.paramvals = paramvals
712
+ frame.params = dict(paramvals.substitutions)
767
713
 
768
714
  try:
769
715
 
@@ -783,12 +729,14 @@ def _execute_script_native(
783
729
  expanded = substitute_vars(condition, effective_locals, ctx=ctx)
784
730
  if not xcmd_test(expanded):
785
731
  break
732
+ frame.iteration += 1
786
733
  try:
787
734
  _run_body()
788
735
  except _BreakLoop:
789
736
  break
790
737
  elif node.loop_type == "UNTIL":
791
738
  while True:
739
+ frame.iteration += 1
792
740
  try:
793
741
  _run_body()
794
742
  except _BreakLoop:
@@ -798,7 +746,13 @@ def _execute_script_native(
798
746
  if xcmd_test(expanded):
799
747
  break
800
748
  else:
801
- _run_body()
749
+ try:
750
+ _run_body()
751
+ except _BreakLoop as exc:
752
+ raise ErrInfo(
753
+ type="cmd",
754
+ other_msg=f"BREAK metacommand inside SCRIPT '{script_block.name}' but not inside a LOOP.",
755
+ ) from exc
802
756
  finally:
803
757
  _pop_frame(ctx)
804
758
 
@@ -863,10 +817,17 @@ def _execute_include_native(
863
817
  _pre_register_scripts(ctx, included_tree.body)
864
818
 
865
819
  # Execute with include-chain tracking
820
+ from execsql.state import ExecFrame
821
+
866
822
  ctx.include_chain.append(resolved)
823
+ _push_block_frame(
824
+ ctx,
825
+ ExecFrame(kind="include", label=Path(resolved).name, source=resolved, line=1),
826
+ )
867
827
  try:
868
828
  _execute_nodes(ctx, included_tree.body, included_tree.source, localvars)
869
829
  finally:
830
+ ctx.ast_exec_stack.pop()
870
831
  ctx.include_chain.pop()
871
832
 
872
833
 
@@ -971,6 +932,8 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
971
932
  with active_context(ctx):
972
933
  ctx.ast_scripts.clear()
973
934
  ctx.include_chain.clear()
935
+ ctx.ast_exec_stack.clear()
936
+ ctx.last_command = None
974
937
  # Seed the include chain with the main script to catch self-includes.
975
938
  if script.source != "<inline>":
976
939
  try:
@@ -982,11 +945,11 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
982
945
  # The legacy engine registered scripts at parse time (two-pass);
983
946
  # the AST executor must do an explicit pre-scan.
984
947
  _pre_register_scripts(ctx, script.body)
985
- # Push a root frame so commandliststack is never empty during AST
986
- # execution. This ensures get_subvarset(), current_script_line(),
987
- # xf_sub_defined(), the REPL, and all other commandliststack readers
988
- # work correctly even at the top level.
989
- _push_frame(ctx, "<main>", script.source)
948
+ # Push a root <main> scope frame so the AST exec stack is never
949
+ # empty during execution. This ensures get_subvarset(),
950
+ # current_script_line(), xf_sub_defined(), the REPL, and every
951
+ # variable-scoping reader works correctly even at the top level.
952
+ _push_frame(ctx, "<main>", script.source, line_no=1, kind="main")
990
953
  try:
991
954
  _execute_nodes(ctx, script.body, script.source)
992
955
  except _BreakLoop as exc:
execsql/script/parser.py CHANGED
@@ -1,9 +1,7 @@
1
1
  """AST-producing parser for execsql scripts.
2
2
 
3
3
  Converts raw ``.sql`` script text into a :class:`~execsql.script.ast.Script`
4
- tree. This is intended to eventually replace ``_parse_script_lines()`` in
5
- :mod:`execsql.script.engine` — during the transition both paths coexist and
6
- can be compared for correctness.
4
+ tree consumed by :func:`execsql.script.executor.execute`.
7
5
 
8
6
  The parser is a single-pass, line-oriented state machine that tracks:
9
7
 
@@ -248,8 +248,12 @@ class SubVarSet:
248
248
  sub = sub.replace("\\", "\\\\")
249
249
  quote = m.group("q")
250
250
  if quote == "'":
251
- sub = sub.replace("'", "''")
251
+ # Wrap value in single quotes, doubling any embedded
252
+ # apostrophe — produces a SQL string literal.
253
+ sub = "'" + sub.replace("'", "''") + "'"
252
254
  elif quote == '"':
255
+ # Wrap value in double quotes — produces a SQL quoted
256
+ # identifier or quoted metacommand argument.
253
257
  sub = '"' + sub + '"'
254
258
  return command_str[: m.start()] + sub + command_str[m.end() :], True
255
259
  # Token found but variable not defined — skip it and keep searching.
@@ -277,12 +281,13 @@ class SubVarSet:
277
281
  idx = cmd_lower.find(token)
278
282
  if idx != -1:
279
283
  return command_str[:idx] + sub + command_str[idx + len(token) :], True
280
- # Single-quote-escaped token: !'!varname!'!
284
+ # Single-quote-wrapped token: !'!varname!'!
281
285
  tokenq = f"!'!{varname}!'!"
282
286
  idxq = cmd_lower.find(tokenq)
283
287
  if idxq != -1:
288
+ wrapped = "'" + sub.replace("'", "''") + "'"
284
289
  return (
285
- command_str[:idxq] + sub.replace("'", "''") + command_str[idxq + len(tokenq) :],
290
+ command_str[:idxq] + wrapped + command_str[idxq + len(tokenq) :],
286
291
  True,
287
292
  )
288
293
  # Double-quote-wrapped token: !"!varname!"!