Passed
Push — 3.x ( 42ab4a...bb376d )
by Aleksei
12:31 queued 16s
created

Configurator::initForeignKeys()   B

Complexity

Conditions 8
Paths 48

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 9.372

Importance

Changes 0
Metric Value
cc 8
eloc 21
c 0
b 0
f 0
nc 48
nop 3
dl 0
loc 35
ccs 13
cts 18
cp 0.7221
crap 9.372
rs 8.4444
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cycle\Annotated;
6
7
use Cycle\Annotated\Annotation\Column;
8
use Cycle\Annotated\Annotation\Embeddable;
9
use Cycle\Annotated\Annotation\Entity;
10
use Cycle\Annotated\Annotation\ForeignKey;
11
use Cycle\Annotated\Annotation\Relation as RelationAnnotation;
12
use Cycle\Annotated\Exception\AnnotationException;
13
use Cycle\Annotated\Exception\AnnotationRequiredArgumentsException;
14
use Cycle\Annotated\Exception\AnnotationWrongTypeArgumentException;
15
use Cycle\Annotated\Utils\EntityUtils;
16
use Cycle\Schema\Definition\Entity as EntitySchema;
17
use Cycle\Schema\Definition\ForeignKey as ForeignKeySchema;
18
use Cycle\Schema\Definition\Field;
19
use Cycle\Schema\Definition\Relation;
20
use Cycle\Schema\Generator\SyncTables;
21
use Cycle\Schema\SchemaModifierInterface;
22
use Doctrine\Common\Annotations\Reader as DoctrineReader;
23
use Doctrine\Inflector\Inflector;
24
use Doctrine\Inflector\Rules\English\InflectorFactory;
25
use Exception;
26
use Spiral\Attributes\ReaderInterface;
27
28
final class Configurator
29
{
30
    private ReaderInterface $reader;
31
    private Inflector $inflector;
32 1056
    private EntityUtils $utils;
33
34
    public function __construct(
35
        DoctrineReader|ReaderInterface $reader,
36 1056
        private int $tableNamingStrategy = Entities::TABLE_NAMING_PLURAL,
37 1056
    ) {
38 1056
        $this->reader = ReaderFactory::create($reader);
39 1056
        $this->inflector = (new InflectorFactory())->build();
40
        $this->utils = new EntityUtils($this->reader);
41 1056
    }
42
43 1056
    public function initEntity(Entity $ann, \ReflectionClass $class): EntitySchema
44 1056
    {
45
        $e = new EntitySchema();
46 1056
        $e->setClass($class->getName());
47
48
        $e->setRole($ann->getRole() ?? $this->inflector->camelize($class->getShortName()));
49 1056
50 1056
        // representing classes
51 1056
        $e->setMapper($this->resolveName($ann->getMapper(), $class));
52 1056
        $e->setRepository($this->resolveName($ann->getRepository(), $class));
53 1056
        $e->setSource($this->resolveName($ann->getSource(), $class));
54 1056
        $e->setScope($this->resolveName($ann->getScope(), $class));
55 1056
        $e->setDatabase($ann->getDatabase());
56
        $e->setTableName(
57
            $ann->getTable() ?? $this->utils->tableName($e->getRole(), $this->tableNamingStrategy)
58 1056
        );
59 1056
60 496
        $typecast = $ann->getTypecast();
61
        if (is_array($typecast)) {
62 1056
            $typecast = array_map(fn (string $value): string => $this->resolveName($value, $class), $typecast);
63
        } else {
64
            $typecast = $this->resolveName($typecast, $class);
65 1056
        }
66
67 1056
        $e->setTypecast($typecast);
68
69
        if ($ann->isReadonlySchema()) {
70
            $e->getOptions()->set(SyncTables::READONLY_SCHEMA, true);
71 1056
        }
72
73
        return $e;
74 72
    }
75
76 72
    public function initEmbedding(Embeddable $emb, \ReflectionClass $class): EntitySchema
77 72
    {
78
        $e = new EntitySchema();
79 72
        $e->setClass($class->getName());
80
81
        $e->setRole($emb->getRole() ?? $this->inflector->camelize($class->getShortName()));
82 72
83
        // representing classes
84 72
        $e->setMapper($this->resolveName($emb->getMapper(), $class));
85
86
        return $e;
87 1056
    }
88
89 1056
    public function initFields(EntitySchema $entity, \ReflectionClass $class, string $columnPrefix = ''): void
90
    {
91 1056
        foreach ($class->getProperties() as $property) {
92 24
            try {
93
                $column = $this->reader->firstPropertyMetadata($property, Column::class);
94 24
            } catch (Exception $e) {
95 24
                throw new AnnotationException($e->getMessage(), $e->getCode(), $e);
96
            } catch (\ArgumentCountError $e) {
97
                throw AnnotationRequiredArgumentsException::createFor($property, Column::class, $e);
98
            } catch (\TypeError $e) {
99
                throw AnnotationWrongTypeArgumentException::createFor($property, $e);
100 1032
            }
101 976
102
            if ($column === null) {
103
                continue;
104 1008
            }
105 1008
106 1008
            $field = $this->initField($property->getName(), $column, $class, $columnPrefix);
107
            $field->setEntityClass($property->getDeclaringClass()->getName());
108 1032
            $entity->getFields()->set($property->getName(), $field);
109
        }
110 1032
    }
111
112 1032
    public function initRelations(EntitySchema $entity, \ReflectionClass $class): void
113
    {
114 1032
        foreach ($class->getProperties() as $property) {
115
            $metadata = $this->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class);
116
117
            foreach ($metadata as $meta) {
118
                assert($meta instanceof RelationAnnotation\RelationInterface);
119 1032
120
                if ($meta->getTarget() === null) {
121
                    throw new AnnotationException(
122 808
                        "Relation target definition is required on `{$entity->getClass()}`.`{$property->getName()}`"
123
                    );
124
                }
125
126
                $relation = new Relation();
127
                $relation->setTarget($this->resolveName($meta->getTarget(), $class));
128 808
                $relation->setType($meta->getType());
129 808
130 808
                $inverse = $meta->getInverse() ?? $this->reader->firstPropertyMetadata(
131
                    $property,
132 808
                    RelationAnnotation\Inverse::class
133
                );
134 768
                if ($inverse !== null) {
135
                    $relation->setInverse(
136 808
                        $inverse->getName(),
137 144
                        $inverse->getType(),
138 144
                        $inverse->getLoadMethod()
139 144
                    );
140 144
                }
141
142
                if ($meta instanceof RelationAnnotation\Embedded && $meta->getPrefix() === null) {
143
                    /** @var Embeddable|null $embeddable */
144 808
                    $embeddable = $this->reader->firstClassMetadata(
145
                        new \ReflectionClass($relation->getTarget()),
146 72
                        Embeddable::class
147 72
                    );
148 72
                    $meta->setPrefix($embeddable->getColumnPrefix());
149
                }
150 72
151
                foreach ($meta->getOptions() as $option => $value) {
152
                    $value = match ($option) {
153 808
                        'collection' => $this->resolveName($value, $class),
154 808
                        'though', 'through' => $this->resolveName($value, $class),
155 568
                        default => $value
156 284
                    };
157 404
158
                    $relation->getOptions()->set($option, $value);
159
                }
160 808
161
                // need relation definition
162
                $entity->getRelations()->set($property->getName(), $relation);
163
            }
164 808
        }
165
    }
