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}