execsql2 2.16.12__py3-none-any.whl → 2.16.14__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 (28) hide show
  1. execsql/db/base.py +2 -0
  2. execsql/format.py +288 -15
  3. execsql/metacommands/io_export.py +14 -10
  4. execsql/metacommands/io_fileops.py +4 -6
  5. execsql/script/ast.py +3 -0
  6. execsql/script/executor.py +7 -3
  7. execsql/script/parser.py +20 -14
  8. {execsql2-2.16.12.dist-info → execsql2-2.16.14.dist-info}/METADATA +1 -1
  9. {execsql2-2.16.12.dist-info → execsql2-2.16.14.dist-info}/RECORD +28 -28
  10. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/README.md +0 -0
  11. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  12. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  13. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/execsql.conf +0 -0
  14. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/make_config_db.sql +0 -0
  15. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/md_compare.sql +0 -0
  16. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/md_glossary.sql +0 -0
  17. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/md_upsert.sql +0 -0
  18. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/pg_compare.sql +0 -0
  19. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  20. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  21. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/script_template.sql +0 -0
  22. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/ss_compare.sql +0 -0
  23. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  24. {execsql2-2.16.12.data → execsql2-2.16.14.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  25. {execsql2-2.16.12.dist-info → execsql2-2.16.14.dist-info}/WHEEL +0 -0
  26. {execsql2-2.16.12.dist-info → execsql2-2.16.14.dist-info}/entry_points.txt +0 -0
  27. {execsql2-2.16.12.dist-info → execsql2-2.16.14.dist-info}/licenses/LICENSE.txt +0 -0
  28. {execsql2-2.16.12.dist-info → execsql2-2.16.14.dist-info}/licenses/NOTICE +0 -0
execsql/db/base.py CHANGED
@@ -226,6 +226,7 @@ class Database(ABC):
226
226
  try:
227
227
  curs.execute(sql)
228
228
  except Exception:
229
+ curs.close()
229
230
  self.rollback()
230
231
  raise
231
232
  try:
@@ -264,6 +265,7 @@ class Database(ABC):
264
265
  try:
265
266
  curs.execute(sql)
266
267
  except Exception:
268
+ curs.close()
267
269
  self.rollback()
268
270
  raise
269
271
  try:
execsql/format.py CHANGED
@@ -148,22 +148,66 @@ def _is_comment_line(line: str, in_block: bool) -> tuple[bool, bool]:
148
148
  return False, False
149
149
 
150
150
 
151
- def _sqlglot_format(sql_lines: list[str]) -> list[str]:
151
+ def _sqlglot_format(
152
+ sql_lines: list[str],
153
+ sql_indent: int = 4,
154
+ leading_comma: bool = False,
155
+ ) -> list[str]:
152
156
  """Format a list of SQL-only lines (no comment-only lines) via sqlglot."""
153
157
  text = "\n".join(sql_lines)
154
158
  protected, replacements = _protect_variables(text)
159
+
160
+ # Count semicolons in input as a rough statement count.
161
+ input_semis = protected.count(";")
162
+
155
163
  try:
156
164
  with contextlib.redirect_stderr(io.StringIO()):
157
165
  ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot.errors.ErrorLevel.IGNORE)
158
- statements = [
159
- node.sql(dialect="postgres", pretty=True)
160
- for node in ast
161
- if node is not None and type(node).__name__ != "Command"
162
- ]
166
+ statements: list[str] = []
167
+ for node in ast:
168
+ if node is None:
169
+ continue
170
+ # For Command nodes (psql backslash commands, ERROR:, etc.)
171
+ # use unpretty output to avoid mangling.
172
+ if type(node).__name__ == "Command":
173
+ statements.append(node.sql(dialect="postgres"))
174
+ else:
175
+ statements.append(
176
+ node.sql(
177
+ dialect="postgres",
178
+ pretty=True,
179
+ pad=sql_indent,
180
+ indent=sql_indent,
181
+ max_text_width=120,
182
+ leading_comma=leading_comma,
183
+ ),
184
+ )
163
185
  stmts = [s for s in statements if s]
164
186
  if not stmts:
165
187
  return sql_lines
188
+
189
+ # Safety: if sqlglot produced more statements than the input had
190
+ # semicolons, it likely split a fragment (e.g. a SELECT column list)
191
+ # into multiple pseudo-statements. Fall back to the original text.
192
+ if len(stmts) > max(input_semis, 1):
193
+ return sql_lines
194
+
166
195
  joined = ";\n".join(stmts) + ";"
196
+
197
+ # Content-loss check: sqlglot with IGNORE error level can silently
198
+ # drop tokens it doesn't understand (e.g. ``ERROR: ...``). If the
199
+ # formatted output lost a significant fraction of the alphanumeric
200
+ # content, the formatting is unreliable — fall back.
201
+ # Exclude comment markers from the comparison — they are injected by
202
+ # _format_preserving_comments and are expected to be dropped by sqlglot
203
+ # for certain AST positions (e.g. inside CASE WHEN).
204
+ _alnum = re.compile(r"[^a-zA-Z0-9]")
205
+ _marker_alnum = re.compile(rf"{re.escape(_CMT_MARKER)}\d+")
206
+ input_for_check = _marker_alnum.sub("", protected)
207
+ input_alnum_len = len(_alnum.sub("", input_for_check))
208
+ output_alnum_len = len(_alnum.sub("", joined))
209
+ if input_alnum_len and output_alnum_len < input_alnum_len * 0.7:
210
+ return sql_lines
167
211
  joined = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", joined)
168
212
  return _restore_variables(joined, replacements).split("\n")
169
213
  except Exception:
@@ -175,7 +219,194 @@ def _sqlglot_format(sql_lines: list[str]) -> list[str]:
175
219
  # ---------------------------------------------------------------------------
176
220
 
177
221
 
