Skip to content

Commit 99d7bd0

Browse files
authored
FEAT: Complex Data Type Support- money & smallmoney (#230)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below (e.g. AB#37452) For external contributors: Insert Github Issue number below (e.g. #149) Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#34936](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34936) [AB#34937](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34937) <!-- External contributors: GitHub Issue --> > GitHub Issue: #<ISSUE_NUMBER> ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request enhances support for SQL Server MONEY and SMALLMONEY types in the Python driver, ensuring correct handling of boundary values, NULLs, and round-trip conversions. It also improves the encoding and decoding of decimal values and adds comprehensive tests to validate the new behavior. **MONEY and SMALLMONEY Type Handling:** * Updated `_map_sql_type` in `cursor.py` to detect decimal values within MONEY and SMALLMONEY ranges and bind them as strings, falling back to generic numeric binding otherwise. **Decimal Value Encoding/Decoding:** * Improved encoding of decimal values in `ddbc_bindings.cpp` by writing them as little-endian byte arrays, ensuring compatibility with SQL_NUMERIC_STRUCT. * Enhanced decoding of SQL DECIMAL/NUMERIC types to Python `decimal.Decimal`, with robust NULL and error handling during result fetching. **Testing Enhancements:** * Added comprehensive tests in `test_004_cursor.py` to verify MONEY and SMALLMONEY handling, including: - Insert/fetch of typical, boundary, and NULL values - Round-trip conversion checks - Boundary value assertions - Validation that out-of-range and invalid values raise errors <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent a3dd3b8 commit 99d7bd0

File tree

3 files changed

+270
-26
lines changed

3 files changed

+270
-26
lines changed

mssql_python/cursor.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020

2121
# Constants for string handling
2222
MAX_INLINE_CHAR = 4000 # NVARCHAR/VARCHAR inline limit; this triggers NVARCHAR(MAX)/VARCHAR(MAX) + DAE
23+
SMALLMONEY_MIN = decimal.Decimal('-214748.3648')
24+
SMALLMONEY_MAX = decimal.Decimal('214748.3647')
25+
MONEY_MIN = decimal.Decimal('-922337203685477.5808')
26+
MONEY_MAX = decimal.Decimal('922337203685477.5807')
2327

2428
class Cursor:
2529
"""
@@ -282,18 +286,39 @@ def _map_sql_type(self, param, parameters_list, i):
282286
0,
283287
False,
284288
)
285-
289+
286290
if isinstance(param, decimal.Decimal):
287-
parameters_list[i] = self._get_numeric_data(
288-
param
289-
) # Replace the parameter with the dictionary
290-
return (
291-
ddbc_sql_const.SQL_NUMERIC.value,
292-
ddbc_sql_const.SQL_C_NUMERIC.value,
293-
parameters_list[i].precision,
294-
parameters_list[i].scale,
295-
False,
296-
)
291+
# Detect MONEY / SMALLMONEY range
292+
if SMALLMONEY_MIN <= param <= SMALLMONEY_MAX:
293+
# smallmoney
294+
parameters_list[i] = str(param)
295+
return (
296+
ddbc_sql_const.SQL_VARCHAR.value,
297+
ddbc_sql_const.SQL_C_CHAR.value,
298+
len(parameters_list[i]),
299+
0,
300+
False,
301+
)
302+
elif MONEY_MIN <= param <= MONEY_MAX:
303+
# money
304+
parameters_list[i] = str(param)
305+
return (
306+
ddbc_sql_const.SQL_VARCHAR.value,
307+
ddbc_sql_const.SQL_C_CHAR.value,
308+
len(parameters_list[i]),
309+
0,
310+
False,
311+
)
312+
else:
313+
# fallback to generic numeric binding
314+
parameters_list[i] = self._get_numeric_data(param)
315+
return (
316+
ddbc_sql_const.SQL_NUMERIC.value,
317+
ddbc_sql_const.SQL_C_NUMERIC.value,
318+
parameters_list[i].precision,
319+
parameters_list[i].scale,
320+
False,
321+
)
297322

298323
if isinstance(param, str):
299324
if (

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -480,9 +480,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
480480
reinterpret_cast<char*>(&decimalParam.val),
481481
sizeof(decimalParam.val));
482482
dataPtr = static_cast<void*>(decimalPtr);
483-
// TODO: Remove these lines
484-
//strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
485-
//*strLenOrIndPtr = sizeof(SQL_NUMERIC_STRUCT);
486483
break;
487484
}
488485
case SQL_C_GUID: {
@@ -1913,28 +1910,31 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
19131910
case SQL_DECIMAL:
19141911
case SQL_NUMERIC: {
19151912
SQLCHAR numericStr[MAX_DIGITS_IN_NUMERIC] = {0};
1916-
SQLLEN indicator;
1913+
SQLLEN indicator = 0;
1914+
19171915
ret = SQLGetData_ptr(hStmt, i, SQL_C_CHAR, numericStr, sizeof(numericStr), &indicator);
19181916

19191917
if (SQL_SUCCEEDED(ret)) {
1920-
try{
1921-
// Convert numericStr to py::decimal.Decimal and append to row
1922-
row.append(py::module_::import("decimal").attr("Decimal")(
1923-
std::string(reinterpret_cast<const char*>(numericStr), indicator)));
1924-
} catch (const py::error_already_set& e) {
1925-
// If the conversion fails, append None
1926-
LOG("Error converting to decimal: {}", e.what());
1918+
if (indicator == SQL_NULL_DATA) {
19271919
row.append(py::none());
1920+
} else {
1921+
try {
1922+
std::string s(reinterpret_cast<const char*>(numericStr));
1923+
auto Decimal = py::module_::import("decimal").attr("Decimal");
1924+
row.append(Decimal(s));
1925+
} catch (const py::error_already_set& e) {
1926+
LOG("Error converting to Decimal: {}", e.what());
1927+
row.append(py::none());
1928+
}
19281929
}
1929-
}
1930-
else {
1931-
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return "
1932-
"code - {}. Returning NULL value instead",
1930+
} else {
1931+
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData rc - {}",
19331932
i, dataType, ret);
19341933
row.append(py::none());
19351934
}
19361935
break;
19371936
}
1937+
19381938
case SQL_DOUBLE:
19391939
case SQL_FLOAT: {
19401940
SQLDOUBLE doubleValue;

tests/test_004_cursor.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6548,6 +6548,225 @@ def test_only_null_and_empty_binary(cursor, db_connection):
65486548
drop_table_if_exists(cursor, "#pytest_null_empty_binary")
65496549
db_connection.commit()
65506550

6551+
6552+
def test_money_smallmoney_insert_fetch(cursor, db_connection):
6553+
"""Test inserting and retrieving valid MONEY and SMALLMONEY values including boundaries and typical data"""
6554+
try:
6555+
drop_table_if_exists(cursor, "dbo.money_test")
6556+
cursor.execute("""
6557+
CREATE TABLE dbo.money_test (
6558+
id INT IDENTITY PRIMARY KEY,
6559+
m MONEY,
6560+
sm SMALLMONEY,
6561+
d DECIMAL(19,4),
6562+
n NUMERIC(10,4)
6563+
)
6564+
""")
6565+
db_connection.commit()
6566+
6567+
# Max values
6568+
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
6569+
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"),
6570+
decimal.Decimal("9999999999999.9999"), decimal.Decimal("1234.5678")))
6571+
6572+
# Min values
6573+
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
6574+
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"),
6575+
decimal.Decimal("-9999999999999.9999"), decimal.Decimal("-1234.5678")))
6576+
6577+
# Typical values
6578+
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
6579+
(decimal.Decimal("1234567.8901"), decimal.Decimal("12345.6789"),
6580+
decimal.Decimal("42.4242"), decimal.Decimal("3.1415")))
6581+
6582+
# NULL values
6583+
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
6584+
(None, None, None, None))
6585+
6586+
db_connection.commit()
6587+
6588+
cursor.execute("SELECT m, sm, d, n FROM dbo.money_test ORDER BY id")
6589+
results = cursor.fetchall()
6590+
assert len(results) == 4, f"Expected 4 rows, got {len(results)}"
6591+
6592+
expected = [
6593+
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"),
6594+
decimal.Decimal("9999999999999.9999"), decimal.Decimal("1234.5678")),
6595+
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"),
6596+
decimal.Decimal("-9999999999999.9999"), decimal.Decimal("-1234.5678")),
6597+
(decimal.Decimal("1234567.8901"), decimal.Decimal("12345.6789"),
6598+
decimal.Decimal("42.4242"), decimal.Decimal("3.1415")),
6599+
(None, None, None, None)
6600+
]
6601+
6602+
for i, (row, exp) in enumerate(zip(results, expected)):
6603+
for j, (val, exp_val) in enumerate(zip(row, exp), 1):
6604+
if exp_val is None:
6605+
assert val is None, f"Row {i+1} col{j}: expected None, got {val}"
6606+
else:
6607+
assert val == exp_val, f"Row {i+1} col{j}: expected {exp_val}, got {val}"
6608+
assert isinstance(val, decimal.Decimal), f"Row {i+1} col{j}: expected Decimal, got {type(val)}"
6609+
6610+
except Exception as e:
6611+
pytest.fail(f"MONEY and SMALLMONEY insert/fetch test failed: {e}")
6612+
finally:
6613+
drop_table_if_exists(cursor, "dbo.money_test")
6614+
db_connection.commit()
6615+
6616+
6617+
def test_money_smallmoney_null_handling(cursor, db_connection):
6618+
"""Test that NULL values for MONEY and SMALLMONEY are stored and retrieved correctly"""
6619+
try:
6620+
drop_table_if_exists(cursor, "dbo.money_test")
6621+
cursor.execute("""
6622+
CREATE TABLE dbo.money_test (
6623+
id INT IDENTITY PRIMARY KEY,
6624+
m MONEY,
6625+
sm SMALLMONEY
6626+
)
6627+
""")
6628+
db_connection.commit()
6629+
6630+
# Row with both NULLs
6631+
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", (None, None))
6632+
6633+
# Row with m filled, sm NULL
6634+
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
6635+
(decimal.Decimal("123.4500"), None))
6636+
6637+
# Row with m NULL, sm filled
6638+
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
6639+
(None, decimal.Decimal("67.8900")))
6640+
6641+
db_connection.commit()
6642+
6643+
cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id")
6644+
results = cursor.fetchall()
6645+
assert len(results) == 3, f"Expected 3 rows, got {len(results)}"
6646+
6647+
expected = [
6648+
(None, None),
6649+
(decimal.Decimal("123.4500"), None),
6650+
(None, decimal.Decimal("67.8900"))
6651+
]
6652+
6653+
for i, (row, exp) in enumerate(zip(results, expected)):
6654+
for j, (val, exp_val) in enumerate(zip(row, exp), 1):
6655+
if exp_val is None:
6656+
assert val is None, f"Row {i+1} col{j}: expected None, got {val}"
6657+
else:
6658+
assert val == exp_val, f"Row {i+1} col{j}: expected {exp_val}, got {val}"
6659+
assert isinstance(val, decimal.Decimal), f"Row {i+1} col{j}: expected Decimal, got {type(val)}"
6660+
6661+
except Exception as e:
6662+
pytest.fail(f"MONEY and SMALLMONEY NULL handling test failed: {e}")
6663+
finally:
6664+
drop_table_if_exists(cursor, "dbo.money_test")
6665+
db_connection.commit()
6666+
6667+
6668+
def test_money_smallmoney_roundtrip(cursor, db_connection):
6669+
"""Test inserting and retrieving MONEY and SMALLMONEY using decimal.Decimal roundtrip"""
6670+
try:
6671+
drop_table_if_exists(cursor, "dbo.money_test")
6672+
cursor.execute("""
6673+
CREATE TABLE dbo.money_test (
6674+
id INT IDENTITY PRIMARY KEY,
6675+
m MONEY,
6676+
sm SMALLMONEY
6677+
)
6678+
""")
6679+
db_connection.commit()
6680+
6681+
values = (decimal.Decimal("12345.6789"), decimal.Decimal("987.6543"))
6682+
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", values)
6683+
db_connection.commit()
6684+
6685+
cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id DESC")
6686+
row = cursor.fetchone()
6687+
for i, (val, exp_val) in enumerate(zip(row, values), 1):
6688+
assert val == exp_val, f"col{i} roundtrip mismatch, got {val}, expected {exp_val}"
6689+
assert isinstance(val, decimal.Decimal), f"col{i} should be Decimal, got {type(val)}"
6690+
6691+
except Exception as e:
6692+
pytest.fail(f"MONEY and SMALLMONEY roundtrip test failed: {e}")
6693+
finally:
6694+
drop_table_if_exists(cursor, "dbo.money_test")
6695+
db_connection.commit()
6696+
6697+
6698+
def test_money_smallmoney_boundaries(cursor, db_connection):
6699+
"""Test boundary values for MONEY and SMALLMONEY types are handled correctly"""
6700+
try:
6701+
drop_table_if_exists(cursor, "dbo.money_test")
6702+
cursor.execute("""
6703+
CREATE TABLE dbo.money_test (
6704+
id INT IDENTITY PRIMARY KEY,
6705+
m MONEY,
6706+
sm SMALLMONEY
6707+
)
6708+
""")
6709+
db_connection.commit()
6710+
6711+
# Insert max boundary
6712+
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
6713+
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647")))
6714+
6715+
# Insert min boundary
6716+
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
6717+
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648")))
6718+
6719+
db_connection.commit()
6720+
6721+
cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id DESC")
6722+
results = cursor.fetchall()
6723+
expected = [
6724+
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648")),
6725+
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"))
6726+
]
6727+
for i, (row, exp_row) in enumerate(zip(results, expected), 1):
6728+
for j, (val, exp_val) in enumerate(zip(row, exp_row), 1):
6729+
assert val == exp_val, f"Row {i} col{j} mismatch, got {val}, expected {exp_val}"
6730+
assert isinstance(val, decimal.Decimal), f"Row {i} col{j} should be Decimal, got {type(val)}"
6731+
6732+
except Exception as e:
6733+
pytest.fail(f"MONEY and SMALLMONEY boundary values test failed: {e}")
6734+
finally:
6735+
drop_table_if_exists(cursor, "dbo.money_test")
6736+
db_connection.commit()
6737+
6738+
6739+
def test_money_smallmoney_invalid_values(cursor, db_connection):
6740+
"""Test that invalid or out-of-range MONEY and SMALLMONEY values raise errors"""
6741+
try:
6742+
drop_table_if_exists(cursor, "dbo.money_test")
6743+
cursor.execute("""
6744+
CREATE TABLE dbo.money_test (
6745+
id INT IDENTITY PRIMARY KEY,
6746+
m MONEY,
6747+
sm SMALLMONEY
6748+
)
6749+
""")
6750+
db_connection.commit()
6751+
6752+
# Out of range MONEY
6753+
with pytest.raises(Exception):
6754+
cursor.execute("INSERT INTO dbo.money_test (m) VALUES (?)", (decimal.Decimal("922337203685477.5808"),))
6755+
6756+
# Out of range SMALLMONEY
6757+
with pytest.raises(Exception):
6758+
cursor.execute("INSERT INTO dbo.money_test (sm) VALUES (?)", (decimal.Decimal("214748.3648"),))
6759+
6760+
# Invalid string
6761+
with pytest.raises(Exception):
6762+
cursor.execute("INSERT INTO dbo.money_test (m) VALUES (?)", ("invalid_string",))
6763+
6764+
except Exception as e:
6765+
pytest.fail(f"MONEY and SMALLMONEY invalid values test failed: {e}")
6766+
finally:
6767+
drop_table_if_exists(cursor, "dbo.money_test")
6768+
db_connection.commit()
6769+
65516770
def test_close(db_connection):
65526771
"""Test closing the cursor"""
65536772
try:

0 commit comments

Comments
 (0)