mssql-python 1.0.0__cp314-cp314-win_amd64.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.
- mssql_python/__init__.py +307 -0
- mssql_python/auth.py +222 -0
- mssql_python/connection.py +1493 -0
- mssql_python/connection_string_builder.py +113 -0
- mssql_python/connection_string_parser.py +381 -0
- mssql_python/constants.py +509 -0
- mssql_python/cursor.py +2601 -0
- mssql_python/db_connection.py +53 -0
- mssql_python/ddbc_bindings.cp314-amd64.pyd +0 -0
- mssql_python/ddbc_bindings.py +137 -0
- mssql_python/exceptions.py +574 -0
- mssql_python/helpers.py +357 -0
- mssql_python/libs/LICENSING +19 -0
- mssql_python/libs/linux/alpine/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/linux/alpine/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/alpine/arm64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/alpine/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/linux/alpine/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/linux/alpine/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/alpine/x86_64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/alpine/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/linux/debian_ubuntu/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/linux/debian_ubuntu/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/debian_ubuntu/arm64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/debian_ubuntu/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/linux/debian_ubuntu/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/debian_ubuntu/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/linux/rhel/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/linux/rhel/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/rhel/arm64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/rhel/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/linux/rhel/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/linux/rhel/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/rhel/x86_64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/rhel/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/linux/suse/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
- mssql_python/libs/linux/suse/x86_64/lib/libodbcinst.so.2 +0 -0
- mssql_python/libs/linux/suse/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/macos/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/macos/arm64/lib/libltdl.7.dylib +0 -0
- mssql_python/libs/macos/arm64/lib/libmsodbcsql.18.dylib +0 -0
- mssql_python/libs/macos/arm64/lib/libodbc.2.dylib +0 -0
- mssql_python/libs/macos/arm64/lib/libodbcinst.2.dylib +0 -0
- mssql_python/libs/macos/arm64/share/doc/msodbcsql18/LICENSE.txt +101 -0
- mssql_python/libs/macos/arm64/share/doc/msodbcsql18/RELEASE_NOTES +465 -0
- mssql_python/libs/macos/arm64/share/msodbcsql18/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/macos/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/macos/x86_64/lib/libltdl.7.dylib +0 -0
- mssql_python/libs/macos/x86_64/lib/libmsodbcsql.18.dylib +0 -0
- mssql_python/libs/macos/x86_64/lib/libodbc.2.dylib +0 -0
- mssql_python/libs/macos/x86_64/lib/libodbcinst.2.dylib +0 -0
- mssql_python/libs/macos/x86_64/share/doc/msodbcsql18/LICENSE.txt +101 -0
- mssql_python/libs/macos/x86_64/share/doc/msodbcsql18/RELEASE_NOTES +465 -0
- mssql_python/libs/macos/x86_64/share/msodbcsql18/resources/en_US/msodbcsqlr18.rll +0 -0
- mssql_python/libs/windows/x64/1033/msodbcsqlr18.rll +0 -0
- mssql_python/libs/windows/x64/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
- mssql_python/libs/windows/x64/MICROSOFT_VISUAL_STUDIO_LICENSE.txt +54 -0
- mssql_python/libs/windows/x64/msodbcdiag18.dll +0 -0
- mssql_python/libs/windows/x64/msodbcsql18.dll +0 -0
- mssql_python/libs/windows/x64/mssql-auth.dll +0 -0
- mssql_python/logging.py +610 -0
- mssql_python/mssql_python.pyi +375 -0
- mssql_python/msvcp140.dll +0 -0
- mssql_python/pooling.py +125 -0
- mssql_python/row.py +194 -0
- mssql_python/type.py +143 -0
- mssql_python-1.0.0.dist-info/METADATA +76 -0
- mssql_python-1.0.0.dist-info/RECORD +73 -0
- mssql_python-1.0.0.dist-info/WHEEL +5 -0
- mssql_python-1.0.0.dist-info/licenses/LICENSE +47 -0
- mssql_python-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1493 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation.
|
|
3
|
+
Licensed under the MIT license.
|
|
4
|
+
This module defines the Connection class, which is used to manage a connection to a database.
|
|
5
|
+
The class provides methods to establish a connection, create cursors, commit transactions,
|
|
6
|
+
roll back transactions, and close the connection.
|
|
7
|
+
Resource Management:
|
|
8
|
+
- All cursors created from this connection are tracked internally.
|
|
9
|
+
- When close() is called on the connection, all open cursors are automatically closed.
|
|
10
|
+
- Do not use any cursor after the connection is closed; doing so will raise an exception.
|
|
11
|
+
- Cursors are also cleaned up automatically when no longer referenced, to prevent memory leaks.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import weakref
|
|
15
|
+
import re
|
|
16
|
+
import codecs
|
|
17
|
+
from typing import Any, Dict, Optional, Union, List, Tuple, Callable, TYPE_CHECKING
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
from mssql_python.cursor import Cursor
|
|
21
|
+
from mssql_python.helpers import (
|
|
22
|
+
add_driver_to_connection_str,
|
|
23
|
+
sanitize_connection_string,
|
|
24
|
+
sanitize_user_input,
|
|
25
|
+
validate_attribute_value,
|
|
26
|
+
)
|
|
27
|
+
from mssql_python.logging import logger
|
|
28
|
+
from mssql_python import ddbc_bindings
|
|
29
|
+
from mssql_python.pooling import PoolingManager
|
|
30
|
+
from mssql_python.exceptions import (
|
|
31
|
+
Warning, # pylint: disable=redefined-builtin
|
|
32
|
+
Error,
|
|
33
|
+
InterfaceError,
|
|
34
|
+
DatabaseError,
|
|
35
|
+
DataError,
|
|
36
|
+
OperationalError,
|
|
37
|
+
IntegrityError,
|
|
38
|
+
InternalError,
|
|
39
|
+
ProgrammingError,
|
|
40
|
+
NotSupportedError,
|
|
41
|
+
)
|
|
42
|
+
from mssql_python.auth import process_connection_string
|
|
43
|
+
from mssql_python.constants import ConstantsDDBC, GetInfoConstants
|
|
44
|
+
from mssql_python.connection_string_parser import _ConnectionStringParser
|
|
45
|
+
from mssql_python.connection_string_builder import _ConnectionStringBuilder
|
|
46
|
+
from mssql_python.constants import _RESERVED_PARAMETERS
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from mssql_python.row import Row
|
|
50
|
+
|
|
51
|
+
# Add SQL_WMETADATA constant for metadata decoding configuration
|
|
52
|
+
SQL_WMETADATA: int = -99 # Special flag for column name decoding
|
|
53
|
+
# Threshold to determine if an info type is string-based
|
|
54
|
+
INFO_TYPE_STRING_THRESHOLD: int = 10000
|
|
55
|
+
|
|
56
|
+
# UTF-16 encoding variants that should use SQL_WCHAR by default
|
|
57
|
+
UTF16_ENCODINGS: frozenset[str] = frozenset(["utf-16", "utf-16le", "utf-16be"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_encoding(encoding: str) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Cached encoding validation using codecs.lookup().
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
encoding (str): The encoding name to validate.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
bool: True if encoding is valid, False otherwise.
|
|
69
|
+
|
|
70
|
+
Note:
|
|
71
|
+
Uses LRU cache to avoid repeated expensive codecs.lookup() calls.
|
|
72
|
+
Cache size is limited to 128 entries which should cover most use cases.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
codecs.lookup(encoding)
|
|
76
|
+
return True
|
|
77
|
+
except LookupError:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Connection:
|
|
82
|
+
"""
|
|
83
|
+
A class to manage a connection to a database, compliant with DB-API 2.0 specifications.
|
|
84
|
+
|
|
85
|
+
This class provides methods to establish a connection to a database, create cursors,
|
|
86
|
+
commit transactions, roll back transactions, and close the connection. It is designed
|
|
87
|
+
to be used in a context where database operations are required, such as executing queries
|
|
88
|
+
and fetching results.
|
|
89
|
+
|
|
90
|
+
The Connection class supports the Python context manager protocol (with statement).
|
|
91
|
+
When used as a context manager, it will automatically close the connection when
|
|
92
|
+
exiting the context, ensuring proper resource cleanup.
|
|
93
|
+
|
|
94
|
+
Example usage:
|
|
95
|
+
with connect(connection_string) as conn:
|
|
96
|
+
cursor = conn.cursor()
|
|
97
|
+
cursor.execute("INSERT INTO table VALUES (?)", [value])
|
|
98
|
+
# Connection is automatically closed when exiting the with block
|
|
99
|
+
|
|
100
|
+
For long-lived connections, use without context manager:
|
|
101
|
+
conn = connect(connection_string)
|
|
102
|
+
try:
|
|
103
|
+
# Multiple operations...
|
|
104
|
+
finally:
|
|
105
|
+
conn.close()
|
|
106
|
+
|
|
107
|
+
Methods:
|
|
108
|
+
__init__(database: str) -> None:
|
|
109
|
+
connect_to_db() -> None:
|
|
110
|
+
cursor() -> Cursor:
|
|
111
|
+
commit() -> None:
|
|
112
|
+
rollback() -> None:
|
|
113
|
+
close() -> None:
|
|
114
|
+
__enter__() -> Connection:
|
|
115
|
+
__exit__() -> None:
|
|
116
|
+
setencoding(encoding=None, ctype=None) -> None:
|
|
117
|
+
setdecoding(sqltype, encoding=None, ctype=None) -> None:
|
|
118
|
+
getdecoding(sqltype) -> dict:
|
|
119
|
+
set_attr(attribute, value) -> None:
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
# DB-API 2.0 Exception attributes
|
|
123
|
+
# These allow users to catch exceptions using connection.Error,
|
|
124
|
+
# connection.ProgrammingError, etc.
|
|
125
|
+
Warning = Warning
|
|
126
|
+
Error = Error
|
|
127
|
+
InterfaceError = InterfaceError
|
|
128
|
+
DatabaseError = DatabaseError
|
|
129
|
+
DataError = DataError
|
|
130
|
+
OperationalError = OperationalError
|
|
131
|
+
IntegrityError = IntegrityError
|
|
132
|
+
InternalError = InternalError
|
|
133
|
+
ProgrammingError = ProgrammingError
|
|
134
|
+
NotSupportedError = NotSupportedError
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
connection_str: str = "",
|
|
139
|
+
autocommit: bool = False,
|
|
140
|
+
attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None,
|
|
141
|
+
timeout: int = 0,
|
|
142
|
+
**kwargs: Any,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Initialize the connection object with the specified connection string and parameters.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
connection_str (str): The connection string to connect to.
|
|
149
|
+
autocommit (bool): If True, causes a commit to be performed after
|
|
150
|
+
each SQL statement.
|
|
151
|
+
attrs_before (dict, optional): Dictionary of connection attributes to set before
|
|
152
|
+
connection establishment. Keys are SQL_ATTR_* constants,
|
|
153
|
+
and values are their corresponding settings.
|
|
154
|
+
Use this for attributes that must be set before
|
|
155
|
+
connecting, such as SQL_ATTR_LOGIN_TIMEOUT,
|
|
156
|
+
SQL_ATTR_ODBC_CURSORS, and SQL_ATTR_PACKET_SIZE.
|
|
157
|
+
timeout (int): Login timeout in seconds. 0 means no timeout.
|
|
158
|
+
**kwargs: Additional key/value pairs for the connection string.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
None
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ValueError: If the connection string is invalid or connection fails.
|
|
165
|
+
|
|
166
|
+
This method sets up the initial state for the connection object,
|
|
167
|
+
preparing it for further operations such as connecting to the
|
|
168
|
+
database, executing queries, etc.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
>>> # Setting login timeout using attrs_before
|
|
172
|
+
>>> import mssql_python as ms
|
|
173
|
+
>>> conn = ms.connect("Server=myserver;Database=mydb",
|
|
174
|
+
... attrs_before={ms.SQL_ATTR_LOGIN_TIMEOUT: 30})
|
|
175
|
+
"""
|
|
176
|
+
self.connection_str = self._construct_connection_string(
|
|
177
|
+
connection_str, **kwargs
|
|
178
|
+
)
|
|
179
|
+
self._attrs_before = attrs_before or {}
|
|
180
|
+
|
|
181
|
+
# Initialize encoding settings with defaults for Python 3
|
|
182
|
+
# Python 3 only has str (which is Unicode), so we use utf-16le by default
|
|
183
|
+
self._encoding_settings = {
|
|
184
|
+
"encoding": "utf-16le",
|
|
185
|
+
"ctype": ConstantsDDBC.SQL_WCHAR.value,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Initialize decoding settings with Python 3 defaults
|
|
189
|
+
self._decoding_settings = {
|
|
190
|
+
ConstantsDDBC.SQL_CHAR.value: {
|
|
191
|
+
"encoding": "utf-8",
|
|
192
|
+
"ctype": ConstantsDDBC.SQL_CHAR.value,
|
|
193
|
+
},
|
|
194
|
+
ConstantsDDBC.SQL_WCHAR.value: {
|
|
195
|
+
"encoding": "utf-16le",
|
|
196
|
+
"ctype": ConstantsDDBC.SQL_WCHAR.value,
|
|
197
|
+
},
|
|
198
|
+
SQL_WMETADATA: {
|
|
199
|
+
"encoding": "utf-16le",
|
|
200
|
+
"ctype": ConstantsDDBC.SQL_WCHAR.value,
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Check if the connection string contains authentication parameters
|
|
205
|
+
# This is important for processing the connection string correctly.
|
|
206
|
+
# If authentication is specified, it will be processed to handle
|
|
207
|
+
# different authentication types like interactive, device code, etc.
|
|
208
|
+
if re.search(r"authentication", self.connection_str, re.IGNORECASE):
|
|
209
|
+
connection_result = process_connection_string(self.connection_str)
|
|
210
|
+
self.connection_str = connection_result[0]
|
|
211
|
+
if connection_result[1]:
|
|
212
|
+
self._attrs_before.update(connection_result[1])
|
|
213
|
+
|
|
214
|
+
self._closed = False
|
|
215
|
+
self._timeout = timeout
|
|
216
|
+
|
|
217
|
+
# Using WeakSet which automatically removes cursors when they are no
|
|
218
|
+
# longer in use
|
|
219
|
+
# It is a set that holds weak references to its elements.
|
|
220
|
+
# When an object is only weakly referenced, it can be garbage
|
|
221
|
+
# collected even if it's still in the set.
|
|
222
|
+
# It prevents memory leaks by ensuring that cursors are cleaned up
|
|
223
|
+
# when no longer in use without requiring explicit deletion.
|
|
224
|
+
# TODO: Think and implement scenarios for multi-threaded access
|
|
225
|
+
# to cursors
|
|
226
|
+
self._cursors = weakref.WeakSet()
|
|
227
|
+
|
|
228
|
+
# Initialize output converters dictionary and its lock for thread safety
|
|
229
|
+
self._output_converters = {}
|
|
230
|
+
self._converters_lock = threading.Lock()
|
|
231
|
+
|
|
232
|
+
# Initialize search escape character
|
|
233
|
+
self._searchescape = None
|
|
234
|
+
|
|
235
|
+
# Auto-enable pooling if user never called
|
|
236
|
+
if not PoolingManager.is_initialized():
|
|
237
|
+
PoolingManager.enable()
|
|
238
|
+
self._pooling = PoolingManager.is_enabled()
|
|
239
|
+
self._conn = ddbc_bindings.Connection(
|
|
240
|
+
self.connection_str, self._pooling, self._attrs_before
|
|
241
|
+
)
|
|
242
|
+
self.setautocommit(autocommit)
|
|
243
|
+
|
|
244
|
+
def _construct_connection_string(
|
|
245
|
+
self, connection_str: str = "", **kwargs: Any
|
|
246
|
+
) -> str:
|
|
247
|
+
"""
|
|
248
|
+
Construct the connection string by parsing, validating, and merging parameters.
|
|
249
|
+
|
|
250
|
+
This method performs a 6-step process:
|
|
251
|
+
1. Parse and validate the base connection_str (validates against allowlist)
|
|
252
|
+
2. Normalize parameter names (e.g., addr/address -> Server, uid -> UID)
|
|
253
|
+
3. Merge kwargs (which override connection_str params after normalization)
|
|
254
|
+
4. Build connection string from normalized, merged params
|
|
255
|
+
5. Add Driver and APP parameters (always controlled by the driver)
|
|
256
|
+
6. Return the final connection string
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
connection_str (str): The base connection string.
|
|
260
|
+
**kwargs: Additional key/value pairs for the connection string.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
str: The constructed and validated connection string.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
# Step 1: Parse base connection string with allowlist validation
|
|
267
|
+
# The parser validates everything: unknown params, reserved params, duplicates, syntax
|
|
268
|
+
parser = _ConnectionStringParser(validate_keywords=True)
|
|
269
|
+
parsed_params = parser._parse(connection_str)
|
|
270
|
+
|
|
271
|
+
# Step 2: Normalize parameter names (e.g., addr/address -> Server, uid -> UID)
|
|
272
|
+
# This handles synonym mapping and deduplication via normalized keys
|
|
273
|
+
normalized_params = _ConnectionStringParser._normalize_params(parsed_params, warn_rejected=False)
|
|
274
|
+
|
|
275
|
+
# Step 3: Process kwargs and merge with normalized_params
|
|
276
|
+
# kwargs override connection string values (processed after, so they take precedence)
|
|
277
|
+
for key, value in kwargs.items():
|
|
278
|
+
normalized_key = _ConnectionStringParser.normalize_key(key)
|
|
279
|
+
if normalized_key:
|
|
280
|
+
# Driver and APP are reserved - raise error if user tries to set them
|
|
281
|
+
if normalized_key in _RESERVED_PARAMETERS:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"Connection parameter '{key}' is reserved and controlled by the driver. "
|
|
284
|
+
f"It cannot be set by the user."
|
|
285
|
+
)
|
|
286
|
+
# kwargs override any existing values from connection string
|
|
287
|
+
normalized_params[normalized_key] = str(value)
|
|
288
|
+
else:
|
|
289
|
+
logger.warning(f"Ignoring unknown connection parameter from kwargs: {key}")
|
|
290
|
+
|
|
291
|
+
# Step 4: Build connection string with merged params
|
|
292
|
+
builder = _ConnectionStringBuilder(normalized_params)
|
|
293
|
+
|
|
294
|
+
# Step 5: Add Driver and APP parameters (always controlled by the driver)
|
|
295
|
+
# These maintain existing behavior: Driver is always hardcoded, APP is always MSSQL-Python
|
|
296
|
+
builder.add_param('Driver', 'ODBC Driver 18 for SQL Server')
|
|
297
|
+
builder.add_param('APP', 'MSSQL-Python')
|
|
298
|
+
|
|
299
|
+
# Step 6: Build final string
|
|
300
|
+
conn_str = builder.build()
|
|
301
|
+
|
|
302
|
+
logger.info("Final connection string: %s", sanitize_connection_string(conn_str))
|
|
303
|
+
|
|
304
|
+
return conn_str
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def timeout(self) -> int:
|
|
308
|
+
"""
|
|
309
|
+
Get the current query timeout setting in seconds.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
int: The timeout value in seconds. Zero means no timeout (wait indefinitely).
|
|
313
|
+
"""
|
|
314
|
+
return self._timeout
|
|
315
|
+
|
|
316
|
+
@timeout.setter
|
|
317
|
+
def timeout(self, value: int) -> None:
|
|
318
|
+
"""
|
|
319
|
+
Set the query timeout for all operations performed by this connection.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
value (int): The timeout value in seconds. Zero means no timeout.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
None
|
|
326
|
+
|
|
327
|
+
Note:
|
|
328
|
+
This timeout applies to all cursors created from this connection.
|
|
329
|
+
It cannot be changed for individual cursors or SQL statements.
|
|
330
|
+
If a query timeout occurs, an OperationalError exception will be raised.
|
|
331
|
+
"""
|
|
332
|
+
if not isinstance(value, int):
|
|
333
|
+
raise TypeError("Timeout must be an integer")
|
|
334
|
+
if value < 0:
|
|
335
|
+
raise ValueError("Timeout cannot be negative")
|
|
336
|
+
self._timeout = value
|
|
337
|
+
logger.info( f"Query timeout set to {value} seconds")
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def autocommit(self) -> bool:
|
|
341
|
+
"""
|
|
342
|
+
Return the current autocommit mode of the connection.
|
|
343
|
+
Returns:
|
|
344
|
+
bool: True if autocommit is enabled, False otherwise.
|
|
345
|
+
"""
|
|
346
|
+
return self._conn.get_autocommit()
|
|
347
|
+
|
|
348
|
+
@autocommit.setter
|
|
349
|
+
def autocommit(self, value: bool) -> None:
|
|
350
|
+
"""
|
|
351
|
+
Set the autocommit mode of the connection.
|
|
352
|
+
Args:
|
|
353
|
+
value (bool): True to enable autocommit, False to disable it.
|
|
354
|
+
Returns:
|
|
355
|
+
None
|
|
356
|
+
"""
|
|
357
|
+
self.setautocommit(value)
|
|
358
|
+
logger.info( "Autocommit mode set to %s.", value)
|
|
359
|
+
|
|
360
|
+
def setautocommit(self, value: bool = False) -> None:
|
|
361
|
+
"""
|
|
362
|
+
Set the autocommit mode of the connection.
|
|
363
|
+
Args:
|
|
364
|
+
value (bool): True to enable autocommit, False to disable it.
|
|
365
|
+
Returns:
|
|
366
|
+
None
|
|
367
|
+
Raises:
|
|
368
|
+
DatabaseError: If there is an error while setting the autocommit mode.
|
|
369
|
+
"""
|
|
370
|
+
self._conn.set_autocommit(value)
|
|
371
|
+
|
|
372
|
+
def setencoding(
|
|
373
|
+
self, encoding: Optional[str] = None, ctype: Optional[int] = None
|
|
374
|
+
) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Sets the text encoding for SQL statements and text parameters.
|
|
377
|
+
|
|
378
|
+
Since Python 3 only has str (which is Unicode), this method configures
|
|
379
|
+
how text is encoded when sending to the database.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
encoding (str, optional): The encoding to use. This must be a valid Python
|
|
383
|
+
encoding that converts text to bytes. If None, defaults to 'utf-16le'.
|
|
384
|
+
ctype (int, optional): The C data type to use when passing data:
|
|
385
|
+
SQL_CHAR or SQL_WCHAR. If not provided, SQL_WCHAR is used for
|
|
386
|
+
UTF-16 variants (see UTF16_ENCODINGS constant). SQL_CHAR is used
|
|
387
|
+
for all other encodings.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
None
|
|
391
|
+
|
|
392
|
+
Raises:
|
|
393
|
+
ProgrammingError: If the encoding is not valid or not supported.
|
|
394
|
+
InterfaceError: If the connection is closed.
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
# For databases that only communicate with UTF-8
|
|
398
|
+
cnxn.setencoding(encoding='utf-8')
|
|
399
|
+
|
|
400
|
+
# For explicitly using SQL_CHAR
|
|
401
|
+
cnxn.setencoding(encoding='utf-8', ctype=mssql_python.SQL_CHAR)
|
|
402
|
+
"""
|
|
403
|
+
logger.debug( 'setencoding: Configuring encoding=%s, ctype=%s',
|
|
404
|
+
str(encoding) if encoding else 'default', str(ctype) if ctype else 'auto')
|
|
405
|
+
if self._closed:
|
|
406
|
+
logger.debug( 'setencoding: Connection is closed')
|
|
407
|
+
raise InterfaceError(
|
|
408
|
+
driver_error="Connection is closed",
|
|
409
|
+
ddbc_error="Connection is closed",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Set default encoding if not provided
|
|
413
|
+
if encoding is None:
|
|
414
|
+
encoding = "utf-16le"
|
|
415
|
+
logger.debug( 'setencoding: Using default encoding=utf-16le')
|
|
416
|
+
|
|
417
|
+
# Validate encoding using cached validation for better performance
|
|
418
|
+
if not _validate_encoding(encoding):
|
|
419
|
+
# Log the sanitized encoding for security
|
|
420
|
+
logger.debug(
|
|
421
|
+
"warning",
|
|
422
|
+
"Invalid encoding attempted: %s",
|
|
423
|
+
sanitize_user_input(str(encoding)),
|
|
424
|
+
)
|
|
425
|
+
raise ProgrammingError(
|
|
426
|
+
driver_error=f"Unsupported encoding: {encoding}",
|
|
427
|
+
ddbc_error=f"The encoding '{encoding}' is not supported by Python",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Normalize encoding to casefold for more robust Unicode handling
|
|
431
|
+
encoding = encoding.casefold()
|
|
432
|
+
logger.debug( 'setencoding: Encoding normalized to %s', encoding)
|
|
433
|
+
|
|
434
|
+
# Set default ctype based on encoding if not provided
|
|
435
|
+
if ctype is None:
|
|
436
|
+
if encoding in UTF16_ENCODINGS:
|
|
437
|
+
ctype = ConstantsDDBC.SQL_WCHAR.value
|
|
438
|
+
logger.debug( 'setencoding: Auto-selected SQL_WCHAR for UTF-16')
|
|
439
|
+
else:
|
|
440
|
+
ctype = ConstantsDDBC.SQL_CHAR.value
|
|
441
|
+
logger.debug( 'setencoding: Auto-selected SQL_CHAR for non-UTF-16')
|
|
442
|
+
|
|
443
|
+
# Validate ctype
|
|
444
|
+
valid_ctypes = [ConstantsDDBC.SQL_CHAR.value, ConstantsDDBC.SQL_WCHAR.value]
|
|
445
|
+
if ctype not in valid_ctypes:
|
|
446
|
+
# Log the sanitized ctype for security
|
|
447
|
+
logger.debug(
|
|
448
|
+
"warning",
|
|
449
|
+
"Invalid ctype attempted: %s",
|
|
450
|
+
sanitize_user_input(str(ctype)),
|
|
451
|
+
)
|
|
452
|
+
raise ProgrammingError(
|
|
453
|
+
driver_error=f"Invalid ctype: {ctype}",
|
|
454
|
+
ddbc_error=(
|
|
455
|
+
f"ctype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}) or "
|
|
456
|
+
f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value})"
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Store the encoding settings
|
|
461
|
+
self._encoding_settings = {"encoding": encoding, "ctype": ctype}
|
|
462
|
+
|
|
463
|
+
# Log with sanitized values for security
|
|
464
|
+
logger.debug(
|
|
465
|
+
"info",
|
|
466
|
+
"Text encoding set to %s with ctype %s",
|
|
467
|
+
sanitize_user_input(encoding),
|
|
468
|
+
sanitize_user_input(str(ctype)),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def getencoding(self) -> Dict[str, Union[str, int]]:
|
|
472
|
+
"""
|
|
473
|
+
Gets the current text encoding settings.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
dict: A dictionary containing 'encoding' and 'ctype' keys.
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
InterfaceError: If the connection is closed.
|
|
480
|
+
|
|
481
|
+
Example:
|
|
482
|
+
settings = cnxn.getencoding()
|
|
483
|
+
print(f"Current encoding: {settings['encoding']}")
|
|
484
|
+
print(f"Current ctype: {settings['ctype']}")
|
|
485
|
+
"""
|
|
486
|
+
if self._closed:
|
|
487
|
+
raise InterfaceError(
|
|
488
|
+
driver_error="Connection is closed",
|
|
489
|
+
ddbc_error="Connection is closed",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return self._encoding_settings.copy()
|
|
493
|
+
|
|
494
|
+
def setdecoding(
|
|
495
|
+
self, sqltype: int, encoding: Optional[str] = None, ctype: Optional[int] = None
|
|
496
|
+
) -> None:
|
|
497
|
+
"""
|
|
498
|
+
Sets the text decoding used when reading SQL_CHAR and SQL_WCHAR from the database.
|
|
499
|
+
|
|
500
|
+
This method configures how text data is decoded when reading from the database.
|
|
501
|
+
In Python 3, all text is Unicode (str), so this primarily affects the encoding
|
|
502
|
+
used to decode bytes from the database.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
sqltype (int): The SQL type being configured: SQL_CHAR, SQL_WCHAR, or SQL_WMETADATA.
|
|
506
|
+
SQL_WMETADATA is a special flag for configuring column name decoding.
|
|
507
|
+
encoding (str, optional): The Python encoding to use when decoding the data.
|
|
508
|
+
If None, uses default encoding based on sqltype.
|
|
509
|
+
ctype (int, optional): The C data type to request from SQLGetData:
|
|
510
|
+
SQL_CHAR or SQL_WCHAR. If None, uses default based on encoding.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
None
|
|
514
|
+
|
|
515
|
+
Raises:
|
|
516
|
+
ProgrammingError: If the sqltype, encoding, or ctype is invalid.
|
|
517
|
+
InterfaceError: If the connection is closed.
|
|
518
|
+
|
|
519
|
+
Example:
|
|
520
|
+
# Configure SQL_CHAR to use UTF-8 decoding
|
|
521
|
+
cnxn.setdecoding(mssql_python.SQL_CHAR, encoding='utf-8')
|
|
522
|
+
|
|
523
|
+
# Configure column metadata decoding
|
|
524
|
+
cnxn.setdecoding(mssql_python.SQL_WMETADATA, encoding='utf-16le')
|
|
525
|
+
|
|
526
|
+
# Use explicit ctype
|
|
527
|
+
cnxn.setdecoding(mssql_python.SQL_WCHAR, encoding='utf-16le',
|
|
528
|
+
ctype=mssql_python.SQL_WCHAR)
|
|
529
|
+
"""
|
|
530
|
+
if self._closed:
|
|
531
|
+
raise InterfaceError(
|
|
532
|
+
driver_error="Connection is closed",
|
|
533
|
+
ddbc_error="Connection is closed",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Validate sqltype
|
|
537
|
+
valid_sqltypes = [
|
|
538
|
+
ConstantsDDBC.SQL_CHAR.value,
|
|
539
|
+
ConstantsDDBC.SQL_WCHAR.value,
|
|
540
|
+
SQL_WMETADATA,
|
|
541
|
+
]
|
|
542
|
+
if sqltype not in valid_sqltypes:
|
|
543
|
+
logger.debug(
|
|
544
|
+
"warning",
|
|
545
|
+
"Invalid sqltype attempted: %s",
|
|
546
|
+
sanitize_user_input(str(sqltype)),
|
|
547
|
+
)
|
|
548
|
+
raise ProgrammingError(
|
|
549
|
+
driver_error=f"Invalid sqltype: {sqltype}",
|
|
550
|
+
ddbc_error=(
|
|
551
|
+
f"sqltype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}), "
|
|
552
|
+
f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value}), or "
|
|
553
|
+
f"SQL_WMETADATA ({SQL_WMETADATA})"
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Set default encoding based on sqltype if not provided
|
|
558
|
+
if encoding is None:
|
|
559
|
+
if sqltype == ConstantsDDBC.SQL_CHAR.value:
|
|
560
|
+
encoding = "utf-8" # Default for SQL_CHAR in Python 3
|
|
561
|
+
else: # SQL_WCHAR or SQL_WMETADATA
|
|
562
|
+
encoding = "utf-16le" # Default for SQL_WCHAR in Python 3
|
|
563
|
+
|
|
564
|
+
# Validate encoding using cached validation for better performance
|
|
565
|
+
if not _validate_encoding(encoding):
|
|
566
|
+
logger.debug(
|
|
567
|
+
"warning",
|
|
568
|
+
"Invalid encoding attempted: %s",
|
|
569
|
+
sanitize_user_input(str(encoding)),
|
|
570
|
+
)
|
|
571
|
+
raise ProgrammingError(
|
|
572
|
+
driver_error=f"Unsupported encoding: {encoding}",
|
|
573
|
+
ddbc_error=f"The encoding '{encoding}' is not supported by Python",
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Normalize encoding to lowercase for consistency
|
|
577
|
+
encoding = encoding.lower()
|
|
578
|
+
|
|
579
|
+
# Set default ctype based on encoding if not provided
|
|
580
|
+
if ctype is None:
|
|
581
|
+
if encoding in UTF16_ENCODINGS:
|
|
582
|
+
ctype = ConstantsDDBC.SQL_WCHAR.value
|
|
583
|
+
else:
|
|
584
|
+
ctype = ConstantsDDBC.SQL_CHAR.value
|
|
585
|
+
|
|
586
|
+
# Validate ctype
|
|
587
|
+
valid_ctypes = [ConstantsDDBC.SQL_CHAR.value, ConstantsDDBC.SQL_WCHAR.value]
|
|
588
|
+
if ctype not in valid_ctypes:
|
|
589
|
+
logger.debug(
|
|
590
|
+
"warning",
|
|
591
|
+
"Invalid ctype attempted: %s",
|
|
592
|
+
sanitize_user_input(str(ctype)),
|
|
593
|
+
)
|
|
594
|
+
raise ProgrammingError(
|
|
595
|
+
driver_error=f"Invalid ctype: {ctype}",
|
|
596
|
+
ddbc_error=(
|
|
597
|
+
f"ctype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}) or "
|
|
598
|
+
f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value})"
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Store the decoding settings for the specified sqltype
|
|
603
|
+
self._decoding_settings[sqltype] = {"encoding": encoding, "ctype": ctype}
|
|
604
|
+
|
|
605
|
+
# Log with sanitized values for security
|
|
606
|
+
sqltype_name = {
|
|
607
|
+
ConstantsDDBC.SQL_CHAR.value: "SQL_CHAR",
|
|
608
|
+
ConstantsDDBC.SQL_WCHAR.value: "SQL_WCHAR",
|
|
609
|
+
SQL_WMETADATA: "SQL_WMETADATA",
|
|
610
|
+
}.get(sqltype, str(sqltype))
|
|
611
|
+
|
|
612
|
+
logger.debug(
|
|
613
|
+
"info",
|
|
614
|
+
"Text decoding set for %s to %s with ctype %s",
|
|
615
|
+
sqltype_name,
|
|
616
|
+
sanitize_user_input(encoding),
|
|
617
|
+
sanitize_user_input(str(ctype)),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def getdecoding(self, sqltype: int) -> Dict[str, Union[str, int]]:
|
|
621
|
+
"""
|
|
622
|
+
Gets the current text decoding settings for the specified SQL type.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
sqltype (int): The SQL type to get settings for: SQL_CHAR, SQL_WCHAR, or SQL_WMETADATA.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
dict: A dictionary containing 'encoding' and 'ctype' keys for the specified sqltype.
|
|
629
|
+
|
|
630
|
+
Raises:
|
|
631
|
+
ProgrammingError: If the sqltype is invalid.
|
|
632
|
+
InterfaceError: If the connection is closed.
|
|
633
|
+
|
|
634
|
+
Example:
|
|
635
|
+
settings = cnxn.getdecoding(mssql_python.SQL_CHAR)
|
|
636
|
+
print(f"SQL_CHAR encoding: {settings['encoding']}")
|
|
637
|
+
print(f"SQL_CHAR ctype: {settings['ctype']}")
|
|
638
|
+
"""
|
|
639
|
+
if self._closed:
|
|
640
|
+
raise InterfaceError(
|
|
641
|
+
driver_error="Connection is closed",
|
|
642
|
+
ddbc_error="Connection is closed",
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Validate sqltype
|
|
646
|
+
valid_sqltypes = [
|
|
647
|
+
ConstantsDDBC.SQL_CHAR.value,
|
|
648
|
+
ConstantsDDBC.SQL_WCHAR.value,
|
|
649
|
+
SQL_WMETADATA,
|
|
650
|
+
]
|
|
651
|
+
if sqltype not in valid_sqltypes:
|
|
652
|
+
raise ProgrammingError(
|
|
653
|
+
driver_error=f"Invalid sqltype: {sqltype}",
|
|
654
|
+
ddbc_error=(
|
|
655
|
+
f"sqltype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}), "
|
|
656
|
+
f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value}), or "
|
|
657
|
+
f"SQL_WMETADATA ({SQL_WMETADATA})"
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
return self._decoding_settings[sqltype].copy()
|
|
662
|
+
|
|
663
|
+
def set_attr(
|
|
664
|
+
self, attribute: int, value: Union[int, str, bytes, bytearray]
|
|
665
|
+
) -> None:
|
|
666
|
+
"""
|
|
667
|
+
Set a connection attribute.
|
|
668
|
+
|
|
669
|
+
This method sets a connection attribute using SQLSetConnectAttr.
|
|
670
|
+
It provides pyodbc-compatible functionality for configuring connection
|
|
671
|
+
behavior such as autocommit mode, transaction isolation level, and
|
|
672
|
+
connection timeouts.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
attribute (int): The connection attribute to set. Should be one of the
|
|
676
|
+
SQL_ATTR_* constants (e.g., SQL_ATTR_AUTOCOMMIT,
|
|
677
|
+
SQL_ATTR_TXN_ISOLATION).
|
|
678
|
+
value: The value to set for the attribute. Can be an integer, string,
|
|
679
|
+
bytes, or bytearray depending on the attribute type.
|
|
680
|
+
|
|
681
|
+
Raises:
|
|
682
|
+
InterfaceError: If the connection is closed or attribute is invalid.
|
|
683
|
+
ProgrammingError: If the value type or range is invalid.
|
|
684
|
+
ProgrammingError: If the attribute cannot be set after connection.
|
|
685
|
+
|
|
686
|
+
Example:
|
|
687
|
+
>>> conn.set_attr(SQL_ATTR_TXN_ISOLATION, SQL_TXN_READ_COMMITTED)
|
|
688
|
+
|
|
689
|
+
Note:
|
|
690
|
+
Some attributes (like SQL_ATTR_LOGIN_TIMEOUT, SQL_ATTR_ODBC_CURSORS, and
|
|
691
|
+
SQL_ATTR_PACKET_SIZE) can only be set before connection establishment and
|
|
692
|
+
must be provided in the attrs_before parameter when creating the connection.
|
|
693
|
+
Attempting to set these attributes after connection will raise a ProgrammingError.
|
|
694
|
+
"""
|
|
695
|
+
if self._closed:
|
|
696
|
+
raise InterfaceError(
|
|
697
|
+
"Cannot set attribute on closed connection", "Connection is closed"
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Use the integrated validation helper function with connection state
|
|
701
|
+
is_valid, error_message, sanitized_attr, sanitized_val = (
|
|
702
|
+
validate_attribute_value(attribute, value, is_connected=True)
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if not is_valid:
|
|
706
|
+
# Use the already sanitized values for logging
|
|
707
|
+
logger.debug(
|
|
708
|
+
"warning",
|
|
709
|
+
f"Invalid attribute or value: {sanitized_attr}={sanitized_val}, {error_message}",
|
|
710
|
+
)
|
|
711
|
+
raise ProgrammingError(
|
|
712
|
+
driver_error=f"Invalid attribute or value: {error_message}",
|
|
713
|
+
ddbc_error=error_message,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Log with sanitized values
|
|
717
|
+
logger.debug( f"Setting connection attribute: {sanitized_attr}={sanitized_val}")
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
# Call the underlying C++ method
|
|
721
|
+
self._conn.set_attr(attribute, value)
|
|
722
|
+
logger.info( f"Connection attribute {sanitized_attr} set successfully")
|
|
723
|
+
|
|
724
|
+
except Exception as e:
|
|
725
|
+
error_msg = f"Failed to set connection attribute {sanitized_attr}: {str(e)}"
|
|
726
|
+
|
|
727
|
+
# Determine appropriate exception type based on error content
|
|
728
|
+
error_str = str(e).lower()
|
|
729
|
+
if (
|
|
730
|
+
"invalid" in error_str
|
|
731
|
+
or "unsupported" in error_str
|
|
732
|
+
or "cast" in error_str
|
|
733
|
+
):
|
|
734
|
+
logger.error(error_msg)
|
|
735
|
+
raise InterfaceError(error_msg, str(e)) from e
|
|
736
|
+
logger.error(error_msg)
|
|
737
|
+
raise ProgrammingError(error_msg, str(e)) from e
|
|
738
|
+
|
|
739
|
+
@property
|
|
740
|
+
def searchescape(self) -> str:
|
|
741
|
+
"""
|
|
742
|
+
The ODBC search pattern escape character, as returned by
|
|
743
|
+
SQLGetInfo(SQL_SEARCH_PATTERN_ESCAPE), used to escape special characters
|
|
744
|
+
such as '%' and '_' in LIKE clauses. These are driver specific.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
str: The search pattern escape character (usually '\' or another character)
|
|
748
|
+
"""
|
|
749
|
+
if not hasattr(self, "_searchescape") or self._searchescape is None:
|
|
750
|
+
try:
|
|
751
|
+
escape_char = self.getinfo(
|
|
752
|
+
GetInfoConstants.SQL_SEARCH_PATTERN_ESCAPE.value
|
|
753
|
+
)
|
|
754
|
+
# Some drivers might return this as an integer memory address
|
|
755
|
+
# or other non-string format, so ensure we have a string
|
|
756
|
+
if not isinstance(escape_char, str):
|
|
757
|
+
# Default to backslash if not a string
|
|
758
|
+
escape_char = "\\"
|
|
759
|
+
self._searchescape = escape_char
|
|
760
|
+
except Exception as e:
|
|
761
|
+
# Log the exception for debugging, but do not expose sensitive info
|
|
762
|
+
logger.debug(
|
|
763
|
+
"warning",
|
|
764
|
+
"Failed to retrieve search escape character, using default '\\'. "
|
|
765
|
+
"Exception: %s",
|
|
766
|
+
type(e).__name__,
|
|
767
|
+
)
|
|
768
|
+
self._searchescape = "\\"
|
|
769
|
+
return self._searchescape
|
|
770
|
+
|
|
771
|
+
def cursor(self) -> Cursor:
|
|
772
|
+
"""
|
|
773
|
+
Return a new Cursor object using the connection.
|
|
774
|
+
|
|
775
|
+
This method creates and returns a new cursor object that can be used to
|
|
776
|
+
execute SQL queries and fetch results. The cursor is associated with the
|
|
777
|
+
current connection and allows interaction with the database.
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
Cursor: A new cursor object for executing SQL queries.
|
|
781
|
+
|
|
782
|
+
Raises:
|
|
783
|
+
DatabaseError: If there is an error while creating the cursor.
|
|
784
|
+
InterfaceError: If there is an error related to the database interface.
|
|
785
|
+
"""
|
|
786
|
+
logger.debug('cursor: Creating new cursor - timeout=%d, total_cursors=%d',
|
|
787
|
+
self._timeout, len(self._cursors))
|
|
788
|
+
if self._closed:
|
|
789
|
+
logger.error('cursor: Cannot create cursor on closed connection')
|
|
790
|
+
# raise InterfaceError
|
|
791
|
+
raise InterfaceError(
|
|
792
|
+
driver_error="Cannot create cursor on closed connection",
|
|
793
|
+
ddbc_error="Cannot create cursor on closed connection",
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
cursor = Cursor(self, timeout=self._timeout)
|
|
797
|
+
self._cursors.add(cursor) # Track the cursor
|
|
798
|
+
logger.debug('cursor: Cursor created successfully - total_cursors=%d', len(self._cursors))
|
|
799
|
+
return cursor
|
|
800
|
+
|
|
801
|
+
def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None:
|
|
802
|
+
"""
|
|
803
|
+
Register an output converter function that will be called whenever a value
|
|
804
|
+
with the given SQL type is read from the database.
|
|
805
|
+
|
|
806
|
+
Thread-safe implementation that protects the converters dictionary with a lock.
|
|
807
|
+
|
|
808
|
+
⚠️ WARNING: Registering an output converter will cause the supplied Python function
|
|
809
|
+
to be executed on every matching database value. Do not register converters from
|
|
810
|
+
untrusted sources, as this can result in arbitrary code execution and security
|
|
811
|
+
vulnerabilities. This API should never be exposed to untrusted or external input.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
sqltype (int): The integer SQL type value to convert, which can be one of the
|
|
815
|
+
defined standard constants (e.g. SQL_VARCHAR) or a database-specific
|
|
816
|
+
value (e.g. -151 for the SQL Server 2008 geometry data type).
|
|
817
|
+
func (callable): The converter function which will be called with a single parameter,
|
|
818
|
+
the value, and should return the converted value. If the value is NULL
|
|
819
|
+
then the parameter passed to the function will be None, otherwise it
|
|
820
|
+
will be a bytes object.
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
None
|
|
824
|
+
"""
|
|
825
|
+
with self._converters_lock:
|
|
826
|
+
self._output_converters[sqltype] = func
|
|
827
|
+
# Pass to the underlying connection if native implementation supports it
|
|
828
|
+
if hasattr(self._conn, "add_output_converter"):
|
|
829
|
+
self._conn.add_output_converter(sqltype, func)
|
|
830
|
+
logger.info( f"Added output converter for SQL type {sqltype}")
|
|
831
|
+
|
|
832
|
+
def get_output_converter(
|
|
833
|
+
self, sqltype: Union[int, type]
|
|
834
|
+
) -> Optional[Callable[[Any], Any]]:
|
|
835
|
+
"""
|
|
836
|
+
Get the output converter function for the specified SQL type.
|
|
837
|
+
|
|
838
|
+
Thread-safe implementation that protects the converters dictionary with a lock.
|
|
839
|
+
|
|
840
|
+
Args:
|
|
841
|
+
sqltype (int or type): The SQL type value or Python type to get the converter for
|
|
842
|
+
|
|
843
|
+
Returns:
|
|
844
|
+
callable or None: The converter function or None if no converter is registered
|
|
845
|
+
|
|
846
|
+
Note:
|
|
847
|
+
⚠️ The returned converter function will be executed on database values. Only use
|
|
848
|
+
converters from trusted sources.
|
|
849
|
+
"""
|
|
850
|
+
with self._converters_lock:
|
|
851
|
+
return self._output_converters.get(sqltype)
|
|
852
|
+
|
|
853
|
+
def remove_output_converter(self, sqltype: Union[int, type]) -> None:
|
|
854
|
+
"""
|
|
855
|
+
Remove the output converter function for the specified SQL type.
|
|
856
|
+
|
|
857
|
+
Thread-safe implementation that protects the converters dictionary with a lock.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
sqltype (int or type): The SQL type value to remove the converter for
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
None
|
|
864
|
+
"""
|
|
865
|
+
with self._converters_lock:
|
|
866
|
+
if sqltype in self._output_converters:
|
|
867
|
+
del self._output_converters[sqltype]
|
|
868
|
+
# Pass to the underlying connection if native implementation supports it
|
|
869
|
+
if hasattr(self._conn, "remove_output_converter"):
|
|
870
|
+
self._conn.remove_output_converter(sqltype)
|
|
871
|
+
logger.info( f"Removed output converter for SQL type {sqltype}")
|
|
872
|
+
|
|
873
|
+
def clear_output_converters(self) -> None:
|
|
874
|
+
"""
|
|
875
|
+
Remove all output converter functions.
|
|
876
|
+
|
|
877
|
+
Thread-safe implementation that protects the converters dictionary with a lock.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
None
|
|
881
|
+
"""
|
|
882
|
+
with self._converters_lock:
|
|
883
|
+
self._output_converters.clear()
|
|
884
|
+
# Pass to the underlying connection if native implementation supports it
|
|
885
|
+
if hasattr(self._conn, "clear_output_converters"):
|
|
886
|
+
self._conn.clear_output_converters()
|
|
887
|
+
logger.info( "Cleared all output converters")
|
|
888
|
+
|
|
889
|
+
def execute(self, sql: str, *args: Any) -> Cursor:
|
|
890
|
+
"""
|
|
891
|
+
Creates a new Cursor object, calls its execute method, and returns the new cursor.
|
|
892
|
+
|
|
893
|
+
This is a convenience method that is not part of the DB API. Since a new Cursor
|
|
894
|
+
is allocated by each call, this should not be used if more than one SQL statement
|
|
895
|
+
needs to be executed on the connection.
|
|
896
|
+
|
|
897
|
+
Note on cursor lifecycle management:
|
|
898
|
+
- Each call creates a new cursor that is tracked by the connection's internal WeakSet
|
|
899
|
+
- Cursors are automatically dereferenced/closed when they go out of scope
|
|
900
|
+
- For long-running applications or loops, explicitly call cursor.close() when done
|
|
901
|
+
to release resources immediately rather than waiting for garbage collection
|
|
902
|
+
|
|
903
|
+
Args:
|
|
904
|
+
sql (str): The SQL query to execute.
|
|
905
|
+
*args: Parameters to be passed to the query.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
Cursor: A new cursor with the executed query.
|
|
909
|
+
|
|
910
|
+
Raises:
|
|
911
|
+
DatabaseError: If there is an error executing the query.
|
|
912
|
+
InterfaceError: If the connection is closed.
|
|
913
|
+
|
|
914
|
+
Example:
|
|
915
|
+
# Automatic cleanup (cursor goes out of scope after the operation)
|
|
916
|
+
row = connection.execute("SELECT name FROM users WHERE id = ?", 123).fetchone()
|
|
917
|
+
|
|
918
|
+
# Manual cleanup for more explicit resource management
|
|
919
|
+
cursor = connection.execute("SELECT * FROM large_table")
|
|
920
|
+
try:
|
|
921
|
+
# Use cursor...
|
|
922
|
+
rows = cursor.fetchall()
|
|
923
|
+
finally:
|
|
924
|
+
cursor.close() # Explicitly release resources
|
|
925
|
+
"""
|
|
926
|
+
cursor = self.cursor()
|
|
927
|
+
try:
|
|
928
|
+
# Add the cursor to our tracking set BEFORE execution
|
|
929
|
+
# This ensures it's tracked even if execution fails
|
|
930
|
+
self._cursors.add(cursor)
|
|
931
|
+
|
|
932
|
+
# Now execute the query
|
|
933
|
+
cursor.execute(sql, *args)
|
|
934
|
+
return cursor
|
|
935
|
+
except Exception:
|
|
936
|
+
# If execution fails, close the cursor to avoid leaking resources
|
|
937
|
+
cursor.close()
|
|
938
|
+
raise
|
|
939
|
+
|
|
940
|
+
def batch_execute(
|
|
941
|
+
self,
|
|
942
|
+
statements: List[str],
|
|
943
|
+
params: Optional[List[Union[None, Any, Tuple[Any, ...], List[Any]]]] = None,
|
|
944
|
+
reuse_cursor: Optional[Cursor] = None,
|
|
945
|
+
auto_close: bool = False,
|
|
946
|
+
) -> Tuple[List[Union[List["Row"], int]], Cursor]:
|
|
947
|
+
"""
|
|
948
|
+
Execute multiple SQL statements efficiently using a single cursor.
|
|
949
|
+
|
|
950
|
+
This method allows executing multiple SQL statements in sequence using a single
|
|
951
|
+
cursor, which is more efficient than creating a new cursor for each statement.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
statements (list): List of SQL statements to execute
|
|
955
|
+
params (list, optional): List of parameter sets corresponding to statements.
|
|
956
|
+
Each item can be None, a single parameter, or a sequence of parameters.
|
|
957
|
+
If None, no parameters will be used for any statement.
|
|
958
|
+
reuse_cursor (Cursor, optional): Existing cursor to reuse instead of creating a new one.
|
|
959
|
+
If None, a new cursor will be created.
|
|
960
|
+
auto_close (bool): Whether to close the cursor after execution if a new one was created.
|
|
961
|
+
Defaults to False. Has no effect if reuse_cursor is provided.
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
tuple: (results, cursor) where:
|
|
965
|
+
- results is a list of execution results, one for each statement
|
|
966
|
+
- cursor is the cursor used for execution (useful if you want to keep using it)
|
|
967
|
+
|
|
968
|
+
Raises:
|
|
969
|
+
TypeError: If statements is not a list or if params is provided but not a list
|
|
970
|
+
ValueError: If params is provided but has different length than statements
|
|
971
|
+
DatabaseError: If there is an error executing any of the statements
|
|
972
|
+
InterfaceError: If the connection is closed
|
|
973
|
+
|
|
974
|
+
Example:
|
|
975
|
+
# Execute multiple statements with a single cursor
|
|
976
|
+
results, _ = conn.batch_execute([
|
|
977
|
+
"INSERT INTO users VALUES (?, ?)",
|
|
978
|
+
"UPDATE stats SET count = count + 1",
|
|
979
|
+
"SELECT * FROM users"
|
|
980
|
+
], [
|
|
981
|
+
(1, "user1"),
|
|
982
|
+
None,
|
|
983
|
+
None
|
|
984
|
+
])
|
|
985
|
+
|
|
986
|
+
# Last result contains the SELECT results
|
|
987
|
+
for row in results[-1]:
|
|
988
|
+
print(row)
|
|
989
|
+
|
|
990
|
+
# Reuse an existing cursor
|
|
991
|
+
my_cursor = conn.cursor()
|
|
992
|
+
results, _ = conn.batch_execute([
|
|
993
|
+
"SELECT * FROM table1",
|
|
994
|
+
"SELECT * FROM table2"
|
|
995
|
+
], reuse_cursor=my_cursor)
|
|
996
|
+
|
|
997
|
+
# Cursor remains open for further use
|
|
998
|
+
my_cursor.execute("SELECT * FROM table3")
|
|
999
|
+
"""
|
|
1000
|
+
# Validate inputs
|
|
1001
|
+
if not isinstance(statements, list):
|
|
1002
|
+
raise TypeError("statements must be a list of SQL statements")
|
|
1003
|
+
|
|
1004
|
+
if params is not None:
|
|
1005
|
+
if not isinstance(params, list):
|
|
1006
|
+
raise TypeError("params must be a list of parameter sets")
|
|
1007
|
+
if len(params) != len(statements):
|
|
1008
|
+
raise ValueError(
|
|
1009
|
+
"params list must have the same length as statements list"
|
|
1010
|
+
)
|
|
1011
|
+
else:
|
|
1012
|
+
# Create a list of None values with the same length as statements
|
|
1013
|
+
params = [None] * len(statements)
|
|
1014
|
+
|
|
1015
|
+
# Determine which cursor to use
|
|
1016
|
+
is_new_cursor = reuse_cursor is None
|
|
1017
|
+
cursor = self.cursor() if is_new_cursor else reuse_cursor
|
|
1018
|
+
|
|
1019
|
+
# Execute statements and collect results
|
|
1020
|
+
results = []
|
|
1021
|
+
try:
|
|
1022
|
+
for i, (stmt, param) in enumerate(zip(statements, params)):
|
|
1023
|
+
try:
|
|
1024
|
+
# Execute the statement with parameters if provided
|
|
1025
|
+
if param is not None:
|
|
1026
|
+
cursor.execute(stmt, param)
|
|
1027
|
+
else:
|
|
1028
|
+
cursor.execute(stmt)
|
|
1029
|
+
|
|
1030
|
+
# For SELECT statements, fetch all rows
|
|
1031
|
+
# For other statements, get the row count
|
|
1032
|
+
if cursor.description is not None:
|
|
1033
|
+
# This is a SELECT statement or similar that returns rows
|
|
1034
|
+
results.append(cursor.fetchall())
|
|
1035
|
+
else:
|
|
1036
|
+
# This is an INSERT, UPDATE, DELETE or similar that doesn't return rows
|
|
1037
|
+
results.append(cursor.rowcount)
|
|
1038
|
+
|
|
1039
|
+
logger.debug( f"Executed batch statement {i+1}/{len(statements)}")
|
|
1040
|
+
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
# If a statement fails, include statement context in the error
|
|
1043
|
+
logger.debug(
|
|
1044
|
+
"error",
|
|
1045
|
+
f"Error executing statement {i+1}/{len(statements)}: {e}",
|
|
1046
|
+
)
|
|
1047
|
+
raise
|
|
1048
|
+
|
|
1049
|
+
except Exception:
|
|
1050
|
+
# If an error occurs and auto_close is True, close the cursor
|
|
1051
|
+
if auto_close:
|
|
1052
|
+
try:
|
|
1053
|
+
# Close the cursor regardless of whether it's reused or new
|
|
1054
|
+
cursor.close()
|
|
1055
|
+
logger.debug(
|
|
1056
|
+
"debug",
|
|
1057
|
+
"Automatically closed cursor after batch execution error",
|
|
1058
|
+
)
|
|
1059
|
+
except Exception as close_err:
|
|
1060
|
+
logger.debug(
|
|
1061
|
+
"warning",
|
|
1062
|
+
f"Error closing cursor after execution failure: {close_err}",
|
|
1063
|
+
)
|
|
1064
|
+
# Re-raise the original exception
|
|
1065
|
+
raise
|
|
1066
|
+
|
|
1067
|
+
# Close the cursor if requested and we created a new one
|
|
1068
|
+
if is_new_cursor and auto_close:
|
|
1069
|
+
cursor.close()
|
|
1070
|
+
logger.debug( "Automatically closed cursor after batch execution")
|
|
1071
|
+
|
|
1072
|
+
return results, cursor
|
|
1073
|
+
|
|
1074
|
+
def getinfo(self, info_type: int) -> Union[str, int, bool, None]:
|
|
1075
|
+
"""
|
|
1076
|
+
Return general information about the driver and data source.
|
|
1077
|
+
|
|
1078
|
+
Args:
|
|
1079
|
+
info_type (int): The type of information to return. See the ODBC
|
|
1080
|
+
SQLGetInfo documentation for the supported values.
|
|
1081
|
+
|
|
1082
|
+
Returns:
|
|
1083
|
+
The requested information. The type of the returned value depends
|
|
1084
|
+
on the information requested. It will be a string, integer, or boolean.
|
|
1085
|
+
|
|
1086
|
+
Raises:
|
|
1087
|
+
DatabaseError: If there is an error retrieving the information.
|
|
1088
|
+
InterfaceError: If the connection is closed.
|
|
1089
|
+
"""
|
|
1090
|
+
if self._closed:
|
|
1091
|
+
raise InterfaceError(
|
|
1092
|
+
driver_error="Cannot get info on closed connection",
|
|
1093
|
+
ddbc_error="Cannot get info on closed connection",
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
# Check that info_type is an integer
|
|
1097
|
+
if not isinstance(info_type, int):
|
|
1098
|
+
raise ValueError(
|
|
1099
|
+
f"info_type must be an integer, got {type(info_type).__name__}"
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
# Check for invalid info_type values
|
|
1103
|
+
if info_type < 0:
|
|
1104
|
+
logger.debug(
|
|
1105
|
+
"warning",
|
|
1106
|
+
f"Invalid info_type: {info_type}. Must be a positive integer.",
|
|
1107
|
+
)
|
|
1108
|
+
return None
|
|
1109
|
+
|
|
1110
|
+
# Get the raw result from the C++ layer
|
|
1111
|
+
try:
|
|
1112
|
+
raw_result = self._conn.get_info(info_type)
|
|
1113
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1114
|
+
# Log the error and return None for invalid info types
|
|
1115
|
+
logger.warning( f"getinfo({info_type}) failed: {e}")
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
if raw_result is None:
|
|
1119
|
+
return None
|
|
1120
|
+
|
|
1121
|
+
# Check if the result is already a simple type
|
|
1122
|
+
if isinstance(raw_result, (str, int, bool)):
|
|
1123
|
+
return raw_result
|
|
1124
|
+
|
|
1125
|
+
# If it's a dictionary with data and metadata
|
|
1126
|
+
if isinstance(raw_result, dict) and "data" in raw_result:
|
|
1127
|
+
# Extract data and metadata from the raw result
|
|
1128
|
+
data = raw_result["data"]
|
|
1129
|
+
length = raw_result["length"]
|
|
1130
|
+
|
|
1131
|
+
# Debug logging to understand the issue better
|
|
1132
|
+
logger.debug(
|
|
1133
|
+
"debug",
|
|
1134
|
+
f"getinfo: info_type={info_type}, length={length}, data_type={type(data)}",
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
# Define constants for different return types
|
|
1138
|
+
# String types - these return strings in pyodbc
|
|
1139
|
+
string_type_constants = {
|
|
1140
|
+
GetInfoConstants.SQL_DATA_SOURCE_NAME.value,
|
|
1141
|
+
GetInfoConstants.SQL_DRIVER_NAME.value,
|
|
1142
|
+
GetInfoConstants.SQL_DRIVER_VER.value,
|
|
1143
|
+
GetInfoConstants.SQL_SERVER_NAME.value,
|
|
1144
|
+
GetInfoConstants.SQL_USER_NAME.value,
|
|
1145
|
+
GetInfoConstants.SQL_DRIVER_ODBC_VER.value,
|
|
1146
|
+
GetInfoConstants.SQL_IDENTIFIER_QUOTE_CHAR.value,
|
|
1147
|
+
GetInfoConstants.SQL_CATALOG_NAME_SEPARATOR.value,
|
|
1148
|
+
GetInfoConstants.SQL_CATALOG_TERM.value,
|
|
1149
|
+
GetInfoConstants.SQL_SCHEMA_TERM.value,
|
|
1150
|
+
GetInfoConstants.SQL_TABLE_TERM.value,
|
|
1151
|
+
GetInfoConstants.SQL_KEYWORDS.value,
|
|
1152
|
+
GetInfoConstants.SQL_PROCEDURE_TERM.value,
|
|
1153
|
+
GetInfoConstants.SQL_SPECIAL_CHARACTERS.value,
|
|
1154
|
+
GetInfoConstants.SQL_SEARCH_PATTERN_ESCAPE.value,
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
# Boolean 'Y'/'N' types
|
|
1158
|
+
yn_type_constants = {
|
|
1159
|
+
GetInfoConstants.SQL_ACCESSIBLE_PROCEDURES.value,
|
|
1160
|
+
GetInfoConstants.SQL_ACCESSIBLE_TABLES.value,
|
|
1161
|
+
GetInfoConstants.SQL_DATA_SOURCE_READ_ONLY.value,
|
|
1162
|
+
GetInfoConstants.SQL_EXPRESSIONS_IN_ORDERBY.value,
|
|
1163
|
+
GetInfoConstants.SQL_LIKE_ESCAPE_CLAUSE.value,
|
|
1164
|
+
GetInfoConstants.SQL_MULTIPLE_ACTIVE_TXN.value,
|
|
1165
|
+
GetInfoConstants.SQL_NEED_LONG_DATA_LEN.value,
|
|
1166
|
+
GetInfoConstants.SQL_PROCEDURES.value,
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
# Numeric type constants that return integers
|
|
1170
|
+
numeric_type_constants = {
|
|
1171
|
+
GetInfoConstants.SQL_MAX_COLUMN_NAME_LEN.value,
|
|
1172
|
+
GetInfoConstants.SQL_MAX_TABLE_NAME_LEN.value,
|
|
1173
|
+
GetInfoConstants.SQL_MAX_SCHEMA_NAME_LEN.value,
|
|
1174
|
+
GetInfoConstants.SQL_MAX_CATALOG_NAME_LEN.value,
|
|
1175
|
+
GetInfoConstants.SQL_MAX_IDENTIFIER_LEN.value,
|
|
1176
|
+
GetInfoConstants.SQL_MAX_STATEMENT_LEN.value,
|
|
1177
|
+
GetInfoConstants.SQL_MAX_DRIVER_CONNECTIONS.value,
|
|
1178
|
+
GetInfoConstants.SQL_NUMERIC_FUNCTIONS.value,
|
|
1179
|
+
GetInfoConstants.SQL_STRING_FUNCTIONS.value,
|
|
1180
|
+
GetInfoConstants.SQL_DATETIME_FUNCTIONS.value,
|
|
1181
|
+
GetInfoConstants.SQL_TXN_CAPABLE.value,
|
|
1182
|
+
GetInfoConstants.SQL_DEFAULT_TXN_ISOLATION.value,
|
|
1183
|
+
GetInfoConstants.SQL_CURSOR_COMMIT_BEHAVIOR.value,
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
# Determine the type of information we're dealing with
|
|
1187
|
+
is_string_type = (
|
|
1188
|
+
info_type > INFO_TYPE_STRING_THRESHOLD
|
|
1189
|
+
or info_type in string_type_constants
|
|
1190
|
+
)
|
|
1191
|
+
is_yn_type = info_type in yn_type_constants
|
|
1192
|
+
is_numeric_type = info_type in numeric_type_constants
|
|
1193
|
+
|
|
1194
|
+
# Process the data based on type
|
|
1195
|
+
if is_string_type:
|
|
1196
|
+
# For string data, ensure we properly handle the byte array
|
|
1197
|
+
if isinstance(data, bytes):
|
|
1198
|
+
# Make sure we use the correct amount of data based on length
|
|
1199
|
+
actual_data = data[:length]
|
|
1200
|
+
|
|
1201
|
+
# Now decode the string data
|
|
1202
|
+
try:
|
|
1203
|
+
return actual_data.decode("utf-8").rstrip("\0")
|
|
1204
|
+
except UnicodeDecodeError:
|
|
1205
|
+
try:
|
|
1206
|
+
return actual_data.decode("latin1").rstrip("\0")
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
logger.debug(
|
|
1209
|
+
"error",
|
|
1210
|
+
"Failed to decode string in getinfo: %s. "
|
|
1211
|
+
"Returning None to avoid silent corruption.",
|
|
1212
|
+
e,
|
|
1213
|
+
)
|
|
1214
|
+
# Explicitly return None to signal decoding failure
|
|
1215
|
+
return None
|
|
1216
|
+
else:
|
|
1217
|
+
# If it's not bytes, return as is
|
|
1218
|
+
return data
|
|
1219
|
+
elif is_yn_type:
|
|
1220
|
+
# For Y/N types, pyodbc returns a string 'Y' or 'N'
|
|
1221
|
+
if isinstance(data, bytes) and length >= 1:
|
|
1222
|
+
byte_val = data[0]
|
|
1223
|
+
if byte_val in (b"Y"[0], b"y"[0], 1):
|
|
1224
|
+
return "Y"
|
|
1225
|
+
return "N"
|
|
1226
|
+
# If it's not a byte or we can't determine, default to 'N'
|
|
1227
|
+
return "N"
|
|
1228
|
+
elif is_numeric_type:
|
|
1229
|
+
# Handle numeric types based on length
|
|
1230
|
+
if isinstance(data, bytes):
|
|
1231
|
+
# Map byte length → signed int size
|
|
1232
|
+
int_sizes = {
|
|
1233
|
+
1: lambda d: int(d[0]),
|
|
1234
|
+
2: lambda d: int.from_bytes(d[:2], "little", signed=True),
|
|
1235
|
+
4: lambda d: int.from_bytes(d[:4], "little", signed=True),
|
|
1236
|
+
8: lambda d: int.from_bytes(d[:8], "little", signed=True),
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
# Direct numeric conversion if supported length
|
|
1240
|
+
if length in int_sizes:
|
|
1241
|
+
result = int_sizes[length](data)
|
|
1242
|
+
return int(result)
|
|
1243
|
+
|
|
1244
|
+
# Helper: check if all chars are digits
|
|
1245
|
+
def is_digit_bytes(b: bytes) -> bool:
|
|
1246
|
+
return all(c in b"0123456789" for c in b)
|
|
1247
|
+
|
|
1248
|
+
# Helper: check if bytes are ASCII-printable or NUL padded
|
|
1249
|
+
def is_printable_bytes(b: bytes) -> bool:
|
|
1250
|
+
return all(32 <= c <= 126 or c == 0 for c in b)
|
|
1251
|
+
|
|
1252
|
+
chunk = data[:length]
|
|
1253
|
+
|
|
1254
|
+
# Try interpret as integer string
|
|
1255
|
+
if is_digit_bytes(chunk):
|
|
1256
|
+
return int(chunk)
|
|
1257
|
+
|
|
1258
|
+
# Try decode as ASCII/UTF-8 string
|
|
1259
|
+
if is_printable_bytes(chunk):
|
|
1260
|
+
str_val = chunk.decode("utf-8", errors="replace").rstrip("\0")
|
|
1261
|
+
return int(str_val) if str_val.isdigit() else str_val
|
|
1262
|
+
|
|
1263
|
+
# For 16-bit values that might be returned for max lengths
|
|
1264
|
+
if length == 2:
|
|
1265
|
+
return int.from_bytes(data[:2], "little", signed=True)
|
|
1266
|
+
|
|
1267
|
+
# For 32-bit values (common for bitwise flags)
|
|
1268
|
+
if length == 4:
|
|
1269
|
+
return int.from_bytes(data[:4], "little", signed=True)
|
|
1270
|
+
|
|
1271
|
+
# Fallback: try to convert to int if possible
|
|
1272
|
+
try:
|
|
1273
|
+
if length <= 8:
|
|
1274
|
+
return int.from_bytes(data[:length], "little", signed=True)
|
|
1275
|
+
except Exception:
|
|
1276
|
+
pass
|
|
1277
|
+
|
|
1278
|
+
# Last resort: return as integer if all else fails
|
|
1279
|
+
try:
|
|
1280
|
+
return int.from_bytes(
|
|
1281
|
+
data[: min(length, 8)], "little", signed=True
|
|
1282
|
+
)
|
|
1283
|
+
except Exception:
|
|
1284
|
+
return 0
|
|
1285
|
+
elif isinstance(data, (int, float)):
|
|
1286
|
+
# Already numeric
|
|
1287
|
+
return int(data)
|
|
1288
|
+
else:
|
|
1289
|
+
# Try to convert to int if it's a string
|
|
1290
|
+
try:
|
|
1291
|
+
if isinstance(data, str) and data.isdigit():
|
|
1292
|
+
return int(data)
|
|
1293
|
+
except Exception:
|
|
1294
|
+
pass
|
|
1295
|
+
|
|
1296
|
+
# Return as is if we can't convert
|
|
1297
|
+
return data
|
|
1298
|
+
|
|
1299
|
+
# For other types, try to determine the most appropriate type
|
|
1300
|
+
if isinstance(data, bytes):
|
|
1301
|
+
# Try to convert to string first
|
|
1302
|
+
try:
|
|
1303
|
+
return data[:length].decode("utf-8").rstrip("\0")
|
|
1304
|
+
except UnicodeDecodeError:
|
|
1305
|
+
pass
|
|
1306
|
+
|
|
1307
|
+
# Try to convert to int for short binary data
|
|
1308
|
+
try:
|
|
1309
|
+
if length <= 8:
|
|
1310
|
+
return int.from_bytes(data[:length], "little", signed=True)
|
|
1311
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
1312
|
+
pass
|
|
1313
|
+
|
|
1314
|
+
# Return as is if we can't determine
|
|
1315
|
+
return data
|
|
1316
|
+
|
|
1317
|
+
return data
|
|
1318
|
+
|
|
1319
|
+
return raw_result # Return as-is
|
|
1320
|
+
|
|
1321
|
+
def commit(self) -> None:
|
|
1322
|
+
"""
|
|
1323
|
+
Commit the current transaction.
|
|
1324
|
+
|
|
1325
|
+
This method commits the current transaction to the database, making all
|
|
1326
|
+
changes made during the transaction permanent. It should be called after
|
|
1327
|
+
executing a series of SQL statements that modify the database to ensure
|
|
1328
|
+
that the changes are saved.
|
|
1329
|
+
|
|
1330
|
+
Raises:
|
|
1331
|
+
InterfaceError: If the connection is closed.
|
|
1332
|
+
DatabaseError: If there is an error while committing the transaction.
|
|
1333
|
+
"""
|
|
1334
|
+
# Check if connection is closed
|
|
1335
|
+
if self._closed or self._conn is None:
|
|
1336
|
+
raise InterfaceError(
|
|
1337
|
+
driver_error="Cannot commit on a closed connection",
|
|
1338
|
+
ddbc_error="Cannot commit on a closed connection",
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
# Commit the current transaction
|
|
1342
|
+
self._conn.commit()
|
|
1343
|
+
logger.info( "Transaction committed successfully.")
|
|
1344
|
+
|
|
1345
|
+
def rollback(self) -> None:
|
|
1346
|
+
"""
|
|
1347
|
+
Roll back the current transaction.
|
|
1348
|
+
|
|
1349
|
+
This method rolls back the current transaction, undoing all changes made
|
|
1350
|
+
during the transaction. It should be called if an error occurs during the
|
|
1351
|
+
transaction or if the changes should not be saved.
|
|
1352
|
+
|
|
1353
|
+
Raises:
|
|
1354
|
+
InterfaceError: If the connection is closed.
|
|
1355
|
+
DatabaseError: If there is an error while rolling back the transaction.
|
|
1356
|
+
"""
|
|
1357
|
+
# Check if connection is closed
|
|
1358
|
+
if self._closed or self._conn is None:
|
|
1359
|
+
raise InterfaceError(
|
|
1360
|
+
driver_error="Cannot rollback on a closed connection",
|
|
1361
|
+
ddbc_error="Cannot rollback on a closed connection",
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
# Roll back the current transaction
|
|
1365
|
+
self._conn.rollback()
|
|
1366
|
+
logger.info( "Transaction rolled back successfully.")
|
|
1367
|
+
|
|
1368
|
+
def close(self) -> None:
|
|
1369
|
+
"""
|
|
1370
|
+
Close the connection now (rather than whenever .__del__() is called).
|
|
1371
|
+
|
|
1372
|
+
This method closes the connection to the database, releasing any resources
|
|
1373
|
+
associated with it. After calling this method, the connection object should
|
|
1374
|
+
not be used for any further operations. The same applies to all cursor objects
|
|
1375
|
+
trying to use the connection. Note that closing a connection without committing
|
|
1376
|
+
the changes first will cause an implicit rollback to be performed.
|
|
1377
|
+
|
|
1378
|
+
Raises:
|
|
1379
|
+
DatabaseError: If there is an error while closing the connection.
|
|
1380
|
+
"""
|
|
1381
|
+
# Close the connection
|
|
1382
|
+
if self._closed:
|
|
1383
|
+
return
|
|
1384
|
+
|
|
1385
|
+
# Close all cursors first, but don't let one failure stop the others
|
|
1386
|
+
if hasattr(self, "_cursors"):
|
|
1387
|
+
# Convert to list to avoid modification during iteration
|
|
1388
|
+
cursors_to_close = list(self._cursors)
|
|
1389
|
+
close_errors = []
|
|
1390
|
+
|
|
1391
|
+
for cursor in cursors_to_close:
|
|
1392
|
+
try:
|
|
1393
|
+
if not cursor.closed:
|
|
1394
|
+
cursor.close()
|
|
1395
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1396
|
+
# Collect errors but continue closing other cursors
|
|
1397
|
+
close_errors.append(f"Error closing cursor: {e}")
|
|
1398
|
+
logger.warning( f"Error closing cursor: {e}")
|
|
1399
|
+
|
|
1400
|
+
# If there were errors closing cursors, log them but continue
|
|
1401
|
+
if close_errors:
|
|
1402
|
+
logger.debug(
|
|
1403
|
+
"warning",
|
|
1404
|
+
"Encountered %d errors while closing cursors",
|
|
1405
|
+
len(close_errors),
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
# Clear the cursor set explicitly to release any internal
|
|
1409
|
+
# references
|
|
1410
|
+
self._cursors.clear()
|
|
1411
|
+
|
|
1412
|
+
# Close the connection even if cursor cleanup had issues
|
|
1413
|
+
try:
|
|
1414
|
+
if self._conn:
|
|
1415
|
+
if not self.autocommit:
|
|
1416
|
+
# If autocommit is disabled, rollback any uncommitted changes
|
|
1417
|
+
# This is important to ensure no partial transactions remain
|
|
1418
|
+
# For autocommit True, this is not necessary as each statement is
|
|
1419
|
+
# committed immediately
|
|
1420
|
+
logger.debug("Rolling back uncommitted changes before closing connection.")
|
|
1421
|
+
self._conn.rollback()
|
|
1422
|
+
# TODO: Check potential race conditions in case of multithreaded scenarios
|
|
1423
|
+
# Close the connection
|
|
1424
|
+
self._conn.close()
|
|
1425
|
+
self._conn = None
|
|
1426
|
+
except Exception as e:
|
|
1427
|
+
logger.error( f"Error closing database connection: {e}")
|
|
1428
|
+
# Re-raise the connection close error as it's more critical
|
|
1429
|
+
raise
|
|
1430
|
+
finally:
|
|
1431
|
+
# Always mark as closed, even if there were errors
|
|
1432
|
+
self._closed = True
|
|
1433
|
+
|
|
1434
|
+
logger.info( "Connection closed successfully.")
|
|
1435
|
+
|
|
1436
|
+
def _remove_cursor(self, cursor: Cursor) -> None:
|
|
1437
|
+
"""
|
|
1438
|
+
Remove a cursor from the connection's tracking.
|
|
1439
|
+
|
|
1440
|
+
This method is called when a cursor is closed to ensure proper cleanup.
|
|
1441
|
+
|
|
1442
|
+
Args:
|
|
1443
|
+
cursor: The cursor to remove from tracking.
|
|
1444
|
+
"""
|
|
1445
|
+
if hasattr(self, "_cursors"):
|
|
1446
|
+
try:
|
|
1447
|
+
self._cursors.discard(cursor)
|
|
1448
|
+
except Exception:
|
|
1449
|
+
pass # Ignore errors during cleanup
|
|
1450
|
+
|
|
1451
|
+
def __enter__(self) -> "Connection":
|
|
1452
|
+
"""
|
|
1453
|
+
Enter the context manager.
|
|
1454
|
+
|
|
1455
|
+
This method enables the Connection to be used with the 'with' statement.
|
|
1456
|
+
When entering the context, it simply returns the connection object itself.
|
|
1457
|
+
|
|
1458
|
+
Returns:
|
|
1459
|
+
Connection: The connection object itself.
|
|
1460
|
+
|
|
1461
|
+
Example:
|
|
1462
|
+
with connect(connection_string) as conn:
|
|
1463
|
+
cursor = conn.cursor()
|
|
1464
|
+
cursor.execute("INSERT INTO table VALUES (?)", [value])
|
|
1465
|
+
# Transaction will be committed automatically when exiting
|
|
1466
|
+
"""
|
|
1467
|
+
logger.info( "Entering connection context manager.")
|
|
1468
|
+
return self
|
|
1469
|
+
|
|
1470
|
+
def __exit__(self, *args: Any) -> None:
|
|
1471
|
+
"""
|
|
1472
|
+
Exit the context manager.
|
|
1473
|
+
|
|
1474
|
+
Closes the connection when exiting the context, ensuring proper
|
|
1475
|
+
resource cleanup. This follows the modern standard used by most
|
|
1476
|
+
database libraries.
|
|
1477
|
+
"""
|
|
1478
|
+
if not self._closed:
|
|
1479
|
+
self.close()
|
|
1480
|
+
|
|
1481
|
+
def __del__(self) -> None:
|
|
1482
|
+
"""
|
|
1483
|
+
Destructor to ensure the connection is closed when the connection object
|
|
1484
|
+
is no longer needed.
|
|
1485
|
+
This is a safety net to ensure resources are cleaned up
|
|
1486
|
+
even if close() was not called explicitly.
|
|
1487
|
+
"""
|
|
1488
|
+
if "_closed" not in self.__dict__ or not self._closed:
|
|
1489
|
+
try:
|
|
1490
|
+
self.close()
|
|
1491
|
+
except Exception as e:
|
|
1492
|
+
# Dont raise exceptions from __del__ to avoid issues during garbage collection
|
|
1493
|
+
logger.warning( f"Error during connection cleanup: {e}")
|