Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
70 / 70
91.76% covered (success)
91.76%
78 / 85
52.05% covered (warning)
52.05%
38 / 73
85.19% covered (warning)
85.19%
23 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Field
100.00% covered (success)
100.00%
70 / 70
91.76% covered (success)
91.76%
78 / 85
52.05% covered (warning)
52.05%
38 / 73
100.00% covered (success)
100.00%
27 / 27
279.21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
3.19
 label
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 finalize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 empty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
7 / 7
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
6.80
 default
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 nullable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 optional
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 strict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 coerce
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defaultValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isNullable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isOptional
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 coercionMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 treatsMissingAsEmpty
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 isBlank
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 message
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messages
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
3.19
 messageOverrides
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 type
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 applyPreparation
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyFinalization
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 matchesEmpty
100.00% covered (success)
100.00%
5 / 5
83.33% covered (warning)
83.33%
15 / 18
62.50% covered (warning)
62.50%
5 / 8
100.00% covered (success)
100.00%
1 / 1
9.58
 messageKey
100.00% covered (success)
100.00%
7 / 7
88.24% covered (warning)
88.24%
15 / 17
22.22% covered (danger)
22.22%
6 / 27
100.00% covered (success)
100.00%
1 / 1
22.94
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Sire;
6
7/** @api */
8final class Field
9{
10    private const int FLAG_HAS_DEFAULT = 1;
11    private const int FLAG_NULLABLE = 2;
12    private const int FLAG_OPTIONAL = 4;
13
14    private ?string $label = null;
15    /** @var list<callable> */
16    private array $preparers = [];
17    /** @var list<callable> */
18    private array $finalizers = [];
19    /** @var list<Blank> */
20    private array $empty = [Blank::Missing];
21    private int $flags = 0;
22    private mixed $default = null;
23    private ?CoercionMode $coercionMode = null;
24    /** @var array<string, string> */
25    private array $messages = [];
26    /** @var list<string> */
27    public private(set) array $rules = [];
28
29    public function __construct(
30        public readonly string $field,
31        public readonly string|Contract\Validator $type,
32    ) {}
33
34    public function rules(string ...$rules): static
35    {
36        foreach ($rules as $rule) {
37            $this->rules[] = $rule;
38        }
39
40        return $this;
41    }
42
43    public function label(string $label): static
44    {
45        $this->label = $label;
46
47        return $this;
48    }
49
50    /** @param callable $callback */
51    public function prepare(callable $callback): static
52    {
53        $this->preparers[] = $callback;
54
55        return $this;
56    }
57
58    /** @param callable(mixed, array<string, mixed>): mixed $callback */
59    public function finalize(callable $callback): static
60    {
61        $this->finalizers[] = $callback;
62
63        return $this;
64    }
65
66    public function empty(Blank|string ...$empty): static
67    {
68        $this->empty = [];
69
70        foreach ($empty as $value) {
71            $this->empty[] = $value instanceof Blank ? $value : Blank::from($value);
72        }
73
74        return $this;
75    }
76
77    public function default(mixed $value): static
78    {
79        $this->default = $value;
80        $this->flags |= self::FLAG_HAS_DEFAULT;
81
82        if ($value === null) {
83            $this->nullable();
84        }
85
86        return $this;
87    }
88
89    public function nullable(): static
90    {
91        $this->flags |= self::FLAG_NULLABLE;
92
93        return $this;
94    }
95
96    public function optional(): static
97    {
98        $this->flags |= self::FLAG_OPTIONAL;
99
100        return $this;
101    }
102
103    public function strict(): static
104    {
105        $this->coercionMode = CoercionMode::Strict;
106
107        return $this;
108    }
109
110    public function coerce(): static
111    {
112        $this->coercionMode = CoercionMode::Coerce;
113
114        return $this;
115    }
116
117    public function hasDefault(): bool
118    {
119        return ($this->flags & self::FLAG_HAS_DEFAULT) !== 0;
120    }
121
122    public function defaultValue(): mixed
123    {
124        return $this->default;
125    }
126
127    public function isNullable(): bool
128    {
129        return ($this->flags & self::FLAG_NULLABLE) !== 0;
130    }
131
132    public function isOptional(): bool
133    {
134        return ($this->flags & self::FLAG_OPTIONAL) !== 0;
135    }
136
137    /** @internal */
138    public function coercionMode(CoercionMode $default): CoercionMode
139    {
140        return $this->coercionMode ?? $default;
141    }
142
143    public function treatsMissingAsEmpty(): bool
144    {
145        return in_array(Blank::Missing, $this->empty, true);
146    }
147
148    public function isBlank(mixed $value): bool
149    {
150        foreach ($this->empty as $empty) {
151            if ($this->matchesEmpty($empty, $value)) {
152                return true;
153            }
154        }
155
156        return false;
157    }
158
159    public function message(string $key, string $message): static
160    {
161        $this->messages[$this->messageKey($key)] = $message;
162
163        return $this;
164    }
165
166    /** @param array<string, string> $messages */
167    public function messages(array $messages): static
168    {
169        foreach ($messages as $key => $message) {
170            $this->message($key, $message);
171        }
172
173        return $this;
174    }
175
176    /** @return array<string, string> */
177    public function messageOverrides(): array
178    {
179        return $this->messages;
180    }
181
182    public function name(): string
183    {
184        return $this->label ?? $this->field;
185    }
186
187    public function type(): string
188    {
189        return is_string($this->type) ? $this->type : 'shape';
190    }
191
192    /** @param array<array-key, mixed> $data */
193    public function applyPreparation(mixed $value, array $data): mixed
194    {
195        return array_reduce(
196            $this->preparers,
197            static fn(mixed $value, callable $prepare): mixed => $prepare($value, $data),
198            $value,
199        );
200    }
201
202    /** @param array<string, mixed> $values */
203    public function applyFinalization(mixed $value, array $values): mixed
204    {
205        return array_reduce(
206            $this->finalizers,
207            fn(mixed $value, callable $finalize): mixed => $finalize(
208                $value,
209                array_replace($values, [$this->field => $value]),
210            ),
211            $value,
212        );
213    }
214
215    private function matchesEmpty(Blank $empty, mixed $value): bool
216    {
217        return match ($empty) {
218            Blank::Missing => false,
219            Blank::Null => $value === null,
220            Blank::String => $value === '',
221            Blank::Whitespace => is_string($value) && trim($value) === '',
222            Blank::List => $value === [],
223        };
224    }
225
226    private function messageKey(string $key): string
227    {
228        if ($key === 'type') {
229            return 'type.' . $this->type();
230        }
231
232        if ($key === 'missing' || $key === 'null') {
233            return $key;
234        }
235
236        if (str_starts_with($key, 'type.') || str_starts_with($key, 'rule.')) {
237            return $key;
238        }
239
240        return 'rule.' . $key;
241    }
242}