ODBC: column-wise parameter array reads the NULL indicator of row 0 for every row — a NULL below row 0 stores an empty string (VARCHAR) or segfaults the client in SQLExecute (BINARY)
- Dominant language
- Java
- Stars
- 5.1k
- Forks
- 1.9k
- Avg merge
- 3d 2h
- Merged PRs (30d)
- 46
Description
Component: ODBC driver (`modules/platforms/cpp/odbc`), Ignite 2.17.0. Filed here rather than in JIRA since this repository accepts issues; happy to mirror it to IGNITE-* if preferred.
### Summary
With `SQL_ATTR_PARAMSET_SIZE` > 1 and column-wise binding, the ODBC driver decides whether a parameter is NULL by looking at the indicator of *row 0* for every row of the array. A `SQL_NULL_DATA` indicator in any later row is therefore not sent as NULL:
- for a `VARCHAR` column the row is stored as a non-NULL *empty string*, with `SQL_SUCCESS`, `SQL_ATTR_PARAMS_PROCESSED_PTR` = 3 and every parameter status `SQL_PARAM_SUCCESS`;
- for a `BINARY`/`VARBINARY` column the client process segfaults inside `SQLExecute` (`memcpy` with length -1);
- a `SQL_NULL_DATA` in row 0 makes *every* row of the array NULL, whatever the other indicators say.
The server is untouched in all three cases. Row-wise binding cannot be used as a workaround: `SQL_ATTR_PARAM_BIND_TYPE` ≠ 0 is refused with `HYC00 Only binding by column is currently supported`.
### Environment
- Apache Ignite 2.17.0 (`apacheignite/ignite:latest`), ODBC driver built from the image's `platforms/cpp` sources (CMake project version 2.17.0.25077, `-DWITH_ODBC=ON -DWITH_CORE=OFF`); the driver reports `SQL_DRIVER_VER` = `SQL_DBMS_VER` = 02.04.0000
- Linux x86_64, unixODBC 2.3.12, gcc 13
### Reproduction (plain ODBC, no other library)
```c
/* Column-wise parameter array with SQL_NULL_DATA below row 0, Apache Ignite 2.17 ODBC.
* Build: gcc -O1 -Wall null_below_row0.c -lodbc -o null_below_row0
* Run: ODBC_CONN='Driver=/path/to/libignite-odbc.so;ADDRESS=127.0.0.1:10800;SCHEMA=PUBLIC;' \
* ./null_below_row0 varchar # row 2 stored as '' instead of NULL, rc=0
* ./null_below_row0 binary # segfault inside SQLExecute
* ./null_below_row0 row0 # NULL in row 0 -> all three rows NULL */
#include
#include
#include
#include
#include
static SQLHENV env; static SQLHDBC dbc;
static void diag(SQLSMALLINT t, SQLHANDLE h) {
SQLCHAR st[6], msg[1024]; SQLINTEGER nat; SQLSMALLINT len, i = 1;
while (SQLGetDiagRec(t, h, i++, st, &nat, msg, sizeof msg, &len) == SQL_SUCCESS)
printf(" %s (%d): %s\n", st, (int)nat, msg);
}
static SQLRETURN exec(const char* sql) {
SQLHSTMT h; SQLAllocHandle(SQL_HANDLE_STMT, dbc, &h);
SQLRETURN rc = SQLExecDirect(h, (SQLCHAR*)sql, SQL_NTS);
if (!SQL_SUCCEEDED(rc)) { printf(" %s -> rc=%d\n", sql, (int)rc); diag(SQL_HANDLE_STMT, h); }
SQLFreeHandle(SQL_HANDLE_STMT, h); return rc;
}
int main(int argc, char** argv) {
const char* mode = argc > 1 ? argv[1] : "varchar";
int binary = !strcmp(mode, "binary"), row0 = !strcmp(mode, "row0");
setvbuf(stdout, NULL, _IONBF, 0);
SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env);
SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0);
SQLAllocHandle(SQL_HANDLE_DBC, env, &dbc);
if (!SQL_SUCCEEDED(SQLDriverConnect(dbc, NULL, (SQLCHAR*)getenv("ODBC_CONN"), SQL_NTS, NULL, 0, NULL,
SQL_DRIVER_NOPROMPT))) { diag(SQL_HANDLE_DBC, dbc); return 1; }
SQLCHAR v[64]; SQLSMALLINT n;
SQLGetInfo(dbc, SQL_DRIVER_VER, v, sizeof v, &n); printf("SQL_DRIVER_VER=%s\n", v);
exec("DROP TABLE IF EXISTS probe_null");
exec(binary ? "CREATE TABLE probe_null (a BIGINT PRIMARY KEY, b BINARY)"
: "CREATE TABLE probe_null (a BIGINT PRIMARY KEY, b VARCHAR)");
SQLHSTMT h; SQLAllocHandle(SQL_HANDLE_STMT, dbc, &h);
SQLBIGINT a[3] = {1, 2, 3}; SQLLEN ia[3] = {0, 0, 0};
char b[3][8] = {"r1", "r2", "r3"}; SQLLEN ib[3];
if (binary) { memset(b, 0xEE, sizeof b); ib[0] = 4; ib[1] = SQL_NULL_DATA; ib[2] = 4; }
else { ib[0] = SQL_NTS; ib[1] = SQL_NULL_DATA; ib[2] = SQL_NTS; }
if (row0) { ib[0] = SQL_NULL_DATA; ib[1] = SQL_NTS; }
SQLULEN processed = 0; SQLUSMALLINT status[3] = {99, 99, 99};
SQLSetStmtAttr(h, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)(SQLULEN)3, 0);
SQLSetStmtAttr(h, SQL_ATTR_PARAMS_PROCESSED_PTR, &processed, 0);
SQLSetStmtAttr(h, SQL_ATTR_PARAM_STATUS_PTR, status, 0);
SQLPrepare(h, (SQLCHAR*)"INSERT INTO probe_null (a, b) VALUES (?, ?)", SQL_NTS);
SQLBindParameter(h, 1, SQL_PARAM_INPUT, SQL_C_SBIGINT, SQL_BIGINT, 0, 0, a, sizeof a[0], ia);
if (binary) SQLBindParameter(h, 2, SQL_PARAM_INPUT, SQL_C_BINARY, SQL_BINARY, 8, 0, b, 8, ib);
else SQLBindParameter(h, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 8, 0, b, 8, ib);
printf("mode=%s indicators = {%ld, %ld, %ld} (SQL_NULL_DATA = %d)\n", mode,
(long)ib[0], (long)ib[1], (long)ib[2], SQL_NULL_DATA);
SQLRETURN rc = SQLExecute(h);
printf("SQLExecute -> rc=%d processed=%lu status=[%d %d %d]\n", (int)rc, (unsigned long)processed,
status[0], status[1], status[2]);
if (!SQL_SUCCEEDED(rc)) diag(SQL_HANDLE_STMT, h);
SQLFreeHandle(SQL_HANDLE_STMT, h);
SQLAllocHandle(SQL_HANDLE_STMT, dbc, &h);
SQLExecDirect(h, (SQLCHAR*)"SELECT a, b IS NULL, LENGTH(b) FROM probe_null ORDER BY a", SQL_NTS);
while (SQLFetch(h) == SQL_SUCCESS) {
SQLBIGINT k = 0; SQLINTEGER isnull = -1, len = -1; SQLLEN l1, l2, l3;
SQLGetData(h, 1, SQL_C_SBIGINT, &k, 0, &l1);
SQLGetData(h, 2, SQL_C_SLONG, &isnull, 0, &l2);
SQLGetData(h, 3, SQL_C_SLONG, &len, 0, &l3);
printf(" row a=%lld b IS NULL=%d LENGTH(b)=%d%s\n", (long long)k, (int)isnull,
l3 == SQL_NULL_DATA ? -1 : (int)len, l3 == SQL_NULL_DATA ? " (NULL)" : "");
}
SQLFreeHandle(SQL_HANDLE_STMT, h);
exec("DROP TABLE probe_null");
SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env);
return 0;
}
```
```
$ ./null_below_row0 varchar
SQL_DRIVER_VER=02.04.0000
mode=varchar indicators = {-3, -1, -3} (SQL_NULL_DATA = -1)
SQLExecute -> rc=0 processed=3 status=[0 0 0]
row a=1 b IS NULL=0 LENGTH(b)=2
row a=2 b IS NULL=0 LENGTH(b)=0
row a=3 b IS NULL=0 LENGTH(b)=2
$ ./null_below_row0 row0
SQL_DRIVER_VER=02.04.0000
mode=row0 indicators = {-1, -3, -3} (SQL_NULL_DATA = -1)
SQLExecute -> rc=0 processed=3 status=[0 0 0]
row a=1 b IS NULL=1 LENGTH(b)=-1 (NULL)
row a=2 b IS NULL=1 LENGTH(b)=-1 (NULL)
row a=3 b IS NULL=1 LENGTH(b)=-1 (NULL)
$ ./null_below_row0 binary
SQL_DRIVER_VER=02.04.0000
mode=binary indicators = {4, -1, 4} (SQL_NULL_DATA = -1)
Segmentation fault (SIGSEGV)
```
gdb backtrace for the binary case:
```
Program received signal SIGSEGV, Segmentation fault.
#0 __memcpy_avx_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:265
#1 0x00007ffff7edf709 in ignite::odbc::query::BatchQuery::MakeRequestExecuteBatch(unsigned long, unsigned long, bool) () from /libignite-odbc.so
#2 0x00007ffff7edfe96 in ignite::odbc::query::BatchQuery::Execute() () from /libignite-odbc.so
#3 0x00007ffff7efd201 in ignite::odbc::Statement::ExecuteSqlQuery() () from /libignite-odbc.so
#4 0x00007ffff7ed0e8c in ignite::SQLExecute(void*) () from /libignite-odbc.so
#5 0x00007ffff7f407dd in SQLExecute () from /lib/x86_64-linux-gnu/libodbc.so.2
#6 0x0000555555555826 in main (argc=, argv=) at null_below_row0.c:54
```
### Cause (`modules/platforms/cpp/odbc/src/app/parameter.cpp`, 2.17)
`Parameter::Write` tests `buffer.GetInputSize()` on the *un-offset* buffer before it copies the buffer and applies the row's element offset:
```cpp
void Parameter::Write(impl::binary::BinaryWriterImpl& writer, int offset, SqlUlen idx) const
{
if (buffer.GetInputSize() == SQL_NULL_DATA) // reads *GetResLen() with elementOffset == 0, i.e. row 0
{
writer.WriteNull();
return;
}
ApplicationDataBuffer buf(buffer); // the copy...
buf.SetByteOffset(offset);
buf.SetElementOffset(idx); // ...is where the row offset is applied
```
(`ApplicationDataBuffer::GetInputSize` returns `*GetResLen()`, and `GetResLen` applies `elementOffset`, which is still 0 on `buffer`.) So every row inherits row 0's NULL-ness. What happens next depends on the SQL-type branch that runs on the offset copy:
- `SQL_CHAR`/`SQL_VARCHAR`: `buf.GetString(columnSize)` reads the row's own indicator (-1) and `utility::SqlStringToString` returns `""` for any negative length other than `SQL_NTS` — the row is written as an empty string.
- `SQL_BINARY`/`SQL_VARBINARY`/`SQL_LONGVARBINARY`: the branch takes `*constRef.GetResLen()` — the row's own indicator, `SQL_NULL_DATA` = -1 — and passes it straight to `writer.WriteInt8Array(data, paramLen)` as the array length:
```cpp
case SQL_BINARY:
case SQL_VARBINARY:
case SQL_LONGVARBINARY:
{
const ApplicationDataBuffer& constRef = buf;
const SqlLen* resLenPtr = constRef.GetResLen();
if (!resLenPtr)
break;
int32_t paramLen = static_cast(*resLenPtr);
writer.WriteInt8Array(reinterpret_cast(constRef.GetData()), paramLen);
break;
}
```
A length of -1 reaches `memcpy` as `size_t` and the process dies.
### Suggested fix
Apply the element offset before the NULL test — build `buf` first and test `buf.GetInputSize() == SQL_NULL_DATA` — and have the binary branch treat a negative `*resLenPtr` as NULL (or `SQL_NTS`/`SQL_DATA_AT_EXEC` per the spec) rather than as a length. With that the three cases above become NULL, NULL and NULL.
### Two smaller things seen on the same path
- `SQLRowCount` after a successful three-row array execute answers 1, not 3, although `SQL_PARAM_ARRAY_ROW_COUNTS` reports `SQL_PARC_BATCH`.
- `SQLGetData` on a zero-length non-NULL character value returns `SQL_NO_DATA` and writes `SQL_NULL_DATA` into the indicator, so an empty string is indistinguishable from NULL on that path (which is why the program above reads `b IS NULL` and `LENGTH(b)` server-side).
Contributor guide
Research direction
Start in modules/platforms/cpp/odbc/src/app/parameter.cpp, especially Parameter::Write and the SQL_BINARY handling described in the issue. Run the supplied null_below_row0 reproduction with VARCHAR and BINARY parameter arrays, then verify that per-row SQL_NULL_DATA indicators produce NULL values without a client crash and that non-NULL rows retain their values.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- api, database
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100