From fc5b56fa19a26fcce057e5a3f19d9b34ba75a5ee Mon Sep 17 00:00:00 2001 From: Stephen Berry Date: Wed, 24 Jun 2026 13:04:31 -0500 Subject: [PATCH] Error when writing un-enumerated named enum values A named enum (glz::meta with enumerate(...) or keys/value arrays) whose value is not one of the enumerated values has no string name. The writer previously fell back to emitting the underlying integer, but the string formats (JSON, TOML, YAML, MessagePack) validate enum names on read, so that output could not be read back (issue #2672). Make the writer error with error_code::unexpected_enum by default for those four formats, consistent with Glaze's read-side validation. Binary formats (BEVE, CBOR, BSON) intentionally round-trip integers and are unchanged. Add the inheritable opt-out option `enum_int_fallback` (default false) to restore the previous integer-emitting behavior when desired. Includes regression tests across all four formats plus an option opt-out suite, and documentation in the README and docs/options.md. --- README.md | 4 + docs/options.md | 19 +++++ include/glaze/core/opts.hpp | 19 +++++ include/glaze/json/write.hpp | 15 +++- include/glaze/msgpack/write.hpp | 14 ++- include/glaze/toml/write.hpp | 12 ++- include/glaze/yaml/write.hpp | 12 ++- tests/reflection/enum_test.cpp | 145 ++++++++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 10 deletions(-) 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; }