execsql2 2.22.4__tar.gz → 2.22.5__tar.gz

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 (378) hide show
  1. {execsql2-2.22.4 → execsql2-2.22.5}/.github/workflows/ci-cd.yml +8 -2
  2. {execsql2-2.22.4 → execsql2-2.22.5}/.pre-commit-hooks.yaml +1 -1
  3. {execsql2-2.22.4 → execsql2-2.22.5}/CHANGELOG.md +16 -3
  4. {execsql2-2.22.4 → execsql2-2.22.5}/PKG-INFO +2 -2
  5. {execsql2-2.22.4 → execsql2-2.22.5}/README.md +1 -1
  6. {execsql2-2.22.4 → execsql2-2.22.5}/docs/about/divergence.md +47 -45
  7. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/formatter.md +11 -2
  8. {execsql2-2.22.4 → execsql2-2.22.5}/docs/reference/metacommands.md +15 -1
  9. {execsql2-2.22.4 → execsql2-2.22.5}/docs/reference/substitution_vars.md +1 -1
  10. {execsql2-2.22.4 → execsql2-2.22.5}/pyproject.toml +2 -2
  11. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/format.py +190 -1
  12. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_format.py +187 -0
  13. {execsql2-2.22.4 → execsql2-2.22.5}/uv.lock +1 -1
  14. {execsql2-2.22.4 → execsql2-2.22.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {execsql2-2.22.4 → execsql2-2.22.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {execsql2-2.22.4 → execsql2-2.22.5}/.github/dependabot.yml +0 -0
  17. {execsql2-2.22.4 → execsql2-2.22.5}/.gitignore +0 -0
  18. {execsql2-2.22.4 → execsql2-2.22.5}/.pre-commit-config.yaml +0 -0
  19. {execsql2-2.22.4 → execsql2-2.22.5}/.python-version +0 -0
  20. {execsql2-2.22.4 → execsql2-2.22.5}/.readthedocs.yaml +0 -0
  21. {execsql2-2.22.4 → execsql2-2.22.5}/CONTRIBUTING.md +0 -0
  22. {execsql2-2.22.4 → execsql2-2.22.5}/LICENSE.txt +0 -0
  23. {execsql2-2.22.4 → execsql2-2.22.5}/NOTICE +0 -0
  24. {execsql2-2.22.4 → execsql2-2.22.5}/SECURITY.md +0 -0
  25. {execsql2-2.22.4 → execsql2-2.22.5}/docs/about/contributors.md +0 -0
  26. {execsql2-2.22.4 → execsql2-2.22.5}/docs/about/copyright.md +0 -0
  27. {execsql2-2.22.4 → execsql2-2.22.5}/docs/api/cli.md +0 -0
  28. {execsql2-2.22.4 → execsql2-2.22.5}/docs/api/db.md +0 -0
  29. {execsql2-2.22.4 → execsql2-2.22.5}/docs/api/exporters.md +0 -0
  30. {execsql2-2.22.4 → execsql2-2.22.5}/docs/api/importers.md +0 -0
  31. {execsql2-2.22.4 → execsql2-2.22.5}/docs/api/index.md +0 -0
  32. {execsql2-2.22.4 → execsql2-2.22.5}/docs/api/metacommands.md +0 -0
  33. {execsql2-2.22.4 → execsql2-2.22.5}/docs/dev/adding_db_adapters.md +0 -0
  34. {execsql2-2.22.4 → execsql2-2.22.5}/docs/dev/adding_exporters.md +0 -0
  35. {execsql2-2.22.4 → execsql2-2.22.5}/docs/dev/adding_importers.md +0 -0
  36. {execsql2-2.22.4 → execsql2-2.22.5}/docs/dev/adding_metacommands.md +0 -0
  37. {execsql2-2.22.4 → execsql2-2.22.5}/docs/dev/architecture.md +0 -0
  38. {execsql2-2.22.4 → execsql2-2.22.5}/docs/dev/releasing.md +0 -0
  39. {execsql2-2.22.4 → execsql2-2.22.5}/docs/getting-started/installation.md +0 -0
  40. {execsql2-2.22.4 → execsql2-2.22.5}/docs/getting-started/requirements.md +0 -0
  41. {execsql2-2.22.4 → execsql2-2.22.5}/docs/getting-started/syntax.md +0 -0
  42. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/debugging.md +0 -0
  43. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/documentation.md +0 -0
  44. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/encoding.md +0 -0
  45. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/examples.md +0 -0
  46. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/logging.md +0 -0
  47. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/sql_syntax.md +0 -0
  48. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/usage.md +0 -0
  49. {execsql2-2.22.4 → execsql2-2.22.5}/docs/guides/using_scripts.md +0 -0
  50. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/Compare_planets.png +0 -0
  51. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/actions.png +0 -0
  52. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/actions2.png +0 -0
  53. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/checkboxes.png +0 -0
  54. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/connect.b64 +0 -0
  55. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/connect.png +0 -0
  56. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/create_conf.png +0 -0
  57. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/data_error1_screenshot.jpg +0 -0
  58. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/entry_form.png +0 -0
  59. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/execsql_console.png +0 -0
  60. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/execsql_logo_01.png +0 -0
  61. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/fatals.png +0 -0
  62. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/logo_small.png +0 -0
  63. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/pause_terminal.png +0 -0
  64. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/pause_terminal_sm.b64 +0 -0
  65. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/pause_terminal_sm.png +0 -0
  66. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/prompt_compare.png +0 -0
  67. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/set_build_commands.jpg +0 -0
  68. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/unit_conversions.b64 +0 -0
  69. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/unit_conversions_029.png +0 -0
  70. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/unmatched.png +0 -0
  71. {execsql2-2.22.4 → execsql2-2.22.5}/docs/images/vim_execsql_highlight.png +0 -0
  72. {execsql2-2.22.4 → execsql2-2.22.5}/docs/index.md +0 -0
  73. {execsql2-2.22.4 → execsql2-2.22.5}/docs/reference/configuration.md +0 -0
  74. {execsql2-2.22.4 → execsql2-2.22.5}/docs/reference/security.md +0 -0
  75. {execsql2-2.22.4 → execsql2-2.22.5}/extras/plugin-template/README.md +0 -0
  76. {execsql2-2.22.4 → execsql2-2.22.5}/extras/plugin-template/pyproject.toml +0 -0
  77. {execsql2-2.22.4 → execsql2-2.22.5}/extras/plugin-template/src/execsql_plugin_YOURNAME/__init__.py +0 -0
  78. {execsql2-2.22.4 → execsql2-2.22.5}/extras/plugin-template/tests/test_plugin.py.example +0 -0
  79. {execsql2-2.22.4 → execsql2-2.22.5}/extras/vscode-execsql/README.md +0 -0
  80. {execsql2-2.22.4 → execsql2-2.22.5}/extras/vscode-execsql/package.json +0 -0
  81. {execsql2-2.22.4 → execsql2-2.22.5}/extras/vscode-execsql/syntaxes/execsql.tmLanguage.json +0 -0
  82. {execsql2-2.22.4 → execsql2-2.22.5}/justfile +0 -0
  83. {execsql2-2.22.4 → execsql2-2.22.5}/scripts/benchmarks/README.md +0 -0
  84. {execsql2-2.22.4 → execsql2-2.22.5}/scripts/benchmarks/results/.gitignore +0 -0
  85. {execsql2-2.22.4 → execsql2-2.22.5}/scripts/benchmarks/run_benchmarks.py +0 -0
  86. {execsql2-2.22.4 → execsql2-2.22.5}/scripts/generate_vscode_grammar.py +0 -0
  87. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/__init__.py +0 -0
  88. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/__main__.py +0 -0
  89. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/api.py +0 -0
  90. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/cli/__init__.py +0 -0
  91. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/cli/dsn.py +0 -0
  92. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/cli/help.py +0 -0
  93. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/cli/lint.py +0 -0
  94. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/cli/run.py +0 -0
  95. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/config.py +0 -0
  96. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/data/__init__.py +0 -0
  97. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/data/execsql.conf.template +0 -0
  98. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/__init__.py +0 -0
  99. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/access.py +0 -0
  100. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/base.py +0 -0
  101. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/dsn.py +0 -0
  102. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/duckdb.py +0 -0
  103. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/factory.py +0 -0
  104. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/firebird.py +0 -0
  105. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/mysql.py +0 -0
  106. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/oracle.py +0 -0
  107. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/postgres.py +0 -0
  108. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/sqlite.py +0 -0
  109. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/db/sqlserver.py +0 -0
  110. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/debug/__init__.py +0 -0
  111. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/debug/repl.py +0 -0
  112. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exceptions.py +0 -0
  113. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/__init__.py +0 -0
  114. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/base.py +0 -0
  115. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/delimited.py +0 -0
  116. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/duckdb.py +0 -0
  117. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/feather.py +0 -0
  118. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/html.py +0 -0
  119. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/json.py +0 -0
  120. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/latex.py +0 -0
  121. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/markdown.py +0 -0
  122. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/ods.py +0 -0
  123. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/parquet.py +0 -0
  124. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/pretty.py +0 -0
  125. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/protocol.py +0 -0
  126. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/raw.py +0 -0
  127. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/sqlite.py +0 -0
  128. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/templates.py +0 -0
  129. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/values.py +0 -0
  130. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/xls.py +0 -0
  131. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/xlsx.py +0 -0
  132. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/xml.py +0 -0
  133. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/yaml.py +0 -0
  134. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/exporters/zip.py +0 -0
  135. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/gui/__init__.py +0 -0
  136. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/gui/base.py +0 -0
  137. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/gui/console.py +0 -0
  138. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/gui/desktop.py +0 -0
  139. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/gui/tui.py +0 -0
  140. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/__init__.py +0 -0
  141. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/base.py +0 -0
  142. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/csv.py +0 -0
  143. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/feather.py +0 -0
  144. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/json.py +0 -0
  145. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/ods.py +0 -0
  146. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/importers/xls.py +0 -0
  147. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/__init__.py +0 -0
  148. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/conditions.py +0 -0
  149. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/connect.py +0 -0
  150. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/control.py +0 -0
  151. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/data.py +0 -0
  152. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/debug.py +0 -0
  153. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/dispatch.py +0 -0
  154. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/io.py +0 -0
  155. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/io_export.py +0 -0
  156. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/io_fileops.py +0 -0
  157. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/io_import.py +0 -0
  158. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/io_write.py +0 -0
  159. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/prompt.py +0 -0
  160. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/script_ext.py +0 -0
  161. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/system.py +0 -0
  162. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/metacommands/upsert.py +0 -0
  163. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/models.py +0 -0
  164. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/parser.py +0 -0
  165. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/plugins.py +0 -0
  166. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/py.typed +0 -0
  167. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/__init__.py +0 -0
  168. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/ast.py +0 -0
  169. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/control.py +0 -0
  170. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/engine.py +0 -0
  171. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/executor.py +0 -0
  172. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/parser.py +0 -0
  173. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/script/variables.py +0 -0
  174. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/state.py +0 -0
  175. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/state.pyi +0 -0
  176. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/types.py +0 -0
  177. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/__init__.py +0 -0
  178. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/auth.py +0 -0
  179. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/crypto.py +0 -0
  180. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/datetime.py +0 -0
  181. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/errors.py +0 -0
  182. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/fileio.py +0 -0
  183. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/gui.py +0 -0
  184. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/mail.py +0 -0
  185. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/numeric.py +0 -0
  186. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/regex.py +0 -0
  187. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/strings.py +0 -0
  188. {execsql2-2.22.4 → execsql2-2.22.5}/src/execsql/utils/timer.py +0 -0
  189. {execsql2-2.22.4 → execsql2-2.22.5}/templates/README.md +0 -0
  190. {execsql2-2.22.4 → execsql2-2.22.5}/templates/config_settings.sqlite +0 -0
  191. {execsql2-2.22.4 → execsql2-2.22.5}/templates/example_config_prompt.sql +0 -0
  192. {execsql2-2.22.4 → execsql2-2.22.5}/templates/make_config_db.sql +0 -0
  193. {execsql2-2.22.4 → execsql2-2.22.5}/templates/md_compare.sql +0 -0
  194. {execsql2-2.22.4 → execsql2-2.22.5}/templates/md_glossary.sql +0 -0
  195. {execsql2-2.22.4 → execsql2-2.22.5}/templates/md_upsert.sql +0 -0
  196. {execsql2-2.22.4 → execsql2-2.22.5}/templates/pg_compare.sql +0 -0
  197. {execsql2-2.22.4 → execsql2-2.22.5}/templates/pg_glossary.sql +0 -0
  198. {execsql2-2.22.4 → execsql2-2.22.5}/templates/pg_upsert.sql +0 -0
  199. {execsql2-2.22.4 → execsql2-2.22.5}/templates/script_template.sql +0 -0
  200. {execsql2-2.22.4 → execsql2-2.22.5}/templates/ss_compare.sql +0 -0
  201. {execsql2-2.22.4 → execsql2-2.22.5}/templates/ss_glossary.sql +0 -0
  202. {execsql2-2.22.4 → execsql2-2.22.5}/templates/ss_upsert.sql +0 -0
  203. {execsql2-2.22.4 → execsql2-2.22.5}/tests/__init__.py +0 -0
  204. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/__init__.py +0 -0
  205. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/test_cli.py +0 -0
  206. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/test_cli_e2e.py +0 -0
  207. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/test_cli_run.py +0 -0
  208. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/test_lint.py +0 -0
  209. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/test_ping.py +0 -0
  210. {execsql2-2.22.4 → execsql2-2.22.5}/tests/cli/test_profile.py +0 -0
  211. {execsql2-2.22.4 → execsql2-2.22.5}/tests/conftest.py +0 -0
  212. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/__init__.py +0 -0
  213. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_access_windows.py +0 -0
  214. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_base.py +0 -0
  215. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_db_adapters_mocked.py +0 -0
  216. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_db_adapters_mocked_extra.py +0 -0
  217. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_dsn.py +0 -0
  218. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_duckdb.py +0 -0
  219. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_factory.py +0 -0
  220. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_mysql_case_folding.py +0 -0
  221. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_mysql_inprocess.py +0 -0
  222. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_postgres.py +0 -0
  223. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_postgres_inprocess.py +0 -0
  224. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_sqlite.py +0 -0
  225. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_sqlite_extra.py +0 -0
  226. {execsql2-2.22.4 → execsql2-2.22.5}/tests/db/test_sqlserver_inprocess.py +0 -0
  227. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/__init__.py +0 -0
  228. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_base.py +0 -0
  229. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_db.py +0 -0
  230. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_delimited.py +0 -0
  231. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_duckdb_exporter.py +0 -0
  232. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_exporters.py +0 -0
  233. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_feather.py +0 -0
  234. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_html_extended.py +0 -0
  235. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_html_latex.py +0 -0
  236. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_json.py +0 -0
  237. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_json_extended.py +0 -0
  238. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_latex_extended.py +0 -0
  239. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_markdown.py +0 -0
  240. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_ods.py +0 -0
  241. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_ods_export.py +0 -0
  242. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_parquet.py +0 -0
  243. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_pretty_extended.py +0 -0
  244. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_raw_extended.py +0 -0
  245. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_sqlite_exporter.py +0 -0
  246. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_templates.py +0 -0
  247. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_templates_extended.py +0 -0
  248. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_values_extended.py +0 -0
  249. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_xls_xlsx.py +0 -0
  250. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_xlsx.py +0 -0
  251. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_xml.py +0 -0
  252. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_yaml.py +0 -0
  253. {execsql2-2.22.4 → execsql2-2.22.5}/tests/exporters/test_zip.py +0 -0
  254. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/__init__.py +0 -0
  255. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_backends.py +0 -0
  256. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_backends_extended.py +0 -0
  257. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_compare_stats.py +0 -0
  258. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_compute_row_diffs.py +0 -0
  259. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_desktop_dialogs.py +0 -0
  260. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_format_table.py +0 -0
  261. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_tui_pilot.py +0 -0
  262. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_tui_pilot_complex.py +0 -0
  263. {execsql2-2.22.4 → execsql2-2.22.5}/tests/gui/test_utils_gui_extended.py +0 -0
  264. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/__init__.py +0 -0
  265. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_base_extended.py +0 -0
  266. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_csv_edge_cases.py +0 -0
  267. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_csv_importer.py +0 -0
  268. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_feather_importer.py +0 -0
  269. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_json_importer.py +0 -0
  270. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_ods_importer.py +0 -0
  271. {execsql2-2.22.4 → execsql2-2.22.5}/tests/importers/test_xls_importer.py +0 -0
  272. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/__init__.py +0 -0
  273. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/conftest.py +0 -0
  274. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/test_dsn.py +0 -0
  275. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/test_duckdb.py +0 -0
  276. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/test_mysql.py +0 -0
  277. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/test_postgres.py +0 -0
  278. {execsql2-2.22.4 → execsql2-2.22.5}/tests/integration/test_sqlite.py +0 -0
  279. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/__init__.py +0 -0
  280. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_assert.py +0 -0
  281. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_breakpoint.py +0 -0
  282. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_conditions_extra.py +0 -0
  283. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_connect.py +0 -0
  284. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_io_export.py +0 -0
  285. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_io_import.py +0 -0
  286. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands.py +0 -0
  287. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_connect.py +0 -0
  288. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_data.py +0 -0
  289. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_extended.py +0 -0
  290. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_fileops_extra.py +0 -0
  291. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_io.py +0 -0
  292. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_io_write_extra.py +0 -0
  293. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_script_ext.py +0 -0
  294. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_system.py +0 -0
  295. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_metacommands_system_extra.py +0 -0
  296. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_pg_upsert.py +0 -0
  297. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_prompt.py +0 -0
  298. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_row_count.py +0 -0
  299. {execsql2-2.22.4 → execsql2-2.22.5}/tests/metacommands/test_show_scripts.py +0 -0
  300. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/__init__.py +0 -0
  301. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/audit_lint_bad.sql +0 -0
  302. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/audit_smoke/sample.json +0 -0
  303. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/audit_smoke/sample.jsonl +0 -0
  304. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/audit_smoke.sql +0 -0
  305. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/config_runtime.sql +0 -0
  306. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/control_flow.sql +0 -0
  307. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/counters_and_locals.sql +0 -0
  308. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/error_handling.sql +0 -0
  309. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/import_options/has_comments.csv +0 -0
  310. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/import_options.sql +0 -0
  311. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/includes/helper.sql +0 -0
  312. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/includes.sql +0 -0
  313. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/io_formats.sql +0 -0
  314. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/io_roundtrip.sql +0 -0
  315. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/multi_db.sql +0 -0
  316. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/parquet_feather_roundtrip.sql +0 -0
  317. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/parse_only/parse_tree.sql +0 -0
  318. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/predicates.sql +0 -0
  319. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/scripts.sql +0 -0
  320. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/smoke.sql +0 -0
  321. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/subroutine_loops.sql +0 -0
  322. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/subvars_advanced.sql +0 -0
  323. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/template_export/greeting.tmpl +0 -0
  324. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/template_export.sql +0 -0
  325. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/timer.sql +0 -0
  326. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/transactions.sql +0 -0
  327. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/write_create_table.sql +0 -0
  328. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/fixtures/xlsx_ods_roundtrip.sql +0 -0
  329. {execsql2-2.22.4 → execsql2-2.22.5}/tests/scripts/test_sql_scripts.py +0 -0
  330. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/__init__.py +0 -0
  331. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_env_detection.py +0 -0
  332. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_expansion_bomb.py +0 -0
  333. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_import_regex_hardening.py +0 -0
  334. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_log_redaction.py +0 -0
  335. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_odbc_injection.py +0 -0
  336. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_path_containment.py +0 -0
  337. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_substitution_injection.py +0 -0
  338. {execsql2-2.22.4 → execsql2-2.22.5}/tests/security/test_zip_bomb.py +0 -0
  339. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_api.py +0 -0
  340. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_api_extended.py +0 -0
  341. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_ast.py +0 -0
  342. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_ast_parser.py +0 -0
  343. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_bundled_templates.py +0 -0
  344. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_config.py +0 -0
  345. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_config_data.py +0 -0
  346. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_config_extended.py +0 -0
  347. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_debug_repl.py +0 -0
  348. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_engine.py +0 -0
  349. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_error_messages.py +0 -0
  350. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_exceptions.py +0 -0
  351. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_executor.py +0 -0
  352. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_executor_inprocess.py +0 -0
  353. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_mail.py +0 -0
  354. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_models.py +0 -0
  355. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_package.py +0 -0
  356. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_parser.py +0 -0
  357. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_parser_params.py +0 -0
  358. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_plugins.py +0 -0
  359. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_registry.py +0 -0
  360. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_script.py +0 -0
  361. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_state.py +0 -0
  362. {execsql2-2.22.4 → execsql2-2.22.5}/tests/test_types.py +0 -0
  363. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/__init__.py +0 -0
  364. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_auth.py +0 -0
  365. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_auth_extra.py +0 -0
  366. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_crypto.py +0 -0
  367. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_datetime.py +0 -0
  368. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_encodedfile_context.py +0 -0
  369. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_errors.py +0 -0
  370. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_errors_extra.py +0 -0
  371. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_fileio.py +0 -0
  372. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_fileio_extra.py +0 -0
  373. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_numeric.py +0 -0
  374. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_regex.py +0 -0
  375. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_strings.py +0 -0
  376. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_timer.py +0 -0
  377. {execsql2-2.22.4 → execsql2-2.22.5}/tests/utils/test_timer_extra.py +0 -0
  378. {execsql2-2.22.4 → execsql2-2.22.5}/zensical.toml +0 -0
@@ -283,7 +283,8 @@ jobs:
283
283
  name: Build distribution 📦
284
284
  runs-on: ubuntu-latest
285
285
  needs: [ lint, typecheck, pre-commit, docs, tests, integration-tests ]
286
- if: startsWith(github.ref, 'refs/tags/v')
286
+ # Deliberately NOT gated on tags: packaging breaks must surface on a PR,
287
+ # not at release time. Only publish/generate-release below are tag-gated.
287
288
  permissions:
288
289
  contents: read
289
290
  steps:
@@ -296,7 +297,12 @@ jobs:
296
297
  - name: Build sdist + wheel
297
298
  run: |
298
299
  python -m pip install --upgrade pip
299
- python -m pip install build twine
300
+ # twine is pinned to the version vendored by the publish job's
301
+ # pypa/gh-action-pypi-publish pin (its requirements/runtime.txt).
302
+ # That action runs its own `twine check` before uploading, so
303
+ # validating here with a *newer* twine can pass while the publish
304
+ # fails. Bump this pin and the action pin together.
305
+ python -m pip install build 'twine==7.0.0'
300
306
  python -m build --sdist --wheel --outdir dist/
301
307
  python -m twine check dist/*
302
308
  - name: Upload dist artifact
@@ -6,4 +6,4 @@
6
6
  types: [file]
7
7
  files: \.sql$
8
8
  args: [--in-place]
9
- additional_dependencies: [sqlglot]
9
+ additional_dependencies: ["sqlglot>=25.0,<31"]
@@ -13,13 +13,26 @@ ______________________________________________________________________
13
13
 
14
14
  ______________________________________________________________________
15
15
 
16
+ ## [2.22.5] - 2026-08-27
17
+
18
+ ### Fixed
19
+
20
+ - `execsql-format` no longer alters PostgreSQL escape strings; `E'\\s+'` was rewritten as `e'\s+'`, turning a whitespace regex into a letter-`s` regex, and lost a further backslash on every run.
21
+ - `execsql-format` now leaves a statement unformatted when its string literals would change; the previous check compared only alphanumeric counts and missed punctuation-only rewrites.
22
+
23
+ ### Changed
24
+
25
+ - The `execsql-format` pre-commit hook pins `sqlglot>=25.0,<31` so a given commit formats identically on every machine.
26
+
27
+ ______________________________________________________________________
28
+
16
29
  ## [2.22.4] - 2026-08-27
17
30
 
18
31
  ### Fixed
19
32
 
20
- - `HALT` with a message no longer opens a dialog and blocks forever in unattended runs. Without a GUI console and below `gui_level` 2 (no `-v2`/`-v3`), the message is written to the console and the script exits with the requested `EXIT_STATUS` — as the documentation has always described. Scripts that worked around this by pairing `WRITE` with a bare `HALT` can go back to `HALT MESSAGE`.
21
- - `HALT … DISPLAY` now shows its table on the console in non-interactive runs instead of discarding it; an empty table appears as its column headings and a count of zero rows.
22
- - Textual (`--gui-framework textual`) `HALT` dialogs now show the single OK button the metacommand asks for, rather than a Cancel/Continue pair whose Cancel exited with status 2 instead of the requested `EXIT_STATUS`. `HALT … DISPLAY` also renders its table in the dialog, and <kbd>Esc</kbd> no longer cancels it.
33
+ - `HALT` with a message no longer blocks on a dialog in unattended runs; below `gui_level` 2 the message is written to the console and the script exits with the requested `EXIT_STATUS`.
34
+ - `HALT … DISPLAY` now writes its table to the console in non-interactive runs instead of discarding it.
35
+ - Textual `HALT` dialogs now show the single OK button the metacommand asks for, render the `DISPLAY` table, and ignore <kbd>Esc</kbd>; cancelling previously exited with status 2 instead of the requested `EXIT_STATUS`.
23
36
 
24
37
  ______________________________________________________________________
25
38
 
@@ -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
  ```
@@ -253,7 +253,7 @@ execsql-format --no-sql --in-place scripts/
253
253
  ```yaml
254
254
  repos:
255
255
  - repo: https://github.com/geocoug/execsql
256
- rev: v2.22.4
256
+ rev: v2.22.5
257
257
  hooks:
258
258
  - id: execsql-format
259
259
  ```
@@ -272,51 +272,53 @@ These are behavioral changes driven by security or correctness issues in the ups
272
272
 
273
273
  ### Bug Fixes
274
274
 
275
- | Area | Fix |
276
- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
277
- | Oracle default port | Corrected from `5432` (PostgreSQL) to `1521`. |
278
- | MySQL `LOAD DATA INFILE` encoding | Python encoding names are now mapped to MySQL charset names. |
279
- | `dt_cast` type converters | Base `Database` class auto-populates 8 type converters that were previously left empty after the refactor. |
280
- | `FileWriter` CPU busy-loop | Uses blocking `queue.get(timeout=0.1)` instead of `get_nowait()` in a tight loop. |
281
- | Substitution variable cycles | 100-iteration limit prevents infinite loops on cyclic variable references. |
282
- | Script location in error messages | `ErrInfo.script_file` and `script_line_no` are now populated via `stamp_errinfo()` so error output includes "Line N of script foo.sql" context — restoring behavior present in the monolith. |
283
- | `$ERROR_MESSAGE` not updated | `$ERROR_MESSAGE` is now set on every error path: `exit_now()`, non-halting SQL errors, and non-halting metacommand errors. Previously it was initialized to `""` and never changed. |
284
- | Metacommand error message lost | When `halt_on_metacommand_err` is `ON`, the original handler `ErrInfo` is now re-raised; the generic "Unknown metacommand" message no longer replaces the specific error from the handler. |
285
- | Empty script name in error msg | `_execute_script_direct()` and `_execute_script_textual_console()` no longer append "in script , line 0" to uncaught-exception messages when `current_script_line()` returns an empty string. |
286
- | `PROMPT COMPARE` diff comparison | Diff engine uses native Python equality instead of string comparison — numeric types, Decimals, and booleans compare correctly. `None` is distinguished from empty string. Columns are matched by name (not position), key columns are excluded from comparison, and duplicate PKs keep the first row. |
287
- | `win_config_file` broken | Checked `os.name == "windows"` which Python never returns (correct value is `"nt"`). Fixed to `os.name == "nt"`. |
288
- | ELSEIF + ANDIF/ORIF | ANDIF/ORIF after an ELSEIF were silently attached to the parent IF condition instead of the ELSEIF clause. The compound condition was never evaluated for the ELSEIF branch. Fixed in the AST parser and executor. |
289
- | Cursor leak in `select_rowsource()` | Cursor was not closed on query execution failure. Row generators in EXPORT and COPY paths were not explicitly closed on error, relying on garbage collection for cleanup. |
290
- | `NumericParser` right-associative | Arithmetic operators were parsed right-to-left. `10 - 3 - 2` evaluated as `9` instead of `5`. Fixed to left-associative parsing. |
291
- | Empty-column check precedence | `DataTable` and `Database.populate_table()` had an operator precedence bug in the extra-column emptiness check — a redundant `and conf.del_empty_cols` caused incorrect short-circuit evaluation. |
292
- | SQLite import string processing | `SQLiteDatabase.populate_table()` applied `trim_strings`, `replace_newlines`, and `empty_strings` after copying row data, so processing never reached the INSERT. Fixed to process before extraction. |
293
- | `$CURRENT_DATABASE`/`$CURRENT_DBMS` stale after USE | These variables were only set at startup and on CONNECT, not refreshed when `USE` switched the active database. Now set in `set_static_system_vars()` so they update on any connection change. |
294
- | `DT_Text.data_type_name` wrong | Was `"character"` (same as `DT_Character`), making error messages indistinguishable. Corrected to `"text"`. |
295
- | `DT_Varchar` non-string data unchecked | `_from_data()` only enforced the 255-char limit for `str` inputs; non-string values passed through without conversion or length check. |
296
- | `WriteHooks.write_err()` crash on empty string | `strval[-1]` raised `IndexError` on empty input. Fixed to use `str.endswith()`. |
297
- | `NumericParser` division by zero | `NumericAstNode.eval()` raised unhandled `ZeroDivisionError`. Now raises `NumericParserError` with a clear message. |
298
- | `CondAstNode.eval()` could return `None` | Missing fallthrough for unknown node types silently returned `None`. Now raises `CondParserError`. |
299
- | `DT_Timestamp` claims time-only values | `dateutil.parser.parse()` silently fills in today's date for bare time strings like `"13:15:45"`, so `DT_Timestamp` matched before `DT_Time` in the inference order. `parse_datetime()` now rejects time-only strings. |
300
- | `CounterVars.substitute` skipped pos 0–1 | `re.I` was passed as the positional `pos` argument, skipping the first 2 characters of every string during counter variable expansion. Counter variables at the very start of a line were never matched. |
301
- | `exec_cmd` (SQLite/DuckDB) always raised TypeError | Passed bytes to `curs.execute()` via `.encode()`, which Python 3 `sqlite3`/`duckdb` reject. `EXECUTE PROCEDURE` metacommand was non-functional on these backends. |
302
- | MySQL `LOAD DATA INFILE` injection | File path, delimiter, and quotechar were interpolated without escaping. Now single-quotes are escaped consistent with the PostgreSQL COPY path. |
303
- | Missing server/database name at connect | Connecting to a server-based database without a server or database name now stops with a clear fatal error ("Configured database requires a server name, but none is provided.") instead of crashing inside the driver with a `TypeError`. |
304
- | Config file chain infinite loop | The `config_file` directive could chain config files without limit. A circular reference (via symlinks or different relative paths) caused an infinite loop at startup. Now capped at 20 files. |
305
- | Cursor leaks in database adapters | ~15 methods across all adapters used `curs = self.cursor()` / `curs.close()` without `try/finally`. If the query raised, the cursor leaked. Converted to `with self._cursor() as curs:`. |
306
- | JSON export malformed on special column names | Column names containing `"` or `\` produced invalid JSON. Now uses `json.dumps()` for all field names. |
307
- | Temp file creation TOCTOU race | `TempFileMgr.new_temp_fn()` discarded the `NamedTemporaryFile` handle, creating a race window. Now uses `tempfile.mkstemp()` for secure creation. |
308
- | `shlex.split` on Windows incorrect mode | Called without `posix=False` on Windows, mishandling backslash-heavy paths in SHELL commands. |
309
- | AST executor `~`/`+` variable scoping broken | The AST executor passed `localvars` through function parameters but never pushed `CommandList` frames onto `commandliststack`. Legacy metacommand handlers (`x_sub`, `x_rm_sub`, `xf_sub_defined`, `SUB_LOCAL`, prompt handlers, REPL) access `commandliststack[-1]` for `~` local and `+` outer-scope variables. This caused `~` vars to be invisible to SQL, `SUB_DEFINED(~var)` to always return false, and the REPL `.vars`/`.stack` to show empty state. Fixed by pushing/popping `CommandList` frames in `execute()` and `_execute_script_native()`. |
310
- | Firebird adapter import | Adapter imported `fdb` (the unmaintained legacy driver) while the `[firebird]` extra declared `firebird-driver`. `pip install execsql2[firebird]` followed by a Firebird connect raised `ModuleNotFoundError: No module named 'fdb'`. Adapter now imports `firebird.driver`, matching the declared dependency. |
311
- | Connect timeouts on network adapters | MySQL, SQL Server / Access / DSN (pyodbc), Firebird, and SMTP all now apply a 30 s connect timeout matching the existing PostgreSQL default. A silently-dropped network peer (firewall rule, dead VM) used to hang scripts indefinitely; now the connect fails fast. |
312
- | AST parser INCLUDE quoted paths broken | The AST parser captured the full INCLUDE target including surrounding quotes (`"path"`), but the legacy dispatch regex stripped them. Quoted INCLUDE paths failed with "File does not exist" even when the file was present. Fixed by stripping matched quote pairs in the parser. |
313
- | AST parser `BEGIN SCRIPT name(params)` rejected | The regex required whitespace between the script name and parameter list. `BEGIN SCRIPT foo(a,b)` (no space before `(`) silently failed to match, causing the matching `END SCRIPT` to raise "Unmatched END SCRIPT metacommand." Fixed by allowing optional whitespace before the parameter expression. |
314
- | AST executor forward SCRIPT references broken | The legacy engine registered all `BEGIN SCRIPT` blocks at parse time (two-pass), so `EXECUTE SCRIPT foo` could appear before the `BEGIN SCRIPT foo` definition. The AST executor walked the tree in a single pass, so forward references failed with "There is no SCRIPT named foo." Fixed by adding a pre-registration scan of all SCRIPT blocks before execution begins. |
315
- | DB-export type inference overrides source types | `DataTable`/`Column` inferred column types from the *string shape* of values — correct for CSV import (everything is `str`) but wrong for DB-query exports where the driver returns typed Python objects. A text column with values like `"2026-06-30 10:07:20 PM"` was inferred as `TIMESTAMP`, causing DuckDB bind errors. SQLite, DuckDB, HDF5, and JSON-schema exports now pass `infer_strings=False` to `DataTable`: string values are classified as text types only, while native Python objects (`Decimal`, `datetime`, etc.) still run through the full accumulator set for accurate schema inference. |
316
- | `EXPORT FORMAT SQLITE` rejects `decimal.Decimal` | SQLite's Python adapter does not accept `Decimal` in `executemany`, raising `ProgrammingError`. SQLite exports now convert `Decimal` values to their exact string representation before binding; SQLite's NUMERIC affinity stores them without loss of precision. |
317
- | Unsupported Python objects crash native exports | `EXPORT FORMAT SQLITE` and `EXPORT FORMAT DUCKDB` raised bind errors when the DB driver returned user-defined Python objects (e.g. PostGIS geometry). Both exporters now stringify any object whose type is not in the adapter's safe-binding set, preserving values as their `str()` form instead of crashing. |
318
- | `HALT` with a message hangs unattended runs | The message-bearing `HALT` patterns are registered after the general one and so take priority, meaning `x_halt`'s `gui_level > 1` guard was never reached and every `HALT MESSAGE` opened a modal dialog — blocking cron and CI runs indefinitely at `gui_level` 0, contrary to the documented behavior. The guard now lives in `x_halt_msg`: without a GUI console and below `gui_level` 2, the message and any `DISPLAY` rowset are written to the error output and the script exits with the requested status. An empty `DISPLAY` table renders as headings plus a zero row count rather than nothing. |
319
- | Textual `HALT` dialog ignored `button_list` | The Textual `MsgScreen` hardcoded a Cancel/Continue pair and dropped the caller's `column_headers`/`rowset`, so a `HALT` asking for a single OK button offered a Cancel that exited with status 2 instead of the requested `EXIT_STATUS`, and `HALT ... DISPLAY` showed no table. `MsgScreen` now honors `button_list` and `no_cancel` (which also suppresses <kbd>Esc</kbd>) and renders the rowset, matching the Tkinter backend. |
275
+ | Area | Fix |
276
+ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
277
+ | Oracle default port | Corrected from `5432` (PostgreSQL) to `1521`. |
278
+ | MySQL `LOAD DATA INFILE` encoding | Python encoding names are now mapped to MySQL charset names. |
279
+ | `dt_cast` type converters | Base `Database` class auto-populates 8 type converters that were previously left empty after the refactor. |
280
+ | `FileWriter` CPU busy-loop | Uses blocking `queue.get(timeout=0.1)` instead of `get_nowait()` in a tight loop. |
281
+ | Substitution variable cycles | 100-iteration limit prevents infinite loops on cyclic variable references. |
282
+ | Script location in error messages | `ErrInfo.script_file` and `script_line_no` are now populated via `stamp_errinfo()` so error output includes "Line N of script foo.sql" context — restoring behavior present in the monolith. |
283
+ | `$ERROR_MESSAGE` not updated | `$ERROR_MESSAGE` is now set on every error path: `exit_now()`, non-halting SQL errors, and non-halting metacommand errors. Previously it was initialized to `""` and never changed. |
284
+ | Metacommand error message lost | When `halt_on_metacommand_err` is `ON`, the original handler `ErrInfo` is now re-raised; the generic "Unknown metacommand" message no longer replaces the specific error from the handler. |
285
+ | Empty script name in error msg | `_execute_script_direct()` and `_execute_script_textual_console()` no longer append "in script , line 0" to uncaught-exception messages when `current_script_line()` returns an empty string. |
286
+ | `PROMPT COMPARE` diff comparison | Diff engine uses native Python equality instead of string comparison — numeric types, Decimals, and booleans compare correctly. `None` is distinguished from empty string. Columns are matched by name (not position), key columns are excluded from comparison, and duplicate PKs keep the first row. |
287
+ | `win_config_file` broken | Checked `os.name == "windows"` which Python never returns (correct value is `"nt"`). Fixed to `os.name == "nt"`. |
288
+ | ELSEIF + ANDIF/ORIF | ANDIF/ORIF after an ELSEIF were silently attached to the parent IF condition instead of the ELSEIF clause. The compound condition was never evaluated for the ELSEIF branch. Fixed in the AST parser and executor. |
289
+ | Cursor leak in `select_rowsource()` | Cursor was not closed on query execution failure. Row generators in EXPORT and COPY paths were not explicitly closed on error, relying on garbage collection for cleanup. |
290
+ | `NumericParser` right-associative | Arithmetic operators were parsed right-to-left. `10 - 3 - 2` evaluated as `9` instead of `5`. Fixed to left-associative parsing. |
291
+ | Empty-column check precedence | `DataTable` and `Database.populate_table()` had an operator precedence bug in the extra-column emptiness check — a redundant `and conf.del_empty_cols` caused incorrect short-circuit evaluation. |
292
+ | SQLite import string processing | `SQLiteDatabase.populate_table()` applied `trim_strings`, `replace_newlines`, and `empty_strings` after copying row data, so processing never reached the INSERT. Fixed to process before extraction. |
293
+ | `$CURRENT_DATABASE`/`$CURRENT_DBMS` stale after USE | These variables were only set at startup and on CONNECT, not refreshed when `USE` switched the active database. Now set in `set_static_system_vars()` so they update on any connection change. |
294
+ | `DT_Text.data_type_name` wrong | Was `"character"` (same as `DT_Character`), making error messages indistinguishable. Corrected to `"text"`. |
295
+ | `DT_Varchar` non-string data unchecked | `_from_data()` only enforced the 255-char limit for `str` inputs; non-string values passed through without conversion or length check. |
296
+ | `WriteHooks.write_err()` crash on empty string | `strval[-1]` raised `IndexError` on empty input. Fixed to use `str.endswith()`. |
297
+ | `NumericParser` division by zero | `NumericAstNode.eval()` raised unhandled `ZeroDivisionError`. Now raises `NumericParserError` with a clear message. |
298
+ | `CondAstNode.eval()` could return `None` | Missing fallthrough for unknown node types silently returned `None`. Now raises `CondParserError`. |
299
+ | `DT_Timestamp` claims time-only values | `dateutil.parser.parse()` silently fills in today's date for bare time strings like `"13:15:45"`, so `DT_Timestamp` matched before `DT_Time` in the inference order. `parse_datetime()` now rejects time-only strings. |
300
+ | `CounterVars.substitute` skipped pos 0–1 | `re.I` was passed as the positional `pos` argument, skipping the first 2 characters of every string during counter variable expansion. Counter variables at the very start of a line were never matched. |
301
+ | `exec_cmd` (SQLite/DuckDB) always raised TypeError | Passed bytes to `curs.execute()` via `.encode()`, which Python 3 `sqlite3`/`duckdb` reject. `EXECUTE PROCEDURE` metacommand was non-functional on these backends. |
302
+ | MySQL `LOAD DATA INFILE` injection | File path, delimiter, and quotechar were interpolated without escaping. Now single-quotes are escaped consistent with the PostgreSQL COPY path. |
303
+ | Missing server/database name at connect | Connecting to a server-based database without a server or database name now stops with a clear fatal error ("Configured database requires a server name, but none is provided.") instead of crashing inside the driver with a `TypeError`. |
304
+ | Config file chain infinite loop | The `config_file` directive could chain config files without limit. A circular reference (via symlinks or different relative paths) caused an infinite loop at startup. Now capped at 20 files. |
305
+ | Cursor leaks in database adapters | ~15 methods across all adapters used `curs = self.cursor()` / `curs.close()` without `try/finally`. If the query raised, the cursor leaked. Converted to `with self._cursor() as curs:`. |
306
+ | JSON export malformed on special column names | Column names containing `"` or `\` produced invalid JSON. Now uses `json.dumps()` for all field names. |
307
+ | Temp file creation TOCTOU race | `TempFileMgr.new_temp_fn()` discarded the `NamedTemporaryFile` handle, creating a race window. Now uses `tempfile.mkstemp()` for secure creation. |
308
+ | `shlex.split` on Windows incorrect mode | Called without `posix=False` on Windows, mishandling backslash-heavy paths in SHELL commands. |
309
+ | AST executor `~`/`+` variable scoping broken | The AST executor passed `localvars` through function parameters but never pushed `CommandList` frames onto `commandliststack`. Legacy metacommand handlers (`x_sub`, `x_rm_sub`, `xf_sub_defined`, `SUB_LOCAL`, prompt handlers, REPL) access `commandliststack[-1]` for `~` local and `+` outer-scope variables. This caused `~` vars to be invisible to SQL, `SUB_DEFINED(~var)` to always return false, and the REPL `.vars`/`.stack` to show empty state. Fixed by pushing/popping `CommandList` frames in `execute()` and `_execute_script_native()`. |
310
+ | Firebird adapter import | Adapter imported `fdb` (the unmaintained legacy driver) while the `[firebird]` extra declared `firebird-driver`. `pip install execsql2[firebird]` followed by a Firebird connect raised `ModuleNotFoundError: No module named 'fdb'`. Adapter now imports `firebird.driver`, matching the declared dependency. |
311
+ | Connect timeouts on network adapters | MySQL, SQL Server / Access / DSN (pyodbc), Firebird, and SMTP all now apply a 30 s connect timeout matching the existing PostgreSQL default. A silently-dropped network peer (firewall rule, dead VM) used to hang scripts indefinitely; now the connect fails fast. |
312
+ | AST parser INCLUDE quoted paths broken | The AST parser captured the full INCLUDE target including surrounding quotes (`"path"`), but the legacy dispatch regex stripped them. Quoted INCLUDE paths failed with "File does not exist" even when the file was present. Fixed by stripping matched quote pairs in the parser. |
313
+ | AST parser `BEGIN SCRIPT name(params)` rejected | The regex required whitespace between the script name and parameter list. `BEGIN SCRIPT foo(a,b)` (no space before `(`) silently failed to match, causing the matching `END SCRIPT` to raise "Unmatched END SCRIPT metacommand." Fixed by allowing optional whitespace before the parameter expression. |
314
+ | AST executor forward SCRIPT references broken | The legacy engine registered all `BEGIN SCRIPT` blocks at parse time (two-pass), so `EXECUTE SCRIPT foo` could appear before the `BEGIN SCRIPT foo` definition. The AST executor walked the tree in a single pass, so forward references failed with "There is no SCRIPT named foo." Fixed by adding a pre-registration scan of all SCRIPT blocks before execution begins. |
315
+ | DB-export type inference overrides source types | `DataTable`/`Column` inferred column types from the *string shape* of values — correct for CSV import (everything is `str`) but wrong for DB-query exports where the driver returns typed Python objects. A text column with values like `"2026-06-30 10:07:20 PM"` was inferred as `TIMESTAMP`, causing DuckDB bind errors. SQLite, DuckDB, HDF5, and JSON-schema exports now pass `infer_strings=False` to `DataTable`: string values are classified as text types only, while native Python objects (`Decimal`, `datetime`, etc.) still run through the full accumulator set for accurate schema inference. |
316
+ | `EXPORT FORMAT SQLITE` rejects `decimal.Decimal` | SQLite's Python adapter does not accept `Decimal` in `executemany`, raising `ProgrammingError`. SQLite exports now convert `Decimal` values to their exact string representation before binding; SQLite's NUMERIC affinity stores them without loss of precision. |
317
+ | Unsupported Python objects crash native exports | `EXPORT FORMAT SQLITE` and `EXPORT FORMAT DUCKDB` raised bind errors when the DB driver returned user-defined Python objects (e.g. PostGIS geometry). Both exporters now stringify any object whose type is not in the adapter's safe-binding set, preserving values as their `str()` form instead of crashing. |
318
+ | `HALT` with a message hangs unattended runs | The message-bearing `HALT` patterns are registered after the general one and so take priority, meaning `x_halt`'s `gui_level > 1` guard was never reached and every `HALT MESSAGE` opened a modal dialog — blocking cron and CI runs indefinitely at `gui_level` 0, contrary to the documented behavior. The guard now lives in `x_halt_msg`: without a GUI console and below `gui_level` 2, the message and any `DISPLAY` rowset are written to the error output and the script exits with the requested status. An empty `DISPLAY` table renders as headings plus a zero row count rather than nothing. |
319
+ | Textual `HALT` dialog ignored `button_list` | The Textual `MsgScreen` hardcoded a Cancel/Continue pair and dropped the caller's `column_headers`/`rowset`, so a `HALT` asking for a single OK button offered a Cancel that exited with status 2 instead of the requested `EXIT_STATUS`, and `HALT ... DISPLAY` showed no table. `MsgScreen` now honors `button_list` and `no_cancel` (which also suppresses <kbd>Esc</kbd>) and renders the rowset, matching the Tkinter backend. |
320
+ | `execsql-format` corrupts `E'...'` literals | sqlglot's Postgres tokenizer consumes backslash escapes inside an escape string but its generator never re-emits them (tobymao/sqlglot#8191), so `E'\\s+'` became `e'\s+'` — a regex matching the letter `s` rather than whitespace — losing one backslash per pass until `E'\\\\'` degraded to the unparsable `e'\''`. Escape strings are now masked out of the sqlglot round trip and restored byte-for-byte, independently of the installed sqlglot version. |
321
+ | Formatter could not detect literal-level content loss | The content-loss guard deleted every non-alphanumeric character before comparing *lengths*, so any rewrite confined to punctuation was invisible to it at any threshold. A literal round-trip check now requires every string literal in the input to survive verbatim in the output, falling back to the unformatted text otherwise. |
320
322
 
321
323
  ______________________________________________________________________
322
324
 
@@ -91,9 +91,18 @@ Block comments (`/* */`) that contain `-- !x!` metacommand markers (e.g. comment
91
91
 
92
92
  execsql substitution variables (`!!varname!!`, `!{varname}!`) are replaced with valid SQL identifiers before formatting, then restored afterward, so the formatter does not corrupt them — including in schema-qualified names (`!!staging!!.!!table!!`), CASE expressions, JOIN conditions, and string concatenation.
93
93
 
94
+ #### String literal preservation { #string_literals }
95
+
96
+ Formatting never changes what a string literal contains. Two mechanisms enforce this:
97
+
98
+ - PostgreSQL escape strings (`E'...'`) are held out of the sqlglot round trip entirely and restored byte-for-byte, keeping both their backslash escapes and their `E` prefix. This matters because sqlglot consumes backslash escapes inside an escape string without re-emitting them, which would turn `E'\\s+'` (a regex matching whitespace) into `e'\s+'` (a regex matching the letter `s`).
99
+ - Every other literal — ordinary `'...'` strings and dollar-quoted bodies (`$$...$$`, `$tag$...$tag$`) — is compared before and after formatting. If any literal fails to come back unchanged, the statement is left unformatted rather than rewritten.
100
+
101
+ The second check is deliberately conservative: a statement may occasionally be left alone when the rewrite would in fact have been harmless (for example, sqlglot normalizes `interval '1 day'` to `INTERVAL '1 DAY'`, which changes the literal's text but not its meaning). Losing formatting on a statement is recoverable; silently changing what a query does is not.
102
+
94
103
  #### Fallback behavior
95
104
 
96
- If sqlglot cannot parse a SQL statement, or if safety checks detect that formatting would corrupt the SQL (e.g. statement count changes, significant content loss), the original text is preserved unchanged.
105
+ If sqlglot cannot parse a SQL statement, or if safety checks detect that formatting would corrupt the SQL — statement count changes, significant content loss, or an altered [string literal](#string_literals) — the original text is preserved unchanged.
97
106
 
98
107
  Use `--no-sql` to skip SQL reformatting entirely and only normalize metacommands.
99
108
 
@@ -160,7 +169,7 @@ select id,name,created_at from users where active = true order by name;
160
169
  ```yaml
161
170
  repos:
162
171
  - repo: https://github.com/geocoug/execsql
163
- rev: v2.22.4
172
+ rev: v2.22.5
164
173
  hooks:
165
174
  - id: execsql-format
166
175
  ```
@@ -1807,7 +1807,7 @@ SUB_DEFINED(<match_string>)
1807
1807
  Evaluates whether a replacement string has been defined for the specified substitution variable (matching string).
1808
1808
 
1809
1809
 
1810
- ### *SUB_EMPTY*
1810
+ ### *SUB_EMPTY* { #sub_empty_cond }
1811
1811
 
1812
1812
  ```
1813
1813
  SUB_EMPTY(<match_string>)
@@ -2946,6 +2946,20 @@ Defines a [substitution variable](substitution_vars.md#substitution_vars) (the \
2946
2946
 
2947
2947
  If the variable name (\<match_string\>) starts with a tilde, a [local variable](substitution_vars.md#local_vars) will be created. If the variable name starts with a plus, the substitution is assigned to the first (proceeding outward) local variable by the same name found in an enclosing script. The plus prefix may only be used to refer to an existing instance of an outer-scope local variables; it cannot be used to create a new instance. If no corresponding local variable exists in any outer scope, an error will be raised.
2948
2948
 
2949
+ The replacement string cannot be empty. A SUB metacommand with no replacement string matches no metacommand at all, and fails with an "Unknown metacommand" error naming the SUB line. Use [SUB_EMPTY](#sub_empty) to assign a zero-length string.
2950
+
2951
+ This also applies when the replacement string comes from a variable — a value taken from [SELECT_SUB](#select_sub) or [SUBDATA](#subdata), for example. If the query returns an empty value, the substituted line reduces to `SUB <match_string>` and fails the same way:
2952
+
2953
+ ```sql
2954
+ -- The script works until the query happens to return an empty string.
2955
+ -- !x! SELECT_SUB v
2956
+ -- !x! SUB myvar !!@c!!
2957
+ ```
2958
+
2959
+ The variable is still *defined* in that case, so [SUB_DEFINED](#sub_defined) will not detect it. Test with the [SUB_EMPTY](#sub_empty_cond) conditional before the SUB, or coalesce the value to a non-empty sentinel in the query.
2960
+
2961
+ Leading and trailing whitespace is removed from the replacement string, so SUB cannot store a value whose surrounding whitespace is significant. Trailing whitespace is stripped from every metacommand line before it is dispatched, so this applies to substituted values as well as to literal text. Indentation that must be preserved should be supplied where the variable is used rather than stored in the variable. [SUB_APPEND](#sub_append) differs here: it preserves leading whitespace in the appended text, while still losing trailing whitespace to the same line-level strip.
2962
+
2949
2963
 
2950
2964
  ## SUB_ADD
2951
2965
 
@@ -298,7 +298,7 @@ Substitution variables can also be defined in the "variables" section of a [conf
298
298
 
299
299
  ## Quoting Convention { #quoting_convention }
300
300
 
301
- [SUB](metacommands.md#subcmd) stores its replacement string verbatim, and substitution inserts the stored text without modification. [EXECUTE SCRIPT](metacommands.md#executescript) argument values, however, are stripped of one pair of surrounding quotes when bound to parameters. As a result, `SUB myfile "filename.txt"` followed by `EXECUTE SCRIPT proc(arg='!!myfile!!')` leaves the called script's `!!#arg!!` expanding to `"filename.txt"` — still wrapped in the double quotes from the SUB, with no in-script primitive to strip them.
301
+ [SUB](metacommands.md#subcmd) stores surrounding quotes verbatim, and substitution inserts the stored text without modification. (Surrounding *whitespace* is the exception — SUB trims it; see the [SUB](metacommands.md#subcmd) entry.) [EXECUTE SCRIPT](metacommands.md#executescript) argument values, however, are stripped of one pair of surrounding quotes when bound to parameters. As a result, `SUB myfile "filename.txt"` followed by `EXECUTE SCRIPT proc(arg='!!myfile!!')` leaves the called script's `!!#arg!!` expanding to `"filename.txt"` — still wrapped in the double quotes from the SUB, with no in-script primitive to strip them.
302
302
 
303
303
  To avoid this, store bare values and apply quoting only at the point of use:
304
304
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "execsql2"
7
- version = "2.22.4"
7
+ version = "2.22.5"
8
8
  description = "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."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { file = "LICENSE.txt" }
@@ -178,7 +178,7 @@ skip-magic-trailing-comma = false
178
178
  line-ending = "auto"
179
179
 
180
180
  [tool.bumpversion]
181
- current_version = "2.22.4"
181
+ current_version = "2.22.5"
182
182
  commit = true
183
183
  tag = true
184
184
  tag_name = "v{new_version}"
@@ -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