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.
Files changed (45) hide show
  1. arrowsql/__init__.py +653 -0
  2. arrowsql/_rust_auth.py +170 -0
  3. arrowsql/_server_sqlstate.py +11 -0
  4. arrowsql/_utf8_strings.py +78 -0
  5. arrowsql/agent.py +620 -0
  6. arrowsql/aio.py +533 -0
  7. arrowsql/arrow_parallel.py +464 -0
  8. arrowsql/async_query/__init__.py +40 -0
  9. arrowsql/async_query/_connection_context.py +39 -0
  10. arrowsql/async_query/_native.py +24 -0
  11. arrowsql/async_query/async_connection.py +160 -0
  12. arrowsql/async_query/async_cursor.py +225 -0
  13. arrowsql/async_query/async_execute.py +113 -0
  14. arrowsql/async_query/async_fetch.py +153 -0
  15. arrowsql/async_query/exception_translator.py +132 -0
  16. arrowsql/auth.py +913 -0
  17. arrowsql/bulk_parallel.py +385 -0
  18. arrowsql/connection.py +2377 -0
  19. arrowsql/connection_string_builder.py +114 -0
  20. arrowsql/connection_string_parser.py +417 -0
  21. arrowsql/constants.py +701 -0
  22. arrowsql/cursor.py +4516 -0
  23. arrowsql/db_connection.py +98 -0
  24. arrowsql/ddbc_bindings.cp313-win_amd64.pyd +0 -0
  25. arrowsql/ddbc_bindings.pyi +283 -0
  26. arrowsql/decimal_config.py +98 -0
  27. arrowsql/exceptions.py +378 -0
  28. arrowsql/helpers.py +436 -0
  29. arrowsql/logging.py +700 -0
  30. arrowsql/mssql_python.pyi +579 -0
  31. arrowsql/odbc_provider.py +269 -0
  32. arrowsql/parameter_helper.py +442 -0
  33. arrowsql/perf_timer.py +286 -0
  34. arrowsql/pooling.py +147 -0
  35. arrowsql/py.typed +1 -0
  36. arrowsql/row.py +462 -0
  37. arrowsql/type.py +160 -0
  38. arrowsql-0.1.0.dist-info/METADATA +146 -0
  39. arrowsql-0.1.0.dist-info/RECORD +45 -0
  40. arrowsql-0.1.0.dist-info/WHEEL +4 -0
  41. arrowsql-0.1.0.dist-info/entry_points.txt +2 -0
  42. arrowsql-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. arrowsql-0.1.0.dist-info/licenses/LICENSE.upstream-mssql-python +47 -0
  44. arrowsql-0.1.0.dist-info/sboms/ddbc_bindings.cyclonedx.json +5195 -0
  45. 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