Skip to content

Commit 9ce15d0

Browse files
committed
added tests and refactored the flow
1 parent ecbea82 commit 9ce15d0

File tree

2 files changed

+319
-17
lines changed

2 files changed

+319
-17
lines changed

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,13 +1779,18 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
17791779
} else if (dataLen == SQL_NULL_DATA) {
17801780
row.append(py::none());
17811781
} else if (dataLen == 0) {
1782-
// Empty string
1782+
// Handle zero-length (non-NULL) data
17831783
row.append(std::string(""));
1784-
} else {
1785-
assert(dataLen == SQL_NO_TOTAL);
1784+
} else if (dataLen == SQL_NO_TOTAL) {
1785+
// This means the length of the data couldn't be determined
17861786
LOG("SQLGetData couldn't determine the length of the data. "
1787-
"Returning NULL value instead. Column ID - {}", i);
1788-
row.append(py::none());
1787+
"Returning NULL value instead. Column ID - {}, Data Type - {}", i, dataType);
1788+
} else if (dataLen < 0) {
1789+
// This is unexpected
1790+
LOG("SQLGetData returned an unexpected negative data length. "
1791+
"Raising exception. Column ID - {}, Data Type - {}, Data Length - {}",
1792+
i, dataType, dataLen);
1793+
ThrowStdException("SQLGetData returned an unexpected negative data length");
17891794
}
17901795
} else {
17911796
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return "
@@ -1838,13 +1843,14 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
18381843
} else if (dataLen == SQL_NULL_DATA) {
18391844
row.append(py::none());
18401845
} else if (dataLen == 0) {
1841-
// Empty string
1846+
// Handle zero-length (non-NULL) data
18421847
row.append(py::str(""));
1843-
} else {
1844-
assert(dataLen == SQL_NO_TOTAL);
1845-
LOG("SQLGetData couldn't determine the length of the data. "
1846-
"Returning NULL value instead. Column ID - {}", i);
1847-
row.append(py::none());
1848+
} else if (dataLen < 0) {
1849+
// This is unexpected
1850+
LOG("SQLGetData returned an unexpected negative data length. "
1851+
"Raising exception. Column ID - {}, Data Type - {}, Data Length - {}",
1852+
i, dataType, dataLen);
1853+
ThrowStdException("SQLGetData returned an unexpected negative data length");
18481854
}
18491855
} else {
18501856
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return "
@@ -2039,11 +2045,12 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
20392045
} else if (dataLen == 0) {
20402046
// Empty bytes
20412047
row.append(py::bytes(""));
2042-
} else {
2043-
assert(dataLen == SQL_NO_TOTAL);
2044-
LOG("SQLGetData couldn't determine the length of the data. "
2045-
"Returning NULL value instead. Column ID - {}", i);
2046-
row.append(py::none());
2048+
} else if (dataLen < 0) {
2049+
// This is unexpected
2050+
LOG("SQLGetData returned an unexpected negative data length. "
2051+
"Raising exception. Column ID - {}, Data Type - {}, Data Length - {}",
2052+
i, dataType, dataLen);
2053+
ThrowStdException("SQLGetData returned an unexpected negative data length");
20472054
}
20482055
} else {
20492056
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return "
@@ -2326,8 +2333,30 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
23262333
"Column ID - {}", col);
23272334
row.append(py::none());
23282335
continue;
2336+
} else if (dataLen == SQL_NULL_DATA) {
2337+
LOG("Column data is NULL. Appending None to the result row. Column ID - {}", col);
2338+
row.append(py::none());
2339+
continue;
2340+
} else if (dataLen == 0) {
2341+
// Handle zero-length (non-NULL) data
2342+
if (dataType == SQL_CHAR || dataType == SQL_VARCHAR || dataType == SQL_LONGVARCHAR) {
2343+
row.append(std::string(""));
2344+
} else if (dataType == SQL_WCHAR || dataType == SQL_WVARCHAR || dataType == SQL_WLONGVARCHAR) {
2345+
row.append(std::wstring(L""));
2346+
} else if (dataType == SQL_BINARY || dataType == SQL_VARBINARY || dataType == SQL_LONGVARBINARY) {
2347+
row.append(py::bytes(""));
2348+
} else {
2349+
// For other datatypes, 0 length is unexpected. Log & append None
2350+
LOG("Column data length is 0 for non-string/binary datatype. Appending None to the result row. Column ID - {}", col);
2351+
row.append(py::none());
2352+
}
2353+
continue;
2354+
} else if (dataLen < 0) {
2355+
// Negative value is unexpected, log column index, SQL type & raise exception
2356+
LOG("Unexpected negative data length. Column ID - {}, SQL Type - {}, Data Length - {}", col, dataType, dataLen);
2357+
ThrowStdException("Unexpected negative data length, check logs for details");
23292358
}
2330-
assert(dataLen >= 0 && "Data length must be >= 0");
2359+
assert(dataLen > 0 && "Data length must be > 0");
23312360

