execsql2 2.27.0__py3-none-any.whl → 2.29.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. execsql/cli/commands/format.py +2 -1
  2. execsql/cli/commands/lsp.py +33 -9
  3. execsql/cli/lint.py +45 -22
  4. execsql/db/access.py +4 -4
  5. execsql/db/base.py +23 -12
  6. execsql/db/dsn.py +2 -2
  7. execsql/db/duckdb.py +14 -2
  8. execsql/db/firebird.py +2 -3
  9. execsql/db/mysql.py +21 -5
  10. execsql/db/oracle.py +5 -5
  11. execsql/db/postgres.py +3 -4
  12. execsql/db/sqlserver.py +2 -2
  13. execsql/exporters/duckdb.py +2 -2
  14. execsql/exporters/ods.py +2 -2
  15. execsql/exporters/templates.py +2 -2
  16. execsql/exporters/xls.py +6 -5
  17. execsql/exporters/xlsx.py +2 -2
  18. execsql/exporters/yaml.py +3 -4
  19. execsql/format.py +71 -21
  20. execsql/importers/base.py +6 -10
  21. execsql/lsp/document.py +7 -3
  22. execsql/metacommands/debug.py +2 -2
  23. execsql/metacommands/io_fileops.py +6 -6
  24. execsql/metacommands/upsert.py +2 -5
  25. execsql/models.py +11 -0
  26. execsql/script/variables.py +42 -10
  27. execsql/types.py +8 -0
  28. execsql/utils/errors.py +16 -0
  29. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/METADATA +4 -2
  30. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/RECORD +48 -48
  31. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/README.md +0 -0
  32. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  33. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  34. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  35. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  36. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  37. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  38. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  39. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  40. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  41. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/script_template.sql +0 -0
  42. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  43. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  44. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  45. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/WHEEL +0 -0
  46. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/entry_points.txt +0 -0
  47. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/LICENSE.txt +0 -0
  48. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/NOTICE +0 -0
execsql/exporters/yaml.py CHANGED
@@ -6,8 +6,7 @@ YAML export for execsql.
6
6
  Provides :func:`write_query_to_yaml` which serialises a query result set as a
7
7
  YAML sequence of mappings (one mapping per row).
8
8
 
