diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 1ed5fbf1..b0ff9ef3 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -216,7 +216,7 @@ output of `glz::write_json_schema()` to add seven annotation groups: | Annotation | Scope | Contents | |---|---|---| -| `required` | Top-level | Array of field names that are **not** `std::optional<...>` and not listed in `A::optionalFields`. | +| `required` | Top-level, and every nested-aggregate object schema (see [Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded)) | Array of field names that are **not** `std::optional<...>` and not listed in `A::optionalFields`. | | `x-order` | Every property | The member's declaration index (0‑based), so a renderer lays fields out in declaration order regardless of JSON key ordering. | | `x-decimalPlaces` | `Quantity` properties | The field's declared precision (`Quantity::declaredDecimals`). | | `x-unitAlternatives` | `Quantity` properties | Convertible display/entry units derived from `UnitTraits::relations`, each with `{id, display, decimals, num, den}` — `id`/`display`/`decimals` come from the alternative unit's `UnitMeta`, and `num`/`den` are the exact alternative-to-canonical ratio. Omitted entirely when the field's unit declares no convertible units. | @@ -518,9 +518,15 @@ must resolve the `$ref` to see both: same unit type share one `$def` and therefore one `ExtUnits`. - **`x-order`, `x-decimalPlaces`, `x-unitAlternatives`, `x-optionsAction` / `x-optionValue` / `x-optionLabel` / `x-optionsDependsOn` are siblings of the - `$ref` on the property** — `mergeSchemaExtras` patches - `dom["properties"][name]`, which is the property node holding the `$ref`, - never the referenced `$def`. + `$ref` on *this* property** — `mergeSchemaExtras` patches + `dom["properties"][name]`, which is the property node holding the `$ref`. + This is still true for a `Quantity`/`Choice` property's own `$def` (the + `quantity_kg_per_m3`-style def shown above never gets `x-order`/`required`/ + title — only `ExtUnits` and glaze's own `type`/bounds/`description` live + there). It is **not** true for a *nested-aggregate* member's `$def`: see + [Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded) + below — that `$def` **does** get `required`/`x-order`/title/etc. patched + directly into it, the same as any other object schema. The **"Where"** column below names the node each key is written to. A renderer resolves the `$ref` into `$defs`, then merges: per-property `x-*` keys (from the @@ -530,7 +536,7 @@ below) `DynamicForm.qml`'s `resolveProp` does exactly this dual read. | Key | Where | JSON type | Meaning / renderer obligation | |---|---|---|---| -| `required` | top-level (object) | array of strings | Names of members that must be engaged before submit. A member is listed unless it is a `std::optional<...>`, appears in `A::optionalFields`, or is a `computedFields` destination (see the [Required-ness rule](#required-ness-rule)). Always emitted (an explicit `[]` when nothing is required). The renderer blocks submission until every listed field has a value. | +| `required` | top-level (object), and every nested-aggregate object schema (inlined property or `$defs` entry) — see [Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded) | array of strings | Names of members that must be engaged before submit. A member is listed unless it is a `std::optional<...>`, appears in `A::optionalFields`, or is a `computedFields` destination (see the [Required-ness rule](#required-ness-rule)). Always emitted (an explicit `[]` when nothing is required). The renderer blocks submission until every listed field has a value. | | `x-order` | property node (sibling of `$ref`) | non-negative integer | The member's 0-based **declaration index**. Renderers lay fields out in ascending `x-order`, not in JSON key order (object key order is not preserved across DOMs). | | `x-decimalPlaces` | property node (sibling of `$ref`) | non-negative integer | The field's *declared* precision (`Quantity::declaredDecimals`, unit default unless the type overrides it). The numeric input step / rounding granularity for entry in the canonical unit. **Enforced, not merely advisory:** the request/reply dispatch path retags each submitted `Quantity` to this precision before storing it (see [Advertised precision is enforced on dispatch](#advertised-precision-is-enforced-on-dispatch)). | | `x-unitAlternatives` | property node (sibling of `$ref`) | array of objects | Convertible display/entry units for the field, derived from `UnitTraits::relations`. **Omitted entirely** when the unit declares no convertible peers. Each element has the five subfields below. The renderer offers these as a unit selector and recomputes the entered value *exactly* on switch; the submitted payload is always in the canonical unit (the one named by `ExtUnits`). | @@ -908,9 +914,9 @@ so it never satisfies `EmptyCapableField` in the first place. (This differs from the `required`-array derivation in `mergeSchemaExtras`, which checks `isStdOptional` **explicitly** — see [Required-ness rule](#required-ness-rule).) The predicate is `noexcept` and `constexpr`, and it inspects only the action's -**own top-level members** — the same flat-actions-only scope as schema -generation ([Scope: flat actions only](#scope-flat-actions-only)); it does not -recurse into nested aggregates. +**own top-level members**; unlike `schemaJson()`'s schema generation (see +[Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded)), +it does **not** recurse into a nested aggregate member's own fields. ## Cross-field rules — the `x-rules` vocabulary @@ -1257,19 +1263,87 @@ for the exhaustive tables and design rationale. ## Failure modes -### Scope: flat actions only - -Annotation and `required`-derivation operate **exclusively on the action's -top-level members**. `mergeSchemaExtras` reflects `A`'s members with -`forEachNamedMember(probe, …)` and patches `dom["properties"][name]` for each — -it never descends into member types. A member that is itself an aggregate is -emitted by glaze into `$defs` and referenced by `$ref`; the generator does not -recurse into that definition, so its sub-members receive **none** of the `x-*` -annotations and are **not** part of any synthesised `required` array (the nested -`$def` gets no `required` at all). Actions meant to drive a generated form must -therefore be **flat**: every field the renderer should understand has to be a -direct member of the action type. Nesting is not a documented form-generation -path. +### Nested aggregates (recursive, cycle-guarded) + +A member whose type is itself a reflectable aggregate — a plain nested +struct, or `std::vector` (a repeated aggregate) — gets its **own** +members annotated too: `x-order`, `title`/`FieldMeta`, `required`, and the +`Quantity`/`Choice`/widget/ranged-bounds rules the top level already applies. +Unlike the top level, this recurses to **whatever depth the type graph +actually has** — a nested aggregate's own nested-aggregate member is +annotated in turn, and so on — rather than stopping after one level. This +closes the gap a flat-only generator has for domains that are naturally +nested (a measurement with a repeated specimen sub-record, a document with a +nested address, a category tree), including domains nested more than one +level deep (an address with a nested geo-coordinate sub-record, say). + +Two schema shapes exist for a nested aggregate, and both are recursed into: + +- **Deduplicated (`$ref`/`$defs`)** — glaze shares one `$defs` entry, `$ref`'d + from every property, when the nested type is used **two or more times** + anywhere in the schema. The shared `$defs` entry is annotated once; every + property that `$ref`s it sees the same annotations. +- **Inlined** — glaze writes the object schema directly into the property + itself (no `$ref`/`$defs` at all) when the nested type is used **exactly + once**. The property node itself is annotated in place. + +`mergeSchemaExtras` resolves whichever form applies (`annotateNestedAggregateRef`, +`forms.hpp`) and hands the resolved node to the same per-member annotation +logic the top level uses (`annotateBasicMemberProperty`), applied against the +nested type's own reflection. Each recursive step passes along the **ancestor +chain** — the action type `A`, followed by every nested-aggregate type +visited since, in order, ending with the type currently being annotated — as +a variadic template parameter pack, so a deeper call can tell whether a +member's type is already somewhere on that chain. + +**Cyclic nested aggregates are a compile error, not infinite recursion.** A +member whose type (or, for a `std::vector` member, `Sub` itself) matches +any type already on the ancestor chain — the action type, the +nested-aggregate type currently being annotated, or anything annotated in +between (a self-referential type such as `struct Node { std::vector +children; };`, or a mutual reference between two distinct types) — trips a +`static_assert` at the point that specific recursive instantiation would +occur, instead of recursing forever. This only rejects genuine cycles: a +"diamond" — the same type reused from two unrelated places in the schema, +e.g. an `Address` nested under both a `Company` and a `Person` member of the +same action — is not a cycle (neither `Address` nor any of its members is its +own ancestor) and recurses normally into both. Restructure a domain type +that trips this (e.g. flatten the self-reference, or represent the recursive +edge as an opaque id instead of a nested value) if you need one; there is no +runtime opt-out. The `static_assert` only fires where the offending type is +actually reached as a nested-aggregate member of some `schemaJson()` (or +`mergeSchemaExtras()`) instantiation — a self-referential type that is +never nested under an action this way compiles and works fine on its own. + +Computed fields, `formLayout`/`fieldSpans`, and `formRules` remain **top-level +only** regardless of nesting depth: a nested aggregate declaring any of those +has no effect on the generated schema. This keeps the generator focused on +what a nested-aggregate schema actually needs (per-field annotations) rather +than becoming a general recursive-descent schema compiler that also +re-derives layout/rules/computed-field semantics at every level. + +**Purely additive, with one source-compatibility exception.** An action with +no nested-aggregate member has nothing here to trigger on, so its generated +schema is byte-for-byte unchanged. A pre-existing action that *does* have a +nested-aggregate member sees its schema gain annotations it previously +lacked — the whole point of this feature — with no change to any of its flat +top-level members. The one exception: an action with a self- or +mutually-referential nested-aggregate member (see the cycle-guard paragraph +above) now fails to *compile*, where it previously compiled (recursion used +to stop before reaching the cycle). No such action exists in this repo today. + +Every nested-aggregate type in the chain must be **default-constructible**, +exactly like the top-level action type (see below): the recursion builds its +own probe instance purely to enumerate its members via reflection. + +### Scope: flat actions only (form layout, computed fields, and rules) + +`formLayout`/`fieldSpans` ([Layout & grouping](#layout--grouping)) and +`formRules` ([Cross-field rules](#cross-field-rules--the-x-rules-vocabulary)) are read only +from the top-level action type — they are not consulted on a nested +aggregate, no matter how deep `mergeSchemaExtras` otherwise recurses (see +[Nested aggregates (recursive, cycle-guarded)](#nested-aggregates-recursive-cycle-guarded) +above). Computed fields (`computedFields`) are likewise top-level only. The action type must also be **default-constructible**: `mergeSchemaExtras` builds a probe instance (`A probe{}`) purely to enumerate member names and types diff --git a/include/morph/forms/forms.hpp b/include/morph/forms/forms.hpp index 4e750e4b..3a0ef2a7 100644 --- a/include/morph/forms/forms.hpp +++ b/include/morph/forms/forms.hpp @@ -68,6 +68,19 @@ /// `morph::time::Timestamp` members need no extension keys: their schema /// carries the standard `"format": "date-time"` annotation. /// +/// **Nested aggregates (recursive, cycle-guarded).** A member whose type is +/// itself a reflectable aggregate — a plain nested struct, or +/// `std::vector` — gets its own members annotated too: `x-order`, +/// title/`FieldMeta`, `required`, and the `Quantity`/`Choice`/widget/ +/// ranged-bounds rules above, applied against the nested type's own +/// reflection. Unlike the top level, this recurses to whatever depth the +/// type graph actually has, stopping only at a genuine cycle (a self- or +/// mutually-referential nested-aggregate type), which is a compile-time +/// `static_assert` rather than infinite recursion. Computed fields/ +/// `formLayout`/`fieldSpans`/`formRules` remain top-level-only regardless of +/// depth. See docs/spec/forms/forms.md, "Nested aggregates (recursive, +/// cycle-guarded)", and `detail::annotateNestedAggregateRef`. +/// /// @par Declaring optional fields /// Required is the default. An action opts individual fields out with a /// static member list: @@ -1534,6 +1547,286 @@ void collectComputedInputs(const A& probe, const ComputedField{resolveMemberName(probe, Inputs)...}); } +/// @brief Trait: is `T` a `std::vector<...>`? Exposes the element type as +/// `ValueType` (`void` when `T` is not a vector). +template +struct IsStdVector : std::false_type { + using ValueType = void; +}; + +template +struct IsStdVector> : std::true_type { + using ValueType = T; +}; + +/// @brief Concept: `T` is glaze-reflectable as a JSON object -- the same test +/// that decides whether glaze emits a member into `$defs`/`$ref` +/// rather than inline. Shared by the cycle-guarded nested-aggregate +/// recursion below and `reconcileDeclaredPrecision` elsewhere. +template +concept ReflectableAggregate = glz::reflectable || glz::glaze_object_t; + +/// @brief Applies the title/`FieldMeta`/`Quantity`/`Choice`/widget/ +/// ranged-bounds annotations to one property node. Shared by +/// `mergeSchemaExtras`'s top-level pass and `annotateNestedAggregate` +/// below (the nested-aggregate recursion, to whatever depth the type +/// graph has) so both apply identical per-member rules -- this is the +/// single implementation of those rules; neither caller duplicates it. +/// +/// Deliberately excludes computed-field annotations (`x-computed`/ +/// `x-readonly`) and `x-order`: computed fields are not supported inside a +/// nested aggregate (see `annotateNestedAggregate`), and `x-order`'s source index +/// differs by caller, so each caller sets it itself. +/// @tparam Owner The type declaring @p name (drives `FieldMeta`/widget-override lookup). +/// @tparam Member The static type of the member itself (drives type-driven annotations). +/// @param property DOM node for this one property; annotations are merged in, not replacing. +/// @param name Wire (JSON) name of the member, for `FieldMeta`/widget-override lookup. +template +void annotateBasicMemberProperty(glz::generic_u64& property, std::string_view name) { + const FieldMeta* fieldMeta = findFieldMeta(name); + std::string_view const declaredLabel = fieldMeta != nullptr ? fieldMeta->label : std::string_view{}; + property["title"] = declaredLabel.empty() ? inferTitle(name) : std::string{declaredLabel}; + if (fieldMeta != nullptr) { + if (!fieldMeta->help.empty()) { + property["description"] = std::string{fieldMeta->help}; + } + if (!fieldMeta->placeholder.empty()) { + property["x-placeholder"] = std::string{fieldMeta->placeholder}; + } + if (fieldMeta->readOnly) { + property["x-readonly"] = true; + } + if (fieldMeta->hidden) { + property["x-hidden"] = true; + } + if (!fieldMeta->i18nKey.empty()) { + property["x-i18nKey"] = std::string{fieldMeta->i18nKey}; + } + } + + if constexpr (units::isQuantity) { + property["x-decimalPlaces"] = std::uint64_t{Member::declaredDecimals}; + auto const alternatives = Member::unitAlternatives(); + if (!alternatives.empty()) { + glz::generic_u64::array_t list{}; + for (auto const& alternative : alternatives) { + auto const meta = + units::UnitTraits>::meta(alternative.unit); + glz::generic_u64 entry{}; + entry["id"] = std::string{meta.id}; + entry["display"] = std::string{meta.display}; + entry["decimals"] = std::uint64_t{meta.defaultDecimals}; + entry["num"] = alternative.num; + entry["den"] = alternative.den; + list.emplace_back(std::move(entry)); + } + property["x-unitAlternatives"] = list; + } + } + if constexpr (isChoice) { + property["x-optionsAction"] = std::string{Member::optionsAction()}; + property["x-optionValue"] = std::string{Member::valueField()}; + property["x-optionLabel"] = std::string{Member::labelField()}; + if constexpr (!Member::optionsDependsOn().empty()) { + glz::generic_u64::array_t dependsOn{}; + for (auto const& parentName : Member::optionsDependsOn()) { + dependsOn.emplace_back(std::string{parentName}); + } + property["x-optionsDependsOn"] = dependsOn; + } + } + + std::string_view widgetHint{}; + if constexpr (DeclaresWidget) { + widgetHint = Member::widget(); + } + if constexpr (HasFieldMetadataWidgets) { + if (auto const overrideWidget = widgetOverride(name); !overrideWidget.empty()) { + widgetHint = overrideWidget; + } + } + if (!widgetHint.empty()) { + property["x-widget"] = std::string{widgetHint}; + } + if constexpr (DeclaresRangedBounds) { + using Bound = std::remove_cvref_t; + if constexpr (std::floating_point) { + property["x-min"] = static_cast(Member::min()); + property["x-max"] = static_cast(Member::max()); + property["x-step"] = static_cast(Member::step()); + } else { + property["x-min"] = static_cast(Member::min()); + property["x-max"] = static_cast(Member::max()); + property["x-step"] = static_cast(Member::step()); + } + } +} + +// annotateNestedAggregate, annotateNestedAggregateRef, and +// recurseIntoNestedAggregateIfAny are mutually recursive (each nested +// aggregate found while annotating one may itself contain another), so all +// three need forward declarations before any of their bodies can reference +// the others. +template +void annotateNestedAggregate(glz::generic_u64& dom, glz::generic_u64& node); + +template +void annotateNestedAggregateRef(glz::generic_u64& dom, glz::generic_u64& propertyOrItems); + +template +void recurseIntoNestedAggregateIfAny(glz::generic_u64& dom, glz::generic_u64& property); + +/// @brief Recurses into @p property's own object schema if @p Member (or, for +/// `std::vector`, its element type) is itself a +/// `ReflectableAggregate` -- the single decision point shared by +/// `mergeSchemaExtras`'s top-level loop and `annotateNestedAggregate`'s +/// own loop, so the cycle guard below has exactly one implementation. +/// +/// @p Ancestors is the chain of nested-aggregate types already being +/// annotated on the current path, **including** the type that declares this +/// member (the caller appends its own `Sub`/`A` before calling this). If the +/// type to recurse into matches any entry already on that chain, recursing +/// further would eventually re-enter this same instantiation and try to do +/// so again -- forever. Rather than let that happen, a `static_assert` (whose +/// condition depends on @p Member and @p Ancestors, so it only fires for the +/// specific cyclic instantiation, not every use of this generator) rejects it +/// at compile time instead: a self-referential nested-aggregate type (e.g. +/// `struct Node { std::vector children; };`), or a mutual reference +/// between two distinct types, fails to build with a clear message rather +/// than exhausting the compiler's template-instantiation depth. This only +/// rejects genuine cycles -- the same type reused from two unrelated places +/// in the schema (a "diamond") is not on either path's ancestor chain and +/// recurses normally into both. See `docs/spec/forms/forms.md`, "Nested +/// aggregates (recursive, cycle-guarded)". +/// @tparam Member The static type of the member `annotateBasicMemberProperty` +/// was just applied to. +/// @tparam Ancestors The ancestor chain so far, ending with the type that +/// declares this member. +/// @param dom The whole schema DOM (so a `$ref`'s `$defs` entry can be found). +/// @param property The property node for this member (or, for `std::vector`, +/// the property whose `"items"` node is the one to check). +template +void recurseIntoNestedAggregateIfAny(glz::generic_u64& dom, glz::generic_u64& property) { + if constexpr (ReflectableAggregate) { + if constexpr ((std::same_as || ...)) { + static_assert(!(std::same_as || ...), + "morph::forms: cyclic nested-aggregate schema -- this member's type already " + "appears in its own chain of enclosing nested-aggregate types (a self- or " + "mutually-referential type). Recursion depth is otherwise unbounded, but cycles " + "are not supported: restructure the domain type (flatten the self-reference, or " + "represent the recursive edge as an opaque id instead of a nested value)."); + } else { + annotateNestedAggregateRef(dom, property); + } + } else if constexpr (IsStdVector::value && + ReflectableAggregate::ValueType>) { + using ItemType = typename IsStdVector::ValueType; + if constexpr ((std::same_as || ...)) { + static_assert(!(std::same_as || ...), + "morph::forms: cyclic nested-aggregate schema -- this std::vector member's " + "element type already appears in its own chain of enclosing nested-aggregate " + "types (a self- or mutually-referential type). Recursion depth is otherwise " + "unbounded, but cycles are not supported: restructure the domain type (flatten " + "the self-reference, or represent the recursive edge as an opaque id instead of " + "a nested value)."); + } else if (property.contains("items")) { + annotateNestedAggregateRef(dom, property["items"]); + } + } +} + +/// @brief Annotates @p node -- the object-schema DOM node for a +/// nested-aggregate member -- applying `required` and +/// `annotateBasicMemberProperty`'s rules to its own properties, then +/// recursing into any of *its* members that are themselves nested +/// aggregates (see `recurseIntoNestedAggregateIfAny`), to whatever +/// depth the type graph actually has. +/// +/// @p node is @e which DOM node depends on how many places in the whole +/// schema reference `Sub`: glaze **inlines** the object schema directly into +/// the referencing property when `Sub` is used exactly once (so @p node +/// *is* that property node), but **deduplicates** via `$defs`/`$ref` when +/// `Sub` is used two or more times (so @p node is the shared `$defs` entry, +/// resolved by the caller). Both forms have the identical `{"properties": +/// {...}}` shape this function needs, so one implementation handles both -- +/// see the call site in `mergeSchemaExtras` for how @p node is resolved. +/// +/// Computed fields, `formLayout`/`fieldSpans`, and `formRules` stay +/// top-level-only regardless of depth; a nested `Sub` declaring any of those +/// has no effect here. +/// +/// @tparam Sub Nested aggregate type (default-constructible, glaze-reflectable +/// -- the same requirements the top-level action type already has). +/// @tparam Ancestors The ancestor chain so far (excluding `Sub`); `Sub` is +/// appended before recursing into each of `Sub`'s own +/// members via `recurseIntoNestedAggregateIfAny` (see +/// that function's doc comment). +/// @param dom The whole schema DOM (so a deeper `$ref`'s `$defs` entry can be found). +/// @param node The object-schema DOM node to annotate in place (see above). +template +void annotateNestedAggregate(glz::generic_u64& dom, glz::generic_u64& node) { + Sub probe{}; + glz::generic_u64::array_t requiredNames{}; + forEachNamedMember(probe, [&](std::string_view name, const auto& member) { + using Member = std::remove_cvref_t; + if (!(isStdOptional || declaredOptional(name))) { + requiredNames.emplace_back(std::string{name}); + } + auto& property = node["properties"][std::string{name}]; + property["x-order"] = std::uint64_t{I}; + annotateBasicMemberProperty(property, name); + recurseIntoNestedAggregateIfAny(dom, property); + }); + // Idempotent if two members (or two actions sharing this schema call) + // resolve to the same $defs entry: re-deriving the identical required + // array is harmless. + node["required"] = requiredNames; +} + +/// @brief Resolves the object-schema DOM node for a nested-aggregate member, +/// given the property (or array `items`) node glaze wrote for it, and +/// annotates it via `annotateNestedAggregate`. +/// +/// Handles both forms `Sub` can take in the schema (see +/// `annotateNestedAggregate`'s doc comment): a `$ref` into `$defs` (`Sub` used +/// 2+ times somewhere in the schema) resolves to that shared def; anything +/// else is assumed to be the inlined object schema itself (`Sub` used exactly +/// once). A property that is neither -- glaze emitted something other than an +/// object schema for a type this function's caller already confirmed is a +/// `ReflectableAggregate` -- is left untouched rather than guessed at. +/// @tparam Sub Nested aggregate type, as `annotateNestedAggregate` requires. +/// @tparam Ancestors The ancestor chain so far (excluding `Sub`), forwarded +/// to `annotateNestedAggregate` unchanged. +/// @param dom The whole schema DOM (so a `$ref`'s `$defs` entry can be found). +/// @param propertyOrItems The property node itself (single nested member) or its +/// array `items` node (`std::vector` member). +template +void annotateNestedAggregateRef(glz::generic_u64& dom, glz::generic_u64& propertyOrItems) { + constexpr std::string_view kDefsPrefix = "#/$defs/"; + if (propertyOrItems.contains("$ref")) { + if (auto const* ref = propertyOrItems["$ref"].get_if()) { + if (std::string_view{*ref}.starts_with(kDefsPrefix)) { + auto const key = std::string{ref->substr(kDefsPrefix.size())}; + // Checked, not indexed-and-hope: glz::generic_u64's object + // storage reallocates on insert, so indexing a missing key + // here would both fabricate a bogus empty $defs entry AND -- + // now that annotateNestedAggregate recurses -- risk dangling + // a `node` reference an enclosing frame still holds into this + // same $defs map. Well-formed glaze output never names a + // $defs key that doesn't exist, so this only changes behavior + // for malformed input, which is left untouched instead. + if (dom.contains("$defs") && dom["$defs"].contains(key)) { + annotateNestedAggregate(dom, dom["$defs"][key]); + } + } + } + return; + } + if (propertyOrItems.contains("properties")) { + annotateNestedAggregate(dom, propertyOrItems); + } +} + /// @brief The DOM post-merge behind `schemaJson`: adds the derived `required` /// array, `x-order`, `x-decimalPlaces`, and (for actions declaring /// `computedFields`) `x-computed`/`x-readonly` to a glaze-produced schema. @@ -1592,108 +1885,24 @@ template property["x-computed"] = computedMeta; } - // Label/help/placeholder/read-only/hidden: an explicit FieldMeta - // entry overrides the inferred title and adds the rest; absent, every - // field still gets an inferred title and nothing else (Field - // metadata is additive/optional per gui_overview.md's versioning - // stance — a renderer that ignores these keys shows the raw wire key - // as the caption, no helper/placeholder text, every field editable - // and visible, exactly as before this feature). - const FieldMeta* fieldMeta = findFieldMeta(name); - std::string_view const declaredLabel = fieldMeta != nullptr ? fieldMeta->label : std::string_view{}; - property["title"] = declaredLabel.empty() ? inferTitle(name) : std::string{declaredLabel}; - if (fieldMeta != nullptr) { - if (!fieldMeta->help.empty()) { - property["description"] = std::string{fieldMeta->help}; - } - if (!fieldMeta->placeholder.empty()) { - property["x-placeholder"] = std::string{fieldMeta->placeholder}; - } - if (fieldMeta->readOnly) { - property["x-readonly"] = true; - } - if (fieldMeta->hidden) { - property["x-hidden"] = true; - } - if (!fieldMeta->i18nKey.empty()) { - property["x-i18nKey"] = std::string{fieldMeta->i18nKey}; - } - } - - if constexpr (units::isQuantity) { - // The field's *declared* precision: the unit default unless the - // field's type overrides it (Quantity). - property["x-decimalPlaces"] = std::uint64_t{Member::declaredDecimals}; - - // Convertible display/entry units with their exact ratios. - auto const alternatives = Member::unitAlternatives(); - if (!alternatives.empty()) { - glz::generic_u64::array_t list{}; - for (auto const& alternative : alternatives) { - auto const meta = - units::UnitTraits>::meta(alternative.unit); - glz::generic_u64 entry{}; - entry["id"] = std::string{meta.id}; - entry["display"] = std::string{meta.display}; - entry["decimals"] = std::uint64_t{meta.defaultDecimals}; - entry["num"] = alternative.num; - entry["den"] = alternative.den; - list.emplace_back(std::move(entry)); - } - property["x-unitAlternatives"] = list; - } - } - if constexpr (isChoice) { - // Which action serves the options, and which result-row fields - // carry the submitted value / display label. - property["x-optionsAction"] = std::string{Member::optionsAction()}; - property["x-optionValue"] = std::string{Member::valueField()}; - property["x-optionLabel"] = std::string{Member::labelField()}; - - // Sibling fields whose current values parameterise the options - // action (cascading picklists). Omitted entirely for an - // independent Choice, so the emitted schema is byte-for-byte - // unchanged from before this key existed. - if constexpr (!Member::optionsDependsOn().empty()) { - glz::generic_u64::array_t dependsOn{}; - for (auto const& parentName : Member::optionsDependsOn()) { - dependsOn.emplace_back(std::string{parentName}); - } - property["x-optionsDependsOn"] = dependsOn; - } - } - - // Widget hint: the field type's own widget() (e.g. Multiline, - // Ranged), overridden — if present — by a fieldMetadata-shaped entry - // naming this field. The override always wins over the type-derived - // default. - std::string_view widgetHint{}; - if constexpr (DeclaresWidget) { - widgetHint = Member::widget(); - } - if constexpr (HasFieldMetadataWidgets) { - if (auto const overrideWidget = widgetOverride(name); !overrideWidget.empty()) { - widgetHint = overrideWidget; - } - } - if (!widgetHint.empty()) { - property["x-widget"] = std::string{widgetHint}; - } - if constexpr (DeclaresRangedBounds) { - // The slider's control track: a UI hint, not a validation bound - // (glaze's own minimum/maximum, when present, stay authoritative - // for validation regardless of what x-widget ends up here). - using Bound = std::remove_cvref_t; - if constexpr (std::floating_point) { - property["x-min"] = static_cast(Member::min()); - property["x-max"] = static_cast(Member::max()); - property["x-step"] = static_cast(Member::step()); - } else { - property["x-min"] = static_cast(Member::min()); - property["x-max"] = static_cast(Member::max()); - property["x-step"] = static_cast(Member::step()); - } - } + // Label/title/FieldMeta/Quantity/Choice/widget/ranged-bounds: shared + // with the nested-aggregate recursion's per-member pass so both apply + // identical rules (see annotateBasicMemberProperty's doc comment). + annotateBasicMemberProperty(property, name); + + // Nested aggregates (recursive, cycle-guarded -- docs/spec/forms/forms.md, + // "Nested aggregates (recursive, cycle-guarded)"): a member whose type + // is itself a reflectable aggregate gets an object schema from glaze -- + // either inlined directly into this property (the type is used exactly + // once in the whole schema) or shared via `$defs`/`$ref` (used 2+ + // times). `recurseIntoNestedAggregateIfAny` resolves whichever form it + // is and recurses so that object schema's own members get + // `x-order`/`required`/title/Quantity/Choice/widget annotations too, + // however deep the type graph goes (guarding against cycles at compile + // time -- see that function's doc comment). Purely additive: an action + // with no nested aggregate member has nothing here to trigger on, so + // its schema is byte-for-byte unchanged. + recurseIntoNestedAggregateIfAny(dom, property); }); // Always assign — an explicit empty array beats leaving whatever the // schema writer may have emitted (or omitted) for `required`. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 86bb7ff2..fb534c86 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,6 +60,7 @@ add_executable(morph_tests test_rational.cpp test_quantity.cpp test_quantity_forms.cpp + test_nested_forms.cpp test_flows_apps.cpp test_views.cpp test_computed_fields.cpp diff --git a/tests/test_nested_forms.cpp b/tests/test_nested_forms.cpp new file mode 100644 index 00000000..b120345b --- /dev/null +++ b/tests/test_nested_forms.cpp @@ -0,0 +1,597 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Coverage for issue #25: form generation recurses into a nested-aggregate +// member's object schema -- a directly-nested struct member or a +// `std::vector` repeated aggregate -- applying the same +// title/x-order/required/widget rules the top level already applies, instead +// of leaving it entirely unannotated. Two distinct schema shapes exist for a +// nested aggregate (see forms.hpp's `annotateNestedAggregateRef`): glaze +// *inlines* the object schema directly into the property when the nested +// type is used exactly once in the whole schema, and *deduplicates* it via a +// shared `$defs` entry (referenced by `$ref`) when it is used two or more +// times. Both are exercised below. Recursion continues to whatever depth the +// type graph actually has, stopping only at a genuine cycle -- a compile-time +// `static_assert`, not something this runtime test suite can exercise +// directly (see docs/spec/forms/forms.md, "Nested aggregates (recursive, +// cycle-guarded)"). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using morph::math::DecimalPlaces; +using morph::math::Denominator; +using morph::math::Numerator; +using morph::math::Rational; + +// A minimal application unit system, purely so a nested-aggregate member can +// carry a Quantity field (see RichSub below) -- exercises the +// `annotateBasicMemberProperty` Quantity branch one level down, which none of +// the plain-scalar nested types above (Specimen/Attachment/Provenance) +// touch. `relations` declares a g<->kg conversion so RichSub's `mass` field +// also exercises the `unitAlternatives()`-non-empty branch one level down +// (see the "FieldMeta/Quantity/Choice" test case below). +enum class NestedFormUnit : std::uint8_t { scalar, kg, g }; + +template <> +struct morph::units::UnitTraits { + static constexpr morph::units::UnitMeta meta(NestedFormUnit unit) noexcept { + switch (unit) { + case NestedFormUnit::kg: + return {.id = "kg", .display = "kg", .defaultDecimals = 3}; + case NestedFormUnit::g: + return {.id = "g", .display = "g", .defaultDecimals = 1}; + case NestedFormUnit::scalar: + default: + return {.id = "scalar", .display = "", .defaultDecimals = 3}; + } + } + + static constexpr std::array, 1> relations{ + {{NestedFormUnit::g, NestedFormUnit::kg, Rational{Numerator{1}, Denominator{1000}, DecimalPlaces{3}}}}}; +}; + +// Named namespace (not anonymous): glaze reflection requires the reflected +// type to have linkage (see test_bridge_fixes.cpp for the same note). +namespace nestedforms { + +struct Specimen { + double massDry = 0.0; + double massWet = 0.0; + std::optional note; // never required, one level down either +}; + +struct Attachment { + std::string filename; + std::int64_t sizeBytes = 0; +}; + +struct Origin { + std::string country; +}; + +// Three levels deep: DeepSpecimen -> Provenance -> Origin. Provenance's own +// member (origin) is itself a nested aggregate too -- proving recursion +// continues past one level. +struct Provenance { + std::string collectedBy; + Origin origin; +}; + +struct DeepSpecimen { + double massDry = 0.0; + Provenance provenance; +}; + +// A self-referential nested-aggregate type (a tree node). Never passed to +// morph::forms::schemaJson() anywhere in this file -- neither as the +// top-level action type itself nor nested inside another action's member -- +// either use would trip forms.hpp's cycle-guard static_assert (see +// docs/spec/forms/forms.md, "Nested aggregates (recursive, cycle-guarded)"). +// This only proves the type itself, and ordinary glaze JSON round-tripping +// over it, are completely unaffected by that guard. +struct TreeNode { + std::string name; + std::vector children; +}; + +// Specimen and Attachment are each used from two places below, so glaze +// deduplicates both via a shared `$defs` entry referenced by `$ref`. +struct Record { + std::string operatorName; // flat -- unaffected by this feature + double temperature = 0.0; // flat -- unaffected by this feature + Specimen reference; // single nested aggregate ($ref form) + Specimen secondary; // second use of Specimen -> forces $ref/$defs + Attachment primary; // single nested aggregate ($ref form) + std::vector files; // second use of Attachment -> forces $ref/$defs +}; + +// Specimen used exactly once here -- glaze inlines the object schema +// directly into the "reference" property instead of using $defs/$ref. +struct SingleUseRecord { + Specimen reference; +}; + +// Attachment used exactly once here (only via the vector) -- glaze inlines +// the object schema into the "files" property's "items" instead of $defs/$ref. +struct SingleUseVectorRecord { + std::vector files; +}; + +struct DeepRecord { + DeepSpecimen sample; +}; + +// A nested aggregate whose own members carry the same annotation-worthy +// shapes the top-level pass already covers elsewhere (FieldMeta, Quantity, +// Choice): proves `annotateBasicMemberProperty` applies those rules one level +// down too, not just title/x-order/required (the only things Specimen/ +// Attachment/Provenance above exercise). +struct RichSub { + std::int64_t code = 0; + morph::units::Quantity mass{}; + morph::forms::Choice option; + + static constexpr std::array fieldMetadata{ + morph::forms::FieldMeta{.field = "code", + .label = "Custom Code", + .help = "Help text", + .placeholder = "e.g. 42", + .i18nKey = "custom.code", + .widget = "custom-widget", + .readOnly = true, + .hidden = true}, + }; +}; + +// RichSub used exactly once -- inlined, not deduplicated via $defs/$ref (see +// the "inline form" tests above for why that matters to resolution). +struct RichRecord { + RichSub rich; +}; + +// A nested aggregate whose FieldMeta entry sets only .field/.label -- the +// mirror of RichSub's "code" above: fieldMeta is found, but every optional +// attribute (help/placeholder/readOnly/hidden/i18nKey) is left at its default, +// so each of their "found but set" branches must resolve false one level down. +struct PlainMetaSub { + std::int64_t code = 0; + + static constexpr std::array fieldMetadata{ + morph::forms::FieldMeta{.field = "code", .label = "Plain Code"}, + }; +}; + +struct PlainMetaRecord { + PlainMetaSub plain; +}; + +// A nested aggregate declaring a non-`std::optional`-typed member optional via +// `optionalFields` -- exercises `declaredOptional` one level down +// (Specimen/DeepSpecimen above only ever exercise the std::optional path). +struct DeclaredOptionalSub { + std::string label; + + static constexpr std::array optionalFields{"label"}; +}; + +struct DeclaredOptionalRecord { + DeclaredOptionalSub sub; +}; + +} // namespace nestedforms + +// A unit with no declared relations, so `unitAlternatives()` is empty -- +// exercises the "no unit alternatives" branch of `annotateBasicMemberProperty` +// one level down (RichSub's `mass` above only ever exercises the non-empty +// case). +enum class BareFormUnit : std::uint8_t { scalar }; + +template <> +struct morph::units::UnitTraits { + static constexpr morph::units::UnitMeta meta(BareFormUnit) noexcept { + return {.id = "scalar", .display = "", .defaultDecimals = 2}; + } + + static constexpr std::array, 0> relations{}; +}; + +namespace nestedforms { + +struct BareQuantitySub { + morph::units::Quantity amount{}; +}; + +struct BareQuantityRecord { + BareQuantitySub bare; +}; + +} // namespace nestedforms + +using nestedforms::Attachment; +using nestedforms::BareQuantityRecord; +using nestedforms::DeclaredOptionalRecord; +using nestedforms::DeepRecord; +using nestedforms::DeepSpecimen; +using nestedforms::Origin; +using nestedforms::PlainMetaRecord; +using nestedforms::Provenance; +using nestedforms::Record; +using nestedforms::RichRecord; +using nestedforms::SingleUseRecord; +using nestedforms::SingleUseVectorRecord; +using nestedforms::Specimen; +using nestedforms::TreeNode; + +namespace { + +// Resolves the object-schema DOM node for a nested-aggregate member, given +// the property (or array `items`) node glaze wrote for it -- mirroring +// exactly what forms.hpp's `annotateNestedAggregateRef` resolves in +// production: a `$ref` into `$defs` (2+ uses) or the node itself, inlined +// (exactly one use). +const glz::generic_u64& resolveNestedSchema(const glz::generic_u64& dom, const glz::generic_u64& propertyOrItems) { + if (propertyOrItems.contains("$ref")) { + constexpr std::string_view kPrefix = "#/$defs/"; + std::string const ref = propertyOrItems["$ref"].get(); + REQUIRE(ref.starts_with(kPrefix)); + return dom["$defs"][ref.substr(kPrefix.size())]; + } + REQUIRE(propertyOrItems.contains("properties")); + return propertyOrItems; +} + +std::vector requiredNamesOf(const glz::generic_u64& node) { + std::vector out; + for (auto const& entry : node["required"].get()) { + out.push_back(entry.get()); + } + return out; +} + +} // namespace + +// ── Flat top-level fields are unaffected ──────────────────────────────────── + +TEST_CASE("Forms::SchemaJson::NestedAggregate: flat top-level members still render exactly as before", + "[forms][nested]") { + auto const schema = morph::forms::schemaJson(); + REQUIRE_FALSE(schema.empty()); + + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + CHECK(dom["properties"]["operatorName"]["x-order"].as() == 0); + CHECK(dom["properties"]["temperature"]["x-order"].as() == 1); + CHECK(dom["properties"]["operatorName"]["title"].get() == "Operator Name"); +} + +// ── Single nested aggregate member, deduplicated ($ref/$defs form) ───────── + +TEST_CASE("Forms::SchemaJson::NestedAggregate: a nested struct member's $defs entry gets annotated ($ref form)", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("reference")); + auto const& def = resolveNestedSchema(dom, dom["properties"]["reference"]); + REQUIRE(dom["properties"]["reference"].contains("$ref")); // Specimen used twice -> deduplicated + + CHECK(def["properties"]["massDry"]["x-order"].as() == 0); + CHECK(def["properties"]["massWet"]["x-order"].as() == 1); + CHECK(def["properties"]["massDry"]["title"].get() == "Mass Dry"); + CHECK(def["properties"]["massWet"]["title"].get() == "Mass Wet"); + + // required derives the same way one level down: massDry/massWet are + // required, the std::optional note is not. + REQUIRE(def.contains("required")); + auto const requiredNames = requiredNamesOf(def); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "massDry") != requiredNames.end()); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "massWet") != requiredNames.end()); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "note") == requiredNames.end()); +} + +// ── Repeated aggregate (std::vector), deduplicated ($ref/$defs form) ── + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: a std::vector repeated-aggregate member's items def is annotated " + "($ref form)", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("files")); + REQUIRE(dom["properties"]["files"].contains("items")); + auto const& itemsNode = dom["properties"]["files"]["items"]; + REQUIRE(itemsNode.contains("$ref")); // Attachment used twice -> deduplicated + auto const& def = resolveNestedSchema(dom, itemsNode); + + CHECK(def["properties"]["filename"]["x-order"].as() == 0); + CHECK(def["properties"]["sizeBytes"]["x-order"].as() == 1); + CHECK(def["properties"]["filename"]["title"].get() == "Filename"); + + REQUIRE(def.contains("required")); + auto const requiredNames = requiredNamesOf(def); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "filename") != requiredNames.end()); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "sizeBytes") != requiredNames.end()); +} + +// ── Single nested aggregate member, inlined (used exactly once) ──────────── + +TEST_CASE("Forms::SchemaJson::NestedAggregate: a singly-used nested struct member is annotated in place (inline form)", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("reference")); + CHECK_FALSE(dom["properties"]["reference"].contains("$ref")); // inlined, not deduplicated + auto const& def = resolveNestedSchema(dom, dom["properties"]["reference"]); + + CHECK(def["properties"]["massDry"]["x-order"].as() == 0); + CHECK(def["properties"]["massDry"]["title"].get() == "Mass Dry"); + REQUIRE(def.contains("required")); + auto const requiredNames = requiredNamesOf(def); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "massDry") != requiredNames.end()); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "note") == requiredNames.end()); +} + +// ── Repeated aggregate (std::vector), inlined (used exactly once) ───── + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: a singly-used std::vector member's items are annotated in place " + "(inline form)", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("files")); + REQUIRE(dom["properties"]["files"].contains("items")); + auto const& itemsNode = dom["properties"]["files"]["items"]; + CHECK_FALSE(itemsNode.contains("$ref")); // inlined, not deduplicated + auto const& def = resolveNestedSchema(dom, itemsNode); + + CHECK(def["properties"]["filename"]["x-order"].as() == 0); + CHECK(def["properties"]["sizeBytes"]["x-order"].as() == 1); + REQUIRE(def.contains("required")); +} + +// ── Recursion continues past one level (no depth cap) ─────────────────────── + +TEST_CASE("Forms::SchemaJson::NestedAggregate: recursion continues past one level to whatever depth exists", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("sample")); + auto const& level1Def = resolveNestedSchema(dom, dom["properties"]["sample"]); + + // Level 1 (DeepSpecimen's own members) is annotated. + CHECK(level1Def["properties"]["massDry"].contains("x-order")); + CHECK(level1Def["properties"]["massDry"].contains("title")); + REQUIRE(level1Def.contains("required")); + + // Level 2 (Provenance, nested inside DeepSpecimen) is now annotated too -- + // both its own property node (x-order/title, same as any level-1 member) + // and, unlike the old one-level cap, its own "required" array. + REQUIRE(level1Def["properties"].contains("provenance")); + CHECK(level1Def["properties"]["provenance"].contains("x-order")); + CHECK(level1Def["properties"]["provenance"].contains("title")); + auto const& level2Def = resolveNestedSchema(dom, level1Def["properties"]["provenance"]); + REQUIRE(level2Def.contains("required")); + CHECK(level2Def["properties"]["collectedBy"].contains("x-order")); + CHECK(level2Def["properties"]["collectedBy"].contains("title")); + + // Level 3 (Origin, nested inside Provenance) is annotated too -- proving + // recursion does not stop at two levels either. + REQUIRE(level2Def["properties"].contains("origin")); + CHECK(level2Def["properties"]["origin"].contains("x-order")); + CHECK(level2Def["properties"]["origin"].contains("title")); + auto const& level3Def = resolveNestedSchema(dom, level2Def["properties"]["origin"]); + REQUIRE(level3Def.contains("required")); + CHECK(level3Def["properties"]["country"].contains("x-order")); + CHECK(level3Def["properties"]["country"].contains("title")); +} + +// ── FieldMeta/Quantity/Choice rules apply one level down too ──────────────── + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: FieldMeta/Quantity/Choice annotations apply to a nested member's own " + "properties", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("rich")); + CHECK_FALSE(dom["properties"]["rich"].contains("$ref")); // RichSub used exactly once -> inlined + auto const& def = resolveNestedSchema(dom, dom["properties"]["rich"]); + + // FieldMeta: label/help/placeholder/readOnly/hidden, from RichSub's own + // fieldMetadata (looked up against RichSub, the Owner one level down — + // not against RichRecord). + auto const& codeProp = def["properties"]["code"]; + CHECK(codeProp["title"].get() == "Custom Code"); + CHECK(codeProp["description"].get() == "Help text"); + CHECK(codeProp["x-placeholder"].get() == "e.g. 42"); + CHECK(codeProp["x-readonly"].get() == true); + CHECK(codeProp["x-hidden"].get() == true); + CHECK(codeProp["x-i18nKey"].get() == "custom.code"); + CHECK(codeProp["x-widget"].get() == "custom-widget"); + // code has no FieldMeta-driven readOnly/hidden peers to compare against in + // this fixture, so also confirm a field the fieldMetadata list does not + // name -- mass -- carries none of these keys. + auto const& massProp = def["properties"]["mass"]; + CHECK_FALSE(massProp.contains("x-readonly")); + CHECK_FALSE(massProp.contains("x-hidden")); + CHECK_FALSE(massProp.contains("x-placeholder")); + + // Quantity: x-decimalPlaces from the unit's declared decimals, plus + // x-unitAlternatives from the g<->kg relation declared above. + CHECK(massProp["x-decimalPlaces"].as() == 3); + REQUIRE(massProp.contains("x-unitAlternatives")); + auto const& alternatives = massProp["x-unitAlternatives"].get(); + REQUIRE(alternatives.size() == 1); + CHECK(alternatives[0]["id"].get() == "g"); + CHECK(alternatives[0]["num"].as() == 1); + CHECK(alternatives[0]["den"].as() == 1000); + + // Choice: x-optionsAction/x-optionValue/x-optionLabel. + auto const& optionProp = def["properties"]["option"]; + CHECK(optionProp["x-optionsAction"].get() == "NestedFormListOptions"); + CHECK(optionProp.contains("x-optionValue")); + CHECK(optionProp.contains("x-optionLabel")); + + // required still derives correctly one level down alongside all of the above. + REQUIRE(def.contains("required")); + auto const requiredNames = requiredNamesOf(def); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "code") != requiredNames.end()); +} + +// ── Idempotence: two members sharing the same nested type ────────────────── + +TEST_CASE("Forms::SchemaJson::NestedAggregate: re-annotating a shared $defs entry is harmless", "[forms][nested]") { + // Record reuses Specimen across "reference" and "secondary" (and + // Attachment across "primary" and "files"): the annotation pass runs once + // per property that resolves to a given def, so this proves multiple + // triggers into the same def produce one consistent, non-corrupted result. + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + std::string const refA = dom["properties"]["reference"]["$ref"].get(); + std::string const refB = dom["properties"]["secondary"]["$ref"].get(); + CHECK(refA == refB); // same underlying type -> same $defs entry + + auto const& def = resolveNestedSchema(dom, dom["properties"]["reference"]); + CHECK(def["properties"]["massDry"]["x-order"].as() == 0); + REQUIRE(def.contains("required")); +} + +// ── FieldMeta found but no optional attributes set ────────────────────────── + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: a FieldMeta entry with no optional attributes leaves them all unset", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("plain")); + auto const& def = resolveNestedSchema(dom, dom["properties"]["plain"]); + auto const& codeProp = def["properties"]["code"]; + CHECK(codeProp["title"].get() == "Plain Code"); + CHECK_FALSE(codeProp.contains("description")); + CHECK_FALSE(codeProp.contains("x-placeholder")); + CHECK_FALSE(codeProp.contains("x-readonly")); + CHECK_FALSE(codeProp.contains("x-hidden")); + CHECK_FALSE(codeProp.contains("x-i18nKey")); +} + +// ── Quantity member with no unit alternatives ─────────────────────────────── + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: a nested Quantity member with no unit alternatives omits " + "x-unitAlternatives", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("bare")); + auto const& def = resolveNestedSchema(dom, dom["properties"]["bare"]); + auto const& amountProp = def["properties"]["amount"]; + CHECK(amountProp.contains("x-decimalPlaces")); + CHECK_FALSE(amountProp.contains("x-unitAlternatives")); +} + +// ── declaredOptional applies one level down too ───────────────────────────── + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: optionalFields marks a non-std::optional nested member as not required", + "[forms][nested][issue25]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + REQUIRE(dom["properties"].contains("sub")); + auto const& def = resolveNestedSchema(dom, dom["properties"]["sub"]); + REQUIRE(def.contains("required")); + auto const requiredNames = requiredNamesOf(def); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "label") == requiredNames.end()); +} + +// ── annotateNestedAggregateRef's defensive fallbacks (issue #25) ─────────── +// +// These call the detail function directly with hand-built DOM fragments, +// rather than through schemaJson(), because glaze itself never actually +// produces the malformed shapes these branches guard against -- see the +// function's own doc comment ("left untouched rather than guessed at"). + +TEST_CASE("Forms::SchemaJson::NestedAggregate: annotateNestedAggregateRef leaves a non-string $ref untouched", + "[forms][nested][issue25]") { + glz::generic_u64 dom{}; + glz::generic_u64 property{}; + property["$ref"] = std::uint64_t{42}; // malformed: $ref present but not a string + morph::forms::detail::annotateNestedAggregateRef(dom, property); + CHECK_FALSE(property.contains("required")); +} + +TEST_CASE("Forms::SchemaJson::NestedAggregate: annotateNestedAggregateRef leaves a $ref outside #/$defs/ untouched", + "[forms][nested][issue25]") { + glz::generic_u64 dom{}; + glz::generic_u64 property{}; + property["$ref"] = std::string{"#/other/Specimen"}; + morph::forms::detail::annotateNestedAggregateRef(dom, property); + CHECK_FALSE(dom.contains("$defs")); +} + +TEST_CASE( + "Forms::SchemaJson::NestedAggregate: annotateNestedAggregateRef leaves a schema with neither $ref nor " + "properties untouched", + "[forms][nested][issue25]") { + glz::generic_u64 dom{}; + glz::generic_u64 property{}; + property["type"] = std::string{"string"}; // glaze emitted something other than an object schema + morph::forms::detail::annotateNestedAggregateRef(dom, property); + CHECK_FALSE(property.contains("required")); + CHECK(property["type"].get() == "string"); +} + +// ── Self-referential nested-aggregate type, standalone ───────────────────── + +TEST_CASE("Forms::SchemaJson::NestedAggregate: a self-referential nested-aggregate type round-trips fine on its own", + "[forms][nested][issue25]") { + // TreeNode is never passed to morph::forms::schemaJson() in this file + // -- see its doc comment. This only proves the type itself, and ordinary + // glaze JSON round-tripping over it, are completely unaffected by + // forms.hpp's cycle-guard static_assert, which fires only when a type + // like this is actually nested under some schemaJson() instantiation. + TreeNode root{}; + root.name = "root"; + TreeNode child{}; + child.name = "child"; + root.children.push_back(child); + + std::string const json = glz::write_json(root).value_or(std::string{}); + REQUIRE_FALSE(json.empty()); + + TreeNode decoded{}; + REQUIRE_FALSE(glz::read_json(decoded, json)); + CHECK(decoded.name == "root"); + REQUIRE(decoded.children.size() == 1); + CHECK(decoded.children[0].name == "child"); +}