diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index b0ff9ef3..debd74a4 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -104,6 +104,10 @@ Satisfied by: `std::optional` is engaged. - `morph::time::Timestamp` — `hasValue()` returns `true` when its `DateTime` payload is present. +- `morph::util::Tagged` — `hasValue()` always returns `true`: it wraps + a *required* protocol scalar, not an optionally-empty one, so it opts into + this concept the same way the others do but never gates submission (see + [`tagged.md`](../util/tagged.md)). - Any user type that exposes `bool hasValue() const noexcept`. A **non**-empty-capable field (plain `int64_t`, `std::string`, …) is always diff --git a/docs/spec/util/quantity_type.md b/docs/spec/util/quantity_type.md index 7fd44bcc..2d78292a 100644 --- a/docs/spec/util/quantity_type.md +++ b/docs/spec/util/quantity_type.md @@ -719,8 +719,11 @@ readability). | `fromDouble` | `static Quantity fromDouble(double raw)` | Tags the leaf at `declaredPrecision()`; **never empty from a finite value** (empty only when `raw` is non-finite / doesn't fit). | | `fromOptional` | `static Quantity fromOptional(std::optional)` | Empty in → empty out, preserving the declared-precision arg. | -> The declared-decimals template argument `Dec` is constrained to `[1, kMaxDecimalPlaces]` (18). -> Values outside that range cause a `static_assert` failure at compile time. +> The declared-decimals template argument `Dec` is constrained to `[0, kMaxDecimalPlaces]` (18). +> Values outside that range cause a `static_assert` failure at compile time. `Dec == 0` is a +> legal, first-class declared precision — it is what a zero-decimal currency (JPY, KRW) or a +> plain integer count declares, and it formats and parses with no fractional digit or decimal +> point at all (see *How a value prints*). ### `Quantity` — access, precision, provenance @@ -730,7 +733,7 @@ readability). | `value()` | `const std::optional& value() const noexcept` | The payload; pattern-match or `->` it. | | `value_or(fallback)` | `Rational value_or(Rational const&) const` | Payload if engaged, else the fallback. | | `operator*` | `const Rational& operator*() const` | Unchecked access to the engaged value (UB when empty, like `std::optional`). | -| `withDecimalPlaces(p)` | `Quantity withDecimalPlaces(DecimalPlaces) const` | Retags actual precision (silently clamped to `[1, kMaxDecimalPlaces]`); no-op on empty. Value unchanged. | +| `withDecimalPlaces(p)` | `Quantity withDecimalPlaces(DecimalPlaces) const` | Retags actual precision (silently clamped to `[0, kMaxDecimalPlaces]`); no-op on empty. Value unchanged. | | `atDeclaredPrecision()` | `Quantity atDeclaredPrecision() const` | Retags actual precision to the declared one; no-op on empty. | | `named(label)` | `Quantity named(std::string label) const` | Returns a same-unit quantity marked as the symbol `label`; builds a fresh history node (no-op returning empty on empty, or with tracing off). | | `equation()` | `std::vector equation() const` | The worked formula as print-ready lines (see *Provenance*). Single-element (the formatted value) when empty or tracing off. | diff --git a/docs/spec/util/rational.md b/docs/spec/util/rational.md index 0fdf29e5..6011cd9a 100644 --- a/docs/spec/util/rational.md +++ b/docs/spec/util/rational.md @@ -39,14 +39,16 @@ Every public operation that produces a `Rational` restores: - `denominator > 0` (strictly positive — never zero, never negative) - `gcd(|numerator|, denominator) == 1` - canonical zero is `0/1` -- `1 <= decimalPlaces.value <= kMaxDecimalPlaces` +- `0 <= decimalPlaces.value <= kMaxDecimalPlaces` All sign lives in the numerator. **No default precision.** Every call site states the precision it intends, e.g. `Rational{Numerator{1}, Denominator{3}, DecimalPlaces{9}}`. Precision is capped at `kMaxDecimalPlaces` (18, the largest `k` for which `10^k` fits in `int64_t`); out-of-range values -assert in debug and clamp into `[1, kMaxDecimalPlaces]` in release. +assert in debug and clamp into `[0, kMaxDecimalPlaces]` in release. Zero decimal places is a +legal, first-class precision — a whole-number value (a zero-decimal currency such as JPY/KRW, or +a plain integer count) declares `DecimalPlaces{0}` and carries no fractional digit at all. The struct never throws. Operations that may fail (zero divisor, non-finite floating-point input, overflow during decimal scaling) return @@ -281,7 +283,7 @@ through `setWire`. |---|---|---| | `numerator` | `int64_t` | Carries the sign of the rational value. | | `denominator` | `int64_t` | Strictly positive. Never zero, never negative. | -| `decimalPlaces` | `DecimalPlaces` | `1 <= value <= kMaxDecimalPlaces`. | +| `decimalPlaces` | `DecimalPlaces` | `0 <= value <= kMaxDecimalPlaces`. | ### `Rational` — accessors @@ -344,7 +346,7 @@ expected operator+(Left const&, Right const&) noexcept; |---|---| | [`quantity_type.md`](quantity_type.md) | `Rational` is the **runtime substrate** for `Quantity`. A `Quantity`'s declared precision and the forms layer's `x-decimalPlaces` schema annotation both resolve, at runtime, to a `Rational`'s `DecimalPlaces` tag — the `dp` value carried on the wire and propagated through arithmetic here is exactly the precision a `Quantity` declares. The overflow envelope and `INT64_MIN` hazards documented above therefore bound `Quantity` too. | | [`forms.md`](../forms/forms.md) | The form generator reads `x-decimalPlaces` (and the `Rational` wire shape `{"num","den","dp"}`) to build precision-aware numeric inputs; a form value is a `Rational` under the hood, so its display uses `toDouble`/formatting and its exact value uses the wire codec. | -| [`security.md`](../security.md) | `setWire` performs the untrusted-wire **clamping** (`den == 0 → 1`, out-of-range `dp` → `[1, 18]`, `INT64_MIN` → `-INT64_MAX`). This is the boundary defence that keeps a hostile payload from reaching the UB-prone negation/overflow sites; see the clamping semantics discussion there. | +| [`security.md`](../security.md) | `setWire` performs the untrusted-wire **clamping** (`den == 0 → 1`, out-of-range `dp` → `[0, 18]`, `INT64_MIN` → `-INT64_MAX`). This is the boundary defence that keeps a hostile payload from reaching the UB-prone negation/overflow sites; see the clamping semantics discussion there. | | [`datetime.md`](datetime.md) | Contrast case for wire-decode policy: the `DateTime` codec is **strict** (rejects malformed input) whereas `Rational::setWire` is **lenient/clamping** (silently repairs it). See [Limitations](#limitations) for why the difference matters. | ## Limitations @@ -360,18 +362,16 @@ expected operator+(Left const&, Right const&) noexcept; `dp` → magnitude table. - **`setWire` clamps hostile input rather than rejecting it.** `den == 0` becomes `1`, an `INT64_MIN` component becomes `-INT64_MAX`, an out-of-range - `dp` is pulled into `[1, 18]`. The invariants are always restored, but a - *corrupt amount silently becomes a specific wrong number* — e.g. a payload - meant to carry `x/0` lands as `x/1`, a completely different value, with no - error surfaced. This is deliberate (a `Rational` never propagates UB from the - wire) but it trades detectability for robustness. It is the opposite policy - from the strict `DateTime` codec, which rejects malformed input outright - (cross-ref [`datetime.md`](datetime.md)); a caller that needs "reject, don't - guess" semantics for amounts must validate before decode. -- **`DecimalPlaces` has a floor of 1.** The invariant is `1 <= value <= 18`, so - precision **0 is unrepresentable**. This excludes zero-decimal currencies - (JPY, KRW) and plain integer counts from being tagged with their true - precision — they must borrow `dp = 1` and carry a spurious fractional digit. + `dp` is pulled into `[0, 18]` (only the upper bound can ever fire, since `dp` + is unsigned and `0` is itself a legal precision). The invariants are always + restored, but a *corrupt amount silently becomes a specific wrong number* — + e.g. a payload meant to carry `x/0` lands as `x/1`, a completely different + value, with no error surfaced. This is deliberate (a `Rational` never + propagates UB from the wire) but it trades detectability for robustness. It + is the opposite policy from the strict `DateTime` codec, which rejects + malformed input outright (cross-ref [`datetime.md`](datetime.md)); a caller + that needs "reject, don't guess" semantics for amounts must validate before + decode. - **`==` and `<=>` ignore precision, so equality is not substitutability.** Comparison is purely value-based on the canonical `(numerator, denominator)` pair. Two `Rational`s can therefore satisfy `a == b` while diff --git a/docs/spec/util/tagged.md b/docs/spec/util/tagged.md new file mode 100644 index 00000000..858ff01e --- /dev/null +++ b/docs/spec/util/tagged.md @@ -0,0 +1,153 @@ +# The `Tagged` type — design + +`morph::util::Tagged` is an opaque, type-safe newtype for protocol +scalars: a pagination cursor, an event id, a job id, a bearer token — anything +that is, on the wire, a bare `T` (usually `std::string` or an integer) but +must not be interchangeable at the C++ level with a different scalar that +happens to share the same underlying type. + +`Tagged` and `Tagged` carry +the identical wire representation but are distinct C++ types: a function +expecting one does not accept the other, and neither is constructible from +the other. Every application built on the framework otherwise hand-rolls the +same wrapper shape per protocol scalar; `Tagged` is the framework-level, +day-one primitive for it. + +## Contents + +- [Identity via the tag](#identity-via-the-tag) +- [Construction and access](#construction-and-access) +- [Comparison](#comparison) +- [Wire and schema](#wire-and-schema) +- [Forms palette — `hasValue()`](#forms-palette--hasvalue) +- [Design decisions](#design-decisions) +- [Cross-references](#cross-references) +- [Limitations](#limitations) +- [Out of scope](#out-of-scope) + +## Identity via the tag + +`Tag` is a `morph::detail::FixedString` non-type template parameter — the +same structural string type `morph::forms::Choice` and +`morph::units::NamedQuantity` use (`morph::detail::FixedString`, +`include/morph/detail/fixed_string.hpp`; there is one definition, not +several look-alikes). Because the tag lives in the type itself: + +- Two `Tagged` spelled identically in different translation units are + one and the same type — no companion `enum` or registry is needed to keep + tags distinct or to make ODR happy. +- `Tagged` and `Tagged` are unrelated types even though they + share `T`: neither converts to nor is constructible from the other + (`Tagged`'s only converting constructor takes a `T`, not another `Tagged` + specialization), so passing a `UserId` where an `AccountId` is expected is + a compile error, not a runtime data bug. +- `tag()` returns the tag text (`Tag.view()`) for introspection or logging. + +## Construction and access + +| Member | Signature | Notes | +|---|---|---| +| default ctor | `constexpr Tagged() noexcept(...)` | Wraps a default-constructed `T{}`. Not "empty" in the `Quantity`/`Choice` sense — see *Forms palette* below. | +| value ctor | `constexpr explicit Tagged(T wrapped) noexcept(...)` | Wraps @p wrapped. `explicit`, so a bare `T` never silently becomes a `Tagged` at a call site — the wrapping is always visible in the code. | +| `get()` | `const T& get() const noexcept` | Named read access. | +| `operator*()` | `const T& operator*() const noexcept` | Unchecked access, mirroring `Quantity`'s and `Choice`'s `operator*` for a consistent shape across the framework's newtype family — there is nothing to check here (the value always exists), but the same spelling reads uniformly next to `*quantity` / `*choice`. | +| `tag()` | `static constexpr std::string_view tag() noexcept` | The compile-time tag text. | + +## Comparison + +`operator==` is defaulted (present only when `T` is +`std::equality_comparable`), and `operator<=>` is defaulted (present only +when `T` is `std::three_way_comparable`) — both compare on the wrapped value +alone. Comparison is only ever defined between two `Tagged` of the **same** +`T` and `Tag`; there is no cross-tag `==`/`<=>` overload, so `userId == +accountId` fails to compile even when both wrap `std::string` — the same +protection the type provides at construction extends to comparison. + +## Wire and schema + +On the morph JSON wire a `Tagged` is **exactly its `T` payload** — +`glz::meta>` maps the instance to the single member `value`, +so it reads and writes byte-for-byte like a bare `T` (a `Tagged` writes as a JSON string, a `Tagged` as a +JSON number). The tag never appears in the wire payload. + +The schema tells a different, narrower story: `to_json_schema>` delegates to `to_json_schema` for the shape (`type`, numeric +bounds, and so on are identical to `T`'s own schema) but `glz::meta`'s `name` +is fixed to the tag text, so the schema's `title` carries the tag +(`"UserId"`, `"EventSeq"`) rather than `T`'s own type name. Two `Tagged` / `Tagged` therefore produce structurally identical but +distinctly titled schema entries — a client-side codegen tool can still tell +them apart even though the wire shape is shared. + +## Forms palette — `hasValue()` + +`Tagged` is a **required** scalar, not an optionally-empty field like +`Quantity` / `Choice` / `Timestamp`: it always holds a `T` (default- or +value-constructed), never `std::nullopt`. Its `hasValue()` therefore always +returns `true` (`noexcept`), which satisfies `morph::forms::EmptyCapableField` +— the concept the forms rule vocabulary, `allRequiredEngaged`, and +`recomputeOne`'s per-input engagement check all key on (see +[forms.md](../forms/forms.md)). + +Satisfying `EmptyCapableField` (rather than simply not satisfying it, as a +plain unwrapped scalar member would) means `Tagged` participates in the +palette the same way `Quantity`/`Choice`/`Timestamp` do — a rule can name a +`Tagged` field and `isEngaged()` resolves it via `hasValue()`, always `true` +— instead of being silently treated as "no empty state, so always counted +present" through the *other*, no-concept branch those helpers also support. +The visible behavior is the same (always engaged) either way; the difference +is that `Tagged` opts in explicitly rather than falling through the fallback +path, which matters if a future palette helper ever distinguishes "declares +no empty state" from "declares an empty state that never triggers." + +A `Tagged` field is therefore always **required** wherever +`morph::forms::schemaJson` derives requiredness, exactly like a plain +non-optional scalar member — wrap the underlying `T` in `std::optional` at +the call site (`std::optional>`) if a genuinely optional +tagged scalar is needed; `Tagged` itself does not grow a second, orthogonal +empty state. + +## Design decisions + +| Decision | Choice | Why | +|---|---|---| +| Identity mechanism | **NTTP string tag (`FixedString`), not a phantom enum/type parameter** | The tag lives in the type without a companion declaration; reuses the same structural string type `Choice`/`NamedQuantity` already rely on, so there is exactly one such type in the codebase. | +| Emptiness | **Always engaged (`hasValue()` → `true`)** | `Tagged` wraps a required protocol scalar (an id, a cursor, a token) — these are not normally optional fields. A genuinely optional tagged scalar composes via `std::optional>` instead of `Tagged` inventing a second empty state. | +| Construction | **`explicit` value constructor, no converting constructor from another `Tagged`** | A bare `T` never silently becomes a `Tagged` (visibility at the call site), and one tag's value never silently becomes another tag's value (no accidental cross-tag construction). | +| Wire | **Transparent — `glz::meta` maps straight to the payload** | Matches the existing newtype family (`Quantity`, `Choice`, `Timestamp` all reduce to their payload on the wire); the tag is a compile-time-only distinction, invisible to any wire consumer. | +| Schema `title` | **Set to the tag, not to `T`'s type name** | Lets client-side codegen distinguish `UserId` from `AccountId` in the generated schema even though both compile down to the same wire shape. | + +## Cross-references + +- **[`../detail/fixed_string.hpp`](../../../include/morph/detail/fixed_string.hpp)** + — the shared NTTP string type; see its own doc comment for the ODR/identity + argument this spec relies on. +- **[`choice.md`](../forms/choice.md)** — `Choice` + shares the same `FixedString` mechanism and the same "wraps to a payload + member on the wire" shape, but is optionally empty where `Tagged` is always + engaged. +- **[`forms.md`](../forms/forms.md)** — `EmptyCapableField`, `isEngaged`, and + `allRequiredEngaged` — the palette `Tagged`'s `hasValue()` plugs into. + +## Limitations + +- **No arithmetic, no formatting, no hashing.** `Tagged` deliberately offers + only construction, access, and same-type comparison — it does not forward + `T`'s other operations (arithmetic, `std::format` support, + `std::hash`). An application that needs one of those either unwraps via + `get()`/`operator*()` at the point of use or extends `Tagged` locally; nothing + here forwards automatically. +- **No implicit conversion to `T`.** Reaching the wrapped value always goes + through `get()` or `operator*()`; there is no `operator T()` that would let + a `Tagged` quietly decay back to a bare scalar (and, from there, back into + another tag's slot through an implicit `T` conversion). + +## Out of scope + +- Runtime-checked tag identity (e.g. a UUID namespace) — the tag is a + compile-time distinction only, erased entirely from the wire and from RTTI. +- A phantom-type variant (`Tagged`) — the chosen + mechanism is the `FixedString` NTTP already used elsewhere in the codebase, + for consistency; nothing prevents an application from rolling its own + phantom-type wrapper alongside `Tagged` if it prefers that idiom. diff --git a/include/morph/util/quantity.hpp b/include/morph/util/quantity.hpp index 36a13880..8167b99f 100644 --- a/include/morph/util/quantity.hpp +++ b/include/morph/util/quantity.hpp @@ -547,8 +547,8 @@ struct Context { template requires UnitEnum struct Quantity { - static_assert(DeclaredDecimals >= 1 && DeclaredDecimals <= math::kMaxDecimalPlaces, - "declared decimals must be within [1, kMaxDecimalPlaces]"); + static_assert(DeclaredDecimals <= math::kMaxDecimalPlaces, + "declared decimals must be within [0, kMaxDecimalPlaces]"); /// @brief The payload; `std::nullopt` means "not entered / not measured". std::optional payload; diff --git a/include/morph/util/rational.hpp b/include/morph/util/rational.hpp index 6340d327..22d26d08 100644 --- a/include/morph/util/rational.hpp +++ b/include/morph/util/rational.hpp @@ -24,7 +24,9 @@ /// `Rational{1, 3, DecimalPlaces{9}}`. Precision is capped at /// `kMaxDecimalPlaces` (18, the largest `k` for which `10^k` fits in /// `int64_t`); out-of-range values assert in debug and clamp into -/// `[1, kMaxDecimalPlaces]` in release. +/// `[0, kMaxDecimalPlaces]` in release. Zero decimal places is a legal +/// precision — a whole-number quantity (e.g. a JPY/KRW amount) carries no +/// fractional digit at all. /// /// @par Cross-precision arithmetic /// Binary arithmetic propagates `std::max` of the two operands' @@ -37,7 +39,7 @@ /// - `denominator > 0` (strictly positive — never zero, never negative) /// - `gcd(|numerator|, denominator) == 1` /// - canonical zero is `0/1` -/// - `1 <= decimalPlaces.value <= kMaxDecimalPlaces` +/// - `0 <= decimalPlaces.value <= kMaxDecimalPlaces` /// /// All sign lives in the numerator. /// @@ -107,7 +109,7 @@ namespace morph::math { /// Prevents the precision from being confused with a numerator or a /// denominator at a call site: `Rational{1, 3, DecimalPlaces{9}}`. struct DecimalPlaces { - /// @brief Raw digit count. Rational's invariant keeps it in [1, kMaxDecimalPlaces]. + /// @brief Raw digit count. Rational's invariant keeps it in [0, kMaxDecimalPlaces]. std::uint32_t value{}; constexpr DecimalPlaces() noexcept = default; @@ -163,12 +165,14 @@ enum class RationalError : std::uint8_t { namespace detail { -/// @brief Clamps a raw precision into `[1, kMaxDecimalPlaces]` silently — for +/// @brief Clamps a raw precision into `[0, kMaxDecimalPlaces]` silently — for /// untrusted wire input. +/// +/// The lower bound is `0`, not `1`: zero decimal places is a legal precision +/// (a whole-number quantity, e.g. a JPY/KRW amount with no fractional +/// subunit). `rawDecimalPlaces` is unsigned, so there is no below-zero case +/// to clamp; only the upper bound can ever fire. [[nodiscard]] constexpr std::uint32_t clampWireDecimalPlaces(std::uint32_t rawDecimalPlaces) noexcept { - if (rawDecimalPlaces < 1) { - return 1; - } if (rawDecimalPlaces > kMaxDecimalPlaces) { return kMaxDecimalPlaces; } @@ -178,7 +182,7 @@ namespace detail { /// @brief Clamps like `clampWireDecimalPlaces` but asserts in debug — for /// call sites that state a precision in code, where out-of-range is a bug. [[nodiscard]] constexpr std::uint32_t clampDecimalPlaces(std::uint32_t rawDecimalPlaces) noexcept { - assert(rawDecimalPlaces >= 1 && rawDecimalPlaces <= kMaxDecimalPlaces); + assert(rawDecimalPlaces <= kMaxDecimalPlaces); return clampWireDecimalPlaces(rawDecimalPlaces); } @@ -233,7 +237,7 @@ struct Rational { /// @brief Strictly positive denominator. Never zero, never negative. std::int64_t denominator{1}; - /// @brief Decimal-precision tag. Invariant: 1 <= value <= kMaxDecimalPlaces. + /// @brief Decimal-precision tag. Invariant: 0 <= value <= kMaxDecimalPlaces. DecimalPlaces decimalPlaces{1}; /// @brief Default-constructs the canonical zero (0/1) at precision 1. @@ -241,7 +245,7 @@ struct Rational { /// @brief Constructs from a whole integer at the given precision. /// @param whole The integer value; stored as `whole/1`. - /// @param wantedPrecision Decimal precision; clamped to [1, kMaxDecimalPlaces]. + /// @param wantedPrecision Decimal precision; clamped to [0, kMaxDecimalPlaces]. constexpr Rational(std::int64_t whole, DecimalPlaces wantedPrecision) noexcept : numerator{whole}, decimalPlaces{detail::clampDecimalPlaces(wantedPrecision.value)} {} @@ -253,7 +257,7 @@ struct Rational { /// /// @param wantedNumerator Signed numerator. /// @param wantedDenominator Denominator. May be negative or zero on input. - /// @param wantedPrecision Decimal precision; clamped to [1, kMaxDecimalPlaces]. + /// @param wantedPrecision Decimal precision; clamped to [0, kMaxDecimalPlaces]. constexpr Rational(Numerator wantedNumerator, Denominator wantedDenominator, DecimalPlaces wantedPrecision) noexcept : numerator{wantedNumerator.value}, @@ -265,7 +269,7 @@ struct Rational { /// @brief Validating factory. /// @param wantedNumerator Signed numerator. /// @param wantedDenominator Denominator; `0` is rejected instead of clamped. - /// @param wantedPrecision Decimal precision; clamped to [1, kMaxDecimalPlaces]. + /// @param wantedPrecision Decimal precision; clamped to [0, kMaxDecimalPlaces]. /// @return The canonical rational, or `DivisionByZero` if @p wantedDenominator is 0. [[nodiscard]] static constexpr std::expected from( Numerator wantedNumerator, Denominator wantedDenominator, DecimalPlaces wantedPrecision) noexcept { diff --git a/include/morph/util/tagged.hpp b/include/morph/util/tagged.hpp new file mode 100644 index 00000000..8f022abc --- /dev/null +++ b/include/morph/util/tagged.hpp @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file util/tagged.hpp +/// @brief `Tagged` — an opaque, type-safe newtype for protocol +/// scalars that serializes transparently as its underlying `T`. +/// +/// A protocol scalar (a pagination cursor, an event id, a job id, a bearer +/// token) is a `T` (usually `std::string` or an integer) that must not be +/// interchangeable with a different scalar of the same underlying type — +/// `Tagged` and `Tagged` +/// carry the same wire representation but are distinct C++ types, so passing +/// one where the other is expected is a compile error rather than a silent +/// mix-up caught (or not) at runtime. +/// +/// `Tag` is a `morph::detail::FixedString` non-type template parameter (the +/// same structural string type `morph::forms::Choice` and +/// `morph::units::NamedQuantity` use), so the identity lives in the type +/// itself — two `Tagged` written identically in different +/// translation units are one and the same type, and no companion `enum` or +/// registry is needed to keep tags distinct. +/// +/// **Wire.** On the morph JSON wire a `Tagged` is exactly its `T` +/// payload — `glz::meta` maps the instance to `value` alone, so it reads and +/// writes identically to a bare `T`, and `to_json_schema` delegates to `T`'s +/// own schema (the tag never appears on the wire or in the generated +/// schema). +/// +/// **Forms palette.** `Tagged` is a *required* scalar, not an +/// optionally-empty field like `Quantity`/`Choice`/`Timestamp`: it always +/// holds a `T` (default-constructed, never `std::nullopt`). Its `hasValue()` +/// therefore always returns `true`, satisfying +/// `morph::forms::EmptyCapableField` so it composes with the palette (rule +/// vocabulary, `allRequiredEngaged`, etc.) the same way any other required +/// field does — nothing in the palette special-cases it away like a plain, +/// non-empty-capable scalar member would be. + +#include + +#include +#include +#include +#include +#include + +#include "../detail/fixed_string.hpp" + +namespace morph::util { + +/// @brief An opaque, type-safe newtype wrapping a protocol scalar `T`, +/// distinguished at compile time by the string tag `Tag`. +/// +/// @tparam T The underlying wire scalar (e.g. `std::string`, `std::int64_t`). +/// @tparam Tag A `morph::detail::FixedString` NTTP naming this newtype (e.g. +/// `"UserId"`); part of the C++ type, never part of the wire. +template +struct Tagged { + /// @brief The underlying scalar value. + T value{}; + + /// @brief Constructs the default-valued state (`T{}`). + constexpr Tagged() noexcept(std::is_nothrow_default_constructible_v) = default; + + /// @brief Wraps @p wrapped. + /// @param wrapped The scalar value to tag. + constexpr explicit Tagged(T wrapped) noexcept(std::is_nothrow_move_constructible_v) + : value{std::move(wrapped)} {} + + /// @brief The compile-time tag naming this newtype. + /// @return The tag text, e.g. `"UserId"`. + [[nodiscard]] static constexpr std::string_view tag() noexcept { return Tag.view(); } + + /// @brief Always `true`: `Tagged` is a required scalar, never empty. + /// @return `true`. + [[nodiscard]] constexpr bool hasValue() const noexcept { return true; } + + /// @brief Read access to the wrapped value. + /// @return The underlying scalar. + [[nodiscard]] constexpr const T& get() const noexcept { return value; } + + /// @brief Unchecked access to the wrapped value, mirroring `Quantity`'s + /// and `Choice`'s `operator*` for consistency across the + /// empty-capable-field family. + /// @return The underlying scalar. + [[nodiscard]] constexpr const T& operator*() const noexcept { return value; } + + /// @brief Equality on the wrapped value. + /// @param other Tagged value to compare against (same `T`/`Tag`). + /// @return `true` when both wrap equal values. + [[nodiscard]] constexpr bool operator==(const Tagged& other) const noexcept(noexcept(value == other.value)) + requires std::equality_comparable + = default; + + /// @brief Three-way comparison on the wrapped value, when `T` supports it. + /// @param other Tagged value to compare against (same `T`/`Tag`). + /// @return The ordering of the wrapped values. + [[nodiscard]] constexpr auto operator<=>(const Tagged& other) const + noexcept(noexcept(value <=> other.value)) + requires std::three_way_comparable + = default; +}; + +namespace detail { + +/// @brief Trait: is `T` some `Tagged<...>`? +template +struct IsTagged : std::false_type {}; + +template +struct IsTagged> : std::true_type {}; + +} // namespace detail + +/// @brief `true` when `T` (cvref-stripped) is a `morph::util::Tagged`. +template +inline constexpr bool isTagged = detail::IsTagged>::value; + +} // namespace morph::util + +/// @brief On the wire a `Tagged` is exactly its underlying `value` — +/// the tag never travels; `T`'s own codec runs unchanged. +template +struct glz::meta> { + /// @brief The single wire field: the wrapped scalar. + static constexpr auto value = &morph::util::Tagged::value; + + /// @brief The tag as the schema type name, so distinct tags produce + /// distinctly named (if structurally identical) schema entries. + static constexpr std::string_view name = Tag.view(); +}; + +namespace glz::detail { + +/// @brief Schema for `Tagged`: identical to `T`'s own schema — the +/// tag is a compile-time-only distinction and never surfaces in the +/// generated JSON Schema. +/// @tparam T The underlying wire scalar. +/// @tparam Tag The compile-time tag (schema-invisible). +template +struct to_json_schema> { + /// @brief Emits the schema. + /// @tparam Opts Glaze options. + /// @param outSchema Schema being built. + /// @param defs Schema definitions. + template + static void op(auto& outSchema, auto& defs) { + to_json_schema::template op(outSchema, defs); + } +}; + +} // namespace glz::detail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 451bc590..a415fdf6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -61,6 +61,7 @@ add_executable(morph_tests test_outbox.cpp test_rational.cpp test_quantity.cpp + test_tagged.cpp test_quantity_forms.cpp test_nested_forms.cpp test_flows_apps.cpp diff --git a/tests/test_quantity.cpp b/tests/test_quantity.cpp index 01526723..78ccadd2 100644 --- a/tests/test_quantity.cpp +++ b/tests/test_quantity.cpp @@ -36,6 +36,7 @@ enum class U : std::uint16_t { tonne, celsius, fahrenheit, + yen, // zero-decimal currency: no fractional subunit. }; } // namespace qt @@ -56,6 +57,7 @@ struct morph::units::UnitTraits { case qt::U::tonne: return {"t", "t", 3}; case qt::U::celsius: return {"celsius", "C", 1}; case qt::U::fahrenheit: return {"fahrenheit", "F", 1}; + case qt::U::yen: return {"yen", "JPY", 0}; default: return {"?", "?", 3}; } } @@ -135,6 +137,7 @@ using Kilogram = Quantity; using Tonne = Quantity; using Celsius = Quantity; using Fahrenheit = Quantity; +using Yen = Quantity; // declaredDecimals defaults to 0. using Tariff = NamedQuantity<"tariff", qt::U::euro_per_kwh>; using StandingCharge = NamedQuantity<"standing", qt::U::euro>; @@ -144,6 +147,11 @@ static_assert(!morph::units::isQuantity); static_assert(morph::units::HasUnitRelations); static_assert(Kilowatt::unit == qt::U::kilowatt); static_assert(Kilowatt::declaredDecimals == 3); +// Zero declared decimals is legal (a zero-decimal currency like JPY/KRW): +// the declared-decimals floor is 0, not 1. Yen's UnitMeta::defaultDecimals +// is 0 and Quantity defaults DeclaredDecimals from it. +static_assert(Yen::declaredDecimals == 0); +static_assert(std::same_as>); static_assert(std::same_as() * std::declval()), KilowattHour>); static_assert(std::same_as() / std::declval()), Kilowatt>); static_assert(std::same_as() / std::declval()), Quantity>); @@ -282,6 +290,41 @@ TEST_CASE("Quantity::formatting", "[quantity]") { CHECK(std::format("{}", Tariff::fromDouble(0.3)) == "0.3EUR/kWh"); } +TEST_CASE("Quantity::zero-decimal currency (declaredDecimals == 0)", "[quantity]") { + // A zero-decimal currency (JPY/KRW): fromDouble rounds to the nearest + // whole unit at DeclaredDecimals == 0, and formatting shows no fractional + // digit or decimal point at all. + auto const price = Yen::fromDouble(1200.0); + REQUIRE(price.hasValue()); + CHECK(price.value()->getDecimalPlaces() == DecimalPlaces{0}); + CHECK(std::format("{}", price) == "1200JPY"); + + // fromDouble rounds a fractional input half-away-from-zero to the nearest + // whole yen (no fractional subunit exists to carry the remainder). + CHECK(std::format("{}", Yen::fromDouble(1200.6)) == "1201JPY"); + CHECK(std::format("{}", Yen::fromDouble(1200.4)) == "1200JPY"); + + // Empty still formats as N/A + unit, same as any other Quantity. + CHECK(std::format("{}", Yen{}) == "N/AJPY"); + + // Arithmetic between two dp-0 values stays at dp 0. + auto const sum = Yen::fromDouble(500.0) + Yen::fromDouble(700.0); + REQUIRE(sum.hasValue()); + CHECK(sum.value()->getDecimalPlaces() == DecimalPlaces{0}); + CHECK(std::format("{}", sum) == "1200JPY"); + + // Wire round-trip: the payload is the nullable Rational, same shape as + // any other Quantity, with "dp":0 preserved rather than bumped to 1. + auto const written = glz::write_json(price); + REQUIRE(written.has_value()); + CHECK(*written == R"({"num":1200,"den":1,"dp":0})"); + + Yen restored{}; + REQUIRE_FALSE(glz::read_json(restored, *written)); + CHECK(restored == price); + CHECK(restored.value()->getDecimalPlaces() == DecimalPlaces{0}); +} + TEST_CASE("formatRationalDecimal - exact decimal rendering (no double path)", "[quantity][format]") { using morph::units::detail::formatRationalDecimal; diff --git a/tests/test_quantity_forms.cpp b/tests/test_quantity_forms.cpp index f4e8de0c..6bc9d8e4 100644 --- a/tests/test_quantity_forms.cpp +++ b/tests/test_quantity_forms.cpp @@ -148,6 +148,12 @@ struct QFCalibrate { morph::units::Quantity referenceMass; }; +struct QFWholeCount { + // A zero-decimal declared-precision override: a whole-unit count (or a + // zero-decimal currency such as JPY/KRW) carries no fractional digit. + morph::units::Quantity wholeUnits; +}; + struct QFSlotInfo { std::int64_t id = 0; std::string name; @@ -447,6 +453,15 @@ TEST_CASE("Forms::SchemaJson::DeclaredPrecisionOverrideSurfaces", "[forms]") { CHECK(schema.contains(R"("required":["referenceMass"])")); } +TEST_CASE("Forms::SchemaJson::ZeroDecimalPlacesOverrideSurfaces", "[forms]") { + // A field-level Quantity override advertises x-decimalPlaces:0 -- + // the declared-decimals floor is 0, not 1, and the field is still + // required like any other non-optional Quantity member. + auto const schema = morph::forms::schemaJson(); + CHECK(schema.contains(R"("x-decimalPlaces":0)")); + CHECK(schema.contains(R"("required":["wholeUnits"])")); +} + TEST_CASE("Forms::SchemaJson::Memoized", "[forms]") { // The schema is fixed per type; repeated calls return the cached result. CHECK(morph::forms::schemaJson() == morph::forms::schemaJson()); diff --git a/tests/test_rational.cpp b/tests/test_rational.cpp index f2f57775..20a35783 100644 --- a/tests/test_rational.cpp +++ b/tests/test_rational.cpp @@ -207,15 +207,46 @@ TEST_CASE("Rational::PrecisionCap", "[rational]") // Precision at the cap is accepted unchanged. CHECK(Rational { Numerator{1}, Denominator{2}, DecimalPlaces { 18 } }.getDecimalPlaces() == DecimalPlaces { 18 }); + // Zero decimal places is a legal precision (zero-decimal currencies like + // JPY/KRW): the floor is 0, not 1. + CHECK(Rational { Numerator{1}, Denominator{2}, DecimalPlaces { 0 } }.getDecimalPlaces() == DecimalPlaces { 0 }); + // In a release build (NDEBUG) an out-of-range precision is clamped into - // [1, kMaxDecimalPlaces]. A debug build asserts before reaching here, so - // these clamp checks only run when NDEBUG is defined. + // [0, kMaxDecimalPlaces]. A debug build asserts before reaching here, so + // this clamp check only runs when NDEBUG is defined. #ifdef NDEBUG - CHECK(Rational { Numerator{1}, Denominator{2}, DecimalPlaces { 0 } }.getDecimalPlaces() == DecimalPlaces { 1 }); CHECK(Rational { Numerator{1}, Denominator{2}, DecimalPlaces { 99 } }.getDecimalPlaces() == DecimalPlaces { 18 }); #endif } +TEST_CASE("Rational::ZeroDecimalPlaces", "[rational]") +{ + // DecimalPlaces{0} is a fully legal precision: a whole-number quantity + // (e.g. a JPY/KRW amount) carries no fractional digit at all. + DecimalPlaces const dp0 { 0 }; + Rational const whole { 1200, dp0 }; + CHECK(whole.getDecimalPlaces() == dp0); + CHECK(whole.numerator == 1200); + CHECK(whole.denominator == 1); + CHECK(std::format("{}", whole) == "1200"); + + // toDouble at 0 requested places rounds to the nearest integer. + CHECK(Rational { Numerator{7}, Denominator{2}, dp0 }.toDouble(0) == Catch::Approx(4.0)); + + // Arithmetic between a dp0 and a wider-precision operand still + // max-propagates, same as any other pair. + auto const sum = whole + Rational { Numerator{1}, Denominator{4}, dp2 }; + CHECK(sum.getDecimalPlaces() == dp2); + + // Wire round-trip at dp == 0. + Rational restored {}; + REQUIRE_FALSE(glz::read_json(restored, R"({"num":1200,"den":1,"dp":0})")); + CHECK(restored.getDecimalPlaces() == dp0); + auto const written = glz::write_json(restored); + REQUIRE(written.has_value()); + CHECK(*written == R"({"num":1200,"den":1,"dp":0})"); +} + TEST_CASE("Rational::StrongTypePrecision", "[rational]") { // DecimalPlaces is a distinct strong type with an explicit constructor: @@ -835,17 +866,19 @@ TEST_CASE("Rational::Wire::CanonicalisesOnRead", "[rational]") TEST_CASE("Rational::Wire::HostileInputClamps", "[rational]") { - // Zero denominator and out-of-range precision are clamped, not asserted: - // wire input is untrusted. + // Zero denominator and out-of-range (too high) precision are clamped, not + // asserted: wire input is untrusted. Rational hostile {}; REQUIRE_FALSE(glz::read_json(hostile, R"({"num":5,"den":0,"dp":99})")); CHECK(hostile.denominator == 1); CHECK(hostile.numerator == 5); CHECK(hostile.getDecimalPlaces() == DecimalPlaces { kMaxDecimalPlaces }); + // dp:0 is a legal precision (the floor is 0, not 1): it is honored as-is, + // not clamped up. Rational zeroPrecision {}; REQUIRE_FALSE(glz::read_json(zeroPrecision, R"({"num":1,"den":2,"dp":0})")); - CHECK(zeroPrecision.getDecimalPlaces() == dp1); + CHECK(zeroPrecision.getDecimalPlaces() == DecimalPlaces { 0 }); } TEST_CASE("Rational::Wire::MissingFieldsUseWireDefaults", "[rational]") diff --git a/tests/test_tagged.cpp b/tests/test_tagged.cpp new file mode 100644 index 00000000..b8479cd2 --- /dev/null +++ b/tests/test_tagged.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include +#include + +using morph::util::Tagged; + +namespace { + +using UserId = Tagged; +using AccountId = Tagged; +using EventSeq = Tagged; + +} // namespace + +// Global scope on purpose: glaze reflection needs a type with linkage. +struct TaggedProbe { + UserId user; + EventSeq seq; +}; + +TEST_CASE("Tagged::ConstructionAndAccess", "[tagged]") { + UserId const id{"u-42"}; + CHECK(*id == "u-42"); + CHECK(id.get() == "u-42"); + + EventSeq const seq{7}; + CHECK(*seq == 7); +} + +TEST_CASE("Tagged::TypeSafeIdentity", "[tagged]") { + // Two Tagged with different tags are distinct types: + // neither constructs from nor is constructible from the other. + static_assert(!std::is_convertible_v); + static_assert(!std::is_constructible_v); + static_assert(!std::is_same_v); +} + +TEST_CASE("Tagged::IsTaggedTrait", "[tagged]") { + static_assert(morph::util::isTagged); + static_assert(morph::util::isTagged); + static_assert(morph::util::isTagged); + static_assert(!morph::util::isTagged); + static_assert(!morph::util::isTagged); +} + +TEST_CASE("Tagged::Equality", "[tagged]") { + CHECK(UserId{"a"} == UserId{"a"}); + CHECK_FALSE(UserId{"a"} == UserId{"b"}); +} + +TEST_CASE("Tagged::HasValueAlwaysEngaged", "[tagged]") { + // Tagged is a required opaque scalar, not an empty-capable field: it always + // reports engaged so it composes with the forms palette without being + // mistaken for an optional field. + UserId const id{"u-1"}; + CHECK(id.hasValue()); + static_assert(noexcept(id.hasValue())); +} + +TEST_CASE("Tagged::WireIsTransparentScalar", "[tagged][wire]") { + UserId const id{"u-99"}; + auto const written = glz::write_json(id); + REQUIRE(written.has_value()); + CHECK(*written == R"("u-99")"); + + UserId restored{}; + REQUIRE_FALSE(glz::read_json(restored, R"("u-99")")); + CHECK(restored == id); + + EventSeq const seq{123}; + auto const writtenSeq = glz::write_json(seq); + REQUIRE(writtenSeq.has_value()); + CHECK(*writtenSeq == "123"); +} + +TEST_CASE("Tagged::WireAsStructMember", "[tagged][wire]") { + TaggedProbe const probe{.user = UserId{"u-1"}, .seq = EventSeq{5}}; + auto const written = glz::write_json(probe); + REQUIRE(written.has_value()); + CHECK(*written == R"({"user":"u-1","seq":5})"); + + TaggedProbe restored{}; + REQUIRE_FALSE(glz::read_json(restored, *written)); + CHECK(restored.user == probe.user); + CHECK(restored.seq == probe.seq); +} + +TEST_CASE("Tagged::JsonSchemaMatchesUnderlyingType", "[tagged][schema]") { + // The generated schema for the tagged scalar has the same shape (type, + // and any bounds) as its underlying type's schema -- transparent wire + // serialization means the wire-facing part of the schema is identical. + // Only the `title` differs: it carries the compile-time tag, so distinct + // tags produce distinctly labelled schema entries even when T is shared. + auto const taggedSchema = glz::write_json_schema(); + auto const plainSchema = glz::write_json_schema(); + REQUIRE(taggedSchema.has_value()); + REQUIRE(plainSchema.has_value()); + CHECK(taggedSchema->find(R"("type":"string")") != std::string::npos); + CHECK(taggedSchema->find(R"("title":"UserId")") != std::string::npos); + + auto const taggedIntSchema = glz::write_json_schema(); + auto const plainIntSchema = glz::write_json_schema(); + REQUIRE(taggedIntSchema.has_value()); + REQUIRE(plainIntSchema.has_value()); + CHECK(taggedIntSchema->find(R"("type":"integer")") != std::string::npos); + CHECK(taggedIntSchema->find(R"("title":"EventSeq")") != std::string::npos); + // Same numeric bounds as the underlying int64_t (schema is otherwise + // untouched by the tag). + CHECK(taggedIntSchema->find(R"("minimum":-9223372036854775808)") != std::string::npos); + CHECK(taggedIntSchema->find(R"("maximum":9223372036854775807)") != std::string::npos); +}