Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -243,6 +244,24 @@ auto json = glz::write<my_opts{}>(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<color> {
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<my_opts{}>(color{}).value_or("error"); // "0"
```

#### `prettify`
When `true`, outputs formatted JSON with indentation and newlines.

Expand Down
19 changes: 19 additions & 0 deletions include/glaze/core/opts.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }) {
Expand Down
15 changes: 12 additions & 3 deletions include/glaze/json/write.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,7 @@ namespace glz
requires((glaze_enum_t<T> || (meta_keys<T> && std::is_enum_v<std::decay_t<T>>)) && not custom_write<T>)
struct to<JSON, T>
{
static constexpr bool can_error = false;
// can_error defaults to true (no declaration) because the unmapped-value branch below errors.

template <auto Opts, class... Args>
GLZ_ALWAYS_INLINE static void op(auto&& value, is_context auto&& ctx, Args&&... args)
Expand All @@ -1040,8 +1040,17 @@ namespace glz
}
}
else [[unlikely]] {
// Value doesn't have a mapped string, serialize as underlying number
serialize<JSON>::op<Opts>(static_cast<std::underlying_type_t<T>>(value), ctx, std::forward<Args>(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<JSON>::op<Opts>(static_cast<std::underlying_type_t<T>>(value), ctx,
std::forward<Args>(args)...);
}
else {
ctx.error = error_code::unexpected_enum;
}
}
}
};
Expand Down
14 changes: 11 additions & 3 deletions include/glaze/msgpack/write.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,17 @@ namespace glz
return;
}
}
else {
// fallback to numeric representation
serialize<MSGPACK>::op<Opts>(static_cast<std::underlying_type_t<T>>(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<MSGPACK>::op<Opts>(static_cast<std::underlying_type_t<T>>(value), ctx, b, ix);
}
else {
ctx.error = error_code::unexpected_enum;
}
}
}
};
Expand Down
12 changes: 10 additions & 2 deletions include/glaze/toml/write.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,16 @@ namespace glz
}
}
else [[unlikely]] {
// Value doesn't have a mapped string, serialize as underlying number
serialize<TOML>::op<Opts>(static_cast<std::underlying_type_t<T>>(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<TOML>::op<Opts>(static_cast<std::underlying_type_t<T>>(value), ctx, b, ix);
}
else {
ctx.error = error_code::unexpected_enum;
}
}
}
};
Expand Down
12 changes: 10 additions & 2 deletions include/glaze/yaml/write.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,16 @@ namespace glz
yaml::write_yaml_string<Opts>(str, ctx, b, ix);
}
else [[unlikely]] {
// Value doesn't have a mapped string, serialize as underlying number
serialize<YAML>::op<Opts>(static_cast<std::underlying_type_t<T>>(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<YAML>::op<Opts>(static_cast<std::underlying_type_t<T>>(value), ctx, b, ix);
}
else {
ctx.error = error_code::unexpected_enum;
}
}
}
};
Expand Down
145 changes: 145 additions & 0 deletions tests/reflection/enum_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

#include <array>

#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;
Expand Down Expand Up @@ -1156,4 +1160,145 @@ suite random_enum_hash_tests = [] {
"RandomI64Enum2_roundtrip"_test = [] { test_enum_roundtrip<RandomI64Enum2>(); };
};

// 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<Issue2672Enum>
{
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<Issue2672Enum> 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<Issue2672Enum>(2)};
expect(not glz::read_beve(r, s));
expect(static_cast<uint8_t>(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<legacy_enum_opts{}>(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<legacy_enum_opts{}>(Issue2672Enum::A, s));
expect(s == "\"A\"") << s;
};

"json_option_struct_writes_integer"_test = [] {
std::string s;
expect(not glz::write<legacy_enum_opts{}>(Issue2672Wrapper{}, s));
expect(s == R"({"e":0})") << s;
};

"toml_option_writes_without_error"_test = [] {
std::string s;
expect(not glz::write<legacy_enum_opts{{.format = glz::TOML}}>(Issue2672Enum{}, s));
};

"yaml_option_writes_without_error"_test = [] {
std::string s;
expect(not glz::write<legacy_enum_opts{{.format = glz::YAML}}>(Issue2672Enum{}, s));
};

"msgpack_option_writes_without_error"_test = [] {
std::string s;
expect(not glz::write<legacy_enum_opts{{.format = glz::MSGPACK}}>(Issue2672Enum{}, s));
};
};

int main() { return 0; }
Loading