|
| 1 | +# Schema internals |
| 2 | + |
| 3 | +How `nette/schema` validates and normalizes data underneath, for agents editing |
| 4 | +it. Small package, one coherent mechanism (the `Processor` pipeline over |
| 5 | +`Elements/*`), so one file. |
| 6 | + |
| 7 | +## The contract and where validation actually lives |
| 8 | + |
| 9 | +`Schema` (`src/Schema/Schema.php`) declares four operations, but they are **not** |
| 10 | +four phases. `Processor` has two public entry points and calls only two of them |
| 11 | +per run: |
| 12 | + |
| 13 | +- `process()` → `normalize()` then `complete()` (each followed by |
| 14 | + `throwsErrors()`). |
| 15 | +- `processMultiple()` → `normalize()` each dataset item, `merge()` them |
| 16 | + left-to-right, then a single `complete()`. |
| 17 | + |
| 18 | +**Validation is not a separate step — it happens inside `complete()`.** Type |
| 19 | +checking, range, pattern, item recursion, default merging, and transforms all run |
| 20 | +there (`Type::complete`). `merge()` is reached **only** through |
| 21 | +`processMultiple`. `completeDefault()` runs for items missing from the input. |
| 22 | +Reading the interface as "normalize / validate / complete" (as older docs do) |
| 23 | +will mislead you: there is no `validate()`. |
| 24 | + |
| 25 | +A non-local consequence for `processMultiple`: **`before()` hooks run per dataset |
| 26 | +item** (inside each `normalize`, before the merge), but **`transform()`/`assert()` |
| 27 | +run once** on the already-merged result (inside the single `complete`). So a |
| 28 | +`before` sees one config layer at a time; a `transform` sees the whole. |
| 29 | + |
| 30 | +## Error accumulation is the control-flow spine |
| 31 | + |
| 32 | +Errors are **collected in `Context`, never thrown mid-validation** (`Context`, |
| 33 | +`Processor::throwsErrors` — yes, with the typo — fires only between phases). |
| 34 | +The mechanism that makes this |
| 35 | +work is `Context::createChecker()`: it snapshots the current error count and |
| 36 | +returns a closure that is `true` only while no new error has been added. |
| 37 | + |
| 38 | +Every element's `complete()` is a chain guarded by that closure: |
| 39 | + |
| 40 | +```php |
| 41 | +$isOk = $context->createChecker(); |
| 42 | +Helpers::validateType(...); |
| 43 | +$isOk() && Helpers::validateRange(...); |
| 44 | +$isOk() && ... && $value = $this->doTransform(...); |
| 45 | +``` |
| 46 | + |
| 47 | +**This `$isOk() && ...` short-circuit is the invariant to preserve.** Each step |
| 48 | +runs only if every prior step stayed clean, so later logic never sees a value an |
| 49 | +earlier check already rejected. Add a validation step without threading it through |
| 50 | +the checker and you will validate/transform garbage. `doTransform` re-arms its own |
| 51 | +checker so a transform that reports an error stops the remaining transforms. |
| 52 | + |
| 53 | +`Processor::createContext()` builds a fresh `Context` per run and invokes the |
| 54 | +public `Processor::onNewContext` closures on it — the official hook by which |
| 55 | +other packages attach (nette/di plugs in here, e.g. to consume `dynamics`). |
| 56 | +Reshaping `Context` or `createContext` breaks them invisibly. |
| 57 | + |
| 58 | +## Adding errors: the `Message` contract |
| 59 | + |
| 60 | +`Context::addError(template, code, variables)` stores a `Message`; rendering is |
| 61 | +deferred to `Message::toString()`. Templates use `%placeholder%` substitution: |
| 62 | +`label`, `path` and `value` are filled in automatically (`addError` injects |
| 63 | +`isKey`, which flips `%label%` between "item" and "key of item"; `addWarning` |
| 64 | +does not). A placeholder whose value is `null` vanishes together with the space |
| 65 | +before it — that is how `%path%` disappears at the root. Codes are the |
| 66 | +`Message::*` string constants, whose docblocks list the expected variables; a |
| 67 | +placeholder with no matching variable triggers an undefined-array-key warning |
| 68 | +in `toString()`, so keep template and variables in sync. |
| 69 | + |
| 70 | +## `PreventMerging`: in-band metadata, handled in many places |
| 71 | + |
| 72 | +The magic array key `Helpers::PreventMerging` (`'_prevent_merging'`) is |
| 73 | +injected **directly into the data** to mean "replace, don't merge with the base / |
| 74 | +default". Because it rides inside the value, **every element must detect and strip |
| 75 | +it** — and they do so in subtly different ways: |
| 76 | + |
| 77 | +- `Type::normalize` strips it, then **re-adds** it after recursing into items (so |
| 78 | + it survives normalization). |
| 79 | +- `Type::complete` strips it and forces `$merge = false` (default not merged in). |
| 80 | +- `Type::merge` / `AnyOf::merge` / `Helpers::merge` strip it and return the value |
| 81 | + as-is (no merge). |
| 82 | +- `Structure::merge` strips it and sets `$base = null` (full replace). |
| 83 | + |
| 84 | +This is the package's sharpest trap: a piece of control state travelling through |
| 85 | +the payload, replicated across five sites. Any new `Schema` element must reproduce |
| 86 | +the strip-and-honor dance or merging silently misbehaves. (There is a standing |
| 87 | +idea to replace it with a declarative `MergeMode::Replace`; DI carries its own |
| 88 | +parallel `PREVENT_MERGING` constant. See `docs/local/ideas/odstranit-prevent-merging.md`.) |
| 89 | + |
| 90 | +## One transform pipeline; `assert`/`castTo` are sugar over `transform` |
| 91 | + |
| 92 | +`before()`, `transform()`, `assert()`, and `castTo()` are **not** independent |
| 93 | +stages. `before` runs in `normalize` (pre-validation) and has a **single slot**: |
| 94 | +a second `before()` call silently replaces the first. Everything else |
| 95 | +appends to a single `$transforms` list (`Base`): `castTo` is |
| 96 | +`transform(getCastStrategy(...))`, `assert` is a `transform` that reports an error |
| 97 | +and returns null on failure. They therefore execute in **declaration order** in |
| 98 | +one `doTransform` pass, after type/range/pattern validation. Reordering |
| 99 | +`->assert()->castTo()` vs `->castTo()->assert()` changes what each sees. |
| 100 | + |
| 101 | +## `default` null is not `nullable` |
| 102 | + |
| 103 | +- A `Type`'s `default` is `null`, but the type does **not** accept `null` unless |
| 104 | + `nullable()` was called — which works by prepending `'null|'` to the type |
| 105 | + string (`Type::nullable`), not by a flag. `dynamic()` similarly prepends |
| 106 | + `DynamicParameter::class . '|'`. |
| 107 | +- **null-to-empty-array coercion:** `complete()` turns a `null` value into `[]` |
| 108 | + whenever the default is an array, with the comment "NEON cannot distinguish null |
| 109 | + from an empty array". The check is **unconditional — it fires even after |
| 110 | + `nullable()`**, so a nullable array-typed item never yields `null`, and a NEON |
| 111 | + key written bare (`key:`) validates as an empty array. |
| 112 | + |
| 113 | +## Keys validate like values — and collapse on failure |
| 114 | + |
| 115 | +`arrayOf(value, key)` runs the key schema through the same |
| 116 | +`normalize`/`complete` cycle as values, with `Context::isKey` set around the |
| 117 | +call (`Type::normalize`, `Type::validateItems`); `Message::toString` renders |
| 118 | +such errors as "key of item". The trap: a key that fails `complete()` comes |
| 119 | +back as `null` and lands in `$res[$key ?? '']`, so **all invalid keys silently |
| 120 | +collapse into a single `''` entry**, later ones overwriting earlier ones. |
| 121 | + |
| 122 | +## Structure specifics |
| 123 | + |
| 124 | +- **Required by default and casts to object.** The constructor sets |
| 125 | + `$required = true` and calls `castTo('object')`, so a `Structure` yields a |
| 126 | + `stdClass` and `default()` **throws** — it cannot have one. |
| 127 | +- **A missing required structure still fills nested defaults.** |
| 128 | + `completeDefault` completes `[]` through the normal path (recursively producing |
| 129 | + every child's default). That path includes `doDeprecation`, so a deprecated |
| 130 | + structure emits its warning even when merely absent from the input. |
| 131 | +- **`skipDefaults` has two independent switches** — the `Processor` |
| 132 | + (`Context::skipDefaults`) and the `Structure` — and `validateItems` fills in a |
| 133 | + default only when **neither** asks to skip it. |
| 134 | + |
| 135 | +## AnyOf: first clean variant wins, in a throwaway context |
| 136 | + |
| 137 | +`findAlternative` tries each variant **in order**. Schema variants are run |
| 138 | +against a **fresh throwaway `Context` (`$dolly`)** that copies only `path`; the |
| 139 | +first variant that completes with **no errors** wins, and only then are its |
| 140 | +`warnings` merged back into the real context. The dolly does **not** inherit |
| 141 | +`skipDefaults`/`isKey`, and even the winning variant's `dynamics` are **not** |
| 142 | +merged back — a dynamic parameter nested inside an `anyOf` variant silently loses |
| 143 | +its deferred validation. Scalar variants are matched with strict `===`. |
| 144 | + |
| 145 | +Two consequences: **order matters**, and **side effects (including transforms) of |
| 146 | +losing variants are discarded** with their dolly context. On total failure, inner |
| 147 | +errors (different path) are surfaced if any exist; otherwise a single aggregated |
| 148 | +"expects to be A|B|C" error is produced. |
| 149 | + |
| 150 | +`completeDefault` has one extra fork: when the default is itself a `Schema` |
| 151 | +(`firstIsDefault()` with a schema variant), it delegates to that schema's |
| 152 | +`completeDefault`. |
| 153 | + |
| 154 | +## Deferred validation of dynamic parameters |
| 155 | + |
| 156 | +`Type::complete`, when the completed value is a `DynamicParameter`, does |
| 157 | +**not** validate the real type now — it records `[value, expectedType, path]` |
| 158 | +(expectedType with the `DynamicParameter|` prefix stripped) in |
| 159 | +`Context::dynamics` for **deferred** validation |
| 160 | +(DI resolves these once runtime parameters are known). An agent must not "fix" this |
| 161 | +by validating dynamics eagerly. |
| 162 | + |
| 163 | +## Merge direction and cast forks (thin) |
| 164 | + |
| 165 | +- **`processMultiple` merges left-value-wins:** each later dataset item is the |
| 166 | + `value` (higher priority) merged over the accumulated `base`, so later configs |
| 167 | + override earlier ones. Numeric-keyed items append; string-keyed recurse. |
| 168 | +- **`Structure::merge` appends numeric keys only when `otherItems` is set** |
| 169 | + (`$index = $this->otherItems === null ? null : 0`); `Type::merge` and |
| 170 | + `Helpers::merge` always append numeric-keyed items. |
| 171 | +- **`castTo` forks by target** (`Helpers::getCastStrategy`): builtin → |
| 172 | + `settype`; class **with** constructor → named args from the array/stdClass |
| 173 | + (a scalar is passed as a single argument); anything else → property assignment |
| 174 | + via `Arrays::toObject((array) $value, new $type)`. There is **no enum branch**: |
| 175 | + an enum has no constructor, falls into the `new $type` path and dies with a |
| 176 | + PHP `Error`. This fork is the mechanism behind both `castTo(Class::class)` |
| 177 | + and Structure's object output. |
| 178 | +- **`min`/`max` mean different things by type** (`validateRange`): item count for |
| 179 | + arrays, character length (`unicode` type) or byte length (otherwise) for |
| 180 | + strings, the value itself for numbers. |
| 181 | + |
| 182 | +## `Expect::from()` mapping rules |
| 183 | + |
| 184 | +`Expect::from($object)` reflects **constructor parameters if `__construct` |
| 185 | +exists, otherwise properties** — a class with a constructor has its properties |
| 186 | +ignored entirely. Per item: uninitialized property / non-optional parameter → |
| 187 | +`required()`; a `null` default on a type that does not accept null → also |
| 188 | +`required()` (not "default null"); an **object** default recurses into a nested |
| 189 | +`from()`; anything else becomes `default($def)`. The type comes from |
| 190 | +`Helpers::getPropertyType` (native type, then `@var`), falling back to `mixed`. |
| 191 | +The result is a `Structure` with `castTo($class)` **stacked after** the |
| 192 | +constructor's built-in `castTo('object')`, so a completed value travels |
| 193 | +array → `stdClass` → instance through the cast fork above. |
| 194 | + |
| 195 | +## Navigation map |
| 196 | + |
| 197 | +| Concern | Where | |
| 198 | +|---|---| |
| 199 | +| Entry points, phase order | `Processor::process`, `processMultiple` | |
| 200 | +| Error accumulation, checker idiom | `Context`, every `Elements/*::complete` | |
| 201 | +| `PreventMerging` handling | `Helpers::merge`, `Type`/`Structure`/`AnyOf` normalize/merge/complete | |
| 202 | +| Transform/assert/castTo pipeline | `Base` (`transforms`, `doTransform`, `assert`, `castTo`) | |
| 203 | +| Type validation & null/dynamic | `Type::complete`, `Helpers::validateType` | |
| 204 | +| Structure object output, defaults | `Structure` (`completeDefault`, `validateItems`) | |
| 205 | +| Union selection | `AnyOf::findAlternative` | |
| 206 | +| Casting strategies | `Helpers::getCastStrategy` | |
| 207 | +| DI / integration hook | `Processor::onNewContext`, `createContext` | |
| 208 | +| Error message rendering | `Message::toString`, `Message::*` code constants | |
| 209 | +| Key schemas, `isKey` | `Type::normalize`/`validateItems`, `Context::isKey` | |
| 210 | +| Object-to-schema mapping | `Expect::from`, `Helpers::getPropertyType` | |
0 commit comments