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.
Files changed (67) hide show
  1. execsql/__main__.py +4 -5
  2. execsql/api.py +6 -0
  3. execsql/cli/__init__.py +3 -14
  4. execsql/cli/application.py +6 -4
  5. execsql/cli/commands/lint.py +5 -2
  6. execsql/cli/commands/lsp.py +42 -0
  7. execsql/cli/dsn.py +38 -2
  8. execsql/cli/lint.py +24 -7
  9. execsql/cli/run.py +22 -9
  10. execsql/config.py +58 -10
  11. execsql/data/execsql.conf.template +8 -2
  12. execsql/db/access.py +7 -5
  13. execsql/db/base.py +18 -0
  14. execsql/db/duckdb.py +96 -14
  15. execsql/db/factory.py +11 -1
  16. execsql/db/mysql.py +56 -3
  17. execsql/db/odbc.py +45 -0
  18. execsql/db/postgres.py +1 -1
  19. execsql/db/sqlserver.py +37 -21
  20. execsql/exporters/ods.py +146 -41
  21. execsql/exporters/values.py +166 -9
  22. execsql/gui/__init__.py +3 -31
  23. execsql/importers/base.py +1 -1
  24. execsql/importers/csv.py +1 -1
  25. execsql/importers/ods.py +2 -0
  26. execsql/lsp/_util.py +21 -0
  27. execsql/lsp/cache.py +73 -0
  28. execsql/lsp/code_actions.py +10 -14
  29. execsql/lsp/completion.py +8 -15
  30. execsql/lsp/diagnostics.py +15 -3
  31. execsql/lsp/document.py +142 -41
  32. execsql/lsp/hover.py +5 -13
  33. execsql/lsp/navigation.py +7 -10
  34. execsql/lsp/server.py +111 -29
  35. execsql/manifest.py +29 -9
  36. execsql/metacommands/__init__.py +0 -2
  37. execsql/metacommands/io_fileops.py +16 -5
  38. execsql/metacommands/reference.py +1 -1
  39. execsql/metacommands/upsert.py +10 -1
  40. execsql/script/executor.py +2 -21
  41. execsql/state.py +6 -3
  42. execsql/state.pyi +2 -0
  43. execsql/utils/auth.py +21 -14
  44. execsql/utils/errors.py +20 -37
  45. execsql/utils/fileio.py +44 -48
  46. execsql/utils/paths.py +31 -0
  47. execsql/utils/regex.py +0 -5
  48. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/README.md +9 -8
  49. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_compare.sql +53 -29
  50. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_glossary.sql +20 -20
  51. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_upsert.sql +59 -66
  52. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_compare.sql +19 -22
  53. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_glossary.sql +13 -17
  54. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/script_template.sql +302 -299
  55. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_compare.sql +39 -29
  56. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_glossary.sql +13 -17
  57. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_upsert.sql +56 -66
  58. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/METADATA +17 -43
  59. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/RECORD +66 -63
  60. execsql2-2.29.0.data/data/execsql2_extras/pg_upsert.sql +0 -2791
  61. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  62. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  63. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  64. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/WHEEL +0 -0
  65. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/entry_points.txt +0 -0
  66. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/LICENSE.txt +0 -0
  67. {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 = f"{cs % db_name} Uid={self.user}; Pwd={self.password};"
124
- else:
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: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
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: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
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
- """The connection, used as a cursor so a statement joins its open transaction.
32
-
33
- A DuckDB cursor is a separate connection with a transaction of its own,
34
- and closing it rolls that transaction back. ``close()`` therefore leaves
35
- the connection open.
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, conn: Any) -> None:
39
- self._conn = conn
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
- pass
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. While it is open, every statement runs on the connection
55
- itself rather than on a cursor (see :class:`_SharedCursor`).
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, or while a transaction is open, the connection itself."""
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
- if self.in_transaction:
90
- return _SharedCursor(self.conn)
91
- return self.conn.cursor()
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(server_name, database_name, user, pw_needed, port, encoding, password=password)
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
- import_sql = f"load data local infile {self.quote_literal(csv_file_obj.csvfname)} into table {sq_name}"
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
- self.execute(import_sql)
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(import_cols) - len(line)))
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(import_cols) - len(line)))
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 = 1433,
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
- if self.user:
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: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
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: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
158
+ f"Connected using: {loggable(drv, attributes)}",
143
159
  )
144
160
  return True
145
161
  return False