Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.58% covered (success)
94.58%
227 / 240
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddDoctrineFields
94.58% covered (success)
94.58%
227 / 240
50.00% covered (danger)
50.00%
2 / 4
61.59
0.00% covered (danger)
0.00%
0 / 1
 postRun
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 applyId
85.71% covered (warning)
85.71%
60 / 70
0.00% covered (danger)
0.00%
0 / 1
23.41
 iterateProperties
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 patch
98.15% covered (success)
98.15%
159 / 162
0.00% covered (danger)
0.00%
0 / 1
32
1<?php
2namespace Apie\DoctrineEntityConverter\CodeGenerators;
3
4use Apie\Core\Context\ApieContext;
5use Apie\Core\Entities\RequiresRecalculatingInterface;
6use Apie\Core\Identifiers\AutoIncrementInteger;
7use Apie\Core\Metadata\MetadataFactory;
8use Apie\Core\Utils\ConverterUtils;
9use Apie\DoctrineEntityConverter\Concerns\HasGeneralDoctrineFields;
10use Apie\DoctrineEntityConverter\Concerns\RequiresDomainUpdate;
11use Apie\DoctrineEntityConverter\Entities\SearchIndex;
12use Apie\StorageMetadata\Attributes\AclLinkAttribute;
13use Apie\StorageMetadata\Attributes\DecimalPropertyAttribute;
14use Apie\StorageMetadata\Attributes\DiscriminatorMappingAttribute;
15use Apie\StorageMetadata\Attributes\GetMethodAttribute;
16use Apie\StorageMetadata\Attributes\GetSearchIndexAttribute;
17use Apie\StorageMetadata\Attributes\ManyToManyAttribute;
18use Apie\StorageMetadata\Attributes\ManyToOneAttribute;
19use Apie\StorageMetadata\Attributes\OneToManyAttribute;
20use Apie\StorageMetadata\Attributes\OneToOneAttribute;
21use Apie\StorageMetadata\Attributes\OrderAttribute;
22use Apie\StorageMetadata\Attributes\ParentAttribute;
23use Apie\StorageMetadata\Attributes\PropertyAttribute;
24use Apie\StorageMetadata\Interfaces\AutoIncrementTableInterface;
25use Apie\StorageMetadata\Interfaces\OwnerLocalEntityStorageInterface;
26use Apie\StorageMetadataBuilder\Interfaces\MixedStorageInterface;
27use Apie\StorageMetadataBuilder\Interfaces\PostRunGeneratedCodeContextInterface;
28use Apie\StorageMetadataBuilder\Mediators\GeneratedCodeContext;
29use Apie\TypeConverter\ReflectionTypeFactory;
30use Doctrine\Common\Collections\Collection;
31use Doctrine\ORM\Mapping\Column;
32use Doctrine\ORM\Mapping\Entity;
33use Doctrine\ORM\Mapping\GeneratedValue;
34use Doctrine\ORM\Mapping\HasLifecycleCallbacks;
35use Doctrine\ORM\Mapping\Id;
36use Doctrine\ORM\Mapping\JoinColumn;
37use Doctrine\ORM\Mapping\ManyToMany;
38use Doctrine\ORM\Mapping\ManyToOne;
39use Doctrine\ORM\Mapping\OneToMany;
40use Doctrine\ORM\Mapping\OneToOne;
41use Doctrine\ORM\Mapping\OrderBy;
42use Generator;
43use Nette\PhpGenerator\Attribute;
44use Nette\PhpGenerator\ClassType;
45use Nette\PhpGenerator\PromotedParameter;
46use Nette\PhpGenerator\Property;
47use ReflectionClass;
48
49/**
50 * Adds created_at and updated_at and Doctrine attributes
51 */
52class AddDoctrineFields implements PostRunGeneratedCodeContextInterface
53{
54    public function postRun(GeneratedCodeContext $generatedCodeContext): void
55    {
56        foreach ($generatedCodeContext->generatedCode->generatedCodeHashmap as $code) {
57            $this->patch($generatedCodeContext, $code);
58        }
59    }
60
61    private function applyId(ClassType $classType): void
62    {
63        $property = null;
64        $doctrineType = null;
65        $nullable = false;
66        $generatedValue = false;
67        if ($classType->hasProperty('id')) {
68            $property = $classType->getProperty('id');
69        } elseif ($classType->hasProperty('search_id')) {
70            $property = $classType->getProperty('search_id')->cloneWithName('id');
71            $classType->addMember($property);
72        }
73        if ($property === null) {
74            $property = $classType->addProperty('id')->setType('?int');
75            $generatedValue = true;
76            $doctrineType = 'integer';
77        } else {
78            // @see ClassTypeFactory
79            $originalClass = $classType->getComment();
80            if ($originalClass && class_exists($originalClass)) {
81                $metadata = MetadataFactory::getResultMetadata(
82                    new ReflectionClass($originalClass),
83                    new ApieContext()
84                );
85                $hashmap = $metadata->getHashmap();
86                if (isset($hashmap['id'])) {
87                    $type = $hashmap['id']->getTypehint();
88                    $nullable = $hashmap['id']->allowsNull();
89                    $class = ConverterUtils::toReflectionClass($type);
90                    if ($class && $class->isSubclassOf(AutoIncrementInteger::class)) {
91                        $generatedValue = true;
92                        $nullable = false;
93                        $property->setInitialized(true);
94                    }
95                    $scalarType = MetadataFactory::getScalarForType($hashmap['id']->getTypehint(), true);
96                    $property->setType(
97                        $scalarType->value
98                    );
99                    $doctrineType = $scalarType->toDoctrineType();
100                }
101            }
102        }
103
104        if (in_array(AutoIncrementTableInterface::class, $classType->getImplements())
105            || in_array(OwnerLocalEntityStorageInterface::class, $classType->getImplements())
106            || in_array(MixedStorageInterface::class, $classType->getImplements())) {
107            $generatedValue = true;
108            $nullable = false;
109            if (in_array(OwnerLocalEntityStorageInterface::class, $classType->getImplements())) {
110                $property->setType('int');
111                $doctrineType = 'integer';
112            }
113        }
114
115        $property->setAttributes(array_map(
116            static function (Attribute $attribute): Attribute {
117                if ($attribute->getName() !== Column::class) {
118                    return $attribute;
119                }
120                return new Attribute(Column::class, ['nullable' => false] + $attribute->getArguments());
121            },
122            $property->getAttributes()
123        ));
124        $hasIdAttribute = false;
125        $hasColumnAttribute = false;
126        foreach ($property->getAttributes() as $attribute) {
127            if (in_array($attribute->getName(), [Column::class, ManyToOne::class, OneToMany::class, ManyToMany::class])) {
128                $hasColumnAttribute = true;
129                break;
130            }
131            if ($attribute->getName() === GeneratedValue::class) {
132                $generatedValue = false;
133            }
134            if ($attribute->getName() === Id::class) {
135                $hasIdAttribute = true;
136            }
137        }
138        if (!$hasIdAttribute) {
139            $property->addAttribute(Id::class);
140        }
141        if (!$hasColumnAttribute) {
142            if ($doctrineType === null) {
143                $doctrineType = MetadataFactory::getScalarForType(
144                    ReflectionTypeFactory::createReflectionType($property->getType()),
145                    true
146                )->toDoctrineType();
147            }
148            $property->addAttribute(Column::class, ['type' => $doctrineType, 'nullable' => false]);
149        }
150        if ($generatedValue) {
151            $property->addAttribute(GeneratedValue::class);
152        }
153    }
154
155    /**
156     * @return Generator<int, PromotedParameter|Property>
157     */
158    private function iterateProperties(ClassType $classType): Generator
159    {
160        foreach ($classType->getProperties() as $property) {
161            yield $property;
162        }
163        if ($classType->hasMethod('__construct')) {
164            foreach ($classType->getMethod('__construct')->getParameters() as $parameter) {
165                if ($parameter instanceof PromotedParameter) {
166                    yield $parameter;
167                }
168            }
169        }
170    }
171
172    private function patch(GeneratedCodeContext $generatedCodeContext, ClassType $classType): void
173    {
174        $classType->addAttribute(Entity::class);
175        $classType->addAttribute(HasLifecycleCallbacks::class);
176        $classType->addTrait('\\' . HasGeneralDoctrineFields::class);
177
178        // @see ClassTypeFactory
179        $originalClass = $classType->getComment();
180        if ($originalClass && class_exists($originalClass)) {
181            if (is_a($originalClass, RequiresRecalculatingInterface::class, true)) {
182                $classType->addTrait('\\' . RequiresDomainUpdate::class);
183            }
184        }
185
186        foreach ($this->iterateProperties($classType) as $property) {
187            $added = false;
188            $attributes = [];
189            foreach ($property->getAttributes() as $attribute) {
190                switch ($attribute->getName()) {
191                    case DecimalPropertyAttribute::class:
192                        $added = true;
193                        $arguments = $attribute->getArguments();
194                        $attributes[] = new Attribute(Column::class, ['nullable' => true, 'type' => 'decimal', 'precision' => $arguments[2] ?? 2]);
195                        break;
196                    case GetMethodAttribute::class:
197                    case PropertyAttribute::class:
198                        $added = true;
199                        if (in_array($property->getType(), ['DateTimeImmutable', '?DateTimeImmutable'])) {
200                            $attributes[] = new Attribute(Column::class, ['nullable' => true, 'type' => 'datetimetz_immutable']);
201                        } else {
202                            $arguments = $attribute->getArguments();
203                            if ($arguments[2] ?? false) {
204                                $attributes[] = new Attribute(Column::class, ['nullable' => true, 'type' => 'text']);
205                            } else {
206                                $attributes[] = new Attribute(Column::class, ['nullable' => true]);
207                            }
208                        }
209                        break;
210                    case DiscriminatorMappingAttribute::class:
211                        $added = true;
212                        $attributes[] = new Attribute(Column::class, ['type' => 'json']);
213                        break;
214                    case ManyToOneAttribute::class:
215                        $added = true;
216                        $targetEntity = $property->getType();
217                        $attributes[] = new Attribute(
218                            ManyToOne::class,
219                            [
220                                'targetEntity' => $targetEntity,
221                                'inversedBy' => $attribute->getArguments()[0],
222                            ]
223                        );
224                        $attributes[] = new Attribute(
225                            JoinColumn::class,
226                            [
227                                'nullable' => true,
228                                'onDelete' => 'CASCADE',
229                            ]
230                        );
231                        break;
232                    case OneToManyAttribute::class:
233                    case AclLinkAttribute::class:
234                        $added = true;
235                        $property->setType(Collection::class);
236                        if ($attribute->getName() === OneToManyAttribute::class) {
237                            $targetEntity = $attribute->getArguments()[1];
238                            $mappedByProperty = $generatedCodeContext->findParentProperty($targetEntity);
239                            $mappedByProperty ??= $attribute->getArguments()[0];
240                            $mappedByProperty ??= 'ref_' . $classType->getName();
241                        } else {
242                            $targetEntity = $attribute->getArguments()[0];
243                            $mappedByProperty = 'ref_' . $classType->getName();
244                        }
245                        $indexByProperty = $generatedCodeContext->findIndexProperty($targetEntity);
246                        if ($indexByProperty) {
247                            $attributes[] = new Attribute(OrderBy::class, [[$indexByProperty => 'ASC']]);
248                        }
249                        $attributes[] = new Attribute(
250                            OneToMany::class,
251                            [
252                                'cascade' => ['all'],
253                                'targetEntity' => $targetEntity,
254                                'mappedBy' => $mappedByProperty,
255                                'fetch' => 'EAGER',
256                                'indexBy' => $indexByProperty,
257                                'orphanRemoval' => true,
258                            ]
259                        );
260
261                        break;
262                    case ManyToManyAttribute::class:
263                        $added = true;
264                        $targetEntity = $attribute->getArguments()[1];
265                        $property->setType(Collection::class);
266                        $indexByProperty = $generatedCodeContext->findIndexProperty($targetEntity);
267                        if ($indexByProperty) {
268                            $attributes[] = new Attribute(OrderBy::class, [[$indexByProperty => 'ASC']]);
269                        }
270                        $attributes[] = new Attribute(
271                            ManyToMany::class,
272                            [
273                                'cascade' => ['all'],
274                                'targetEntity' => $targetEntity,
275                                'fetch' => 'EAGER',
276                                'indexBy' => $indexByProperty,
277                                'orphanRemoval' => true,
278                            ]
279                        );
280                        break;
281                    case OneToOneAttribute::class:
282                        $added = true;
283                        $targetEntity = $property->getType();
284                        // look for @ParentAttribute for inversedBy?
285                        $attributes[] = new Attribute(
286                            OneToOne::class,
287                            [
288                                'cascade' => ['all'],
289                                'targetEntity' => $targetEntity,
290                                'fetch' => 'EAGER',
291                                'orphanRemoval' => true,
292                            ]
293                        );
294                        break;
295                    case GetSearchIndexAttribute::class:
296                        $added = true;
297                        $property->setType(Collection::class);
298                        $searchTableName = strpos($classType->getName(), 'apie_resource__') === 0
299                            ? preg_replace('/^apie_resource__/', 'apie_index__', $classType->getName())
300                            : 'apie_index__' . $classType->getName();
301                        $searchTableName .= '_' . $property->getName();
302                        $searchTable = SearchIndex::createFor(
303                            $searchTableName,
304                            $classType->getName(),
305                            $property->getName(),
306                        );
307                        $generatedCodeContext->generatedCode->generatedCodeHashmap[$searchTableName] = $searchTable;
308                        $attributes[] = new Attribute(
309                            OneToMany::class,
310                            [
311                                'cascade' => ['all'],
312                                'targetEntity' => $searchTableName,
313                                'mappedBy' => 'parent',
314                                'orphanRemoval' => true,
315                            ]
316                        );
317                        $args = $attribute->getArguments();
318                        $args['arrayValueType'] = $searchTableName;
319                        $attribute = new Attribute($attribute->getName(), $args);
320                        $type = $property->getType();
321                        break;
322                    case OrderAttribute::class:
323                        $added = true;
324                        $type = 'text';
325                        if ($property->getType() === 'int') {
326                            $type = 'integer';
327                        }
328                        $attributes[] = new Attribute(Column::class, ['type' => $type]);
329                        break;
330                    case ParentAttribute::class:
331                        $added = true;
332                        $inversedBy = $generatedCodeContext->findInverseProperty($property->getType(), $classType->getName());
333                        $attributes[] = new Attribute(
334                            ManyToOne::class,
335                            ['targetEntity' => $property->getType(), 'inversedBy' => $inversedBy]
336                        );
337                        $attributes[] = new Attribute(
338                            JoinColumn::class,
339                            [
340                                'onDelete' => 'CASCADE',
341                            ]
342                        );
343                        break;
344                }
345                $attributes[] = $attribute;
346            }
347            if (!$added) {
348                $type = $property->getType();
349                switch ((string) $type) {
350                    case 'string':
351                        $attributes[] = new Attribute(Column::class, ['type' => 'text', 'nullable' => $property->isNullable()]);
352                        break;
353                    case 'float':
354                        $attributes[] = new Attribute(Column::class, ['type' => 'float', 'nullable' => $property->isNullable()]);
355                        break;
356                    case 'int':
357                    case '?int':
358                        $attributes[] = new Attribute(Column::class, ['type' => 'integer', 'nullable' => $property->isNullable()]);
359                        break;
360                    case 'array':
361                    case '?array':
362                        $attributes[] = new Attribute(Column::class, ['type' => 'json', 'nullable' => $property->isNullable()]);
363                        break;
364                }
365            }
366            $property->setAttributes($attributes);
367        }
368
369        $this->applyId($classType);
370    }
371}