166
167 1032
    public function initModifiers(EntitySchema $entity, \ReflectionClass $class): void
168
    {
169 1032
        $metadata = $this->getClassMetadata($class, SchemaModifierInterface::class);
170
171
        foreach ($metadata as $meta) {
172 1032
            assert($meta instanceof SchemaModifierInterface);
173
174
            // need relation definition
175
            $entity->addSchemaModifier($meta);
176
        }
177 1032
    }
178
179
    /**
180
     * @param Column[] $columns
181 24
     */
182
    public function initColumns(EntitySchema $entity, array $columns, \ReflectionClass $class): void
183 1032
    {
184
        foreach ($columns as $key => $column) {
185
            $isNumericKey = is_numeric($key);
186
            $propertyName = $column->getProperty();
187
188 1032
            if (!$isNumericKey && $propertyName !== null && $key !== $propertyName) {
189
                throw new AnnotationException(
190 1032
                    "Can not use name \"{$key}\" for Column of the `{$entity->getRole()}` role, because the "
191 472
                    . "\"property\" field of the metadata class has already been set to \"{$propertyName}\"."
192 472
                );
193
            }
194 472
195 6
            $propertyName = $propertyName ?? ($isNumericKey ? null : $key);
196 6
            $columnName = $column->getColumn() ?? $propertyName;
197 6
            $propertyName = $propertyName ?? $columnName;
198
199
            if ($columnName === null) {
200
                throw new AnnotationException(
201 466
                    "Column name definition is required on `{$entity->getClass()}`"
202 466
                );
203 466
            }
204
205 466
            if ($column->getType() === null) {
206
                throw new AnnotationException(
207
                    "Column type definition is required on `{$entity->getClass()}`.`{$columnName}`"
208
                );
209
            }
210
211 466
            $field = $this->initField($columnName, $column, $class, '');
212
            $field->setEntityClass($entity->getClass());
213
            $entity->getFields()->set($propertyName, $field);
0 ignored issues
show
Bug introduced by
It seems like $propertyName can also be of type null; however, parameter $name of Cycle\Schema\Definition\Map\FieldMap::set() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

213
            $entity->getFields()->set(/** @scrutinizer ignore-type */ $propertyName, $field);
Loading history...
214
        }
215
    }
216
217 466
    public function initField(string $name, Column $column, \ReflectionClass $class, string $columnPrefix): Field
