Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
8e9a31a
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Sep 17, 2026
114264b
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Sep 18, 2026
63b199e
FIX: treat absent Row column map as missing key in row._mapping (#606)
jahnvi480 Sep 18, 2026
d1d2684
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Sep 18, 2026
77a6874
FIX: Make RowMapping lookup/membership agree with iteration
jahnvi480 Sep 18, 2026
4f92ddd
Merge branch 'main' into jahnvi/row-dict-api-606
jahnvi480 Sep 18, 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
119 changes: 116 additions & 3 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 @@ -172,7 +195,9 @@ def _apply_output_converters_optimized(self, values, converter_map):
def __getitem__(self, index) -> Any:
"""Allow accessing by numeric index (row[0]) or column name (row["col"])."""
if isinstance(index, str):
if index in self._column_map:
# A row built without a column map has no named columns, so any
# string key is simply absent (KeyError), never a TypeError.
if self._column_map is not None and index in self._column_map:
return self._values[self._column_map[index]]
# O(1) case-insensitive lookup when lowercase is enabled
if self._column_map_lower is not None:
Expand Down Expand Up @@ -209,6 +234,55 @@ 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 :class:`RowMapping` (a ``collections.abc.Mapping``). Typical use::

row = cursor.fetchone()
dict(row._mapping) # {'id': 1, 'name': 'Alice'}
for name, value in row._mapping.items():
...
row._mapping["name"] # value by column name
"name" in row._mapping # membership by column name

Semantics and caveats:

- Keys are the result set's column names, order-preserving and
de-duplicated: when a name repeats, one key is kept and the last column
with that name supplies its value (matching ``row[name]`` / ``row.name``).
Every duplicate value stays reachable positionally via ``row[i]``.
- Lookup and membership use the canonical column names exactly, so the key
set, ``in`` and ``[]`` always agree. Unlike ``row[name]`` / ``row.name``,
the view does NOT resolve case-insensitive names or catalog aliases; with
``lowercase=True`` the keys are the lowercased names.
- ``_mapping`` is a property, so a column literally named ``_mapping`` is
shadowed: read it with ``row["_mapping"]`` or ``row._mapping["_mapping"]``.
"""
Comment thread
jahnvi480 marked this conversation as resolved.
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, which
preserve the result set's column order. When a row was built without that
snapshot (e.g. a direct ``Row(values, column_map)`` construction), names are
reconstructed from ``_column_map`` in column-index order; that order can
differ from ``_column_map``'s insertion order, so a directly-constructed row
may key differently from an otherwise-equivalent cursor row. Returns ``()``
when neither source is available. Normal cursor fetches always supply the
snapshot.
"""
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))
Comment thread
jahnvi480 marked this conversation as resolved.
return ()

def __eq__(self, other: Any) -> bool:
"""
Support comparison with lists for test compatibility.
Expand Down Expand Up @@ -253,3 +327,42 @@ 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 canonical column names,
order-preserving and de-duplicated (last column wins for a repeated name).
Lookup and membership use those names exactly -- no case-insensitive or catalog
alias resolution -- so iteration, ``in`` and ``[]`` always agree. 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:
# Restrict lookups to the canonical column names yielded by __iter__ so
# membership and lookup agree with iteration (proper Mapping semantics).
# Row.__getitem__ additionally accepts case-insensitive names and catalog
# aliases, but those are not iterated keys, so the view must not resolve
# them here.
if isinstance(key, str) and key in self._row._mapping_keys():
return self._row[key]
raise KeyError(key)

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})"
38 changes: 38 additions & 0 deletions tests/test_001_globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,3 +1059,41 @@ def test_row_string_key_case_insensitive_with_lowercase():
# Non-existent attribute raises AttributeError
with pytest.raises(AttributeError):
row.nonexistent


def test_row_mapping_lookup_agrees_with_iteration_aliases():
"""RowMapping ignores case-insensitive names and catalog aliases (Mapping contract).

Metadata result sets store several keys per column index in _column_map (exact,
lowercase, and ODBC 2.x/3.x aliases). The mapping view must expose only the
canonical snapshot names it iterates, so lookup and membership never accept a
name the view does not yield.
"""
from mssql_python.row import Row

# One physical column at index 0, reachable in _column_map under several names.
column_map = {"TABLE_NAME": 0, "table_name": 0, "TABLE_QUALIFIER": 0}
row = Row(
["dbo"],
column_map,
cursor=None,
column_names=("TABLE_NAME",),
)
mapping = row._mapping

# Canonical key set == iteration == the snapshot.
assert list(mapping) == ["TABLE_NAME"]
assert dict(mapping) == {"TABLE_NAME": "dbo"}
assert mapping["TABLE_NAME"] == "dbo"
assert "TABLE_NAME" in mapping

# Row still resolves the aliases directly (unchanged behavior)...
assert row["table_name"] == "dbo"
assert row["TABLE_QUALIFIER"] == "dbo"
# ...but the mapping view does NOT, so its keys agree with membership/lookup.
assert "table_name" not in mapping
assert "TABLE_QUALIFIER" not in mapping
with pytest.raises(KeyError):
mapping["table_name"]
assert mapping.get("TABLE_QUALIFIER") is None
assert mapping.get("table_name", "fallback") == "fallback"
Loading
Loading