Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.58% covered (warning)
86.58%
129 / 149
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
DoctrineEntityDatalayer
86.58% covered (warning)
86.58%
129 / 149
50.00% covered (danger)
50.00%
7 / 14
48.68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEntityManager
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 toDoctrineClass
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getOrderByColumns
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 getFilterColumns
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 all
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 find
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getMetadata
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
6.06
 getDoctrineProperty
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 parseTimestamp
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 persistNew
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
4.02
 persistExisting
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
4.22
 removeExisting
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 upsert
66.67% covered (warning)
66.67%
16 / 24
0.00% covered (danger)
0.00%
0 / 1
4.59
1<?php
2namespace Apie\DoctrineEntityDatalayer;
3
4use Apie\Core\Attributes\OverwriteAfterPersist;
5use Apie\Core\BoundedContext\BoundedContextId;
6use Apie\Core\Context\ApieContext;
7use Apie\Core\Datalayers\ApieDatalayerWithFilters;
8use Apie\Core\Datalayers\DataLayerMetadata;
9use Apie\Core\Datalayers\Lists\EntityListInterface;
10use Apie\Core\Entities\EntityInterface;
11use Apie\Core\Entities\RequiresRecalculatingInterface;
12use Apie\Core\Enums\ScalarType;
13use Apie\Core\Exceptions\EntityNotFoundException;
14use Apie\Core\Identifiers\IdentifierInterface;
15use Apie\Core\Lists\StringSet;
16use Apie\Core\Metadata\MetadataFactory;
17use Apie\Core\TypeUtils;
18use Apie\DoctrineEntityDatalayer\Exceptions\InsertConflict;
19use Apie\DoctrineEntityDatalayer\Factories\DoctrineListFactory;
20use Apie\DoctrineEntityDatalayer\Factories\EntityQueryFilterFactory;
21use Apie\DoctrineEntityDatalayer\IndexStrategy\IndexStrategyInterface;
22use Apie\StorageMetadata\Attributes\GetSearchIndexAttribute;
23use Apie\StorageMetadata\Attributes\PropertyAttribute;
24use Apie\StorageMetadata\DomainToStorageConverter;
25use Apie\StorageMetadata\Interfaces\StorageDtoInterface;
26use Apie\StorageMetadataBuilder\Interfaces\HasIndexInterface;
27use Apie\StorageMetadataBuilder\Interfaces\RootObjectInterface;
28use Apie\TypeConverter\ReflectionTypeFactory;
29use DateTimeImmutable;
30use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
31use Doctrine\ORM\EntityManagerInterface;
32use Doctrine\ORM\Exception\EntityIdentityCollisionException;
33use ReflectionAttribute;
34use ReflectionClass;
35use ReflectionProperty;
36
37class DoctrineEntityDatalayer implements ApieDatalayerWithFilters
38{
39    private ?EntityManagerInterface $entityManager = null;
40
41    private bool $logged = false;
42
43    public function __construct(
44        private readonly OrmBuilder $ormBuilder,
45        private readonly DomainToStorageConverter $domainToStorageConverter,
46        private readonly IndexStrategyInterface $entityReindexer,
47        private readonly DoctrineListFactory $doctrineListFactory
48    ) {
49    }
50
51    private function getEntityManager(): EntityManagerInterface
52    {
53        if (!isset($this->entityManager) || !$this->entityManager->isOpen()) {
54            $this->entityManager = $this->ormBuilder->createEntityManager();
55            $log = $this->ormBuilder->getLogEntity();
56            if ($log) {
57                $this->upsert($log, new BoundedContextId('core'));
58            }
59        }
60
61        return $this->entityManager;
62    }
63
64    /**
65     * @param ReflectionClass<covariant EntityInterface> $class
66     * @return ReflectionClass<covariant StorageDtoInterface>
67     */
68    public function toDoctrineClass(ReflectionClass $class, ?BoundedContextId $boundedContextId = null): ReflectionClass
69    {
70        $doctrineEntityClass = $this->ormBuilder->toDoctrineClass($class, $boundedContextId);
71        if (!$this->logged) {
72            $log = $this->ormBuilder->getLogEntity();
73            if ($log) {
74                $this->upsert($log, new BoundedContextId('core'));
75            }
76            $this->logged = true;
77        }
78        return $doctrineEntityClass;
79    }
80
81    /**
82     * @see EntityQueryFilterFactory
83     */
84    public function getOrderByColumns(ReflectionClass $class, BoundedContextId $boundedContextId): ?StringSet
85    {
86        $doctrineEntityClass = $this->toDoctrineClass($class, $boundedContextId);
87        $list = [];
88        if (in_array(RequiresRecalculatingInterface::class, $class->getInterfaceNames())) {
89            $list[] = 'dateToRecalculate';
90        }
91        $list[] = 'createdAt';
92        $list[] = 'updatedAt';
93        foreach ($doctrineEntityClass->getProperties(ReflectionProperty::IS_PUBLIC) as $publicProperty) {
94            foreach ($publicProperty->getAttributes(PropertyAttribute::class, ReflectionAttribute::IS_INSTANCEOF) as $publicPropertyAttribute) {
95                $metadata = MetadataFactory::getModificationMetadata(
96                    $publicProperty->getType() ?? ReflectionTypeFactory::createReflectionType('mixed'),
97                    new ApieContext()
98                );
99
100                if (in_array($metadata->toScalarType(true), ScalarType::PRIMITIVES)) {
101                    $list[] = $publicPropertyAttribute->newInstance()->propertyName;
102                }
103                break;
104            }
105        }
106        return new StringSet($list);
107    }
108
109    public function getFilterColumns(ReflectionClass $class, BoundedContextId $boundedContextId): StringSet
110    {
111        $doctrineEntityClass = $this->ormBuilder->toDoctrineClass($class);
112        $list = [];
113        foreach ($doctrineEntityClass->getProperties(ReflectionProperty::IS_PUBLIC) as $publicProperty) {
114            if (str_starts_with($publicProperty->name, 'search_')) {
115                foreach ($publicProperty->getAttributes(GetSearchIndexAttribute::class) as $publicPropertyAttribute) {
116                    $list[] = substr($publicProperty->name, strlen('search_'));
117                }
118            }
119        }
120        return new StringSet($list);
121    }
122
123    public function all(ReflectionClass $class, ?BoundedContextId $boundedContextId = null): EntityListInterface
124    {
125        return $this->doctrineListFactory->createFor(
126            $class,
127            $this->toDoctrineClass($class, $boundedContextId),
128            $boundedContextId ?? new BoundedContextId('unknown')
129        );
130    }
131
132    public function find(IdentifierInterface $identifier, ?BoundedContextId $boundedContextId = null): EntityInterface
133    {
134        $entityManager = $this->getEntityManager();
135        $domainClass = $identifier->getReferenceFor();
136        $doctrineEntityClass = $this->toDoctrineClass($domainClass, $boundedContextId)->name;
137        $doctrineEntity = $entityManager->find($doctrineEntityClass, $identifier->toNative());
138        if (!($doctrineEntity instanceof StorageDtoInterface)) {
139            throw new EntityNotFoundException($identifier);
140        }
141        assert($doctrineEntity instanceof RootObjectInterface);
142        DoctrineUtils::loadAllProxies($doctrineEntity);
143        return $this->domainToStorageConverter->createDomainObject($doctrineEntity);
144    }
145
146    /**
147     * @template T of EntityInterface
148     * @param IdentifierInterface<T> $identifier
149     */
150    public function getMetadata(
151        IdentifierInterface $identifier,
152        ?BoundedContextId $boundedContextId = null
153    ): DataLayerMetadata {
154        $entityManager = $this->getEntityManager();
155        $doctrineEntityClass = $this->toDoctrineClass($identifier->getReferenceFor(), $boundedContextId);
156        $doctrineEntity = $entityManager->find($doctrineEntityClass->name, $identifier->toNative());
157        if (!($doctrineEntity instanceof StorageDtoInterface)) {
158            throw new EntityNotFoundException($identifier);
159        }
160
161        $createdAt = $this->getDoctrineProperty($doctrineEntity, 'createdAt');
162        $updatedAt = $this->getDoctrineProperty($doctrineEntity, 'updatedAt');
163        $createdVersion = $this->getDoctrineProperty($doctrineEntity, 'internalApieVersion');
164        $updatedVersion = $this->getDoctrineProperty($doctrineEntity, 'lastUpdateApieVersion');
165        if (!is_string($createdAt) || !is_string($updatedAt) || !is_string($createdVersion) || !is_string($updatedVersion)) {
166            throw new \UnexpectedValueException('Stored entity metadata has an unexpected type.');
167        }
168
169        return new DataLayerMetadata(
170            $this->parseTimestamp($createdAt),
171            $this->parseTimestamp($updatedAt),
172            $createdVersion,
173            $updatedVersion
174        );
175    }
176
177    private function getDoctrineProperty(StorageDtoInterface $entity, string $property): mixed
178    {
179        if (!property_exists($entity, $property)) {
180            throw new \UnexpectedValueException(sprintf('Stored entity is missing the "%s" metadata field.', $property));
181        }
182
183        return (new ReflectionProperty($entity, $property))->getValue($entity);
184    }
185
186    private function parseTimestamp(string $timestamp): DateTimeImmutable
187    {
188        $result = DateTimeImmutable::createFromFormat('U.u', $timestamp);
189        if (!$result) {
190            throw new \UnexpectedValueException('Stored entity metadata contains an invalid timestamp.');
191        }
192
193        return $result;
194    }
195    
196    public function persistNew(EntityInterface $entity, ?BoundedContextId $boundedContextId = null): EntityInterface
197    {
198        $entityManager = $this->getEntityManager();
199        $identifier = $entity->getId();
200        $domainClass = $identifier->getReferenceFor();
201        /** @var class-string<StorageDtoInterface> $doctrineEntityClass */
202        $doctrineEntityClass = $this->toDoctrineClass($domainClass, $boundedContextId)->name;
203        $doctrineEntity = $this->domainToStorageConverter->createStorageObject(
204            $entity,
205            new ReflectionClass($doctrineEntityClass)
206        );
207
208        try {
209            $entityManager->persist($doctrineEntity);
210            $entityManager->flush();
211        } catch (UniqueConstraintViolationException|EntityIdentityCollisionException $uniqueConstraintViolation) {
212            throw new InsertConflict($uniqueConstraintViolation);
213        }
214        if (TypeUtils::findAttributes(new ReflectionClass($entity), OverwriteAfterPersist::class)) {
215            $this->domainToStorageConverter->injectExistingDomainObject(
216                $entity,
217                $doctrineEntity
218            );
219        }
220        if ($doctrineEntity instanceof HasIndexInterface) {
221            $this->entityReindexer->updateIndex($doctrineEntity, $entity);
222        }
223        
224        return $entity;
225    }
226    
227    public function persistExisting(EntityInterface $entity, ?BoundedContextId $boundedContextId = null): EntityInterface
228    {
229        $entityManager = $this->getEntityManager();
230        $identifier = $entity->getId();
231        $domainClass = $identifier->getReferenceFor();
232        $doctrineEntityClass = $this->toDoctrineClass($domainClass, $boundedContextId)->name;
233        /** @var (StorageDtoInterface&RootObjectInterface)|null $doctrineEntity */
234        $doctrineEntity = $entityManager->find($doctrineEntityClass, $identifier->toNative());
235        if (!$doctrineEntity) {
236            throw new EntityNotFoundException($identifier);
237        }
238        $this->domainToStorageConverter->injectExistingStorageObject(
239            $entity,
240            $doctrineEntity
241        );
242        $entityManager->persist($doctrineEntity);
243        $entityManager->flush();
244        if (TypeUtils::findAttributes(new ReflectionClass($entity), OverwriteAfterPersist::class)) {
245            $this->domainToStorageConverter->injectExistingDomainObject(
246                $entity,
247                $doctrineEntity
248            );
249        }
250        if ($doctrineEntity instanceof HasIndexInterface) {
251            $this->entityReindexer->updateIndex($doctrineEntity, $entity);
252        }
253        return $entity;
254    }
255
256    public function removeExisting(EntityInterface $entity, ?BoundedContextId $boundedContextId = null): void
257    {
258        $entityManager = $this->getEntityManager();
259        $identifier = $entity->getId();
260        $domainClass = $identifier->getReferenceFor();
261        $doctrineEntityClass = $this->toDoctrineClass($domainClass, $boundedContextId)->name;
262        /** @var (StorageDtoInterface&RootObjectInterface)|null $doctrineEntity */
263        $doctrineEntity = $entityManager->find($doctrineEntityClass, $identifier->toNative());
264        if (!$doctrineEntity) {
265            throw new EntityNotFoundException($identifier);
266        }
267        $entityManager->remove($doctrineEntity);
268        $entityManager->flush();
269    }
270
271    public function upsert(EntityInterface $entity, ?BoundedContextId $boundedContextId): EntityInterface
272    {
273        $entityManager = $this->getEntityManager();
274        $identifier = $entity->getId();
275        $domainClass = $identifier->getReferenceFor();
276        $doctrineEntityClass = $this->toDoctrineClass($domainClass, $boundedContextId);
277        /** @var (StorageDtoInterface&RootObjectInterface)|null $doctrineEntity */
278        $doctrineEntity = $entityManager->find($doctrineEntityClass->name, $identifier->toNative());
279        if ($doctrineEntity) {
280            $this->domainToStorageConverter->injectExistingStorageObject(
281                $entity,
282                $doctrineEntity
283            );
284        } else {
285            $doctrineEntity = $this->domainToStorageConverter->createStorageObject(
286                $entity,
287                $doctrineEntityClass
288            );
289        }
290        $entityManager->persist($doctrineEntity);
291        $entityManager->flush();
292        if (TypeUtils::findAttributes(new ReflectionClass($entity), OverwriteAfterPersist::class)) {
293            $this->domainToStorageConverter->injectExistingDomainObject(
294                $entity,
295                $doctrineEntity
296            );
297        }
298        if ($doctrineEntity instanceof HasIndexInterface) {
299            $this->entityReindexer->updateIndex($doctrineEntity, $entity);
300        }
301        return $entity;
302    }
303}