mssql-python 1.16.0__cp315-cp315-win_arm64.whl

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