9
- Requires the ``PyYAML`` package (``pip install PyYAML`` or
10
- ``pip install 'execsql2[formats]'``).
9
+ Requires the ``PyYAML`` package (the ``formats`` extra).
11
10
  """
12
11
 
13
12
  from typing import Any
@@ -15,7 +14,7 @@ from typing import Any
15
14
  import execsql.state as _state
16
15
  from execsql.exceptions import ErrInfo
17
16
  from execsql.exporters.zip import ZipWriter
18
- from execsql.utils.errors import exception_desc
17
+ from execsql.utils.errors import exception_desc, install_hint
19
18
  from execsql.utils.fileio import filewriter_close
20
19
 
21
20
  __all__ = ["write_query_to_yaml"]
@@ -54,7 +53,7 @@ def write_query_to_yaml(
54
53
  except ImportError as exc:
55
54
  raise ErrInfo(
56
55
  "error",
57
- other_msg=("PyYAML is required for FORMAT YAML export. Install it with: pip install PyYAML"),
56
+ other_msg=f"PyYAML is required for FORMAT YAML export. {install_hint('formats')}",
58
57
  ) from exc
59
58
 
60
59
  conf = _state.conf
execsql/format.py CHANGED
@@ -21,11 +21,14 @@ from types import ModuleType
21
21
  __all__ = ["collect_paths", "format_file", "open_dollar_quote", "parse_keyword", "run_formatter"]
22
22
 
23
23
 
24
- _SQLGLOT_MISSING_MSG = (
25
- "execsql format requires sqlglot for SQL reformatting.\n"
26
- ' Install the formatter extra: uv tool install "execsql2[formatter]" (or pip install "execsql2[formatter]")\n'
27
- " Or skip SQL reformatting with the --no-sql flag."
28
- )
24
+ def _sqlglot_missing_msg() -> str:
25
+ from execsql.utils.errors import install_hint
26
+
27
+ return (
28
+ "execsql format requires sqlglot for SQL reformatting.\n"
29
+ f" {install_hint('formatter')}\n"
30
+ " Or skip SQL reformatting with the --no-sql flag."
31
+ )
29
32
 
30
33
 
31
34
  def _require_sqlglot():
@@ -36,7 +39,7 @@ def _require_sqlglot():
36
39
 
37
40
  return sqlglot
38
41
  except ImportError as e:
39
- raise ImportError(_SQLGLOT_MISSING_MSG) from e
42
+ raise ImportError(_sqlglot_missing_msg()) from e
40
43
 
41
44
 
42
45
  # ---------------------------------------------------------------------------
@@ -532,20 +535,57 @@ def _statement_sources(sqlglot: ModuleType, sql: str) -> list[str]:
532
535
  return sources
533
536
 
534
537
 
535
- def _layout_tokens(sqlglot: ModuleType, sql: str) -> list[str]:
536
- """*sql*'s tokens with everything the formatter may change taken out.
538
+ def _ends_with_semicolon(sqlglot: ModuleType, sql: str) -> bool:
539
+ """Whether the last token of *sql* is a statement-ending ``;``."""
540
+ tokens = sqlglot.tokenize(sql, read="postgres")
541
+ return bool(tokens) and tokens[-1].token_type.name == "SEMICOLON"
542
+
543
+
544
+ def _layout_tokens(sqlglot: ModuleType, sql: str) -> list[tuple[str, str]]:
545
+ """*sql*'s tokens, as (type, text), with everything the formatter may change taken out.
537
546
 
538
- Whitespace and comments are not tokens. Keywords and unquoted names are
539
- compared without case; strings and quoted names exactly.
547
+ Whitespace and comments are not tokens. Keywords and unquoted names
548
+ (function names included) are compared without case; strings and quoted
549
+ names exactly. The type is compared too, so a string that came back as a
550
+ quoted name of the same text is a change.
540
551
  """
541
552
  return [
542
- token.text
543
- if token.token_type.name.endswith("STRING") or token.token_type.name == "IDENTIFIER"
544
- else token.text.upper()
553
+ (
554
+ token.token_type.name,
555
+ token.text
556
+ if token.token_type.name.endswith("STRING") or token.token_type.name == "IDENTIFIER"
557
+ else token.text.upper(),
558
+ )
545
559
  for token in sqlglot.tokenize(sql, read="postgres")
546
560
  ]
547
561
 
548
562
 
