Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
157 / 157 |
|
94.61% |
193 / 204 |
|
15.62% |
62 / 397 |
|
61.11% |
11 / 18 |
CRAP | |
0.00% |
0 / 1 |
| TypeCoercer | |
100.00% |
157 / 157 |
|
94.61% |
193 / 204 |
|
15.62% |
62 / 397 |
|
100.00% |
18 / 18 |
6602.75 | |
100.00% |
1 / 1 |
| coerce | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
60.00% |
3 / 5 |
|
100.00% |
1 / 1 |
5.02 | |||
| coerceUnion | |
100.00% |
15 / 15 |
|
100.00% |
18 / 18 |
|
2.27% |
1 / 44 |
|
100.00% |
1 / 1 |
67.73 | |||
| coerceNamed | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
6 | |||
| coerceSpecial | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| coerceInt | |
100.00% |
7 / 7 |
|
100.00% |
6 / 6 |
|
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| coerceFloat | |
100.00% |
11 / 11 |
|
90.91% |
20 / 22 |
|
5.41% |
6 / 111 |
|
100.00% |
1 / 1 |
77.56 | |||
| coerceBool | |
100.00% |
16 / 16 |
|
93.33% |
14 / 15 |
|
17.86% |
5 / 28 |
|
100.00% |
1 / 1 |
53.89 | |||
| coerceString | |
100.00% |
7 / 7 |
|
91.67% |
11 / 12 |
|
44.44% |
4 / 9 |
|
100.00% |
1 / 1 |
12.17 | |||
| coerceImmutableDate | |
100.00% |
10 / 10 |
|
100.00% |
16 / 16 |
|
4.88% |
2 / 41 |
|
100.00% |
1 / 1 |
63.08 | |||
| coerceMutableDate | |
100.00% |
10 / 10 |
|
100.00% |
16 / 16 |
|
2.44% |
1 / 41 |
|
100.00% |
1 / 1 |
67.43 | |||
| coerceEnum | |
100.00% |
18 / 18 |
|
100.00% |
22 / 22 |
|
27.78% |
5 / 18 |
|
100.00% |
1 / 1 |
47.67 | |||
| satisfies | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
6 | |||
| satisfiesSpecial | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| matchesKind | |
100.00% |
6 / 6 |
|
83.33% |
5 / 6 |
|
37.50% |
3 / 8 |
|
100.00% |
1 / 1 |
11.10 | |||
| intFromString | |
100.00% |
5 / 5 |
|
87.50% |
7 / 8 |
|
50.00% |
3 / 6 |
|
100.00% |
1 / 1 |
4.12 | |||
| integerStringFits | |
100.00% |
11 / 11 |
|
81.82% |
18 / 22 |
|
6.67% |
4 / 60 |
|
100.00% |
1 / 1 |
35.27 | |||
| dateHasErrors | |
100.00% |
5 / 5 |
|
80.00% |
4 / 5 |
|
66.67% |
2 / 3 |
|
100.00% |
1 / 1 |
3.33 | |||
| fail | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celemas\Quma\Hydration; |
| 6 | |
| 7 | use BackedEnum; |
| 8 | use Celemas\Quma\Exception\InvalidTypeCoercion; |
| 9 | use DateTime; |
| 10 | use DateTimeImmutable; |
| 11 | use DateTimeZone; |
| 12 | use ReflectionEnum; |
| 13 | use ReflectionNamedType; |
| 14 | use ValueError; |
| 15 | |
| 16 | /** @internal */ |
| 17 | final 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 | } |
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.
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 372 | mixed $value, |
| 373 | HydrationContext $context, |
| 374 | string $description, |
| 375 | string $reason, |
| 376 | ): never { |
| 377 | throw InvalidTypeCoercion::forContext($context, $value, $description, $reason); |
| 378 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |