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 (#207)
by joseph
17:43
created

EntityFactory::createEntity()   B

Complexity

Conditions 9
Paths 92

Size

Total Lines 46
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

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

489
            $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...
490
        }
491
        if (null === $dtoFqn) {
492
            $dtoFqn = $this->namespaceHelper->getEntityDtoFqnFromEntityFqn($collectionEntityFqn);
493
        }
494
495
        return [$dtoFqn, $collectionEntityFqn];
496
    }
497
498
    /**
499
     * Loop through all created entities and reset the transaction running property to false,
500
     * then remove the list of created entities
501
     *
502
     * @throws MultipleValidationException
503
     */
504
    private function stopTransaction(): void
505
    {
506
        $validationExceptions = [];
507
        foreach (self::$created as $entities) {
508
            foreach ($entities as $entity) {
509
                $transactionProperty =
510
                    $entity::getDoctrineStaticMeta()
511
                           ->getReflectionClass()
512
                           ->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
513
                $transactionProperty->setAccessible(true);
514
                $transactionProperty->setValue($entity, false);
515
                try {
516
                    $entity->getValidator()->validate();
517
                } catch (ValidationException $validationException) {
518
                    $validationExceptions[] = $validationException;
519
                    continue;
520
                }
521
            }
522
        }
523
        if ([] !== $validationExceptions) {
524
            throw new MultipleValidationException($validationExceptions);
525
        }
526
        self::$created       = [];
527
        $this->dtosProcessed = [];
528
    }
529
}
530