From a4852adfac5dddab38e7df477b388d5c5924bfa3 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 1 Jun 2026 21:47:42 +0530 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 = () From 75998e2877cdde3b33abd5ed5297c97e522e5fec Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 08:24:25 +0530 Subject: [PATCH 04/11] FIX: Move _column_names to cursor for zero per-row cost, fix items() reusability, add integration tests --- mssql_python/cursor.py | 24 +++++- mssql_python/row.py | 23 ++++-- tests/test_001_globals.py | 56 ++++++++++++++ tests/test_004_cursor.py | 150 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 11 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 244afa9c..31f5e4fb 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -1146,6 +1146,7 @@ def _get_column_and_converter_maps(self): self._cached_column_map_lower = ( {k.lower(): v for k, v in column_map.items()} if get_settings().lowercase else None ) + self._cached_column_names = tuple(desc[0] for desc in self.description) # Fallback to legacy column name map if no cached map column_map = column_map or getattr(self, "_column_name_map", None) @@ -1153,7 +1154,7 @@ def _get_column_and_converter_maps(self): # Get cached converter map converter_map = getattr(self, "_cached_converter_map", None) - return column_map, converter_map, self._cached_column_map_lower + return column_map, converter_map, self._cached_column_map_lower, self._cached_column_names def _map_data_type(self, sql_type): """ @@ -1568,6 +1569,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state if get_settings().lowercase else None ) + self._cached_column_names = tuple(desc[0] for desc in self.description) self._cached_converter_map = self._build_converter_map() self._uuid_str_indices = self._compute_uuid_str_indices() else: @@ -1575,6 +1577,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state self._clear_rownumber() self._cached_column_map = None self._cached_column_map_lower = None + self._cached_column_names = None self._cached_converter_map = None self._uuid_str_indices = None @@ -2497,6 +2500,7 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s if get_settings().lowercase else None ) + self._cached_column_names = tuple(desc[0] for desc in self.description) self._cached_converter_map = self._build_converter_map() self._uuid_str_indices = self._compute_uuid_str_indices() else: @@ -2504,6 +2508,7 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s self._clear_rownumber() self._cached_column_map = None self._cached_column_map_lower = None + self._cached_column_names = None self._cached_converter_map = None self._uuid_str_indices = None finally: @@ -2553,7 +2558,9 @@ def fetchone(self) -> Union[None, Row]: self.rowcount = self._next_row_index # Get column and converter maps - column_map, converter_map, column_map_lower = self._get_column_and_converter_maps() + column_map, converter_map, column_map_lower, column_names = ( + self._get_column_and_converter_maps() + ) return Row( row_data, column_map, @@ -2561,6 +2568,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=column_names, ) except Exception as e: # On error, don't increment rownumber - rethrow the error @@ -2617,7 +2625,9 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]: self.rowcount = self._next_row_index # Get column and converter maps - column_map, converter_map, column_map_lower = self._get_column_and_converter_maps() + column_map, converter_map, column_map_lower, column_names = ( + self._get_column_and_converter_maps() + ) # Convert raw data to Row objects uuid_idx = self._uuid_str_indices @@ -2629,6 +2639,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=column_names, ) for row_data in rows_data ] @@ -2679,7 +2690,9 @@ def fetchall(self) -> List[Row]: self.rowcount = self._next_row_index # Get column and converter maps - column_map, converter_map, column_map_lower = self._get_column_and_converter_maps() + column_map, converter_map, column_map_lower, column_names = ( + self._get_column_and_converter_maps() + ) # Convert raw data to Row objects uuid_idx = self._uuid_str_indices @@ -2691,6 +2704,7 @@ def fetchall(self) -> List[Row]: converter_map=converter_map, uuid_str_indices=uuid_idx, column_map_lower=column_map_lower, + column_names=column_names, ) for row_data in rows_data ] @@ -2809,6 +2823,7 @@ def nextset(self) -> Optional[bool]: # Clear cached column and converter maps for the new result set self._cached_column_map = None self._cached_column_map_lower = None + self._cached_column_names = None self._cached_converter_map = None self._uuid_str_indices = None @@ -2846,6 +2861,7 @@ def nextset(self) -> Optional[bool]: if get_settings().lowercase else None ) + self._cached_column_names = tuple(desc[0] for desc in self.description) self._cached_converter_map = self._build_converter_map() self._uuid_str_indices = self._compute_uuid_str_indices() except Exception as e: # pylint: disable=broad-exception-caught diff --git a/mssql_python/row.py b/mssql_python/row.py index 173f0e87..2d220c22 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -34,6 +34,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. @@ -48,6 +49,9 @@ 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: Pre-built tuple of deduplicated column names in ordinal order. + Built once per result set in the cursor and shared across all rows. + When None, falls back to deduplicating column_map (for unit tests). """ # Apply output converters if available using pre-computed converter map if converter_map: @@ -74,10 +78,10 @@ def __init__( # 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) + # Pre-built once per result set in the cursor and shared across all rows. + # Falls back to deduplicating column_map when not provided (e.g. unit tests). + if column_names is not None: + self._column_names = column_names elif column_map is not None: # Fallback: deduplicate _column_map by keeping first name per index idx_to_name: dict = {} @@ -233,16 +237,21 @@ def values(self): def items(self): """Return (column_name, value) pairs, like dict.items().""" - return zip(self._column_names, self._values) + return list(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.""" + """Support 'col_name in row' membership testing. + + Uses the full _column_map (which includes lowercase aliases), + so 'ProductID' in row and 'productid' in row both return True + even though keys() only lists the original-cased name. + """ if isinstance(key, str): - if key in self._column_map: + if self._column_map and key in self._column_map: return True if self._column_map_lower is not None: return key.lower() in self._column_map_lower diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 7d25a8d4..9612ec6c 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -1160,3 +1160,59 @@ def test_row_contains_case_insensitive(): assert "ProductID" in row assert "NAME" in row assert "missing" not in row + + +def test_row_dict_methods_with_column_names_param(): + """Test dict methods use pre-built column_names from cursor (zero per-row cost).""" + from mssql_python.row import Row + + # Simulate cursor passing column_names alongside a column_map with aliases + column_map = {"ProductID": 0, "productid": 0, "Name": 1, "name": 1} + column_names = ("ProductID", "Name") # pre-built by cursor + + row = Row([1, "foo"], column_map, cursor=None, column_names=column_names) + + assert list(row.keys()) == ["ProductID", "Name"] + assert row.to_dict() == {"ProductID": 1, "Name": "foo"} + assert list(row.items()) == [("ProductID", 1), ("Name", "foo")] + + +def test_row_dict_methods_with_none_column_map(): + """Test dict methods when column_map is None (empty row).""" + from mssql_python.row import Row + + row = Row([], None, cursor=None) + + assert list(row.keys()) == [] + assert list(row.values()) == [] + assert list(row.items()) == [] + assert row.to_dict() == {} + assert "anything" not in row + + +def test_row_items_is_reusable(): + """Test items() returns a reusable list, not a one-shot iterator.""" + from mssql_python.row import Row + + row = Row([1, "foo"], {"id": 0, "name": 1}, cursor=None) + + items = row.items() + assert list(items) == [("id", 1), ("name", "foo")] + # Second iteration should produce the same result (not empty) + assert list(items) == [("id", 1), ("name", "foo")] + + +def test_row_dict_methods_with_mock_cursor_description(): + """Test that when column_names is provided (as cursor would), it's used directly.""" + from mssql_python.row import Row + + # column_map has aliases, but column_names is authoritative + column_map = {"MyCol": 0, "mycol": 0, "Score": 1, "score": 1} + column_names = ("MyCol", "Score") + + row = Row([42, 99.5], column_map, cursor=None, column_names=column_names) + + assert list(row.keys()) == ["MyCol", "Score"] + assert len(row.keys()) == len(row) + assert len(row.to_dict()) == len(row) + assert row.to_dict() == {"MyCol": 42, "Score": 99.5} diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index d39f42ae..883e7c2c 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2914,6 +2914,156 @@ def test_row_string_key_indexing(cursor, db_connection): pass +def test_row_to_dict(cursor, db_connection): + """Test Row.to_dict() returns a plain dict from a real cursor row.""" + try: + cursor.execute( + "CREATE TABLE #pytest_row_todict (id INT PRIMARY KEY, name VARCHAR(50), price FLOAT)" + ) + db_connection.commit() + + cursor.execute("INSERT INTO #pytest_row_todict VALUES (1, 'Widget', 9.99)") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_todict") + row = cursor.fetchone() + + d = row.to_dict() + assert isinstance(d, dict) + assert d["id"] == 1 + assert d["name"] == "Widget" + assert d["price"] == 9.99 + assert len(d) == len(row) + + except Exception as e: + pytest.fail(f"Row to_dict test failed: {e}") + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_todict") + db_connection.commit() + except Exception: + pass + + +def test_row_keys_values_items(cursor, db_connection): + """Test Row.keys(), values(), items() from a real cursor row.""" + try: + cursor.execute( + "CREATE TABLE #pytest_row_kvi (id INT PRIMARY KEY, name VARCHAR(50))" + ) + db_connection.commit() + + cursor.execute("INSERT INTO #pytest_row_kvi VALUES (42, 'Alice')") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_kvi") + row = cursor.fetchone() + + # keys() returns column names matching description + keys = list(row.keys()) + assert len(keys) == 2 + assert keys == [desc[0] for desc in cursor.description] + + # values() matches positional access + vals = list(row.values()) + assert vals == [row[0], row[1]] + + # items() returns (name, value) pairs + items = row.items() + assert len(items) == 2 + assert items[0] == (keys[0], row[0]) + assert items[1] == (keys[1], row[1]) + + # items() is reusable (not one-shot iterator) + assert list(items) == list(items) + + # len consistency + assert len(keys) == len(row) + assert len(vals) == len(row) + assert len(items) == len(row) + + except Exception as e: + pytest.fail(f"Row keys/values/items test failed: {e}") + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_kvi") + db_connection.commit() + except Exception: + pass + + +def test_row_contains(cursor, db_connection): + """Test 'column_name in row' membership testing from a real cursor row.""" + try: + cursor.execute( + "CREATE TABLE #pytest_row_contains (ProductID INT, Name VARCHAR(50))" + ) + db_connection.commit() + + cursor.execute("INSERT INTO #pytest_row_contains VALUES (1, 'foo')") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_contains") + row = cursor.fetchone() + + # Column names from description should be found + for desc in cursor.description: + assert desc[0] in row, f"Column '{desc[0]}' not found in row" + + # Non-existent column + assert "nonexistent" not in row + + # Integer is not a column name + assert 0 not in row + + except Exception as e: + pytest.fail(f"Row __contains__ test failed: {e}") + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_contains") + db_connection.commit() + except Exception: + pass + + +def test_row_dict_no_duplicate_keys(cursor, db_connection): + """Test that dict-like methods don't produce duplicate keys from cursor rows. + + The cursor may inject lowercase aliases into _column_map, but keys(), + items(), and to_dict() must return exactly N entries with original casing. + """ + try: + cursor.execute( + "CREATE TABLE #pytest_row_nodup (ProductID INT, MixedCase VARCHAR(20))" + ) + db_connection.commit() + + cursor.execute("INSERT INTO #pytest_row_nodup VALUES (1, 'test')") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_nodup") + row = cursor.fetchone() + + keys = list(row.keys()) + items = row.items() + d = row.to_dict() + + # Exactly 2 columns, no duplicates + assert len(keys) == 2 + assert len(items) == 2 + assert len(d) == 2 + assert len(keys) == len(row) + + except Exception as e: + pytest.fail(f"Row dict no-duplicate-keys test failed: {e}") + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_nodup") + db_connection.commit() + except Exception: + pass + + def test_row_comparison_with_list(cursor, db_connection): """Test comparing Row objects with lists (__eq__ method)""" try: From 31f3d471f8fdc6dc13690e7be21dfb8519b162f9 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 08:36:54 +0530 Subject: [PATCH 05/11] REFACTOR: Move all Row tests from globals to cursor integration tests --- tests/test_001_globals.py | 220 -------------------------------------- tests/test_004_cursor.py | 97 +++++++++++++++-- 2 files changed, 88 insertions(+), 229 deletions(-) diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 9612ec6c..9e44e011 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -996,223 +996,3 @@ def test_stringify_uuids_with_tuple_values(): assert row[2] == "hello" # Internal storage should now be a list (converted from tuple) assert isinstance(row._values, list) - - -def test_row_string_key_indexing(): - """Test Row supports string-key indexing via __getitem__ (row['col']).""" - from mssql_python.row import Row - - row = Row( - [1, "foo", 3.14], - {"ProductID": 0, "Name": 1, "Price": 2}, - cursor=None, - ) - - # String-key access - assert row["ProductID"] == 1 - assert row["Name"] == "foo" - assert row["Price"] == 3.14 - - # Integer index access still works - assert row[0] == 1 - assert row[1] == "foo" - assert row[2] == 3.14 - - # Slice access still works - assert row[0:2] == [1, "foo"] - - # Missing key raises KeyError - with pytest.raises(KeyError): - row["nonexistent"] - - # Unsupported index types raise TypeError - with pytest.raises(TypeError): - row[3.5] - with pytest.raises(TypeError): - row[None] - - -def test_row_string_key_case_insensitive_with_lowercase(): - """Test Row string-key indexing 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, - ) - - # Exact match via __getitem__ - assert row["productid"] == 1 - # Exact match via __getattr__ - assert row.productid == 1 - # Case-insensitive match via __getitem__ - assert row["ProductID"] == 1 - assert row["NAME"] == "bar" - # Case-insensitive match via __getattr__ (attribute access) - assert row.ProductID == 1 - assert row.NAME == "bar" - # 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 - - -def test_row_dict_methods_with_column_names_param(): - """Test dict methods use pre-built column_names from cursor (zero per-row cost).""" - from mssql_python.row import Row - - # Simulate cursor passing column_names alongside a column_map with aliases - column_map = {"ProductID": 0, "productid": 0, "Name": 1, "name": 1} - column_names = ("ProductID", "Name") # pre-built by cursor - - row = Row([1, "foo"], column_map, cursor=None, column_names=column_names) - - assert list(row.keys()) == ["ProductID", "Name"] - assert row.to_dict() == {"ProductID": 1, "Name": "foo"} - assert list(row.items()) == [("ProductID", 1), ("Name", "foo")] - - -def test_row_dict_methods_with_none_column_map(): - """Test dict methods when column_map is None (empty row).""" - from mssql_python.row import Row - - row = Row([], None, cursor=None) - - assert list(row.keys()) == [] - assert list(row.values()) == [] - assert list(row.items()) == [] - assert row.to_dict() == {} - assert "anything" not in row - - -def test_row_items_is_reusable(): - """Test items() returns a reusable list, not a one-shot iterator.""" - from mssql_python.row import Row - - row = Row([1, "foo"], {"id": 0, "name": 1}, cursor=None) - - items = row.items() - assert list(items) == [("id", 1), ("name", "foo")] - # Second iteration should produce the same result (not empty) - assert list(items) == [("id", 1), ("name", "foo")] - - -def test_row_dict_methods_with_mock_cursor_description(): - """Test that when column_names is provided (as cursor would), it's used directly.""" - from mssql_python.row import Row - - # column_map has aliases, but column_names is authoritative - column_map = {"MyCol": 0, "mycol": 0, "Score": 1, "score": 1} - column_names = ("MyCol", "Score") - - row = Row([42, 99.5], column_map, cursor=None, column_names=column_names) - - assert list(row.keys()) == ["MyCol", "Score"] - assert len(row.keys()) == len(row) - assert len(row.to_dict()) == len(row) - assert row.to_dict() == {"MyCol": 42, "Score": 99.5} diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 883e7c2c..a7bd01f1 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2948,9 +2948,7 @@ def test_row_to_dict(cursor, db_connection): def test_row_keys_values_items(cursor, db_connection): """Test Row.keys(), values(), items() from a real cursor row.""" try: - cursor.execute( - "CREATE TABLE #pytest_row_kvi (id INT PRIMARY KEY, name VARCHAR(50))" - ) + cursor.execute("CREATE TABLE #pytest_row_kvi (id INT PRIMARY KEY, name VARCHAR(50))") db_connection.commit() cursor.execute("INSERT INTO #pytest_row_kvi VALUES (42, 'Alice')") @@ -2995,9 +2993,7 @@ def test_row_keys_values_items(cursor, db_connection): def test_row_contains(cursor, db_connection): """Test 'column_name in row' membership testing from a real cursor row.""" try: - cursor.execute( - "CREATE TABLE #pytest_row_contains (ProductID INT, Name VARCHAR(50))" - ) + cursor.execute("CREATE TABLE #pytest_row_contains (ProductID INT, Name VARCHAR(50))") db_connection.commit() cursor.execute("INSERT INTO #pytest_row_contains VALUES (1, 'foo')") @@ -3033,9 +3029,7 @@ def test_row_dict_no_duplicate_keys(cursor, db_connection): items(), and to_dict() must return exactly N entries with original casing. """ try: - cursor.execute( - "CREATE TABLE #pytest_row_nodup (ProductID INT, MixedCase VARCHAR(20))" - ) + cursor.execute("CREATE TABLE #pytest_row_nodup (ProductID INT, MixedCase VARCHAR(20))") db_connection.commit() cursor.execute("INSERT INTO #pytest_row_nodup VALUES (1, 'test')") @@ -3064,6 +3058,91 @@ def test_row_dict_no_duplicate_keys(cursor, db_connection): pass +def test_row_getitem_type_guard(cursor, db_connection): + """Test Row.__getitem__ raises TypeError for unsupported index types.""" + try: + cursor.execute("CREATE TABLE #pytest_row_typeguard (id INT)") + db_connection.commit() + cursor.execute("INSERT INTO #pytest_row_typeguard VALUES (1)") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_typeguard") + row = cursor.fetchone() + + with pytest.raises(TypeError): + row[3.5] + with pytest.raises(TypeError): + row[None] + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_typeguard") + db_connection.commit() + except Exception: + pass + + +def test_row_case_insensitive_access(cursor, db_connection): + """Test case-insensitive __getitem__, __getattr__, and __contains__ via cursor rows.""" + try: + cursor.execute("CREATE TABLE #pytest_row_ci (ProductID INT, Name VARCHAR(50))") + db_connection.commit() + cursor.execute("INSERT INTO #pytest_row_ci VALUES (1, 'bar')") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_ci") + row = cursor.fetchone() + + # Original casing works via all access methods + assert row["ProductID"] == 1 + assert row.ProductID == 1 + assert "ProductID" in row + + # Lowercase alias works (cursor injects lowercase aliases into _column_map) + assert row["productid"] == 1 + assert row.productid == 1 + assert "productid" in row + + # Non-existent + assert "nonexistent" not in row + with pytest.raises(KeyError): + row["nonexistent"] + with pytest.raises(AttributeError): + row.nonexistent + + except Exception as e: + pytest.fail(f"Row case-insensitive access test failed: {e}") + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_ci") + db_connection.commit() + except Exception: + pass + + +def test_row_items_is_reusable(cursor, db_connection): + """Test items() returns a reusable list, not a one-shot iterator.""" + try: + cursor.execute("CREATE TABLE #pytest_row_reuse (id INT, name VARCHAR(50))") + db_connection.commit() + cursor.execute("INSERT INTO #pytest_row_reuse VALUES (1, 'foo')") + db_connection.commit() + + cursor.execute("SELECT * FROM #pytest_row_reuse") + row = cursor.fetchone() + + items = row.items() + first = list(items) + second = list(items) + assert first == second + assert len(first) > 0 + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_reuse") + db_connection.commit() + except Exception: + pass + + def test_row_comparison_with_list(cursor, db_connection): """Test comparing Row objects with lists (__eq__ method)""" try: From 89c3b2f5d93c3bc7ba41e54f0b2a511c3a0f53a8 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 09:33:50 +0530 Subject: [PATCH 06/11] FIX: Correct test_row_case_insensitive_access - normal SELECT has no lowercase aliases --- tests/test_004_cursor.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index a7bd01f1..2786d61a 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -3081,8 +3081,13 @@ def test_row_getitem_type_guard(cursor, db_connection): pass -def test_row_case_insensitive_access(cursor, db_connection): - """Test case-insensitive __getitem__, __getattr__, and __contains__ via cursor rows.""" +def test_row_case_sensitive_access(cursor, db_connection): + """Test exact-case access via cursor rows. + + Normal SELECT uses _cached_column_map (original casing only, no lowercase + aliases). Case-insensitive access only works when the global lowercase + setting is enabled or when rows come from metadata methods. + """ try: cursor.execute("CREATE TABLE #pytest_row_ci (ProductID INT, Name VARCHAR(50))") db_connection.commit() @@ -3097,10 +3102,9 @@ def test_row_case_insensitive_access(cursor, db_connection): assert row.ProductID == 1 assert "ProductID" in row - # Lowercase alias works (cursor injects lowercase aliases into _column_map) - assert row["productid"] == 1 - assert row.productid == 1 - assert "productid" in row + assert row["Name"] == "bar" + assert row.Name == "bar" + assert "Name" in row # Non-existent assert "nonexistent" not in row From 0917d1fbe6601f061ab185f32466d25b7fb446a7 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 10:51:37 +0530 Subject: [PATCH 07/11] TEST: Cover _column_names=() and __contains__ lowercase branches (lines 93, 257) --- tests/test_004_cursor.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index f2c5b228..958696e3 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -3290,6 +3290,36 @@ def test_row_case_sensitive_access(cursor, db_connection): pass +def test_row_none_column_map(): + """Test Row edge case with column_map=None (covers _column_names=() branch).""" + from mssql_python.row import Row + + row = Row([], None, cursor=None) + assert list(row.keys()) == [] + assert list(row.values()) == [] + assert list(row.items()) == [] + assert row.to_dict() == {} + assert "anything" not in row + + +def test_row_contains_with_lowercase_map(): + """Test __contains__ with column_map_lower (covers lowercase lookup branch).""" + from mssql_python.row import Row + + column_map = {"ProductID": 0, "Name": 1} + column_map_lower = {"productid": 0, "name": 1} + row = Row( + [1, "foo"], column_map, cursor=None, column_map_lower=column_map_lower + ) + + # Exact match hits _column_map directly + assert "ProductID" in row + # Case-insensitive match hits _column_map_lower branch + assert "productid" in row + assert "NAME" in row + assert "missing" not in row + + def test_row_items_is_reusable(cursor, db_connection): """Test items() returns a reusable list, not a one-shot iterator.""" try: From 93d9f372ccf7eac4be2142fd4d7c0466d0d3c703 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 11:12:18 +0530 Subject: [PATCH 08/11] Resolving linting issue --- tests/test_004_cursor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 958696e3..08171ea7 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -3308,9 +3308,7 @@ def test_row_contains_with_lowercase_map(): column_map = {"ProductID": 0, "Name": 1} column_map_lower = {"productid": 0, "name": 1} - row = Row( - [1, "foo"], column_map, cursor=None, column_map_lower=column_map_lower - ) + row = Row([1, "foo"], column_map, cursor=None, column_map_lower=column_map_lower) # Exact match hits _column_map directly assert "ProductID" in row From c2c6927893a1044764c8a21ddaa50eb8994c34e5 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 13:05:00 +0530 Subject: [PATCH 09/11] PERF: Lazy-compute _column_names, add type annotations, update class docstring --- mssql_python/cursor.py | 25 ++++------------- mssql_python/row.py | 63 ++++++++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 47 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 6f1c79f8..3d9ec9df 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -151,6 +151,7 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None: self._rownumber = -1 # DB-API extension: last returned row index, -1 before first self._cached_column_map = None + self._cached_column_map_lower = None self._cached_converter_map = None self._uuid_str_indices = None # Pre-computed UUID column indices for str conversion # Cache the effective native_uuid setting for this cursor's connection. @@ -1149,7 +1150,6 @@ def _get_column_and_converter_maps(self): self._cached_column_map_lower = ( {k.lower(): v for k, v in column_map.items()} if get_settings().lowercase else None ) - self._cached_column_names = tuple(desc[0] for desc in self.description) # Fallback to legacy column name map if no cached map column_map = column_map or getattr(self, "_column_name_map", None) @@ -1157,7 +1157,7 @@ def _get_column_and_converter_maps(self): # Get cached converter map converter_map = getattr(self, "_cached_converter_map", None) - return column_map, converter_map, self._cached_column_map_lower, self._cached_column_names + return column_map, converter_map, self._cached_column_map_lower def _map_data_type(self, sql_type): """ @@ -1572,7 +1572,6 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state if get_settings().lowercase else None ) - self._cached_column_names = tuple(desc[0] for desc in self.description) self._cached_converter_map = self._build_converter_map() self._uuid_str_indices = self._compute_uuid_str_indices() else: @@ -1580,7 +1579,6 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state self._clear_rownumber() self._cached_column_map = None self._cached_column_map_lower = None - self._cached_column_names = None self._cached_converter_map = None self._uuid_str_indices = None @@ -2499,7 +2497,6 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s if get_settings().lowercase else None ) - self._cached_column_names = tuple(desc[0] for desc in self.description) self._cached_converter_map = self._build_converter_map() self._uuid_str_indices = self._compute_uuid_str_indices() else: @@ -2507,7 +2504,6 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s self._clear_rownumber() self._cached_column_map = None self._cached_column_map_lower = None - self._cached_column_names = None self._cached_converter_map = None self._uuid_str_indices = None finally: @@ -2557,9 +2553,7 @@ def fetchone(self) -> Union[None, Row]: self.rowcount = self._next_row_index # Get column and converter maps - column_map, converter_map, column_map_lower, column_names = ( - self._get_column_and_converter_maps() - ) + column_map, converter_map, column_map_lower = self._get_column_and_converter_maps() return Row( row_data, column_map, @@ -2567,7 +2561,6 @@ 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=column_names, ) except Exception as e: # On error, don't increment rownumber - rethrow the error @@ -2624,9 +2617,7 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]: self.rowcount = self._next_row_index # Get column and converter maps - column_map, converter_map, column_map_lower, column_names = ( - self._get_column_and_converter_maps() - ) + column_map, converter_map, column_map_lower = self._get_column_and_converter_maps() # Convert raw data to Row objects uuid_idx = self._uuid_str_indices @@ -2638,7 +2629,6 @@ 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=column_names, ) for row_data in rows_data ] @@ -2689,9 +2679,7 @@ def fetchall(self) -> List[Row]: self.rowcount = self._next_row_index # Get column and converter maps - column_map, converter_map, column_map_lower, column_names = ( - self._get_column_and_converter_maps() - ) + column_map, converter_map, column_map_lower = self._get_column_and_converter_maps() # Convert raw data to Row objects uuid_idx = self._uuid_str_indices @@ -2703,7 +2691,6 @@ def fetchall(self) -> List[Row]: converter_map=converter_map, uuid_str_indices=uuid_idx, column_map_lower=column_map_lower, - column_names=column_names, ) for row_data in rows_data ] @@ -2822,7 +2809,6 @@ def nextset(self) -> Optional[bool]: # Clear cached column and converter maps for the new result set self._cached_column_map = None self._cached_column_map_lower = None - self._cached_column_names = None self._cached_converter_map = None self._uuid_str_indices = None @@ -2860,7 +2846,6 @@ def nextset(self) -> Optional[bool]: if get_settings().lowercase else None ) - self._cached_column_names = tuple(desc[0] for desc in self.description) self._cached_converter_map = self._build_converter_map() self._uuid_str_indices = self._compute_uuid_str_indices() except Exception as e: # pylint: disable=broad-exception-caught diff --git a/mssql_python/row.py b/mssql_python/row.py index 2d220c22..3695afc6 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -16,14 +16,21 @@ class Row: A row of data from a cursor fetch operation. Provides both tuple-like indexing and attribute access to column values. + Row supports dict-like access via keys(), values(), items(), and to_dict(). + However, iteration (for x in row) yields values, not keys — consistent with + pyodbc.Row and sqlite3.Row. Use row.keys() to iterate 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(row.to_dict()) # Convert to dict + for value in row: # Iterates values, not keys + print(value) """ def __init__( @@ -34,7 +41,6 @@ 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. @@ -49,9 +55,6 @@ 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: Pre-built tuple of deduplicated column names in ordinal order. - Built once per result set in the cursor and shared across all rows. - When None, falls back to deduplicating column_map (for unit tests). """ # Apply output converters if available using pre-computed converter map if converter_map: @@ -77,20 +80,7 @@ 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. - # Pre-built once per result set in the cursor and shared across all rows. - # Falls back to deduplicating column_map when not provided (e.g. unit tests). - if column_names is not None: - self._column_names = column_names - 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 = () + self._column_names = None # Lazy-computed on first access via _get_column_names() def _stringify_uuids(self, indices): """ @@ -227,21 +217,40 @@ def __getattr__(self, name: str) -> Any: raise AttributeError(f"Row has no attribute '{name}'") - def keys(self): + def _get_column_names(self) -> tuple: + """Lazy-compute and cache deduplicated column names on first access.""" + if self._column_names is not None: + return self._column_names + + if self._cursor and hasattr(self._cursor, "description") and self._cursor.description: + column_names = tuple(desc[0] for desc in self._cursor.description) + elif self._column_map: + idx_to_name: dict = {} + for name, idx in self._column_map.items(): + if idx not in idx_to_name: + idx_to_name[idx] = name + column_names = tuple(idx_to_name[i] for i in sorted(idx_to_name)) + else: + column_names = () + + self._column_names = column_names + return column_names + + def keys(self) -> tuple: """Return column names, like dict.keys().""" - return self._column_names + return self._get_column_names() - def values(self): + def values(self) -> list: """Return column values, like dict.values().""" return self._values - def items(self): + def items(self) -> list: """Return (column_name, value) pairs, like dict.items().""" - return list(zip(self._column_names, self._values)) + return list(zip(self._get_column_names(), self._values)) - def to_dict(self): + def to_dict(self) -> dict: """Return the row as a plain dict mapping column names to values.""" - return dict(zip(self._column_names, self._values)) + return dict(zip(self._get_column_names(), self._values)) def __contains__(self, key) -> bool: """Support 'col_name in row' membership testing. From e6142d0c3ad815a99acfe325077aed5ae037e480 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 14:16:34 +0530 Subject: [PATCH 10/11] TEST: Cover _get_column_names dedup fallback (lines 228-232) --- tests/test_004_cursor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 08171ea7..9ab9562f 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -3317,6 +3317,10 @@ def test_row_contains_with_lowercase_map(): assert "NAME" in row assert "missing" not in row + # Verify dict-like methods use deduplicated column names (covers _get_column_names fallback) + assert list(row.keys()) == ["ProductID", "Name"] + assert row.to_dict() == {"ProductID": 1, "Name": "foo"} + def test_row_items_is_reusable(cursor, db_connection): """Test items() returns a reusable list, not a one-shot iterator.""" From 04111b9f3f36e8e36efb1d011dc87388bf1a3972 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 11 Jun 2026 15:11:45 +0530 Subject: [PATCH 11/11] FIX: Remove __contains__ (breaking change), make values() return tuple for consistency --- mssql_python/row.py | 20 +++------------- tests/test_004_cursor.py | 51 +++------------------------------------- 2 files changed, 6 insertions(+), 65 deletions(-) diff --git a/mssql_python/row.py b/mssql_python/row.py index 3695afc6..86902c01 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -240,9 +240,9 @@ def keys(self) -> tuple: """Return column names, like dict.keys().""" return self._get_column_names() - def values(self) -> list: - """Return column values, like dict.values().""" - return self._values + def values(self) -> tuple: + """Return column values as a tuple, like dict.values().""" + return tuple(self._values) def items(self) -> list: """Return (column_name, value) pairs, like dict.items().""" @@ -252,20 +252,6 @@ def to_dict(self) -> dict: """Return the row as a plain dict mapping column names to values.""" return dict(zip(self._get_column_names(), self._values)) - def __contains__(self, key) -> bool: - """Support 'col_name in row' membership testing. - - Uses the full _column_map (which includes lowercase aliases), - so 'ProductID' in row and 'productid' in row both return True - even though keys() only lists the original-cased name. - """ - if isinstance(key, str): - if self._column_map and 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_004_cursor.py b/tests/test_004_cursor.py index 9ab9562f..ee23a947 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -3157,38 +3157,6 @@ def test_row_keys_values_items(cursor, db_connection): pass -def test_row_contains(cursor, db_connection): - """Test 'column_name in row' membership testing from a real cursor row.""" - try: - cursor.execute("CREATE TABLE #pytest_row_contains (ProductID INT, Name VARCHAR(50))") - db_connection.commit() - - cursor.execute("INSERT INTO #pytest_row_contains VALUES (1, 'foo')") - db_connection.commit() - - cursor.execute("SELECT * FROM #pytest_row_contains") - row = cursor.fetchone() - - # Column names from description should be found - for desc in cursor.description: - assert desc[0] in row, f"Column '{desc[0]}' not found in row" - - # Non-existent column - assert "nonexistent" not in row - - # Integer is not a column name - assert 0 not in row - - except Exception as e: - pytest.fail(f"Row __contains__ test failed: {e}") - finally: - try: - cursor.execute("DROP TABLE IF EXISTS #pytest_row_contains") - db_connection.commit() - except Exception: - pass - - def test_row_dict_no_duplicate_keys(cursor, db_connection): """Test that dict-like methods don't produce duplicate keys from cursor rows. @@ -3267,14 +3235,11 @@ def test_row_case_sensitive_access(cursor, db_connection): # Original casing works via all access methods assert row["ProductID"] == 1 assert row.ProductID == 1 - assert "ProductID" in row assert row["Name"] == "bar" assert row.Name == "bar" - assert "Name" in row # Non-existent - assert "nonexistent" not in row with pytest.raises(KeyError): row["nonexistent"] with pytest.raises(AttributeError): @@ -3299,25 +3264,15 @@ def test_row_none_column_map(): assert list(row.values()) == [] assert list(row.items()) == [] assert row.to_dict() == {} - assert "anything" not in row -def test_row_contains_with_lowercase_map(): - """Test __contains__ with column_map_lower (covers lowercase lookup branch).""" +def test_row_dict_dedup_fallback(): + """Test dict-like methods deduplicate _column_map when cursor is None.""" from mssql_python.row import Row column_map = {"ProductID": 0, "Name": 1} - column_map_lower = {"productid": 0, "name": 1} - row = Row([1, "foo"], column_map, cursor=None, column_map_lower=column_map_lower) - - # Exact match hits _column_map directly - assert "ProductID" in row - # Case-insensitive match hits _column_map_lower branch - assert "productid" in row - assert "NAME" in row - assert "missing" not in row + row = Row([1, "foo"], column_map, cursor=None) - # Verify dict-like methods use deduplicated column names (covers _get_column_names fallback) assert list(row.keys()) == ["ProductID", "Name"] assert row.to_dict() == {"ProductID": 1, "Name": "foo"}