GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Completed
Pull Request — master (#224)
by joseph
19:55
created

EntityFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1.125

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 3
dl 0
loc 8
ccs 4
cts 8
cp 0.5
crap 1.125
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace EdmondsCommerce\DoctrineStaticMeta\Entity\Factory;
6
7
use Doctrine\Common\Collections\Collection;
8
use Doctrine\Common\NotifyPropertyChanged;
9
use Doctrine\ORM\EntityManagerInterface;
10
use EdmondsCommerce\DoctrineStaticMeta\CodeGeneration\NamespaceHelper;
11
use EdmondsCommerce\DoctrineStaticMeta\DoctrineStaticMeta;
12
use EdmondsCommerce\DoctrineStaticMeta\Entity\DataTransferObjects\DtoFactory;
13
use EdmondsCommerce\DoctrineStaticMeta\Entity\Fields\Interfaces\PrimaryKey\IdFieldInterface;
14
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\AlwaysValidInterface;
15
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\DataTransferObjectInterface;
16
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\EntityInterface;
17
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\UsesPHPMetaDataInterface;
18
use EdmondsCommerce\DoctrineStaticMeta\Exception\MultipleValidationException;
19
use EdmondsCommerce\DoctrineStaticMeta\Exception\ValidationException;
20
use InvalidArgumentException;
21
use LogicException;
22
use RuntimeException;
23
use ts\Reflection\ReflectionClass;
24
use TypeError;
25
26
use function get_class;
27
use function is_object;
28
use function print_r;
29
use function spl_object_hash;
30
31
/**
32
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
33
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
34
 */
35
class EntityFactory implements EntityFactoryInterface
36
{
37
    /**
38
     * This array is used to track Entities that in the process of being created as part of a transaction
39
     *
40
     * @var EntityInterface[][]
41
     */
42
    private static $created = [];
43
    /**
44
     * @var NamespaceHelper
45
     */
46
    protected $namespaceHelper;
47
    /**
48
     * @var EntityDependencyInjector
49
     */
50
    protected $entityDependencyInjector;
51
    /**
52
     * @var EntityManagerInterface
53
     */
54
    private $entityManager;
55
    /**
56
     * @var DtoFactory
57
     */
58
    private $dtoFactory;
59
    /**
60
     * @var array|bool[]
61
     */
62
    private $dtosProcessed;
63
64 5
    public function __construct(
65
        NamespaceHelper $namespaceHelper,
66
        EntityDependencyInjector $entityDependencyInjector,
67
        DtoFactory $dtoFactory
68
    ) {
69 5
        $this->namespaceHelper          = $namespaceHelper;
70 5
        $this->entityDependencyInjector = $entityDependencyInjector;
71 5
        $this->dtoFactory               = $dtoFactory;
72 5
    }
73
74 5
    public function setEntityManager(EntityManagerInterface $entityManager): EntityFactoryInterface
75
    {
76 5
        $this->entityManager = $entityManager;
77
78 5
        return $this;
79
    }
80
81
    /**
82
     * Get an instance of the specific Entity Factory for a specified Entity
83
     *
84
     * Not type hinting the return because the whole point of this is to have an entity specific method, which we
85
     * can't hint for
86
     *
87
     * @param string $entityFqn
88
     *
89
     * @return mixed
90
     */
91 1
    public function createFactoryForEntity(string $entityFqn)
92
    {
93 1
        $this->assertEntityManagerSet();
94 1
        $factoryFqn = $this->namespaceHelper->getFactoryFqnFromEntityFqn($entityFqn);
95
96 1
        return new $factoryFqn($this, $this->entityManager);
97
    }
98
99 5
    private function assertEntityManagerSet(): void
100
    {
101 5
        if (!$this->entityManager instanceof EntityManagerInterface) {
0 ignored issues
show
introduced by
$this->entityManager is always a sub-type of Doctrine\ORM\EntityManagerInterface.
Loading history...
102
            throw new RuntimeException(
103
                'No EntityManager set, this must be set first using setEntityManager()'
104
            );
105
        }
106 5
    }
107
108
    public function getEntity(string $className)
109
    {
110
        return $this->create($className);
111
    }
112
113
    /**
114
     * Build a new entity, optionally pass in a DTO to provide the data that should be used
115
     *
116
     * Optionally pass in an array of property=>value
117
     *
118
     * @param string                           $entityFqn
119
     *
120
     * @param DataTransferObjectInterface|null $dto
121
     *
122
     * @return mixed
123
     * @throws MultipleValidationException
124
     * @throws ValidationException
125
     */
126 5
    public function create(string $entityFqn, DataTransferObjectInterface $dto = null)
127
    {
128 5
        $this->assertEntityManagerSet();
129
130 5
        return $this->createEntity($entityFqn, $dto);
131
    }
132
133
    /**
134
     * Create the Entity
135
     *
136
     * If the update step throw an exception, then we detach the entity to prevent us having an empty entity in the
137
     * unit of work which would otherwise be saved to the DB
138
     *
139
     * @param string                           $entityFqn
140
     *
141
     * @param DataTransferObjectInterface|null $dto
142
     *
143
     * @param bool                             $isRootEntity
144
     *
145
     * @return EntityInterface
146
     * @throws MultipleValidationException
147
     * @throws ValidationException
148
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
149
     */
150 5
    private function createEntity(
151
        string $entityFqn,
152
        DataTransferObjectInterface $dto = null,
153
        $isRootEntity = true
154
    ): EntityInterface {
155 5
        if ($isRootEntity) {
156 5
            $this->dtosProcessed = [];
157
        }
158 5
        if (null === $dto) {
159
            $dto = $this->dtoFactory->createEmptyDtoFromEntityFqn($entityFqn);
160
        }
161 5
        $idString = (string)$dto->getId();
162 5
        if (isset(self::$created[$entityFqn][$idString])) {
163 1
            return self::$created[$entityFqn][$idString];
164
        }
165
        try {
166
            #At this point a new entity is added to the unit of work
167 5
            $entity = $this->getNewInstance($entityFqn, $dto->getId());
168
169 5
            self::$created[$entityFqn][$idString] = $entity;
170
171
            #At this point, nested entities are added to the unit of work
172 5
            $this->updateDto($entity, $dto);
173
            #At this point, the entity values are set
174 5
            $entity->update($dto);
175
176 5
            if ($isRootEntity) {
177
                #Now we have persisted all the entities, we need to validate them all
178 5
                $this->stopTransaction();
179
            }
180 1
        } catch (ValidationException | MultipleValidationException | TypeError $e) {
181
            # Something has gone wrong, now we need to remove all created entities from the unit of work
182 1
            foreach (self::$created as $entities) {
183 1
                foreach ($entities as $createdEntity) {
184 1
                    if ($createdEntity instanceof EntityInterface) {
185 1
                        $this->entityManager->getUnitOfWork()->detach($createdEntity);
186
                    }
187
                }
188
            }
189
            # And then we need to ensure that they are cleared out from the created and processed arrays
190 1
            self::$created       = [];
191 1
            $this->dtosProcessed = [];
192 1
            throw $e;
193
        }
194
195 4
        return $entity;
196
    }
197
198
    /**
199
     * Build a new instance, bypassing PPP protections so that we can call private methods and set the private
200
     * transaction property
201
     *
202
     * @param string $entityFqn
203
     * @param mixed  $id
204
     *
205
     * @return EntityInterface
206
     */
207 5
    private function getNewInstance(string $entityFqn, $id): EntityInterface
208
    {
209 5
        if (isset(self::$created[$entityFqn][(string)$id])) {
210
            throw new RuntimeException('Trying to get a new instance when one has already been created for this ID');
211
        }
212 5
        $reflection = $this->getDoctrineStaticMetaForEntityFqn($entityFqn)
213 5
                           ->getReflectionClass();
214 5
        $entity     = $reflection->newInstanceWithoutConstructor();
215
216 5
        $runInit = $reflection->getMethod(UsesPHPMetaDataInterface::METHOD_RUN_INIT);
217 5
        $runInit->setAccessible(true);
218 5
        $runInit->invoke($entity);
219
220 5
        $transactionProperty = $reflection->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
221 5
        $transactionProperty->setAccessible(true);
222 5
        $transactionProperty->setValue($entity, true);
223
224 5
        $idSetter = $reflection->getMethod('set' . IdFieldInterface::PROP_ID);
225 5
        $idSetter->setAccessible(true);
226 5
        $idSetter->invoke($entity, $id);
227
228 5
        if ($entity instanceof EntityInterface) {
229 5
            $this->initialiseEntity($entity);
230
231 5
            $this->entityManager->persist($entity);
232
233 5
            return $entity;
234
        }
235
        throw new LogicException('Failed to create an instance of EntityInterface');
236
    }
237
238 5
    private function getDoctrineStaticMetaForEntityFqn(string $entityFqn): DoctrineStaticMeta
239
    {
240 5
        return $entityFqn::getDoctrineStaticMeta();
241
    }
242
243
    /**
244
     * Take an already instantiated Entity and perform the final initialisation steps
245
     *
246
     * @param EntityInterface $entity
247
     *
248
     * @throws \ReflectionException
249
     */
250 5
    public function initialiseEntity(EntityInterface $entity): void
251
    {
252 5
        $entity->ensureMetaDataIsSet($this->entityManager);
253 5
        $this->addListenerToEntityIfRequired($entity);
254 5
        $this->entityDependencyInjector->injectEntityDependencies($entity);
255 5
        $debugInitMethod = $entity::getDoctrineStaticMeta()
256 5
                                  ->getReflectionClass()
257 5
                                  ->getMethod(UsesPHPMetaDataInterface::METHOD_DEBUG_INIT);
258 5
        $debugInitMethod->setAccessible(true);
259 5
        $debugInitMethod->invoke($entity);
260 5
    }
261
262
    /**
263
     * Generally DSM Entities are using the Notify change tracking policy.
264
     * This ensures that they are fully set up for that
265
     *
266
     * @param EntityInterface $entity
267
     */
268 5
    private function addListenerToEntityIfRequired(EntityInterface $entity): void
269
    {
270 5
        if (!$entity instanceof NotifyPropertyChanged) {
0 ignored issues
show
introduced by
$entity is always a sub-type of Doctrine\Common\NotifyPropertyChanged.
Loading history...
271
            return;
272
        }
273 5
        $listener = $this->entityManager->getUnitOfWork();
274 5
        $entity->addPropertyChangedListener($listener);
275 5
    }
276
277 5
    private function updateDto(
278
        EntityInterface $entity,
279
        DataTransferObjectInterface $dto
280
    ): void {
281 5
        $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($dto, $entity);
282 5
        $this->replaceNestedDtosWithNewEntities($dto);
283 5
        $this->dtosProcessed[spl_object_hash($dto)] = true;
284 5
    }
285
286
    /**
287
     * @param DataTransferObjectInterface $dto
288
     * @param EntityInterface             $entity
289
     * @SuppressWarnings(PHPMD.NPathComplexity)
290
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
291
     */
292 5
    private function replaceNestedDtoWithEntityInstanceIfIdsMatch(
293
        DataTransferObjectInterface $dto,
294
        EntityInterface $entity
295
    ): void {
296 5
        $dtoHash = spl_object_hash($dto);
297 5
        if (isset($this->dtosProcessed[$dtoHash])) {
298 1
            return;
299
        }
300 5
        $this->dtosProcessed[$dtoHash] = true;
301 5
        $getters                       = $this->getGettersForDtosOrCollections($dto);
302 5
        if ([[], []] === $getters) {
303 4
            return;
304
        }
305 1
        [$dtoGetters, $collectionGetters] = array_values($getters);
306 1
        $entityFqn = get_class($entity);
307 1
        foreach ($dtoGetters as $getter) {
308 1
            $propertyName        = substr($getter, 3, -3);
309 1
            $issetAsEntityMethod = 'isset' . $propertyName . 'AsEntity';
310 1
            if (true === $dto->$issetAsEntityMethod()) {
311
                continue;
312
            }
313
314 1
            $got = $dto->$getter();
315 1
            if (null === $got) {
316 1
                continue;
317
            }
318 1
            $gotHash = spl_object_hash($got);
319 1
            if (isset($this->dtosProcessed[$gotHash])) {
320
                continue;
321
            }
322
323 1
            if ($got instanceof DataTransferObjectInterface) {
324 1
                if ($got::getEntityFqn() === $entityFqn && $got->getId() === $entity->getId()) {
325
                    $setter = 'set' . $propertyName;
326
                    $dto->$setter($entity);
327
                    continue;
328
                }
329 1
                $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($got, $entity);
330 1
                continue;
331
            }
332
333
            throw new LogicException('Unexpected got item ' . get_class($got));
334
        }
335 1
        foreach ($collectionGetters as $getter) {
336 1
            $got = $dto->$getter();
337 1
            if (false === ($got instanceof Collection)) {
338
                continue;
339
            }
340 1
            foreach ($got as $key => $gotItem) {
341 1
                if (false === ($gotItem instanceof DataTransferObjectInterface)) {
342
                    continue;
343
                }
344 1
                if ($gotItem::getEntityFqn() === $entityFqn && $gotItem->getId() === $entity->getId()) {
345 1
                    $got->set($key, $entity);
346 1
                    continue;
347
                }
348 1
                $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($gotItem, $entity);
349
            }
350
        }
351 1
    }
352
353 5
    private function getGettersForDtosOrCollections(DataTransferObjectInterface $dto): array
354
    {
355 5
        $dtoReflection     = new ReflectionClass(get_class($dto));
356 5
        $dtoGetters        = [];
357 5
        $collectionGetters = [];
358 5
        foreach ($dtoReflection->getMethods() as $method) {
359 5
            $methodName = $method->getName();
360 5
            if (0 !== strpos($methodName, 'get')) {
361 5
                continue;
362
            }
363 5
            $returnType = $method->getReturnType();
364 5
            if (null === $returnType) {
365 5
                continue;
366
            }
367 5
            $returnTypeName = $returnType->getName();
368 5
            if (false === \ts\stringContains($returnTypeName, '\\')) {
369 5
                continue;
370
            }
371 5
            $returnTypeReflection = new ReflectionClass($returnTypeName);
372
373 5
            if ($returnTypeReflection->implementsInterface(DataTransferObjectInterface::class)) {
374 1
                $dtoGetters[] = $methodName;
375 1
                continue;
376
            }
377 5
            if ($returnTypeReflection->implementsInterface(Collection::class)) {
378 1
                $collectionGetters[] = $methodName;
379 1
                continue;
380
            }
381
        }
382
383 5
        return [$dtoGetters, $collectionGetters];
384
    }
385
386
    /**
387
     * @param DataTransferObjectInterface $dto
388
     *
389
     * @throws MultipleValidationException
390
     * @throws ValidationException
391
     * @SuppressWarnings(PHPMD.NPathComplexity)
392
     */
393 5
    private function replaceNestedDtosWithNewEntities(DataTransferObjectInterface $dto): void
394
    {
395 5
        $getters = $this->getGettersForDtosOrCollections($dto);
396 5
        if ([[], []] === $getters) {
397 4
            return;
398
        }
399 1
        [$dtoGetters, $collectionGetters] = array_values($getters);
400 1
        foreach ($dtoGetters as $getter) {
401 1
            $propertyName        = substr($getter, 3, -3);
402 1
            $issetAsEntityMethod = 'isset' . $propertyName . 'AsEntity';
403 1
            if (true === $dto->$issetAsEntityMethod()) {
404
                continue;
405
            }
406
407 1
            $nestedDto = $dto->$getter();
408 1
            if (null === $nestedDto) {
409 1
                continue;
410
            }
411 1
            $setter = 'set' . substr($getter, 3, -3);
412 1
            $dto->$setter($this->createEntity($nestedDto::getEntityFqn(), $nestedDto, false));
413
        }
414 1
        foreach ($collectionGetters as $getter) {
415 1
            $nestedDto = $dto->$getter();
416 1
            if (false === ($nestedDto instanceof Collection)) {
417
                continue;
418
            }
419 1
            $this->convertCollectionOfDtosToEntities($nestedDto);
420
        }
421 1
    }
422
423
    /**
424
     * This will take an ArrayCollection of DTO objects and replace them with the Entities
425
     *
426
     * @param Collection $collection
427
     *
428
     * @throws MultipleValidationException
429
     * @throws ValidationException
430
     */
431 1
    private function convertCollectionOfDtosToEntities(Collection $collection): void
432
    {
433 1
        if (0 === $collection->count()) {
434 1
            return;
435
        }
436 1
        [$dtoFqn, $collectionEntityFqn] = $this->deriveDtoAndEntityFqnFromCollection($collection);
437
438 1
        foreach ($collection as $key => $dto) {
439 1
            if ($dto instanceof $collectionEntityFqn) {
440 1
                continue;
441
            }
442 1
            if (false === is_object($dto)) {
443
                throw new InvalidArgumentException('Unexpected DTO value ' .
444
                                                    print_r($dto, true) .
445
                                                    ', expected an instance of' .
446
                                                    $dtoFqn);
447
            }
448 1
            if (false === ($dto instanceof DataTransferObjectInterface)) {
449
                throw new InvalidArgumentException('Found none DTO item in collection, was instance of ' .
450
                                                    get_class($dto));
451
            }
452 1
            if (false === ($dto instanceof $dtoFqn)) {
453
                throw new InvalidArgumentException('Unexpected DTO ' . get_class($dto) . ', expected ' . $dtoFqn);
454
            }
455 1
            $collection->set($key, $this->createEntity($collectionEntityFqn, $dto, false));
456
        }
457 1
    }
458
459
    /**
460
     * Loop through a collection and determine the DTO and Entity Fqn it contains
461
     *
462
     * @param Collection $collection
463
     *
464
     * @return array
465
     * @SuppressWarnings(PHPMD.NPathComplexity)
466
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
467
     */
468 1
    private function deriveDtoAndEntityFqnFromCollection(Collection $collection): array
469
    {
470 1
        if (0 === $collection->count()) {
471
            throw new RuntimeException('Collection is empty');
472
        }
473 1
        $dtoFqn              = null;
474 1
        $collectionEntityFqn = null;
475 1
        foreach ($collection as $dto) {
476 1
            if ($dto instanceof EntityInterface) {
477 1
                $collectionEntityFqn = get_class($dto);
478 1
                continue;
479
            }
480 1
            if (false === ($dto instanceof DataTransferObjectInterface)) {
481
                throw new InvalidArgumentException(
482
                    'Found none DTO item in collection, was instance of ' . get_class($dto)
483
                );
484
            }
485 1
            if (null === $dtoFqn) {
486 1
                $dtoFqn = get_class($dto);
487 1
                continue;
488
            }
489 1
            if (false === ($dto instanceof $dtoFqn)) {
490
                throw new InvalidArgumentException(
491
                    'Mismatched collection, expecting dtoFqn ' .
492
                    $dtoFqn .
493
                    ' but found ' .
494
                    get_class($dto)
495
                );
496
            }
497
        }
498 1
        if (null === $dtoFqn && null === $collectionEntityFqn) {
499
            throw new RuntimeException('Failed deriving either the DTO or Entity FQN from the collection');
500
        }
501 1
        if (null === $collectionEntityFqn) {
502 1
            $collectionEntityFqn = $this->namespaceHelper->getEntityFqnFromEntityDtoFqn($dtoFqn);
0 ignored issues
show
Deprecated Code introduced by
The function EdmondsCommerce\Doctrine...tyFqnFromEntityDtoFqn() has been deprecated: please use the static method on the DTO directly ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

502
            $collectionEntityFqn = /** @scrutinizer ignore-deprecated */ $this->namespaceHelper->getEntityFqnFromEntityDtoFqn($dtoFqn);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
503
        }
504 1
        if (null === $dtoFqn) {
505 1
            $dtoFqn = $this->namespaceHelper->getEntityDtoFqnFromEntityFqn($collectionEntityFqn);
506
        }
507
508 1
        return [$dtoFqn, $collectionEntityFqn];
509
    }
510
511
    /**
512
     * Loop through all created entities and reset the transaction running property to false,
513
     * then remove the list of created entities
514
     *
515
     * @throws MultipleValidationException
516
     */
517 5
    private function stopTransaction(): void
518
    {
519 5
        $validationExceptions = [];
520 5
        foreach (self::$created as $entities) {
521 5
            foreach ($entities as $entity) {
522
                $transactionProperty =
523 5
                    $entity::getDoctrineStaticMeta()
524 5
                           ->getReflectionClass()
525 5
                           ->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
526 5
                $transactionProperty->setAccessible(true);
527 5
                $transactionProperty->setValue($entity, false);
528
                try {
529 5
                    $entity->getValidator()->validate();
530 1
                } catch (ValidationException $validationException) {
531 1
                    $validationExceptions[] = $validationException;
532 1
                    continue;
533
                }
534
            }
535
        }
536 5
        if ([] !== $validationExceptions) {
537 1
            throw new MultipleValidationException($validationExceptions);
538
        }
539 4
        self::$created       = [];
540 4
        $this->dtosProcessed = [];
541 4
    }
542
}
543