Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a4852ad
FEAT: Add to_dict(), keys(), values(), items(), __contains__ to Row (…
jahnvi480 Jun 1, 2026
d21846e
FIX: Deduplicate dict-like methods when _column_map has lowercase ali…
jahnvi480 Jun 2, 2026
757a550
Linting fix
jahnvi480 Jun 2, 2026
a4804ae
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 2, 2026
9b96304
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 11, 2026
75998e2
FIX: Move _column_names to cursor for zero per-row cost, fix items() …
jahnvi480 Jun 11, 2026
31f3d47
REFACTOR: Move all Row tests from globals to cursor integration tests
jahnvi480 Jun 11, 2026
89c3b2f
FIX: Correct test_row_case_insensitive_access - normal SELECT has no …
jahnvi480 Jun 11, 2026
b7cb1a8
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 11, 2026
0917d1f
TEST: Cover _column_names=() and __contains__ lowercase branches (lin…
jahnvi480 Jun 11, 2026
93d9f37
Resolving linting issue
jahnvi480 Jun 11, 2026
c2c6927
PERF: Lazy-compute _column_names, add type annotations, update class …
jahnvi480 Jun 11, 2026
ae22ff8
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 11, 2026
e6142d0
TEST: Cover _get_column_names dedup fallback (lines 228-232)
jahnvi480 Jun 11, 2026
04111b9
FIX: Remove __contains__ (breaking change), make values() return tupl…
jahnvi480 Jun 11, 2026
10292eb
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 11, 2026
a4b4ac2
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 11, 2026
fa3c112
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Jun 15, 2026
ac74000
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Aug 13, 2026
dfd4dfd
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Sep 16, 2026
9551a1c
FEAT: add read-only row._mapping dict view, remove Row dict methods (…
jahnvi480 Sep 16, 2026
df1b8eb
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Sep 16, 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
3 changes: 2 additions & 1 deletion mssql_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
from .cursor import Cursor

# Row Objects
from .row import Row
from .row import Row, RowMapping

# Logging Configuration (Simplified single-level DEBUG system)
from .logging import logger, setup_logging, driver_logger
Expand Down Expand Up @@ -355,6 +355,7 @@ def _cleanup_connections():
"TokenProvider",
"Cursor",
"Row",
"RowMapping",
# Settings
"Settings",
"get_settings",
Expand Down
16 changes: 16 additions & 0 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
self._cached_column_map = None
self._cached_column_map_lower = None
self._cached_converter_map = None
# Canonical, order-preserving column names snapshotted once per result set
# and handed to each Row so mapping views never read the live cursor.description
# (which changes when the cursor is reused for another query). _result_columns_src
# tracks the self.description identity the snapshot was built from, so a new
# result set rebuilds it exactly once.
self._cached_result_columns = None
self._result_columns_src = None
# Raw ODBC SQL type codes (from SQLDescribeCol) per column, parallel to
# self.description. Kept so output-converter dispatch can key on the integer
# ODBC SQL type code (pyodbc-compatible), not just the mapped Python type. See #684.
Expand Down Expand Up @@ -1447,6 +1454,12 @@ def _get_column_and_converter_maps(self):
# Get cached converter map
converter_map = getattr(self, "_cached_converter_map", None)

# Snapshot canonical column names once per result set (identity-tracked against
# self.description) so each Row carries stable names even after the cursor is reused.
if self.description is not None and self._result_columns_src is not self.description:
self._cached_result_columns = tuple(col_desc[0] for col_desc in self.description)
self._result_columns_src = self.description

return column_map, converter_map, self._cached_column_map_lower

def _map_data_type(self, sql_type):
Expand Down Expand Up @@ -2817,6 +2830,7 @@ def fetchone(self) -> Union[None, Row]:
converter_map=converter_map,
uuid_str_indices=self._uuid_str_indices,
column_map_lower=column_map_lower,
column_names=self._cached_result_columns,
)
except Exception:
# On error, don't increment rownumber - rethrow the error
Expand Down Expand Up @@ -2888,6 +2902,7 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
converter_map=converter_map,
uuid_str_indices=uuid_idx,
column_map_lower=column_map_lower,
column_names=self._cached_result_columns,
)
for row_data in rows_data
]
Expand Down Expand Up @@ -2953,6 +2968,7 @@ def fetchall(self) -> List[Row]:
converter_map=converter_map,
uuid_str_indices=uuid_idx,
column_map_lower=column_map_lower,
column_names=self._cached_result_columns,
)
for row_data in rows_data
]
Expand Down
13 changes: 13 additions & 0 deletions mssql_python/mssql_python.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ class Row:
cursor: Optional["Cursor"] = None,
converter_map: Optional[List[Any]] = None,
uuid_str_indices: Optional[Tuple[int, ...]] = None,
column_map_lower: Optional[Dict[str, int]] = None,
column_names: Optional[Tuple[str, ...]] = None,
) -> None: ...
@property
def _mapping(self) -> "RowMapping": ...
def __getitem__(self, index: int) -> Any: ...
def __getattr__(self, name: str) -> Any: ...
def __eq__(self, other: Any) -> bool: ...
Expand All @@ -158,6 +162,15 @@ class Row:
def __str__(self) -> str: ...
def __repr__(self) -> str: ...

class RowMapping(Mapping[str, Any]):
"""Read-only mapping view (column name -> value) over a Row."""

