diff --git a/include/cpp_yyjson.hpp b/include/cpp_yyjson.hpp index 0857916..dbcf356 100644 --- a/include/cpp_yyjson.hpp +++ b/include/cpp_yyjson.hpp @@ -4352,8 +4352,25 @@ namespace yyjson requires (std::same_as || writer::detail::base_of_const_value) static auto from_json(const Json& json) { + // --- Library's own value-owning types (writer value/array/object) --- + // Deep-copy the JSON subtree into a fresh self-owned value via the + // implicit converting constructor (T(json) -> doc_.copy_value). This + // MUST rank ahead of the scalar/string branches: due to constructors + // inherited through `using base::base`, std::constructible_from spuriously evaluates to true (on both MSVC and clang), + // which would otherwise misroute object/array input into the string + // branch and throw "is not constructible from JSON string". + // default_initializable excludes the non-owning _ref view types + // (value_ref/array_ref/object_ref), which have no default ctor and + // cannot be a cast target. + if constexpr (std::default_initializable && + writer::detail::base_of_value && + requires(const Json& j) { T(j); }) + { + return T(json); + } // --- Scalar types: compile-time dispatch --- - if constexpr (std::same_as) + else if constexpr (std::same_as) { if (json.is_bool()) return *json.as_bool(); @@ -4403,19 +4420,27 @@ namespace yyjson throw bad_cast(CPPYYJSON_FMT_NS::format("{} is not constructible from JSON number", type_name())); } // --- String-like types --- - else if constexpr (!std::is_aggregate_v && std::constructible_from) + // base_of_value guard is belt-and-suspenders: the deep-copy branch + // above already short-circuits library value types, but exclude them + // here too so the inherited-ctor constructible_from false-positive + // (see comment above) can never misroute a value type into string + // construction, even if the deep-copy branch is somehow not reached. + else if constexpr (!std::is_aggregate_v && !writer::detail::base_of_value && + std::constructible_from) { if (!json.is_string()) throw bad_cast(CPPYYJSON_FMT_NS::format("{} is not constructible from JSON string", type_name())); return T(*json.as_string()); } - else if constexpr (!std::is_aggregate_v && std::constructible_from) + else if constexpr (!std::is_aggregate_v && !writer::detail::base_of_value && + std::constructible_from) { if (!json.is_string()) throw bad_cast(CPPYYJSON_FMT_NS::format("{} is not constructible from JSON string", type_name())); return T(std::string(*json.as_string())); } - else if constexpr (!std::is_aggregate_v && std::constructible_from) + else if constexpr (!std::is_aggregate_v && !writer::detail::base_of_value && + std::constructible_from) { if (!json.is_string()) throw bad_cast(CPPYYJSON_FMT_NS::format("{} is not constructible from JSON string", type_name())); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d68fe0f..67af9e4 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -75,6 +75,11 @@ target_link_libraries("${PROJECT_NAME}_test_aggregate_ctor" PRIVATE ${PROJECT_NAME} GTest::gtest GTest::gtest_main) gtest_discover_tests("${PROJECT_NAME}_test_aggregate_ctor" DISCOVERY_MODE PRE_TEST) +add_executable("${PROJECT_NAME}_test_value_cast" test_value_cast.cpp) +target_link_libraries("${PROJECT_NAME}_test_value_cast" + PRIVATE ${PROJECT_NAME} GTest::gtest GTest::gtest_main) +gtest_discover_tests("${PROJECT_NAME}_test_value_cast" DISCOVERY_MODE PRE_TEST) + # benchmark if(CPPYYJSON_BUILD_BENCH) diff --git a/test/test_value_cast.cpp b/test/test_value_cast.cpp new file mode 100644 index 0000000..5ce1ea2 --- /dev/null +++ b/test/test_value_cast.cpp @@ -0,0 +1,60 @@ +// Regression: cast and aggregate reflection of DTOs with +// yyjson::value fields must deep-copy the JSON subtree. +// +// Before the fix, default_caster fell into the scalar string +// branch (because std::constructible_from spuriously +// evaluated to true via inherited constructors), throwing +// "is not constructible from JSON string" for object/array input on MSVC +// (and for direct cast on clang too). The library now deep-copies its +// own value-owning types ahead of the scalar/string branches. +#include +#include + +#include +#include +namespace yyjson_value_cast_test +{ + struct TaskDto + { + std::string task_name; + std::string entry; + yyjson::value pipeline_override; + }; +} // namespace yyjson_value_cast_test + +template <> +struct yyjson::field_name_rule +{ + using type = yyjson::snake_to_camel_transform; +}; + +TEST(ValueCast, DirectObjectSubtreeDeepCopies) +{ + auto parsed = yyjson::read(std::string_view( + R"({"taskName":"T","entry":"E","pipelineOverride":{"Stage":{"value":"one"}}})")); + auto v = yyjson::cast(parsed); + EXPECT_TRUE(v.is_object()); +} + +TEST(ValueCast, DirectScalarSubtreeDeepCopies) +{ + auto parsed = yyjson::read(std::string_view("42")); + auto v = yyjson::cast(parsed); + EXPECT_TRUE(v.is_uint()); +} + +TEST(ValueCast, AggregateDtoWithValueField) +{ + auto parsed = yyjson::read(std::string_view( + R"({"taskName":"T","entry":"E","pipelineOverride":{"Stage":{"value":"one"}}})")); + auto t = yyjson::cast(parsed); + EXPECT_EQ(t.task_name, "T"); + EXPECT_EQ(t.entry, "E"); + EXPECT_TRUE(t.pipeline_override.is_object()); +} + +TEST(ValueCast, StringCastUnaffectedBySfinaeTightening) +{ + auto parsed = yyjson::read(std::string_view("\"hello\"")); + EXPECT_EQ(yyjson::cast(parsed), "hello"); +}