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.

EntityFactory::createEntity()   B
last analyzed

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
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
    public function __construct(
65
        NamespaceHelper $namespaceHelper,
66
        EntityDependencyInjector $entityDependencyInjector,
67
        DtoFactory $dtoFactory
68
    ) {
69
        $this->namespaceHelper          = $namespaceHelper;
70
        $this->entityDependencyInjector = $entityDependencyInjector;
71
        $this->dtoFactory               = $dtoFactory;
72
    }
73
74
    public function setEntityManager(EntityManagerInterface $entityManager): EntityFactoryInterface
75
    {
76
        $this->entityManager = $entityManager;
77
78
        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
    public function createFactoryForEntity(string $entityFqn)
92
    {
93
        $this->assertEntityManagerSet();
94
        $factoryFqn = $this->namespaceHelper->getFactoryFqnFromEntityFqn($entityFqn);
95
96
        return new $factoryFqn($this, $this->entityManager);
97
    }
98
99
    private function assertEntityManagerSet(): void
100
    {
101
        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
    }
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
    public function create(string $entityFqn, DataTransferObjectInterface $dto = null)
127
    {
128
        $this->assertEntityManagerSet();
129
130
        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
    private function createEntity(
151
        string $entityFqn,
152
        DataTransferObjectInterface $dto = null,
153
        $isRootEntity = true
154
    ): EntityInterface {
155
        if ($isRootEntity) {
156
            $this->dtosProcessed = [];
157
        }
158
        if (null === $dto) {
159
            $dto = $this->dtoFactory->createEmptyDtoFromEntityFqn($entityFqn);
160
        }
161
        $idString = (string)$dto->getId();
162
        if (isset(self::$created[$entityFqn][$idString])) {
163
            return self::$created[$entityFqn][$idString];
164
        }
165
        try {
166
            #At this point a new entity is added to the unit of work
167
            $entity = $this->getNewInstance($entityFqn, $dto->getId());
168
169
            self::$created[$entityFqn][$idString] = $entity;
170
171
            #At this point, nested entities are added to the unit of work
172
            $this->updateDto($entity, $dto);
173
            #At this point, the entity values are set
174
            $entity->update($dto);
175
176
            if ($isRootEntity) {
177
                #Now we have persisted all the entities, we need to validate them all
178
                $this->stopTransaction();
179
            }
180
        } catch (ValidationException | MultipleValidationException | TypeError $e) {
181
            # Something has gone wrong, now we need to remove all created entities from the unit of work
182
            foreach (self::$created as $entities) {
183
                foreach ($entities as $createdEntity) {
184
                    if ($createdEntity instanceof EntityInterface) {
185
                        $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
            self::$created       = [];
191
            $this->dtosProcessed = [];
192
            throw $e;
193
        }
194
195
        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
    private function getNewInstance(string $entityFqn, $id): EntityInterface
208
    {
209
        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
        $reflection = $this->getDoctrineStaticMetaForEntityFqn($entityFqn)
213
                           ->getReflectionClass();
214
        $entity     = $reflection->newInstanceWithoutConstructor();
215
216
        $runInit = $reflection->getMethod(UsesPHPMetaDataInterface::METHOD_RUN_INIT);
217
        $runInit->setAccessible(true);
218
        $runInit->invoke($entity);
219
220
        $transactionProperty = $reflection->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
221
        $transactionProperty->setAccessible(true);
222
        $transactionProperty->setValue($entity, true);
223
224
        $idSetter = $reflection->getMethod('set' . IdFieldInterface::PROP_ID);
225
        $idSetter->setAccessible(true);
226
        $idSetter->invoke($entity, $id);
227
228
        if ($entity instanceof EntityInterface) {
229
            $this->initialiseEntity($entity);
230
231
            $this->entityManager->persist($entity);
232
233
            return $entity;
234
        }
235
        throw new LogicException('Failed to create an instance of EntityInterface');
236
    }
237
238
    private function getDoctrineStaticMetaForEntityFqn(string $entityFqn): DoctrineStaticMeta
239
    {
240
        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
    public function initialiseEntity(EntityInterface $entity): void
251
    {
252
        $entity->ensureMetaDataIsSet($this->entityManager);
253
        $this->addListenerToEntityIfRequired($entity);
254
        $this->entityDependencyInjector->injectEntityDependencies($entity);
255
        $debugInitMethod = $entity::getDoctrineStaticMeta()
256
                                  ->getReflectionClass()
257
                                  ->getMethod(UsesPHPMetaDataInterface::METHOD_DEBUG_INIT);
258
        $debugInitMethod->setAccessible(true);
259
        $debugInitMethod->invoke($entity);
260
    }
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
    private function addListenerToEntityIfRequired(EntityInterface $entity): void
269
    {
270
        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
        $listener = $this->entityManager->getUnitOfWork();
274
        $entity->addPropertyChangedListener($listener);
275
    }
276
277
    private function updateDto(
278
        EntityInterface $entity,
279
        DataTransferObjectInterface $dto
280
    ): void {
281
        $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($dto, $entity);
282
        $this->replaceNestedDtosWithNewEntities($dto);
283
        $this->dtosProcessed[spl_object_hash($dto)] = true;
284
    }
285
286
    /**
287
     * @param DataTransferObjectInterface $dto
288
     * @param EntityInterface             $entity
289
     * @SuppressWarnings(PHPMD.NPathComplexity)
290
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
291
     */
292
    private function replaceNestedDtoWithEntityInstanceIfIdsMatch(
293
        DataTransferObjectInterface $dto,
294
        EntityInterface $entity
295
    ): void {
296
        $dtoHash = spl_object_hash($dto);
297
        if (isset($this->dtosProcessed[$dtoHash])) {
298
            return;
299
        }
300
        $this->dtosProcessed[$dtoHash] = true;
301
        $getters                       = $this->getGettersForDtosOrCollections($dto);
302
        if ([[], []] === $getters) {
303
            return;
304
        }
305
        [$dtoGetters, $collectionGetters] = array_values($getters);
306
        $entityFqn = get_class($entity);
307
        foreach ($dtoGetters as $getter) {
308
            $propertyName        = substr($getter, 3, -3);
309
            $issetAsEntityMethod = 'isset' . $propertyName . 'AsEntity';
310
            if (true === $dto->$issetAsEntityMethod()) {
311
                continue;
312
            }
313
314
            $got = $dto->$getter();
315
            if (null === $got) {
316
                continue;
317
            }
318
            $gotHash = spl_object_hash($got);
319
            if (isset($this->dtosProcessed[$gotHash])) {
320
                continue;
321
            }
322
323
            if ($got instanceof DataTransferObjectInterface) {
324
                if ($got::getEntityFqn() === $entityFqn && $got->getId() === $entity->getId()) {
325
                    $setter = 'set' . $propertyName;
326
                    $dto->$setter($entity);
327
                    continue;
328
                }
329
                $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($got, $entity);
330
                continue;
331
            }
332
333
            throw new LogicException('Unexpected got item ' . get_class($got));
334
        }
335
        foreach ($collectionGetters as $getter) {
336
            $got = $dto->$getter();
337
            if (false === ($got instanceof Collection)) {
338
                continue;
339
            }
340
            foreach ($got as $key => $gotItem) {
341
                if (false === ($gotItem instanceof DataTransferObjectInterface)) {
342
                    continue;
343
                }
344
                if ($gotItem::getEntityFqn() === $entityFqn && $gotItem->getId() === $entity->getId()) {
345
                    $got->set($key, $entity);
346
                    continue;
347
                }
348
                $this->replaceNestedDtoWithEntityInstanceIfIdsMatch($gotItem, $entity);
349
            }
350
        }
351
    }
352
353
    private function getGettersForDtosOrCollections(DataTransferObjectInterface $dto): array
354
    {
355
        $dtoReflection     = new ReflectionClass(get_class($dto));
356
        $dtoGetters        = [];
357
        $collectionGetters = [];
358
        foreach ($dtoReflection->getMethods() as $method) {
359
            $methodName = $method->getName();
360
            if (0 !== strpos($methodName, 'get')) {
361
                continue;
362
            }
363
            $returnType = $method->getReturnType();
364
            if (null === $returnType) {
365
                continue;
366
            }
367
            $returnTypeName = $returnType->getName();
368
            if (false === \ts\stringContains($returnTypeName, '\\')) {
369
                continue;
370
            }
371
            $returnTypeReflection = new ReflectionClass($returnTypeName);
372
373
            if ($returnTypeReflection->implementsInterface(DataTransferObjectInterface::class)) {
374
                $dtoGetters[] = $methodName;
375
                continue;
376
            }
377
            if ($returnTypeReflection->implementsInterface(Collection::class)) {
378
                $collectionGetters[] = $methodName;
379
                continue;
380
            }
381
        }
382
383
        return [$dtoGetters, $collectionGetters];
384
    }
385
386
    /**
387
     * @param DataTransferObjectInterface $dto
388
     *
389
     * @throws MultipleValidationException
390
     * @throws ValidationException
391
     * @SuppressWarnings(PHPMD.NPathComplexity)
392
     */
393
    private function replaceNestedDtosWithNewEntities(DataTransferObjectInterface $dto): void
394
    {
395
        $getters = $this->getGettersForDtosOrCollections($dto);
396
        if ([[], []] === $getters) {
397
            return;
398
        }
399
        [$dtoGetters, $collectionGetters] = array_values($getters);
400
        foreach ($dtoGetters as $getter) {
401
            $propertyName        = substr($getter, 3, -3);
402
            $issetAsEntityMethod = 'isset' . $propertyName . 'AsEntity';
403
            if (true === $dto->$issetAsEntityMethod()) {
404
                continue;
405
            }
406
407
            $nestedDto = $dto->$getter();
408
            if (null === $nestedDto) {
409
                continue;
410
            }
411
            $setter = 'set' . substr($getter, 3, -3);
412
            $dto->$setter($this->createEntity($nestedDto::getEntityFqn(), $nestedDto, false));
413
        }
414
        foreach ($collectionGetters as $getter) {
415
            $nestedDto = $dto->$getter();
416
            if (false === ($nestedDto instanceof Collection)) {
417
                continue;
418
            }
419
            $this->convertCollectionOfDtosToEntities($nestedDto);
420
        }
421
    }
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
    private function convertCollectionOfDtosToEntities(Collection $collection): void
432
    {
433
        if (0 === $collection->count()) {
434
            return;
435
        }
436
        [$dtoFqn, $collectionEntityFqn] = $this->deriveDtoAndEntityFqnFromCollection($collection);
437
438
        foreach ($collection as $key => $dto) {
439
            if ($dto instanceof $collectionEntityFqn) {
440
                continue;
441
            }
442
            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
            if (false === ($dto instanceof DataTransferObjectInterface)) {
449
                throw new InvalidArgumentException('Found none DTO item in collection, was instance of ' .
450
                                                    get_class($dto));
451
            }
452
            if (false === ($dto instanceof $dtoFqn)) {
453
                throw new InvalidArgumentException('Unexpected DTO ' . get_class($dto) . ', expected ' . $dtoFqn);
454
            }
455
            $collection->set($key, $this->createEntity($collectionEntityFqn, $dto, false));
456
        }
457
    }
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
    private function deriveDtoAndEntityFqnFromCollection(Collection $collection): array
469
    {
470
        if (0 === $collection->count()) {
471
            throw new RuntimeException('Collection is empty');
472
        }
473
        $dtoFqn              = null;
474
        $collectionEntityFqn = null;
475
        foreach ($collection as $dto) {
476
            if ($dto instanceof EntityInterface) {
477
                $collectionEntityFqn = get_class($dto);
478
                continue;
479
            }
480
            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
            if (null === $dtoFqn) {
486
                $dtoFqn = get_class($dto);
487
                continue;
488
            }
489
            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
        if (null === $dtoFqn && null === $collectionEntityFqn) {
499
            throw new RuntimeException('Failed deriving either the DTO or Entity FQN from the collection');
500
        }
501
        if (null === $collectionEntityFqn) {
502
            $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
        if (null === $dtoFqn) {
505
            $dtoFqn = $this->namespaceHelper->getEntityDtoFqnFromEntityFqn($collectionEntityFqn);
506
        }
507
508
        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
    private function stopTransaction(): void
518
    {
519
        $validationExceptions = [];
520
        foreach (self::$created as $entities) {
521
            foreach ($entities as $entity) {
522
                $transactionProperty =
523
                    $entity::getDoctrineStaticMeta()
524
                           ->getReflectionClass()
525
                           ->getProperty(AlwaysValidInterface::CREATION_TRANSACTION_RUNNING_PROPERTY);
526
                $transactionProperty->setAccessible(true);
527
                $transactionProperty->setValue($entity, false);
528
                try {
529
                    $entity->getValidator()->validate();
530
                } catch (ValidationException $validationException) {
531
                    $validationExceptions[] = $validationException;
532
                    continue;
533
                }
534
            }
535
        }
536
        if ([] !== $validationExceptions) {
537
            throw new MultipleValidationException($validationExceptions);
538
        }
539
        self::$created       = [];
540
        $this->dtosProcessed = [];
541
    }
542
}
543