Lines 100.00% 11 / 11
Functions and Methods 100.00% 4 / 4
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Optional 100.00% 11 / 11 100.00% 4 / 4 9 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 defaultMessage 100.00% 1 / 1 100.00% 1 / 1 1
 validate 100.00% 6 / 6 100.00% 1 / 1 3
 isEmpty 100.00% 3 / 3 100.00% 1 / 1 4
1<?php
2
3declare(strict_types=1);
4
5namespace Lucent\Validation\Combinators;
6
7use Closure;
8use Lucent\Validation\Constraint;
9use Lucent\Validation\FieldContext;
10use Override;
11
12/**
13 * A combinator that makes a wrapped constraint optional.
14 *
15 * Present-but-empty values (null, empty string, or empty array) are normalized
16 * to null before the inner constraint is applied; absent fields are left
17 * untouched so they do not appear in the result. Note that `false` and `0`
18 * are **not** treated as empty — they are validated by the inner constraint.
19 */
20final class Optional extends Constraint
21{
22    /**
23     * Create an optional wrapper around another constraint.
24     *
25     * @param Constraint $inner The constraint to apply when a value is present.
26     */
27    public function __construct(private readonly Constraint $inner) {}
28
29    /**
30     * Delegate the error message to the wrapped constraint.
31     *
32     * @return string|Closure(FieldContext): string The inner constraint's message or closure.
33     */
34    #[Override]
35    protected function defaultMessage(): string|Closure|null
36    {
37        return fn(FieldContext $ctx) => $this->inner->message($ctx);
38    }
39
40    /**
41     * Validate the wrapped constraint, skipping it when the field is empty.
42     *
43     * Validation is skipped when the field was not present in the request
44     * body, or when its value is null, an empty string, or an empty array.
45     * This mirrors the set of values {@see \Lucent\Validation\Constraints\Required}
46     * treats as missing, so an optional field may be omitted or left blank.
47     * Any other value is validated by the inner constraint.
48     *
49     * @param FieldContext $ctx The context of the field being validated.
50     * @return bool True when the field is empty, otherwise the result of the wrapped constraint.
51     */
52    #[Override]
53    public function validate(FieldContext $ctx): bool
54    {
55        if (!$ctx->present) {
56            return true;
57        }
58
59        if ($this->isEmpty($ctx->value)) {
60            // Normalize a present-but-empty value to null so the result
61            // reflects the documented behaviour of an optional field that was
62            // left blank. Absent fields are left untouched so they do not
63            // appear in the result.
64            $ctx->normalize(null);
65            return true;
66        }
67
68        return $this->inner->validate($ctx);
69    }
70
71    /**
72     * Determine whether a value counts as empty.
73     *
74     * @param mixed $value The value to inspect.
75     * @return bool True if the value is null, an empty string, or an empty array.
76     */
77    private function isEmpty(mixed $value): bool
78    {
79        if ($value === null || $value === '') {
80            return true;
81        }
82
83        return is_array($value) && $value === [];
84    }
85}