Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c403899
Fix for FetchMany(number of rows) ignores batch size when table conta…
dlevy-msft-sql Nov 25, 2025
f96d14c
First round of code review fixes
dlevy-msft-sql Nov 25, 2025
e135e40
Fix test_fetchone
dlevy-msft-sql Nov 25, 2025
38966d0
Fix for issue 352
dlevy-msft-sql Dec 1, 2025
3f52dd7
Apply fixes from copilot review
dlevy-msft-sql Dec 1, 2025
38b041b
Fix for FetchMany(number of rows) ignores batch size when table conta…
dlevy-msft-sql Nov 25, 2025
139994a
First round of code review fixes
dlevy-msft-sql Nov 25, 2025
6fd823e
Fix test_fetchone
dlevy-msft-sql Nov 25, 2025
a7d9b8e
Fix for issue 352
dlevy-msft-sql Dec 1, 2025
646ef04
Apply fixes from copilot review
dlevy-msft-sql Dec 1, 2025
8018872
Merge branch 'Issue-352' of https://github.com/dlevy-msft-sql/mssql-p…
dlevy-msft-sql Jan 5, 2026
67268e5
Merge branch 'main' into Issue-352
dlevy-msft-sql Jan 5, 2026
d335ed0
Merge branch 'main' into Issue-352
dlevy-msft-sql Jan 8, 2026
2dc93f3
Merge branch 'main' into Issue-352
dlevy-msft-sql Jan 9, 2026
eee5256
Merge branch 'main' into Issue-352
dlevy-msft-sql Jan 16, 2026
d289a9b
Merge branch 'microsoft:main' into Issue-352
dlevy-msft-sql Jan 16, 2026
ced5fc2
feat: Add SQLTypeCode for dual-compatible cursor.description type codes
dlevy-msft-sql Jan 19, 2026
0568451
fix: Remove stray comment from constants.py
dlevy-msft-sql Jan 19, 2026
257f0f6
chore: Update black target-version to include py312, py313
dlevy-msft-sql Jan 19, 2026
033f7cd
style: Apply black 26.1.0 formatting and pin version in requirements.txt
dlevy-msft-sql Jan 19, 2026
63c08f5
test: Improve SQLTypeCode equality test for 100% coverage
dlevy-msft-sql Jan 19, 2026
6adeead
chore: Remove unnecessary .gitattributes
dlevy-msft-sql Jan 19, 2026
7b6da4e
chore: Keep black unpinned to always use latest
dlevy-msft-sql Jan 19, 2026
42d3e09
fix: Address Copilot review comments
dlevy-msft-sql Jan 19, 2026
31a6d13
refactor: Use DROP TABLE IF EXISTS for cleaner test cleanup
dlevy-msft-sql Jan 19, 2026
6eb43ea
refactor: Remove dead _map_data_type method
dlevy-msft-sql Jan 20, 2026
327ee42
Sync SQL Server ODBC constants between Python and C++
dlevy-msft-sql Jan 20, 2026
7a487b8
test: Add spatial type error handling and thread safety tests
dlevy-msft-sql Jan 20, 2026
839019c
Merge branch 'main' into Issue-352
dlevy-msft-sql Jan 20, 2026
466a4d5
test: Add spatial type error handling and thread safety tests
dlevy-msft-sql Jan 20, 2026
5e67a1b
test: Add spatial type error handling and thread safety tests
dlevy-msft-sql Jan 20, 2026
f484e8d
Merge remote branch, keep table name fix
dlevy-msft-sql Jan 20, 2026
c0c2117
Ran black --line-length=100 mssql_python/ tests/ to fix issue
dlevy-msft-sql Jan 20, 2026
b5460dd
docs: Add sync comment for SQL Server constants
dlevy-msft-sql Jan 20, 2026
bced470
fix: Use per-thread connections in thread safety test (MARS not enabled)
dlevy-msft-sql Jan 20, 2026
3d09f1a
style: Fix black formatting
dlevy-msft-sql Jan 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

### Added

- New feature: Support for macOS and Linux.
- Documentation: Added API documentation in the Wiki.
- Added support for SQL Server spatial data types (geography, geometry, hierarchyid) via SQL_SS_UDT type handling.
- Added `SQLTypeCode` class for dual-compatible type codes in `cursor.description`.

### Changed

- Improved error handling in the connection module.
- Enhanced `cursor.description[i][1]` to return `SQLTypeCode` objects that compare equal to both SQL type integers and Python types, maintaining full backwards compatibility while aligning with DB-API 2.0.

