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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

TypeCoercer->coerce
29    public function coerce(mixed $value, TypeMetadata $type, HydrationContext $context): mixed
30    {
31        if ($value === null) {
32            if ($type->allowsNull) {
33                return null;
36            $this->fail($value, $context, $type->describe(), 'null is not allowed');
37        }
38
39        if ($type->kind === 'union') {
39        if ($type->kind === 'union') {
40            return $this->coerceUnion($value, $type, $context);
43        return $this->coerceNamed($value, $type->names[0], $context, $type->describe());
44    }
TypeCoercer->coerceBool
157    private function coerceBool(mixed $value, HydrationContext $context, string $description): bool
158    {
159        if (is_bool($value)) {
160            return $value;
163        if (is_int($value) && ($value === 0 || $value === 1)) {
163        if (is_int($value) && ($value === 0 || $value === 1)) {
163        if (is_int($value) && ($value === 0 || $value === 1)) {
163        if (is_int($value) && ($value === 0 || $value === 1)) {
163        if (is_int($value) && ($value === 0 || $value === 1)) {
164            return $value === 1;
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',
175                    'expected bool, 0/1, or lowercase true/false token',
180        $this->fail($value, $context, $description, 'expected bool, 0/1, or lowercase true/false token');
181    }
TypeCoercer->coerceEnum
254        mixed $value,
255        string $enum,
256        HydrationContext $context,
257        string $description,
258    ): BackedEnum {
259        if ($value instanceof $enum) {
260            return $value;
263        $backing = new ReflectionEnum($enum)->getBackingType();
264        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
264        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
264        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
264        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
265
266        if ($backingType === 'int') {
267            if (is_int($value)) {
267            if (is_int($value)) {
268                $backingValue = $value;
269            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
269            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
269            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
269            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
270                $backingValue = $int;
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');
266        if ($backingType === 'int') {
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;
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;
281            $this->fail($value, $context, $description, 'enum is not backed');
282        }
283
284        try {
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    }
TypeCoercer->coerceFloat
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;
139            $this->fail($value, $context, $description, 'float must be finite');
140        }
141
142        if (is_int($value)) {
142        if (is_int($value)) {
143            return (float) $value;
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
146        if (is_string($value) && $value !== '' && trim($value) === $value && is_numeric($value)) {
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;
154        $this->fail($value, $context, $description, 'expected finite float, int, or numeric string');
155    }
TypeCoercer->coerceImmutableDate
201        mixed $value,
202        HydrationContext $context,
203        string $description,
204    ): DateTimeImmutable {
205        if ($value instanceof DateTimeImmutable) {
206            return $value;
209        if (!is_string($value) || $value === '') {
209        if (!is_string($value) || $value === '') {
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());
213        $timezone = new DateTimeZone(date_default_timezone_get());
214
215        foreach (self::DATE_FORMATS as $format) {
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) {
218            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
218            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
218            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
218            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
219                return $date;
215        foreach (self::DATE_FORMATS as $format) {
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    }
TypeCoercer->coerceInt
115    private function coerceInt(mixed $value, HydrationContext $context, string $description): int
116    {
117        if (is_int($value)) {
118            return $value;
121        if (is_string($value)) {
122            $int = $this->intFromString($value);
123
124            if ($int !== null) {
125                return $int;
129        $this->fail($value, $context, $description, 'expected int or decimal integer string');
130    }
TypeCoercer->coerceMutableDate
227        mixed $value,
228        HydrationContext $context,
229        string $description,
230    ): DateTime {
231        if ($value instanceof DateTime) {
232            return $value;
235        if (!is_string($value) || $value === '') {
235        if (!is_string($value) || $value === '') {
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());
239        $timezone = new DateTimeZone(date_default_timezone_get());
240
241        foreach (self::DATE_FORMATS as $format) {
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) {
244            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
244            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
244            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
244            if ($date !== false && !$this->dateHasErrors() && $date->format($format) === $value) {
245                return $date;
241        foreach (self::DATE_FORMATS as $format) {
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    }
TypeCoercer->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),
90            default => $this->coerceSpecial($value, $name, $context, $description),
91        };
92    }
TypeCoercer->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);
104        if ($name->date === 'mutable') {
105            return $this->coerceMutableDate($value, $context, $description);
108        if ($name->enum !== null) {
109            return $this->coerceEnum($value, $name->enum, $context, $description);
112        $this->fail($value, $context, $description, 'unsupported declared type');
113    }
TypeCoercer->coerceString
183    private function coerceString(mixed $value, HydrationContext $context, string $description): string
184    {
185        if (is_string($value)) {
186            return $value;
189        if (is_int($value) || is_float($value)) {
189        if (is_int($value) || is_float($value)) {
189        if (is_int($value) || is_float($value)) {
190            return (string) $value;
193        if (is_bool($value)) {
194            return $value ? '1' : '0';
194            return $value ? '1' : '0';
194            return $value ? '1' : '0';
194            return $value ? '1' : '0';
197        $this->fail($value, $context, $description, 'expected string or scalar value');
198    }
TypeCoercer->coerceUnion
46    private function coerceUnion(mixed $value, TypeMetadata $type, HydrationContext $context): mixed
47    {
48        foreach ($type->names as $name) {
48        foreach ($type->names as $name) {
49            if ($this->satisfies($value, $name)) {
50                return $value;
48        foreach ($type->names as $name) {
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) {
56        foreach (['enum', 'immutable', 'mutable', 'int', 'float', 'bool', 'string'] as $kind) {
57            foreach ($type->names as $name) {
57            foreach ($type->names as $name) {
58                if (!$this->matchesKind($name, $kind)) {
59                    continue;
62                try {
63                    return $this->coerceNamed($value, $name, $context, $type->describe());
64                } catch (InvalidTypeCoercion $e) {
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();
56        foreach (['enum', 'immutable', 'mutable', 'int', 'float', 'bool', 'string'] as $kind) {
57            foreach ($type->names as $name) {
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);
76        $this->fail($value, $context, $type->describe(), $reason);
77    }
TypeCoercer->dateHasErrors
363        $errors = DateTimeImmutable::getLastErrors();
364
365        return (
366            is_array($errors)
367            && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)
367            && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)
367            && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)
367            && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)
368        );
369    }
TypeCoercer->fail
372        mixed $value,
373        HydrationContext $context,
374        string $description,
375        string $reason,
376    ): never {
377        throw InvalidTypeCoercion::forContext($context, $value, $description, $reason);
378    }
TypeCoercer->intFromString
329    private function intFromString(string $value): ?int
330    {
331        if (!preg_match('/^-?\d+$/', $value)) {
331        if (!preg_match('/^-?\d+$/', $value)) {
331        if (!preg_match('/^-?\d+$/', $value)) {
331        if (!preg_match('/^-?\d+$/', $value)) {
332            return null;
335        if (!$this->integerStringFits($value)) {
336            return null;
339        return (int) $value;
340    }
TypeCoercer->integerStringFits
342    private function integerStringFits(string $value): bool
343    {
344        $negative = str_starts_with($value, '-');
344        $negative = str_starts_with($value, '-');
344        $negative = str_starts_with($value, '-');
344        $negative = str_starts_with($value, '-');
345        $digits = $negative ? substr($value, 1) : $value;
345        $digits = $negative ? substr($value, 1) : $value;
345        $digits = $negative ? substr($value, 1) : $value;
345        $digits = $negative ? substr($value, 1) : $value;
345        $digits = $negative ? substr($value, 1) : $value;
345        $digits = $negative ? substr($value, 1) : $value;
345        $digits = $negative ? substr($value, 1) : $value;
346        $digits = ltrim($digits, '0');
347
348        if ($digits === '') {
349            return true;
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
352        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
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
357            && strcmp($digits, $limit) <= 0
357            && strcmp($digits, $limit) <= 0
358        );
359    }
TypeCoercer->matchesKind
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,
325            default => false,
326        };
327    }
TypeCoercer->satisfies
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),
298            default => $this->satisfiesSpecial($value, $name),
299        };
300    }
TypeCoercer->satisfiesSpecial
302    private function satisfiesSpecial(mixed $value, NamedTypeMetadata $name): bool
303    {
304        if ($name->date === 'immutable') {
305            return $value instanceof DateTimeImmutable;
308        if ($name->date === 'mutable') {
309            return $value instanceof DateTime;
312        if ($name->enum !== null) {
313            return $value instanceof $name->enum;
316        return false;
317    }