From 6f1ea897a95926f3801064f207f2c155c846f5c6 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 18 May 2026 16:59:28 +0530 Subject: [PATCH 01/10] FEAT: Row supporting string-key indexing --- mssql_python/row.py | 14 ++++++- tests/test_001_globals.py | 50 +++++++++++++++++++++++ tests/test_004_cursor.py | 86 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/mssql_python/row.py b/mssql_python/row.py index b74e451e9..cef34eefa 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -156,8 +156,18 @@ 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]""" + 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 hasattr(self._cursor, "lowercase") and self._cursor.lowercase: + index_lower = index.lower() + for col_name in self._column_map: + if col_name.lower() == index_lower: + return self._values[self._column_map[col_name]] + raise KeyError(f"Row has no column '{index}'") return self._values[index] def __getattr__(self, name: str) -> Any: diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 9e44e0115..b21b0ea6d 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -996,3 +996,53 @@ 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"] + + +def test_row_string_key_case_insensitive_with_lowercase(): + """Test Row string-key indexing is case-insensitive when cursor.lowercase is True.""" + from mssql_python.row import Row + from unittest.mock import Mock + + mock_cursor = Mock() + mock_cursor.lowercase = True + mock_cursor.connection._output_converters = None + + row = Row( + [1, "bar"], + {"productid": 0, "name": 1}, + cursor=mock_cursor, + ) + + # Exact match + assert row["productid"] == 1 + # Case-insensitive match + assert row["ProductID"] == 1 + assert row["NAME"] == "bar" diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index f47361070..f3e08f5e4 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2304,6 +2304,54 @@ def test_executemany_Decimal_list(cursor, db_connection): db_connection.commit() +def test_executemany_decimal_sign_change(cursor, db_connection): + """Test executemany with decimals that change signs (GH-557). + + When the sample value chosen for column sizing is shorter than a negative + value in the batch, the formatted string (with a leading '-') can exceed + the allocated column_size, causing a RuntimeError. + """ + try: + cursor.execute("CREATE TABLE #pytest_decimal_sign (col_1 DECIMAL(28, 14))") + + # Case 1: negative first, then positive — previously worked + data1 = [(decimal.Decimal("-0.1"),), (decimal.Decimal("1.0"),)] + cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data1) + + # Case 2: positive first, then negative — previously failed + data2 = [(decimal.Decimal("0.1"),), (decimal.Decimal("-0.1"),)] + cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data2) + + # Case 3: positive then negative with different integer parts + data3 = [(decimal.Decimal("1.0"),), (decimal.Decimal("-0.1"),)] + cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data3) + + # Case 4: multiple sign changes in a single batch + data4 = [ + (decimal.Decimal("100.5"),), + (decimal.Decimal("-0.001"),), + (decimal.Decimal("0.5"),), + (decimal.Decimal("-999.99"),), + ] + cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data4) + + db_connection.commit() + + # Verify row count + cursor.execute("SELECT COUNT(*) FROM #pytest_decimal_sign") + count = cursor.fetchone()[0] + assert count == 10 + + # Verify data correctness for the originally-failing case + cursor.execute("SELECT col_1 FROM #pytest_decimal_sign ORDER BY col_1") + rows = [row[0] for row in cursor.fetchall()] + assert decimal.Decimal("-999.99") in [r.quantize(decimal.Decimal("0.01")) for r in rows] + assert decimal.Decimal("0.1") in [r.quantize(decimal.Decimal("0.1")) for r in rows] + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_sign") + db_connection.commit() + + def test_executemany_decimal_sign_change(cursor, db_connection): """Test executemany with decimals that change signs (GH-557). @@ -16201,3 +16249,41 @@ def test_long_print_message(cursor, message_len): msg = cursor.messages[0][1] # SQL Server truncates at 8000 characters assert msg.endswith("a" * min(8000, message_len)), msg + + +@pytest.mark.parametrize( + "raiserror_len", + [2047, 4000], +) +def test_long_raiserror(cursor, raiserror_len): + """Test that long error messages from RAISERROR are correctly captured""" + query = f""" + DECLARE @msg NVARCHAR(MAX) = REPLICATE(N'a', {raiserror_len}); + RAISERROR(@msg, 16, 1); + """ + try: + cursor.execute(query) + except mssql_python.ProgrammingError as e: + msg = e.args[0] + if raiserror_len <= 2047: # SQL Server length cap + assert msg.endswith("a" * raiserror_len), msg + else: + assert msg.endswith("a" * (2047 - 3) + "..."), msg + + +@pytest.mark.parametrize( + "message_len", + [2047, 4000, 8000, 8001, 80000], +) +def test_long_print_message(cursor, message_len): + """Test that long messages from PRINT are correctly captured.""" + query = f""" + DECLARE @msg VARCHAR(MAX); + /* Cast to VARCHAR(MAX) so REPLICATE is not truncated to 8000 bytes. */ + SET @msg = REPLICATE(CAST('a' AS VARCHAR(MAX)), {message_len}); + PRINT @msg; + """ + cursor.execute(query) + msg = cursor.messages[0][1] + # SQL Server truncates at 8000 characters + assert msg.endswith("a" * min(8000, message_len)), msg From fe513d0643466838ec865232a592cb90501eaf27 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 18 May 2026 17:02:37 +0530 Subject: [PATCH 02/10] Removing stale tests --- tests/test_004_cursor.py | 49 ---------------------------------------- 1 file changed, 49 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index f3e08f5e4..6cd9e6a44 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2352,55 +2352,6 @@ def test_executemany_decimal_sign_change(cursor, db_connection): db_connection.commit() -def test_executemany_decimal_sign_change(cursor, db_connection): - """Test executemany with decimals that change signs (GH-557). - - When the sample value chosen for column sizing is a positive Decimal, - its format(v, 'f') string is shorter than the negative counterpart - (e.g. '1.0' vs '-0.1'), causing the C++ buffer validation to reject - the longer row with a column-size overflow error. - """ - try: - cursor.execute("CREATE TABLE #pytest_decimal_sign (col_1 DECIMAL(28, 14))") - - # Case 1: negative first, then positive — previously worked - data1 = [(decimal.Decimal("-0.1"),), (decimal.Decimal("1.0"),)] - cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data1) - - # Case 2: positive first, then negative — previously failed - data2 = [(decimal.Decimal("0.1"),), (decimal.Decimal("-0.1"),)] - cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data2) - - # Case 3: positive then negative with different integer parts - data3 = [(decimal.Decimal("1.0"),), (decimal.Decimal("-0.1"),)] - cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data3) - - # Case 4: multiple sign changes in a single batch - data4 = [ - (decimal.Decimal("100.5"),), - (decimal.Decimal("-0.001"),), - (decimal.Decimal("0.5"),), - (decimal.Decimal("-999.99"),), - ] - cursor.executemany("INSERT INTO #pytest_decimal_sign VALUES (?)", data4) - - db_connection.commit() - - # Verify row count - cursor.execute("SELECT COUNT(*) FROM #pytest_decimal_sign") - count = cursor.fetchone()[0] - assert count == 10 - - # Verify data correctness for the originally-failing case - cursor.execute("SELECT col_1 FROM #pytest_decimal_sign ORDER BY col_1") - rows = [row[0] for row in cursor.fetchall()] - assert decimal.Decimal("-999.99") in [r.quantize(decimal.Decimal("0.01")) for r in rows] - assert decimal.Decimal("0.1") in [r.quantize(decimal.Decimal("0.1")) for r in rows] - finally: - cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_sign") - db_connection.commit() - - def test_executemany_DecimalString_list(cursor, db_connection): """Test executemany with an string of decimal parameter list.""" try: From 726ea17c8dac991fc4d06db559336e0914b2762f Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 19 May 2026 13:25:11 +0530 Subject: [PATCH 03/10] Adding test case --- tests/test_004_cursor.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 6cd9e6a44..67bd8704e 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2875,6 +2875,41 @@ 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: + cursor.execute("DROP TABLE #pytest_row_strkey") + db_connection.commit() + + def test_row_comparison_with_list(cursor, db_connection): """Test comparing Row objects with lists (__eq__ method)""" try: From 33b430cad000ce6a10c1960530f12f9cbc94d801 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 19 May 2026 13:26:22 +0530 Subject: [PATCH 04/10] Adding test case --- tests/test_004_cursor.py | 76 ---------------------------------------- 1 file changed, 76 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 67bd8704e..66ad3c678 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16197,79 +16197,3 @@ def test_catalog_rownumber_increments_correctly(cursor, db_connection, catalog_f assert cursor.rownumber == expected_idx assert cursor.fetchone() is None - - -@pytest.mark.parametrize( - "raiserror_len", - [2047, 4000], -) -def test_long_raiserror(cursor, raiserror_len): - """Test that long error messages from RAISERROR are correctly captured""" - query = f""" - DECLARE @msg NVARCHAR(MAX) = REPLICATE(N'a', {raiserror_len}); - RAISERROR(@msg, 16, 1); - """ - try: - cursor.execute(query) - except mssql_python.ProgrammingError as e: - msg = e.args[0] - if raiserror_len <= 2047: # SQL Server length cap - assert msg.endswith("a" * raiserror_len), msg - else: - assert msg.endswith("a" * (2047 - 3) + "..."), msg - - -@pytest.mark.parametrize( - "message_len", - [2047, 4000, 8000, 8001, 80000], -) -def test_long_print_message(cursor, message_len): - """Test that long messages from PRINT are correctly captured.""" - query = f""" - DECLARE @msg VARCHAR(MAX); - /* Cast to VARCHAR(MAX) so REPLICATE is not truncated to 8000 bytes. */ - SET @msg = REPLICATE(CAST('a' AS VARCHAR(MAX)), {message_len}); - PRINT @msg; - """ - cursor.execute(query) - msg = cursor.messages[0][1] - # SQL Server truncates at 8000 characters - assert msg.endswith("a" * min(8000, message_len)), msg - - -@pytest.mark.parametrize( - "raiserror_len", - [2047, 4000], -) -def test_long_raiserror(cursor, raiserror_len): - """Test that long error messages from RAISERROR are correctly captured""" - query = f""" - DECLARE @msg NVARCHAR(MAX) = REPLICATE(N'a', {raiserror_len}); - RAISERROR(@msg, 16, 1); - """ - try: - cursor.execute(query) - except mssql_python.ProgrammingError as e: - msg = e.args[0] - if raiserror_len <= 2047: # SQL Server length cap - assert msg.endswith("a" * raiserror_len), msg - else: - assert msg.endswith("a" * (2047 - 3) + "..."), msg - - -@pytest.mark.parametrize( - "message_len", - [2047, 4000, 8000, 8001, 80000], -) -def test_long_print_message(cursor, message_len): - """Test that long messages from PRINT are correctly captured.""" - query = f""" - DECLARE @msg VARCHAR(MAX); - /* Cast to VARCHAR(MAX) so REPLICATE is not truncated to 8000 bytes. */ - SET @msg = REPLICATE(CAST('a' AS VARCHAR(MAX)), {message_len}); - PRINT @msg; - """ - cursor.execute(query) - msg = cursor.messages[0][1] - # SQL Server truncates at 8000 characters - assert msg.endswith("a" * min(8000, message_len)), msg From f656c020c6e887f4bb60c979ab524130739e632f Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 19 May 2026 13:28:38 +0530 Subject: [PATCH 05/10] Adding test case --- tests/test_004_cursor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 66ad3c678..8b531f387 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2307,9 +2307,10 @@ def test_executemany_Decimal_list(cursor, db_connection): def test_executemany_decimal_sign_change(cursor, db_connection): """Test executemany with decimals that change signs (GH-557). - When the sample value chosen for column sizing is shorter than a negative - value in the batch, the formatted string (with a leading '-') can exceed - the allocated column_size, causing a RuntimeError. + When the sample value chosen for column sizing is a positive Decimal, + its format(v, 'f') string is shorter than the negative counterpart + (e.g. '1.0' vs '-0.1'), causing the C++ buffer validation to reject + the longer row with a column-size overflow error. """ try: cursor.execute("CREATE TABLE #pytest_decimal_sign (col_1 DECIMAL(28, 14))") From b7077da73cfca41dcd6ea4f6a98657df13f53c40 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 19 May 2026 13:29:31 +0530 Subject: [PATCH 06/10] Adding test case --- tests/test_004_cursor.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 8b531f387..cf8f9453c 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16198,3 +16198,41 @@ def test_catalog_rownumber_increments_correctly(cursor, db_connection, catalog_f assert cursor.rownumber == expected_idx assert cursor.fetchone() is None + + +@pytest.mark.parametrize( + "raiserror_len", + [2047, 4000], +) +def test_long_raiserror(cursor, raiserror_len): + """Test that long error messages from RAISERROR are correctly captured""" + query = f""" + DECLARE @msg NVARCHAR(MAX) = REPLICATE(N'a', {raiserror_len}); + RAISERROR(@msg, 16, 1); + """ + try: + cursor.execute(query) + except mssql_python.ProgrammingError as e: + msg = e.args[0] + if raiserror_len <= 2047: # SQL Server length cap + assert msg.endswith("a" * raiserror_len), msg + else: + assert msg.endswith("a" * (2047 - 3) + "..."), msg + + +@pytest.mark.parametrize( + "message_len", + [2047, 4000, 8000, 8001, 80000], +) +def test_long_print_message(cursor, message_len): + """Test that long messages from PRINT are correctly captured.""" + query = f""" + DECLARE @msg VARCHAR(MAX); + /* Cast to VARCHAR(MAX) so REPLICATE is not truncated to 8000 bytes. */ + SET @msg = REPLICATE(CAST('a' AS VARCHAR(MAX)), {message_len}); + PRINT @msg; + """ + cursor.execute(query) + msg = cursor.messages[0][1] + # SQL Server truncates at 8000 characters + assert msg.endswith("a" * min(8000, message_len)), msg \ No newline at end of file From d775a20fe5a55ca2199b24daa648daa82bb4c763 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 19 May 2026 13:36:44 +0530 Subject: [PATCH 07/10] Adding test case --- tests/test_004_cursor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index cf8f9453c..84773b8b1 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16235,4 +16235,4 @@ def test_long_print_message(cursor, message_len): cursor.execute(query) msg = cursor.messages[0][1] # SQL Server truncates at 8000 characters - assert msg.endswith("a" * min(8000, message_len)), msg \ No newline at end of file + assert msg.endswith("a" * min(8000, message_len)), msg From 375b737808e9f47b58a87907f3165890921be2f0 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 19 May 2026 13:37:09 +0530 Subject: [PATCH 08/10] Adding test case --- tests/test_004_cursor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 84773b8b1..cf8f9453c 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16235,4 +16235,4 @@ def test_long_print_message(cursor, message_len): cursor.execute(query) msg = cursor.messages[0][1] # SQL Server truncates at 8000 characters - assert msg.endswith("a" * min(8000, message_len)), msg + assert msg.endswith("a" * min(8000, message_len)), msg \ No newline at end of file From ca1de4537cf7536f4f8aaf3521f5ff96e60e21a4 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Fri, 22 May 2026 09:18:23 +0530 Subject: [PATCH 09/10] Resolving comments --- tests/test_004_cursor.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index cf8f9453c..a63c4d8ff 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -2907,8 +2907,11 @@ def test_row_string_key_indexing(cursor, db_connection): except Exception as e: pytest.fail(f"Row string-key indexing test failed: {e}") finally: - cursor.execute("DROP TABLE #pytest_row_strkey") - db_connection.commit() + 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): @@ -16235,4 +16238,4 @@ def test_long_print_message(cursor, message_len): cursor.execute(query) msg = cursor.messages[0][1] # SQL Server truncates at 8000 characters - assert msg.endswith("a" * min(8000, message_len)), msg \ No newline at end of file + assert msg.endswith("a" * min(8000, message_len)), msg From 4287d76930ddde0f0bb14d20f2ca1b3f0882ddba Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Fri, 22 May 2026 09:23:50 +0530 Subject: [PATCH 10/10] Resolving comments --- mssql_python/row.py | 6 +++--- tests/test_001_globals.py | 33 ++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/mssql_python/row.py b/mssql_python/row.py index cef34eefa..368ec73e6 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -162,7 +162,7 @@ def __getitem__(self, index) -> Any: if index in self._column_map: return self._values[self._column_map[index]] # Case-insensitive lookup when lowercase is enabled - if hasattr(self._cursor, "lowercase") and self._cursor.lowercase: + if get_settings().lowercase: index_lower = index.lower() for col_name in self._column_map: if col_name.lower() == index_lower: @@ -185,8 +185,8 @@ 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: + # If lowercase is enabled, try case-insensitive lookup + if get_settings().lowercase: name_lower = name.lower() for col_name in self._column_map: if col_name.lower() == name_lower: diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index b21b0ea6d..7a2a47453 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -1027,22 +1027,25 @@ def test_row_string_key_indexing(): def test_row_string_key_case_insensitive_with_lowercase(): - """Test Row string-key indexing is case-insensitive when cursor.lowercase is True.""" + """Test Row string-key indexing is case-insensitive when global lowercase is True.""" from mssql_python.row import Row - from unittest.mock import Mock + from mssql_python.helpers import get_settings - mock_cursor = Mock() - mock_cursor.lowercase = True - mock_cursor.connection._output_converters = None + settings = get_settings() + original = settings.lowercase + try: + settings.lowercase = True - row = Row( - [1, "bar"], - {"productid": 0, "name": 1}, - cursor=mock_cursor, - ) + 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" + # Exact match + assert row["productid"] == 1 + # Case-insensitive match + assert row["ProductID"] == 1 + assert row["NAME"] == "bar" + finally: + settings.lowercase = original