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.
- execsql/__init__.py +8 -3
- execsql/api.py +580 -0
- execsql/cli/__init__.py +123 -0
- execsql/cli/lint_ast.py +439 -0
- execsql/cli/run.py +113 -102
- execsql/config.py +29 -4
- execsql/db/access.py +1 -0
- execsql/db/base.py +4 -1
- execsql/db/dsn.py +3 -2
- execsql/db/duckdb.py +1 -1
- execsql/db/factory.py +3 -0
- execsql/db/firebird.py +2 -1
- execsql/db/mysql.py +2 -1
- execsql/db/oracle.py +2 -1
- execsql/db/postgres.py +2 -1
- execsql/db/sqlite.py +1 -1
- execsql/db/sqlserver.py +3 -2
- execsql/debug/repl.py +27 -10
- execsql/exporters/base.py +6 -4
- execsql/exporters/delimited.py +11 -3
- execsql/exporters/pretty.py +9 -12
- execsql/gui/tui.py +59 -2
- execsql/metacommands/__init__.py +3 -0
- execsql/metacommands/conditions.py +20 -2
- execsql/metacommands/connect.py +1 -1
- execsql/metacommands/control.py +8 -14
- execsql/metacommands/debug.py +6 -4
- execsql/metacommands/io_export.py +117 -315
- execsql/metacommands/io_fileops.py +7 -13
- execsql/metacommands/io_write.py +1 -1
- execsql/metacommands/script_ext.py +8 -5
- execsql/metacommands/upsert.py +40 -0
- execsql/models.py +8 -12
- execsql/plugins.py +414 -0
- execsql/script/__init__.py +36 -12
- execsql/script/ast.py +562 -0
- execsql/script/engine.py +59 -368
- execsql/script/executor.py +833 -0
- execsql/script/parser.py +663 -0
- execsql/script/variables.py +11 -0
- execsql/state.py +55 -2
- execsql/utils/crypto.py +14 -10
- execsql/utils/errors.py +31 -8
- execsql/utils/gui.py +139 -17
- execsql/utils/mail.py +15 -12
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/METADATA +59 -1
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/RECORD +66 -60
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/WHEEL +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/NOTICE +0 -0
execsql/script/ast.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Abstract Syntax Tree node definitions for execsql scripts.
|
|
2
|
+
|
|
3
|
+
This module defines the node types that make up the execsql AST. A parser
|
|
4
|
+
(to be added in a later phase) will convert raw ``.sql`` script text into a
|
|
5
|
+
tree of these nodes; an executor will walk the tree to run the script.
|
|
6
|
+
|
|
7
|
+
Design principles:
|
|
8
|
+
- Every node carries a :class:`SourceSpan` so that error messages, the
|
|
9
|
+
LSP, and ``--lint`` can report precise source locations.
|
|
10
|
+
- Block structures (IF, LOOP, BATCH, SCRIPT) are represented as nodes
|
|
11
|
+
whose ``body`` (and optional ``else_body``, ``elseif_clauses``) contain
|
|
12
|
+
child nodes, forming the tree structure.
|
|
13
|
+
- Leaf nodes (:class:`SqlStatement`, :class:`MetaCommandStatement`,
|
|
14
|
+
:class:`Comment`) have no children.
|
|
15
|
+
- All nodes inherit from :class:`Node`, which provides a uniform
|
|
16
|
+
``children()`` iterator for tree traversal.
|
|
17
|
+
- The tree is meant to be *walked* for execution — nodes are data, not
|
|
18
|
+
behavior. Execution logic will live in a separate executor module.
|
|
19
|
+
|
|
20
|
+
Node hierarchy::
|
|
21
|
+
|
|
22
|
+
Node (abstract base)
|
|
23
|
+
├── SqlStatement — a single SQL statement
|
|
24
|
+
├── MetaCommandStatement — a single metacommand (flat, no block structure)
|
|
25
|
+
├── Comment — a comment line or block (preserved for formatting)
|
|
26
|
+
├── IfBlock — IF / ELSEIF / ELSE / ENDIF structure
|
|
27
|
+
├── LoopBlock — LOOP WHILE|UNTIL ... ENDLOOP structure
|
|
28
|
+
├── BatchBlock — BEGIN BATCH ... END BATCH structure
|
|
29
|
+
├── ScriptBlock — BEGIN SCRIPT name ... END SCRIPT structure
|
|
30
|
+
├── SqlBlock — BEGIN SQL ... END SQL structure
|
|
31
|
+
└── IncludeDirective — INCLUDE or EXECUTE SCRIPT reference
|
|
32
|
+
|
|
33
|
+
Container::
|
|
34
|
+
|
|
35
|
+
Script — top-level container holding a sequence of nodes
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from collections.abc import Iterator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"SourceSpan",
|
|
46
|
+
"Node",
|
|
47
|
+
"SqlStatement",
|
|
48
|
+
"MetaCommandStatement",
|
|
49
|
+
"Comment",
|
|
50
|
+
"ConditionModifier",
|
|
51
|
+
"ElseIfClause",
|
|
52
|
+
"IfBlock",
|
|
53
|
+
"LoopBlock",
|
|
54
|
+
"BatchBlock",
|
|
55
|
+
"ScriptBlock",
|
|
56
|
+
"SqlBlock",
|
|
57
|
+
"IncludeDirective",
|
|
58
|
+
"Script",
|
|
59
|
+
"format_tree",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Source location
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class SourceSpan:
|
|
70
|
+
"""Location of a node within its source file.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
file: Path or name of the source file (e.g. ``"pipeline.sql"`` or
|
|
74
|
+
``"<inline>"``).
|
|
75
|
+
start_line: 1-based line number where the node begins.
|
|
76
|
+
end_line: 1-based line number where the node ends (inclusive).
|
|
77
|
+
Defaults to *start_line* for single-line nodes.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
file: str
|
|
81
|
+
start_line: int
|
|
82
|
+
end_line: int | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def effective_end_line(self) -> int:
|
|
86
|
+
"""Return *end_line*, falling back to *start_line* if not set."""
|
|
87
|
+
return self.end_line if self.end_line is not None else self.start_line
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
end = self.effective_end_line
|
|
91
|
+
if end == self.start_line:
|
|
92
|
+
return f"{self.file}:{self.start_line}"
|
|
93
|
+
return f"{self.file}:{self.start_line}-{end}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Abstract base
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Node:
|
|
103
|
+
"""Base class for all AST nodes.
|
|
104
|
+
|
|
105
|
+
Every node carries a :attr:`span` indicating where it appeared in the
|
|
106
|
+
source file. Subclasses that contain child nodes must override
|
|
107
|
+
:meth:`children` to yield them.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
span: SourceSpan
|
|
111
|
+
|
|
112
|
+
def children(self) -> Iterator[Node]:
|
|
113
|
+
"""Yield immediate child nodes (empty for leaf nodes)."""
|
|
114
|
+
return iter(())
|
|
115
|
+
|
|
116
|
+
def walk(self) -> Iterator[Node]:
|
|
117
|
+
"""Depth-first traversal of this node and all descendants."""
|
|
118
|
+
yield self
|
|
119
|
+
for child in self.children():
|
|
120
|
+
yield from child.walk()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Leaf nodes
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class SqlStatement(Node):
|
|
130
|
+
"""A single SQL statement to be executed against the active database.
|
|
131
|
+
|
|
132
|
+
Attributes:
|
|
133
|
+
text: The raw SQL text, including any trailing semicolon.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
text: str
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
preview = self.text[:60] + ("..." if len(self.text) > 60 else "")
|
|
140
|
+
return f"SqlStatement({self.span}, {preview!r})"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class MetaCommandStatement(Node):
|
|
145
|
+
"""A single metacommand line (not a block-opening or block-closing command).
|
|
146
|
+
|
|
147
|
+
This covers all metacommands that do not introduce block structure:
|
|
148
|
+
SUB, EXPORT, CONNECT, CONFIG, ASSERT, CD, LOG, etc.
|
|
149
|
+
|
|
150
|
+
Attributes:
|
|
151
|
+
command: The metacommand text *after* the ``-- !x!`` prefix has been
|
|
152
|
+
stripped (e.g. ``"SUB myvar = hello"``).
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
command: str
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
preview = self.command[:60] + ("..." if len(self.command) > 60 else "")
|
|
159
|
+
return f"MetaCommandStatement({self.span}, {preview!r})"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class Comment(Node):
|
|
164
|
+
"""A comment line or block comment preserved for round-trip formatting.
|
|
165
|
+
|
|
166
|
+
Attributes:
|
|
167
|
+
text: The full comment text including delimiters (``--`` or
|
|
168
|
+
``/* ... */``).
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
text: str
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
preview = self.text[:60] + ("..." if len(self.text) > 60 else "")
|
|
175
|
+
return f"Comment({self.span}, {preview!r})"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Block nodes
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class ConditionModifier:
|
|
185
|
+
"""An ANDIF or ORIF modifier that compounds an IF condition.
|
|
186
|
+
|
|
187
|
+
These are not separate branches — they modify the IF's boolean result
|
|
188
|
+
at runtime. ``IF (A) / ANDIF (B)`` means ``A AND B``.
|
|
189
|
+
|
|
190
|
+
Attributes:
|
|
191
|
+
kind: ``"AND"`` for ANDIF, ``"OR"`` for ORIF.
|
|
192
|
+
condition: The condition expression text.
|
|
193
|
+
span: Source location of the ANDIF/ORIF line.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
kind: str # "AND" or "OR"
|
|
197
|
+
condition: str
|
|
198
|
+
span: SourceSpan
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class ElseIfClause:
|
|
203
|
+
"""A single ELSEIF branch within an :class:`IfBlock`.
|
|
204
|
+
|
|
205
|
+
Not a full :class:`Node` subclass because it is always contained within
|
|
206
|
+
an :class:`IfBlock` — its source span is derived from the parent.
|
|
207
|
+
|
|
208
|
+
Attributes:
|
|
209
|
+
condition: The condition expression text (e.g. ``"HAS_ROWS"``).
|
|
210
|
+
span: Source location of the ELSEIF line itself.
|
|
211
|
+
body: Nodes executed when this condition is true.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
condition: str
|
|
215
|
+
span: SourceSpan
|
|
216
|
+
body: list[Node] = field(default_factory=list)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class IfBlock(Node):
|
|
221
|
+
"""An IF / ANDIF / ORIF / ELSEIF / ELSE / ENDIF structure.
|
|
222
|
+
|
|
223
|
+
Attributes:
|
|
224
|
+
condition: The condition expression text for the initial IF.
|
|
225
|
+
condition_modifiers: ANDIF/ORIF modifiers that compound the IF
|
|
226
|
+
condition. Evaluated left-to-right at runtime.
|
|
227
|
+
body: Nodes executed when the (possibly compounded) condition is true.
|
|
228
|
+
elseif_clauses: Zero or more ELSEIF branches, evaluated in order.
|
|
229
|
+
else_body: Nodes executed when the IF condition (and all ELSEIFs)
|
|
230
|
+
are false. Empty list means no ELSE branch.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
condition: str
|
|
234
|
+
condition_modifiers: list[ConditionModifier] = field(default_factory=list)
|
|
235
|
+
body: list[Node] = field(default_factory=list)
|
|
236
|
+
elseif_clauses: list[ElseIfClause] = field(default_factory=list)
|
|
237
|
+
else_body: list[Node] = field(default_factory=list)
|
|
238
|
+
else_span: SourceSpan | None = None
|
|
239
|
+
|
|
240
|
+
def children(self) -> Iterator[Node]:
|
|
241
|
+
yield from self.body
|
|
242
|
+
for clause in self.elseif_clauses:
|
|
243
|
+
yield from clause.body
|
|
244
|
+
yield from self.else_body
|
|
245
|
+
|
|
246
|
+
def __repr__(self) -> str:
|
|
247
|
+
branches = 1 + len(self.elseif_clauses) + (1 if self.else_body else 0)
|
|
248
|
+
total = sum(1 for _ in self.walk()) - 1 # exclude self
|
|
249
|
+
return f"IfBlock({self.span}, condition={self.condition!r}, branches={branches}, descendants={total})"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class LoopBlock(Node):
|
|
254
|
+
"""A LOOP WHILE|UNTIL ... ENDLOOP structure.
|
|
255
|
+
|
|
256
|
+
Attributes:
|
|
257
|
+
loop_type: Either ``"WHILE"`` or ``"UNTIL"``.
|
|
258
|
+
condition: The condition expression text.
|
|
259
|
+
body: Nodes executed on each iteration.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
loop_type: str # "WHILE" or "UNTIL"
|
|
263
|
+
condition: str
|
|
264
|
+
body: list[Node] = field(default_factory=list)
|
|
265
|
+
|
|
266
|
+
def children(self) -> Iterator[Node]:
|
|
267
|
+
yield from self.body
|
|
268
|
+
|
|
269
|
+
def __repr__(self) -> str:
|
|
270
|
+
return f"LoopBlock({self.span}, {self.loop_type} {self.condition!r}, body={len(self.body)})"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class BatchBlock(Node):
|
|
275
|
+
"""A BEGIN BATCH ... END BATCH structure.
|
|
276
|
+
|
|
277
|
+
All SQL statements within the batch are executed as an atomic unit
|
|
278
|
+
(committed or rolled back together).
|
|
279
|
+
|
|
280
|
+
Attributes:
|
|
281
|
+
body: Nodes within the batch.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
body: list[Node] = field(default_factory=list)
|
|
285
|
+
|
|
286
|
+
def children(self) -> Iterator[Node]:
|
|
287
|
+
yield from self.body
|
|
288
|
+
|
|
289
|
+
def __repr__(self) -> str:
|
|
290
|
+
return f"BatchBlock({self.span}, body={len(self.body)})"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass
|
|
294
|
+
class ScriptBlock(Node):
|
|
295
|
+
"""A BEGIN SCRIPT name ... END SCRIPT structure.
|
|
296
|
+
|
|
297
|
+
Defines a named, reusable block of commands that can be invoked later
|
|
298
|
+
via EXECUTE SCRIPT.
|
|
299
|
+
|
|
300
|
+
Attributes:
|
|
301
|
+
name: The script block name (lowercased).
|
|
302
|
+
param_names: Optional list of formal parameter names.
|
|
303
|
+
body: Nodes within the script block.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
name: str
|
|
307
|
+
param_names: list[str] | None = None
|
|
308
|
+
body: list[Node] = field(default_factory=list)
|
|
309
|
+
|
|
310
|
+
def children(self) -> Iterator[Node]:
|
|
311
|
+
yield from self.body
|
|
312
|
+
|
|
313
|
+
def __repr__(self) -> str:
|
|
314
|
+
params = f", params={self.param_names}" if self.param_names else ""
|
|
315
|
+
return f"ScriptBlock({self.span}, name={self.name!r}{params}, body={len(self.body)})"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class SqlBlock(Node):
|
|
320
|
+
"""A BEGIN SQL ... END SQL structure.
|
|
321
|
+
|
|
322
|
+
Multi-line SQL that should be treated as a single statement, even if
|
|
323
|
+
it contains semicolons on intermediate lines.
|
|
324
|
+
|
|
325
|
+
Attributes:
|
|
326
|
+
body: Nodes within the SQL block (typically a single
|
|
327
|
+
:class:`SqlStatement`).
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
body: list[Node] = field(default_factory=list)
|
|
331
|
+
|
|
332
|
+
def children(self) -> Iterator[Node]:
|
|
333
|
+
yield from self.body
|
|
334
|
+
|
|
335
|
+
def __repr__(self) -> str:
|
|
336
|
+
return f"SqlBlock({self.span}, body={len(self.body)})"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class IncludeDirective(Node):
|
|
341
|
+
"""An INCLUDE or EXECUTE SCRIPT reference to an external file or named script.
|
|
342
|
+
|
|
343
|
+
Resolution happens at execution time, not parse time.
|
|
344
|
+
|
|
345
|
+
Attributes:
|
|
346
|
+
target: The file path or script name to include.
|
|
347
|
+
is_execute_script: True if this is ``EXECUTE SCRIPT`` (named block
|
|
348
|
+
invocation) rather than ``INCLUDE`` (file inclusion).
|
|
349
|
+
if_exists: True if the ``IF EXISTS`` modifier was present (skip
|
|
350
|
+
silently if the target does not exist).
|
|
351
|
+
arguments: Optional argument expression for EXECUTE SCRIPT.
|
|
352
|
+
loop_type: Optional ``"WHILE"`` or ``"UNTIL"`` for looped execution.
|
|
353
|
+
loop_condition: The loop condition expression, if *loop_type* is set.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
target: str
|
|
357
|
+
is_execute_script: bool = False
|
|
358
|
+
if_exists: bool = False
|
|
359
|
+
arguments: str | None = None
|
|
360
|
+
loop_type: str | None = None
|
|
361
|
+
loop_condition: str | None = None
|
|
362
|
+
|
|
363
|
+
def __repr__(self) -> str:
|
|
364
|
+
kind = "EXECUTE SCRIPT" if self.is_execute_script else "INCLUDE"
|
|
365
|
+
loop = f" {self.loop_type} {self.loop_condition}" if self.loop_type else ""
|
|
366
|
+
return f"IncludeDirective({self.span}, {kind} {self.target!r}{loop})"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Top-level container
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass
|
|
375
|
+
class Script:
|
|
376
|
+
"""Top-level container for a parsed script file.
|
|
377
|
+
|
|
378
|
+
Not a :class:`Node` subclass because it represents an entire file, not a
|
|
379
|
+
syntactic element within one.
|
|
380
|
+
|
|
381
|
+
Attributes:
|
|
382
|
+
source: Path or name of the source file.
|
|
383
|
+
body: The ordered sequence of top-level nodes.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
source: str
|
|
387
|
+
body: list[Node] = field(default_factory=list)
|
|
388
|
+
|
|
389
|
+
def walk(self) -> Iterator[Node]:
|
|
390
|
+
"""Depth-first traversal of all nodes in the script."""
|
|
391
|
+
for node in self.body:
|
|
392
|
+
yield from node.walk()
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def span(self) -> SourceSpan | None:
|
|
396
|
+
"""Return a span covering the entire script, or None if empty."""
|
|
397
|
+
if not self.body:
|
|
398
|
+
return None
|
|
399
|
+
first = self.body[0].span
|
|
400
|
+
last = self.body[-1].span
|
|
401
|
+
return SourceSpan(
|
|
402
|
+
file=self.source,
|
|
403
|
+
start_line=first.start_line,
|
|
404
|
+
end_line=last.effective_end_line,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def __repr__(self) -> str:
|
|
408
|
+
return f"Script({self.source!r}, nodes={len(self.body)})"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Tree formatting
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def format_tree(script: Script) -> str:
|
|
417
|
+
"""Return a human-readable tree representation of a :class:`Script`.
|
|
418
|
+
|
|
419
|
+
Example output::
|
|
420
|
+
|
|
421
|
+
Script: pipeline.sql (12 nodes)
|
|
422
|
+
├── [1] SUB table = users
|
|
423
|
+
├── [3-5] IF (HAS_ROWS)
|
|
424
|
+
│ ├── [4] SELECT * FROM users;
|
|
425
|
+
│ └── ELSE
|
|
426
|
+
│ └── [6] LOG "no rows"
|
|
427
|
+
├── [7] SELECT 1;
|
|
428
|
+
└── [8-10] LOOP WHILE (ROW_COUNT_GT(0))
|
|
429
|
+
└── [9] DELETE FROM t LIMIT 100;
|
|
430
|
+
"""
|
|
431
|
+
lines: list[str] = []
|
|
432
|
+
lines.append(f"Script: {script.source} ({len(script.body)} nodes)")
|
|
433
|
+
_format_nodes(script.body, lines, prefix="")
|
|
434
|
+
return "\n".join(lines)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _format_nodes(nodes: list[Node], lines: list[str], prefix: str) -> None:
|
|
438
|
+
"""Recursively format a list of nodes into tree lines."""
|
|
439
|
+
for i, node in enumerate(nodes):
|
|
440
|
+
is_last = i == len(nodes) - 1
|
|
441
|
+
connector = "└── " if is_last else "├── "
|
|
442
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
443
|
+
|
|
444
|
+
loc = _format_location(node.span)
|
|
445
|
+
label = _node_label(node)
|
|
446
|
+
lines.append(f"{prefix}{connector}{loc}{label}")
|
|
447
|
+
|
|
448
|
+
# Render children based on node type
|
|
449
|
+
if isinstance(node, IfBlock):
|
|
450
|
+
_format_if_block(node, lines, child_prefix)
|
|
451
|
+
elif isinstance(node, (LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
|
|
452
|
+
_format_nodes(node.body, lines, child_prefix)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _format_if_block(node: IfBlock, lines: list[str], prefix: str) -> None:
|
|
456
|
+
"""Format the branches of an IF block.
|
|
457
|
+
|
|
458
|
+
ELSEIF and ELSE are rendered as section headers at the same indent
|
|
459
|
+
level as the IF body — they are sibling branches, not nested children.
|
|
460
|
+
"""
|
|
461
|
+
# IF body (the "then" branch)
|
|
462
|
+
if node.body:
|
|
463
|
+
_format_nodes(node.body, lines, prefix)
|
|
464
|
+
|
|
465
|
+
# ELSEIF clauses — section headers at the same level
|
|
466
|
+
for clause in node.elseif_clauses:
|
|
467
|
+
loc = _format_location(clause.span)
|
|
468
|
+
lines.append(f"{prefix}{loc}ELSEIF ({clause.condition})")
|
|
469
|
+
if clause.body:
|
|
470
|
+
_format_nodes(clause.body, lines, prefix)
|
|
471
|
+
|
|
472
|
+
# ELSE body — section header at the same level
|
|
473
|
+
if node.else_body:
|
|
474
|
+
if node.else_span:
|
|
475
|
+
loc = _format_location(node.else_span)
|
|
476
|
+
else:
|
|
477
|
+
loc = ""
|
|
478
|
+
lines.append(f"{prefix}{loc}ELSE")
|
|
479
|
+
_format_nodes(node.else_body, lines, prefix)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _format_location(span: SourceSpan) -> str:
|
|
483
|
+
"""Format a source span as a dim, bracket-enclosed location prefix."""
|
|
484
|
+
end = span.effective_end_line
|
|
485
|
+
if end == span.start_line:
|
|
486
|
+
return f"[dim]\\[{span.start_line}][/dim] "
|
|
487
|
+
return f"[dim]\\[{span.start_line}-{end}][/dim] "
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _tag(name: str) -> str:
|
|
491
|
+
"""Return a Rich-colored type tag for parse-tree output."""
|
|
492
|
+
# Color scheme: SQL=cyan, CMD=green, CMT=dim, blocks=yellow, includes=magenta
|
|
493
|
+
_COLORS = {
|
|
494
|
+
"SQL": "bold cyan",
|
|
495
|
+
"CMD": "bold green",
|
|
496
|
+
"CMT": "dim",
|
|
497
|
+
"IF": "bold yellow",
|
|
498
|
+
"LOOP": "bold yellow",
|
|
499
|
+
"BATCH": "bold yellow",
|
|
500
|
+
"SCRIPT": "bold yellow",
|
|
501
|
+
"SQL_BLK": "bold yellow",
|
|
502
|
+
"INC": "bold magenta",
|
|
503
|
+
}
|
|
504
|
+
color = _COLORS.get(name, "")
|
|
505
|
+
if color:
|
|
506
|
+
return f"[{color}]<{name}>[/{color}]"
|
|
507
|
+
return f"<{name}>"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
_PREVIEW_LEN = 60
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _truncate(text: str, maxlen: int = _PREVIEW_LEN) -> str:
|
|
514
|
+
"""Truncate *text* to *maxlen* characters, appending ``...`` if clipped."""
|
|
515
|
+
if len(text) <= maxlen:
|
|
516
|
+
return text
|
|
517
|
+
return text[:maxlen] + "..."
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _node_label(node: Node) -> str:
|
|
521
|
+
"""Return a concise label for a node.
|
|
522
|
+
|
|
523
|
+
Each label is prefixed with a Rich-colored ``<TAG>`` indicating the
|
|
524
|
+
node type: ``<SQL>``, ``<CMD>``, ``<CMT>``, ``<IF>``, ``<LOOP>``,
|
|
525
|
+
``<BATCH>``, ``<SCRIPT>``, ``<SQL_BLK>``, ``<INC>``.
|
|
526
|
+
"""
|
|
527
|
+
if isinstance(node, SqlStatement):
|
|
528
|
+
preview = _truncate(node.text.replace("\n", " "))
|
|
529
|
+
return f"{_tag('SQL')} {preview}"
|
|
530
|
+
if isinstance(node, MetaCommandStatement):
|
|
531
|
+
preview = _truncate(node.command)
|
|
532
|
+
return f"{_tag('CMD')} {preview}"
|
|
533
|
+
if isinstance(node, Comment):
|
|
534
|
+
lines = node.text.split("\n")
|
|
535
|
+
first = _truncate(lines[0].strip())
|
|
536
|
+
if len(lines) > 1:
|
|
537
|
+
return f"{_tag('CMT')} {first} (+{len(lines) - 1} lines)"
|
|
538
|
+
return f"{_tag('CMT')} {first}"
|
|
539
|
+
if isinstance(node, IfBlock):
|
|
540
|
+
parts = [f"IF ({node.condition})"]
|
|
541
|
+
for mod in node.condition_modifiers:
|
|
542
|
+
keyword = "ANDIF" if mod.kind == "AND" else "ORIF"
|
|
543
|
+
parts.append(f"{keyword} ({mod.condition})")
|
|
544
|
+
return f"{_tag('IF')} {' '.join(parts)}"
|
|
545
|
+
if isinstance(node, LoopBlock):
|
|
546
|
+
return f"{_tag('LOOP')} {node.loop_type} ({node.condition})"
|
|
547
|
+
if isinstance(node, BatchBlock):
|
|
548
|
+
return f"{_tag('BATCH')} BEGIN BATCH"
|
|
549
|
+
if isinstance(node, ScriptBlock):
|
|
550
|
+
params = f" ({', '.join(node.param_names)})" if node.param_names else ""
|
|
551
|
+
return f"{_tag('SCRIPT')} {node.name}{params}"
|
|
552
|
+
if isinstance(node, SqlBlock):
|
|
553
|
+
return f"{_tag('SQL_BLK')} BEGIN SQL"
|
|
554
|
+
if isinstance(node, IncludeDirective):
|
|
555
|
+
exists = " IF EXISTS" if node.if_exists else ""
|
|
556
|
+
if node.is_execute_script:
|
|
557
|
+
extra = ""
|
|
558
|
+
if node.loop_type:
|
|
559
|
+
extra = f" {node.loop_type} ({node.loop_condition})"
|
|
560
|
+
return f"{_tag('INC')} EXECUTE SCRIPT{exists} {node.target}{extra}"
|
|
561
|
+
return f"{_tag('INC')} INCLUDE{exists} {node.target}"
|
|
562
|
+
return repr(node) # pragma: no cover
|