diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index e859d77c..06b382c9 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -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 @@ -355,6 +355,7 @@ def _cleanup_connections(): "TokenProvider", "Cursor", "Row", + "RowMapping", # Settings "Settings", "get_settings", diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 1cb12cb4..db45462c 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -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. @@ -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): @@ -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 @@ -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 ] @@ -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 ] diff --git a/mssql_python/mssql_python.pyi b/mssql_python/mssql_python.pyi index 81222f16..18f70c28 100644 --- a/mssql_python/mssql_python.pyi +++ b/mssql_python/mssql_python.pyi @@ -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: ... @@ -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: diff --git a/mssql_python/row.py b/mssql_python/row.py index 8ebe0dab..58c5fb7d 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -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 @@ -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__( @@ -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. @@ -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: @@ -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): """ @@ -209,6 +232,33 @@ 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 ``collections.abc.Mapping``; use ``dict(row._mapping)`` for a plain + dict, ``row._mapping.items()`` for name/value pairs, and ``iter(row._mapping)`` + for column names. Names are order-preserving and de-duplicated (last column + wins for a repeated name, matching subscript and attribute access). + """ + 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. When a + row was built without that snapshot, reconstructs names from ``_column_map`` + (one name per column index); returns ``()`` when neither is available. + """ + 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)) + return () + def __eq__(self, other: Any) -> bool: """ Support comparison with lists for test compatibility. @@ -253,3 +303,39 @@ 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 column names, order- + preserving and de-duplicated (last column wins for a repeated name, matching + ``row[name]`` / ``row.name``). 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: + if isinstance(key, str): + try: + return self._row[key] + except KeyError: + raise KeyError(key) from None + 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})" diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 219d4833..7fb80bdb 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -3445,6 +3445,482 @@ def test_row_string_key_indexing(cursor, db_connection): pass +def test_row_mapping_to_dict(cursor, db_connection): + """Test dict(row._mapping) 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 = dict(row._mapping) + 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 _mapping 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_mapping_keys_values_items(cursor, db_connection): + """Test row._mapping 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() + + mapping = row._mapping + + # keys() returns column names matching description + keys = list(mapping.keys()) + assert len(keys) == 2 + assert keys == [desc[0] for desc in cursor.description] + + # values() matches positional access + vals = list(mapping.values()) + assert vals == [row[0], row[1]] + + # items() returns (name, value) pairs + items = list(mapping.items()) + assert len(items) == 2 + assert items[0] == (keys[0], row[0]) + assert items[1] == (keys[1], row[1]) + + # The mapping is a repeatable view, not a one-shot iterator + assert list(mapping.items()) == list(mapping.items()) + + # len consistency + assert len(keys) == len(row) + assert len(vals) == len(row) + assert len(items) == len(row) + assert len(mapping) == len(row) + + except Exception as e: + pytest.fail(f"Row _mapping 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_mapping_no_duplicate_keys(cursor, db_connection): + """Test that row._mapping doesn't produce duplicate keys from cursor rows. + + The cursor may inject lowercase aliases into _column_map, but the mapping + must expose 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() + + mapping = row._mapping + keys = list(mapping.keys()) + items = list(mapping.items()) + d = dict(mapping) + + # Exactly 2 columns, no duplicates, original casing preserved + assert len(keys) == 2 + assert len(items) == 2 + assert len(d) == 2 + assert len(keys) == len(row) + assert keys == ["ProductID", "MixedCase"] + + except Exception as e: + pytest.fail(f"Row _mapping 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_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_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() + 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 row["Name"] == "bar" + assert row.Name == "bar" + + # Non-existent + 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_mapping_none_column_map(): + """Test row._mapping edge case with column_map=None (empty mapping).""" + from mssql_python.row import Row + + row = Row([], None, cursor=None) + mapping = row._mapping + assert list(mapping.keys()) == [] + assert list(mapping.values()) == [] + assert list(mapping.items()) == [] + assert dict(mapping) == {} + assert len(mapping) == 0 + + +def test_row_mapping_dedup_fallback(): + """Test row._mapping reconstructs names from _column_map when cursor is None.""" + from mssql_python.row import Row + + column_map = {"ProductID": 0, "Name": 1} + row = Row([1, "foo"], column_map, cursor=None) + + mapping = row._mapping + assert list(mapping.keys()) == ["ProductID", "Name"] + assert dict(mapping) == {"ProductID": 1, "Name": "foo"} + + +def test_row_mapping_is_reusable(cursor, db_connection): + """Test row._mapping is a repeatable view, 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() + + mapping = row._mapping + first = list(mapping.items()) + second = list(mapping.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_mapping_stable_after_cursor_reuse(cursor, db_connection): + """row._mapping reflects the row's own result set, not a later reused query. + + Regression: an already-fetched row must not pick up column names from a + subsequent, differently-shaped query executed on the same cursor. + """ + cursor.execute("SELECT 1 AS alpha, 2 AS beta") + row_a = cursor.fetchone() + + # Reuse the same cursor for a different-shaped query. + cursor.execute("SELECT 10 AS gamma") + cursor.fetchone() + + # row_a's mapping must still describe the FIRST result set. + assert list(row_a._mapping.keys()) == ["alpha", "beta"] + assert dict(row_a._mapping) == {"alpha": 1, "beta": 2} + + +def test_row_mapping_column_named_mapping(cursor, db_connection): + """A column literally named '_mapping' is reachable via subscript/the view. + + row._mapping is a property, so it always returns the mapping view; the column + value is reached with row['_mapping'] (and via the view itself). + """ + from mssql_python.row import RowMapping + + cursor.execute("SELECT 7 AS _mapping") + row = cursor.fetchone() + + # The property still returns the mapping view, not the column value. + assert isinstance(row._mapping, RowMapping) + # The column value is reachable by subscript and through the view. + assert row["_mapping"] == 7 + assert row._mapping["_mapping"] == 7 + + +def test_row_mapping_getitem_get_contains(cursor, db_connection): + """RowMapping supports __getitem__, get(), and 'in' with dict semantics.""" + cursor.execute("SELECT 1 AS id, 'Alice' AS name") + row = cursor.fetchone() + mapping = row._mapping + + # __getitem__ for a present string key + assert mapping["id"] == 1 + assert mapping["name"] == "Alice" + + # __getitem__ for a missing string key raises KeyError (not IndexError/TypeError) + with pytest.raises(KeyError): + mapping["missing"] + + # __getitem__ for a non-string key raises KeyError (the mapping is name-keyed) + with pytest.raises(KeyError): + mapping[0] + with pytest.raises(KeyError): + mapping[None] + + # get() returns the value or the default + assert mapping.get("id") == 1 + assert mapping.get("missing") is None + assert mapping.get("missing", "fallback") == "fallback" + + # 'in' membership: only existing string names are members + assert "id" in mapping + assert "missing" not in mapping + assert 0 not in mapping + + +def test_row_mapping_repr_and_equality(cursor, db_connection): + """RowMapping compares equal to an equivalent dict and has a dict-like repr.""" + cursor.execute("SELECT 1 AS id, 'Bob' AS name") + row = cursor.fetchone() + mapping = row._mapping + + # Equality against a plain dict and another mapping (Mapping.__eq__). + assert mapping == {"id": 1, "name": "Bob"} + assert mapping == dict(mapping) + assert mapping != {"id": 1, "name": "DIFFERENT"} + assert mapping != {"id": 1} + + # repr round-trips through the underlying dict representation. + assert repr(mapping) == f"RowMapping({dict(mapping)!r})" + + # The property returns a fresh view each access, but views compare equal. + assert row._mapping is not row._mapping + assert row._mapping == row._mapping + + +def test_row_mapping_is_read_only(cursor, db_connection): + """RowMapping is a read-only view: no item assignment or deletion.""" + cursor.execute("SELECT 1 AS id") + row = cursor.fetchone() + mapping = row._mapping + + with pytest.raises(TypeError): + mapping["id"] = 99 + with pytest.raises(TypeError): + del mapping["id"] + + +def test_row_mapping_duplicate_column_names(cursor, db_connection): + """Duplicate column labels collapse last-wins by name; values stay positional. + + Design §6.4: a mapping holds one entry per name (last column wins), but every + value remains reachable via positional indexing. + """ + cursor.execute("SELECT 1 AS dup, 2 AS dup") + row = cursor.fetchone() + + # The name view is de-duplicated; the last column wins for the value. + assert list(row._mapping) == ["dup"] + assert dict(row._mapping) == {"dup": 2} + assert len(row._mapping) == 1 + + # Both values are still reachable positionally. + assert len(row) == 2 + assert row[0] == 1 + assert row[1] == 2 + + +def test_row_mapping_from_fetchall_and_fetchmany(cursor, db_connection): + """Rows from fetchall() and fetchmany() carry a correct _mapping. + + Exercises the canonical-name snapshot on all three fetch paths, not just + fetchone(). + """ + try: + cursor.execute("CREATE TABLE #pytest_row_map_many (id INT, name VARCHAR(20))") + db_connection.commit() + cursor.execute("INSERT INTO #pytest_row_map_many VALUES (1, 'a'), (2, 'b'), (3, 'c')") + db_connection.commit() + + # fetchall: every row maps correctly. + cursor.execute("SELECT id, name FROM #pytest_row_map_many ORDER BY id") + rows = cursor.fetchall() + assert [dict(r._mapping) for r in rows] == [ + {"id": 1, "name": "a"}, + {"id": 2, "name": "b"}, + {"id": 3, "name": "c"}, + ] + for r in rows: + assert list(r._mapping.keys()) == ["id", "name"] + + # fetchmany: the first batch maps correctly. + cursor.execute("SELECT id, name FROM #pytest_row_map_many ORDER BY id") + batch = cursor.fetchmany(2) + assert [dict(r._mapping) for r in batch] == [ + {"id": 1, "name": "a"}, + {"id": 2, "name": "b"}, + ] + for r in batch: + assert list(r._mapping.keys()) == ["id", "name"] + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_map_many") + db_connection.commit() + except Exception: + pass + + +def test_row_mapping_reflects_converted_values(cursor, db_connection): + """_mapping values equal the positional (already type-converted) values. + + Design §7: decimal, GUID, datetime, unicode and NULL flow through the row's + normal conversion; the mapping copies nothing and must reflect them exactly. + """ + try: + cursor.execute( + "CREATE TABLE #pytest_row_map_types (" + "amount DECIMAL(10,2), guid UNIQUEIDENTIFIER, " + "ts DATETIME2, note NVARCHAR(50), maybe INT)" + ) + db_connection.commit() + cursor.execute( + "INSERT INTO #pytest_row_map_types VALUES " + "(123.45, '6F9619FF-8B86-D011-B42D-00CF4FC964FF', " + "'2024-01-02T03:04:05', N'café', NULL)" + ) + db_connection.commit() + + cursor.execute("SELECT amount, guid, ts, note, maybe FROM #pytest_row_map_types") + row = cursor.fetchone() + mapping = row._mapping + + # Mapping values are identical to positional (post-conversion) values. + assert list(mapping.values()) == [row[0], row[1], row[2], row[3], row[4]] + + # Spot-check that converted types are preserved, not stringified by the view. + assert isinstance(mapping["amount"], decimal.Decimal) + assert mapping["amount"] == decimal.Decimal("123.45") + assert mapping["note"] == "café" + assert mapping["maybe"] is None # SQL NULL -> None + finally: + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_row_map_types") + db_connection.commit() + except Exception: + pass + + +def test_row_mapping_output_converter_reflected(db_connection): + """A registered output converter is reflected in _mapping values. + + Design §7: output converters run at row construction, so the mapping must + expose the converted value, identical to positional access. + """ + from mssql_python.constants import ConstantsDDBC + + cursor = db_connection.cursor() + try: + db_connection.add_output_converter( + ConstantsDDBC.SQL_WVARCHAR.value, + lambda raw: "CONV:" + raw.decode("utf-16-le"), + ) + cursor.execute("SELECT CAST(N'hello' AS NVARCHAR(20)) AS greeting") + row = cursor.fetchone() + + assert row[0] == "CONV:hello" + assert row._mapping["greeting"] == "CONV:hello" + assert dict(row._mapping) == {"greeting": "CONV:hello"} + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_row_mapping_lowercase_setting(db_connection): + """With lowercase=True, _mapping keys are lowercased (design §6.4). + + lowercase is captured per result set at execute time, so a result fetched + under lowercase=True yields lowercased mapping keys. + """ + original = mssql_python.lowercase + cursor = None + try: + mssql_python.lowercase = True + cursor = db_connection.cursor() + cursor.execute("SELECT 1 AS MixedCase, 2 AS UPPER") + row = cursor.fetchone() + + assert list(row._mapping.keys()) == ["mixedcase", "upper"] + assert dict(row._mapping) == {"mixedcase": 1, "upper": 2} + assert row._mapping["mixedcase"] == 1 + assert row._mapping["upper"] == 2 + finally: + mssql_python.lowercase = original + if cursor is not None: + cursor.close() + + def test_row_comparison_with_list(cursor, db_connection): """Test comparing Row objects with lists (__eq__ method)""" try: