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