Skip to content
33 changes: 24 additions & 9 deletions mssql_python/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def __init__(self, values, column_map, cursor=None, converter_map=None, uuid_str

self._column_map = column_map
self._cursor = cursor
# Pre-built lowercase-key map case-insensitive lookups.
# Built once per Row, shared cost amortised across all accesses.
self._column_map_lower = {k.lower(): v for k, v in column_map.items()} if column_map else {}

def _stringify_uuids(self, indices):
"""
Expand Down Expand Up @@ -156,9 +159,22 @@ def _apply_output_converters_optimized(self, values, converter_map):

return converted_values

def __getitem__(self, index: int) -> Any:
"""Allow accessing by numeric index: row[0]"""
return self._values[index]
def __getitem__(self, index) -> Any:
"""Allow accessing by numeric index (row[0]) or column name (row["col"])."""
if isinstance(index, str):
if index in self._column_map:
return self._values[self._column_map[index]]
# Case-insensitive lookup when lowercase is enabled
if get_settings().lowercase:
idx = self._column_map_lower.get(index.lower())
if idx is not None:
return self._values[idx]
raise KeyError(f"Row has no column '{index}'")
Comment thread
jahnvi480 marked this conversation as resolved.
if isinstance(index, (int, slice)):
return self._values[index]
raise TypeError(
f"Row indices must be integers, slices, or strings, not {type(index).__name__}"
)

def __getattr__(self, name: str) -> Any:
"""
Expand All @@ -175,12 +191,11 @@ def __getattr__(self, name: str) -> Any:
if name in self._column_map:
return self._values[self._column_map[name]]

# If lowercase is enabled on the cursor, try case-insensitive lookup
if hasattr(self._cursor, "lowercase") and self._cursor.lowercase:
name_lower = name.lower()
for col_name in self._column_map:
if col_name.lower() == name_lower:
return self._values[self._column_map[col_name]]
# If lowercase is enabled, try case-insensitive lookup
if get_settings().lowercase:
idx = self._column_map_lower.get(name.lower())
if idx is not None:
return self._values[idx]

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

Expand Down
59 changes: 59 additions & 0 deletions tests/test_001_globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -996,3 +996,62 @@ 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 global lowercase is True."""
from mssql_python.row import Row
from mssql_python.helpers import get_settings

settings = get_settings()
original = settings.lowercase
try:
settings.lowercase = True

row = Row(
[1, "bar"],
{"productid": 0, "name": 1},
cursor=None,
)

# Exact match
assert row["productid"] == 1
# Case-insensitive match
assert row["ProductID"] == 1
assert row["NAME"] == "bar"
finally:
settings.lowercase = original
38 changes: 38 additions & 0 deletions tests/test_004_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2876,6 +2876,44 @@ def test_row_attribute_access(cursor, db_connection):
db_connection.commit()


def test_row_string_key_indexing(cursor, db_connection):
"""Test accessing row values by column name as string key: row['col']"""
try:
cursor.execute(
"CREATE TABLE #pytest_row_strkey (id INT PRIMARY KEY, name VARCHAR(50), age INT)"
)
db_connection.commit()

cursor.execute("INSERT INTO #pytest_row_strkey (id, name, age) VALUES (1, 'Alice', 25)")
db_connection.commit()

cursor.execute("SELECT * FROM #pytest_row_strkey")
row = cursor.fetchone()

# String-key access
assert row["id"] == 1, "Failed to access 'id' by string key"
assert row["name"] == "Alice", "Failed to access 'name' by string key"
assert row["age"] == 25, "Failed to access 'age' by string key"

# Consistency with index and attribute access
assert row["id"] == row[0] == row.id
assert row["name"] == row[1] == row.name
assert row["age"] == row[2] == row.age

# Non-existent key raises KeyError
with pytest.raises(KeyError):
row["nonexistent"]

except Exception as e:
pytest.fail(f"Row string-key indexing test failed: {e}")
finally:
try:
cursor.execute("DROP TABLE IF EXISTS #pytest_row_strkey")
db_connection.commit()
except Exception:
pass


def test_row_comparison_with_list(cursor, db_connection):
"""Test comparing Row objects with lists (__eq__ method)"""
try:
Expand Down
Loading