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
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