execsql2 2.29.0__py3-none-any.whl → 2.31.1__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/__main__.py +4 -5
- execsql/api.py +6 -0
- execsql/cli/__init__.py +3 -14
- execsql/cli/application.py +6 -4
- execsql/cli/commands/lint.py +5 -2
- execsql/cli/commands/lsp.py +42 -0
- execsql/cli/dsn.py +38 -2
- execsql/cli/lint.py +24 -7
- execsql/cli/run.py +22 -9
- execsql/config.py +58 -10
- execsql/data/execsql.conf.template +8 -2
- execsql/db/access.py +7 -5
- execsql/db/base.py +18 -0
- execsql/db/duckdb.py +96 -14
- execsql/db/factory.py +11 -1
- execsql/db/mysql.py +56 -3
- execsql/db/odbc.py +45 -0
- execsql/db/postgres.py +1 -1
- execsql/db/sqlserver.py +37 -21
- execsql/exporters/ods.py +146 -41
- execsql/exporters/values.py +166 -9
- execsql/gui/__init__.py +3 -31
- execsql/importers/base.py +1 -1
- execsql/importers/csv.py +1 -1
- execsql/importers/ods.py +2 -0
- execsql/lsp/_util.py +21 -0
- execsql/lsp/cache.py +73 -0
- execsql/lsp/code_actions.py +10 -14
- execsql/lsp/completion.py +8 -15
- execsql/lsp/diagnostics.py +15 -3
- execsql/lsp/document.py +142 -41
- execsql/lsp/hover.py +5 -13
- execsql/lsp/navigation.py +7 -10
- execsql/lsp/server.py +111 -29
- execsql/manifest.py +29 -9
- execsql/metacommands/__init__.py +0 -2
- execsql/metacommands/io_fileops.py +16 -5
- execsql/metacommands/reference.py +1 -1
- execsql/metacommands/upsert.py +10 -1
- execsql/script/executor.py +2 -21
- execsql/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/utils/auth.py +21 -14
- execsql/utils/errors.py +20 -37
- execsql/utils/fileio.py +44 -48
- execsql/utils/paths.py +31 -0
- execsql/utils/regex.py +0 -5
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/README.md +9 -8
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_compare.sql +53 -29
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_glossary.sql +20 -20
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_upsert.sql +59 -66
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_compare.sql +19 -22
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_glossary.sql +13 -17
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/script_template.sql +302 -299
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_compare.sql +39 -29
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_glossary.sql +13 -17
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_upsert.sql +56 -66
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/METADATA +17 -43
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/RECORD +66 -63
- execsql2-2.29.0.data/data/execsql2_extras/pg_upsert.sql +0 -2791
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/WHEEL +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/NOTICE +0 -0
execsql/db/access.py
CHANGED
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Any, cast
|
|
16
16
|
|
|
17
17
|
from execsql.db.base import Database, _fetched
|
|
18
|
+
from execsql.db.odbc import odbc_value
|
|
18
19
|
from execsql.db.tiers import SupportTier
|
|
19
20
|
from execsql.exceptions import ErrInfo
|
|
20
21
|
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
@@ -119,10 +120,11 @@ class AccessDatabase(Database):
|
|
|
119
120
|
def _try_odbc_drivers():
|
|
120
121
|
db_name = str(Path(self.db_name).resolve())
|
|
121
122
|
for cs, jet4flag in self.connection_strings:
|
|
123
|
+
connstr = cs % odbc_value(db_name)
|
|
124
|
+
logstr = connstr
|
|
122
125
|
if self.need_passwd:
|
|
123
|
-
connstr
|
|
124
|
-
|
|
125
|
-
connstr = cs % db_name
|
|
126
|
+
connstr += f" Uid={odbc_value(self.user)}; Pwd={odbc_value(self.password)};"
|
|
127
|
+
logstr += f" Uid={odbc_value(self.user)}; Pwd=***;"
|
|
126
128
|
try:
|
|
127
129
|
# Access is file-based but a UNC path on an
|
|
128
130
|
# unreachable share can still block forever; cap
|
|
@@ -133,12 +135,12 @@ class AccessDatabase(Database):
|
|
|
133
135
|
errors.append(e)
|
|
134
136
|
if _state.exec_log is not None:
|
|
135
137
|
_state.exec_log.log_status_info(
|
|
136
|
-
f"Could not connect via ODBC using: {
|
|
138
|
+
f"Could not connect via ODBC using: {logstr}",
|
|
137
139
|
)
|
|
138
140
|
else:
|
|
139
141
|
if _state.exec_log is not None:
|
|
140
142
|
_state.exec_log.log_status_info(
|
|
141
|
-
f"Connected via ODBC using: {
|
|
143
|
+
f"Connected via ODBC using: {logstr}",
|
|
142
144
|
)
|
|
143
145
|
self.jet4 = jet4flag
|
|
144
146
|
return True
|
execsql/db/base.py
CHANGED
|
@@ -641,6 +641,24 @@ class Database(ABC):
|
|
|
641
641
|
|
|
642
642
|
raise DatabaseNotImplementedError(self.name(), "role_exists")
|
|
643
643
|
|
|
644
|
+
def drop_table_for_replacement(self, schema_name: str | None, table_name: str) -> None:
|
|
645
|
+
"""Drop the table a ``TO REPLACEMENT`` import or copy is about to recreate, if it is there.
|
|
646
|
+
|
|
647
|
+
Not every DBMS has ``DROP TABLE IF EXISTS``: SQL Server, Oracle,
|
|
648
|
+
Firebird and Access refuse to drop a table that does not exist. The
|
|
649
|
+
drop is tried first, and its error is ignored only when the table is
|
|
650
|
+
confirmed absent, so a drop that would have succeeded always runs.
|
|
651
|
+
"""
|
|
652
|
+
try:
|
|
653
|
+
self.drop_table(self.schema_qualified_table_name(schema_name, table_name))
|
|
654
|
+
except Exception:
|
|
655
|
+
try:
|
|
656
|
+
there = self.table_exists_where_created(table_name, schema_name)
|
|
657
|
+
except Exception:
|
|
658
|
+
there = True
|
|
659
|
+
if there:
|
|
660
|
+
raise
|
|
661
|
+
|
|
644
662
|
def drop_table(self, tablename: str) -> None:
|
|
645
663
|
"""Drop *tablename* if it exists; *tablename* must already be schema-qualified and quoted."""
|
|
646
664
|
# The 'tablename' argument should be schema-qualified and quoted as necessary.
|
execsql/db/duckdb.py
CHANGED
|
@@ -9,6 +9,7 @@ on the CLI.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
|
+
from collections import deque
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -28,21 +29,83 @@ _TRANSACTION_RX = re.compile(r"(?P<begin>BEGIN|START\s+TRANSACTION)\b|(?P<end>CO
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
class _SharedCursor:
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
A DuckDB cursor is a separate connection
|
|
34
|
-
|
|
35
|
-
the connection
|
|
32
|
+
"""A cursor that runs on the database's one connection.
|
|
33
|
+
|
|
34
|
+
A DuckDB cursor is a separate connection: a temporary table created on
|
|
35
|
+
one is invisible to the next, and a cursor's transaction is its own. So
|
|
36
|
+
every statement runs on the connection itself. A connection holds one
|
|
37
|
+
pending result, though, and the next statement discards it; COPY reads
|
|
38
|
+
its source twice at once (the rows, and a scan for their types). So
|
|
39
|
+
before another statement runs, the result this cursor still has to
|
|
40
|
+
deliver is read into memory (:meth:`DuckDBDatabase._take_connection`).
|
|
41
|
+
An EXPORT, which runs nothing else while it reads, still streams.
|
|
36
42
|
"""
|
|
37
43
|
|
|
38
|
-
def __init__(self,
|
|
39
|
-
self.
|
|
44
|
+
def __init__(self, db: DuckDBDatabase) -> None:
|
|
45
|
+
self._db = db
|
|
46
|
+
self._conn = db.conn
|
|
47
|
+
self._buffer: deque[Any] | None = None # the rest of the result, once read ahead
|
|
48
|
+
self._pending = False # a result the connection still holds for this cursor
|
|
49
|
+
self.description: Any = None
|
|
50
|
+
self.arraysize = 1000
|
|
40
51
|
|
|
41
52
|
def __getattr__(self, name: str) -> Any:
|
|
42
53
|
return getattr(self._conn, name)
|
|
43
54
|
|
|
55
|
+
def execute(self, sql: Any, params: Any = None) -> _SharedCursor:
|
|
56
|
+
self._db._take_connection(self)
|
|
57
|
+
if params is None:
|
|
58
|
+
self._conn.execute(sql)
|
|
59
|
+
else:
|
|
60
|
+
self._conn.execute(sql, params)
|
|
61
|
+
self.description = self._conn.description
|
|
62
|
+
self._buffer = None
|
|
63
|
+
self._pending = self.description is not None
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def executemany(self, sql: Any, params: Any) -> _SharedCursor:
|
|
67
|
+
self._db._take_connection(self)
|
|
68
|
+
self._conn.executemany(sql, params)
|
|
69
|
+
self.description = self._conn.description
|
|
70
|
+
self._buffer = None
|
|
71
|
+
self._pending = False
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def read_ahead(self) -> None:
|
|
75
|
+
"""Move the rest of the pending result into memory, freeing the connection."""
|
|
76
|
+
if self._pending:
|
|
77
|
+
self._buffer = deque(self._conn.fetchall())
|
|
78
|
+
self._pending = False
|
|
79
|
+
|
|
80
|
+
def fetchmany(self, size: int | None = None) -> list[Any]:
|
|
81
|
+
size = size or self.arraysize
|
|
82
|
+
if self._buffer is not None:
|
|
83
|
+
return [self._buffer.popleft() for _ in range(min(size, len(self._buffer)))]
|
|
84
|
+
if not self._pending:
|
|
85
|
+
return []
|
|
86
|
+
rows: list[Any] = self._conn.fetchmany(size)
|
|
87
|
+
if not rows:
|
|
88
|
+
self._pending = False
|
|
89
|
+
return rows
|
|
90
|
+
|
|
91
|
+
def fetchone(self) -> Any:
|
|
92
|
+
rows = self.fetchmany(1)
|
|
93
|
+
return rows[0] if rows else None
|
|
94
|
+
|
|
95
|
+
def fetchall(self) -> list[Any]:
|
|
96
|
+
if self._buffer is not None:
|
|
97
|
+
rows, self._buffer = list(self._buffer), deque()
|
|
98
|
+
return rows
|
|
99
|
+
if not self._pending:
|
|
100
|
+
return []
|
|
101
|
+
self._pending = False
|
|
102
|
+
return list(self._conn.fetchall())
|
|
103
|
+
|
|
44
104
|
def close(self) -> None:
|
|
45
|
-
|
|
105
|
+
"""Leave the connection, and any transaction on it, open."""
|
|
106
|
+
self._buffer = None
|
|
107
|
+
self._pending = False
|
|
108
|
+
self._db._release_connection(self)
|
|
46
109
|
|
|
47
110
|
|
|
48
111
|
class DuckDBDatabase(Database):
|
|
@@ -51,12 +114,15 @@ class DuckDBDatabase(Database):
|
|
|
51
114
|
The duckdb driver commits each statement unless a transaction was begun,
|
|
52
115
|
and has no way to ask whether one is open, so this adapter begins one
|
|
53
116
|
itself while statements are held (AUTOCOMMIT OFF, BEGIN BATCH) and keeps
|
|
54
|
-
track of it.
|
|
55
|
-
|
|
117
|
+
track of it. Every statement runs on the connection itself rather than
|
|
118
|
+
on a cursor (see :class:`_SharedCursor`), so it joins that transaction
|
|
119
|
+
and sees the run's temporary tables.
|
|
56
120
|
"""
|
|
57
121
|
|
|
58
122
|
#: A transaction this adapter or a script's own BEGIN opened, not yet ended.
|
|
59
123
|
in_transaction: bool = False
|
|
124
|
+
#: The cursor whose result the connection holds, if any.
|
|
125
|
+
_holder: _SharedCursor | None = None
|
|
60
126
|
|
|
61
127
|
def __init__(self, DuckDB_fn: str) -> None:
|
|
62
128
|
try:
|
|
@@ -83,12 +149,21 @@ class DuckDBDatabase(Database):
|
|
|
83
149
|
return f"DuckDBDatabase({self.db_name!r})"
|
|
84
150
|
|
|
85
151
|
def cursor(self) -> Any:
|
|
86
|
-
"""A cursor
|
|
152
|
+
"""A cursor on the database's one connection (see :class:`_SharedCursor`)."""
|
|
87
153
|
if self.conn is None:
|
|
88
154
|
self.open_db()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
155
|
+
return _SharedCursor(self)
|
|
156
|
+
|
|
157
|
+
def _take_connection(self, cursor: _SharedCursor | None = None) -> None:
|
|
158
|
+
"""Before a statement runs, read ahead the result another cursor has not finished with."""
|
|
159
|
+
holder = self._holder
|
|
160
|
+
if holder is not None and holder is not cursor:
|
|
161
|
+
holder.read_ahead()
|
|
162
|
+
self._holder = cursor
|
|
163
|
+
|
|
164
|
+
def _release_connection(self, cursor: _SharedCursor) -> None:
|
|
165
|
+
if self._holder is cursor:
|
|
166
|
+
self._holder = None
|
|
92
167
|
|
|
93
168
|
def begin_transaction(self) -> None:
|
|
94
169
|
"""Open a transaction if none is open."""
|
|
@@ -96,6 +171,7 @@ class DuckDBDatabase(Database):
|
|
|
96
171
|
self.open_db()
|
|
97
172
|
if not self.in_transaction:
|
|
98
173
|
# A second BEGIN would abort the open transaction, hence the tracking.
|
|
174
|
+
self._take_connection()
|
|
99
175
|
self.conn.execute("BEGIN TRANSACTION")
|
|
100
176
|
self.in_transaction = True
|
|
101
177
|
|
|
@@ -124,17 +200,23 @@ class DuckDBDatabase(Database):
|
|
|
124
200
|
|
|
125
201
|
def commit(self) -> None:
|
|
126
202
|
"""Commit the open transaction if autocommit is enabled."""
|
|
203
|
+
self._take_connection()
|
|
127
204
|
super().commit()
|
|
128
205
|
if self.autocommit:
|
|
129
206
|
self.in_transaction = False
|
|
130
207
|
|
|
131
208
|
def rollback(self) -> None:
|
|
132
209
|
"""Roll back the open transaction."""
|
|
210
|
+
try:
|
|
211
|
+
self._take_connection()
|
|
212
|
+
except Exception: # best effort, as the rollback is: a failed transaction may not give up its rows
|
|
213
|
+
self._holder = None
|
|
133
214
|
super().rollback()
|
|
134
215
|
self.in_transaction = False
|
|
135
216
|
|
|
136
217
|
def close(self) -> None:
|
|
137
218
|
"""Close the connection; an open transaction is rolled back."""
|
|
219
|
+
self._holder = None
|
|
138
220
|
super().close()
|
|
139
221
|
self.in_transaction = False
|
|
140
222
|
|
execsql/db/factory.py
CHANGED
|
@@ -106,9 +106,19 @@ def db_SqlServer(
|
|
|
106
106
|
port: int | None = None,
|
|
107
107
|
encoding: str | None = None,
|
|
108
108
|
password: str | None = None,
|
|
109
|
+
trust_server_certificate: bool | None = None,
|
|
109
110
|
) -> SqlServerDatabase:
|
|
110
111
|
"""Open a Microsoft SQL Server connection via pyodbc."""
|
|
111
|
-
return SqlServerDatabase(
|
|
112
|
+
return SqlServerDatabase(
|
|
113
|
+
server_name,
|
|
114
|
+
database_name,
|
|
115
|
+
user,
|
|
116
|
+
pw_needed,
|
|
117
|
+
port,
|
|
118
|
+
encoding,
|
|
119
|
+
password=password,
|
|
120
|
+
trust_server_certificate=trust_server_certificate,
|
|
121
|
+
)
|
|
112
122
|
|
|
113
123
|
|
|
114
124
|
def db_MySQL(
|
execsql/db/mysql.py
CHANGED
|
@@ -7,7 +7,9 @@ Implements :class:`MySQLDatabase`, which connects to MySQL and MariaDB
|
|
|
7
7
|
servers via ``pymysql``. Corresponds to ``-t m`` on the CLI.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import re
|
|
12
|
+
import tempfile
|
|
11
13
|
from typing import Any, cast
|
|
12
14
|
|
|
13
15
|
from execsql.db.base import Database
|
|
@@ -53,6 +55,49 @@ def _crlf_terminated(filename: str) -> bool:
|
|
|
53
55
|
return i > 0 and chunk[i - 1 : i] == b"\r"
|
|
54
56
|
|
|
55
57
|
|
|
58
|
+
def _data_length(filename: str) -> int | None:
|
|
59
|
+
"""Return the file's length without its trailing blank lines, or None if it has none.
|
|
60
|
+
|
|
61
|
+
LOAD DATA reads each blank line at the end of a file as a row of NULLs;
|
|
62
|
+
every other import path drops them. The file is read backwards from the
|
|
63
|
+
end, as bytes, so no decoding can hide a line ending.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
with open(filename, "rb") as f:
|
|
67
|
+
size = f.seek(0, 2)
|
|
68
|
+
pos = size
|
|
69
|
+
content_end = 0
|
|
70
|
+
while pos > 0:
|
|
71
|
+
start = max(0, pos - 65536)
|
|
72
|
+
f.seek(start)
|
|
73
|
+
block = f.read(pos - start)
|
|
74
|
+
stripped = len(block.rstrip(b"\r\n"))
|
|
75
|
+
if stripped:
|
|
76
|
+
content_end = start + stripped
|
|
77
|
+
break
|
|
78
|
+
pos = start
|
|
79
|
+
f.seek(content_end)
|
|
80
|
+
ending = f.read(2)
|
|
81
|
+
except OSError:
|
|
82
|
+
return None
|
|
83
|
+
# Keep the line ending of the last line of data.
|
|
84
|
+
keep = content_end + (2 if ending == b"\r\n" else 1 if ending[:1] in (b"\r", b"\n") else 0)
|
|
85
|
+
return keep if keep < size else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _copy_head(filename: str, length: int) -> str:
|
|
89
|
+
"""Copy the first *length* bytes of *filename* to a temporary file; return its path."""
|
|
90
|
+
with open(filename, "rb") as src, tempfile.NamedTemporaryFile("wb", suffix=".csv", delete=False) as dst:
|
|
91
|
+
remaining = length
|
|
92
|
+
while remaining > 0:
|
|
93
|
+
chunk = src.read(min(remaining, 1 << 20))
|
|
94
|
+
if not chunk:
|
|
95
|
+
break
|
|
96
|
+
dst.write(chunk)
|
|
97
|
+
remaining -= len(chunk)
|
|
98
|
+
return dst.name
|
|
99
|
+
|
|
100
|
+
|
|
56
101
|
class MySQLDatabase(Database):
|
|
57
102
|
"""MySQL and MariaDB adapter using the pymysql package."""
|
|
58
103
|
|
|
@@ -353,7 +398,11 @@ class MySQLDatabase(Database):
|
|
|
353
398
|
# String literals in this session treat a backslash as an escape,
|
|
354
399
|
# so the file name, delimiter and quote character are quoted with
|
|
355
400
|
# quote_literal, which doubles it.
|
|
356
|
-
|
|
401
|
+
load_file = csv_file_obj.csvfname
|
|
402
|
+
data_length = _data_length(load_file)
|
|
403
|
+
if data_length is not None:
|
|
404
|
+
load_file = _copy_head(load_file, data_length)
|
|
405
|
+
import_sql = f"load data local infile {self.quote_literal(load_file)} into table {sq_name}"
|
|
357
406
|
if csv_file_obj.encoding:
|
|
358
407
|
charset = _PYTHON_TO_MYSQL_CHARSET.get(csv_file_obj.encoding.lower(), csv_file_obj.encoding)
|
|
359
408
|
import_sql = f"{import_sql} character set {charset}"
|
|
@@ -385,7 +434,11 @@ class MySQLDatabase(Database):
|
|
|
385
434
|
_state.exec_log.log_status_info(
|
|
386
435
|
f"IMPORTing {csv_file_obj.csvfname} using the DBMS' fast file reading routine",
|
|
387
436
|
)
|
|
388
|
-
|
|
437
|
+
try:
|
|
438
|
+
self.execute(import_sql)
|
|
439
|
+
finally:
|
|
440
|
+
if load_file != csv_file_obj.csvfname:
|
|
441
|
+
os.unlink(load_file)
|
|
389
442
|
else:
|
|
390
443
|
data_indexes = [csv_file_cols.index(col) for col in import_cols]
|
|
391
444
|
paramspec = ",".join(["%s"] * len(import_cols))
|
|
@@ -450,7 +503,7 @@ class MySQLDatabase(Database):
|
|
|
450
503
|
if not _state.conf.empty_strings and line[i].strip() == "":
|
|
451
504
|
line[i] = None
|
|
452
505
|
# Pad short line with nulls
|
|
453
|
-
line.extend([None] * (len(
|
|
506
|
+
line.extend([None] * (len(csv_file_cols) - len(line)))
|
|
454
507
|
linedata = [line[ix] for ix in data_indexes]
|
|
455
508
|
add_line = True
|
|
456
509
|
if not _state.conf.empty_rows:
|
execsql/db/odbc.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ODBC connection strings for the SQL Server and Access adapters.
|
|
3
|
+
|
|
4
|
+
An ODBC connection string is a ``;``-separated list of ``KEY=value``
|
|
5
|
+
attributes, so a value containing ``;`` (a password, a file path, a name
|
|
6
|
+
taken from a substitution variable) would end its attribute early and
|
|
7
|
+
start another. :func:`odbc_value` brace-quotes such values;
|
|
8
|
+
:func:`connection_string` joins the attributes, and :func:`loggable`
|
|
9
|
+
gives the same string with the password masked for the log.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__all__ = ["connection_string", "loggable", "odbc_value"]
|
|
15
|
+
|
|
16
|
+
_SECRET_KEYS = frozenset({"pwd", "password"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def odbc_value(value: object) -> str:
|
|
20
|
+
"""Return *value* as an ODBC attribute value, brace-quoted when it needs it.
|
|
21
|
+
|
|
22
|
+
A ``;`` ends an attribute and a leading ``{`` opens a quoted value, so a
|
|
23
|
+
value with either is wrapped in ``{…}`` with each ``}`` doubled. Other
|
|
24
|
+
values are written as they are, because older drivers do not all accept
|
|
25
|
+
braces around every attribute.
|
|
26
|
+
"""
|
|
27
|
+
text = str(value)
|
|
28
|
+
if ";" in text or text.startswith("{"):
|
|
29
|
+
return "{" + text.replace("}", "}}") + "}"
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def connection_string(driver: str, attributes: list[tuple[str, object]]) -> str:
|
|
34
|
+
"""Return ``DRIVER={driver};KEY=value;…`` with every value quoted as needed."""
|
|
35
|
+
parts = [f"DRIVER={{{driver}}}"]
|
|
36
|
+
parts.extend(f"{key}={odbc_value(value)}" for key, value in attributes)
|
|
37
|
+
return ";".join(parts) + ";"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def loggable(driver: str, attributes: list[tuple[str, object]]) -> str:
|
|
41
|
+
"""Return the connection string with the password replaced by ``***``."""
|
|
42
|
+
return connection_string(
|
|
43
|
+
driver,
|
|
44
|
+
[(key, "***" if key.lower() in _SECRET_KEYS else value) for key, value in attributes],
|
|
45
|
+
)
|
execsql/db/postgres.py
CHANGED
|
@@ -462,7 +462,7 @@ class PostgresDatabase(Database):
|
|
|
462
462
|
if not _state.conf.empty_strings and line[i].strip() == "":
|
|
463
463
|
line[i] = None
|
|
464
464
|
# Pad short line with nulls
|
|
465
|
-
line.extend([None] * (len(
|
|
465
|
+
line.extend([None] * (len(csv_file_cols) - len(line)))
|
|
466
466
|
linedata = [line[ix] for ix in data_indexes]
|
|
467
467
|
add_line = True
|
|
468
468
|
if not _state.conf.empty_rows:
|
execsql/db/sqlserver.py
CHANGED
|
@@ -7,9 +7,8 @@ Implements :class:`SqlServerDatabase`, which connects to Microsoft SQL
|
|
|
7
7
|
Server via ``pyodbc``. Corresponds to ``-t s`` on the CLI.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
import re
|
|
11
|
-
|
|
12
10
|
from execsql.db.base import Database
|
|
11
|
+
from execsql.db.odbc import connection_string, loggable
|
|
13
12
|
from execsql.exceptions import ErrInfo
|
|
14
13
|
from execsql.utils.errors import fatal_error, install_hint
|
|
15
14
|
from execsql.utils.auth import (
|
|
@@ -34,9 +33,10 @@ class SqlServerDatabase(Database):
|
|
|
34
33
|
db_name: str,
|
|
35
34
|
user_name: str | None,
|
|
36
35
|
need_passwd: bool = False,
|
|
37
|
-
port: int | None =
|
|
36
|
+
port: int | None = None,
|
|
38
37
|
encoding: str | None = "latin1",
|
|
39
38
|
password: str | None = None,
|
|
39
|
+
trust_server_certificate: bool | None = None,
|
|
40
40
|
) -> None:
|
|
41
41
|
try:
|
|
42
42
|
import pyodbc # noqa: F401
|
|
@@ -51,6 +51,15 @@ class SqlServerDatabase(Database):
|
|
|
51
51
|
need_passwd=need_passwd,
|
|
52
52
|
port=port if port else 1433,
|
|
53
53
|
)
|
|
54
|
+
# Only a port that was asked for goes into the connection string: a
|
|
55
|
+
# named instance (``host\instance``) is found through SQL Server
|
|
56
|
+
# Browser, which an explicit port bypasses.
|
|
57
|
+
self.explicit_port = port or None
|
|
58
|
+
# None: the [connect] trust_server_certificate setting, which also
|
|
59
|
+
# covers connections a script opens with CONNECT.
|
|
60
|
+
if trust_server_certificate is None:
|
|
61
|
+
trust_server_certificate = bool(getattr(_state.conf, "trust_server_certificate", False))
|
|
62
|
+
self.trust_server_certificate = trust_server_certificate
|
|
54
63
|
self.type = dbt_sqlserver
|
|
55
64
|
self.password = password
|
|
56
65
|
self.encoding = encoding or "latin1" # Default on installation of SQL Server
|
|
@@ -79,6 +88,12 @@ class SqlServerDatabase(Database):
|
|
|
79
88
|
"""
|
|
80
89
|
return "[" + identifier.replace("]", "]]") + "]"
|
|
81
90
|
|
|
91
|
+
def _server_attribute(self) -> str:
|
|
92
|
+
"""Return the ``SERVER`` value: ``host,port`` when a port was given."""
|
|
93
|
+
if self.explicit_port and "," not in self.server_name:
|
|
94
|
+
return f"{self.server_name},{self.explicit_port}"
|
|
95
|
+
return str(self.server_name)
|
|
96
|
+
|
|
82
97
|
def open_db(self) -> None:
|
|
83
98
|
"""Open a connection to the SQL Server database via pyodbc."""
|
|
84
99
|
import pyodbc
|
|
@@ -95,6 +110,7 @@ class SqlServerDatabase(Database):
|
|
|
95
110
|
)
|
|
96
111
|
# Use pyodbc to connect. Try different driver versions from newest to oldest.
|
|
97
112
|
ssdrivers = (
|
|
113
|
+
"ODBC Driver 18 for SQL Server",
|
|
98
114
|
"ODBC Driver 17 for SQL Server",
|
|
99
115
|
"ODBC Driver 13.1 for SQL Server",
|
|
100
116
|
"ODBC Driver 13 for SQL Server",
|
|
@@ -108,23 +124,23 @@ class SqlServerDatabase(Database):
|
|
|
108
124
|
errors: list[BaseException] = []
|
|
109
125
|
|
|
110
126
|
def _try_drivers():
|
|
127
|
+
attributes: list[tuple[str, object]] = [
|
|
128
|
+
("SERVER", self._server_attribute()),
|
|
129
|
+
("MARS_Connection", "Yes"),
|
|
130
|
+
("DATABASE", self.db_name),
|
|
131
|
+
]
|
|
132
|
+
if self.user:
|
|
133
|
+
attributes.append(("Uid", self.user))
|
|
134
|
+
if self.password:
|
|
135
|
+
attributes.append(("Pwd", self.password))
|
|
136
|
+
else:
|
|
137
|
+
attributes.append(("Trusted_Connection", "yes"))
|
|
138
|
+
if self.trust_server_certificate:
|
|
139
|
+
# Driver 18 encrypts and verifies the server's certificate
|
|
140
|
+
# by default; this accepts a self-signed one.
|
|
141
|
+
attributes.append(("TrustServerCertificate", "yes"))
|
|
111
142
|
for drv in ssdrivers:
|
|
112
|
-
|
|
113
|
-
if self.password:
|
|
114
|
-
connstr = (
|
|
115
|
-
f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
|
|
116
|
-
f"DATABASE={self.db_name};Uid={self.user};Pwd={self.password}"
|
|
117
|
-
)
|
|
118
|
-
else:
|
|
119
|
-
connstr = (
|
|
120
|
-
f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
|
|
121
|
-
f"DATABASE={self.db_name};Uid={self.user}"
|
|
122
|
-
)
|
|
123
|
-
else:
|
|
124
|
-
connstr = (
|
|
125
|
-
f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
|
|
126
|
-
f"DATABASE={self.db_name};Trusted_Connection=yes"
|
|
127
|
-
)
|
|
143
|
+
connstr = connection_string(drv, attributes)
|
|
128
144
|
try:
|
|
129
145
|
# 30 s connect timeout matches the Postgres adapter
|
|
130
146
|
# default; pyodbc treats `timeout` as the login-and-
|
|
@@ -134,12 +150,12 @@ class SqlServerDatabase(Database):
|
|
|
134
150
|
errors.append(e)
|
|
135
151
|
if _state.exec_log is not None:
|
|
136
152
|
_state.exec_log.log_status_info(
|
|
137
|
-
f"Could not connect using: {
|
|
153
|
+
f"Could not connect using: {loggable(drv, attributes)}",
|
|
138
154
|
)
|
|
139
155
|
else:
|
|
140
156
|
if _state.exec_log is not None:
|
|
141
157
|
_state.exec_log.log_status_info(
|
|
142
|
-
f"Connected using: {
|
|
158
|
+
f"Connected using: {loggable(drv, attributes)}",
|
|
143
159
|
)
|
|
144
160
|
return True
|
|
145
161
|
return False
|