From 2aa5d5e3c7f2f134d6fd1f9f99663266e9033aa7 Mon Sep 17 00:00:00 2001 From: EJainDev Date: Sat, 26 Sep 2026 17:11:30 -0400 Subject: [PATCH] feat(xml): add better consistency for edge cases --- README.md | 40 +++++++++++ include/serial_xml.cxx | 136 ++++++++++++++++++++++++++++++++++++++ tests/test_serial_xml.cpp | 57 ++++++++++++++++ 3 files changed, 233 insertions(+) diff --git a/README.md b/README.md index 38fae94..ea9777c 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,46 @@ As you can see, there are some parameters for configuration" - `first` -- a bool indicating whether to insert the XML header defining the file as XML. `true` means yes - `fixed_name` -- a custom name to specify for the instance being serialized. Overrides `name` annotation. +### Schema and scalar conversion rules + +`serial_xml::schema()` exposes the field-to-XML mapping inferred from the same reflection and +annotations used by `to_xml`. Each descriptor reports the C++ member name, XML name, and whether +the member is an attribute or child element. A member-level `name` annotation overrides the C++ +identifier; otherwise the identifier is used. `skip` members are absent from the schema. The type's +`name` annotation controls the root name, falling back to the type name. Child fields represented +by an automatically iterated container or an `iter` annotation are marked `repeated`. + +The schema API prepares for deserialization; it does not parse XML. The intended reader rules are: +missing required fields are errors, missing `std::optional` fields remain disengaged, duplicate +scalar attributes or child elements are errors, and repeated child elements append in document +order. A required field is one that is neither optional nor otherwise defaulted by a future reader +configuration. These policy constants are exposed as `default_missing_required_value_policy`, +`default_missing_optional_value_policy`, `default_duplicate_value_policy`, and +`default_repeated_value_policy`. + +`serial_xml::convert_scalar(text)` converts already-decoded scalar text. It supports strings, +single-byte `char`, booleans (`true`, `false`, `1`, or `0`), integral values, and floating-point +values. Numeric conversions must consume the complete string and do not trim whitespace. Invalid +or out-of-range values throw `std::invalid_argument`. Entity decoding and any XML whitespace rules +belong to the XML reader. + +For another scalar type, specialize `serial_xml::conversion` and provide a static +`T from_xml(std::string_view)` function. For example: + +```cpp +struct Temperature { double celsius; }; + +template <> +struct serial_xml::conversion { + static Temperature from_xml(std::string_view text) { + return {serial_xml::convert_scalar(text)}; + } +}; +``` + +This conversion customization is separate from `format` annotations, which only control C++ to +XML text formatting. + ### The `prettify` function A simple function to prettify (add indentation and newlines) the generated XML output. The only parameter is the output and it returns a new string with the output. diff --git a/include/serial_xml.cxx b/include/serial_xml.cxx index 87d82b1..7808812 100644 --- a/include/serial_xml.cxx +++ b/include/serial_xml.cxx @@ -1,5 +1,6 @@ module; +#include #include #include // GCC 16.1 does not have in this in the std module @@ -45,6 +46,76 @@ export constexpr cdata_ cdata; struct exclude_on_empty_ {}; export constexpr exclude_on_empty_ exclude_on_empty; +// Specialize conversion to define how scalar XML text becomes T. A +// specialization provides `static T from_xml(std::string_view)`. +export template +struct conversion; + +export enum class field_kind { attribute, child }; + +// These policies describe the behavior intended for the XML reader. They are +// metadata only until from_xml is implemented. +export enum class missing_value_policy { error, default_initialize, disengage_optional }; +export enum class duplicate_value_policy { error, use_first, use_last }; +export enum class repeated_value_policy { append_in_document_order }; + +export struct field_schema { + std::string_view member_name; + std::string_view xml_name; + field_kind kind; + bool repeated; +}; + +export struct type_schema { + std::string_view xml_name; + std::vector fields; +}; + +export inline constexpr auto default_missing_required_value_policy = missing_value_policy::error; +export inline constexpr auto default_missing_optional_value_policy = + missing_value_policy::disengage_optional; +export inline constexpr auto default_duplicate_value_policy = duplicate_value_policy::error; +export inline constexpr auto default_repeated_value_policy = + repeated_value_policy::append_in_document_order; + +// Convert one already-decoded XML text value to a scalar C++ value. XML entity +// decoding and whitespace normalization are the caller's responsibility. +export template +T convert_scalar(std::string_view text) { + using value_type = std::remove_cv_t; + + if constexpr (requires { conversion::from_xml(text); }) { + return conversion::from_xml(text); + } else if constexpr (std::same_as) { + return std::string(text); + } else if constexpr (std::same_as) { + if (text == "true" || text == "1") return true; + if (text == "false" || text == "0") return false; + throw std::invalid_argument("invalid boolean XML scalar"); + } else if constexpr (std::same_as) { + if (text.size() != 1) throw std::invalid_argument("XML char scalar must contain one byte"); + return text.front(); + } else if constexpr (std::is_integral_v) { + value_type result{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), result); + if (error != std::errc{} || end != text.data() + text.size()) { + throw std::invalid_argument("invalid integral XML scalar"); + } + return result; + } else if constexpr (std::is_floating_point_v) { + value_type result{}; + const auto [end, error] = + std::from_chars(text.data(), text.data() + text.size(), result, std::chars_format::general); + if (error != std::errc{} || end != text.data() + text.size()) { + throw std::invalid_argument("invalid floating-point XML scalar"); + } + return result; + } else { + static_assert(std::same_as, + "No serial_xml scalar conversion exists; specialize serial_xml::conversion"); + } +} + constexpr bool is_alpha(const char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } constexpr bool is_num(const char c) { return (c >= '0' && c <= '9'); } @@ -211,6 +282,14 @@ consteval bool is_stl_handled() { return false; } +template +consteval bool is_optional() { + if constexpr (get_namespace() == ^^std) { + return std::meta::template_of(std::meta::dealias(m)) == ^^std::optional; + } + return false; +} + template consteval auto get_annotations() -> structural_tuple::tuple + requires(std::is_class_v) +type_schema make_schema() { + static constexpr auto type_info = ^^T; + std::string_view root_name; + static constexpr auto type_annotations = + std::define_static_array(std::meta::annotations_of(type_info)); + + template for (constexpr auto annotation : type_annotations) { + if constexpr (std::meta::has_template_arguments(std::meta::type_of(annotation)) && + std::meta::template_of(std::meta::type_of(annotation)) == ^^::serial_xml::name) { + static constexpr auto configured_name = + std::meta::extract(annotation); + root_name = configured_name.value; + } + } + + if (root_name.empty()) { + if constexpr (std::meta::has_identifier(type_info)) { + static constexpr auto default_name = std::meta::identifier_of(type_info); + root_name = default_name; + } + } + + type_schema result{root_name, {}}; + static constexpr auto members = get_members(); + + template for (constexpr auto member : members.first) { + static constexpr auto reflected_member = member.first; + static constexpr auto member_name = std::meta::identifier_of(reflected_member); + static constexpr auto xml_name = structural_tuple::get<0>(member.second); + result.fields.push_back(field_schema{member_name, xml_name, field_kind::attribute, false}); + } + + template for (constexpr auto member : members.second) { + static constexpr auto reflected_member = member.first; + static constexpr auto member_name = std::meta::identifier_of(reflected_member); + static constexpr auto iter_names = structural_tuple::get<4>(member.second); + static constexpr auto no_iter = structural_tuple::get<1>(member.second); + static constexpr auto automatically_repeated = is_stl_handled>() && + std::ranges::range> && + !is_optional>() && + !no_iter; + static constexpr auto repeated = iter_names.first != nullptr || automatically_repeated; + static constexpr auto xml_name = structural_tuple::get<5>(member.second); + result.fields.push_back(field_schema{member_name, xml_name, field_kind::child, repeated}); + } + + return result; +} + +export template + requires(std::is_class_v) +type_schema schema() { + return make_schema(); +} + template consteval auto get_attribute_prefix() { std::string prefix; diff --git a/tests/test_serial_xml.cpp b/tests/test_serial_xml.cpp index a637cbc..2936e16 100644 --- a/tests/test_serial_xml.cpp +++ b/tests/test_serial_xml.cpp @@ -4,6 +4,17 @@ import std; import serial_xml; +struct TestTemperature { + double celsius; +}; + +template <> +struct serial_xml::conversion { + static TestTemperature from_xml(std::string_view text) { + return {serial_xml::convert_scalar(text)}; + } +}; + std::string clean_to_xml(auto obj) { return serial_xml::to_xml(obj, false); } TEST(Naming, EmptyWithName) { @@ -13,6 +24,52 @@ TEST(Naming, EmptyWithName) { ASSERT_EQ(clean_to_xml(obj), ""); } +TEST(Schema, MatchesSerializedMemberNamesAndShape) { + struct[[= serial_xml::name{"person"}]] PersonSchema { + [[= serial_xml::name{"full-name"}]] std::string name; + [[= serial_xml::attribute]] int age; + std::vector scores; + std::optional nickname; + [[= serial_xml::skip]] int internal; + }; + + const auto result = serial_xml::schema(); + ASSERT_EQ(result.xml_name, "person"); + ASSERT_EQ(result.fields.size(), 4); + EXPECT_EQ(result.fields[0].member_name, "age"); + EXPECT_EQ(result.fields[0].xml_name, "age"); + EXPECT_EQ(result.fields[0].kind, serial_xml::field_kind::attribute); + EXPECT_FALSE(result.fields[0].repeated); + EXPECT_EQ(result.fields[1].member_name, "name"); + EXPECT_EQ(result.fields[1].xml_name, "full-name"); + EXPECT_EQ(result.fields[1].kind, serial_xml::field_kind::child); + EXPECT_FALSE(result.fields[1].repeated); + EXPECT_EQ(result.fields[2].xml_name, "scores"); + EXPECT_TRUE(result.fields[2].repeated); + EXPECT_EQ(result.fields[3].xml_name, "nickname"); + EXPECT_FALSE(result.fields[3].repeated); +} + +TEST(Conversion, BuiltInScalars) { + EXPECT_EQ(serial_xml::convert_scalar("-42"), -42); + EXPECT_DOUBLE_EQ(serial_xml::convert_scalar("1.25"), 1.25); + EXPECT_TRUE(serial_xml::convert_scalar("true")); + EXPECT_FALSE(serial_xml::convert_scalar("0")); + EXPECT_EQ(serial_xml::convert_scalar("x"), 'x'); + EXPECT_EQ(serial_xml::convert_scalar("value"), "value"); +} + +TEST(Conversion, RejectsInvalidOrPartialInput) { + EXPECT_THROW(serial_xml::convert_scalar("12tail"), std::invalid_argument); + EXPECT_THROW(serial_xml::convert_scalar("yes"), std::invalid_argument); + EXPECT_THROW(serial_xml::convert_scalar("xy"), std::invalid_argument); +} + +TEST(Conversion, UsesExplicitSpecialization) { + const auto value = serial_xml::convert_scalar("21.5"); + EXPECT_DOUBLE_EQ(value.celsius, 21.5); +} + TEST(Naming, NamedAttribute) { struct NamedAttribute { [[ = serial_xml::name{"MyAttribute"}, = serial_xml::attribute ]] int x;