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