Passed
Push — master ( 79bf3c...52a24b )
by Alex
01:04 queued 13s
created

HydratorCollectionStrategy::getById()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 28
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 18
c 0
b 0
f 0
dl 0
loc 28
rs 9.6666
cc 3
nc 3
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Arp\LaminasDoctrine\Hydrator\Strategy;
6
7
use Arp\DoctrineEntityRepository\EntityRepositoryInterface;
8
use Arp\Entity\EntityInterface;
9
use Arp\LaminasDoctrine\Hydrator\Strategy\Exception\RuntimeException;
10
use Doctrine\Common\Collections\ArrayCollection;
11
use Doctrine\Common\Collections\Collection;
12
use Laminas\Hydrator\HydratorInterface;
13
use Laminas\Hydrator\Strategy\Exception\InvalidArgumentException;
14
15
/**
16
 * @author  Alex Patterson <[email protected]>
17
 * @package Arp\LaminasDoctrine\Hydrator\Strategy
18
 */
19
class HydratorCollectionStrategy extends AbstractHydratorStrategy implements HydrationObjectAwareInterface
20
{
21
    /**
22
     * @var string
23
     */
24
    private string $name;
25
26
    /**
27
     * @var HydratorInterface
28
     */
29
    private HydratorInterface $hydrator;
30
31
    /**
32
     * @var object|EntityInterface|null
33
     */
34
    private ?object $object;
35
36
    /**
37
     * @param string                    $name
38
     * @param EntityRepositoryInterface $repository
39
     * @param HydratorInterface         $hydrator
40
     */
41
    public function __construct(string $name, EntityRepositoryInterface $repository, HydratorInterface $hydrator)
42
    {
43
        parent::__construct($repository);
44
45
        $this->name = $name;
46
        $this->hydrator = $hydrator;
47
    }
48
49
    /**
50
     * @param mixed                     $value
51
     * @param array<string, mixed>|null $data
52
     *
53
     * @return iterable|EntityInterface[]
54
     *
55
     * @throws InvalidArgumentException
56
     * @throws RuntimeException
57
     */
58
    public function hydrate($value, ?array $data): iterable
59
    {
60
        $entityName = $this->repository->getClassName();
61
        $object = $this->getObject();
62
63
        if (null === $object) {
64
            throw new InvalidArgumentException(
65
                sprintf('The hydration object has not been set for strategy \'%s\'', static::class)
66
            );
67
        }
68
69
        if (!is_iterable($value)) {
70
            throw new InvalidArgumentException(
71
                sprintf(
72
                    'The \'value\' argument must be of type \'iterable\'; \'%s\' provided for entity \'%s\'',
73
                    gettype($value),
74
                    $entityName
75
                )
76
            );
77
        }
78
79
        $addMethodName = 'add' . ucfirst($this->name);
80
        if (!is_callable([$object, $addMethodName])) {
81
            throw new InvalidArgumentException(
82
                sprintf('The \'%s\' method is not callable for entity \'%s\'', $addMethodName, $entityName)
83
            );
84
        }
85
86
        $removeMethodName = 'remove' . ucfirst($this->name);
87
        if (!is_callable([$object, $addMethodName])) {
88
            throw new InvalidArgumentException(
89
                sprintf('The \'%s\' method is not callable for entity \'%s\'', $removeMethodName, $entityName)
90
            );
91
        }
92
93
        $collection = $this->resolveEntityCollection($object);
94
        $values = $this->prepareCollectionValues($entityName, $value);
95
96
        $toAdd = $this->createArrayCollection(array_udiff($values, $collection, [$this, 'compareEntities']));
97
        if (!$toAdd->isEmpty()) {
98
            $object->$addMethodName($toAdd);
99
        }
100
101
        $toRemove = $this->createArrayCollection(array_udiff($collection, $values, [$this, 'compareEntities']));
102
        if (!$toRemove->isEmpty()) {
103
            $object->$removeMethodName($toRemove);
104
        }
105
106
        return $this->resolveEntityCollection($object);
107
    }
108
109
    /**
110
     * @param object $object
111
     *
112
     * @return EntityInterface[]
113
     *
114
     * @throws InvalidArgumentException
115
     */
116
    private function resolveEntityCollection(object $object): array
117
    {
118
        $methodName = 'get' . ucfirst($this->name);
119
        if (!is_callable([$object, $methodName])) {
120
            throw new InvalidArgumentException(
121
                sprintf('The method \'%s\' is not callable for entity \'%s\'', $methodName, get_class($object))
122
            );
123
        }
124
125
        if (!$this->isInitialized($object)) {
126
            return [];
127
        }
128
129
        $collection = $object->$methodName();
130
        if ($collection instanceof Collection) {
131
            $collection = $collection->toArray();
132
        }
133
134
        return $collection;
135
    }
136
137
    /**
138
     * @param object $object
139
     *
140
     * @return bool
141
     *
142
     * @throws InvalidArgumentException
143
     */
144
    private function isInitialized(object $object): bool
145
    {
146
        try {
147
            $reflectionProperty = new \ReflectionProperty(get_class($object), $this->name);
148
        } catch (\Throwable $e) {
149
            throw new InvalidArgumentException(
150
                sprintf(
151
                    'Failed to create reflection property \'%s::%s\': %s',
152
                    get_class($object),
153
                    $this->name,
154
                    $e->getMessage()
155
                ),
156
                $e->getCode(),
157
                $e
158
            );
159
        }
160
161
        $isPublic = $reflectionProperty->isPublic();
162
        if (!$isPublic) {
163
            $reflectionProperty->setAccessible(true);
164
        }
165
166
        $isInitialized = $reflectionProperty->isInitialized($object);
167
168
        if (!$isPublic) {
169
            $reflectionProperty->setAccessible(false);
170
        }
171
172
        return $isInitialized;
173
    }
174
175
    /**
176
     * @param string $entityName
177
     * @param mixed  $value
178
     *
179
     * @return array<EntityInterface>
180
     *
181
     * @throws InvalidArgumentException
182
     * @throws RuntimeException
183
     */
184
    private function prepareCollectionValues(string $entityName, $value): array
185
    {
186
        $collection = [];
187
        foreach ($value as $item) {
188
            if ($item instanceof EntityInterface) {
189
                $collection[] = $collection;
190
                continue;
191
            }
192
193
            if (empty($item)) {
194
                $collection[] = null;
195
                continue;
196
            }
197
198
            // Attempt to resolve the identity of the item
199
            $id = $this->resolveId($item);
200
201
            $entity = empty($id)
202
                ? $this->createInstance($entityName)
203
                : $this->getById($entityName, $id);
204
205
            $collection[] = is_array($item)
206
                ? $this->hydrator->hydrate($item, $entity)
207
                : $entity;
208
        }
209
210
        return array_filter($collection, static fn ($item) => null !== $item);
211
    }
212
213
    /**
214
     * @param string $entityName
215
     *
216
     * @return object
217
     *
218
     * @throws RuntimeException
219
     */
220
    private function createInstance(string $entityName): object
221
    {
222
        if (!class_exists($entityName, true)) {
223
            throw new RuntimeException(
224
                sprintf(
225
                    'The hydrator was unable to create a reflection instance for class \'%s\': %s',
226
                    'The class could not be found',
227
                    $entityName,
228
                )
229
            );
230
        }
231
232
        try {
233
            return (new \ReflectionClass($entityName))->newInstanceWithoutConstructor();
234
        } catch (\ReflectionException $e) {
235
            throw new RuntimeException(
236
                sprintf('The reflection class \'%s\' could not be created: %s', $entityName, $e->getMessage()),
237
                $e->getCode(),
238
                $e
239
            );
240
        }
241
    }
242
243
    /**
244
     * @param string $entityName
245
     * @param mixed  $id
246
     *
247
     * @return object
248
     *
249
     * @throws InvalidArgumentException
250
     * @throws RuntimeException
251
     */
252
    private function getById(string $entityName, $id): object
253
    {
254
        try {
255
            $entity = $this->repository->find($id);
256
        } catch (\Exception $e) {
257
            throw new RuntimeException(
258
                sprintf(
259
                    'Collection item of type \'%s\', with id \'%d\' could not be found: %s',
260
                    $entityName,
261
                    $id,
262
                    $e->getMessage()
263
                ),
264
                $e->getCode(),
265
                $e
266
            );
267
        }
268
269
        if (null === $entity) {
270
            throw new InvalidArgumentException(
271
                sprintf(
272
                    'Collection item of type \'%s\' with id \'%d\' could not be found',
273
                    $entityName,
274
                    $id
275
                )
276
            );
277
        }
278
279
        return $entity;
280
    }
281
282
    /**
283
     * @param EntityInterface[] $items
284
     *
285
     * @return ArrayCollection<int, EntityInterface>
286
     */
287
    private function createArrayCollection(array $items): ArrayCollection
288
    {
289
        return new ArrayCollection($items);
290
    }
291
292
    /**
293
     * @noinspection PhpUnusedPrivateMethodInspection It is used as a user defined callback in array_udiff()
294
     *
295
     * @param EntityInterface $a
296
     * @param EntityInterface $b
297
     *
298
     * @return int
299
     */
300
    private function compareEntities(EntityInterface $a, EntityInterface $b): int
301
    {
302
        return strcmp(spl_object_hash($a), spl_object_hash($b));
303
    }
304
305
    /**
306
     * @param mixed       $value
307
     * @param object|null $object
308
     *
309
     * @return iterable<EntityInterface>
310
     */
311
    public function extract($value, ?object $object = null): iterable
312
    {
313
        return $value;
314
    }
315
316
    /**
317
     * @param object|null $object
318
     */
319
    public function setObject(?object $object): void
320
    {
321
        $this->object = $object;
322
    }
323
324
    /**
325
     * @return object|null
326
     */
327
    public function getObject(): ?object
328
    {
329
        return $this->object;
330
    }
331
}
332