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
30final 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

18trait 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    }