execsql2 2.15.8__py3-none-any.whl → 2.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. execsql/__init__.py +8 -3
  2. execsql/api.py +580 -0
  3. execsql/cli/__init__.py +123 -0
  4. execsql/cli/lint_ast.py +439 -0
  5. execsql/cli/run.py +113 -102
  6. execsql/config.py +29 -4
  7. execsql/db/access.py +1 -0
  8. execsql/db/base.py +4 -1
  9. execsql/db/dsn.py +3 -2
  10. execsql/db/duckdb.py +1 -1
  11. execsql/db/factory.py +3 -0
  12. execsql/db/firebird.py +2 -1
  13. execsql/db/mysql.py +2 -1
  14. execsql/db/oracle.py +2 -1
  15. execsql/db/postgres.py +2 -1
  16. execsql/db/sqlite.py +1 -1
  17. execsql/db/sqlserver.py +3 -2
  18. execsql/debug/repl.py +27 -10
  19. execsql/exporters/base.py +6 -4
  20. execsql/exporters/delimited.py +11 -3
  21. execsql/exporters/pretty.py +9 -12
  22. execsql/gui/tui.py +59 -2
  23. execsql/metacommands/__init__.py +3 -0
  24. execsql/metacommands/conditions.py +20 -2
  25. execsql/metacommands/connect.py +1 -1
  26. execsql/metacommands/control.py +8 -14
  27. execsql/metacommands/debug.py +6 -4
  28. execsql/metacommands/io_export.py +117 -315
  29. execsql/metacommands/io_fileops.py +7 -13
  30. execsql/metacommands/io_write.py +1 -1
  31. execsql/metacommands/script_ext.py +8 -5
  32. execsql/metacommands/upsert.py +40 -0
  33. execsql/models.py +8 -12
  34. execsql/plugins.py +414 -0
  35. execsql/script/__init__.py +36 -12
  36. execsql/script/ast.py +562 -0
  37. execsql/script/engine.py +59 -368
  38. execsql/script/executor.py +833 -0
  39. execsql/script/parser.py +663 -0
  40. execsql/script/variables.py +11 -0
  41. execsql/state.py +55 -2
  42. execsql/utils/crypto.py +14 -10
  43. execsql/utils/errors.py +31 -8
  44. execsql/utils/gui.py +139 -17
  45. execsql/utils/mail.py +15 -12
  46. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/METADATA +59 -1
  47. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/RECORD +66 -60
  48. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/README.md +0 -0
  49. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  50. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  51. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/execsql.conf +0 -0
  52. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  53. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  54. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  55. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  56. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  57. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  58. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  59. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/script_template.sql +0 -0
  60. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  61. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  62. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  63. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/WHEEL +0 -0
  64. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/entry_points.txt +0 -0
  65. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/LICENSE.txt +0 -0
  66. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,833 @@
