Skip to content

Honour SQL_NULL_DATA when a parameter value buffer is bound - #586

Merged
slabko merged 1 commit into
ClickHouse:masterfrom
singhpratech:null-data-with-bound-value-buffer
Sep 24, 2026
Merged

slabko merged 1 commit into
ClickHouse:masterfrom
singhpratech:null-data-with-bound-value-buffer

Conversation

@singhpratech

Copy link
Copy Markdown
Contributor

This is the first of the two parameter-binding defects I reported in #582; the parameter-array
one is separate and I would be glad to follow up with it. I have left out the closing keyword
so the issue stays open for that second half.

What a caller sees. ODBC lets an application keep a value buffer bound and say "this row's
value is NULL" through the length/indicator buffer, which is what reusing one buffer per column
across rows amounts to. The driver only treats a parameter as NULL when the value pointer itself
is null, so with a buffer bound it sends an empty string. Into a Nullable(Int32) column that is
a server-side error:

Code: 32. DB::Exception: Attempt to read after eof: value  cannot be parsed as Nullable(Int32) for query parameter 'odbc_positional_1'. (ATTEMPT_TO_READ_AFTER_EOF)

Float64, Date, Decimal and Bool fail the same way. String is worse, because it
succeeds: the row is stored as '' rather than NULL, and DateTime64 becomes the epoch. An
application cannot work around it by describing the parameter first either, since
SQL_DESCRIBE_PARAMETER is N.

The cause. Two places in Statement decide nullability from the value pointer alone and
never look at the indicator:

  • driver/statement.cpp:87 — if (binding_info.value == nullptr) value = "\\N";, so with a
    buffer bound the value goes through readReadyDataTo(), whose to_null() for a std::string
    destination yields an empty string.
  • driver/statement.cpp:336 — type_info.is_nullable = (binding_info.is_nullable || binding_info.value == nullptr);,
    so the query parameter's data source type stays non-nullable; for the types that default to
    SQL_NO_NULLS (the string-mapped ones) even a correct \N would be rejected.

The conversion helpers in driver/utils/type_info.h already read SQL_NULL_DATA from the
indicator (from_buffer<...>::to_value<...>::convert); only these two decisions did not.

The change. Add a small isNullParamValue() helper in driver/statement.cpp that treats a
parameter as NULL when the value pointer is null or the indicator is SQL_NULL_DATA, and use
it in both places. A NULL value pointer keeps working exactly as before.

Test. driver/test/statement_parameter_binding_ut.cpp gains the bound-buffer cases for an
Int32 and a String parameter, checking the query parameter is \N and the placeholder type
is nullable; the existing NullValueBinding asserted the empty string, so its assertion is
corrected, and a NullValueBindingWithoutBuffer case pins the NULL-pointer path that already
worked. driver/test/statement_parameter_bindings_it.cpp gains
NullValueForIntegerWithBoundBuffer and NullValueForStringWithBoundBuffer, which run
SELECT isNull(?) with the value buffer bound and the indicator set to SQL_NULL_DATA. All
four fail on master and pass with the change. Locally clickhouse-odbc-ut goes from 545/545 to
547/547 and clickhouse-odbc-client-it from 387/388 to 389/390, the one failure in both runs
being AuthenticationTest.PasswordEncoding, which needs a ClickHouse admin account my test
server does not have.

Found while verifying ClickHouse for adbcBridge,
an ADBC driver over ODBC, where a row of bound parameters with a NULL in it is the ordinary case.

A parameter was sent as NULL only when its value pointer was null. An
application that keeps a value buffer bound and signals NULL through the
length/indicator buffer -- the shape that reusing one buffer per column
for every row produces -- got an empty string instead: '' stored in a
String column, and a server-side parse error for Int32, Float64, Date,
Decimal and Bool.

Read the indicator alongside the value pointer, so that SQL_NULL_DATA
sends \N as the query parameter and makes its data source type Nullable.
The conversion helpers in type_info.h already honour the indicator; the
two places in Statement that decide whether a parameter is NULL did not.

Extend statement_parameter_binding_ut with the bound-buffer cases for an
Int32 and a String parameter, and statement_parameter_bindings_it with
NullValueForIntegerWithBoundBuffer and NullValueForStringWithBoundBuffer,
which run SELECT isNull(?) with a value buffer bound.
@CLAassistant

CLAassistant commented Sep 18, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@slabko
slabko merged commit eb0fdb2 into ClickHouse:master Sep 24, 2026
10 of 19 checks passed
@singhpratech

singhpratech commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you for the review and the merge — much appreciated.

For anyone finding this later: with a value buffer bound and the length/indicator set to SQL_NULL_DATA, the driver sent the buffer's contents instead of NULL. That is what an application does when it reuses one buffer per column across rows, so it is an ordinary pattern rather than an exotic one. The consequences split by type: a String column silently stored '', while numeric, date and decimal parameters failed server-side.

I will re-run the reproducer from #582 against the first release that carries this and confirm here.

On the rest of #582: the second defect — parameter arrays stopping after the first set, because SQLMoreResults executes the next set and answers SQL_NO_DATA — is a different mechanism and needs its own change. Happy to prepare that as a separate pull request if you would like it.

Context on where the report came from: adbcBridge (https://github.com/singhpratech/adbcbridge) is a driver for ADBC, Apache Arrow's database connectivity API, that works over any ODBC driver, and binding a column of values with NULLs in it is the everyday path through it — which is how both of these turned up.

slabko pushed a commit that referenced this pull request Sep 29, 2026
A parameter array stopped after the first set on a statement that returns
nothing. SQLExecute sent set 0 and left the rest to SQLMoreResults, which the
driver uses to send one set per call; but an INSERT produces no result sets, so
a conforming caller has no reason to call SQLMoreResults at all and the
remaining sets were never sent. A five-set array inserted one row, under
SQL_SUCCESS and with no diagnostic.

ODBC has the driver execute the statement once per parameter set during
SQLExecute; SQLMoreResults then walks the result sets those executions
produced. executeQuery now keeps sending sets while the last one produced no
result set, and stops as soon as one does, so the one-result-set-per-
SQLMoreResults behaviour a SELECT with a parameter array relies on is unchanged.

SQL_ATTR_PARAMS_PROCESSED_PTR was also assigned next_param_set_idx before the
set was sent, while that index still named the set about to go, so it reported
one less than the number processed: 0 after a five-set INSERT and 2 after a
three-set SELECT. It is now written after the send, where the index equals the
count completed. The existing TODO about output parameters is kept.

Verified against ClickHouse 26.7.5.10 with two standalone ODBC programs:
- INSERT, 5 sets: 1 row and params_processed=0 before, 5 and 5 after.
- SELECT ? , 3 sets: 3 result sets in order both before and after, with
  params_processed going from 2 to 3.
The four tests from #586 also still pass on this build.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants