From 5f009309408daf2f8def8dbd80eae75364fb9a0c Mon Sep 17 00:00:00 2001 From: Antoine Leclair Date: Sun, 25 Jan 2026 21:57:28 -0500 Subject: [PATCH] fix: preserve SQLITE_NULL type for NULL values When encoding query results, value_type() remaps certain column types (BOOLEAN, DATETIME) to dqlite-specific type codes. However, it was also doing this for NULL values, causing them to be encoded as their "zero" values (0 for BOOLEAN, "" for DATETIME) instead of NULL. This makes it impossible for clients to distinguish: - BOOLEAN NULL vs FALSE - DATETIME NULL vs empty string SQLite's sqlite3_column_type() correctly returns SQLITE_NULL for NULL values regardless of declared column type. This change preserves that behavior by returning early when the value is NULL. Example demonstrating SQLite's behavior: CREATE TABLE t (b BOOLEAN, d DATETIME); INSERT INTO t VALUES (NULL, NULL); INSERT INTO t VALUES (0, ''); -- sqlite3_column_type() returns: -- Row 1: NULL (5), NULL (5) <- both are SQLITE_NULL -- Row 2: INTEGER (1), TEXT (3) <- actual types Before this fix, dqlite would return: -- Row 1: BOOLEAN (11) with 0, ISO8601 (10) with "" -- Row 2: BOOLEAN (11) with 0, ISO8601 (10) with "" After this fix: -- Row 1: NULL (5), NULL (5) -- Row 2: BOOLEAN (11) with 0, ISO8601 (10) with "" --- src/query.c | 10 ++++++--- test/integration/test_client.c | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/query.c b/src/query.c index afa104b34..44a0a7b86 100644 --- a/src/query.c +++ b/src/query.c @@ -8,6 +8,11 @@ static int value_type(sqlite3_stmt *stmt, int i) { int type = sqlite3_column_type(stmt, i); + + if (type == SQLITE_NULL) { + return SQLITE_NULL; + } + const char *column_type_name = sqlite3_column_decltype(stmt, i); if (column_type_name != NULL) { if ((strcasecmp(column_type_name, "DATETIME") == 0) || @@ -16,12 +21,11 @@ static int value_type(sqlite3_stmt *stmt, int i) if (type == SQLITE_INTEGER) { type = DQLITE_UNIXTIME; } else { - dqlite_assert(type == SQLITE_TEXT || - type == SQLITE_NULL); + dqlite_assert(type == SQLITE_TEXT); type = DQLITE_ISO8601; } } else if (strcasecmp(column_type_name, "BOOLEAN") == 0) { - dqlite_assert(type == SQLITE_INTEGER || type == SQLITE_NULL); + dqlite_assert(type == SQLITE_INTEGER); type = DQLITE_BOOLEAN; } } diff --git a/test/integration/test_client.c b/test/integration/test_client.c index 9826ef4a4..aa288dcb7 100644 --- a/test/integration/test_client.c +++ b/test/integration/test_client.c @@ -180,3 +180,42 @@ TEST(client, semicolons, setUp, tearDown, 0, NULL) return MUNIT_OK; } + +/* A NULL value must be encoded with the SQLITE_NULL type tag regardless of + * the column's declared type. */ +TEST(client, nullPreservedAcrossDeclaredTypes, setUp, tearDown, 0, NULL) +{ + struct fixture *f = data; + uint64_t last_insert_id; + uint64_t rows_affected; + struct row *row; + (void)params; + + EXEC_SQL("CREATE TABLE t (" + " int_col INTEGER," + " text_col TEXT," + " real_col REAL," + " blob_col BLOB," + " bool_col BOOLEAN," + " datetime_col DATETIME," + " date_col DATE," + " timestamp_col TIMESTAMP)", + &last_insert_id, &rows_affected); + EXEC_SQL("INSERT INTO t VALUES " + "(NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", + &last_insert_id, &rows_affected); + + QUERY_SQL("SELECT int_col, text_col, real_col, blob_col, " + " bool_col, datetime_col, date_col, timestamp_col " + "FROM t", + &f->rows); + munit_assert_uint(f->rows.column_count, ==, 8); + row = f->rows.next; + munit_assert_not_null(row); + for (unsigned i = 0; i < f->rows.column_count; i++) { + munit_assert_int(row->values[i].type, ==, SQLITE_NULL); + } + munit_assert_ptr_null(row->next); + + return MUNIT_OK; +}