Skip to content

Commit 187f4bb

Browse files
authored
fix: Correct REST JSON serialization of UUID/HUGEINT/BLOB/BIT (#89) (#92)
Type-coverage audit follow-up. Several scalar types were mis-routed in the REST serializer: - UUID -> read via the VARCHAR path (as a string pointer) but is physically a 128-bit int, causing a SEGFAULT on any UUID column. Now formatted as the canonical 8-4-4-4-12 hex string. - HUGEINT/UHUGEINT -> read as 64-bit ints, truncating values and mis-striding multi-row chunks (e.g. MAX::HUGEINT -> -1). Now emitted as exact decimal strings (lossless; JSON numbers lose precision above 2^53). - BLOB -> emitted raw bytes (invalid UTF-8 / invalid JSON). Now uses DuckDB's blob string form (printable as-is, others as \xNN). - BIT -> emitted raw bit storage as garbage. Now emitted as its 0/1 string. All honor row validity (NULL -> null). Adds [scalar_types] regression tests. VARINT/BIGNUM, GEOMETRY and VARIANT remain serialized as null (internal/extension encodings not safely convertible at the vector level) — documented as a known limitation. Found via type audit + codex review.
1 parent ac97de0 commit 187f4bb

3 files changed

Lines changed: 231 additions & 5 deletions

File tree

src/include/query_executor.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ class QueryResult {
5555
static crow::json::wvalue convertVectorTimeToJson(const duckdb_vector &vector, const idx_t row_idx);
5656
static crow::json::wvalue convertVectorIntervalToJson(const duckdb_vector &vector, const idx_t row_idx);
5757
static crow::json::wvalue convertVectorEnumToJson(const duckdb_vector &vector, const idx_t row_idx);
58+
static crow::json::wvalue convertVectorUuidToJson(const duckdb_vector &vector, const idx_t row_idx);
59+
static crow::json::wvalue convertVectorHugeintToJson(const duckdb_vector &vector, const idx_t row_idx);
60+
static crow::json::wvalue convertVectorUhugeintToJson(const duckdb_vector &vector, const idx_t row_idx);
61+
static crow::json::wvalue convertVectorBlobToJson(const duckdb_vector &vector, const idx_t row_idx);
62+
static crow::json::wvalue convertVectorBitToJson(const duckdb_vector &vector, const idx_t row_idx);
5863
static crow::json::wvalue convertVectorListToJson(const duckdb_vector &vector, const idx_t row_idx);
5964
static crow::json::wvalue convertVectorArrayToJson(const duckdb_vector &vector, const idx_t row_idx);
6065
static crow::json::wvalue convertVectorStructToJson(const duckdb_vector &vector, const idx_t row_idx);

src/query_executor.cpp

Lines changed: 142 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ crow::json::wvalue QueryResult::convertVectorEntryToJson(const duckdb_vector &ve
234234
case DUCKDB_TYPE_BIGINT:
235235
return convertVectorEntryToJson<std::int64_t>(vector, row_idx);
236236
case DUCKDB_TYPE_HUGEINT:
237-
return convertVectorEntryToJson<std::int64_t>(vector, row_idx);
237+
return convertVectorHugeintToJson(vector, row_idx);
238238
case DUCKDB_TYPE_FLOAT:
239239
return convertVectorEntryToJson<float>(vector, row_idx);
240240
case DUCKDB_TYPE_DOUBLE:
@@ -278,13 +278,13 @@ crow::json::wvalue QueryResult::convertVectorEntryToJson(const duckdb_vector &ve
278278
case DUCKDB_TYPE_UBIGINT:
279279
return convertVectorEntryToJson<std::uint64_t>(vector, row_idx);
280280
case DUCKDB_TYPE_UHUGEINT:
281-
return convertVectorEntryToJson<std::uint64_t>(vector, row_idx); // Treat as uint64
281+
return convertVectorUhugeintToJson(vector, row_idx);
282282
case DUCKDB_TYPE_BLOB:
283-
return convertVectorVarcharToJson(vector, row_idx); // Treat as string for JSON
283+
return convertVectorBlobToJson(vector, row_idx);
284284
case DUCKDB_TYPE_UUID:
285-
return convertVectorVarcharToJson(vector, row_idx); // Treat as string for JSON
285+
return convertVectorUuidToJson(vector, row_idx);
286286
case DUCKDB_TYPE_BIT:
287-
return convertVectorVarcharToJson(vector, row_idx); // Treat as string for JSON
287+
return convertVectorBitToJson(vector, row_idx);
288288
case DUCKDB_TYPE_MAP:
289289
return convertVectorMapToJson(vector, row_idx);
290290
case DUCKDB_TYPE_ARRAY:
@@ -422,6 +422,143 @@ crow::json::wvalue QueryResult::convertVectorEnumToJson(const duckdb_vector &vec
422422
return crow::json::wvalue(str_val);
423423
}
424424

425+
crow::json::wvalue QueryResult::convertVectorUuidToJson(const duckdb_vector &vector, const idx_t row_idx) {
426+
auto validity = duckdb_vector_get_validity(vector);
427+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
428+
return crow::json::wvalue(nullptr);
429+
}
430+
431+
// UUID is physically a 128-bit integer (hugeint), NOT a string — reading
432+
// it via the VARCHAR path dereferences garbage and crashes (issue #89).
433+
// Format it the way DuckDB's UUID::ToString does: flip the stored MSB
434+
// back, then emit the canonical 8-4-4-4-12 lowercase hex form.
435+
auto data = static_cast<duckdb_hugeint *>(duckdb_vector_get_data(vector));
436+
auto value = data[row_idx];
437+
uint64_t upper = static_cast<uint64_t>(value.upper) ^ (uint64_t(1) << 63);
438+
uint64_t lower = value.lower;
439+
440+
static const char *HEX = "0123456789abcdef";
441+
char buf[36];
442+
idx_t pos = 0;
443+
auto byte_to_hex = [&](uint64_t byte_val) {
444+
buf[pos++] = HEX[(byte_val >> 4) & 0xf];
445+
buf[pos++] = HEX[byte_val & 0xf];
446+
};
447+
byte_to_hex((upper >> 56) & 0xFF);
448+
byte_to_hex((upper >> 48) & 0xFF);
449+
byte_to_hex((upper >> 40) & 0xFF);
450+
byte_to_hex((upper >> 32) & 0xFF);
451+
buf[pos++] = '-';
452+
byte_to_hex((upper >> 24) & 0xFF);
453+
byte_to_hex((upper >> 16) & 0xFF);
454+
buf[pos++] = '-';
455+
byte_to_hex((upper >> 8) & 0xFF);
456+
byte_to_hex(upper & 0xFF);
457+
buf[pos++] = '-';
458+
byte_to_hex((lower >> 56) & 0xFF);
459+
byte_to_hex((lower >> 48) & 0xFF);
460+
buf[pos++] = '-';
461+
byte_to_hex((lower >> 40) & 0xFF);
462+
byte_to_hex((lower >> 32) & 0xFF);
463+
byte_to_hex((lower >> 24) & 0xFF);
464+
byte_to_hex((lower >> 16) & 0xFF);
465+
byte_to_hex((lower >> 8) & 0xFF);
466+
byte_to_hex(lower & 0xFF);
467+
468+
return crow::json::wvalue(std::string(buf, pos));
469+
}
470+
471+
crow::json::wvalue QueryResult::convertVectorHugeintToJson(const duckdb_vector &vector, const idx_t row_idx) {
472+
auto validity = duckdb_vector_get_validity(vector);
473+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
474+
return crow::json::wvalue(nullptr);
475+
}
476+
477+
// HUGEINT is 128-bit: reading it as int64 truncates and mis-strides for
478+
// multi-row chunks (issue #89). Emit the exact decimal as a JSON string so
479+
// values beyond 2^63 survive (JSON consumers lose precision above 2^53).
480+
auto data = static_cast<duckdb_hugeint *>(duckdb_vector_get_data(vector));
481+
auto value = duckdb_create_hugeint(data[row_idx]);
482+
DuckDBString str(duckdb_value_to_string(value));
483+
duckdb_destroy_value(&value);
484+
return crow::json::wvalue(str.to_string());
485+
}
486+
487+
crow::json::wvalue QueryResult::convertVectorUhugeintToJson(const duckdb_vector &vector, const idx_t row_idx) {
488+
auto validity = duckdb_vector_get_validity(vector);
489+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
490+
return crow::json::wvalue(nullptr);
491+
}
492+
493+
// UHUGEINT is unsigned 128-bit; emit as a decimal string for the same
494+
// precision reasons as HUGEINT.
495+
auto data = static_cast<duckdb_uhugeint *>(duckdb_vector_get_data(vector));
496+
auto value = duckdb_create_uhugeint(data[row_idx]);
497+
DuckDBString str(duckdb_value_to_string(value));
498+
duckdb_destroy_value(&value);
499+
return crow::json::wvalue(str.to_string());
500+
}
501+
502+
crow::json::wvalue QueryResult::convertVectorBlobToJson(const duckdb_vector &vector, const idx_t row_idx) {
503+
auto validity = duckdb_vector_get_validity(vector);
504+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
505+
return crow::json::wvalue(nullptr);
506+
}
507+
508+
// A BLOB's bytes are arbitrary binary; emitting them raw via the VARCHAR
509+
// path can produce invalid UTF-8 / invalid JSON. Render DuckDB's own blob
510+
// string form (printable bytes as-is, others as \xNN), matching to_json /
511+
// CAST AS VARCHAR. (duckdb_value_to_string would add a '...'::BLOB wrapper.)
512+
auto data = static_cast<duckdb_string_t *>(duckdb_vector_get_data(vector));
513+
auto length = duckdb_string_is_inlined(data[row_idx])
514+
? data[row_idx].value.inlined.length
515+
: data[row_idx].value.pointer.length;
516+
auto bytes = duckdb_string_is_inlined(data[row_idx])
517+
? reinterpret_cast<const uint8_t *>(data[row_idx].value.inlined.inlined)
518+
: reinterpret_cast<const uint8_t *>(data[row_idx].value.pointer.ptr);
519+
520+
static const char *HEX = "0123456789ABCDEF";
521+
std::string out;
522+
out.reserve(length);
523+
for (idx_t i = 0; i < length; i++) {
524+
uint8_t c = bytes[i];
525+
bool regular = c >= 32 && c <= 126 && c != '\\' && c != '\'' && c != '"';
526+
if (regular) {
527+
out.push_back(static_cast<char>(c));
528+
} else {
529+
out.push_back('\\');
530+
out.push_back('x');
531+
out.push_back(HEX[(c >> 4) & 0xF]);
532+
out.push_back(HEX[c & 0xF]);
533+
}
534+
}
535+
return crow::json::wvalue(out);
536+
}
537+
538+
crow::json::wvalue QueryResult::convertVectorBitToJson(const duckdb_vector &vector, const idx_t row_idx) {
539+
auto validity = duckdb_vector_get_validity(vector);
540+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
541+
return crow::json::wvalue(nullptr);
542+
}
543+
544+
// BIT is stored as its internal bitstring blob; the VARCHAR path renders
545+
// that raw storage (padding byte + bytes) as garbage. Round-trip through a
546+
// BIT value so it serializes as a "0101" string, matching to_json.
547+
auto data = static_cast<duckdb_string_t *>(duckdb_vector_get_data(vector));
548+
auto length = duckdb_string_is_inlined(data[row_idx])
549+
? data[row_idx].value.inlined.length
550+
: data[row_idx].value.pointer.length;
551+
auto bytes = duckdb_string_is_inlined(data[row_idx])
552+
? reinterpret_cast<const uint8_t *>(data[row_idx].value.inlined.inlined)
553+
: reinterpret_cast<const uint8_t *>(data[row_idx].value.pointer.ptr);
554+
555+
duckdb_bit bit { const_cast<uint8_t *>(bytes), length };
556+
auto value = duckdb_create_bit(bit);
557+
DuckDBString str(duckdb_value_to_string(value));
558+
duckdb_destroy_value(&value);
559+
return crow::json::wvalue(str.to_string());
560+
}
561+
425562
crow::json::wvalue QueryResult::convertVectorListToJson(const duckdb_vector &vector, const idx_t row_idx) {
426563
auto validity = duckdb_vector_get_validity(vector);
427564
if (!duckdb_validity_row_is_valid(validity, row_idx)) {

test/cpp/query_executor_test.cpp

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,90 @@ TEST_CASE("QueryExecutor type coverage", "[query_executor]") {
199199
duckdb_close(&database);
200200
}
201201

202+
TEST_CASE("QueryExecutor scalar type coverage", "[query_executor][scalar_types]") {
203+
// Regression for issue #89 type audit: UUID previously crashed (read as a
204+
// string pointer), HUGEINT/UHUGEINT silently truncated, BLOB/BIT emitted
205+
// garbage. All now match DuckDB's own string/JSON representation.
206+
duckdb_database database;
207+
REQUIRE(duckdb_open(NULL, &database) == DuckDBSuccess);
208+
209+
SECTION("UUID serializes as canonical string (no crash)") {
210+
QueryExecutor executor(database);
211+
executor.execute("SELECT '12345678-1234-5678-1234-567812345678'::UUID AS v");
212+
auto doc = crow::json::load(executor.toJson().dump());
213+
REQUIRE(doc.size() == 1);
214+
REQUIRE(doc[0]["v"].t() == crow::json::type::String);
215+
REQUIRE(doc[0]["v"].s() == "12345678-1234-5678-1234-567812345678");
216+
}
217+
218+
SECTION("multi-row UUID keeps each row's own value") {
219+
QueryExecutor executor(database);
220+
executor.execute(R"SQL(
221+
SELECT * FROM (VALUES
222+
('11111111-1111-1111-1111-111111111111'::UUID),
223+
('22222222-2222-2222-2222-222222222222'::UUID)
224+
) t(v) ORDER BY v
225+
)SQL");
226+
auto doc = crow::json::load(executor.toJson().dump());
227+
REQUIRE(doc.size() == 2);
228+
REQUIRE(doc[0]["v"].s() == "11111111-1111-1111-1111-111111111111");
229+
REQUIRE(doc[1]["v"].s() == "22222222-2222-2222-2222-222222222222");
230+
}
231+
232+
SECTION("HUGEINT emits exact decimal string") {
233+
QueryExecutor executor(database);
234+
executor.execute(R"SQL(
235+
SELECT * FROM (VALUES
236+
(1, 1::HUGEINT),
237+
(2, 170141183460469231731687303715884105727::HUGEINT),
238+
(3, (-9999999999999999999)::HUGEINT)
239+
) t(id, v) ORDER BY id
240+
)SQL");
241+
auto doc = crow::json::load(executor.toJson().dump());
242+
REQUIRE(doc.size() == 3);
243+
REQUIRE(doc[0]["v"].s() == "1");
244+
REQUIRE(doc[1]["v"].s() == "170141183460469231731687303715884105727");
245+
REQUIRE(doc[2]["v"].s() == "-9999999999999999999");
246+
}
247+
248+
SECTION("UHUGEINT emits exact decimal string") {
249+
QueryExecutor executor(database);
250+
executor.execute("SELECT 340282366920938463463374607431768211455::UHUGEINT AS v");
251+
auto doc = crow::json::load(executor.toJson().dump());
252+
REQUIRE(doc[0]["v"].s() == "340282366920938463463374607431768211455");
253+
}
254+
255+
SECTION("BLOB emits DuckDB blob string, not garbled bytes") {
256+
QueryExecutor executor(database);
257+
executor.execute("SELECT '\\xAA\\xBB'::BLOB AS v");
258+
auto doc = crow::json::load(executor.toJson().dump());
259+
REQUIRE(doc[0]["v"].t() == crow::json::type::String);
260+
REQUIRE(doc[0]["v"].s() == "\\xAA\\xBB");
261+
}
262+
263+
SECTION("BIT emits its 0/1 string") {
264+
QueryExecutor executor(database);
265+
executor.execute("SELECT '101010'::BIT AS v");
266+
auto doc = crow::json::load(executor.toJson().dump());
267+
REQUIRE(doc[0]["v"].s() == "101010");
268+
}
269+
270+
SECTION("NULL values of these types stay null") {
271+
QueryExecutor executor(database);
272+
executor.execute(R"SQL(
273+
SELECT CAST(NULL AS UUID) AS u, CAST(NULL AS HUGEINT) AS h,
274+
CAST(NULL AS BLOB) AS b, CAST(NULL AS BIT) AS t
275+
)SQL");
276+
auto doc = crow::json::load(executor.toJson().dump());
277+
REQUIRE(doc[0]["u"].t() == crow::json::type::Null);
278+
REQUIRE(doc[0]["h"].t() == crow::json::type::Null);
279+
REQUIRE(doc[0]["b"].t() == crow::json::type::Null);
280+
REQUIRE(doc[0]["t"].t() == crow::json::type::Null);
281+
}
282+
283+
duckdb_close(&database);
284+
}
285+
202286
TEST_CASE("QueryExecutor chunk experiment", "[query_executor]") {
203287
duckdb_database database;
204288
REQUIRE(duckdb_open(NULL, &database) == DuckDBSuccess);

0 commit comments

Comments
 (0)