From a4852adfac5dddab38e7df477b388d5c5924bfa3 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 1 Jun 2026 21:47:42 +0530 Subject: [PATCH 1/3] FEAT: Add to_dict(), keys(), values(), items(), __contains__ to Row (GH-606) --- mssql_python/row.py | 25 ++++++++++++++++ tests/test_001_globals.py | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/mssql_python/row.py b/mssql_python/row.py index 8ebe0dab..db0b2467 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -209,6 +209,31 @@ def __getattr__(self, name: str) -> Any: raise AttributeError(f"Row has no attribute '{name}'") + def keys(self): + """Return column names, like dict.keys().""" + return self._column_map.keys() + + def values(self): + """Return column values, like dict.values().""" + return self._values + + def items(self): + """Return (column_name, value) pairs, like dict.items().""" + return ((name, self._values[idx]) for name, idx in self._column_map.items()) + + def to_dict(self): + """Return the row as a plain dict mapping column names to values.""" + return {name: self._values[idx] for name, idx in self._column_map.items()} + + def __contains__(self, key) -> bool: + """Support 'col_name in row' membership testing.""" + if isinstance(key, str): + if key in self._column_map: + return True + if self._column_map_lower is not None: + return key.lower() in self._column_map_lower + return False + def __eq__(self, other: Any) -> bool: """ Support comparison with lists for test compatibility. diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 08d31b5a..3025ff5b 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -1059,3 +1059,66 @@ def test_row_string_key_case_insensitive_with_lowercase(): # Non-existent attribute raises AttributeError with pytest.raises(AttributeError): row.nonexistent + + +def test_row_to_dict(): + """Test Row.to_dict() returns a plain dict of column names to values.""" + from mssql_python.row import Row + + row = Row( + [1, "foo", 3.14], + {"ProductID": 0, "Name": 1, "Price": 2}, + cursor=None, + ) + + d = row.to_dict() + assert d == {"ProductID": 1, "Name": "foo", "Price": 3.14} + assert isinstance(d, dict) + + +def test_row_keys_values_items(): + """Test Row.keys(), values(), and items() behave like dict counterparts.""" + from mssql_python.row import Row + + column_map = {"id": 0, "name": 1} + row = Row([42, "Alice"], column_map, cursor=None) + + # keys() + assert list(row.keys()) == ["id", "name"] + + # values() + assert list(row.values()) == [42, "Alice"] + + # items() + assert list(row.items()) == [("id", 42), ("name", "Alice")] + + +def test_row_contains(): + """Test 'column_name in row' membership testing.""" + from mssql_python.row import Row + + row = Row( + [1, "foo"], + {"ProductID": 0, "Name": 1}, + cursor=None, + ) + + assert "ProductID" in row + assert "Name" in row + assert "nonexistent" not in row + # Integer is not a column name + assert 0 not in row + + +def test_row_contains_case_insensitive(): + """Test 'in' operator is case-insensitive when column_map_lower is provided.""" + from mssql_python.row import Row + + column_map = {"productid": 0, "name": 1} + column_map_lower = {k.lower(): v for k, v in column_map.items()} + row = Row([1, "bar"], column_map, cursor=None, column_map_lower=column_map_lower) + + assert "productid" in row + assert "ProductID" in row + assert "NAME" in row + assert "missing" not in row From d21846e0591f638a23eb7c23b40ec2e8d927b9c7 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 2 Jun 2026 08:54:57 +0530 Subject: [PATCH 2/3] FIX: Deduplicate dict-like methods when _column_map has lowercase aliases --- mssql_python/row.py | 22 +++++++++++++++++++--- tests/test_001_globals.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/mssql_python/row.py b/mssql_python/row.py index db0b2467..5e1f99d2 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -73,6 +73,22 @@ 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 in ordinal order, deduplicated. + # cursor.description is the authoritative source; _column_map may + # contain lowercase aliases injected by _prepare_metadata_result_set. + if cursor and hasattr(cursor, "description") and cursor.description: + self._column_names = tuple(desc[0] for desc in cursor.description) + elif column_map is not None: + # Fallback: deduplicate _column_map by keeping first name per index + idx_to_name: dict = {} + for name, idx in column_map.items(): + if idx not in idx_to_name: + idx_to_name[idx] = name + self._column_names = tuple( + idx_to_name[i] for i in sorted(idx_to_name) + ) + else: + self._column_names = () def _stringify_uuids(self, indices): """ @@ -211,7 +227,7 @@ def __getattr__(self, name: str) -> Any: def keys(self): """Return column names, like dict.keys().""" - return self._column_map.keys() + return self._column_names def values(self): """Return column values, like dict.values().""" @@ -219,11 +235,11 @@ def values(self): def items(self): """Return (column_name, value) pairs, like dict.items().""" - return ((name, self._values[idx]) for name, idx in self._column_map.items()) + return zip(self._column_names, self._values) def to_dict(self): """Return the row as a plain dict mapping column names to values.""" - return {name: self._values[idx] for name, idx in self._column_map.items()} + return dict(zip(self._column_names, self._values)) def __contains__(self, key) -> bool: """Support 'col_name in row' membership testing.""" diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 3025ff5b..7d25a8d4 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -1093,6 +1093,44 @@ def test_row_keys_values_items(): assert list(row.items()) == [("id", 42), ("name", "Alice")] +def test_row_dict_methods_with_lowercase_aliases(): + """Test dict-like methods deduplicate when _column_map contains lowercase aliases. + + The cursor's _prepare_metadata_result_set injects both original-cased and + lowercase entries (e.g. {"ProductID": 0, "productid": 0}). The dict methods + must return N entries (not 2N) and preserve original casing. + """ + from mssql_python.row import Row + + # Simulate what _prepare_metadata_result_set produces: + # original name + lowercase alias per column + column_map = {"ProductID": 0, "productid": 0, "Name": 1, "name": 1} + row = Row([1, "foo"], column_map, cursor=None) + + # keys() — N entries, original casing preserved + keys = list(row.keys()) + assert len(keys) == 2 + assert keys == ["ProductID", "Name"] + + # values() — N entries matching the values + assert list(row.values()) == [1, "foo"] + + # items() — N pairs + items = list(row.items()) + assert len(items) == 2 + assert items == [("ProductID", 1), ("Name", "foo")] + + # to_dict() — N entries, original casing as keys + d = row.to_dict() + assert len(d) == 2 + assert d == {"ProductID": 1, "Name": "foo"} + + # len consistency: keys, values, items all match len(row) + assert len(keys) == len(row) + assert len(list(row.values())) == len(row) + assert len(items) == len(row) + + def test_row_contains(): """Test 'column_name in row' membership testing.""" from mssql_python.row import Row From 757a5503dc94e84a8a6539dc98552f99e6aed819 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 2 Jun 2026 08:55:59 +0530 Subject: [PATCH 3/3] Linting fix --- mssql_python/row.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mssql_python/row.py b/mssql_python/row.py index 5e1f99d2..173f0e87 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -84,9 +84,7 @@ def __init__( for name, idx in column_map.items(): if idx not in idx_to_name: idx_to_name[idx] = name - self._column_names = tuple( - idx_to_name[i] for i in sorted(idx_to_name) - ) + self._column_names = tuple(idx_to_name[i] for i in sorted(idx_to_name)) else: self._column_names = ()