218 466
    {
219 466
        $field = new Field();
220
221 1032
        $field->setType($column->getType());
222
        $field->setColumn($columnPrefix . ($column->getColumn() ?? $this->inflector->tableize($name)));
223 1032
        $field->setPrimary($column->isPrimary());
224
225 1032
        $field->setTypecast($this->resolveTypecast($column->getTypecast(), $class));
226
227 1032
        if ($column->isNullable()) {
228 1032
            $field->getOptions()->set(\Cycle\Schema\Table\Column::OPT_NULLABLE, true);
229 1032
            $field->getOptions()->set(\Cycle\Schema\Table\Column::OPT_DEFAULT, null);
230
        }
231 1032
232
        if ($column->hasDefault()) {
233 1032
            $field->getOptions()->set(\Cycle\Schema\Table\Column::OPT_DEFAULT, $column->getDefault());
234 48
        }
235 48
236
        if ($column->castDefault()) {
237
            $field->getOptions()->set(\Cycle\Schema\Table\Column::OPT_CAST_DEFAULT, true);
238 1032
        }
239 496
240
        if ($column->isReadonlySchema()) {
241
            $field->getAttributes()->set('readonlySchema', true);
242 1032
        }
243
244
        foreach ($column->getAttributes() as $k => $v) {
245
            $field->getAttributes()->set($k, $v);
246 1032
        }
247
248
        return $field;
249
    }
250
251
    public function initForeignKeys(Entity $ann, EntitySchema $entity, \ReflectionClass $class): void
252 1056
    {
253
        $foreignKeys = [];
254 1056
        foreach ($ann->getForeignKeys() as $foreignKey) {
255 1056
            $foreignKeys[] = $foreignKey;
256
        }
257
258 680
        foreach ($this->getClassMetadata($class, ForeignKey::class) as $foreignKey) {
259 680
            $foreignKeys[] = $foreignKey;
260 680
        }
261 680
262
        foreach ($class->getProperties() as $property) {
263
            foreach ($this->getPropertyMetadata($property, ForeignKey::class) as $foreignKey) {
264 680
                if ($foreignKey->innerKey === null) {
265 656
                    $foreignKey->innerKey = [$property->getName()];
266
                }
267
                $foreignKeys[] = $foreignKey;
268 520
            }
269
        }
270
271 1032
        foreach ($foreignKeys as $foreignKey) {
272
            if ($foreignKey->innerKey === null) {
273 1032
                throw new AnnotationException(
274
                    "Inner column definition for the foreign key is required on `{$entity->getClass()}`"
275
                );
276
            }
277
278
            $fk = new ForeignKeySchema();
279
            $fk->setTarget($foreignKey->target);
280
            $fk->setInnerColumns((array) $foreignKey->innerKey);
281 1032
            $fk->setOuterColumns((array) $foreignKey->outerKey);
282
            $fk->createIndex($foreignKey->indexCreate);
283
            $fk->setAction($foreignKey->action);
284
285
            $entity->getForeignKeys()->set($fk);
286
        }
287
    }
288 1032
289
    /**
290
     * Resolve class or role name relative to the current class.
291
     */
292
    public function resolveName(?string $name, \ReflectionClass $class): ?string
293
    {
294
        if ($name === null || $this->exists($name)) {
295
            return $name;
296
        }
297
298
        $resolved = \sprintf(
299
            '%s\\%s',
300
            $class->getNamespaceName(),
301
            \ltrim(\str_replace('/', '\\', $name), '\\')
302
        );
303
304
        if ($this->exists($resolved)) {
305
            return \ltrim($resolved, '\\');
306
        }
307
308
        return $name;
309
    }
310
311
    private function exists(string $name): bool
312
    {
313
        return \class_exists($name, true) || \interface_exists($name, true);
314
    }
315
316
    private function resolveTypecast(mixed $typecast, \ReflectionClass $class): mixed
317
    {
318
        if (\is_string($typecast) && \str_contains($typecast, '::')) {
319
            // short definition
320
            $typecast = \explode('::', $typecast);
321
322
            // resolve class name
323
            $typecast[0] = $this->resolveName($typecast[0], $class);
324
        }
325
326
        if (\is_string($typecast)) {
327
            $typecast = $this->resolveName($typecast, $class);
328
            if (\class_exists($typecast) && \method_exists($typecast, 'typecast')) {
329
                $typecast = [$typecast, 'typecast'];
330
            }
331
        }
332
333
        return $typecast;
334
    }
335
336
    /**
337
     * @template T
338
     *
339
     * @param class-string<T>|null
340
     *
341
     * @throws AnnotationException
342
     *
343
     * @return iterable<T>
344
     */
345
    private function getClassMetadata(\ReflectionClass $class, string $name): iterable
346
    {
347
        try {
348
            return $this->reader->getClassMetadata($class, $name);
349
        } catch (\Exception $e) {
350
            throw new AnnotationException($e->getMessage(), $e->getCode(), $e);
351
        }
352
    }
353
354
    /**
355
     * @template T
356
     *
357
     * @param class-string<T>|null $name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T>|null at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>|null.
Loading history...
358
     *
359
     * @throws AnnotationException
360
     *
361
     * @return iterable<T>
362
     */
363
    private function getPropertyMetadata(\ReflectionProperty $property, string $name): iterable
364
    {
365
        try {
366
            return $this->reader->getPropertyMetadata($property, $name);
367
        } catch (\Exception $e) {
368
            throw new AnnotationException($e->getMessage(), $e->getCode(), $e);
369
        }
370
    }
371
}
372