diff --git a/mssql_python/row.py b/mssql_python/row.py index 8ebe0dab..173f0e87 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -73,6 +73,20 @@ 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): """ @@ -209,6 +223,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_names + + def values(self): + """Return column values, like dict.values().""" + return self._values + + def items(self): + """Return (column_name, value) pairs, like dict.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 dict(zip(self._column_names, self._values)) + + 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..7d25a8d4 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -1059,3 +1059,104 @@ 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_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 + + 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