Stringify numeric parameters bound to character columns (re: #163) - #164
Merged
staticlibs merged 7 commits intoApr 26, 2026
Merged
Conversation
Binding SQL_C_SLONG/SQL_C_SBIGINT/SQL_C_FLOAT/... to a SQL_VARCHAR column forces the ODBC driver to convert numeric-C to character-SQL internally. That conversion path is one of the least-exercised corners of many drivers: the Firebird ODBC driver ≤ 3.5.0 silently corrupted writes on it (duckdb#161 / FirebirdSQL/firebird-odbc-driver#292), and older MSSQL and MySQL ODBC releases have shipped similar defects. Params::SetExpectedTypes now transforms TINYINT…BIGINT and FLOAT/DOUBLE ScannerValues into TYPE_DECIMAL_AS_CHARS whenever the prepared parameter's expected type is a character family (CHAR / VARCHAR / LONGVARCHAR / WCHAR / WVARCHAR / WLONGVARCHAR). The scanner then binds SQL_C_CHAR → the actual expected character SQL type via the existing DecimalChars path. This entirely bypasses the driver's numeric-to-string coercion — no driver bug required. BindOdbcParam<DecimalChars> also now honors the expected character SQL type (it hard-coded SQL_VARCHAR before) so CHAR / WVARCHAR columns are not silently re-coerced.
Guards against duckdb#161: copying integer source rows into a VARCHAR PK column silently dropped 489 / 500 rows on the Firebird ODBC driver because the scanner bound SQL_C_SLONG to the VARCHAR column and hit a driver bug in the numeric-C → character-SQL path. Every ODBC call returned SUCCESS, so a round-trip test that reads the stored VARCHAR back and compares to the source integer as a decimal string is the only way to spot a regression of this class. batch_size=1 forces the single-row bind path (the shape that exposed the original bug — per-row rebind amplified corruption into row loss). column_quotes='' keeps the INSERT column names unquoted so drivers that fold unquoted identifiers to upper case (Oracle, DB2, Firebird) still match the `id` column created in the DDL. The row-count assertion uses DECIMAL(18, 0) + DecimalValue<int64_t> — the one int64 accessor that works uniformly across drivers, since Result::Value<int64_t> routes through per-driver type expectations (Oracle's count(*) returns DECIMAL, DuckDB's BIGINT, etc.). Skips: - FlightSQL / Spark / Snowflake / ClickHouse: no representative ad-hoc DDL / INSERT path for the scenario. - MySQL / MariaDB: CI connection strings do not select a default database, so CREATE TABLE fails at SQLPrepare with "No database selected" — the bug is not MySQL-specific and other drivers cover it.
staticlibs
reviewed
Apr 24, 2026
staticlibs
left a comment
Member
There was a problem hiding this comment.
Thanks for the PR! In general it looks good to me, added one nit and one possible change.
fdcastel
marked this pull request as draft
April 24, 2026 17:38
1. Move Types::IsCharacterSQLType declaration out of the header — the definition lives in types.cpp alongside the other Types members. Also adds Types::IsWideCharacterSQLType to support (2). 2. For wide-character expected types (SQL_WCHAR / SQL_WVARCHAR / SQL_WLONGVARCHAR), stringify to UTF-16 and dispatch through the SQL_C_WCHAR binding path instead of SQL_C_CHAR. Some ODBC drivers do not reliably auto-convert SQL_C_CHAR into a wide-character column on bind, so we hand the driver wide bytes directly. ScannerValue::TransformNumericToChars gains a `wide` flag: the narrow path keeps the existing TYPE_DECIMAL_AS_CHARS representation; the wide path widens via WideChar::Widen and re-tags as DUCKDB_TYPE_VARCHAR so BindOdbcParam<std::string> picks it up and binds SQL_C_WCHAR. 3. Drop the C++ regression test in favor of SQLLogic tests added to each existing *_copy.test (db2, duckdb, firebird, mssql, oracle). Each test creates a VARCHAR primary-key column, copies 500 integers into it with batch_size=1 (the per-row rebind shape that exposed the original bug), and reads sampled values back to confirm they match the source integers as decimal strings. Per-driver DDL differences are folded into the respective test files rather than into C++ DBMSConfigured branches.
fdcastel
marked this pull request as ready for review
April 24, 2026 20:42
staticlibs
reviewed
Apr 24, 2026
Member
|
Thanks for the update! Added a comment there - sorry for being picky, just currently 2 code paths used for wide/narrow expected types look a bit confusing. |
Contributor
Author
|
Not picky: thorough. Those are the most helpful kinds of reviews. Keep them coming. No worries at all! 🤗👍🏻 |
Drop the `bool wide` parameter from `ScannerValue::TransformNumericToChars` and always re-tag the value as TYPE_DECIMAL_AS_CHARS. Wide character targets (SQL_WCHAR / SQL_WVARCHAR / SQL_WLONGVARCHAR) are now handled inside `BindOdbcParam<DecimalChars>`, which widens the ASCII digits into a SQLWCHAR buffer stored on `DecimalChars` and binds `SQL_C_WCHAR` for the expected type. This keeps the integer/float binding dispatch on a single coalesced code path and validates the existing "if target is character, value has already been transformed to TYPE_DECIMAL_AS_CHARS" comments in integer_types.cpp and float_types.cpp for all character subtypes.
Applies the project's clang-format style to the new SQLBindParameter call added in the previous commit. No behavior change.
staticlibs
reviewed
Apr 25, 2026
staticlibs
reviewed
Apr 25, 2026
staticlibs
reviewed
Apr 25, 2026
staticlibs
reviewed
Apr 25, 2026
staticlibs
reviewed
Apr 25, 2026
staticlibs
left a comment
Member
There was a problem hiding this comment.
Thanks for the update! Apart from the 4 minor things commented above - it looks good to me!
Pulls the per-type stringification switch out of `TransformNumericToChars` into a private `ScannerValue::NumericToString()` returning `std::string`. Each case in the switch now returns directly, so a missed `break` / forgotten assignment can't silently leave the result empty.
…VARCHAR test - ScannerValue: add SetLengthBytes(SQLLEN) and use it in BindOdbcParam<DecimalChars> instead of assigning to the LengthBytes() reference (the getter-style assignment was confusing). - BindOdbcParam<DecimalChars>: pass dc.size<size_t>() directly to WideChar::Widen so the explicit static_cast<size_t> is no longer needed. - mssql_copy.test: extend the int-to-VARCHAR regression with an additional NVARCHAR(20) column so the wide (SQL_C_WCHAR) branch of BindOdbcParam<DecimalChars> is now covered by CI.
fdcastel
added a commit
to fdcastel/odbc-scanner
that referenced
this pull request
Apr 25, 2026
- BindSlotShape: drop the redundant `is_null` field — its value was always `type_id == DUCKDB_TYPE_SQLNULL`, so it added nothing to the equality check and one more thing to keep in sync. - IsFixedWidthShape: include `Params::TYPE_SQL_GUID` (raw `SQLGUID` in the union, address-stable across move-assignment) so GUID-keyed single-row inserts also hit the bind-once fast path. - Rename the helper to `BindToOdbcIfShapeUnchanged` and flip the return semantics to `bool reused` (true = cache hit, false = full rebind). This removes the awkward `bool did_bind = ...; reused_bindings = !did_bind;` inversion at the call site and matches the local `reused_bindings` flag. - Header comment: note that integer / float parameters whose target column is CHAR/VARCHAR/WCHAR are coalesced to TYPE_DECIMAL_AS_CHARS by SetExpectedTypes, so they intentionally stay on the rebind-per-row path. - duckdb_copy.test: bump the bind-once smoke test from 50 to 500 rows so the reuse path is exercised many times across multiple internal data chunks.
fdcastel
added a commit
to fdcastel/odbc-scanner
that referenced
this pull request
Apr 27, 2026
- BindSlotShape: drop the redundant `is_null` field — its value was always `type_id == DUCKDB_TYPE_SQLNULL`, so it added nothing to the equality check and one more thing to keep in sync. - IsFixedWidthShape: include `Params::TYPE_SQL_GUID` (raw `SQLGUID` in the union, address-stable across move-assignment) so GUID-keyed single-row inserts also hit the bind-once fast path. - Rename the helper to `BindToOdbcIfShapeUnchanged` and flip the return semantics to `bool reused` (true = cache hit, false = full rebind). This removes the awkward `bool did_bind = ...; reused_bindings = !did_bind;` inversion at the call site and matches the local `reused_bindings` flag. - Header comment: note that integer / float parameters whose target column is CHAR/VARCHAR/WCHAR are coalesced to TYPE_DECIMAL_AS_CHARS by SetExpectedTypes, so they intentionally stay on the rebind-per-row path. - duckdb_copy.test: bump the bind-once smoke test from 50 to 500 rows so the reuse path is exercised many times across multiple internal data chunks.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a first attempt at items 1 and 3 of the suggestions I filed
in #163. (Item 2 — bind-once-execute-many — is intentionally out of scope for
this PR; see notes at the end.)
What this changes
1. Stringify numeric → CHAR/VARCHAR in the scanner
Binding
SQL_C_SLONG/SQL_C_SBIGINT/SQL_C_FLOAT/ … to aSQL_VARCHARcolumn forces the ODBC driver to convert numeric-C → character-SQL internally.
That conversion path is empirically one of the least-exercised corners of many
drivers: the Firebird ODBC driver ≤ 3.5.0 silently corrupted writes on it
(#161 / FirebirdSQL/firebird-odbc-driver#292), and older
MSSQL and MySQL ODBC releases have shipped similar defects.
Params::SetExpectedTypesnow transformsTINYINT…BIGINTandFLOAT/DOUBLEScannerValues intoTYPE_DECIMAL_AS_CHARSwhenever the prepared parameter'sexpected type is in the character family (
SQL_CHAR/SQL_VARCHAR/SQL_LONGVARCHAR/SQL_WCHAR/SQL_WVARCHAR/SQL_WLONGVARCHAR). Thescanner then binds
SQL_C_CHAR→ the actual expected character SQL type viathe existing
DecimalCharspath. This entirely bypasses the driver'snumeric-to-string coercion — no driver fix required.
BindOdbcParam<DecimalChars>also now honors the expected character SQL type(it hard-coded
SQL_VARCHARbefore) soCHAR/WVARCHARcolumns are notsilently re-coerced.
3. Regression test in
test_copy.cppNew
TEST_CASE("Copy integer source into VARCHAR primary key", ...)copies500
INTEGERrows throughodbc_copywithbatch_size=1into aVARCHAR(20) NOT NULL PRIMARY KEYcolumn, then verifies both the row count(via
rows_processed) and the stored string values. Row-count alone would notcatch the original bug, which stored 500 corrupted (NUL-byte) values without
any driver error under one execute shape and collapsed to 11 distinct values
under another — so the test reads sampled values back and compares to the
source integers as decimal strings.
batch_size=1deliberately forces the single-row bind path (SetExpectedTypesBindToOdbcper row) which is the shape that exposed the original bug.column_quotes=''keeps the INSERT column names unquoted so drivers that foldunquoted identifiers to upper case (Oracle, DB2, Firebird) still match the
idcolumn created in the DDL.The test is skipped on:
DDL / INSERT path for the scenario.
database, so
CREATE TABLE <name>fails atSQLPreparewith "No databaseselected". The bug is not MySQL-specific and other drivers cover it.
CI
All driver jobs pass on my fork except for two pre-existing failures that
exist on
maintoo and are unrelated to this diff:MSSQL ODBC Libstep looks forlibmsodbcsql-18.6.so.1.1but the currently-packaged driver installslibmsodbcsql-18.6.so.2.1(thelddin the CI script then fails the jobat setup time, before any test runs).
test/sql/firebird/14_time_with_time_zone.test:30asserts
12:45:00for'15:45:00 Europe/London'but the CI is currentlyreturning
11:45:00(looks DST-related).Happy to file separate tickets / PRs for either of those if they are not
already on the radar.
What is not in this PR
attempt broke
test/sql/duckdb/duckdb_copy.test:46on the DuckDB ODBC driver(reusing bindings across executes without an intervening
SQLFreeStmt(SQL_CLOSE)raised an HY010 sequence error at rollback time). I reverted it from this PR
so the fix for item 1 is not blocked while I figure out a driver-aware
approach (probably an explicit
SQLFreeStmt(SQL_CLOSE)between reusedexecutes, or a narrower opt-in per
DbmsQuirks). Happy to open it as afollow-up PR.
KNOWN_ISSUES.md) and 5 (post-commit row count validation):intentionally left out of this PR. I can do them separately once the direction
here lands.
Thanks for looking — again, this is a starting point, please point at anything
you would like changed.