mssql-python 1.0.0__cp314-cp314-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. mssql_python/__init__.py +307 -0
  2. mssql_python/auth.py +222 -0
  3. mssql_python/connection.py +1493 -0
  4. mssql_python/connection_string_builder.py +113 -0
  5. mssql_python/connection_string_parser.py +381 -0
  6. mssql_python/constants.py +509 -0
  7. mssql_python/cursor.py +2601 -0
  8. mssql_python/db_connection.py +53 -0
  9. mssql_python/ddbc_bindings.cp314-amd64.pyd +0 -0
  10. mssql_python/ddbc_bindings.py +137 -0
  11. mssql_python/exceptions.py +574 -0
  12. mssql_python/helpers.py +357 -0
  13. mssql_python/libs/LICENSING +19 -0
  14. mssql_python/libs/linux/alpine/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  15. mssql_python/libs/linux/alpine/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  16. mssql_python/libs/linux/alpine/arm64/lib/libodbcinst.so.2 +0 -0
  17. mssql_python/libs/linux/alpine/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  18. mssql_python/libs/linux/alpine/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  19. mssql_python/libs/linux/alpine/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  20. mssql_python/libs/linux/alpine/x86_64/lib/libodbcinst.so.2 +0 -0
  21. mssql_python/libs/linux/alpine/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  22. mssql_python/libs/linux/debian_ubuntu/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  23. mssql_python/libs/linux/debian_ubuntu/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  24. mssql_python/libs/linux/debian_ubuntu/arm64/lib/libodbcinst.so.2 +0 -0
  25. mssql_python/libs/linux/debian_ubuntu/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  26. mssql_python/libs/linux/debian_ubuntu/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  27. mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  28. mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libodbcinst.so.2 +0 -0
  29. mssql_python/libs/linux/debian_ubuntu/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  30. mssql_python/libs/linux/rhel/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  31. mssql_python/libs/linux/rhel/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  32. mssql_python/libs/linux/rhel/arm64/lib/libodbcinst.so.2 +0 -0
  33. mssql_python/libs/linux/rhel/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  34. mssql_python/libs/linux/rhel/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  35. mssql_python/libs/linux/rhel/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  36. mssql_python/libs/linux/rhel/x86_64/lib/libodbcinst.so.2 +0 -0
  37. mssql_python/libs/linux/rhel/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  38. mssql_python/libs/linux/suse/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  39. mssql_python/libs/linux/suse/x86_64/lib/libodbcinst.so.2 +0 -0
  40. mssql_python/libs/linux/suse/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  41. mssql_python/libs/macos/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  42. mssql_python/libs/macos/arm64/lib/libltdl.7.dylib +0 -0
  43. mssql_python/libs/macos/arm64/lib/libmsodbcsql.18.dylib +0 -0
  44. mssql_python/libs/macos/arm64/lib/libodbc.2.dylib +0 -0
  45. mssql_python/libs/macos/arm64/lib/libodbcinst.2.dylib +0 -0
  46. mssql_python/libs/macos/arm64/share/doc/msodbcsql18/LICENSE.txt +101 -0
  47. mssql_python/libs/macos/arm64/share/doc/msodbcsql18/RELEASE_NOTES +465 -0
  48. mssql_python/libs/macos/arm64/share/msodbcsql18/resources/en_US/msodbcsqlr18.rll +0 -0
  49. mssql_python/libs/macos/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  50. mssql_python/libs/macos/x86_64/lib/libltdl.7.dylib +0 -0
  51. mssql_python/libs/macos/x86_64/lib/libmsodbcsql.18.dylib +0 -0
  52. mssql_python/libs/macos/x86_64/lib/libodbc.2.dylib +0 -0
  53. mssql_python/libs/macos/x86_64/lib/libodbcinst.2.dylib +0 -0
  54. mssql_python/libs/macos/x86_64/share/doc/msodbcsql18/LICENSE.txt +101 -0
  55. mssql_python/libs/macos/x86_64/share/doc/msodbcsql18/RELEASE_NOTES +465 -0
  56. mssql_python/libs/macos/x86_64/share/msodbcsql18/resources/en_US/msodbcsqlr18.rll +0 -0
  57. mssql_python/libs/windows/x64/1033/msodbcsqlr18.rll +0 -0
  58. mssql_python/libs/windows/x64/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  59. mssql_python/libs/windows/x64/MICROSOFT_VISUAL_STUDIO_LICENSE.txt +54 -0
  60. mssql_python/libs/windows/x64/msodbcdiag18.dll +0 -0
  61. mssql_python/libs/windows/x64/msodbcsql18.dll +0 -0
  62. mssql_python/libs/windows/x64/mssql-auth.dll +0 -0
  63. mssql_python/logging.py +610 -0
  64. mssql_python/mssql_python.pyi +375 -0
  65. mssql_python/msvcp140.dll +0 -0
  66. mssql_python/pooling.py +125 -0
  67. mssql_python/row.py +194 -0
  68. mssql_python/type.py +143 -0
  69. mssql_python-1.0.0.dist-info/METADATA +76 -0
  70. mssql_python-1.0.0.dist-info/RECORD +73 -0
  71. mssql_python-1.0.0.dist-info/WHEEL +5 -0
  72. mssql_python-1.0.0.dist-info/licenses/LICENSE +47 -0
  73. mssql_python-1.0.0.dist-info/top_level.txt +1 -0
