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}

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
256        mixed $value,
257        string $enum,
258        HydrationContext $context,
259        string $description,
260    ): BackedEnum {
261        if ($value instanceof $enum) {
262            return $value;
265        $backing = new ReflectionEnum($enum)->getBackingType();
266        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
266        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
266        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
266        $backingType = $backing instanceof ReflectionNamedType ? $backing->getName() : null;
267
268        if ($backingType === 'int') {
269            if (is_int($value)) {
269            if (is_int($value)) {
270                $backingValue = $value;
271            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
271            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
271            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
271            } elseif (is_string($value) && ($int = $this->intFromString($value)) !== null) {
272                $backingValue = $int;
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');
268        if ($backingType === 'int') {
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;
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;
283            $this->fail($value, $context, $description, 'enum is not backed');
284        }
285
286        try {
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    }
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            // The round trip also rejects input that parsed with warnings or errors.
219            if ($date !== false && $date->format($format) === $value) {
219            if ($date !== false && $date->format($format) === $value) {
219            if ($date !== false && $date->format($format) === $value) {
220                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            // 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    }
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
228        mixed $value,
229        HydrationContext $context,
230        string $description,
231    ): DateTime {
232        if ($value instanceof DateTime) {
233            return $value;
236        if (!is_string($value) || $value === '') {
236        if (!is_string($value) || $value === '') {
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());
240        $timezone = new DateTimeZone(date_default_timezone_get());
241
242        foreach (self::DATE_FORMATS as $format) {
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) {
246            if ($date !== false && $date->format($format) === $value) {
246            if ($date !== false && $date->format($format) === $value) {
247                return $date;
242        foreach (self::DATE_FORMATS as $format) {
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    }
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->reason();
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->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);
76        $this->fail($value, $context, $type->describe(), $reason);
77    }
TypeCoercer->fail
358        mixed $value,
359        HydrationContext $context,
360        string $description,
361        string $reason,
362    ): never {
363        throw InvalidTypeCoercion::forContext($context, $value, $description, $reason);
364    }
TypeCoercer->intFromString
330    private function intFromString(string $value): ?int
331    {
332        if (!preg_match('/^-?\d+$/', $value)) {
332        if (!preg_match('/^-?\d+$/', $value)) {
332        if (!preg_match('/^-?\d+$/', $value)) {
332        if (!preg_match('/^-?\d+$/', $value)) {
333            return null;
336        if (!$this->integerStringFits($value)) {
337            return null;
340        return (int) $value;
341    }
TypeCoercer->integerStringFits
343    private function integerStringFits(string $value): bool
344    {
345        $negative = str_starts_with($value, '-');
345        $negative = str_starts_with($value, '-');
345        $negative = str_starts_with($value, '-');
345        $negative = str_starts_with($value, '-');
346        $digits = $negative ? substr($value, 1) : $value;
346        $digits = $negative ? substr($value, 1) : $value;
346        $digits = $negative ? substr($value, 1) : $value;
346        $digits = $negative ? substr($value, 1) : $value;
346        $digits = $negative ? substr($value, 1) : $value;
346        $digits = $negative ? substr($value, 1) : $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;
348        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
348        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
348        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
348        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
348        $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
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
353                && strcmp($digits, $limit) <= 0
353                && strcmp($digits, $limit) <= 0
354        );
355    }
TypeCoercer->matchesKind
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,
326            default => $name->scalar === $kind,
327        };
328    }
TypeCoercer->satisfies
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),
297            'float' => is_float($value) && is_finite($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),
300            default => $this->satisfiesSpecial($value, $name),
301        };
302    }
TypeCoercer->satisfiesSpecial
304    private function satisfiesSpecial(mixed $value, NamedTypeMetadata $name): bool
305    {
306        if ($name->date === 'immutable') {
307            return $value instanceof DateTimeImmutable;
310        if ($name->date === 'mutable') {
311            return $value instanceof DateTime;
314        if ($name->enum !== null) {
315            return $value instanceof $name->enum;
318        return false;
319    }