Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
122 / 122
96.30% covered (success)
96.30%
52 / 54
69.70% covered (warning)
69.70%
23 / 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%
122 / 122
96.30% covered (success)
96.30%
52 / 54
69.70% covered (warning)
69.70%
23 / 33
100.00% covered (success)
100.00%
9 / 9
47.29
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%
15 / 15
100.00% covered (success)
100.00%
6 / 6
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
50.00% covered (danger)
50.00%
5 / 10
100.00% covered (success)
100.00%
1 / 1
16.00
 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 Celemas\Quma\Hydration;
6
7use Celemas\Quma\Exception\HydrationFailure;
8use Celemas\Quma\Exception\InvalidHydrationTarget;
9use Celemas\Quma\Exception\InvalidTypeCoercion;
10use Celemas\Quma\Exception\MissingColumn;
11use Celemas\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, ?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}

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