From d34d796b261178b2dc709c426250d29e94c20ff8 Mon Sep 17 00:00:00 2001 From: clockblocker Date: Tue, 10 Nov 2026 13:08:31 +0100 Subject: [PATCH 01/10] Let DefaultVal's converting constructor throw DefaultVal(U&&) was unconditionally noexcept, so an exception from constructing the stored value called std::terminate. That is the case for an initializer that a validator rejects: rfl::DefaultVal>> retries = 0; Default-constructing such a struct aborted instead of throwing, and so did every rfl::json::read of it, although read_struct_with_default catches the exception to return it as an error. Make the constructor noexcept only if constructing Type from U is. Co-Authored-By: Claude Fable 5.2 --- include/rfl/DefaultVal.hpp | 3 +- .../test_default_val_throwing_initializer.cpp | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/json/test_default_val_throwing_initializer.cpp diff --git a/include/rfl/DefaultVal.hpp b/include/rfl/DefaultVal.hpp index 630a3f5bd..d3c2edc4d 100644 --- a/include/rfl/DefaultVal.hpp +++ b/include/rfl/DefaultVal.hpp @@ -63,7 +63,8 @@ struct DefaultVal { /// @param _value The value to convert and store (will be moved) template requires(std::is_convertible_v) - DefaultVal(U&& _value) noexcept : value_(std::forward(_value)) {} + DefaultVal(U&& _value) noexcept(std::is_nothrow_constructible_v) + : value_(std::forward(_value)) {} /// Constructs from a DefaultVal with compatible type. /// @tparam U Type convertible to T diff --git a/tests/json/test_default_val_throwing_initializer.cpp b/tests/json/test_default_val_throwing_initializer.cpp new file mode 100644 index 000000000..4b83a05ef --- /dev/null +++ b/tests/json/test_default_val_throwing_initializer.cpp @@ -0,0 +1,41 @@ +#include + +#include +#include +#include +#include + +namespace test_default_val_throwing_initializer { + +using Positive = rfl::Validator>; + +static_assert(!std::is_nothrow_constructible_v, int>, + "Converting to a Validator can throw."); +static_assert(std::is_nothrow_constructible_v, short>); + +struct InvalidInitializer { + rfl::DefaultVal retries = 0; +}; + +struct ValidInitializer { + rfl::DefaultVal retries = 5; +}; + +TEST(json, test_default_val_throwing_initializer_throws) { + EXPECT_THROW((void)InvalidInitializer{}, std::exception); +} + +TEST(json, test_default_val_throwing_initializer_is_an_error_when_reading) { + const auto res = rfl::json::read(R"({"retries":3})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Value expected to be greater than or equal to 1, but got 0."); +} + +TEST(json, test_default_val_throwing_initializer_valid_default_is_used) { + const auto res = rfl::json::read(R"({})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->retries.value().value(), 5); +} + +} // namespace test_default_val_throwing_initializer From db9da74aacb4b92409198e7e7d2b02687d22ad35 Mon Sep 17 00:00:00 2001 From: clockblocker Date: Tue, 10 Nov 2026 16:05:49 +0100 Subject: [PATCH 02/10] Return an error when an rfl::Skip field's default value throws ParserSkip::read is noexcept. For a field that is skipped when reading, it constructs the default value of the wrapped type, which throws for a Validator whose default value is invalid, such as rfl::Skip>>. Reading a struct with such a field in the input called std::terminate. Catch the exception and return it as the field's error. Co-Authored-By: Claude Fable 5.2 --- include/rfl/parsing/ParserSkip.hpp | 11 +++++++++-- tests/json/test_skip_throwing_default.cpp | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 tests/json/test_skip_throwing_default.cpp diff --git a/include/rfl/parsing/ParserSkip.hpp b/include/rfl/parsing/ParserSkip.hpp index 6d748ba04..5b31767f6 100644 --- a/include/rfl/parsing/ParserSkip.hpp +++ b/include/rfl/parsing/ParserSkip.hpp @@ -1,6 +1,7 @@ #ifndef RFL_PARSING_PARSER_SKIP_HPP_ #define RFL_PARSING_PARSER_SKIP_HPP_ +#include #include #include @@ -39,8 +40,14 @@ struct ParserSkip { static Result> read(const R& _r, const InputVarType& _var) noexcept { if constexpr (_skip_deserialization) { - return internal::Skip( - std::remove_cvref_t()); + // The default value can throw, for instance a Validator whose default + // value is invalid. + try { + return internal::Skip( + std::remove_cvref_t()); + } catch (std::exception& e) { + return error(e.what()); + } } else { const auto to_skip = [&](auto&& _t) { return internal::Skip( diff --git a/tests/json/test_skip_throwing_default.cpp b/tests/json/test_skip_throwing_default.cpp new file mode 100644 index 000000000..a8d530060 --- /dev/null +++ b/tests/json/test_skip_throwing_default.cpp @@ -0,0 +1,23 @@ +#include + +#include +#include + +namespace test_skip_throwing_default { + +using Positive = rfl::Validator>; + +struct WithSkip { + rfl::Skip s; + int y; +}; + +TEST(json, test_skip_throwing_default) { + const auto res = rfl::json::read(R"({"y": 1, "s": 5})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Failed to parse field 's': Value expected to be greater than or " + "equal to 1, but got 0."); +} + +} // namespace test_skip_throwing_default From 127ec54a1c12359062cacb866f23ef17403b70de Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 08:04:39 +0100 Subject: [PATCH 03/10] Say why default construction failed when reading a struct with defaults A struct that contains rfl::DefaultVal or is read with rfl::DefaultIfMissing is default-constructed before it is read. If T{} throws, for instance because a validated field has no valid default value, the message of the exception was returned as it is: Size validation failed: Value expected to be greater than or equal to 1, but got 0. This reads as if the input was wrong, even when the field is in the input and valid, and it names neither the struct nor the cause. What is read and what fails stays the same. But the error now names the struct, says why it was default-constructed and what to do about it, followed by the original message: Could not default-construct Person, which is required because it contains rfl::DefaultVal. Give every field a valid default value. Original error: Size validation failed: Value expected to be greater than or equal to 1, but got 0. Only a failure of T{} gets this text. An exception thrown later in read_struct_with_default keeps its own message. Also document the restriction and the workaround in default_val.md and processors.md: a field that is given an initializer for this reason stays required, unless the struct is read with rfl::DefaultIfMissing. Co-Authored-By: Claude Fable 5.2 --- docs/content/default_val.md | 36 +++++ docs/content/processors.md | 4 + include/rfl/parsing/Parser_default.hpp | 43 ++++-- tests/json/test_default_construct_error.cpp | 147 ++++++++++++++++++++ 4 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 tests/json/test_default_construct_error.cpp diff --git a/docs/content/default_val.md b/docs/content/default_val.md index ced77a072..6b29b52ba 100644 --- a/docs/content/default_val.md +++ b/docs/content/default_val.md @@ -52,6 +52,42 @@ const auto homer = rfl::json::read(R"(["Homer"])").va // homer.town == "" ``` +## All fields need a valid default value + +A struct that contains an `rfl::DefaultVal` is default-constructed before it is read. The fields found in the input are then written into it. This means that every field of the struct needs a valid default value, even a required field that is in the input. + +This matters when the default value of a field's type is not valid, for instance when the field has a validator: + +```cpp +struct Person { + rfl::DefaultVal name = "Homer Simpson"; + rfl::Validator, rfl::Size>> favorite_foods; +}; + +// Fails, even though favorite_foods is in the input. +rfl::json::read(R"({"favorite_foods":["donuts","pizza"]})"); +``` + +An empty vector does not satisfy `rfl::Size>`, so `Person{}` throws an exception and you get the following error: + +``` +Could not default-construct Person, which is required because it contains rfl::DefaultVal. Give every field a valid default value. Original error: Size validation failed: Value expected to be greater than or equal to 1, but got 0. +``` + +To avoid this, give the field a valid default value: + +```cpp +struct Person { + rfl::DefaultVal name = "Homer Simpson"; + rfl::Validator, rfl::Size>> favorite_foods = + std::vector({"none"}); +}; +``` + +The field is still required. If `favorite_foods` is missing in the input, you get the error `Field named 'favorite_foods' not found.` The default value is only used for a missing field if you pass the `rfl::DefaultIfMissing` processor. + +The same restriction applies to every struct that is read with the `rfl::DefaultIfMissing` processor, whether it contains an `rfl::DefaultVal` or not. + ## When to use Use `rfl::DefaultVal` when you want a field to be optional at the input side but still available as a value on the resulting object (no std::optional or pointer indirection). It is particularly useful for fields with sensible defaults (for example, a common last name, a default configuration value, or empty containers/strings). diff --git a/docs/content/processors.md b/docs/content/processors.md index 64c75dd52..a431bb4e6 100644 --- a/docs/content/processors.md +++ b/docs/content/processors.md @@ -310,6 +310,10 @@ have gotten had you read the following JSON string: Because you have not passed a default value to town, the default value of the type is used instead. +Note that the struct is default-constructed before it is read. Every field +therefore needs a valid default value, even if the field is in the input. +This is explained in the documentation on `rfl::DefaultVal`. + ### `rfl::DurationsAsCount` By default, `std::chrono::duration` types are written as an object containing diff --git a/include/rfl/parsing/Parser_default.hpp b/include/rfl/parsing/Parser_default.hpp index f17c13e16..91cd540d6 100644 --- a/include/rfl/parsing/Parser_default.hpp +++ b/include/rfl/parsing/Parser_default.hpp @@ -2,12 +2,14 @@ #define RFL_PARSING_PARSER_DEFAULT_HPP_ #include +#include #include #include "../Result.hpp" #include "../always_false.hpp" #include "../internal/HasFromReflectionMethod.hpp" #include "../internal/always_inline.hpp" +#include "../internal/cold.hpp" #include "../internal/default_if_missing_v.hpp" #include "../internal/has_default_val_v.hpp" #include "../internal/has_reflection_method_v.hpp" @@ -720,22 +722,39 @@ struct Parser { static Result read_struct_with_default(const R& _r, const InputVarType& _var) { try { - auto t = T{}; // This might fail, for instance if the default value does - // not satisfy the validator, but in that case we will just - // return the error. - auto view = ProcessorsType::template process(to_view(t)); - using ViewType = decltype(view); - auto err = - Parser::read_view_with_default( - _r, _var, &view); - if (err) [[unlikely]] { - return error(std::move(*err)); + // This might fail, for instance if the default value of a field does not + // satisfy its validator. The outer handler is for this line only. + auto t = T{}; + try { + auto view = ProcessorsType::template process(to_view(t)); + using ViewType = decltype(view); + auto err = + Parser::read_view_with_default( + _r, _var, &view); + if (err) [[unlikely]] { + return error(std::move(*err)); + } + return t; + } catch (std::exception& e) { + return error(e.what()); } - return t; } catch (std::exception& e) { - return error(e.what()); + return error(default_construction_error(e.what())); } } + + /// The error returned by read_struct_with_default when T{} throws. On its + /// own, the exception reads as if the input was wrong, so we name the struct + /// and explain why it was default-constructed. + RFL_COLD static Error default_construction_error(const char* _what) { + const std::string reason = internal::has_default_val_v + ? "it contains rfl::DefaultVal" + : "it is read with rfl::DefaultIfMissing"; + return Error( + "Could not default-construct " + type_name_t().str() + + ", which is required because " + reason + + ". Give every field a valid default value. Original error: " + _what); + } }; } // namespace rfl::parsing diff --git a/tests/json/test_default_construct_error.cpp b/tests/json/test_default_construct_error.cpp new file mode 100644 index 000000000..4157eadd9 --- /dev/null +++ b/tests/json/test_default_construct_error.cpp @@ -0,0 +1,147 @@ +#include + +#include +#include +#include +#include +#include + +namespace test_default_construct_error { + +using Foods = + rfl::Validator, rfl::Size>>; + +struct Person { + rfl::DefaultVal name = "Homer Simpson"; + Foods favorite_foods; +}; + +struct PersonWithValidDefault { + rfl::DefaultVal name = "Homer Simpson"; + Foods favorite_foods = std::vector({"none"}); +}; + +struct Pet { + std::string name; + Foods favorite_foods; +}; + +struct Family { + std::string town; + Person head; +}; + +inline int no_default_port() { throw std::runtime_error("No default port"); } + +struct Server { + rfl::DefaultVal host = "localhost"; + int port = no_default_port(); +}; + +bool contains(const std::string& _str, const std::string& _substr) { + return _str.find(_substr) != std::string::npos; +} + +const std::string validation_error = + "Size validation failed: Value expected to be greater than or equal to 1, " + "but got 0."; + +TEST(json, test_default_construct_error_with_default_val) { + const auto res = + rfl::json::read(R"({"favorite_foods":["donuts","pizza"]})"); + + ASSERT_FALSE(res && true); + const auto what = res.error().what(); + EXPECT_TRUE(contains(what, "default-construct")) << what; + EXPECT_TRUE(contains(what, "Person")) << what; + EXPECT_TRUE(contains(what, "rfl::DefaultVal")) << what; + EXPECT_TRUE(contains(what, "valid default value")) << what; + EXPECT_TRUE(contains(what, validation_error)) << what; +} + +TEST(json, test_default_construct_error_with_default_if_missing) { + const auto json_string = R"({"name":"Bart","favorite_foods":["pizza"]})"; + + EXPECT_TRUE(rfl::json::read(json_string) && true); + + const auto res = rfl::json::read(json_string); + + ASSERT_FALSE(res && true); + const auto what = res.error().what(); + EXPECT_TRUE(contains(what, "default-construct")) << what; + EXPECT_TRUE(contains(what, "Pet")) << what; + EXPECT_TRUE(contains(what, "rfl::DefaultIfMissing")) << what; + EXPECT_TRUE(contains(what, validation_error)) << what; +} + +TEST(json, test_default_construct_error_in_nested_struct) { + const auto res = rfl::json::read( + R"({"town":"Springfield","head":{"favorite_foods":["donuts"]}})"); + + ASSERT_FALSE(res && true); + const auto what = res.error().what(); + EXPECT_TRUE(contains(what, "Failed to parse field 'head': ")) << what; + + const auto details = res.error().details(); + ASSERT_EQ(details.size(), 1); + EXPECT_EQ(details[0].path, + std::vector({"head"})); + EXPECT_TRUE(contains(details[0].message, "default-construct")); + EXPECT_TRUE(contains(details[0].message, "Person")); + EXPECT_TRUE(contains(details[0].message, validation_error)); +} + +TEST(json, test_default_construct_error_without_validator) { + const auto res = rfl::json::read(R"({"port":8080})"); + + ASSERT_FALSE(res && true); + const auto what = res.error().what(); + EXPECT_TRUE(contains(what, "default-construct")) << what; + EXPECT_TRUE(contains(what, "Server")) << what; + EXPECT_TRUE(contains(what, "No default port")) << what; +} + +TEST(json, test_default_construct_error_valid_default) { + const auto homer = rfl::json::read( + R"({"favorite_foods":["donuts","pizza"]})") + .value(); + + EXPECT_EQ(homer.name.value(), "Homer Simpson"); + EXPECT_EQ(homer.favorite_foods.value(), + std::vector({"donuts", "pizza"})); + + // The initializer does not make the field optional. + const auto missing = rfl::json::read(R"({})"); + + ASSERT_FALSE(missing && true); + EXPECT_EQ(missing.error().what(), "Field named 'favorite_foods' not found."); + + // With rfl::DefaultIfMissing, the initializer is the value of a missing + // field. + const auto with_default = + rfl::json::read(R"({})") + .value(); + + EXPECT_EQ(with_default.favorite_foods.value(), + std::vector({"none"})); +} + +TEST(json, test_default_construct_error_not_used_for_later_failures) { + const auto wrong_type = + rfl::json::read(R"({"favorite_foods":"donuts"})"); + + ASSERT_FALSE(wrong_type && true); + EXPECT_TRUE(contains(wrong_type.error().what(), + "Failed to parse field 'favorite_foods': ")) + << wrong_type.error().what(); + EXPECT_FALSE(contains(wrong_type.error().what(), "default-construct")) + << wrong_type.error().what(); + + const auto invalid = + rfl::json::read(R"({"favorite_foods":[]})"); + + ASSERT_FALSE(invalid && true); + EXPECT_EQ(invalid.error().what(), + "Failed to parse field 'favorite_foods': " + validation_error); +} +} // namespace test_default_construct_error From 3b35bb8fd3485d84790c8d0f4033861645b12267 Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 08:15:14 +0100 Subject: [PATCH 04/10] Return an error when the default value of a missing field throws NamedTupleParser::handle_one_missing_field and TupleReader::handle_missing_fields_impl are noexcept. For a field that is not required and missing from the input, they construct the default value in place, which throws for a type such as rfl::Skip>>. Reading a struct, a named tuple or a tuple without that field called std::terminate. Catch the exception and report it as the field's error. The field is not marked as set, so its destructor is not run. Types that are nothrow default constructible are constructed as before. Co-Authored-By: Claude Fable 5.2 --- include/rfl/parsing/NamedTupleParser.hpp | 24 +++++-- include/rfl/parsing/TupleReader.hpp | 19 ++++- tests/json/test_skip_throwing_default.cpp | 84 +++++++++++++++++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/include/rfl/parsing/NamedTupleParser.hpp b/include/rfl/parsing/NamedTupleParser.hpp index c4e2fd312..835e7abc7 100644 --- a/include/rfl/parsing/NamedTupleParser.hpp +++ b/include/rfl/parsing/NamedTupleParser.hpp @@ -2,6 +2,7 @@ #define RFL_PARSING_NAMEDTUPLEPARSER_HPP_ #include +#include #include #include #include @@ -353,11 +354,26 @@ struct NamedTupleParser { make_nested_error(std::string(current_name), stream.str())); } else if constexpr (!internal::has_default_val_v) { - if constexpr (!std::is_const_v) { - ::new (rfl::get<_i>(_view)) ValueType(); + using NonConstT = std::remove_const_t; + const auto ptr = const_cast(rfl::get<_i>(_view)); + if constexpr (std::is_nothrow_default_constructible_v) { + ::new (ptr) NonConstT(); } else { - using NonConstT = std::remove_const_t; - ::new (const_cast(rfl::get<_i>(_view))) NonConstT(); + // The default value can throw, for instance a Validator whose + // default value is invalid. + try { + ::new (ptr) NonConstT(); + } catch (std::exception& e) { + constexpr auto current_name = + internal::nth_element_t<_i, FieldTypes...>::name(); + _errors->emplace_back(make_nested_error( + std::string(current_name), + "Field named '" + std::string(current_name) + + "' not found, and its default value could not be " + "constructed: " + + e.what())); + return; + } } std::get<_i>(*_set) = true; } diff --git a/include/rfl/parsing/TupleReader.hpp b/include/rfl/parsing/TupleReader.hpp index bc48a8700..dab0c7fdf 100644 --- a/include/rfl/parsing/TupleReader.hpp +++ b/include/rfl/parsing/TupleReader.hpp @@ -1,6 +1,7 @@ #ifndef RFL_PARSING_TUPLEREADER_HPP_ #define RFL_PARSING_TUPLEREADER_HPP_ +#include #include #include #include @@ -80,9 +81,25 @@ class TupleReader { static_cast(_i), "Field " + std::to_string(_i) + " was required, but missing."); return; - } else { + } else if constexpr (std::is_nothrow_default_constructible_v< + CurrentType>) { ::new (&(rfl::get<_i>(*tuple_))) CurrentType(); ++num_set_; + } else { + // The default value can throw, for instance a Validator whose + // default value is invalid. + try { + ::new (&(rfl::get<_i>(*tuple_))) CurrentType(); + ++num_set_; + } catch (std::exception& e) { + *_err = make_nested_error( + static_cast(_i), + "Field " + std::to_string(_i) + + " was missing, and its default value could not be " + "constructed: " + + e.what()); + return; + } } } handle_missing_fields_impl<_i + 1>(_err); diff --git a/tests/json/test_skip_throwing_default.cpp b/tests/json/test_skip_throwing_default.cpp index a8d530060..652eff917 100644 --- a/tests/json/test_skip_throwing_default.cpp +++ b/tests/json/test_skip_throwing_default.cpp @@ -1,17 +1,48 @@ #include +#include #include #include +#include +#include +#include namespace test_skip_throwing_default { using Positive = rfl::Validator>; +using Path = std::vector; + struct WithSkip { rfl::Skip s; int y; }; +struct SkipLast { + int y; + rfl::Skip s; +}; + +/// Not required, because its ReflectionType is a std::optional, and counts +/// the instances that are alive. +struct ThrowingDefault { + using ReflectionType = std::optional; + + static inline int alive = 0; + + ThrowingDefault() { throw std::runtime_error("no default"); } + ThrowingDefault(const ReflectionType&) { ++alive; } + ThrowingDefault(const ThrowingDefault&) { ++alive; } + ~ThrowingDefault() { --alive; } + + ReflectionType reflection() const { return std::nullopt; } +}; + +struct WithThrowingDefault { + int y; + ThrowingDefault t; +}; + TEST(json, test_skip_throwing_default) { const auto res = rfl::json::read(R"({"y": 1, "s": 5})"); ASSERT_FALSE(res); @@ -20,4 +51,57 @@ TEST(json, test_skip_throwing_default) { "equal to 1, but got 0."); } +TEST(json, test_skip_throwing_default_field_absent) { + const auto res = rfl::json::read(R"({"y": 1})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field named 's' not found, and its default value could not be " + "constructed: Value expected to be greater than or equal to 1, " + "but got 0."); + const auto details = res.error().details(); + ASSERT_EQ(details.size(), 1); + EXPECT_EQ(details[0].path, (Path{"s"})); + EXPECT_EQ(details[0].message, res.error().what()); +} + +TEST(json, test_skip_throwing_default_field_absent_no_field_names) { + const auto res = rfl::json::read(R"([1])"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field named 's' not found, and its default value could not be " + "constructed: Value expected to be greater than or equal to 1, " + "but got 0."); +} + +TEST(json, test_skip_throwing_default_tuple_element_absent) { + const auto res = + rfl::json::read>>(R"([1])"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field 1 was missing, and its default value could not be " + "constructed: Value expected to be greater than or equal to 1, " + "but got 0."); + const auto details = res.error().details(); + ASSERT_EQ(details.size(), 1); + EXPECT_EQ(details[0].path, (Path{size_t{1}})); +} + +// No destructor may run for the field that could not be constructed. +TEST(json, test_skip_throwing_default_does_not_destroy_the_field) { + const auto res = rfl::json::read(R"({"y": 1})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field named 't' not found, and its default value could not be " + "constructed: no default"); + EXPECT_EQ(ThrowingDefault::alive, 0); + + const auto tup = + rfl::json::read>(R"([1])"); + ASSERT_FALSE(tup); + EXPECT_EQ(ThrowingDefault::alive, 0); + + ASSERT_TRUE(rfl::json::read(R"({"y": 1, "t": 2})")); + EXPECT_EQ(ThrowingDefault::alive, 0); +} + } // namespace test_skip_throwing_default From e19c8f37bab26ec03fa169fe1e7750ef98599ae4 Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 08:37:37 +0100 Subject: [PATCH 05/10] Adapt a test of fork PR 10 to the message of fork PR 11 test_default_val_throwing_initializer (PR 10) expects the bare validator message when T{} throws, while PR 11 prefixes it with "Could not default-construct ...". Both are merged here, so the test failed. Expect the message that the combination produces. Co-Authored-By: Claude Opus 5.5 --- tests/json/test_default_val_throwing_initializer.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/json/test_default_val_throwing_initializer.cpp b/tests/json/test_default_val_throwing_initializer.cpp index 4b83a05ef..3e09f81c1 100644 --- a/tests/json/test_default_val_throwing_initializer.cpp +++ b/tests/json/test_default_val_throwing_initializer.cpp @@ -29,7 +29,11 @@ TEST(json, test_default_val_throwing_initializer_is_an_error_when_reading) { const auto res = rfl::json::read(R"({"retries":3})"); ASSERT_FALSE(res); EXPECT_EQ(res.error().what(), - "Value expected to be greater than or equal to 1, but got 0."); + "Could not default-construct " + "test_default_val_throwing_initializer::InvalidInitializer, which " + "is required because it contains rfl::DefaultVal. Give every field " + "a valid default value. Original error: Value expected to be " + "greater than or equal to 1, but got 0."); } TEST(json, test_default_val_throwing_initializer_valid_default_is_used) { From e5fcfe8c672c3f090228d28d5e4ec2fd4146bead Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 09:58:21 +0100 Subject: [PATCH 06/10] Construct the first alternative of rfl::Variant in place rfl::Variant() default-constructed its first alternative as a temporary and moved it into its storage. Construct it in place instead, which saves the move. This matters for the next commit: when a struct is read, the parser can defer the check of a Validator whose default value is invalid, but only if the Validator is constructed inside the struct. A temporary is not, so a struct with an rfl::Variant or rfl::TaggedUnion field whose first alternative holds such a Validator could not be read. Co-Authored-By: Claude Opus 5.5 --- include/rfl/Variant.hpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/include/rfl/Variant.hpp b/include/rfl/Variant.hpp index 018edca72..e9ab9d772 100644 --- a/include/rfl/Variant.hpp +++ b/include/rfl/Variant.hpp @@ -45,11 +45,10 @@ class Variant { public: /// @brief Default-constructs the Variant, initializing it with the first /// alternative type. - /// @details The first alternative type is default-constructed and stored in - /// the variant. + /// @details The first alternative type is default-constructed in place. Variant() : index_(IndexType()), data_(DataType()) { using FirstAlternative = internal::nth_element_t<0, AlternativeTypes...>; - move_from_type(FirstAlternative()); + new (data_.data()) FirstAlternative(); } /// @brief Copy-constructs the Variant from another Variant. From c62804bf4179183d648fde2c4abda9dbe446b9cc Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 09:59:01 +0100 Subject: [PATCH 07/10] Read structs with defaults that hold a Validator without a valid default A struct that contains an rfl::DefaultVal, or that is read with rfl::DefaultIfMissing, is default-constructed before it is read, so that missing fields keep their default values. If a Validator in it has no valid default value, as in struct Person { rfl::DefaultVal name = "Homer Simpson"; rfl::Validator, rfl::Size>> favorite_foods; }; T{} throws, and the struct could not be read at all, even when the input contains the field (upstream issue `#700`). C++ has no way to get the default member initializer of one field without constructing the whole aggregate, and Validator() can only tell that it runs inside a read from state outside of it. So: - read_struct_with_default still tries T{} first, exactly as before. Only if that throws does it construct T{} again, in the same storage, while an internal::DeferredDefaults is active on this thread. - Validator() validates as before. Only if validation fails does it look for an active DeferredDefaults. If the Validator lies inside the storage of the struct being constructed, it keeps T() and records its address and error instead of throwing. Validators anywhere else, such as temporaries, on the heap or in a nested read, throw as before. - After reading, a record inside a field that the input contains is harmless, because the reader replaces such a field as a whole. A record inside a missing field is the error "Field named 'x' not found, and its default value is invalid: ...". A record outside any field (not expected to happen) is the default-construction error. - Once T{} needed this, later reads of T skip the first, throwing try. T is constructed with placement new rather than returned from a helper, because a trivially copyable T may be copied on return, which would move the recorded Validators. Outside of read, Validator() and T{} behave as before. A struct whose T{} does not throw is read by the same steps as before. The test of fork PR 11 used structs that can now be read. They now get invalid default values that are moved into the struct, which cannot be deferred. Co-Authored-By: Claude Opus 5.5 --- include/rfl/Validator.hpp | 27 +- include/rfl/internal/DeferredDefaults.hpp | 82 +++++ include/rfl/parsing/NamedTupleParser.hpp | 14 +- include/rfl/parsing/Parser_default.hpp | 145 ++++++++- include/rfl/parsing/ViewReaderWithDefault.hpp | 2 + ...ReaderWithDefaultAndStrippedFieldNames.hpp | 2 + tests/cli/test_default_val_with_validator.cpp | 55 ++++ .../test_default_val_with_validator.cpp | 65 ++++ tests/json/test_default_construct_error.cpp | 8 +- .../json/test_default_val_with_validator.cpp | 216 +++++++++++++ ...test_default_val_with_validator_limits.cpp | 304 ++++++++++++++++++ 11 files changed, 896 insertions(+), 24 deletions(-) create mode 100644 include/rfl/internal/DeferredDefaults.hpp create mode 100644 tests/cli/test_default_val_with_validator.cpp create mode 100644 tests/generic/test_default_val_with_validator.cpp create mode 100644 tests/json/test_default_val_with_validator.cpp create mode 100644 tests/json/test_default_val_with_validator_limits.cpp diff --git a/include/rfl/Validator.hpp b/include/rfl/Validator.hpp index 9be58bf61..0cacc59d5 100644 --- a/include/rfl/Validator.hpp +++ b/include/rfl/Validator.hpp @@ -9,7 +9,9 @@ #include "AllOf.hpp" #include "Result.hpp" +#include "internal/DeferredDefaults.hpp" #include "internal/HasValidation.hpp" +#include "internal/cold.hpp" namespace rfl { @@ -52,8 +54,10 @@ struct Validator { } /// Default constructor - validates a default-constructed T. - /// @throws std::exception if validation of the default value fails - Validator() : value_(ValidationType::validate(T()).value()) {} + /// @throws std::exception if validation of the default value fails, unless + /// the parser is default-constructing a struct to read into, see + /// internal::DeferredDefaults. + Validator() : value_(valid_or_deferred(this)) {} /// Move constructor. /// @param _other The validator to move from @@ -197,6 +201,25 @@ struct Validator { Validator(AlreadyValidated, T&& _value) : value_(std::move(_value)) {} + /// Returns the validated default value. If it is invalid, this throws, + /// unless the parser defers its validation, see internal::DeferredDefaults. + /// _self is the Validator under construction. It is not a pointer to const, + /// because GCC would then warn that it is used uninitialized. + static T valid_or_deferred(void* _self) { + auto res = ValidationType::validate(T()); + if (!res) [[unlikely]] { + return deferred_or_throw(_self, res.error()); + } + return *std::move(res); + } + + RFL_COLD static T deferred_or_throw(void* _self, const Error& _err) { + if (!internal::DeferredDefaults::defer(_self, _err)) { + Result(error(_err)).value(); + } + return T(); + } + /// Wraps the result of a validation without throwing if it failed. static Result wrap_validated(Result&& _res) { return std::move(_res).transform( diff --git a/include/rfl/internal/DeferredDefaults.hpp b/include/rfl/internal/DeferredDefaults.hpp new file mode 100644 index 000000000..5ac3f3dfc --- /dev/null +++ b/include/rfl/internal/DeferredDefaults.hpp @@ -0,0 +1,82 @@ +#ifndef RFL_INTERNAL_DEFERREDDEFAULTS_HPP_ +#define RFL_INTERNAL_DEFERREDDEFAULTS_HPP_ + +#include +#include +#include + +#include "../Result.hpp" + +namespace rfl::internal { + +/// Parser::read_struct_with_default default-constructs the struct it reads +/// into, to obtain the default values of the fields that are missing from the +/// input. If an rfl::Validator in it has an invalid default value, that +/// throws, even if the input contains the field. The parser then constructs +/// the struct again while a DeferredDefaults is active on this thread. A +/// Validator inside the struct then keeps its invalid default value instead +/// of throwing and is recorded here, so that the parser can check after +/// reading that the value has been replaced by one from the input. Validators +/// anywhere else, such as temporaries or on the heap, throw as usual. +class DeferredDefaults { + public: + struct Record { + /// The address of the Validator. + const void* address; + + /// Why its default value is invalid. + Error error; + }; + + /// Makes _deferred the active DeferredDefaults of this thread for as long + /// as the Scope lives. + class Scope { + public: + explicit Scope(DeferredDefaults* _deferred) noexcept : prev_(active()) { + active() = _deferred; + } + + ~Scope() { active() = prev_; } + + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + + private: + DeferredDefaults* prev_; + }; + + /// _begin and _size describe the storage of the struct. + DeferredDefaults(const void* _begin, size_t _size) noexcept + : begin_(static_cast(_begin)), + end_(begin_ + _size) {} + + /// Called by rfl::Validator() if its default value is invalid. Returns + /// whether the Validator keeps the value instead of throwing. + static bool defer(const void* _address, const Error& _error) { + auto* deferred = active(); + const auto* p = static_cast(_address); + const auto less = std::less(); + if (!deferred || less(p, deferred->begin_) || !less(p, deferred->end_)) { + return false; + } + deferred->records_.push_back(Record{_address, _error}); + return true; + } + + const std::vector& records() const noexcept { return records_; } + + private: + static DeferredDefaults*& active() noexcept { + static thread_local DeferredDefaults* deferred = nullptr; + return deferred; + } + + private: + const unsigned char* begin_; + const unsigned char* end_; + std::vector records_; +}; + +} // namespace rfl::internal + +#endif diff --git a/include/rfl/parsing/NamedTupleParser.hpp b/include/rfl/parsing/NamedTupleParser.hpp index 835e7abc7..0d1d4a311 100644 --- a/include/rfl/parsing/NamedTupleParser.hpp +++ b/include/rfl/parsing/NamedTupleParser.hpp @@ -177,23 +177,24 @@ struct NamedTupleParser { * @param _r The reader to use. * @param _var The input variable to read from. * @param _view The view to read into. + * @param _found If not null, receives which fields the input contains. * @return An optional error. */ static std::optional read_view_with_default( - const R& _r, const InputVarType& _var, - NamedTuple* _view) noexcept { + const R& _r, const InputVarType& _var, NamedTuple* _view, + std::array* _found = nullptr) noexcept { if constexpr (_no_field_names) { auto arr = _r.to_array(_var); if (!arr) [[unlikely]] { return arr.error(); } - return read_object_or_array_with_default(_r, *arr, _view); + return read_object_or_array_with_default(_r, *arr, _view, _found); } else { auto obj = _r.to_object(_var); if (!obj) [[unlikely]] { return obj.error(); } - return read_object_or_array_with_default(_r, *obj, _view); + return read_object_or_array_with_default(_r, *obj, _view, _found); } } @@ -436,7 +437,7 @@ struct NamedTupleParser { static std::optional read_object_or_array_with_default( const R& _r, const InputObjectOrArrayType& _obj_or_arr, - NamedTupleType* _view) noexcept { + NamedTupleType* _view, std::array* _found) noexcept { FieldErrors errors; const auto reader = ViewReaderWithDefaultType(&_r, _view, &errors); if constexpr (_no_field_names) { @@ -450,6 +451,9 @@ struct NamedTupleParser { return err; } } + if (_found) [[unlikely]] { + *_found = reader.found(); + } if constexpr (internal::has_default_val_v && !internal::default_if_missing_v) { if (!should_stop_on_field_error(errors)) { diff --git a/include/rfl/parsing/Parser_default.hpp b/include/rfl/parsing/Parser_default.hpp index 91cd540d6..8cefde32b 100644 --- a/include/rfl/parsing/Parser_default.hpp +++ b/include/rfl/parsing/Parser_default.hpp @@ -1,12 +1,20 @@ #ifndef RFL_PARSING_PARSER_DEFAULT_HPP_ #define RFL_PARSING_PARSER_DEFAULT_HPP_ +#include +#include +#include #include +#include +#include +#include #include +#include #include #include "../Result.hpp" #include "../always_false.hpp" +#include "../internal/DeferredDefaults.hpp" #include "../internal/HasFromReflectionMethod.hpp" #include "../internal/always_inline.hpp" #include "../internal/cold.hpp" @@ -25,6 +33,7 @@ #include "../internal/to_ptr_named_tuple.hpp" #include "../to_view.hpp" #include "AreReaderAndWriter.hpp" +#include "FieldErrors.hpp" #include "MapParser.hpp" #include "Parent.hpp" #include "ParserArray.hpp" @@ -71,6 +80,8 @@ #include "schema/Type.hpp" #include "schemaful/IsSchemafulReader.hpp" #include "schemaful/IsSchemafulWriter.hpp" +#include "to_single_error_message.hpp" +#include "wrap_error.hpp" namespace rfl::parsing { @@ -721,25 +732,129 @@ struct Parser { /// so we only use it when the DefaultIfMissing preprocessor is added. static Result read_struct_with_default(const R& _r, const InputVarType& _var) { + // T is constructed in place, so that the Validators that deferred records + // stay where they are: a trivially copyable T returned from a function + // may be copied. + alignas(T) unsigned char buf[sizeof(T)]; + internal::DeferredDefaults deferred(&buf, sizeof(T)); + std::unique_ptr t; try { - // This might fail, for instance if the default value of a field does not - // satisfy its validator. The outer handler is for this line only. - auto t = T{}; + t.reset(default_construct(&buf, &deferred)); + } catch (std::exception& e) { + return error(default_construction_error(e.what())); + } + try { + auto view = ProcessorsType::template process(to_view(*t)); + using ViewType = decltype(view); + auto found = std::array{}; + const bool any_deferred = !deferred.records().empty(); + auto err = + Parser::read_view_with_default( + _r, _var, &view, any_deferred ? &found : nullptr); + if (!err && any_deferred) [[unlikely]] { + err = check_deferred_defaults( + deferred, view, found, + std::make_index_sequence()); + } + if (err) [[unlikely]] { + return error(std::move(*err)); + } + return std::move(*t); + } catch (std::exception& e) { + return error(e.what()); + } + } + + struct Destroy { + void operator()(T* _ptr) const { _ptr->~T(); } + }; + + /// Constructs T{} in _buf. If that throws, for instance because the default + /// value of a Validator is invalid, we try again with such Validators + /// deferred to _deferred (see internal::DeferredDefaults). + static T* default_construct(void* _buf, + internal::DeferredDefaults* _deferred) { + // Whether that has been necessary before, so that the first try would + // throw again. This is only a shortcut, so relaxed is enough. + static std::atomic defer_right_away = false; + if (!defer_right_away.load(std::memory_order_relaxed)) { try { - auto view = ProcessorsType::template process(to_view(t)); - using ViewType = decltype(view); - auto err = - Parser::read_view_with_default( - _r, _var, &view); - if (err) [[unlikely]] { - return error(std::move(*err)); + return ::new (_buf) T{}; + } catch (std::exception&) { + } + } + const auto scope = internal::DeferredDefaults::Scope(_deferred); + auto* ptr = ::new (_buf) T{}; + if (!_deferred->records().empty()) { + defer_right_away.store(true, std::memory_order_relaxed); + } + return ptr; + } + + /// Returns an error if an invalid default value that has been deferred is + /// still part of the result. This relies on every field that the input + /// contains having been assigned a value that was read as a whole (see + /// ViewReaderWithDefault and ViewReaderWithDefaultAndStrippedFieldNames), + /// which replaces any invalid default value inside it. A field that is + /// missing from the input keeps its default value. + template + static std::optional check_deferred_defaults( + const internal::DeferredDefaults& _deferred, const ViewType& _view, + const std::array& _found, + std::index_sequence<_is...>) { + constexpr size_t none = sizeof...(_is); + constexpr auto names = std::array{ + tuple_element_t<_is, typename ViewType::Fields>::name()...}; + FieldErrors errors; + auto reported = std::array{}; + for (const auto& record : _deferred.records()) { + const auto* address = static_cast(record.address); + // [[no_unique_address]] lets a field be placed in the tail padding of + // another one, so the address can be inside both. It then belongs to + // the one that starts later. + size_t field = none; + const unsigned char* field_begin = nullptr; + const auto find_field = [&]( + std::integral_constant) { + using Type = + typename tuple_element_t<_i, typename ViewType::Fields>::Type; + if constexpr (std::is_pointer_v && + !std::is_empty_v>) { + const auto* begin = + internal::ptr_cast(rfl::get<_i>(_view)); + const auto* end = begin + sizeof(std::remove_pointer_t); + const auto less = std::less(); + if (!less(address, begin) && less(address, end) && + (field == none || less(field_begin, begin))) { + field = _i; + field_begin = begin; + } } - return t; - } catch (std::exception& e) { - return error(e.what()); + }; + (find_field(std::integral_constant{}), ...); + if (field == none) [[unlikely]] { + // Not inside any field, so we cannot tell what happened to it. + return default_construction_error(record.error.what().c_str()); } - } catch (std::exception& e) { - return error(default_construction_error(e.what())); + if (!_found[field] && !reported[field]) { + reported[field] = true; + const auto name = std::string(names[field]); + errors.emplace_back(make_nested_error( + name, "Field named '" + name + + "' not found, and its default value is invalid: " + + record.error.what())); + if (should_stop_on_field_error(errors)) { + break; + } + } + } + if constexpr (internal::fail_fast_v) { + return std::move(errors).error(); + } else { + if (errors.empty()) { + return std::nullopt; + } + return to_single_error(std::move(errors)); } } diff --git a/include/rfl/parsing/ViewReaderWithDefault.hpp b/include/rfl/parsing/ViewReaderWithDefault.hpp index 5faf08f21..5616b481e 100644 --- a/include/rfl/parsing/ViewReaderWithDefault.hpp +++ b/include/rfl/parsing/ViewReaderWithDefault.hpp @@ -87,6 +87,8 @@ class ViewReaderWithDefault { _errors->emplace_back(wrap_field_error(name, std::move(res).error())); return; } + // Parser::check_deferred_defaults relies on the field being replaced as + // a whole. if constexpr (std::is_pointer_v) { move_to(rfl::get(*_view), &(*res)); } else { diff --git a/include/rfl/parsing/ViewReaderWithDefaultAndStrippedFieldNames.hpp b/include/rfl/parsing/ViewReaderWithDefaultAndStrippedFieldNames.hpp index 264380265..766be47fe 100644 --- a/include/rfl/parsing/ViewReaderWithDefaultAndStrippedFieldNames.hpp +++ b/include/rfl/parsing/ViewReaderWithDefaultAndStrippedFieldNames.hpp @@ -90,6 +90,8 @@ class ViewReaderWithDefaultAndStrippedFieldNames { _errors->emplace_back(wrap_field_error(name, std::move(res).error())); return; } + // Parser::check_deferred_defaults relies on the field being replaced as + // a whole. if constexpr (std::is_pointer_v) { move_to(rfl::get(*_view), &(*res)); } else { diff --git a/tests/cli/test_default_val_with_validator.cpp b/tests/cli/test_default_val_with_validator.cpp new file mode 100644 index 000000000..d3fe17e54 --- /dev/null +++ b/tests/cli/test_default_val_with_validator.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include +#include +#include + +namespace test_default_val_with_validator { + +using Path = std::vector; + +std::vector paths(const rfl::Error& _err) { + std::vector result; + for (const auto& detail : _err.details()) { + result.push_back(detail.path); + } + return result; +} + +using Positive = rfl::Validator>; + +struct Config { + rfl::DefaultVal port = 8080; + rfl::Validator>> host_name; + rfl::DefaultVal max_retries; +}; + +TEST(cli, test_default_val_with_validator) { + const char* args[] = {"program", "--host-name=srv", "--max-retries=3"}; + const auto res = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->port.value(), 8080); + EXPECT_EQ(res->host_name.value(), "srv"); + EXPECT_EQ(res->max_retries.value().value(), 3); +} + +TEST(cli, test_default_val_with_validator_invalid_default) { + const char* args[] = {"program", "--host-name=srv"}; + const auto res = rfl::cli::read(2, const_cast(args)); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field named 'max-retries' not found, and its default value is " + "invalid: Value expected to be greater than or equal to 1, but " + "got 0."); + EXPECT_EQ(paths(res.error()), std::vector({{"max-retries"}})); +} + +TEST(cli, test_default_val_with_validator_required) { + const char* args[] = {"program", "--max-retries=3"}; + const auto res = rfl::cli::read(2, const_cast(args)); + ASSERT_FALSE(res); + EXPECT_EQ(paths(res.error()), std::vector({{"host-name"}})); +} + +} // namespace test_default_val_with_validator diff --git a/tests/generic/test_default_val_with_validator.cpp b/tests/generic/test_default_val_with_validator.cpp new file mode 100644 index 000000000..d150c07fe --- /dev/null +++ b/tests/generic/test_default_val_with_validator.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include +#include + +namespace test_default_val_with_validator { + +using Path = std::vector; + +std::vector paths(const rfl::Error& _err) { + std::vector result; + for (const auto& detail : _err.details()) { + result.push_back(detail.path); + } + return result; +} + +using Foods = + rfl::Validator, rfl::Size>>; +using Positive = rfl::Validator>; + +struct Person { + rfl::DefaultVal name = "Homer Simpson"; + Foods favorite_foods; + rfl::DefaultVal age; +}; + +rfl::Generic foods() { + return rfl::Generic( + rfl::Generic::Array({rfl::Generic(std::string("donuts"))})); +} + +TEST(generic, test_default_val_with_validator) { + auto obj = rfl::Generic::Object(); + obj["favorite_foods"] = foods(); + obj["age"] = rfl::Generic(int64_t{39}); + const auto res = rfl::from_generic(rfl::Generic(std::move(obj))); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->name.value(), "Homer Simpson"); + EXPECT_EQ(res->favorite_foods->at(0), "donuts"); + EXPECT_EQ(res->age.value().value(), 39); +} + +TEST(generic, test_default_val_with_validator_invalid_default) { + auto obj = rfl::Generic::Object(); + obj["favorite_foods"] = foods(); + const auto res = rfl::from_generic(rfl::Generic(std::move(obj))); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field named 'age' not found, and its default value is invalid: " + "Value expected to be greater than or equal to 1, but got 0."); + EXPECT_EQ(paths(res.error()), std::vector({{"age"}})); +} + +TEST(generic, test_default_val_with_validator_default_if_missing) { + auto obj = rfl::Generic::Object(); + obj["age"] = rfl::Generic(int64_t{3}); + const auto res = rfl::from_generic( + rfl::Generic(std::move(obj))); + ASSERT_FALSE(res); + EXPECT_EQ(paths(res.error()), std::vector({{"favorite_foods"}})); +} + +} // namespace test_default_val_with_validator diff --git a/tests/json/test_default_construct_error.cpp b/tests/json/test_default_construct_error.cpp index 4157eadd9..e2586d8fe 100644 --- a/tests/json/test_default_construct_error.cpp +++ b/tests/json/test_default_construct_error.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -11,9 +12,12 @@ namespace test_default_construct_error { using Foods = rfl::Validator, rfl::Size>>; +// The invalid default values are constructed outside of the struct and then +// moved into it, so reading cannot defer them (see +// test_default_val_with_validator_limits.cpp). struct Person { rfl::DefaultVal name = "Homer Simpson"; - Foods favorite_foods; + rfl::DefaultVal favorite_foods = Foods(); }; struct PersonWithValidDefault { @@ -23,7 +27,7 @@ struct PersonWithValidDefault { struct Pet { std::string name; - Foods favorite_foods; + std::optional favorite_foods = Foods(); }; struct Family { diff --git a/tests/json/test_default_val_with_validator.cpp b/tests/json/test_default_val_with_validator.cpp new file mode 100644 index 000000000..8ac1d1ff3 --- /dev/null +++ b/tests/json/test_default_val_with_validator.cpp @@ -0,0 +1,216 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace test_default_val_with_validator { + +using Path = std::vector; + +using Foods = + rfl::Validator, rfl::Size>>; +using Positive = rfl::Validator>; + +const std::string foods_error = + "Size validation failed: Value expected to be greater than or equal to 1, " + "but got 0."; +const std::string positive_error = + "Value expected to be greater than or equal to 1, but got 0."; + +struct Person { + rfl::DefaultVal name = "Homer Simpson"; + Foods favorite_foods; +}; + +TEST(json, test_default_val_with_validator) { + const auto homer = + rfl::json::read(R"({"favorite_foods": ["donuts", "pizza"]})"); + ASSERT_TRUE(homer) << homer.error().what(); + EXPECT_EQ(homer->name.value(), "Homer Simpson"); + EXPECT_EQ(homer->favorite_foods.value(), + std::vector({"donuts", "pizza"})); + + const auto empty = rfl::json::read(R"({})"); + ASSERT_FALSE(empty); + EXPECT_EQ(empty.error().what(), "Field named 'favorite_foods' not found."); + + const auto invalid = rfl::json::read(R"({"favorite_foods": []})"); + ASSERT_FALSE(invalid); + EXPECT_EQ(invalid.error().what(), + "Failed to parse field 'favorite_foods': " + foods_error); + + // Reading does not make the default value valid elsewhere. + EXPECT_THROW((void)Person{}, std::exception); + EXPECT_THROW((void)Foods{}, std::exception); +} + +struct Reversed { + Foods favorite_foods; + rfl::DefaultVal name = "Homer Simpson"; +}; + +TEST(json, test_default_val_with_validator_reversed) { + const auto res = rfl::json::read( + R"({"name": "Bart", "favorite_foods": ["pizza"]})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(rfl::json::write(*res), + R"({"favorite_foods":["pizza"],"name":"Bart"})"); +} + +// A missing field keeps its default value, so with DefaultIfMissing or +// DefaultVal that value must be valid. +struct WithDefaultVal { + rfl::DefaultVal id; + rfl::DefaultVal retries; +}; + +TEST(json, test_default_val_with_validator_missing) { + const auto with_dim = + rfl::json::read(R"({})"); + ASSERT_FALSE(with_dim); + EXPECT_EQ(with_dim.error().what(), + "Field named 'favorite_foods' not found, and its default value is " + "invalid: " + + foods_error); + ASSERT_EQ(with_dim.error().details().size(), 1); + EXPECT_EQ(with_dim.error().details()[0].path, Path({"favorite_foods"})); + + const auto present = rfl::json::read( + R"({"favorite_foods": ["donuts"]})"); + ASSERT_TRUE(present) << present.error().what(); + EXPECT_EQ(present->name.value(), "Homer Simpson"); + + const auto missing = rfl::json::read(R"({"id": 1})"); + ASSERT_FALSE(missing); + EXPECT_EQ(missing.error().what(), + "Field named 'retries' not found, and its default value is " + "invalid: " + + positive_error); + + const auto res = rfl::json::read(R"({"retries": 3})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->retries.value().value(), 3); +} + +struct Family { + rfl::DefaultVal town = "Springfield"; + Person head; + std::vector children; +}; + +TEST(json, test_default_val_with_validator_nested) { + const auto res = rfl::json::read( + R"({"head": {"favorite_foods": ["donuts"]}, + "children": [{"name": "Bart", "favorite_foods": ["pizza"]}]})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->head.name.value(), "Homer Simpson"); + EXPECT_EQ(res->children.at(0).favorite_foods->at(0), "pizza"); + + const auto missing = + rfl::json::read(R"({"children": []})"); + ASSERT_FALSE(missing); + EXPECT_EQ(missing.error().what(), + "Field named 'head' not found, and its default value is invalid: " + + foods_error); + + // The nested struct is read as a whole, so its own missing field is + // reported, with the full path. + const auto inner_missing = + rfl::json::read(R"({"head": {}})"); + ASSERT_FALSE(inner_missing); + ASSERT_EQ(inner_missing.error().details().size(), 1); + EXPECT_EQ(inner_missing.error().details()[0].path, + Path({"head", "favorite_foods"})); +} + +struct Flat { + Positive f2; +}; + +struct Containers { + rfl::DefaultVal id; + std::array array; + std::optional optional; + std::variant variant; + rfl::TaggedUnion<"type", Person, Flat> tagged; + rfl::Flatten flat; + rfl::Rename<"renamed", Positive> rename; + rfl::ExtraFields extra; +}; + +TEST(json, test_default_val_with_validator_containers) { + const auto res = rfl::json::read( + R"({"array": [1, 2], "variant": 3, + "tagged": {"type": "Person", "favorite_foods": ["donuts"]}, + "f2": 4, "renamed": 5, "other": 6})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(rfl::json::write(*res), + R"({"id":0,"array":[1,2],"variant":3,)" + R"("tagged":{"type":"Person","name":"Homer Simpson",)" + R"("favorite_foods":["donuts"]},"f2":4,"renamed":5,"other":6})"); + + const auto missing = rfl::json::read( + R"({"array": [1, 2], "variant": 3, "f2": 4, "renamed": 5})"); + ASSERT_FALSE(missing); + EXPECT_EQ(missing.error().what(), + "Field named 'tagged' not found, and its default value is " + "invalid: " + + foods_error); + + const auto invalid_extra = rfl::json::read( + R"({"array": [1, 2], "variant": 3, + "tagged": {"type": "Flat", "f2": 1}, + "f2": 4, "renamed": 5, "other": 0})"); + ASSERT_FALSE(invalid_extra); + EXPECT_EQ(invalid_extra.error().what(), + "Failed to parse field 'other': " + positive_error); +} + +TEST(json, test_default_val_with_validator_no_field_names) { + const auto res = rfl::json::read( + R"(["Bart", ["pizza"]])"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->name.value(), "Bart"); + + const auto missing = + rfl::json::read( + R"(["Bart"])"); + ASSERT_FALSE(missing); + EXPECT_EQ(missing.error().what(), + "Field named 'favorite_foods' not found, and its default value is " + "invalid: " + + foods_error); +} + +struct Snake { + rfl::DefaultVal user_id; + Positive retry_count; +}; + +TEST(json, test_default_val_with_validator_processors) { + using Processors = + rfl::Processors>; + const auto res = rfl::json::read( + R"({"type": "Snake", "retryCount": 2})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->retry_count.value(), 2); + + const auto missing = + rfl::json::read( + R"({"type": "Snake"})"); + ASSERT_FALSE(missing); + EXPECT_EQ(missing.error().details().at(0).path, Path({"retryCount"})); + + const auto fail_fast = + rfl::json::read(R"({})"); + ASSERT_FALSE(fail_fast); + EXPECT_EQ(fail_fast.error().details().size(), 1); +} + +} // namespace test_default_val_with_validator diff --git a/tests/json/test_default_val_with_validator_limits.cpp b/tests/json/test_default_val_with_validator_limits.cpp new file mode 100644 index 000000000..e3f27fdb9 --- /dev/null +++ b/tests/json/test_default_val_with_validator_limits.cpp @@ -0,0 +1,304 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// What does not change, and the limits of reading structs that contain a +// Validator without a valid default value. +namespace test_default_val_with_validator_limits { + +using Path = std::vector; + +using Foods = + rfl::Validator, rfl::Size>>; +using Positive = rfl::Validator>; + +const std::string positive_error = + "Value expected to be greater than or equal to 1, but got 0."; + +std::string construction_error(const std::string& _type) { + return "Could not default-construct " + "test_default_val_with_validator_limits::" + + _type + + ", which is required because it contains rfl::DefaultVal. Give every " + "field a valid default value. Original error: " + + positive_error; +} + +struct Person { + rfl::DefaultVal name = "Homer Simpson"; + Foods favorite_foods; +}; + +// A default member initializer that catches the exception of a Validator +// still sees it, whether the struct can be constructed or not. +inline Positive port_or_fallback() { + try { + return Positive(); + } catch (std::exception&) { + return Positive(8080); + } +} + +struct Fallback { + rfl::DefaultVal host = "localhost"; + Positive port = port_or_fallback(); +}; + +struct FallbackAndInvalid { + Positive port = port_or_fallback(); + Positive retries; +}; + +TEST(json, test_default_val_with_validator_limits_fallback) { + const auto res = rfl::json::read(R"({})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(rfl::json::write(*res), R"({"host":"localhost","port":8080})"); + + const auto res2 = rfl::json::read( + R"({"retries": 2})"); + ASSERT_TRUE(res2) << res2.error().what(); + EXPECT_EQ(rfl::json::write(*res2), R"({"port":8080,"retries":2})"); +} + +// A read inside a default member initializer has its own invalid default +// values deferred, not those of the struct around it. +struct WithNestedRead { + Person person = + rfl::json::read(R"({"favorite_foods": ["beer"]})").value(); + rfl::DefaultVal retries; +}; + +TEST(json, test_default_val_with_validator_limits_nested_read) { + const auto res = rfl::json::read( + R"({"retries": 2})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->person.favorite_foods->at(0), "beer"); + EXPECT_EQ(res->retries.value().value(), 2); +} + +// Validators that are default-constructed on other threads still throw. +TEST(json, test_default_val_with_validator_limits_threads) { + std::atomic done = false; + std::atomic constructed = 0; + auto other = std::thread([&] { + while (!done) { + try { + Foods foods; + ++constructed; + } catch (std::exception&) { + } + } + }); + auto readers = std::vector(); + std::atomic failed_reads = 0; + for (int i = 0; i < 2; ++i) { + readers.emplace_back([&] { + for (int j = 0; j < 2000; ++j) { + if (!rfl::json::read(R"({"favorite_foods": ["donuts"]})")) { + ++failed_reads; + } + } + }); + } + for (auto& reader : readers) { + reader.join(); + } + done = true; + other.join(); + EXPECT_EQ(failed_reads, 0); + EXPECT_EQ(constructed, 0); +} + +// The regressions found in the review of an earlier attempt to fix upstream +// issue #700. +struct WithMap { + rfl::DefaultVal x; + std::map m; +}; + +struct NoDefaultConstructor { + NoDefaultConstructor(int _v) : v_(_v) {} + using ReflectionType = int; + int reflection() const { return v_; } + int v_; +}; + +struct NotDefaultConstructible { + NoDefaultConstructor n; + Positive p; +}; + +static_assert(!std::is_default_constructible_v); + +struct ConstValidator { + const rfl::Validator> age; + rfl::DefaultVal name = "Maggie"; +}; + +TEST(json, test_default_val_with_validator_limits_earlier_regressions) { + EXPECT_FALSE(rfl::json::read(R"({"m": {"0": "zero"}})")); + const auto map = rfl::json::read(R"({"m": {"1": "one"}})"); + ASSERT_TRUE(map) << map.error().what(); + EXPECT_EQ(map->m.begin()->first.value(), 1); + + const auto ndc = + rfl::json::read(R"({"n": 3, "p": 4})"); + ASSERT_TRUE(ndc) << ndc.error().what(); + EXPECT_EQ(ndc->p.value(), 4); + + const auto con = rfl::json::read(R"({"age": 8})"); + ASSERT_TRUE(con) << con.error().what(); + EXPECT_EQ(rfl::json::write(*con), R"({"age":8,"name":"Maggie"})"); +} + +// A class that is not reflectable, kept out of reading by rfl::Skip. +class Cache { + public: + int hits() const { return hits_; } + + private: + int hits_ = 0; + std::string last_; +}; + +struct WithCache { + rfl::DefaultVal port = 8080; + rfl::Skip cache; + Positive retries; +}; + +struct Schema { + rfl::DefaultVal nullable = false; + std::optional> all_of; + std::optional> properties; + std::unique_ptr items; +}; + +TEST(json, test_default_val_with_validator_limits_other_types) { + const auto cache = rfl::json::read(R"({"retries": 1})"); + ASSERT_TRUE(cache) << cache.error().what(); + EXPECT_EQ(cache->port.value(), 8080); + + const auto schema = rfl::json::read( + R"({"properties": {"a": {"nullable": true}}, "items": {}})"); + ASSERT_TRUE(schema) << schema.error().what(); + EXPECT_TRUE(schema->properties->at("a").nullable.value()); + EXPECT_FALSE(schema->items->nullable.value()); +} + +// [[no_unique_address]] can place a field in the tail padding of another. +struct InTailPadding { + rfl::Validator> a; + int8_t c = 0; +}; + +struct SharesPadding { + [[no_unique_address]] InTailPadding inner; + rfl::DefaultVal p; +}; + +TEST(json, test_default_val_with_validator_limits_no_unique_address) { + const auto res = + rfl::json::read(R"({"inner": {"a": 1, "c": 2}})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().details().at(0).path, Path({"p"})); + + const auto res2 = + rfl::json::read(R"({"inner": {"a": 1, "c": 2}, "p": 3})"); + ASSERT_TRUE(res2) << res2.error().what(); + EXPECT_EQ(res2->p.value().value(), 3); +} + +// A field in the input replaces the default value as a whole. Should a field +// ever be read into its default value instead, the invalid default of p would +// survive here. +struct Inner { + Positive p; + rfl::DefaultVal q; +}; + +struct Outer { + rfl::DefaultVal x; + Inner inner; +}; + +TEST(json, test_default_val_with_validator_limits_found_fields_are_replaced) { + const auto res = + rfl::json::read(R"({"inner": {"q": 1}})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().details().at(0).path, Path({"inner", "p"})); +} + +// Only a Validator that is default-constructed inside the struct can be +// deferred. Anywhere else, it throws as before, and so does T{}. +struct OnTheHeap { + rfl::DefaultVal x; + std::vector v = std::vector(1); +}; + +struct FromTemporary { + rfl::DefaultVal x; + rfl::DefaultVal p = Positive(); +}; + +struct InRflTuple { + rfl::DefaultVal x; + rfl::Tuple t; +}; + +TEST(json, test_default_val_with_validator_limits_not_in_place) { + const auto heap = rfl::json::read(R"({"v": [2]})"); + ASSERT_FALSE(heap); + EXPECT_EQ(heap.error().what(), construction_error("OnTheHeap")); + + const auto temp = rfl::json::read(R"({"p": 2})"); + ASSERT_FALSE(temp); + EXPECT_EQ(temp.error().what(), construction_error("FromTemporary")); + + const auto tuple = rfl::json::read(R"({"t": [2, 3]})"); + ASSERT_FALSE(tuple); + EXPECT_EQ(tuple.error().what(), construction_error("InRflTuple")); +} + +// A field that is never read keeps its default value. +struct SkipsValidator { + rfl::DefaultVal x; + rfl::Skip s; +}; + +TEST(json, test_default_val_with_validator_limits_skip) { + const auto res = rfl::json::read(R"({"x": 1})"); + ASSERT_FALSE(res); + EXPECT_EQ(res.error().what(), + "Field named 's' not found, and its default value is invalid: " + + positive_error); +} + +// Known gap: a default member initializer that copies a Validator whose +// default value is invalid. Outside of read, Copies{} throws, but the copy +// in b is not checked, so the read succeeds with an invalid value. +struct Copies { + Positive a; + rfl::DefaultVal b = a; +}; + +TEST(json, test_default_val_with_validator_limits_copy_is_not_checked) { + EXPECT_THROW((void)Copies{}, std::exception); + const auto res = rfl::json::read(R"({"a": 5})"); + ASSERT_TRUE(res) << res.error().what(); + EXPECT_EQ(res->a.value(), 5); + EXPECT_EQ(res->b.value().value(), 0); +} + +} // namespace test_default_val_with_validator_limits From f82f7ccafebf08dd5e716ab3d1456835f337468c Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 09:59:47 +0100 Subject: [PATCH 08/10] Generate a schema for structs that cannot be default-constructed To put the default values of rfl::DefaultVal fields into a JSON schema, to_schema default-constructs the struct. If that throws, for instance because a Validator has no valid default value, the exception escaped rfl::json::to_schema, and for a nested struct it called std::terminate, because NamedTupleParser::to_schema is noexcept. Leave out the default values of such a struct instead. An invalid default value would not belong in the schema anyway, and the struct can be read since the previous commit. Co-Authored-By: Claude Opus 5.5 --- include/rfl/parsing/Parser_default.hpp | 18 +++++++++++++----- .../test_default_val_with_validator_limits.cpp | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/include/rfl/parsing/Parser_default.hpp b/include/rfl/parsing/Parser_default.hpp index 8cefde32b..20a25610e 100644 --- a/include/rfl/parsing/Parser_default.hpp +++ b/include/rfl/parsing/Parser_default.hpp @@ -676,11 +676,19 @@ struct Parser { } else { using NamedTupleType = internal::processed_t; if constexpr (internal::has_default_val_v) { - auto t = U{}; - auto view = ProcessorsType::template process(to_view(t)); - (*_definitions)[name] = - Parser::to_schema( - _definitions, &view); + try { + auto t = U{}; + auto view = ProcessorsType::template process(to_view(t)); + (*_definitions)[name] = + Parser::to_schema( + _definitions, &view); + } catch (std::exception&) { + // A field has no valid default value, such as a Validator, so we + // leave out the default values. + (*_definitions)[name] = + Parser::to_schema( + _definitions); + } } else { (*_definitions)[name] = Parser::to_schema( diff --git a/tests/json/test_default_val_with_validator_limits.cpp b/tests/json/test_default_val_with_validator_limits.cpp index e3f27fdb9..43ded7ca1 100644 --- a/tests/json/test_default_val_with_validator_limits.cpp +++ b/tests/json/test_default_val_with_validator_limits.cpp @@ -197,6 +197,24 @@ TEST(json, test_default_val_with_validator_limits_other_types) { EXPECT_FALSE(schema->items->nullable.value()); } +// The JSON schema leaves out the default values of a struct that cannot be +// default-constructed. +struct WithPerson { + int id; + Person person; +}; + +TEST(json, test_default_val_with_validator_limits_to_schema) { + const auto schema = rfl::json::to_schema(); + EXPECT_NE(schema.find(R"("favorite_foods":{"type":"array")"), + std::string::npos) + << schema; + EXPECT_EQ(schema.find("Homer Simpson"), std::string::npos) << schema; + + const auto nested = rfl::json::to_schema(); + EXPECT_NE(nested.find(R"("person":{"$ref":)"), std::string::npos) << nested; +} + // [[no_unique_address]] can place a field in the tail padding of another. struct InTailPadding { rfl::Validator> a; From d8cdf8197bed2f67fd8fd69c69bf45b58e3932b5 Mon Sep 17 00:00:00 2001 From: clockblocker Date: Wed, 11 Nov 2026 10:00:18 +0100 Subject: [PATCH 09/10] Document reading fields without a valid default value Replace the section that said every field of a struct with defaults needs a valid default value. Such a field can now be read; it must not be missing. List the cases that still fail with the default-construction error and the copy that is not checked. Co-Authored-By: Claude Opus 5.5 --- docs/content/default_val.md | 34 ++++++++++++++++++++++------------ docs/content/processors.md | 9 ++++++--- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/content/default_val.md b/docs/content/default_val.md index 6b29b52ba..f9b31130a 100644 --- a/docs/content/default_val.md +++ b/docs/content/default_val.md @@ -52,41 +52,51 @@ const auto homer = rfl::json::read(R"(["Homer"])").va // homer.town == "" ``` -## All fields need a valid default value +## Fields without a valid default value -A struct that contains an `rfl::DefaultVal` is default-constructed before it is read. The fields found in the input are then written into it. This means that every field of the struct needs a valid default value, even a required field that is in the input. +A struct that contains an `rfl::DefaultVal` is default-constructed before it is read, so that the fields missing from the input keep their default values. The fields found in the input are then replaced by the values read from it. -This matters when the default value of a field's type is not valid, for instance when the field has a validator: +Some fields have no valid default value, for instance a validator that an empty vector does not satisfy: ```cpp +using Foods = rfl::Validator, rfl::Size>>; + struct Person { rfl::DefaultVal name = "Homer Simpson"; - rfl::Validator, rfl::Size>> favorite_foods; + Foods favorite_foods; }; -// Fails, even though favorite_foods is in the input. -rfl::json::read(R"({"favorite_foods":["donuts","pizza"]})"); +const auto homer = rfl::json::read(R"({"favorite_foods":["donuts","pizza"]})"); ``` -An empty vector does not satisfy `rfl::Size>`, so `Person{}` throws an exception and you get the following error: +`Person{}` throws, but `read` then constructs the struct again with the check of such a validator deferred until the input has been read. If `favorite_foods` is in the input, its invalid default value has been replaced. If it is missing, you get the error `Field named 'favorite_foods' not found.` Outside of `read`, `Person{}` still throws. + +A field whose default value is invalid must not be missing, even where a missing field would otherwise get its default value, that is, for an `rfl::DefaultVal` or with the `rfl::DefaultIfMissing` processor: + +``` +Field named 'favorite_foods' not found, and its default value is invalid: Size validation failed: Value expected to be greater than or equal to 1, but got 0. +``` + +Only a validator that is default-constructed in place inside the struct is deferred, also inside nested structs, `std::optional`, `std::variant`, `std::array` or `rfl::Variant`. While the struct is constructed, it holds its invalid value, which a default member initializer that reads it would see. The struct cannot be read, and you get the error below, if the invalid default value is constructed elsewhere and then moved into the struct (as in `rfl::DefaultVal favorite_foods = Foods();` or inside `rfl::Tuple`), if it is on the heap (as in `std::vector(1)`), or if `Person{}` throws for another reason: ``` Could not default-construct Person, which is required because it contains rfl::DefaultVal. Give every field a valid default value. Original error: Size validation failed: Value expected to be greater than or equal to 1, but got 0. ``` -To avoid this, give the field a valid default value: +One case is not detected: if a default member initializer copies a validator whose default value is invalid, as in `rfl::DefaultVal other_foods = favorite_foods;`, the copy is not checked, and it keeps the invalid value if `other_foods` is missing from the input. + +In all of these cases, you can give the field a valid default value: ```cpp struct Person { rfl::DefaultVal name = "Homer Simpson"; - rfl::Validator, rfl::Size>> favorite_foods = - std::vector({"none"}); + Foods favorite_foods = std::vector({"none"}); }; ``` -The field is still required. If `favorite_foods` is missing in the input, you get the error `Field named 'favorite_foods' not found.` The default value is only used for a missing field if you pass the `rfl::DefaultIfMissing` processor. +The field is still required. The default value is only used for a missing field if you pass the `rfl::DefaultIfMissing` processor. -The same restriction applies to every struct that is read with the `rfl::DefaultIfMissing` processor, whether it contains an `rfl::DefaultVal` or not. +All of this applies to every struct that is read with the `rfl::DefaultIfMissing` processor, whether it contains an `rfl::DefaultVal` or not. The JSON schema of a struct that cannot be default-constructed contains no default values. ## When to use diff --git a/docs/content/processors.md b/docs/content/processors.md index a431bb4e6..014c44dab 100644 --- a/docs/content/processors.md +++ b/docs/content/processors.md @@ -310,9 +310,12 @@ have gotten had you read the following JSON string: Because you have not passed a default value to town, the default value of the type is used instead. -Note that the struct is default-constructed before it is read. Every field -therefore needs a valid default value, even if the field is in the input. -This is explained in the documentation on `rfl::DefaultVal`. +Note that the struct is default-constructed before it is read. A field +whose default value is invalid, such as a validator that the default value +of its type does not satisfy, can be read, but it must not be missing: you +get the error `Field named '...' not found, and its default value is +invalid: ...`. The documentation on `rfl::DefaultVal` explains this and the +cases in which such a struct cannot be read at all. ### `rfl::DurationsAsCount` From 4c04f889ef890c10bf1916d0698993453856444f Mon Sep 17 00:00:00 2001 From: clockblocker Date: Thu, 12 Nov 2026 15:53:45 +0100 Subject: [PATCH 10/10] Do not require a caught Validator exception while validation is deferred test_default_val_with_validator_limits_fallback failed with MSVC. Its default member initializer catches the exception of a Validator and falls back to a valid value. While a struct is constructed with deferred validation, whether that exception is thrown depends on where the compiler constructs the returned Validator: outside of the struct (returned in registers under the Itanium ABI), where it throws as usual, or directly inside the struct (MSVC), where its validation is deferred like that of any other field. In the second case the read fails with an error if the field is missing from the input. The test now accepts both outcomes and checks that the read succeeds with every compiler if the input contains the field. The docs mention the case. Co-Authored-By: Claude Fable 5.2 --- docs/content/default_val.md | 2 +- ...test_default_val_with_validator_limits.cpp | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/content/default_val.md b/docs/content/default_val.md index f9b31130a..6b39decbc 100644 --- a/docs/content/default_val.md +++ b/docs/content/default_val.md @@ -77,7 +77,7 @@ A field whose default value is invalid must not be missing, even where a missing Field named 'favorite_foods' not found, and its default value is invalid: Size validation failed: Value expected to be greater than or equal to 1, but got 0. ``` -Only a validator that is default-constructed in place inside the struct is deferred, also inside nested structs, `std::optional`, `std::variant`, `std::array` or `rfl::Variant`. While the struct is constructed, it holds its invalid value, which a default member initializer that reads it would see. The struct cannot be read, and you get the error below, if the invalid default value is constructed elsewhere and then moved into the struct (as in `rfl::DefaultVal favorite_foods = Foods();` or inside `rfl::Tuple`), if it is on the heap (as in `std::vector(1)`), or if `Person{}` throws for another reason: +Only a validator that is default-constructed in place inside the struct is deferred, also inside nested structs, `std::optional`, `std::variant`, `std::array` or `rfl::Variant`. While the struct is constructed, it holds its invalid value, which a default member initializer that reads it would see. A default member initializer that catches the exception of such a validator in order to fall back to another value might not get the exception either, depending on whether the compiler constructs the validator directly inside the struct. The struct cannot be read, and you get the error below, if the invalid default value is constructed elsewhere and then moved into the struct (as in `rfl::DefaultVal favorite_foods = Foods();` or inside `rfl::Tuple`), if it is on the heap (as in `std::vector(1)`), or if `Person{}` throws for another reason: ``` Could not default-construct Person, which is required because it contains rfl::DefaultVal. Give every field a valid default value. Original error: Size validation failed: Value expected to be greater than or equal to 1, but got 0. diff --git a/tests/json/test_default_val_with_validator_limits.cpp b/tests/json/test_default_val_with_validator_limits.cpp index 43ded7ca1..97a09bb6e 100644 --- a/tests/json/test_default_val_with_validator_limits.cpp +++ b/tests/json/test_default_val_with_validator_limits.cpp @@ -41,7 +41,12 @@ struct Person { }; // A default member initializer that catches the exception of a Validator -// still sees it, whether the struct can be constructed or not. +// still sees it, as long as the struct can be default-constructed. If it +// cannot and its validation is deferred, that depends on where the compiler +// constructs the Validator that port_or_fallback() returns: outside of the +// struct, where it throws as usual (Positive is returned in registers under +// the Itanium ABI), or directly inside the struct, where its validation is +// deferred like that of any other field (MSVC). inline Positive port_or_fallback() { try { return Positive(); @@ -67,8 +72,20 @@ TEST(json, test_default_val_with_validator_limits_fallback) { const auto res2 = rfl::json::read( R"({"retries": 2})"); - ASSERT_TRUE(res2) << res2.error().what(); - EXPECT_EQ(rfl::json::write(*res2), R"({"port":8080,"retries":2})"); + if (res2) { + EXPECT_EQ(rfl::json::write(*res2), R"({"port":8080,"retries":2})"); + } else { + EXPECT_EQ(std::string(res2.error().what()), + "Field named 'port' not found, and its default value is " + "invalid: " + + positive_error); + } + + // If the input contains the field, it does not matter. + const auto res3 = rfl::json::read( + R"({"port": 443, "retries": 2})"); + ASSERT_TRUE(res3) << res3.error().what(); + EXPECT_EQ(rfl::json::write(*res3), R"({"port":443,"retries":2})"); } // A read inside a default member initializer has its own invalid default