1
+ """AST-based script executor for execsql.
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.
6
+
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.
20
+
21
+ Usage::
22
+
23
+ from execsql.script.executor import execute
24
+ from execsql.script.parser import parse_script
25
+
26
+ tree = parse_script("pipeline.sql")
27
+ execute(tree) # uses global context
28
+
29
+ # Or with an explicit context:
30
+ from execsql.state import RuntimeContext, get_context
31
+ ctx = get_context()
32
+ execute(tree, ctx=ctx)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import copy
38
+ import datetime
39
+ import os
40
+ import re
41
+ import time as _time
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from execsql.exceptions import ErrInfo
46
+ from execsql.script.ast import (
47
+ BatchBlock,
48
+ Comment,
49
+ ConditionModifier,
50
+ IfBlock,
51
+ IncludeDirective,
52
+ LoopBlock,
53
+ MetaCommandStatement,
54
+ Node,
55
+ Script,
56
+ ScriptBlock,
57
+ SqlBlock,
58
+ SqlStatement,
59
+ )
60
+ from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
61
+ from execsql.script.variables import SubVarSet
62
+ from execsql.state import RuntimeContext, active_context, get_context, xcmd_test
63
+ from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
64
+
65
+ __all__ = ["execute"]
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+ # Regex for deferred variable conversion: !{$VAR}! → !!$VAR!!
73
+ _DEFER_RX = re.compile(r"!\{([$@&~#+]?\w+)\}!")
74
+
75
+ # Compiled regex to match prefixed variables (for unsubstituted-var warnings)
76
+ _VARLIKE = re.compile(r"!![$@&~#]?\w+!!", re.I)
77
+
78
+
79
+ # Legacy module-level alias — ``_ast_scripts`` is now ``ctx.ast_scripts``
80
+ # on the RuntimeContext. Kept as a comment for grep-ability.
81
+
82
+
83
+ def _convert_deferred_vars(text: str) -> str:
84
+ """Convert deferred substitution variables to regular ones.
85
+
86
+ In loop bodies, ``!{$VAR}!`` is converted to ``!!$VAR!!`` so that
87
+ variables are re-evaluated on each iteration instead of being captured
88
+ once at loop entry.
89
+ """
90
+ return _DEFER_RX.sub(r"!!\1!!", text)
91
+
92
+
93
+ def _eval_condition(
94
+ ctx: RuntimeContext,
95
+ condition: str,
96
+ modifiers: list[ConditionModifier] | None = None,
97
+ ) -> bool:
98
+ """Evaluate a condition string with optional ANDIF/ORIF modifiers."""
99
+ expanded = substitute_vars(condition, ctx=ctx)
100
+ result = xcmd_test(expanded)
101
+
102
+ if modifiers:
103
+ for mod in modifiers:
104
+ mod_expanded = substitute_vars(mod.condition, ctx=ctx)
105
+ mod_result = xcmd_test(mod_expanded)
106
+ if mod.kind == "AND":
107
+ result = result and mod_result
108
+ else: # OR
109
+ result = result or mod_result
110
+
111
+ return result
112
+
113
+
114
+ def _set_command_vars(ctx: RuntimeContext, source: str, line_no: int) -> None:
115
+ """Set per-command system variables (current script, line, time)."""
116
+ now = datetime.datetime.now()
117
+ ctx.subvars.add_substitution("$CURRENT_TIME", now.strftime("%Y-%m-%d %H:%M"))
118
+ ctx.subvars.add_substitution("$CURRENT_DATE", now.strftime("%Y-%m-%d"))
119
+ utcnow = datetime.datetime.now(tz=datetime.timezone.utc)
120
+ ctx.subvars.add_substitution("$CURRENT_TIME_UTC", utcnow.strftime("%Y-%m-%d %H:%M"))
121
+ _p = Path(source)
122
+ ctx.subvars.add_substitution("$CURRENT_SCRIPT", source)
123
+ ctx.subvars.add_substitution("$CURRENT_SCRIPT_PATH", str(_p.resolve().parent) + os.sep)
124
+ ctx.subvars.add_substitution("$CURRENT_SCRIPT_NAME", _p.name)
125
+ ctx.subvars.add_substitution("$CURRENT_SCRIPT_LINE", str(line_no))
126
+ ctx.subvars.add_substitution("$SCRIPT_LINE", str(line_no))
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # SQL execution (bypasses SqlStmt.run's if_stack check)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def _exec_sql(
135
+ ctx: RuntimeContext,
136
+ text: str,
137
+ source: str,
138
+ line_no: int,
139
+ localvars: SubVarSet | None = None,
140
+ commit: bool = True,
141
+ ) -> None:
142
+ """Execute a SQL statement against the current database."""
143
+ ctx.status.sql_error = False
144
+ if ctx.status.batch.in_batch():
145
+ ctx.status.batch.using_db(ctx.dbs.current())
146
+ cmd = substitute_vars(text, localvars, ctx=ctx)
147
+ if _VARLIKE.search(cmd):
148
+ ctx.output.write(
149
+ f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
150
+ )
151
+ e = None
152
+ try:
153
+ db = ctx.dbs.current()
154
+ if ctx.conf.log_sql and ctx.exec_log:
155
+ ctx.exec_log.log_sql_query(cmd, db.name(), line_no)
156
+ db.execute(cmd)
157
+ if commit:
158
+ db.commit()
159
+ except ErrInfo as errinfo:
160
+ e = errinfo
161
+ except SystemExit:
162
+ raise
163
+ except Exception:
164
+ e = ErrInfo(type="exception", exception_msg=exception_desc())
165
+ if e:
166
+ stamp_errinfo(e)
167
+ ctx.subvars.add_substitution("$LAST_ERROR", cmd)
168
+ ctx.subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
169
+ ctx.status.sql_error = True
170
+ if ctx.exec_log is not None:
171
+ ctx.exec_log.log_status_info(f"SQL error: {e.errmsg()}")
172
+ if ctx.status.halt_on_err:
173
+ exit_now(1, e)
174
+ return
175
+ ctx.subvars.add_substitution("$LAST_SQL", cmd)
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Metacommand execution (bypasses MetacommandStmt.run's if_stack check)
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def _exec_metacommand(
184
+ ctx: RuntimeContext,
185
+ command: str,
186
+ source: str,
187
+ line_no: int,
188
+ localvars: SubVarSet | None = None,
189
+ ) -> Any:
190
+ """Dispatch a metacommand through the dispatch table."""
191
+ cmd = substitute_vars(command, localvars, ctx=ctx)
192
+ if _VARLIKE.search(cmd):
193
+ ctx.output.write(
194
+ f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
195
+ )
196
+ e = None
197
+ try:
198
+ applies, result = ctx.metacommandlist.eval(cmd)
199
+ if applies:
200
+ return result
201
+ except ErrInfo as errinfo:
202
+ e = errinfo
203
+ except SystemExit:
204
+ raise
205
+ except Exception:
206
+ e = ErrInfo(type="exception", exception_msg=exception_desc())
207
+ if e:
208
+ stamp_errinfo(e)
209
+ ctx.status.metacommand_error = True
210
+ ctx.subvars.add_substitution("$LAST_ERROR", cmd)
211
+ ctx.subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
212
+ if ctx.exec_log is not None:
213
+ ctx.exec_log.log_status_info(f"Metacommand error: {e.errmsg()}")
214
+ if ctx.status.halt_on_metacommand_err:
215
+ raise e
216
+ return None
217
+ # No handler matched — truly unknown metacommand
218
+ ctx.status.metacommand_error = True
219
+ raise ErrInfo(type="cmd", command_text=cmd, other_msg="Unknown metacommand")
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Core tree walker
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def _execute_nodes(
228
+ ctx: RuntimeContext,
229
+ nodes: list[Node],
230
+ source: str,
231
+ localvars: SubVarSet | None = None,
232
+ *,
233
+ in_loop: bool = False,
234
+ ) -> None:
235
+ """Execute a list of AST nodes sequentially."""
236
+ for node in nodes:
237
+ if isinstance(node, Comment):
238
+ continue # Comments have no runtime semantics
239
+ set_dynamic_system_vars(ctx)
240
+ _set_command_vars(ctx, node.span.file, node.span.start_line)
241
+
242
+ # Debug step mode
243
+ if ctx.step_mode:
244
+ ctx.step_mode = False
245
+ from execsql.debug.repl import _debug_repl
246
+
247
+ _debug_repl(step=True)
248
+
249
+ # Profiling
250
+ profiling = ctx.profile_data is not None
251
+ if profiling:
252
+ t0 = _time.perf_counter()
253
+
254
+ _execute_node(ctx, node, localvars, in_loop=in_loop)
255
+
256
+ if profiling:
257
+ elapsed = _time.perf_counter() - t0
258
+ cmd_type = _node_cmd_type(node)
259
+ cmd_text = _node_cmd_text(node)[:100]
260
+ ctx.profile_data.append(
261
+ (node.span.file, node.span.start_line, cmd_type, elapsed, cmd_text),
262
+ )
263
+
264
+ ctx.cmds_run += 1
265
+
266
+
267
+ def _execute_node(
268
+ ctx: RuntimeContext,
269
+ node: Node,
270
+ localvars: SubVarSet | None = None,
271
+ *,
272
+ in_loop: bool = False,
273
+ ) -> None:
274
+ """Execute a single AST node."""
275
+ if isinstance(node, SqlStatement):
276
+ text = node.text
277
+ if in_loop:
278
+ text = _convert_deferred_vars(text)
279
+ # Deduplicate trailing semicolons (matches SqlStmt.__init__)
280
+ text = re.sub(r"\s*;(\s*;\s*)+$", ";", text)
281
+ ctx.last_command = _FakeScriptCmd(node)
282
+ _exec_sql(
283
+ ctx,
284
+ text,
285
+ node.span.file,
286
+ node.span.start_line,
287
+ localvars,
288
+ commit=not ctx.status.batch.in_batch(),
289
+ )
290
+
291
+ elif isinstance(node, MetaCommandStatement):
292
+ command = node.command
293
+ if in_loop:
294
+ command = _convert_deferred_vars(command)
295
+ # Intercept BREAK before dispatch — it controls loop flow
296
+ expanded = substitute_vars(command, localvars, ctx=ctx)
297
+ if _BREAK_RX.match(expanded):
298
+ raise _BreakLoop
299
+ ctx.last_command = _FakeScriptCmd(node)
300
+ _exec_metacommand(ctx, command, node.span.file, node.span.start_line, localvars)
301
+
302
+ elif isinstance(node, IfBlock):
303
+ _execute_if(ctx, node, localvars, in_loop=in_loop)
304
+
305
+ elif isinstance(node, LoopBlock):
306
+ _execute_loop(ctx, node, localvars)
307
+
308
+ elif isinstance(node, BatchBlock):
309
+ _execute_batch(ctx, node, localvars, in_loop=in_loop)
310
+
311
+ elif isinstance(node, ScriptBlock):
312
+ _register_script_block(ctx, node)
313
+
314
+ elif isinstance(node, SqlBlock):
315
+ _execute_sql_block(ctx, node, localvars, in_loop=in_loop)
316
+
317
+ elif isinstance(node, IncludeDirective):
318
+ _execute_include(ctx, node, localvars)
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Block executors
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def _execute_if(
327
+ ctx: RuntimeContext,
328
+ node: IfBlock,
329
+ localvars: SubVarSet | None = None,
330
+ *,
331
+ in_loop: bool = False,
332
+ ) -> None:
333
+ """Evaluate an IF block and execute the matching branch."""
334
+ if _eval_condition(ctx, node.condition, node.condition_modifiers):
335
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
336
+ return
337
+
338
+ # Try ELSEIF clauses
339
+ for clause in node.elseif_clauses:
340
+ expanded = substitute_vars(clause.condition, ctx=ctx)
341
+ if xcmd_test(expanded):
342
+ _execute_nodes(ctx, clause.body, node.span.file, localvars, in_loop=in_loop)
343
+ return
344
+
345
+ # ELSE branch
346
+ if node.else_body:
347
+ _execute_nodes(ctx, node.else_body, node.span.file, localvars, in_loop=in_loop)
348
+
349
+
350
+ def _execute_loop(
351
+ ctx: RuntimeContext,
352
+ node: LoopBlock,
353
+ localvars: SubVarSet | None = None,
354
+ ) -> None:
355
+ """Execute a LOOP WHILE or LOOP UNTIL block."""
356
+ # Convert deferred vars in the condition — they re-evaluate each iteration
357
+ condition = _convert_deferred_vars(node.condition)
358
+
359
+ if node.loop_type == "WHILE":
360
+ while True:
361
+ expanded = substitute_vars(condition, ctx=ctx)
362
+ if not xcmd_test(expanded):
363
+ break
364
+ try:
365
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
366
+ except _BreakLoop:
367
+ break
368
+ else: # UNTIL
369
+ while True:
370
+ try:
371
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
372
+ except _BreakLoop:
373
+ break
374
+ expanded = substitute_vars(condition, ctx=ctx)
375
+ if xcmd_test(expanded):
376
+ break
377
+
378
+
379
+ def _execute_batch(
380
+ ctx: RuntimeContext,
381
+ node: BatchBlock,
382
+ localvars: SubVarSet | None = None,
383
+ *,
384
+ in_loop: bool = False,
385
+ ) -> None:
386
+ """Execute a BEGIN BATCH / END BATCH block."""
387
+ ctx.status.batch.new_batch()
388
+ try:
389
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
390
+ finally:
391
+ if ctx.status.batch.in_batch():
392
+ ctx.status.batch.end_batch()
393
+
394
+
395
+ def _register_script_block(ctx: RuntimeContext, node: ScriptBlock) -> None:
396
+ """Register a named SCRIPT block.
397
+
398
+ Stores the AST node in ``ctx.ast_scripts`` for native execution, and also
399
+ builds a legacy ``CommandList`` in ``ctx.savedscripts`` so that dispatch
400
+ table handlers (e.g., ON ERROR_HALT EXECUTE SCRIPT) still work.
401
+ """
402
+ from execsql.script.engine import CommandList
403
+
404
+ # AST-native registry
405
+ ctx.ast_scripts[node.name] = node
406
+
407
+ # Legacy compatibility — flatten to CommandList for dispatch table
408
+ cmdlist = _flatten_for_legacy(node.body, node.span.file)
409
+ cl = CommandList(cmdlist, node.name, node.param_names)
410
+ ctx.savedscripts[node.name] = cl
411
+
412
+
413
+ def _flatten_for_legacy(nodes: list[Node], source: str) -> list:
414
+ """Convert AST nodes to flat ScriptCmd list for legacy compatibility."""
415
+ from execsql.script.engine import MetacommandStmt, ScriptCmd, SqlStmt
416
+
417
+ result = []
418
+ for node in nodes:
419
+ if isinstance(node, SqlStatement):
420
+ text = re.sub(r"\s*;(\s*;\s*)+$", ";", node.text)
421
+ result.append(
422
+ ScriptCmd(node.span.file, node.span.start_line, "sql", SqlStmt(text)),
423
+ )
424
+ elif isinstance(node, MetaCommandStatement):
425
+ result.append(
426
+ ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(node.command)),
427
+ )
428
+ elif isinstance(node, IfBlock):
429
+ result.append(
430
+ ScriptCmd(
431
+ node.span.file,
432
+ node.span.start_line,
433
+ "cmd",
434
+ MetacommandStmt(f"IF ({node.condition})"),
435
+ ),
436
+ )
437
+ # Emit ANDIF/ORIF condition modifiers after the IF
438
+ for mod in node.condition_modifiers:
439
+ keyword = "ANDIF" if mod.kind == "AND" else "ORIF"
440
+ result.append(
441
+ ScriptCmd(
442
+ mod.span.file,
443
+ mod.span.start_line,
444
+ "cmd",
445
+ MetacommandStmt(f"{keyword} ({mod.condition})"),
446
+ ),
447
+ )
448
+ result.extend(_flatten_for_legacy(node.body, source))
449
+ for clause in node.elseif_clauses:
450
+ result.append(
451
+ ScriptCmd(
452
+ clause.span.file,
453
+ clause.span.start_line,
454
+ "cmd",
455
+ MetacommandStmt(f"ELSEIF ({clause.condition})"),
456
+ ),
457
+ )
458
+ result.extend(_flatten_for_legacy(clause.body, source))
459
+ if node.else_body:
460
+ result.append(
461
+ ScriptCmd(
462
+ node.span.file,
463
+ node.else_span.start_line if node.else_span else node.span.start_line,
464
+ "cmd",
465
+ MetacommandStmt("ELSE"),
466
+ ),
467
+ )
468
+ result.extend(_flatten_for_legacy(node.else_body, source))
469
+ result.append(
470
+ ScriptCmd(
471
+ node.span.file,
472
+ node.span.effective_end_line,
473
+ "cmd",
474
+ MetacommandStmt("ENDIF"),
475
+ ),
476
+ )
477
+ elif isinstance(node, LoopBlock):
478
+ result.append(
479
+ ScriptCmd(
480
+ node.span.file,
481
+ node.span.start_line,
482
+ "cmd",
483
+ MetacommandStmt(f"LOOP {node.loop_type} ({node.condition})"),
484
+ ),
485
+ )
486
+ result.extend(_flatten_for_legacy(node.body, source))
487
+ result.append(
488
+ ScriptCmd(
489
+ node.span.file,
490
+ node.span.effective_end_line,
491
+ "cmd",
492
+ MetacommandStmt("END LOOP"),
493
+ ),
494
+ )
495
+ elif isinstance(node, BatchBlock):
496
+ result.append(
497
+ ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt("BEGIN BATCH")),
498
+ )
499
+ result.extend(_flatten_for_legacy(node.body, source))
500
+ result.append(
501
+ ScriptCmd(node.span.file, node.span.effective_end_line, "cmd", MetacommandStmt("END BATCH")),
502
+ )
503
+ elif isinstance(node, SqlBlock):
504
+ result.extend(_flatten_for_legacy(node.body, source))
505
+ elif isinstance(node, IncludeDirective):
506
+ if node.is_execute_script:
507
+ parts = ["EXECUTE SCRIPT"]
508
+ if node.if_exists:
509
+ parts.append("IF EXISTS")
510
+ parts.append(node.target)
511
+ if node.arguments:
512
+ parts.append(f"WITH ARGS ({node.arguments})")
513
+ if node.loop_type:
514
+ parts.append(f"{node.loop_type} ({node.loop_condition})")
515
+ result.append(
516
+ ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(" ".join(parts))),
517
+ )
518
+ else:
519
+ prefix = "INCLUDE IF EXISTS" if node.if_exists else "INCLUDE"
520
+ result.append(
521
+ ScriptCmd(node.span.file, node.span.start_line, "cmd", MetacommandStmt(f"{prefix} {node.target}")),
522
+ )
523
+ return result
524
+
525
+
526
+ def _execute_sql_block(
527
+ ctx: RuntimeContext,
528
+ node: SqlBlock,
529
+ localvars: SubVarSet | None = None,
530
+ *,
531
+ in_loop: bool = False,
532
+ ) -> None:
533
+ """Execute a BEGIN SQL / END SQL block."""
534
+ _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
535
+
536
+
537
+ def _execute_include(
538
+ ctx: RuntimeContext,
539
+ node: IncludeDirective,
540
+ localvars: SubVarSet | None = None,
541
+ ) -> None:
542
+ """Execute an INCLUDE or EXECUTE SCRIPT directive.
543
+
544
+ **INCLUDE** is handled natively: the target file is parsed by the AST
545
+ parser and executed through the AST executor with circular-include
546
+ detection.
547
+
548
+ **EXECUTE SCRIPT** is handled natively when the target is in
549
+ ``ctx.ast_scripts``: arguments are parsed, a local variable overlay
550
+ is created, and the body is executed through the AST executor.
551
+ WHILE/UNTIL loops are handled natively too.
552
+ """
553
+ if node.is_execute_script:
554
+ target = node.target.lower()
555
+
556
+ # Native path: target is in our AST registry
557
+ if target in ctx.ast_scripts:
558
+ _execute_script_native(ctx, node, ctx.ast_scripts[target], localvars)
559
+ return
560
+
561
+ # Target not in AST registry — might be defined in an INCLUDE'd file
562
+ # that hasn't been loaded yet. Fall through to legacy dispatch.
563
+ if not node.if_exists and target not in ctx.savedscripts:
564
+ raise ErrInfo(
565
+ "cmd",
566
+ other_msg=f"There is no SCRIPT named {node.target}.",
567
+ )
568
+ if node.if_exists and target not in ctx.savedscripts:
569
+ return # IF EXISTS — skip silently
570
+
571
+ # Target is in savedscripts but not ast_scripts — this shouldn't
572
+ # happen when the AST executor is the only engine, but handle it
573
+ # gracefully by raising an error.
574
+ raise ErrInfo(
575
+ "cmd",
576
+ other_msg=f"SCRIPT {node.target} is not registered in the AST executor.",
577
+ )
578
+
579
+ # --- INCLUDE (file inclusion) — parse and execute natively ---
580
+ _execute_include_native(ctx, node, localvars)
581
+
582
+
583
+ def _execute_script_native(
584
+ ctx: RuntimeContext,
585
+ node: IncludeDirective,
586
+ script_block: ScriptBlock,
587
+ localvars: SubVarSet | None = None,
588
+ ) -> None:
589
+ """Execute a SCRIPT block natively through the AST executor."""
590
+ from execsql.script.variables import ScriptArgSubVarSet
591
+ from execsql.utils.strings import wo_quotes
592
+
593
+ # Parse arguments (replicates ScriptExecSpec logic)
594
+ script_localvars = None
595
+ if node.arguments is not None:
596
+ args_rx = re.compile(
597
+ r'(?P<param>#?\w+)\s*=\s*(?P<arg>(?:(?:[^"\'\[][^,\)]*)|(?:"[^"]*")|(?:\'[^\']*\')|(?:\[[^\]]*\])))',
598
+ re.I,
599
+ )
600
+ all_args = re.findall(args_rx, node.arguments)
601
+ all_cleaned_args = [(ae[0], wo_quotes(ae[1])) for ae in all_args]
602
+ all_prepared_args = [(ae[0] if ae[0][0] == "#" else "#" + ae[0], ae[1]) for ae in all_cleaned_args]
603
+ scriptvarset = ScriptArgSubVarSet()
604
+ for param, arg in all_prepared_args:
605
+ scriptvarset.add_substitution(param, arg)
606
+
607
+ # Validate parameter names match
608
+ if script_block.param_names is not None:
609
+ passed_names = [p[0].lstrip("#") for p in all_prepared_args]
610
+ if not all(p in passed_names for p in script_block.param_names):
611
+ raise ErrInfo(
612
+ "error",
613
+ other_msg=f"Formal and actual parameter name mismatch in call to {script_block.name}.",
614
+ )
615
+ script_localvars = scriptvarset
616
+ else:
617
+ if script_block.param_names is not None:
618
+ raise ErrInfo(
619
+ "error",
620
+ other_msg=(
621
+ f"Missing expected parameters ({', '.join(script_block.param_names)}) "
622
+ f"in call to {script_block.name}."
623
+ ),
624
+ )
625
+
626
+ # Merge script-local vars with any existing local vars
627
+ merged = script_localvars
628
+
629
+ def _run_body() -> None:
630
+ # Deep-copy the body to avoid mutation across iterations
631
+ body = copy.deepcopy(script_block.body)
632
+ _execute_nodes(ctx, body, script_block.span.file, merged, in_loop=False)
633
+
634
+ # Handle WHILE/UNTIL loops
635
+ # Convert deferred vars once — node.loop_condition is immutable after parsing
636
+ if node.loop_type is not None:
637
+ condition = _convert_deferred_vars(node.loop_condition)
638
+
639
+ if node.loop_type == "WHILE":
640
+ while True:
641
+ expanded = substitute_vars(condition, ctx=ctx)
642
+ if not xcmd_test(expanded):
643
+ break
644
+ try:
645
+ _run_body()
646
+ except _BreakLoop:
647
+ break
648
+ elif node.loop_type == "UNTIL":
649
+ while True:
650
+ try:
651
+ _run_body()
652
+ except _BreakLoop:
653
+ break
654
+ expanded = substitute_vars(condition, ctx=ctx)
655
+ if xcmd_test(expanded):
656
+ break
657
+ else:
658
+ _run_body()
659
+
660
+
661
+ def _execute_include_native(
662
+ ctx: RuntimeContext,
663
+ node: IncludeDirective,
664
+ localvars: SubVarSet | None = None,
665
+ ) -> None:
666
+ """Parse an INCLUDE'd file with the AST parser and execute it natively.
667
+
668
+ Handles tilde expansion, IF EXISTS, logging, and circular-include
669
+ detection via ``ctx.include_chain``.
670
+ """
671
+ from execsql.script.parser import parse_script
672
+ from execsql.utils.errors import file_size_date
673
+
674
+ # Substitute variables in the target path
675
+ target = substitute_vars(node.target, localvars, ctx=ctx).strip()
676
+
677
+ # Tilde expansion (matches x_include legacy handler)
678
+ if len(target) > 1 and target[0] == "~" and target[1] == os.sep:
679
+ target = str(Path.home() / target[2:])
680
+
681
+ target_path = Path(target)
682
+
683
+ # IF EXISTS handling
684
+ if node.if_exists:
685
+ if not target_path.is_file():
686
+ return
687
+ else:
688
+ if not target_path.is_file():
689
+ raise ErrInfo(type="error", other_msg=f"File {target} does not exist.")
690
+
691
+ # Resolve to absolute for consistent circular-include detection
692
+ resolved = str(target_path.resolve())
693
+
694
+ # Circular include detection
695
+ if resolved in ctx.include_chain:
696
+ chain = " → ".join(ctx.include_chain + [resolved])
697
+ raise ErrInfo(
698
+ type="error",
699
+ other_msg=f"Circular INCLUDE detected: {chain}",
700
+ )
701
+
702
+ # Log the include (matching legacy read_sqlfile behavior)
703
+ if ctx.exec_log:
704
+ sz, dt = file_size_date(target)
705
+ ctx.exec_log.log_status_info(f"Reading script file {target} (size: {sz}; date: {dt})")
706
+
707
+ # Parse with AST parser
708
+ encoding = ctx.conf.script_encoding if ctx.conf else "utf-8"
709
+ included_tree = parse_script(target, encoding=encoding)
710
+
711
+ # Execute with include-chain tracking
712
+ ctx.include_chain.append(resolved)
713
+ try:
714
+ _execute_nodes(ctx, included_tree.body, included_tree.source, localvars)
715
+ finally:
716
+ ctx.include_chain.pop()
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # BREAK support
721
+ # ---------------------------------------------------------------------------
722
+
723
+
724
+ class _BreakLoop(Exception):
725
+ """Raised by BREAK metacommand to exit the innermost loop."""
726
+
727
+
728
+ _BREAK_RX = re.compile(r"^\s*BREAK\s*$", re.I)
729
+
730
+
731
+ # ---------------------------------------------------------------------------
732
+ # Fake ScriptCmd for ctx.last_command compatibility
733
+ # ---------------------------------------------------------------------------
734
+
735
+
736
+ class _FakeScriptCmd:
737
+ """Minimal stand-in for ScriptCmd to satisfy ctx.last_command readers."""
738
+
739
+ __slots__ = ("source", "line_no", "source_dir", "source_name", "command", "command_type")
740
+
741
+ def __init__(self, node: Node) -> None:
742
+ self.source = node.span.file
743
+ self.line_no = node.span.start_line
744
+ _p = Path(node.span.file)
745
+ self.source_dir = str(_p.resolve().parent) + os.sep
746
+ self.source_name = _p.name
747
+ self.command_type = "sql" if isinstance(node, SqlStatement) else "cmd"
748
+ if isinstance(node, SqlStatement):
749
+ self.command = type("_cmd", (), {"statement": node.text, "commandline": lambda self: self.statement})()
750
+ elif isinstance(node, MetaCommandStatement):
751
+ self.command = type(
752
+ "_cmd",
753
+ (),
754
+ {"statement": node.command, "commandline": lambda self: "-- !x! " + self.statement},
755
+ )()
756
+ else:
757
+ self.command = type("_cmd", (), {"statement": "", "commandline": lambda self: ""})()
758
+
759
+ def current_script_line(self) -> tuple:
760
+ return (self.source, self.line_no)
761
+
762
+ def commandline(self) -> str:
763
+ return self.command.commandline()
764
+
765
+
766
+ # ---------------------------------------------------------------------------
767
+ # Node type/text helpers for profiling
768
+ # ---------------------------------------------------------------------------
769
+
770
+
771
+ def _node_cmd_type(node: Node) -> str:
772
+ if isinstance(node, SqlStatement):
773
+ return "sql"
774
+ return "cmd"
775
+
776
+
777
+ def _node_cmd_text(node: Node) -> str:
778
+ if isinstance(node, SqlStatement):
779
+ return node.text
780
+ if isinstance(node, MetaCommandStatement):
781
+ return "-- !x! " + node.command
782
+ if isinstance(node, IfBlock):
783
+ return f"-- !x! IF ({node.condition})"
784
+ if isinstance(node, LoopBlock):
785
+ return f"-- !x! LOOP {node.loop_type} ({node.condition})"
786
+ if isinstance(node, BatchBlock):
787
+ return "-- !x! BEGIN BATCH"
788
+ if isinstance(node, ScriptBlock):
789
+ return f"-- !x! BEGIN SCRIPT {node.name}"
790
+ if isinstance(node, IncludeDirective):
791
+ if node.is_execute_script:
792
+ return f"-- !x! EXECUTE SCRIPT {node.target}"
793
+ return f"-- !x! INCLUDE {node.target}"
794
+ return repr(node)
795
+
796
+
797
+ # ---------------------------------------------------------------------------
798
+ # Public entry point
799
+ # ---------------------------------------------------------------------------
800
+
801
+
802
+ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
803
+ """Execute an AST-parsed script.
804
+
805
+ Args:
806
+ script: The parsed :class:`Script` tree to execute.
807
+ ctx: The :class:`RuntimeContext` to use. Defaults to the global
808
+ context via :func:`get_context` if not provided.
809
+ """
810
+ if ctx is None:
811
+ ctx = get_context()
812
+
813
+ # Activate this context so all _state.foo accesses in metacommand
814
+ # handlers, database adapters, and other legacy code resolve against
815
+ # it. This gives full isolation without modifying 200+ handler
816
+ # function signatures.
817
+ with active_context(ctx):
818
+ ctx.ast_scripts.clear()
819
+ ctx.include_chain.clear()
820
+ # Seed the include chain with the main script to catch self-includes.
821
+ if script.source != "<inline>":
822
+ try:
823
+ ctx.include_chain.append(str(Path(script.source).resolve()))
824
+ except (OSError, ValueError):
825
+ ctx.include_chain.append(script.source)
826
+ set_static_system_vars(ctx)
827
+ try:
828
+ _execute_nodes(ctx, script.body, script.source)
829
+ except _BreakLoop as exc:
830
+ raise ErrInfo(
831
+ type="cmd",
832
+ other_msg="BREAK metacommand outside of a LOOP block.",
833
+ ) from exc