execsql2 2.17.0__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 (73) 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/script/__init__.py +28 -37
  40. execsql/script/ast.py +7 -7
  41. execsql/script/control.py +4 -101
  42. execsql/script/engine.py +37 -251
  43. execsql/script/executor.py +181 -222
  44. execsql/script/parser.py +1 -3
  45. execsql/script/variables.py +8 -3
  46. execsql/state.py +125 -37
  47. execsql/utils/errors.py +0 -2
  48. execsql/utils/fileio.py +47 -3
  49. execsql/utils/mail.py +3 -2
  50. execsql/utils/strings.py +5 -5
  51. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/METADATA +42 -36
  52. execsql2-2.17.2.dist-info/RECORD +124 -0
  53. execsql2-2.17.2.dist-info/licenses/NOTICE +11 -0
  54. execsql2-2.17.0.dist-info/RECORD +0 -124
  55. execsql2-2.17.0.dist-info/licenses/NOTICE +0 -10
  56. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/README.md +0 -0
  57. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  58. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  59. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/execsql.conf +0 -0
  60. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  61. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  62. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  63. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  64. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  65. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  66. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  67. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/script_template.sql +0 -0
  68. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  69. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  70. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  71. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/WHEEL +0 -0
  72. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/entry_points.txt +0 -0
  73. {execsql2-2.17.0.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:
@@ -376,12 +403,15 @@ def _execute_node(
376
403
  _execute_loop(ctx, node, localvars)
377
404
 
378
405
  elif isinstance(node, BatchBlock):
406
+ ctx.last_command = _FakeScriptCmd(node)
379
407
  _execute_batch(ctx, node, localvars, in_loop=in_loop)
380
408
 
381
409
  elif isinstance(node, ScriptBlock):
410
+ ctx.last_command = _FakeScriptCmd(node)
382
411
  _register_script_block(ctx, node)
383
412
 
384
413
  elif isinstance(node, SqlBlock):
414
+ ctx.last_command = _FakeScriptCmd(node)
385
415
  _execute_sql_block(ctx, node, localvars, in_loop=in_loop)
386
416
 
387
417
  elif isinstance(node, IncludeDirective):
@@ -407,20 +437,48 @@ def _execute_if(
407
437
  *,
408
438
  in_loop: bool = False,
409
439
  ) -> None:
410
- """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
+
411
448
  if _eval_condition(ctx, node.condition, node.condition_modifiers):
412
- _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()
413
457
  return
414
458
 
415
459
  # Try ELSEIF clauses
416
460
  for clause in node.elseif_clauses:
417
461
  if _eval_condition(ctx, clause.condition, clause.condition_modifiers):
418
- _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()
419
470
  return
420
471
 
421
472
  # ELSE branch
422
473
  if node.else_body:
423
- _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()
424
482
 
425
483
 
426
484
  def _execute_loop(
@@ -429,29 +487,39 @@ def _execute_loop(
429
487
  localvars: SubVarSet | None = None,
430
488
  ) -> None:
431
489
  """Execute a LOOP WHILE or LOOP UNTIL block."""
490
+ from execsql.state import ExecFrame
491
+
432
492
  # Convert deferred vars in the condition — they re-evaluate each iteration
433
493
  condition = _convert_deferred_vars(node.condition)
434
494
 
435
- if node.loop_type == "WHILE":
436
- while True:
437
- effective_locals = _stack_localvars(ctx)
438
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
439
- if not xcmd_test(expanded):
440
- break
441
- try:
442
- _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
443
- except _BreakLoop:
444
- break
445
- else: # UNTIL
446
- while True:
447
- try:
448
- _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
449
- except _BreakLoop:
450
- break
451
- effective_locals = _stack_localvars(ctx)
452
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
453
- if xcmd_test(expanded):
454
- 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()
455
523
 
456
524
 
457
525
  def _execute_batch(
@@ -462,10 +530,17 @@ def _execute_batch(
462
530
  in_loop: bool = False,
463
531
  ) -> None:
464
532
  """Execute a BEGIN BATCH / END BATCH block."""
533
+ from execsql.state import ExecFrame
534
+
465
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
+ )
466
540
  try:
467
541
  _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
468
542
  finally:
543
+ ctx.ast_exec_stack.pop()
469
544
  if ctx.status.batch.in_batch():
470
545
  ctx.status.batch.end_batch()
471
546
 
@@ -498,135 +573,9 @@ def _pre_register_scripts(ctx: RuntimeContext, nodes: list[Node]) -> None:
498
573
 
499
574
 
500
575
  def _register_script_block(ctx: RuntimeContext, node: ScriptBlock) -> None:
501
- """Register a named SCRIPT block.
502
-
503
- Stores the AST node in ``ctx.ast_scripts`` for native execution, and also
504
- builds a legacy ``CommandList`` in ``ctx.savedscripts`` so that dispatch
505
- table handlers (e.g., ON ERROR_HALT EXECUTE SCRIPT) still work.
506
- """
507
- from execsql.script.engine import CommandList
508
-
509
- # AST-native registry
576
+ """Register a named SCRIPT block in the AST script registry."""
510
577
  ctx.ast_scripts[node.name] = node
511
578
 
512
- # Legacy compatibility — flatten to CommandList for dispatch table
513
- cmdlist = _flatten_for_legacy(node.body, node.span.file)
514
- cl = CommandList(cmdlist, node.name, node.param_names)
515
- ctx.savedscripts[node.name] = cl
516
-
517
-
518
- def _flatten_for_legacy(nodes: list[Node], source: str) -> list:
519
- """Convert AST nodes to flat ScriptCmd list for legacy compatibility."""
520
- from execsql.script.engine import MetacommandStmt, ScriptCmd, SqlStmt
521
-
522
- result = []
523
- for node in nodes:
524
- if isinstance(node, SqlStatement):
525
- text = re.sub(r"\s*;(\s*;\s*)+$", ";", node.text)
526
- result.append(
527
- ScriptCmd(node.span.file, node.span.start_line, "sql", SqlStmt(text)),
528
- )
529
- elif isinstance(node, MetaCommandStatement):
530
- result.append(
531
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(node.command)),
532
- )
533
- elif isinstance(node, IfBlock):
534
- result.append(
535
- ScriptCmd(
536
- node.span.file,
537
- node.span.start_line,
538
- "cmd",
539
- MetacommandStmt(f"IF ({node.condition})"),
540
- ),
541
- )
542
- # Emit ANDIF/ORIF condition modifiers after the IF
543
- for mod in node.condition_modifiers:
544
- keyword = "ANDIF" if mod.kind == "AND" else "ORIF"
545
- result.append(
546
- ScriptCmd(
547
- mod.span.file,
548
- mod.span.start_line,
549
- "cmd",
550
- MetacommandStmt(f"{keyword} ({mod.condition})"),
551
- ),
552
- )
553
- result.extend(_flatten_for_legacy(node.body, source))
554
- for clause in node.elseif_clauses:
555
- result.append(
556
- ScriptCmd(
557
- clause.span.file,
558
- clause.span.start_line,
559
- "cmd",
560
- MetacommandStmt(f"ELSEIF ({clause.condition})"),
561
- ),
562
- )
563
- result.extend(_flatten_for_legacy(clause.body, source))
564
- if node.else_body:
565
- result.append(
566
- ScriptCmd(
567
- node.span.file,
568
- node.else_span.start_line if node.else_span else node.span.start_line,
569
- "cmd",
570
- MetacommandStmt("ELSE"),
571
- ),
572
- )
573
- result.extend(_flatten_for_legacy(node.else_body, source))
574
- result.append(
575
- ScriptCmd(
576
- node.span.file,
577
- node.span.effective_end_line,
578
- "cmd",
579
- MetacommandStmt("ENDIF"),
580
- ),
581
- )
582
- elif isinstance(node, LoopBlock):
583
- result.append(
584
- ScriptCmd(
585
- node.span.file,
586
- node.span.start_line,
587
- "cmd",
588
- MetacommandStmt(f"LOOP {node.loop_type} ({node.condition})"),
589
- ),
590
- )
591
- result.extend(_flatten_for_legacy(node.body, source))
592
- result.append(
593
- ScriptCmd(
594
- node.span.file,
595
- node.span.effective_end_line,
596
- "cmd",
597
- MetacommandStmt("END LOOP"),
598
- ),
599
- )
600
- elif isinstance(node, BatchBlock):
601
- result.append(
602
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt("BEGIN BATCH")),
603
- )
604
- result.extend(_flatten_for_legacy(node.body, source))
605
- result.append(
606
- ScriptCmd(node.span.file, node.span.effective_end_line, "cmd", MetacommandStmt("END BATCH")),
607
- )
608
- elif isinstance(node, SqlBlock):
609
- result.extend(_flatten_for_legacy(node.body, source))
610
- elif isinstance(node, IncludeDirective):
611
- if node.is_execute_script:
612
- parts = ["EXECUTE SCRIPT"]
613
- if node.if_exists:
614
- parts.append("IF EXISTS")
615
- parts.append(node.target)
616
- if node.arguments:
617
- parts.append(f"WITH ARGS ({node.arguments})")
618
- if node.loop_type:
619
- parts.append(f"{node.loop_type} ({node.loop_condition})")
620
- result.append(
621
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(" ".join(parts))),
622
- )
623
- else:
624
- prefix = "INCLUDE IF EXISTS" if node.if_exists else "INCLUDE"
625
- result.append(
626
- ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(f"{prefix} {node.target}")),
627
- )
628
- return result
629
-
630
579
 
631
580
  def _execute_sql_block(
632
581
  ctx: RuntimeContext,
@@ -666,22 +615,12 @@ def _execute_include(
666
615
  _execute_script_native(ctx, node, ctx.ast_scripts[target], localvars)
667
616
  return
668
617
 
669
- # Target not in AST registry might be defined in an INCLUDE'd file
670
- # that hasn't been loaded yet. Fall through to legacy dispatch.
671
- if not node.if_exists and target not in ctx.savedscripts:
672
- raise ErrInfo(
673
- "cmd",
674
- other_msg=f"There is no SCRIPT named {node.target}.",
675
- )
676
- if node.if_exists and target not in ctx.savedscripts:
677
- return # IF EXISTS — skip silently
678
-
679
- # Target is in savedscripts but not ast_scripts — this shouldn't
680
- # happen when the AST executor is the only engine, but handle it
681
- # gracefully by raising an error.
618
+ # Target not registeredIF EXISTS silently skips; otherwise error.
619
+ if node.if_exists:
620
+ return
682
621
  raise ErrInfo(
683
622
  "cmd",
684
- other_msg=f"SCRIPT {node.target} is not registered in the AST executor.",
623
+ other_msg=f"There is no SCRIPT named {node.target}.",
685
624
  )
686
625
 
687
626
  # --- INCLUDE (file inclusion) — parse and execute natively ---
@@ -696,10 +635,11 @@ def _execute_script_native(
696
635
  ) -> None:
697
636
  """Execute a SCRIPT block natively through the AST executor.
698
637
 
699
- Pushes a :class:`CommandList` frame onto ``ctx.commandliststack`` so that
700
- legacy metacommand handlers (``x_sub``, ``x_rm_sub``, ``xf_sub_defined``,
701
- prompt handlers, etc.) can access ``~`` local variables and ``#`` script
702
- 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
703
643
  is popped on exit (including on error) via ``try/finally``.
704
644
  """
705
645
  from execsql.script.variables import ScriptArgSubVarSet
@@ -754,7 +694,7 @@ def _execute_script_native(
754
694
  if pdef.default is not None:
755
695
  paramvals.add_substitution(f"#{pdef.name}", pdef.default)
756
696
 
757
- # Push a CommandList frame onto the stack so that:
697
+ # Push a SCRIPT-kind ExecFrame onto ast_exec_stack so that:
758
698
  # - get_subvarset() can find ~local and +outer-scope variables
759
699
  # - xf_sub_defined/xf_sub_empty can check ~local and #param variables
760
700
  # - current_script_line() returns meaningful source location
@@ -765,9 +705,11 @@ def _execute_script_native(
765
705
  script_block.span.file,
766
706
  script_block.span.start_line,
767
707
  paramnames=script_block.param_names,
708
+ kind="script",
768
709
  )
769
710
  if paramvals is not None:
770
711
  frame.paramvals = paramvals
712
+ frame.params = dict(paramvals.substitutions)
771
713
 
772
714
  try:
773
715
 
@@ -787,12 +729,14 @@ def _execute_script_native(
787
729
  expanded = substitute_vars(condition, effective_locals, ctx=ctx)
788
730
  if not xcmd_test(expanded):
789
731
  break
732
+ frame.iteration += 1
790
733
  try:
791
734
  _run_body()
792
735
  except _BreakLoop:
793
736
  break
794
737
  elif node.loop_type == "UNTIL":
795
738
  while True:
739
+ frame.iteration += 1
796
740
  try:
797
741
  _run_body()
798
742
  except _BreakLoop:
@@ -802,7 +746,13 @@ def _execute_script_native(
802
746
  if xcmd_test(expanded):
803
747
  break
804
748
  else:
805
- _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
806
756
  finally:
807
757
  _pop_frame(ctx)
808
758
 
@@ -867,10 +817,17 @@ def _execute_include_native(
867
817
  _pre_register_scripts(ctx, included_tree.body)
868
818
 
869
819
  # Execute with include-chain tracking
820
+ from execsql.state import ExecFrame
821
+
870
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
+ )
871
827
  try:
872
828
  _execute_nodes(ctx, included_tree.body, included_tree.source, localvars)
873
829
  finally:
830
+ ctx.ast_exec_stack.pop()
874
831
  ctx.include_chain.pop()
875
832
 
876
833
 
@@ -975,6 +932,8 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
975
932
  with active_context(ctx):
976
933
  ctx.ast_scripts.clear()
977
934
  ctx.include_chain.clear()
935
+ ctx.ast_exec_stack.clear()
936
+ ctx.last_command = None
978
937
  # Seed the include chain with the main script to catch self-includes.
979
938
  if script.source != "<inline>":
980
939
  try:
@@ -986,11 +945,11 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
986
945
  # The legacy engine registered scripts at parse time (two-pass);
987
946
  # the AST executor must do an explicit pre-scan.
988
947
  _pre_register_scripts(ctx, script.body)
989
- # Push a root frame so commandliststack is never empty during AST
990
- # execution. This ensures get_subvarset(), current_script_line(),
991
- # xf_sub_defined(), the REPL, and all other commandliststack readers
992
- # work correctly even at the top level.
993
- _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")
994
953
  try:
995
954
  _execute_nodes(ctx, script.body, script.source)
996
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!"!