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 (#214)
by joseph
21:10
created

EntityFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

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