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.
- execsql/cli/commands/format.py +2 -1
- execsql/cli/commands/lsp.py +33 -9
- execsql/cli/lint.py +45 -22
- execsql/db/access.py +4 -4
- execsql/db/base.py +23 -12
- execsql/db/dsn.py +2 -2
- execsql/db/duckdb.py +14 -2
- execsql/db/firebird.py +2 -3
- execsql/db/mysql.py +21 -5
- execsql/db/oracle.py +5 -5
- execsql/db/postgres.py +3 -4
- execsql/db/sqlserver.py +2 -2
- execsql/exporters/duckdb.py +2 -2
- execsql/exporters/ods.py +2 -2
- execsql/exporters/templates.py +2 -2
- execsql/exporters/xls.py +6 -5
- execsql/exporters/xlsx.py +2 -2
- execsql/exporters/yaml.py +3 -4
- execsql/format.py +71 -21
- execsql/importers/base.py +6 -10
- execsql/lsp/document.py +7 -3
- execsql/metacommands/debug.py +2 -2
- execsql/metacommands/io_fileops.py +6 -6
- execsql/metacommands/upsert.py +2 -5
- execsql/models.py +11 -0
- execsql/script/variables.py +42 -10
- execsql/types.py +8 -0
- execsql/utils/errors.py +16 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/METADATA +4 -2
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/RECORD +48 -48
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/WHEEL +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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 (
|
|
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=
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
|
536
|
-
"""
|
|
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
|
|
539
|
-
compared without case; strings and quoted
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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 =
|
|
600
|
-
if
|
|
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
|
|
618
|
-
|
|
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
|
|
626
|
-
# into multiple pseudo-statements.
|
|
627
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
execsql/metacommands/debug.py
CHANGED
|
@@ -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
|
|
execsql/metacommands/upsert.py
CHANGED
|
@@ -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)
|
execsql/script/variables.py
CHANGED
|
@@ -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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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!'! —
|
|
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 =
|
|
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.
|
|
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.
|
|
469
|
+
rev: v2.29.0
|
|
468
470
|
hooks:
|
|
469
471
|
- id: execsql-format
|
|
470
472
|
- id: execsql-lint
|