|
| 1 | +# Discriminator and const fields |
| 2 | + |
| 3 | +> **Status:** design specification. The snippets show the *target shape* a future generator would |
| 4 | +> emit. Nothing here is compiled in this repository. |
| 5 | +
|
| 6 | +Builds on [the four-state JSON field model](json-field-model.md) and |
| 7 | +[thin model classes](model-classes.md). |
| 8 | + |
| 9 | +## Problem |
| 10 | + |
| 11 | +Two closely related field kinds fall out of the four-state field model and need a dedicated codegen |
| 12 | +template: |
| 13 | + |
| 14 | +- **Const fields.** A schema pins a field to a fixed value (`"object": "user"`, `"version": 2`). The |
| 15 | + generated model should default it to that value so a caller never has to set it, while still |
| 16 | + surviving a server that sends something *other* than the expected constant (forward compatibility — |
| 17 | + a const today may gain new allowed values tomorrow). |
| 18 | +- **Discriminator fields.** A union keys member selection off a field's value (`"type": "circle"` vs |
| 19 | + `"type": "square"`). The discriminator must be readable *both* as the typed enum the model expects |
| 20 | + *and* as the raw wire value, because union resolution reads the raw value before any member has been |
| 21 | + chosen, and forward compat requires tolerating a discriminator value we do not yet have a member |
| 22 | + for. |
| 23 | + |
| 24 | +Both want the same thing: a field that has a **sensible default** and exposes **both a typed and a |
| 25 | +raw view**. That is the dual-accessor pattern. |
| 26 | + |
| 27 | +## Proposed shape: defaulted raw value + dual accessors |
| 28 | + |
| 29 | +A const/discriminator field is generated as a `JsonField<T>` (from |
| 30 | +[json-field-model.md](json-field-model.md)) that **defaults to the const's raw value** and exposes |
| 31 | +two getters and two setters. |
| 32 | + |
| 33 | +```kotlin |
| 34 | +// TARGET OUTPUT — generated const field on a model. Illustrative; not compiled here. |
| 35 | +public class User /* private constructor(...) : JsonModel() */ { |
| 36 | + |
| 37 | + // ---- const field "object" pinned to "user" ---- |
| 38 | + |
| 39 | + /** Typed accessor: the const projected to its declared type. Returns the default const when the |
| 40 | + * field was absent, and the typed value when the server sent the expected shape. A server value |
| 41 | + * that does not match T comes back via the raw accessor instead (forward-compat). */ |
| 42 | + public fun objectType(): JsonField<String> = field("object").orDefault(DEFAULT_OBJECT) |
| 43 | + |
| 44 | + /** Raw accessor: the underlying wire value, whatever it was — including an unexpected constant a |
| 45 | + * newer server sent that this model has no typed mapping for. */ |
| 46 | + public fun _objectType(): RawJson = field<String>("object").asRaw(DEFAULT_OBJECT_RAW) |
| 47 | + |
| 48 | + public companion object { |
| 49 | + private const val DEFAULT_OBJECT: String = "user" |
| 50 | + private val DEFAULT_OBJECT_RAW: RawJson = RawJson.Str("user") |
| 51 | + } |
| 52 | + |
| 53 | + public class Builder /* : Builder<User> */ { |
| 54 | + private val acc = LinkedHashMap<String, JsonField<*>>() |
| 55 | + |
| 56 | + /** Typed setter: set the const/discriminator to a typed value. */ |
| 57 | + public fun objectType(value: String): Builder = apply { acc["object"] = JsonField.known(value) } |
| 58 | + |
| 59 | + /** Raw setter: forward an arbitrary wire value verbatim — used to round-trip an unknown |
| 60 | + * discriminator value the SDK does not model yet. */ |
| 61 | + public fun objectType(raw: RawJson): Builder = apply { acc["object"] = JsonField.raw(raw) } |
| 62 | + } |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +### The default is applied at the accessor, not baked into the stored field |
| 67 | + |
| 68 | +The stored field map ([model-classes.md](model-classes.md)) keeps the *actual* state — `Missing` when |
| 69 | +the server omitted the key, `Known`/`Raw` when it sent something. The const default is applied by the |
| 70 | +accessor (`orDefault(...)` / `asRaw(default)`), not written into the map on construction. This keeps |
| 71 | +two properties: |
| 72 | + |
| 73 | +- **Round-trip fidelity.** A model that was deserialized from a payload that omitted the const |
| 74 | + re-serializes without inventing a key (`additionalProperties()` and the serializer see `Missing`), |
| 75 | + unless a caller explicitly set it. The default is a *read-time* convenience, not a *write-time* |
| 76 | + fabrication. |
| 77 | +- **Forward compatibility.** A server that sends an unexpected const value stores it as `Raw`; the |
| 78 | + typed accessor still has a sane default to fall back on, and the raw accessor surfaces the real |
| 79 | + value so nothing is lost. |
| 80 | + |
| 81 | +### Discriminator fields are the same template, plus a const value the union keys on |
| 82 | + |
| 83 | +A discriminator is a const field whose value is what union resolution matches against. The dual |
| 84 | +accessor is what makes resolution work *before* a member is chosen: |
| 85 | + |
| 86 | +```kotlin |
| 87 | +// TARGET OUTPUT — union member matching by discriminator. Not compiled here. |
| 88 | +public fun matchByDiscriminator(raw: RawJson): Shape? { |
| 89 | + // Read the RAW discriminator off the undecoded tree — no member committed yet. |
| 90 | + val tag = (raw as? RawJson.Object)?.entries?.get("type") as? RawJson.Str ?: return null |
| 91 | + return when (tag.value) { |
| 92 | + "circle" -> Circle.fromRaw(raw) |
| 93 | + "square" -> Square.fromRaw(raw) |
| 94 | + else -> null // unknown tag: let the caller fall back to validity scoring |
| 95 | + } |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +The raw accessor is load-bearing here: resolution must read the discriminator from `RawJson` before |
| 100 | +any typed model exists, and an unknown tag returns `null` so the union strategy can fall back to |
| 101 | +[validity scoring](model-validation.md) rather than throwing. This is exactly why the discriminator |
| 102 | +is the **first**, cheap path in `resolveUnion` (one raw read, O(1) member lookup) and scoring is the |
| 103 | +fallback. |
| 104 | + |
| 105 | +## Why two getters and two setters per field |
| 106 | + |
| 107 | +The dual-accessor pattern means every const/discriminator field contributes **two getters |
| 108 | +(`objectType()` typed, `_objectType()` raw) and two setters (typed `objectType(String)`, raw |
| 109 | +`objectType(RawJson)`)** to the public surface. This is deliberate, and it reinforces the binary- |
| 110 | +compatibility decision from [model-classes.md](model-classes.md): |
| 111 | + |
| 112 | +- The generated surface is **wide and regular** — exactly the kind of large, mechanically-generated |
| 113 | + API that should live behind its **own `.api` baseline**, separate from the curated `sdk-core` |
| 114 | + surface, and be regenerated (`apiDump`) as part of a schema-update change rather than mixed into a |
| 115 | + hand-written API change. |
| 116 | +- Because every field follows the identical two-getter/two-setter template, the baseline churns |
| 117 | + predictably with the schema and never with the runtime. |
| 118 | + |
| 119 | +## How it ties into the existing runtime |
| 120 | + |
| 121 | +- **`JsonField` / `RawJson`.** The whole template is expressed in the four-state field types from |
| 122 | + [json-field-model.md](json-field-model.md): the typed accessor reads `Known`/falls back to the |
| 123 | + const default, the raw accessor reads `asRaw(...)`, and an unexpected server constant lands in |
| 124 | + `Raw`. No new field machinery is introduced. |
| 125 | +- **`Serde` SPI.** Re-serializing a const field round-trips through the |
| 126 | + [`Serde`](../../sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt) serializer; an |
| 127 | + explicitly-set typed value serializes as the value, a `Raw` serializes verbatim, and a `Missing` |
| 128 | + const is omitted (the Jackson `JsonField` module's property writer skips it, the same way |
| 129 | + [`TristatePropertyWriter`](../../sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt) |
| 130 | + skips `Tristate.Absent`). |
| 131 | +- **Union resolution and validation.** The discriminator template is the cheap first path of the |
| 132 | + union strategy described in [model-validation.md](model-validation.md); validity scoring is only |
| 133 | + reached when the discriminator is absent or carries an unknown value. |
| 134 | + |
| 135 | +## Design decisions and trade-offs |
| 136 | + |
| 137 | +- **Default at read time, not construction time.** Applying the const default in the accessor keeps |
| 138 | + re-serialization faithful (no fabricated keys) and keeps the stored field map a truthful record of |
| 139 | + the wire. The trade-off is that the default lives in a generated constant per field rather than in |
| 140 | + the map; that is cheap and keeps models immutable and round-trip-safe. |
| 141 | +- **Dual accessors instead of a single typed-or-throw accessor.** A typed-only accessor would have to |
| 142 | + throw or lie when a server sends an unmodelled const/discriminator value. The raw sibling makes |
| 143 | + forward compatibility explicit and gives union resolution the pre-decode read it needs. |
| 144 | +- **Accepting the wide surface.** Two getters and two setters per field is more public API than a |
| 145 | + single typed accessor, but it is the cost of honest forward compatibility, and the separate `.api` |
| 146 | + baseline absorbs the churn so it never destabilizes the curated `sdk-core` surface. |
| 147 | + |
| 148 | +## Acceptance mapping |
| 149 | + |
| 150 | +- Const/discriminator template — const and discriminator fields generated as `JsonField<T>` defaulted |
| 151 | + to the const's raw value, with the default applied at the accessor and the discriminator readable |
| 152 | + pre-decode for union matching. |
| 153 | +- Dual accessors generated — a typed getter + raw getter and a typed setter + raw setter per field, |
| 154 | + forming the wide, regular surface that lives behind the separate generated-code `.api` baseline. |
0 commit comments