diff --git a/src/include/query_executor.hpp b/src/include/query_executor.hpp index e748149..b18df01 100644 --- a/src/include/query_executor.hpp +++ b/src/include/query_executor.hpp @@ -66,6 +66,8 @@ class QueryResult { static crow::json::wvalue convertVectorUnionToJson(const duckdb_vector &vector, const idx_t row_idx); static crow::json::wvalue convertVectorMapToJson(const duckdb_vector &vector, const idx_t row_idx); static std::string vectorEntryToMapKey(const duckdb_vector &vector, const idx_t row_idx); + // Universal C++-API fallback for types without a dedicated C-API reader. + static crow::json::wvalue convertVectorValueToJson(const duckdb_vector &vector, const idx_t row_idx); template static crow::json::wvalue convertVectorEntryToJson(const duckdb_vector &vector, const idx_t row_idx) { diff --git a/src/query_executor.cpp b/src/query_executor.cpp index 3dc5f1e..0436c6d 100644 --- a/src/query_executor.cpp +++ b/src/query_executor.cpp @@ -6,6 +6,12 @@ #include #include +// The C++ API is used as a universal fallback for exotic column types +// (BIGNUM/VARINT, GEOMETRY, VARIANT, ...) that have no dedicated C-API +// reader. A duckdb_vector handle is just a reinterpret_cast of a +// duckdb::Vector*, so dropping to Vector::GetValue() is a safe round-trip. +#include "duckdb.hpp" + namespace flapi { QueryExecutor::QueryExecutor(duckdb_database db) : has_result(false) { @@ -291,9 +297,12 @@ crow::json::wvalue QueryResult::convertVectorEntryToJson(const duckdb_vector &ve return convertVectorArrayToJson(vector, row_idx); case DUCKDB_TYPE_UNION: return convertVectorUnionToJson(vector, row_idx); - default: - CROW_LOG_WARNING << "Unknown type: " << type_id; - return crow::json::wvalue(nullptr); + case DUCKDB_TYPE_BIGNUM: // arbitrary-precision integer (VARINT) + case DUCKDB_TYPE_VARIANT: // dynamically-typed value + default: // GEOMETRY and any future/unknown type + // No dedicated C-API reader; use DuckDB's own value + // stringification via the C++ fallback. + return convertVectorValueToJson(vector, row_idx); } } @@ -731,4 +740,37 @@ crow::json::wvalue QueryResult::convertVectorMapToJson(const duckdb_vector &vect return result; } +crow::json::wvalue QueryResult::convertVectorValueToJson(const duckdb_vector &vector, const idx_t row_idx) { + // duckdb_vector is a reinterpret_cast of duckdb::Vector* (see the C API's + // data_chunk-c.cpp), so we can borrow the C++ Vector to let DuckDB itself + // produce the value's canonical string — covering BIGNUM/VARINT, GEOMETRY, + // VARIANT and any type lacking a dedicated reader. Wrapped in try/catch so + // an unconvertible extension value degrades to null instead of crashing. + try { + auto *vec = reinterpret_cast(vector); + duckdb::Value value = vec->GetValue(row_idx); + if (value.IsNull()) { + return crow::json::wvalue(nullptr); + } + + // A VARIANT whose string form is already JSON (the common case for + // JSON-derived values) is embedded as nested JSON so callers don't have + // to parse twice. Other VARIANT renderings (DuckDB's SQL-ish form, e.g. + // {'a': 1}) and every other fallback type are emitted as a string. + if (value.type().id() == duckdb::LogicalTypeId::VARIANT) { + auto str = value.ToString(); + auto parsed = crow::json::load(str); + if (parsed) { + return crow::json::wvalue(parsed); + } + return crow::json::wvalue(str); + } + + return crow::json::wvalue(value.ToString()); + } catch (const std::exception &e) { + CROW_LOG_WARNING << "Unable to serialize value at row " << row_idx << ": " << e.what(); + return crow::json::wvalue(nullptr); + } +} + } // namespace flapi \ No newline at end of file diff --git a/test/cpp/query_executor_test.cpp b/test/cpp/query_executor_test.cpp index 8522daa..345ce08 100644 --- a/test/cpp/query_executor_test.cpp +++ b/test/cpp/query_executor_test.cpp @@ -199,6 +199,72 @@ TEST_CASE("QueryExecutor type coverage", "[query_executor]") { duckdb_close(&database); } +TEST_CASE("QueryExecutor exotic type coverage", "[query_executor][exotic_types]") { + // Regression for the issue #89 follow-up: VARINT/BIGNUM and VARIANT used + // to serialize as null (no dedicated reader). They now round-trip via the + // C++ Value fallback. GEOMETRY uses the same path (verified manually; not + // tested here as it needs the spatial extension download). + duckdb_database database; + REQUIRE(duckdb_open(NULL, &database) == DuckDBSuccess); + + SECTION("VARINT emits exact decimal string (lossless)") { + QueryExecutor executor(database); + executor.execute(R"SQL( + SELECT * FROM (VALUES + (1, '123456789012345678901234567890'::VARINT), + (2, (-5)::VARINT), + (3, '0'::VARINT) + ) t(id, v) ORDER BY id + )SQL"); + auto doc = crow::json::load(executor.toJson().dump()); + REQUIRE(doc.size() == 3); + REQUIRE(doc[0]["v"].t() == crow::json::type::String); + REQUIRE(doc[0]["v"].s() == "123456789012345678901234567890"); + REQUIRE(doc[1]["v"].s() == "-5"); + REQUIRE(doc[2]["v"].s() == "0"); + } + + SECTION("VARIANT object is embedded as nested JSON") { + QueryExecutor executor(database); + executor.execute(R"SQL(SELECT '{"a":1,"b":[2,3]}'::VARIANT AS v)SQL"); + auto doc = crow::json::load(executor.toJson().dump()); + REQUIRE(doc.size() == 1); + REQUIRE(doc[0]["v"].t() == crow::json::type::Object); + REQUIRE(doc[0]["v"]["a"].i() == 1); + REQUIRE(doc[0]["v"]["b"].size() == 2); + REQUIRE(doc[0]["v"]["b"][1].i() == 3); + } + + SECTION("struct-origin VARIANT serializes (as DuckDB's string form)") { + // A VARIANT built from a struct stringifies as {'k': v} (single + // quotes), which isn't JSON — so it degrades to a string rather than + // null. (JSON-derived VARIANTs embed as nested JSON, see above.) + QueryExecutor executor(database); + executor.execute("SELECT {'test': 123, 'arr': [1, 2]}::VARIANT AS v"); + auto doc = crow::json::load(executor.toJson().dump()); + REQUIRE(doc.size() == 1); + REQUIRE(doc[0]["v"].t() == crow::json::type::String); + REQUIRE(std::string(doc[0]["v"].s()).find("test") != std::string::npos); + } + + SECTION("VARIANT scalar is embedded as its JSON value") { + QueryExecutor executor(database); + executor.execute("SELECT 42::VARIANT AS v"); + auto doc = crow::json::load(executor.toJson().dump()); + REQUIRE(doc[0]["v"].i() == 42); + } + + SECTION("NULL VARINT / VARIANT stay null") { + QueryExecutor executor(database); + executor.execute("SELECT CAST(NULL AS VARINT) AS a, CAST(NULL AS VARIANT) AS b"); + auto doc = crow::json::load(executor.toJson().dump()); + REQUIRE(doc[0]["a"].t() == crow::json::type::Null); + REQUIRE(doc[0]["b"].t() == crow::json::type::Null); + } + + duckdb_close(&database); +} + TEST_CASE("QueryExecutor scalar type coverage", "[query_executor][scalar_types]") { // Regression for issue #89 type audit: UUID previously crashed (read as a // string pointer), HUGEINT/UHUGEINT silently truncated, BLOB/BIT emitted