diff --git a/README.md b/README.md index b4dd193bbb..20d256f33d 100644 --- a/README.md +++ b/README.md @@ -975,6 +975,10 @@ glz::write_json(color, buffer); expect(buffer == "\"Red\""); ``` +> [!NOTE] +> +> Writing a registered enum whose value is not one of the enumerated values (for example a default-constructed sentinel) returns an `unexpected_enum` error. The string formats (JSON, TOML, YAML, MessagePack) validate enum names on read, so emitting such a value would produce output that could not be read back. Binary formats (BEVE, CBOR, BSON) intentionally serialize the underlying integer and round-trip any value. To restore the previous behavior of emitting the underlying integer for unmapped values, set the inheritable option `enum_int_fallback = true` (e.g. `struct my_opts : glz::opts { bool enum_int_fallback = true; };`). + > [!TIP] > > For automatic enum-to-string serialization without writing metadata for each enum, use an enum reflection library ([magic_enum](https://github.com/Neargye/magic_enum), [enchantum](https://github.com/ZXShady/enchantum), or [simple_enum](https://github.com/arturbac/simple_enum)) with a generic `glz::meta` specialization. See [Automatic Enum Strings](https://stephenberry.github.io/glaze/enum-reflection/) for details. diff --git a/docs/options.md b/docs/options.md index db407a9e48..a85bef9435 100644 --- a/docs/options.md +++ b/docs/options.md @@ -64,6 +64,7 @@ These options are **not** in `glz::opts` by default. Add them to a custom option | `size_t max_map_size` | `0` | Maximum map size when reading (0 = no limit). See also [Runtime Constraints](security.md#runtime-limits-via-custom-context). | | `bool skip_default_members` | `false` | Skip writing members with default values (empty strings, empty containers, zero numbers, false bools) | | `bool allocate_raw_pointers` | `false` | Allocate memory for null raw pointers during deserialization. See also [Runtime Control](security.md#runtime-raw-pointer-allocation-control). | +| `bool enum_int_fallback` | `false` | Emit the underlying integer instead of erroring when writing a named enum value that has no registered name | ### CSV Options (`glz::opts_csv`) @@ -243,6 +244,24 @@ auto json = glz::write(p).value_or("error"); See also [Skip Keys](skip-keys.md) for per-field skip control. +#### `enum_int_fallback` (Inheritable) +A named enum (registered via `glz::meta` with `enumerate(...)` or `keys`/`value` arrays) is normally written as its string name. A value that is not one of the enumerated values (for example a default-constructed sentinel) has no name. By default Glaze errors with `error_code::unexpected_enum` in that case, because the string formats (JSON, TOML, YAML, MessagePack) validate enum names on read and could not round-trip the value. Set this option to `true` to restore the previous behavior of emitting the underlying integer instead. Binary formats (BEVE, CBOR, BSON) always serialize the underlying integer and are unaffected. + +```cpp +struct my_opts : glz::opts { + bool enum_int_fallback = true; +}; + +enum class color : uint8_t { red = 1, green = 2 }; +template <> struct glz::meta { + using enum color; + static constexpr auto value = glz::enumerate("red", red, "green", green); +}; + +auto def = glz::write_json(color{}); // error: unexpected_enum (0 has no name) +auto legacy = glz::write(color{}).value_or("error"); // "0" +``` + #### `prettify` When `true`, outputs formatted JSON with indentation and newlines. diff --git a/include/glaze/core/opts.hpp b/include/glaze/core/opts.hpp index 0ec3f8eab1..3a13afe990 100644 --- a/include/glaze/core/opts.hpp +++ b/include/glaze/core/opts.hpp @@ -204,6 +204,15 @@ namespace glz // using C++26 P2996 reflection, serializing them as strings instead of integers. // This allows enum string serialization without explicit glz::meta specializations. + // --- + // bool enum_int_fallback = false; + // Controls how named enums (registered via glz::meta with enumerate(...) or keys/value arrays) are + // written when the value is not one of the enumerated values, so it has no string name. + // By default Glaze errors with error_code::unexpected_enum, because the string formats (JSON, TOML, + // YAML, MessagePack) validate enum names on read and could not round-trip such a value. + // Set this to true to restore the previous behavior of emitting the underlying integer instead. + // Only affects writing of named enums; binary formats (BEVE, CBOR, BSON) always write the integer. + // --- // bool qualified_type_names = false; // When true and GLZ_REFLECTION26 is enabled, type_name_for_opts and name_for_opts return @@ -791,6 +800,16 @@ namespace glz } } + consteval bool check_enum_int_fallback(auto&& Opts) + { + if constexpr (requires { Opts.enum_int_fallback; }) { + return Opts.enum_int_fallback; + } + else { + return false; + } + } + consteval bool check_qualified_type_names(auto&& Opts) { if constexpr (requires { Opts.qualified_type_names; }) { diff --git a/include/glaze/json/write.hpp b/include/glaze/json/write.hpp index 5e16f99948..85fcfde98c 100644 --- a/include/glaze/json/write.hpp +++ b/include/glaze/json/write.hpp @@ -1022,7 +1022,7 @@ namespace glz requires((glaze_enum_t || (meta_keys && std::is_enum_v>)) && not custom_write) struct to { - static constexpr bool can_error = false; + // can_error defaults to true (no declaration) because the unmapped-value branch below errors. template GLZ_ALWAYS_INLINE static void op(auto&& value, is_context auto&& ctx, Args&&... args) @@ -1040,8 +1040,17 @@ namespace glz } } else [[unlikely]] { - // Value doesn't have a mapped string, serialize as underlying number - serialize::op(static_cast>(value), ctx, std::forward(args)...); + // The value is not in the enumerate map, so it has no string name. By default error rather + // than emitting the underlying integer, which the reader (requiring a known name) could not + // round-trip; writes are trusted, so this signals a producer bug (issue #2672). The + // enum_int_fallback option restores the old integer-fallback behavior. + if constexpr (check_enum_int_fallback(Opts)) { + serialize::op(static_cast>(value), ctx, + std::forward(args)...); + } + else { + ctx.error = error_code::unexpected_enum; + } } } }; diff --git a/include/glaze/msgpack/write.hpp b/include/glaze/msgpack/write.hpp index fe057abb6c..b53a1bf856 100644 --- a/include/glaze/msgpack/write.hpp +++ b/include/glaze/msgpack/write.hpp @@ -541,9 +541,17 @@ namespace glz return; } } - else { - // fallback to numeric representation - serialize::op(static_cast>(value), ctx, b, ix); + else [[unlikely]] { + // The value is not in the enumerate map, so it has no string name. By default error rather + // than emitting the underlying integer, which the reader (requiring a known name) could not + // round-trip; writes are trusted, so this signals a producer bug (issue #2672). The + // enum_int_fallback option restores the old integer-fallback behavior. + if constexpr (check_enum_int_fallback(Opts)) { + serialize::op(static_cast>(value), ctx, b, ix); + } + else { + ctx.error = error_code::unexpected_enum; + } } } }; diff --git a/include/glaze/toml/write.hpp b/include/glaze/toml/write.hpp index cd495a0ed8..73ee9d469f 100644 --- a/include/glaze/toml/write.hpp +++ b/include/glaze/toml/write.hpp @@ -170,8 +170,16 @@ namespace glz } } else [[unlikely]] { - // Value doesn't have a mapped string, serialize as underlying number - serialize::op(static_cast>(value), ctx, b, ix); + // The value is not in the enumerate map, so it has no string name. By default error rather + // than emitting the underlying integer, which the reader (requiring a known name) could not + // round-trip; writes are trusted, so this signals a producer bug (issue #2672). The + // enum_int_fallback option restores the old integer-fallback behavior. + if constexpr (check_enum_int_fallback(Opts)) { + serialize::op(static_cast>(value), ctx, b, ix); + } + else { + ctx.error = error_code::unexpected_enum; + } } } }; diff --git a/include/glaze/yaml/write.hpp b/include/glaze/yaml/write.hpp index de7c8a72ab..d591841b91 100644 --- a/include/glaze/yaml/write.hpp +++ b/include/glaze/yaml/write.hpp @@ -388,8 +388,16 @@ namespace glz yaml::write_yaml_string(str, ctx, b, ix); } else [[unlikely]] { - // Value doesn't have a mapped string, serialize as underlying number - serialize::op(static_cast>(value), ctx, b, ix); + // The value is not in the enumerate map, so it has no string name. By default error rather + // than emitting the underlying integer, which the reader (requiring a known name) could not + // round-trip; writes are trusted, so this signals a producer bug (issue #2672). The + // enum_int_fallback option restores the old integer-fallback behavior. + if constexpr (check_enum_int_fallback(Opts)) { + serialize::op(static_cast>(value), ctx, b, ix); + } + else { + ctx.error = error_code::unexpected_enum; + } } } }; diff --git a/tests/reflection/enum_test.cpp b/tests/reflection/enum_test.cpp index 3dd64f70b5..82f18c7dbe 100644 --- a/tests/reflection/enum_test.cpp +++ b/tests/reflection/enum_test.cpp @@ -3,7 +3,11 @@ #include +#include "glaze/beve.hpp" #include "glaze/glaze.hpp" +#include "glaze/msgpack.hpp" +#include "glaze/toml.hpp" +#include "glaze/yaml.hpp" #include "ut/ut.hpp" using namespace ut; @@ -1156,4 +1160,145 @@ suite random_enum_hash_tests = [] { "RandomI64Enum2_roundtrip"_test = [] { test_enum_roundtrip(); }; }; +// Issue #2672: a named enum value that is not in the enumerate map has no string name. The text +// formats (JSON, TOML, YAML, MessagePack) validate names on read, so the writer must error rather +// than silently emit the underlying integer it could not read back. Binary formats (BEVE/CBOR/BSON) +// intentionally round-trip integers and are unaffected. +enum class Issue2672Enum : uint8_t { A = 1, B = 2 }; // a default-constructed value (0) is un-enumerated + +template <> +struct glz::meta +{ + using T = Issue2672Enum; + static constexpr auto value = enumerate("A", T::A, "B", T::B); +}; + +struct Issue2672Wrapper +{ + Issue2672Enum e{}; +}; + +suite issue_2672_write_error_tests = [] { + // ---- JSON ---- + "json_unenumerated_write_errors"_test = [] { + std::string s; + expect(glz::write_json(Issue2672Enum{}, s) == glz::error_code::unexpected_enum); + }; + + "json_enumerated_write_ok"_test = [] { + std::string s; + expect(not glz::write_json(Issue2672Enum::A, s)); + expect(s == "\"A\"") << s; + }; + + "json_error_propagates_through_struct"_test = [] { + std::string s; + expect(glz::write_json(Issue2672Wrapper{}, s) == glz::error_code::unexpected_enum); + }; + + "json_error_propagates_through_vector"_test = [] { + // First element is valid, second is un-enumerated: the error must surface from the container. + std::vector v{Issue2672Enum::A, Issue2672Enum{}}; + std::string s; + expect(glz::write_json(v, s) == glz::error_code::unexpected_enum); + }; + + "json_valid_still_roundtrips"_test = [] { + std::string s; + expect(not glz::write_json(Issue2672Enum::B, s)); + Issue2672Enum r{}; + expect(not glz::read_json(r, s)); + expect(r == Issue2672Enum::B); + }; + + // ---- TOML ---- + "toml_unenumerated_write_errors"_test = [] { + std::string s; + expect(glz::write_toml(Issue2672Enum{}, s) == glz::error_code::unexpected_enum); + }; + + "toml_enumerated_write_ok"_test = [] { + std::string s; + expect(not glz::write_toml(Issue2672Enum::A, s)); + }; + + // ---- YAML ---- + "yaml_unenumerated_write_errors"_test = [] { + std::string s; + expect(glz::write_yaml(Issue2672Enum{}, s) == glz::error_code::unexpected_enum); + }; + + "yaml_enumerated_write_ok"_test = [] { + std::string s; + expect(not glz::write_yaml(Issue2672Enum::A, s)); + }; + + // ---- MessagePack ---- + "msgpack_unenumerated_write_errors"_test = [] { + std::string s; + expect(glz::write_msgpack(Issue2672Enum{}, s) == glz::error_code::unexpected_enum); + }; + + "msgpack_enumerated_roundtrips"_test = [] { + std::string s; + expect(not glz::write_msgpack(Issue2672Enum::B, s)); + Issue2672Enum r{}; + expect(not glz::read_msgpack(r, s)); + expect(r == Issue2672Enum::B); + }; + + // ---- BEVE (binary): intentionally round-trips integers, so the fix must NOT make it error ---- + "beve_unenumerated_still_roundtrips"_test = [] { + std::string s; + expect(not glz::write_beve(Issue2672Enum{}, s)); + Issue2672Enum r{static_cast(2)}; + expect(not glz::read_beve(r, s)); + expect(static_cast(r) == 0); + }; +}; + +// The inheritable enum_int_fallback option restores the previous behavior (emit the +// underlying integer instead of erroring) for code that intentionally serializes sentinel/unmapped +// enum values. Default (option absent) is the new erroring behavior, verified above. +struct legacy_enum_opts : glz::opts +{ + bool enum_int_fallback = true; +}; + +suite issue_2672_legacy_option_tests = [] { + "json_option_writes_integer"_test = [] { + std::string s; + expect(not glz::write(Issue2672Enum{}, s)); + expect(s == "0") << s; + }; + + "json_option_enumerated_still_string"_test = [] { + // Values that DO have a name are unaffected by the option. + std::string s; + expect(not glz::write(Issue2672Enum::A, s)); + expect(s == "\"A\"") << s; + }; + + "json_option_struct_writes_integer"_test = [] { + std::string s; + expect(not glz::write(Issue2672Wrapper{}, s)); + expect(s == R"({"e":0})") << s; + }; + + "toml_option_writes_without_error"_test = [] { + std::string s; + expect(not glz::write(Issue2672Enum{}, s)); + }; + + "yaml_option_writes_without_error"_test = [] { + std::string s; + expect(not glz::write(Issue2672Enum{}, s)); + }; + + "msgpack_option_writes_without_error"_test = [] { + std::string s; + expect(not glz::write(Issue2672Enum{}, s)); + }; +}; + int main() { return 0; }