Skip to content

Commit d6ddedb

Browse files
authored
FEAT: Row string-key indexing (#589)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below For external contributors: Insert Github Issue number below Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#45119](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/45119) <!-- External contributors: GitHub Issue --> > GitHub Issue: #582 ------------------------------------------------------------------- ### Summary This pull request enhances the usability of the `Row` class by allowing access to row values using column names as string keys (e.g., `row["col"]`), in addition to existing integer index and attribute access. It also introduces case-insensitive string-key access when the cursor's `lowercase` attribute is enabled. Comprehensive tests have been added to ensure correct behavior for these new access patterns. ### Enhancements to Row Access Patterns * Updated the `Row.__getitem__` method in `mssql_python/row.py` to support accessing values by column name as a string key, including case-insensitive lookup when the cursor's `lowercase` attribute is set. ### Testing Additions * Added `test_row_string_key_indexing` and `test_row_string_key_case_insensitive_with_lowercase` to `tests/test_001_globals.py` to verify string-key and case-insensitive access, as well as backward compatibility with integer and slice access. * Added `test_row_string_key_indexing` to `tests/test_004_cursor.py` to test string-key access on real query results, ensuring consistency with index and attribute access and correct error handling for missing keys.
1 parent bef3d31 commit d6ddedb

4 files changed

Lines changed: 165 additions & 15 deletions

File tree

mssql_python/cursor.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,14 +1124,17 @@ def _get_column_and_converter_maps(self):
11241124
# Build column map locally first, then assign to cache
11251125
column_map = {col_desc[0]: i for i, col_desc in enumerate(self.description)}
11261126
self._cached_column_map = column_map
1127+
self._cached_column_map_lower = (
1128+
{k.lower(): v for k, v in column_map.items()} if get_settings().lowercase else None
1129+
)
11271130

11281131
# Fallback to legacy column name map if no cached map
11291132
column_map = column_map or getattr(self, "_column_name_map", None)
11301133

11311134
# Get cached converter map
11321135
converter_map = getattr(self, "_cached_converter_map", None)
11331136

1134-
return column_map, converter_map
1137+
return column_map, converter_map, self._cached_column_map_lower
11351138

11361139
def _map_data_type(self, sql_type):
11371140
"""
@@ -1547,12 +1550,18 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
15471550
self._cached_column_map = {
15481551
col_desc[0]: i for i, col_desc in enumerate(self.description)
15491552
}
1553+
self._cached_column_map_lower = (
1554+
{k.lower(): v for k, v in self._cached_column_map.items()}
1555+
if get_settings().lowercase
1556+
else None
1557+
)
15501558
self._cached_converter_map = self._build_converter_map()
15511559
self._uuid_str_indices = self._compute_uuid_str_indices()
15521560
else:
15531561
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
15541562
self._clear_rownumber()
15551563
self._cached_column_map = None
1564+
self._cached_column_map_lower = None
15561565
self._cached_converter_map = None
15571566
self._uuid_str_indices = None
15581567

@@ -2451,12 +2460,18 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
24512460
self._cached_column_map = {
24522461
col_desc[0]: i for i, col_desc in enumerate(self.description)
24532462
}
2463+
self._cached_column_map_lower = (
2464+
{k.lower(): v for k, v in self._cached_column_map.items()}
2465+
if get_settings().lowercase
2466+
else None
2467+
)
24542468
self._cached_converter_map = self._build_converter_map()
24552469
self._uuid_str_indices = self._compute_uuid_str_indices()
24562470
else:
24572471
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
24582472
self._clear_rownumber()
24592473
self._cached_column_map = None
2474+
self._cached_column_map_lower = None
24602475
self._cached_converter_map = None
24612476
self._uuid_str_indices = None
24622477
finally:
@@ -2506,13 +2521,14 @@ def fetchone(self) -> Union[None, Row]:
25062521
self.rowcount = self._next_row_index
25072522

25082523
# Get column and converter maps
2509-
column_map, converter_map = self._get_column_and_converter_maps()
2524+
column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
25102525
return Row(
25112526
row_data,
25122527
column_map,
25132528
cursor=self,
25142529
converter_map=converter_map,
25152530
uuid_str_indices=self._uuid_str_indices,
2531+
column_map_lower=column_map_lower,
25162532
)
25172533
except Exception as e:
25182534
# On error, don't increment rownumber - rethrow the error
@@ -2569,7 +2585,7 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
25692585
self.rowcount = self._next_row_index
25702586

25712587
# Get column and converter maps
2572-
column_map, converter_map = self._get_column_and_converter_maps()
2588+
column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
25732589

25742590
# Convert raw data to Row objects
25752591
uuid_idx = self._uuid_str_indices
@@ -2580,6 +2596,7 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
25802596
cursor=self,
25812597
converter_map=converter_map,
25822598
uuid_str_indices=uuid_idx,
2599+
column_map_lower=column_map_lower,
25832600
)
25842601
for row_data in rows_data
25852602
]
@@ -2630,7 +2647,7 @@ def fetchall(self) -> List[Row]:
26302647
self.rowcount = self._next_row_index
26312648

26322649
# Get column and converter maps
2633-
column_map, converter_map = self._get_column_and_converter_maps()
2650+
column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
26342651

26352652
# Convert raw data to Row objects
26362653
uuid_idx = self._uuid_str_indices
@@ -2641,6 +2658,7 @@ def fetchall(self) -> List[Row]:
26412658
cursor=self,
26422659
converter_map=converter_map,
26432660
uuid_str_indices=uuid_idx,
2661+
column_map_lower=column_map_lower,
26442662
)
26452663
for row_data in rows_data
26462664
]
@@ -2754,6 +2772,7 @@ def nextset(self) -> Union[bool, None]:
27542772

27552773
# Clear cached column and converter maps for the new result set
27562774
self._cached_column_map = None
2775+
self._cached_column_map_lower = None
27572776
self._cached_converter_map = None
27582777
self._uuid_str_indices = None
27592778

@@ -2780,6 +2799,11 @@ def nextset(self) -> Union[bool, None]:
27802799
self._cached_column_map = {
27812800
col_desc[0]: i for i, col_desc in enumerate(self.description)
27822801
}
2802+
self._cached_column_map_lower = (
2803+
{k.lower(): v for k, v in self._cached_column_map.items()}
2804+
if get_settings().lowercase
2805+
else None
2806+
)
27832807
self._cached_converter_map = self._build_converter_map()
27842808
self._uuid_str_indices = self._compute_uuid_str_indices()
27852809
except Exception as e: # pylint: disable=broad-exception-caught

mssql_python/row.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import decimal
99
import uuid as _uuid
1010
from typing import Any
11-
from mssql_python.helpers import get_settings
1211
from mssql_python.logging import logger
1312

1413

@@ -27,7 +26,15 @@ class Row:
2726
print(row.column_name) # Access by column name (case sensitivity varies)
2827
"""
2928

30-
def __init__(self, values, column_map, cursor=None, converter_map=None, uuid_str_indices=None):
29+
def __init__(
30+
self,
31+
values,
32+
column_map,
33+
cursor=None,
34+
converter_map=None,
35+
uuid_str_indices=None,
36+
column_map_lower=None,
37+
):
3138
"""
3239
Initialize a Row object with values and pre-built column map.
3340
Args:
@@ -38,6 +45,9 @@ def __init__(self, values, column_map, cursor=None, converter_map=None, uuid_str
3845
uuid_str_indices: Tuple of column indices whose uuid.UUID values should be
3946
converted to str. Pre-computed once per result set when native_uuid=False.
4047
None means no conversion (native_uuid=True, the default).
48+
column_map_lower: Pre-built lowercase column map for O(1) case-insensitive
49+
lookups. Built once per result set in the cursor when lowercase is enabled;
50+
None when lowercase is off (the default). Shared across all rows.
4151
"""
4252
# Apply output converters if available using pre-computed converter map
4353
if converter_map:
@@ -60,6 +70,9 @@ def __init__(self, values, column_map, cursor=None, converter_map=None, uuid_str
6070

6171
self._column_map = column_map
6272
self._cursor = cursor
73+
# Lowercase map is pre-built once per result set in the cursor and shared
74+
# across all rows. None when lowercase is off (the default) — zero cost.
75+
self._column_map_lower = column_map_lower
6376

6477
def _stringify_uuids(self, indices):
6578
"""
@@ -156,9 +169,22 @@ def _apply_output_converters_optimized(self, values, converter_map):
156169

157170
return converted_values
158171

159-
def __getitem__(self, index: int) -> Any:
160-
"""Allow accessing by numeric index: row[0]"""
161-
return self._values[index]
172+
def __getitem__(self, index) -> Any:
173+
"""Allow accessing by numeric index (row[0]) or column name (row["col"])."""
174+
if isinstance(index, str):
175+
if index in self._column_map:
176+
return self._values[self._column_map[index]]
177+
# O(1) case-insensitive lookup when lowercase is enabled
178+
if self._column_map_lower is not None:
179+
idx = self._column_map_lower.get(index.lower())
180+
if idx is not None:
181+
return self._values[idx]
182+
raise KeyError(f"Row has no column '{index}'")
183+
if isinstance(index, (int, slice)):
184+
return self._values[index]
185+
raise TypeError(
186+
f"Row indices must be integers, slices, or strings, not {type(index).__name__}"
187+
)
162188

163189
def __getattr__(self, name: str) -> Any:
164190
"""
@@ -175,12 +201,11 @@ def __getattr__(self, name: str) -> Any:
175201
if name in self._column_map:
176202
return self._values[self._column_map[name]]
177203

178-
# If lowercase is enabled on the cursor, try case-insensitive lookup
179-
if hasattr(self._cursor, "lowercase") and self._cursor.lowercase:
180-
name_lower = name.lower()
181-
for col_name in self._column_map:
182-
if col_name.lower() == name_lower:
183-
return self._values[self._column_map[col_name]]
204+
# O(1) case-insensitive lookup when lowercase is enabled
205+
if self._column_map_lower is not None:
206+
idx = self._column_map_lower.get(name.lower())
207+
if idx is not None:
208+
return self._values[idx]
184209

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

tests/test_001_globals.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,3 +996,66 @@ def test_stringify_uuids_with_tuple_values():
996996
assert row[2] == "hello"
997997
# Internal storage should now be a list (converted from tuple)
998998
assert isinstance(row._values, list)
999+
1000+
1001+
def test_row_string_key_indexing():
1002+
"""Test Row supports string-key indexing via __getitem__ (row['col'])."""
1003+
from mssql_python.row import Row
1004+
1005+
row = Row(
1006+
[1, "foo", 3.14],
1007+
{"ProductID": 0, "Name": 1, "Price": 2},
1008+
cursor=None,
1009+
)
1010+
1011+
# String-key access
1012+
assert row["ProductID"] == 1
1013+
assert row["Name"] == "foo"
1014+
assert row["Price"] == 3.14
1015+
1016+
# Integer index access still works
1017+
assert row[0] == 1
1018+
assert row[1] == "foo"
1019+
assert row[2] == 3.14
1020+
1021+
# Slice access still works
1022+
assert row[0:2] == [1, "foo"]
1023+
1024+
# Missing key raises KeyError
1025+
with pytest.raises(KeyError):
1026+
row["nonexistent"]
1027+
1028+
# Unsupported index types raise TypeError
1029+
with pytest.raises(TypeError):
1030+
row[3.5]
1031+
with pytest.raises(TypeError):
1032+
row[None]
1033+
1034+
1035+
def test_row_string_key_case_insensitive_with_lowercase():
1036+
"""Test Row string-key indexing is case-insensitive when column_map_lower is provided."""
1037+
from mssql_python.row import Row
1038+
1039+
column_map = {"productid": 0, "name": 1}
1040+
column_map_lower = {k.lower(): v for k, v in column_map.items()}
1041+
1042+
row = Row(
1043+
[1, "bar"],
1044+
column_map,
1045+
cursor=None,
1046+
column_map_lower=column_map_lower,
1047+
)
1048+
1049+
# Exact match via __getitem__
1050+
assert row["productid"] == 1
1051+
# Exact match via __getattr__
1052+
assert row.productid == 1
1053+
# Case-insensitive match via __getitem__
1054+
assert row["ProductID"] == 1
1055+
assert row["NAME"] == "bar"
1056+
# Case-insensitive match via __getattr__ (attribute access)
1057+
assert row.ProductID == 1
1058+
assert row.NAME == "bar"
1059+
# Non-existent attribute raises AttributeError
1060+
with pytest.raises(AttributeError):
1061+
row.nonexistent

tests/test_004_cursor.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2876,6 +2876,44 @@ def test_row_attribute_access(cursor, db_connection):
28762876
db_connection.commit()
28772877

28782878

2879+
def test_row_string_key_indexing(cursor, db_connection):
2880+
"""Test accessing row values by column name as string key: row['col']"""
2881+
try:
2882+
cursor.execute(
2883+
"CREATE TABLE #pytest_row_strkey (id INT PRIMARY KEY, name VARCHAR(50), age INT)"
2884+
)
2885+
db_connection.commit()
2886+
2887+
cursor.execute("INSERT INTO #pytest_row_strkey (id, name, age) VALUES (1, 'Alice', 25)")
2888+
db_connection.commit()
2889+
2890+
cursor.execute("SELECT * FROM #pytest_row_strkey")
2891+
row = cursor.fetchone()
2892+
2893+
# String-key access
2894+
assert row["id"] == 1, "Failed to access 'id' by string key"
2895+
assert row["name"] == "Alice", "Failed to access 'name' by string key"
2896+
assert row["age"] == 25, "Failed to access 'age' by string key"
2897+
2898+
# Consistency with index and attribute access
2899+
assert row["id"] == row[0] == row.id
2900+
assert row["name"] == row[1] == row.name
2901+
assert row["age"] == row[2] == row.age
2902+
2903+
# Non-existent key raises KeyError
2904+
with pytest.raises(KeyError):
2905+
row["nonexistent"]
2906+
2907+
except Exception as e:
2908+
pytest.fail(f"Row string-key indexing test failed: {e}")
2909+
finally:
2910+
try:
2911+
cursor.execute("DROP TABLE IF EXISTS #pytest_row_strkey")
2912+
db_connection.commit()
2913+
except Exception:
2914+
pass
2915+
2916+
28792917
def test_row_comparison_with_list(cursor, db_connection):
28802918
"""Test comparing Row objects with lists (__eq__ method)"""
28812919
try:

0 commit comments

Comments
 (0)