Lines
98.66%
74 / 75
Methods
94.44%
17 / 18
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| addError | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| set | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| setSegments | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 6 | ||
| ensureContainer | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 4 | ||
| errors | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| snapshotErrors | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| restoreErrors | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| merge | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 3 | ||
| mergeValues | 85.71% | 6 / 7 | 0.00% | 0 / 1 | 5.07 | ||
| hasErrors | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| values | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| hasValue | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | ||
| value | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| valueAs | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 10 | ||
| requireValueAs | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 4 | ||
| tryValue | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [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 | ||
| 27 | final class Result | |
| 28 | { | |
| 29 | use ResolvesPaths; | |
| 30 | ||
| 31 | /** | |
| 32 | * Validation errors keyed by dotted field path. | |
| 33 | * | |
| 34 | * @var array<string, list<string>> | |
| 35 | */ | |
| 36 | private array $errors = []; | |
| 37 | ||
| 38 | /** | |
| 39 | * Validated values. | |
| 40 | * | |
| 41 | * Seeded with each present field's raw value and overwritten by any | |
| 42 | * normalization that occurs during validation. Usually a nested array | |
| 43 | * keyed by field name, but a top-level scalar constraint (e.g. | |
| 44 | * `new Validator(new Length(min: 3))->validate('abc')`) stores a scalar | |
| 45 | * at the root. | |
| 46 | * | |
| 47 | * @var mixed | |
| 48 | */ | |
| 49 | private mixed $values = []; | |
| 50 | ||
| 51 | /** | |
| 52 | * Record a validation error for a field. | |
| 53 | * | |
| 54 | * Multiple errors may be recorded for the same field. | |
| 55 | * | |
| 56 | * @param string $field The dotted path of the field that failed. | |
| 57 | * @param string $message The error message to record. | |
| 58 | * @return void | |
| 59 | */ | |
| 60 | public function addError(string $field, string $message): void | |
| 61 | { | |
| 62 | $this->errors[$field] ??= []; | |
| 63 | $this->errors[$field][] = $message; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Store a value at a dotted path. | |
| 68 | * | |
| 69 | * Called by the {@see Validator} to seed each present field's raw value, | |
| 70 | * and by {@see FieldContext::normalize()} to overwrite it with a | |
| 71 | * normalized value. Intermediate arrays are created as needed. | |
| 72 | * | |
| 73 | * @param string $path The dotted path of the field (e.g. `user.name`). | |
| 74 | * @param mixed $value The value to store. | |
| 75 | * @return void | |
| 76 | */ | |
| 77 | public function set(string $path, mixed $value): void | |
| 78 | { | |
| 79 | $this->setSegments($this->segments($path), $value); | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Store a value at a pre-split dotted path. | |
| 84 | * | |
| 85 | * The hot path for storing values: {@see FieldContext} caches its path | |
| 86 | * segments and passes them here directly, avoiding a re-split on every | |
| 87 | * call. {@see set()} is a thin wrapper that splits the path first. | |
| 88 | * | |
| 89 | * Intermediate segments that do not yet exist are created as arrays. If an | |
| 90 | * intermediate segment already holds a non-array value, that value would | |
| 91 | * otherwise be silently destroyed; this throws instead so the conflict is | |
| 92 | * surfaced rather than corrupting the result. | |
| 93 | * | |
| 94 | * @param list<string> $segments The dotted path segments (e.g. `['user', 'name']`). | |
| 95 | * @param mixed $value The value to store. | |
| 96 | * @return void | |
| 97 | * @throws \LogicException If an intermediate segment holds a non-array value. | |
| 98 | */ | |
| 99 | public function setSegments(array $segments, mixed $value): void | |
| 100 | { | |
| 101 | if ($segments === []) { | |
| 102 | $this->values = $value; | |
| 103 | return; | |
| 104 | } | |
| 105 | ||
| 106 | $ref = &$this->values; | |
| 107 | $last = array_pop($segments); | |
| 108 | ||
| 109 | // Descend through the intermediate segments, creating arrays as | |
| 110 | // needed. A non-array intermediate would otherwise be silently | |
| 111 | // destroyed, so throw instead of corrupting the result. | |
| 112 | foreach ($segments as $segment) { | |
| 113 | if (array_key_exists($segment, $ref) && !is_array($ref[$segment])) { | |
| 114 | throw new \LogicException( | |
| 115 | "Cannot store value at '" . implode('.', [...$segments, $last]) . "': segment '$segment' already holds a non-array value.", | |
| 116 | ); | |
| 117 | } | |
| 118 | if (!isset($ref[$segment])) { | |
| 119 | $ref[$segment] = []; | |
| 120 | } | |
| 121 | $ref = &$ref[$segment]; | |
| 122 | } | |
| 123 | ||
| 124 | // The final segment is overwritten (e.g. seedRaw then normalize). | |
| 125 | $ref[$last] = $value; | |
| 126 | unset($ref); | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Ensure a container (array) exists at a dotted path. | |
| 131 | * | |
| 132 | * Creates an empty array at the path if none exists, creating any | |
| 133 | * intermediate arrays as needed. Used by container constraints | |
| 134 | * ({@see \Lucent\Validation\Combinators\Shape} and | |
| 135 | * {@see \Lucent\Validation\Combinators\Each}) to declare the field as a | |
| 136 | * structure whose children will be seeded individually, so undeclared | |
| 137 | * keys from the raw input are never stored. | |
| 138 | * | |
| 139 | * At the root (empty path) this is a no-op, since the values store is | |
| 140 | * always an array. An existing array at the path is left untouched; a | |
| 141 | * non-array value is replaced with an empty array. | |
| 142 | * | |
| 143 | * @param string $path The dotted path of the container field. | |
| 144 | * @return void | |
| 145 | */ | |
| 146 | public function ensureContainer(string $path): void | |
| 147 | { | |
| 148 | $segments = $this->segments($path); | |
| 149 | ||
| 150 | $ref = &$this->values; | |
| 151 | foreach ($segments as $segment) { | |
| 152 | if (!isset($ref[$segment]) || !is_array($ref[$segment])) { | |
| 153 | $ref[$segment] = []; | |
| 154 | } | |
| 155 | $ref = &$ref[$segment]; | |
| 156 | } | |
| 157 | unset($ref); | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * Get all validation errors. | |
| 162 | * | |
| 163 | * @return array<string, list<string>> Errors keyed by dotted field path. | |
| 164 | */ | |
| 165 | public function errors(): array | |
| 166 | { | |
| 167 | return $this->errors; | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Capture the current error state. | |
| 172 | * | |
| 173 | * Used by combinators such as {@see \Lucent\Validation\Combinators\Any} | |
| 174 | * to validate alternatives in isolation and roll back a failed branch's | |
| 175 | * errors via {@see restoreErrors()}. | |
| 176 | * | |
| 177 | * @return array<string, list<string>> A snapshot of the current errors. | |
| 178 | */ | |
| 179 | public function snapshotErrors(): array | |
| 180 | { | |
| 181 | return $this->errors; | |
| 182 | } | |
| 183 | ||
| 184 | /** | |
| 185 | * Restore a previously captured error state. | |
| 186 | * | |
| 187 | * Replaces the current errors with the given snapshot, discarding any | |
| 188 | * errors recorded since it was captured. | |
| 189 | * | |
| 190 | * @param array<string, list<string>> $errors The error snapshot to restore. | |
| 191 | * @return void | |
| 192 | */ | |
| 193 | public function restoreErrors(array $errors): void | |
| 194 | { | |
| 195 | $this->errors = $errors; | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Merge another result's errors and values into this one. | |
| 200 | * | |
| 201 | * Commits the errors and validated values of a branch result into this | |
| 202 | * result. Used by combinators such as | |
| 203 | * {@see \Lucent\Validation\Combinators\One} to validate each alternative | |
| 204 | * against a fresh, throwaway {@see Result} and then commit only the | |
| 205 | * winning branch — so a losing branch's errors *and* values never leak | |
| 206 | * into the final result. | |
| 207 | * | |
| 208 | * Errors are appended to any existing errors at the same field path. | |
| 209 | * Values are merged recursively: nested arrays are merged key-by-key, and | |
| 210 | * scalar values overwrite whatever is currently stored at that path. | |
| 211 | * | |
| 212 | * @param Result $other The branch result to commit into this one. | |
| 213 | * @return void | |
| 214 | */ | |
| 215 | public function merge(Result $other): void | |
| 216 | { | |
| 217 | foreach ($other->errors as $field => $messages) { | |
| 218 | $this->errors[$field] ??= []; | |
| 219 | foreach ($messages as $message) { | |
| 220 | $this->errors[$field][] = $message; | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | $this->values = $this->mergeValues($this->values, $other->values); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Recursively merge two value trees. | |
| 229 | * | |
| 230 | * When both sides are arrays, the result is a key-by-key merge (the | |
| 231 | * right-hand side wins on scalar conflicts). Otherwise the right-hand | |
| 232 | * value replaces the left-hand value. | |
| 233 | * | |
| 234 | * @param mixed $left The current value tree. | |
| 235 | * @param mixed $right The branch value tree to merge in. | |
| 236 | * @return mixed The merged value tree. | |
| 237 | */ | |
| 238 | private function mergeValues(mixed $left, mixed $right): mixed | |
| 239 | { | |
| 240 | if (is_array($left) && is_array($right)) { | |
| 241 | foreach ($right as $key => $value) { | |
| 242 | $left[$key] = array_key_exists($key, $left) | |
| 243 | ? $this->mergeValues($left[$key], $value) | |
| 244 | : $value; | |
| 245 | } | |
| 246 | return $left; | |
| 247 | } | |
| 248 | ||
| 249 | return $right; | |
| 250 | } | |
| 251 | ||
| 252 | /** | |
| 253 | * Determine whether any validation errors were recorded. | |
| 254 | * | |
| 255 | * @return bool True if at least one error exists. | |
| 256 | */ | |
| 257 | public function hasErrors(): bool | |
| 258 | { | |
| 259 | return count($this->errors) > 0; | |
| 260 | } | |
| 261 | ||
| 262 | /** | |
| 263 | * Get all validated values. | |
| 264 | * | |
| 265 | * @return mixed The validated values. Usually a nested array keyed by | |
| 266 | * top-level field name, but a scalar when a top-level scalar | |
| 267 | * constraint normalized a plain value. | |
| 268 | */ | |
| 269 | public function values(): mixed | |
| 270 | { | |
| 271 | return $this->values; | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Determine whether a value exists at a dotted path. | |
| 276 | * | |
| 277 | * @param string $path The dotted path of the field. | |
| 278 | * @return bool True if a value is stored at the path. | |
| 279 | */ | |
| 280 | public function hasValue(string $path): bool | |
| 281 | { | |
| 282 | return $path === '' || $this->tryValueAtPath($this->values, $path)[0]; | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * Get the value at a dotted path, or a default if absent. | |
| 287 | * | |
| 288 | * @param string $path The dotted path of the field. | |
| 289 | * @param mixed $default The value to return when the path has no stored value. | |
| 290 | * @return mixed The stored value, or $default. | |
| 291 | */ | |
| 292 | public function value(string $path, mixed $default = null): mixed | |
| 293 | { | |
| 294 | if ($path === '') { | |
| 295 | return $this->values; | |
| 296 | } | |
| 297 | ||
| 298 | [$found, $value] = $this->tryValue($path); | |
| 299 | ||
| 300 | return $found ? $value : $default; | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Get a value at a dotted path cast to a given type. | |
| 305 | * | |
| 306 | * A typed getter: the type is given as a class-string, so userland | |
| 307 | * classes work via `User::class`. Built-in scalar types are passed as | |
| 308 | * string literals (`'int'`, `'string'`, `'bool'`, `'float'`, `'array'`) — | |
| 309 | * `int::class` is not valid PHP. The stored value is cast to the | |
| 310 | * requested type, falling back to the default when the path is absent or | |
| 311 | * the value cannot be cast. | |
| 312 | * | |
| 313 | * ```php | |
| 314 | * $age = $result->valueAs('age', 'int'); // (int) value | |
| 315 | * $name = $result->valueAs('name', 'string'); // (string) value | |
| 316 | * $user = $result->valueAs('user', User::class); // value, or $default | |
| 317 | * ``` | |
| 318 | * | |
| 319 | * @template T | |
| 320 | * @param string $path The dotted path of the field. | |
| 321 | * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`). | |
| 322 | * @param T|null $default The value to return when the path is absent or the | |
| 323 | * value cannot be cast to the requested type. | |
| 324 | * @return ($default is null ? T|null : T) The value cast to the requested | |
| 325 | * type, or $default. When $default is non-null the return is always T. | |
| 326 | */ | |
| 327 | public function valueAs(string $path, string $type, mixed $default = null): mixed | |
| 328 | { | |
| 329 | $value = $this->value($path, $default); | |
| 330 | ||
| 331 | if ($value === $default) { | |
| 332 | return $default; | |
| 333 | } | |
| 334 | ||
| 335 | return match ($type) { | |
| 336 | 'int' => (int) $value, | |
| 337 | 'string' => (string) $value, | |
| 338 | 'bool' => (bool) $value, | |
| 339 | 'float' => (float) $value, | |
| 340 | 'array' => is_array($value) ? $value : $default, | |
| 341 | default => $value instanceof $type ? $value : $default, | |
| 342 | }; | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Get a value at a dotted path cast to a given type, or throw if absent. | |
| 347 | * | |
| 348 | * The fail-fast counterpart to {@see valueAs()}: intended for values the | |
| 349 | * caller genuinely cannot proceed without. Instead of returning a default | |
| 350 | * and forcing a null-check, it throws a descriptive | |
| 351 | * {@see \RuntimeException} so a missing or wrongly-typed value surfaces at | |
| 352 | * the point of use with a clear message. | |
| 353 | * | |
| 354 | * Scalar casts always succeed, so this throws only when the path is | |
| 355 | * absent or the value cannot satisfy a class/array type. It does not | |
| 356 | * throw on a scalar cast (e.g. `(int) 'abc'` → `0`). | |
| 357 | * | |
| 358 | * @template T | |
| 359 | * @param string $path The dotted path of the field. | |
| 360 | * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`). | |
| 361 | * @return T The value cast to the requested type. | |
| 362 | * @throws \RuntimeException When the path is absent or the value cannot | |
| 363 | * satisfy the requested class/array type. | |
| 364 | */ | |
| 365 | public function requireValueAs(string $path, string $type): mixed | |
| 366 | { | |
| 367 | if (!$this->hasValue($path)) { | |
| 368 | throw new \RuntimeException( | |
| 369 | sprintf('Result has no value at path "%s".', $path) | |
| 370 | ); | |
| 371 | } | |
| 372 | ||
| 373 | $value = $this->valueAs($path, $type); | |
| 374 | ||
| 375 | if ($value === null && !in_array($type, ['int', 'string', 'bool', 'float'], true)) { | |
| 376 | throw new \RuntimeException( | |
| 377 | sprintf('Result value at path "%s" cannot be cast to type "%s".', $path, $type) | |
| 378 | ); | |
| 379 | } | |
| 380 | ||
| 381 | return $value; | |
| 382 | } | |
| 383 | ||
| 384 | /** | |
| 385 | * Get the value at a dotted path in a single traversal. | |
| 386 | * | |
| 387 | * Distinguishes a key that is present with a `null` value from a key that | |
| 388 | * is absent entirely, without traversing the path twice. Delegates to the | |
| 389 | * shared {@see ResolvesPaths} helper. | |
| 390 | * | |
| 391 | * @param string $path The dotted path of the field. | |
| 392 | * @return array{0: bool, 1: mixed} `[true, value]` when the path resolves, | |
| 393 | * `[false, null]` when it does not. | |
| 394 | */ | |
| 395 | public function tryValue(string $path): array | |
| 396 | { | |
| 397 | return $this->tryValueAtPath($this->values, $path); | |
| 398 | } | |
| 399 | } |
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 | } |