23322361
switch (dataType) {
23332362
case SQL_CHAR:

tests/test_004_cursor.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5433,6 +5433,279 @@ def test_empty_string_chunk(cursor, db_connection):
54335433
cursor.execute("DROP TABLE IF EXISTS #pytest_empty_string")
54345434
db_connection.commit()
54355435

5436+
def test_empty_char_single_and_batch_fetch(cursor, db_connection):
5437+
"""Test that empty CHAR data is handled correctly in both single and batch fetch"""
5438+
try:
5439+
# Create test table with regular VARCHAR (CHAR is fixed-length and pads with spaces)
5440+
drop_table_if_exists(cursor, "#pytest_empty_char")
5441+
cursor.execute("CREATE TABLE #pytest_empty_char (id INT, char_col VARCHAR(100))")
5442+
db_connection.commit()
5443+
5444+
# Insert empty VARCHAR data
5445+
cursor.execute("INSERT INTO #pytest_empty_char VALUES (1, '')")
5446+
cursor.execute("INSERT INTO #pytest_empty_char VALUES (2, '')")
5447+
db_connection.commit()
5448+
5449+
# Test single-row fetch (fetchone)
5450+
cursor.execute("SELECT char_col FROM #pytest_empty_char WHERE id = 1")
5451+
row = cursor.fetchone()
5452+
assert row is not None, "Should return a row"
5453+
assert row[0] == '', "Should return empty string, not None"
5454+
5455+
# Test batch fetch (fetchall)
5456+
cursor.execute("SELECT char_col FROM #pytest_empty_char ORDER BY id")
5457+
rows = cursor.fetchall()
5458+
assert len(rows) == 2, "Should return 2 rows"
5459+
assert rows[0][0] == '', "Row 1 should have empty string"
5460+
assert rows[1][0] == '', "Row 2 should have empty string"
5461+
5462+
# Test batch fetch (fetchmany)
5463+
cursor.execute("SELECT char_col FROM #pytest_empty_char ORDER BY id")
5464+
many_rows = cursor.fetchmany(2)
5465+
assert len(many_rows) == 2, "Should return 2 rows with fetchmany"
5466+
assert many_rows[0][0] == '', "fetchmany row 1 should have empty string"
5467+
assert many_rows[1][0] == '', "fetchmany row 2 should have empty string"
5468+
5469+
except Exception as e:
5470+
pytest.fail(f"Empty VARCHAR handling test failed: {e}")
5471+
finally:
5472+
cursor.execute("DROP TABLE #pytest_empty_char")
5473+
db_connection.commit()
5474+
5475+
def test_empty_varbinary_batch_fetch(cursor, db_connection):
5476+
"""Test that empty VARBINARY data is handled correctly in batch fetch operations"""
5477+
try:
5478+
# Create test table
5479+
drop_table_if_exists(cursor, "#pytest_empty_varbinary_batch")
5480+
cursor.execute("CREATE TABLE #pytest_empty_varbinary_batch (id INT, binary_col VARBINARY(100))")
5481+
db_connection.commit()
5482+
5483+
# Insert multiple rows with empty binary data
5484+
cursor.execute("INSERT INTO #pytest_empty_varbinary_batch VALUES (1, 0x)") # Empty binary
5485+
cursor.execute("INSERT INTO #pytest_empty_varbinary_batch VALUES (2, 0x)") # Empty binary
5486+
cursor.execute("INSERT INTO #pytest_empty_varbinary_batch VALUES (3, 0x1234)") # Non-empty for comparison
5487+
db_connection.commit()
5488+
5489+
# Test fetchall for batch processing
5490+
cursor.execute("SELECT id, binary_col FROM #pytest_empty_varbinary_batch ORDER BY id")
5491+
rows = cursor.fetchall()
5492+
assert len(rows) == 3, "Should return 3 rows"
5493+
5494+
# Check empty binary rows
5495+
assert rows[0][1] == b'', "Row 1 should have empty bytes"
5496+
assert rows[1][1] == b'', "Row 2 should have empty bytes"
5497+
assert isinstance(rows[0][1], bytes), "Should return bytes type for empty binary"
5498+
assert len(rows[0][1]) == 0, "Should be zero-length bytes"
5499+
5500+
# Check non-empty row for comparison
5501+
assert rows[2][1] == b'\x12\x34', "Row 3 should have non-empty binary"
5502+
5503+
# Test fetchmany batch processing
5504+
cursor.execute("SELECT binary_col FROM #pytest_empty_varbinary_batch WHERE id <= 2 ORDER BY id")
5505+
many_rows = cursor.fetchmany(2)
5506+
assert len(many_rows) == 2, "fetchmany should return 2 rows"
5507+
assert many_rows[0][0] == b'', "fetchmany row 1 should have empty bytes"
5508+
assert many_rows[1][0] == b'', "fetchmany row 2 should have empty bytes"
5509+
5510+
except Exception as e:
5511+
pytest.fail(f"Empty VARBINARY batch fetch test failed: {e}")
5512+
finally:
5513+
cursor.execute("DROP TABLE #pytest_empty_varbinary_batch")
5514+
db_connection.commit()
5515+
5516+
def test_empty_values_fetchmany(cursor, db_connection):
5517+
"""Test fetchmany with empty values for all string/binary types"""
5518+
try:
5519+
# Create comprehensive test table
5520+
drop_table_if_exists(cursor, "#pytest_fetchmany_empty")
5521+
cursor.execute("""
5522+
CREATE TABLE #pytest_fetchmany_empty (
5523+
id INT,
5524+
varchar_col VARCHAR(50),
5525+
nvarchar_col NVARCHAR(50),
5526+
binary_col VARBINARY(50)
5527+
)
5528+
""")
5529+
db_connection.commit()
5530+
5531+
# Insert multiple rows with empty values
5532+
for i in range(1, 6): # 5 rows
5533+
cursor.execute("""
5534+
INSERT INTO #pytest_fetchmany_empty
5535+
VALUES (?, '', '', 0x)
5536+
""", [i])
5537+
db_connection.commit()
5538+
5539+
# Test fetchmany with different sizes
5540+
cursor.execute("SELECT varchar_col, nvarchar_col, binary_col FROM #pytest_fetchmany_empty ORDER BY id")
5541+
5542+
# Fetch 3 rows
5543+
rows = cursor.fetchmany(3)
5544+
assert len(rows) == 3, "Should fetch 3 rows"
5545+
for i, row in enumerate(rows):
5546+
assert row[0] == '', f"Row {i+1} VARCHAR should be empty string"
5547+
assert row[1] == '', f"Row {i+1} NVARCHAR should be empty string"
5548+
assert row[2] == b'', f"Row {i+1} VARBINARY should be empty bytes"
5549+
assert isinstance(row[2], bytes), f"Row {i+1} VARBINARY should be bytes type"
5550+
5551+
# Fetch remaining rows
5552+
remaining_rows = cursor.fetchmany(5) # Ask for 5 but should get 2
5553+
assert len(remaining_rows) == 2, "Should fetch remaining 2 rows"
5554+
for i, row in enumerate(remaining_rows):
5555+
assert row[0] == '', f"Remaining row {i+1} VARCHAR should be empty string"
5556+
assert row[1] == '', f"Remaining row {i+1} NVARCHAR should be empty string"
5557+
assert row[2] == b'', f"Remaining row {i+1} VARBINARY should be empty bytes"
5558+
5559+
except Exception as e:
5560+
pytest.fail(f"Empty values fetchmany test failed: {e}")
5561+
finally:
5562+
cursor.execute("DROP TABLE #pytest_fetchmany_empty")
5563+
db_connection.commit()
5564+
5565+
def test_sql_no_total_large_data_scenario(cursor, db_connection):
5566+
"""Test very large data that might trigger SQL_NO_TOTAL handling"""
5567+
try:
5568+
# Create test table for large data
5569+
drop_table_if_exists(cursor, "#pytest_large_data_no_total")
5570+
cursor.execute("CREATE TABLE #pytest_large_data_no_total (id INT, large_text NVARCHAR(MAX), large_binary VARBINARY(MAX))")
5571+
db_connection.commit()
5572+
5573+
# Create large data that might trigger SQL_NO_TOTAL
5574+
large_string = 'A' * (5 * 1024 * 1024) # 5MB string
5575+
large_binary = b'\x00' * (5 * 1024 * 1024) # 5MB binary
5576+
5577+
cursor.execute("INSERT INTO #pytest_large_data_no_total VALUES (1, ?, ?)", [large_string, large_binary])
5578+
cursor.execute("INSERT INTO #pytest_large_data_no_total VALUES (2, ?, ?)", [large_string, large_binary])
5579+
db_connection.commit()
5580+
5581+
# Test single fetch - should not crash if SQL_NO_TOTAL occurs
5582+
cursor.execute("SELECT large_text, large_binary FROM #pytest_large_data_no_total WHERE id = 1")
5583+
row = cursor.fetchone()
5584+
5585+
# If SQL_NO_TOTAL occurs, it should return None, not crash
5586+
# If it works normally, it should return the large data
5587+
if row[0] is not None:
5588+
assert isinstance(row[0], str), "Text data should be str if not None"
5589+
assert len(row[0]) > 0, "Text data should be non-empty if not None"
5590+
if row[1] is not None:
5591+
assert isinstance(row[1], bytes), "Binary data should be bytes if not None"
5592+
assert len(row[1]) > 0, "Binary data should be non-empty if not None"
5593+
5594+
# Test batch fetch - should handle SQL_NO_TOTAL consistently
5595+
cursor.execute("SELECT large_text, large_binary FROM #pytest_large_data_no_total ORDER BY id")
5596+
rows = cursor.fetchall()
5597+
assert len(rows) == 2, "Should return 2 rows"
5598+
5599+
# Both rows should behave consistently
5600+
for i, row in enumerate(rows):
5601+
if row[0] is not None:
5602+
assert isinstance(row[0], str), f"Row {i+1} text should be str if not None"
5603+
if row[1] is not None:
5604+
assert isinstance(row[1], bytes), f"Row {i+1} binary should be bytes if not None"
5605+
5606+
# Test fetchmany - should handle SQL_NO_TOTAL consistently
5607+
cursor.execute("SELECT large_text FROM #pytest_large_data_no_total ORDER BY id")
5608+
many_rows = cursor.fetchmany(2)
5609+
assert len(many_rows) == 2, "fetchmany should return 2 rows"
5610+
5611+
for i, row in enumerate(many_rows):
5612+
if row[0] is not None:
5613+
assert isinstance(row[0], str), f"fetchmany row {i+1} should be str if not None"
5614+
5615+
except Exception as e:
5616+
# Should not crash with assertion errors about dataLen
5617+
assert "Data length must be" not in str(e), "Should not fail with dataLen assertion"
5618+
assert "assert" not in str(e).lower(), "Should not fail with assertion errors"
5619+
# If it fails for other reasons (like memory), that's acceptable
5620+
print(f"Large data test completed with expected limitation: {e}")
5621+
5622+
finally:
5623+
try:
5624+
cursor.execute("DROP TABLE #pytest_large_data_no_total")
5625+
db_connection.commit()
5626+
except:
5627+
pass # Table might not exist if test failed early
5628+
5629+
def test_batch_fetch_empty_values_no_assertion_failure(cursor, db_connection):
5630+
"""Test that batch fetch operations don't fail with assertions on empty values"""
5631+
try:
5632+
# Create comprehensive test table
5633+
drop_table_if_exists(cursor, "#pytest_batch_empty_assertions")
5634+
cursor.execute("""
5635+
CREATE TABLE #pytest_batch_empty_assertions (
5636+
id INT,
5637+
empty_varchar VARCHAR(100),
5638+
empty_nvarchar NVARCHAR(100),
5639+
empty_binary VARBINARY(100),
5640+
null_varchar VARCHAR(100),
5641+
null_nvarchar NVARCHAR(100),
5642+
null_binary VARBINARY(100)
5643+
)
5644+
""")
5645+
db_connection.commit()
5646+
5647+
# Insert rows with mix of empty and NULL values
5648+
cursor.execute("""
5649+
INSERT INTO #pytest_batch_empty_assertions VALUES
5650+
(1, '', '', 0x, NULL, NULL, NULL),
5651+
(2, '', '', 0x, NULL, NULL, NULL),
5652+
(3, '', '', 0x, NULL, NULL, NULL)
5653+
""")
5654+
db_connection.commit()
5655+
5656+
# Test fetchall - should not trigger any assertions about dataLen
5657+
cursor.execute("""
5658+
SELECT empty_varchar, empty_nvarchar, empty_binary,
5659+
null_varchar, null_nvarchar, null_binary
5660+
FROM #pytest_batch_empty_assertions ORDER BY id
5661+
""")
5662+
5663+
rows = cursor.fetchall()
5664+
assert len(rows) == 3, "Should return 3 rows"
5665+
5666+
for i, row in enumerate(rows):
5667+
# Check empty values (should be empty strings/bytes, not None)
5668+
assert row[0] == '', f"Row {i+1} empty_varchar should be empty string"
5669+
assert row[1] == '', f"Row {i+1} empty_nvarchar should be empty string"
5670+
assert row[2] == b'', f"Row {i+1} empty_binary should be empty bytes"
5671+
5672+
# Check NULL values (should be None)
5673+
assert row[3] is None, f"Row {i+1} null_varchar should be None"
5674+
assert row[4] is None, f"Row {i+1} null_nvarchar should be None"
5675+
assert row[5] is None, f"Row {i+1} null_binary should be None"
5676+
5677+
# Test fetchmany - should also not trigger assertions
5678+
cursor.execute("""
5679+
SELECT empty_nvarchar, empty_binary
5680+
FROM #pytest_batch_empty_assertions ORDER BY id
5681+
""")
5682+
5683+
# Fetch in batches
5684+
first_batch = cursor.fetchmany(2)
5685+
assert len(first_batch) == 2, "First batch should return 2 rows"
5686+
5687+
second_batch = cursor.fetchmany(2) # Ask for 2, get 1
5688+
assert len(second_batch) == 1, "Second batch should return 1 row"
5689+
5690+
# All batches should have correct empty values
5691+
all_batch_rows = first_batch + second_batch
5692+
for i, row in enumerate(all_batch_rows):
5693+
assert row[0] == '', f"Batch row {i+1} empty_nvarchar should be empty string"
5694+
assert row[1] == b'', f"Batch row {i+1} empty_binary should be empty bytes"
5695+
assert isinstance(row[1], bytes), f"Batch row {i+1} should return bytes type"
5696+
5697+
except Exception as e:
5698+
# Should specifically not fail with dataLen assertion errors
5699+
error_msg = str(e).lower()
5700+
assert "data length must be" not in error_msg, f"Should not fail with dataLen assertion: {e}"
5701+
assert "assert" not in error_msg or "assertion" not in error_msg, f"Should not fail with assertion errors: {e}"
5702+
# Re-raise if it's a different kind of error
5703+
raise
5704+
5705+
finally:
5706+
cursor.execute("DROP TABLE #pytest_batch_empty_assertions")
5707+
db_connection.commit()
5708+
54365709
def test_close(db_connection):
54375710
"""Test closing the cursor"""
54385711
try:

0 commit comments

Comments
 (0)