Skip to content

Commit d849a09

Browse files
jahnvi480Copilot
andauthored
FIX: Resolve UDT parameter metadata before binding (#818)
### Work Item / Issue Reference > [AB#48445](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/48445) ------------------------------------------------------------------- ### Summary Resolve explicitly declared SQL_SS_UDT parameter identities through SQLDescribeParam before binding, without changing the public API. - Cover scalar and array binding, typed NULLs, and statement reuse. - Normalize large UDT sizes and preserve setinputsizes overrides across streamed executemany rows. - Add 13 regression cases to the existing spatial-types test file. - Document metadata-discovery limitations and available workarounds. ### Validation Windows x64, Python 3.13.15, SQL Server 2019: - All 13 new regressions fail on unmodified HEAD and pass with this fix. - Focused regression suite: 738 passed, 4 skipped. - Full non-stress suite: 5,241 passed, 173 skipped, 2 failed. - Both failures also reproduce on unmodified HEAD: a password-masking assertion with passwordless authentication and a Windows path-length limit. - Repository formatting checks pass. ### Limitations Temporary tables and table variables may not support metadata discovery. Custom CLR assembly UDTs and cross-platform behavior remain unverified. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 3089361 commit d849a09

3 files changed

Lines changed: 191 additions & 4 deletions

File tree

‎mssql_python/cursor.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,14 @@ def setinputsizes(self, sizes: List[Union[int, tuple]]) -> None:
11841184
cursor.executemany(sql, params)
11851185
11861186
Note:
1187+
SQL_SS_UDT parameters require a statement whose parameter metadata
1188+
SQL Server can describe. The driver discovers the UDT identity before
1189+
binding; this may require a metadata round trip. Temporary tables and
1190+
table variables may not be describable, and discovery errors are
1191+
propagated. For built-in spatial types, bind serialized bytes without
1192+
a SQL_SS_UDT override or use a SQL constructor such as
1193+
hierarchyid::Parse(?) with a text parameter instead.
1194+
11871195
When inserting NULL into BINARY/VARBINARY columns in temp tables (#table)
11881196
or table variables, SQLDescribeParam cannot resolve the column type and
11891197
falls back to SQL_VARCHAR. This causes an implicit conversion error from
@@ -2686,15 +2694,21 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
26862694
paraminfo.columnSize = max(max_binary_size, 1)
26872695

26882696
parameters_type.append(paraminfo)
2689-
if paraminfo.isDAE:
2690-
any_dae = True
2697+
if paraminfo.isDAE:
2698+
any_dae = True
26912699

26922700
if any_dae:
26932701
logger.debug(
26942702
"DAE parameters detected. Falling back to row-by-row execution with streaming.",
26952703
)
2696-
for row in seq_of_parameters:
2697-
self.execute(operation, row)
2704+
input_sizes = self._inputsizes
2705+
try:
2706+
for row in seq_of_parameters:
2707+
# execute() consumes overrides; every streamed row needs them.
2708+
self._inputsizes = input_sizes
2709+
self.execute(operation, row)
2710+
finally:
2711+
self._reset_inputsizes()
26982712
return
26992713

27002714
# Process parameters into column-wise format with possible type conversions

‎mssql_python/pybind/ddbc_bindings.cpp‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,45 @@ static void PreResolveUnknownNullTypes(SqlHandle& handle, SQLHANDLE hStmt,
452452
}
453453
}
454454

455+
static SQLRETURN PreResolveUdtTypes(SQLHANDLE hStmt, std::vector<ParamInfo>& paramInfos) {
456+
bool hasUdt = false;
457+
for (const auto& info : paramInfos) {
458+
if (info.paramSQLType == SQL_SS_UDT) {
459+
hasUdt = true;
460+
break;
461+
}
462+
}
463+
if (!hasUdt) return SQL_SUCCESS;
464+
465+
// UDT identity lives in the IPD, not in the scalar describe cache. Describe
466+
// unbound records on each execution, including reused statements and DAE rows.
467+
SQLRETURN rc = SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS);
468+
if (!SQL_SUCCEEDED(rc)) {
469+
LOG("PreResolveUdtTypes: SQL_RESET_PARAMS failed, rc=%d", rc);
470+
return rc;
471+
}
472+
for (size_t i = 0; i < paramInfos.size(); ++i) {
473+
if (paramInfos[i].paramSQLType != SQL_SS_UDT) continue;
474+
SQLSMALLINT type, digits, nullable;
475+
SQLULEN size;
476+
{
477+
py::gil_scoped_release release;
478+
rc = SQLDescribeParam_ptr(hStmt, static_cast<SQLUSMALLINT>(i + 1),
479+
&type, &size, &digits, &nullable);
480+
}
481+
if (!SQL_SUCCEEDED(rc)) {
482+
LOG("PreResolveUdtTypes: SQLDescribeParam failed for param[%zu], rc=%d", i, rc);
483+
return rc;
484+
}
485+
// ODBC requires SQL_SS_LENGTH_UNLIMITED (0), not a byte count above
486+
// 8000, for large UDTs. Type detection has already selected streaming.
487+
if (paramInfos[i].columnSize > MAX_INLINE_BINARY) {
488+
paramInfos[i].columnSize = 0;
489+
}
490+
}
491+
return SQL_SUCCESS;
492+
}
493+
455494
// Given a list of parameters and their ParamInfo, calls SQLBindParameter on
456495
// each of them with appropriate arguments
457496
SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& params,
@@ -463,6 +502,8 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par
463502
"with %zu parameters",
464503
(void*)hStmt, params.size());
465504

