Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
157 / 157
94.61% covered (success)
94.61%
193 / 204
15.62% covered (danger)
15.62%
62 / 397
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
TypeCoercer
100.00% covered (success)
100.00%
157 / 157
94.61% covered (success)
94.61%
193 / 204
15.62% covered (danger)
15.62%
62 / 397
100.00% covered (success)
100.00%
18 / 18
6602.75
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
2.27% covered (danger)
2.27%
1 / 44
100.00% covered (success)
100.00%
1 / 1
67.73
 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
5.41% covered (danger)
5.41%
6 / 111
100.00% covered (success)
100.00%
1 / 1
77.56
 coerceBool
100.00% covered (success)
100.00%
16 / 16
93.33% covered (success)
93.33%
14 / 15
17.86% covered (danger)
17.86%
5 / 28
100.00% covered (success)
100.00%
1 / 1
53.89
 coerceString
100.00% covered (success)
100.00%
7 / 7
91.67% covered (success)
91.67%
11 / 12
44.44% covered (danger)
44.44%
4 / 9
100.00% covered (success)
100.00%
1 / 1
12.17
 coerceImmutableDate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
16 / 16
4.88% covered (danger)
4.88%
2 / 41
100.00% covered (success)
100.00%
1 / 1
63.08
 coerceMutableDate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
16 / 16
2.44% covered (danger)
2.44%
1 / 41
100.00% covered (success)
100.00%
1 / 1
67.43
 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%
7 / 7
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 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%
6 / 6
83.33% covered (warning)
83.33%
5 / 6
37.50% covered (danger)
37.50%
3 / 8
100.00% covered (success)
100.00%
1 / 1
11.10
 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%
11 / 11
81.82% covered (warning)
81.82%
18 / 22
6.67% covered (danger)
6.67%
4 / 60
100.00% covered (success)
100.00%
1 / 1
35.27
 dateHasErrors
100.00% covered (success)
100.00%
5 / 5
80.00% covered (warning)
80.00%
4 / 5
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
3.33
 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 Celemas\Quma\Hydration;
6
7use BackedEnum;
8use Celemas\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->getMessage();
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            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
219                return $date;
220            }
221        }
222
223        $this->fail($value, $context, $description, 'unsupported date/time format');
224    }
225
226    private function coerceMutableDate(
227        mixed $value,
228        HydrationContext $context,
229        string $description,
230    ): DateTime {
231        if ($value instanceof DateTime) {
232            return $value;
233        }
234
235        if (!is_string($value) || $value === '') {
236            $this->fail($value, $context, $description, 'expected non-empty date string');
237        }
238
239        $timezone = new DateTimeZone(date_default_timezone_get());
240
241        foreach (self::DATE_FORMATS as $format) {
242            $date = DateTime::createFromFormat('!' . $format, $value, $timezone);
243
244            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
245                return $date;
246            }
247        }
248
249        $this->fail($value, $context, $description, 'unsupported date/time format');
250    }
251
252    /** @param class-string<BackedEnum> $enum */
253    private function coerceEnum(
254        mixed $value,
255        string $enum,
256        HydrationContext $context,
257        string $description,
258    ): BackedEnum {
259        if ($value instanceof $enum) {
260            return $value;
261        }
262
263        $backing = new ReflectionEnum($enum)->getBackingType();
264        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
265
266        if ($backingType === 'int') {
267            if (is_int($value)) {
268                $backingValue = $value;
269            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
270                $backingValue = $int;
271            } else {
272                $this->fail($value, $context, $description, 'expected int enum backing value');
273            }
274        } elseif ($backingType === 'string') {
275            if (!is_string($value)) {
276                $this->fail($value, $context, $description, 'expected string enum backing value');
277            }
278
279            $backingValue = $value;
280        } else {
281            $this->fail($value, $context, $description, 'enum is not backed');
282        }
283
284        try {
285            return $enum::from($backingValue);
286        } catch (ValueError) {
287            $this->fail($value, $context, $description, 'no enum case matches the backing value');
288        }
289    }
290
291    private function satisfies(mixed $value, NamedTypeMetadata $name): bool
292    {
293        return match ($name->scalar) {
294            'int' => is_int($value),
295            'float' => is_float($value),
296            'bool' => is_bool($value),
297            'string' => is_string($value),
298            default => $this->satisfiesSpecial($value, $name),
299        };
300    }
301
302    private function satisfiesSpecial(mixed $value, NamedTypeMetadata $name): bool
303    {
304        if ($name->date === 'immutable') {
305            return $value instanceof DateTimeImmutable;
306        }
307
308        if ($name->date === 'mutable') {
309            return $value instanceof DateTime;
310        }
311
312        if ($name->enum !== null) {
313            return $value instanceof $name->enum;
314        }
315
316        return false;
317    }
318
319    private function matchesKind(NamedTypeMetadata $name, string $kind): bool
320    {
321        return match ($kind) {
322            'enum' => $name->enum !== null,
323            'immutable', 'mutable' => $name->date === $kind,
324            'int', 'float', 'bool', 'string' => $name->scalar === $kind,
325            default => false,
326        };
327    }
328
329    private function intFromString(string $value): ?int
330    {
331        if (!preg_match('/^-?\d+$/', $value)) {
332            return null;
333        }
334
335        if (!$this->integerStringFits($value)) {
336            return null;
337        }
338
339        return (int) $value;
340    }
341
342    private function integerStringFits(string $value): bool
343    {
344        $negative = str_starts_with($value, '-');
345        $digits = $negative ? substr($value, 1) : $value;
346        $digits = ltrim($digits, '0');
347
348        if ($digits === '') {
349            return true;
350        }
351
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
353
354        return (
355            strlen($digits) < strlen($limit)
356            || strlen($digits) === strlen($limit)
357            && strcmp($digits, $limit) <= 0
358        );
359    }
360
361    private function dateHasErrors(): bool
362    {
363        $errors = DateTimeImmutable::getLastErrors();
364
365        return (
366            is_array($errors)
367            && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)
368        );
369    }
370
371    private function fail(
372        mixed $value,
373        HydrationContext $context,
374        string $description,
375        string $reason,
376    ): never {
377        throw InvalidTypeCoercion::forContext($context, $value, $description, $reason);
378    }
379}