178
- def format_sql_block(lines: list[str], depth: int, indent: int, use_sql: bool) -> list[str]:
222
+ def _has_mid_statement_comments(lines: list[str]) -> bool:
223
+ """Return True if any comment-only line appears inside a SQL statement.
224
+
225
+ A comment is "mid-statement" if it occurs after a SQL line that does not
226
+ end with ``;`` (i.e. the statement is still open). This is a lightweight
227
+ heuristic — it can be fooled by ``;`` inside string literals, but in that
228
+ case the block simply gets the benefit of sqlglot formatting rather than
229
+ being skipped (which is harmless because the SQL isn't fragmented).
230
+ """
231
+ in_block = False
232
+ in_statement = False
233
+ for line in lines:
234
+ is_comment, in_block = _is_comment_line(line, in_block)
235
+ stripped = line.strip()
236
+ if not stripped:
237
+ continue
238
+ if is_comment:
239
+ if in_statement:
240
+ return True
241
+ else:
242
+ in_statement = True
243
+ if stripped.endswith(";"):
244
+ in_statement = False
245
+ return False
246
+
247
+
248
+ _CMT_MARKER = "EXECSQL_CMTMARKER_"
249
+ _CMT_MARKER_RE = re.compile(rf"/\*\s*({re.escape(_CMT_MARKER)}\d+)\s*\*/")
250
+
251
+
252
+ def _format_preserving_comments(
253
+ lines: list[str],
254
+ sql_indent: int = 4,
255
+ leading_comma: bool = False,
256
+ ) -> list[str]:
257
+ """Format SQL with interleaved comments via marker-based round-tripping.
258
+
259
+ Strategy
260
+ --------
261
+ 1. Replace each comment-only line with a unique inline ``/* marker */``
262
+ prepended to the *next* SQL line. This lets sqlglot see the full
263
+ statement without fragmentation while preserving comment anchors.
264
+ 2. Format the marker-annotated SQL through ``_sqlglot_format``.
265
+ 3. Walk the formatted output: wherever a marker appears, emit the
266
+ original comment on its own line **before** that SQL line, then
267
+ strip the marker from the SQL.
268
+ 4. Any marker that sqlglot dropped (e.g. inside a CASE expression)
269
+ is re-inserted by matching key tokens from its anchor SQL line
270
+ against the formatted output.
271
+ """
272
+ # ---- Step 1: extract comments, replace with inline markers ----------
273
+ comment_store: dict[str, str] = {} # marker → original comment line
274
+ # Track the SQL line that originally followed each comment, for fallback
275
+ anchor_sql: dict[str, str] = {} # marker → next SQL line (stripped)
276
+ pending_markers: list[str] = []
277
+ processed: list[str] = []
278
+ in_block = False
279
+
280
+ for line in lines:
281
+ is_comment, in_block = _is_comment_line(line, in_block)
282
+ stripped = line.strip()
283
+ if not stripped:
284
+ # Blank lines: if we have pending markers, attach blanks as
285
+ # comment entries so they reappear in the right place.
286
+ if pending_markers:
287
+ mid = f"{_CMT_MARKER}{len(comment_store)}"
288
+ comment_store[mid] = line
289
+ pending_markers.append(mid)
290
+ else:
291
+ processed.append(line)
292
+ elif is_comment:
293
+ mid = f"{_CMT_MARKER}{len(comment_store)}"
294
+ comment_store[mid] = line
295
+ pending_markers.append(mid)
296
+ else:
297
+ # SQL line — prepend any pending markers as inline comments
298
+ prefix = " ".join(f"/* {m} */" for m in pending_markers)
299
+ processed.append(f"{prefix} {line}" if prefix else line)
300
+ for m in pending_markers:
301
+ anchor_sql[m] = stripped
302
+ pending_markers.clear()
303
+
304
+ # Trailing comments with no following SQL — preserve as-is
305
+ trailing: list[str] = [comment_store[m] for m in pending_markers]
306
+
307
+ # ---- Step 2: format through sqlglot ---------------------------------
308
+ formatted = _sqlglot_format(processed, sql_indent=sql_indent, leading_comma=leading_comma)
309
+
310
+ # ---- Step 3: restore surviving markers to comment lines -------------
311
+ found_markers: set[str] = set()
312
+ result: list[str] = []
313
+ for fline in formatted:
314
+ markers_here = _CMT_MARKER_RE.findall(fline)
315
+ if markers_here:
316
+ # Strip markers to get the underlying SQL line and its indent
317
+ cleaned = _CMT_MARKER_RE.sub("", fline).strip()
318
+ # Determine indent: use the SQL line's indent from sqlglot
319
+ sql_indent = ""
320
+ if cleaned:
321
+ raw_cleaned = _CMT_MARKER_RE.sub("", fline)
322
+ sql_indent = raw_cleaned[: len(raw_cleaned) - len(raw_cleaned.lstrip())]
323
+ for m in markers_here:
324
+ if m in comment_store:
325
+ orig = comment_store[m]
326
+ # Re-indent the comment to match the SQL line it precedes
327
+ orig_stripped = orig.strip()
328
+ if orig_stripped:
329
+ result.append(sql_indent + orig_stripped)
330
+ else:
331
+ result.append("")
332
+ found_markers.add(m)
333
+ if cleaned:
334
+ result.append(sql_indent + cleaned)
335
+ else:
336
+ result.append(fline)
337
+
338
+ # ---- Step 4: reinsert lost markers ----------------------------------
339
+ lost = [m for m in comment_store if m not in found_markers and m not in set(pending_markers)]
340
+ if lost:
341
+ _reinsert_lost_comments(result, lost, comment_store, anchor_sql)
342
+
343
+ result.extend(trailing)
344
+ return result
345
+
346
+
347
+ def _reinsert_lost_comments(
348
+ result: list[str],
349
+ lost_markers: list[str],
350
+ comment_store: dict[str, str],
351
+ anchor_sql: dict[str, str],
352
+ ) -> None:
353
+ """Best-effort reinsertion of comments that sqlglot dropped.
354
+
355
+ For each lost comment, extract key tokens from its anchor SQL line and
356
+ find the output line that best matches, then insert the comment before
357
+ that line (indented to match). Operates on *result* in place.
358
+ """
359
+ _word_re = re.compile(r"[a-zA-Z_]\w*")
360
+
361
+ # Process in reverse order so earlier inserts don't shift later indices.
362
+ insertions: list[tuple[int, str]] = []
363
+ for marker in lost_markers:
364
+ anchor = anchor_sql.get(marker, "")
365
+ orig = comment_store[marker]
366
+ orig_stripped = orig.strip()
367
+ if not anchor or not orig_stripped:
368
+ insertions.append((len(result), orig))
369
+ continue
370
+
371
+ anchor_words = [w.lower() for w in _word_re.findall(anchor)]
372
+ if not anchor_words:
373
+ insertions.append((len(result), orig))
374
+ continue
375
+
376
+ # Find the output line with the best token overlap
377
+ best_idx = len(result)
378
+ best_score = 0
379
+ for i, rline in enumerate(result):
380
+ rwords = {w.lower() for w in _word_re.findall(rline)}
381
+ score = sum(1 for w in anchor_words if w in rwords)
382
+ if score > best_score:
383
+ best_score = score
384
+ best_idx = i
385
+
386
+ # Re-indent comment to match the target line
387
+ if best_idx < len(result):
388
+ target = result[best_idx]
389
+ indent_str = target[: len(target) - len(target.lstrip())]
390
+ else:
391
+ indent_str = ""
392
+ insertions.append((best_idx, indent_str + orig_stripped))
393
+
394
+ # Sort descending by index; within the same index, reverse the
395
+ # original order so sequential result.insert() calls produce the
396
+ # correct final ordering (last inserted at a given index ends up first).
397
+ indexed = list(enumerate(insertions))
398
+ indexed.sort(key=lambda x: (x[1][0], x[0]), reverse=True)
399
+ for _, (idx, text) in indexed:
400
+ result.insert(idx, text)
401
+
402
+
403
+ def format_sql_block(
404
+ lines: list[str],
405
+ depth: int,
406
+ indent: int,
407
+ use_sql: bool,
408
+ leading_comma: bool = False,
409
+ ) -> list[str]:
179
410
  """Re-indent a SQL block to the current depth, optionally formatting via sqlglot."""
180
411
  if not lines:
181
412
  return lines
@@ -191,6 +422,13 @@ def format_sql_block(lines: list[str], depth: int, indent: int, use_sql: bool) -
191
422
  if not use_sql:
192
423
  return [target_prefix + line if line.strip() else "" for line in rebased]
193
424
 
425
+ # When comments appear mid-statement, use the marker-based approach
426
+ # which preserves both comments AND sqlglot formatting. When all
427
+ # comments are between statements, the simpler segmentation works.
428
+ if _has_mid_statement_comments(rebased):
429
+ result = _format_preserving_comments(rebased, sql_indent=indent, leading_comma=leading_comma)
430
+ return [target_prefix + line if line.strip() else "" for line in result]
431
+
194
432
  result: list[str] = []
195
433
  seg: list[str] = []
196
434
  seg_is_comment: bool | None = None
@@ -202,7 +440,7 @@ def format_sql_block(lines: list[str], depth: int, indent: int, use_sql: bool) -
202
440
  if seg_is_comment:
203
441
  result.extend(seg)
204
442
  else:
205
- result.extend(_sqlglot_format(seg))
443
+ result.extend(_sqlglot_format(seg, sql_indent=indent, leading_comma=leading_comma))
206
444
  seg.clear()
207
445
 
208
446
  for line in rebased:
@@ -238,34 +476,58 @@ def format_metacommand(payload: str, depth: int, indent: int) -> str:
238
476
  return f"{prefix}-- !x! {keyword}"
239
477
 
240
478
 
241
- def format_file(source: str, indent: int = 4, use_sql: bool = True) -> str:
479
+ def format_file(source: str, indent: int = 4, use_sql: bool = True, leading_comma: bool = False) -> str:
242
480
  """Format the source text of an execsql script and return the result."""
243
481
  depth = 0
244
482
  sql_acc: list[str] = []
245
483
  output: list[str] = []
246
484
 
247
485
  in_dollar_quote = False
486
+ in_block_comment = False
487
+ # Track whether we are inside an open SQL statement (last SQL line
488
+ # did not end with ';'). Blank lines mid-statement should NOT flush
489
+ # the accumulator — doing so would split a single statement into
490
+ # fragments that sqlglot cannot parse.
491
+ in_sql_statement = False
248
492
 
249
493
  def flush_sql() -> None:
250
- nonlocal in_dollar_quote
494
+ nonlocal in_dollar_quote, in_sql_statement
251
495
  if sql_acc:
252
496
  # If any line in the accumulated block is inside a $$-delimited
253
497
  # region, skip sqlglot formatting entirely. PL/pgSQL function
254
498
  # bodies contain IF/END IF, LOOP, RETURN, etc. that sqlglot does
255
499
  # not understand and will corrupt (e.g., rewriting to COMMIT).
256
500
  safe_for_sqlglot = use_sql and not in_dollar_quote
257
- output.extend(format_sql_block(sql_acc, depth, indent, safe_for_sqlglot))
501
+ output.extend(format_sql_block(sql_acc, depth, indent, safe_for_sqlglot, leading_comma=leading_comma))
258
502
  sql_acc.clear()
503
+ in_sql_statement = False
259
504
 
260
505
  for raw_line in source.expandtabs(4).splitlines():
506
+ stripped_line = raw_line.strip()
507
+
508
+ # Track /* */ block comment boundaries (but not inside $$ regions).
509
+ # Lines inside block comments must not be processed as metacommands.
510
+ if not in_dollar_quote:
511
+ if in_block_comment:
512
+ sql_acc.append(raw_line)
513
+ if "*/" in raw_line:
514
+ in_block_comment = False
515
+ continue
516
+ if stripped_line.startswith("/*") and "*/" not in stripped_line[2:]:
517
+ in_block_comment = True
518
+ sql_acc.append(raw_line)
519
+ continue
520
+
261
521
  m = METACOMMAND_RE.match(raw_line)
262
522
 
263
- if not raw_line.strip():
264
- if not in_dollar_quote:
523
+ if not stripped_line:
524
+ if not in_dollar_quote and not in_sql_statement:
265
525
  flush_sql()
526
+ output.append("")
266
527
  else:
528
+ # Mid-statement blank line stays in the accumulator and
529
+ # will appear in the output when the block is formatted.
267
530
  sql_acc.append(raw_line)
