Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
35 changes: 35 additions & 0 deletions docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyType, my_opts{}>();
```

### 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<T>` entry with `.defaultValue = ...` takes precedence, whether or not the flag is on:

```c++
struct explicit_override { int value{42}; };

template <>
struct glz::json_schema<explicit_override>
{
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.
Expand Down
6 changes: 6 additions & 0 deletions include/glaze/core/feature_test.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

#pragma once

// <version> (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 <version>

// 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
Expand Down
182 changes: 182 additions & 0 deletions include/glaze/json/schema.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <class T>
constexpr auto to_schema_default(const T& value) -> std::optional<schema::schema_any>
{
using V = std::decay_t<T>;
if constexpr (std::same_as<V, bool>) {
return schema::schema_any{value};
}
else if constexpr (std::same_as<V, std::monostate>) {
return schema::schema_any{std::monostate{}};
}
else if constexpr (std::signed_integral<V> && !std::same_as<V, char>) {
return schema::schema_any{static_cast<int64_t>(value)};
}
else if constexpr (std::unsigned_integral<V> && !std::same_as<V, char>) {
return schema::schema_any{static_cast<uint64_t>(value)};
}
else if constexpr (std::floating_point<V>) {
return schema::schema_any{static_cast<double>(value)};
}
else {
return std::nullopt;
}
}

template <class T>
constexpr bool is_schema_default_convertible_raw =
std::same_as<T, bool> || std::same_as<T, std::monostate> ||
(std::integral<T> && !std::same_as<T, bool> && !std::same_as<T, char>) ||
std::floating_point<T>;

template <class T>
constexpr bool is_schema_default_convertible = is_schema_default_convertible_raw<std::decay_t<T>>;

// 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<T>.
template <size_t I, class Tied>
constexpr auto extract_default_from_tie(Tied& tied) -> std::optional<schema::schema_any>
{
using val_t = std::decay_t<decltype(get<I>(tied))>;
if constexpr (is_schema_default_convertible<val_t>) {
if (get<I>(tied) == val_t{}) {
return std::nullopt;
}
return to_schema_default(get<I>(tied));
}
else {
return std::nullopt;
}
}

template <class T, size_t I>
constexpr auto extract_default_from_member(T& instance) -> std::optional<schema::schema_any>
{
using member_type = std::decay_t<decltype(get<I>(reflect<T>::values))>;
if constexpr (std::is_member_object_pointer_v<member_type>) {
constexpr auto member_ptr = get<I>(reflect<T>::values);
using val_t = std::decay_t<decltype(instance.*member_ptr)>;
if constexpr (is_schema_default_convertible<val_t>) {
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<T>::values member pointers,
// reflectable types go through to_tie(instance). glaze_object_t wins if a type
// happens to satisfy both.
template <class T>
requires((reflectable<T> || glaze_object_t<T>) && std::default_initializable<T>)
consteval auto extract_all_defaults_impl()
{
constexpr auto N = reflect<T>::size;
std::array<std::optional<schema::schema_any>, N> result{};
T instance{};
[&]<size_t... Is>(std::index_sequence<Is...>) {
if constexpr (glaze_object_t<T>) {
((result[Is] = extract_default_from_member<T, Is>(instance)), ...);
}
else {
auto tied = to_tie(instance);
((result[Is] = extract_default_from_tie<Is>(tied)), ...);
}
}(std::make_index_sequence<N>{});
return result;
}

// SFINAE-friendly probe: consteval-call failure propagates as "not a constant
// expression" at the integral_constant<int, ...> substitution, which is
// SFINAE-detectable. A variable-template initializer failure is not — so
// the probe has to call extract_all_defaults_impl directly.
template <class T>
consteval int check_extractable()
{
(void)extract_all_defaults_impl<T>();
return 0;
}

template <class T>
concept can_extract_defaults =
(reflectable<T> || glaze_object_t<T>) && std::default_initializable<T> &&
requires { typename std::integral_constant<int, check_extractable<T>()>; };

// 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 <class T>
requires can_extract_defaults<T>
inline constexpr auto cached_defaults = extract_all_defaults_impl<T>();

// 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 <class T, bool Enable>
consteval auto defaults_array_for()
{
if constexpr (Enable && can_extract_defaults<T>) {
return cached_defaults<T>;
}
else {
constexpr auto N = reflect<T>::size;
std::array<std::optional<schema::schema_any>, 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 <class T = void>
struct to_json_schema
{
Expand Down Expand Up @@ -969,6 +1138,12 @@ namespace glz
auto req = s.required.value_or(std::vector<std::string_view>{});

s.properties = std::map<sv, schema, std::less<>>();

// 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<V, check_schema_auto_defaults(Opts)>();

for_each<N>([&]<auto I>() {
using val_t = std::decay_t<refl_t<T, I>>;

Expand Down Expand Up @@ -1016,6 +1191,13 @@ namespace glz
}
}

// Apply pre-extracted default if not already set by explicit json_schema<T>
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<val_t>;
constexpr bool can_inline = std::same_as<inner_val_t, bool> || str_t<inner_val_t> || char_t<inner_val_t>;
Expand Down
Loading
Loading