Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ below) `DynamicForm.qml`'s `resolveProp` does exactly this dual read.
| `x-widget` | property node (sibling of `$ref`) | string | The preferred control id: `"textarea"`, `"slider"`, `"radio"`, `"combo"`, `"password"`, `"checkbox"`, … A `fieldMetadata`-shaped override (a `.field`/`.widget` entry, read structurally — see [widget_hints.md](widget_hints.md)) wins; else the field type's own `widget()` (`Multiline`, `Ranged`). **Advisory** — a renderer that lacks the named control falls back to the type-default control (text area → text field, slider → numeric input, radio → combo). Omitted when neither a wrapper type nor an override supplies one. |
| `x-min` | property node (sibling of `$ref`) | number | Slider lower bound, from `Ranged::min()`. Emitted only for a `Ranged` field. Distinct from glaze's schema `minimum` (a *validation* bound, when present) — `x-min` is the *control track* start and is never enforced. |
| `x-max` | property node (sibling of `$ref`) | number | Slider upper bound, from `Ranged::max()`. Emitted only for a `Ranged` field. |
| `x-exactMinimum` | wherever `minimum` sits (property node, or the `$def` reached through its `$ref`) | string | Exact decimal spelling of `minimum`, emitted **only** when the bound's magnitude exceeds 2^53 — i.e. when an IEEE-754 double cannot hold it. See [Exact numeric bounds](#exact-numeric-bounds--x-minimumtext--x-maximumtext). |
| `x-exactMaximum` | wherever `maximum` sits | string | Exact decimal spelling of `maximum`, under the same condition. |
| `x-step` | property node (sibling of `$ref`) | number | Slider / numeric increment, from `Ranged::step()`. Emitted only for a `Ranged` field. For a `Quantity` the entry granularity remains `x-decimalPlaces` (above); `x-step` is not emitted for `Quantity`. |
| `x-minimum` | property node (sibling of `$ref`) | object `{num,den,dp}` | Inclusive lower bound for the field's value, from a model's `InstanceConstraints` — an exact `Rational` in the same wire shape as the value it bounds, never a `double`. Emitted only for a decorated instance schema ([Per-instance constraints](#per-instance-constraints--values-that-live-in-data)); never by `schemaJson<A>()`. Distinct from `x-min` (a *slider track* start, which is never checked). |
| `x-maximum` | property node (sibling of `$ref`) | object `{num,den,dp}` | Inclusive upper bound, same source and shape as `x-minimum`. |
Expand Down Expand Up @@ -694,6 +696,46 @@ constraints such as `minimum`/`maximum`. Integers on this path are emitted as
bare, exact numbers — the payload is assembled as JSON *text*, never round-tripped
through `JSON.parse`, so values beyond 2^53 (including `INT64_MAX`) survive
intact.
### Exact numeric bounds — `x-exactMinimum` / `x-exactMaximum`

`minimum` and `maximum` are standard JSON-Schema vocabulary, stamped by glaze.
They are JSON *numbers*, and a renderer reaches them by parsing the schema —
every shipped app does `JSON.parse(controller.schemasJson)`. JavaScript numbers
are IEEE-754 doubles, so any bound above 2^53 loses precision at that moment:

```
schema maximum for an int64_t field: 9223372036854775807
after JSON.parse into a JS number: 9223372036854775808 (rounded up)
```

That breaks the client-side gate at exactly the value it is closest to failing
on. `INT64_MAX + 1` compared against a maximum rounded *up* to
`9223372036854775808` is judged **equal, not greater**, so the renderer's own
validation admits an out-of-range value. Nothing is corrupted — the payload is
assembled as JSON text and keeps the exact digits, and the server rejects it
with `parse_number_failure` — but the client claimed a value was valid that
never was.

`schemaJson<A>()` therefore also emits the bound as an exact decimal **string**,
which `JSON.parse` cannot round. A renderer that validates integer input should
prefer `x-exactMinimum`/`x-exactMaximum` when present and fall back to the numeric
`minimum`/`maximum` otherwise. The shipped `DynamicForm.qml` compares digits
directly in that case, since no JS number can hold the bound.

Two deliberate limits:

- **Emitted only above 2^53.** An ordinary bound (`int32_t`, a `Ranged` slider,
a hand-written `maximum: 10`) loses nothing to a double, so its schema is
byte-for-byte what it was before this key existed. Only the definitions that
genuinely need it — `$defs/int64_t`, `$defs/uint64_t` — carry the companion.
- **The numeric bound stays.** The companion is additive: `minimum`/`maximum`
remain exactly as glaze emitted them, so a renderer that ignores the new keys
behaves precisely as it did before, per the versioning stance below.

Note the companion sits **wherever the bound sits**. For a `std::int64_t`
member that is the `$defs` entry the property's `$ref` points at, not the
property node — a renderer reads it from the merged node after resolving the
`$ref` (or the non-null `anyOf` branch), the same way it reads `type`.

### Versioning stance

Expand Down
78 changes: 78 additions & 0 deletions include/morph/forms/forms.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2135,6 +2135,80 @@ void rejectUnsatisfiableRules(const glz::generic_u64::array_t& xRules,
}
}

/// @brief Largest magnitude an IEEE-754 double holds exactly: 2^53.
///
/// A JSON number beyond this cannot survive `JSON.parse` intact, so a bound
/// above it needs an exact companion the renderer can read instead.
inline constexpr std::uint64_t kExactDoubleLimit = 9007199254740992ULL;

/// @brief Signed spelling of `kExactDoubleLimit`, for the negative bound.
inline constexpr std::int64_t kExactDoubleLimitSigned = 9007199254740992LL;

/// @brief Adds an exact decimal-string companion for one numeric bound, when
/// the bound is too large for a double to hold exactly.
///
/// `minimum`/`maximum` are standard JSON-Schema vocabulary stamped by glaze,
/// and `mergeSchemaExtras` reads the schema in u64 number mode precisely so
/// they are not rounded on the C++ side. They are rounded anyway the moment a
/// renderer does `JSON.parse(controller.schemasJson)`, which every shipped app
/// does -- `INT64_MAX` becomes `9223372036854775808`, and a client-side gate
/// comparing against it then admits `INT64_MAX + 1` as "not greater"
/// (morph#213). The exact digits travel as a string, which `JSON.parse` cannot
/// round.
///
/// Emitted only above `kExactDoubleLimit`: an ordinary bound loses nothing to a
/// double, so schemas that do not need this are byte-for-byte unchanged.
///
/// @param node Schema node to annotate in place (a property or a `$defs` entry).
/// @param key Bound to read: `"minimum"` or `"maximum"`.
/// @param textKey Companion key to write: `"x-exactMinimum"` or `"x-exactMaximum"`.
// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
inline void annotateExactBound(glz::generic_u64& node, const std::string& key, const std::string& textKey) {
if (!node.contains(key)) {
return;
}
auto const& bound = node[key];
// std::cmp_* rather than a cast: the two bounds arrive in different
// signednesses and the limit is unsigned, so a cast would be the very
// sign-mismatch this comparison exists to get right.
if (bound.template holds<std::uint64_t>()) {
auto const value = bound.template get<std::uint64_t>();
if (std::cmp_greater(value, kExactDoubleLimit)) {
node[textKey] = std::to_string(value);
}
} else if (bound.template holds<std::int64_t>()) {
auto const value = bound.template get<std::int64_t>();
if (std::cmp_greater(value, kExactDoubleLimit) || std::cmp_less(value, -kExactDoubleLimitSigned)) {
node[textKey] = std::to_string(value);
}
}
}

/// @brief Walks a schema DOM, adding `x-exactMinimum`/`x-exactMaximum` wherever a
/// bound is too large for a double.
///
/// Recursive over the whole document rather than over `properties` alone,
/// because the bounds that actually matter live in `$defs`: a `std::int64_t`
/// member is emitted as a `$ref` to `$defs/int64_t`, and that definition is
/// where `minimum`/`maximum` sit.
///
/// @param node Node to walk; objects and arrays recurse, scalars are left alone.
// NOLINTNEXTLINE(misc-no-recursion) -- walking a JSON tree is inherently recursive
inline void annotateExactNumericBounds(glz::generic_u64& node) {
if (node.is_object()) {
annotateExactBound(node, "minimum", "x-exactMinimum");
annotateExactBound(node, "maximum", "x-exactMaximum");
for (auto& [childKey, child] : node.get_object()) {
annotateExactNumericBounds(child);
}
} else if (node.is_array()) {
for (auto& child : node.get_array()) {
annotateExactNumericBounds(child);
}
}
}
// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)

/// @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.
Expand Down Expand Up @@ -2311,6 +2385,10 @@ template <typename A>
dom["x-rules"] = xRules;
}

// Exact companions for any bound a double cannot hold (morph#213). Last,
// so it also covers nodes added by the passes above.
annotateExactNumericBounds(dom);

// value_or without a move: the copy is irrelevant (schemaJson memoises),
// and keeping the fallback branch inside glaze's expected avoids an
// untestable line here (write_json of a DOM we just built cannot fail).
Expand Down
54 changes: 50 additions & 4 deletions src/qt/forms/qml/DynamicForm.qml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ Frame {
return value === undefined ? fallback : value
}

// Three-way compare of two integers held as decimal strings: -1, 0, 1.
// Needed because a JS number cannot hold an int64 bound exactly, so the
// comparison has to happen on digits (morph#213). Inputs are already
// /^-?\d+$/-validated by the caller.
function compareIntText(left, right) {
const leftNeg = left.charAt(0) === "-"
const rightNeg = right.charAt(0) === "-"
if (leftNeg !== rightNeg)
return leftNeg ? -1 : 1
// Strip sign and leading zeros so "007" and "7" compare equal.
const leftDigits = left.replace(/^-?0*/, "") || "0"
const rightDigits = right.replace(/^-?0*/, "") || "0"
let cmp = 0
if (leftDigits.length !== rightDigits.length)
cmp = leftDigits.length < rightDigits.length ? -1 : 1
else if (leftDigits < rightDigits)
cmp = -1
else if (leftDigits > rightDigits)
cmp = 1
// Both negative reverses the magnitude ordering.
return leftNeg ? -cmp : cmp
}

