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}