Skip to content

forms: two Choice fields of different types collapse into one $defs entry, and the renderer draws the wrong control #543

Description

@Yaraslaut

Part of the sweep tracked in #518. Finding F25.

Summary

choice.hpp:162 and widget_hints.hpp:138 give every instantiation the same schema name:

static constexpr std::string_view name = "Choice";   // for EVERY instantiation
static constexpr std::string_view name = "Ranged";   // for EVERY Min/Max/Step

glaze keys $defs by name_v<T> and populates a definition only once (glaze/json/schema.hpp:1069-1072: auto& def = defs[name_v<val_t>]; if (!def.type) { … }). The second distinct instantiation is silently skipped and $refs the first one's definition.

Verification status

Reproduced. Revision: master @ 4017228d. Compiled against the repo's pinned glaze.

For struct BoolFirst { Choice<bool,"YesNo"> flag; Choice<std::int64_t,"ListSamples"> sampleId; }:

"sampleId":{"$ref":"#/$defs/Choice", ...}
"$defs":{"Choice":{"type":["boolean","null"]}}

An int64 picklist described as a boolean. Also verified:

  • Choice<std::string,"ListCodes"> next to Choice<std::int64_t,…> → the string field gets {"type":["integer","null"],"minimum":-9223372036854775808,…}.
  • Ranged<0.0,1.0,0.1> next to Ranged<0,100> → {"type":["integer","null"],"minimum":-2147483648,…} while the property correctly carries "x-step":0.1. Every legal value of the double slider fails the type it was served under.

Not verified: I did not drive the QML renderer end-to-end to observe the wrong control on screen; the control-selection path is traced by reading (below).

Why this is not benign

docs/spec/forms/choice.md:150-161 and widget_hints.md:128-138 call this benign — "renderers that submit the raw nullable value observe no problem". That is false for morph's own shipped renderer:

  • src/qt/forms/qml/DynamicForm.qml:229 (resolveRef) merges the $def over the property.
  • :460 sets isBoolean: types.indexOf("boolean") !== -1.
  • :1069 draws a checkbox.

It is also false for any generic validating client: the document emits additionalProperties: false and a standard required array, i.e. it is presented as validatable.

This is the same failure mode that the glaze_enum_t static_assert at forms.hpp:2064 was added to prevent (morph#392), re-entering through a different door.

Suggested fix

Make name unique per instantiation, exactly as Quantity already does (util/quantity.hpp:1209 composes from UnitTraits<…>::meta(U).id):

  • Choice — compose from the FixedString NTTPs, e.g. "Choice_" + OptionsAction + "_" + ValueField.
  • Ranged — compose from a constexpr rendering of Min/Max/Step.

Both are compile-time deterministic and compiler-independent (important: they must not derive from glz::name_v, for the reason payload_schema.hpp:66-70 gives).

This changes $defs keys, so it is a wire-shape version bump — but the current behaviour is a wrong schema, not a compatible one.

If the name cannot change, the minimum fix is a static_assert rejecting two Choice/Ranged instantiations with different payload types in one action.

What would change the verdict

  • Close it if the renderer contract is changed so that $def type is never consulted — but DynamicForm.qml:460 consults it today.
  • Regression test: generate a schema for an action with two differently-typed Choice members and assert both $defs entries exist with the right types. A single-Choice action passes with or without the fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: formsSubsystem: formsbugSomething isn't workingtriage: rescopeReal problem, wrong framing; rewrite before building

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions