morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
instance_constraints.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/instance_constraints.hpp
6/// @brief Per-*instance* constraints on a form field: the seam by which data
7/// that lives in a database row — not in a template parameter — reaches
8/// the framework-meaningful keys of a served schema, and is checked
9/// against a submitted value by the same declaration that served it.
10///
11/// `schemaJson<A>()` is a pure function of the compiled action type `A`: every
12/// key it emits is derived from `A`'s members, so two rows that describe the
13/// *same* action differently — an analysis catalogue whose version 1 declares
14/// three decimal places and whose version 2 declares one — cannot be told
15/// apart by anything the framework enforces. Before this header the only way
16/// out was for the application to patch a *second*, app-private key beside the
17/// framework's (`x-versionDecimalPlaces` next to `x-decimalPlaces`) and
18/// re-implement the check by hand, which leaves a renderer with two keys for
19/// one concept and no way to know which to believe.
20///
21/// `InstanceConstraints` replaces that with one declaration that does both
22/// jobs:
23///
24/// - `decorate()` rewrites the served schema so the *framework's own* keys
25/// carry the instance's values (`x-decimalPlaces`, `x-minimum`,
26/// `x-maximum`), and stamps the document-level `x-instanceConstraints` array
27/// naming every field whose keys came from instance data rather than from
28/// the compiled type — so a renderer can tell the two apart.
29/// - `checkAction()` / `checkValue()` report what a submitted value violates,
30/// against the identical declaration. Serving a bound and checking it can no
31/// longer drift apart, because there is only one place the bound is written.
32///
33/// @par What this deliberately does not do
34/// The framework does **not** decide what a violation means. An out-of-range
35/// value is a rejection in one domain and a value to *flag* in another (a lab
36/// result outside its specification range is reportable, not refusable), so
37/// `checkAction`/`checkValue` return violations and the model applies its own
38/// policy. Nor is any of this wired into the dispatch runners: those decode an
39/// action from bytes with no access to the row a constraint lives in (see
40/// docs/spec/forms/instance_constraints.md, "Why dispatch cannot apply these
41/// automatically"). A model that decorates a schema is responsible for
42/// checking against the same constraints; the `x-instanceConstraints` stamp is
43/// what makes a schema that was decorated visible to a reviewer and a
44/// renderer.
45///
46/// @par Structure is still compile-time
47/// Only *values* of existing keys vary per instance. Which fields exist, which
48/// are required, and the `x-rules` list all remain functions of the compiled
49/// type — an instance cannot add a field. See the spec's "Limitations".
50
51#include <cstdint>
52#include <glaze/glaze.hpp>
53#include <optional>
54#include <string>
55#include <string_view>
56#include <vector>
57
58#include "../attributes.hpp"
59#include "../util/quantity.hpp"
60#include "../util/rational.hpp"
61#include "forms.hpp"
62
63namespace morph::forms {
64
65/// @brief Which framework-meaningful key a submitted value fell foul of.
66enum class ConstraintViolationKind : std::uint8_t {
67 /// @brief The value is strictly below the declared inclusive `minimum`.
69 /// @brief The value is strictly above the declared inclusive `maximum`.
71 /// @brief The value is not exactly representable at the declared
72 /// `decimalPlaces` — it carries more precision than the instance
73 /// says the field has.
75};
76
77/// @brief One violation: which field, and which declared key it broke.
79 /// @brief The offending field's wire (JSON) name.
80 std::string field;
81 /// @brief What was violated.
83
84 /// @brief Value equality, so violations can be compared in tests.
85 /// @param other The violation to compare against.
86 /// @return `true` when both the field and the kind match.
87 [[nodiscard]] bool operator==(const ConstraintViolation& other) const noexcept = default;
88};
89
90/// @brief A stable, machine-readable name for a violation kind — for log
91/// lines and model-level error messages.
92/// @param kind The violation kind to name.
93/// @return `"belowMinimum"`, `"aboveMaximum"` or `"precisionExceeded"`.
94[[nodiscard]] inline std::string_view violationKindName(ConstraintViolationKind kind) noexcept {
95 switch (kind) {
97 return "belowMinimum";
99 return "aboveMaximum";
101 return "precisionExceeded";
102 default:
103 return "unknown";
104 }
105}
106
107/// @brief The per-instance constraint declared for one field of an action.
108///
109/// Every member other than `field` is optional and defaults to "not declared":
110/// an absent `decimalPlaces` leaves the compiled `x-decimalPlaces` alone, and
111/// an absent bound emits nothing and checks nothing. A constraint naming a
112/// field the action does not have is ignored (schema decoration never throws
113/// over an application's declaration mistake, exactly like `formLayout`).
115 /// @brief Wire (JSON) key of the member this entry constrains.
116 std::string field;
117
118 /// @brief The instance's decimal precision for this field. Overwrites
119 /// `x-decimalPlaces`, and a submitted value that is not exactly
120 /// representable at this many places is `PrecisionExceeded`.
121 std::optional<std::uint32_t> decimalPlaces{};
122
123 /// @brief Inclusive lower bound; emitted as `x-minimum`.
124 std::optional<math::Rational> minimum{};
125
126 /// @brief Inclusive upper bound; emitted as `x-maximum`.
127 std::optional<math::Rational> maximum{};
128};
129
130namespace detail {
131
132/// @brief `10^places`, for the exact-representability test below.
133///
134/// `Rational` bounds its own `decimalPlaces` to `kMaxDecimalPlaces` (18, the
135/// largest power of ten that fits an `std::int64_t`), and callers clamp to the
136/// same range before calling this, so it cannot overflow.
137/// @param places Decimal places, in `[0, morph::math::kMaxDecimalPlaces]`.
138/// @return `10^places`.
139[[nodiscard]] constexpr std::int64_t powerOfTenForConstraint(std::uint32_t places) noexcept {
140 std::int64_t result = 1;
141 for (std::uint32_t i = 0; i < places; ++i) {
142 result *= 10;
143 }
144 return result;
145}
146
147/// @brief Whether @p value is *exactly* representable at @p places decimals.
148///
149/// `Rational` keeps `gcd(|numerator|, denominator) == 1`, so `numerator *
150/// 10^places` is divisible by `denominator` exactly when `10^places` is — the
151/// test needs no multiplication and therefore cannot overflow.
152/// @param value The submitted exact value.
153/// @param places The instance's declared decimal places.
154/// @return `true` when the value needs no rounding at that precision.
155[[nodiscard]] constexpr bool exactAtDecimals(const math::Rational& value, std::uint32_t places) noexcept {
156 if (places > math::kMaxDecimalPlaces) {
157 return false;
158 }
159 return powerOfTenForConstraint(places) % value.denominator == 0;
160}
161
162/// @brief Renders one exact bound as the same `{"num","den","dp"}` node shape
163/// a `Rational` uses on the wire, so a renderer parses a bound exactly
164/// the way it parses the value it bounds.
165/// @param bound The exact bound.
166/// @return The DOM node.
167[[nodiscard]] inline glz::generic_u64 boundNode(const math::Rational& bound) {
168 glz::generic_u64 node{};
169 // NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) — glaze DOM requires operator[]
170 node["num"] = bound.numerator;
171 node["den"] = bound.denominator;
172 node["dp"] = static_cast<std::uint64_t>(bound.decimalPlaces.value);
173 // NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
174 return node;
175}
176
177} // namespace detail
178
179/// @brief A set of per-field constraints sourced from one instance (a database
180/// row, a tenant record, a catalogue version) — the single declaration
181/// that both decorates a served schema and checks a submitted value.
182///
183/// @code{.cpp}
184/// morph::forms::InstanceConstraints constraints;
185/// constraints.declare({.field = "value",
186/// .decimalPlaces = version.decimalPlaces,
187/// .minimum = version.specLow,
188/// .maximum = version.specHigh});
189///
190/// // Serving: the framework's own keys now carry the version's values.
191/// const auto schema = morph::forms::instanceSchemaJson<CaptureConcentration>(constraints);
192///
193/// // Checking: the same object, so the served bound and the checked bound
194/// // cannot disagree.
195/// for (const auto& violation : constraints.checkAction(action)) { ... }
196/// @endcode
198public:
199 /// @brief Declares (or replaces, by field name) one field's constraint.
200 /// @param constraint The constraint to record.
201 /// @return `*this`, so declarations can be chained — a reference into this
202 /// `InstanceConstraints`, valid only for as long as it is.
203 InstanceConstraints& declare(FieldConstraint constraint) MORPH_LIFETIMEBOUND {
204 for (auto& existing : _fields) {
205 if (existing.field == constraint.field) {
206 existing = std::move(constraint);
207 return *this;
208 }
209 }
210 _fields.push_back(std::move(constraint));
211 return *this;
212 }
213
214 /// @brief The constraint declared for @p field, if any.
215 /// @param field The wire (JSON) field name to look up.
216 /// @return A pointer to the entry, or `nullptr` when the field is
217 /// unconstrained — into this `InstanceConstraints`, so valid only
218 /// for as long as it is. Invalidated by any later `declare()`.
219 [[nodiscard]] const FieldConstraint* forField(std::string_view field) const noexcept MORPH_LIFETIMEBOUND {
220 for (const auto& entry : _fields) {
221 if (entry.field == field) {
222 return &entry;
223 }
224 }
225 return nullptr;
226 }
227
228 /// @brief Whether nothing has been declared.
229 /// @return `true` when no field carries a constraint.
230 [[nodiscard]] bool empty() const noexcept { return _fields.empty(); }
231
232 /// @brief Every declared constraint, in declaration order.
233 /// @return The constraint entries — a reference into this
234 /// `InstanceConstraints`, valid only for as long as it is.
235 [[nodiscard]] const std::vector<FieldConstraint>& fields() const noexcept MORPH_LIFETIMEBOUND { return _fields; }
236
237 /// @brief Rewrites @p schema so the framework's own keys carry this
238 /// instance's values.
239 ///
240 /// For each declared field that the schema actually has a property for:
241 /// `x-decimalPlaces` is **overwritten** with the declared precision (when
242 /// one is declared), and `x-minimum` / `x-maximum` are added as
243 /// `{"num","den","dp"}` nodes. The document then carries
244 /// `x-instanceConstraints`: the array of field names whose keys came from
245 /// instance data, which is how a renderer (and a reviewer) tells a
246 /// decorated schema from the compiled one.
247 ///
248 /// Never throws and never mangles: a @p schema that is not readable JSON
249 /// is returned verbatim, and a constraint naming a field the schema has no
250 /// property for is skipped — the same "an author's declaration mistake is
251 /// not worth a crash" rule `mergeSchemaExtras` follows.
252 /// @param schema The compiled schema text, typically `schemaJson<A>()`.
253 /// @return The decorated schema text.
254 [[nodiscard]] std::string decorate(std::string schema) const {
255 if (_fields.empty()) {
256 return schema;
257 }
258 // NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) — glaze DOM requires operator[]
259 // u64 number mode, for the same reason mergeSchemaExtras uses it: the
260 // schema carries int64/uint64 bounds in $defs that a double-only DOM
261 // would silently round on the round trip.
262 glz::generic_u64 dom{};
263 if (glz::read_json(dom, schema)) {
264 return schema;
265 }
266 // Reads go through findMember, which answers "is it there?" and "where
267 // is it?" in one probe and cannot grow the document on a miss; the
268 // `x-*` writes below intend `operator[]`'s insert and keep it, which
269 // is what the suppression above is for.
270 auto* const properties = detail::findMember(dom, "properties");
271 if (properties == nullptr) {
272 return schema;
273 }
274 glz::generic_u64::array_t decorated{};
275 for (const auto& entry : _fields) {
276 auto* const property = detail::findMember(*properties, entry.field);
277 if (property == nullptr) {
278 continue; // names a field the action does not have: ignored, never thrown
279 }
280 if (entry.decimalPlaces.has_value()) {
281 (*property)["x-decimalPlaces"] =
282 static_cast<std::uint64_t>(math::detail::clampWireDecimalPlaces(*entry.decimalPlaces));
283 }
284 if (entry.minimum.has_value()) {
285 (*property)["x-minimum"] = detail::boundNode(*entry.minimum);
286 }
287 if (entry.maximum.has_value()) {
288 (*property)["x-maximum"] = detail::boundNode(*entry.maximum);
289 }
290 decorated.emplace_back(entry.field);
291 }
292 if (decorated.empty()) {
293 return schema;
294 }
295 dom["x-instanceConstraints"] = decorated;
296 // NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
297 return glz::write_json(dom).value_or(schema);
298 }
299
300 /// @brief Checks one exact value against the constraint declared for
301 /// @p field.
302 ///
303 /// The value-level entry point, for a model whose stored value is
304 /// *derived* from the payload rather than taken from it verbatim (a
305 /// reading multiplied by a dilution factor, a total recomputed from line
306 /// items): the constraint governs the value the model is about to store,
307 /// which is not a member of the action at all.
308 /// @param field The wire (JSON) field name the value belongs to.
309 /// @param value The exact value to check.
310 /// @return Every violation, in `minimum`, `maximum`, `decimalPlaces`
311 /// order; empty when the field is unconstrained or the value
312 /// satisfies every declared key.
313 [[nodiscard]] std::vector<ConstraintViolation> checkValue(std::string_view field,
314 const math::Rational& value) const {
315 std::vector<ConstraintViolation> violations{};
316 const auto* entry = forField(field);
317 if (entry == nullptr) {
318 return violations;
319 }
320 if (entry->minimum.has_value() && (value <=> *entry->minimum) == std::strong_ordering::less) {
321 violations.push_back({.field = std::string{field}, .kind = ConstraintViolationKind::BelowMinimum});
322 }
323 if (entry->maximum.has_value() && (value <=> *entry->maximum) == std::strong_ordering::greater) {
324 violations.push_back({.field = std::string{field}, .kind = ConstraintViolationKind::AboveMaximum});
325 }
326 if (entry->decimalPlaces.has_value() && !detail::exactAtDecimals(value, *entry->decimalPlaces)) {
327 violations.push_back({.field = std::string{field}, .kind = ConstraintViolationKind::PrecisionExceeded});
328 }
329 return violations;
330 }
331
332 /// @brief Checks every constrained `Quantity` member of @p action.
333 ///
334 /// Walks the action's reflected members in declaration order and runs
335 /// `checkValue` for each engaged `Quantity` whose wire name carries a
336 /// constraint. An empty `Quantity` is skipped (emptiness is `required`'s
337 /// business, not a bound's), as is any member that is not a `Quantity` —
338 /// bounds and decimal precision are exact-value concepts, and `Quantity`
339 /// is the only field type morph forms carry an exact value in. A no-op for
340 /// an action type glaze cannot reflect.
341 /// @tparam A Action type (a reflectable aggregate).
342 /// @param action The decoded action to check.
343 /// @return Every violation across every constrained field, in member
344 /// declaration order.
345 template <typename A>
346 [[nodiscard]] std::vector<ConstraintViolation> checkAction(const A& action) const {
347 using Plain = std::remove_cvref_t<A>;
348 std::vector<ConstraintViolation> violations{};
349 if constexpr (glz::reflectable<Plain> || glz::glaze_object_t<Plain>) {
350 forms::detail::forEachNamedMember(action, [&]<std::size_t I>(std::string_view name, const auto& member) {
351 static_cast<void>(I);
352 using Member = std::remove_cvref_t<decltype(member)>;
353 if constexpr (units::isQuantity<Member>) {
354 if (member.hasValue()) {
355 for (auto& violation : checkValue(name, *member)) {
356 violations.push_back(std::move(violation));
357 }
358 }
359 } else {
360 static_cast<void>(name);
361 static_cast<void>(member);
362 }
363 });
364 } else {
365 static_cast<void>(action);
366 }
367 return violations;
368 }
369
370private:
371 std::vector<FieldConstraint> _fields{};
372};
373
374/// @brief `schemaJson<A>()` decorated with @p constraints — the served form
375/// for one instance.
376///
377/// Not memoised: unlike `schemaJson<A>()` (one cached string per compiled
378/// type), the result varies per instance, so the caller caches it if it wants
379/// caching. The compiled half is still memoised inside `schemaJson<A>()`, so
380/// the per-call cost is the DOM round trip, not schema generation.
381/// @tparam A Action type (a reflectable aggregate).
382/// @param constraints The instance's constraints.
383/// @return The decorated schema text.
384template <typename A>
385[[nodiscard]] std::string instanceSchemaJson(const InstanceConstraints& constraints) {
386 return constraints.decorate(schemaJson<A>());
387}
388
389} // namespace morph::forms
A set of per-field constraints sourced from one instance (a database row, a tenant record,...
const std::vector< FieldConstraint > & fields() const noexcept
Every declared constraint, in declaration order.
std::vector< ConstraintViolation > checkValue(std::string_view field, const math::Rational &value) const
Checks one exact value against the constraint declared for field.
bool empty() const noexcept
Whether nothing has been declared.
std::string decorate(std::string schema) const
Rewrites schema so the framework's own keys carry this instance's values.
const FieldConstraint * forField(std::string_view field) const noexcept
The constraint declared for field, if any.
InstanceConstraints & declare(FieldConstraint constraint)
Declares (or replaces, by field name) one field's constraint.
std::vector< ConstraintViolation > checkAction(const A &action) const
Checks every constrained Quantity member of action.
JSON-Forms-style schema generation for morph actions.
const std::string & schemaJson()
Generates the JSON Schema for action type A, ready for a client-side form renderer.
Definition forms.hpp:3705
std::string_view violationKindName(ConstraintViolationKind kind) noexcept
A stable, machine-readable name for a violation kind — for log lines and model-level error messages.
std::string instanceSchemaJson(const InstanceConstraints &constraints)
schemaJson<A>() decorated with constraints — the served form for one instance.
ConstraintViolationKind
Which framework-meaningful key a submitted value fell foul of.
@ BelowMinimum
The value is strictly below the declared inclusive minimum.
@ AboveMaximum
The value is strictly above the declared inclusive maximum.
@ PrecisionExceeded
The value is not exactly representable at the declared decimalPlaces — it carries more precision than...
Unit-tagged, optionally-empty exact values with a derivation trail.
constexpr bool isQuantity
true when T (cvref-stripped) is a morph::units::Quantity.
Definition quantity.hpp:950
Exact rational arithmetic for morph model/action values.
One violation: which field, and which declared key it broke.
std::string field
The offending field's wire (JSON) name.
bool operator==(const ConstraintViolation &other) const noexcept=default
Value equality, so violations can be compared in tests.
ConstraintViolationKind kind
What was violated.
The per-instance constraint declared for one field of an action.
std::optional< math::Rational > maximum
Inclusive upper bound; emitted as x-maximum.
std::optional< math::Rational > minimum
Inclusive lower bound; emitted as x-minimum.
std::optional< std::uint32_t > decimalPlaces
The instance's decimal precision for this field. Overwrites x-decimalPlaces, and a submitted value th...
std::string field
Wire (JSON) key of the member this entry constrains.
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
DecimalPlaces decimalPlaces
Decimal-precision tag. Invariant: 0 <= value <= kMaxDecimalPlaces.
Definition rational.hpp:383