mssql_python/cursor.py ADDED
@@ -0,0 +1,2601 @@
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
+ # pylint: disable=too-many-lines # Large file due to comprehensive DB-API 2.0 implementation
12
+
13
+ import decimal
14
+ import uuid
15
+ import datetime
16
+ import warnings
17
+ from typing import List, Union, Any, Optional, Tuple, Sequence, TYPE_CHECKING
18
+ from mssql_python.constants import ConstantsDDBC as ddbc_sql_const, SQLTypes
19
+ from mssql_python.helpers import check_error
20
+ from mssql_python.logging import logger
21
+ from mssql_python import ddbc_bindings
22
+ from mssql_python.exceptions import InterfaceError, NotSupportedError, ProgrammingError
23
+ from mssql_python.row import Row
24
+ from mssql_python import get_settings
25
+
26
+ if TYPE_CHECKING:
27
+ from mssql_python.connection import Connection
28
+
29
+ # Constants for string handling
30
+ MAX_INLINE_CHAR: int = (
31
+ 4000 # NVARCHAR/VARCHAR inline limit; this triggers NVARCHAR(MAX)/VARCHAR(MAX) + DAE
32
+ )
33
+ SMALLMONEY_MIN: decimal.Decimal = decimal.Decimal("-214748.3648")
34
+ SMALLMONEY_MAX: decimal.Decimal = decimal.Decimal("214748.3647")
35
+ MONEY_MIN: decimal.Decimal = decimal.Decimal("-922337203685477.5808")
36
+ MONEY_MAX: decimal.Decimal = decimal.Decimal("922337203685477.5807")
37
+
38
+
39
+ class Cursor: # pylint: disable=too-many-instance-attributes,too-many-public-methods
40
+ """
41
+ Represents a database cursor, which is used to manage the context of a fetch operation.
42
+
43
+ Attributes:
44
+ connection: Database connection object.
45
+ description: Sequence of 7-item sequences describing one result column.
46
+ rowcount: Number of rows produced or affected by the last execute operation.
47
+ arraysize: Number of rows to fetch at a time with fetchmany().
48
+ rownumber: Track the current row index in the result set.
49
+
50
+ Methods:
51
+ __init__(connection_str) -> None.
52
+ callproc(procname, parameters=None) ->
53
+ Modified copy of the input sequence with output parameters.
54
+ close() -> None.
55
+ execute(operation, parameters=None) -> Cursor.
56
+ executemany(operation, seq_of_parameters) -> None.
57
+ fetchone() -> Single sequence or None if no more data is available.
58
+ fetchmany(size=None) -> Sequence of sequences (e.g. list of tuples).
59
+ fetchall() -> Sequence of sequences (e.g. list of tuples).
60
+ nextset() -> True if there is another result set, None otherwise.
61
+ next() -> Fetch the next row from the cursor.
62
+ setinputsizes(sizes) -> None.
63
+ setoutputsize(size, column=None) -> None.
64
+ """
65
+
66
+ # TODO(jathakkar): Thread safety considerations
67
+ # The cursor class contains methods that are not thread-safe due to:
68
+ # 1. Methods that mutate cursor state (_reset_cursor, self.description, etc.)
69
+ # 2. Methods that call ODBC functions with shared handles (self.hstmt)
70
+ #
71
+ # These methods should be properly synchronized or redesigned when implementing
72
+ # async functionality to prevent race conditions and data corruption.
73
+ # Consider using locks, redesigning for immutability, or ensuring
74
+ # cursor objects are never shared across threads.
75
+
76
+ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
77
+ """
78
+ Initialize the cursor with a database connection.
79
+
80
+ Args:
81
+ connection: Database connection object.
82
+ timeout: Query timeout in seconds
83
+ """
84
+ self._connection: "Connection" = connection # Store as private attribute
85
+ self._timeout: int = timeout
86
+ self._inputsizes: Optional[List[Union[int, Tuple[Any, ...]]]] = None
87
+ # self.connection.autocommit = False
88
+ self.hstmt: Optional[Any] = None
89
+ self._initialize_cursor()
90
+ self.description: Optional[
91
+ List[
92
+ Tuple[
93
+ str,
94
+ Any,
95
+ Optional[int],
96
+ Optional[int],
97
+ Optional[int],
98
+ Optional[int],
99
+ Optional[bool],
100
+ ]
101
+ ]
102
+ ] = None
103
+ self.rowcount: int = -1
104
+ self.arraysize: int = (
105
+ 1 # Default number of rows to fetch at a time is 1, user can change it
106
+ )
107
+ self.buffer_length: int = 1024 # Default buffer length for string data
108
+ self.closed: bool = False
109
+ self._result_set_empty: bool = False # Add this initialization
110
+ self.last_executed_stmt: str = (
111
+ "" # Stores the last statement executed by this cursor
112
+ )
113
+ self.is_stmt_prepared: List[bool] = [
114
+ False
115
+ ] # Indicates if last_executed_stmt was prepared by ddbc shim.
116
+ # Is a list instead of a bool coz bools in Python are immutable.
117
+
118
+ # Initialize attributes that may be defined later to avoid pylint warnings
119
+ # Note: _original_fetch* methods are not initialized here as they need to be
120
+ # conditionally set based on hasattr() checks
121
+ # Hence, we can't pass around bools by reference & modify them.
122
+ # Therefore, it must be a list with exactly one bool element.
123
+
124
+ self._rownumber = -1 # DB-API extension: last returned row index, -1 before first
125
+
126
+ self._cached_column_map = None
127
+ self._cached_converter_map = None
128
+ self._next_row_index = 0 # internal: index of the next row the driver will return (0-based)
129
+ self._has_result_set = False # Track if we have an active result set
130
+ self._skip_increment_for_next_fetch = False # Track if we need to skip incrementing the row index
131
+ self.messages = [] # Store diagnostic messages
132
+
133
+ def _is_unicode_string(self, param: str) -> bool:
134
+ """
135
+ Check if a string contains non-ASCII characters.
136
+
137
+ Args:
138
+ param: The string to check.
139
+
140
+ Returns:
141
+ True if the string contains non-ASCII characters, False otherwise.
142
+ """
143
+ try:
144
+ param.encode("ascii")
145
+ return False # Can be encoded to ASCII, so not Unicode
146
+ except UnicodeEncodeError:
147
+ return True # Contains non-ASCII characters, so treat as Unicode
148
+
149
+ def _parse_date(self, param: str) -> Optional[datetime.date]:
150
+ """
151
+ Attempt to parse a string as a date.
152
+
153
+ Args:
154
+ param: The string to parse.
155
+
156
+ Returns:
157
+ A datetime.date object if parsing is successful, else None.
158
+ """
159
+ formats = ["%Y-%m-%d"]
160
+ for fmt in formats:
161
+ try:
162
+ return datetime.datetime.strptime(param, fmt).date()
163
+ except ValueError:
164
+ continue
165
+ return None
166
+
167
+ def _parse_datetime(self, param: str) -> Optional[datetime.datetime]:
168
+ """
169
+ Attempt to parse a string as a datetime, smalldatetime, datetime2, timestamp.
170
+
171
+ Args:
172
+ param: The string to parse.
173
+
174
+ Returns:
175
+ A datetime.datetime object if parsing is successful, else None.
176
+ """
177
+ formats = [
178
+ "%Y-%m-%dT%H:%M:%S.%f", # ISO 8601 datetime with fractional seconds
179
+ "%Y-%m-%dT%H:%M:%S", # ISO 8601 datetime
180
+ "%Y-%m-%d %H:%M:%S.%f", # Datetime with fractional seconds
181
+ "%Y-%m-%d %H:%M:%S", # Datetime without fractional seconds
182
+ ]
183
+ for fmt in formats:
184
+ try:
185
+ return datetime.datetime.strptime(param, fmt) # Valid datetime
186
+ except ValueError:
187
+ continue # Try next format
188
+
189
+ return None # If all formats fail, return None
190
+
191
+ def _parse_time(self, param: str) -> Optional[datetime.time]:
192
+ """
193
+ Attempt to parse a string as a time.
194
+
195
+ Args:
196
+ param: The string to parse.
197
+
198
+ Returns:
199
+ A datetime.time object if parsing is successful, else None.
200
+ """
201
+ formats = [
202
+ "%H:%M:%S", # Time only
203
+ "%H:%M:%S.%f", # Time with fractional seconds
204
+ ]
205
+ for fmt in formats:
206
+ try:
207
+ return datetime.datetime.strptime(param, fmt).time()
208
+ except ValueError:
209
+ continue
210
+ return None
211
+
212
+ def _get_numeric_data(self, param: decimal.Decimal) -> Any:
213
+ """
214
+ Get the data for a numeric parameter.
215
+
216
+ Args:
217
+ param: The numeric parameter.
218
+
219
+ Returns:
220
+ numeric_data: A NumericData struct containing
221
+ the numeric data.
222
+ """
223
+ decimal_as_tuple = param.as_tuple()
224
+ digits_tuple = decimal_as_tuple.digits
225
+ num_digits = len(digits_tuple)
226
+ exponent = decimal_as_tuple.exponent
227
+
228
+ # Handle special values (NaN, Infinity, etc.)
229
+ if isinstance(exponent, str):
230
+ # For special values like 'n' (NaN), 'N' (sNaN), 'F' (Infinity)
231
+ # Return default precision and scale
232
+ precision = 38 # SQL Server default max precision
233
+ scale = 0
234
+ else:
235
+ # Calculate the SQL precision & scale
236
+ # precision = no. of significant digits
237
+ # scale = no. digits after decimal point
238
+ if exponent >= 0:
239
+ # digits=314, exp=2 ---> '31400' --> precision=5, scale=0
240
+ precision = num_digits + exponent
241
+ scale = 0
242
+ elif (-1 * exponent) <= num_digits:
243
+ # digits=3140, exp=-3 ---> '3.140' --> precision=4, scale=3
244
+ precision = num_digits
245
+ scale = exponent * -1
246
+ else:
247
+ # digits=3140, exp=-5 ---> '0.03140' --> precision=5, scale=5
248
+ # TODO: double check the precision calculation here with SQL documentation
249
+ precision = exponent * -1
250
+ scale = exponent * -1
251
+
252
+ if precision > 38:
253
+ raise ValueError(
254
+ "Precision of the numeric value is too high - "
255
+ + str(param)
256
+ + ". Should be less than or equal to 38"
257
+ )
258
+ Numeric_Data = ddbc_bindings.NumericData
259
+ numeric_data = Numeric_Data()
260
+ numeric_data.scale = scale
261
+ numeric_data.precision = precision
262
+ numeric_data.sign = 1 if decimal_as_tuple.sign == 0 else 0
263
+ # strip decimal point from param & convert the significant digits to integer
264
+ # Ex: 12.34 ---> 1234
265
+ int_str = "".join(str(d) for d in digits_tuple)
266
+ if exponent > 0:
267
+ int_str = int_str + ("0" * exponent)
268
+ elif exponent < 0:
269
+ if -exponent > num_digits:
270
+ int_str = ("0" * (-exponent - num_digits)) + int_str
271
+
272
+ if int_str == "":
273
+ int_str = "0"
274
+
275
+ # Convert decimal base-10 string to python int, then to 16 little-endian bytes
276
+ big_int = int(int_str)
277
+ byte_array = bytearray(16) # SQL_MAX_NUMERIC_LEN
278
+ for i in range(16):
279
+ byte_array[i] = big_int & 0xFF
280
+ big_int >>= 8
281
+ if big_int == 0:
282
+ break
283
+
284
+ numeric_data.val = bytes(byte_array)
285
+ return numeric_data
286
+
287
+ def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-return-statements,too-many-branches
288
+ self,
289
+ param: Any,
290
+ parameters_list: List[Any],
291
+ i: int,
292
+ min_val: Optional[Any] = None,
293
+ max_val: Optional[Any] = None,
294
+ ) -> Tuple[int, int, int, int, bool]:
295
+ """
296
+ Map a Python data type to the corresponding SQL type,
297
+ C type, Column size, and Decimal digits.
298
+ Takes:
299
+ - param: The parameter to map.
300
+ - parameters_list: The list of parameters to bind.
301
+ - i: The index of the parameter in the list.
302
+ Returns:
303
+ - A tuple containing the SQL type, C type, column size, and decimal digits.
304
+ """
305
+ logger.debug('_map_sql_type: Mapping param index=%d, type=%s', i, type(param).__name__)
306
+ if param is None:
307
+ logger.debug('_map_sql_type: NULL parameter - index=%d', i)
308
+ return (
309
+ ddbc_sql_const.SQL_VARCHAR.value,
310
+ ddbc_sql_const.SQL_C_DEFAULT.value,
311
+ 1,
312
+ 0,
313
+ False,
314
+ )
315
+
316
+ if isinstance(param, bool):
317
+ logger.debug('_map_sql_type: BOOL detected - index=%d', i)
318
+ return (
319
+ ddbc_sql_const.SQL_BIT.value,
320
+ ddbc_sql_const.SQL_C_BIT.value,
321
+ 1,
322
+ 0,
323
+ False,
324
+ )
325
+
326
+ if isinstance(param, int):
327
+ # Use min_val/max_val if available
328
+ value_to_check = max_val if max_val is not None else param
329
+ min_to_check = min_val if min_val is not None else param
330
+ logger.debug('_map_sql_type: INT detected - index=%d, min=%s, max=%s',
331
+ i, str(min_to_check)[:50], str(value_to_check)[:50])
332
+
333
+ if 0 <= min_to_check and value_to_check <= 255:
334
+ logger.debug('_map_sql_type: INT -> TINYINT - index=%d', i)
335
+ return (
336
+ ddbc_sql_const.SQL_TINYINT.value,
337
+ ddbc_sql_const.SQL_C_TINYINT.value,
338
+ 3,
339
+ 0,
340
+ False,
341
+ )
342
+ if -32768 <= min_to_check and value_to_check <= 32767:
343
+ logger.debug('_map_sql_type: INT -> SMALLINT - index=%d', i)
344
+ return (
345
+ ddbc_sql_const.SQL_SMALLINT.value,
346
+ ddbc_sql_const.SQL_C_SHORT.value,
347
+ 5,
348
+ 0,
349
+ False,
350
+ )
351
+ if -2147483648 <= min_to_check and value_to_check <= 2147483647:
352
+ logger.debug('_map_sql_type: INT -> INTEGER - index=%d', i)
353
+ return (
354
+ ddbc_sql_const.SQL_INTEGER.value,
355
+ ddbc_sql_const.SQL_C_LONG.value,
356
+ 10,
357
+ 0,
358
+ False,
359
+ )
360
+ logger.debug('_map_sql_type: INT -> BIGINT - index=%d', i)
361
+ return (
362
+ ddbc_sql_const.SQL_BIGINT.value,
363
+ ddbc_sql_const.SQL_C_SBIGINT.value,
364
+ 19,
365
+ 0,
366
+ False,
367
+ )
368
+
369
+ if isinstance(param, float):
370
+ logger.debug('_map_sql_type: FLOAT detected - index=%d', i)
371
+ return (
372
+ ddbc_sql_const.SQL_DOUBLE.value,
373
+ ddbc_sql_const.SQL_C_DOUBLE.value,
374
+ 15,
375
+ 0,
376
+ False,
377
+ )
378
+
379
+ if isinstance(param, decimal.Decimal):
380
+ logger.debug('_map_sql_type: DECIMAL detected - index=%d', i)
381
+ # First check precision limit for all decimal values
382
+ decimal_as_tuple = param.as_tuple()
383
+ digits_tuple = decimal_as_tuple.digits
384
+ num_digits = len(digits_tuple)
385
+ exponent = decimal_as_tuple.exponent
386
+
387
+ # Handle special values (NaN, Infinity, etc.)
388
+ if isinstance(exponent, str):
389
+ logger.debug('_map_sql_type: DECIMAL special value - index=%d, exponent=%s', i, exponent)
390
+ # For special values like 'n' (NaN), 'N' (sNaN), 'F' (Infinity)
391
+ # Return default precision and scale
392
+ precision = 38 # SQL Server default max precision
393
+ else:
394
+ # Calculate the SQL precision (same logic as _get_numeric_data)
395
+ if exponent >= 0:
396
+ precision = num_digits + exponent
397
+ elif (-1 * exponent) <= num_digits:
398
+ precision = num_digits
399
+ else:
400
+ precision = exponent * -1
401
+ logger.debug('_map_sql_type: DECIMAL precision calculated - index=%d, precision=%d', i, precision)
402
+
403
+ if precision > 38:
404
+ logger.debug('_map_sql_type: DECIMAL precision too high - index=%d, precision=%d', i, precision)
405
+ raise ValueError(
406
+ f"Precision of the numeric value is too high. "
407
+ f"The maximum precision supported by SQL Server is 38, but got {precision}."
408
+ )
409
+
410
+ # Detect MONEY / SMALLMONEY range
411
+ if SMALLMONEY_MIN <= param <= SMALLMONEY_MAX:
412
+ logger.debug('_map_sql_type: DECIMAL -> SMALLMONEY - index=%d', i)
413
+ # smallmoney
414
+ parameters_list[i] = format(param, 'f')
415
+ return (
416
+ ddbc_sql_const.SQL_VARCHAR.value,
417
+ ddbc_sql_const.SQL_C_CHAR.value,
418
+ len(parameters_list[i]),
419
+ 0,
420
+ False,
421
+ )
422
+ if MONEY_MIN <= param <= MONEY_MAX:
423
+ logger.debug('_map_sql_type: DECIMAL -> MONEY - index=%d', i)
424
+ # money
425
+ parameters_list[i] = format(param, 'f')
426
+ return (
427
+ ddbc_sql_const.SQL_VARCHAR.value,
428
+ ddbc_sql_const.SQL_C_CHAR.value,
429
+ len(parameters_list[i]),
430
+ 0,
431
+ False,
432
+ )
433
+ # fallback to generic numeric binding
434
+ logger.debug('_map_sql_type: DECIMAL -> NUMERIC - index=%d', i)
435
+ parameters_list[i] = self._get_numeric_data(param)
436
+ logger.debug('_map_sql_type: NUMERIC created - index=%d, precision=%d, scale=%d',
437
+ i, parameters_list[i].precision, parameters_list[i].scale)
438
+ return (
439
+ ddbc_sql_const.SQL_NUMERIC.value,
440
+ ddbc_sql_const.SQL_C_NUMERIC.value,
441
+ parameters_list[i].precision,
442
+ parameters_list[i].scale,
443
+ False,
444
+ )
445
+
446
+ if isinstance(param, uuid.UUID):
447
+ logger.debug('_map_sql_type: UUID detected - index=%d', i)
448
+ parameters_list[i] = param.bytes_le
449
+ return (
450
+ ddbc_sql_const.SQL_GUID.value,
451
+ ddbc_sql_const.SQL_C_GUID.value,
452
+ 16,
453
+ 0,
454
+ False,
455
+ )
456
+
457
+ if isinstance(param, str):
458
+ logger.debug('_map_sql_type: STR detected - index=%d, length=%d', i, len(param))
459
+ if (
460
+ param.startswith("POINT")
461
+ or param.startswith("LINESTRING")
462
+ or param.startswith("POLYGON")
463
+ ):
464
+ logger.debug('_map_sql_type: STR is geometry type - index=%d', i)
465
+ return (
466
+ ddbc_sql_const.SQL_WVARCHAR.value,
467
+ ddbc_sql_const.SQL_C_WCHAR.value,
468
+ len(param),
469
+ 0,
470
+ False,
471
+ )
472
+
473
+ # String mapping logic here
474
+ is_unicode = self._is_unicode_string(param)
475
+
476
+ # Computes UTF-16 code units (handles surrogate pairs)
477
+ utf16_len = sum(2 if ord(c) > 0xFFFF else 1 for c in param)
478
+ logger.debug('_map_sql_type: STR analysis - index=%d, is_unicode=%s, utf16_len=%d',
479
+ i, str(is_unicode), utf16_len)
480
+ if utf16_len > MAX_INLINE_CHAR: # Long strings -> DAE
481
+ logger.debug('_map_sql_type: STR exceeds MAX_INLINE_CHAR, using DAE - index=%d', i)
482
+ if is_unicode:
483
+ return (
484
+ ddbc_sql_const.SQL_WVARCHAR.value,
485
+ ddbc_sql_const.SQL_C_WCHAR.value,
486
+ 0,
487
+ 0,
488
+ True,
489
+ )
490
+ return (
491
+ ddbc_sql_const.SQL_VARCHAR.value,
492
+ ddbc_sql_const.SQL_C_CHAR.value,
493
+ 0,
494
+ 0,
495
+ True,
496
+ )
497
+
498
+ # Short strings
499
+ if is_unicode:
500
+ return (
501
+ ddbc_sql_const.SQL_WVARCHAR.value,
502
+ ddbc_sql_const.SQL_C_WCHAR.value,
503
+ utf16_len,
504
+ 0,
505
+ False,
506
+ )
507
+ return (
508
+ ddbc_sql_const.SQL_VARCHAR.value,
509
+ ddbc_sql_const.SQL_C_CHAR.value,
510
+ len(param),
511
+ 0,
512
+ False,
513
+ )
514
+
515
+ if isinstance(param, (bytes, bytearray)):
516
+ length = len(param)
517
+ if length > 8000: # Use VARBINARY(MAX) for large blobs
518
+ return (
519
+ ddbc_sql_const.SQL_VARBINARY.value,
520
+ ddbc_sql_const.SQL_C_BINARY.value,
521
+ 0,
522
+ 0,
523
+ True,
524
+ )
525
+ # Small blobs → direct binding
526
+ return (
527
+ ddbc_sql_const.SQL_VARBINARY.value,
528
+ ddbc_sql_const.SQL_C_BINARY.value,
529
+ max(length, 1),
530
+ 0,
531
+ False,
532
+ )
533
+
534
+ if isinstance(param, datetime.datetime):
535
+ if param.tzinfo is not None:
536
+ # Timezone-aware datetime -> DATETIMEOFFSET
537
+ return (
538
+ ddbc_sql_const.SQL_DATETIMEOFFSET.value,
539
+ ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
540
+ 34,
541
+ 7,
542
+ False,
543
+ )
544
+ # Naive datetime -> TIMESTAMP
545
+ return (
546
+ ddbc_sql_const.SQL_TIMESTAMP.value,
547
+ ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
548
+ 26,
549
+ 6,
550
+ False,
551
+ )
552
+
553
+ if isinstance(param, datetime.date):
554
+ return (
555
+ ddbc_sql_const.SQL_DATE.value,
556
+ ddbc_sql_const.SQL_C_TYPE_DATE.value,
557
+ 10,
558
+ 0,
559
+ False,
560
+ )
561
+
562
+ if isinstance(param, datetime.time):
563
+ return (
564
+ ddbc_sql_const.SQL_TIME.value,
565
+ ddbc_sql_const.SQL_C_TYPE_TIME.value,
566
+ 8,
567
+ 0,
568
+ False,
569
+ )
570
+
571
+ # For safety: unknown/unhandled Python types should not silently go to SQL
572
+ raise TypeError(
573
+ "Unsupported parameter type: The driver cannot safely convert it to a SQL type."
574
+ )
575
+
576
+ def _initialize_cursor(self) -> None:
577
+ """
578
+ Initialize the DDBC statement handle.
579
+ """
580
+ self._allocate_statement_handle()
581
+
582
+ def _allocate_statement_handle(self) -> None:
583
+ """
584
+ Allocate the DDBC statement handle.
585
+ """
586
+ self.hstmt = self._connection._conn.alloc_statement_handle()
587
+
588
+ def _reset_cursor(self) -> None:
589
+ """
590
+ Reset the DDBC statement handle.
591
+ """
592
+ if self.hstmt:
593
+ self.hstmt.free()
594
+ self.hstmt = None
595
+ logger.debug( "SQLFreeHandle succeeded")
596
+
597
+ self._clear_rownumber()
598
+
599
+ # Reinitialize the statement handle
600
+ self._initialize_cursor()
601
+
602
+ def close(self) -> None:
603
+ """
604
+ Close the connection now (rather than whenever .__del__() is called).
605
+ Idempotent: subsequent calls have no effect and will be no-ops.
606
+
607
+ The cursor will be unusable from this point forward; an InterfaceError
608
+ will be raised if any operation (other than close) is attempted with the cursor.
609
+ This is a deviation from pyodbc, which raises an exception if the cursor is already closed.
610
+ """
611
+ if self.closed:
612
+ # Do nothing - not calling _check_closed() here since we want this to be idempotent
613
+ return
614
+
615
+ # Clear messages per DBAPI
616
+ self.messages = []
617
+
618
+ # Remove this cursor from the connection's tracking
619
+ if (
620
+ hasattr(self, "connection")
621
+ and self.connection
622
+ and hasattr(self.connection, "_cursors")
623
+ ):
624
+ try:
625
+ self.connection._cursors.discard(self)
626
+ except Exception as e: # pylint: disable=broad-exception-caught
627
+ logger.warning( "Error removing cursor from connection tracking: %s", e)
628
+
629
+ if self.hstmt:
630
+ self.hstmt.free()
631
+ self.hstmt = None
632
+ logger.debug( "SQLFreeHandle succeeded")
633
+ self._clear_rownumber()
634
+ self.closed = True
635
+
636
+ def _check_closed(self) -> None:
637
+ """
638
+ Check if the cursor is closed and raise an exception if it is.
639
+
640
+ Raises:
641
+ ProgrammingError: If the cursor is closed.
642
+ """
643
+ if self.closed:
644
+ raise ProgrammingError(
645
+ driver_error="Operation cannot be performed: The cursor is closed.",
646
+ ddbc_error="",
647
+ )
648
+
649
+ def setinputsizes(self, sizes: List[Union[int, tuple]]) -> None:
650
+ """
651
+ Sets the type information to be used for parameters in execute and executemany.
652
+
653
+ This method can be used to explicitly declare the types and sizes of query parameters.
654
+ For example:
655
+
656
+ sql = "INSERT INTO product (item, price) VALUES (?, ?)"
657
+ params = [('bicycle', 499.99), ('ham', 17.95)]
658
+ # specify that parameters are for NVARCHAR(50) and DECIMAL(18,4) columns
659
+ cursor.setinputsizes([(SQL_WVARCHAR, 50, 0), (SQL_DECIMAL, 18, 4)])
660
+ cursor.executemany(sql, params)
661
+
662
+ Args:
663
+ sizes: A sequence of tuples, one for each parameter. Each tuple contains
664
+ (sql_type, size, decimal_digits) where size and decimal_digits are optional.
665
+ """
666
+
667
+ # Get valid SQL types from centralized constants
668
+ valid_sql_types = SQLTypes.get_valid_types()
669
+
670
+ self._inputsizes = []
671
+
672
+ if sizes:
673
+ for size_info in sizes:
674
+ if isinstance(size_info, tuple):
675
+ # Handle tuple format (sql_type, size, decimal_digits)
676
+ if len(size_info) == 1:
677
+ sql_type = size_info[0]
678
+ column_size = 0
679
+ decimal_digits = 0
680
+ elif len(size_info) == 2:
681
+ sql_type, column_size = size_info
682
+ decimal_digits = 0
683
+ elif len(size_info) >= 3:
684
+ sql_type, column_size, decimal_digits = size_info
685
+
686
+ # Validate SQL type
687
+ if not isinstance(sql_type, int) or sql_type not in valid_sql_types:
688
+ raise ValueError(
689
+ f"Invalid SQL type: {sql_type}. Must be a valid SQL type constant."
690
+ )
691
+
692
+ # Validate size and precision
693
+ if not isinstance(column_size, int) or column_size < 0:
694
+ raise ValueError(
695
+ f"Invalid column size: {column_size}. Must be a non-negative integer."
696
+ )
697
+
698
+ if not isinstance(decimal_digits, int) or decimal_digits < 0:
699
+ raise ValueError(
700
+ f"Invalid decimal digits: {decimal_digits}. "
701
+ f"Must be a non-negative integer."
702
+ )
703
+
704
+ self._inputsizes.append((sql_type, column_size, decimal_digits))
705
+ else:
706
+ # Handle single value (just sql_type)
707
+ sql_type = size_info
708
+
709
+ # Validate SQL type
710
+ if not isinstance(sql_type, int) or sql_type not in valid_sql_types:
711
+ raise ValueError(
712
+ f"Invalid SQL type: {sql_type}. Must be a valid SQL type constant."
713
+ )
714
+
715
+ self._inputsizes.append((sql_type, 0, 0))
716
+
717
+ def _reset_inputsizes(self) -> None:
718
+ """Reset input sizes after execution"""
719
+ self._inputsizes = None
720
+
721
+ def _get_c_type_for_sql_type(self, sql_type: int) -> int:
722
+ """Map SQL type to appropriate C type for parameter binding"""
723
+ sql_to_c_type = {
724
+ ddbc_sql_const.SQL_CHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
725
+ ddbc_sql_const.SQL_VARCHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
726
+ ddbc_sql_const.SQL_LONGVARCHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
727
+ ddbc_sql_const.SQL_WCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value,
728
+ ddbc_sql_const.SQL_WVARCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value,
729
+ ddbc_sql_const.SQL_WLONGVARCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value,
730
+ ddbc_sql_const.SQL_DECIMAL.value: ddbc_sql_const.SQL_C_NUMERIC.value,
731
+ ddbc_sql_const.SQL_NUMERIC.value: ddbc_sql_const.SQL_C_NUMERIC.value,
732
+ ddbc_sql_const.SQL_BIT.value: ddbc_sql_const.SQL_C_BIT.value,
733
+ ddbc_sql_const.SQL_TINYINT.value: ddbc_sql_const.SQL_C_TINYINT.value,
734
+ ddbc_sql_const.SQL_SMALLINT.value: ddbc_sql_const.SQL_C_SHORT.value,
735
+ ddbc_sql_const.SQL_INTEGER.value: ddbc_sql_const.SQL_C_LONG.value,
736
+ ddbc_sql_const.SQL_BIGINT.value: ddbc_sql_const.SQL_C_SBIGINT.value,
737
+ ddbc_sql_const.SQL_REAL.value: ddbc_sql_const.SQL_C_FLOAT.value,
738
+ ddbc_sql_const.SQL_FLOAT.value: ddbc_sql_const.SQL_C_DOUBLE.value,
739
+ ddbc_sql_const.SQL_DOUBLE.value: ddbc_sql_const.SQL_C_DOUBLE.value,
740
+ ddbc_sql_const.SQL_BINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
741
+ ddbc_sql_const.SQL_VARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
742
+ ddbc_sql_const.SQL_LONGVARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
743
+ ddbc_sql_const.SQL_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
744
+ ddbc_sql_const.SQL_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
745
+ ddbc_sql_const.SQL_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
746
+ }
747
+ return sql_to_c_type.get(sql_type, ddbc_sql_const.SQL_C_DEFAULT.value)
748
+
749
+ def _create_parameter_types_list( # pylint: disable=too-many-arguments,too-many-positional-arguments
750
+ self,
751
+ parameter: Any,
752
+ param_info: Optional[Tuple[Any, ...]],
753
+ parameters_list: List[Any],
754
+ i: int,
755
+ min_val: Optional[Any] = None,
756
+ max_val: Optional[Any] = None,
757
+ ) -> Tuple[int, int, int, int, bool]:
758
+ """
759
+ Maps parameter types for the given parameter.
760
+ Args:
761
+ parameter: parameter to bind.
762
+ Returns:
763
+ paraminfo.
764
+ """
765
+ paraminfo = param_info()
766
+
767
+ # Check if we have explicit type information from setinputsizes
768
+ if self._inputsizes and i < len(self._inputsizes):
769
+ # Use explicit type information
770
+ sql_type, column_size, decimal_digits = self._inputsizes[i]
771
+
772
+ # Default is_dae to False for explicit types, but set to True for large strings/binary
773
+ is_dae = False
774
+
775
+ if parameter is None:
776
+ # For NULL parameters, always use SQL_C_DEFAULT regardless of SQL type
777
+ c_type = ddbc_sql_const.SQL_C_DEFAULT.value
778
+ else:
779
+ # For non-NULL parameters, determine the appropriate C type based on SQL type
780
+ c_type = self._get_c_type_for_sql_type(sql_type)
781
+
782
+ # Check if this should be a DAE (data at execution) parameter
783
+ # For string types with large column sizes
784
+ if isinstance(parameter, str) and column_size > MAX_INLINE_CHAR:
785
+ is_dae = True
786
+ # For binary types with large column sizes
787
+ elif isinstance(parameter, (bytes, bytearray)) and column_size > 8000:
788
+ is_dae = True
789
+
790
+ # Sanitize precision/scale for numeric types
791
+ if sql_type in (
792
+ ddbc_sql_const.SQL_DECIMAL.value,
793
+ ddbc_sql_const.SQL_NUMERIC.value,
794
+ ):
795
+ column_size = max(
796
+ 1, min(int(column_size) if column_size > 0 else 18, 38)
797
+ )
798
+ decimal_digits = min(max(0, decimal_digits), column_size)
799
+
800
+ else:
801
+ # Fall back to automatic type inference
802
+ sql_type, c_type, column_size, decimal_digits, is_dae = self._map_sql_type(
803
+ parameter, parameters_list, i, min_val=min_val, max_val=max_val
804
+ )
805
+
806
+ paraminfo.paramCType = c_type
807
+ paraminfo.paramSQLType = sql_type
808
+ paraminfo.inputOutputType = ddbc_sql_const.SQL_PARAM_INPUT.value
809
+ paraminfo.columnSize = column_size
810
+ paraminfo.decimalDigits = decimal_digits
811
+ paraminfo.isDAE = is_dae
812
+
813
+ if is_dae:
814
+ paraminfo.dataPtr = parameter # Will be converted to py::object* in C++
815
+
816
+ return paraminfo
817
+
818
+ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None:
819
+ """Initialize the description attribute from column metadata."""
820
+ if not column_metadata:
821
+ self.description = None
822
+ return
823
+
824
+ description = []
825
+ for _, col in enumerate(column_metadata):
826
+ # Get column name - lowercase it if the lowercase flag is set
827
+ column_name = col["ColumnName"]
828
+
829
+ # Use the current global setting to ensure tests pass correctly
830
+ if get_settings().lowercase:
831
+ column_name = column_name.lower()
832
+
833
+ # Add to description tuple (7 elements as per PEP-249)
834
+ description.append(
835
+ (
836
+ column_name, # name
837
+ self._map_data_type(col["DataType"]), # type_code
838
+ None, # display_size
839
+ col["ColumnSize"], # internal_size
840
+ col["ColumnSize"], # precision - should match ColumnSize
841
+ col["DecimalDigits"], # scale
842
+ col["Nullable"] == ddbc_sql_const.SQL_NULLABLE.value, # null_ok
843
+ )
844
+ )
845
+ self.description = description
846
+
847
+ def _build_converter_map(self):
848
+ """
849
+ Build a pre-computed converter map for output converters.
850
+ Returns a list where each element is either a converter function or None.
851
+ This eliminates the need to look up converters for every row.
852
+ """
853
+ if not self.description or not hasattr(self.connection, '_output_converters') or not self.connection._output_converters:
854
+ return None
855
+
856
+ converter_map = []
857
+
858
+ for desc in self.description:
859
+ if desc is None:
860
+ converter_map.append(None)
861
+ continue
862
+ sql_type = desc[1]
863
+ converter = self.connection.get_output_converter(sql_type)
864
+ # If no converter found for the SQL type, try the WVARCHAR converter as a fallback
865
+ if converter is None:
866
+ from mssql_python.constants import ConstantsDDBC
867
+ converter = self.connection.get_output_converter(ConstantsDDBC.SQL_WVARCHAR.value)
868
+
869
+ converter_map.append(converter)
870
+
871
+ return converter_map
872
+
873
+ def _get_column_and_converter_maps(self):
874
+ """
875
+ Get column map and converter map for Row construction (thread-safe).
876
+ This centralizes the column map building logic to eliminate duplication
877
+ and ensure thread-safe lazy initialization.
878
+
879
+ Returns:
880
+ tuple: (column_map, converter_map)
881
+ """
882
+ # Thread-safe lazy initialization of column map
883
+ column_map = self._cached_column_map
884
+ if column_map is None and self.description:
885
+ # Build column map locally first, then assign to cache
886
+ column_map = {col_desc[0]: i for i, col_desc in enumerate(self.description)}
887
+ self._cached_column_map = column_map
888
+
889
+ # Fallback to legacy column name map if no cached map
890
+ column_map = column_map or getattr(self, '_column_name_map', None)
891
+
892
+ # Get cached converter map
893
+ converter_map = getattr(self, '_cached_converter_map', None)
894
+
895
+ return column_map, converter_map
896
+
897
+ def _map_data_type(self, sql_type):
898
+ """
899
+ Map SQL data type to Python data type.
900
+
901
+ Args:
902
+ sql_type: SQL data type.
903
+
904
+ Returns:
905
+ Corresponding Python data type.
906
+ """
907
+ sql_to_python_type = {
908
+ ddbc_sql_const.SQL_INTEGER.value: int,
909
+ ddbc_sql_const.SQL_VARCHAR.value: str,
910
+ ddbc_sql_const.SQL_WVARCHAR.value: str,
911
+ ddbc_sql_const.SQL_CHAR.value: str,
912
+ ddbc_sql_const.SQL_WCHAR.value: str,
913
+ ddbc_sql_const.SQL_FLOAT.value: float,
914
+ ddbc_sql_const.SQL_DOUBLE.value: float,
915
+ ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
916
+ ddbc_sql_const.SQL_NUMERIC.value: decimal.Decimal,
917
+ ddbc_sql_const.SQL_DATE.value: datetime.date,
918
+ ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime,
919
+ ddbc_sql_const.SQL_TIME.value: datetime.time,
920
+ ddbc_sql_const.SQL_BIT.value: bool,
921
+ ddbc_sql_const.SQL_TINYINT.value: int,
922
+ ddbc_sql_const.SQL_SMALLINT.value: int,
923
+ ddbc_sql_const.SQL_BIGINT.value: int,
924
+ ddbc_sql_const.SQL_BINARY.value: bytes,
925
+ ddbc_sql_const.SQL_VARBINARY.value: bytes,
926
+ ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
927
+ ddbc_sql_const.SQL_GUID.value: uuid.UUID,
928
+ # Add more mappings as needed
929
+ }
930
+ return sql_to_python_type.get(sql_type, str)
931
+
932
+ @property
933
+ def rownumber(self) -> int:
934
+ """
935
+ DB-API extension: Current 0-based index of the cursor in the result set.
936
+
937
+ Returns:
938
+ int or None: The current 0-based index of the cursor in the result set,
939
+ or None if no row has been fetched yet or the index cannot be determined.
940
+
941
+ Note:
942
+ - Returns -1 before the first successful fetch
943
+ - Returns 0 after fetching the first row
944
+ - Returns -1 for empty result sets (since no rows can be fetched)
945
+
946
+ Warning:
947
+ This is a DB-API extension and may not be portable across different
948
+ database modules.
949
+ """
950
+ # Use mssql_python logging system instead of standard warnings
951
+ logger.warning( "DB-API extension cursor.rownumber used")
952
+
953
+ # Return None if cursor is closed or no result set is available
954
+ if self.closed or not self._has_result_set:
955
+ return -1
956
+
957
+ return self._rownumber # Will be None until first fetch, then 0, 1, 2, etc.
958
+
959
+ @property
960
+ def connection(self) -> "Connection":
961
+ """
962
+ DB-API 2.0 attribute: Connection object that created this cursor.
963
+
964
+ This is a read-only reference to the Connection object that was used to create
965
+ this cursor. This attribute is useful for polymorphic code that needs access
966
+ to connection-level functionality.
967
+
968
+ Returns:
969
+ Connection: The connection object that created this cursor.
970
+
971
+ Note:
972
+ This attribute is read-only as specified by DB-API 2.0. Attempting to
973
+ assign to this attribute will raise an AttributeError.
974
+ """
975
+ return self._connection
976
+
977
+ def _reset_rownumber(self) -> None:
978
+ """Reset the rownumber tracking when starting a new result set."""
979
+ self._rownumber = -1
980
+ self._next_row_index = 0
981
+ self._has_result_set = True
982
+ self._skip_increment_for_next_fetch = False
983
+
984
+ def _increment_rownumber(self):
985
+ """
986
+ Called after a successful fetch from the driver. Keep both counters consistent.
987
+ """
988
+ if self._has_result_set:
989
+ # driver returned one row, so the next row index increments by 1
990
+ self._next_row_index += 1
991
+ # rownumber is last returned row index
992
+ self._rownumber = self._next_row_index - 1
993
+ else:
994
+ raise InterfaceError(
995
+ "Cannot increment rownumber: no active result set.",
996
+ "No active result set.",
997
+ )
998
+
999
+ # Will be used when we add support for scrollable cursors
1000
+ def _decrement_rownumber(self):
1001
+ """
1002
+ Decrement the rownumber by 1.
1003
+
1004
+ This could be used for error recovery or cursor positioning operations.
1005
+ """
1006
+ if self._has_result_set and self._rownumber >= 0:
1007
+ if self._rownumber > 0:
1008
+ self._rownumber -= 1
1009
+ else:
1010
+ self._rownumber = -1
1011
+ else:
1012
+ raise InterfaceError(
1013
+ "Cannot decrement rownumber: no active result set.",
1014
+ "No active result set.",
1015
+ )
1016
+
1017
+ def _clear_rownumber(self):
1018
+ """
1019
+ Clear the rownumber tracking.
1020
+
1021
+ This should be called when the result set is cleared or when the cursor is reset.
1022
+ """
1023
+ self._rownumber = -1
1024
+ self._has_result_set = False
1025
+ self._skip_increment_for_next_fetch = False
1026
+
1027
+ def __iter__(self):
1028
+ """
1029
+ Return the cursor itself as an iterator.
1030
+
1031
+ This allows direct iteration over the cursor after execute():
1032
+
1033
+ for row in cursor.execute("SELECT * FROM table"):
1034
+ print(row)
1035
+ """
1036
+ self._check_closed()
1037
+ return self
1038
+
1039
+ def __next__(self):
1040
+ """
1041
+ Fetch the next row when iterating over the cursor.
1042
+
1043
+ Returns:
1044
+ The next Row object.
1045
+
1046
+ Raises:
1047
+ StopIteration: When no more rows are available.
1048
+ """
1049
+ self._check_closed()
1050
+ row = self.fetchone()
1051
+ if row is None:
1052
+ raise StopIteration
1053
+ return row
1054
+
1055
+ def next(self):
1056
+ """
1057
+ Fetch the next row from the cursor.
1058
+
1059
+ This is an alias for __next__() to maintain compatibility with older code.
1060
+
1061
+ Returns:
1062
+ The next Row object.
1063
+
1064
+ Raises:
1065
+ StopIteration: When no more rows are available.
1066
+ """
1067
+ return next(self)
1068
+
1069
+ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
1070
+ self,
1071
+ operation: str,
1072
+ *parameters,
1073
+ use_prepare: bool = True,
1074
+ reset_cursor: bool = True,
1075
+ ) -> "Cursor":
1076
+ """
1077
+ Prepare and execute a database operation (query or command).
1078
+
1079
+ Args:
1080
+ operation: SQL query or command.
1081
+ parameters: Sequence of parameters to bind.
1082
+ use_prepare: Whether to use SQLPrepareW (default) or SQLExecDirectW.
1083
+ reset_cursor: Whether to reset the cursor before execution.
1084
+ """
1085
+ logger.debug('execute: Starting - operation_length=%d, param_count=%d, use_prepare=%s',
1086
+ len(operation), len(parameters), str(use_prepare))
1087
+
1088
+ # Log the actual query being executed
1089
+ logger.debug('Executing query: %s', operation)
1090
+
1091
+ # Restore original fetch methods if they exist
1092
+ if hasattr(self, "_original_fetchone"):
1093
+ logger.debug('execute: Restoring original fetch methods')
1094
+ self.fetchone = self._original_fetchone
1095
+ self.fetchmany = self._original_fetchmany
1096
+ self.fetchall = self._original_fetchall
1097
+ del self._original_fetchone
1098
+ del self._original_fetchmany
1099
+ del self._original_fetchall
1100
+
1101
+ self._check_closed() # Check if the cursor is closed
1102
+ if reset_cursor:
1103
+ logger.debug('execute: Resetting cursor state')
1104
+ self._reset_cursor()
1105
+
1106
+ # Clear any previous messages
1107
+ self.messages = []
1108
+
1109
+ # Apply timeout if set (non-zero)
1110
+ if self._timeout > 0:
1111
+ logger.debug('execute: Setting query timeout=%d seconds', self._timeout)
1112
+ try:
1113
+ timeout_value = int(self._timeout)
1114
+ ret = ddbc_bindings.DDBCSQLSetStmtAttr(
1115
+ self.hstmt,
1116
+ ddbc_sql_const.SQL_ATTR_QUERY_TIMEOUT.value,
1117
+ timeout_value,
1118
+ )
1119
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
1120
+ logger.debug("Set query timeout to %d seconds", timeout_value)
1121
+ except Exception as e: # pylint: disable=broad-exception-caught
1122
+ logger.warning("Failed to set query timeout: %s", str(e))
1123
+
1124
+ logger.debug('execute: Creating parameter type list')
1125
+ param_info = ddbc_bindings.ParamInfo
1126
+ parameters_type = []
1127
+
1128
+ # Flatten parameters if a single tuple or list is passed
1129
+ if len(parameters) == 1 and isinstance(parameters[0], (tuple, list)):
1130
+ parameters = parameters[0]
1131
+
1132
+ parameters = list(parameters)
1133
+
1134
+ # Validate that inputsizes matches parameter count if both are present
1135
+ if parameters and self._inputsizes:
1136
+ if len(self._inputsizes) != len(parameters):
1137
+
1138
+ warnings.warn(
1139
+ f"Number of input sizes ({len(self._inputsizes)}) does not match "
1140
+ f"number of parameters ({len(parameters)}). "
1141
+ f"This may lead to unexpected behavior.",
1142
+ Warning,
1143
+ )
1144
+
1145
+ if parameters:
1146
+ for i, param in enumerate(parameters):
1147
+ paraminfo = self._create_parameter_types_list(
1148
+ param, param_info, parameters, i
1149
+ )
1150
+ parameters_type.append(paraminfo)
1151
+
1152
+ # TODO: Use a more sophisticated string compare that handles redundant spaces etc.
1153
+ # Also consider storing last query's hash instead of full query string. This will help
1154
+ # in low-memory conditions
1155
+ # (Ex: huge number of parallel queries with huge query string sizes)
1156
+ if operation != self.last_executed_stmt:
1157
+ # Executing a new statement. Reset is_stmt_prepared to false
1158
+ self.is_stmt_prepared = [False]
1159
+
1160
+ for i, param in enumerate(parameters):
1161
+ logger.debug(
1162
+ """Parameter number: %s, Parameter: %s,
1163
+ Param Python Type: %s, ParamInfo: %s, %s, %s, %s, %s""",
1164
+ i + 1,
1165
+ param,
1166
+ str(type(param)),
1167
+ parameters_type[i].paramSQLType,
1168
+ parameters_type[i].paramCType,
1169
+ parameters_type[i].columnSize,
1170
+ parameters_type[i].decimalDigits,
1171
+ parameters_type[i].inputOutputType,
1172
+ )
1173
+
1174
+ ret = ddbc_bindings.DDBCSQLExecute(
1175
+ self.hstmt,
1176
+ operation,
1177
+ parameters,
1178
+ parameters_type,
1179
+ self.is_stmt_prepared,
1180
+ use_prepare,
1181
+ )
1182
+ # Check return code
1183
+ try:
1184
+
1185
+ # Check for errors but don't raise exceptions for info/warning messages
1186
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
1187
+ except Exception as e: # pylint: disable=broad-exception-caught
1188
+ logger.warning( "Execute failed, resetting cursor: %s", e)
1189
+ self._reset_cursor()
1190
+ raise
1191
+
1192
+ # Capture any diagnostic messages (SQL_SUCCESS_WITH_INFO, etc.)
1193
+ if self.hstmt:
1194
+ self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
1195
+
1196
+ self.last_executed_stmt = operation
1197
+
1198
+ # Update rowcount after execution
1199
+ # TODO: rowcount return code from SQL needs to be handled
1200
+ self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
1201
+
1202
+ # Initialize description after execution
1203
+ # After successful execution, initialize description if there are results
1204
+ column_metadata = []
1205
+ try:
1206
+ ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
1207
+ self._initialize_description(column_metadata)
1208
+ except Exception as e: # pylint: disable=broad-exception-caught
1209
+ # If describe fails, it's likely there are no results (e.g., for INSERT)
1210
+ self.description = None
1211
+
1212
+ # Reset rownumber for new result set (only for SELECT statements)
1213
+ if self.description: # If we have column descriptions, it's likely a SELECT
1214
+ self.rowcount = -1
1215
+ self._reset_rownumber()
1216
+ # Pre-build column map and converter map
1217
+ self._cached_column_map = {col_desc[0]: i for i, col_desc in enumerate(self.description)}
1218
+ self._cached_converter_map = self._build_converter_map()
1219
+ else:
1220
+ self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
1221
+ self._clear_rownumber()
1222
+ self._cached_column_map = None
1223
+ self._cached_converter_map = None
1224
+
1225
+ # After successful execution, initialize description if there are results
1226
+ column_metadata = []
1227
+ try:
1228
+ ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
1229
+ self._initialize_description(column_metadata)
1230
+ except Exception as e:
1231
+ # If describe fails, it's likely there are no results (e.g., for INSERT)
1232
+ self.description = None
1233
+
1234
+ self._reset_inputsizes() # Reset input sizes after execution
1235
+ # Return self for method chaining
1236
+ return self
1237
+
1238
+ def _prepare_metadata_result_set( # pylint: disable=too-many-statements
1239
+ self, column_metadata=None, fallback_description=None, specialized_mapping=None
1240
+ ):
1241
+ """
1242
+ Prepares a metadata result set by:
1243
+ 1. Retrieving column metadata if not provided
1244
+ 2. Initializing the description attribute
1245
+ 3. Setting up column name mappings
1246
+ 4. Creating wrapper fetch methods with column mapping support
1247
+
1248
+ Args:
1249
+ column_metadata (list, optional): Pre-fetched column metadata.
1250
+ If None, it will be retrieved.
1251
+ fallback_description (list, optional): Fallback description to use if
1252
+ metadata retrieval fails.
1253
+ specialized_mapping (dict, optional): Custom column mapping for special cases.
1254
+
1255
+ Returns:
1256
+ Cursor: Self, for method chaining
1257
+ """
1258
+ # Retrieve column metadata if not provided
1259
+ if column_metadata is None:
1260
+ column_metadata = []
1261
+ try:
1262
+ ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
1263
+ except InterfaceError as e:
1264
+ logger.warning( f"Driver interface error during metadata retrieval: {e}")
1265
+ except Exception as e: # pylint: disable=broad-exception-caught
1266
+ # Log the exception with appropriate context
1267
+ logger.warning(
1268
+ f"Failed to retrieve column metadata: {e}. "
1269
+ f"Using standard ODBC column definitions instead.",
1270
+ )
1271
+
1272
+ # Initialize the description attribute with the column metadata
1273
+ self._initialize_description(column_metadata)
1274
+
1275
+ # Use fallback description if provided and current description is empty
1276
+ if not self.description and fallback_description:
1277
+ self.description = fallback_description
1278
+
1279
+ # Define column names in ODBC standard order
1280
+ self._column_map = {} # pylint: disable=attribute-defined-outside-init
1281
+ for i, (name, *_) in enumerate(self.description):
1282
+ # Add standard name
1283
+ self._column_map[name] = i
1284
+ # Add lowercase alias
1285
+ self._column_map[name.lower()] = i
1286
+
1287
+ # If specialized mapping is provided, handle it differently
1288
+ if specialized_mapping:
1289
+ # Define specialized fetch methods that use the custom mapping
1290
+ def fetchone_with_specialized_mapping():
1291
+ row = self._original_fetchone()
1292
+ if row is not None:
1293
+ merged_map = getattr(row, "_column_map", {}).copy()
1294
+ merged_map.update(specialized_mapping)
1295
+ row._column_map = merged_map
1296
+ return row
1297
+
1298
+ def fetchmany_with_specialized_mapping(size=None):
1299
+ rows = self._original_fetchmany(size)
1300
+ for row in rows:
1301
+ merged_map = getattr(row, "_column_map", {}).copy()
1302
+ merged_map.update(specialized_mapping)
1303
+ row._column_map = merged_map
1304
+ return rows
1305
+
1306
+ def fetchall_with_specialized_mapping():
1307
+ rows = self._original_fetchall()
1308
+ for row in rows:
1309
+ merged_map = getattr(row, "_column_map", {}).copy()
1310
+ merged_map.update(specialized_mapping)
1311
+ row._column_map = merged_map
1312
+ return rows
1313
+
1314
+ # Save original fetch methods
1315
+ if not hasattr(self, "_original_fetchone"):
1316
+ self._original_fetchone = self.fetchone # pylint: disable=attribute-defined-outside-init
1317
+ self._original_fetchmany = self.fetchmany # pylint: disable=attribute-defined-outside-init
1318
+ self._original_fetchall = self.fetchall # pylint: disable=attribute-defined-outside-init
1319
+
1320
+ # Use specialized mapping methods
1321
+ self.fetchone = fetchone_with_specialized_mapping
1322
+ self.fetchmany = fetchmany_with_specialized_mapping
1323
+ self.fetchall = fetchall_with_specialized_mapping
1324
+ else:
1325
+ # Standard column mapping
1326
+ # Remember original fetch methods (store only once)
1327
+ if not hasattr(self, "_original_fetchone"):
1328
+ self._original_fetchone = self.fetchone # pylint: disable=attribute-defined-outside-init
1329
+ self._original_fetchmany = self.fetchmany # pylint: disable=attribute-defined-outside-init
1330
+ self._original_fetchall = self.fetchall # pylint: disable=attribute-defined-outside-init
1331
+
1332
+ # Create wrapper fetch methods that add column mappings
1333
+ def fetchone_with_mapping():
1334
+ row = self._original_fetchone()
1335
+ if row is not None:
1336
+ row._column_map = self._column_map
1337
+ return row
1338
+
1339
+ def fetchmany_with_mapping(size=None):
1340
+ rows = self._original_fetchmany(size)
1341
+ for row in rows:
1342
+ row._column_map = self._column_map
1343
+ return rows
1344
+
1345
+ def fetchall_with_mapping():
1346
+ rows = self._original_fetchall()
1347
+ for row in rows:
1348
+ row._column_map = self._column_map
1349
+ return rows
1350
+
1351
+ # Replace fetch methods
1352
+ self.fetchone = fetchone_with_mapping
1353
+ self.fetchmany = fetchmany_with_mapping
1354
+ self.fetchall = fetchall_with_mapping
1355
+
1356
+ # Return the cursor itself for method chaining
1357
+ return self
1358
+
1359
+ def getTypeInfo(self, sqlType=None):
1360
+ """
1361
+ Executes SQLGetTypeInfo and creates a result set with information about
1362
+ the specified data type or all data types supported by the ODBC driver if not specified.
1363
+ """
1364
+ self._check_closed()
1365
+ self._reset_cursor()
1366
+
1367
+ sql_all_types = 0 # SQL_ALL_TYPES = 0
1368
+
1369
+ try:
1370
+ # Get information about data types
1371
+ ret = ddbc_bindings.DDBCSQLGetTypeInfo(
1372
+ self.hstmt, sqlType if sqlType is not None else sql_all_types
1373
+ )
1374
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
1375
+
1376
+ # Use the helper method to prepare the result set
1377
+ return self._prepare_metadata_result_set()
1378
+ except Exception as e: # pylint: disable=broad-exception-caught
1379
+ self._reset_cursor()
1380
+ raise e
1381
+
1382
+ def procedures(self, procedure=None, catalog=None, schema=None):
1383
+ """
1384
+ Executes SQLProcedures and creates a result set of information about procedures
1385
+ in the data source.
1386
+
1387
+ Args:
1388
+ procedure (str, optional): Procedure name pattern. Default is None (all procedures).
1389
+ catalog (str, optional): Catalog name pattern. Default is None (current catalog).
1390
+ schema (str, optional): Schema name pattern. Default is None (all schemas).
1391
+ """
1392
+ self._check_closed()
1393
+ self._reset_cursor()
1394
+
1395
+ # Call the SQLProcedures function
1396
+ retcode = ddbc_bindings.DDBCSQLProcedures(
1397
+ self.hstmt, catalog, schema, procedure
1398
+ )
1399
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1400
+
1401
+ # Define fallback description for procedures
1402
+ fallback_description = [
1403
+ ("procedure_cat", str, None, 128, 128, 0, True),
1404
+ ("procedure_schem", str, None, 128, 128, 0, True),
1405
+ ("procedure_name", str, None, 128, 128, 0, False),
1406
+ ("num_input_params", int, None, 10, 10, 0, True),
1407
+ ("num_output_params", int, None, 10, 10, 0, True),
1408
+ ("num_result_sets", int, None, 10, 10, 0, True),
1409
+ ("remarks", str, None, 254, 254, 0, True),
1410
+ ("procedure_type", int, None, 10, 10, 0, False),
1411
+ ]
1412
+
1413
+ # Use the helper method to prepare the result set
1414
+ return self._prepare_metadata_result_set(
1415
+ fallback_description=fallback_description
1416
+ )
1417
+
1418
+ def primaryKeys(self, table, catalog=None, schema=None):
1419
+ """
1420
+ Creates a result set of column names that make up the primary key for a table
1421
+ by executing the SQLPrimaryKeys function.
1422
+
1423
+ Args:
1424
+ table (str): The name of the table
1425
+ catalog (str, optional): The catalog name (database). Defaults to None.
1426
+ schema (str, optional): The schema name. Defaults to None.
1427
+ """
1428
+ self._check_closed()
1429
+ self._reset_cursor()
1430
+
1431
+ if not table:
1432
+ raise ProgrammingError("Table name must be specified", "HY000")
1433
+
1434
+ # Call the SQLPrimaryKeys function
1435
+ retcode = ddbc_bindings.DDBCSQLPrimaryKeys(self.hstmt, catalog, schema, table)
1436
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1437
+
1438
+ # Define fallback description for primary keys
1439
+ fallback_description = [
1440
+ ("table_cat", str, None, 128, 128, 0, True),
1441
+ ("table_schem", str, None, 128, 128, 0, True),
1442
+ ("table_name", str, None, 128, 128, 0, False),
1443
+ ("column_name", str, None, 128, 128, 0, False),
1444
+ ("key_seq", int, None, 10, 10, 0, False),
1445
+ ("pk_name", str, None, 128, 128, 0, True),
1446
+ ]
1447
+
1448
+ # Use the helper method to prepare the result set
1449
+ return self._prepare_metadata_result_set(
1450
+ fallback_description=fallback_description
1451
+ )
1452
+
1453
+ def foreignKeys( # pylint: disable=too-many-arguments,too-many-positional-arguments
1454
+ self,
1455
+ table=None,
1456
+ catalog=None,
1457
+ schema=None,
1458
+ foreignTable=None,
1459
+ foreignCatalog=None,
1460
+ foreignSchema=None,
1461
+ ):
1462
+ """
1463
+ Executes the SQLForeignKeys function and creates a result set of column names
1464
+ that are foreign keys.
1465
+
1466
+ This function returns:
1467
+ 1. Foreign keys in the specified table that reference primary keys in other tables, OR
1468
+ 2. Foreign keys in other tables that reference the primary key in the specified table
1469
+ """
1470
+ self._check_closed()
1471
+ self._reset_cursor()
1472
+
1473
+ # Check if we have at least one table specified
1474
+ if table is None and foreignTable is None:
1475
+ raise ProgrammingError(
1476
+ "Either table or foreignTable must be specified", "HY000"
1477
+ )
1478
+
1479
+ # Call the SQLForeignKeys function
1480
+ retcode = ddbc_bindings.DDBCSQLForeignKeys(
1481
+ self.hstmt,
1482
+ foreignCatalog,
1483
+ foreignSchema,
1484
+ foreignTable,
1485
+ catalog,
1486
+ schema,
1487
+ table,
1488
+ )
1489
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1490
+
1491
+ # Define fallback description for foreign keys
1492
+ fallback_description = [
1493
+ ("pktable_cat", str, None, 128, 128, 0, True),
1494
+ ("pktable_schem", str, None, 128, 128, 0, True),
1495
+ ("pktable_name", str, None, 128, 128, 0, False),
1496
+ ("pkcolumn_name", str, None, 128, 128, 0, False),
1497
+ ("fktable_cat", str, None, 128, 128, 0, True),
1498
+ ("fktable_schem", str, None, 128, 128, 0, True),
1499
+ ("fktable_name", str, None, 128, 128, 0, False),
1500
+ ("fkcolumn_name", str, None, 128, 128, 0, False),
1501
+ ("key_seq", int, None, 10, 10, 0, False),
1502
+ ("update_rule", int, None, 10, 10, 0, False),
1503
+ ("delete_rule", int, None, 10, 10, 0, False),
1504
+ ("fk_name", str, None, 128, 128, 0, True),
1505
+ ("pk_name", str, None, 128, 128, 0, True),
1506
+ ("deferrability", int, None, 10, 10, 0, False),
1507
+ ]
1508
+
1509
+ # Use the helper method to prepare the result set
1510
+ return self._prepare_metadata_result_set(
1511
+ fallback_description=fallback_description
1512
+ )
1513
+
1514
+ def rowIdColumns(self, table, catalog=None, schema=None, nullable=True):
1515
+ """
1516
+ Executes SQLSpecialColumns with SQL_BEST_ROWID which creates a result set of
1517
+ columns that uniquely identify a row.
1518
+ """
1519
+ self._check_closed()
1520
+ self._reset_cursor()
1521
+
1522
+ if not table:
1523
+ raise ProgrammingError("Table name must be specified", "HY000")
1524
+
1525
+ # Set the identifier type and options
1526
+ identifier_type = ddbc_sql_const.SQL_BEST_ROWID.value
1527
+ scope = ddbc_sql_const.SQL_SCOPE_CURROW.value
1528
+ nullable_flag = (
1529
+ ddbc_sql_const.SQL_NULLABLE.value
1530
+ if nullable
1531
+ else ddbc_sql_const.SQL_NO_NULLS.value
1532
+ )
1533
+
1534
+ # Call the SQLSpecialColumns function
1535
+ retcode = ddbc_bindings.DDBCSQLSpecialColumns(
1536
+ self.hstmt, identifier_type, catalog, schema, table, scope, nullable_flag
1537
+ )
1538
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1539
+
1540
+ # Define fallback description for special columns
1541
+ fallback_description = [
1542
+ ("scope", int, None, 10, 10, 0, False),
1543
+ ("column_name", str, None, 128, 128, 0, False),
1544
+ ("data_type", int, None, 10, 10, 0, False),
1545
+ ("type_name", str, None, 128, 128, 0, False),
1546
+ ("column_size", int, None, 10, 10, 0, False),
1547
+ ("buffer_length", int, None, 10, 10, 0, False),
1548
+ ("decimal_digits", int, None, 10, 10, 0, True),
1549
+ ("pseudo_column", int, None, 10, 10, 0, False),
1550
+ ]
1551
+
1552
+ # Use the helper method to prepare the result set
1553
+ return self._prepare_metadata_result_set(
1554
+ fallback_description=fallback_description
1555
+ )
1556
+
1557
+ def rowVerColumns(self, table, catalog=None, schema=None, nullable=True):
1558
+ """
1559
+ Executes SQLSpecialColumns with SQL_ROWVER which creates a result set of
1560
+ columns that are automatically updated when any value in the row is updated.
1561
+ """
1562
+ self._check_closed()
1563
+ self._reset_cursor()
1564
+
1565
+ if not table:
1566
+ raise ProgrammingError("Table name must be specified", "HY000")
1567
+
1568
+ # Set the identifier type and options
1569
+ identifier_type = ddbc_sql_const.SQL_ROWVER.value
1570
+ scope = ddbc_sql_const.SQL_SCOPE_CURROW.value
1571
+ nullable_flag = (
1572
+ ddbc_sql_const.SQL_NULLABLE.value
1573
+ if nullable
1574
+ else ddbc_sql_const.SQL_NO_NULLS.value
1575
+ )
1576
+
1577
+ # Call the SQLSpecialColumns function
1578
+ retcode = ddbc_bindings.DDBCSQLSpecialColumns(
1579
+ self.hstmt, identifier_type, catalog, schema, table, scope, nullable_flag
1580
+ )
1581
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1582
+
1583
+ # Same fallback description as rowIdColumns
1584
+ fallback_description = [
1585
+ ("scope", int, None, 10, 10, 0, False),
1586
+ ("column_name", str, None, 128, 128, 0, False),
1587
+ ("data_type", int, None, 10, 10, 0, False),
1588
+ ("type_name", str, None, 128, 128, 0, False),
1589
+ ("column_size", int, None, 10, 10, 0, False),
1590
+ ("buffer_length", int, None, 10, 10, 0, False),
1591
+ ("decimal_digits", int, None, 10, 10, 0, True),
1592
+ ("pseudo_column", int, None, 10, 10, 0, False),
1593
+ ]
1594
+
1595
+ # Use the helper method to prepare the result set
1596
+ return self._prepare_metadata_result_set(
1597
+ fallback_description=fallback_description
1598
+ )
1599
+
1600
+ def statistics( # pylint: disable=too-many-arguments,too-many-positional-arguments
1601
+ self,
1602
+ table: str,
1603
+ catalog: str = None,
1604
+ schema: str = None,
1605
+ unique: bool = False,
1606
+ quick: bool = True,
1607
+ ) -> "Cursor":
1608
+ """
1609
+ Creates a result set of statistics about a single table and the indexes associated
1610
+ with the table by executing SQLStatistics.
1611
+ """
1612
+ self._check_closed()
1613
+ self._reset_cursor()
1614
+
1615
+ if not table:
1616
+ raise ProgrammingError("Table name is required", "HY000")
1617
+
1618
+ # Set unique and quick flags
1619
+ unique_option = (
1620
+ ddbc_sql_const.SQL_INDEX_UNIQUE.value
1621
+ if unique
1622
+ else ddbc_sql_const.SQL_INDEX_ALL.value
1623
+ )
1624
+ reserved_option = (
1625
+ ddbc_sql_const.SQL_QUICK.value if quick else ddbc_sql_const.SQL_ENSURE.value
1626
+ )
1627
+
1628
+ # Call the SQLStatistics function
1629
+ retcode = ddbc_bindings.DDBCSQLStatistics(
1630
+ self.hstmt, catalog, schema, table, unique_option, reserved_option
1631
+ )
1632
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1633
+
1634
+ # Define fallback description for statistics
1635
+ fallback_description = [
1636
+ ("table_cat", str, None, 128, 128, 0, True),
1637
+ ("table_schem", str, None, 128, 128, 0, True),
1638
+ ("table_name", str, None, 128, 128, 0, False),
1639
+ ("non_unique", bool, None, 1, 1, 0, False),
1640
+ ("index_qualifier", str, None, 128, 128, 0, True),
1641
+ ("index_name", str, None, 128, 128, 0, True),
1642
+ ("type", int, None, 10, 10, 0, False),
1643
+ ("ordinal_position", int, None, 10, 10, 0, False),
1644
+ ("column_name", str, None, 128, 128, 0, True),
1645
+ ("asc_or_desc", str, None, 1, 1, 0, True),
1646
+ ("cardinality", int, None, 20, 20, 0, True),
1647
+ ("pages", int, None, 20, 20, 0, True),
1648
+ ("filter_condition", str, None, 128, 128, 0, True),
1649
+ ]
1650
+
1651
+ # Use the helper method to prepare the result set
1652
+ return self._prepare_metadata_result_set(
1653
+ fallback_description=fallback_description
1654
+ )
1655
+
1656
+ def columns(self, table=None, catalog=None, schema=None, column=None):
1657
+ """
1658
+ Creates a result set of column information in the specified tables
1659
+ using the SQLColumns function.
1660
+ """
1661
+ self._check_closed()
1662
+ self._reset_cursor()
1663
+
1664
+ # Call the SQLColumns function
1665
+ retcode = ddbc_bindings.DDBCSQLColumns(
1666
+ self.hstmt, catalog, schema, table, column
1667
+ )
1668
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, retcode)
1669
+
1670
+ # Define fallback description for columns
1671
+ fallback_description = [
1672
+ ("table_cat", str, None, 128, 128, 0, True),
1673
+ ("table_schem", str, None, 128, 128, 0, True),
1674
+ ("table_name", str, None, 128, 128, 0, False),
1675
+ ("column_name", str, None, 128, 128, 0, False),
1676
+ ("data_type", int, None, 10, 10, 0, False),
1677
+ ("type_name", str, None, 128, 128, 0, False),
1678
+ ("column_size", int, None, 10, 10, 0, True),
1679
+ ("buffer_length", int, None, 10, 10, 0, True),
1680
+ ("decimal_digits", int, None, 10, 10, 0, True),
1681
+ ("num_prec_radix", int, None, 10, 10, 0, True),
1682
+ ("nullable", int, None, 10, 10, 0, False),
1683
+ ("remarks", str, None, 254, 254, 0, True),
1684
+ ("column_def", str, None, 254, 254, 0, True),
1685
+ ("sql_data_type", int, None, 10, 10, 0, False),
1686
+ ("sql_datetime_sub", int, None, 10, 10, 0, True),
1687
+ ("char_octet_length", int, None, 10, 10, 0, True),
1688
+ ("ordinal_position", int, None, 10, 10, 0, False),
1689
+ ("is_nullable", str, None, 254, 254, 0, True),
1690
+ ]
1691
+
1692
+ # Use the helper method to prepare the result set
1693
+ return self._prepare_metadata_result_set(
1694
+ fallback_description=fallback_description
1695
+ )
1696
+
1697
+ def _transpose_rowwise_to_columnwise(
1698
+ self, seq_of_parameters: list
1699
+ ) -> tuple[list, int]:
1700
+ """
1701
+ Convert sequence of rows (row-wise) into list of columns (column-wise),
1702
+ for array binding via ODBC. Works with both iterables and generators.
1703
+
1704
+ Args:
1705
+ seq_of_parameters: Sequence of sequences or mappings of parameters.
1706
+
1707
+ Returns:
1708
+ tuple: (columnwise_data, row_count)
1709
+ """
1710
+ columnwise = []
1711
+ first_row = True
1712
+ row_count = 0
1713
+
1714
+ for row in seq_of_parameters:
1715
+ row_count += 1
1716
+ if first_row:
1717
+ # Initialize columnwise lists based on first row
1718
+ num_params = len(row)
1719
+ columnwise = [[] for _ in range(num_params)]
1720
+ first_row = False
1721
+ else:
1722
+ # Validate row size consistency
1723
+ if len(row) != num_params:
1724
+ raise ValueError("Inconsistent parameter row size in executemany()")
1725
+
1726
+ # Add each value to its column list
1727
+ for i, val in enumerate(row):
1728
+ columnwise[i].append(val)
1729
+
1730
+ return columnwise, row_count
1731
+
1732
+ def _compute_column_type(self, column):
1733
+ """
1734
+ Determine representative value and integer min/max for a column.
1735
+
1736
+ Returns:
1737
+ sample_value: Representative value for type inference and modified_row.
1738
+ min_val: Minimum for integers (None otherwise).
1739
+ max_val: Maximum for integers (None otherwise).
1740
+ """
1741
+ non_nulls = [v for v in column if v is not None]
1742
+ if not non_nulls:
1743
+ return None, None, None
1744
+
1745
+ int_values = [v for v in non_nulls if isinstance(v, int)]
1746
+ if int_values:
1747
+ min_val, max_val = min(int_values), max(int_values)
1748
+ sample_value = max(int_values, key=abs)
1749
+ return sample_value, min_val, max_val
1750
+
1751
+ sample_value = None
1752
+ for v in non_nulls:
1753
+ if not sample_value:
1754
+ sample_value = v
1755
+ elif isinstance(v, (str, bytes, bytearray)) and isinstance(sample_value, (str, bytes, bytearray)):
1756
+ # For string/binary objects, prefer the longer one
1757
+ # Use safe length comparison to avoid exceptions from custom __len__ implementations
1758
+ try:
1759
+ if len(v) > len(sample_value):
1760
+ sample_value = v
1761
+ except (TypeError, ValueError, AttributeError):
1762
+ # If length comparison fails, keep the current sample_value
1763
+ pass
1764
+ elif isinstance(v, decimal.Decimal) and isinstance(sample_value, decimal.Decimal):
1765
+ # For Decimal objects, prefer the one that requires higher precision or scale
1766
+ v_tuple = v.as_tuple()
1767
+ sample_tuple = sample_value.as_tuple()
1768
+
1769
+ # Calculate precision (total significant digits) and scale (decimal places)
1770
+ # For a number like 0.000123456789, we need precision = 9, scale = 12
1771
+ # The precision is the number of significant digits (len(digits))
1772
+ # The scale is the number of decimal places needed to represent the number
1773
+
1774
+ v_precision = len(v_tuple.digits)
1775
+ if v_tuple.exponent < 0:
1776
+ v_scale = -v_tuple.exponent
1777
+ else:
1778
+ v_scale = 0
1779
+
1780
+ sample_precision = len(sample_tuple.digits)
1781
+ if sample_tuple.exponent < 0:
1782
+ sample_scale = -sample_tuple.exponent
1783
+ else:
1784
+ sample_scale = 0
1785
+
1786
+ # For SQL DECIMAL(precision, scale), we need:
1787
+ # precision >= number of significant digits
1788
+ # scale >= number of decimal places
1789
+ # For 0.000123456789: precision needs to be at least 12 (to accommodate 12 decimal places)
1790
+ # So we need to adjust precision to be at least as large as scale
1791
+ v_required_precision = max(v_precision, v_scale)
1792
+ sample_required_precision = max(sample_precision, sample_scale)
1793
+
1794
+ # Prefer the decimal that requires higher precision or scale
1795
+ # This ensures we can accommodate all values in the column
1796
+ if (v_required_precision > sample_required_precision or
1797
+ (v_required_precision == sample_required_precision and v_scale > sample_scale)):
1798
+ sample_value = v
1799
+ elif isinstance(v, decimal.Decimal) and not isinstance(sample_value, decimal.Decimal):
1800
+ # If comparing Decimal to non-Decimal, prefer Decimal for better type inference
1801
+ sample_value = v
1802
+
1803
+ return sample_value, None, None
1804
+
1805
+ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
1806
+ self, operation: str, seq_of_parameters: List[Sequence[Any]]
1807
+ ) -> None:
1808
+ """
1809
+ Prepare a database operation and execute it against all parameter sequences.
1810
+ This version uses column-wise parameter binding and a single batched SQLExecute().
1811
+ Args:
1812
+ operation: SQL query or command.
1813
+ seq_of_parameters: Sequence of sequences or mappings of parameters.
1814
+ Raises:
1815
+ Error: If the operation fails.
1816
+ """
1817
+ logger.debug( 'executemany: Starting - operation_length=%d, batch_count=%d',
1818
+ len(operation), len(seq_of_parameters))
1819
+
1820
+ self._check_closed()
1821
+ self._reset_cursor()
1822
+ self.messages = []
1823
+ logger.debug( 'executemany: Cursor reset complete')
1824
+
1825
+ if not seq_of_parameters:
1826
+ self.rowcount = 0
1827
+ return
1828
+
1829
+ # Apply timeout if set (non-zero)
1830
+ if self._timeout > 0:
1831
+ try:
1832
+ timeout_value = int(self._timeout)
1833
+ ret = ddbc_bindings.DDBCSQLSetStmtAttr(
1834
+ self.hstmt,
1835
+ ddbc_sql_const.SQL_ATTR_QUERY_TIMEOUT.value,
1836
+ timeout_value,
1837
+ )
1838
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
1839
+ logger.debug( f"Set query timeout to {self._timeout} seconds")
1840
+ except Exception as e: # pylint: disable=broad-exception-caught
1841
+ logger.warning( f"Failed to set query timeout: {e}")
1842
+
1843
+ # Get sample row for parameter type detection and validation
1844
+ sample_row = (
1845
+ seq_of_parameters[0]
1846
+ if hasattr(seq_of_parameters, "__getitem__")
1847
+ else next(iter(seq_of_parameters))
1848
+ )
1849
+ param_count = len(sample_row)
1850
+ param_info = ddbc_bindings.ParamInfo
1851
+ parameters_type = []
1852
+ any_dae = False
1853
+
1854
+ # Check if we have explicit input sizes set
1855
+ if self._inputsizes:
1856
+ # Validate input sizes match parameter count
1857
+ if len(self._inputsizes) != param_count:
1858
+ warnings.warn(
1859
+ f"Number of input sizes ({len(self._inputsizes)}) does not match "
1860
+ f"number of parameters ({param_count}). This may lead to unexpected behavior.",
1861
+ Warning,
1862
+ )
1863
+
1864
+ # Prepare parameter type information
1865
+ for col_index in range(param_count):
1866
+ column = (
1867
+ [row[col_index] for row in seq_of_parameters]
1868
+ if hasattr(seq_of_parameters, "__getitem__")
1869
+ else []
1870
+ )
1871
+ sample_value, min_val, max_val = self._compute_column_type(column)
1872
+
1873
+ if self._inputsizes and col_index < len(self._inputsizes):
1874
+ # Use explicitly set input sizes
1875
+ sql_type, column_size, decimal_digits = self._inputsizes[col_index]
1876
+
1877
+ # Default is_dae to False
1878
+ is_dae = False
1879
+
1880
+ # Determine appropriate C type based on SQL type
1881
+ c_type = self._get_c_type_for_sql_type(sql_type)
1882
+
1883
+ # Check if this should be a DAE (data at execution) parameter based on column size
1884
+ if sample_value is not None:
1885
+ if isinstance(sample_value, str) and column_size > MAX_INLINE_CHAR:
1886
+ is_dae = True
1887
+ elif (
1888
+ isinstance(sample_value, (bytes, bytearray))
1889
+ and column_size > 8000
1890
+ ):
1891
+ is_dae = True
1892
+
1893
+ # Sanitize precision/scale for numeric types
1894
+ if sql_type in (
1895
+ ddbc_sql_const.SQL_DECIMAL.value,
1896
+ ddbc_sql_const.SQL_NUMERIC.value,
1897
+ ):
1898
+ column_size = max(
1899
+ 1, min(int(column_size) if column_size > 0 else 18, 38)
1900
+ )
1901
+ decimal_digits = min(max(0, decimal_digits), column_size)
1902
+
1903
+ # For binary data columns with mixed content, we need to find max size
1904
+ if sql_type in (
1905
+ ddbc_sql_const.SQL_BINARY.value,
1906
+ ddbc_sql_const.SQL_VARBINARY.value,
1907
+ ddbc_sql_const.SQL_LONGVARBINARY.value,
1908
+ ):
1909
+ # Find the maximum size needed for any row's binary data
1910
+ max_binary_size = 0
1911
+ for row in seq_of_parameters:
1912
+ value = row[col_index]
1913
+ if value is not None and isinstance(value, (bytes, bytearray)):
1914
+ max_binary_size = max(max_binary_size, len(value))
1915
+
1916
+ # For SQL Server VARBINARY(MAX), we need to use large object binding
1917
+ if column_size > 8000 or max_binary_size > 8000:
1918
+ sql_type = ddbc_sql_const.SQL_LONGVARBINARY.value
1919
+ is_dae = True
1920
+
1921
+ # Update column_size to actual maximum size if it's larger
1922
+ # Always ensure at least a minimum size of 1 for empty strings
1923
+ column_size = max(max_binary_size, 1)
1924
+
1925
+ paraminfo = param_info()
1926
+ paraminfo.paramCType = c_type
1927
+ paraminfo.paramSQLType = sql_type
1928
+ paraminfo.inputOutputType = ddbc_sql_const.SQL_PARAM_INPUT.value
1929
+ paraminfo.columnSize = column_size
1930
+ paraminfo.decimalDigits = decimal_digits
1931
+ paraminfo.isDAE = is_dae
1932
+
1933
+ # Ensure we never have SQL_C_DEFAULT (0) for C-type
1934
+ if paraminfo.paramCType == 0:
1935
+ paraminfo.paramCType = ddbc_sql_const.SQL_C_DEFAULT.value
1936
+
1937
+ parameters_type.append(paraminfo)
1938
+ else:
1939
+ # Use auto-detection for columns without explicit types
1940
+ column = (
1941
+ [row[col_index] for row in seq_of_parameters]
1942
+ if hasattr(seq_of_parameters, "__getitem__")
1943
+ else []
1944
+ )
1945
+ sample_value, min_val, max_val = self._compute_column_type(column)
1946
+
1947
+ dummy_row = list(sample_row)
1948
+ paraminfo = self._create_parameter_types_list(
1949
+ sample_value,
1950
+ param_info,
1951
+ dummy_row,
1952
+ col_index,
1953
+ min_val=min_val,
1954
+ max_val=max_val,
1955
+ )
1956
+ # Special handling for binary data in auto-detected types
1957
+ if paraminfo.paramSQLType in (
1958
+ ddbc_sql_const.SQL_BINARY.value,
1959
+ ddbc_sql_const.SQL_VARBINARY.value,
1960
+ ddbc_sql_const.SQL_LONGVARBINARY.value,
1961
+ ):
1962
+ # Find the maximum size needed for any row's binary data
1963
+ max_binary_size = 0
1964
+ for row in seq_of_parameters:
1965
+ value = row[col_index]
1966
+ if value is not None and isinstance(value, (bytes, bytearray)):
1967
+ max_binary_size = max(max_binary_size, len(value))
1968
+
1969
+ # For SQL Server VARBINARY(MAX), we need to use large object binding
1970
+ if max_binary_size > 8000:
1971
+ paraminfo.paramSQLType = ddbc_sql_const.SQL_LONGVARBINARY.value
1972
+ paraminfo.isDAE = True
1973
+
1974
+ # Update column_size to actual maximum size
1975
+ # Always ensure at least a minimum size of 1 for empty strings
1976
+ paraminfo.columnSize = max(max_binary_size, 1)
1977
+
1978
+ parameters_type.append(paraminfo)
1979
+ if paraminfo.isDAE:
1980
+ any_dae = True
1981
+
1982
+ if any_dae:
1983
+ logger.debug(
1984
+ "DAE parameters detected. Falling back to row-by-row execution with streaming.",
1985
+ )
1986
+ for row in seq_of_parameters:
1987
+ self.execute(operation, row)
1988
+ return
1989
+
1990
+ # Process parameters into column-wise format with possible type conversions
1991
+ # First, convert any Decimal types as needed for NUMERIC/DECIMAL columns
1992
+ processed_parameters = []
1993
+ for row in seq_of_parameters:
1994
+ processed_row = list(row)
1995
+ for i, val in enumerate(processed_row):
1996
+ if val is None:
1997
+ continue
1998
+ if (
1999
+ isinstance(val, decimal.Decimal)
2000
+ and parameters_type[i].paramSQLType
2001
+ == ddbc_sql_const.SQL_VARCHAR.value
2002
+ ):
2003
+ processed_row[i] = format(val, 'f')
2004
+ # Existing numeric conversion
2005
+ elif parameters_type[i].paramSQLType in (
2006
+ ddbc_sql_const.SQL_DECIMAL.value,
2007
+ ddbc_sql_const.SQL_NUMERIC.value,
2008
+ ) and not isinstance(val, decimal.Decimal):
2009
+ try:
2010
+ processed_row[i] = decimal.Decimal(str(val))
2011
+ except Exception as e: # pylint: disable=broad-exception-caught
2012
+ raise ValueError(
2013
+ f"Failed to convert parameter at row {row}, column {i} to Decimal: {e}"
2014
+ ) from e
2015
+ processed_parameters.append(processed_row)
2016
+
2017
+ # Now transpose the processed parameters
2018
+ columnwise_params, row_count = self._transpose_rowwise_to_columnwise(
2019
+ processed_parameters
2020
+ )
2021
+
2022
+ # Add debug logging
2023
+ logger.debug(
2024
+ "Executing batch query with %d parameter sets:\n%s",
2025
+ len(seq_of_parameters),
2026
+ "\n".join(
2027
+ f" {i+1}: {tuple(p) if isinstance(p, (list, tuple)) else p}"
2028
+ for i, p in enumerate(seq_of_parameters[:5])
2029
+ ), # Limit to first 5 rows for large batches
2030
+ )
2031
+
2032
+ ret = ddbc_bindings.SQLExecuteMany(
2033
+ self.hstmt, operation, columnwise_params, parameters_type, row_count
2034
+ )
2035
+
2036
+ # Capture any diagnostic messages after execution
2037
+ if self.hstmt:
2038
+ self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
2039
+
2040
+ try:
2041
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
2042
+ self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
2043
+ self.last_executed_stmt = operation
2044
+ self._initialize_description()
2045
+
2046
+ if self.description:
2047
+ self.rowcount = -1
2048
+ self._reset_rownumber()
2049
+ else:
2050
+ self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
2051
+ self._clear_rownumber()
2052
+ finally:
2053
+ # Reset input sizes after execution
2054
+ self._reset_inputsizes()
2055
+
2056
+ def fetchone(self) -> Union[None, Row]:
2057
+ """
2058
+ Fetch the next row of a query result set.
2059
+
2060
+ Returns:
2061
+ Single Row object or None if no more data is available.
2062
+ """
2063
+ self._check_closed() # Check if the cursor is closed
2064
+
2065
+ # Fetch raw data
2066
+ row_data = []
2067
+ try:
2068
+ ret = ddbc_bindings.DDBCSQLFetchOne(self.hstmt, row_data)
2069
+
2070
+ if self.hstmt:
2071
+ self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
2072
+
2073
+ if ret == ddbc_sql_const.SQL_NO_DATA.value:
2074
+ # No more data available
2075
+ if self._next_row_index == 0 and self.description is not None:
2076
+ # This is an empty result set, set rowcount to 0
2077
+ self.rowcount = 0
2078
+ return None
2079
+
2080
+ # Update internal position after successful fetch
2081
+ if self._skip_increment_for_next_fetch:
2082
+ self._skip_increment_for_next_fetch = False
2083
+ self._next_row_index += 1
2084
+ else:
2085
+ self._increment_rownumber()
2086
+
2087
+ self.rowcount = self._next_row_index
2088
+
2089
+ # Get column and converter maps
2090
+ column_map, converter_map = self._get_column_and_converter_maps()
2091
+ return Row(row_data, column_map, cursor=self, converter_map=converter_map)
2092
+ except Exception as e:
2093
+ # On error, don't increment rownumber - rethrow the error
2094
+ raise e
2095
+
2096
+ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
2097
+ """
2098
+ Fetch the next set of rows of a query result.
2099
+
2100
+ Args:
2101
+ size: Number of rows to fetch at a time.
2102
+
2103
+ Returns:
2104
+ List of Row objects.
2105
+ """
2106
+ self._check_closed() # Check if the cursor is closed
2107
+ if not self._has_result_set and self.description:
2108
+ self._reset_rownumber()
2109
+
2110
+ if size is None:
2111
+ size = self.arraysize
2112
+
2113
+ if size <= 0:
2114
+ return []
2115
+
2116
+ # Fetch raw data
2117
+ rows_data = []
2118
+ try:
2119
+ _ = ddbc_bindings.DDBCSQLFetchMany(self.hstmt, rows_data, size)
2120
+
2121
+ if self.hstmt:
2122
+ self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
2123
+
2124
+ # Update rownumber for the number of rows actually fetched
2125
+ if rows_data and self._has_result_set:
2126
+ # advance counters by number of rows actually returned
2127
+ self._next_row_index += len(rows_data)
2128
+ self._rownumber = self._next_row_index - 1
2129
+
2130
+ # Centralize rowcount assignment after fetch
2131
+ if len(rows_data) == 0 and self._next_row_index == 0:
2132
+ self.rowcount = 0
2133
+ else:
2134
+ self.rowcount = self._next_row_index
2135
+
2136
+ # Get column and converter maps
2137
+ column_map, converter_map = self._get_column_and_converter_maps()
2138
+
2139
+ # Convert raw data to Row objects
2140
+ return [Row(row_data, column_map, cursor=self, converter_map=converter_map) for row_data in rows_data]
2141
+ except Exception as e:
2142
+ # On error, don't increment rownumber - rethrow the error
2143
+ raise e
2144
+
2145
+ def fetchall(self) -> List[Row]:
2146
+ """
2147
+ Fetch all (remaining) rows of a query result.
2148
+
2149
+ Returns:
2150
+ List of Row objects.
2151
+ """
2152
+ self._check_closed() # Check if the cursor is closed
2153
+ if not self._has_result_set and self.description:
2154
+ self._reset_rownumber()
2155
+
2156
+ # Fetch raw data
2157
+ rows_data = []
2158
+ try:
2159
+ _ = ddbc_bindings.DDBCSQLFetchAll(self.hstmt, rows_data)
2160
+
2161
+ if self.hstmt:
2162
+ self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
2163
+
2164
+ # Update rownumber for the number of rows actually fetched
2165
+ if rows_data and self._has_result_set:
2166
+ self._next_row_index += len(rows_data)
2167
+ self._rownumber = self._next_row_index - 1
2168
+
2169
+ # Centralize rowcount assignment after fetch
2170
+ if len(rows_data) == 0 and self._next_row_index == 0:
2171
+ self.rowcount = 0
2172
+ else:
2173
+ self.rowcount = self._next_row_index
2174
+
2175
+ # Get column and converter maps
2176
+ column_map, converter_map = self._get_column_and_converter_maps()
2177
+
2178
+ # Convert raw data to Row objects
2179
+ return [Row(row_data, column_map, cursor=self, converter_map=converter_map) for row_data in rows_data]
2180
+ except Exception as e:
2181
+ # On error, don't increment rownumber - rethrow the error
2182
+ raise e
2183
+
2184
+ def nextset(self) -> Union[bool, None]:
2185
+ """
2186
+ Skip to the next available result set.
2187
+
2188
+ Returns:
2189
+ True if there is another result set, None otherwise.
2190
+
2191
+ Raises:
2192
+ Error: If the previous call to execute did not produce any result set.
2193
+ """
2194
+ logger.debug('nextset: Moving to next result set')
2195
+ self._check_closed() # Check if the cursor is closed
2196
+
2197
+ # Clear messages per DBAPI
2198
+ self.messages = []
2199
+
2200
+ # Clear cached column and converter maps for the new result set
2201
+ self._cached_column_map = None
2202
+ self._cached_converter_map = None
2203
+
2204
+ # Skip to the next result set
2205
+ ret = ddbc_bindings.DDBCSQLMoreResults(self.hstmt)
2206
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
2207
+
2208
+ if ret == ddbc_sql_const.SQL_NO_DATA.value:
2209
+ logger.debug('nextset: No more result sets available')
2210
+ self._clear_rownumber()
2211
+ self.description = None
2212
+ return False
2213
+
2214
+ self._reset_rownumber()
2215
+
2216
+ # Initialize description for the new result set
2217
+ column_metadata = []
2218
+ try:
2219
+ ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
2220
+ self._initialize_description(column_metadata)
2221
+
2222
+ # Pre-build column map and converter map for the new result set
2223
+ if self.description:
2224
+ self._cached_column_map = {col_desc[0]: i for i, col_desc in enumerate(self.description)}
2225
+ self._cached_converter_map = self._build_converter_map()
2226
+ except Exception as e: # pylint: disable=broad-exception-caught
2227
+ # If describe fails, there might be no results in this result set
2228
+ self.description = None
2229
+
2230
+ logger.debug('nextset: Moved to next result set - column_count=%d',
2231
+ len(self.description) if self.description else 0)
2232
+ return True
2233
+
2234
+ def __enter__(self):
2235
+ """
2236
+ Enter the runtime context for the cursor.
2237
+
2238
+ Returns:
2239
+ The cursor instance itself.
2240
+ """
2241
+ self._check_closed()
2242
+ return self
2243
+
2244
+ def __exit__(self, *args):
2245
+ """Closes the cursor when exiting the context, ensuring proper resource cleanup."""
2246
+ if not self.closed:
2247
+ self.close()
2248
+
2249
+ def fetchval(self):
2250
+ """
2251
+ Fetch the first column of the first row if there are results.
2252
+
2253
+ This is a convenience method for queries that return a single value,
2254
+ such as SELECT COUNT(*) FROM table, SELECT MAX(id) FROM table, etc.
2255
+
2256
+ Returns:
2257
+ The value of the first column of the first row, or None if no rows
2258
+ are available or the first column value is NULL.
2259
+
2260
+ Raises:
2261
+ Exception: If the cursor is closed.
2262
+
2263
+ Example:
2264
+ >>> count = cursor.execute('SELECT COUNT(*) FROM users').fetchval()
2265
+ >>> max_id = cursor.execute('SELECT MAX(id) FROM users').fetchval()
2266
+ >>> name = cursor.execute('SELECT name FROM users WHERE id = ?', user_id).fetchval()
2267
+
2268
+ Note:
2269
+ This is a convenience extension beyond the DB-API 2.0 specification.
2270
+ After calling fetchval(), the cursor position advances by one row,
2271
+ just like fetchone().
2272
+ """
2273
+ logger.debug('fetchval: Fetching single value from first column')
2274
+ self._check_closed() # Check if the cursor is closed
2275
+
2276
+ # Check if this is a result-producing statement
2277
+ if not self.description:
2278
+ # Non-result-set statement (INSERT, UPDATE, DELETE, etc.)
2279
+ logger.debug('fetchval: No result set available (non-SELECT statement)')
2280
+ return None
2281
+
2282
+ # Fetch the first row
2283
+ row = self.fetchone()
2284
+
2285
+ if row is None:
2286
+ logger.debug('fetchval: No value available (no rows)')
2287
+ return None
2288
+
2289
+ logger.debug('fetchval: Value retrieved successfully')
2290
+ return row[0]
2291
+
2292
+ def commit(self):
2293
+ """
2294
+ Commit all SQL statements executed on the connection that created this cursor.
2295
+
2296
+ This is a convenience method that calls commit() on the underlying connection.
2297
+ It affects all cursors created by the same connection since the last commit/rollback.
2298
+
2299
+ The benefit is that many uses can now just use the cursor and not have to track
2300
+ the connection object.
2301
+
2302
+ Raises:
2303
+ Exception: If the cursor is closed or if the commit operation fails.
2304
+
2305
+ Example:
2306
+ >>> cursor.execute("INSERT INTO users (name) VALUES (?)", "John")
2307
+ >>> cursor.commit() # Commits the INSERT
2308
+
2309
+ Note:
2310
+ This is equivalent to calling connection.commit() but provides convenience
2311
+ for code that only has access to the cursor object.
2312
+ """
2313
+ self._check_closed() # Check if the cursor is closed
2314
+
2315
+ # Clear messages per DBAPI
2316
+ self.messages = []
2317
+
2318
+ # Delegate to the connection's commit method
2319
+ self._connection.commit()
2320
+
2321
+ def rollback(self):
2322
+ """
2323
+ Roll back all SQL statements executed on the connection that created this cursor.
2324
+
2325
+ This is a convenience method that calls rollback() on the underlying connection.
2326
+ It affects all cursors created by the same connection since the last commit/rollback.
2327
+
2328
+ The benefit is that many uses can now just use the cursor and not have to track
2329
+ the connection object.
2330
+
2331
+ Raises:
2332
+ Exception: If the cursor is closed or if the rollback operation fails.
2333
+
2334
+ Example:
2335
+ >>> cursor.execute("INSERT INTO users (name) VALUES (?)", "John")
2336
+ >>> cursor.rollback() # Rolls back the INSERT
2337
+
2338
+ Note:
2339
+ This is equivalent to calling connection.rollback() but provides convenience
2340
+ for code that only has access to the cursor object.
2341
+ """
2342
+ self._check_closed() # Check if the cursor is closed
2343
+
2344
+ # Clear messages per DBAPI
2345
+ self.messages = []
2346
+
2347
+ # Delegate to the connection's rollback method
2348
+ self._connection.rollback()
2349
+
2350
+ def __del__(self):
2351
+ """
2352
+ Destructor to ensure the cursor is closed when it is no longer needed.
2353
+ This is a safety net to ensure resources are cleaned up
2354
+ even if close() was not called explicitly.
2355
+ If the cursor is already closed, it will not raise an exception during cleanup.
2356
+ """
2357
+ if "closed" not in self.__dict__ or not self.closed:
2358
+ try:
2359
+ self.close()
2360
+ except Exception as e: # pylint: disable=broad-exception-caught
2361
+ # Don't raise an exception in __del__, just log it
2362
+ # If interpreter is shutting down, we might not have logging set up
2363
+ import sys
2364
+
2365
+ if sys and sys._is_finalizing():
2366
+ # Suppress logging during interpreter shutdown
2367
+ return
2368
+ logger.debug( "Exception during cursor cleanup in __del__: %s", e)
2369
+
2370
+ def scroll(self, value: int, mode: str = "relative") -> None: # pylint: disable=too-many-branches
2371
+ """
2372
+ Scroll using SQLFetchScroll only, matching test semantics:
2373
+ - relative(N>0): consume N rows; rownumber = previous + N;
2374
+ next fetch returns the following row.
2375
+ - absolute(-1): before first (rownumber = -1), no data consumed.
2376
+ - absolute(0): position so next fetch returns first row;
2377
+ rownumber stays 0 even after that fetch.
2378
+ - absolute(k>0): next fetch returns row index k (0-based);
2379
+ rownumber == k after scroll.
2380
+ """
2381
+ logger.debug('scroll: Scrolling cursor - mode=%s, value=%d, current_rownumber=%d',
2382
+ mode, value, self._rownumber)
2383
+ self._check_closed()
2384
+
2385
+ # Clear messages per DBAPI
2386
+ self.messages = []
2387
+
2388
+ if mode not in ("relative", "absolute"):
2389
+ logger.error('scroll: Invalid mode - mode=%s', mode)
2390
+ raise ProgrammingError(
2391
+ "Invalid scroll mode",
2392
+ f"mode must be 'relative' or 'absolute', got '{mode}'",
2393
+ )
2394
+ if not self._has_result_set:
2395
+ logger.error('scroll: No active result set')
2396
+ raise ProgrammingError(
2397
+ "No active result set",
2398
+ "Cannot scroll: no result set available. Execute a query first.",
2399
+ )
2400
+ if not isinstance(value, int):
2401
+ logger.error('scroll: Invalid value type - type=%s', type(value).__name__)
2402
+ raise ProgrammingError(
2403
+ "Invalid scroll value type",
2404
+ f"scroll value must be an integer, got {type(value).__name__}",
2405
+ )
2406
+
2407
+ # Relative backward not supported
2408
+ if mode == "relative" and value < 0:
2409
+ logger.error('scroll: Backward scrolling not supported - value=%d', value)
2410
+ raise NotSupportedError(
2411
+ "Backward scrolling not supported",
2412
+ f"Cannot move backward by {value} rows on a forward-only cursor",
2413
+ )
2414
+
2415
+ row_data: list = []
2416
+
2417
+ # Absolute positioning not supported with forward-only cursors
2418
+ if mode == "absolute":
2419
+ raise NotSupportedError(
2420
+ "Absolute positioning not supported",
2421
+ "Forward-only cursors do not support absolute positioning"
2422
+ )
2423
+
2424
+ try:
2425
+ if mode == "relative":
2426
+ if value == 0:
2427
+ return
2428
+
2429
+ # For forward-only cursors, use multiple SQL_FETCH_NEXT calls
2430
+ # This matches pyodbc's approach for skip operations
2431
+ for i in range(value):
2432
+ ret = ddbc_bindings.DDBCSQLFetchScroll(
2433
+ self.hstmt, ddbc_sql_const.SQL_FETCH_NEXT.value, 0, row_data
2434
+ )
2435
+ if ret == ddbc_sql_const.SQL_NO_DATA.value:
2436
+ raise IndexError(
2437
+ "Cannot scroll to specified position: end of result set reached"
2438
+ )
2439
+
2440
+ # Update position tracking
2441
+ self._rownumber = self._rownumber + value
2442
+ self._next_row_index = self._rownumber + 1
2443
+ logger.debug('scroll: Scroll complete - new_rownumber=%d, next_row_index=%d',
2444
+ self._rownumber, self._next_row_index)
2445
+ return
2446
+
2447
+ except Exception as e: # pylint: disable=broad-exception-caught
2448
+ if isinstance(e, (IndexError, NotSupportedError)):
2449
+ raise
2450
+ raise IndexError(f"Scroll operation failed: {e}") from e
2451
+
2452
+ def skip(self, count: int) -> None:
2453
+ """
2454
+ Skip the next count records in the query result set.
2455
+
2456
+ Args:
2457
+ count: Number of records to skip.
2458
+
2459
+ Raises:
2460
+ IndexError: If attempting to skip past the end of the result set.
2461
+ ProgrammingError: If count is not an integer.
2462
+ NotSupportedError: If attempting to skip backwards.
2463
+ """
2464
+ self._check_closed()
2465
+
2466
+ # Clear messages
2467
+ self.messages = []
2468
+
2469
+ # Simply delegate to the scroll method with 'relative' mode
2470
+ self.scroll(count, "relative")
2471
+
2472
+ def _execute_tables( # pylint: disable=too-many-arguments,too-many-positional-arguments
2473
+ self,
2474
+ stmt_handle,
2475
+ catalog_name=None,
2476
+ schema_name=None,
2477
+ table_name=None,
2478
+ table_type=None,
2479
+ ):
2480
+ """
2481
+ Execute SQLTables ODBC function to retrieve table metadata.
2482
+
2483
+ Args:
2484
+ stmt_handle: ODBC statement handle
2485
+ catalog_name: The catalog name pattern
2486
+ schema_name: The schema name pattern
2487
+ table_name: The table name pattern
2488
+ table_type: The table type filter
2489
+ search_escape: The escape character for pattern matching
2490
+ """
2491
+ # Convert None values to empty strings for ODBC
2492
+ catalog = "" if catalog_name is None else catalog_name
2493
+ schema = "" if schema_name is None else schema_name
2494
+ table = "" if table_name is None else table_name
2495
+ types = "" if table_type is None else table_type
2496
+
2497
+ # Call the ODBC SQLTables function
2498
+ retcode = ddbc_bindings.DDBCSQLTables(
2499
+ stmt_handle, catalog, schema, table, types
2500
+ )
2501
+
2502
+ # Check return code and handle errors
2503
+ check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, stmt_handle, retcode)
2504
+
2505
+ # Capture any diagnostic messages
2506
+ if stmt_handle:
2507
+ self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(stmt_handle))
2508
+
2509
+ def tables(self, table=None, catalog=None, schema=None, tableType=None): # pylint: disable=too-many-arguments,too-many-positional-arguments
2510
+ """
2511
+ Returns information about tables in the database that match the given criteria using
2512
+ the SQLTables ODBC function.
2513
+
2514
+ Args:
2515
+ table (str, optional): The table name pattern. Default is None (all tables).
2516
+ catalog (str, optional): The catalog name. Default is None.
2517
+ schema (str, optional): The schema name pattern. Default is None.
2518
+ tableType (str or list, optional): The table type filter. Default is None.
2519
+ Example: "TABLE" or ["TABLE", "VIEW"]
2520
+
2521
+ Returns:
2522
+ Cursor: The cursor object itself for method chaining with fetch methods.
2523
+ """
2524
+ self._check_closed()
2525
+ self._reset_cursor()
2526
+
2527
+ # Format table_type parameter - SQLTables expects comma-separated string
2528
+ table_type_str = None
2529
+ if tableType is not None:
2530
+ if isinstance(tableType, (list, tuple)):
2531
+ table_type_str = ",".join(tableType)
2532
+ else:
2533
+ table_type_str = str(tableType)
2534
+
2535
+ try:
2536
+ # Call SQLTables via the helper method
2537
+ self._execute_tables(
2538
+ self.hstmt,
2539
+ catalog_name=catalog,
2540
+ schema_name=schema,
2541
+ table_name=table,
2542
+ table_type=table_type_str,
2543
+ )
2544
+
2545
+ # Define fallback description for tables
2546
+ fallback_description = [
2547
+ ("table_cat", str, None, 128, 128, 0, True),
2548
+ ("table_schem", str, None, 128, 128, 0, True),
2549
+ ("table_name", str, None, 128, 128, 0, False),
2550
+ ("table_type", str, None, 128, 128, 0, False),
2551
+ ("remarks", str, None, 254, 254, 0, True),
2552
+ ]
2553
+
2554
+ # Use the helper method to prepare the result set
2555
+ return self._prepare_metadata_result_set(
2556
+ fallback_description=fallback_description
2557
+ )
2558
+
2559
+ except Exception as e: # pylint: disable=broad-exception-caught
2560
+ # Log the error and re-raise
2561
+ logger.error( f"Error executing tables query: {e}")
2562
+ raise
2563
+
2564
+ def callproc(
2565
+ self, procname: str, parameters: Optional[Sequence[Any]] = None
2566
+ ) -> Optional[Sequence[Any]]:
2567
+ """
2568
+ Call a stored database procedure with the given name.
2569
+
2570
+ Args:
2571
+ procname: Name of the stored procedure to call
2572
+ parameters: Optional sequence of parameters to pass to the procedure
2573
+
2574
+ Returns:
2575
+ A sequence containing the result parameters (input parameters unchanged,
2576
+ output parameters with their new values)
2577
+
2578
+ Raises:
2579
+ NotSupportedError: This method is not yet implemented
2580
+ """
2581
+ raise NotSupportedError(
2582
+ driver_error="callproc() is not yet implemented",
2583
+ ddbc_error="Stored procedure calls are not currently supported",
2584
+ )
2585
+
2586
+ def setoutputsize(self, size: int, column: Optional[int] = None) -> None:
2587
+ """
2588
+ Set a column buffer size for fetches of large columns.
2589
+
2590
+ This method is optional and is not implemented in this driver.
2591
+
2592
+ Args:
2593
+ size: Maximum size of the column buffer
2594
+ column: Optional column index (0-based) to set the size for
2595
+
2596
+ Note:
2597
+ This method is a no-op in this implementation as buffer sizes
2598
+ are managed automatically by the underlying driver.
2599
+ """
2600
+ # This is a no-op - buffer sizes are managed automatically
2601
+