563
+ # An optimizer hint (``/*+ ... */``, Oracle and MySQL) or a MySQL executable
564
+ # comment (``/*! ... */``) is SQL the server reads, though it looks like a
565
+ # comment. sqlglot keeps neither: it writes them back as ``/* + ... */`` and
566
+ # moves a hint away from its keyword, which disables it.
567
+ _RX_HINT_COMMENT = re.compile(r"/\*[+!]")
568
+
569
+
570
+ def _has_hint_comment(sqlglot: ModuleType, sql: str) -> bool:
571
+ """Whether *sql* holds a hint or executable comment outside its strings and quoted names."""
572
+ if not _RX_HINT_COMMENT.search(sql):
573
+ return False
574
+ tokens = [(t.start, t.end) for t in sqlglot.tokenize(sql, read="postgres")]
575
+ return any(not any(start <= m.start() <= end for start, end in tokens) for m in _RX_HINT_COMMENT.finditer(sql))
576
+
577
+
578
+ def _as_written(source: str) -> str:
579
+ """A statement's source text to keep as written, without the space that followed the previous ``;``.
580
+
581
+ A statement that starts on its own line keeps that line's indentation.
582
+ """
583
+ source = source.rstrip()
584
+ body = source.lstrip()
585
+ lead = source[: len(source) - len(body)]
586
+ return source[lead.rfind("\n") + 1 :] if "\n" in lead else body
587
+
588
+
549
589
  def _sqlglot_format(
550
590
  sql_lines: list[str],
551
591
  sql_indent: int = 4,
@@ -596,9 +636,10 @@ def _sqlglot_format(
596
636
  # parse is left exactly as written.
597
637
  ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot_errors.ErrorLevel.RAISE)
598
638
  nodes = [node for node in ast if node is not None]
599
- sources = None if rewrite_sql else _statement_sources(sqlglot, protected)
600
- if sources is not None and len(sources) != len(nodes):
639
+ sources = _statement_sources(sqlglot, protected)
640
+ if len(sources) != len(nodes) and (not rewrite_sql or _has_hint_comment(sqlglot, protected)):
601
641
  return sql_lines
642
+ aligned = len(sources) == len(nodes)
602
643
  statements: list[str] = []
603
644
  for i, node in enumerate(nodes):
604
645
  # For Command nodes (psql backslash commands, ERROR:, etc.)
@@ -614,20 +655,29 @@ def _sqlglot_format(
614
655
  max_text_width=120,
615
656
  )
616
657
  generated = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", generated)
617
- if sources is not None and _layout_tokens(sqlglot, generated) != _layout_tokens(sqlglot, sources[i]):
618
- generated = sources[i].lstrip("\n").rstrip() # layout only: keep it as written
658
+ if aligned and (
659
+ _has_hint_comment(sqlglot, sources[i])
660
+ or (not rewrite_sql and _layout_tokens(sqlglot, generated) != _layout_tokens(sqlglot, sources[i]))
661
+ ):
662
+ generated = _as_written(sources[i])
619
663
  statements.append(generated)
620
664
  stmts = [s for s in statements if s]
621
665
  if not stmts:
622
666
  return sql_lines
623
667
 
668
+ # Terminate the last statement only if the source did: execsql refuses
669
+ # a script whose last statement is unfinished, and formatting must not
670
+ # turn it into one that runs.
671
+ terminated = _ends_with_semicolon(sqlglot, protected)
672
+
624
673
  # Safety: if sqlglot produced more statements than the input had
625
- # semicolons, it likely split a fragment (e.g. a SELECT column list)
626
- # into multiple pseudo-statements. Fall back to the original text.
627
- if len(stmts) > max(input_semis, 1):
674
+ # semicolons (plus an unterminated last one), it likely split a
675
+ # fragment (e.g. a SELECT column list) into multiple pseudo-statements.
676
+ # Fall back to the original text.
677
+ if len(stmts) > max(input_semis + (0 if terminated else 1), 1):
628
678
  return sql_lines
629
679
 
630
- joined = ";\n".join(stmts) + ";"
680
+ joined = ";\n".join(stmts) + (";" if terminated else "")
631
681
 
632
682
  # Content-loss check: sqlglot with IGNORE error level can silently
633
683
  # drop tokens it doesn't understand (e.g. ``ERROR: ...``). If the
execsql/importers/base.py CHANGED
@@ -16,15 +16,7 @@ from execsql.exceptions import ErrInfo
16
16
  from execsql.db.base import Database
17
17
  import execsql.state as _state
18
18
 
19
- __all__ = ["import_data_table", "refuse_existing_table", "table_exists_if_known"]
20
-
21
-
22
- def table_exists_if_known(db: Database, table_name: str, schema_name: str | None = None) -> bool | None:
23
- """Whether the table exists, or ``None`` when the database cannot say (no catalog access, ...)."""
24
- try:
25
- return db.table_exists(table_name, schema_name)
26
- except Exception:
27
- return None
19
+ __all__ = ["import_data_table", "refuse_existing_table"]
28
20
 
29
21
 
30
22
  def refuse_existing_table(db: Database, schema_name: str | None, table_name: str) -> None:
@@ -43,7 +35,11 @@ def refuse_existing_table(db: Database, schema_name: str | None, table_name: str
43
35
  lookup_schema = None
44
36
  if lookup_schema is None:
45
37
  return # An unqualified lookup would search every schema.
46
- if table_exists_if_known(db, table_name, lookup_schema):
38
+ try:
39
+ exists = db.table_exists_where_created(table_name, lookup_schema)
40
+ except Exception:
41
+ return # The database cannot say; CREATE TABLE reports any conflict.
42
+ if exists:
47
43
  name = db.schema_qualified_table_name(schema_name, table_name)
48
44
  raise ErrInfo(
49
45
  type="cmd",
execsql/lsp/document.py CHANGED
@@ -86,6 +86,8 @@ class ScriptIndex:
86
86
  # cannot make every request read a huge file or walk an endless include chain.
87
87
  MAX_INCLUDED_BYTES = 10 * 1024 * 1024
88
88
  MAX_INCLUDED_FILES = 200
89
+ # The files an INCLUDE names, all together; past it indexing follows no more of them.
90
+ MAX_INCLUDED_TOTAL_BYTES = 20 * 1024 * 1024
89
91
 
90
92
 
91
93
  def read_included(path: Path) -> str | None:
@@ -121,7 +123,7 @@ def index_script(
121
123
  prefers the editor's unsaved copy of a file that is open.
122
124
  """
123
125
  index = ScriptIndex(path=path)
124
- _index_into(index, source, path, set(), read, top=True)
126
+ _index_into(index, source, path, set(), read, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
125
127
  return index
126
128
 
127
129
 
@@ -133,6 +135,7 @@ def _index_into(
133
135
  read: Callable[[Path], str | None],
134
136
  *,
135
137
  top: bool,
138
+ budget: list[int],
136
139
  ) -> None:
137
140
  if path is not None:
138
141
  seen.add(str(Path(path).resolve()))
@@ -166,8 +169,9 @@ def _index_into(
166
169
  index.includes.append((Location(path, row, start, end), str(resolved)))
167
170
  if "!" not in target and str(resolved.resolve()) not in seen and len(seen) < MAX_INCLUDED_FILES:
168
171
  included = read(resolved)
169
- if included is not None:
170
- _index_into(index, included, str(resolved), seen, read, top=False)
172
+ if included is not None and len(included) <= budget[0]:
173
+ budget[0] -= len(included)
174
+ _index_into(index, included, str(resolved), seen, read, top=False, budget=budget)
171
175
 
172
176
  for row, text in enumerate(lines):
173
177
  for m in _RX_VARIABLE.finditer(text):
@@ -27,7 +27,7 @@ from pathlib import Path
27
27
  from typing import Any
28
28
 
29
29
  import execsql.state as _state
30
- from execsql.utils.errors import fatal_error
30
+ from execsql.utils.errors import fatal_error, install_hint
31
31
  from execsql.utils.fileio import EncodedFile, filewriter_open_as_new, filewriter_write
32
32
 
33
33
 
@@ -110,7 +110,7 @@ def x_debug_write_odbc_drivers(**kwargs: Any) -> None:
110
110
  try:
111
111
  import pyodbc
112
112
  except Exception:
113
- fatal_error("The pyodbc module is required.")
113
+ fatal_error(f"The pyodbc module is required to list ODBC drivers. {install_hint('odbc')}")
114
114
  output_dest = kwargs["filename"]
115
115
  append = kwargs["append"]
116
116
  if output_dest is not None and output_dest != "stdout" and append is None:
@@ -114,7 +114,7 @@ def x_copy(**kwargs: Any) -> None:
114
114
 
115
115
  if new_tbl2:
116
116
  try:
117
- hdrs, rows = db1.select_rowsource(select_stmt)
117
+ hdrs, rows = db1.select_rowsource(select_stmt, decode_bytes=False)
118
118
  except ErrInfo:
119
119
  raise
120
120
  except Exception as e:
@@ -142,7 +142,7 @@ def x_copy(**kwargs: Any) -> None:
142
142
  db2.execute("COMMIT;")
143
143
  else:
144
144
  try:
145
- hdrs, rows = db1.select_rowsource(select_stmt)
145
+ hdrs, rows = db1.select_rowsource(select_stmt, decode_bytes=False)
146
146
  except ErrInfo:
147
147
  raise
148
148
  except Exception as e:
@@ -153,7 +153,7 @@ def x_copy(**kwargs: Any) -> None:
153
153
  def get_ts() -> DataTable:
154
154
  nonlocal get_ts_tablespec
155
155
  if get_ts_tablespec is None:
156
- ts_hdrs, ts_rows = db1.select_rowsource(select_stmt)
156
+ ts_hdrs, ts_rows = db1.select_rowsource(select_stmt, decode_bytes=False)
157
157
  get_ts_tablespec = DataTable(ts_hdrs, ts_rows, infer_strings=False)
158
158
  return get_ts_tablespec
159
159
 
@@ -201,7 +201,7 @@ def x_copy_query(**kwargs: Any) -> None:
201
201
 
202
202
  if new_tbl2:
203
203
  try:
204
- hdrs, rows = db1.select_rowsource(select_stmt)
204
+ hdrs, rows = db1.select_rowsource(select_stmt, decode_bytes=False)
205
205
  except ErrInfo:
206
206
  raise
207
207
  except Exception as e:
@@ -229,7 +229,7 @@ def x_copy_query(**kwargs: Any) -> None:
229
229
  db2.execute("COMMIT;")
230
230
  else:
231
231
  try:
232
- hdrs, rows = db1.select_rowsource(select_stmt)
232
+ hdrs, rows = db1.select_rowsource(select_stmt, decode_bytes=False)
233
233
  except ErrInfo:
234
234
  raise
235
235
  except Exception as e:
@@ -240,7 +240,7 @@ def x_copy_query(**kwargs: Any) -> None:
240
240
  def get_ts() -> DataTable:
241
241
  nonlocal get_ts_tablespec
242
242
  if get_ts_tablespec is None:
243
- ts_hdrs, ts_rows = db1.select_rowsource(select_stmt)
243
+ ts_hdrs, ts_rows = db1.select_rowsource(select_stmt, decode_bytes=False)
244
244
  get_ts_tablespec = DataTable(ts_hdrs, ts_rows, infer_strings=False)
245
245
  return get_ts_tablespec
246
246
 
@@ -17,7 +17,7 @@ from typing import Any
17
17
  import execsql.state as _state
18
18
  from execsql.exceptions import ErrInfo
19
19
  from execsql.types import dbt_postgres
20
- from execsql.utils.errors import exception_desc
20
+ from execsql.utils.errors import exception_desc, install_hint
21
21
 
22
22
 
23
23
  # ---------------------------------------------------------------------------
@@ -213,10 +213,7 @@ def _require_pg_upsert() -> None:
213
213
  except ImportError as exc:
214
214
  raise ErrInfo(
215
215
  "exception",
216
- other_msg=(
217
- 'PG_UPSERT requires the pg-upsert package. Install the upsert extra: uv tool install "execsql2[upsert]"'
218
- ' (or pip install "execsql2[upsert]").'
219
- ),
216
+ other_msg=f"PG_UPSERT requires the pg-upsert package. {install_hint('upsert')}",
220
217
  ) from exc
221
218
 
222
219
 
execsql/models.py CHANGED
@@ -167,8 +167,16 @@ class Column:
167
167
  self.Accum(DT_Text()),
168
168
  self.Accum(DT_Binary()),
169
169
  )
170
+ if not infer_strings:
171
+ # Values from a database cursor: a bytes value is binary data, so
172
+ # Binary is tried before the text types (which accept anything).
173
+ text = [ac for ac in self.accums if isinstance(ac.dt, (DT_Character, DT_Varchar, DT_Text))]
174
+ binary = [ac for ac in self.accums if isinstance(ac.dt, DT_Binary)]
175
+ self.accums = tuple(ac for ac in self.accums if ac not in text and ac not in binary) + tuple(binary + text)
170
176
  self.infer_strings = infer_strings
171
177
  self.str_accums = tuple(ac for ac in self.accums if isinstance(ac.dt, (DT_Character, DT_Varchar, DT_Text)))
178
+ # A bytes value is checked as binary or text only: int(b"12") is 12.
179
+ self.bytes_accums = tuple(ac for ac in self.accums if isinstance(ac.dt, DT_Binary)) + self.str_accums
172
180
  self.dt_eval = False
173
181
  # self.dt is a tuple of: 0: column name; 1: data type class; 2: max length or None;
174
182
  # 3: bool indicating any null values; 4: precision or None; 5: scale or None.
@@ -198,6 +206,9 @@ class Column:
198
206
  if not self.infer_strings and isinstance(column_value, str):
199
207
  for dt in self.str_accums:
200
208
  dt.check(column_value)
209
+ elif not self.infer_strings and isinstance(column_value, (bytes, bytearray, memoryview)):
210
+ for dt in self.bytes_accums:
211
+ dt.check(column_value)
201
212
  else:
202
213
  for dt in self.accums:
203
214
  dt.check(column_value)
@@ -17,6 +17,42 @@ from execsql.exceptions import ErrInfo
17
17
  __all__ = ["CounterVars", "SubVarSet", "LocalSubVarSet", "ScriptArgSubVarSet"]
18
18
 
19
19
 
20
+ def _after_escape_string_prefix(command: str, start: int) -> bool:
21
+ """Whether the ``!'!`` token at *start* follows an ``E`` that makes it a PostgreSQL escape string (``E!'!v!'!``).
22
+
23
+ The ``E`` must stand alone: in ``TYPE!'!v!'!`` it ends a word.
24
+ """
25
+ if start < 1 or command[start - 1] not in "Ee":
26
+ return False
27
+ return start < 2 or not (command[start - 2].isalnum() or command[start - 2] == "_")
28
+
29
+
30
+ def _sql_string_literal(value: str, *, escape_string: bool = False) -> str:
31
+ """*value* as the string literal ``!'!var!'!`` substitutes, in the current connection's form.
32
+
33
+ An apostrophe is doubled everywhere; a backslash only where the DBMS
34
+ reads it as an escape (MySQL/MariaDB), or inside an escape string the
35
+ script wrote as ``E!'!var!'!`` (*escape_string*), so ``C:\\data`` is
36
+ stored as written in every case. Without a connection, the standard SQL
37
+ form. (Upstream doubled backslashes only on Windows, to protect
38
+ ``re.sub``'s replacement template, not the SQL.)
39
+ """
40
+ if escape_string:
41
+ return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"
42
+ db = None
43
+ try:
44
+ import execsql.state as _state
45
+
46
+ dbs = _state.dbs
47
+ if dbs is not None and dbs.current_db is not None:
48
+ db = dbs.current()
49
+ except Exception:
50
+ db = None
51
+ if db is not None:
52
+ return db.quote_literal(value)
53
+ return "'" + value.replace("'", "''") + "'"
54
+
55
+
20
56
  # ---------------------------------------------------------------------------
21
57
  # CounterVars
22
58
  # ---------------------------------------------------------------------------
@@ -253,13 +289,10 @@ class SubVarSet:
253
289
  )
254
290
  quote = m.group("q")
255
291
  if quote == "'":
256
- # B07a/F002: wrap in single quotes, doubling embedded
257
- # apostrophes AND escaping backslashes so MySQL default
258
- # mode and PostgreSQL E-string literals can't end the
259
- # quoted region via ``\'``. The previous Windows-only
260
- # branch (``os.name != 'posix'``) applied the escape on
261
- # the wrong axis — host OS vs target DBMS.
262
- sub = "'" + sub.replace("\\", "\\\\").replace("'", "''") + "'"
292
+ sub = _sql_string_literal(
293
+ sub,
294
+ escape_string=_after_escape_string_prefix(command_str, m.start()),
295
+ )
263
296
  elif quote == '"':
264
297
  # B07a/F001: wrap in double quotes, doubling embedded
265
298
  # ``"`` so a value containing ``"; DROP TABLE x; --``
@@ -306,13 +339,12 @@ class SubVarSet:
306
339
  if idx != -1:
307
340
  _check_nul(varname, sub)
308
341
  return command_str[:idx] + sub + command_str[idx + len(token) :], True
309
- # Single-quote-wrapped token: !'!varname!'! — escape ``\`` and
310
- # double embedded ``'`` (see substitute_one for rationale).
342
+ # Single-quote-wrapped token: !'!varname!'! — a string literal.
311
343
  tokenq = f"!'!{varname}!'!"
312
344
  idxq = cmd_lower.find(tokenq)
313
345
  if idxq != -1:
314
346
  _check_nul(varname, sub)
315
- wrapped = "'" + sub.replace("\\", "\\\\").replace("'", "''") + "'"
347
+ wrapped = _sql_string_literal(sub, escape_string=_after_escape_string_prefix(command_str, idxq))
316
348
  return (
317
349
  command_str[:idxq] + wrapped + command_str[idxq + len(tokenq) :],
318
350
  True,
execsql/types.py CHANGED
@@ -884,6 +884,14 @@ dbt_oracle.name_datatype(DT_TimestampTZ, "TIMESTAMP WITH TIME ZONE")
884
884
  dbt_oracle.name_datatype(DT_Timestamp, "TIMESTAMP", casting_name="TIMESTAMP")
885
885
  dbt_oracle.name_datatype(DT_Date, "DATE", casting_name="DATE")
886
886
  dbt_oracle.name_datatype(DT_Time_Oracle, "VARCHAR2", True, casting_name="VARCHAR(20)")
887
+ # Oracle has no time-of-day type, and oracledb cannot bind a datetime.time: a
888
+ # time column is stored as its ISO text (at most 15 characters, HH:MM:SS.ffffff).
889
+ dbt_oracle.name_datatype(
890
+ DT_Time,
891
+ "VARCHAR2(15)",
892
+ casting_name="VARCHAR(20)",
893
+ conv_mod_fn=lambda x: x.isoformat() if x is not None else None,
894
+ )
887
895
  dbt_oracle.name_datatype(DT_Integer, "NUMBER")
888
896
  dbt_oracle.name_datatype(DT_Long, "NUMBER")
889
897
  dbt_oracle.name_datatype(DT_Float, "FLOAT")
execsql/utils/errors.py CHANGED
@@ -37,8 +37,24 @@ __all__ = [
37
37
  "stamp_errinfo",
38
38
  "write_warning",
39
39
  "file_size_date",
40
+ "install_hint",
40
41
  ]
41
42
 
43
+ _ADDING_AN_EXTRA = "https://execsql2.readthedocs.io/en/latest/getting-started/installation/#adding-an-extra-later"
44
+
45
+
46
+ def install_hint(extra: str) -> str:
47
+ """How to install the execsql2 *extra*, for a message about a missing optional package.
48
+
49
+ ``uv tool install`` and ``pipx install --force`` replace an install's
50
+ extras with the ones named, so the hint says to keep those already there.
51
+ """
52
+ return (
53
+ f'Install the {extra} extra along with the extras you already have: uv tool install "execsql2[...,{extra}]" '
54
+ f'or pipx install --force "execsql2[...,{extra}]" (in a project environment: pip install "execsql2[{extra}]"). '
55
+ f"See {_ADDING_AN_EXTRA}"
56
+ )
57
+
42
58
 
43
59
  def exception_info() -> tuple:
44
60
  # Returns the exception type, value, source file name, source line number, and source line text.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: execsql2
3
- Version: 2.27.0
3
+ Version: 2.29.0
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
@@ -95,8 +95,10 @@ Requires-Dist: openpyxl>=3.1; extra == 'dev'
95
95
  Requires-Dist: polars>=1.0; extra == 'dev'
96
96
  Requires-Dist: pre-commit>=3.5.0; extra == 'dev'
97
97
  Requires-Dist: pygls<3,>=2.1; extra == 'dev'
98
+ Requires-Dist: pytest-asyncio>=1.0; extra == 'dev'
98
99
  Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
99
100
  Requires-Dist: pytest-lsp>=1.0; extra == 'dev'
101
+ Requires-Dist: pytest>=8; extra == 'dev'
100
102
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
101
103
  Requires-Dist: ruff>=0.4; extra == 'dev'
102
104
  Requires-Dist: sqlglot<30.22,>=30.21.0; extra == 'dev'
@@ -464,7 +466,7 @@ execsql format --no-sql --in-place scripts/
464
466
  ```yaml
465
467
  repos:
466
468
  - repo: https://github.com/geocoug/execsql
467
- rev: v2.27.0
469
+ rev: v2.29.0
468
470
  hooks:
469
471
  - id: execsql-format
470
472
  - id: execsql-lint