Skip to content

forms: recurse into nested-aggregate members' schemas to any depth - #35

Merged
Yaraslaut merged 13 commits into
masterfrom
feature/25-nested-aggregate-forms
Aug 5, 2026
Merged

forms: recurse into nested-aggregate members' schemas to any depth#35
Yaraslaut merged 13 commits into
masterfrom
feature/25-nested-aggregate-forms

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 3, 2026

Copy link
Copy Markdown
Member

Issue #25 asked whether flat-only form generation was a deliberate boundary or a gap worth closing. mergeSchemaExtras previously annotated (x-order, required, title, Quantity/Choice/widget hints) only an action's own top-level members — a member that was itself a reflectable aggregate (a nested struct, or std::vector<Sub>) got none of that on its own sub-members. This PR closes that gap fully: mergeSchemaExtras now recurses into a nested-aggregate member's own schema to whatever depth the type graph actually has, not just one level, so a naturally nested domain (a measurement with a repeated specimen sub-record, a document with a nested address, a category tree) gets consistent annotations at every level.

Unbounded depth needs a way to terminate on a genuinely cyclic type graph: a self-referential type (struct Node { std::vector<Node> children; };) or a mutual reference between two distinct types now fails to compile — a static_assert dependent on the specific cyclic instantiation — instead of recursing forever. This is the one source-compatibility exception to an otherwise purely additive change; no such type exists in this repo today.