### Fixed

- Bug fix: Resolved issue with connection timeout.
- Fixed `cursor.description` type handling for better DB-API 2.0 compliance (Issue #352).

### SQLTypeCode Usage

The `type_code` field in `cursor.description` now returns `SQLTypeCode` objects that support both comparison styles:

```python
cursor.execute("SELECT id, name FROM users")
desc = cursor.description

# Style 1: Compare with Python types (backwards compatible with pandas, etc.)
if desc[0][1] == int:
print("Integer column")

# Style 2: Compare with SQL type codes (DB-API 2.0 compliant)
from mssql_python.constants import ConstantsDDBC as sql_types
if desc[0][1] == sql_types.SQL_INTEGER.value: # or just == 4
print("Integer column")

# Get the raw SQL type code
type_code = int(desc[0][1]) # Returns 4 for SQL_INTEGER
```

## [1.0.0-alpha] - 2025-02-24

### Added

- Initial release of the mssql-python driver for SQL Server.

### Changed

- N/A

### Fixed
- N/A

- N/A
2 changes: 1 addition & 1 deletion mssql_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
from .connection_string_builder import _ConnectionStringBuilder

# Cursor Objects
from .cursor import Cursor
from .cursor import Cursor, SQLTypeCode

# Logging Configuration (Simplified single-level DEBUG system)
from .logging import logger, setup_logging, driver_logger
Expand Down
5 changes: 5 additions & 0 deletions mssql_python/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ class ConstantsDDBC(Enum):
SQL_FETCH_ABSOLUTE = 5
SQL_FETCH_RELATIVE = 6
SQL_FETCH_BOOKMARK = 8
# NOTE: The following SQL Server-specific type constants MUST stay in sync with
# the corresponding values in mssql_python/pybind/ddbc_bindings.cpp
SQL_DATETIMEOFFSET = -155
SQL_SS_TIME2 = -154 # SQL Server TIME(n) type
SQL_SS_UDT = -151 # SQL Server User-Defined Types (geometry, geography, hierarchyid)
SQL_SS_XML = -152 # SQL Server XML type
Comment on lines +120 to +122
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constants SQL_SS_TIME2, SQL_SS_UDT, and SQL_SS_XML are being added here in the constants file (lines 118-120), but SQL_DATETIME2 (line 65) and SQL_SMALLDATETIME (line 63) already exist earlier in the file. This duplication is not present in the diff, but the C++ code comment on line 34-36 mentions that these constants MUST stay in sync. Consider adding a similar comment in the Python constants file to document this requirement and prevent future drift between the C++ and Python constant definitions.

Copilot uses AI. Check for mistakes.
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
SQL_SCOPE_CURROW = 0
SQL_BEST_ROWID = 1
Expand Down
162 changes: 120 additions & 42 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,103 @@
MONEY_MAX: decimal.Decimal = decimal.Decimal("922337203685477.5807")


class SQLTypeCode:
"""
A dual-compatible type code that compares equal to both SQL type integers and Python types.

This class maintains backwards compatibility with code that checks
`cursor.description[i][1] == str` while also supporting DB-API 2.0
compliant code that checks `cursor.description[i][1] == -9`.

Examples:
>>> type_code = SQLTypeCode(-9, str)
>>> type_code == str # Backwards compatible with pandas, etc.
True
>>> type_code == -9 # DB-API 2.0 compliant
True
>>> int(type_code) # Get the raw SQL type code
-9
"""

# SQL type code to Python type mapping (class-level cache)
_type_map = None

def __init__(self, type_code: int, python_type: type = None):
self.type_code = type_code
# If python_type not provided, look it up from the mapping
if python_type is None:
python_type = self._get_python_type(type_code)
self.python_type = python_type

@classmethod
def _get_type_map(cls):
"""Lazily build the SQL to Python type mapping."""
if cls._type_map is None:
cls._type_map = {
ddbc_sql_const.SQL_CHAR.value: str,
ddbc_sql_const.SQL_VARCHAR.value: str,
ddbc_sql_const.SQL_LONGVARCHAR.value: str,
ddbc_sql_const.SQL_WCHAR.value: str,
ddbc_sql_const.SQL_WVARCHAR.value: str,
ddbc_sql_const.SQL_WLONGVARCHAR.value: str,
ddbc_sql_const.SQL_INTEGER.value: int,
ddbc_sql_const.SQL_REAL.value: float,
ddbc_sql_const.SQL_FLOAT.value: float,
ddbc_sql_const.SQL_DOUBLE.value: float,
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
ddbc_sql_const.SQL_NUMERIC.value: decimal.Decimal,
ddbc_sql_const.SQL_DATE.value: datetime.date,
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime,
ddbc_sql_const.SQL_TIME.value: datetime.time,
ddbc_sql_const.SQL_SS_TIME2.value: datetime.time, # SQL Server TIME(n)
ddbc_sql_const.SQL_BIT.value: bool,
ddbc_sql_const.SQL_TINYINT.value: int,
ddbc_sql_const.SQL_SMALLINT.value: int,
ddbc_sql_const.SQL_BIGINT.value: int,
ddbc_sql_const.SQL_BINARY.value: bytes,
ddbc_sql_const.SQL_VARBINARY.value: bytes,
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
ddbc_sql_const.SQL_SS_UDT.value: bytes,
ddbc_sql_const.SQL_SS_XML.value: str, # SQL Server XML type (-152)
ddbc_sql_const.SQL_DATETIME2.value: datetime.datetime,
ddbc_sql_const.SQL_SMALLDATETIME.value: datetime.datetime,
ddbc_sql_const.SQL_DATETIMEOFFSET.value: datetime.datetime,
}
return cls._type_map

@classmethod
def _get_python_type(cls, sql_code: int) -> type:
"""Get the Python type for a SQL type code."""
return cls._get_type_map().get(sql_code, str)

def __eq__(self, other):
"""Compare equal to both Python types and SQL integer codes."""
if isinstance(other, type):
return self.python_type == other
if isinstance(other, int):
return self.type_code == other
if isinstance(other, SQLTypeCode):
return self.type_code == other.type_code
return False

def __ne__(self, other):
return not self.__eq__(other)

def __hash__(self):
return hash(self.type_code)

def __int__(self):
return self.type_code

def __repr__(self):
type_name = self.python_type.__name__ if self.python_type else "Unknown"
return f"SQLTypeCode({self.type_code}, {type_name})"

def __str__(self):
return str(self.type_code)


class Cursor: # pylint: disable=too-many-instance-attributes,too-many-public-methods
"""
Represents a database cursor, which is used to manage the context of a fetch operation.
Expand Down Expand Up @@ -142,6 +239,9 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
)
self.messages = [] # Store diagnostic messages

# Store raw column metadata for converter lookups
self._column_metadata = None

def _is_unicode_string(self, param: str) -> bool:
"""
Check if a string contains non-ASCII characters.
Expand Down Expand Up @@ -756,6 +856,7 @@ def close(self) -> None:
self.hstmt = None
logger.debug("SQLFreeHandle succeeded")
self._clear_rownumber()
self._column_metadata = None # Clear metadata to prevent memory leaks
self.closed = True

def _check_closed(self) -> None:
Expand Down Expand Up @@ -942,8 +1043,12 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
"""Initialize the description attribute from column metadata."""
if not column_metadata:
self.description = None
self._column_metadata = None # Clear metadata too
return

# Store raw metadata for converter map building
self._column_metadata = column_metadata

description = []
for _, col in enumerate(column_metadata):
# Get column name - lowercase it if the lowercase flag is set
Expand All @@ -954,10 +1059,13 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
column_name = column_name.lower()

# Add to description tuple (7 elements as per PEP-249)
# Use SQLTypeCode for backwards-compatible type_code that works with both
# `desc[1] == str` (pandas) and `desc[1] == -9` (DB-API 2.0)
sql_type = col["DataType"]
description.append(
(
column_name, # name
self._map_data_type(col["DataType"]), # type_code
SQLTypeCode(sql_type), # type_code - dual compatible
None, # display_size
col["ColumnSize"], # internal_size
col["ColumnSize"], # precision - should match ColumnSize
Expand All @@ -975,18 +1083,17 @@ def _build_converter_map(self):
"""
if (
not self.description
or not self._column_metadata
or not hasattr(self.connection, "_output_converters")
or not self.connection._output_converters
):
return None

converter_map = []

for desc in self.description:
if desc is None:
converter_map.append(None)
continue
sql_type = desc[1]
for col_meta in self._column_metadata:
# Use the raw SQL type code from metadata, not the mapped Python type
sql_type = col_meta["DataType"]
converter = self.connection.get_output_converter(sql_type)
# If no converter found for the SQL type, try the WVARCHAR converter as a fallback
if converter is None:
Expand Down Expand Up @@ -1022,41 +1129,6 @@ def _get_column_and_converter_maps(self):

return column_map, converter_map

def _map_data_type(self, sql_type):
"""
Map SQL data type to Python data type.

Args:
sql_type: SQL data type.

Returns:
Corresponding Python data type.
"""
sql_to_python_type = {
ddbc_sql_const.SQL_INTEGER.value: int,
ddbc_sql_const.SQL_VARCHAR.value: str,
ddbc_sql_const.SQL_WVARCHAR.value: str,
ddbc_sql_const.SQL_CHAR.value: str,
ddbc_sql_const.SQL_WCHAR.value: str,
ddbc_sql_const.SQL_FLOAT.value: float,
ddbc_sql_const.SQL_DOUBLE.value: float,
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
ddbc_sql_const.SQL_NUMERIC.value: decimal.Decimal,
ddbc_sql_const.SQL_DATE.value: datetime.date,
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime,
ddbc_sql_const.SQL_TIME.value: datetime.time,
ddbc_sql_const.SQL_BIT.value: bool,
ddbc_sql_const.SQL_TINYINT.value: int,
ddbc_sql_const.SQL_SMALLINT.value: int,
ddbc_sql_const.SQL_BIGINT.value: int,
ddbc_sql_const.SQL_BINARY.value: bytes,
ddbc_sql_const.SQL_VARBINARY.value: bytes,
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
# Add more mappings as needed
}
return sql_to_python_type.get(sql_type, str)

@property
def rownumber(self) -> int:
"""
Expand Down Expand Up @@ -2572,7 +2644,13 @@ def __del__(self):
Destructor to ensure the cursor is closed when it is no longer needed.
This is a safety net to ensure resources are cleaned up
even if close() was not called explicitly.
If the cursor is already closed, it will not raise an exception during cleanup.

Error handling:
This destructor performs best-effort cleanup only. Any exceptions raised
while closing the cursor are caught and, when possible, logged instead of
being propagated, because raising from __del__ can cause hard-to-debug
failures during garbage collection. During interpreter shutdown, logging
may be suppressed if the logging subsystem is no longer available.
"""
if "closed" not in self.__dict__ or not self.closed:
try:
Expand Down
34 changes: 32 additions & 2 deletions mssql_python/mssql_python.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ def TimeFromTicks(ticks: int) -> datetime.time: ...
def TimestampFromTicks(ticks: int) -> datetime.datetime: ...
def Binary(value: Union[str, bytes, bytearray]) -> bytes: ...

# SQLTypeCode - Dual-compatible type code for cursor.description
class SQLTypeCode:
"""
A type code that supports dual comparison with both SQL type integers and Python types.

This class is used in cursor.description[i][1] to provide backwards compatibility
with libraries like pandas (which compare with Python types like str, int, float)
while also supporting DB-API 2.0 style integer type code comparisons.

Examples:
>>> desc = cursor.description
>>> desc[0][1] == str # True if column is string type
>>> desc[0][1] == 12 # True if SQL_VARCHAR
>>> int(desc[0][1]) # Returns the SQL type code as integer
"""

type_code: int
python_type: type

def __init__(self, type_code: int, python_type: Optional[type] = None) -> None: ...
def __eq__(self, other: Any) -> bool: ...
def __ne__(self, other: Any) -> bool: ...
def __int__(self) -> int: ...
def __hash__(self) -> int: ...
def __repr__(self) -> str: ...
def __str__(self) -> str: ...

# DB-API 2.0 Exception Hierarchy
# https://www.python.org/dev/peps/pep-0249/#exceptions
class Warning(Exception):
Expand Down Expand Up @@ -133,7 +160,7 @@ class Row:
description: List[
Tuple[
str,
Any,
SQLTypeCode,
Optional[int],
Optional[int],
Optional[int],
Expand Down Expand Up @@ -163,11 +190,14 @@ class Cursor:
"""

# DB-API 2.0 Required Attributes
# description is a sequence of 7-item tuples:
# (name, type_code, display_size, internal_size, precision, scale, null_ok)
# type_code is SQLTypeCode which compares equal to both SQL integers and Python types
description: Optional[
List[
Tuple[
str,
Any,
SQLTypeCode,
Optional[int],
Optional[int],
Optional[int],
Expand Down
Loading
Loading