diff --git a/docs/json-schema.md b/docs/json-schema.md index c2865e635b..2b0718918c 100644 --- a/docs/json-schema.md +++ b/docs/json-schema.md @@ -55,6 +55,41 @@ struct local_schema_t }; ``` +## Automatic Default Extraction (opt-in) + +Glaze can populate each property's `"default"` keyword from the corresponding C++ member's default-constructed value. The feature is **off by default** — C++ default-initialization and JSON Schema's `default` keyword have overlapping but not identical semantics, so enabling it implicitly would change the meaning of every generated schema. + +Opt in via a custom `opts` struct: + +```c++ +struct my_opts : glz::opts { bool schema_auto_defaults = true; }; +auto schema = glz::write_json_schema(); +``` + +### What gets extracted + +Only primitive, representable-in-JSON defaults are emitted: `bool`, integer types, floating-point types, and `std::monostate`. `std::string`, containers, and user-defined types are skipped (their storage wouldn't outlive the transient consteval `T{}`). + +### What gets filtered + +Values that equal their value-initialized form (`int{}`, `bool{}`, `double{}`, …) are **not** emitted, so value-init members like `int x{};` or `bool enabled{false};` don't produce `"default":0` or `"default":false`. Only deliberate non-sentinel values like `int count{42}` or `bool flag{true}` are surfaced. + +### Explicit overrides always win + +An explicit `glz::json_schema` entry with `.defaultValue = ...` takes precedence, whether or not the flag is on: + +```c++ +struct explicit_override { int value{42}; }; + +template <> +struct glz::json_schema +{ + schema value{ .defaultValue = 99L }; // wins over the struct's 42 +}; +``` + +If you want `"default":0` or another sentinel value that the filter would otherwise drop, set it explicitly here. + ## Required Fields Glaze can automatically mark fields as required in the generated JSON schema based on their nullability and compile options. diff --git a/include/glaze/core/feature_test.hpp b/include/glaze/core/feature_test.hpp index 74901c9fe9..694f1189cd 100644 --- a/include/glaze/core/feature_test.hpp +++ b/include/glaze/core/feature_test.hpp @@ -3,6 +3,12 @@ #pragma once +// (C++20) pulls in the standard library's feature-test macros and vendor +// identification (__GLIBCXX__, _LIBCPP_VERSION, etc.) without dragging in any types. +// Needed here so the __GLIBCXX__ check below sees a value when the including TU +// has not yet touched the stdlib. +#include + // Detect constexpr std::string support // The old GCC ABI (_GLIBCXX_USE_CXX11_ABI=0) does not have constexpr std::string::size() // This affects features like rename_key returning std::string diff --git a/include/glaze/json/schema.hpp b/include/glaze/json/schema.hpp index a556b83058..ad55db6af5 100644 --- a/include/glaze/json/schema.hpp +++ b/include/glaze/json/schema.hpp @@ -386,6 +386,175 @@ namespace glz merge(dst.ExtAdvanced, src.ExtAdvanced); } + // Automatic default value extraction for JSON Schema. + // In C++20+, std::string/vector support transient constexpr allocation, + // so we can construct T{} in consteval, extract primitive member defaults, and destroy it. + // + // Optimization: we construct T{} exactly once per type and extract all primitive + // defaults in a single pass, rather than once per member. + + // Convert a member value to schema_any at compile time (primitives only) + template + constexpr auto to_schema_default(const T& value) -> std::optional + { + using V = std::decay_t; + if constexpr (std::same_as) { + return schema::schema_any{value}; + } + else if constexpr (std::same_as) { + return schema::schema_any{std::monostate{}}; + } + else if constexpr (std::signed_integral && !std::same_as) { + return schema::schema_any{static_cast(value)}; + } + else if constexpr (std::unsigned_integral && !std::same_as) { + return schema::schema_any{static_cast(value)}; + } + else if constexpr (std::floating_point) { + return schema::schema_any{static_cast(value)}; + } + else { + return std::nullopt; + } + } + + template + constexpr bool is_schema_default_convertible_raw = + std::same_as || std::same_as || + (std::integral && !std::same_as && !std::same_as) || + std::floating_point; + + template + constexpr bool is_schema_default_convertible = is_schema_default_convertible_raw>; + + // Per-member extraction helpers. + // Avoids nested lambdas in fold expressions (ICEs GCC 13). Declared constexpr rather + // than consteval so GCC accepts non-constexpr local references from the consteval + // caller — consteval helpers would require constant-expression arguments. + // + // Skips emitting when the member value equals its value-initialized form. C++ + // default-init uses a sentinel (0, 0.0, false, {}) that rarely matches JSON Schema's + // "default" semantics of "recommended value when the field is omitted". Filtering + // keeps the feature useful for deliberate non-sentinel values (flag{true}, + // count{42}) while dropping the noisy all-zero cases. Users who genuinely want + // "default":0 can set it explicitly via glz::json_schema. + template + constexpr auto extract_default_from_tie(Tied& tied) -> std::optional + { + using val_t = std::decay_t(tied))>; + if constexpr (is_schema_default_convertible) { + if (get(tied) == val_t{}) { + return std::nullopt; + } + return to_schema_default(get(tied)); + } + else { + return std::nullopt; + } + } + + template + constexpr auto extract_default_from_member(T& instance) -> std::optional + { + using member_type = std::decay_t(reflect::values))>; + if constexpr (std::is_member_object_pointer_v) { + constexpr auto member_ptr = get(reflect::values); + using val_t = std::decay_t; + if constexpr (is_schema_default_convertible) { + if (instance.*member_ptr == val_t{}) { + return std::nullopt; + } + return to_schema_default(instance.*member_ptr); + } + else { + return std::nullopt; + } + } + else { + return std::nullopt; + } + } + + // Extract all primitive defaults from T in one T{} construction. Branches on the + // access pattern: glaze_object_t goes through reflect::values member pointers, + // reflectable types go through to_tie(instance). glaze_object_t wins if a type + // happens to satisfy both. + template + requires((reflectable || glaze_object_t) && std::default_initializable) + consteval auto extract_all_defaults_impl() + { + constexpr auto N = reflect::size; + std::array, N> result{}; + T instance{}; + [&](std::index_sequence) { + if constexpr (glaze_object_t) { + ((result[Is] = extract_default_from_member(instance)), ...); + } + else { + auto tied = to_tie(instance); + ((result[Is] = extract_default_from_tie(tied)), ...); + } + }(std::make_index_sequence{}); + return result; + } + + // SFINAE-friendly probe: consteval-call failure propagates as "not a constant + // expression" at the integral_constant substitution, which is + // SFINAE-detectable. A variable-template initializer failure is not — so + // the probe has to call extract_all_defaults_impl directly. + template + consteval int check_extractable() + { + (void)extract_all_defaults_impl(); + return 0; + } + + template + concept can_extract_defaults = + (reflectable || glaze_object_t) && std::default_initializable && + requires { typename std::integral_constant()>; }; + + // Variable-template cache, guarded by can_extract_defaults so it's only + // ever instantiated for types the probe has already cleared. The cache + // value is computed once per T; repeated accesses at call sites are free. + template + requires can_extract_defaults + inline constexpr auto cached_defaults = extract_all_defaults_impl(); + + // Returns the pre-extracted defaults array for T, or an all-nullopt array + // when disabled or when T is not extractable (e.g. missing default ctor). + // Kept as a named helper rather than an in-place lambda because MSVC cannot + // deduce the common return type of an immediately-invoked lambda whose + // branches mix a consteval call with a braced std::array initialization. + template + consteval auto defaults_array_for() + { + if constexpr (Enable && can_extract_defaults) { + return cached_defaults; + } + else { + constexpr auto N = reflect::size; + std::array, N> empty{}; + return empty; + } + } + + // Opt-in: populate JSON Schema "default" from each primitive member's + // default-constructed value. Off by default — C++ default-initialization + // often differs in meaning from JSON Schema's "default" keyword (which + // recommends a value to consumers when the field is omitted). Enable + // via a custom opts struct: + // struct my_opts : glz::opts { bool schema_auto_defaults = true; }; + consteval bool check_schema_auto_defaults(auto&& Opts) + { + if constexpr (requires { Opts.schema_auto_defaults; }) { + return Opts.schema_auto_defaults; + } + else { + return false; + } + } + template struct to_json_schema { @@ -969,6 +1138,12 @@ namespace glz auto req = s.required.value_or(std::vector{}); s.properties = std::map>(); + + // Extract all primitive defaults in a single T{} construction (once per type), + // but only when the caller opts in via Opts.schema_auto_defaults = true. + // Empty array otherwise, or when V is not extractable (no default ctor, etc.). + static constexpr auto defaults = defaults_array_for(); + for_each([&]() { using val_t = std::decay_t>; @@ -1016,6 +1191,13 @@ namespace glz } } + // Apply pre-extracted default if not already set by explicit json_schema + if constexpr (defaults[I].has_value()) { + if (!prop.defaultValue) { + prop.defaultValue = *defaults[I]; + } + } + // Determine if this type can be inlined (bool, string, or nullable versions) using inner_val_t = unwrap_nullable_t; constexpr bool can_inline = std::same_as || str_t || char_t; diff --git a/tests/json_test/jsonschema_test.cpp b/tests/json_test/jsonschema_test.cpp index f1de935fb8..a876f0faea 100644 --- a/tests/json_test/jsonschema_test.cpp +++ b/tests/json_test/jsonschema_test.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -448,7 +449,8 @@ suite schema_tests = [] { std::string schema_str = glz::write_json_schema().value_or("error"); expect( schema_str == - R"({"type":"object","properties":{"a":{"$ref":"#/$defs/int32_t"},"b":{"$ref":"#/$defs/int32_t"},"reserved_1":{"$ref":"#/$defs/int32_t"},"reserved_2":{"$ref":"#/$defs/int32_t"}},"additionalProperties":false,"$defs":{"int32_t":{"type":"integer","minimum":-2147483648,"maximum":2147483647}},"required":["a","b"],"title":"required_meta"})"); + R"({"type":"object","properties":{"a":{"$ref":"#/$defs/int32_t"},"b":{"$ref":"#/$defs/int32_t"},"reserved_1":{"$ref":"#/$defs/int32_t"},"reserved_2":{"$ref":"#/$defs/int32_t"}},"additionalProperties":false,"$defs":{"int32_t":{"type":"integer","minimum":-2147483648,"maximum":2147483647}},"required":["a","b"],"title":"required_meta"})") + << schema_str; }; "Opts.error_on_missing_keys as fallback"_test = [] { @@ -1176,4 +1178,212 @@ suite schema_round_trip_test = [] { }; }; +// Test structs for automatic default value extraction (Issue #1296) +struct auto_defaults +{ + bool flag{true}; + int32_t count{42}; + double ratio{3.14}; + uint64_t big{1000}; + int8_t small{-5}; +}; + +struct mixed_defaults +{ + int with_default{100}; // primitives CAN be auto-extracted + std::string no_schema_default{"hello"}; // std::string is not in is_schema_default_convertible; + // its buffer would not outlive the transient consteval T{} + std::vector container{1, 2, 3}; // likewise, no extraction path for container types +}; + +struct explicit_override +{ + int value{42}; +}; + +template <> +struct glz::json_schema +{ + schema value{ + .defaultValue = 99L, // explicit default should override the 42 from struct + }; +}; + +struct nested_defaults +{ + int outer{10}; + auto_defaults inner{}; // nested struct with defaults +}; + +// Value-init members should NOT emit a "default" — they are sentinel-init, +// not a deliberate recommendation. Only non-sentinel inits get extracted. +struct value_init_defaults +{ + bool flag_true{true}; // deliberate non-zero → extract + bool flag_false{false}; // matches bool{} → skip + int zero{}; // matches int{} → skip + int nonzero{7}; // deliberate → extract + double ratio_zero{0.0}; // matches double{} → skip +}; + +// Opt in to primitive-member default extraction for these tests. +struct auto_defaults_opts : glz::opts +{ + bool schema_auto_defaults = true; +}; + +suite auto_default_tests = [] { + "default opts emit no auto-extracted defaults"_test = [] { + // With the default options, primitive member defaults are not extracted. + std::string schema_str = glz::write_json_schema().value_or("error"); + glz::schema obj{}; + auto err = glz::read(obj, schema_str); + expect(!err) << glz::format_error(err, schema_str); + expect(obj.properties.has_value()); + auto& props = *obj.properties; + expect(props.contains("flag")); + expect(!props.at("flag").defaultValue.has_value()) << schema_str; + expect(!props.at("count").defaultValue.has_value()) << schema_str; + expect(!props.at("ratio").defaultValue.has_value()) << schema_str; + }; + + "auto_defaults extracts primitive defaults"_test = [] { + std::string schema_str = glz::write_json_schema().value_or("error"); + + // Parse and check defaults + glz::schema obj{}; + auto err = glz::read(obj, schema_str); + expect(!err) << glz::format_error(err, schema_str); + + expect(obj.properties.has_value()) << schema_str; + auto& props = *obj.properties; + + // Check bool default + expect(props.contains("flag")) << schema_str; + expect(props.at("flag").defaultValue.has_value()) << schema_str; + expect(std::holds_alternative(props.at("flag").defaultValue.value())) << schema_str; + expect(std::get(props.at("flag").defaultValue.value()) == true) << schema_str; + + // Check int32_t default + expect(props.contains("count")) << schema_str; + expect(props.at("count").defaultValue.has_value()) << schema_str; + expect(std::holds_alternative(props.at("count").defaultValue.value())) << schema_str; + expect(std::get(props.at("count").defaultValue.value()) == 42) << schema_str; + + // Check double default + expect(props.contains("ratio")) << schema_str; + expect(props.at("ratio").defaultValue.has_value()) << schema_str; + expect(std::holds_alternative(props.at("ratio").defaultValue.value())) << schema_str; + expect(std::get(props.at("ratio").defaultValue.value()) == 3.14) << schema_str; + + // Check uint64_t default (note: when read back from JSON, positive integers parse as int64_t) + expect(props.contains("big")) << schema_str; + expect(props.at("big").defaultValue.has_value()) << schema_str; + // JSON parsing reads positive integers as int64_t, not uint64_t + expect(std::holds_alternative(props.at("big").defaultValue.value())) << schema_str; + expect(std::get(props.at("big").defaultValue.value()) == 1000) << schema_str; + + // Check int8_t default (should be converted to int64_t) + expect(props.contains("small")) << schema_str; + expect(props.at("small").defaultValue.has_value()) << schema_str; + expect(std::holds_alternative(props.at("small").defaultValue.value())) << schema_str; + expect(std::get(props.at("small").defaultValue.value()) == -5) << schema_str; + }; + +#if GLZ_HAS_CONSTEXPR_STRING + "mixed_defaults extracts primitive defaults even with non-trivial members"_test = [] { + // mixed_defaults contains std::string and std::vector, but with C++20 transient constexpr allocation + // we can still extract defaults for primitive members + std::string schema_str = glz::write_json_schema().value_or("error"); + + glz::schema obj{}; + auto err = glz::read(obj, schema_str); + expect(!err) << glz::format_error(err, schema_str); + + expect(obj.properties.has_value()) << schema_str; + auto& props = *obj.properties; + + // Primitive types CAN have defaults extracted even when struct contains non-trivial types + expect(props.contains("with_default")) << schema_str; + expect(props.at("with_default").defaultValue.has_value()) << schema_str; + expect(std::holds_alternative(props.at("with_default").defaultValue.value())) << schema_str; + expect(std::get(props.at("with_default").defaultValue.value()) == 100) << schema_str; + + // std::string is not in is_schema_default_convertible (see struct comment) + expect(props.contains("no_schema_default")) << schema_str; + expect(!props.at("no_schema_default").defaultValue.has_value()) << schema_str; + + // Containers likewise have no extraction path + expect(props.contains("container")) << schema_str; + expect(!props.at("container").defaultValue.has_value()) << schema_str; + }; +#endif + + "explicit json_schema default overrides struct default"_test = [] { + std::string schema_str = glz::write_json_schema().value_or("error"); + + glz::schema obj{}; + auto err = glz::read(obj, schema_str); + expect(!err) << glz::format_error(err, schema_str); + + expect(obj.properties.has_value()) << schema_str; + auto& props = *obj.properties; + + // Explicit schema default (99) should override struct default (42) + expect(props.contains("value")) << schema_str; + expect(props.at("value").defaultValue.has_value()) << schema_str; + expect(std::holds_alternative(props.at("value").defaultValue.value())) << schema_str; + expect(std::get(props.at("value").defaultValue.value()) == 99) << schema_str; + }; + + "value-init members are filtered out"_test = [] { + std::string schema_str = + glz::write_json_schema().value_or("error"); + glz::schema obj{}; + auto err = glz::read(obj, schema_str); + expect(!err) << glz::format_error(err, schema_str); + expect(obj.properties.has_value()); + auto& props = *obj.properties; + + // Deliberate non-sentinel values: extracted + expect(props.at("flag_true").defaultValue.has_value()) << schema_str; + expect(std::get(props.at("flag_true").defaultValue.value()) == true); + expect(props.at("nonzero").defaultValue.has_value()) << schema_str; + expect(std::get(props.at("nonzero").defaultValue.value()) == 7); + + // Value-init cases: filtered + expect(!props.at("flag_false").defaultValue.has_value()) << schema_str; + expect(!props.at("zero").defaultValue.has_value()) << schema_str; + expect(!props.at("ratio_zero").defaultValue.has_value()) << schema_str; + }; + + "nested struct defaults work in inlined definition"_test = [] { + std::string schema_str = glz::write_json_schema().value_or("error"); + + glz::schema obj{}; + auto err = glz::read(obj, schema_str); + expect(!err) << glz::format_error(err, schema_str); + + expect(obj.properties.has_value()) << schema_str; + auto& props = *obj.properties; + + // outer field should have default + expect(props.contains("outer")) << schema_str; + expect(props.at("outer").defaultValue.has_value()) << schema_str; + expect(std::get(props.at("outer").defaultValue.value()) == 10) << schema_str; + + // inner field (complex type) should not have default on the property itself + expect(props.contains("inner")) << schema_str; + expect(!props.at("inner").defaultValue.has_value()) << schema_str; + + // auto_defaults is single-use, so it gets inlined into the inner property. + // Verify the inlined struct has defaults for its members. + expect(props.at("inner").properties.has_value()) << schema_str; + auto& inner_props = *props.at("inner").properties; + expect(inner_props.contains("count")) << schema_str; + expect(inner_props.at("count").defaultValue.has_value()) << schema_str; + expect(std::get(inner_props.at("count").defaultValue.value()) == 42) << schema_str; + }; +}; + int main() { return 0; }