Changes

  • annotateNestedAggregate/annotateNestedAggregateRef thread an Ancestors... template parameter chain (the nested-aggregate types already being annotated on the current path) through a new shared recurseIntoNestedAggregateIfAny, replacing the old one-level cap with cycle-guarded recursion to any depth.
  • Consolidated a ~90-line block in mergeSchemaExtras that duplicated annotateBasicMemberProperty's per-member annotation logic instead of calling it — both the top-level pass and the nested-recursion pass now share one implementation.
  • Closed a use-after-free window in the nested-aggregate $defs lookup that the new recursion made reachable (glz::generic_u64's object storage reallocates on insert, which could dangle a reference an enclosing recursive frame still holds).
  • docs/spec/forms/forms.md: replaces "Nested aggregates (one level)" with "Nested aggregates (recursive, cycle-guarded)", and reconciles the pre-existing normative renderer-contract section, which had assumed nested $defs entries are never patched.

Closes #25

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut force-pushed the feature/25-nested-aggregate-forms branch from 9b16554 to 371386b Compare August 4, 2026 16:38
Yaraslaut and others added 12 commits August 5, 2026 11:00
mergeSchemaExtras previously annotated (x-order, required, title, Quantity/
Choice/widget hints) only an action's own top-level members. A member that
was itself a reflectable aggregate -- a nested struct, or std::vector<Sub> --
got none of that on its own sub-members, and its $defs entry got no required
array at all, per docs/spec/forms/forms.md's documented "flat actions only"
boundary. Domains that are naturally nested (a measurement with a repeated
specimen sub-record) had to either flatten the action type (impossible for a
repeated sub-record without a fixed max count) or hand-write the form,
bypassing the schema-driven generator for exactly the screens that would
benefit most from it.

Recurse one level into a nested-aggregate member's own schema, applying the
same annotation rules the top level already applies (factored into a shared
annotateBasicMemberProperty helper). Two schema shapes exist for a nested
aggregate, both handled: glaze deduplicates via a shared $defs entry
referenced by $ref when the nested type is used two or more times anywhere
in the schema, or inlines the object schema directly into the property when
it is used exactly once -- annotateNestedAggregateRef resolves whichever
form applies before recursing.

Deliberately capped at exactly one level, matching the design doc's own
suggested bound: a nested aggregate's own nested-aggregate members are left
unannotated, and computed fields/formLayout/fieldSpans/formRules stay
top-level-only. Purely additive -- an action with no nested-aggregate member
has nothing to trigger on, so its schema is byte-for-byte unchanged (the full
existing suite, including every prior schema-generation test, passes
unmodified).

Render-side add/remove affordances for a repeated-aggregate array are a
separate, downstream renderer concern the issue explicitly called out as
distinct from the annotation-reach gap this closes, and are out of scope here.

Closes #25

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
The nested-aggregate annotation pass (issue #25) shares
annotateBasicMemberProperty with the top-level pass, so it applies the same
FieldMeta/Quantity/Choice/widget/ranged-bounds rules to a nested member's own
properties -- but every existing nested-forms fixture (Specimen, Attachment,
Provenance) only has plain scalar members, so those branches were never
exercised through the nested path. Add RichSub/RichRecord, a nested type
whose own members carry a FieldMeta override, a Quantity, and a Choice, and
assert the resolved nested schema carries the expected x-placeholder/
x-readonly/x-hidden/x-decimalPlaces/x-optionsAction annotations.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
…gaps

Re-applies two fixes that were accidentally dropped by a stale-branch
rebase (the local branch used for the rebase predated these commits on
origin, so `git rebase origin/master` + force-push clobbered them).

1. UnitTraits<NestedFormUnit>::meta()'s switch had only a `kg` case plus
   `default`, tripping -Wswitch-enum (part of -Weverything, and not in
   apply_warnings()'s opt-out list) since it requires every enumerator
   named regardless of a default label. Add an explicit
   `case NestedFormUnit::scalar:` falling through to the existing default
   body.

2. codecov/patch still failed (79.41%, target 97.11%) after fix #1 because
   three annotation branches in the nested-aggregate path this PR adds --
   FieldMeta::i18nKey, FieldMeta::widget, and Quantity::unitAlternatives()
   -- were structurally unreachable by the RichSub/RichRecord fixture.
   Declare a g<->kg unit relation and set i18nKey/widget on the fixture,
   and assert on x-i18nKey/x-widget/x-unitAlternatives at the nested
   level. Verified locally with llvm-cov: all three branches now execute
   at least once; full suite (826 cases) still passes.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Six new cases: a FieldMeta entry with no optional attributes set, a
nested Quantity member with no unit alternatives, optionalFields applied
one level down, and annotateNestedAggregateRef's three defensive
fallbacks (non-string $ref, $ref outside #/$defs/, neither $ref nor
properties) via direct detail:: calls, since glaze itself never produces
those malformed shapes through the public schemaJson<A>() path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the "one level" cap documented for mergeSchemaExtras with
cycle-guarded recursion to arbitrary depth: a nested-aggregate member's
own nested-aggregate members are now annotated too, however deep the
type graph goes, stopping only (via a static_assert) when a member's
type would repeat a type already on the current ancestor chain.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Three review passes: fixed a terminology wobble (the ancestor chain's
first element is the action type, not itself a "nested-aggregate
type"), tightened the cycle-detection paragraph's phrasing, and closed
an ambiguity about when the static_assert actually fires (only when
the offending type is nested under an action's schema, not for the
type standing alone).

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Three tasks: extend test fixtures with a failing three-level-deep test
(TDD red), implement the Ancestors-threaded cycle-guarded recursion in
forms.hpp (TDD green), then add a standalone self-referential-type
test plus a Doxygen docs-build verification pass.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Replaces the one-level "depth cap" test with a failing test proving
recursion should continue past one level: DeepSpecimen -> Provenance ->
Origin, three levels deep. Also adds a self-referential TreeNode
fixture (never passed to schemaJson<A>() here -- that would trip the
forthcoming cycle guard) proving such a type is unaffected on its own.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Threads an Ancestors... template parameter pack through
annotateNestedAggregate/annotateNestedAggregateRef -- the chain of
nested-aggregate types already being annotated on the current path,
starting with the action type. Factors the "is this member itself a
nested aggregate, and should I recurse into it" decision (previously
duplicated inline in mergeSchemaExtras's loop, and entirely absent from
annotateNestedAggregate since it never went past one level) into one
shared recurseIntoNestedAggregateIfAny, used by both loops.

A member whose type -- or, for std::vector<Sub>, Sub -- already appears
on the ancestor chain would recurse into this same instantiation again,
forever. Rather than let that happen, a static_assert (dependent on the
member type and the chain, so it only fires for the actual cyclic
instantiation) rejects a self- or mutually-referential nested-aggregate
type graph at compile time instead.

See docs/spec/forms/forms.md, "Nested aggregates (recursive,
cycle-guarded)".

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Two doc-only fixes caught by task review: annotateNestedAggregate's
@tparam Ancestors said the chain ends with Sub, when Sub is actually
appended only when recursing one level deeper (contradicting the
sibling comment on annotateNestedAggregateRef, which already had it
right). The file's top-of-header public API doc still described the
old one-level cap and pointed at a spec section title that no longer
exists.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Proves TreeNode -- a tree-shaped, self-referential nested-aggregate
type -- round-trips through glaze JSON encode/decode normally on its
own. It is never passed to schemaJson<A>() in this file; doing so
would trip forms.hpp's cycle-guard static_assert, which this test
suite has no harness to exercise directly (see
docs/spec/forms/forms.md, "Nested aggregates (recursive,
cycle-guarded)").

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Three Important findings from the final whole-branch review:
mergeSchemaExtras duplicated annotateBasicMemberProperty's ~90-line
body instead of calling it (the doc comment already falsely claimed
this sharing existed); annotateNestedAggregateRef's $defs lookup
inserted an empty entry on a miss, which the new recursion turned
from dormant into a live use-after-free risk (glz::generic_u64's
object storage reallocates on insert); and the spec's normative
renderer-contract section still asserted nested $defs are never
patched, which this branch's whole point contradicts. Also fixes a
broken anchor and adds a one-sentence compatibility caveat the same
review flagged as cheap Minor fixes.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
@Yaraslaut
Yaraslaut force-pushed the feature/25-nested-aggregate-forms branch from 2a72ea9 to 820a993 Compare August 5, 2026 10:11
@Yaraslaut Yaraslaut changed the title forms: recurse one level into nested-aggregate members' schemas forms: recurse into nested-aggregate members' schemas to any depth Aug 5, 2026
The plan document served its purpose during implementation; the spec
(docs/spec/forms/forms.md) and git history are the lasting record.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
@Yaraslaut
Yaraslaut merged commit 9002553 into master Aug 5, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Form generation cannot render nested aggregates (flat actions only)

1 participant