Skip to content

Commit ac97de0

Browse files
authored
fix: Serialize MAP columns as {key: value} in REST JSON (#89) (#91)
Follow-up to the LIST/STRUCT/ARRAY/UNION fix. MAP columns were aliased onto the struct serializer, but a DuckDB MAP is physically LIST(STRUCT(key, value)), so they did not round-trip (serialized to null / wrong type) — reported on #89 after v26.06.25. - Add convertVectorMapToJson: slice the row's duckdb_list_entry, read key/value from the list child struct, emit {key: value} matching DuckDB's to_json. Empty map -> {}, NULL map -> null. - Add vectorEntryToMapKey: raw string for VARCHAR keys (escape-safe), JSON rendering for scalar keys (e.g. 10 -> "10"). - Fix a pre-existing logical-type leak in convertVectorDecimalToJson (now reachable via decimal map keys). - Add MAP regression tests: string keys, integer keys, empty, and NULL. Found via user follow-up + codex review.
1 parent ab902db commit ac97de0

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

src/include/query_executor.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ class QueryResult {
5959
static crow::json::wvalue convertVectorArrayToJson(const duckdb_vector &vector, const idx_t row_idx);
6060
static crow::json::wvalue convertVectorStructToJson(const duckdb_vector &vector, const idx_t row_idx);
6161
static crow::json::wvalue convertVectorUnionToJson(const duckdb_vector &vector, const idx_t row_idx);
62+
static crow::json::wvalue convertVectorMapToJson(const duckdb_vector &vector, const idx_t row_idx);
63+
static std::string vectorEntryToMapKey(const duckdb_vector &vector, const idx_t row_idx);
6264

6365
template<typename T>
6466
static crow::json::wvalue convertVectorEntryToJson(const duckdb_vector &vector, const idx_t row_idx) {

src/query_executor.cpp

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ crow::json::wvalue QueryResult::convertVectorEntryToJson(const duckdb_vector &ve
286286
case DUCKDB_TYPE_BIT:
287287
return convertVectorVarcharToJson(vector, row_idx); // Treat as string for JSON
288288
case DUCKDB_TYPE_MAP:
289-
return convertVectorStructToJson(vector, row_idx); // Treat as struct for JSON
289+
return convertVectorMapToJson(vector, row_idx);
290290
case DUCKDB_TYPE_ARRAY:
291291
return convertVectorArrayToJson(vector, row_idx);
292292
case DUCKDB_TYPE_UNION:
@@ -348,6 +348,7 @@ crow::json::wvalue QueryResult::convertVectorDecimalToJson(const duckdb_vector &
348348
auto decimal_type = duckdb_decimal_internal_type(type);
349349
auto decimal_width = duckdb_decimal_width(type);
350350
auto decimal_scale = duckdb_decimal_scale(type);
351+
duckdb_destroy_logical_type(&type);
351352
auto hugeint = duckdb_hugeint {0, 0};
352353

353354
switch (decimal_type) {
@@ -533,4 +534,64 @@ crow::json::wvalue QueryResult::convertVectorUnionToJson(const duckdb_vector &ve
533534
return result;
534535
}
535536

537+
std::string QueryResult::vectorEntryToMapKey(const duckdb_vector &vector, const idx_t row_idx) {
538+
auto type = duckdb_vector_get_column_type(vector);
539+
bool is_string = duckdb_get_type_id(type) == DUCKDB_TYPE_VARCHAR;
540+
duckdb_destroy_logical_type(&type);
541+
542+
auto validity = duckdb_vector_get_validity(vector);
543+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
544+
return std::string();
545+
}
546+
547+
if (is_string) {
548+
auto data = static_cast<duckdb_string_t *>(duckdb_vector_get_data(vector));
549+
return duckdb_string_is_inlined(data[row_idx])
550+
? std::string(data[row_idx].value.inlined.inlined, data[row_idx].value.inlined.length)
551+
: std::string(static_cast<const char *>(data[row_idx].value.pointer.ptr), data[row_idx].value.pointer.length);
552+
}
553+
554+
// Non-string key: render the scalar and use its JSON form as the object
555+
// key (e.g. integer 10 -> "10"), matching DuckDB's to_json for scalar
556+
// keys. Strip the quotes a string-like rendering (date/UUID/etc.) adds.
557+
// Composite keys (STRUCT/LIST) are rare and fall back to their JSON
558+
// rendering rather than DuckDB's VARCHAR cast.
559+
auto rendered = convertVectorEntryToJson(vector, row_idx);
560+
auto dumped = rendered.dump();
561+
if (dumped.size() >= 2 && dumped.front() == '"' && dumped.back() == '"') {
562+
return dumped.substr(1, dumped.size() - 2);
563+
}
564+
return dumped;
565+
}
566+
567+
crow::json::wvalue QueryResult::convertVectorMapToJson(const duckdb_vector &vector, const idx_t row_idx) {
568+
auto validity = duckdb_vector_get_validity(vector);
569+
if (!duckdb_validity_row_is_valid(validity, row_idx)) {
570+
return crow::json::wvalue(nullptr);
571+
}
572+
573+
// A MAP is physically LIST(STRUCT(key, value)). Slice the row's entries
574+
// from the list child via its duckdb_list_entry, then emit a JSON object
575+
// {key: value} (DuckDB's own to_json shape). A non-null but empty map
576+
// serializes as {} rather than null.
577+
auto entries = static_cast<duckdb_list_entry *>(duckdb_vector_get_data(vector));
578+
auto entry = entries[row_idx];
579+
580+
auto kv_struct = duckdb_list_vector_get_child(vector);
581+
auto child_size = duckdb_list_vector_get_size(vector);
582+
auto key_vector = duckdb_struct_vector_get_child(kv_struct, 0);
583+
auto value_vector = duckdb_struct_vector_get_child(kv_struct, 1);
584+
585+
crow::json::wvalue result = crow::json::wvalue::empty_object();
586+
for (idx_t i = 0; i < entry.length; i++) {
587+
idx_t child_idx = entry.offset + i;
588+
if (child_idx >= child_size) {
589+
break;
590+
}
591+
result[vectorEntryToMapKey(key_vector, child_idx)] = convertVectorEntryToJson(value_vector, child_idx);
592+
}
593+
594+
return result;
595+
}
596+
536597
} // namespace flapi

test/cpp/query_executor_test.cpp

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,58 @@ TEST_CASE("QueryExecutor LIST/STRUCT per-row serialization", "[query_executor][l
379379
REQUIRE_FALSE(doc[2]["u"].has("str"));
380380
}
381381

382+
SECTION("multi-row MAP serializes as a per-row {key: value} object") {
383+
QueryExecutor executor(database);
384+
executor.execute(R"SQL(
385+
SELECT * FROM (VALUES
386+
(1, map_from_entries([('a', 1), ('b', 2)])),
387+
(2, map_from_entries([('x', 9)]))
388+
) AS t(id, reasons)
389+
ORDER BY id
390+
)SQL");
391+
392+
auto doc = crow::json::load(executor.toJson().dump());
393+
REQUIRE(doc.size() == 2);
394+
395+
// Matches DuckDB's to_json(map) shape; previously serialized to null.
396+
REQUIRE(doc[0]["reasons"].t() == crow::json::type::Object);
397+
REQUIRE(doc[0]["reasons"]["a"].i() == 1);
398+
REQUIRE(doc[0]["reasons"]["b"].i() == 2);
399+
400+
REQUIRE(doc[1]["reasons"].t() == crow::json::type::Object);
401+
REQUIRE(doc[1]["reasons"]["x"].i() == 9);
402+
REQUIRE_FALSE(doc[1]["reasons"].has("a"));
403+
}
404+
405+
SECTION("MAP with integer keys stringifies keys like to_json") {
406+
QueryExecutor executor(database);
407+
executor.execute(R"SQL(
408+
SELECT map_from_entries([(10, 'x'), (20, 'y')]) AS m
409+
)SQL");
410+
411+
auto doc = crow::json::load(executor.toJson().dump());
412+
REQUIRE(doc.size() == 1);
413+
REQUIRE(doc[0]["m"].t() == crow::json::type::Object);
414+
REQUIRE(doc[0]["m"]["10"].s() == "x");
415+
REQUIRE(doc[0]["m"]["20"].s() == "y");
416+
}
417+
418+
SECTION("NULL and empty MAP") {
419+
QueryExecutor executor(database);
420+
executor.execute(R"SQL(
421+
SELECT id, m FROM (
422+
SELECT 1 AS id, MAP{}::MAP(VARCHAR, INTEGER) AS m
423+
UNION ALL
424+
SELECT 2, CAST(NULL AS MAP(VARCHAR, INTEGER))
425+
) ORDER BY id
426+
)SQL");
427+
428+
auto doc = crow::json::load(executor.toJson().dump());
429+
REQUIRE(doc.size() == 2);
430+
REQUIRE(doc[0]["m"].t() == crow::json::type::Object); // empty -> {}
431+
REQUIRE(doc[1]["m"].t() == crow::json::type::Null); // null -> null
432+
}
433+
382434
SECTION("NULL list entry stays null") {
383435
QueryExecutor executor(database);
384436
executor.execute(R"SQL(

0 commit comments

Comments
 (0)