// Follow a $ref into $defs; attributes on the field win over the def's.
function resolveRef(prop) {
if (prop && prop["$ref"] !== undefined) {
Expand Down Expand Up @@ -262,6 +285,16 @@ Frame {
required: required.indexOf(name) !== -1,
minimum: p.minimum,
maximum: p.maximum,
// Exact decimal-string companions for a bound a double
// cannot hold. `p.minimum`/`p.maximum` reached this object
// through JSON.parse (every app does
// `JSON.parse(controller.schemasJson)`), so an int64 bound
// is already rounded by the time it gets here -- INT64_MAX
// arrives as 9223372036854775808. These strings are not
// (morph#213). Undefined for any bound a double holds
// exactly, which is the overwhelmingly common case.
exactMinimum: p["x-exactMinimum"],
exactMaximum: p["x-exactMaximum"],
section: opt(raw["x-section"], p["x-section"]),
colspan: opt(opt(raw["x-colspan"], p["x-colspan"]), 1),
isMultiline: widget === "textarea",
Expand Down Expand Up @@ -719,13 +752,26 @@ Frame {
if (f.isInteger) {
if (!/^-?\d+$/.test(text))
return null
// Normalise "007" -> "7": JSON forbids leading zeros in numbers.
const normalised = text.replace(/^(-?)0+(?=\d)/, "$1")
// Prefer the exact string bound when the schema carries one: a
// double-valued bound rounds at 2^53, and comparing INT64_MAX + 1
// against a maximum rounded *up* to 9223372036854775808 judges it
// "not greater" and lets it through the gate (morph#213).
const value = parseInt(text)
if (f.minimum !== undefined && value < f.minimum)
if (f.exactMinimum !== undefined) {
if (compareIntText(normalised, f.exactMinimum) < 0)
return null
} else if (f.minimum !== undefined && value < f.minimum) {
return null
if (f.maximum !== undefined && value > f.maximum)
}
if (f.exactMaximum !== undefined) {
if (compareIntText(normalised, f.exactMaximum) > 0)
return null
} else if (f.maximum !== undefined && value > f.maximum) {
return null
// Normalise "007" -> "7": JSON forbids leading zeros in numbers.
return text.replace(/^(-?)0+(?=\d)/, "$1")
}
return normalised
}
if (f.isBoolean) {
// Emitted bare, never quoted. The CheckBox only ever stores these
Expand Down
Loading
Loading