268
- output.append("")
269
531
 
270
532
  elif m:
271
533
  flush_sql()
@@ -296,6 +558,12 @@ def format_file(source: str, indent: int = 4, use_sql: bool = True) -> str:
296
558
  if "$$" in raw_line and raw_line.count("$$") % 2 == 1:
297
559
  in_dollar_quote = not in_dollar_quote
298
560
  sql_acc.append(raw_line)
561
+ # Update statement tracking: if this SQL line ends with ';'
562
+ # (and isn't a comment), the statement is complete.
563
+ if stripped_line.endswith(";") and not stripped_line.startswith("--"):
564
+ in_sql_statement = False
565
+ elif not stripped_line.startswith("--"):
566
+ in_sql_statement = True
299
567
 
300
568
  flush_sql()
301
569
 
@@ -350,6 +618,11 @@ def main() -> None:
350
618
  in_place: bool = typer.Option(False, "-i", "--in-place", help="Modify files in place."),
351
619
  no_sql: bool = typer.Option(False, "--no-sql", help="Skip SQL formatting via sqlglot."),
352
620
  indent: int = typer.Option(4, "--indent", metavar="N", help="Spaces per indent level."),
621
+ leading_comma: bool = typer.Option(
622
+ False,
623
+ "--leading-comma",
624
+ help="Place commas at the start of lines instead of the end.",
625
+ ),
353
626
  ) -> None:
354
627
  use_sql = not no_sql
355
628
  paths = collect_paths(targets)
@@ -365,7 +638,7 @@ def main() -> None:
365
638
  _err_console.print(f"[bold red]Error:[/bold red] reading {path}: {exc}")
366
639
  raise typer.Exit(code=1) from None
367
640
 
368
- formatted = format_file(source, indent=indent, use_sql=use_sql)
641
+ formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
369
642
 
370
643
  if check:
371
644
  if formatted != source:
