execsql2 2.22.4__py3-none-any.whl → 2.22.5__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 (21) hide show
  1. execsql/format.py +190 -1
  2. {execsql2-2.22.4.dist-info → execsql2-2.22.5.dist-info}/METADATA +2 -2
  3. {execsql2-2.22.4.dist-info → execsql2-2.22.5.dist-info}/RECORD +21 -21
  4. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/README.md +0 -0
  5. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  6. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  7. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/make_config_db.sql +0 -0
  8. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/md_compare.sql +0 -0
  9. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/md_glossary.sql +0 -0
  10. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/md_upsert.sql +0 -0
  11. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/pg_compare.sql +0 -0
  12. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  13. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  14. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/script_template.sql +0 -0
  15. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/ss_compare.sql +0 -0
  16. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  17. {execsql2-2.22.4.data → execsql2-2.22.5.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  18. {execsql2-2.22.4.dist-info → execsql2-2.22.5.dist-info}/WHEEL +0 -0
  19. {execsql2-2.22.4.dist-info → execsql2-2.22.5.dist-info}/entry_points.txt +0 -0
  20. {execsql2-2.22.4.dist-info → execsql2-2.22.5.dist-info}/licenses/LICENSE.txt +0 -0
  21. {execsql2-2.22.4.dist-info → execsql2-2.22.5.dist-info}/licenses/NOTICE +0 -0
execsql/format.py CHANGED
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import contextlib
15
15
  import io
16
16
  import re
17
+ from collections.abc import Iterator
17
18
  from pathlib import Path
18
19
 
19
20
  __all__ = ["collect_paths", "format_file", "main", "parse_keyword"]
@@ -169,6 +170,170 @@ def parse_keyword(payload: str) -> str:
169
170
  _EXECSQL_VAR_RE = re.compile(r"""!(['"]?)!([^!\s][^!]*)!\1!|!\{[^}]+\}!""")
170
171
 
171
172
 
173
+ # Characters that may appear in an identifier, used to tell an ``E'...'``
174
+ # escape-string prefix from a trailing ``e`` on an identifier (``abce'x'`` is
175
+ # the identifier ``abce`` followed by a separate literal).
176
+ _IDENT_CHAR_RE = re.compile(r"[A-Za-z0-9_$]")
177
+
178
+
179
+ def _iter_sql_literals(sql: str) -> Iterator[tuple[int, int, str]]:
180
+ """Yield ``(start, end, kind)`` spans for every string literal in *sql*.
181
+
182
+ This is a lexical walk rather than a regex sweep, because an apostrophe
183
+ only starts a literal in some contexts. Line comments, block comments
184
+ (which nest in PostgreSQL), and quoted identifiers are skipped entirely,
185
+ so quotes inside them never open a literal.
186
+
187
+ *kind* is one of:
188
+
189
+ ``"estring"``
190
+ A PostgreSQL escape string, ``E'...'``. Backslash escapes are
191
+ significant inside it, which is what makes it unsafe to hand to
192
+ sqlglot — see :func:`_protect_estrings`.
193
+ ``"plain"``
194
+ An ordinary ``'...'`` literal, where only a doubled ``''`` escapes.
195
+ ``"dollar"``
196
+ A dollar-quoted body, ``$$...$$`` or ``$tag$...$tag$``, in which no
197
+ character is special.
198
+ """
199
+ i, n = 0, len(sql)
200
+ while i < n:
201
+ c = sql[i]
202
+
203
+ if c == "-" and sql.startswith("--", i):
204
+ nl = sql.find("\n", i)
205
+ i = n if nl < 0 else nl + 1
206
+ continue
207
+
208
+ if c == "/" and sql.startswith("/*", i):
209
+ depth, i = 1, i + 2
210
+ while i < n and depth:
211
+ if sql.startswith("/*", i):
212
+ depth, i = depth + 1, i + 2
213
+ elif sql.startswith("*/", i):
214
+ depth, i = depth - 1, i + 2
215
+ else:
216
+ i += 1
217
+ continue
218
+
219
+ if c == '"':
220
+ i += 1
221
+ while i < n:
222
+ if sql[i] == '"':
223
+ if sql.startswith('""', i):
224
+ i += 2
225
+ continue
226
+ i += 1
227
+ break
228
+ i += 1
229
+ continue
230
+
231
+ if c == "$":
232
+ m = _DOLLAR_QUOTE_RE.match(sql, i)
233
+ if m:
234
+ tag = m.group(0)
235
+ close = sql.find(tag, m.end())
236
+ stop = n if close < 0 else close + len(tag)
237
+ yield i, stop, "dollar"
238
+ i = stop
239
+ continue
240
+
241
+ # E'...' — only when the `e` is not the tail of an identifier.
242
+ if c in "eE" and sql.startswith("'", i + 1) and not (i and _IDENT_CHAR_RE.match(sql[i - 1])):
243
+ j = i + 2
244
+ while j < n:
245
+ if sql[j] == "\\" and j + 1 < n:
246
+ j += 2
247
+ continue
248
+ if sql[j] == "'":
249
+ if sql.startswith("''", j):
250
+ j += 2
251
+ continue
252
+ j += 1
253
+ break
254
+ j += 1
255
+ yield i, j, "estring"
256
+ i = j
257
+ continue
258
+
259
+ if c == "'":
260
+ j = i + 1
261
+ while j < n:
262
+ if sql[j] == "'":
263
+ if sql.startswith("''", j):
264
+ j += 2
265
+ continue
266
+ j += 1
267
+ break
268
+ j += 1
269
+ yield i, j, "plain"
270
+ i = j
271
+ continue
272
+
273
+ i += 1
274
+
275
+
276
+ def _sql_literal_texts(sql: str) -> list[str]:
277
+ """Return the source text of every string literal in *sql*, in order."""
278
+ return [sql[start:end] for start, end, _ in _iter_sql_literals(sql)]
279
+
280
+
281
+ # Placeholder standing in for a masked E'...' literal. It is a plain literal
282
+ # so that sqlglot sees a string where a string was, and lays the statement out
283
+ # the same way it would have for the real one.
284
+ _ESTR_PLACEHOLDER = "execsqlestr"
285
+ _ESTR_RESTORE_RE = re.compile(rf"'{_ESTR_PLACEHOLDER}(\d+)'")
286
+
287
+
288
+ def _protect_estrings(sql: str) -> tuple[str, list[str]]:
289
+ """Replace ``E'...'`` literals with placeholders, return (protected, originals).
290
+
291
+ sqlglot's Postgres tokenizer consumes backslash escapes inside an escape
292
+ string but its generator never re-emits them, so ``E'\\\\s+'`` round-trips
293
+ to ``e'\\s+'`` — a different string (tobymao/sqlglot#8191). Masking these
294
+ literals keeps them out of the round trip entirely and restores them
295
+ byte-for-byte, independently of the installed sqlglot version.
296
+ """
297
+ originals: list[str] = []
298
+ out: list[str] = []
299
+ last = 0
300
+ for start, end, kind in _iter_sql_literals(sql):
301
+ if kind != "estring":
302
+ continue
303
+ out.append(sql[last:start])
304
+ out.append(f"'{_ESTR_PLACEHOLDER}{len(originals)}'")
305
+ originals.append(sql[start:end])
306
+ last = end
307
+ if not originals:
308
+ return sql, []
309
+ out.append(sql[last:])
310
+ return "".join(out), originals
311
+
312
+
313
+ def _restore_estrings(sql: str, originals: list[str]) -> str | None:
314
+ """Put masked ``E'...'`` literals back; return None if any went missing.
315
+
316
+ A missing placeholder means sqlglot dropped or mangled the literal, so the
317
+ caller must fall back to the unformatted text rather than emit SQL with a
318
+ placeholder left in it.
319
+ """
320
+ if not originals:
321
+ return sql
322
+ seen: set[int] = set()
323
+
324
+ def replace(m: re.Match[str]) -> str:
325
+ idx = int(m.group(1))
326
+ if idx >= len(originals):
327
+ return m.group(0)
328
+ seen.add(idx)
329
+ return originals[idx]
330
+
331
+ restored = _ESTR_RESTORE_RE.sub(replace, sql)
332
+ if len(seen) != len(originals) or _ESTR_PLACEHOLDER in restored:
333
+ return None
334
+ return restored
335
+
336
+
172
337
  def _protect_variables(sql: str) -> tuple[str, list[tuple[str, str]]]:
173
338
  """Replace execsql substitutions with valid SQL identifiers, return (protected, replacements)."""
174
339
  replacements: list[tuple[str, str]] = []
@@ -224,6 +389,9 @@ def _sqlglot_format(
224
389
 
225
390
  text = "\n".join(sql_lines)
226
391
  protected, replacements = _protect_variables(text)
392
+ # Keep E'...' literals out of the round trip; sqlglot loses their
393
+ # backslash escapes (see _protect_estrings).
394
+ protected, estrings = _protect_estrings(protected)
227
395
 
228
396
  # Count semicolons in input as a rough statement count.
229
397
  input_semis = protected.count(";")
@@ -275,8 +443,29 @@ def _sqlglot_format(
275
443
  output_alnum_len = len(_alnum.sub("", joined))
276
444
  if input_alnum_len and output_alnum_len < input_alnum_len * 0.7:
277
445
  return sql_lines
446
+
447
+ # Literal round-trip check. The alphanumeric check above is a
448
+ # gross-token-loss detector: it deletes every non-alphanumeric
449
+ # character before counting, so a transformation that changes only
450
+ # punctuation — a dropped backslash, a swapped quote — is invisible to
451
+ # it at any threshold. Requiring every literal to survive verbatim
452
+ # closes that blind spot for the whole class, not just for the E'...'
453
+ # case masked above.
454
+ #
455
+ # Containment, not equality: sqlglot rewrites dialect-specific
456
+ # constructs into Postgres equivalents that can legitimately repeat a
457
+ # literal (``SUBSTRING_INDEX(d, ',', 1)`` expands into a form using
458
+ # ``','`` more than once), so the output may hold more literals than
459
+ # the input. What must never happen is an input literal coming back
460
+ # altered or not at all — that is content loss, and it falls back.
461
+ if set(_sql_literal_texts(protected)) - set(_sql_literal_texts(joined)):
462
+ return sql_lines
463
+
278
464
  joined = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", joined)
279
- return _restore_variables(joined, replacements).split("\n")
465
+ restored = _restore_estrings(joined, estrings)
466
+ if restored is None:
467
+ return sql_lines
468
+ return _restore_variables(restored, replacements).split("\n")
280
469
  except Exception:
281
470
  return sql_lines
282
471
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: execsql2
3
- Version: 2.22.4
3
+ Version: 2.22.5
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
@@ -384,7 +384,7 @@ execsql-format --no-sql --in-place scripts/
384
384
  ```yaml
385
385
  repos:
386
386
  - repo: https://github.com/geocoug/execsql
387
- rev: v2.22.4
387
+ rev: v2.22.5
388
388
  hooks:
389
389
  - id: execsql-format
390
390
  ```
@@ -3,7 +3,7 @@ execsql/__main__.py,sha256=HdbK-SAhyUmfB6xINY5AzxdMSxGzWSGEG_2dv42Jn64,315
3
3
  execsql/api.py,sha256=EOtBwMnm-8fmB_KmvxyjglR6C7CjdYhCPBisJ9CFfJg,22227
4
4
  execsql/config.py,sha256=txKziUJLV7UPonj2sGFgkmlinDdSV50aHmR_cuCbEIo,34369
5
5
  execsql/exceptions.py,sha256=GlphAiR8iZu4UR4U50WgSqEUMd9_8AeQJ_hhPcJOeQU,9381
6
- execsql/format.py,sha256=3w-m0EsHapBdSKx6oTFWYlXspFLdUR_RF79gMvHhrqQ,34440
6
+ execsql/format.py,sha256=tfg0m3EbDVRlAR3ZhwV8BjgMFckL3u3IdldyCONrutU,41400
7
7
  execsql/models.py,sha256=-DWTVrPNwM-n1h3e3rXMoBv9-wpWVdZahyy4RB7fRRE,15502
8
8
  execsql/parser.py,sha256=aOxZ7Mzgvk7G4-nvy9Bgz7xlaZm6oo81cffDeBk_LCk,15597
9
9
  execsql/plugins.py,sha256=l82VWa5NpvOQWD6vcO-WKP-vtn0wp9__wWDFAbwxZg0,12838
@@ -101,23 +101,23 @@ execsql/utils/numeric.py,sha256=_7bi5R6pHjMCkOPne_Sh5nUj-B8e9g6WFmjptFHaunA,1573
101
101
  execsql/utils/regex.py,sha256=B3mSBhRRx_DpUnX_t9IoYd05KvWAebiLMCZqQAmEwcw,3885
102
102
  execsql/utils/strings.py,sha256=UQNjpRCEFa1UO6feU-M-9e24wWAvizs_iu_4fFusLxo,8516
103
103
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
104
- execsql2-2.22.4.data/data/execsql2_extras/README.md,sha256=vX4NTL095dUoA_hesyRMGYBorEZ_Y_tJ9qrd-MVV09I,5032
105
- execsql2-2.22.4.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
106
- execsql2-2.22.4.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
107
- execsql2-2.22.4.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
108
- execsql2-2.22.4.data/data/execsql2_extras/md_compare.sql,sha256=qYYVAjSeHZzjszxV3Bv6bg8Ckbq2kMHl87_gh4sywMU,24140
109
- execsql2-2.22.4.data/data/execsql2_extras/md_glossary.sql,sha256=hkZ2Onn57LAKKsuXxzhR8tPtcWXkmWEQkwPE58-Tm2k,10796
110
- execsql2-2.22.4.data/data/execsql2_extras/md_upsert.sql,sha256=_CAK4BzEboRXTNy03SJR-oOjcEdSNMuRBPL6noWUptY,112560
111
- execsql2-2.22.4.data/data/execsql2_extras/pg_compare.sql,sha256=1zJd4hVUKHR0tncc2qTBC9B4qVV4Us2ITkJpsjN3tMw,24352
112
- execsql2-2.22.4.data/data/execsql2_extras/pg_glossary.sql,sha256=IKuwna-_8b20ljSkXZruuiQigrCpo7ueQdUqd1MXiuI,9908
113
- execsql2-2.22.4.data/data/execsql2_extras/pg_upsert.sql,sha256=HpPJtTHvpEjQy03j-3iPxDEOHMRkudOg7O4D4YR38UI,108315
114
- execsql2-2.22.4.data/data/execsql2_extras/script_template.sql,sha256=2J35ddZPguJ-vwTsz83wErv0jiWUyJcdW_JM0mNKDXA,11155
115
- execsql2-2.22.4.data/data/execsql2_extras/ss_compare.sql,sha256=j1qVNUPXQsEU7-DoVgDJCGcE0EuIl7whLBT3fgeiMAo,24833
116
- execsql2-2.22.4.data/data/execsql2_extras/ss_glossary.sql,sha256=2gLxv34xzKt0vy7hSzJH7a9JiMC3ETrv9MofxQwAibU,13065
117
- execsql2-2.22.4.data/data/execsql2_extras/ss_upsert.sql,sha256=G_8rQ0VzuKIZHWs24O_WrfzpC5S27R1JsL-bFBR3SUQ,117730
118
- execsql2-2.22.4.dist-info/METADATA,sha256=gD2Dm8BheKQrFz6xAs7KyfpqGzs93Rpv7vxWyPWXeQE,20382
119
- execsql2-2.22.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
120
- execsql2-2.22.4.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
121
- execsql2-2.22.4.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
122
- execsql2-2.22.4.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
123
- execsql2-2.22.4.dist-info/RECORD,,
104
+ execsql2-2.22.5.data/data/execsql2_extras/README.md,sha256=vX4NTL095dUoA_hesyRMGYBorEZ_Y_tJ9qrd-MVV09I,5032
105
+ execsql2-2.22.5.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
106
+ execsql2-2.22.5.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
107
+ execsql2-2.22.5.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
108
+ execsql2-2.22.5.data/data/execsql2_extras/md_compare.sql,sha256=qYYVAjSeHZzjszxV3Bv6bg8Ckbq2kMHl87_gh4sywMU,24140
109
+ execsql2-2.22.5.data/data/execsql2_extras/md_glossary.sql,sha256=hkZ2Onn57LAKKsuXxzhR8tPtcWXkmWEQkwPE58-Tm2k,10796
110
+ execsql2-2.22.5.data/data/execsql2_extras/md_upsert.sql,sha256=_CAK4BzEboRXTNy03SJR-oOjcEdSNMuRBPL6noWUptY,112560
111
+ execsql2-2.22.5.data/data/execsql2_extras/pg_compare.sql,sha256=1zJd4hVUKHR0tncc2qTBC9B4qVV4Us2ITkJpsjN3tMw,24352
112
+ execsql2-2.22.5.data/data/execsql2_extras/pg_glossary.sql,sha256=IKuwna-_8b20ljSkXZruuiQigrCpo7ueQdUqd1MXiuI,9908
113
+ execsql2-2.22.5.data/data/execsql2_extras/pg_upsert.sql,sha256=HpPJtTHvpEjQy03j-3iPxDEOHMRkudOg7O4D4YR38UI,108315
114
+ execsql2-2.22.5.data/data/execsql2_extras/script_template.sql,sha256=2J35ddZPguJ-vwTsz83wErv0jiWUyJcdW_JM0mNKDXA,11155
115
+ execsql2-2.22.5.data/data/execsql2_extras/ss_compare.sql,sha256=j1qVNUPXQsEU7-DoVgDJCGcE0EuIl7whLBT3fgeiMAo,24833
116
+ execsql2-2.22.5.data/data/execsql2_extras/ss_glossary.sql,sha256=2gLxv34xzKt0vy7hSzJH7a9JiMC3ETrv9MofxQwAibU,13065
117
+ execsql2-2.22.5.data/data/execsql2_extras/ss_upsert.sql,sha256=G_8rQ0VzuKIZHWs24O_WrfzpC5S27R1JsL-bFBR3SUQ,117730
118
+ execsql2-2.22.5.dist-info/METADATA,sha256=OHGAN6pqfqOTBPDSst372rskkKNgeXUofH9K_mfl3sc,20382
119
+ execsql2-2.22.5.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
120
+ execsql2-2.22.5.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
121
+ execsql2-2.22.5.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
122
+ execsql2-2.22.5.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
123
+ execsql2-2.22.5.dist-info/RECORD,,