Lines
97.61%
82 / 84
Methods
92.30%
12 / 13
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| child | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 2 | ||
| branch | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | ||
| withResult | 100.00% | 12 / 12 | 100.00% | 1 / 1 | 1 | ||
| context | 80.00% | 8 / 10 | 0.00% | 0 / 1 | 10.80 | ||
| requireContext | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 4 | ||
| file | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 4 | ||
| seedRaw | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 3 | ||
| valueOf | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 4 | ||
| normalize | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| resolveSibling | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | ||
| [Lucent\Validation\Concerns\ResolvesPaths] segments | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | ||
| [Lucent\Validation\Concerns\ResolvesPaths] tryValueAtPath | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 4 | ||
| 33 | final class FieldContext | |
| 34 | { | |
| 35 | use ResolvesPaths; | |
| 36 | ||
| 37 | /** | |
| 38 | * Arbitrary per-validation values keyed by name. | |
| 39 | * | |
| 40 | * Resolved once at construction and read via {@see context()}. Stored on the | |
| 41 | * context — created fresh per validation call — so it is coroutine-safe | |
| 42 | * and never shared across requests. | |
| 43 | * | |
| 44 | * @var array<string, mixed> | |
| 45 | */ | |
| 46 | private readonly array $context; | |
| 47 | ||
| 48 | /** | |
| 49 | * Create a new field context. | |
| 50 | * | |
| 51 | * @param string $field The dotted path of the field being validated (e.g. `user.name`). | |
| 52 | * @param mixed $value The raw value of the field. | |
| 53 | * @param bool $present Whether the field key was present in the data. | |
| 54 | * @param Result $result The result object that collects errors and normalized values. | |
| 55 | * @param array<string, UploadedFileInterface>|null $files The uploaded files, or null if none. | |
| 56 | * @param mixed $body The data payload being validated, or null if none. | |
| 57 | * @param array<string, mixed> $context Per-validation values (e.g. the originating | |
| 58 | * ServerRequest, the authenticated user) exposed to constraints via {@see get()}. | |
| 59 | * @param string $name The leaf field name, used for file lookups. Defaults to $field. | |
| 60 | */ | |
| 61 | /** | |
| 62 | * Whether a child constraint failed during validation of this field. | |
| 63 | * | |
| 64 | * Used by the shape combinators ({@see \Lucent\Validation\Combinators\Shape} | |
| 65 | * and {@see \Lucent\Validation\Combinators\Each}) to suppress their generic | |
| 66 | * error message when a child already recorded a specific error. Stored on | |
| 67 | * the context — which is created fresh for every validation call — rather | |
| 68 | * than on the constraint instance, so it cannot bleed across requests | |
| 69 | * under long-running runtimes (Octane/Swoole/RoadRunner). | |
| 70 | */ | |
| 71 | public bool $childFailed = false; | |
| 72 | ||
| 73 | /** | |
| 74 | * The constraint that failed, when this field's validation is a composite | |
| 75 | * ({@see \Lucent\Validation\Combinators\All}). | |
| 76 | * | |
| 77 | * Used to delegate the error message to the failing constraint. Stored on | |
| 78 | * the context for the same reason as {@see $childFailed}: it is | |
| 79 | * per-validation and never shared across requests. | |
| 80 | */ | |
| 81 | public ?Constraint $failedConstraint = null; | |
| 82 | ||
| 83 | /** | |
| 84 | * The dotted path split into segments, cached to avoid re-splitting on | |
| 85 | * every {@see Result} write. | |
| 86 | * | |
| 87 | * @var list<string> | |
| 88 | */ | |
| 89 | public array $segments; | |
| 90 | ||
| 91 | public function __construct( | |
| 92 | public readonly string $field, | |
| 93 | public private(set) mixed $value, | |
| 94 | public readonly bool $present, | |
| 95 | public readonly Result $result, | |
| 96 | private readonly array|null $files, | |
| 97 | private readonly mixed $body, | |
| 98 | array $context = [], | |
| 99 | public string $name = '', | |
| 100 | ) { | |
| 101 | $this->context = $context; | |
| 102 | ||
| 103 | if ($this->name === '') { | |
| 104 | $this->name = $this->field; | |
| 105 | } | |
| 106 | ||
| 107 | $this->segments = $this->segments($this->field); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Derive a child context for a sub-field. | |
| 112 | * | |
| 113 | * Extends the current dotted path with the sub-field name so errors and | |
| 114 | * normalized values are namespaced. Used by {@see \Lucent\Validation\Combinators\Shape} | |
| 115 | * and {@see \Lucent\Validation\Combinators\Each}. | |
| 116 | * | |
| 117 | * @param int|string $name The sub-field name (leaf). Integer keys (e.g. | |
| 118 | * tuple indices) are cast to a string for the dotted path. | |
| 119 | * @param mixed $value The sub-field's raw value. | |
| 120 | * @param bool $present Whether the sub-field key was present. | |
| 121 | * @return self A new context for the sub-field. | |
| 122 | */ | |
| 123 | public function child(int|string $name, mixed $value, bool $present): self | |
| 124 | { | |
| 125 | $name = (string) $name; | |
| 126 | $path = $this->field === '' ? $name : $this->field . '.' . $name; | |
| 127 | ||
| 128 | $child = new self( | |
| 129 | $path, | |
| 130 | $value, | |
| 131 | $present, | |
| 132 | $this->result, | |
| 133 | $this->files, | |
| 134 | $this->body, | |
| 135 | $this->context, | |
| 136 | $name, | |
| 137 | ); | |
| 138 | ||
| 139 | // Reuse the parent's cached segments to avoid re-splitting the shared | |
| 140 | // prefix of the dotted path. The leaf name is split through the same | |
| 141 | // segments() helper so the cached segments always agree with the | |
| 142 | // dotted-path string. (Dotted keys are not supported — see | |
| 143 | // ResolvesPaths — so a leaf name is normally a single segment.) | |
| 144 | $child->segments = [...$this->segments, ...$this->segments($name)]; | |
| 145 | ||
| 146 | return $child; | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * Validate a constraint in isolation against a throwaway result. | |
| 151 | * | |
| 152 | * Runs the constraint against a fresh {@see Result}, so its errors and | |
| 153 | * normalized values are captured in isolation and never touch the shared | |
| 154 | * result. Used by combinators such as {@see \Lucent\Validation\Combinators\One} | |
| 155 | * and {@see \Lucent\Validation\Combinators\Any} to try each alternative | |
| 156 | * independently, then commit only the winning branch via | |
| 157 | * {@see Result::merge()} — so a losing branch's errors *and* values never | |
| 158 | * leak into the final result. | |
| 159 | * | |
| 160 | * The pass/fail is the constraint's {@see Constraint::validate()} return | |
| 161 | * value, not whether it recorded an error: most constraints return false | |
| 162 | * without recording an error themselves (that is done by the caller via | |
| 163 | * {@see Constraint::message()}). The branch result therefore holds the | |
| 164 | * constraint's normalized values and any errors it recorded directly | |
| 165 | * (e.g. child errors from a {@see \Lucent\Validation\Combinators\Shape}). | |
| 166 | * | |
| 167 | * @param Constraint $constraint The constraint to validate in isolation. | |
| 168 | * @return array{0: bool, 1: Result} `[passed, branch]` where `passed` is | |
| 169 | * the constraint's validate() result and `branch` holds its | |
| 170 | * normalized values and recorded errors. | |
| 171 | */ | |
| 172 | public function branch(Constraint $constraint): array | |
| 173 | { | |
| 174 | $branch = new Result(); | |
| 175 | $branchCtx = $this->withResult($branch); | |
| 176 | ||
| 177 | $passed = $constraint->validate($branchCtx); | |
| 178 | ||
| 179 | return [$passed, $branch]; | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Derive a copy of this context that writes to a different result. | |
| 184 | * | |
| 185 | * Returns a context with the same field, value, presence, files, body, | |
| 186 | * and context bag, but a fresh {@see Result}. Used by combinators such as | |
| 187 | * {@see \Lucent\Validation\Combinators\One} to validate each alternative | |
| 188 | * in isolation against a throwaway result, then commit only the winning | |
| 189 | * branch via {@see Result::merge()} — so a losing branch's errors *and* | |
| 190 | * values never leak into the final result. | |
| 191 | * | |
| 192 | * @param Result $result The result the new context should write to. | |
| 193 | * @return self A new context sharing everything but the result. | |
| 194 | */ | |
| 195 | public function withResult(Result $result): self | |
| 196 | { | |
| 197 | $copy = new self( | |
| 198 | $this->field, | |
| 199 | $this->value, | |
| 200 | $this->present, | |
| 201 | $result, | |
| 202 | $this->files, | |
| 203 | $this->body, | |
| 204 | $this->context, | |
| 205 | $this->name, | |
| 206 | ); | |
| 207 | ||
| 208 | $copy->segments = $this->segments; | |
| 209 | ||
| 210 | return $copy; | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Get a value from the context bag, cast to a given type. | |
| 215 | * | |
| 216 | * A typed getter mirroring {@see Result::valueAs()}: scalar types are | |
| 217 | * passed as string literals (`'int'`, `'string'`, `'bool'`, `'float'`, | |
| 218 | * `'array'`), userland classes via `::class`. Falls back to the default | |
| 219 | * when the key is absent or the value cannot be cast. | |
| 220 | * | |
| 221 | * ```php | |
| 222 | * $request = $ctx->context('request', ServerRequestInterface::class); | |
| 223 | * $userId = $ctx->context('user_id', 'int'); | |
| 224 | * ``` | |
| 225 | * | |
| 226 | * @template T | |
| 227 | * @param string $key The name of the value. | |
| 228 | * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`). | |
| 229 | * @param T|null $default The value to return when the key is absent or the | |
| 230 | * value cannot be cast to the requested type. | |
| 231 | * @return ($default is null ? T|null : T) The value cast to the requested | |
| 232 | * type, or $default. When $default is non-null the return is always T. | |
| 233 | */ | |
| 234 | public function context(string $key, string $type, mixed $default = null): mixed | |
| 235 | { | |
| 236 | $value = $this->context[$key] ?? $default; | |
| 237 | ||
| 238 | if ($value === $default) { | |
| 239 | return $default; | |
| 240 | } | |
| 241 | ||
| 242 | return match ($type) { | |
| 243 | 'int' => (int) $value, | |
| 244 | 'string' => (string) $value, | |
| 245 | 'bool' => (bool) $value, | |
| 246 | 'float' => (float) $value, | |
| 247 | 'array' => is_array($value) ? $value : $default, | |
| 248 | default => $value instanceof $type ? $value : $default, | |
| 249 | }; | |
| 250 | } | |
| 251 | ||
| 252 | /** | |
| 253 | * Get a value from the context bag cast to a given type, or throw if absent. | |
| 254 | * | |
| 255 | * The fail-fast counterpart to {@see context()}: intended for values the | |
| 256 | * constraint genuinely cannot run without (the originating request, the | |
| 257 | * authenticated user, a tenant id). Instead of returning a default and | |
| 258 | * forcing a null-check, it throws a descriptive {@see \RuntimeException} | |
| 259 | * so a missing or wrongly-typed context value surfaces at the point of | |
| 260 | * use with a clear message. | |
| 261 | * | |
| 262 | * Scalar casts always succeed, so this throws only when the key is absent | |
| 263 | * or the value cannot satisfy a class/array type. It does not throw on a | |
| 264 | * scalar cast (e.g. `(int) 'abc'` → `0`). | |
| 265 | * | |
| 266 | * @template T | |
| 267 | * @param string $key The name of the value. | |
| 268 | * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`). | |
| 269 | * @return T The value cast to the requested type. | |
| 270 | * @throws \RuntimeException When the key is absent or the value cannot | |
| 271 | * satisfy the requested class/array type. | |
| 272 | */ | |
| 273 | public function requireContext(string $key, string $type): mixed | |
| 274 | { | |
| 275 | if (!array_key_exists($key, $this->context)) { | |
| 276 | throw new \RuntimeException( | |
| 277 | sprintf('Context key "%s" is missing.', $key) | |
| 278 | ); | |
| 279 | } | |
| 280 | ||
| 281 | $value = $this->context($key, $type); | |
| 282 | ||
| 283 | if ($value === null && !in_array($type, ['int', 'string', 'bool', 'float'], true)) { | |
| 284 | throw new \RuntimeException( | |
| 285 | sprintf('Context value "%s" cannot be cast to type "%s".', $key, $type) | |
| 286 | ); | |
| 287 | } | |
| 288 | ||
| 289 | return $value; | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Get the uploaded file for the current field, if any. | |
| 294 | * | |
| 295 | * @return UploadedFileInterface|null The uploaded file, or null if the | |
| 296 | * field has no file or the file is not a valid upload. | |
| 297 | */ | |
| 298 | public function file(): ?UploadedFileInterface | |
| 299 | { | |
| 300 | if ($this->files === null) { | |
| 301 | return null; | |
| 302 | } | |
| 303 | ||
| 304 | $file = array_key_exists($this->name, $this->files) ? $this->files[$this->name] : null; | |
| 305 | return $file instanceof UploadedFileInterface ? $file : null; | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Store the field's raw value in the result. | |
| 310 | * | |
| 311 | * Called by container constraints for each present child before running | |
| 312 | * its constraint, so the raw value is retrievable even when the child | |
| 313 | * constraint only validates and does not normalize. A child constraint | |
| 314 | * that does normalize overwrites this via {@see normalize()}. | |
| 315 | * | |
| 316 | * Only scalar values are seeded. Arrays and objects are containers: a | |
| 317 | * nested {@see \Lucent\Validation\Combinators\Shape} or | |
| 318 | * {@see \Lucent\Validation\Combinators\Each} declares them via | |
| 319 | * {@see Result::ensureContainer()} and seeds their declared sub-fields, so | |
| 320 | * seeding the whole raw value here would leak undeclared keys. | |
| 321 | * | |
| 322 | * @return void | |
| 323 | */ | |
| 324 | public function seedRaw(): void | |
| 325 | { | |
| 326 | if (is_array($this->value) || is_object($this->value)) { | |
| 327 | return; | |
| 328 | } | |
| 329 | ||
| 330 | $this->result->setSegments($this->segments, $this->value); | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Get the value of another field. | |
| 335 | * | |
| 336 | * Returns the current field's value when the requested field is this one. | |
| 337 | * Otherwise, resolves the requested field relative to the current field's | |
| 338 | * parent (so a sibling lookup inside a nested shape works), prefers a | |
| 339 | * normalized value already stored in the result, and falls back to the | |
| 340 | * raw request body. | |
| 341 | * | |
| 342 | * @param string $field The name of the field to read. | |
| 343 | * @return mixed The value of the requested field, or null if absent. | |
| 344 | */ | |
| 345 | public function valueOf(string $field): mixed | |
| 346 | { | |
| 347 | if ($field === $this->field) { | |
| 348 | return $this->value; | |
| 349 | } | |
| 350 | ||
| 351 | $target = $this->resolveSibling($field); | |
| 352 | ||
| 353 | [$found, $value] = $this->result->tryValue($target); | |
| 354 | if ($found) { | |
| 355 | return $value; | |
| 356 | } | |
| 357 | ||
| 358 | [$found, $value] = $this->tryValueAtPath($this->body, $target); | |
| 359 | ||
| 360 | return $found ? $value : null; | |
| 361 | } | |
| 362 | ||
| 363 | /** | |
| 364 | * Normalize the current field's value. | |
| 365 | * | |
| 366 | * Updates the context's value and stores the normalized value in the | |
| 367 | * result at the field's dotted path so it can be retrieved later via | |
| 368 | * {@see Result::value()}. | |
| 369 | * | |
| 370 | * @param mixed $value The normalized value to store. | |
| 371 | * @return void | |
| 372 | */ | |
| 373 | public function normalize(mixed $value): void | |
| 374 | { | |
| 375 | $this->value = $value; | |
| 376 | $this->result->setSegments($this->segments, $value); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * Resolve a sibling field name against the current field's parent path. | |
| 381 | * | |
| 382 | * The resolution algorithm is: | |
| 383 | * | |
| 384 | * 1. A field name containing a dot is treated as an **absolute** dotted | |
| 385 | * path and returned unchanged (e.g. `user.password_confirmation` stays | |
| 386 | * `user.password_confirmation`). This lets a nested shape reference a | |
| 387 | * field anywhere in the tree, not just a sibling. | |
| 388 | * 2. A bare leaf name is resolved relative to the current field's parent | |
| 389 | * (e.g. inside `user.password`, `password_confirmation` resolves to | |
| 390 | * `user.password_confirmation`). | |
| 391 | * 3. At the top level (no dot in the current field), a bare leaf name is | |
| 392 | * returned unchanged. | |
| 393 | * | |
| 394 | * @param string $field The leaf name or absolute dotted path of the field. | |
| 395 | * @return string The resolved dotted path. | |
| 396 | */ | |
| 397 | private function resolveSibling(string $field): string | |
| 398 | { | |
| 399 | if (str_contains($field, '.')) { | |
| 400 | return $field; | |
| 401 | } | |
| 402 | ||
| 403 | $dot = strrpos($this->field, '.'); | |
| 404 | ||
| 405 | if ($dot === false) { | |
| 406 | return $field; | |
| 407 | } | |
| 408 | ||
| 409 | return substr($this->field, 0, $dot) . '.' . $field; | |
| 410 | } | |
| 411 | } |
From Lucent\Validation\Concerns\ResolvesPaths
| 22 | trait ResolvesPaths | |
| 23 | { | |
| 24 | /** | |
| 25 | * Split a dotted path into its segments. | |
| 26 | * | |
| 27 | * The `.` character is always a separator; there is no escape mechanism | |
| 28 | * for a literal dot in a key. | |
| 29 | * | |
| 30 | * @param string $path The dotted path to split. | |
| 31 | * @return list<string> The path segments. | |
| 32 | */ | |
| 33 | protected function segments(string $path): array | |
| 34 | { | |
| 35 | return $path === '' ? [] : explode('.', $path); | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Read a value from a nested array by dotted path in a single traversal. | |
| 40 | * | |
| 41 | * Distinguishes a key that is present with a `null` value from a key that | |
| 42 | * is absent entirely, without traversing the path twice. | |
| 43 | * | |
| 44 | * @param mixed $array The nested array to read from. | |
| 45 | * @param string $path The dotted path to read. | |
| 46 | * @return array{0: bool, 1: mixed} `[true, value]` when the path resolves, | |
| 47 | * `[false, null]` when it does not. | |
| 48 | */ | |
| 49 | protected function tryValueAtPath(mixed $array, string $path): array | |
| 50 | { | |
| 51 | $ref = $array; | |
| 52 | ||
| 53 | foreach ($this->segments($path) as $segment) { | |
| 54 | if (!is_array($ref) || !array_key_exists($segment, $ref)) { | |
| 55 | return [false, null]; | |
| 56 | } | |
| 57 | $ref = $ref[$segment]; | |
| 58 | } | |
| 59 | ||
| 60 | return [true, $ref]; | |
| 61 | } | |
| 62 | } |