@@ -136,16 +136,20 @@ def _dispatch_format(
136
136
  raise
137
137
  except Exception as e:
138
138
  raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
139
- if filefmt == "raw":
140
- write_query_raw(outfile, rows, db.encoding, append, zipfile=zipfilename)
141
- elif filefmt == "b64":
142
- write_query_b64(outfile, rows, append, zipfile=zipfilename)
143
- elif filefmt == "feather":
144
- write_query_to_feather(outfile, hdrs, rows)
145
- elif filefmt == "parquet":
146
- write_query_to_parquet(outfile, hdrs, rows)
147
- else:
148
- write_delimited_file(outfile, filefmt, hdrs, rows, _state.conf.output_encoding, append, zipfilename)
139
+ try:
140
+ if filefmt == "raw":
141
+ write_query_raw(outfile, rows, db.encoding, append, zipfile=zipfilename)
142
+ elif filefmt == "b64":
143
+ write_query_b64(outfile, rows, append, zipfile=zipfilename)
144
+ elif filefmt == "feather":
145
+ write_query_to_feather(outfile, hdrs, rows)
146
+ elif filefmt == "parquet":
147
+ write_query_to_parquet(outfile, hdrs, rows)
148
+ else:
149
+ write_delimited_file(outfile, filefmt, hdrs, rows, _state.conf.output_encoding, append, zipfilename)
150
+ except BaseException:
151
+ rows.close()
152
+ raise
149
153
 
150
154
 
151
155
  # ---------------------------------------------------------------------------
@@ -106,10 +106,9 @@ def x_copy(**kwargs: Any) -> None:
106
106
  try:
107
107
  db2.populate_table(schema2, table2, rows, hdrs, get_ts)
108
108
  db2.commit()
109
- except ErrInfo:
109
+ except BaseException:
110
+ rows.close()
110
111
  raise
111
- except Exception as e:
112
- raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
113
112
 
114
113
 
115
114
  def x_copy_query(**kwargs: Any) -> None:
@@ -181,10 +180,9 @@ def x_copy_query(**kwargs: Any) -> None:
181
180
  try:
182
181
  db2.populate_table(schema2, table2, rows, hdrs, get_ts)
183
182
  db2.commit()
184
- except ErrInfo:
183
+ except BaseException:
184
+ rows.close()
185
185
  raise
186
- except Exception as e:
187
- raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
188
186
 
189
187
 
190
188
  def x_zip(**kwargs: Any) -> None:
execsql/script/ast.py CHANGED
@@ -209,11 +209,14 @@ class ElseIfClause:
209
209
  Attributes:
210
210
  condition: The condition expression text (e.g. ``"HAS_ROWS"``).
211
211
  span: Source location of the ELSEIF line itself.
212
+ condition_modifiers: ANDIF/ORIF modifiers that compound the ELSEIF
213
+ condition, evaluated left-to-right at runtime.
212
214
  body: Nodes executed when this condition is true.
213
215
  """
214
216
 
215
217
  condition: str
216
218
  span: SourceSpan
219
+ condition_modifiers: list[ConditionModifier] = field(default_factory=list)
217
220
  body: list[Node] = field(default_factory=list)
218
221
 
219
222
 
@@ -384,6 +384,12 @@ def _execute_node(
384
384
  ctx.last_command = _FakeScriptCmd(node)
385
385
  _execute_include(ctx, node, localvars)
386
386
 
387
+ else:
388
+ raise ErrInfo(
389
+ type="error",
390
+ other_msg=f"Unhandled AST node type: {type(node).__name__} at {node.span}",
391
+ )
392
+
387
393
 
388
394
  # ---------------------------------------------------------------------------
389
395
  # Block executors
@@ -404,9 +410,7 @@ def _execute_if(
404
410
 
405
411
  # Try ELSEIF clauses
406
412
  for clause in node.elseif_clauses:
407
- effective_locals = _stack_localvars(ctx)
408
- expanded = substitute_vars(clause.condition, effective_locals, ctx=ctx)
409
- if xcmd_test(expanded):
413
+ if _eval_condition(ctx, clause.condition, clause.condition_modifiers):
410
414
  _execute_nodes(ctx, clause.body, node.span.file, localvars, in_loop=in_loop)
411
415
  return
412
416
 
execsql/script/parser.py CHANGED
@@ -580,14 +580,17 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
580
580
  command_text=line,
581
581
  other_msg=f"ANDIF without matching IF on line {file_lineno} of {source_name}.",
582
582
  )
583
- if_node = block_stack[-1].node
584
- if_node.condition_modifiers.append( # type: ignore[union-attr]
585
- ConditionModifier(
586
- kind="AND",
587
- condition=m.group("cond").strip(),
588
- span=SourceSpan(source_name, file_lineno),
589
- ),
583
+ modifier = ConditionModifier(
584
+ kind="AND",
585
+ condition=m.group("cond").strip(),
586
+ span=SourceSpan(source_name, file_lineno),
590
587
  )
588
+ frame = block_stack[-1]
589
+ if_node = frame.node
590
+ if frame._in_elseif and if_node.elseif_clauses: # type: ignore[union-attr]
591
+ if_node.elseif_clauses[-1].condition_modifiers.append(modifier) # type: ignore[union-attr]
592
+ else:
593
+ if_node.condition_modifiers.append(modifier) # type: ignore[union-attr]
591
594
  continue
592
595
 
593
596
  # -- ORIF --
@@ -599,14 +602,17 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
599
602
  command_text=line,
600
603
  other_msg=f"ORIF without matching IF on line {file_lineno} of {source_name}.",
601
604
  )
602
- if_node = block_stack[-1].node
603
- if_node.condition_modifiers.append( # type: ignore[union-attr]
604
- ConditionModifier(
605
- kind="OR",
606
- condition=m.group("cond").strip(),
607
- span=SourceSpan(source_name, file_lineno),
608
- ),
605
+ modifier = ConditionModifier(
606
+ kind="OR",
607
+ condition=m.group("cond").strip(),
608
+ span=SourceSpan(source_name, file_lineno),
609
609
  )
610
+ frame = block_stack[-1]
611
+ if_node = frame.node
612
+ if frame._in_elseif and if_node.elseif_clauses: # type: ignore[union-attr]
613
+ if_node.elseif_clauses[-1].condition_modifiers.append(modifier) # type: ignore[union-attr]
614
+ else:
615
+ if_node.condition_modifiers.append(modifier) # type: ignore[union-attr]
610
616
  continue
611
617
 
612
618
  # -- ELSE --
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: execsql2
3
- Version: 2.16.12
3
+ Version: 2.16.14
4
4
  Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
5
5
  Project-URL: Homepage, https://execsql2.readthedocs.io
6
6
  Project-URL: Repository, https://github.com/geocoug/execsql
@@ -3,7 +3,7 @@ execsql/__main__.py,sha256=HdbK-SAhyUmfB6xINY5AzxdMSxGzWSGEG_2dv42Jn64,315
3
3
  execsql/api.py,sha256=0D2Rl329fvDXERNrJBUzoWEt_VxqCHwRgaGzrq04rVs,19709
4
4
  execsql/config.py,sha256=6icjr8PKenUGfFF6lciSclvejjDzY8GTW1OZ1-IZt-Y,29480
5
5
  execsql/exceptions.py,sha256=j8hykBiof9H3Za9hwLIbDcVB2Xn65ODXXplp1jkvdgM,8453
6
- execsql/format.py,sha256=RZVxfIHduF6AU2c_O6k93I0kxTUa8gfUn6bRbcgxHYM,12762
6
+ execsql/format.py,sha256=nftyFgzNuv9aGUsp07WD3SjfttZ_dPhY45zJ7ocObL4,24384
7
7
  execsql/models.py,sha256=kCTUQg9-vReM6WNFfB_ZrEppuOW5u1uMBQThSkfPC0o,13264
8
8
  execsql/parser.py,sha256=P3ea8k7T_XLMrbhpFNZXwytdShrY302MKnhosqza1lo,15493
9
9
  execsql/plugins.py,sha256=2voLwT6eFap6BCBoZYndNNC_bMEJO1f_aP6xQTVXwYI,12815
@@ -18,7 +18,7 @@ execsql/cli/lint_ast.py,sha256=c9UEFsZ7PZlFdrK0zJCe-WXfCqvS3WlOUWEycZozqB8,14688
18
18
  execsql/cli/run.py,sha256=QrJ5SqIGiRO5AXavmxyhlJCMGtgZydC8MtHHAieQ4II,34571
19
19
  execsql/db/__init__.py,sha256=jTbuafuKOqYtXFR1wvCOoKK5Lr3l1uErfaIbIr6UywI,1063
20
20
  execsql/db/access.py,sha256=dAFuP1YeL7e7sy13T-9wll2Av5cr_PcUdxHf1NlvRNY,18204
21
- execsql/db/base.py,sha256=-el1s7YrxHkdy0lFx86BKZ8mBXWD-1EvE44e5ZhD5KI,31322
21
+ execsql/db/base.py,sha256=lUhS9yGNX4Nhxfu1wxjLdGbLoabcDYXZQB_5VUw5kqE,31372
22
22
  execsql/db/dsn.py,sha256=ZkKQrRgNr8VilOhE7jzLIZKVhrmEL0Bt3BppegQ03KM,5486
23
23
  execsql/db/duckdb.py,sha256=79lRzKRhw1Pjfqcrba27S4Oq8a8AbDO_d0XkaNKKPQo,3197
24
24
  execsql/db/factory.py,sha256=YHdgyqQYy16548O3fGyElLC5C7DdIgva4Z29OsDxXjs,5367
@@ -72,8 +72,8 @@ execsql/metacommands/data.py,sha256=tRQBGTAuW-eJ2tBNWaoZI9OjTyNNyHJISo7gOdL-sm8,
72
72
  execsql/metacommands/debug.py,sha256=3QVm0N5uc7mcZco36kqlDq8tiWW0sdo8E8BoQZkE_DI,11784
73
73
  execsql/metacommands/dispatch.py,sha256=Gao7rwiBFdghmYKcbWVm21Va30evobQuoghLY_FOZto,87602
74
74
  execsql/metacommands/io.py,sha256=vlGBje5sgnqeilooMdhJDgSRIhysHy5_7LrKtik9Xjs,3011
75
- execsql/metacommands/io_export.py,sha256=J2FnXA6452FIGStv6vbZXORmA-Ges70na0W7YwpMESA,13253
76
- execsql/metacommands/io_fileops.py,sha256=QKFj-94W32zxJiDcMpRsaohSCdtCRPIOG10K0WPigFk,9616
75
+ execsql/metacommands/io_export.py,sha256=iX4L3lpZGT2P5ca2C3XJ3MYxXJ_loC7nWmfKcWv4UGQ,13379
76
+ execsql/metacommands/io_fileops.py,sha256=K7eHMAuCUEVpNITYnJhWaSmXtAnnx1ZaNIFnOwKtXzc,9456
77
77
  execsql/metacommands/io_import.py,sha256=dXI4Bpar0i3Swm7xU-0naY8zLmV96Ie0hsrqBus22Nc,14176
78
78
  execsql/metacommands/io_write.py,sha256=eayUxsLnEDqp2R5iqlEjeOK8DxyoOFkG9hBe2JkVR0A,8243
79
79
  execsql/metacommands/prompt.py,sha256=E2e7q4pxbl_wEBrhco0B2gm5hO_HG3rNIF75PLdTgGg,36767
@@ -81,11 +81,11 @@ execsql/metacommands/script_ext.py,sha256=sw4YKUQl0SRlVlmhIoGbMokOo_hVqh1EcTuYCN
81
81
  execsql/metacommands/system.py,sha256=azRbv_P8l0t8BkDM9bmAUkhpnLSLHSCcmByqs-a3FxQ,7352
82
82
  execsql/metacommands/upsert.py,sha256=XE_P3mjaUkqT-LR4_28n2NbXwdjSgAGXJyZmKZC4Oy4,21136
83
83
  execsql/script/__init__.py,sha256=3WaBklMVIWjtCsYQ-BVo9UAVEIATOgeGsuyv21YKnxo,3969
84
- execsql/script/ast.py,sha256=SLn1fp0QDsqvJegNeIuyKV9f1BFnkEdzRota5EaNTfo,20053
84
+ execsql/script/ast.py,sha256=AviMXseSpZtaPpJtJEs3olaXuk23kN_dU5raHbymy6s,20266
85
85
  execsql/script/control.py,sha256=s-1eZdGARM6H1FwZ6VDdO_f50j7bvvRtTHesfUm9tbc,6144
86
86
  execsql/script/engine.py,sha256=EhuVBniOrFkzAW4I3NIZLt3INHTZJvlYoF7B99rZBLI,29391
87
- execsql/script/executor.py,sha256=T-pGa3o5S0oX1ySB5zztzRoJHYRZI58CKw3qcFz_95Q,37602
88
- execsql/script/parser.py,sha256=huznh4RnW6Y_yJyIIoMG5McUS2T9o3kfOW37CXPR5dk,31689
87
+ execsql/script/executor.py,sha256=RclV-uG33yEWs8p_sj9KbW5IpHbmkRyG2DO5L4UJK50,37678
88
+ execsql/script/parser.py,sha256=a8ufVvyK6X-E6PbTiOpETscyi38XbiTypkgP8COHrQg,32171
89
89
  execsql/script/variables.py,sha256=ZSBGQUsoii6w3dLDVY9xxoPIV6wY0sAV_BNIQ6pgQAE,14328
90
90
  execsql/utils/__init__.py,sha256=0uR6JwVJQRX3vceByNBduCAf5dd5assKjeqJUWvpZoA,278
91
91
  execsql/utils/auth.py,sha256=onXzNkNZQZxGC5w7eey06sjvAIAX_Lf9g7nUJtcsel0,7009
@@ -99,24 +99,24 @@ execsql/utils/numeric.py,sha256=xh02ANSRk3nUpQ-rtm66ILoMqoi7HtzCoRMIOT9U8QI,1570
99
99
  execsql/utils/regex.py,sha256=diEzTZqU_HHwVMadPAvN1Vgzhl7I03eVaEFGCXyGGL8,3770
100
100
  execsql/utils/strings.py,sha256=5Dvzrk-9SIw2lpxXZQkiJbNyo1sy7iXXAtSULlZ0KG8,8488
101
101
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
102
- execsql2-2.16.12.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
103
- execsql2-2.16.12.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
104
- execsql2-2.16.12.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
105
- execsql2-2.16.12.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
106
- execsql2-2.16.12.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
107
- execsql2-2.16.12.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
108
- execsql2-2.16.12.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
109
- execsql2-2.16.12.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
110
- execsql2-2.16.12.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
111
- execsql2-2.16.12.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
112
- execsql2-2.16.12.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
113
- execsql2-2.16.12.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
114
- execsql2-2.16.12.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
115
- execsql2-2.16.12.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
116
- execsql2-2.16.12.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
117
- execsql2-2.16.12.dist-info/METADATA,sha256=Op2AjS7Au3YMFnJ8rX0eE3DJm4SZcRVCzoCIhE3cDEo,20921
118
- execsql2-2.16.12.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
119
- execsql2-2.16.12.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
120
- execsql2-2.16.12.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
121
- execsql2-2.16.12.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
122
- execsql2-2.16.12.dist-info/RECORD,,
102
+ execsql2-2.16.14.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
103
+ execsql2-2.16.14.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
104
+ execsql2-2.16.14.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
105
+ execsql2-2.16.14.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
106
+ execsql2-2.16.14.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
107
+ execsql2-2.16.14.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
108
+ execsql2-2.16.14.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
109
+ execsql2-2.16.14.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
110
+ execsql2-2.16.14.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
111
+ execsql2-2.16.14.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
112
+ execsql2-2.16.14.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
113
+ execsql2-2.16.14.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
114
+ execsql2-2.16.14.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
115
+ execsql2-2.16.14.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
116
+ execsql2-2.16.14.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
117
+ execsql2-2.16.14.dist-info/METADATA,sha256=APNebYdwunkij8RDQOlTNkFS-BKMASdRLhNJefPx75o,20921
118
+ execsql2-2.16.14.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
119
+ execsql2-2.16.14.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
120
+ execsql2-2.16.14.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
121
+ execsql2-2.16.14.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
122
+ execsql2-2.16.14.dist-info/RECORD,,