Summary
Connection.setencoding(encoding=..., ctype=SQL_CHAR) is validated, stored, and returned by getencoding() — but it has no effect on parameter binding. Text parameters are always encoded UTF-16LE and bound as ODBC SQL_C_WCHAR, whatever encoding was requested.
Nothing fails, so callers have no way to discover the setting was ignored.
Repro
import mssql_python
from mssql_python import SQL_CHAR
conn = mssql_python.connect(CONN_STR)
cur = conn.cursor()
cur.execute("CREATE TABLE #t (v VARCHAR(10) COLLATE SQL_Latin1_General_CP1_CI_AS)")
conn.setencoding(encoding="cp1252", ctype=SQL_CHAR)
assert conn.getencoding() == {"encoding": "cp1252", "ctype": 1} # stored as asked
cur.execute("INSERT INTO #t (v) VALUES (?)", "café")
- Expected: parameter encoded CP1252 and bound narrow —
63 61 66 E9, C type SQL_C_CHAR.
- Actual: parameter encoded UTF-16LE and bound wide —
63 00 61 00 66 00 E9 00, C type SQL_C_WCHAR. The cp1252 setting is never read.
The row round-trips correctly, so the no-op is invisible from the result.
Cause
SQLExecute_wrap honours the requested encoding only when the C type is a real ODBC SQL_C_CHAR:
std::string charEncoding = "utf-8";
if (encoding_settings.contains("ctype") && encoding_settings.contains("encoding")) {
int ctype = encoding_settings["ctype"].cast<int>();
if (ctype == SQL_C_CHAR /* real ODBC value: 1 */) {
charEncoding = encoding_settings["encoding"].cast<std::string>();
}
}
charEncoding is then consumed only inside case SQL_C_CHAR: in BindParameters / BindParameterArray. But no route sets paramCType to 1:
| Route |
C type source |
Value |
execute() |
PARAM_C_TYPE_TEXT in param_detect.hpp (ODBC's real SQL_C_WCHAR) |
-8 |
execute() + setinputsizes |
_SQL_TO_C_TYPE → ConstantsDDBC.SQL_C_CHAR.value |
-8 |
executemany() |
ParamInfo built in cursor.py, same map |
-8 |
(ConstantsDDBC.SQL_C_CHAR is -8, which is ODBC's SQL_C_WCHAR, not SQL_C_CHAR (1) — a long-standing alias, already noted in param_detect.hpp.)
So execution always reaches case SQL_C_WCHAR:, which does param.cast<std::u16string>() and never reads charEncoding. case SQL_C_CHAR: is unreachable for text parameters.
To be clear: binding text wide is a deliberate, well-reasoned choice, and param_detect.hpp:146 explains why (matching the legacy path, unixODBC requiring wide chars on Linux/macOS, and avoiding a Windows-only divergence). This issue is not asking to change that. It is asking that setencoding stop silently accepting a request it cannot honour.
The existing tests can't catch this
Worth flagging separately, since it means CI would not detect a regression here either:
test_sql_c_char_encoding_failure — ends if not error_raised: pass
test_encoding_error_propagation_in_bind_parameters — "If no error was raised, that's also acceptable behavior (data may be mangled)", then assert count >= 0
test_shift_jis_encoding_japanese — calls fetchone() and never asserts the result
test_cpp_bind_params_str_encoding — ASCII-only payload, passes either way
test_sql_c_char_encoding_failure sets ascii and inserts "Non-ASCII: 你好世界". If the narrow path actually ran, .encode("ascii", "strict") would raise deterministically and the test could assert unconditionally.
Request
Make the no-op non-silent. In order of preference:
- Honour the requested encoding for parameters; or
- Document
setencoding as not affecting parameter binding; or
warnings.warn (or raise) when a requested combination cannot be honoured.
Accepting the value, storing it, echoing it from getencoding(), and ignoring it is the worst of the available options.
Two smaller notes
execute and executemany disagree on the gate. SQLExecuteMany_wrap reads encoding with no ctype check:
std::string charEncoding = "utf-8"; // default
if (encodingSettings.contains("encoding")) {
charEncoding = encodingSettings["encoding"].cast<std::string>();
}
Inert today because paramCType is never 1, but the two paths should agree.
If ConstantsDDBC.SQL_C_CHAR is ever corrected to 1, please do it together with the above. On its own it would route executemany text through case SQL_C_CHAR: with charEncoding defaulting to utf-16le — i.e. UTF-16 bytes bound as narrow characters, with no user configuration involved.
Question
Is binding text parameters as SQL_C_WCHAR a stable contract across all three routes (execute, execute + setinputsizes, executemany), or is it intentional only for the native execute() path and incidental for the other two?
param_detect.hpp documents the intent clearly for execute(). It's the other two routes, which reach the same result only through the -8 alias, where the guarantee is unclear.
Environment
- mssql-python
main, static source review; not reproduced against a packaged wheel
- Files:
mssql_python/constants.py, connection.py, cursor.py, pybind/ddbc_bindings.cpp, pybind/param_detect.hpp
- Related config exercised by
tests/test_017_varchar_cp1252_boundary.py
Tracking
Summary
Connection.setencoding(encoding=..., ctype=SQL_CHAR)is validated, stored, and returned bygetencoding()— but it has no effect on parameter binding. Text parameters are always encoded UTF-16LE and bound as ODBCSQL_C_WCHAR, whatever encoding was requested.Nothing fails, so callers have no way to discover the setting was ignored.
Repro
63 61 66 E9, C typeSQL_C_CHAR.63 00 61 00 66 00 E9 00, C typeSQL_C_WCHAR. Thecp1252setting is never read.The row round-trips correctly, so the no-op is invisible from the result.
Cause
SQLExecute_wraphonours the requested encoding only when the C type is a real ODBCSQL_C_CHAR:charEncodingis then consumed only insidecase SQL_C_CHAR:inBindParameters/BindParameterArray. But no route setsparamCTypeto1:execute()PARAM_C_TYPE_TEXTinparam_detect.hpp(ODBC's realSQL_C_WCHAR)-8execute()+setinputsizes_SQL_TO_C_TYPE→ConstantsDDBC.SQL_C_CHAR.value-8executemany()ParamInfobuilt incursor.py, same map-8(
ConstantsDDBC.SQL_C_CHARis-8, which is ODBC'sSQL_C_WCHAR, notSQL_C_CHAR(1) — a long-standing alias, already noted inparam_detect.hpp.)So execution always reaches
case SQL_C_WCHAR:, which doesparam.cast<std::u16string>()and never readscharEncoding.case SQL_C_CHAR:is unreachable for text parameters.To be clear: binding text wide is a deliberate, well-reasoned choice, and
param_detect.hpp:146explains why (matching the legacy path, unixODBC requiring wide chars on Linux/macOS, and avoiding a Windows-only divergence). This issue is not asking to change that. It is asking thatsetencodingstop silently accepting a request it cannot honour.The existing tests can't catch this
Worth flagging separately, since it means CI would not detect a regression here either:
test_sql_c_char_encoding_failure— endsif not error_raised: passtest_encoding_error_propagation_in_bind_parameters— "If no error was raised, that's also acceptable behavior (data may be mangled)", thenassert count >= 0test_shift_jis_encoding_japanese— callsfetchone()and never asserts the resulttest_cpp_bind_params_str_encoding— ASCII-only payload, passes either waytest_sql_c_char_encoding_failuresetsasciiand inserts"Non-ASCII: 你好世界". If the narrow path actually ran,.encode("ascii", "strict")would raise deterministically and the test could assert unconditionally.Request
Make the no-op non-silent. In order of preference:
setencodingas not affecting parameter binding; orwarnings.warn(or raise) when a requested combination cannot be honoured.Accepting the value, storing it, echoing it from
getencoding(), and ignoring it is the worst of the available options.Two smaller notes
executeandexecutemanydisagree on the gate.SQLExecuteMany_wrapreadsencodingwith noctypecheck:Inert today because
paramCTypeis never1, but the two paths should agree.If
ConstantsDDBC.SQL_C_CHARis ever corrected to1, please do it together with the above. On its own it would routeexecutemanytext throughcase SQL_C_CHAR:withcharEncodingdefaulting toutf-16le— i.e. UTF-16 bytes bound as narrow characters, with no user configuration involved.Question
Is binding text parameters as
SQL_C_WCHARa stable contract across all three routes (execute,execute+setinputsizes,executemany), or is it intentional only for the nativeexecute()path and incidental for the other two?param_detect.hppdocuments the intent clearly forexecute(). It's the other two routes, which reach the same result only through the-8alias, where the guarantee is unclear.Environment
main, static source review; not reproduced against a packaged wheelmssql_python/constants.py,connection.py,cursor.py,pybind/ddbc_bindings.cpp,pybind/param_detect.hpptests/test_017_varchar_cp1252_boundary.pyTracking