From d6a3e46615e1fd5aa6233fd73bf0bd605bfb2d9c Mon Sep 17 00:00:00 2001 From: EJainDev Date: Fri, 9 Oct 2026 16:25:58 +0000 Subject: [PATCH] feat: add mocking for unmodifiable classes --- README.md | 38 +++++++- examples/CMakeLists.txt | 3 + examples/mocking.cpp | 13 +++ include/serial_xml.cxx | 200 ++++++++++++++++++++++++++++---------- tests/test_serial_xml.cpp | 111 +++++++++++++++++++++ 5 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 examples/mocking.cpp diff --git a/README.md b/README.md index 38fae94..aef02e1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SerialXML is a C++26 reflection based serialization library for XML. Behaviour i * [Annotations](#annotations) * [Common Confusion Points](#common-confusion-points) * [Configuring `to_xml`](#configuring-to_xml) + * [Mocking classes](#mocking-classes) * [The `prettify` function](#the-prettify-function) * [Examples](#examples) * [Contributing](#contributing) @@ -151,8 +152,8 @@ This is the complete list of annotations: The function signature of `to_xml` is the following: ``` -template - requires(std::is_class_v) +template + requires(std::is_class_v && (std::is_void_v || std::is_class_v)) auto to_xml(const T& value, bool first = true, const std::string& fixed_name = "") -> std::string; ``` @@ -161,6 +162,39 @@ 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. +### Mocking classes + +Use `to_xml(value)` to serialize a class you cannot annotate directly: + +```cpp +class mock_vector { + public: + [[= serial_xml::attribute]] std::size_t size() const; +}; + +const std::vector values{1, 2, 3}; +std::println("{}", serial_xml::to_xml(values, false)); +// +``` + +The mock lists the fields and const getters to serialize, in output order, and supplies their XML annotations. Member identifiers must match accessible members of the actual class. Field placeholders match fields; getter placeholders match non-static const getters callable without arguments (including getters with default arguments). Mock methods need no implementation, and the mock is never constructed. Serialization reads values from the actual object, using its member types for formatting, iteration, and nested serialization. Target member annotations are replaced by the mock's annotations; nested objects use their own annotations normally. + +The root name defaults to the actual type's identifier. A `name` annotation on the +mock overrides it, and `fixed_name` overrides both. Members marked `skip` need not +exist on the target. Missing members, incompatible field/getter kinds, and +ambiguous callable const overloads produce compile-time errors. Mapping covers +direct accessible members; inherited members are not included. + +The identifier map is built once per concrete target type, such as +`std::vector`, during constant evaluation. It uses an open-addressed hash +table with at most 50% occupancy and expected O(1) member lookup after hashing the +identifier. Hash collisions are resolved by probing and comparing identifiers; +worst-case lookup is O(N). This avoids a target-member template expansion or a +linear scan of all target members for every mock member. No map or name lookup +runs during serialization. Calling `to_xml(value)` still serializes normally. + +See [the mocking example](examples/mocking.cpp). + ### 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/examples/CMakeLists.txt b/examples/CMakeLists.txt index b94f85c..2ca1edb 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -33,3 +33,6 @@ target_link_libraries(advanced_mixed PRIVATE serial_xml) add_executable(optional_types optional_types.cpp) target_link_libraries(optional_types PRIVATE serial_xml) + +add_executable(mocking mocking.cpp) +target_link_libraries(mocking PRIVATE serial_xml) diff --git a/examples/mocking.cpp b/examples/mocking.cpp new file mode 100644 index 0000000..f189167 --- /dev/null +++ b/examples/mocking.cpp @@ -0,0 +1,13 @@ +import std; +import serial_xml; + +class mock_vector { + public: + // Only the declaration and annotations matter; this method is never called. + [[= serial_xml::attribute]] std::size_t size() const; +}; + +int main() { + const std::vector values{1, 2, 3}; + std::println("{}", serial_xml::to_xml(values)); +} diff --git a/include/serial_xml.cxx b/include/serial_xml.cxx index 87d82b1..a5fc5a7 100644 --- a/include/serial_xml.cxx +++ b/include/serial_xml.cxx @@ -211,7 +211,7 @@ consteval bool is_stl_handled() { return false; } -template +template consteval auto get_annotations() -> structural_tuple::tuple, char const*, bool> { @@ -219,11 +219,11 @@ consteval auto get_annotations() bool is_attribute = false; bool is_cdata = false; - bool is_no_iter = !is_stl_handled>(); + bool is_no_iter = !is_stl_handled>(); bool is_raw = false; bool is_skip = false; - bool is_unpack = std::meta::is_class_type(value_m_t) && !std::formattable, char> && - !is_stl_handled>(); + bool is_unpack = std::meta::is_class_type(value_m_t) && + !std::formattable, char> && !is_stl_handled>(); bool has_custom_format_function = false; std::optional> iter_names; @@ -257,7 +257,7 @@ consteval auto get_annotations() has_custom_format_function = true; } } else if constexpr (std::meta::template_of(a_t) == ^^::serial_xml::iter) { - if constexpr (!std::ranges::range>) { + if constexpr (!std::ranges::range>) { throw std::logic_error( "serial_xml::iter annotation can only be applied to range types. Member name: " + std::string(std::meta::identifier_of(m))); @@ -295,9 +295,9 @@ consteval auto get_annotations() } } - if constexpr (std::ranges::range>) { + if constexpr (std::ranges::range>) { if (iter_names.has_value()) { - using m_t = std::ranges::range_value_t>; + using m_t = std::ranges::range_value_t>; is_unpack = std::is_class_v && !std::formattable; } } @@ -329,35 +329,120 @@ consteval auto get_annotations() is_exclude_on_empty}; } -template -consteval auto is_invalid_function() { - if constexpr (std::meta::is_function(m)) { - if constexpr (!(std::meta::is_constructor(m) || std::meta::is_destructor(m))) { - if constexpr (std::meta::return_type_of(m) == ^^void) { - return true; - } - } else { +consteval bool is_skipped_member(std::meta::info member) { + for (auto annotation : std::meta::annotations_of(member)) { + if (std::meta::type_of(annotation) == ^^decltype(::serial_xml::skip)) { return true; } + } + return false; +} - if constexpr (!std::meta::is_const(m)) { - return true; +// A persistent, compile-time hash table. Build once per concrete target type, then +// resolve each mock member without expanding a target-member template loop. +struct member_map_entry { + char const* identifier = nullptr; + std::meta::info member{}; + bool ambiguous = false; +}; + +consteval std::size_t identifier_hash(std::string_view identifier) { + std::size_t hash = 14695981039346656037ULL; + for (unsigned char c : identifier) { + hash = (hash ^ c) * 1099511628211ULL; + } + return hash; +} + +consteval bool is_serializable_member(std::meta::info member) { + if (std::meta::is_nonstatic_data_member(member) || std::meta::is_variable(member)) { + return true; + } + if (!std::meta::is_function(member) || std::meta::is_constructor(member) || + std::meta::is_destructor(member) || std::meta::is_static_member(member) || + !std::meta::is_const(member) || std::meta::is_rvalue_reference_qualified(member) || + std::meta::return_type_of(member) == ^^void) { + return false; + } + for (auto parameter : std::meta::parameters_of(member)) { + if (!std::meta::has_default_argument(parameter)) { + return false; } + } + return true; +} - static constexpr auto params = std::define_static_array(std::meta::parameters_of(m)); - template for (constexpr auto p : params) { - if constexpr (!std::meta::has_default_argument(p)) { - return true; - } +template +consteval auto make_string_to_field_map() { + // Includes functions as well as fields. Inaccessible members and members that + // cannot be read from a const object do not participate in serialization. + auto members = std::meta::members_of(^^T, std::meta::access_context::current()); + std::size_t count = 0; + for (auto member : members) { + if (is_serializable_member(member)) { + ++count; + } + } + std::size_t capacity = 1; + while (capacity < count * 2) { + capacity *= 2; + } + std::vector entries(capacity); + for (auto member : members) { + if (!is_serializable_member(member)) { + continue; + } + auto identifier = std::meta::identifier_of(member); + auto index = identifier_hash(identifier) & (capacity - 1); + while (entries[index].identifier != nullptr && + std::string_view(entries[index].identifier) != identifier) { + index = (index + 1) & (capacity - 1); + } + if (entries[index].identifier == nullptr) { + entries[index] = {std::define_static_string(identifier), member, false}; + } else { + entries[index].ambiguous = true; } - } else if constexpr (std::meta::is_function_template(m)) { - return true; } + return std::define_static_array(entries); +} - return false; +template +constexpr auto string_to_field_map = make_string_to_field_map(); + +template +consteval member_map_entry find_member(std::string_view identifier) { + constexpr auto entries = string_to_field_map; + auto index = identifier_hash(identifier) & (entries.size() - 1); + while (entries[index].identifier != nullptr) { + if (std::string_view(entries[index].identifier) == identifier) { + return entries[index]; + } + index = (index + 1) & (entries.size() - 1); + } + return {}; } -template +template +consteval std::meta::info resolve_member() { + if constexpr (std::meta::parent_of(member) == std::meta::dealias(^^T)) { + return member; + } else { + constexpr auto identifier = std::meta::identifier_of(member); + constexpr auto entry = find_member(identifier); + static_assert( + entry.identifier != nullptr, + "No accessible field or const getter for mock member: " + std::string(identifier)); + static_assert(!entry.ambiguous, "Ambiguous mock member: " + std::string(identifier)); + if constexpr (entry.identifier != nullptr) { + static_assert(std::meta::is_function(member) == std::meta::is_function(entry.member), + "Mock member must match a field or a const getter: " + std::string(identifier)); + } + return entry.member; + } +} + +template consteval auto get_members() { static constexpr auto members = std::define_static_array( std::meta::members_of(container, std::meta::access_context::current())); @@ -371,10 +456,10 @@ consteval auto get_members() { attribute_annotations; template for (constexpr auto m : members) { - static constexpr auto invalid_function = is_invalid_function(); - - if constexpr (!invalid_function) { - static constexpr auto m_annotations = get_annotations(); + // A skipped placeholder need not exist on the target. + if constexpr (is_serializable_member(m) && !is_skipped_member(m)) { + static constexpr auto source = resolve_member(); + static constexpr auto m_annotations = get_annotations(); // Invalid attribute combinations static_assert( @@ -805,7 +890,7 @@ void add_child(std::string& result, std::string& buffer, const auto& value) { } } -template +template requires(std::is_class_v) void to_xml(const T& value, std::string& result, std::string& buffer, bool first, const std::string& fixed_name = ""); @@ -877,7 +962,7 @@ auto handle_stl(std::string& result, std::string& buffer, const auto& value) -> return false; } -template +template requires(std::is_class_v) void to_xml(const T& value, std::string& result, std::string& buffer, bool first, const std::string& fixed_name) { @@ -885,7 +970,8 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first result += ""; } - static constexpr auto M = ^^T; + using schema_type = std::conditional_t, T, Schema>; + static constexpr auto M = std::meta::dealias(^^schema_type); static constexpr auto annotations = std::define_static_array(std::meta::annotations_of(M)); @@ -893,15 +979,21 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first buffer = fixed_name; } else { template for (constexpr auto a : annotations) { - if constexpr (std::meta::template_of(std::meta::type_of(a)) == ^^::serial_xml::name) { + if constexpr (std::meta::has_template_arguments(std::meta::type_of(a)) && + std::meta::template_of(std::meta::type_of(a)) == ^^::serial_xml::name) { static constexpr auto temp_name = std::meta::extract(a); buffer = std::string(temp_name.value); } } if (buffer.empty()) { - if constexpr (std::meta::has_identifier(M)) { - static constexpr auto temp_name = std::meta::identifier_of(M); + if constexpr (std::meta::has_identifier(std::meta::dealias(^^T))) { + static constexpr auto temp_name = std::meta::identifier_of(std::meta::dealias(^^T)); buffer = std::string(temp_name); + } else if constexpr (std::meta::has_template_arguments(std::meta::dealias(^^T))) { + static constexpr auto type_template = std::meta::template_of(std::meta::dealias(^^T)); + if constexpr (std::meta::has_identifier(type_template)) { + buffer = std::string(std::meta::identifier_of(type_template)); + } } } } @@ -909,14 +1001,14 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first std::string name{buffer}; std::format_to(std::back_inserter(result), "<{}", name); - static constexpr auto members = get_members(); + static constexpr auto members = get_members(); static constexpr auto attribute_annotations = members.first; static constexpr auto child_annotations = members.second; template for (constexpr auto m_a : attribute_annotations) { - static constexpr auto is_std = is_stl_handled(); - static constexpr auto m = m_a.first; + static constexpr auto source = resolve_member(); + static constexpr auto is_std = is_stl_handled>(); static constexpr auto m_annotations = m_a.second; static constexpr auto formatter = get_format(); @@ -932,10 +1024,10 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first std::string("Invalid XML name: '") + std::string(view_name) + "'"); if constexpr (is_std) { - handle_stl(result, buffer, - get_value(value)); + handle_stl( + result, buffer, get_value(value)); } else { - add_attribute(result, buffer, get_value(value)); + add_attribute(result, buffer, get_value(value)); } } @@ -945,9 +1037,9 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first result += '>'; template for (constexpr auto m_a : child_annotations) { - static constexpr auto is_std = is_stl_handled(); - static constexpr auto m = m_a.first; + static constexpr auto source = resolve_member(); + static constexpr auto is_std = is_stl_handled>(); static constexpr auto m_annotations = m_a.second; static constexpr auto is_cdata = structural_tuple::get<0>(m_annotations); @@ -974,17 +1066,17 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first if constexpr (iter_names.first == nullptr && is_std && !is_no_iter) { handle_stl(result, buffer, get_value(value)); + formatter>(result, buffer, get_value(value)); } else { - if constexpr (iter_names.first != nullptr && std::meta::is_class_type(value_m_t) && - std::ranges::range>) { + if constexpr (iter_names.first != nullptr && std::meta::is_class_type(value_m_t) && + std::ranges::range>) { if constexpr (!is_raw) { result.push_back('<'); result.append(iter_names.second); result.push_back('>'); } - for (const auto& item : get_value(value)) { + for (const auto& item : get_value(value)) { if constexpr (is_unpack) { to_xml(item, result, buffer, false, iter_names.first); } else { @@ -998,10 +1090,10 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first result.push_back('>'); } } else if constexpr (is_unpack) { - to_xml(get_value(value), result, buffer, false, m_name); + to_xml(get_value(value), result, buffer, false, m_name); } else { if constexpr (is_raw) { - buffer = format_value(get_value(value)); + buffer = format_value(get_value(value)); auto padded_size = get_escape_bitmask(buffer); @@ -1015,7 +1107,7 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first return original_size + copy_with_escapes(buf, buffer, padded_size); }); } else { - add_child(result, buffer, get_value(value)); + add_child(result, buffer, get_value(value)); } } } @@ -1033,8 +1125,8 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first } } -export template - requires(std::is_class_v) +export template + requires(std::is_class_v && (std::is_void_v || std::is_class_v)) auto to_xml(const T& value, bool first = true, const std::string& fixed_name = "") -> std::string { std::string result; std::string buffer; @@ -1042,7 +1134,7 @@ auto to_xml(const T& value, bool first = true, const std::string& fixed_name = " result.reserve(4096); buffer.reserve(256); - to_xml(value, result, buffer, first, fixed_name); + to_xml(value, result, buffer, first, fixed_name); escape_flags.clear(); escape_flags.shrink_to_fit(); diff --git a/tests/test_serial_xml.cpp b/tests/test_serial_xml.cpp index a637cbc..9e46c96 100644 --- a/tests/test_serial_xml.cpp +++ b/tests/test_serial_xml.cpp @@ -646,3 +646,114 @@ TEST(Functions, IgnoreTemplated) { ASSERT_EQ(clean_to_xml(obj), ""); } + +class mock_vector { + public: + [[= serial_xml::attribute]] std::size_t size() const { return 0; } +}; + +TEST(Mocking, VectorGetterUsesActualObject) { + const std::vector values{10, 20, 30}; + ASSERT_EQ(serial_xml::to_xml(values, false), ""); + ASSERT_EQ(serial_xml::to_xml(std::vector{}, false), + ""); + ASSERT_EQ(serial_xml::to_xml(values), + ""); +} + +TEST(Mocking, FieldsUseMockAnnotationsAndOrder) { + struct MockFieldsTarget { + int a; + int i; + int ignored; + }; + struct MockFields { + [[ = serial_xml::attribute, = serial_xml::name{"second"} ]] int i; + [[= serial_xml::name{"first"}]] int a; + [[= serial_xml::skip]] int nonexistent; + }; + // a and i collide in the eight-slot identifier table. + ASSERT_EQ(serial_xml::to_xml(MockFieldsTarget{4, 7, 99}, false), + "4"); +} + +TEST(Mocking, GetterSelectsConstCallableOverload) { + struct MockOverloadsTarget { + int amount() { return 100; } + int amount() const { return 8; } + int amount(int) const { return 200; } + }; + struct MockOverloads { + [[= serial_xml::attribute]] int amount() const; + }; + ASSERT_EQ(serial_xml::to_xml(MockOverloadsTarget{}, false), + ""); +} + +TEST(Mocking, TargetReturnTypeControlsSerialization) { + struct MockRangeTarget { + std::vector values() const { return {2, 3}; } + }; + struct MockRange { + // The placeholder body and type never provide the serialized value. + int values() const; + }; + ASSERT_EQ(serial_xml::to_xml(MockRangeTarget{}, false), + "23"); +} + +TEST(Mocking, FormatsAndNamesComeFromMock) { + struct MockFormatTarget { + [[= serial_xml::skip]] int value; + }; + struct[[= serial_xml::name{"record"}]] MockFormat { + MockFormat() = delete; + [[ = serial_xml::format{"04"}, = serial_xml::name{"number"} ]] int value; + }; + ASSERT_EQ(serial_xml::to_xml(MockFormatTarget{12}, false), + "0012"); + ASSERT_EQ(serial_xml::to_xml(MockFormatTarget{12}, false, "override"), + "0012"); +} + +TEST(Mocking, NestedValuesAndExplicitIteration) { + struct Item { + int value; + }; + struct MockNestedTarget { + Item child; + std::vector items; + }; + struct MockNested { + int child; + [[= serial_xml::iter{"item", "list"}]] int items; + }; + ASSERT_EQ(serial_xml::to_xml(MockNestedTarget{{5}, {{6}, {7}}}, false), + "56" + "7"); +} + +TEST(Mocking, EmptySchemaDoesNotSerializeTargetMembers) { + struct MockEmptyTarget { + int value; + }; + struct Empty {}; + ASSERT_EQ(serial_xml::to_xml(MockEmptyTarget{5}, false), ""); + ASSERT_EQ(serial_xml::to_xml(MockEmptyTarget{5}, false), + "5"); +} + +TEST(Mocking, GetterWithDefaultArgument) { + struct MockDefaultTarget { + int value(int offset = 2) const { return 5 + offset; } + }; + struct MockDefault { + int value() const; + }; + ASSERT_EQ(serial_xml::to_xml(MockDefaultTarget{}, false), + "7"); +}