505+
SQLRETURN describeRc = PreResolveUdtTypes(hStmt, paramInfos);
506+
if (!SQL_SUCCEEDED(describeRc)) return describeRc;
466507
// GH-627: resolve unknown NULL param SQL types before binding any param.
467508
PreResolveUnknownNullTypes(handle, hStmt, paramInfos, &params);
468509
for (int paramIndex = 0; paramIndex < params.size(); paramIndex++) {
@@ -2257,6 +2298,8 @@ SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list&
22572298
std::vector<std::shared_ptr<void>> tempBuffers;
22582299

22592300
try {
2301+
SQLRETURN describeRc = PreResolveUdtTypes(hStmt, paramInfos);
2302+
if (!SQL_SUCCEEDED(describeRc)) return describeRc;
22602303
// GH-627: resolve unknown NULL array param SQL types before binding any param.
22612304
PreResolveUnknownNullTypes(handle, hStmt, paramInfos);
22622305
for (int paramIndex = 0; paramIndex < columnwise_params.size(); ++paramIndex) {

‎tests/test_017_spatial_types.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Tests for SQL Server spatial types (geography, geometry, hierarchyid)."""
22

33
import pytest
4+
import uuid
45
from decimal import Decimal
56
import mssql_python
7+
from mssql_python.constants import ConstantsDDBC as C
68

79
# ==================== GEOGRAPHY TYPE TESTS ====================
810

@@ -824,3 +826,131 @@ def test_hierarchyid_invalid_parsing(cursor, db_connection):
824826
"1/2/",
825827
)
826828
db_connection.rollback()
829+
830+
831+
# ==================== UDT PARAMETER BINDING (GH-816) ====================
832+
833+
834+
@pytest.fixture
835+
def udt_table(db_connection):
836+
name = f"dbo.udt_parameters_{uuid.uuid4().hex}"
837+
with db_connection.cursor() as cursor:
838+
cursor.execute(
839+
f"CREATE TABLE {name} "
840+
"(id int, h hierarchyid NULL, g geometry NULL, geo geography NULL, b varbinary(10))"
841+
)
842+
try:
843+
yield name
844+
finally:
845+
with db_connection.cursor() as cursor:
846+
cursor.execute(f"DROP TABLE {name}")
847+
db_connection.commit()
848+
849+
850+
@pytest.fixture
851+
def udt_payloads(db_connection):
852+
with db_connection.cursor() as cursor:
853+
cursor.execute(
854+
"SELECT CONVERT(varbinary(max), hierarchyid::Parse('/1/')), "
855+
"CONVERT(varbinary(max), geometry::STGeomFromText('POINT(1 2)', 0)), "
856+
"CONVERT(varbinary(max), geography::STGeomFromText('POINT(1 2)', 4326))"
857+
)
858+
return tuple(cursor.fetchone())
859+
860+
861+
@pytest.mark.parametrize("method", ["execute", "executemany"])
862+
@pytest.mark.parametrize("value_kind", ["bytes", "bytearray", "null"])
863+
def test_explicit_udt_parameters(db_connection, udt_table, udt_payloads, method, value_kind):
864+
if value_kind == "null":
865+
values = [None] * 3
866+
elif value_kind == "bytearray":
867+
values = [bytearray(value) for value in udt_payloads]
868+
else:
869+
values = list(udt_payloads)
870+
sql = f"INSERT INTO {udt_table} (id, h, g, geo) VALUES (?, ?, ?, ?)"
871+
sizes = [(C.SQL_INTEGER.value, 0, 0)] + [(C.SQL_SS_UDT.value, 8000, 0)] * 3
872+
with db_connection.cursor() as cursor:
873+
for i in range(2):
874+
cursor.setinputsizes(sizes)
875+
params = [i, *values]
876+
getattr(cursor, method)(sql, params if method == "execute" else [params])
877+
cursor.execute(
878+
f"SELECT CONVERT(varbinary(max), h), CONVERT(varbinary(max), g), "
879+
f"CONVERT(varbinary(max), geo) FROM {udt_table} ORDER BY id"
880+
)
881+
expected = (None, None, None) if value_kind == "null" else udt_payloads
882+
assert [tuple(row) for row in cursor.fetchall()] == [expected, expected]
883+
884+
885+
def test_udt_array_mixed_nulls(db_connection, udt_table, udt_payloads):
886+
with db_connection.cursor() as cursor:
887+
cursor.setinputsizes([(C.SQL_SS_UDT.value, 8000, 0)] * 3)
888+
cursor.executemany(
889+
f"INSERT INTO {udt_table} (h, g, geo) VALUES (?, ?, ?)",
890+
[(None, None, None), udt_payloads, (None, None, None), udt_payloads],
891+
)
892+
cursor.execute(f"SELECT COUNT(*), COUNT(h), COUNT(g), COUNT(geo) FROM {udt_table}")
893+
assert tuple(cursor.fetchone()) == (4, 2, 2, 2)
894+
895+
896+
def test_udt_changed_statement_and_type(db_connection, udt_table, udt_payloads):
897+
with db_connection.cursor() as cursor:
898+
for column, payload in zip(("h", "g", "geo", "h"), (*udt_payloads, udt_payloads[0])):
899+
cursor.setinputsizes([(C.SQL_SS_UDT.value, 8000, 0)])
900+
cursor.execute(f"INSERT INTO {udt_table} ({column}) VALUES (?)", [payload])
901+
cursor.execute(f"SELECT COUNT(h), COUNT(g), COUNT(geo) FROM {udt_table}")
902+
assert tuple(cursor.fetchone()) == (2, 1, 1)
903+
904+
905+
def test_udt_with_inferred_binary_null(db_connection, udt_table, udt_payloads):
906+
with db_connection.cursor() as cursor:
907+
for _ in range(2):
908+
cursor.setinputsizes([(C.SQL_SS_UDT.value, 8000, 0)])
909+
with pytest.warns(Warning, match="Number of input sizes"):
910+
cursor.execute(
911+
f"INSERT INTO {udt_table} (h, b) VALUES (?, ?)", [udt_payloads[0], None]
912+
)
913+
cursor.execute(f"SELECT h.ToString(), b FROM {udt_table}")
914+
assert [tuple(row) for row in cursor.fetchall()] == [("/1/", None)] * 2
915+
916+
917+
@pytest.mark.parametrize("method", ["execute", "executemany"])
918+
def test_large_udt_parameters(db_connection, udt_table, method, monkeypatch):
919+
wkt = "LINESTRING(" + ", ".join(f"{i} {i % 7}" for i in range(1000)) + ")"
920+
with db_connection.cursor() as cursor:
921+
cursor.execute("SELECT CONVERT(varbinary(max), geometry::STGeomFromText(?, 0))", [wkt])
922+
payload = cursor.fetchone()[0]
923+
assert len(payload) > 8000
924+
cursor.setinputsizes([(C.SQL_SS_UDT.value, len(payload), 0)])
925+
sql = f"INSERT INTO {udt_table} (g) VALUES (?)"
926+
params = [payload] if method == "execute" else [(payload,), (payload,)]
927+
sizes = cursor._inputsizes
928+
observed_sizes = []
929+
execute = cursor.execute
930+
931+
def record_overrides(operation, parameters):
932+
observed_sizes.append(cursor._inputsizes)
933+
return execute(operation, parameters)
934+
935+
with monkeypatch.context() as patch:
936+
patch.setattr(cursor, "execute", record_overrides)
937+
getattr(cursor, method)(sql, params)
938+
expected_rows = 1 if method == "execute" else 2
939+
assert observed_sizes == [sizes] * expected_rows
940+
assert cursor._inputsizes is None
941+
cursor.execute(f"SELECT CONVERT(varbinary(max), g) FROM {udt_table}")
942+
assert [row[0] for row in cursor.fetchall()] == [payload] * expected_rows
943+
944+
945+
@pytest.mark.parametrize("method", ["execute", "executemany"])
946+
def test_udt_discovery_error_and_recovery(db_connection, udt_table, udt_payloads, method):
947+
with db_connection.cursor() as cursor:
948+
cursor.setinputsizes([(C.SQL_SS_UDT.value, 8000, 0)])
949+
missing = f"dbo.udt_missing_{uuid.uuid4().hex}"
950+
params = [udt_payloads[0]] if method == "execute" else [(udt_payloads[0],)]
951+
with pytest.raises(mssql_python.DatabaseError, match="Invalid object name"):
952+
getattr(cursor, method)(f"INSERT INTO {missing} (h) VALUES (?)", params)
953+
cursor.setinputsizes([(C.SQL_SS_UDT.value, 8000, 0)])
954+
getattr(cursor, method)(f"INSERT INTO {udt_table} (h) VALUES (?)", params)
955+
cursor.execute(f"SELECT h.ToString() FROM {udt_table}")
956+
assert cursor.fetchone()[0] == "/1/"

0 commit comments

Comments
 (0)