Lines
100.00%
52 / 52
Methods
100.00%
11 / 11
Classes
100.00%
1 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| object | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| tuple | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| defaultMessage | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 3 | ||
| validate | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 3 | ||
| normalizeValue | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 4 | ||
| validateTuple | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 5 | ||
| validateObject | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 5 | ||
| [Lucent\Validation\Concerns\RecordsConstraintFailure] recordConstraintFailure | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | ||
| [Lucent\Validation\Constraint] withMessage | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| [Lucent\Validation\Constraint] message | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | ||
| 30 | final class Shape extends Constraint | |
| 31 | { | |
| 32 | use RecordsConstraintFailure; | |
| 33 | ||
| 34 | /** | |
| 35 | * Create a shape. | |
| 36 | * | |
| 37 | * @param array<int|string, Constraint> $constraints Constraints keyed by | |
| 38 | * sub-field name (object) or position (tuple). | |
| 39 | * @param bool $isTuple Whether the shape is a tuple. | |
| 40 | */ | |
| 41 | private function __construct( | |
| 42 | private readonly array $constraints, | |
| 43 | private readonly bool $isTuple, | |
| 44 | ) {} | |
| 45 | ||
| 46 | /** | |
| 47 | * Create an object shape from a set of named sub-constraints. | |
| 48 | * | |
| 49 | * @param array<string, Constraint> $constraints Constraints keyed by sub-field name. | |
| 50 | * @return self A new object Shape instance. | |
| 51 | */ | |
| 52 | public static function object(array $constraints): self | |
| 53 | { | |
| 54 | return new self($constraints, false); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Create a tuple shape from a set of positional constraints. | |
| 59 | * | |
| 60 | * The tuple has exactly as many positions as constraints. Each position | |
| 61 | * is validated by its own constraint. | |
| 62 | * | |
| 63 | * @param Constraint ...$constraints Constraints applied positionally. | |
| 64 | * @return self A new tuple Shape instance. | |
| 65 | */ | |
| 66 | public static function tuple(Constraint ...$constraints): self | |
| 67 | { | |
| 68 | return new self($constraints, true); | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * @return string|\Closure(FieldContext): ?string|null The default error message. | |
| 73 | */ | |
| 74 | #[Override] | |
| 75 | protected function defaultMessage(): string|\Closure|null | |
| 76 | { | |
| 77 | return fn(FieldContext $ctx) => $ctx->childFailed | |
| 78 | ? null | |
| 79 | : ($this->isTuple | |
| 80 | ? "The {$ctx->field} must be an array with exactly " . count($this->constraints) . ' elements.' | |
| 81 | : "The {$ctx->field} must be an object."); | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * Validate the value as an object or tuple. | |
| 86 | * | |
| 87 | * Fails (returns false) when the value is not an array (or, for an object | |
| 88 | * shape, not an object), when a tuple's length does not match its | |
| 89 | * constraints, or when any child constraint fails. When a child fails, | |
| 90 | * its specific error is already recorded on the result at its dotted | |
| 91 | * path, so {@see defaultMessage()} returns null to avoid a redundant | |
| 92 | * generic error. | |
| 93 | * | |
| 94 | * @param FieldContext $ctx The context of the field being validated. | |
| 95 | * @return bool True if the value has the expected shape and all children pass. | |
| 96 | */ | |
| 97 | #[Override] | |
| 98 | public function validate(FieldContext $ctx): bool | |
| 99 | { | |
| 100 | $ctx->childFailed = false; | |
| 101 | ||
| 102 | $value = $this->normalizeValue($ctx->value); | |
| 103 | ||
| 104 | if ($value === null) { | |
| 105 | return false; | |
| 106 | } | |
| 107 | ||
| 108 | if ($this->isTuple) { | |
| 109 | return $this->validateTuple($ctx, $value); | |
| 110 | } | |
| 111 | ||
| 112 | return $this->validateObject($ctx, $value); | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * Coerce the raw value into an array, or null if it has the wrong shape. | |
| 117 | * | |
| 118 | * @param mixed $value The raw value. | |
| 119 | * @return array<int|string, mixed>|null The value as an array, or null. | |
| 120 | */ | |
| 121 | private function normalizeValue(mixed $value): array|null | |
| 122 | { | |
| 123 | if (is_array($value)) { | |
| 124 | return $value; | |
| 125 | } | |
| 126 | ||
| 127 | if (!$this->isTuple && is_object($value)) { | |
| 128 | return get_object_vars($value); | |
| 129 | } | |
| 130 | ||
| 131 | return null; | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Validate a tuple value against its positional constraints. | |
| 136 | * | |
| 137 | * @param FieldContext $ctx The context of the field being validated. | |
| 138 | * @param array<int|string, mixed> $value The tuple value. | |
| 139 | * @return bool False when the length does not match or a child fails. | |
| 140 | */ | |
| 141 | private function validateTuple(FieldContext $ctx, array $value): bool | |
| 142 | { | |
| 143 | // A tuple is a positional list. An associative array is a map/object | |
| 144 | // and is rejected, as is a list whose length does not match the | |
| 145 | // number of constraints. | |
| 146 | if (!array_is_list($value) || count($value) !== count($this->constraints)) { | |
| 147 | return false; | |
| 148 | } | |
| 149 | ||
| 150 | $ctx->result->ensureContainer($ctx->field); | |
| 151 | ||
| 152 | foreach ($this->constraints as $index => $constraint) { | |
| 153 | $child = $ctx->child($index, $value[$index], true); | |
| 154 | $child->seedRaw(); | |
| 155 | ||
| 156 | if (!$this->recordConstraintFailure($constraint, $child)) { | |
| 157 | $ctx->childFailed = true; | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | return !$ctx->childFailed; | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Validate an object value against its named sub-constraints. | |
| 166 | * | |
| 167 | * @param FieldContext $ctx The context of the field being validated. | |
| 168 | * @param array<int|string, mixed> $value The object value. | |
| 169 | * @return bool False when a child fails, true otherwise. | |
| 170 | */ | |
| 171 | private function validateObject(FieldContext $ctx, array $value): bool | |
| 172 | { | |
| 173 | // Declare the field as a container, then seed each present declared | |
| 174 | // sub-field with its raw value. Undeclared keys are never written, so | |
| 175 | // they are excluded from the result. Each present sub-field is then | |
| 176 | // validated and may overwrite its value via normalization. | |
| 177 | $ctx->result->ensureContainer($ctx->field); | |
| 178 | ||
| 179 | foreach ($this->constraints as $name => $constraint) { | |
| 180 | $present = array_key_exists($name, $value); | |
| 181 | $child = $ctx->child($name, $present ? $value[$name] : null, $present); | |
| 182 | ||
| 183 | if ($present) { | |
| 184 | $child->seedRaw(); | |
| 185 | } | |
| 186 | ||
| 187 | if (!$this->recordConstraintFailure($constraint, $child)) { | |
| 188 | $ctx->childFailed = true; | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | return !$ctx->childFailed; | |
| 193 | } | |
| 194 | } |
From Lucent\Validation\Concerns\RecordsConstraintFailure
| 18 | trait RecordsConstraintFailure | |
| 19 | { | |
| 20 | /** | |
| 21 | * Validate a constraint and record its error if it fails. | |
| 22 | * | |
| 23 | * Runs the constraint against the context. On failure, resolves the | |
| 24 | * constraint's message and records it on the result at the context's | |
| 25 | * field path, unless the message is null (meaning a child already | |
| 26 | * recorded its specific error). | |
| 27 | * | |
| 28 | * @param Constraint $constraint The constraint to validate. | |
| 29 | * @param FieldContext $ctx The context of the field being validated. | |
| 30 | * @return bool True if the constraint passed, false otherwise. | |
| 31 | */ | |
| 32 | protected function recordConstraintFailure(Constraint $constraint, FieldContext $ctx): bool | |
| 33 | { | |
| 34 | if ($constraint->validate($ctx)) { | |
| 35 | return true; | |
| 36 | } | |
| 37 | ||
| 38 | $message = $constraint->message($ctx); | |
| 39 | if ($message !== null) { | |
| 40 | $ctx->result->addError($ctx->field, $message); | |
| 41 | } | |
| 42 | ||
| 43 | return false; | |
| 44 | } | |
| 45 | } |
Inherited from Lucent\Validation\Constraint
| 38 | final public function withMessage(string|\Closure $message): static | |
| 39 | { | |
| 40 | $this->customMessage = $message; | |
| 41 | return $this; | |
| 42 | } |
| 56 | final public function message(FieldContext $ctx): ?string | |
| 57 | { | |
| 58 | $message = $this->customMessage ?? $this->defaultMessage(); | |
| 59 | ||
| 60 | if ($message === null) { | |
| 61 | return null; | |
| 62 | } | |
| 63 | ||
| 64 | if ($message instanceof \Closure) { | |
| 65 | return call_user_func($message, $ctx); | |
| 66 | } | |
| 67 | ||
| 68 | return $message; | |
| 69 | } |