Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
123 / 123 |
|
96.36% |
53 / 55 |
|
72.73% |
24 / 33 |
|
77.78% |
7 / 9 |
CRAP | |
0.00% |
0 / 1 |
| Hydrator | |
100.00% |
123 / 123 |
|
96.36% |
53 / 55 |
|
72.73% |
24 / 33 |
|
100.00% |
9 / 9 |
41.79 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| default | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hydrate | |
100.00% |
16 / 16 |
|
100.00% |
7 / 7 |
|
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| stringKeyRow | |
100.00% |
6 / 6 |
|
100.00% |
6 / 6 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
4.12 | |||
| resolveClass | |
100.00% |
6 / 6 |
|
100.00% |
5 / 5 |
|
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| validateClass | |
100.00% |
15 / 15 |
|
87.50% |
7 / 8 |
|
60.00% |
3 / 5 |
|
100.00% |
1 / 1 |
3.58 | |||
| hydrateViaFactory | |
100.00% |
13 / 13 |
|
100.00% |
7 / 7 |
|
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| hydrateViaConstructor | |
100.00% |
44 / 44 |
|
100.00% |
16 / 16 |
|
60.00% |
6 / 10 |
|
100.00% |
1 / 1 |
12.10 | |||
| isBuiltinTypeName | |
100.00% |
20 / 20 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celema\Quma\Hydration; |
| 6 | |
| 7 | use Celema\Quma\Exception\HydrationFailure; |
| 8 | use Celema\Quma\Exception\InvalidHydrationTarget; |
| 9 | use Celema\Quma\Exception\InvalidTypeCoercion; |
| 10 | use Celema\Quma\Exception\MissingColumn; |
| 11 | use Celema\Quma\Hydratable; |
| 12 | use Closure; |
| 13 | use Throwable; |
| 14 | use TypeError; |
| 15 | |
| 16 | /** @internal */ |
| 17 | final class Hydrator |
| 18 | { |
| 19 | private MetadataCache $cache; |
| 20 | private TypeCoercer $coercer; |
| 21 | |
| 22 | public function __construct(?MetadataCache $cache = null) |
| 23 | { |
| 24 | $this->cache = $cache ?? new StaticReflectionCache(); |
| 25 | $this->coercer = new TypeCoercer(); |
| 26 | } |
| 27 | |
| 28 | public static function default(): self |
| 29 | { |
| 30 | return new self(); |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * @template T of object |
| 35 | * |
| 36 | * @param array<array-key, mixed> $record |
| 37 | * @param class-string<T>|Closure(array<string, mixed>):class-string<T> $map |
| 38 | * @return T |
| 39 | */ |
| 40 | public function hydrate(array $record, string|Closure $map, ?string $sourcePath): object |
| 41 | { |
| 42 | $row = $this->stringKeyRow($record); |
| 43 | $rowKeys = array_keys($row); |
| 44 | $class = $this->resolveClass($map, $row, $sourcePath, $rowKeys); |
| 45 | |
| 46 | try { |
| 47 | $metadata = $this->cache->metadata($class); |
| 48 | } catch (InvalidHydrationTarget $e) { |
| 49 | throw InvalidHydrationTarget::forTarget( |
| 50 | $class, |
| 51 | $sourcePath, |
| 52 | $rowKeys, |
| 53 | $e->reason(), |
| 54 | $e, |
| 55 | ); |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * @mago-expect lint:inline-variable-return Psalm makes this necessary |
| 60 | * @var T $object The object's class is the one $map names or returns. |
| 61 | */ |
| 62 | $object = $metadata->hydratable |
| 63 | ? $this->hydrateViaFactory($class, $row, $sourcePath, $rowKeys) |
| 64 | : $this->hydrateViaConstructor($metadata, $row, $sourcePath, $rowKeys); |
| 65 | |
| 66 | return $object; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * @param array<array-key, mixed> $record |
| 71 | * @return array<string, mixed> |
| 72 | * @psalm-suppress MixedAssignment Row values can be anything. |
| 73 | */ |
| 74 | private function stringKeyRow(array $record): array |
| 75 | { |
| 76 | $row = []; |
| 77 | |
| 78 | foreach ($record as $key => $value) { |
| 79 | if (!is_string($key)) { |
| 80 | continue; |
| 81 | } |
| 82 | |
| 83 | $row[$key] = $value; |
| 84 | } |
| 85 | |
| 86 | return $row; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * @param array<string, mixed> $row |
| 91 | * @param list<string> $rowKeys |
| 92 | * @param string|Closure $map |
| 93 | * @return class-string |
| 94 | */ |
| 95 | private function resolveClass( |
| 96 | string|Closure $map, |
| 97 | array $row, |
| 98 | ?string $sourcePath, |
| 99 | array $rowKeys, |
| 100 | ): string { |
| 101 | if (is_string($map)) { |
| 102 | return $this->validateClass($map, $sourcePath, $rowKeys); |
| 103 | } |
| 104 | |
| 105 | $result = $map($row); |
| 106 | |
| 107 | if (!is_string($result)) { |
| 108 | throw InvalidHydrationTarget::forClosureResult($result, $sourcePath, $rowKeys); |
| 109 | } |
| 110 | |
| 111 | return $this->validateClass($result, $sourcePath, $rowKeys); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * @param list<string> $rowKeys |
| 116 | * @return class-string |
| 117 | */ |
| 118 | private function validateClass(string $target, ?string $sourcePath, array $rowKeys): string |
| 119 | { |
| 120 | if ($this->isBuiltinTypeName($target)) { |
| 121 | throw InvalidHydrationTarget::forTarget( |
| 122 | $target, |
| 123 | $sourcePath, |
| 124 | $rowKeys, |
| 125 | 'built-in type names cannot be hydrated', |
| 126 | ); |
| 127 | } |
| 128 | |
| 129 | if (!class_exists($target)) { |
| 130 | throw InvalidHydrationTarget::forTarget( |
| 131 | $target, |
| 132 | $sourcePath, |
| 133 | $rowKeys, |
| 134 | 'target is not an existing class', |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | return $target; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * @param array<string, mixed> $row |
| 143 | * @param list<string> $rowKeys |
| 144 | * @param class-string $class |
| 145 | */ |
| 146 | private function hydrateViaFactory( |
| 147 | string $class, |
| 148 | array $row, |
| 149 | ?string $sourcePath, |
| 150 | array $rowKeys, |
| 151 | ): object { |
| 152 | if (!is_a($class, Hydratable::class, true)) { |
| 153 | throw InvalidHydrationTarget::forTarget( |
| 154 | $class, |
| 155 | $sourcePath, |
| 156 | $rowKeys, |
| 157 | 'target is not hydratable', |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | $hydratable = $class; |
| 162 | |
| 163 | try { |
| 164 | return $hydratable::fromRow($row); |
| 165 | } catch (HydrationFailure $e) { |
| 166 | throw $e; |
| 167 | } catch (Throwable $e) { |
| 168 | throw HydrationFailure::fromHydratableFailure($class, $sourcePath, $rowKeys, $e); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * @param array<string, mixed> $row |
| 174 | * @param list<string> $rowKeys |
| 175 | */ |
| 176 | private function hydrateViaConstructor( |
| 177 | ClassMetadata $metadata, |
| 178 | array $row, |
| 179 | ?string $sourcePath, |
| 180 | array $rowKeys, |
| 181 | ): object { |
| 182 | if (!$metadata->instantiable) { |
| 183 | throw InvalidHydrationTarget::forTarget( |
| 184 | $metadata->class, |
| 185 | $sourcePath, |
| 186 | $rowKeys, |
| 187 | 'target is not instantiable', |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | $args = []; |
| 192 | |
| 193 | foreach ($metadata->parameters ?? [] as $parameter) { |
| 194 | if (array_key_exists($parameter->column, $row)) { |
| 195 | if ($parameter->nullable && $row[$parameter->column] === null) { |
| 196 | $args[$parameter->name] = null; |
| 197 | |
| 198 | continue; |
| 199 | } |
| 200 | |
| 201 | /** @psalm-suppress MixedAssignment The parameter's declared type can be anything. */ |
| 202 | $args[$parameter->name] = $this->coercer->coerce( |
| 203 | $row[$parameter->column], |
| 204 | $parameter->type, |
| 205 | new HydrationContext( |
| 206 | $metadata->class, |
| 207 | $parameter->name, |
| 208 | $parameter->column, |
| 209 | $sourcePath, |
| 210 | $rowKeys, |
| 211 | ), |
| 212 | ); |
| 213 | |
| 214 | continue; |
| 215 | } |
| 216 | |
| 217 | if ($parameter->hasDefault) { |
| 218 | /** @psalm-suppress MixedAssignment Default values can be anything. */ |
| 219 | $args[$parameter->name] = $parameter->defaultValue; |
| 220 | |
| 221 | continue; |
| 222 | } |
| 223 | |
| 224 | throw MissingColumn::forColumn( |
| 225 | $metadata->class, |
| 226 | $parameter->name, |
| 227 | $parameter->column, |
| 228 | $sourcePath, |
| 229 | $rowKeys, |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | try { |
| 234 | $class = $metadata->class; |
| 235 | |
| 236 | /** @psalm-suppress MixedMethodCall Psalm cannot see the constructor of a bare class-string. */ |
| 237 | return new $class(...$args); |
| 238 | } catch (TypeError $e) { |
| 239 | throw InvalidTypeCoercion::constructorFailure( |
| 240 | $metadata->class, |
| 241 | $sourcePath, |
| 242 | $rowKeys, |
| 243 | $e, |
| 244 | ); |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | private function isBuiltinTypeName(string $target): bool |
| 249 | { |
| 250 | return in_array( |
| 251 | strtolower($target), |
| 252 | [ |
| 253 | 'array', |
| 254 | 'bool', |
| 255 | 'callable', |
| 256 | 'false', |
| 257 | 'float', |
| 258 | 'int', |
| 259 | 'iterable', |
| 260 | 'mixed', |
| 261 | 'never', |
| 262 | 'null', |
| 263 | 'object', |
| 264 | 'string', |
| 265 | 'true', |
| 266 | 'void', |
| 267 | ], |
| 268 | true, |
| 269 | ); |
| 270 | } |
| 271 | } |
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.
| 22 | public function __construct(?MetadataCache $cache = null) |
| 23 | { |
| 24 | $this->cache = $cache ?? new StaticReflectionCache(); |
| 25 | $this->coercer = new TypeCoercer(); |
| 26 | } |
| 30 | return new self(); |
| 31 | } |
| 40 | public function hydrate(array $record, string|Closure $map, ?string $sourcePath): object |
| 41 | { |
| 42 | $row = $this->stringKeyRow($record); |
| 43 | $rowKeys = array_keys($row); |
| 44 | $class = $this->resolveClass($map, $row, $sourcePath, $rowKeys); |
| 45 | |
| 46 | try { |
| 47 | $metadata = $this->cache->metadata($class); |
| 48 | } catch (InvalidHydrationTarget $e) { |
| 49 | throw InvalidHydrationTarget::forTarget( |
| 50 | $class, |
| 62 | $object = $metadata->hydratable |
| 63 | ? $this->hydrateViaFactory($class, $row, $sourcePath, $rowKeys) |
| 62 | $object = $metadata->hydratable |
| 63 | ? $this->hydrateViaFactory($class, $row, $sourcePath, $rowKeys) |
| 64 | : $this->hydrateViaConstructor($metadata, $row, $sourcePath, $rowKeys); |
| 62 | $object = $metadata->hydratable |
| 63 | ? $this->hydrateViaFactory($class, $row, $sourcePath, $rowKeys) |
| 64 | : $this->hydrateViaConstructor($metadata, $row, $sourcePath, $rowKeys); |
| 65 | |
| 66 | return $object; |
| 67 | } |
| 177 | ClassMetadata $metadata, |
| 178 | array $row, |
| 179 | ?string $sourcePath, |
| 180 | array $rowKeys, |
| 181 | ): object { |
| 182 | if (!$metadata->instantiable) { |
| 183 | throw InvalidHydrationTarget::forTarget( |
| 184 | $metadata->class, |
| 185 | $sourcePath, |
| 186 | $rowKeys, |
| 187 | 'target is not instantiable', |
| 191 | $args = []; |
| 192 | |
| 193 | foreach ($metadata->parameters ?? [] as $parameter) { |
| 193 | foreach ($metadata->parameters ?? [] as $parameter) { |
| 194 | if (array_key_exists($parameter->column, $row)) { |
| 195 | if ($parameter->nullable && $row[$parameter->column] === null) { |
| 195 | if ($parameter->nullable && $row[$parameter->column] === null) { |
| 195 | if ($parameter->nullable && $row[$parameter->column] === null) { |
| 196 | $args[$parameter->name] = null; |
| 197 | |
| 198 | continue; |
| 202 | $args[$parameter->name] = $this->coercer->coerce( |
| 203 | $row[$parameter->column], |
| 204 | $parameter->type, |
| 205 | new HydrationContext( |
| 206 | $metadata->class, |
| 207 | $parameter->name, |
| 208 | $parameter->column, |
| 209 | $sourcePath, |
| 210 | $rowKeys, |
| 211 | ), |
| 212 | ); |
| 213 | |
| 214 | continue; |
| 217 | if ($parameter->hasDefault) { |
| 219 | $args[$parameter->name] = $parameter->defaultValue; |
| 220 | |
| 221 | continue; |
| 224 | throw MissingColumn::forColumn( |
| 225 | $metadata->class, |
| 193 | foreach ($metadata->parameters ?? [] as $parameter) { |
| 194 | if (array_key_exists($parameter->column, $row)) { |
| 195 | if ($parameter->nullable && $row[$parameter->column] === null) { |
| 196 | $args[$parameter->name] = null; |
| 197 | |
| 198 | continue; |
| 199 | } |
| 200 | |
| 201 | /** @psalm-suppress MixedAssignment The parameter's declared type can be anything. */ |
| 202 | $args[$parameter->name] = $this->coercer->coerce( |
| 203 | $row[$parameter->column], |
| 204 | $parameter->type, |
| 205 | new HydrationContext( |
| 206 | $metadata->class, |
| 207 | $parameter->name, |
| 208 | $parameter->column, |
| 209 | $sourcePath, |
| 210 | $rowKeys, |
| 211 | ), |
| 212 | ); |
| 213 | |
| 214 | continue; |
| 215 | } |
| 216 | |
| 217 | if ($parameter->hasDefault) { |
| 218 | /** @psalm-suppress MixedAssignment Default values can be anything. */ |
| 219 | $args[$parameter->name] = $parameter->defaultValue; |
| 220 | |
| 221 | continue; |
| 222 | } |
| 223 | |
| 224 | throw MissingColumn::forColumn( |
| 225 | $metadata->class, |
| 226 | $parameter->name, |
| 227 | $parameter->column, |
| 228 | $sourcePath, |
| 229 | $rowKeys, |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | try { |
| 234 | $class = $metadata->class; |
| 235 | |
| 236 | /** @psalm-suppress MixedMethodCall Psalm cannot see the constructor of a bare class-string. */ |
| 237 | return new $class(...$args); |
| 238 | } catch (TypeError $e) { |
| 239 | throw InvalidTypeCoercion::constructorFailure( |
| 240 | $metadata->class, |
| 241 | $sourcePath, |
| 242 | $rowKeys, |
| 243 | $e, |
| 244 | ); |
| 245 | } |
| 246 | } |
| 147 | string $class, |
| 148 | array $row, |
| 149 | ?string $sourcePath, |
| 150 | array $rowKeys, |
| 151 | ): object { |
| 152 | if (!is_a($class, Hydratable::class, true)) { |
| 153 | throw InvalidHydrationTarget::forTarget( |
| 154 | $class, |
| 155 | $sourcePath, |
| 156 | $rowKeys, |
| 157 | 'target is not hydratable', |
| 161 | $hydratable = $class; |
| 162 | |
| 163 | try { |
| 164 | return $hydratable::fromRow($row); |
| 165 | } catch (HydrationFailure $e) { |
| 166 | throw $e; |
| 167 | } catch (Throwable $e) { |
| 168 | throw HydrationFailure::fromHydratableFailure($class, $sourcePath, $rowKeys, $e); |
| 169 | } |
| 170 | } |
| 248 | private function isBuiltinTypeName(string $target): bool |
| 249 | { |
| 250 | return in_array( |
| 250 | return in_array( |
| 251 | strtolower($target), |
| 252 | [ |
| 253 | 'array', |
| 254 | 'bool', |
| 255 | 'callable', |
| 256 | 'false', |
| 257 | 'float', |
| 258 | 'int', |
| 259 | 'iterable', |
| 260 | 'mixed', |
| 261 | 'never', |
| 262 | 'null', |
| 263 | 'object', |
| 264 | 'string', |
| 265 | 'true', |
| 266 | 'void', |
| 267 | ], |
| 268 | true, |
| 268 | true, |
| 269 | ); |
| 270 | } |
| 96 | string|Closure $map, |
| 97 | array $row, |
| 98 | ?string $sourcePath, |
| 99 | array $rowKeys, |
| 100 | ): string { |
| 101 | if (is_string($map)) { |
| 102 | return $this->validateClass($map, $sourcePath, $rowKeys); |
| 105 | $result = $map($row); |
| 106 | |
| 107 | if (!is_string($result)) { |
| 108 | throw InvalidHydrationTarget::forClosureResult($result, $sourcePath, $rowKeys); |
| 111 | return $this->validateClass($result, $sourcePath, $rowKeys); |
| 112 | } |
| 74 | private function stringKeyRow(array $record): array |
| 75 | { |
| 76 | $row = []; |
| 77 | |
| 78 | foreach ($record as $key => $value) { |
| 78 | foreach ($record as $key => $value) { |
| 78 | foreach ($record as $key => $value) { |
| 79 | if (!is_string($key)) { |
| 80 | continue; |
| 78 | foreach ($record as $key => $value) { |
| 79 | if (!is_string($key)) { |
| 80 | continue; |
| 81 | } |
| 82 | |
| 83 | $row[$key] = $value; |
| 78 | foreach ($record as $key => $value) { |
| 79 | if (!is_string($key)) { |
| 80 | continue; |
| 81 | } |
| 82 | |
| 83 | $row[$key] = $value; |
| 84 | } |
| 85 | |
| 86 | return $row; |
| 87 | } |
| 118 | private function validateClass(string $target, ?string $sourcePath, array $rowKeys): string |
| 119 | { |
| 120 | if ($this->isBuiltinTypeName($target)) { |
| 121 | throw InvalidHydrationTarget::forTarget( |
| 122 | $target, |
| 123 | $sourcePath, |
| 124 | $rowKeys, |
| 125 | 'built-in type names cannot be hydrated', |
| 129 | if (!class_exists($target)) { |
| 129 | if (!class_exists($target)) { |
| 129 | if (!class_exists($target)) { |
| 129 | if (!class_exists($target)) { |
| 130 | throw InvalidHydrationTarget::forTarget( |
| 131 | $target, |
| 132 | $sourcePath, |
| 133 | $rowKeys, |
| 134 | 'target is not an existing class', |
| 138 | return $target; |
| 139 | } |