Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
149 / 149
96.39% covered (success)
96.39%
187 / 194
19.55% covered (danger)
19.55%
69 / 353
76.47% covered (warning)
76.47%
13 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TypeCoercer
100.00% covered (success)
100.00%
149 / 149
96.39% covered (success)
96.39%
187 / 194
19.55% covered (danger)
19.55%
69 / 353
100.00% covered (success)
100.00%
17 / 17
5099.30
100.00% covered (success)
100.00%
1 / 1
 coerce
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
60.00% covered (warning)
60.00%
3 / 5
100.00% covered (success)
100.00%
1 / 1
5.02
 coerceUnion
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
18 / 18
4.55% covered (danger)
4.55%
2 / 44
100.00% covered (success)
100.00%
1 / 1
63.66
 coerceNamed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 coerceSpecial
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 coerceInt
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 coerceFloat
100.00% covered (success)
100.00%
11 / 11
90.91% covered (success)
90.91%
20 / 22
6.31% covered (danger)
6.31%
7 / 111
100.00% covered (success)
100.00%
1 / 1
75.62
 coerceBool
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
15 / 15
25.00% covered (danger)
25.00%
7 / 28
100.00% covered (success)
100.00%
1 / 1
43.17
 coerceString
100.00% covered (success)
100.00%
7 / 7
91.67% covered (success)
91.67%
11 / 12
55.56% covered (warning)
55.56%
5 / 9
100.00% covered (success)
100.00%
1 / 1
9.16
 coerceImmutableDate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
14 / 14
12.00% covered (danger)
12.00%
3 / 25
100.00% covered (success)
100.00%
1 / 1
40.39
 coerceMutableDate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
14 / 14
8.00% covered (danger)
8.00%
2 / 25
100.00% covered (success)
100.00%
1 / 1
45.16
 coerceEnum
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
22 / 22
27.78% covered (danger)
27.78%
5 / 18
100.00% covered (success)
100.00%
1 / 1
47.67
 satisfies
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 satisfiesSpecial
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 matchesKind
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
4.25
 intFromString
100.00% covered (success)
100.00%
5 / 5
87.50% covered (warning)
87.50%
7 / 8
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
4.12
 integerStringFits
100.00% covered (success)
100.00%
9 / 9
85.00% covered (warning)
85.00%
17 / 20
9.26% covered (danger)
9.26%
5 / 54
100.00% covered (success)
100.00%
1 / 1
23.68
 fail
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
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma\Hydration;
6
7use BackedEnum;
8use Celema\Quma\Exception\InvalidTypeCoercion;
9use DateTime;
10use DateTimeImmutable;
11use DateTimeZone;
12use ReflectionEnum;
13use ReflectionNamedType;
14use ValueError;
15
16/** @internal */
17final class TypeCoercer
18{
19    private const array DATE_FORMATS = [
20        'Y-m-d H:i:s.uP',
21        'Y-m-d H:i:sP',
22        'Y-m-d\TH:i:s.uP',
23        'Y-m-d\TH:i:sP',
24        'Y-m-d H:i:s.u',
25        'Y-m-d H:i:s',
26        'Y-m-d',
27    ];
28
29    public function coerce(mixed $value, TypeMetadata $type, HydrationContext $context): mixed
30    {
31        if ($value === null) {
32            if ($type->allowsNull) {
33                return null;
34            }
35
36            $this->fail($value, $context, $type->describe(), 'null is not allowed');
37        }
38
39        if ($type->kind === 'union') {
40            return $this->coerceUnion($value, $type, $context);
41        }
42
43        return $this->coerceNamed($value, $type->names[0], $context, $type->describe());
44    }
45
46    private function coerceUnion(mixed $value, TypeMetadata $type, HydrationContext $context): mixed
47    {
48        foreach ($type->names as $name) {
49            if ($this->satisfies($value, $name)) {
50                return $value;
51            }
52        }
53
54        $lastFailure = null;
55
56        foreach (['enum', 'immutable', 'mutable', 'int', 'float', 'bool', 'string'] as $kind) {
57            foreach ($type->names as $name) {
58                if (!$this->matchesKind($name, $kind)) {
59                    continue;
60                }
61
62                try {
63                    return $this->coerceNamed($value, $name, $context, $type->describe());
64                } catch (InvalidTypeCoercion $e) {
65                    $lastFailure = $e->reason();
66                }
67            }
68        }
69
70        $reason = 'no union arm accepted the value';
71
72        if ($lastFailure !== null) {
73            $reason .= '; last failure: ' . $lastFailure;
74        }
75
76        $this->fail($value, $context, $type->describe(), $reason);
77    }
78
79    private function coerceNamed(
80        mixed $value,
81        NamedTypeMetadata $name,
82        HydrationContext $context,
83        string $description,
84    ): mixed {
85        return match ($name->scalar) {
86            'int' => $this->coerceInt($value, $context, $description),
87            'float' => $this->coerceFloat($value, $context, $description),
88            'bool' => $this->coerceBool($value, $context, $description),
89            'string' => $this->coerceString($value, $context, $description),
90            default => $this->coerceSpecial($value, $name, $context, $description),
91        };
92    }
93
94    private function coerceSpecial(
95        mixed $value,
96        NamedTypeMetadata $name,
97        HydrationContext $context,
98        string $description,
99    ): mixed {
100        if ($name->date === 'immutable') {
101            return $this->coerceImmutableDate($value, $context, $description);
102        }
103
104        if ($name->date === 'mutable') {
105            return $this->coerceMutableDate($value, $context, $description);
106        }
107
108        if ($name->enum !== null) {
109            return $this->coerceEnum($value, $name->enum, $context, $description);
110        }
111
112        $this->fail($value, $context, $description, 'unsupported declared type');
113    }
114
115    private function coerceInt(mixed $value, HydrationContext $context, string $description): int
116    {
117        if (is_int($value)) {
118            return $value;
119        }
120
121        if (is_string($value)) {
122            $int = $this->intFromString($value);
123
124            if ($int !== null) {
125                return $int;
126            }
127        }
128
129        $this->fail($value, $context, $description, 'expected int or decimal integer string');
130    }
131
132    private function coerceFloat(mixed $value, HydrationContext $context, string $description): float
133    {
134        if (is_float($value)) {
135            if (is_finite($value)) {
136                return $value;
137            }
138
139            $this->fail($value, $context, $description, 'float must be finite');
140        }
141
142        if (is_int($value)) {
143            return (float) $value;
144        }
145
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
147            $float = (float) $value;
148
149            if (is_finite($float)) {
150                return $float;
151            }
152        }
153
154        $this->fail($value, $context, $description, 'expected finite float, int, or numeric string');
155    }
156
157    private function coerceBool(mixed $value, HydrationContext $context, string $description): bool
158    {
159        if (is_bool($value)) {
160            return $value;
161        }
162
163        if (is_int($value) && ($value === 0 || $value === 1)) {
164            return $value === 1;
165        }
166
167        if (is_string($value)) {
168            return match ($value) {
169                '0', 'false', 'f' => false,
170                '1', 'true', 't' => true,
171                default => $this->fail(
172                    $value,
173                    $context,
174                    $description,
175                    'expected bool, 0/1, or lowercase true/false token',
176                ),
177            };
178        }
179
180        $this->fail($value, $context, $description, 'expected bool, 0/1, or lowercase true/false token');
181    }
182
183    private function coerceString(mixed $value, HydrationContext $context, string $description): string
184    {
185        if (is_string($value)) {
186            return $value;
187        }
188
189        if (is_int($value) || is_float($value)) {
190            return (string) $value;
191        }
192
193        if (is_bool($value)) {
194            return $value ? '1' : '0';
195        }
196
197        $this->fail($value, $context, $description, 'expected string or scalar value');
198    }
199
200    private function coerceImmutableDate(
201        mixed $value,
202        HydrationContext $context,
203        string $description,
204    ): DateTimeImmutable {
205        if ($value instanceof DateTimeImmutable) {
206            return $value;
207        }
208
209        if (!is_string($value) || $value === '') {
210            $this->fail($value, $context, $description, 'expected non-empty date string');
211        }
212
213        $timezone = new DateTimeZone(date_default_timezone_get());
214
215        foreach (self::DATE_FORMATS as $format) {
216            $date = DateTimeImmutable::createFromFormat('!' . $format, $value, $timezone);
217
218            // The round trip also rejects input that parsed with warnings or errors.
219            if ($date !== false && $date->format($format) === $value) {
220                return $date;
221            }
222        }
223
224        $this->fail($value, $context, $description, 'unsupported date/time format');
225    }
226
227    private function coerceMutableDate(
228        mixed $value,
229        HydrationContext $context,
230        string $description,
231    ): DateTime {
232        if ($value instanceof DateTime) {
233            return $value;
234        }
235
236        if (!is_string($value) || $value === '') {
237            $this->fail($value, $context, $description, 'expected non-empty date string');
238        }
239
240        $timezone = new DateTimeZone(date_default_timezone_get());
241
242        foreach (self::DATE_FORMATS as $format) {
243            $date = DateTime::createFromFormat('!' . $format, $value, $timezone);
244
245            // The round trip also rejects input that parsed with warnings or errors.
246            if ($date !== false && $date->format($format) === $value) {
247                return $date;
248            }
249        }
250
251        $this->fail($value, $context, $description, 'unsupported date/time format');
252    }
253
254    /** @param class-string<BackedEnum> $enum */
255    private function coerceEnum(
256        mixed $value,
257        string $enum,
258        HydrationContext $context,
259        string $description,
260    ): BackedEnum {
261        if ($value instanceof $enum) {
262            return $value;
263        }
264
265        $backing = new ReflectionEnum($enum)->getBackingType();
266        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
267
268        if ($backingType === 'int') {
269            if (is_int($value)) {
270                $backingValue = $value;
271            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
272                $backingValue = $int;
273            } else {
274                $this->fail($value, $context, $description, 'expected int enum backing value');
275            }
276        } elseif ($backingType === 'string') {
277            if (!is_string($value)) {
278                $this->fail($value, $context, $description, 'expected string enum backing value');
279            }
280
281            $backingValue = $value;
282        } else {
283            $this->fail($value, $context, $description, 'enum is not backed');
284        }
285
286        try {
287            return $enum::from($backingValue);
288        } catch (ValueError) {
289            $this->fail($value, $context, $description, 'no enum case matches the backing value');
290        }
291    }
292
293    private function satisfies(mixed $value, NamedTypeMetadata $name): bool
294    {
295        return match ($name->scalar) {
296            'int' => is_int($value),
297            'float' => is_float($value) && is_finite($value),
298            'bool' => is_bool($value),
299            'string' => is_string($value),
300            default => $this->satisfiesSpecial($value, $name),
301        };
302    }
303
304    private function satisfiesSpecial(mixed $value, NamedTypeMetadata $name): bool
305    {
306        if ($name->date === 'immutable') {
307            return $value instanceof DateTimeImmutable;
308        }
309
310        if ($name->date === 'mutable') {
311            return $value instanceof DateTime;
312        }
313
314        if ($name->enum !== null) {
315            return $value instanceof $name->enum;
316        }
317
318        return false;
319    }
320
321    private function matchesKind(NamedTypeMetadata $name, string $kind): bool
322    {
323        return match ($kind) {
324            'enum' => $name->enum !== null,
325            'immutable', 'mutable' => $name->date === $kind,
326            default => $name->scalar === $kind,
327        };
328    }
329
330    private function intFromString(string $value): ?int
331    {
332        if (!preg_match('/^-?\d+$/', $value)) {
333            return null;
334        }
335
336        if (!$this->integerStringFits($value)) {
337            return null;
338        }
339
340        return (int) $value;
341    }
342
343    private function integerStringFits(string $value): bool
344    {
345        $negative = str_starts_with($value, '-');
346        $digits = $negative ? substr($value, 1) : $value;
347        $digits = ltrim($digits, '0');
348        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
349
350        return (
351            strlen($digits) < strlen($limit)
352                || strlen($digits) === strlen($limit)
353                && strcmp($digits, $limit) <= 0
354        );
355    }
356
357    private function fail(
358        mixed $value,
359        HydrationContext $context,
360        string $description,
361        string $reason,
362    ): never {
363        throw InvalidTypeCoercion::forContext($context, $value, $description, $reason);
364    }
365}