mssql-python 1.16.0__cp315-cp315-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 +662 -0
- mssql_python/async_query/__init__.py +40 -0
- mssql_python/async_query/_connection_context.py +39 -0
- mssql_python/async_query/_native.py +23 -0
- mssql_python/async_query/async_connection.py +160 -0
- mssql_python/async_query/async_cursor.py +230 -0
- mssql_python/async_query/async_execute.py +115 -0
- mssql_python/async_query/async_fetch.py +134 -0
- mssql_python/async_query/exception_translator.py +114 -0
- mssql_python/auth.py +913 -0
- mssql_python/connection.py +2357 -0
- mssql_python/connection_string_builder.py +114 -0
- mssql_python/connection_string_parser.py +424 -0
- mssql_python/constants.py +701 -0
- mssql_python/cursor.py +4188 -0
- mssql_python/db_connection.py +98 -0
- mssql_python/ddbc_bindings.cp315-amd64.pyd +0 -0
- mssql_python/ddbc_bindings.py +215 -0
- mssql_python/decimal_config.py +114 -0
- mssql_python/exceptions.py +588 -0
- mssql_python/helpers.py +416 -0
- mssql_python/logging.py +714 -0
- mssql_python/mssql_python.pyi +481 -0
- mssql_python/msvcp140.dll +0 -0
- mssql_python/odbc_provider.py +251 -0
- mssql_python/parameter_helper.py +488 -0
- mssql_python/perf_timer.py +286 -0
- mssql_python/pooling.py +151 -0
- mssql_python/py.typed +1 -0
- mssql_python/row.py +398 -0
- mssql_python/type.py +160 -0
- mssql_python-1.16.0.dist-info/METADATA +102 -0
- mssql_python-1.16.0.dist-info/RECORD +36 -0
- mssql_python-1.16.0.dist-info/WHEEL +5 -0
- mssql_python-1.16.0.dist-info/licenses/LICENSE +47 -0
- mssql_python-1.16.0.dist-info/top_level.txt +1 -0
mssql_python/cursor.py
ADDED
|
@@ -0,0 +1,4188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation.
|
|
3
|
+
Licensed under the MIT license.
|
|
4
|
+
This module contains the Cursor class, which represents a database cursor.
|
|
5
|
+
Resource Management:
|
|
6
|
+
- Cursors are tracked by their parent connection.
|
|
7
|
+
- Closing the connection will automatically close all open cursors.
|
|
8
|
+
- Do not use a cursor after it is closed, or after its parent connection is closed.
|
|
9
|
+
- Use close() to release resources held by the cursor as soon as it is no longer needed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# pylint: disable=too-many-lines # Large file due to comprehensive DB-API 2.0 implementation
|
|
13
|
+
|
|
14
|
+
import decimal
|
|
15
|
+
import logging
|
|
16
|
+
import uuid
|
|
17
|
+
import datetime
|
|
18
|
+
import warnings
|
|
19
|
+
from typing import List, Mapping, Union, Any, Optional, Tuple, Sequence, TYPE_CHECKING, Iterable
|
|
20
|
+
from mssql_python.constants import ConstantsDDBC as ddbc_sql_const, SQLTypes
|
|
21
|
+
from mssql_python.helpers import check_error, connstr_to_pycore_params
|
|
22
|
+
from mssql_python.logging import logger
|
|
23
|
+
from mssql_python import ddbc_bindings
|
|
24
|
+
from mssql_python.exceptions import (
|
|
25
|
+
InterfaceError,
|
|
26
|
+
NotSupportedError,
|
|
27
|
+
ProgrammingError,
|
|
28
|
+
OperationalError,
|
|
29
|
+
DatabaseError,
|
|
30
|
+
)
|
|
31
|
+
from mssql_python.row import Row
|
|
32
|
+
from mssql_python.perf_timer import perf_phase
|
|
33
|
+
from mssql_python import get_settings
|
|
34
|
+
from mssql_python.parameter_helper import (
|
|
35
|
+
detect_and_convert_parameters,
|
|
36
|
+
parse_pyformat_params,
|
|
37
|
+
convert_pyformat_to_qmark,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
import pyarrow # type: ignore
|
|
42
|
+
from mssql_python.connection import Connection
|
|
43
|
+
else:
|
|
44
|
+
pyarrow = None
|
|
45
|
+
|
|
46
|
+
# Constants for string handling
|
|
47
|
+
MAX_INLINE_CHAR: int = (
|
|
48
|
+
4000 # NVARCHAR/VARCHAR inline limit; this triggers NVARCHAR(MAX)/VARCHAR(MAX) + DAE
|
|
49
|
+
)
|
|
50
|
+
SMALLMONEY_MIN: decimal.Decimal = decimal.Decimal("-214748.3648")
|
|
51
|
+
SMALLMONEY_MAX: decimal.Decimal = decimal.Decimal("214748.3647")
|
|
52
|
+
MONEY_MIN: decimal.Decimal = decimal.Decimal("-922337203685477.5808")
|
|
53
|
+
MONEY_MAX: decimal.Decimal = decimal.Decimal("922337203685477.5807")
|
|
54
|
+
# SQL BIGINT is a signed 64-bit integer. Ints outside this range have no BIGINT
|
|
55
|
+
# encoding and must be rejected at detect time on both paths (see _map_sql_type).
|
|
56
|
+
BIGINT_MIN: int = -(2**63)
|
|
57
|
+
BIGINT_MAX: int = 2**63 - 1
|
|
58
|
+
ODBC3_TEMPORAL_SQL_TYPES = {
|
|
59
|
+
ddbc_sql_const.SQL_DATE.value: ddbc_sql_const.SQL_TYPE_DATE.value,
|
|
60
|
+
ddbc_sql_const.SQL_TIME.value: ddbc_sql_const.SQL_TYPE_TIME.value,
|
|
61
|
+
ddbc_sql_const.SQL_TIMESTAMP.value: ddbc_sql_const.SQL_TYPE_TIMESTAMP.value,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _string_only_output_converter(converter):
|
|
66
|
+
"""Gate fallback conversion on the fetched value, not its column metadata."""
|
|
67
|
+
|
|
68
|
+
def convert(value):
|
|
69
|
+
if isinstance(value, (str, bytes)):
|
|
70
|
+
return converter(value)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
return convert
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_time_param(value, c_type):
|
|
77
|
+
"""Convert a datetime.time to its isoformat string when bound via text C-types.
|
|
78
|
+
|
|
79
|
+
Returns the isoformat string if conversion applies, otherwise *None*.
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(value, datetime.time) and c_type in (
|
|
82
|
+
ddbc_sql_const.SQL_C_CHAR.value,
|
|
83
|
+
ddbc_sql_const.SQL_C_WCHAR.value,
|
|
84
|
+
):
|
|
85
|
+
return value.isoformat(timespec="microseconds")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _ArrowReader:
|
|
90
|
+
"""RecordBatchReader-compatible wrapper that makes ``close()`` actually
|
|
91
|
+
release server-side resources.
|
|
92
|
+
|
|
93
|
+
``pyarrow.RecordBatchReader.from_batches(...)`` returns a reader whose
|
|
94
|
+
``close()`` only releases the internal ArrowArrayStream — it does **not**
|
|
95
|
+
propagate into the underlying Python generator and does **not** stop the
|
|
96
|
+
server-side ODBC cursor. This wrapper closes that gap.
|
|
97
|
+
|
|
98
|
+
Interoperability: this class exposes ``__arrow_c_stream__`` (Arrow
|
|
99
|
+
PyCapsule Protocol, pyarrow >= 14), so Arrow-aware consumers
|
|
100
|
+
(``pyarrow.RecordBatchReader.from_stream``, ``polars.from_arrow``,
|
|
101
|
+
``duckdb.from_arrow``, etc.) can accept it directly without any
|
|
102
|
+
``isinstance(x, pyarrow.RecordBatchReader)`` check. Subclassing
|
|
103
|
+
``pyarrow.RecordBatchReader`` (a Cython extension type) isn't a viable
|
|
104
|
+
alternative because its ``from_batches`` factory returns the base class
|
|
105
|
+
regardless of the subclass, ``__class__`` reassignment is rejected on
|
|
106
|
+
Cython types, and instances cannot hold arbitrary Python attributes —
|
|
107
|
+
so a subclass could not carry the cursor/generator refs this wrapper
|
|
108
|
+
needs for cancellation semantics.
|
|
109
|
+
|
|
110
|
+
Design (optimized):
|
|
111
|
+
* The Python generator backing the reader carries its own ``try/finally``
|
|
112
|
+
block — so server-side cleanup runs symmetrically whether the user
|
|
113
|
+
exhausts the reader, calls ``close()`` mid-iteration, exits a ``with``
|
|
114
|
+
block, or just lets the reader be garbage-collected. ``close()``
|
|
115
|
+
itself only has to (a) call ``SQLCancel`` to unblock any fetch in
|
|
116
|
+
flight on another thread and (b) close the generator; the
|
|
117
|
+
``finally`` clause does the rest.
|
|
118
|
+
* ``SQLCancel`` is called *before* ``SQLFreeStmt(SQL_CLOSE)`` so a fetch
|
|
119
|
+
running on another thread returns cleanly first. ``SQLCancel`` is
|
|
120
|
+
the single ODBC entry point (with the diag-record functions) that the
|
|
121
|
+
spec marks as safe to call from a different thread than the one
|
|
122
|
+
owning the statement.
|
|
123
|
+
* Cancellation/error diagnostics are drained before closing the cursor.
|
|
124
|
+
Natural exhaustion uses the native fetch's captured diagnostics; a
|
|
125
|
+
drain after close picks up anything ``SQL_CLOSE`` itself emits.
|
|
126
|
+
* Cached ``pyarrow.ArrowInvalid`` avoids per-read imports on the
|
|
127
|
+
post-close error path.
|
|
128
|
+
* ``__del__`` is guarded against interpreter finalization.
|
|
129
|
+
* The public method surface is *delegated* to the inner pyarrow reader
|
|
130
|
+
via ``__getattr__`` rather than hand-enumerated: any method pyarrow
|
|
131
|
+
provides (``read_all``, ``read_pandas``, ``cast``, ``schema``,
|
|
132
|
+
``read_next_batch``, and anything added by future pyarrow
|
|
133
|
+
versions) transparently forwards. Only the methods the wrapper
|
|
134
|
+
genuinely intercepts stay explicit: ``close``, ``closed``,
|
|
135
|
+
``__arrow_c_stream__``, ``__iter__``/``__next__``,
|
|
136
|
+
``__enter__``/``__exit__``, ``__del__``.
|
|
137
|
+
|
|
138
|
+
The parent ``Cursor`` is **not** closed; it remains fully usable.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
__slots__ = (
|
|
142
|
+
"_cursor",
|
|
143
|
+
"_inner",
|
|
144
|
+
"_generator",
|
|
145
|
+
"_closed",
|
|
146
|
+
"_arrow_invalid",
|
|
147
|
+
"_close_requested",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
cursor: "Cursor",
|
|
153
|
+
inner: "pyarrow.RecordBatchReader",
|
|
154
|
+
generator,
|
|
155
|
+
arrow_invalid_exc: type,
|
|
156
|
+
close_requested: list[bool],
|
|
157
|
+
) -> None:
|
|
158
|
+
self._cursor = cursor
|
|
159
|
+
self._inner = inner
|
|
160
|
+
self._generator = generator
|
|
161
|
+
self._closed = False
|
|
162
|
+
self._close_requested = close_requested
|
|
163
|
+
# Cache the exception class so post-close reads in a hot loop don't
|
|
164
|
+
# re-import pyarrow.
|
|
165
|
+
self._arrow_invalid = arrow_invalid_exc
|
|
166
|
+
|
|
167
|
+
# ── Public surface mirroring pyarrow.RecordBatchReader ────────────────
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def closed(self) -> bool:
|
|
171
|
+
"""True once ``close()`` has been called."""
|
|
172
|
+
return self._closed
|
|
173
|
+
|
|
174
|
+
def __getattr__(self, name):
|
|
175
|
+
"""Delegate any attribute we don't explicitly define to the inner
|
|
176
|
+
``pyarrow.RecordBatchReader``.
|
|
177
|
+
|
|
178
|
+
Rationale: enumerating pyarrow's surface by hand was fragile —
|
|
179
|
+
methods like ``read_all()``, ``read_pandas()``, and ``cast()`` were
|
|
180
|
+
silently missing, breaking existing user code on upgrade, and every
|
|
181
|
+
future addition to ``RecordBatchReader`` would repeat the same
|
|
182
|
+
regression. ``__getattr__`` is only invoked when normal attribute
|
|
183
|
+
lookup fails, so our explicit overrides (``close``, ``closed``,
|
|
184
|
+
``__arrow_c_stream__``, iteration and context-manager protocols)
|
|
185
|
+
always win; everything else falls through to the wrapped reader.
|
|
186
|
+
|
|
187
|
+
Private / dunder names (leading ``_``) are refused so that a
|
|
188
|
+
partially-constructed instance during ``__del__`` cannot recurse
|
|
189
|
+
forever trying to resolve its own slot names via ``self._inner``.
|
|
190
|
+
|
|
191
|
+
Post-close access raises ``pyarrow.ArrowInvalid`` to match the
|
|
192
|
+
behaviour of the explicit ``__next__`` / ``__arrow_c_stream__``
|
|
193
|
+
methods — a reader that has been marked closed must not delegate
|
|
194
|
+
even if a retry-pending state still holds ``self._inner``.
|
|
195
|
+
"""
|
|
196
|
+
if name.startswith("_"):
|
|
197
|
+
raise AttributeError(name)
|
|
198
|
+
if self._closed:
|
|
199
|
+
raise self._arrow_invalid("Reader is closed")
|
|
200
|
+
return getattr(self._inner, name)
|
|
201
|
+
|
|
202
|
+
def __arrow_c_stream__(self, requested_schema=None):
|
|
203
|
+
"""Arrow PyCapsule Protocol — export as an Arrow C stream.
|
|
204
|
+
|
|
205
|
+
Implements the Arrow PyCapsule Protocol for streams (pyarrow >= 14),
|
|
206
|
+
so this wrapper can be consumed by any Arrow-compatible library
|
|
207
|
+
(``pyarrow.RecordBatchReader.from_stream``, ``polars.from_arrow``,
|
|
208
|
+
``duckdb.from_arrow``, ``pandas.api.interchange.from_dataframe`` for
|
|
209
|
+
streams, etc.) without an ``isinstance(x, pa.RecordBatchReader)``
|
|
210
|
+
check. See
|
|
211
|
+
https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html
|
|
212
|
+
|
|
213
|
+
Note: once the capsule has been consumed by the caller, the
|
|
214
|
+
underlying pyarrow reader's internal C stream is transferred out;
|
|
215
|
+
further calls to ``read_next_batch()`` on this wrapper will fail
|
|
216
|
+
with ``ArrowInvalid``. That mirrors pyarrow's own semantics.
|
|
217
|
+
"""
|
|
218
|
+
if self._closed:
|
|
219
|
+
raise self._arrow_invalid("Reader is closed")
|
|
220
|
+
# Delegate to the inner pyarrow reader. pyarrow >= 14 exposes
|
|
221
|
+
# ``__arrow_c_stream__`` directly on ``RecordBatchReader``; older
|
|
222
|
+
# versions do not implement the protocol. Fail explicitly rather
|
|
223
|
+
# than silently returning something invalid.
|
|
224
|
+
inner_export = getattr(self._inner, "__arrow_c_stream__", None)
|
|
225
|
+
if inner_export is None:
|
|
226
|
+
raise self._arrow_invalid(
|
|
227
|
+
"Arrow PyCapsule Protocol requires pyarrow>=14; "
|
|
228
|
+
"the installed pyarrow version does not expose "
|
|
229
|
+
"RecordBatchReader.__arrow_c_stream__."
|
|
230
|
+
)
|
|
231
|
+
return inner_export(requested_schema)
|
|
232
|
+
|
|
233
|
+
def __iter__(self):
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def __next__(self):
|
|
237
|
+
if self._closed:
|
|
238
|
+
raise self._arrow_invalid("Reader is closed")
|
|
239
|
+
return self._inner.read_next_batch()
|
|
240
|
+
|
|
241
|
+
def __enter__(self):
|
|
242
|
+
if self._closed:
|
|
243
|
+
raise self._arrow_invalid("Reader is closed")
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
247
|
+
self.close()
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def __del__(self):
|
|
251
|
+
# Best-effort cleanup if the user never called close() (or a previous
|
|
252
|
+
# close() attempt failed to release the generator and left cleanup
|
|
253
|
+
# incomplete) and the reader is being garbage-collected. Skip during
|
|
254
|
+
# interpreter shutdown — the module globals (pyarrow, ddbc_bindings)
|
|
255
|
+
# may already be torn down, and touching native code at that point is
|
|
256
|
+
# unsafe.
|
|
257
|
+
try:
|
|
258
|
+
import sys as _sys
|
|
259
|
+
|
|
260
|
+
if _sys.is_finalizing():
|
|
261
|
+
return
|
|
262
|
+
# Retry whenever the generator is still referenced — covers both
|
|
263
|
+
# "user never called close()" and "earlier close() raised before
|
|
264
|
+
# the generator was released".
|
|
265
|
+
if getattr(self, "_generator", None) is not None:
|
|
266
|
+
self.close()
|
|
267
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# ── Close implementation ──────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
"""Synchronously stop fetching, release the server-side cursor, and
|
|
274
|
+
reset parent-cursor bookkeeping. Idempotent **and retry-safe**:
|
|
275
|
+
if a previous call raised before the generator was released (for
|
|
276
|
+
example because another thread was still executing it and
|
|
277
|
+
``generator.close()`` raised ``ValueError: generator already
|
|
278
|
+
executing``), subsequent calls will pick up where the failed call
|
|
279
|
+
left off rather than silently no-op'ing.
|
|
280
|
+
|
|
281
|
+
Most of the actual cleanup work lives in the generator's ``finally``
|
|
282
|
+
clause (see ``Cursor.arrow_reader``); this method just unblocks any
|
|
283
|
+
in-flight fetch and closes the generator, which triggers that
|
|
284
|
+
``finally`` block.
|
|
285
|
+
"""
|
|
286
|
+
# Fast path: cleanup already completed on a previous call. We use
|
|
287
|
+
# the *generator* reference — not ``_closed`` — as the completion
|
|
288
|
+
# marker, because ``_closed`` is flipped early (so racing reads
|
|
289
|
+
# raise) and must not by itself disable retry of failed cleanup.
|
|
290
|
+
if self._generator is None and self._cursor is None:
|
|
291
|
+
self._closed = True
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# Mark closed first so any racing read raises immediately, even if
|
|
295
|
+
# the cleanup steps below fail and we end up retried later.
|
|
296
|
+
self._closed = True
|
|
297
|
+
self._close_requested[0] = True
|
|
298
|
+
|
|
299
|
+
# SQLCancel (cross-thread safe) — unblocks a fetch running on another
|
|
300
|
+
# thread so that the generator's finally clause can then run
|
|
301
|
+
# SQLFreeStmt(SQL_CLOSE) without risking the undefined-behaviour
|
|
302
|
+
# window of closing an HSTMT mid-fetch. Safe no-op for an idle stmt.
|
|
303
|
+
cursor = self._cursor
|
|
304
|
+
if cursor is not None and not cursor.closed and cursor.hstmt is not None:
|
|
305
|
+
try:
|
|
306
|
+
cursor.hstmt._cancel() # pylint: disable=protected-access
|
|
307
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
308
|
+
logger.debug("arrow_reader.close: SQLCancel raised: %s", e)
|
|
309
|
+
|
|
310
|
+
# Close the generator — this raises GeneratorExit inside it, which
|
|
311
|
+
# runs the try/finally cleanup block (SQLFreeStmt + diag drain +
|
|
312
|
+
# cursor bookkeeping reset). If close() raises and the generator is
|
|
313
|
+
# still alive (e.g. another thread is currently executing it), keep
|
|
314
|
+
# the reference so a subsequent close() / __del__ can retry; only
|
|
315
|
+
# drop refs once the generator is actually dead.
|
|
316
|
+
gen = self._generator
|
|
317
|
+
if gen is not None:
|
|
318
|
+
try:
|
|
319
|
+
gen.close()
|
|
320
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
321
|
+
logger.debug("arrow_reader.close: generator.close raised: %s", e)
|
|
322
|
+
if getattr(gen, "gi_frame", None) is not None:
|
|
323
|
+
# Generator still alive — leave _generator (and _cursor,
|
|
324
|
+
# so the next retry can re-issue SQLCancel) intact.
|
|
325
|
+
return
|
|
326
|
+
self._generator = None
|
|
327
|
+
|
|
328
|
+
# Drop strong refs so the wrapper does not extend the lifetime of
|
|
329
|
+
# the parent Cursor or the inner pyarrow reader.
|
|
330
|
+
self._cursor = None
|
|
331
|
+
self._inner = None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Cursor: # pylint: disable=too-many-instance-attributes,too-many-public-methods
|
|
335
|
+
"""
|
|
336
|
+
Represents a database cursor, which is used to manage the context of a fetch operation.
|
|
337
|
+
|
|
338
|
+
Attributes:
|
|
339
|
+
connection: Database connection object.
|
|
340
|
+
description: Sequence of 7-item sequences describing one result column.
|
|
341
|
+
rowcount: Number of rows produced or affected by the last execute operation.
|
|
342
|
+
arraysize: Number of rows to fetch at a time with fetchmany().
|
|
343
|
+
rownumber: Track the current row index in the result set.
|
|
344
|
+
|
|
345
|
+
Methods:
|
|
346
|
+
__init__(connection_str) -> None.
|
|
347
|
+
callproc(procname, parameters=None) ->
|
|
348
|
+
Modified copy of the input sequence with output parameters.
|
|
349
|
+
close() -> None.
|
|
350
|
+
execute(operation, parameters=None) -> Cursor.
|
|
351
|
+
executemany(operation, seq_of_parameters) -> None.
|
|
352
|
+
fetchone() -> Single sequence or None if no more data is available.
|
|
353
|
+
fetchmany(size=None) -> Sequence of sequences (e.g. list of tuples).
|
|
354
|
+
fetchall() -> Sequence of sequences (e.g. list of tuples).
|
|
355
|
+
nextset() -> True if there is another result set, None otherwise.
|
|
356
|
+
next() -> Fetch the next row from the cursor.
|
|
357
|
+
setinputsizes(sizes) -> None.
|
|
358
|
+
setoutputsize(size, column=None) -> None.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
# TODO(jathakkar): Thread safety considerations
|
|
362
|
+
# The cursor class contains methods that are not thread-safe due to:
|
|
363
|
+
# 1. Methods that mutate cursor state (_reset_cursor, self.description, etc.)
|
|
364
|
+
# 2. Methods that call ODBC functions with shared handles (self.hstmt)
|
|
365
|
+
#
|
|
366
|
+
# These methods should be properly synchronized or redesigned when implementing
|
|
367
|
+
# async functionality to prevent race conditions and data corruption.
|
|
368
|
+
# Consider using locks, redesigning for immutability, or ensuring
|
|
369
|
+
# cursor objects are never shared across threads.
|
|
370
|
+
|
|
371
|
+
def __init__(self, connection: "Connection", timeout: int = 0) -> None:
|
|
372
|
+
"""
|
|
373
|
+
Initialize the cursor with a database connection.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
connection: Database connection object.
|
|
377
|
+
timeout: Query timeout in seconds
|
|
378
|
+
"""
|
|
379
|
+
# Establish the close() invariant *first*, before any statement that
|
|
380
|
+
# can raise (notably ``_initialize_cursor`` below). Setting
|
|
381
|
+
# ``closed=False`` (not True) up front means that if ``__init__``
|
|
382
|
+
# fails partway — even after ``hstmt`` was allocated — a subsequent
|
|
383
|
+
# ``close()`` / ``__del__`` will still see a consistent view and
|
|
384
|
+
# correctly release whatever was allocated. Pairing it with
|
|
385
|
+
# ``hstmt=None`` keeps ``close()`` safe when allocation fails before
|
|
386
|
+
# an HSTMT exists.
|
|
387
|
+
self.closed: bool = False
|
|
388
|
+
self.hstmt: Optional[Any] = None
|
|
389
|
+
|
|
390
|
+
self._connection: "Connection" = connection # Store as private attribute
|
|
391
|
+
self._timeout: int = timeout
|
|
392
|
+
self._inputsizes: Optional[List[Tuple[int, int, int, int]]] = None
|
|
393
|
+
# self.connection.autocommit = False
|
|
394
|
+
self._initialize_cursor()
|
|
395
|
+
self.description: Optional[
|
|
396
|
+
List[
|
|
397
|
+
Tuple[
|
|
398
|
+
str,
|
|
399
|
+
Any,
|
|
400
|
+
Optional[int],
|
|
401
|
+
Optional[int],
|
|
402
|
+
Optional[int],
|
|
403
|
+
Optional[int],
|
|
404
|
+
Optional[bool],
|
|
405
|
+
]
|
|
406
|
+
]
|
|
407
|
+
] = None
|
|
408
|
+
self.rowcount: int = -1
|
|
409
|
+
self.arraysize: int = (
|
|
410
|
+
1 # Default number of rows to fetch at a time is 1, user can change it
|
|
411
|
+
)
|
|
412
|
+
self.buffer_length: int = 1024 # Default buffer length for string data
|
|
413
|
+
self._result_set_empty: bool = False # Add this initialization
|
|
414
|
+
self.last_executed_stmt: str = "" # Stores the last statement executed by this cursor
|
|
415
|
+
self.is_stmt_prepared: List[bool] = [
|
|
416
|
+
False
|
|
417
|
+
] # Indicates if last_executed_stmt was prepared by ddbc shim.
|
|
418
|
+
# Is a list instead of a bool coz bools in Python are immutable.
|
|
419
|
+
# Hence, we can't pass around bools by reference & modify them.
|
|
420
|
+
# Therefore, it must be a list with exactly one bool element.
|
|
421
|
+
|
|
422
|
+
self._rownumber = -1 # DB-API extension: last returned row index, -1 before first
|
|
423
|
+
|
|
424
|
+
# Column-name -> index map for the current result set. For catalog/metadata
|
|
425
|
+
# result sets this also carries lowercase and friendly aliases (see
|
|
426
|
+
# _prepare_metadata_result_set).
|
|
427
|
+
self._cached_column_map = None
|
|
428
|
+
self._cached_column_map_lower = None
|
|
429
|
+
self._cached_converter_map = None
|
|
430
|
+
self._cached_converters_generation = self._connection._converters_generation
|
|
431
|
+
# Canonical, order-preserving column names snapshotted once per result set
|
|
432
|
+
# and handed to each Row so mapping views never read the live cursor.description
|
|
433
|
+
# (which changes when the cursor is reused for another query). _result_columns_src
|
|
434
|
+
# tracks the self.description identity the snapshot was built from, so a new
|
|
435
|
+
# result set rebuilds it exactly once.
|
|
436
|
+
self._cached_result_columns = None
|
|
437
|
+
self._result_columns_src = None
|
|
438
|
+
# Raw ODBC SQL type codes (from SQLDescribeCol) per column, parallel to
|
|
439
|
+
# self.description. Kept so output-converter dispatch can key on the integer
|
|
440
|
+
# ODBC SQL type code (pyodbc-compatible), not just the mapped Python type. See #684.
|
|
441
|
+
self._column_sql_types = None
|
|
442
|
+
self._uuid_str_indices = None # Pre-computed UUID column indices for str conversion
|
|
443
|
+
# Cache the effective native_uuid setting for this cursor's connection.
|
|
444
|
+
# Resolution order: connection._native_uuid (if not None) → module-level setting.
|
|
445
|
+
self._conn_native_uuid = getattr(self.connection, "_native_uuid", None)
|
|
446
|
+
self._next_row_index = 0 # internal: index of the next row the driver will return (0-based)
|
|
447
|
+
self._has_result_set = False # Track if we have an active result set
|
|
448
|
+
self._refresh_decoding_cache()
|
|
449
|
+
self._skip_increment_for_next_fetch = (
|
|
450
|
+
False # Track if we need to skip incrementing the row index
|
|
451
|
+
)
|
|
452
|
+
self.messages: List[Tuple[str, str]] = [] # Store diagnostic messages
|
|
453
|
+
|
|
454
|
+
def _is_unicode_string(self, param: str) -> bool:
|
|
455
|
+
"""
|
|
456
|
+
Check if a string contains non-ASCII characters.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
param: The string to check.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
True if the string contains non-ASCII characters, False otherwise.
|
|
463
|
+
"""
|
|
464
|
+
return not param.isascii()
|
|
465
|
+
|
|
466
|
+
def _parse_date(self, param: str) -> Optional[datetime.date]:
|
|
467
|
+
"""
|
|
468
|
+
Attempt to parse a string as a date.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
param: The string to parse.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
A datetime.date object if parsing is successful, else None.
|
|
475
|
+
"""
|
|
476
|
+
formats = ["%Y-%m-%d"]
|
|
477
|
+
for fmt in formats:
|
|
478
|
+
try:
|
|
479
|
+
return datetime.datetime.strptime(param, fmt).date()
|
|
480
|
+
except ValueError:
|
|
481
|
+
continue
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
def _parse_datetime(self, param: str) -> Optional[datetime.datetime]:
|
|
485
|
+
"""
|
|
486
|
+
Attempt to parse a string as a datetime, smalldatetime, datetime2, timestamp.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
param: The string to parse.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
A datetime.datetime object if parsing is successful, else None.
|
|
493
|
+
"""
|
|
494
|
+
formats = [
|
|
495
|
+
"%Y-%m-%dT%H:%M:%S.%f", # ISO 8601 datetime with fractional seconds
|
|
496
|
+
"%Y-%m-%dT%H:%M:%S", # ISO 8601 datetime
|
|
497
|
+
"%Y-%m-%d %H:%M:%S.%f", # Datetime with fractional seconds
|
|
498
|
+
"%Y-%m-%d %H:%M:%S", # Datetime without fractional seconds
|
|
499
|
+
]
|
|
500
|
+
for fmt in formats:
|
|
501
|
+
try:
|
|
502
|
+
return datetime.datetime.strptime(param, fmt) # Valid datetime
|
|
503
|
+
except ValueError:
|
|
504
|
+
continue # Try next format
|
|
505
|
+
|
|
506
|
+
return None # If all formats fail, return None
|
|
507
|
+
|
|
508
|
+
def _parse_time(self, param: str) -> Optional[datetime.time]:
|
|
509
|
+
"""
|
|
510
|
+
Attempt to parse a string as a time.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
param: The string to parse.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
A datetime.time object if parsing is successful, else None.
|
|
517
|
+
"""
|
|
518
|
+
formats = [
|
|
519
|
+
"%H:%M:%S", # Time only
|
|
520
|
+
"%H:%M:%S.%f", # Time with fractional seconds
|
|
521
|
+
]
|
|
522
|
+
for fmt in formats:
|
|
523
|
+
try:
|
|
524
|
+
return datetime.datetime.strptime(param, fmt).time()
|
|
525
|
+
except ValueError:
|
|
526
|
+
continue
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
def _get_numeric_data(self, param: decimal.Decimal) -> Any:
|
|
530
|
+
"""
|
|
531
|
+
Get the data for a numeric parameter.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
param: The numeric parameter.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
numeric_data: A NumericData struct containing
|
|
538
|
+
the numeric data.
|
|
539
|
+
"""
|
|
540
|
+
decimal_as_tuple = param.as_tuple()
|
|
541
|
+
digits_tuple = decimal_as_tuple.digits
|
|
542
|
+
num_digits = len(digits_tuple)
|
|
543
|
+
exponent = decimal_as_tuple.exponent
|
|
544
|
+
|
|
545
|
+
# NaN / sNaN / Infinity report a string exponent ('n', 'N', 'F') instead of an
|
|
546
|
+
# int. There is no SQL NUMERIC encoding for them, so refuse here rather than
|
|
547
|
+
# falling through to precision=38 and letting the digit packing below emit a
|
|
548
|
+
# silent zero. The native detection path raises ValueError for the same input.
|
|
549
|
+
if isinstance(exponent, str):
|
|
550
|
+
raise ValueError("Cannot bind non-finite Decimal (NaN/Infinity) as SQL NUMERIC")
|
|
551
|
+
|
|
552
|
+
# Calculate the SQL precision & scale
|
|
553
|
+
# precision = no. of significant digits
|
|
554
|
+
# scale = no. digits after decimal point
|
|
555
|
+
if exponent >= 0:
|
|
556
|
+
# digits=314, exp=2 ---> '31400' --> precision=5, scale=0
|
|
557
|
+
precision = num_digits + exponent
|
|
558
|
+
scale = 0
|
|
559
|
+
elif (-1 * exponent) <= num_digits:
|
|
560
|
+
# digits=3140, exp=-3 ---> '3.140' --> precision=4, scale=3
|
|
561
|
+
precision = num_digits
|
|
562
|
+
scale = exponent * -1
|
|
563
|
+
else:
|
|
564
|
+
# digits=3140, exp=-5 ---> '0.03140' --> precision=5, scale=5
|
|
565
|
+
# TODO: double check the precision calculation here with SQL documentation
|
|
566
|
+
precision = exponent * -1
|
|
567
|
+
scale = exponent * -1
|
|
568
|
+
|
|
569
|
+
if precision > 38:
|
|
570
|
+
raise ValueError(
|
|
571
|
+
"Precision of the numeric value is too high - "
|
|
572
|
+
+ str(param)
|
|
573
|
+
+ ". Should be less than or equal to 38"
|
|
574
|
+
)
|
|
575
|
+
Numeric_Data = ddbc_bindings.NumericData
|
|
576
|
+
numeric_data = Numeric_Data()
|
|
577
|
+
numeric_data.scale = scale
|
|
578
|
+
numeric_data.precision = precision
|
|
579
|
+
numeric_data.sign = 1 if decimal_as_tuple.sign == 0 else 0
|
|
580
|
+
# strip decimal point from param & convert the significant digits to integer
|
|
581
|
+
# Ex: 12.34 ---> 1234
|
|
582
|
+
int_str = "".join(str(d) for d in digits_tuple)
|
|
583
|
+
if exponent > 0:
|
|
584
|
+
int_str = int_str + ("0" * exponent)
|
|
585
|
+
elif exponent < 0:
|
|
586
|
+
if -exponent > num_digits:
|
|
587
|
+
int_str = ("0" * (-exponent - num_digits)) + int_str
|
|
588
|
+
|
|
589
|
+
if int_str == "":
|
|
590
|
+
int_str = "0"
|
|
591
|
+
|
|
592
|
+
# Convert decimal base-10 string to python int, then to 16 little-endian bytes
|
|
593
|
+
big_int = int(int_str)
|
|
594
|
+
byte_array = bytearray(16) # SQL_MAX_NUMERIC_LEN
|
|
595
|
+
for i in range(16):
|
|
596
|
+
byte_array[i] = big_int & 0xFF
|
|
597
|
+
big_int >>= 8
|
|
598
|
+
if big_int == 0:
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
numeric_data.val = bytes(byte_array)
|
|
602
|
+
return numeric_data
|
|
603
|
+
|
|
604
|
+
def _get_encoding_settings(self):
|
|
605
|
+
"""
|
|
606
|
+
Get the encoding settings from the connection.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
dict: A dictionary with 'encoding' and 'ctype' keys, or default settings if not available
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
OperationalError, DatabaseError: If there are unexpected database connection issues
|
|
613
|
+
that indicate a broken connection state. These should not be silently ignored
|
|
614
|
+
as they can lead to data corruption or inconsistent behavior.
|
|
615
|
+
"""
|
|
616
|
+
if hasattr(self._connection, "getencoding"):
|
|
617
|
+
try:
|
|
618
|
+
return self._connection.getencoding()
|
|
619
|
+
except (OperationalError, DatabaseError) as db_error:
|
|
620
|
+
# Log the error for debugging but re-raise for fail-fast behavior
|
|
621
|
+
# Silently returning defaults can lead to data corruption and hard-to-debug issues
|
|
622
|
+
logger.error(
|
|
623
|
+
"Failed to get encoding settings from connection due to database error: %s. "
|
|
624
|
+
"This indicates a broken connection state that should not be ignored.",
|
|
625
|
+
db_error,
|
|
626
|
+
)
|
|
627
|
+
# Re-raise to fail fast - users should know their connection is broken
|
|
628
|
+
raise
|
|
629
|
+
except Exception as unexpected_error:
|
|
630
|
+
# Handle other unexpected errors (connection closed, programming errors, etc.)
|
|
631
|
+
logger.error("Unexpected error getting encoding settings: %s", unexpected_error)
|
|
632
|
+
# Re-raise unexpected errors as well
|
|
633
|
+
raise
|
|
634
|
+
|
|
635
|
+
# Return default encoding settings if getencoding is not available
|
|
636
|
+
# This is the only case where defaults are appropriate (method doesn't exist)
|
|
637
|
+
return {"encoding": "utf-16le", "ctype": ddbc_sql_const.SQL_WCHAR.value}
|
|
638
|
+
|
|
639
|
+
def _refresh_decoding_cache(self):
|
|
640
|
+
"""Read decoding settings only when the connection configuration changes."""
|
|
641
|
+
generation = self._connection._decoding_generation
|
|
642
|
+
char_decoding = self._get_decoding_settings(ddbc_sql_const.SQL_CHAR.value)
|
|
643
|
+
wchar_encoding = self._get_decoding_settings(ddbc_sql_const.SQL_WCHAR.value).get(
|
|
644
|
+
"encoding", "utf-16le"
|
|
645
|
+
)
|
|
646
|
+
self._cached_char_encoding = char_decoding.get("encoding", "utf-16le")
|
|
647
|
+
self._cached_char_ctype = char_decoding.get("ctype", ddbc_sql_const.SQL_WCHAR.value)
|
|
648
|
+
self._cached_wchar_encoding = wchar_encoding
|
|
649
|
+
self._cached_decoding_generation = generation
|
|
650
|
+
|
|
651
|
+
def _get_decoding_settings(self, sql_type):
|
|
652
|
+
"""
|
|
653
|
+
Get decoding settings for a specific SQL type.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
sql_type: SQL type constant (SQL_CHAR, SQL_WCHAR, etc.)
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Dictionary containing the decoding settings.
|
|
660
|
+
|
|
661
|
+
Raises:
|
|
662
|
+
OperationalError, DatabaseError: If there are unexpected database connection issues
|
|
663
|
+
that indicate a broken connection state. These should not be silently ignored
|
|
664
|
+
as they can lead to data corruption or inconsistent behavior.
|
|
665
|
+
"""
|
|
666
|
+
try:
|
|
667
|
+
# Get decoding settings from connection for this SQL type
|
|
668
|
+
return self._connection.getdecoding(sql_type)
|
|
669
|
+
except (OperationalError, DatabaseError) as db_error:
|
|
670
|
+
# Log the error for debugging but re-raise for fail-fast behavior
|
|
671
|
+
# Silently returning defaults can lead to data corruption and hard-to-debug issues
|
|
672
|
+
logger.error(
|
|
673
|
+
"Failed to get decoding settings for SQL type %s due to database error: %s. "
|
|
674
|
+
"This indicates a broken connection state that should not be ignored.",
|
|
675
|
+
sql_type,
|
|
676
|
+
db_error,
|
|
677
|
+
)
|
|
678
|
+
# Re-raise to fail fast - users should know their connection is broken
|
|
679
|
+
raise
|
|
680
|
+
except Exception as unexpected_error:
|
|
681
|
+
# Handle other unexpected errors (connection closed, programming errors, etc.)
|
|
682
|
+
logger.error(
|
|
683
|
+
"Unexpected error getting decoding settings for SQL type %s: %s",
|
|
684
|
+
sql_type,
|
|
685
|
+
unexpected_error,
|
|
686
|
+
)
|
|
687
|
+
# Re-raise unexpected errors as well
|
|
688
|
+
raise
|
|
689
|
+
|
|
690
|
+
def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-return-statements,too-many-branches
|
|
691
|
+
self,
|
|
692
|
+
param: Any,
|
|
693
|
+
parameters_list: List[Any],
|
|
694
|
+
i: int,
|
|
695
|
+
min_val: Optional[Any] = None,
|
|
696
|
+
max_val: Optional[Any] = None,
|
|
697
|
+
decimal_as_numeric: bool = False,
|
|
698
|
+
) -> Tuple[int, int, int, int, bool]:
|
|
699
|
+
"""
|
|
700
|
+
Map a Python data type to the corresponding SQL type,
|
|
701
|
+
C type, Column size, and Decimal digits.
|
|
702
|
+
Takes:
|
|
703
|
+
- param: The parameter to map.
|
|
704
|
+
- parameters_list: The list of parameters to bind.
|
|
705
|
+
- i: The index of the parameter in the list.
|
|
706
|
+
- decimal_as_numeric: When True, bind a Decimal as SQL_NUMERIC regardless of
|
|
707
|
+
value, skipping the MONEY/SMALLMONEY-range VARCHAR shortcut. The execute()
|
|
708
|
+
path sets this so a money-range Decimal compared against a numeric column
|
|
709
|
+
does not overflow (GH-740). executemany() leaves it False because it
|
|
710
|
+
string-binds Decimals for the whole batch (GH-503).
|
|
711
|
+
Returns:
|
|
712
|
+
- A tuple containing the SQL type, C type, column size, and decimal digits.
|
|
713
|
+
"""
|
|
714
|
+
logger.debug("_map_sql_type: Mapping param index=%d, type=%s", i, type(param).__name__)
|
|
715
|
+
if param is None:
|
|
716
|
+
logger.debug("_map_sql_type: NULL parameter - index=%d", i)
|
|
717
|
+
# GH-610: Send SQL_UNKNOWN_TYPE to C++ where the describe-cache
|
|
718
|
+
# in BindParameters / BindParameterArray resolves the correct
|
|
719
|
+
# type via SQLDescribeParam (cached after first call).
|
|
720
|
+
return (
|
|
721
|
+
ddbc_sql_const.SQL_UNKNOWN_TYPE.value,
|
|
722
|
+
ddbc_sql_const.SQL_C_DEFAULT.value,
|
|
723
|
+
1,
|
|
724
|
+
0,
|
|
725
|
+
False,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
if isinstance(param, bool):
|
|
729
|
+
logger.debug("_map_sql_type: BOOL detected - index=%d", i)
|
|
730
|
+
return (
|
|
731
|
+
ddbc_sql_const.SQL_BIT.value,
|
|
732
|
+
ddbc_sql_const.SQL_C_BIT.value,
|
|
733
|
+
1,
|
|
734
|
+
0,
|
|
735
|
+
False,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
if isinstance(param, int):
|
|
739
|
+
# Use min_val/max_val if available
|
|
740
|
+
value_to_check = max_val if max_val is not None else param
|
|
741
|
+
min_to_check = min_val if min_val is not None else param
|
|
742
|
+
logger.debug(
|
|
743
|
+
"_map_sql_type: INT detected - index=%d, min=%s, max=%s",
|
|
744
|
+
i,
|
|
745
|
+
str(min_to_check)[:50],
|
|
746
|
+
str(value_to_check)[:50],
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
if 0 <= min_to_check and value_to_check <= 255:
|
|
750
|
+
logger.debug("_map_sql_type: INT -> TINYINT - index=%d", i)
|
|
751
|
+
return (
|
|
752
|
+
ddbc_sql_const.SQL_TINYINT.value,
|
|
753
|
+
ddbc_sql_const.SQL_C_TINYINT.value,
|
|
754
|
+
3,
|
|
755
|
+
0,
|
|
756
|
+
False,
|
|
757
|
+
)
|
|
758
|
+
if -32768 <= min_to_check and value_to_check <= 32767:
|
|
759
|
+
logger.debug("_map_sql_type: INT -> SMALLINT - index=%d", i)
|
|
760
|
+
return (
|
|
761
|
+
ddbc_sql_const.SQL_SMALLINT.value,
|
|
762
|
+
ddbc_sql_const.SQL_C_SHORT.value,
|
|
763
|
+
5,
|
|
764
|
+
0,
|
|
765
|
+
False,
|
|
766
|
+
)
|
|
767
|
+
if -2147483648 <= min_to_check and value_to_check <= 2147483647:
|
|
768
|
+
logger.debug("_map_sql_type: INT -> INTEGER - index=%d", i)
|
|
769
|
+
return (
|
|
770
|
+
ddbc_sql_const.SQL_INTEGER.value,
|
|
771
|
+
ddbc_sql_const.SQL_C_LONG.value,
|
|
772
|
+
10,
|
|
773
|
+
0,
|
|
774
|
+
False,
|
|
775
|
+
)
|
|
776
|
+
# Beyond INTEGER, the only integer SQL type is BIGINT (signed 64-bit). An int
|
|
777
|
+
# outside its range cannot bind, so reject here with a clear message rather than
|
|
778
|
+
# labelling it BIGINT and failing later at the C++ cast. Mirrors the native path.
|
|
779
|
+
if value_to_check > BIGINT_MAX or min_to_check < BIGINT_MIN:
|
|
780
|
+
offending = value_to_check if value_to_check > BIGINT_MAX else min_to_check
|
|
781
|
+
raise ValueError(
|
|
782
|
+
f"integer {offending} is out of range for SQL BIGINT [-2^63, 2^63-1]"
|
|
783
|
+
)
|
|
784
|
+
logger.debug("_map_sql_type: INT -> BIGINT - index=%d", i)
|
|
785
|
+
return (
|
|
786
|
+
ddbc_sql_const.SQL_BIGINT.value,
|
|
787
|
+
ddbc_sql_const.SQL_C_SBIGINT.value,
|
|
788
|
+
19,
|
|
789
|
+
0,
|
|
790
|
+
False,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if isinstance(param, float):
|
|
794
|
+
logger.debug("_map_sql_type: FLOAT detected - index=%d", i)
|
|
795
|
+
return (
|
|
796
|
+
ddbc_sql_const.SQL_DOUBLE.value,
|
|
797
|
+
ddbc_sql_const.SQL_C_DOUBLE.value,
|
|
798
|
+
15,
|
|
799
|
+
0,
|
|
800
|
+
False,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
if isinstance(param, decimal.Decimal):
|
|
804
|
+
logger.debug("_map_sql_type: DECIMAL detected - index=%d", i)
|
|
805
|
+
# First check precision limit for all decimal values
|
|
806
|
+
decimal_as_tuple = param.as_tuple()
|
|
807
|
+
digits_tuple = decimal_as_tuple.digits
|
|
808
|
+
num_digits = len(digits_tuple)
|
|
809
|
+
exponent = decimal_as_tuple.exponent
|
|
810
|
+
|
|
811
|
+
# NaN / sNaN / Infinity report a string exponent ('n', 'N', 'F'). Reject them
|
|
812
|
+
# before the MONEY range comparison below, which would otherwise raise
|
|
813
|
+
# decimal.InvalidOperation for NaN, and before _get_numeric_data, which used to
|
|
814
|
+
# fail with TypeError for Infinity. The native detection path raises ValueError
|
|
815
|
+
# for the same input, so both paths now agree on type and message.
|
|
816
|
+
if isinstance(exponent, str):
|
|
817
|
+
logger.debug(
|
|
818
|
+
"_map_sql_type: DECIMAL non-finite value - index=%d, exponent=%s", i, exponent
|
|
819
|
+
)
|
|
820
|
+
raise ValueError("Cannot bind non-finite Decimal (NaN/Infinity) as SQL NUMERIC")
|
|
821
|
+
|
|
822
|
+
# Calculate the SQL precision (same logic as _get_numeric_data)
|
|
823
|
+
if exponent >= 0:
|
|
824
|
+
precision = num_digits + exponent
|
|
825
|
+
elif (-1 * exponent) <= num_digits:
|
|
826
|
+
precision = num_digits
|
|
827
|
+
else:
|
|
828
|
+
precision = exponent * -1
|
|
829
|
+
logger.debug(
|
|
830
|
+
"_map_sql_type: DECIMAL precision calculated - index=%d, precision=%d",
|
|
831
|
+
i,
|
|
832
|
+
precision,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
if precision > 38:
|
|
836
|
+
logger.debug(
|
|
837
|
+
"_map_sql_type: DECIMAL precision too high - index=%d, precision=%d",
|
|
838
|
+
i,
|
|
839
|
+
precision,
|
|
840
|
+
)
|
|
841
|
+
raise ValueError(
|
|
842
|
+
f"Precision of the numeric value is too high. "
|
|
843
|
+
f"The maximum precision supported by SQL Server is 38, but got {precision}."
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# Detect MONEY / SMALLMONEY range. Skipped on the execute() path
|
|
847
|
+
# (decimal_as_numeric=True), where a money-range Decimal must bind as
|
|
848
|
+
# SQL_NUMERIC so a comparison against a smaller numeric column returns no
|
|
849
|
+
# match instead of overflowing (GH-740). executemany keeps the VARCHAR
|
|
850
|
+
# shortcut because it string-binds Decimals for the batch (GH-503).
|
|
851
|
+
if not decimal_as_numeric and SMALLMONEY_MIN <= param <= SMALLMONEY_MAX:
|
|
852
|
+
logger.debug("_map_sql_type: DECIMAL -> SMALLMONEY - index=%d", i)
|
|
853
|
+
# smallmoney
|
|
854
|
+
parameters_list[i] = format(param, "f")
|
|
855
|
+
return (
|
|
856
|
+
ddbc_sql_const.SQL_VARCHAR.value,
|
|
857
|
+
ddbc_sql_const.SQL_C_CHAR.value,
|
|
858
|
+
len(parameters_list[i]),
|
|
859
|
+
0,
|
|
860
|
+
False,
|
|
861
|
+
)
|
|
862
|
+
if not decimal_as_numeric and MONEY_MIN <= param <= MONEY_MAX:
|
|
863
|
+
logger.debug("_map_sql_type: DECIMAL -> MONEY - index=%d", i)
|
|
864
|
+
# money
|
|
865
|
+
parameters_list[i] = format(param, "f")
|
|
866
|
+
return (
|
|
867
|
+
ddbc_sql_const.SQL_VARCHAR.value,
|
|
868
|
+
ddbc_sql_const.SQL_C_CHAR.value,
|
|
869
|
+
len(parameters_list[i]),
|
|
870
|
+
0,
|
|
871
|
+
False,
|
|
872
|
+
)
|
|
873
|
+
# fallback to generic numeric binding
|
|
874
|
+
logger.debug("_map_sql_type: DECIMAL -> NUMERIC - index=%d", i)
|
|
875
|
+
parameters_list[i] = self._get_numeric_data(param)
|
|
876
|
+
logger.debug(
|
|
877
|
+
"_map_sql_type: NUMERIC created - index=%d, precision=%d, scale=%d",
|
|
878
|
+
i,
|
|
879
|
+
parameters_list[i].precision,
|
|
880
|
+
parameters_list[i].scale,
|
|
881
|
+
)
|
|
882
|
+
return (
|
|
883
|
+
ddbc_sql_const.SQL_NUMERIC.value,
|
|
884
|
+
ddbc_sql_const.SQL_C_NUMERIC.value,
|
|
885
|
+
parameters_list[i].precision,
|
|
886
|
+
parameters_list[i].scale,
|
|
887
|
+
False,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
if isinstance(param, uuid.UUID):
|
|
891
|
+
logger.debug("_map_sql_type: UUID detected - index=%d", i)
|
|
892
|
+
parameters_list[i] = param.bytes_le
|
|
893
|
+
return (
|
|
894
|
+
ddbc_sql_const.SQL_GUID.value,
|
|
895
|
+
ddbc_sql_const.SQL_C_GUID.value,
|
|
896
|
+
16,
|
|
897
|
+
0,
|
|
898
|
+
False,
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
if isinstance(param, str):
|
|
902
|
+
logger.debug("_map_sql_type: STR detected - index=%d, length=%d", i, len(param))
|
|
903
|
+
if (
|
|
904
|
+
param.startswith("POINT")
|
|
905
|
+
or param.startswith("LINESTRING")
|
|
906
|
+
or param.startswith("POLYGON")
|
|
907
|
+
):
|
|
908
|
+
logger.debug("_map_sql_type: STR is geometry type - index=%d", i)
|
|
909
|
+
return (
|
|
910
|
+
ddbc_sql_const.SQL_WVARCHAR.value,
|
|
911
|
+
ddbc_sql_const.SQL_C_WCHAR.value,
|
|
912
|
+
len(param),
|
|
913
|
+
0,
|
|
914
|
+
False,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
# String mapping logic here
|
|
918
|
+
is_unicode = self._is_unicode_string(param)
|
|
919
|
+
|
|
920
|
+
# Computes UTF-16 code units (handles surrogate pairs)
|
|
921
|
+
utf16_len = sum(2 if ord(c) > 0xFFFF else 1 for c in param)
|
|
922
|
+
logger.debug(
|
|
923
|
+
"_map_sql_type: STR analysis - index=%d, is_unicode=%s, utf16_len=%d",
|
|
924
|
+
i,
|
|
925
|
+
str(is_unicode),
|
|
926
|
+
utf16_len,
|
|
927
|
+
)
|
|
928
|
+
if utf16_len > MAX_INLINE_CHAR: # Long strings -> DAE
|
|
929
|
+
logger.debug("_map_sql_type: STR exceeds MAX_INLINE_CHAR, using DAE - index=%d", i)
|
|
930
|
+
if is_unicode:
|
|
931
|
+
return (
|
|
932
|
+
ddbc_sql_const.SQL_WVARCHAR.value,
|
|
933
|
+
ddbc_sql_const.SQL_C_WCHAR.value,
|
|
934
|
+
0,
|
|
935
|
+
0,
|
|
936
|
+
True,
|
|
937
|
+
)
|
|
938
|
+
return (
|
|
939
|
+
ddbc_sql_const.SQL_VARCHAR.value,
|
|
940
|
+
ddbc_sql_const.SQL_C_CHAR.value,
|
|
941
|
+
0,
|
|
942
|
+
0,
|
|
943
|
+
True,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
# Short strings
|
|
947
|
+
if is_unicode:
|
|
948
|
+
return (
|
|
949
|
+
ddbc_sql_const.SQL_WVARCHAR.value,
|
|
950
|
+
ddbc_sql_const.SQL_C_WCHAR.value,
|
|
951
|
+
utf16_len,
|
|
952
|
+
0,
|
|
953
|
+
False,
|
|
954
|
+
)
|
|
955
|
+
return (
|
|
956
|
+
ddbc_sql_const.SQL_VARCHAR.value,
|
|
957
|
+
ddbc_sql_const.SQL_C_CHAR.value,
|
|
958
|
+
len(param),
|
|
959
|
+
0,
|
|
960
|
+
False,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
if isinstance(param, (bytes, bytearray)):
|
|
964
|
+
length = len(param)
|
|
965
|
+
if length > 8000: # Use VARBINARY(MAX) for large blobs
|
|
966
|
+
return (
|
|
967
|
+
ddbc_sql_const.SQL_VARBINARY.value,
|
|
968
|
+
ddbc_sql_const.SQL_C_BINARY.value,
|
|
969
|
+
0,
|
|
970
|
+
0,
|
|
971
|
+
True,
|
|
972
|
+
)
|
|
973
|
+
# Small blobs → direct binding
|
|
974
|
+
return (
|
|
975
|
+
ddbc_sql_const.SQL_VARBINARY.value,
|
|
976
|
+
ddbc_sql_const.SQL_C_BINARY.value,
|
|
977
|
+
max(length, 1),
|
|
978
|
+
0,
|
|
979
|
+
False,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
if isinstance(param, datetime.datetime):
|
|
983
|
+
if param.tzinfo is not None:
|
|
984
|
+
# Timezone-aware datetime -> DATETIMEOFFSET
|
|
985
|
+
return (
|
|
986
|
+
ddbc_sql_const.SQL_DATETIMEOFFSET.value,
|
|
987
|
+
ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
|
|
988
|
+
34,
|
|
989
|
+
7,
|
|
990
|
+
False,
|
|
991
|
+
)
|
|
992
|
+
# Naive datetime -> TIMESTAMP
|
|
993
|
+
return (
|
|
994
|
+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value,
|
|
995
|
+
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
|
|
996
|
+
26,
|
|
997
|
+
6,
|
|
998
|
+
False,
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
if isinstance(param, datetime.date):
|
|
1002
|
+
return (
|
|
1003
|
+
ddbc_sql_const.SQL_TYPE_DATE.value,
|
|
1004
|
+
ddbc_sql_const.SQL_C_TYPE_DATE.value,
|
|
1005
|
+
10,
|
|
1006
|
+
0,
|
|
1007
|
+
False,
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
if isinstance(param, datetime.time):
|
|
1011
|
+
return (
|
|
1012
|
+
ddbc_sql_const.SQL_TYPE_TIME.value,
|
|
1013
|
+
ddbc_sql_const.SQL_C_CHAR.value,
|
|
1014
|
+
16,
|
|
1015
|
+
6,
|
|
1016
|
+
False,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
# For safety: unknown/unhandled Python types should not silently go to SQL
|
|
1020
|
+
raise TypeError(
|
|
1021
|
+
"Unsupported parameter type: The driver cannot safely convert it to a SQL type."
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
def _initialize_cursor(self) -> None:
|
|
1025
|
+
"""
|
|
1026
|
+
Initialize the DDBC statement handle.
|
|
1027
|
+
"""
|
|
1028
|
+
self._allocate_statement_handle()
|
|
1029
|
+
self._set_timeout()
|
|
1030
|
+
|
|
1031
|
+
def _allocate_statement_handle(self) -> None:
|
|
1032
|
+
"""
|
|
1033
|
+
Allocate the DDBC statement handle.
|
|
1034
|
+
"""
|
|
1035
|
+
self.hstmt = self._connection._conn.alloc_statement_handle()
|
|
1036
|
+
|
|
1037
|
+
def _set_timeout(self) -> None:
|
|
1038
|
+
"""
|
|
1039
|
+
Set the query timeout attribute on the statement handle.
|
|
1040
|
+
This is called once when the cursor is created and after any handle reallocation.
|
|
1041
|
+
Following pyodbc's approach for better performance.
|
|
1042
|
+
"""
|
|
1043
|
+
if self._timeout > 0:
|
|
1044
|
+
logger.debug("_set_timeout: Setting query timeout=%d seconds", self._timeout)
|
|
1045
|
+
try:
|
|
1046
|
+
timeout_value = int(self._timeout)
|
|
1047
|
+
ret = ddbc_bindings.DDBCSQLSetStmtAttr(
|
|
1048
|
+
self.hstmt,
|
|
1049
|
+
ddbc_sql_const.SQL_ATTR_QUERY_TIMEOUT.value,
|
|
1050
|
+
timeout_value,
|
|
1051
|
+
)
|
|
1052
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
1053
|
+
logger.debug("Query timeout set to %d seconds", timeout_value)
|
|
1054
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1055
|
+
logger.warning("Failed to set query timeout: %s", str(e))
|
|
1056
|
+
|
|
1057
|
+
def _reset_cursor(self) -> None:
|
|
1058
|
+
"""
|
|
1059
|
+
Reset the DDBC statement handle.
|
|
1060
|
+
"""
|
|
1061
|
+
if self.hstmt:
|
|
1062
|
+
self.hstmt.free()
|
|
1063
|
+
self.hstmt = None
|
|
1064
|
+
logger.debug("SQLFreeHandle succeeded")
|
|
1065
|
+
|
|
1066
|
+
self._clear_rownumber()
|
|
1067
|
+
|
|
1068
|
+
# Reinitialize the statement handle
|
|
1069
|
+
self._initialize_cursor()
|
|
1070
|
+
self.is_stmt_prepared = [False]
|
|
1071
|
+
|
|
1072
|
+
def _soft_reset_cursor(self) -> None:
|
|
1073
|
+
"""Lightweight reset: close cursor and unbind params without freeing the HSTMT.
|
|
1074
|
+
|
|
1075
|
+
Preserves the prepared statement plan on the server so repeated
|
|
1076
|
+
executions of the same SQL skip SQLPrepare entirely.
|
|
1077
|
+
"""
|
|
1078
|
+
if self.hstmt:
|
|
1079
|
+
ret = ddbc_bindings.DDBCSQLResetStmt(self.hstmt)
|
|
1080
|
+
try:
|
|
1081
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
1082
|
+
except Exception:
|
|
1083
|
+
logger.warning("_soft_reset_cursor failed; falling back to full reset")
|
|
1084
|
+
self._reset_cursor()
|
|
1085
|
+
self.last_executed_stmt = ""
|
|
1086
|
+
return
|
|
1087
|
+
self._clear_rownumber()
|
|
1088
|
+
|
|
1089
|
+
def close(self) -> None:
|
|
1090
|
+
"""
|
|
1091
|
+
Close the connection now (rather than whenever .__del__() is called).
|
|
1092
|
+
Idempotent: subsequent calls have no effect and will be no-ops.
|
|
1093
|
+
|
|
1094
|
+
The cursor will be unusable from this point forward; an InterfaceError
|
|
1095
|
+
will be raised if any operation (other than close) is attempted with the cursor.
|
|
1096
|
+
This is a deviation from pyodbc, which raises an exception if the cursor is already closed.
|
|
1097
|
+
"""
|
|
1098
|
+
# ``closed`` may be missing on the instance only if the object was
|
|
1099
|
+
# built via ``Cursor.__new__(Cursor)`` (no ``__init__``). Normal
|
|
1100
|
+
# construction sets ``self.closed = False`` as the first statement
|
|
1101
|
+
# of ``__init__``, so any partially-initialized instance still has
|
|
1102
|
+
# the attribute. Treat the no-``__init__`` case as "already closed"
|
|
1103
|
+
# — there's nothing to release — and let GC reap the object cleanly.
|
|
1104
|
+
if getattr(self, "closed", True):
|
|
1105
|
+
# Do nothing - not calling _check_closed() here since we want this to be idempotent
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
# Clear messages per DBAPI
|
|
1109
|
+
self.messages = []
|
|
1110
|
+
|
|
1111
|
+
# Remove this cursor from the connection's tracking
|
|
1112
|
+
if hasattr(self, "connection") and self.connection and hasattr(self.connection, "_cursors"):
|
|
1113
|
+
try:
|
|
1114
|
+
self.connection._cursors.discard(self)
|
|
1115
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1116
|
+
logger.warning("Error removing cursor from connection tracking: %s", e)
|
|
1117
|
+
|
|
1118
|
+
if self.hstmt:
|
|
1119
|
+
self.hstmt.free()
|
|
1120
|
+
self.hstmt = None
|
|
1121
|
+
logger.debug("SQLFreeHandle succeeded")
|
|
1122
|
+
self._clear_rownumber()
|
|
1123
|
+
self.closed = True
|
|
1124
|
+
|
|
1125
|
+
def _check_closed(self) -> None:
|
|
1126
|
+
"""
|
|
1127
|
+
Check if the cursor is closed and raise an exception if it is.
|
|
1128
|
+
|
|
1129
|
+
Raises:
|
|
1130
|
+
ProgrammingError: If the cursor is closed.
|
|
1131
|
+
"""
|
|
1132
|
+
if self.closed:
|
|
1133
|
+
raise ProgrammingError(
|
|
1134
|
+
driver_error="Operation cannot be performed: The cursor is closed.",
|
|
1135
|
+
ddbc_error="",
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
def _capture_diagnostics(self, ret: int) -> None:
|
|
1139
|
+
"""Append diagnostic messages to self.messages when the return code
|
|
1140
|
+
indicates records may be present.
|
|
1141
|
+
|
|
1142
|
+
Captures on SQL_SUCCESS_WITH_INFO (info/warning messages) and
|
|
1143
|
+
SQL_NO_DATA (trailing diagnostics attached by SQLMoreResults,
|
|
1144
|
+
e.g. a PRINT after the final result set).
|
|
1145
|
+
|
|
1146
|
+
Skips SQL_SUCCESS to avoid the ~10 ms overhead of scanning the
|
|
1147
|
+
driver's internal state when no records exist. SQL_ERROR is
|
|
1148
|
+
handled separately by check_error() which extracts diagnostics
|
|
1149
|
+
and raises.
|
|
1150
|
+
|
|
1151
|
+
Composite native fetches capture intermediate diagnostics directly
|
|
1152
|
+
into self.messages before subsequent ODBC calls can replace them.
|
|
1153
|
+
"""
|
|
1154
|
+
if self.hstmt and ret in (
|
|
1155
|
+
ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value,
|
|
1156
|
+
ddbc_sql_const.SQL_NO_DATA.value,
|
|
1157
|
+
):
|
|
1158
|
+
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
|
|
1159
|
+
|
|
1160
|
+
def _ensure_pyarrow(self) -> Any:
|
|
1161
|
+
"""
|
|
1162
|
+
Import and return pyarrow or raise ImportError accordingly.
|
|
1163
|
+
"""
|
|
1164
|
+
try:
|
|
1165
|
+
import pyarrow
|
|
1166
|
+
|
|
1167
|
+
return pyarrow
|
|
1168
|
+
except ImportError as e:
|
|
1169
|
+
raise ImportError(
|
|
1170
|
+
"pyarrow is required for Arrow fetch methods. Please install pyarrow."
|
|
1171
|
+
) from e
|
|
1172
|
+
|
|
1173
|
+
def setinputsizes(self, sizes: List[Union[int, tuple]]) -> None:
|
|
1174
|
+
"""
|
|
1175
|
+
Sets the type information to be used for parameters in execute and executemany.
|
|
1176
|
+
|
|
1177
|
+
This method can be used to explicitly declare the types and sizes of query parameters.
|
|
1178
|
+
For example:
|
|
1179
|
+
|
|
1180
|
+
sql = "INSERT INTO product (item, price) VALUES (?, ?)"
|
|
1181
|
+
params = [('bicycle', 499.99), ('ham', 17.95)]
|
|
1182
|
+
# specify that parameters are for NVARCHAR(50) and DECIMAL(18,4) columns
|
|
1183
|
+
cursor.setinputsizes([(SQL_WVARCHAR, 50, 0), (SQL_DECIMAL, 18, 4)])
|
|
1184
|
+
cursor.executemany(sql, params)
|
|
1185
|
+
|
|
1186
|
+
Note:
|
|
1187
|
+
SQL_SS_UDT parameters require a statement whose parameter metadata
|
|
1188
|
+
SQL Server can describe. The driver discovers the UDT identity before
|
|
1189
|
+
binding; this may require a metadata round trip. Temporary tables and
|
|
1190
|
+
table variables may not be describable, and discovery errors are
|
|
1191
|
+
propagated. For built-in spatial types, bind serialized bytes without
|
|
1192
|
+
a SQL_SS_UDT override or use a SQL constructor such as
|
|
1193
|
+
hierarchyid::Parse(?) with a text parameter instead.
|
|
1194
|
+
|
|
1195
|
+
When inserting NULL into BINARY/VARBINARY columns in temp tables (#table)
|
|
1196
|
+
or table variables, SQLDescribeParam cannot resolve the column type and
|
|
1197
|
+
falls back to SQL_VARCHAR. This causes an implicit conversion error from
|
|
1198
|
+
SQL Server. Use setinputsizes to explicitly specify types for ALL
|
|
1199
|
+
parameters::
|
|
1200
|
+
|
|
1201
|
+
from mssql_python.constants import ConstantsDDBC
|
|
1202
|
+
cursor.setinputsizes([
|
|
1203
|
+
(ConstantsDDBC.SQL_INTEGER.value, 0, 0),
|
|
1204
|
+
(ConstantsDDBC.SQL_VARBINARY.value, 100, 0)
|
|
1205
|
+
])
|
|
1206
|
+
cursor.execute("INSERT INTO #t (id, data) VALUES (?, ?)", [1, None])
|
|
1207
|
+
|
|
1208
|
+
Args:
|
|
1209
|
+
sizes: A sequence of tuples, one for each parameter. Each tuple contains
|
|
1210
|
+
(sql_type, size, decimal_digits) where size and decimal_digits are optional.
|
|
1211
|
+
"""
|
|
1212
|
+
|
|
1213
|
+
# Get valid SQL types from centralized constants
|
|
1214
|
+
valid_sql_types = SQLTypes.get_valid_types()
|
|
1215
|
+
|
|
1216
|
+
self._inputsizes = []
|
|
1217
|
+
|
|
1218
|
+
if sizes:
|
|
1219
|
+
for size_info in sizes:
|
|
1220
|
+
if isinstance(size_info, tuple):
|
|
1221
|
+
# Handle tuple format (sql_type, size, decimal_digits)
|
|
1222
|
+
if len(size_info) == 1:
|
|
1223
|
+
sql_type = size_info[0]
|
|
1224
|
+
column_size = 0
|
|
1225
|
+
decimal_digits = 0
|
|
1226
|
+
elif len(size_info) == 2:
|
|
1227
|
+
sql_type, column_size = size_info
|
|
1228
|
+
decimal_digits = 0
|
|
1229
|
+
elif len(size_info) >= 3:
|
|
1230
|
+
sql_type, column_size, decimal_digits = size_info
|
|
1231
|
+
|
|
1232
|
+
# Validate SQL type
|
|
1233
|
+
if not isinstance(sql_type, int) or sql_type not in valid_sql_types:
|
|
1234
|
+
raise ValueError(
|
|
1235
|
+
f"Invalid SQL type: {sql_type}. Must be a valid SQL type constant."
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
sql_type = ODBC3_TEMPORAL_SQL_TYPES.get(sql_type, sql_type)
|
|
1239
|
+
|
|
1240
|
+
# Validate size and precision
|
|
1241
|
+
if not isinstance(column_size, int) or column_size < 0:
|
|
1242
|
+
raise ValueError(
|
|
1243
|
+
f"Invalid column size: {column_size}. Must be a non-negative integer."
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
if not isinstance(decimal_digits, int) or decimal_digits < 0:
|
|
1247
|
+
raise ValueError(
|
|
1248
|
+
f"Invalid decimal digits: {decimal_digits}. "
|
|
1249
|
+
f"Must be a non-negative integer."
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
self._inputsizes.append(
|
|
1253
|
+
(
|
|
1254
|
+
sql_type,
|
|
1255
|
+
self._get_c_type_for_sql_type(sql_type),
|
|
1256
|
+
column_size,
|
|
1257
|
+
decimal_digits,
|
|
1258
|
+
)
|
|
1259
|
+
)
|
|
1260
|
+
else:
|
|
1261
|
+
# Handle single value (just sql_type)
|
|
1262
|
+
sql_type = size_info
|
|
1263
|
+
|
|
1264
|
+
# Validate SQL type
|
|
1265
|
+
if not isinstance(sql_type, int) or sql_type not in valid_sql_types:
|
|
1266
|
+
raise ValueError(
|
|
1267
|
+
f"Invalid SQL type: {sql_type}. Must be a valid SQL type constant."
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
sql_type = ODBC3_TEMPORAL_SQL_TYPES.get(sql_type, sql_type)
|
|
1271
|
+
|
|
1272
|
+
self._inputsizes.append(
|
|
1273
|
+
(sql_type, self._get_c_type_for_sql_type(sql_type), 0, 0)
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
def _reset_inputsizes(self) -> None:
|
|
1277
|
+
"""Reset input sizes after execution"""
|
|
1278
|
+
self._inputsizes = None
|
|
1279
|
+
|
|
1280
|
+
# Pre-built constant lookup table — avoids rebuilding ~30 entries on every call.
|
|
1281
|
+
# Used by setinputsizes fallback path (PR #549 fast path doesn't need this).
|
|
1282
|
+
_SQL_TO_C_TYPE = None
|
|
1283
|
+
|
|
1284
|
+
@classmethod
|
|
1285
|
+
def _get_sql_to_c_type_map(cls):
|
|
1286
|
+
if cls._SQL_TO_C_TYPE is None:
|
|
1287
|
+
cls._SQL_TO_C_TYPE = {
|
|
1288
|
+
ddbc_sql_const.SQL_CHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
|
|
1289
|
+
ddbc_sql_const.SQL_VARCHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
|
|
1290
|
+
ddbc_sql_const.SQL_LONGVARCHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
|
|
1291
|
+
ddbc_sql_const.SQL_WCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value,
|
|
1292
|
+
ddbc_sql_const.SQL_WVARCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value,
|
|
1293
|
+
ddbc_sql_const.SQL_WLONGVARCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value,
|
|
1294
|
+
ddbc_sql_const.SQL_DECIMAL.value: ddbc_sql_const.SQL_C_NUMERIC.value,
|
|
1295
|
+
ddbc_sql_const.SQL_NUMERIC.value: ddbc_sql_const.SQL_C_NUMERIC.value,
|
|
1296
|
+
ddbc_sql_const.SQL_BIT.value: ddbc_sql_const.SQL_C_BIT.value,
|
|
1297
|
+
ddbc_sql_const.SQL_TINYINT.value: ddbc_sql_const.SQL_C_TINYINT.value,
|
|
1298
|
+
ddbc_sql_const.SQL_SMALLINT.value: ddbc_sql_const.SQL_C_SHORT.value,
|
|
1299
|
+
ddbc_sql_const.SQL_INTEGER.value: ddbc_sql_const.SQL_C_LONG.value,
|
|
1300
|
+
ddbc_sql_const.SQL_BIGINT.value: ddbc_sql_const.SQL_C_SBIGINT.value,
|
|
1301
|
+
ddbc_sql_const.SQL_REAL.value: ddbc_sql_const.SQL_C_FLOAT.value,
|
|
1302
|
+
ddbc_sql_const.SQL_FLOAT.value: ddbc_sql_const.SQL_C_DOUBLE.value,
|
|
1303
|
+
ddbc_sql_const.SQL_DOUBLE.value: ddbc_sql_const.SQL_C_DOUBLE.value,
|
|
1304
|
+
ddbc_sql_const.SQL_BINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
|
|
1305
|
+
ddbc_sql_const.SQL_VARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
|
|
1306
|
+
ddbc_sql_const.SQL_LONGVARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
|
|
1307
|
+
ddbc_sql_const.SQL_SS_UDT.value: ddbc_sql_const.SQL_C_BINARY.value,
|
|
1308
|
+
ddbc_sql_const.SQL_TYPE_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
|
|
1309
|
+
ddbc_sql_const.SQL_TYPE_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
|
|
1310
|
+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
|
|
1311
|
+
ddbc_sql_const.SQL_SS_TIME2.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
|
|
1312
|
+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
|
|
1313
|
+
ddbc_sql_const.SQL_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
|
|
1314
|
+
ddbc_sql_const.SQL_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
|
|
1315
|
+
ddbc_sql_const.SQL_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
|
|
1316
|
+
ddbc_sql_const.SQL_GUID.value: ddbc_sql_const.SQL_C_GUID.value,
|
|
1317
|
+
ddbc_sql_const.SQL_SS_XML.value: ddbc_sql_const.SQL_C_WCHAR.value,
|
|
1318
|
+
ddbc_sql_const.SQL_SS_VARIANT.value: ddbc_sql_const.SQL_C_BINARY.value,
|
|
1319
|
+
}
|
|
1320
|
+
return cls._SQL_TO_C_TYPE
|
|
1321
|
+
|
|
1322
|
+
def _get_c_type_for_sql_type(self, sql_type: int) -> int:
|
|
1323
|
+
"""Map SQL type to appropriate C type for parameter binding."""
|
|
1324
|
+
return self._get_sql_to_c_type_map().get(sql_type, ddbc_sql_const.SQL_C_DEFAULT.value)
|
|
1325
|
+
|
|
1326
|
+
def _create_parameter_types_list( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
1327
|
+
self,
|
|
1328
|
+
parameter: Any,
|
|
1329
|
+
param_info: Optional[Tuple[Any, ...]],
|
|
1330
|
+
parameters_list: List[Any],
|
|
1331
|
+
i: int,
|
|
1332
|
+
min_val: Optional[Any] = None,
|
|
1333
|
+
max_val: Optional[Any] = None,
|
|
1334
|
+
decimal_as_numeric: bool = False,
|
|
1335
|
+
) -> Tuple[int, int, int, int, bool]:
|
|
1336
|
+
"""
|
|
1337
|
+
Maps parameter types for the given parameter.
|
|
1338
|
+
|
|
1339
|
+
Python-side type detection for executemany(). The standard execute() path
|
|
1340
|
+
uses DetectParamTypes in C++ instead. This helper goes away once executemany
|
|
1341
|
+
has native columnwise detection.
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
parameter: parameter to bind.
|
|
1345
|
+
Returns:
|
|
1346
|
+
paraminfo.
|
|
1347
|
+
"""
|
|
1348
|
+
paraminfo = param_info()
|
|
1349
|
+
|
|
1350
|
+
sql_type, c_type, column_size, decimal_digits, is_dae = self._map_sql_type(
|
|
1351
|
+
parameter,
|
|
1352
|
+
parameters_list,
|
|
1353
|
+
i,
|
|
1354
|
+
min_val=min_val,
|
|
1355
|
+
max_val=max_val,
|
|
1356
|
+
decimal_as_numeric=decimal_as_numeric,
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
# If TIME values are being bound via text C-types, normalize them to a
|
|
1360
|
+
# textual representation expected by SQL_C_CHAR/SQL_C_WCHAR binding.
|
|
1361
|
+
time_text = _normalize_time_param(parameter, c_type)
|
|
1362
|
+
if time_text is not None:
|
|
1363
|
+
parameters_list[i] = time_text
|
|
1364
|
+
column_size = max(column_size, len(time_text))
|
|
1365
|
+
|
|
1366
|
+
paraminfo.paramCType = c_type
|
|
1367
|
+
paraminfo.paramSQLType = sql_type
|
|
1368
|
+
paraminfo.inputOutputType = ddbc_sql_const.SQL_PARAM_INPUT.value
|
|
1369
|
+
paraminfo.columnSize = column_size
|
|
1370
|
+
paraminfo.decimalDigits = decimal_digits
|
|
1371
|
+
paraminfo.isDAE = is_dae
|
|
1372
|
+
|
|
1373
|
+
if is_dae:
|
|
1374
|
+
paraminfo.dataPtr = parameter # Will be converted to py::object* in C++
|
|
1375
|
+
|
|
1376
|
+
return paraminfo
|
|
1377
|
+
|
|
1378
|
+
def _initialize_description(self, column_metadata: Optional[Any] = None) -> None:
|
|
1379
|
+
"""Initialize the description attribute from column metadata."""
|
|
1380
|
+
if not column_metadata:
|
|
1381
|
+
self.description = None
|
|
1382
|
+
self._column_sql_types = None
|
|
1383
|
+
return
|
|
1384
|
+
|
|
1385
|
+
description = []
|
|
1386
|
+
# Raw ODBC SQL type codes, parallel to description, for output-converter
|
|
1387
|
+
# dispatch by integer SQL type (see _build_converter_map / #684).
|
|
1388
|
+
sql_type_codes = []
|
|
1389
|
+
for _, col in enumerate(column_metadata):
|
|
1390
|
+
# Get column name - lowercase it if the lowercase flag is set
|
|
1391
|
+
column_name = col["ColumnName"]
|
|
1392
|
+
|
|
1393
|
+
# Use the current global setting to ensure tests pass correctly
|
|
1394
|
+
if get_settings().lowercase:
|
|
1395
|
+
column_name = column_name.lower()
|
|
1396
|
+
|
|
1397
|
+
sql_type_codes.append(col["DataType"])
|
|
1398
|
+
# Add to description tuple (7 elements as per PEP-249)
|
|
1399
|
+
description.append(
|
|
1400
|
+
(
|
|
1401
|
+
column_name, # name
|
|
1402
|
+
self._map_data_type(col["DataType"]), # type_code
|
|
1403
|
+
None, # display_size
|
|
1404
|
+
col["ColumnSize"], # internal_size
|
|
1405
|
+
col["ColumnSize"], # precision - should match ColumnSize
|
|
1406
|
+
col["DecimalDigits"], # scale
|
|
1407
|
+
col["Nullable"] == ddbc_sql_const.SQL_NULLABLE.value, # null_ok
|
|
1408
|
+
)
|
|
1409
|
+
)
|
|
1410
|
+
self.description = description
|
|
1411
|
+
self._column_sql_types = sql_type_codes
|
|
1412
|
+
|
|
1413
|
+
def _build_converter_map(self):
|
|
1414
|
+
"""
|
|
1415
|
+
Build a pre-computed converter map for output converters.
|
|
1416
|
+
Returns a list where each element is either a converter function or None.
|
|
1417
|
+
An empty tuple means no converters apply; None is reserved for uncached
|
|
1418
|
+
direct Row construction and its legacy connection lookup.
|
|
1419
|
+
String fallback converters check each value's type because variant and
|
|
1420
|
+
unknown SQL types can have str metadata but non-string values.
|
|
1421
|
+
This eliminates the need to look up converters for every row.
|
|
1422
|
+
"""
|
|
1423
|
+
generation = self._connection._converters_generation
|
|
1424
|
+
if (
|
|
1425
|
+
not self.description
|
|
1426
|
+
or not hasattr(self.connection, "_output_converters")
|
|
1427
|
+
or not self.connection._output_converters
|
|
1428
|
+
):
|
|
1429
|
+
self._cached_converters_generation = generation
|
|
1430
|
+
return ()
|
|
1431
|
+
|
|
1432
|
+
sql_type_codes = self._column_sql_types
|
|
1433
|
+
converter_map = []
|
|
1434
|
+
|
|
1435
|
+
for i, desc in enumerate(self.description):
|
|
1436
|
+
if desc is None:
|
|
1437
|
+
converter_map.append(None)
|
|
1438
|
+
continue
|
|
1439
|
+
converter = None
|
|
1440
|
+
# 1) pyodbc-compatible: dispatch on the raw ODBC integer SQL type code
|
|
1441
|
+
# (e.g. SQL_DECIMAL). This is the key add_output_converter documents. #684
|
|
1442
|
+
if sql_type_codes is not None and i < len(sql_type_codes):
|
|
1443
|
+
converter = self.connection.get_output_converter(sql_type_codes[i])
|
|
1444
|
+
# 2) fall back to the Python type stored in description[i][1]
|
|
1445
|
+
# (e.g. decimal.Decimal) - the pre-existing mssql-python key style.
|
|
1446
|
+
if converter is None:
|
|
1447
|
+
converter = self.connection.get_output_converter(desc[1])
|
|
1448
|
+
# 3) The WVARCHAR fallback must also check the value: SQL_VARIANT and
|
|
1449
|
+
# unknown types map to str but can return non-string Python values.
|
|
1450
|
+
if converter is None and desc[1] in (str, bytes):
|
|
1451
|
+
converter = self.connection.get_output_converter(ddbc_sql_const.SQL_WVARCHAR.value)
|
|
1452
|
+
if converter:
|
|
1453
|
+
converter = _string_only_output_converter(converter)
|
|
1454
|
+
|
|
1455
|
+
converter_map.append(converter)
|
|
1456
|
+
|
|
1457
|
+
self._cached_converters_generation = generation
|
|
1458
|
+
return converter_map if any(converter is not None for converter in converter_map) else ()
|
|
1459
|
+
|
|
1460
|
+
def _compute_uuid_str_indices(self):
|
|
1461
|
+
"""
|
|
1462
|
+
Compute the tuple of column indices whose uuid.UUID values should be
|
|
1463
|
+
stringified (as uppercase), based on the effective native_uuid setting.
|
|
1464
|
+
|
|
1465
|
+
Resolution order: connection-level (if set) → module-level (fallback).
|
|
1466
|
+
|
|
1467
|
+
Returns:
|
|
1468
|
+
tuple of int or None: Column indices to stringify, or None when
|
|
1469
|
+
native_uuid is True — meaning zero per-row overhead.
|
|
1470
|
+
"""
|
|
1471
|
+
if not self.description:
|
|
1472
|
+
return None
|
|
1473
|
+
|
|
1474
|
+
effective_native_uuid = (
|
|
1475
|
+
self._conn_native_uuid
|
|
1476
|
+
if self._conn_native_uuid is not None
|
|
1477
|
+
else get_settings().native_uuid
|
|
1478
|
+
)
|
|
1479
|
+
if not effective_native_uuid:
|
|
1480
|
+
indices = tuple(
|
|
1481
|
+
i for i, desc in enumerate(self.description) if desc and desc[1] is uuid.UUID
|
|
1482
|
+
)
|
|
1483
|
+
return indices if indices else None
|
|
1484
|
+
return None
|
|
1485
|
+
|
|
1486
|
+
def _get_column_and_converter_maps(self):
|
|
1487
|
+
"""
|
|
1488
|
+
Get column map and converter map for Row construction (thread-safe).
|
|
1489
|
+
This centralizes the column map building logic to eliminate duplication
|
|
1490
|
+
and ensure thread-safe lazy initialization.
|
|
1491
|
+
|
|
1492
|
+
Returns:
|
|
1493
|
+
tuple: (column_map, converter_map)
|
|
1494
|
+
"""
|
|
1495
|
+
# Thread-safe lazy initialization of column map
|
|
1496
|
+
column_map = self._cached_column_map
|
|
1497
|
+
if column_map is None and self.description:
|
|
1498
|
+
# Build column map locally first, then assign to cache
|
|
1499
|
+
column_map = {col_desc[0]: i for i, col_desc in enumerate(self.description)}
|
|
1500
|
+
self._cached_column_map = column_map
|
|
1501
|
+
self._cached_column_map_lower = (
|
|
1502
|
+
{k.lower(): v for k, v in column_map.items()} if get_settings().lowercase else None
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
# Fallback to legacy column name map if no cached map
|
|
1506
|
+
column_map = column_map or getattr(self, "_column_name_map", None)
|
|
1507
|
+
|
|
1508
|
+
# Refresh once per settings change, not once per row.
|
|
1509
|
+
if self._cached_converters_generation != self._connection._converters_generation:
|
|
1510
|
+
self._cached_converter_map = self._build_converter_map()
|
|
1511
|
+
converter_map = getattr(self, "_cached_converter_map", None)
|
|
1512
|
+
|
|
1513
|
+
# Snapshot canonical column names once per result set (identity-tracked against
|
|
1514
|
+
# self.description) so each Row carries stable names even after the cursor is reused.
|
|
1515
|
+
if self.description is not None and self._result_columns_src is not self.description:
|
|
1516
|
+
self._cached_result_columns = tuple(col_desc[0] for col_desc in self.description)
|
|
1517
|
+
self._result_columns_src = self.description
|
|
1518
|
+
|
|
1519
|
+
return column_map, converter_map, self._cached_column_map_lower
|
|
1520
|
+
|
|
1521
|
+
def _map_data_type(self, sql_type):
|
|
1522
|
+
"""
|
|
1523
|
+
Map SQL data type to Python data type.
|
|
1524
|
+
|
|
1525
|
+
Maps the ODBC SQL type code returned by SQLDescribeCol to the
|
|
1526
|
+
corresponding Python type for cursor.description[i][1].
|
|
1527
|
+
|
|
1528
|
+
The ODBC 18 driver for SQL Server reports these type codes:
|
|
1529
|
+
Standard ODBC 3.x types:
|
|
1530
|
+
SQL_CHAR(1), SQL_VARCHAR(12), SQL_LONGVARCHAR(-1),
|
|
1531
|
+
SQL_WCHAR(-8), SQL_WVARCHAR(-9), SQL_WLONGVARCHAR(-10),
|
|
1532
|
+
SQL_INTEGER(4), SQL_SMALLINT(5), SQL_TINYINT(-6), SQL_BIGINT(-5),
|
|
1533
|
+
SQL_BIT(-7), SQL_FLOAT(6), SQL_REAL(7), SQL_DOUBLE(8),
|
|
1534
|
+
SQL_DECIMAL(3), SQL_NUMERIC(2),
|
|
1535
|
+
SQL_BINARY(-2), SQL_VARBINARY(-3), SQL_LONGVARBINARY(-4),
|
|
1536
|
+
SQL_TYPE_DATE(91), SQL_TYPE_TIME(92), SQL_TYPE_TIMESTAMP(93), SQL_GUID(-11)
|
|
1537
|
+
SQL Server-specific types (from msodbcsql.h):
|
|
1538
|
+
SQL_SS_TIME2(-154) for time columns
|
|
1539
|
+
SQL_DATETIMEOFFSET(-155) for datetimeoffset columns
|
|
1540
|
+
SQL_SS_XML(-152) for xml columns
|
|
1541
|
+
SQL_SS_UDT(-151) for geography/geometry/hierarchyid columns
|
|
1542
|
+
|
|
1543
|
+
ODBC 2.x aliases (9, 10, 11) are also accepted defensively.
|
|
1544
|
+
|
|
1545
|
+
Args:
|
|
1546
|
+
sql_type: SQL data type code from SQLDescribeCol.
|
|
1547
|
+
|
|
1548
|
+
Returns:
|
|
1549
|
+
Corresponding Python data type.
|
|
1550
|
+
"""
|
|
1551
|
+
sql_to_python_type = {
|
|
1552
|
+
# String types
|
|
1553
|
+
ddbc_sql_const.SQL_CHAR.value: str,
|
|
1554
|
+
ddbc_sql_const.SQL_VARCHAR.value: str,
|
|
1555
|
+
ddbc_sql_const.SQL_LONGVARCHAR.value: str,
|
|
1556
|
+
ddbc_sql_const.SQL_WCHAR.value: str,
|
|
1557
|
+
ddbc_sql_const.SQL_WVARCHAR.value: str,
|
|
1558
|
+
ddbc_sql_const.SQL_WLONGVARCHAR.value: str,
|
|
1559
|
+
# Integer types
|
|
1560
|
+
ddbc_sql_const.SQL_INTEGER.value: int,
|
|
1561
|
+
ddbc_sql_const.SQL_SMALLINT.value: int,
|
|
1562
|
+
ddbc_sql_const.SQL_TINYINT.value: int,
|
|
1563
|
+
ddbc_sql_const.SQL_BIGINT.value: int,
|
|
1564
|
+
# Floating-point types
|
|
1565
|
+
ddbc_sql_const.SQL_FLOAT.value: float,
|
|
1566
|
+
ddbc_sql_const.SQL_DOUBLE.value: float,
|
|
1567
|
+
ddbc_sql_const.SQL_REAL.value: float,
|
|
1568
|
+
# Exact numeric types
|
|
1569
|
+
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
|
|
1570
|
+
ddbc_sql_const.SQL_NUMERIC.value: decimal.Decimal,
|
|
1571
|
+
# Date/time types — values the ODBC 18 driver actually reports
|
|
1572
|
+
ddbc_sql_const.SQL_TYPE_DATE.value: datetime.date, # 91 — date
|
|
1573
|
+
ddbc_sql_const.SQL_TYPE_TIME.value: datetime.time, # 92 — time (ODBC 3.x)
|
|
1574
|
+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: datetime.datetime, # 93 — datetime/datetime2/smalldatetime
|
|
1575
|
+
ddbc_sql_const.SQL_SS_TIME2.value: datetime.time, # -154 — time
|
|
1576
|
+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: datetime.datetime, # -155 — datetimeoffset
|
|
1577
|
+
# ODBC 2.x date/time aliases (defensive, in case any driver reports these)
|
|
1578
|
+
ddbc_sql_const.SQL_DATE.value: datetime.date, # 9
|
|
1579
|
+
ddbc_sql_const.SQL_TIME.value: datetime.time, # 10
|
|
1580
|
+
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime, # 11
|
|
1581
|
+
# Boolean
|
|
1582
|
+
ddbc_sql_const.SQL_BIT.value: bool,
|
|
1583
|
+
# Binary types
|
|
1584
|
+
ddbc_sql_const.SQL_BINARY.value: bytes,
|
|
1585
|
+
ddbc_sql_const.SQL_VARBINARY.value: bytes,
|
|
1586
|
+
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
|
|
1587
|
+
ddbc_sql_const.SQL_SS_UDT.value: bytes,
|
|
1588
|
+
# UUID
|
|
1589
|
+
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
|
|
1590
|
+
# XML — driver reports SQL_SS_XML (-152), fetched as str
|
|
1591
|
+
ddbc_sql_const.SQL_SS_XML.value: str,
|
|
1592
|
+
}
|
|
1593
|
+
return sql_to_python_type.get(sql_type, str)
|
|
1594
|
+
|
|
1595
|
+
@property
|
|
1596
|
+
def rownumber(self) -> int:
|
|
1597
|
+
"""
|
|
1598
|
+
DB-API extension: Current 0-based index of the cursor in the result set.
|
|
1599
|
+
|
|
1600
|
+
Returns:
|
|
1601
|
+
int or None: The current 0-based index of the cursor in the result set,
|
|
1602
|
+
or None if no row has been fetched yet or the index cannot be determined.
|
|
1603
|
+
|
|
1604
|
+
Note:
|
|
1605
|
+
- Returns -1 before the first successful fetch
|
|
1606
|
+
- Returns 0 after fetching the first row
|
|
1607
|
+
- Returns -1 for empty result sets (since no rows can be fetched)
|
|
1608
|
+
|
|
1609
|
+
Warning:
|
|
1610
|
+
This is a DB-API extension and may not be portable across different
|
|
1611
|
+
database modules.
|
|
1612
|
+
"""
|
|
1613
|
+
# Use mssql_python logging system instead of standard warnings
|
|
1614
|
+
logger.warning("DB-API extension cursor.rownumber used")
|
|
1615
|
+
|
|
1616
|
+
# Return None if cursor is closed or no result set is available
|
|
1617
|
+
if self.closed or not self._has_result_set:
|
|
1618
|
+
return -1
|
|
1619
|
+
|
|
1620
|
+
return self._rownumber # Will be None until first fetch, then 0, 1, 2, etc.
|
|
1621
|
+
|
|
1622
|
+
@property
|
|
1623
|
+
def connection(self) -> "Connection":
|
|
1624
|
+
"""
|
|
1625
|
+
DB-API 2.0 attribute: Connection object that created this cursor.
|
|
1626
|
+
|
|
1627
|
+
This is a read-only reference to the Connection object that was used to create
|
|
1628
|
+
this cursor. This attribute is useful for polymorphic code that needs access
|
|
1629
|
+
to connection-level functionality.
|
|
1630
|
+
|
|
1631
|
+
Returns:
|
|
1632
|
+
Connection: The connection object that created this cursor.
|
|
1633
|
+
|
|
1634
|
+
Note:
|
|
1635
|
+
This attribute is read-only as specified by DB-API 2.0. Attempting to
|
|
1636
|
+
assign to this attribute will raise an AttributeError.
|
|
1637
|
+
"""
|
|
1638
|
+
return self._connection
|
|
1639
|
+
|
|
1640
|
+
def _reset_rownumber(self) -> None:
|
|
1641
|
+
"""Reset the rownumber tracking when starting a new result set."""
|
|
1642
|
+
self._rownumber = -1
|
|
1643
|
+
self._next_row_index = 0
|
|
1644
|
+
self._has_result_set = True
|
|
1645
|
+
self._skip_increment_for_next_fetch = False
|
|
1646
|
+
|
|
1647
|
+
def _increment_rownumber(self):
|
|
1648
|
+
"""
|
|
1649
|
+
Called after a successful fetch from the driver. Keep both counters consistent.
|
|
1650
|
+
"""
|
|
1651
|
+
if self._has_result_set:
|
|
1652
|
+
# driver returned one row, so the next row index increments by 1
|
|
1653
|
+
self._next_row_index += 1
|
|
1654
|
+
# rownumber is last returned row index
|
|
1655
|
+
self._rownumber = self._next_row_index - 1
|
|
1656
|
+
else:
|
|
1657
|
+
raise InterfaceError(
|
|
1658
|
+
"Cannot increment rownumber: no active result set.",
|
|
1659
|
+
"No active result set.",
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
# Will be used when we add support for scrollable cursors
|
|
1663
|
+
def _decrement_rownumber(self):
|
|
1664
|
+
"""
|
|
1665
|
+
Decrement the rownumber by 1.
|
|
1666
|
+
|
|
1667
|
+
This could be used for error recovery or cursor positioning operations.
|
|
1668
|
+
"""
|
|
1669
|
+
if self._has_result_set and self._rownumber >= 0:
|
|
1670
|
+
if self._rownumber > 0:
|
|
1671
|
+
self._rownumber -= 1
|
|
1672
|
+
else:
|
|
1673
|
+
self._rownumber = -1
|
|
1674
|
+
else:
|
|
1675
|
+
raise InterfaceError(
|
|
1676
|
+
"Cannot decrement rownumber: no active result set.",
|
|
1677
|
+
"No active result set.",
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
def _clear_rownumber(self):
|
|
1681
|
+
"""
|
|
1682
|
+
Clear the rownumber tracking.
|
|
1683
|
+
|
|
1684
|
+
This should be called when the result set is cleared or when the cursor is reset.
|
|
1685
|
+
"""
|
|
1686
|
+
self._rownumber = -1
|
|
1687
|
+
self._has_result_set = False
|
|
1688
|
+
self._skip_increment_for_next_fetch = False
|
|
1689
|
+
|
|
1690
|
+
def __iter__(self):
|
|
1691
|
+
"""
|
|
1692
|
+
Return the cursor itself as an iterator.
|
|
1693
|
+
|
|
1694
|
+
This allows direct iteration over the cursor after execute():
|
|
1695
|
+
|
|
1696
|
+
for row in cursor.execute("SELECT * FROM table"):
|
|
1697
|
+
print(row)
|
|
1698
|
+
"""
|
|
1699
|
+
self._check_closed()
|
|
1700
|
+
return self
|
|
1701
|
+
|
|
1702
|
+
def __next__(self):
|
|
1703
|
+
"""
|
|
1704
|
+
Fetch the next row when iterating over the cursor.
|
|
1705
|
+
|
|
1706
|
+
Returns:
|
|
1707
|
+
The next Row object.
|
|
1708
|
+
|
|
1709
|
+
Raises:
|
|
1710
|
+
StopIteration: When no more rows are available.
|
|
1711
|
+
"""
|
|
1712
|
+
self._check_closed()
|
|
1713
|
+
row = self.fetchone()
|
|
1714
|
+
if row is None:
|
|
1715
|
+
raise StopIteration
|
|
1716
|
+
return row
|
|
1717
|
+
|
|
1718
|
+
def next(self):
|
|
1719
|
+
"""
|
|
1720
|
+
Fetch the next row from the cursor.
|
|
1721
|
+
|
|
1722
|
+
This is an alias for __next__() to maintain compatibility with older code.
|
|
1723
|
+
|
|
1724
|
+
Returns:
|
|
1725
|
+
The next Row object.
|
|
1726
|
+
|
|
1727
|
+
Raises:
|
|
1728
|
+
StopIteration: When no more rows are available.
|
|
1729
|
+
"""
|
|
1730
|
+
return next(self)
|
|
1731
|
+
|
|
1732
|
+
def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
1733
|
+
self,
|
|
1734
|
+
operation: str,
|
|
1735
|
+
*parameters,
|
|
1736
|
+
use_prepare: bool = True,
|
|
1737
|
+
reset_cursor: bool = True,
|
|
1738
|
+
) -> "Cursor":
|
|
1739
|
+
"""
|
|
1740
|
+
Prepare and execute a database operation (query or command).
|
|
1741
|
+
|
|
1742
|
+
Args:
|
|
1743
|
+
operation: SQL query or command.
|
|
1744
|
+
parameters: Sequence of parameters to bind.
|
|
1745
|
+
use_prepare: Whether to use SQLPrepareW (default) or SQLExecDirectW.
|
|
1746
|
+
reset_cursor: Whether to reset the cursor before execution.
|
|
1747
|
+
"""
|
|
1748
|
+
logger.debug(
|
|
1749
|
+
"execute: Starting - operation_length=%d, param_count=%d, use_prepare=%s",
|
|
1750
|
+
len(operation),
|
|
1751
|
+
len(parameters),
|
|
1752
|
+
str(use_prepare),
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
# Log the actual query being executed
|
|
1756
|
+
logger.debug("Executing query: %s", operation)
|
|
1757
|
+
|
|
1758
|
+
self._check_closed() # Check if the cursor is closed
|
|
1759
|
+
if reset_cursor:
|
|
1760
|
+
if self.hstmt:
|
|
1761
|
+
self._soft_reset_cursor()
|
|
1762
|
+
else:
|
|
1763
|
+
self._reset_cursor()
|
|
1764
|
+
else:
|
|
1765
|
+
# Close just the ODBC cursor (not the statement handle) so the
|
|
1766
|
+
# prepared plan can be reused. SQLFreeStmt(SQL_CLOSE) releases
|
|
1767
|
+
# the cursor associated with hstmt without destroying the
|
|
1768
|
+
# prepared statement, which is the standard ODBC pattern for
|
|
1769
|
+
# re-executing a prepared query.
|
|
1770
|
+
if self.hstmt:
|
|
1771
|
+
logger.debug("execute: Closing cursor for re-execution (reset_cursor=False)")
|
|
1772
|
+
self.hstmt._close_cursor()
|
|
1773
|
+
self._clear_rownumber()
|
|
1774
|
+
|
|
1775
|
+
# Clear any previous messages
|
|
1776
|
+
self.messages = []
|
|
1777
|
+
|
|
1778
|
+
# Auto-detect and convert parameter style if needed
|
|
1779
|
+
# Supports both qmark (?) and pyformat (%(name)s)
|
|
1780
|
+
# Note: parameters is always a tuple due to *parameters in method signature
|
|
1781
|
+
#
|
|
1782
|
+
# Parameter Passing Rules (handling ambiguity):
|
|
1783
|
+
#
|
|
1784
|
+
# 1. Single value:
|
|
1785
|
+
# cursor.execute("SELECT ?", 42)
|
|
1786
|
+
# → parameters = (42,)
|
|
1787
|
+
# → Wrapped as single parameter
|
|
1788
|
+
#
|
|
1789
|
+
# 2. Multiple values (two equivalent ways):
|
|
1790
|
+
# cursor.execute("SELECT ?, ?", 1, 2) # Varargs
|
|
1791
|
+
# cursor.execute("SELECT ?, ?", (1, 2)) # Tuple
|
|
1792
|
+
# → Both result in parameters = (1, 2) or ((1, 2),)
|
|
1793
|
+
# → If single tuple/list/dict arg, it's unwrapped
|
|
1794
|
+
#
|
|
1795
|
+
# 3. Dict for named parameters:
|
|
1796
|
+
# cursor.execute("SELECT %(id)s", {"id": 42})
|
|
1797
|
+
# → parameters = ({"id": 42},)
|
|
1798
|
+
# → Unwrapped to {"id": 42}, then converted to qmark style
|
|
1799
|
+
#
|
|
1800
|
+
# Important: If you pass a tuple/list/dict as the ONLY argument,
|
|
1801
|
+
# it will be unwrapped for parameter binding. This means you cannot
|
|
1802
|
+
# pass a tuple as a single parameter value (but SQL Server doesn't
|
|
1803
|
+
# support tuple types as parameter values anyway).
|
|
1804
|
+
with perf_phase("py::execute::param_prep"):
|
|
1805
|
+
if parameters:
|
|
1806
|
+
# Check if single parameter is a nested container that should be unwrapped
|
|
1807
|
+
# e.g., execute("SELECT ?", (value,)) vs execute("SELECT ?, ?", ((1, 2),))
|
|
1808
|
+
if isinstance(parameters, tuple) and len(parameters) == 1:
|
|
1809
|
+
if isinstance(parameters[0], (tuple, list, dict)):
|
|
1810
|
+
actual_params = parameters[0]
|
|
1811
|
+
elif isinstance(parameters[0], Row):
|
|
1812
|
+
# A Row (e.g. from fetchone()) is a sequence of column values.
|
|
1813
|
+
# Normalize it to a tuple so the downstream binding logic, which
|
|
1814
|
+
# only handles tuple/list/dict, can unwrap it into individual
|
|
1815
|
+
# parameters instead of treating the whole Row as one value.
|
|
1816
|
+
actual_params = tuple(parameters[0])
|
|
1817
|
+
else:
|
|
1818
|
+
actual_params = parameters
|
|
1819
|
+
else:
|
|
1820
|
+
actual_params = parameters
|
|
1821
|
+
|
|
1822
|
+
# Skip detect_and_convert_parameters when re-executing the same SQL —
|
|
1823
|
+
# the parameter style (qmark vs pyformat) won't change between calls.
|
|
1824
|
+
if operation == self.last_executed_stmt and isinstance(
|
|
1825
|
+
actual_params, (tuple, list)
|
|
1826
|
+
):
|
|
1827
|
+
parameters = list(actual_params)
|
|
1828
|
+
else:
|
|
1829
|
+
operation, converted_params = detect_and_convert_parameters(
|
|
1830
|
+
operation, actual_params
|
|
1831
|
+
)
|
|
1832
|
+
parameters = list(converted_params)
|
|
1833
|
+
else:
|
|
1834
|
+
parameters = []
|
|
1835
|
+
|
|
1836
|
+
# Getting encoding setting
|
|
1837
|
+
encoding_settings = self._get_encoding_settings()
|
|
1838
|
+
|
|
1839
|
+
# Validate that inputsizes matches parameter count if both are present
|
|
1840
|
+
if parameters and self._inputsizes:
|
|
1841
|
+
if len(self._inputsizes) != len(parameters):
|
|
1842
|
+
|
|
1843
|
+
warnings.warn(
|
|
1844
|
+
f"Number of input sizes ({len(self._inputsizes)}) does not match "
|
|
1845
|
+
f"number of parameters ({len(parameters)}). "
|
|
1846
|
+
f"This may lead to unexpected behavior.",
|
|
1847
|
+
Warning,
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
# Prepare caching: skip SQLPrepare when re-executing the same SQL
|
|
1851
|
+
# with parameters. The HSTMT is reused via _soft_reset_cursor, so the
|
|
1852
|
+
# server-side plan from the previous SQLPrepare is still valid.
|
|
1853
|
+
same_sql = parameters and operation == self.last_executed_stmt and self.is_stmt_prepared[0]
|
|
1854
|
+
if not same_sql:
|
|
1855
|
+
self.is_stmt_prepared = [False]
|
|
1856
|
+
effective_use_prepare = use_prepare and not same_sql
|
|
1857
|
+
|
|
1858
|
+
with perf_phase("py::execute::cpp_call"):
|
|
1859
|
+
if parameters:
|
|
1860
|
+
ret = ddbc_bindings.DDBCSQLExecute(
|
|
1861
|
+
self.hstmt,
|
|
1862
|
+
operation,
|
|
1863
|
+
parameters,
|
|
1864
|
+
self._inputsizes,
|
|
1865
|
+
self.is_stmt_prepared,
|
|
1866
|
+
effective_use_prepare,
|
|
1867
|
+
encoding_settings,
|
|
1868
|
+
)
|
|
1869
|
+
else:
|
|
1870
|
+
ret = ddbc_bindings.DDBCSQLExecDirect(self.hstmt, operation)
|
|
1871
|
+
# Check return code
|
|
1872
|
+
try:
|
|
1873
|
+
|
|
1874
|
+
# Check for errors but don't raise exceptions for info/warning messages
|
|
1875
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
1876
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1877
|
+
logger.warning("Execute failed, resetting cursor: %s", e)
|
|
1878
|
+
self._reset_cursor()
|
|
1879
|
+
raise
|
|
1880
|
+
|
|
1881
|
+
# Capture any diagnostic messages (SQL_SUCCESS_WITH_INFO, etc.)
|
|
1882
|
+
with perf_phase("py::execute::diag_records"):
|
|
1883
|
+
self._capture_diagnostics(ret)
|
|
1884
|
+
|
|
1885
|
+
self.last_executed_stmt = operation
|
|
1886
|
+
|
|
1887
|
+
with perf_phase("py::execute::post_execute"):
|
|
1888
|
+
# Update rowcount after execution
|
|
1889
|
+
# TODO: rowcount return code from SQL needs to be handled
|
|
1890
|
+
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
|
|
1891
|
+
|
|
1892
|
+
# Initialize description after execution
|
|
1893
|
+
# After successful execution, initialize description if there are results
|
|
1894
|
+
column_metadata = []
|
|
1895
|
+
try:
|
|
1896
|
+
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
|
|
1897
|
+
self._initialize_description(column_metadata)
|
|
1898
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1899
|
+
# If describe fails, it's likely there are no results (e.g., for INSERT)
|
|
1900
|
+
self.description = None
|
|
1901
|
+
self._column_sql_types = None
|
|
1902
|
+
|
|
1903
|
+
# Reset rownumber for new result set (only for SELECT statements)
|
|
1904
|
+
if self.description: # If we have column descriptions, it's likely a SELECT
|
|
1905
|
+
self.rowcount = -1
|
|
1906
|
+
self._reset_rownumber()
|
|
1907
|
+
# Pre-build column map and converter map
|
|
1908
|
+
self._cached_column_map = {
|
|
1909
|
+
col_desc[0]: i for i, col_desc in enumerate(self.description)
|
|
1910
|
+
}
|
|
1911
|
+
self._cached_column_map_lower = (
|
|
1912
|
+
{k.lower(): v for k, v in self._cached_column_map.items()}
|
|
1913
|
+
if get_settings().lowercase
|
|
1914
|
+
else None
|
|
1915
|
+
)
|
|
1916
|
+
self._cached_converter_map = self._build_converter_map()
|
|
1917
|
+
self._uuid_str_indices = self._compute_uuid_str_indices()
|
|
1918
|
+
else:
|
|
1919
|
+
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
|
|
1920
|
+
self._clear_rownumber()
|
|
1921
|
+
self._cached_column_map = None
|
|
1922
|
+
self._cached_column_map_lower = None
|
|
1923
|
+
self._cached_converter_map = None
|
|
1924
|
+
self._uuid_str_indices = None
|
|
1925
|
+
|
|
1926
|
+
self._reset_inputsizes() # Reset input sizes after execution
|
|
1927
|
+
# Return self for method chaining
|
|
1928
|
+
return self
|
|
1929
|
+
|
|
1930
|
+
def _prepare_metadata_result_set( # pylint: disable=too-many-statements
|
|
1931
|
+
self, column_metadata=None, fallback_description=None, specialized_mapping=None
|
|
1932
|
+
):
|
|
1933
|
+
"""
|
|
1934
|
+
Prepares a metadata result set by:
|
|
1935
|
+
1. Retrieving column metadata if not provided
|
|
1936
|
+
2. Initializing the description attribute
|
|
1937
|
+
3. Setting up column name mappings (including any specialized aliases)
|
|
1938
|
+
so the standard fetch methods return rows keyed by catalog columns
|
|
1939
|
+
|
|
1940
|
+
Args:
|
|
1941
|
+
column_metadata (list, optional): Pre-fetched column metadata.
|
|
1942
|
+
If None, it will be retrieved.
|
|
1943
|
+
fallback_description (list, optional): Fallback description to use if
|
|
1944
|
+
metadata retrieval fails.
|
|
1945
|
+
specialized_mapping (dict, optional): Custom column mapping for special cases.
|
|
1946
|
+
|
|
1947
|
+
Returns:
|
|
1948
|
+
Cursor: Self, for method chaining
|
|
1949
|
+
"""
|
|
1950
|
+
# Retrieve column metadata if not provided
|
|
1951
|
+
if column_metadata is None:
|
|
1952
|
+
column_metadata = []
|
|
1953
|
+
try:
|
|
1954
|
+
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
|
|
1955
|
+
except InterfaceError as e:
|
|
1956
|
+
logger.warning(f"Driver interface error during metadata retrieval: {e}")
|
|
1957
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
1958
|
+
# Log the exception with appropriate context
|
|
1959
|
+
logger.warning(
|
|
1960
|
+
f"Failed to retrieve column metadata: {e}. "
|
|
1961
|
+
f"Using standard ODBC column definitions instead.",
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
# Initialize the description attribute with the column metadata
|
|
1965
|
+
self._initialize_description(column_metadata)
|
|
1966
|
+
|
|
1967
|
+
# Use fallback description if provided and current description is empty
|
|
1968
|
+
if not self.description and fallback_description:
|
|
1969
|
+
self.description = fallback_description
|
|
1970
|
+
|
|
1971
|
+
# Build the converter map so output converters (including integer SQL-type
|
|
1972
|
+
# keys) apply to metadata result sets consistently with normal result sets.
|
|
1973
|
+
# See GH #684.
|
|
1974
|
+
self._cached_converter_map = self._build_converter_map()
|
|
1975
|
+
|
|
1976
|
+
# Build the column-name -> index map for this metadata result set.
|
|
1977
|
+
# Both the exact name and its lowercase alias are stored so that rows
|
|
1978
|
+
# support case-insensitive lookup regardless of the global ``lowercase``
|
|
1979
|
+
# setting (catalog column names are well-known and case-stable).
|
|
1980
|
+
# ``self.description`` is None when DDBCSQLDescribeCol failed above and no
|
|
1981
|
+
# fallback_description was supplied (e.g. getTypeInfo()); guard with ``or ()``
|
|
1982
|
+
# so the cursor simply yields no rows instead of raising TypeError here.
|
|
1983
|
+
column_map = {}
|
|
1984
|
+
for i, (name, *_) in enumerate(self.description or ()):
|
|
1985
|
+
column_map[name] = i
|
|
1986
|
+
column_map[name.lower()] = i
|
|
1987
|
+
|
|
1988
|
+
# Some catalog helpers expose additional friendly aliases (e.g. ODBC 2.x
|
|
1989
|
+
# vs 3.x column names) via a specialized mapping. Merge them in so they
|
|
1990
|
+
# resolve to the same column indices.
|
|
1991
|
+
if specialized_mapping:
|
|
1992
|
+
column_map.update(specialized_mapping)
|
|
1993
|
+
|
|
1994
|
+
# Route the map through the shared fetch path. The standard fetchone/
|
|
1995
|
+
# fetchmany/fetchall methods read these cached maps when constructing Row
|
|
1996
|
+
# objects, so metadata rows pick up the catalog column names without any
|
|
1997
|
+
# per-call method reassignment (the previous approach broke static type
|
|
1998
|
+
# checking - see GH #620).
|
|
1999
|
+
self._cached_column_map = column_map
|
|
2000
|
+
# Mirror execute(): when ``lowercase`` is enabled the description names are
|
|
2001
|
+
# already lowercased, so build the lowercase lookup map (including any
|
|
2002
|
+
# specialized aliases) to keep case-insensitive access working for catalog
|
|
2003
|
+
# rows (e.g. row.TABLE_NAME when lowercase=True).
|
|
2004
|
+
self._cached_column_map_lower = (
|
|
2005
|
+
{k.lower(): v for k, v in column_map.items()} if get_settings().lowercase else None
|
|
2006
|
+
)
|
|
2007
|
+
|
|
2008
|
+
# Initialize rownumber tracking so fetchone() and iteration work
|
|
2009
|
+
self._reset_rownumber()
|
|
2010
|
+
|
|
2011
|
+
# Metadata methods produce a new result set, so rowcount is unknown
|
|
2012
|
+
# until rows are fetched. Reset it to avoid exposing a stale value
|
|
2013
|
+
# from a previous statement.
|
|
2014
|
+
self.rowcount = -1
|
|
2015
|
+
|
|
2016
|
+
# Return the cursor itself for method chaining
|
|
2017
|
+
return self
|
|
2018
|
+
|
|
2019
|
+
def getTypeInfo(self, sqlType=None):
|
|
2020
|
+
"""
|
|
2021
|
+
Executes SQLGetTypeInfo and creates a result set with information about
|
|
2022
|
+
the specified data type or all data types supported by the ODBC driver if not specified.
|
|
2023
|
+
"""
|
|
2024
|
+
self._check_closed()
|
|
2025
|
+
self._reset_cursor()
|
|
2026
|
+
|
|
2027
|
+
sql_all_types = 0 # SQL_ALL_TYPES = 0
|
|
2028
|
+
|
|
2029
|
+
try:
|
|
2030
|
+
# Get information about data types
|
|
2031
|
+
ret = ddbc_bindings.DDBCSQLGetTypeInfo(
|
|
2032
|
+
self.hstmt, sqlType if sqlType is not None else sql_all_types
|
|
2033
|
+
)
|
|
2034
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
2035
|
+
|
|
2036
|
+
# Use the helper method to prepare the result set
|
|
2037
|
+
return self._prepare_metadata_result_set()
|
|
2038
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
2039
|
+
self._reset_cursor()
|
|
2040
|
+
raise e
|
|
2041
|
+
|
|
2042
|
+
def procedures(self, procedure=None, catalog=None, schema=None):
|
|
2043
|
+
"""
|
|
2044
|
+
Executes SQLProcedures and creates a result set of information about procedures
|
|
2045
|
+
in the data source.
|
|
2046
|
+
|
|
2047
|
+
Args:
|
|
2048
|
+
procedure (str, optional): Procedure name pattern. Default is None (all procedures).
|
|
2049
|
+
catalog (str, optional): Catalog name pattern. Default is None (current catalog).
|
|
2050
|
+
schema (str, optional): Schema name pattern. Default is None (all schemas).
|
|
2051
|
+
"""
|
|
2052
|
+
self._check_closed()
|
|
2053
|
+
self._reset_cursor()
|
|
2054
|
+
|
|
2055
|
+
# Call the SQLProcedures function
|
|
2056
|
+
retcode = ddbc_bindings.DDBCSQLProcedures(self.hstmt, catalog, schema, procedure)
|
|
2057
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2058
|
+
|
|
2059
|
+
# Define fallback description for procedures
|
|
2060
|
+
fallback_description = [
|
|
2061
|
+
("procedure_cat", str, None, 128, 128, 0, True),
|
|
2062
|
+
("procedure_schem", str, None, 128, 128, 0, True),
|
|
2063
|
+
("procedure_name", str, None, 128, 128, 0, False),
|
|
2064
|
+
("num_input_params", int, None, 10, 10, 0, True),
|
|
2065
|
+
("num_output_params", int, None, 10, 10, 0, True),
|
|
2066
|
+
("num_result_sets", int, None, 10, 10, 0, True),
|
|
2067
|
+
("remarks", str, None, 254, 254, 0, True),
|
|
2068
|
+
("procedure_type", int, None, 10, 10, 0, False),
|
|
2069
|
+
]
|
|
2070
|
+
|
|
2071
|
+
# Use the helper method to prepare the result set
|
|
2072
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2073
|
+
|
|
2074
|
+
def primaryKeys(self, table, catalog=None, schema=None):
|
|
2075
|
+
"""
|
|
2076
|
+
Creates a result set of column names that make up the primary key for a table
|
|
2077
|
+
by executing the SQLPrimaryKeys function.
|
|
2078
|
+
|
|
2079
|
+
Args:
|
|
2080
|
+
table (str): The name of the table
|
|
2081
|
+
catalog (str, optional): The catalog name (database). Defaults to None.
|
|
2082
|
+
schema (str, optional): The schema name. Defaults to None.
|
|
2083
|
+
"""
|
|
2084
|
+
self._check_closed()
|
|
2085
|
+
self._reset_cursor()
|
|
2086
|
+
|
|
2087
|
+
if not table:
|
|
2088
|
+
raise ProgrammingError("Table name must be specified", "HY000")
|
|
2089
|
+
|
|
2090
|
+
# Call the SQLPrimaryKeys function
|
|
2091
|
+
retcode = ddbc_bindings.DDBCSQLPrimaryKeys(self.hstmt, catalog, schema, table)
|
|
2092
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2093
|
+
|
|
2094
|
+
# Define fallback description for primary keys
|
|
2095
|
+
fallback_description = [
|
|
2096
|
+
("table_cat", str, None, 128, 128, 0, True),
|
|
2097
|
+
("table_schem", str, None, 128, 128, 0, True),
|
|
2098
|
+
("table_name", str, None, 128, 128, 0, False),
|
|
2099
|
+
("column_name", str, None, 128, 128, 0, False),
|
|
2100
|
+
("key_seq", int, None, 10, 10, 0, False),
|
|
2101
|
+
("pk_name", str, None, 128, 128, 0, True),
|
|
2102
|
+
]
|
|
2103
|
+
|
|
2104
|
+
# Use the helper method to prepare the result set
|
|
2105
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2106
|
+
|
|
2107
|
+
def foreignKeys( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
2108
|
+
self,
|
|
2109
|
+
table=None,
|
|
2110
|
+
catalog=None,
|
|
2111
|
+
schema=None,
|
|
2112
|
+
foreignTable=None,
|
|
2113
|
+
foreignCatalog=None,
|
|
2114
|
+
foreignSchema=None,
|
|
2115
|
+
):
|
|
2116
|
+
"""
|
|
2117
|
+
Executes the SQLForeignKeys function and creates a result set of column names
|
|
2118
|
+
that are foreign keys.
|
|
2119
|
+
|
|
2120
|
+
This function returns:
|
|
2121
|
+
1. Foreign keys in the specified table that reference primary keys in other tables, OR
|
|
2122
|
+
2. Foreign keys in other tables that reference the primary key in the specified table
|
|
2123
|
+
"""
|
|
2124
|
+
self._check_closed()
|
|
2125
|
+
self._reset_cursor()
|
|
2126
|
+
|
|
2127
|
+
# Check if we have at least one table specified
|
|
2128
|
+
if table is None and foreignTable is None:
|
|
2129
|
+
raise ProgrammingError("Either table or foreignTable must be specified", "HY000")
|
|
2130
|
+
|
|
2131
|
+
# Call the SQLForeignKeys function
|
|
2132
|
+
retcode = ddbc_bindings.DDBCSQLForeignKeys(
|
|
2133
|
+
self.hstmt,
|
|
2134
|
+
foreignCatalog,
|
|
2135
|
+
foreignSchema,
|
|
2136
|
+
foreignTable,
|
|
2137
|
+
catalog,
|
|
2138
|
+
schema,
|
|
2139
|
+
table,
|
|
2140
|
+
)
|
|
2141
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2142
|
+
|
|
2143
|
+
# Define fallback description for foreign keys
|
|
2144
|
+
fallback_description = [
|
|
2145
|
+
("pktable_cat", str, None, 128, 128, 0, True),
|
|
2146
|
+
("pktable_schem", str, None, 128, 128, 0, True),
|
|
2147
|
+
("pktable_name", str, None, 128, 128, 0, False),
|
|
2148
|
+
("pkcolumn_name", str, None, 128, 128, 0, False),
|
|
2149
|
+
("fktable_cat", str, None, 128, 128, 0, True),
|
|
2150
|
+
("fktable_schem", str, None, 128, 128, 0, True),
|
|
2151
|
+
("fktable_name", str, None, 128, 128, 0, False),
|
|
2152
|
+
("fkcolumn_name", str, None, 128, 128, 0, False),
|
|
2153
|
+
("key_seq", int, None, 10, 10, 0, False),
|
|
2154
|
+
("update_rule", int, None, 10, 10, 0, False),
|
|
2155
|
+
("delete_rule", int, None, 10, 10, 0, False),
|
|
2156
|
+
("fk_name", str, None, 128, 128, 0, True),
|
|
2157
|
+
("pk_name", str, None, 128, 128, 0, True),
|
|
2158
|
+
("deferrability", int, None, 10, 10, 0, False),
|
|
2159
|
+
]
|
|
2160
|
+
|
|
2161
|
+
# Use the helper method to prepare the result set
|
|
2162
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2163
|
+
|
|
2164
|
+
def rowIdColumns(self, table, catalog=None, schema=None, nullable=True):
|
|
2165
|
+
"""
|
|
2166
|
+
Executes SQLSpecialColumns with SQL_BEST_ROWID which creates a result set of
|
|
2167
|
+
columns that uniquely identify a row.
|
|
2168
|
+
"""
|
|
2169
|
+
self._check_closed()
|
|
2170
|
+
self._reset_cursor()
|
|
2171
|
+
|
|
2172
|
+
if not table:
|
|
2173
|
+
raise ProgrammingError("Table name must be specified", "HY000")
|
|
2174
|
+
|
|
2175
|
+
# Set the identifier type and options
|
|
2176
|
+
identifier_type = ddbc_sql_const.SQL_BEST_ROWID.value
|
|
2177
|
+
scope = ddbc_sql_const.SQL_SCOPE_CURROW.value
|
|
2178
|
+
nullable_flag = (
|
|
2179
|
+
ddbc_sql_const.SQL_NULLABLE.value if nullable else ddbc_sql_const.SQL_NO_NULLS.value
|
|
2180
|
+
)
|
|
2181
|
+
|
|
2182
|
+
# Call the SQLSpecialColumns function
|
|
2183
|
+
retcode = ddbc_bindings.DDBCSQLSpecialColumns(
|
|
2184
|
+
self.hstmt, identifier_type, catalog, schema, table, scope, nullable_flag
|
|
2185
|
+
)
|
|
2186
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2187
|
+
|
|
2188
|
+
# Define fallback description for special columns
|
|
2189
|
+
fallback_description = [
|
|
2190
|
+
("scope", int, None, 10, 10, 0, False),
|
|
2191
|
+
("column_name", str, None, 128, 128, 0, False),
|
|
2192
|
+
("data_type", int, None, 10, 10, 0, False),
|
|
2193
|
+
("type_name", str, None, 128, 128, 0, False),
|
|
2194
|
+
("column_size", int, None, 10, 10, 0, False),
|
|
2195
|
+
("buffer_length", int, None, 10, 10, 0, False),
|
|
2196
|
+
("decimal_digits", int, None, 10, 10, 0, True),
|
|
2197
|
+
("pseudo_column", int, None, 10, 10, 0, False),
|
|
2198
|
+
]
|
|
2199
|
+
|
|
2200
|
+
# Use the helper method to prepare the result set
|
|
2201
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2202
|
+
|
|
2203
|
+
def rowVerColumns(self, table, catalog=None, schema=None, nullable=True):
|
|
2204
|
+
"""
|
|
2205
|
+
Executes SQLSpecialColumns with SQL_ROWVER which creates a result set of
|
|
2206
|
+
columns that are automatically updated when any value in the row is updated.
|
|
2207
|
+
"""
|
|
2208
|
+
self._check_closed()
|
|
2209
|
+
self._reset_cursor()
|
|
2210
|
+
|
|
2211
|
+
if not table:
|
|
2212
|
+
raise ProgrammingError("Table name must be specified", "HY000")
|
|
2213
|
+
|
|
2214
|
+
# Set the identifier type and options
|
|
2215
|
+
identifier_type = ddbc_sql_const.SQL_ROWVER.value
|
|
2216
|
+
scope = ddbc_sql_const.SQL_SCOPE_CURROW.value
|
|
2217
|
+
nullable_flag = (
|
|
2218
|
+
ddbc_sql_const.SQL_NULLABLE.value if nullable else ddbc_sql_const.SQL_NO_NULLS.value
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
# Call the SQLSpecialColumns function
|
|
2222
|
+
retcode = ddbc_bindings.DDBCSQLSpecialColumns(
|
|
2223
|
+
self.hstmt, identifier_type, catalog, schema, table, scope, nullable_flag
|
|
2224
|
+
)
|
|
2225
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2226
|
+
|
|
2227
|
+
# Same fallback description as rowIdColumns
|
|
2228
|
+
fallback_description = [
|
|
2229
|
+
("scope", int, None, 10, 10, 0, False),
|
|
2230
|
+
("column_name", str, None, 128, 128, 0, False),
|
|
2231
|
+
("data_type", int, None, 10, 10, 0, False),
|
|
2232
|
+
("type_name", str, None, 128, 128, 0, False),
|
|
2233
|
+
("column_size", int, None, 10, 10, 0, False),
|
|
2234
|
+
("buffer_length", int, None, 10, 10, 0, False),
|
|
2235
|
+
("decimal_digits", int, None, 10, 10, 0, True),
|
|
2236
|
+
("pseudo_column", int, None, 10, 10, 0, False),
|
|
2237
|
+
]
|
|
2238
|
+
|
|
2239
|
+
# Use the helper method to prepare the result set
|
|
2240
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2241
|
+
|
|
2242
|
+
def statistics( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
2243
|
+
self,
|
|
2244
|
+
table: str,
|
|
2245
|
+
catalog: str = None,
|
|
2246
|
+
schema: str = None,
|
|
2247
|
+
unique: bool = False,
|
|
2248
|
+
quick: bool = True,
|
|
2249
|
+
) -> "Cursor":
|
|
2250
|
+
"""
|
|
2251
|
+
Creates a result set of statistics about a single table and the indexes associated
|
|
2252
|
+
with the table by executing SQLStatistics.
|
|
2253
|
+
"""
|
|
2254
|
+
self._check_closed()
|
|
2255
|
+
self._reset_cursor()
|
|
2256
|
+
|
|
2257
|
+
if not table:
|
|
2258
|
+
raise ProgrammingError("Table name is required", "HY000")
|
|
2259
|
+
|
|
2260
|
+
# Set unique and quick flags
|
|
2261
|
+
unique_option = (
|
|
2262
|
+
ddbc_sql_const.SQL_INDEX_UNIQUE.value if unique else ddbc_sql_const.SQL_INDEX_ALL.value
|
|
2263
|
+
)
|
|
2264
|
+
reserved_option = (
|
|
2265
|
+
ddbc_sql_const.SQL_QUICK.value if quick else ddbc_sql_const.SQL_ENSURE.value
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
# Call the SQLStatistics function
|
|
2269
|
+
retcode = ddbc_bindings.DDBCSQLStatistics(
|
|
2270
|
+
self.hstmt, catalog, schema, table, unique_option, reserved_option
|
|
2271
|
+
)
|
|
2272
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2273
|
+
|
|
2274
|
+
# Define fallback description for statistics
|
|
2275
|
+
fallback_description = [
|
|
2276
|
+
("table_cat", str, None, 128, 128, 0, True),
|
|
2277
|
+
("table_schem", str, None, 128, 128, 0, True),
|
|
2278
|
+
("table_name", str, None, 128, 128, 0, False),
|
|
2279
|
+
("non_unique", bool, None, 1, 1, 0, False),
|
|
2280
|
+
("index_qualifier", str, None, 128, 128, 0, True),
|
|
2281
|
+
("index_name", str, None, 128, 128, 0, True),
|
|
2282
|
+
("type", int, None, 10, 10, 0, False),
|
|
2283
|
+
("ordinal_position", int, None, 10, 10, 0, False),
|
|
2284
|
+
("column_name", str, None, 128, 128, 0, True),
|
|
2285
|
+
("asc_or_desc", str, None, 1, 1, 0, True),
|
|
2286
|
+
("cardinality", int, None, 20, 20, 0, True),
|
|
2287
|
+
("pages", int, None, 20, 20, 0, True),
|
|
2288
|
+
("filter_condition", str, None, 128, 128, 0, True),
|
|
2289
|
+
]
|
|
2290
|
+
|
|
2291
|
+
# Use the helper method to prepare the result set
|
|
2292
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2293
|
+
|
|
2294
|
+
def columns(self, table=None, catalog=None, schema=None, column=None):
|
|
2295
|
+
"""
|
|
2296
|
+
Creates a result set of column information in the specified tables
|
|
2297
|
+
using the SQLColumns function.
|
|
2298
|
+
"""
|
|
2299
|
+
self._check_closed()
|
|
2300
|
+
self._reset_cursor()
|
|
2301
|
+
|
|
2302
|
+
# Call the SQLColumns function
|
|
2303
|
+
retcode = ddbc_bindings.DDBCSQLColumns(self.hstmt, catalog, schema, table, column)
|
|
2304
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
|
|
2305
|
+
|
|
2306
|
+
# Define fallback description for columns
|
|
2307
|
+
fallback_description = [
|
|
2308
|
+
("table_cat", str, None, 128, 128, 0, True),
|
|
2309
|
+
("table_schem", str, None, 128, 128, 0, True),
|
|
2310
|
+
("table_name", str, None, 128, 128, 0, False),
|
|
2311
|
+
("column_name", str, None, 128, 128, 0, False),
|
|
2312
|
+
("data_type", int, None, 10, 10, 0, False),
|
|
2313
|
+
("type_name", str, None, 128, 128, 0, False),
|
|
2314
|
+
("column_size", int, None, 10, 10, 0, True),
|
|
2315
|
+
("buffer_length", int, None, 10, 10, 0, True),
|
|
2316
|
+
("decimal_digits", int, None, 10, 10, 0, True),
|
|
2317
|
+
("num_prec_radix", int, None, 10, 10, 0, True),
|
|
2318
|
+
("nullable", int, None, 10, 10, 0, False),
|
|
2319
|
+
("remarks", str, None, 254, 254, 0, True),
|
|
2320
|
+
("column_def", str, None, 254, 254, 0, True),
|
|
2321
|
+
("sql_data_type", int, None, 10, 10, 0, False),
|
|
2322
|
+
("sql_datetime_sub", int, None, 10, 10, 0, True),
|
|
2323
|
+
("char_octet_length", int, None, 10, 10, 0, True),
|
|
2324
|
+
("ordinal_position", int, None, 10, 10, 0, False),
|
|
2325
|
+
("is_nullable", str, None, 254, 254, 0, True),
|
|
2326
|
+
]
|
|
2327
|
+
|
|
2328
|
+
# Use the helper method to prepare the result set
|
|
2329
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
2330
|
+
|
|
2331
|
+
def _transpose_rowwise_to_columnwise(
|
|
2332
|
+
self,
|
|
2333
|
+
seq_of_parameters: Sequence[Sequence[Any]],
|
|
2334
|
+
) -> tuple[list, int]:
|
|
2335
|
+
"""
|
|
2336
|
+
Convert sequence of rows (row-wise) into list of columns (column-wise),
|
|
2337
|
+
for array binding via ODBC. Works with both iterables and generators.
|
|
2338
|
+
|
|
2339
|
+
Args:
|
|
2340
|
+
seq_of_parameters: Sequence of sequences or mappings of parameters.
|
|
2341
|
+
|
|
2342
|
+
Returns:
|
|
2343
|
+
tuple: (columnwise_data, row_count)
|
|
2344
|
+
"""
|
|
2345
|
+
columnwise = []
|
|
2346
|
+
first_row = True
|
|
2347
|
+
row_count = 0
|
|
2348
|
+
|
|
2349
|
+
for row in seq_of_parameters:
|
|
2350
|
+
row_count += 1
|
|
2351
|
+
if first_row:
|
|
2352
|
+
# Initialize columnwise lists based on first row
|
|
2353
|
+
num_params = len(row)
|
|
2354
|
+
columnwise = [[] for _ in range(num_params)]
|
|
2355
|
+
first_row = False
|
|
2356
|
+
else:
|
|
2357
|
+
# Validate row size consistency
|
|
2358
|
+
if len(row) != num_params:
|
|
2359
|
+
raise ValueError("Inconsistent parameter row size in executemany()")
|
|
2360
|
+
|
|
2361
|
+
# Add each value to its column list
|
|
2362
|
+
for i, val in enumerate(row):
|
|
2363
|
+
columnwise[i].append(val)
|
|
2364
|
+
|
|
2365
|
+
return columnwise, row_count
|
|
2366
|
+
|
|
2367
|
+
def _compute_column_type(self, column):
|
|
2368
|
+
"""
|
|
2369
|
+
Determine representative value and integer min/max for a column.
|
|
2370
|
+
|
|
2371
|
+
Returns:
|
|
2372
|
+
sample_value: Representative value for type inference and modified_row.
|
|
2373
|
+
min_val: Minimum for integers (None otherwise).
|
|
2374
|
+
max_val: Maximum for integers (None otherwise).
|
|
2375
|
+
max_decimal_formatted_len: Maximum len(format(d, 'f')) across all
|
|
2376
|
+
Decimal values in the column (0 when no Decimals are present).
|
|
2377
|
+
Used by executemany to correct the SQL_VARCHAR column size when
|
|
2378
|
+
the sample value's formatted string is shorter than another
|
|
2379
|
+
value's (e.g. positive sample vs negative row value) (GH-557).
|
|
2380
|
+
"""
|
|
2381
|
+
non_nulls = [v for v in column if v is not None]
|
|
2382
|
+
if not non_nulls:
|
|
2383
|
+
return None, None, None, 0
|
|
2384
|
+
|
|
2385
|
+
int_values = [v for v in non_nulls if isinstance(v, int)]
|
|
2386
|
+
if int_values:
|
|
2387
|
+
min_val, max_val = min(int_values), max(int_values)
|
|
2388
|
+
sample_value = max(int_values, key=abs)
|
|
2389
|
+
return sample_value, min_val, max_val, 0
|
|
2390
|
+
|
|
2391
|
+
sample_value = None
|
|
2392
|
+
max_decimal_formatted_len = 0
|
|
2393
|
+
for v in non_nulls:
|
|
2394
|
+
if isinstance(v, decimal.Decimal):
|
|
2395
|
+
max_decimal_formatted_len = max(max_decimal_formatted_len, len(format(v, "f")))
|
|
2396
|
+
if not sample_value:
|
|
2397
|
+
sample_value = v
|
|
2398
|
+
elif isinstance(v, (str, bytes, bytearray)) and isinstance(
|
|
2399
|
+
sample_value, (str, bytes, bytearray)
|
|
2400
|
+
):
|
|
2401
|
+
# For string/binary objects, prefer the longer one
|
|
2402
|
+
# Use safe length comparison to avoid exceptions from custom __len__ implementations
|
|
2403
|
+
try:
|
|
2404
|
+
if len(v) > len(sample_value):
|
|
2405
|
+
sample_value = v
|
|
2406
|
+
except (TypeError, ValueError, AttributeError):
|
|
2407
|
+
# If length comparison fails, keep the current sample_value
|
|
2408
|
+
pass
|
|
2409
|
+
elif isinstance(v, decimal.Decimal) and isinstance(sample_value, decimal.Decimal):
|
|
2410
|
+
# For Decimal objects, prefer the one that requires higher precision or scale
|
|
2411
|
+
v_tuple = v.as_tuple()
|
|
2412
|
+
sample_tuple = sample_value.as_tuple()
|
|
2413
|
+
|
|
2414
|
+
# Calculate precision (total significant digits) and scale (decimal places)
|
|
2415
|
+
# For a number like 0.000123456789, we need precision = 9, scale = 12
|
|
2416
|
+
# The precision is the number of significant digits (len(digits))
|
|
2417
|
+
# The scale is the number of decimal places needed to represent the number
|
|
2418
|
+
|
|
2419
|
+
v_precision = len(v_tuple.digits)
|
|
2420
|
+
if v_tuple.exponent < 0:
|
|
2421
|
+
v_scale = -v_tuple.exponent
|
|
2422
|
+
else:
|
|
2423
|
+
v_scale = 0
|
|
2424
|
+
|
|
2425
|
+
sample_precision = len(sample_tuple.digits)
|
|
2426
|
+
if sample_tuple.exponent < 0:
|
|
2427
|
+
sample_scale = -sample_tuple.exponent
|
|
2428
|
+
else:
|
|
2429
|
+
sample_scale = 0
|
|
2430
|
+
|
|
2431
|
+
# For SQL DECIMAL(precision, scale), we need:
|
|
2432
|
+
# precision >= number of significant digits
|
|
2433
|
+
# scale >= number of decimal places
|
|
2434
|
+
# For 0.000123456789: precision needs to be at least 12 (to accommodate 12 decimal places)
|
|
2435
|
+
# So we need to adjust precision to be at least as large as scale
|
|
2436
|
+
v_required_precision = max(v_precision, v_scale)
|
|
2437
|
+
sample_required_precision = max(sample_precision, sample_scale)
|
|
2438
|
+
|
|
2439
|
+
# Prefer the decimal that requires higher precision or scale
|
|
2440
|
+
# This ensures we can accommodate all values in the column
|
|
2441
|
+
if v_required_precision > sample_required_precision or (
|
|
2442
|
+
v_required_precision == sample_required_precision and v_scale > sample_scale
|
|
2443
|
+
):
|
|
2444
|
+
sample_value = v
|
|
2445
|
+
elif isinstance(v, decimal.Decimal) and not isinstance(sample_value, decimal.Decimal):
|
|
2446
|
+
# If comparing Decimal to non-Decimal, prefer Decimal for better type inference
|
|
2447
|
+
sample_value = v
|
|
2448
|
+
|
|
2449
|
+
return sample_value, None, None, max_decimal_formatted_len
|
|
2450
|
+
|
|
2451
|
+
def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
2452
|
+
self,
|
|
2453
|
+
operation: str,
|
|
2454
|
+
seq_of_parameters: Union[Sequence[Sequence[Any]], Sequence[Mapping[str, Any]]],
|
|
2455
|
+
) -> None:
|
|
2456
|
+
"""
|
|
2457
|
+
Prepare a database operation and execute it against all parameter sequences.
|
|
2458
|
+
This version uses column-wise parameter binding and a single batched SQLExecute().
|
|
2459
|
+
Args:
|
|
2460
|
+
operation: SQL query or command.
|
|
2461
|
+
seq_of_parameters: Sequence of sequences or mappings of parameters.
|
|
2462
|
+
Raises:
|
|
2463
|
+
Error: If the operation fails.
|
|
2464
|
+
"""
|
|
2465
|
+
logger.debug(
|
|
2466
|
+
"executemany: Starting - operation_length=%d, batch_count=%d",
|
|
2467
|
+
len(operation),
|
|
2468
|
+
len(seq_of_parameters),
|
|
2469
|
+
)
|
|
2470
|
+
|
|
2471
|
+
self._check_closed()
|
|
2472
|
+
self._reset_cursor()
|
|
2473
|
+
self.messages = []
|
|
2474
|
+
logger.debug("executemany: Cursor reset complete")
|
|
2475
|
+
|
|
2476
|
+
if not seq_of_parameters:
|
|
2477
|
+
self.rowcount = 0
|
|
2478
|
+
return
|
|
2479
|
+
|
|
2480
|
+
# Auto-detect and convert parameter style for executemany
|
|
2481
|
+
# Check first row to determine if we need to convert from pyformat to qmark
|
|
2482
|
+
first_row = (
|
|
2483
|
+
seq_of_parameters[0]
|
|
2484
|
+
if hasattr(seq_of_parameters, "__getitem__")
|
|
2485
|
+
else next(iter(seq_of_parameters))
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
if isinstance(first_row, dict):
|
|
2489
|
+
# pyformat style - convert all rows
|
|
2490
|
+
# Parse parameter names from SQL (determines order for all rows)
|
|
2491
|
+
param_names = parse_pyformat_params(operation)
|
|
2492
|
+
|
|
2493
|
+
if param_names:
|
|
2494
|
+
# Convert SQL to qmark style
|
|
2495
|
+
operation, _ = convert_pyformat_to_qmark(operation, first_row)
|
|
2496
|
+
|
|
2497
|
+
# Convert all parameter dicts to tuples in the same order
|
|
2498
|
+
converted_params = []
|
|
2499
|
+
for param_dict in seq_of_parameters:
|
|
2500
|
+
if not isinstance(param_dict, dict):
|
|
2501
|
+
raise TypeError(
|
|
2502
|
+
f"Mixed parameter types in executemany: first row is dict, "
|
|
2503
|
+
f"but row has {type(param_dict).__name__}"
|
|
2504
|
+
)
|
|
2505
|
+
# Build tuple in the order determined by param_names
|
|
2506
|
+
row_tuple = tuple(param_dict[name] for name in param_names)
|
|
2507
|
+
converted_params.append(row_tuple)
|
|
2508
|
+
|
|
2509
|
+
seq_of_parameters = converted_params
|
|
2510
|
+
logger.debug(
|
|
2511
|
+
"executemany: Converted %d rows from pyformat to qmark", len(seq_of_parameters)
|
|
2512
|
+
)
|
|
2513
|
+
|
|
2514
|
+
# Apply timeout if set (non-zero)
|
|
2515
|
+
if self._timeout > 0:
|
|
2516
|
+
try:
|
|
2517
|
+
timeout_value = int(self._timeout)
|
|
2518
|
+
ret = ddbc_bindings.DDBCSQLSetStmtAttr(
|
|
2519
|
+
self.hstmt,
|
|
2520
|
+
ddbc_sql_const.SQL_ATTR_QUERY_TIMEOUT.value,
|
|
2521
|
+
timeout_value,
|
|
2522
|
+
)
|
|
2523
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
2524
|
+
logger.debug(f"Set query timeout to {self._timeout} seconds")
|
|
2525
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
2526
|
+
logger.warning(f"Failed to set query timeout: {e}")
|
|
2527
|
+
|
|
2528
|
+
# Get sample row for parameter type detection and validation
|
|
2529
|
+
sample_row = (
|
|
2530
|
+
seq_of_parameters[0]
|
|
2531
|
+
if hasattr(seq_of_parameters, "__getitem__")
|
|
2532
|
+
else next(iter(seq_of_parameters))
|
|
2533
|
+
)
|
|
2534
|
+
param_count = len(sample_row)
|
|
2535
|
+
param_info = ddbc_bindings.ParamInfo
|
|
2536
|
+
parameters_type = []
|
|
2537
|
+
any_dae = False
|
|
2538
|
+
|
|
2539
|
+
# Check if we have explicit input sizes set
|
|
2540
|
+
if self._inputsizes:
|
|
2541
|
+
# Validate input sizes match parameter count
|
|
2542
|
+
if len(self._inputsizes) != param_count:
|
|
2543
|
+
warnings.warn(
|
|
2544
|
+
f"Number of input sizes ({len(self._inputsizes)}) does not match "
|
|
2545
|
+
f"number of parameters ({param_count}). This may lead to unexpected behavior.",
|
|
2546
|
+
Warning,
|
|
2547
|
+
)
|
|
2548
|
+
|
|
2549
|
+
# Prepare parameter type information
|
|
2550
|
+
with perf_phase("py::executemany::param_type_detection"):
|
|
2551
|
+
for col_index in range(param_count):
|
|
2552
|
+
column = (
|
|
2553
|
+
[row[col_index] for row in seq_of_parameters]
|
|
2554
|
+
if hasattr(seq_of_parameters, "__getitem__")
|
|
2555
|
+
else []
|
|
2556
|
+
)
|
|
2557
|
+
sample_value, min_val, max_val, _ = self._compute_column_type(column)
|
|
2558
|
+
|
|
2559
|
+
if self._inputsizes and col_index < len(self._inputsizes):
|
|
2560
|
+
# Use explicitly set input sizes
|
|
2561
|
+
sql_type, c_type, column_size, decimal_digits = self._inputsizes[col_index]
|
|
2562
|
+
|
|
2563
|
+
# Default is_dae to False
|
|
2564
|
+
is_dae = False
|
|
2565
|
+
|
|
2566
|
+
# Override DECIMAL/NUMERIC to use SQL_C_CHAR string binding (GH-503)
|
|
2567
|
+
if sql_type in (
|
|
2568
|
+
ddbc_sql_const.SQL_DECIMAL.value,
|
|
2569
|
+
ddbc_sql_const.SQL_NUMERIC.value,
|
|
2570
|
+
):
|
|
2571
|
+
c_type = ddbc_sql_const.SQL_C_CHAR.value
|
|
2572
|
+
|
|
2573
|
+
# Check if this should be a DAE (data at execution) parameter based on column size
|
|
2574
|
+
if sample_value is not None:
|
|
2575
|
+
if isinstance(sample_value, str) and column_size > MAX_INLINE_CHAR:
|
|
2576
|
+
is_dae = True
|
|
2577
|
+
elif isinstance(sample_value, (bytes, bytearray)) and column_size > 8000:
|
|
2578
|
+
is_dae = True
|
|
2579
|
+
|
|
2580
|
+
# Sanitize precision/scale for numeric types
|
|
2581
|
+
if sql_type in (
|
|
2582
|
+
ddbc_sql_const.SQL_DECIMAL.value,
|
|
2583
|
+
ddbc_sql_const.SQL_NUMERIC.value,
|
|
2584
|
+
):
|
|
2585
|
+
column_size = max(1, min(int(column_size) if column_size > 0 else 18, 38))
|
|
2586
|
+
decimal_digits = min(max(0, decimal_digits), column_size)
|
|
2587
|
+
|
|
2588
|
+
# For binary data columns with mixed content, we need to find max size
|
|
2589
|
+
if sql_type in (
|
|
2590
|
+
ddbc_sql_const.SQL_BINARY.value,
|
|
2591
|
+
ddbc_sql_const.SQL_VARBINARY.value,
|
|
2592
|
+
ddbc_sql_const.SQL_LONGVARBINARY.value,
|
|
2593
|
+
):
|
|
2594
|
+
# Find the maximum size needed for any row's binary data
|
|
2595
|
+
max_binary_size = 0
|
|
2596
|
+
for row in seq_of_parameters:
|
|
2597
|
+
value = row[col_index]
|
|
2598
|
+
if value is not None and isinstance(value, (bytes, bytearray)):
|
|
2599
|
+
max_binary_size = max(max_binary_size, len(value))
|
|
2600
|
+
|
|
2601
|
+
# For SQL Server VARBINARY(MAX), we need to use large object binding
|
|
2602
|
+
if column_size > 8000 or max_binary_size > 8000:
|
|
2603
|
+
sql_type = ddbc_sql_const.SQL_LONGVARBINARY.value
|
|
2604
|
+
is_dae = True
|
|
2605
|
+
|
|
2606
|
+
# Update column_size to actual maximum size if it's larger
|
|
2607
|
+
# Always ensure at least a minimum size of 1 for empty strings
|
|
2608
|
+
column_size = max(max_binary_size, 1)
|
|
2609
|
+
|
|
2610
|
+
paraminfo = param_info()
|
|
2611
|
+
paraminfo.paramCType = c_type
|
|
2612
|
+
paraminfo.paramSQLType = sql_type
|
|
2613
|
+
paraminfo.inputOutputType = ddbc_sql_const.SQL_PARAM_INPUT.value
|
|
2614
|
+
paraminfo.columnSize = column_size
|
|
2615
|
+
paraminfo.decimalDigits = decimal_digits
|
|
2616
|
+
paraminfo.isDAE = is_dae
|
|
2617
|
+
|
|
2618
|
+
# Ensure we never have SQL_C_DEFAULT (0) for C-type
|
|
2619
|
+
if paraminfo.paramCType == 0:
|
|
2620
|
+
paraminfo.paramCType = ddbc_sql_const.SQL_C_DEFAULT.value
|
|
2621
|
+
|
|
2622
|
+
parameters_type.append(paraminfo)
|
|
2623
|
+
else:
|
|
2624
|
+
# Use auto-detection for columns without explicit types
|
|
2625
|
+
column = (
|
|
2626
|
+
[row[col_index] for row in seq_of_parameters]
|
|
2627
|
+
if hasattr(seq_of_parameters, "__getitem__")
|
|
2628
|
+
else []
|
|
2629
|
+
)
|
|
2630
|
+
sample_value, min_val, max_val, max_decimal_len = self._compute_column_type(
|
|
2631
|
+
column
|
|
2632
|
+
)
|
|
2633
|
+
|
|
2634
|
+
dummy_row = list(sample_row)
|
|
2635
|
+
paraminfo = self._create_parameter_types_list(
|
|
2636
|
+
sample_value,
|
|
2637
|
+
param_info,
|
|
2638
|
+
dummy_row,
|
|
2639
|
+
col_index,
|
|
2640
|
+
min_val=min_val,
|
|
2641
|
+
max_val=max_val,
|
|
2642
|
+
)
|
|
2643
|
+
|
|
2644
|
+
# GH-610: all-NULL columns now pass SQL_UNKNOWN_TYPE to C++,
|
|
2645
|
+
# where BindParameterArray resolves the correct type via the
|
|
2646
|
+
# SQLDescribeParam cache. The previous SQL_VARCHAR hardcoded
|
|
2647
|
+
# fallback was removed because it broke VARBINARY columns.
|
|
2648
|
+
|
|
2649
|
+
# Override DECIMAL/NUMERIC to use SQL_C_CHAR string binding.
|
|
2650
|
+
# _map_sql_type may return SQL_C_NUMERIC (expecting NumericData structs)
|
|
2651
|
+
# but the conversion loop below converts all Decimal values to strings.
|
|
2652
|
+
# The C type must match the actual data to avoid:
|
|
2653
|
+
# RuntimeError: Parameter's object type does not match parameter's C type
|
|
2654
|
+
if paraminfo.paramSQLType in (
|
|
2655
|
+
ddbc_sql_const.SQL_DECIMAL.value,
|
|
2656
|
+
ddbc_sql_const.SQL_NUMERIC.value,
|
|
2657
|
+
):
|
|
2658
|
+
paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value
|
|
2659
|
+
# Ensure columnSize accommodates the longest string representation
|
|
2660
|
+
if max_decimal_len > paraminfo.columnSize:
|
|
2661
|
+
paraminfo.columnSize = max_decimal_len
|
|
2662
|
+
|
|
2663
|
+
# Correct column size for Decimal columns sent as SQL_VARCHAR (GH-557).
|
|
2664
|
+
# The sample value's formatted string may be shorter than another
|
|
2665
|
+
# row's (e.g. positive sample "1.0" = 3 chars vs negative "-0.1" = 4).
|
|
2666
|
+
# max_decimal_len was already computed during _compute_column_type
|
|
2667
|
+
# so no extra iteration is needed.
|
|
2668
|
+
if (
|
|
2669
|
+
paraminfo.paramSQLType == ddbc_sql_const.SQL_VARCHAR.value
|
|
2670
|
+
and max_decimal_len > paraminfo.columnSize
|
|
2671
|
+
):
|
|
2672
|
+
paraminfo.columnSize = max_decimal_len
|
|
2673
|
+
|
|
2674
|
+
# Special handling for binary data in auto-detected types
|
|
2675
|
+
if paraminfo.paramSQLType in (
|
|
2676
|
+
ddbc_sql_const.SQL_BINARY.value,
|
|
2677
|
+
ddbc_sql_const.SQL_VARBINARY.value,
|
|
2678
|
+
ddbc_sql_const.SQL_LONGVARBINARY.value,
|
|
2679
|
+
):
|
|
2680
|
+
# Find the maximum size needed for any row's binary data
|
|
2681
|
+
max_binary_size = 0
|
|
2682
|
+
for row in seq_of_parameters:
|
|
2683
|
+
value = row[col_index]
|
|
2684
|
+
if value is not None and isinstance(value, (bytes, bytearray)):
|
|
2685
|
+
max_binary_size = max(max_binary_size, len(value))
|
|
2686
|
+
|
|
2687
|
+
# For SQL Server VARBINARY(MAX), we need to use large object binding
|
|
2688
|
+
if max_binary_size > 8000:
|
|
2689
|
+
paraminfo.paramSQLType = ddbc_sql_const.SQL_LONGVARBINARY.value
|
|
2690
|
+
paraminfo.isDAE = True
|
|
2691
|
+
|
|
2692
|
+
# Update column_size to actual maximum size
|
|
2693
|
+
# Always ensure at least a minimum size of 1 for empty strings
|
|
2694
|
+
paraminfo.columnSize = max(max_binary_size, 1)
|
|
2695
|
+
|
|
2696
|
+
parameters_type.append(paraminfo)
|
|
2697
|
+
if paraminfo.isDAE:
|
|
2698
|
+
any_dae = True
|
|
2699
|
+
|
|
2700
|
+
if any_dae:
|
|
2701
|
+
logger.debug(
|
|
2702
|
+
"DAE parameters detected. Falling back to row-by-row execution with streaming.",
|
|
2703
|
+
)
|
|
2704
|
+
input_sizes = self._inputsizes
|
|
2705
|
+
try:
|
|
2706
|
+
for row in seq_of_parameters:
|
|
2707
|
+
# execute() consumes overrides; every streamed row needs them.
|
|
2708
|
+
self._inputsizes = input_sizes
|
|
2709
|
+
self.execute(operation, row)
|
|
2710
|
+
finally:
|
|
2711
|
+
self._reset_inputsizes()
|
|
2712
|
+
return
|
|
2713
|
+
|
|
2714
|
+
# Process parameters into column-wise format with possible type conversions
|
|
2715
|
+
# First, convert any Decimal types as needed for NUMERIC/DECIMAL columns
|
|
2716
|
+
with perf_phase("py::executemany::param_conversion"):
|
|
2717
|
+
processed_parameters = []
|
|
2718
|
+
for row_index, row in enumerate(seq_of_parameters):
|
|
2719
|
+
processed_row = list(row)
|
|
2720
|
+
for i, val in enumerate(processed_row):
|
|
2721
|
+
if val is None:
|
|
2722
|
+
continue
|
|
2723
|
+
time_text = _normalize_time_param(val, parameters_type[i].paramCType)
|
|
2724
|
+
if time_text is not None:
|
|
2725
|
+
processed_row[i] = time_text
|
|
2726
|
+
continue
|
|
2727
|
+
if (
|
|
2728
|
+
isinstance(val, decimal.Decimal)
|
|
2729
|
+
and parameters_type[i].paramSQLType == ddbc_sql_const.SQL_VARCHAR.value
|
|
2730
|
+
):
|
|
2731
|
+
processed_row[i] = format(val, "f")
|
|
2732
|
+
# Convert all values to string for DECIMAL/NUMERIC columns (GH-503)
|
|
2733
|
+
elif parameters_type[i].paramSQLType in (
|
|
2734
|
+
ddbc_sql_const.SQL_DECIMAL.value,
|
|
2735
|
+
ddbc_sql_const.SQL_NUMERIC.value,
|
|
2736
|
+
):
|
|
2737
|
+
if isinstance(val, decimal.Decimal):
|
|
2738
|
+
processed_row[i] = format(val, "f")
|
|
2739
|
+
else:
|
|
2740
|
+
# Do not embed the parameter value or the full row in the
|
|
2741
|
+
# message: rows may contain PII (SSNs, emails, balances)
|
|
2742
|
+
# that would leak into caller error handlers, tracebacks,
|
|
2743
|
+
# and log/APM stores. Report metadata only (row index,
|
|
2744
|
+
# column index, value type).
|
|
2745
|
+
err_msg = (
|
|
2746
|
+
f"Failed to convert parameter to Decimal at row "
|
|
2747
|
+
f"{row_index}, column {i} (value type: {type(val).__name__})"
|
|
2748
|
+
)
|
|
2749
|
+
# Raise outside the handler so conversion exceptions are
|
|
2750
|
+
# not retained as context with rejected input.
|
|
2751
|
+
conversion_failed = False
|
|
2752
|
+
try:
|
|
2753
|
+
val_text = str(val)
|
|
2754
|
+
processed_row[i] = format(decimal.Decimal(val_text), "f")
|
|
2755
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
2756
|
+
conversion_failed = True
|
|
2757
|
+
if conversion_failed:
|
|
2758
|
+
raise ValueError(err_msg) from None
|
|
2759
|
+
processed_parameters.append(processed_row)
|
|
2760
|
+
|
|
2761
|
+
# Now transpose the processed parameters
|
|
2762
|
+
with perf_phase("py::executemany::param_processing"):
|
|
2763
|
+
columnwise_params, row_count = self._transpose_rowwise_to_columnwise(
|
|
2764
|
+
processed_parameters
|
|
2765
|
+
)
|
|
2766
|
+
|
|
2767
|
+
# Get encoding settings
|
|
2768
|
+
encoding_settings = self._get_encoding_settings()
|
|
2769
|
+
|
|
2770
|
+
# Debug logging: emit batch metadata only. Never log parameter values or
|
|
2771
|
+
# row representations here -- rows may contain PII (SSNs, emails,
|
|
2772
|
+
# balances) that would leak into log files and APM/log shippers even
|
|
2773
|
+
# though this is a DEBUG-level statement. Metadata (batch size, column
|
|
2774
|
+
# count) is sufficient for diagnostics without exposing user data.
|
|
2775
|
+
logger.debug(
|
|
2776
|
+
"Executing batch query with %d parameter sets (%d columns per row)",
|
|
2777
|
+
len(seq_of_parameters),
|
|
2778
|
+
len(parameters_type),
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
with perf_phase("py::executemany::cpp_call"):
|
|
2782
|
+
ret = ddbc_bindings.SQLExecuteMany(
|
|
2783
|
+
self.hstmt,
|
|
2784
|
+
operation,
|
|
2785
|
+
columnwise_params,
|
|
2786
|
+
parameters_type,
|
|
2787
|
+
row_count,
|
|
2788
|
+
encoding_settings,
|
|
2789
|
+
)
|
|
2790
|
+
|
|
2791
|
+
# Capture any diagnostic messages after execution
|
|
2792
|
+
with perf_phase("py::executemany::diag_records"):
|
|
2793
|
+
if self.hstmt:
|
|
2794
|
+
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
|
|
2795
|
+
|
|
2796
|
+
try:
|
|
2797
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
2798
|
+
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
|
|
2799
|
+
self.last_executed_stmt = operation
|
|
2800
|
+
|
|
2801
|
+
# Fetch column metadata (e.g. for INSERT … OUTPUT)
|
|
2802
|
+
column_metadata = []
|
|
2803
|
+
try:
|
|
2804
|
+
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
|
|
2805
|
+
self._initialize_description(column_metadata)
|
|
2806
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
2807
|
+
self.description = None
|
|
2808
|
+
self._column_sql_types = None
|
|
2809
|
+
|
|
2810
|
+
if self.description:
|
|
2811
|
+
self.rowcount = -1
|
|
2812
|
+
self._reset_rownumber()
|
|
2813
|
+
self._cached_column_map = {
|
|
2814
|
+
col_desc[0]: i for i, col_desc in enumerate(self.description)
|
|
2815
|
+
}
|
|
2816
|
+
self._cached_column_map_lower = (
|
|
2817
|
+
{k.lower(): v for k, v in self._cached_column_map.items()}
|
|
2818
|
+
if get_settings().lowercase
|
|
2819
|
+
else None
|
|
2820
|
+
)
|
|
2821
|
+
self._cached_converter_map = self._build_converter_map()
|
|
2822
|
+
self._uuid_str_indices = self._compute_uuid_str_indices()
|
|
2823
|
+
else:
|
|
2824
|
+
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
|
|
2825
|
+
self._clear_rownumber()
|
|
2826
|
+
self._cached_column_map = None
|
|
2827
|
+
self._cached_column_map_lower = None
|
|
2828
|
+
self._cached_converter_map = None
|
|
2829
|
+
self._uuid_str_indices = None
|
|
2830
|
+
finally:
|
|
2831
|
+
# Reset input sizes after execution
|
|
2832
|
+
self._reset_inputsizes()
|
|
2833
|
+
|
|
2834
|
+
def fetchone(self) -> Union[None, Row]:
|
|
2835
|
+
"""
|
|
2836
|
+
Fetch the next row of a query result set.
|
|
2837
|
+
|
|
2838
|
+
Returns:
|
|
2839
|
+
Single Row object or None if no more data is available.
|
|
2840
|
+
"""
|
|
2841
|
+
self._check_closed() # Check if the cursor is closed
|
|
2842
|
+
|
|
2843
|
+
if self._cached_decoding_generation != self._connection._decoding_generation:
|
|
2844
|
+
self._refresh_decoding_cache()
|
|
2845
|
+
char_enc = self._cached_char_encoding
|
|
2846
|
+
wchar_enc = self._cached_wchar_encoding
|
|
2847
|
+
|
|
2848
|
+
# Fetch raw data
|
|
2849
|
+
row_data = []
|
|
2850
|
+
try:
|
|
2851
|
+
with perf_phase("py::fetchone::cpp_call"):
|
|
2852
|
+
ret = ddbc_bindings.DDBCSQLFetchOne(
|
|
2853
|
+
self.hstmt,
|
|
2854
|
+
row_data,
|
|
2855
|
+
char_enc,
|
|
2856
|
+
wchar_enc,
|
|
2857
|
+
self._cached_char_ctype,
|
|
2858
|
+
self.messages,
|
|
2859
|
+
)
|
|
2860
|
+
|
|
2861
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
2862
|
+
|
|
2863
|
+
if ret == ddbc_sql_const.SQL_NO_DATA.value:
|
|
2864
|
+
# No more data available
|
|
2865
|
+
if self._next_row_index == 0 and self.description is not None:
|
|
2866
|
+
self.rowcount = 0
|
|
2867
|
+
return None
|
|
2868
|
+
|
|
2869
|
+
# Update internal position after successful fetch
|
|
2870
|
+
if self._skip_increment_for_next_fetch:
|
|
2871
|
+
self._skip_increment_for_next_fetch = False
|
|
2872
|
+
self._next_row_index += 1
|
|
2873
|
+
else:
|
|
2874
|
+
self._increment_rownumber()
|
|
2875
|
+
|
|
2876
|
+
self.rowcount = self._next_row_index
|
|
2877
|
+
|
|
2878
|
+
# Get column and converter maps
|
|
2879
|
+
column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
|
|
2880
|
+
with perf_phase("py::fetchone::row_wrap"):
|
|
2881
|
+
if not converter_map and not self._uuid_str_indices:
|
|
2882
|
+
return Row._fast_create(
|
|
2883
|
+
row_data, column_map, self, column_map_lower, self._cached_result_columns
|
|
2884
|
+
)
|
|
2885
|
+
return Row(
|
|
2886
|
+
row_data,
|
|
2887
|
+
column_map,
|
|
2888
|
+
cursor=self,
|
|
2889
|
+
converter_map=converter_map,
|
|
2890
|
+
uuid_str_indices=self._uuid_str_indices,
|
|
2891
|
+
column_map_lower=column_map_lower,
|
|
2892
|
+
column_names=self._cached_result_columns,
|
|
2893
|
+
)
|
|
2894
|
+
except Exception:
|
|
2895
|
+
# On error, don't increment rownumber - rethrow the error
|
|
2896
|
+
raise
|
|
2897
|
+
|
|
2898
|
+
def fetchmany(self, size: Optional[int] = None) -> List[Row]:
|
|
2899
|
+
"""
|
|
2900
|
+
Fetch the next set of rows of a query result.
|
|
2901
|
+
|
|
2902
|
+
Args:
|
|
2903
|
+
size: Number of rows to fetch at a time.
|
|
2904
|
+
|
|
2905
|
+
Returns:
|
|
2906
|
+
List of Row objects.
|
|
2907
|
+
"""
|
|
2908
|
+
self._check_closed() # Check if the cursor is closed
|
|
2909
|
+
if not self._has_result_set and self.description:
|
|
2910
|
+
self._reset_rownumber()
|
|
2911
|
+
|
|
2912
|
+
if size is None:
|
|
2913
|
+
size = self.arraysize
|
|
2914
|
+
|
|
2915
|
+
if size <= 0:
|
|
2916
|
+
return []
|
|
2917
|
+
|
|
2918
|
+
if self._cached_decoding_generation != self._connection._decoding_generation:
|
|
2919
|
+
self._refresh_decoding_cache()
|
|
2920
|
+
char_enc = self._cached_char_encoding
|
|
2921
|
+
wchar_enc = self._cached_wchar_encoding
|
|
2922
|
+
|
|
2923
|
+
# Fetch raw data
|
|
2924
|
+
rows_data = []
|
|
2925
|
+
try:
|
|
2926
|
+
with perf_phase("py::fetchmany::cpp_call"):
|
|
2927
|
+
ret = ddbc_bindings.DDBCSQLFetchMany(
|
|
2928
|
+
self.hstmt,
|
|
2929
|
+
rows_data,
|
|
2930
|
+
size,
|
|
2931
|
+
char_enc,
|
|
2932
|
+
wchar_enc,
|
|
2933
|
+
self._cached_char_ctype,
|
|
2934
|
+
self.messages,
|
|
2935
|
+
)
|
|
2936
|
+
|
|
2937
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
2938
|
+
|
|
2939
|
+
# Update rownumber for the number of rows actually fetched
|
|
2940
|
+
if rows_data and self._has_result_set:
|
|
2941
|
+
# advance counters by number of rows actually returned
|
|
2942
|
+
self._next_row_index += len(rows_data)
|
|
2943
|
+
self._rownumber = self._next_row_index - 1
|
|
2944
|
+
|
|
2945
|
+
# Centralize rowcount assignment after fetch
|
|
2946
|
+
if len(rows_data) == 0 and self._next_row_index == 0:
|
|
2947
|
+
self.rowcount = 0
|
|
2948
|
+
else:
|
|
2949
|
+
self.rowcount = self._next_row_index
|
|
2950
|
+
|
|
2951
|
+
# Get column and converter maps
|
|
2952
|
+
column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
|
|
2953
|
+
|
|
2954
|
+
# Convert raw data to Row objects
|
|
2955
|
+
uuid_idx = self._uuid_str_indices
|
|
2956
|
+
with perf_phase("py::fetchmany::row_wrap"):
|
|
2957
|
+
if not converter_map and not uuid_idx:
|
|
2958
|
+
return ddbc_bindings.construct_rows(
|
|
2959
|
+
rows_data,
|
|
2960
|
+
Row,
|
|
2961
|
+
column_map,
|
|
2962
|
+
self,
|
|
2963
|
+
column_map_lower,
|
|
2964
|
+
self._cached_result_columns,
|
|
2965
|
+
)
|
|
2966
|
+
return [
|
|
2967
|
+
Row(
|
|
2968
|
+
row_data,
|
|
2969
|
+
column_map,
|
|
2970
|
+
cursor=self,
|
|
2971
|
+
converter_map=converter_map,
|
|
2972
|
+
uuid_str_indices=uuid_idx,
|
|
2973
|
+
column_map_lower=column_map_lower,
|
|
2974
|
+
column_names=self._cached_result_columns,
|
|
2975
|
+
)
|
|
2976
|
+
for row_data in rows_data
|
|
2977
|
+
]
|
|
2978
|
+
except Exception:
|
|
2979
|
+
# On error, don't increment rownumber - rethrow the error
|
|
2980
|
+
raise
|
|
2981
|
+
|
|
2982
|
+
def fetchall(self) -> List[Row]:
|
|
2983
|
+
"""
|
|
2984
|
+
Fetch all (remaining) rows of a query result.
|
|
2985
|
+
|
|
2986
|
+
Returns:
|
|
2987
|
+
List of Row objects.
|
|
2988
|
+
"""
|
|
2989
|
+
self._check_closed() # Check if the cursor is closed
|
|
2990
|
+
if not self._has_result_set and self.description:
|
|
2991
|
+
self._reset_rownumber()
|
|
2992
|
+
|
|
2993
|
+
if self._cached_decoding_generation != self._connection._decoding_generation:
|
|
2994
|
+
self._refresh_decoding_cache()
|
|
2995
|
+
char_enc = self._cached_char_encoding
|
|
2996
|
+
wchar_enc = self._cached_wchar_encoding
|
|
2997
|
+
|
|
2998
|
+
# Fetch raw data
|
|
2999
|
+
rows_data = []
|
|
3000
|
+
try:
|
|
3001
|
+
with perf_phase("py::fetchall::cpp_call"):
|
|
3002
|
+
ret = ddbc_bindings.DDBCSQLFetchAll(
|
|
3003
|
+
self.hstmt,
|
|
3004
|
+
rows_data,
|
|
3005
|
+
char_enc,
|
|
3006
|
+
wchar_enc,
|
|
3007
|
+
self._cached_char_ctype,
|
|
3008
|
+
self.messages,
|
|
3009
|
+
)
|
|
3010
|
+
|
|
3011
|
+
# Check for errors
|
|
3012
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
3013
|
+
|
|
3014
|
+
# Update rownumber for the number of rows actually fetched
|
|
3015
|
+
if rows_data and self._has_result_set:
|
|
3016
|
+
self._next_row_index += len(rows_data)
|
|
3017
|
+
self._rownumber = self._next_row_index - 1
|
|
3018
|
+
|
|
3019
|
+
# Centralize rowcount assignment after fetch
|
|
3020
|
+
if len(rows_data) == 0 and self._next_row_index == 0:
|
|
3021
|
+
self.rowcount = 0
|
|
3022
|
+
else:
|
|
3023
|
+
self.rowcount = self._next_row_index
|
|
3024
|
+
|
|
3025
|
+
# Get column and converter maps
|
|
3026
|
+
column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
|
|
3027
|
+
|
|
3028
|
+
# Convert raw data to Row objects
|
|
3029
|
+
uuid_idx = self._uuid_str_indices
|
|
3030
|
+
with perf_phase("py::fetchall::row_wrap"):
|
|
3031
|
+
if not converter_map and not uuid_idx:
|
|
3032
|
+
return ddbc_bindings.construct_rows(
|
|
3033
|
+
rows_data,
|
|
3034
|
+
Row,
|
|
3035
|
+
column_map,
|
|
3036
|
+
self,
|
|
3037
|
+
column_map_lower,
|
|
3038
|
+
self._cached_result_columns,
|
|
3039
|
+
)
|
|
3040
|
+
return [
|
|
3041
|
+
Row(
|
|
3042
|
+
row_data,
|
|
3043
|
+
column_map,
|
|
3044
|
+
cursor=self,
|
|
3045
|
+
converter_map=converter_map,
|
|
3046
|
+
uuid_str_indices=uuid_idx,
|
|
3047
|
+
column_map_lower=column_map_lower,
|
|
3048
|
+
column_names=self._cached_result_columns,
|
|
3049
|
+
)
|
|
3050
|
+
for row_data in rows_data
|
|
3051
|
+
]
|
|
3052
|
+
except Exception:
|
|
3053
|
+
# On error, don't increment rownumber - rethrow the error
|
|
3054
|
+
raise
|
|
3055
|
+
|
|
3056
|
+
def arrow_batch(self, batch_size: int = 8192) -> "pyarrow.RecordBatch":
|
|
3057
|
+
"""
|
|
3058
|
+
Fetch a single pyarrow Record Batch of the specified size from the
|
|
3059
|
+
query result set.
|
|
3060
|
+
|
|
3061
|
+
Args:
|
|
3062
|
+
batch_size: Maximum number of rows to fetch in the Record Batch.
|
|
3063
|
+
|
|
3064
|
+
Returns:
|
|
3065
|
+
A pyarrow RecordBatch object containing up to batch_size rows.
|
|
3066
|
+
"""
|
|
3067
|
+
self._check_closed() # Check if the cursor is closed
|
|
3068
|
+
pyarrow = self._ensure_pyarrow()
|
|
3069
|
+
|
|
3070
|
+
if not self._has_result_set and self.description:
|
|
3071
|
+
self._reset_rownumber()
|
|
3072
|
+
|
|
3073
|
+
capsules = []
|
|
3074
|
+
char_decoding = self._get_decoding_settings(ddbc_sql_const.SQL_CHAR.value)
|
|
3075
|
+
char_c_type = char_decoding.get("ctype", ddbc_sql_const.SQL_WCHAR.value)
|
|
3076
|
+
ret = ddbc_bindings.DDBCSQLFetchArrowBatch(
|
|
3077
|
+
self.hstmt, capsules, max(batch_size, 0), char_c_type, self.messages
|
|
3078
|
+
)
|
|
3079
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
3080
|
+
|
|
3081
|
+
batch = pyarrow.RecordBatch._import_from_c_capsule(*capsules)
|
|
3082
|
+
|
|
3083
|
+
# Update rownumber for the number of rows actually fetched
|
|
3084
|
+
num_fetched = batch.num_rows
|
|
3085
|
+
if num_fetched > 0 and self._has_result_set:
|
|
3086
|
+
self._next_row_index += num_fetched
|
|
3087
|
+
self._rownumber = self._next_row_index - 1
|
|
3088
|
+
|
|
3089
|
+
# Centralize rowcount assignment after fetch
|
|
3090
|
+
if num_fetched == 0 and self._next_row_index == 0:
|
|
3091
|
+
self.rowcount = 0
|
|
3092
|
+
else:
|
|
3093
|
+
self.rowcount = self._next_row_index
|
|
3094
|
+
|
|
3095
|
+
return batch
|
|
3096
|
+
|
|
3097
|
+
def arrow(self, batch_size: int = 8192) -> "pyarrow.Table":
|
|
3098
|
+
"""
|
|
3099
|
+
Fetch the entire result as a pyarrow Table.
|
|
3100
|
+
|
|
3101
|
+
Args:
|
|
3102
|
+
batch_size: Size of the Record Batches which make up the Table.
|
|
3103
|
+
|
|
3104
|
+
Returns:
|
|
3105
|
+
A pyarrow Table containing all remaining rows from the result set.
|
|
3106
|
+
"""
|
|
3107
|
+
self._check_closed() # Check if the cursor is closed
|
|
3108
|
+
pyarrow = self._ensure_pyarrow()
|
|
3109
|
+
|
|
3110
|
+
batches: list["pyarrow.RecordBatch"] = []
|
|
3111
|
+
while True:
|
|
3112
|
+
batch = self.arrow_batch(batch_size)
|
|
3113
|
+
if batch.num_rows < batch_size or batch_size <= 0:
|
|
3114
|
+
if not batches or batch.num_rows > 0:
|
|
3115
|
+
batches.append(batch)
|
|
3116
|
+
break
|
|
3117
|
+
batches.append(batch)
|
|
3118
|
+
return pyarrow.Table.from_batches(batches, schema=batches[0].schema)
|
|
3119
|
+
|
|
3120
|
+
def arrow_reader(self, batch_size: int = 8192) -> "_ArrowReader":
|
|
3121
|
+
"""
|
|
3122
|
+
Fetch the result as a pyarrow-compatible RecordBatchReader, which
|
|
3123
|
+
yields Record Batches of the specified size until the current result
|
|
3124
|
+
set is exhausted.
|
|
3125
|
+
|
|
3126
|
+
The returned object is an ``_ArrowReader`` wrapper that behaves like
|
|
3127
|
+
``pyarrow.RecordBatchReader``
|
|
3128
|
+
(``schema``, ``read_next_batch``, iteration, context manager) but
|
|
3129
|
+
its ``close()`` is fully effective. Cleanup is driven
|
|
3130
|
+
by a ``try/finally`` block inside the underlying batch generator, so
|
|
3131
|
+
the same teardown — ``SQLCancel`` to unblock any in-flight fetch on
|
|
3132
|
+
another thread, ``SQLFreeStmt(SQL_CLOSE)`` to release the server-side
|
|
3133
|
+
cursor and locks, draining diagnostics into ``cursor.messages``, and
|
|
3134
|
+
resetting the parent ``Cursor``'s rownumber / ``rowcount`` state —
|
|
3135
|
+
runs whether the user (a) exhausts the reader normally, (b) calls
|
|
3136
|
+
``close()`` mid-iteration, (c) exits a ``with`` block, or (d) just
|
|
3137
|
+
lets the reader be garbage-collected. The parent ``Cursor`` itself
|
|
3138
|
+
is **not** closed and can be re-executed. ``close()`` is idempotent.
|
|
3139
|
+
|
|
3140
|
+
Args:
|
|
3141
|
+
batch_size: Size of the Record Batches produced by the reader.
|
|
3142
|
+
|
|
3143
|
+
Returns:
|
|
3144
|
+
A pyarrow-compatible RecordBatchReader for the result set.
|
|
3145
|
+
"""
|
|
3146
|
+
self._check_closed() # Check if the cursor is closed
|
|
3147
|
+
pyarrow = self._ensure_pyarrow()
|
|
3148
|
+
|
|
3149
|
+
# Fetch schema without advancing cursor
|
|
3150
|
+
schema_batch = self.arrow_batch(0)
|
|
3151
|
+
schema = schema_batch.schema
|
|
3152
|
+
|
|
3153
|
+
# Capture the parent cursor in a closure cell that the generator
|
|
3154
|
+
# can null out after cleanup, so a GC'd reader does not keep the
|
|
3155
|
+
# cursor pinned.
|
|
3156
|
+
cursor_ref = [self]
|
|
3157
|
+
close_requested = [False]
|
|
3158
|
+
|
|
3159
|
+
def batch_generator():
|
|
3160
|
+
exhausted = False
|
|
3161
|
+
try:
|
|
3162
|
+
while (batch := cursor_ref[0].arrow_batch(batch_size)).num_rows > 0:
|
|
3163
|
+
yield batch
|
|
3164
|
+
exhausted = True
|
|
3165
|
+
finally:
|
|
3166
|
+
# Symmetric server-side teardown — runs on exhaustion,
|
|
3167
|
+
# GeneratorExit (from close()), or an exception inside the
|
|
3168
|
+
# body. This is the single canonical cleanup site.
|
|
3169
|
+
cur = cursor_ref[0]
|
|
3170
|
+
cursor_ref[0] = None
|
|
3171
|
+
if not cur.closed and cur.hstmt is not None:
|
|
3172
|
+
# Natural EOF was captured natively. A close/cancel request
|
|
3173
|
+
# (including a racing one) or an error still needs the drain.
|
|
3174
|
+
if not exhausted or close_requested[0]:
|
|
3175
|
+
try:
|
|
3176
|
+
cur.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(cur.hstmt))
|
|
3177
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
3178
|
+
logger.debug("arrow_reader cleanup: pre-close diag drain failed: %s", e)
|
|
3179
|
+
|
|
3180
|
+
# 2) Release the server-side cursor & locks while keeping the
|
|
3181
|
+
# HSTMT and prepared plan intact, so the parent Cursor can
|
|
3182
|
+
# be re-executed.
|
|
3183
|
+
try:
|
|
3184
|
+
cur.hstmt._close_cursor() # pylint: disable=protected-access
|
|
3185
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
3186
|
+
# Elevated to WARNING: unlike the diag-drain failures
|
|
3187
|
+
# (which only cost us some warning text), a failed
|
|
3188
|
+
# SQLFreeStmt(SQL_CLOSE) leaves the server-side cursor
|
|
3189
|
+
# and its locks/tempdb resources open on SQL Server
|
|
3190
|
+
# until this parent Cursor is closed or re-executed.
|
|
3191
|
+
# DEBUG is typically disabled in production, so that
|
|
3192
|
+
# leak would be invisible; WARNING makes it visible.
|
|
3193
|
+
logger.warning(
|
|
3194
|
+
"arrow_reader cleanup: _close_cursor failed (%s); "
|
|
3195
|
+
"server-side cursor may remain open until this "
|
|
3196
|
+
"Cursor is closed or re-executed",
|
|
3197
|
+
e,
|
|
3198
|
+
)
|
|
3199
|
+
|
|
3200
|
+
# 3) Drain diagnostics produced by SQL_CLOSE itself. This
|
|
3201
|
+
# runs unconditionally because SQL_CLOSE can return
|
|
3202
|
+
# SQL_SUCCESS_WITH_INFO (a *success* code) and still leave
|
|
3203
|
+
# warning records on the HSTMT diag stack; the previous
|
|
3204
|
+
# "only on failure" path would silently drop those.
|
|
3205
|
+
try:
|
|
3206
|
+
cur.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(cur.hstmt))
|
|
3207
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
3208
|
+
logger.debug("arrow_reader cleanup: post-close diag drain failed: %s", e)
|
|
3209
|
+
|
|
3210
|
+
# 4) Reset cursor bookkeeping to a clean "no result set"
|
|
3211
|
+
# state. rowcount becomes -1 to signal that the prior
|
|
3212
|
+
# result is no longer meaningful.
|
|
3213
|
+
try:
|
|
3214
|
+
cur._clear_rownumber() # pylint: disable=protected-access
|
|
3215
|
+
cur.rowcount = -1
|
|
3216
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
3217
|
+
logger.debug("arrow_reader cleanup: bookkeeping reset failed: %s", e)
|
|
3218
|
+
|
|
3219
|
+
gen = batch_generator()
|
|
3220
|
+
inner = pyarrow.RecordBatchReader.from_batches(schema, gen)
|
|
3221
|
+
return _ArrowReader(self, inner, gen, pyarrow.ArrowInvalid, close_requested)
|
|
3222
|
+
|
|
3223
|
+
def nextset(self) -> Optional[bool]:
|
|
3224
|
+
"""
|
|
3225
|
+
Skip to the next available result set.
|
|
3226
|
+
|
|
3227
|
+
Returns:
|
|
3228
|
+
True if there is another result set, False otherwise.
|
|
3229
|
+
Note: PEP 249 specifies True/None; we return True/False
|
|
3230
|
+
for backward compatibility with existing callers and pyodbc
|
|
3231
|
+
parity. The signature is Optional[bool] to keep the door
|
|
3232
|
+
open for a future migration to True/None semantics.
|
|
3233
|
+
|
|
3234
|
+
Raises:
|
|
3235
|
+
Error: If the previous call to execute did not produce any result set.
|
|
3236
|
+
"""
|
|
3237
|
+
logger.debug("nextset: Moving to next result set")
|
|
3238
|
+
self._check_closed() # Check if the cursor is closed
|
|
3239
|
+
|
|
3240
|
+
# Clear messages per DBAPI
|
|
3241
|
+
self.messages = []
|
|
3242
|
+
|
|
3243
|
+
# Clear cached column and converter maps for the new result set
|
|
3244
|
+
self._cached_column_map = None
|
|
3245
|
+
self._cached_column_map_lower = None
|
|
3246
|
+
self._cached_converter_map = None
|
|
3247
|
+
self._column_sql_types = None
|
|
3248
|
+
self._uuid_str_indices = None
|
|
3249
|
+
|
|
3250
|
+
# Skip to the next result set
|
|
3251
|
+
ret = ddbc_bindings.DDBCSQLMoreResults(self.hstmt)
|
|
3252
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
|
|
3253
|
+
|
|
3254
|
+
# Capture diagnostic messages (e.g. PRINT output) — handles both
|
|
3255
|
+
# SQL_SUCCESS_WITH_INFO and SQL_NO_DATA (trailing PRINT after the
|
|
3256
|
+
# final result set). Without this, messages from subsequent result
|
|
3257
|
+
# sets are silently lost (GH-612).
|
|
3258
|
+
self._capture_diagnostics(ret)
|
|
3259
|
+
|
|
3260
|
+
if ret == ddbc_sql_const.SQL_NO_DATA.value:
|
|
3261
|
+
logger.debug("nextset: No more result sets available")
|
|
3262
|
+
self._clear_rownumber()
|
|
3263
|
+
self.description = None
|
|
3264
|
+
return False
|
|
3265
|
+
|
|
3266
|
+
self._reset_rownumber()
|
|
3267
|
+
|
|
3268
|
+
# Initialize description for the new result set
|
|
3269
|
+
column_metadata = []
|
|
3270
|
+
try:
|
|
3271
|
+
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
|
|
3272
|
+
self._initialize_description(column_metadata)
|
|
3273
|
+
|
|
3274
|
+
# Pre-build column map and converter map for the new result set
|
|
3275
|
+
if self.description:
|
|
3276
|
+
self._cached_column_map = {
|
|
3277
|
+
col_desc[0]: i for i, col_desc in enumerate(self.description)
|
|
3278
|
+
}
|
|
3279
|
+
self._cached_column_map_lower = (
|
|
3280
|
+
{k.lower(): v for k, v in self._cached_column_map.items()}
|
|
3281
|
+
if get_settings().lowercase
|
|
3282
|
+
else None
|
|
3283
|
+
)
|
|
3284
|
+
self._cached_converter_map = self._build_converter_map()
|
|
3285
|
+
self._uuid_str_indices = self._compute_uuid_str_indices()
|
|
3286
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
3287
|
+
# If describe fails, there might be no results in this result set
|
|
3288
|
+
self.description = None
|
|
3289
|
+
|
|
3290
|
+
logger.debug(
|
|
3291
|
+
"nextset: Moved to next result set - column_count=%d",
|
|
3292
|
+
len(self.description) if self.description else 0,
|
|
3293
|
+
)
|
|
3294
|
+
return True
|
|
3295
|
+
|
|
3296
|
+
# ── Mapping from ODBC connection-string keywords (lowercase, as _parse returns)
|
|
3297
|
+
def _build_pycore_context(self) -> dict:
|
|
3298
|
+
"""Build the connection context dict expected by mssql_py_core.
|
|
3299
|
+
|
|
3300
|
+
Parses the underlying ODBC connection string, validates the SERVER
|
|
3301
|
+
parameter, and (when Azure AD auth is in use) either registers a
|
|
3302
|
+
ServicePrincipal token factory or acquires a fresh access token,
|
|
3303
|
+
replacing credential fields as py-core requires. Returns a dict that
|
|
3304
|
+
can be handed to ``PyCoreConnection``. Shared by :meth:`bulkcopy`
|
|
3305
|
+
and :meth:`bulkcopy_arrow` so both paths get identical auth handling.
|
|
3306
|
+
|
|
3307
|
+
Raises:
|
|
3308
|
+
RuntimeError: connection string unavailable or token acquisition failed.
|
|
3309
|
+
ValueError: SERVER parameter missing.
|
|
3310
|
+
"""
|
|
3311
|
+
# Get and parse connection string
|
|
3312
|
+
if not hasattr(self.connection, "connection_str"):
|
|
3313
|
+
logger.error("_build_pycore_context: Connection string not available")
|
|
3314
|
+
raise RuntimeError("Connection string not available for bulk copy")
|
|
3315
|
+
|
|
3316
|
+
# Use the proper connection string parser that handles braced values
|
|
3317
|
+
from mssql_python.connection_string_parser import _ConnectionStringParser
|
|
3318
|
+
|
|
3319
|
+
parser = _ConnectionStringParser(validate_keywords=False)
|
|
3320
|
+
params = parser._parse(self.connection.connection_str)
|
|
3321
|
+
|
|
3322
|
+
# Check for server parameter (accepts synonyms: server, addr, address)
|
|
3323
|
+
if not (params.get("server") or params.get("addr") or params.get("address")):
|
|
3324
|
+
raise ValueError("SERVER parameter is required in connection string")
|
|
3325
|
+
|
|
3326
|
+
# Translate parsed connection string into the dict py-core expects.
|
|
3327
|
+
pycore_context = connstr_to_pycore_params(params)
|
|
3328
|
+
|
|
3329
|
+
# Forward the cursor's query timeout to py-core so the bulkcopy
|
|
3330
|
+
# connection uses the same limit instead of py-core's compiled-in 15s
|
|
3331
|
+
# default. _timeout is the snapshot taken at cursor creation (same value
|
|
3332
|
+
# _set_timeout uses). Accept any int the public Connection.timeout setter
|
|
3333
|
+
# accepts (including IntEnum) but exclude bool, then normalise to a plain
|
|
3334
|
+
# int so py-core's u32 extract is unambiguous. 0 stays a "no override":
|
|
3335
|
+
# py-core's default connect path treats 0 as no deadline, but its TCP
|
|
3336
|
+
# attempt path turns 0 into a 0ms timeout that fails instantly, so we
|
|
3337
|
+
# leave 0 unset and let py-core apply its own 15s default.
|
|
3338
|
+
connect_timeout = self._timeout
|
|
3339
|
+
if isinstance(connect_timeout, int) and not isinstance(connect_timeout, bool):
|
|
3340
|
+
if connect_timeout > 0:
|
|
3341
|
+
pycore_context["connect_timeout"] = int(connect_timeout)
|
|
3342
|
+
|
|
3343
|
+
# Token acquisition — only thing cursor must handle (needs azure-identity SDK)
|
|
3344
|
+
if self.connection._token_provider is not None:
|
|
3345
|
+
# User-supplied credential — use it directly for a fresh token.
|
|
3346
|
+
from mssql_python.auth import acquire_raw_token_from_credential
|
|
3347
|
+
|
|
3348
|
+
try:
|
|
3349
|
+
raw_token, _ = acquire_raw_token_from_credential(self.connection._token_provider)
|
|
3350
|
+
except (OperationalError, InterfaceError) as e:
|
|
3351
|
+
# Preserve the original exception *type* so the same failure is
|
|
3352
|
+
# catchable the same way from connect() and bulkcopy(): an empty
|
|
3353
|
+
# or invalid token raises InterfaceError, while a provider or
|
|
3354
|
+
# transport failure raises OperationalError. Re-wrapping every
|
|
3355
|
+
# case as OperationalError would make an InterfaceError from
|
|
3356
|
+
# connect() surface as OperationalError here, splitting one
|
|
3357
|
+
# failure across two DB-API error categories.
|
|
3358
|
+
raise type(e)(
|
|
3359
|
+
driver_error=(
|
|
3360
|
+
"Bulk copy failed: unable to acquire token from custom credential"
|
|
3361
|
+
),
|
|
3362
|
+
ddbc_error=str(e),
|
|
3363
|
+
) from e
|
|
3364
|
+
pycore_context["access_token"] = raw_token
|
|
3365
|
+
logger.debug(
|
|
3366
|
+
"Bulk copy: acquired fresh token from custom credential (%s)",
|
|
3367
|
+
type(self.connection._token_provider).__name__,
|
|
3368
|
+
)
|
|
3369
|
+
elif self.connection._auth_type:
|
|
3370
|
+
# Fresh token acquisition for mssql-py-core connection
|
|
3371
|
+
from mssql_python.auth import AADAuth, ServicePrincipalAuth
|
|
3372
|
+
from mssql_python.constants import _AuthInternal
|
|
3373
|
+
|
|
3374
|
+
if self.connection._auth_type == _AuthInternal.SERVICE_PRINCIPAL:
|
|
3375
|
+
# Callback-based path: tenant_id is only known from the STS URL
|
|
3376
|
+
# the server returns mid-handshake, so we register a factory
|
|
3377
|
+
# that py-core invokes during FedAuth (workflow 0x02).
|
|
3378
|
+
# Cert-based ServicePrincipal is not supported on this path.
|
|
3379
|
+
client_id = params.get("uid", "")
|
|
3380
|
+
client_secret = params.get("pwd", "")
|
|
3381
|
+
if not client_id or not client_secret:
|
|
3382
|
+
raise RuntimeError(
|
|
3383
|
+
"Bulk copy with Authentication=ActiveDirectoryServicePrincipal "
|
|
3384
|
+
"currently supports client-secret only. "
|
|
3385
|
+
"Provide UID (client_id) and PWD (client_secret) in the "
|
|
3386
|
+
"connection string."
|
|
3387
|
+
)
|
|
3388
|
+
try:
|
|
3389
|
+
factory = ServicePrincipalAuth.make_token_factory(client_id, client_secret)
|
|
3390
|
+
except (RuntimeError, ValueError) as e:
|
|
3391
|
+
raise RuntimeError(
|
|
3392
|
+
f"Bulk copy failed: unable to build ServicePrincipal token factory: {e}"
|
|
3393
|
+
) from e
|
|
3394
|
+
pycore_context["entra_id_token_factory"] = factory
|
|
3395
|
+
# Keep authentication/user_name/password in pycore_context —
|
|
3396
|
+
# py-core's auth validator + transformer need them to resolve
|
|
3397
|
+
# the auth method to ActiveDirectoryServicePrincipal before
|
|
3398
|
+
# the factory is dispatched at handshake time.
|
|
3399
|
+
logger.debug("Bulk copy: registered ServicePrincipal token factory")
|
|
3400
|
+
else:
|
|
3401
|
+
# Pre-acquired token path. Used for Default, DeviceCode,
|
|
3402
|
+
# Interactive (non-Windows), MSI (system- or user-assigned),
|
|
3403
|
+
# and any other AD method whose tenant_id is discoverable
|
|
3404
|
+
# client-side via Azure Identity SDK. credential kwargs
|
|
3405
|
+
# (e.g. user-assigned MSI client_id) were captured by
|
|
3406
|
+
# Connection.__init__ before remove_sensitive_params stripped
|
|
3407
|
+
# UID from connection_str. re-parsing here would miss them.
|
|
3408
|
+
try:
|
|
3409
|
+
raw_token = AADAuth.get_raw_token(
|
|
3410
|
+
self.connection._auth_type,
|
|
3411
|
+
self.connection._credential_kwargs,
|
|
3412
|
+
)
|
|
3413
|
+
except (RuntimeError, ValueError) as e:
|
|
3414
|
+
raise RuntimeError(
|
|
3415
|
+
f"Bulk copy failed: unable to acquire Azure AD token "
|
|
3416
|
+
f"for auth_type '{self.connection._auth_type}': {e}"
|
|
3417
|
+
) from e
|
|
3418
|
+
pycore_context["access_token"] = raw_token
|
|
3419
|
+
# Token replaces credential fields — py-core's validator rejects
|
|
3420
|
+
# access_token combined with authentication/user_name/password.
|
|
3421
|
+
for key in ("authentication", "user_name", "password"):
|
|
3422
|
+
pycore_context.pop(key, None)
|
|
3423
|
+
logger.debug(
|
|
3424
|
+
"Bulk copy: acquired fresh Azure AD token for auth_type=%s",
|
|
3425
|
+
self.connection._auth_type,
|
|
3426
|
+
)
|
|
3427
|
+
|
|
3428
|
+
return pycore_context
|
|
3429
|
+
|
|
3430
|
+
def _looks_like_arrow_source(self, data) -> bool:
|
|
3431
|
+
"""Return True if ``data`` should be routed to :meth:`bulkcopy_arrow`.
|
|
3432
|
+
|
|
3433
|
+
Soft check: never imports pyarrow eagerly and never raises. Anything
|
|
3434
|
+
exposing the Arrow C-stream PyCapsule protocol counts, as do the
|
|
3435
|
+
concrete pyarrow container types when pyarrow is importable.
|
|
3436
|
+
"""
|
|
3437
|
+
if data is None:
|
|
3438
|
+
return False
|
|
3439
|
+
if hasattr(data, "__arrow_c_stream__") or hasattr(data, "__arrow_c_array__"):
|
|
3440
|
+
return True
|
|
3441
|
+
try:
|
|
3442
|
+
import pyarrow as pa
|
|
3443
|
+
except ImportError:
|
|
3444
|
+
return False
|
|
3445
|
+
return isinstance(data, (pa.Table, pa.RecordBatch, pa.RecordBatchReader))
|
|
3446
|
+
|
|
3447
|
+
@staticmethod
|
|
3448
|
+
def _bulkcopy_core_and_validate(table_name, batch_size, timeout):
|
|
3449
|
+
"""Import the native core and validate the args shared by ``bulkcopy``
|
|
3450
|
+
and ``bulkcopy_arrow``. Returns the imported ``mssql_py_core`` module."""
|
|
3451
|
+
try:
|
|
3452
|
+
import mssql_py_core
|
|
3453
|
+
except ImportError as exc:
|
|
3454
|
+
logger.error("bulkcopy: Failed to import mssql_py_core module")
|
|
3455
|
+
raise ImportError(
|
|
3456
|
+
"Bulk copy requires the mssql_py_core library which is not available. "
|
|
3457
|
+
"This is an unexpected error. "
|
|
3458
|
+
) from exc
|
|
3459
|
+
|
|
3460
|
+
if not table_name or not isinstance(table_name, str):
|
|
3461
|
+
logger.error("bulkcopy: Invalid table_name parameter")
|
|
3462
|
+
raise ValueError("table_name must be a non-empty string")
|
|
3463
|
+
|
|
3464
|
+
if not isinstance(batch_size, int):
|
|
3465
|
+
raise TypeError(
|
|
3466
|
+
f"batch_size must be a non-negative integer, got {type(batch_size).__name__}"
|
|
3467
|
+
)
|
|
3468
|
+
if batch_size < 0:
|
|
3469
|
+
raise ValueError(f"batch_size must be non-negative, got {batch_size}")
|
|
3470
|
+
|
|
3471
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool):
|
|
3472
|
+
raise TypeError(f"timeout must be a non-negative integer, got {type(timeout).__name__}")
|
|
3473
|
+
if timeout < 0:
|
|
3474
|
+
raise ValueError(f"timeout must be non-negative, got {timeout}")
|
|
3475
|
+
|
|
3476
|
+
return mssql_py_core
|
|
3477
|
+
|
|
3478
|
+
@staticmethod
|
|
3479
|
+
def _bulkcopy_teardown(pycore_context, pycore_cursor, pycore_connection):
|
|
3480
|
+
"""Scrub credential material from the context and close native
|
|
3481
|
+
bulk-copy resources. Safe to call with partially-initialized state."""
|
|
3482
|
+
if pycore_context:
|
|
3483
|
+
for key in ("password", "user_name", "access_token", "entra_id_token_factory"):
|
|
3484
|
+
pycore_context.pop(key, None)
|
|
3485
|
+
for resource in (pycore_cursor, pycore_connection):
|
|
3486
|
+
if resource and hasattr(resource, "close"):
|
|
3487
|
+
try:
|
|
3488
|
+
resource.close()
|
|
3489
|
+
except Exception as cleanup_error:
|
|
3490
|
+
logger.debug(
|
|
3491
|
+
"Failed to close bulk copy resource %s: %s",
|
|
3492
|
+
type(resource).__name__,
|
|
3493
|
+
cleanup_error,
|
|
3494
|
+
)
|
|
3495
|
+
|
|
3496
|
+
def bulkcopy(
|
|
3497
|
+
self,
|
|
3498
|
+
table_name: str,
|
|
3499
|
+
data: Iterable[Union[Tuple, "Row"]],
|
|
3500
|
+
batch_size: int = 0,
|
|
3501
|
+
timeout: int = 30,
|
|
3502
|
+
column_mappings: Optional[Union[List[str], List[Tuple[int, str]]]] = None,
|
|
3503
|
+
keep_identity: bool = False,
|
|
3504
|
+
check_constraints: bool = False,
|
|
3505
|
+
table_lock: bool = False,
|
|
3506
|
+
keep_nulls: bool = False,
|
|
3507
|
+
fire_triggers: bool = False,
|
|
3508
|
+
use_internal_transaction: bool = False,
|
|
3509
|
+
): # pragma: no cover
|
|
3510
|
+
"""
|
|
3511
|
+
Perform bulk copy operation for high-performance data loading.
|
|
3512
|
+
|
|
3513
|
+
Args:
|
|
3514
|
+
table_name: Target table name (can include schema, e.g., 'dbo.MyTable').
|
|
3515
|
+
The table must exist and the user must have INSERT permissions.
|
|
3516
|
+
|
|
3517
|
+
data: Iterable of tuples or Row objects containing row data to be inserted.
|
|
3518
|
+
Row objects from fetchone/fetchmany/fetchall are automatically
|
|
3519
|
+
converted to tuples. Lists and other types are not accepted.
|
|
3520
|
+
|
|
3521
|
+
Data Format Requirements:
|
|
3522
|
+
- Each element in the iterable represents one row
|
|
3523
|
+
- Each row should be a tuple or Row object
|
|
3524
|
+
- Column order must match the target table's column order (by ordinal
|
|
3525
|
+
position), unless column_mappings is specified
|
|
3526
|
+
- The number of values in each row must match the number of columns
|
|
3527
|
+
in the target table
|
|
3528
|
+
|
|
3529
|
+
batch_size: Number of rows to send per batch. Default 0 uses server optimal.
|
|
3530
|
+
|
|
3531
|
+
timeout: Operation timeout in seconds. Default is 30. 0 disables the
|
|
3532
|
+
bulk copy operation timeout.
|
|
3533
|
+
|
|
3534
|
+
column_mappings: Maps source data columns to target table column names.
|
|
3535
|
+
Two formats supported:
|
|
3536
|
+
|
|
3537
|
+
Simple Format - List[str]:
|
|
3538
|
+
List of destination column names in order. Position in list = source index.
|
|
3539
|
+
Example: ['UserID', 'FirstName', 'Email']
|
|
3540
|
+
Maps: index 0 → UserID, index 1 → FirstName, index 2 → Email
|
|
3541
|
+
|
|
3542
|
+
Advanced Format - List[Tuple[int, str]]:
|
|
3543
|
+
Explicit index mapping. Allows skipping or reordering columns.
|
|
3544
|
+
Each tuple is (source_index, target_column_name).
|
|
3545
|
+
Example: [(0, 'UserID'), (1, 'FirstName'), (3, 'Email')]
|
|
3546
|
+
Maps: index 0 → UserID, index 1 → FirstName, index 3 → Email (skips index 2)
|
|
3547
|
+
|
|
3548
|
+
When omitted: Columns are mapped by ordinal position (first data
|
|
3549
|
+
column → first table column, second → second, etc.)
|
|
3550
|
+
|
|
3551
|
+
keep_identity: Preserve identity values from source data.
|
|
3552
|
+
|
|
3553
|
+
check_constraints: Check constraints during bulk copy.
|
|
3554
|
+
|
|
3555
|
+
table_lock: Use table-level lock instead of row-level locks.
|
|
3556
|
+
|
|
3557
|
+
keep_nulls: Preserve null values instead of using default values.
|
|
3558
|
+
|
|
3559
|
+
fire_triggers: Fire insert triggers on the target table.
|
|
3560
|
+
|
|
3561
|
+
use_internal_transaction: Use an internal transaction for each batch.
|
|
3562
|
+
|
|
3563
|
+
Returns:
|
|
3564
|
+
Dictionary with bulk copy results including:
|
|
3565
|
+
- rows_copied: Number of rows successfully copied
|
|
3566
|
+
- batch_count: Number of batches processed
|
|
3567
|
+
- elapsed_time: Time taken for the operation
|
|
3568
|
+
|
|
3569
|
+
Raises:
|
|
3570
|
+
ImportError: If mssql_py_core library is not installed
|
|
3571
|
+
TypeError: If data is None, not iterable, is a string/bytes, or is an
|
|
3572
|
+
Arrow source (use :meth:`bulkcopy_arrow` for those)
|
|
3573
|
+
ValueError: If table_name is empty or parameters are invalid
|
|
3574
|
+
RuntimeError: If connection string is not available
|
|
3575
|
+
"""
|
|
3576
|
+
# Fast check if logging is enabled to avoid overhead
|
|
3577
|
+
is_logging_enabled = logger.is_debug_enabled
|
|
3578
|
+
|
|
3579
|
+
# Steer Arrow-shaped sources to the dedicated method instead of
|
|
3580
|
+
# silently re-routing or failing deep inside the tuple validator.
|
|
3581
|
+
if self._looks_like_arrow_source(data):
|
|
3582
|
+
raise TypeError(
|
|
3583
|
+
"bulkcopy() expects an iterable of row tuples or Row objects. "
|
|
3584
|
+
"For pyarrow.Table / RecordBatch / RecordBatchReader / objects "
|
|
3585
|
+
"implementing __arrow_c_stream__/__arrow_c_array__, call "
|
|
3586
|
+
"cursor.bulkcopy_arrow() instead."
|
|
3587
|
+
)
|
|
3588
|
+
|
|
3589
|
+
mssql_py_core = self._bulkcopy_core_and_validate(table_name, batch_size, timeout)
|
|
3590
|
+
|
|
3591
|
+
# Validate that data is iterable (but not a string or bytes, which are technically iterable)
|
|
3592
|
+
if data is None:
|
|
3593
|
+
raise TypeError("data must be an iterable of tuples or lists, got None")
|
|
3594
|
+
if isinstance(data, (str, bytes)):
|
|
3595
|
+
raise TypeError(
|
|
3596
|
+
f"data must be an iterable of tuples or lists, got {type(data).__name__}. "
|
|
3597
|
+
"Strings and bytes are not valid row collections."
|
|
3598
|
+
)
|
|
3599
|
+
if not hasattr(data, "__iter__"):
|
|
3600
|
+
raise TypeError(
|
|
3601
|
+
f"data must be an iterable of tuples or lists, got non-iterable {type(data).__name__}"
|
|
3602
|
+
)
|
|
3603
|
+
|
|
3604
|
+
pycore_context = self._build_pycore_context()
|
|
3605
|
+
|
|
3606
|
+
pycore_connection = None
|
|
3607
|
+
pycore_cursor = None
|
|
3608
|
+
try:
|
|
3609
|
+
# Only pass logger to Rust if logging is enabled (performance optimization)
|
|
3610
|
+
pycore_connection = mssql_py_core.PyCoreConnection(
|
|
3611
|
+
pycore_context, python_logger=logger if is_logging_enabled else None
|
|
3612
|
+
)
|
|
3613
|
+
pycore_cursor = pycore_connection.cursor()
|
|
3614
|
+
|
|
3615
|
+
# Enforce the bulkcopy type contract: only tuple and Row accepted.
|
|
3616
|
+
# Created to support the fetchone/fetchmany/fetchall -> bulkcopy
|
|
3617
|
+
# pipeline where Row objects need conversion to native tuples.
|
|
3618
|
+
# Rust (mssql_py_core) requires PyTuple via cast::<PyTuple>() and
|
|
3619
|
+
# rejects all other types including list. Row objects are converted
|
|
3620
|
+
# using direct _values access (4x faster than __iter__ protocol).
|
|
3621
|
+
# Uses itertools.chain for C-level iteration (avoids Python
|
|
3622
|
+
# generator frame overhead on the tuple passthrough path).
|
|
3623
|
+
def _prepare_row_iterator(iterable):
|
|
3624
|
+
from itertools import chain
|
|
3625
|
+
|
|
3626
|
+
it = iter(iterable)
|
|
3627
|
+
first = next(it, None)
|
|
3628
|
+
if first is None:
|
|
3629
|
+
return iter(())
|
|
3630
|
+
if isinstance(first, tuple):
|
|
3631
|
+
return chain((first,), it)
|
|
3632
|
+
if isinstance(first, Row):
|
|
3633
|
+
return (tuple(item._values) for item in chain((first,), it))
|
|
3634
|
+
raise TypeError(
|
|
3635
|
+
f"bulkcopy data rows must be tuples or Row objects, "
|
|
3636
|
+
f"got {type(first).__name__}"
|
|
3637
|
+
)
|
|
3638
|
+
|
|
3639
|
+
# Call bulkcopy with explicit keyword arguments
|
|
3640
|
+
# The API signature: bulkcopy(table_name, data_source, batch_size=0, timeout=30, ...)
|
|
3641
|
+
result = pycore_cursor.bulkcopy(
|
|
3642
|
+
table_name,
|
|
3643
|
+
_prepare_row_iterator(data),
|
|
3644
|
+
batch_size=batch_size,
|
|
3645
|
+
timeout=timeout,
|
|
3646
|
+
column_mappings=column_mappings,
|
|
3647
|
+
keep_identity=keep_identity,
|
|
3648
|
+
check_constraints=check_constraints,
|
|
3649
|
+
table_lock=table_lock,
|
|
3650
|
+
keep_nulls=keep_nulls,
|
|
3651
|
+
fire_triggers=fire_triggers,
|
|
3652
|
+
use_internal_transaction=use_internal_transaction,
|
|
3653
|
+
python_logger=logger if is_logging_enabled else None, # Only pass logger if enabled
|
|
3654
|
+
)
|
|
3655
|
+
|
|
3656
|
+
logger.info(
|
|
3657
|
+
"_bulkcopy: Bulk copy completed successfully - rows_copied=%s, batch_count=%s, elapsed_time=%s",
|
|
3658
|
+
result.get("rows_copied", "N/A"),
|
|
3659
|
+
result.get("batch_count", "N/A"),
|
|
3660
|
+
result.get("elapsed_time", "N/A"),
|
|
3661
|
+
)
|
|
3662
|
+
|
|
3663
|
+
return result
|
|
3664
|
+
|
|
3665
|
+
except Exception as e:
|
|
3666
|
+
# Log the error for debugging (without exposing credentials)
|
|
3667
|
+
logger.debug(
|
|
3668
|
+
"Bulk copy operation failed for table '%s': %s: %s",
|
|
3669
|
+
table_name,
|
|
3670
|
+
type(e).__name__,
|
|
3671
|
+
str(e),
|
|
3672
|
+
)
|
|
3673
|
+
# Re-raise the original exception, preserving its type, args, and
|
|
3674
|
+
# traceback. The finally block scrubs credentials, and Python
|
|
3675
|
+
# tracebacks don't expose local values, so no reconstruction is needed.
|
|
3676
|
+
raise
|
|
3677
|
+
|
|
3678
|
+
finally:
|
|
3679
|
+
self._bulkcopy_teardown(pycore_context, pycore_cursor, pycore_connection)
|
|
3680
|
+
|
|
3681
|
+
def bulkcopy_arrow(
|
|
3682
|
+
self,
|
|
3683
|
+
table_name: str,
|
|
3684
|
+
source,
|
|
3685
|
+
batch_size: int = 0,
|
|
3686
|
+
timeout: int = 30,
|
|
3687
|
+
column_mappings: Optional[Union[List[str], List[Tuple[int, str]]]] = None,
|
|
3688
|
+
keep_identity: bool = False,
|
|
3689
|
+
check_constraints: bool = False,
|
|
3690
|
+
table_lock: bool = False,
|
|
3691
|
+
keep_nulls: bool = False,
|
|
3692
|
+
fire_triggers: bool = False,
|
|
3693
|
+
use_internal_transaction: bool = False,
|
|
3694
|
+
):
|
|
3695
|
+
"""Bulk-copy from an Apache Arrow source straight into TDS.
|
|
3696
|
+
|
|
3697
|
+
``source`` may be any of:
|
|
3698
|
+
|
|
3699
|
+
* ``pyarrow.Table``
|
|
3700
|
+
* ``pyarrow.RecordBatch``
|
|
3701
|
+
* ``pyarrow.RecordBatchReader``
|
|
3702
|
+
* any object exposing ``__arrow_c_stream__`` (Arrow PyCapsule stream
|
|
3703
|
+
interface — e.g. polars/pandas DataFrames ≥ 2.2, duckdb results)
|
|
3704
|
+
* any object exposing ``__arrow_c_array__`` (single-batch PyCapsule
|
|
3705
|
+
array interface)
|
|
3706
|
+
* an iterable of ``pyarrow.RecordBatch`` (all batches must share the
|
|
3707
|
+
same schema)
|
|
3708
|
+
|
|
3709
|
+
Compared to :meth:`bulkcopy`, this path skips per-cell Python
|
|
3710
|
+
round-trips: each batch's typed Arrow buffers are read directly by
|
|
3711
|
+
the Rust core and streamed into the TDS bulk-load packets. Schema and
|
|
3712
|
+
column-mapping semantics, the options, and the returned dictionary are
|
|
3713
|
+
identical to :meth:`bulkcopy`.
|
|
3714
|
+
|
|
3715
|
+
Args:
|
|
3716
|
+
table_name: Target table name (may include schema, e.g. 'dbo.MyTable').
|
|
3717
|
+
source: Arrow source (see above).
|
|
3718
|
+
batch_size: Rows per TDS commit. Default 0 uses server optimal.
|
|
3719
|
+
timeout: Operation timeout in seconds. Default 30. 0 disables the
|
|
3720
|
+
bulk copy operation timeout.
|
|
3721
|
+
column_mappings: Same two formats as :meth:`bulkcopy`. When omitted,
|
|
3722
|
+
Arrow fields map to destination columns by ordinal position.
|
|
3723
|
+
keep_identity: Preserve identity values from the source.
|
|
3724
|
+
check_constraints: Check constraints during bulk copy.
|
|
3725
|
+
table_lock: Use a table-level lock instead of row-level locks.
|
|
3726
|
+
keep_nulls: Preserve null values instead of using column defaults.
|
|
3727
|
+
fire_triggers: Fire insert triggers on the target table.
|
|
3728
|
+
use_internal_transaction: Use an internal transaction per batch.
|
|
3729
|
+
|
|
3730
|
+
Returns:
|
|
3731
|
+
Dictionary with ``rows_copied``, ``batch_count``, ``elapsed_time``
|
|
3732
|
+
and ``rows_per_second``.
|
|
3733
|
+
|
|
3734
|
+
Raises:
|
|
3735
|
+
ImportError: If the mssql_py_core library is not installed.
|
|
3736
|
+
TypeError: If ``source`` is None, a str, or bytes.
|
|
3737
|
+
ValueError: If ``table_name`` is empty or parameters are invalid.
|
|
3738
|
+
RuntimeError: If the connection string is not available.
|
|
3739
|
+
|
|
3740
|
+
Example:
|
|
3741
|
+
>>> import pyarrow as pa
|
|
3742
|
+
>>> table = pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]})
|
|
3743
|
+
>>> result = cursor.bulkcopy_arrow("dbo.MyTable", table)
|
|
3744
|
+
>>> result["rows_copied"]
|
|
3745
|
+
3
|
|
3746
|
+
"""
|
|
3747
|
+
is_logging_enabled = logger.is_debug_enabled
|
|
3748
|
+
|
|
3749
|
+
mssql_py_core = self._bulkcopy_core_and_validate(table_name, batch_size, timeout)
|
|
3750
|
+
|
|
3751
|
+
if source is None or isinstance(source, (str, bytes)):
|
|
3752
|
+
got = "None" if source is None else type(source).__name__
|
|
3753
|
+
raise TypeError(
|
|
3754
|
+
"source must be a pyarrow Table/RecordBatch/RecordBatchReader, "
|
|
3755
|
+
"an iterable of RecordBatch, or an object implementing "
|
|
3756
|
+
f"__arrow_c_stream__/__arrow_c_array__, got {got}"
|
|
3757
|
+
)
|
|
3758
|
+
|
|
3759
|
+
pycore_context = self._build_pycore_context()
|
|
3760
|
+
|
|
3761
|
+
pycore_connection = None
|
|
3762
|
+
pycore_cursor = None
|
|
3763
|
+
try:
|
|
3764
|
+
pycore_connection = mssql_py_core.PyCoreConnection(
|
|
3765
|
+
pycore_context, python_logger=logger if is_logging_enabled else None
|
|
3766
|
+
)
|
|
3767
|
+
pycore_cursor = pycore_connection.cursor()
|
|
3768
|
+
|
|
3769
|
+
result = pycore_cursor.bulkcopy_arrow(
|
|
3770
|
+
table_name,
|
|
3771
|
+
source,
|
|
3772
|
+
batch_size=batch_size,
|
|
3773
|
+
timeout=timeout,
|
|
3774
|
+
column_mappings=column_mappings,
|
|
3775
|
+
keep_identity=keep_identity,
|
|
3776
|
+
check_constraints=check_constraints,
|
|
3777
|
+
table_lock=table_lock,
|
|
3778
|
+
keep_nulls=keep_nulls,
|
|
3779
|
+
fire_triggers=fire_triggers,
|
|
3780
|
+
use_internal_transaction=use_internal_transaction,
|
|
3781
|
+
python_logger=logger if is_logging_enabled else None,
|
|
3782
|
+
)
|
|
3783
|
+
|
|
3784
|
+
logger.info(
|
|
3785
|
+
"bulkcopy_arrow: completed - rows_copied=%s, batch_count=%s, elapsed_time=%s",
|
|
3786
|
+
result.get("rows_copied", "N/A"),
|
|
3787
|
+
result.get("batch_count", "N/A"),
|
|
3788
|
+
result.get("elapsed_time", "N/A"),
|
|
3789
|
+
)
|
|
3790
|
+
return result
|
|
3791
|
+
|
|
3792
|
+
except Exception as e:
|
|
3793
|
+
logger.debug(
|
|
3794
|
+
"bulkcopy_arrow failed for table '%s': %s: %s",
|
|
3795
|
+
table_name,
|
|
3796
|
+
type(e).__name__,
|
|
3797
|
+
str(e),
|
|
3798
|
+
)
|
|
3799
|
+
raise
|
|
3800
|
+
|
|
3801
|
+
finally:
|
|
3802
|
+
self._bulkcopy_teardown(pycore_context, pycore_cursor, pycore_connection)
|
|
3803
|
+
|
|
3804
|
+
def __enter__(self):
|
|
3805
|
+
"""
|
|
3806
|
+
Enter the runtime context for the cursor.
|
|
3807
|
+
|
|
3808
|
+
Returns:
|
|
3809
|
+
The cursor instance itself.
|
|
3810
|
+
"""
|
|
3811
|
+
self._check_closed()
|
|
3812
|
+
return self
|
|
3813
|
+
|
|
3814
|
+
def __exit__(self, *args):
|
|
3815
|
+
"""Closes the cursor when exiting the context, ensuring proper resource cleanup."""
|
|
3816
|
+
if not self.closed:
|
|
3817
|
+
self.close()
|
|
3818
|
+
|
|
3819
|
+
def fetchval(self):
|
|
3820
|
+
"""
|
|
3821
|
+
Fetch the first column of the first row if there are results.
|
|
3822
|
+
|
|
3823
|
+
This is a convenience method for queries that return a single value,
|
|
3824
|
+
such as SELECT COUNT(*) FROM table, SELECT MAX(id) FROM table, etc.
|
|
3825
|
+
|
|
3826
|
+
Returns:
|
|
3827
|
+
The value of the first column of the first row, or None if no rows
|
|
3828
|
+
are available or the first column value is NULL.
|
|
3829
|
+
|
|
3830
|
+
Raises:
|
|
3831
|
+
Exception: If the cursor is closed.
|
|
3832
|
+
|
|
3833
|
+
Example:
|
|
3834
|
+
>>> count = cursor.execute('SELECT COUNT(*) FROM users').fetchval()
|
|
3835
|
+
>>> max_id = cursor.execute('SELECT MAX(id) FROM users').fetchval()
|
|
3836
|
+
>>> name = cursor.execute('SELECT name FROM users WHERE id = ?', user_id).fetchval()
|
|
3837
|
+
|
|
3838
|
+
Note:
|
|
3839
|
+
This is a convenience extension beyond the DB-API 2.0 specification.
|
|
3840
|
+
After calling fetchval(), the cursor position advances by one row,
|
|
3841
|
+
just like fetchone().
|
|
3842
|
+
"""
|
|
3843
|
+
if logger.is_debug_enabled:
|
|
3844
|
+
logger.debug("fetchval: Fetching single value from first column")
|
|
3845
|
+
self._check_closed() # Check if the cursor is closed
|
|
3846
|
+
|
|
3847
|
+
# Check if this is a result-producing statement
|
|
3848
|
+
if not self.description:
|
|
3849
|
+
# Non-result-set statement (INSERT, UPDATE, DELETE, etc.)
|
|
3850
|
+
if logger.is_debug_enabled:
|
|
3851
|
+
logger.debug("fetchval: No result set available (non-SELECT statement)")
|
|
3852
|
+
return None
|
|
3853
|
+
|
|
3854
|
+
# Fetch the first row
|
|
3855
|
+
row = self.fetchone()
|
|
3856
|
+
|
|
3857
|
+
if row is None:
|
|
3858
|
+
if logger.is_debug_enabled:
|
|
3859
|
+
logger.debug("fetchval: No value available (no rows)")
|
|
3860
|
+
return None
|
|
3861
|
+
|
|
3862
|
+
if logger.is_debug_enabled:
|
|
3863
|
+
logger.debug("fetchval: Value retrieved successfully")
|
|
3864
|
+
return row[0]
|
|
3865
|
+
|
|
3866
|
+
def commit(self):
|
|
3867
|
+
"""
|
|
3868
|
+
Commit all SQL statements executed on the connection that created this cursor.
|
|
3869
|
+
|
|
3870
|
+
This is a convenience method that calls commit() on the underlying connection.
|
|
3871
|
+
It affects all cursors created by the same connection since the last commit/rollback.
|
|
3872
|
+
|
|
3873
|
+
The benefit is that many uses can now just use the cursor and not have to track
|
|
3874
|
+
the connection object.
|
|
3875
|
+
|
|
3876
|
+
Raises:
|
|
3877
|
+
Exception: If the cursor is closed or if the commit operation fails.
|
|
3878
|
+
|
|
3879
|
+
Example:
|
|
3880
|
+
>>> cursor.execute("INSERT INTO users (name) VALUES (?)", "John")
|
|
3881
|
+
>>> cursor.commit() # Commits the INSERT
|
|
3882
|
+
|
|
3883
|
+
Note:
|
|
3884
|
+
This is equivalent to calling connection.commit() but provides convenience
|
|
3885
|
+
for code that only has access to the cursor object.
|
|
3886
|
+
"""
|
|
3887
|
+
self._check_closed() # Check if the cursor is closed
|
|
3888
|
+
|
|
3889
|
+
# Clear messages per DBAPI
|
|
3890
|
+
self.messages = []
|
|
3891
|
+
|
|
3892
|
+
# Delegate to the connection's commit method
|
|
3893
|
+
self._connection.commit()
|
|
3894
|
+
|
|
3895
|
+
def rollback(self):
|
|
3896
|
+
"""
|
|
3897
|
+
Roll back all SQL statements executed on the connection that created this cursor.
|
|
3898
|
+
|
|
3899
|
+
This is a convenience method that calls rollback() on the underlying connection.
|
|
3900
|
+
It affects all cursors created by the same connection since the last commit/rollback.
|
|
3901
|
+
|
|
3902
|
+
The benefit is that many uses can now just use the cursor and not have to track
|
|
3903
|
+
the connection object.
|
|
3904
|
+
|
|
3905
|
+
Raises:
|
|
3906
|
+
Exception: If the cursor is closed or if the rollback operation fails.
|
|
3907
|
+
|
|
3908
|
+
Example:
|
|
3909
|
+
>>> cursor.execute("INSERT INTO users (name) VALUES (?)", "John")
|
|
3910
|
+
>>> cursor.rollback() # Rolls back the INSERT
|
|
3911
|
+
|
|
3912
|
+
Note:
|
|
3913
|
+
This is equivalent to calling connection.rollback() but provides convenience
|
|
3914
|
+
for code that only has access to the cursor object.
|
|
3915
|
+
"""
|
|
3916
|
+
self._check_closed() # Check if the cursor is closed
|
|
3917
|
+
|
|
3918
|
+
# Clear messages per DBAPI
|
|
3919
|
+
self.messages = []
|
|
3920
|
+
|
|
3921
|
+
# Delegate to the connection's rollback method
|
|
3922
|
+
self._connection.rollback()
|
|
3923
|
+
|
|
3924
|
+
def __del__(self):
|
|
3925
|
+
"""
|
|
3926
|
+
Destructor to ensure the cursor is closed when it is no longer needed.
|
|
3927
|
+
This is a safety net to ensure resources are cleaned up
|
|
3928
|
+
even if close() was not called explicitly.
|
|
3929
|
+
If the cursor is already closed, it will not raise an exception during cleanup.
|
|
3930
|
+
"""
|
|
3931
|
+
if "closed" not in self.__dict__ or not self.closed:
|
|
3932
|
+
try:
|
|
3933
|
+
self.close()
|
|
3934
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
3935
|
+
# Don't raise an exception in __del__, just log it
|
|
3936
|
+
# If interpreter is shutting down, we might not have logging set up
|
|
3937
|
+
import sys
|
|
3938
|
+
|
|
3939
|
+
if sys and sys.is_finalizing():
|
|
3940
|
+
# Suppress logging during interpreter shutdown
|
|
3941
|
+
return
|
|
3942
|
+
# ``logger`` could be torn down or have its handlers closed
|
|
3943
|
+
# late in interpreter shutdown; guard so the debug call
|
|
3944
|
+
# cannot itself raise an unraisable exception.
|
|
3945
|
+
if logger is not None:
|
|
3946
|
+
logger.debug("Exception during cursor cleanup in __del__: %s", e)
|
|
3947
|
+
|
|
3948
|
+
def scroll(
|
|
3949
|
+
self, value: int, mode: str = "relative"
|
|
3950
|
+
) -> None: # pylint: disable=too-many-branches
|
|
3951
|
+
"""
|
|
3952
|
+
Scroll using SQLFetchScroll only, matching test semantics:
|
|
3953
|
+
- relative(N>0): consume N rows; rownumber = previous + N;
|
|
3954
|
+
next fetch returns the following row.
|
|
3955
|
+
- absolute(-1): before first (rownumber = -1), no data consumed.
|
|
3956
|
+
- absolute(0): position so next fetch returns first row;
|
|
3957
|
+
rownumber stays 0 even after that fetch.
|
|
3958
|
+
- absolute(k>0): next fetch returns row index k (0-based);
|
|
3959
|
+
rownumber == k after scroll.
|
|
3960
|
+
"""
|
|
3961
|
+
logger.debug(
|
|
3962
|
+
"scroll: Scrolling cursor - mode=%s, value=%d, current_rownumber=%d",
|
|
3963
|
+
mode,
|
|
3964
|
+
value,
|
|
3965
|
+
self._rownumber,
|
|
3966
|
+
)
|
|
3967
|
+
self._check_closed()
|
|
3968
|
+
|
|
3969
|
+
# Clear messages per DBAPI
|
|
3970
|
+
self.messages = []
|
|
3971
|
+
|
|
3972
|
+
if mode not in ("relative", "absolute"):
|
|
3973
|
+
logger.error("scroll: Invalid mode - mode=%s", mode)
|
|
3974
|
+
raise ProgrammingError(
|
|
3975
|
+
"Invalid scroll mode",
|
|
3976
|
+
f"mode must be 'relative' or 'absolute', got '{mode}'",
|
|
3977
|
+
)
|
|
3978
|
+
if not self._has_result_set:
|
|
3979
|
+
logger.error("scroll: No active result set")
|
|
3980
|
+
raise ProgrammingError(
|
|
3981
|
+
"No active result set",
|
|
3982
|
+
"Cannot scroll: no result set available. Execute a query first.",
|
|
3983
|
+
)
|
|
3984
|
+
if not isinstance(value, int):
|
|
3985
|
+
logger.error("scroll: Invalid value type - type=%s", type(value).__name__)
|
|
3986
|
+
raise ProgrammingError(
|
|
3987
|
+
"Invalid scroll value type",
|
|
3988
|
+
f"scroll value must be an integer, got {type(value).__name__}",
|
|
3989
|
+
)
|
|
3990
|
+
|
|
3991
|
+
# Relative backward not supported
|
|
3992
|
+
if mode == "relative" and value < 0:
|
|
3993
|
+
logger.error("scroll: Backward scrolling not supported - value=%d", value)
|
|
3994
|
+
raise NotSupportedError(
|
|
3995
|
+
"Backward scrolling not supported",
|
|
3996
|
+
f"Cannot move backward by {value} rows on a forward-only cursor",
|
|
3997
|
+
)
|
|
3998
|
+
|
|
3999
|
+
row_data: list = []
|
|
4000
|
+
|
|
4001
|
+
# Absolute positioning not supported with forward-only cursors
|
|
4002
|
+
if mode == "absolute":
|
|
4003
|
+
raise NotSupportedError(
|
|
4004
|
+
"Absolute positioning not supported",
|
|
4005
|
+
"Forward-only cursors do not support absolute positioning",
|
|
4006
|
+
)
|
|
4007
|
+
|
|
4008
|
+
try:
|
|
4009
|
+
if mode == "relative":
|
|
4010
|
+
if value == 0:
|
|
4011
|
+
return
|
|
4012
|
+
|
|
4013
|
+
# For forward-only cursors, use multiple SQL_FETCH_NEXT calls
|
|
4014
|
+
# This matches pyodbc's approach for skip operations
|
|
4015
|
+
for i in range(value):
|
|
4016
|
+
ret = ddbc_bindings.DDBCSQLFetchScroll(
|
|
4017
|
+
self.hstmt, ddbc_sql_const.SQL_FETCH_NEXT.value, 0, row_data
|
|
4018
|
+
)
|
|
4019
|
+
if ret == ddbc_sql_const.SQL_NO_DATA.value:
|
|
4020
|
+
raise IndexError(
|
|
4021
|
+
"Cannot scroll to specified position: end of result set reached"
|
|
4022
|
+
)
|
|
4023
|
+
|
|
4024
|
+
# Update position tracking
|
|
4025
|
+
self._rownumber = self._rownumber + value
|
|
4026
|
+
self._next_row_index = self._rownumber + 1
|
|
4027
|
+
logger.debug(
|
|
4028
|
+
"scroll: Scroll complete - new_rownumber=%d, next_row_index=%d",
|
|
4029
|
+
self._rownumber,
|
|
4030
|
+
self._next_row_index,
|
|
4031
|
+
)
|
|
4032
|
+
return
|
|
4033
|
+
|
|
4034
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
4035
|
+
if isinstance(e, (IndexError, NotSupportedError)):
|
|
4036
|
+
raise
|
|
4037
|
+
raise IndexError(f"Scroll operation failed: {e}") from e
|
|
4038
|
+
|
|
4039
|
+
def skip(self, count: int) -> None:
|
|
4040
|
+
"""
|
|
4041
|
+
Skip the next count records in the query result set.
|
|
4042
|
+
|
|
4043
|
+
Args:
|
|
4044
|
+
count: Number of records to skip.
|
|
4045
|
+
|
|
4046
|
+
Raises:
|
|
4047
|
+
IndexError: If attempting to skip past the end of the result set.
|
|
4048
|
+
ProgrammingError: If count is not an integer.
|
|
4049
|
+
NotSupportedError: If attempting to skip backwards.
|
|
4050
|
+
"""
|
|
4051
|
+
self._check_closed()
|
|
4052
|
+
|
|
4053
|
+
# Clear messages
|
|
4054
|
+
self.messages = []
|
|
4055
|
+
|
|
4056
|
+
# Simply delegate to the scroll method with 'relative' mode
|
|
4057
|
+
self.scroll(count, "relative")
|
|
4058
|
+
|
|
4059
|
+
def _execute_tables( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
4060
|
+
self,
|
|
4061
|
+
stmt_handle,
|
|
4062
|
+
catalog_name=None,
|
|
4063
|
+
schema_name=None,
|
|
4064
|
+
table_name=None,
|
|
4065
|
+
table_type=None,
|
|
4066
|
+
):
|
|
4067
|
+
"""
|
|
4068
|
+
Execute SQLTables ODBC function to retrieve table metadata.
|
|
4069
|
+
|
|
4070
|
+
Args:
|
|
4071
|
+
stmt_handle: ODBC statement handle
|
|
4072
|
+
catalog_name: The catalog name pattern
|
|
4073
|
+
schema_name: The schema name pattern
|
|
4074
|
+
table_name: The table name pattern
|
|
4075
|
+
table_type: The table type filter
|
|
4076
|
+
search_escape: The escape character for pattern matching
|
|
4077
|
+
"""
|
|
4078
|
+
# Convert None values to empty strings for ODBC
|
|
4079
|
+
catalog = "" if catalog_name is None else catalog_name
|
|
4080
|
+
schema = "" if schema_name is None else schema_name
|
|
4081
|
+
table = "" if table_name is None else table_name
|
|
4082
|
+
types = "" if table_type is None else table_type
|
|
4083
|
+
|
|
4084
|
+
# Call the ODBC SQLTables function
|
|
4085
|
+
retcode = ddbc_bindings.DDBCSQLTables(stmt_handle, catalog, schema, table, types)
|
|
4086
|
+
|
|
4087
|
+
# Check return code and handle errors
|
|
4088
|
+
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, stmt_handle, retcode)
|
|
4089
|
+
|
|
4090
|
+
# Capture any diagnostic messages
|
|
4091
|
+
if stmt_handle and retcode in (
|
|
4092
|
+
ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value,
|
|
4093
|
+
ddbc_sql_const.SQL_NO_DATA.value,
|
|
4094
|
+
):
|
|
4095
|
+
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(stmt_handle))
|
|
4096
|
+
|
|
4097
|
+
def tables(
|
|
4098
|
+
self, table=None, catalog=None, schema=None, tableType=None
|
|
4099
|
+
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
4100
|
+
"""
|
|
4101
|
+
Returns information about tables in the database that match the given criteria using
|
|
4102
|
+
the SQLTables ODBC function.
|
|
4103
|
+
|
|
4104
|
+
Args:
|
|
4105
|
+
table (str, optional): The table name pattern. Default is None (all tables).
|
|
4106
|
+
catalog (str, optional): The catalog name. Default is None.
|
|
4107
|
+
schema (str, optional): The schema name pattern. Default is None.
|
|
4108
|
+
tableType (str or list, optional): The table type filter. Default is None.
|
|
4109
|
+
Example: "TABLE" or ["TABLE", "VIEW"]
|
|
4110
|
+
|
|
4111
|
+
Returns:
|
|
4112
|
+
Cursor: The cursor object itself for method chaining with fetch methods.
|
|
4113
|
+
"""
|
|
4114
|
+
self._check_closed()
|
|
4115
|
+
self._reset_cursor()
|
|
4116
|
+
|
|
4117
|
+
# Format table_type parameter - SQLTables expects comma-separated string
|
|
4118
|
+
table_type_str = None
|
|
4119
|
+
if tableType is not None:
|
|
4120
|
+
if isinstance(tableType, (list, tuple)):
|
|
4121
|
+
table_type_str = ",".join(tableType)
|
|
4122
|
+
else:
|
|
4123
|
+
table_type_str = str(tableType)
|
|
4124
|
+
|
|
4125
|
+
try:
|
|
4126
|
+
# Call SQLTables via the helper method
|
|
4127
|
+
self._execute_tables(
|
|
4128
|
+
self.hstmt,
|
|
4129
|
+
catalog_name=catalog,
|
|
4130
|
+
schema_name=schema,
|
|
4131
|
+
table_name=table,
|
|
4132
|
+
table_type=table_type_str,
|
|
4133
|
+
)
|
|
4134
|
+
|
|
4135
|
+
# Define fallback description for tables
|
|
4136
|
+
fallback_description = [
|
|
4137
|
+
("table_cat", str, None, 128, 128, 0, True),
|
|
4138
|
+
("table_schem", str, None, 128, 128, 0, True),
|
|
4139
|
+
("table_name", str, None, 128, 128, 0, False),
|
|
4140
|
+
("table_type", str, None, 128, 128, 0, False),
|
|
4141
|
+
("remarks", str, None, 254, 254, 0, True),
|
|
4142
|
+
]
|
|
4143
|
+
|
|
4144
|
+
# Use the helper method to prepare the result set
|
|
4145
|
+
return self._prepare_metadata_result_set(fallback_description=fallback_description)
|
|
4146
|
+
|
|
4147
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
4148
|
+
# Log the error and re-raise
|
|
4149
|
+
logger.error(f"Error executing tables query: {e}")
|
|
4150
|
+
raise
|
|
4151
|
+
|
|
4152
|
+
def callproc(
|
|
4153
|
+
self, procname: str, parameters: Optional[Sequence[Any]] = None
|
|
4154
|
+
) -> Optional[Sequence[Any]]:
|
|
4155
|
+
"""
|
|
4156
|
+
Call a stored database procedure with the given name.
|
|
4157
|
+
|
|
4158
|
+
Args:
|
|
4159
|
+
procname: Name of the stored procedure to call
|
|
4160
|
+
parameters: Optional sequence of parameters to pass to the procedure
|
|
4161
|
+
|
|
4162
|
+
Returns:
|
|
4163
|
+
A sequence containing the result parameters (input parameters unchanged,
|
|
4164
|
+
output parameters with their new values)
|
|
4165
|
+
|
|
4166
|
+
Raises:
|
|
4167
|
+
NotSupportedError: This method is not yet implemented
|
|
4168
|
+
"""
|
|
4169
|
+
raise NotSupportedError(
|
|
4170
|
+
driver_error="callproc() is not yet implemented",
|
|
4171
|
+
ddbc_error="Stored procedure calls are not currently supported",
|
|
4172
|
+
)
|
|
4173
|
+
|
|
4174
|
+
def setoutputsize(self, size: int, column: Optional[int] = None) -> None:
|
|
4175
|
+
"""
|
|
4176
|
+
Set a column buffer size for fetches of large columns.
|
|
4177
|
+
|
|
4178
|
+
This method is optional and is not implemented in this driver.
|
|
4179
|
+
|
|
4180
|
+
Args:
|
|
4181
|
+
size: Maximum size of the column buffer
|
|
4182
|
+
column: Optional column index (0-based) to set the size for
|
|
4183
|
+
|
|
4184
|
+
Note:
|
|
4185
|
+
This method is a no-op in this implementation as buffer sizes
|
|
4186
|
+
are managed automatically by the underlying driver.
|
|
4187
|
+
"""
|
|
4188
|
+
# This is a no-op - buffer sizes are managed automatically
|