Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

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

class Cursor:
"""
Expand Down Expand Up @@ -282,18 +286,39 @@ def _map_sql_type(self, param, parameters_list, i):
0,
False,
)

if isinstance(param, decimal.Decimal):
parameters_list[i] = self._get_numeric_data(
param
) # Replace the parameter with the dictionary
return (
ddbc_sql_const.SQL_NUMERIC.value,
ddbc_sql_const.SQL_C_NUMERIC.value,
parameters_list[i].precision,
parameters_list[i].scale,
False,
)
# Detect MONEY / SMALLMONEY range
if SMALLMONEY_MIN <= param <= SMALLMONEY_MAX:
# smallmoney
parameters_list[i] = str(param)
return (
ddbc_sql_const.SQL_VARCHAR.value,
ddbc_sql_const.SQL_C_CHAR.value,
len(parameters_list[i]),
0,
False,
)
elif MONEY_MIN <= param <= MONEY_MAX:
# money
parameters_list[i] = str(param)
return (
ddbc_sql_const.SQL_VARCHAR.value,
ddbc_sql_const.SQL_C_CHAR.value,
len(parameters_list[i]),
0,
False,
)
else:
# fallback to generic numeric binding
parameters_list[i] = self._get_numeric_data(param)
return (
ddbc_sql_const.SQL_NUMERIC.value,
ddbc_sql_const.SQL_C_NUMERIC.value,
parameters_list[i].precision,
parameters_list[i].scale,
False,
)

if isinstance(param, str):
if (
Expand Down
30 changes: 15 additions & 15 deletions mssql_python/pybind/ddbc_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
reinterpret_cast<char*>(&decimalParam.val),
sizeof(decimalParam.val));
dataPtr = static_cast<void*>(decimalPtr);
// TODO: Remove these lines
//strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
//*strLenOrIndPtr = sizeof(SQL_NUMERIC_STRUCT);
break;
}
case SQL_C_GUID: {
Expand Down Expand Up @@ -1913,28 +1910,31 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
case SQL_DECIMAL:
case SQL_NUMERIC: {
SQLCHAR numericStr[MAX_DIGITS_IN_NUMERIC] = {0};
SQLLEN indicator;
SQLLEN indicator = 0;

ret = SQLGetData_ptr(hStmt, i, SQL_C_CHAR, numericStr, sizeof(numericStr), &indicator);

if (SQL_SUCCEEDED(ret)) {
try{
// Convert numericStr to py::decimal.Decimal and append to row
row.append(py::module_::import("decimal").attr("Decimal")(
std::string(reinterpret_cast<const char*>(numericStr), indicator)));
} catch (const py::error_already_set& e) {
// If the conversion fails, append None
LOG("Error converting to decimal: {}", e.what());
if (indicator == SQL_NULL_DATA) {
row.append(py::none());
} else {
try {
std::string s(reinterpret_cast<const char*>(numericStr));
auto Decimal = py::module_::import("decimal").attr("Decimal");
row.append(Decimal(s));
} catch (const py::error_already_set& e) {
LOG("Error converting to Decimal: {}", e.what());
row.append(py::none());
}
}
}
else {
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return "
"code - {}. Returning NULL value instead",
} else {
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData rc - {}",
i, dataType, ret);
row.append(py::none());
}
break;
}

case SQL_DOUBLE:
case SQL_FLOAT: {
SQLDOUBLE doubleValue;
Expand Down
219 changes: 219 additions & 0 deletions tests/test_004_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6548,6 +6548,225 @@ def test_only_null_and_empty_binary(cursor, db_connection):
drop_table_if_exists(cursor, "#pytest_null_empty_binary")
db_connection.commit()


def test_money_smallmoney_insert_fetch(cursor, db_connection):
"""Test inserting and retrieving valid MONEY and SMALLMONEY values including boundaries and typical data"""
try:
drop_table_if_exists(cursor, "dbo.money_test")
cursor.execute("""
CREATE TABLE dbo.money_test (
id INT IDENTITY PRIMARY KEY,
m MONEY,
sm SMALLMONEY,
d DECIMAL(19,4),
n NUMERIC(10,4)
)
""")
db_connection.commit()

# Max values
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"),
decimal.Decimal("9999999999999.9999"), decimal.Decimal("1234.5678")))

# Min values
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"),
decimal.Decimal("-9999999999999.9999"), decimal.Decimal("-1234.5678")))

# Typical values
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
(decimal.Decimal("1234567.8901"), decimal.Decimal("12345.6789"),
decimal.Decimal("42.4242"), decimal.Decimal("3.1415")))

# NULL values
cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)",
(None, None, None, None))

db_connection.commit()

cursor.execute("SELECT m, sm, d, n FROM dbo.money_test ORDER BY id")
results = cursor.fetchall()
assert len(results) == 4, f"Expected 4 rows, got {len(results)}"

expected = [
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"),
decimal.Decimal("9999999999999.9999"), decimal.Decimal("1234.5678")),
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"),
decimal.Decimal("-9999999999999.9999"), decimal.Decimal("-1234.5678")),
(decimal.Decimal("1234567.8901"), decimal.Decimal("12345.6789"),
decimal.Decimal("42.4242"), decimal.Decimal("3.1415")),
(None, None, None, None)
]

for i, (row, exp) in enumerate(zip(results, expected)):
for j, (val, exp_val) in enumerate(zip(row, exp), 1):
if exp_val is None:
assert val is None, f"Row {i+1} col{j}: expected None, got {val}"
else:
assert val == exp_val, f"Row {i+1} col{j}: expected {exp_val}, got {val}"
assert isinstance(val, decimal.Decimal), f"Row {i+1} col{j}: expected Decimal, got {type(val)}"

except Exception as e:
pytest.fail(f"MONEY and SMALLMONEY insert/fetch test failed: {e}")
finally:
drop_table_if_exists(cursor, "dbo.money_test")
db_connection.commit()


def test_money_smallmoney_null_handling(cursor, db_connection):
"""Test that NULL values for MONEY and SMALLMONEY are stored and retrieved correctly"""
try:
drop_table_if_exists(cursor, "dbo.money_test")
cursor.execute("""
CREATE TABLE dbo.money_test (
id INT IDENTITY PRIMARY KEY,
m MONEY,
sm SMALLMONEY
)
""")
db_connection.commit()

# Row with both NULLs
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", (None, None))

# Row with m filled, sm NULL
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
(decimal.Decimal("123.4500"), None))

# Row with m NULL, sm filled
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
(None, decimal.Decimal("67.8900")))

db_connection.commit()

cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id")
results = cursor.fetchall()
assert len(results) == 3, f"Expected 3 rows, got {len(results)}"

expected = [
(None, None),
(decimal.Decimal("123.4500"), None),
(None, decimal.Decimal("67.8900"))
]

for i, (row, exp) in enumerate(zip(results, expected)):
for j, (val, exp_val) in enumerate(zip(row, exp), 1):
if exp_val is None:
assert val is None, f"Row {i+1} col{j}: expected None, got {val}"
else:
assert val == exp_val, f"Row {i+1} col{j}: expected {exp_val}, got {val}"
assert isinstance(val, decimal.Decimal), f"Row {i+1} col{j}: expected Decimal, got {type(val)}"

except Exception as e:
pytest.fail(f"MONEY and SMALLMONEY NULL handling test failed: {e}")
finally:
drop_table_if_exists(cursor, "dbo.money_test")
db_connection.commit()


def test_money_smallmoney_roundtrip(cursor, db_connection):
"""Test inserting and retrieving MONEY and SMALLMONEY using decimal.Decimal roundtrip"""
try:
drop_table_if_exists(cursor, "dbo.money_test")
cursor.execute("""
CREATE TABLE dbo.money_test (
id INT IDENTITY PRIMARY KEY,
m MONEY,
sm SMALLMONEY
)
""")
db_connection.commit()

values = (decimal.Decimal("12345.6789"), decimal.Decimal("987.6543"))
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", values)
db_connection.commit()

cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id DESC")
row = cursor.fetchone()
for i, (val, exp_val) in enumerate(zip(row, values), 1):
assert val == exp_val, f"col{i} roundtrip mismatch, got {val}, expected {exp_val}"
assert isinstance(val, decimal.Decimal), f"col{i} should be Decimal, got {type(val)}"

except Exception as e:
pytest.fail(f"MONEY and SMALLMONEY roundtrip test failed: {e}")
finally:
drop_table_if_exists(cursor, "dbo.money_test")
db_connection.commit()


def test_money_smallmoney_boundaries(cursor, db_connection):
"""Test boundary values for MONEY and SMALLMONEY types are handled correctly"""
try:
drop_table_if_exists(cursor, "dbo.money_test")
cursor.execute("""
CREATE TABLE dbo.money_test (
id INT IDENTITY PRIMARY KEY,
m MONEY,
sm SMALLMONEY
)
""")
db_connection.commit()

# Insert max boundary
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647")))

# Insert min boundary
cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)",
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648")))

db_connection.commit()

cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id DESC")
results = cursor.fetchall()
expected = [
(decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648")),
(decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"))
]
for i, (row, exp_row) in enumerate(zip(results, expected), 1):
for j, (val, exp_val) in enumerate(zip(row, exp_row), 1):
assert val == exp_val, f"Row {i} col{j} mismatch, got {val}, expected {exp_val}"
assert isinstance(val, decimal.Decimal), f"Row {i} col{j} should be Decimal, got {type(val)}"

except Exception as e:
pytest.fail(f"MONEY and SMALLMONEY boundary values test failed: {e}")
finally:
drop_table_if_exists(cursor, "dbo.money_test")
db_connection.commit()


def test_money_smallmoney_invalid_values(cursor, db_connection):
"""Test that invalid or out-of-range MONEY and SMALLMONEY values raise errors"""
try:
drop_table_if_exists(cursor, "dbo.money_test")
cursor.execute("""
CREATE TABLE dbo.money_test (
id INT IDENTITY PRIMARY KEY,
m MONEY,
sm SMALLMONEY
)
""")
db_connection.commit()

# Out of range MONEY
with pytest.raises(Exception):
cursor.execute("INSERT INTO dbo.money_test (m) VALUES (?)", (decimal.Decimal("922337203685477.5808"),))

# Out of range SMALLMONEY
with pytest.raises(Exception):
cursor.execute("INSERT INTO dbo.money_test (sm) VALUES (?)", (decimal.Decimal("214748.3648"),))

# Invalid string
with pytest.raises(Exception):
cursor.execute("INSERT INTO dbo.money_test (m) VALUES (?)", ("invalid_string",))

except Exception as e:
pytest.fail(f"MONEY and SMALLMONEY invalid values test failed: {e}")
finally:
drop_table_if_exists(cursor, "dbo.money_test")
db_connection.commit()

def test_close(db_connection):
"""Test closing the cursor"""
try:
Expand Down