def __init__(self, row: "Row") -> None: ...
def __getitem__(self, key: str) -> Any: ...
def __iter__(self) -> Iterator[str]: ...
def __len__(self) -> int: ...
def __repr__(self) -> str: ...

# DB-API 2.0 Cursor Object
# https://www.python.org/dev/peps/pep-0249/#cursor-objects
class Cursor:
Expand Down
90 changes: 88 additions & 2 deletions mssql_python/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import decimal
import uuid as _uuid
from collections.abc import Mapping
from typing import Any
from mssql_python.logging import logger

Expand All @@ -16,14 +17,25 @@ class Row:
A row of data from a cursor fetch operation. Provides both tuple-like indexing
and attribute access to column values.

For dict-like access, use the read-only ``row._mapping`` view (a
``collections.abc.Mapping`` of column name -> value). Iterating the Row itself
(for x in row) yields values, not keys — consistent with pyodbc.Row and
sqlite3.Row; iterate ``row._mapping`` to get column names.

Column attribute access behavior depends on the global 'lowercase' setting:
- When enabled: Case-insensitive attribute access
- When disabled (default): Case-sensitive attribute access matching original column names

Example:
row = cursor.fetchone()
print(row[0]) # Access by index
print(row.column_name) # Access by column name (case sensitivity varies)
print(row[0]) # Access by index
print(row.column_name) # Access by column name
print(dict(row._mapping)) # Convert to a plain dict
print(row._mapping["col"]) # Access a value by column name via the mapping
for name in row._mapping: # Iterate column names
print(name, row._mapping[name])
for value in row: # Iterating the Row yields values, not keys
print(value)
"""

def __init__(
Expand All @@ -34,6 +46,7 @@ def __init__(
converter_map=None,
uuid_str_indices=None,
column_map_lower=None,
column_names=None,
):
"""
Initialize a Row object with values and pre-built column map.
Expand All @@ -48,6 +61,11 @@ def __init__(
column_map_lower: Pre-built lowercase column map for O(1) case-insensitive
lookups. Built once per result set in the cursor when lowercase is enabled;
None when lowercase is off (the default). Shared across all rows.
column_names: Canonical, order- and duplicate-preserving column names for
the result set, snapshotted once by the cursor and shared by reference
across all rows. Backs ``row._mapping``. None for rows built without a
cursor snapshot; ``_mapping_keys()`` then reconstructs names from
``column_map``.
"""
# Apply output converters if available using pre-computed converter map
if converter_map:
Expand All @@ -73,6 +91,11 @@ def __init__(
# Lowercase map is pre-built once per result set in the cursor and shared
# across all rows. None when lowercase is off (the default) — zero cost.
self._column_map_lower = column_map_lower
# Canonical column names for this row's result set, snapshotted once by the
# cursor (order- and duplicate-preserving) and shared by reference across every
# row. None only for rows built without a cursor snapshot (e.g. some direct or
# test constructions); _mapping_keys() then reconstructs names from _column_map.
self._column_names = column_names

def _stringify_uuids(self, indices):
"""
Expand Down Expand Up @@ -209,6 +232,33 @@ def __getattr__(self, name: str) -> Any:

raise AttributeError(f"Row has no attribute '{name}'")

@property
def _mapping(self) -> "RowMapping":
"""Read-only dict-like view (column name -> value) over this row.

Returns a ``collections.abc.Mapping``; use ``dict(row._mapping)`` for a plain
Comment thread
jahnvi480 marked this conversation as resolved.
dict, ``row._mapping.items()`` for name/value pairs, and ``iter(row._mapping)``
for column names. Names are order-preserving and de-duplicated (last column
wins for a repeated name, matching subscript and attribute access).
"""
return RowMapping(self)

def _mapping_keys(self) -> tuple:
"""Canonical, order-preserving column names backing ``_mapping``.

Prefers the names snapshotted once by the cursor for the result set. When a
row was built without that snapshot, reconstructs names from ``_column_map``
(one name per column index); returns ``()`` when neither is available.
"""
if self._column_names is not None:
return self._column_names
if self._column_map:
idx_to_name: dict = {}
for name, idx in self._column_map.items():
idx_to_name.setdefault(idx, name)
return tuple(idx_to_name[i] for i in sorted(idx_to_name))
return ()

def __eq__(self, other: Any) -> bool:
"""
Support comparison with lists for test compatibility.
Expand Down Expand Up @@ -253,3 +303,39 @@ def __str__(self) -> str:
def __repr__(self) -> str:
"""Return a detailed string representation for debugging"""
return repr(tuple(self._values))


class RowMapping(Mapping):
"""Read-only ``Mapping`` view over a :class:`Row` (column name -> value).

Created via :attr:`Row._mapping`. Keys are the row's column names, order-
preserving and de-duplicated (last column wins for a repeated name, matching
``row[name]`` / ``row.name``). The view reflects the row it wraps and copies
no values.
"""

__slots__ = ("_row",)

def __init__(self, row: "Row") -> None:
self._row = row

def __getitem__(self, key: str) -> Any:
if isinstance(key, str):
try:
return self._row[key]
except KeyError:
raise KeyError(key) from None
raise KeyError(key)
Comment thread
jahnvi480 marked this conversation as resolved.

def __iter__(self):
seen = set()
for name in self._row._mapping_keys():
if name not in seen:
seen.add(name)
yield name

def __len__(self) -> int:
return sum(1 for _ in self)

def __repr__(self) -> str:
return f"RowMapping({dict(self)!r})"
Loading
Loading