Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
123 / 123
96.36% covered (success)
96.36%
53 / 55
72.73% covered (warning)
72.73%
24 / 33
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hydrator
100.00% covered (success)
100.00%
123 / 123
96.36% covered (success)
96.36%
53 / 55
72.73% covered (warning)
72.73%
24 / 33
100.00% covered (success)
100.00%
9 / 9
41.79
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
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
 default
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
 hydrate
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 stringKeyRow
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 resolveClass
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateClass
100.00% covered (success)
100.00%
15 / 15
87.50% covered (warning)
87.50%
7 / 8
60.00% covered (warning)
60.00%
3 / 5
100.00% covered (success)
100.00%
1 / 1
3.58
 hydrateViaFactory
100.00% covered (success)
100.00%
13 / 13
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
 hydrateViaConstructor
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
16 / 16
60.00% covered (warning)
60.00%
6 / 10
100.00% covered (success)
100.00%
1 / 1
12.10
 isBuiltinTypeName
100.00% covered (success)
100.00%
20 / 20
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma\Hydration;
6
7use Celema\Quma\Exception\HydrationFailure;
8use Celema\Quma\Exception\InvalidHydrationTarget;
9use Celema\Quma\Exception\InvalidTypeCoercion;
10use Celema\Quma\Exception\MissingColumn;
11use Celema\Quma\Hydratable;
12use Closure;
13use Throwable;
14use TypeError;
15
16/** @internal */
17final 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}

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.

Hydrator->__construct
22    public function __construct(?MetadataCache $cache = null)
23    {
24        $this->cache = $cache ?? new StaticReflectionCache();
25        $this->coercer = new TypeCoercer();
26    }
Hydrator->default
30        return new self();
31    }
Hydrator->hydrate
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    }
Hydrator->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',
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    }
Hydrator->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',
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    }
Hydrator->isBuiltinTypeName
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    }
Hydrator->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);
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    }
Hydrator->stringKeyRow
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    }
Hydrator->validateClass
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    }