Passed
Pull Request — master (#7065)
by Michael
11:41
created

SchemaTool::addDiscriminatorColumnDefinition()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4.3035

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 6
nop 2
dl 0
loc 24
ccs 11
cts 15
cp 0.7332
crap 4.3035
rs 8.6845
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ORM\Tools;
6
7
use Doctrine\DBAL\Platforms\AbstractPlatform;
8
use Doctrine\DBAL\Schema\Column;
9
use Doctrine\DBAL\Schema\Comparator;
10
use Doctrine\DBAL\Schema\Index;
11
use Doctrine\DBAL\Schema\Schema;
12
use Doctrine\DBAL\Schema\Table;
13
use Doctrine\DBAL\Schema\Visitor\DropSchemaSqlCollector;
14
use Doctrine\DBAL\Schema\Visitor\RemoveNamespacedAssets;
15
use Doctrine\ORM\EntityManagerInterface;
16
use Doctrine\ORM\Mapping\AssociationMetadata;
17
use Doctrine\ORM\Mapping\ClassMetadata;
18
use Doctrine\ORM\Mapping\FieldMetadata;
19
use Doctrine\ORM\Mapping\GeneratorType;
20
use Doctrine\ORM\Mapping\InheritanceType;
21
use Doctrine\ORM\Mapping\JoinColumnMetadata;
22
use Doctrine\ORM\Mapping\ManyToManyAssociationMetadata;
23
use Doctrine\ORM\Mapping\MappingException;
24
use Doctrine\ORM\Mapping\OneToManyAssociationMetadata;
25
use Doctrine\ORM\Mapping\ToOneAssociationMetadata;
26
use Doctrine\ORM\ORMException;
27
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
28
use Doctrine\ORM\Tools\Event\GenerateSchemaTableEventArgs;
29
use function array_diff;
30
use function array_key_exists;
31
use function array_keys;
32
use function count;
33
use function implode;
34
use function in_array;
35
use function is_int;
36
use function is_numeric;
37
use function reset;
38
use function sprintf;
39
use function strtolower;
40
41
/**
42
 * The SchemaTool is a tool to create/drop/update database schemas based on
43
 * <tt>ClassMetadata</tt> class descriptors.
44
 */
45
class SchemaTool
46
{
47
    /**
48
     * @var EntityManagerInterface
49
     */
50
    private $em;
51
52
    /**
53
     * @var AbstractPlatform
54
     */
55
    private $platform;
56
57
    /**
58
     * Initializes a new SchemaTool instance that uses the connection of the
59
     * provided EntityManager.
60
     */
61 1213
    public function __construct(EntityManagerInterface $em)
62
    {
63 1213
        $this->em       = $em;
64 1213
        $this->platform = $em->getConnection()->getDatabasePlatform();
65 1213
    }
66
67
    /**
68
     * Creates the database schema for the given array of ClassMetadata instances.
69
     *
70
     * @param ClassMetadata[] $classes
71
     *
72
     * @throws ToolsException
73
     */
74 246
    public function createSchema(array $classes)
75
    {
76 246
        $createSchemaSql = $this->getCreateSchemaSql($classes);
77 246
        $conn            = $this->em->getConnection();
78
79 246
        foreach ($createSchemaSql as $sql) {
80
            try {
81 246
                $conn->executeQuery($sql);
82 68
            } catch (\Throwable $e) {
83 246
                throw ToolsException::schemaToolFailure($sql, $e);
84
            }
85
        }
86 178
    }
87
88
    /**
89
     * Gets the list of DDL statements that are required to create the database schema for
90
     * the given list of ClassMetadata instances.
91
     *
92
     * @param ClassMetadata[] $classes
93
     *
94
     * @return string[] The SQL statements needed to create the schema for the classes.
95
     */
96 246
    public function getCreateSchemaSql(array $classes)
97
    {
98 246
        $schema = $this->getSchemaFromMetadata($classes);
99
100 246
        return $schema->toSql($this->platform);
101
    }
102
103
    /**
104
     * Detects instances of ClassMetadata that don't need to be processed in the SchemaTool context.
105
     *
106
     * @param ClassMetadata   $class
107
     * @param ClassMetadata[] $processedClasses
108
     *
109
     * @return bool
110
     */
111 255
    private function processingNotRequired($class, array $processedClasses)
112
    {
113 255
        return isset($processedClasses[$class->getClassName()]) ||
114 255
            $class->isMappedSuperclass ||
115 255
            $class->isEmbeddedClass ||
116 255
            ($class->inheritanceType === InheritanceType::SINGLE_TABLE && ! $class->isRootEntity())
117
        ;
118
    }
119
120
    /**
121
     * Creates a Schema instance from a given set of metadata classes.
122
     *
123
     * @param ClassMetadata[] $classes
124
     *
125
     * @return Schema
126
     *
127
     * @throws ORMException
128
     */
129 255
    public function getSchemaFromMetadata(array $classes)
130
    {
131
        // Reminder for processed classes, used for hierarchies
132 255
        $processedClasses     = [];
133 255
        $eventManager         = $this->em->getEventManager();
134 255
        $schemaManager        = $this->em->getConnection()->getSchemaManager();
135 255
        $metadataSchemaConfig = $schemaManager->createSchemaConfig();
136
137 255
        $metadataSchemaConfig->setExplicitForeignKeyIndexes(false);
138 255
        $schema = new Schema([], [], $metadataSchemaConfig);
139
140 255
        $addedFks       = [];
141 255
        $blacklistedFks = [];
142
143 255
        foreach ($classes as $class) {
144
            /** @var ClassMetadata $class */
145 255
            if ($this->processingNotRequired($class, $processedClasses)) {
146 19
                continue;
147
            }
148
149 255
            $table = $schema->createTable($class->table->getQuotedQualifiedName($this->platform));
150
151 255
            switch ($class->inheritanceType) {
152
                case InheritanceType::SINGLE_TABLE:
153 21
                    $this->gatherColumns($class, $table);
154 21
                    $this->gatherRelationsSql($class, $table, $schema, $addedFks, $blacklistedFks);
155
156
                    // Add the discriminator column
157 21
                    $this->addDiscriminatorColumnDefinition($class, $table);
158
159
                    // Aggregate all the information from all classes in the hierarchy
160 21
                    $parentClass = $class;
161
162 21
                    while (($parentClass = $parentClass->getParent()) !== null) {
163
                        // Parent class information is already contained in this class
164
                        $processedClasses[$parentClass->getClassName()] = true;
165
                    }
166
167 21
                    foreach ($class->getSubClasses() as $subClassName) {
168 19
                        $subClass = $this->em->getClassMetadata($subClassName);
169
170 19
                        $this->gatherColumns($subClass, $table);
0 ignored issues
show
Bug introduced by
$subClass of type Doctrine\Common\Persistence\Mapping\ClassMetadata is incompatible with the type Doctrine\ORM\Mapping\ClassMetadata expected by parameter $class of Doctrine\ORM\Tools\SchemaTool::gatherColumns(). ( Ignorable by Annotation )

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

170
                        $this->gatherColumns(/** @scrutinizer ignore-type */ $subClass, $table);
Loading history...
171 19
                        $this->gatherRelationsSql($subClass, $table, $schema, $addedFks, $blacklistedFks);
0 ignored issues
show
Bug introduced by
$subClass of type Doctrine\Common\Persistence\Mapping\ClassMetadata is incompatible with the type Doctrine\ORM\Mapping\ClassMetadata expected by parameter $class of Doctrine\ORM\Tools\Schem...l::gatherRelationsSql(). ( Ignorable by Annotation )

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

171
                        $this->gatherRelationsSql(/** @scrutinizer ignore-type */ $subClass, $table, $schema, $addedFks, $blacklistedFks);
Loading history...
172
173 19
                        $processedClasses[$subClassName] = true;
174
                    }
175
176 21
                    break;
177
178
                case InheritanceType::JOINED:
179
                    // Add all non-inherited fields as columns
180 58
                    $pkColumns = [];
181
182 58
                    foreach ($class->getDeclaredPropertiesIterator() as $fieldName => $property) {
183 58
                        if (! ($property instanceof FieldMetadata)) {
184 16
                            continue;
185
                        }
186
187 58
                        if (! $class->isInheritedProperty($fieldName)) {
188 58
                            $columnName = $this->platform->quoteIdentifier($property->getColumnName());
189
190 58
                            $this->gatherColumn($class, $property, $table);
191
192 58
                            if ($class->isIdentifier($fieldName)) {
193 58
                                $pkColumns[] = $columnName;
194
                            }
195
                        }
196
                    }
197
198 58
                    $this->gatherRelationsSql($class, $table, $schema, $addedFks, $blacklistedFks);
199
200
                    // Add the discriminator column only to the root table
201 58
                    if ($class->isRootEntity()) {
202 58
                        $this->addDiscriminatorColumnDefinition($class, $table);
203
                    } else {
204
                        // Add an ID FK column to child tables
205 57
                        $inheritedKeyColumns = [];
206
207 57
                        foreach ($class->identifier as $identifierField) {
208 57
                            $idProperty = $class->getProperty($identifierField);
209
210 57
                            if ($class->isInheritedProperty($identifierField)) {
211 57
                                $column     = $this->gatherColumn($class, $idProperty, $table);
212 57
                                $columnName = $column->getQuotedName($this->platform);
213
214
                                // TODO: This seems rather hackish, can we optimize it?
215 57
                                $column->setAutoincrement(false);
216
217 57
                                $pkColumns[]           = $columnName;
218 57
                                $inheritedKeyColumns[] = $columnName;
219
                            }
220
                        }
221
222 57
                        if (! empty($inheritedKeyColumns)) {
223
                            // Add a FK constraint on the ID column
224 57
                            $rootClass = $this->em->getClassMetadata($class->getRootClassName());
225
226 57
                            $table->addForeignKeyConstraint(
227 57
                                $rootClass->table->getQuotedQualifiedName($this->platform),
0 ignored issues
show
Bug introduced by
Accessing table on the interface Doctrine\Common\Persistence\Mapping\ClassMetadata suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
228 57
                                $inheritedKeyColumns,
229 57
                                $inheritedKeyColumns,
230 57
                                ['onDelete' => 'CASCADE']
231
                            );
232
                        }
233
                    }
234
235 58
                    $table->setPrimaryKey($pkColumns);
236
237 58
                    break;
238
239
                case InheritanceType::TABLE_PER_CLASS:
240
                    throw ORMException::notSupported();
241
242
                default:
243 230
                    $this->gatherColumns($class, $table);
244 230
                    $this->gatherRelationsSql($class, $table, $schema, $addedFks, $blacklistedFks);
245
246 230
                    break;
247
            }
248
249 255
            $pkColumns = [];
250
251 255
            foreach ($class->identifier as $identifierField) {
252 255
                $property = $class->getProperty($identifierField);
253
254 255
                if ($property instanceof FieldMetadata) {
255 255
                    $pkColumns[] = $this->platform->quoteIdentifier($property->getColumnName());
256
257 255
                    continue;
258
                }
259
260 29
                if ($property instanceof ToOneAssociationMetadata) {
261 29
                    foreach ($property->getJoinColumns() as $joinColumn) {
262 29
                        $pkColumns[] = $this->platform->quoteIdentifier($joinColumn->getColumnName());
263
                    }
264
                }
265
            }
266
267 255
            if (! $table->hasIndex('primary')) {
268 237
                $table->setPrimaryKey($pkColumns);
269
            }
270
271
            // there can be unique indexes automatically created for join column
272
            // if join column is also primary key we should keep only primary key on this column
273
            // so, remove indexes overruled by primary key
274 255
            $primaryKey = $table->getIndex('primary');
275
276 255
            foreach ($table->getIndexes() as $idxKey => $existingIndex) {
277 255
                if ($primaryKey->overrules($existingIndex)) {
278 255
                    $table->dropIndex($idxKey);
279
                }
280
            }
281
282 255
            if ($class->table->getIndexes()) {
283 1
                foreach ($class->table->getIndexes() as $indexName => $indexData) {
284 1
                    $indexName = is_numeric($indexName) ? null : $indexName;
285 1
                    $index     = new Index($indexName, $indexData['columns'], $indexData['unique'], $indexData['flags'], $indexData['options']);
286
287 1
                    foreach ($table->getIndexes() as $tableIndexName => $tableIndex) {
288 1
                        if ($tableIndex->isFullfilledBy($index)) {
289
                            $table->dropIndex($tableIndexName);
290 1
                            break;
291
                        }
292
                    }
293
294 1
                    if ($indexData['unique']) {
295
                        $table->addUniqueIndex($indexData['columns'], $indexName, $indexData['options']);
296
                    } else {
297 1
                        $table->addIndex($indexData['columns'], $indexName, $indexData['flags'], $indexData['options']);
298
                    }
299
                }
300
            }
301
302 255
            if ($class->table->getUniqueConstraints()) {
303 4
                foreach ($class->table->getUniqueConstraints() as $indexName => $indexData) {
304 4
                    $indexName = is_numeric($indexName) ? null : $indexName;
305 4
                    $uniqIndex = new Index($indexName, $indexData['columns'], true, false, $indexData['flags'], $indexData['options']);
306
307 4
                    foreach ($table->getIndexes() as $tableIndexName => $tableIndex) {
308 4
                        if ($tableIndex->isFullfilledBy($uniqIndex)) {
309 3
                            $table->dropIndex($tableIndexName);
310 4
                            break;
311
                        }
312
                    }
313
314 4
                    $table->addUniqueConstraint($indexData['columns'], $indexName, $indexData['flags'], $indexData['options']);
315
                }
316
            }
317
318 255
            if ($class->table->getOptions()) {
319 1
                foreach ($class->table->getOptions() as $key => $val) {
320 1
                    $table->addOption($key, $val);
321
                }
322
            }
323
324 255
            $processedClasses[$class->getClassName()] = true;
325
326 255
            foreach ($class->getDeclaredPropertiesIterator() as $property) {
327 255
                if (! $property instanceof FieldMetadata
328 255
                    || ! $property->hasValueGenerator()
329 225
                    || $property->getValueGenerator()->getType() !== GeneratorType::SEQUENCE
330 255
                    || $class->getClassName() !== $class->getRootClassName()) {
331 255
                    continue;
332
                }
333
334
                $quotedName = $this->platform->quoteIdentifier($property->getValueGenerator()->getDefinition()['sequenceName']);
335
336
                if (! $schema->hasSequence($quotedName)) {
337
                    $schema->createSequence($quotedName, $property->getValueGenerator()->getDefinition()['allocationSize']);
338
                }
339
            }
340
341 255
            if ($eventManager->hasListeners(ToolEvents::postGenerateSchemaTable)) {
342 1
                $eventManager->dispatchEvent(
343 1
                    ToolEvents::postGenerateSchemaTable,
344 255
                    new GenerateSchemaTableEventArgs($class, $schema, $table)
345
                );
346
            }
347
        }
348
349 255
        if (! $this->platform->supportsSchemas() && ! $this->platform->canEmulateSchemas()) {
350 8
            $schema->visit(new RemoveNamespacedAssets());
351
        }
352
353 255
        if ($eventManager->hasListeners(ToolEvents::postGenerateSchema)) {
354 1
            $eventManager->dispatchEvent(
355 1
                ToolEvents::postGenerateSchema,
356 1
                new GenerateSchemaEventArgs($this->em, $schema)
357
            );
358
        }
359
360 255
        return $schema;
361
    }
362
363
    /**
364
     * Gets a portable column definition as required by the DBAL for the discriminator
365
     * column of a class.
366
     *
367
     * @param ClassMetadata $class
368
     */
369 74
    private function addDiscriminatorColumnDefinition($class, Table $table)
370
    {
371 74
        $discrColumn     = $class->discriminatorColumn;
372 74
        $discrColumnType = $discrColumn->getTypeName();
373
        $options         = [
374 74
            'notnull' => ! $discrColumn->isNullable(),
375
        ];
376
377 74
        switch ($discrColumnType) {
378 74
            case 'string':
379 67
                $options['length'] = $discrColumn->getLength() ?? 255;
380 67
                break;
381
382 7
            case 'decimal':
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
383
                $options['scale']     = $discrColumn->getScale();
384
                $options['precision'] = $discrColumn->getPrecision();
385
                break;
386
        }
387
388 74
        if (! empty($discrColumn->getColumnDefinition())) {
389
            $options['columnDefinition'] = $discrColumn->getColumnDefinition();
390
        }
391
392 74
        $table->addColumn($discrColumn->getColumnName(), $discrColumnType, $options);
393 74
    }
394
395
    /**
396
     * Gathers the column definitions as required by the DBAL of all field mappings
397
     * found in the given class.
398
     *
399
     * @param ClassMetadata $class
400
     */
401 237
    private function gatherColumns($class, Table $table)
402
    {
403 237
        $pkColumns = [];
404
405 237
        foreach ($class->getDeclaredPropertiesIterator() as $fieldName => $property) {
406 237
            if (! ($property instanceof FieldMetadata)) {
407 178
                continue;
408
            }
409
410 237
            if ($class->inheritanceType === InheritanceType::SINGLE_TABLE && $class->isInheritedProperty($fieldName)) {
411 19
                continue;
412
            }
413
414 237
            $this->gatherColumn($class, $property, $table);
415
416 237
            if ($property->isPrimaryKey()) {
417 237
                $pkColumns[] = $this->platform->quoteIdentifier($property->getColumnName());
418
            }
419
        }
420 237
    }
421
422
    /**
423
     * Creates a column definition as required by the DBAL from an ORM field mapping definition.
424
     *
425
     * @param ClassMetadata $classMetadata The class that owns the field mapping.
426
     * @param FieldMetadata $fieldMetadata The field mapping.
427
     *
428
     * @return Column The portable column definition as required by the DBAL.
429
     */
430 255
    private function gatherColumn($classMetadata, FieldMetadata $fieldMetadata, Table $table)
431
    {
432 255
        $fieldName  = $fieldMetadata->getName();
433 255
        $columnName = $fieldMetadata->getColumnName();
434 255
        $columnType = $fieldMetadata->getTypeName();
435
436
        $options = [
437 255
            'length'          => $fieldMetadata->getLength(),
438 255
            'notnull'         => ! $fieldMetadata->isNullable(),
439
            'platformOptions' => [
440 255
                'version' => ($classMetadata->isVersioned() && $classMetadata->versionProperty->getName() === $fieldName),
441
            ],
442
        ];
443
444 255
        if ($classMetadata->inheritanceType === InheritanceType::SINGLE_TABLE && $classMetadata->getParent()) {
445 7
            $options['notnull'] = false;
446
        }
447
448 255
        if (strtolower($columnType) === 'string' && $options['length'] === null) {
449
            $options['length'] = 255;
450
        }
451
452 255
        if (is_int($fieldMetadata->getPrecision())) {
453 255
            $options['precision'] = $fieldMetadata->getPrecision();
454
        }
455
456 255
        if (is_int($fieldMetadata->getScale())) {
457 255
            $options['scale'] = $fieldMetadata->getScale();
458
        }
459
460 255
        if ($fieldMetadata->getColumnDefinition()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fieldMetadata->getColumnDefinition() of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
461 1
            $options['columnDefinition'] = $fieldMetadata->getColumnDefinition();
462
        }
463
464 255
        $fieldOptions = $fieldMetadata->getOptions();
465
466 255
        if ($fieldOptions) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fieldOptions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
467 32
            $knownOptions = ['comment', 'unsigned', 'fixed', 'default'];
468
469 32
            foreach ($knownOptions as $knownOption) {
470 32
                if (array_key_exists($knownOption, $fieldOptions)) {
471 31
                    $options[$knownOption] = $fieldOptions[$knownOption];
472
473 32
                    unset($fieldOptions[$knownOption]);
474
                }
475
            }
476
477 32
            $options['customSchemaOptions'] = $fieldOptions;
478
        }
479
480 255
        if ($fieldMetadata->hasValueGenerator() && $fieldMetadata->getValueGenerator()->getType() === GeneratorType::IDENTITY && $classMetadata->getIdentifierFieldNames() === [$fieldName]) {
481 224
            $options['autoincrement'] = true;
482
        }
483
484 255
        if ($classMetadata->inheritanceType === InheritanceType::JOINED && ! $classMetadata->isRootEntity()) {
485 57
            $options['autoincrement'] = false;
486
        }
487
488 255
        $quotedColumnName = $this->platform->quoteIdentifier($fieldMetadata->getColumnName());
489
490 255
        if ($table->hasColumn($quotedColumnName)) {
491
            // required in some inheritance scenarios
492
            $table->changeColumn($quotedColumnName, $options);
493
494
            $column = $table->getColumn($quotedColumnName);
495
        } else {
496 255
            $column = $table->addColumn($quotedColumnName, $columnType, $options);
497
        }
498
499 255
        if ($fieldMetadata->isUnique()) {
500 17
            $table->addUniqueIndex([$columnName]);
501
        }
502
503 255
        return $column;
504
    }
505
506
    /**
507
     * Gathers the SQL for properly setting up the relations of the given class.
508
     * This includes the SQL for foreign key constraints and join tables.
509
     *
510
     * @param ClassMetadata $class
511
     * @param Table         $table
512
     * @param Schema        $schema
513
     * @param mixed[][]     $addedFks
514
     * @param bool[]        $blacklistedFks
515
     *
516
     * @throws ORMException
517
     */
518 255
    private function gatherRelationsSql($class, $table, $schema, &$addedFks, &$blacklistedFks)
519
    {
520 255
        foreach ($class->getDeclaredPropertiesIterator() as $fieldName => $property) {
521 255
            if (! ($property instanceof AssociationMetadata)) {
522 255
                continue;
523
            }
524
525 183
            if ($class->isInheritedProperty($fieldName) && ! $property->getDeclaringClass()->isMappedSuperclass) {
526 21
                continue;
527
            }
528
529 183
            if (! $property->isOwningSide()) {
530 131
                continue;
531
            }
532
533 183
            $foreignClass = $this->em->getClassMetadata($property->getTargetEntity());
534
535
            switch (true) {
536 183
                case ($property instanceof ToOneAssociationMetadata):
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
537 166
                    $primaryKeyColumns = []; // PK is unnecessary for this relation-type
538
539 166
                    $this->gatherRelationJoinColumns(
540 166
                        $property->getJoinColumns(),
541 166
                        $table,
542 166
                        $foreignClass,
0 ignored issues
show
Bug introduced by
$foreignClass of type Doctrine\Common\Persistence\Mapping\ClassMetadata is incompatible with the type Doctrine\ORM\Mapping\ClassMetadata expected by parameter $class of Doctrine\ORM\Tools\Schem...erRelationJoinColumns(). ( Ignorable by Annotation )

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

542
                        /** @scrutinizer ignore-type */ $foreignClass,
Loading history...
543 166
                        $property,
544 166
                        $primaryKeyColumns,
545 166
                        $addedFks,
546 166
                        $blacklistedFks
547
                    );
548
549 166
                    break;
550
551 42
                case ($property instanceof OneToManyAssociationMetadata):
552
                    //... create join table, one-many through join table supported later
553
                    throw ORMException::notSupported();
554
555 42
                case ($property instanceof ManyToManyAssociationMetadata):
556
                    // create join table
557 42
                    $joinTable     = $property->getJoinTable();
558 42
                    $joinTableName = $joinTable->getQuotedQualifiedName($this->platform);
559 42
                    $theJoinTable  = $schema->createTable($joinTableName);
560
561 42
                    $primaryKeyColumns = [];
562
563
                    // Build first FK constraint (relation table => source table)
564 42
                    $this->gatherRelationJoinColumns(
565 42
                        $joinTable->getJoinColumns(),
566 42
                        $theJoinTable,
567 42
                        $class,
568 42
                        $property,
569 42
                        $primaryKeyColumns,
570 42
                        $addedFks,
571 42
                        $blacklistedFks
572
                    );
573
574
                    // Build second FK constraint (relation table => target table)
575 42
                    $this->gatherRelationJoinColumns(
576 42
                        $joinTable->getInverseJoinColumns(),
577 42
                        $theJoinTable,
578 42
                        $foreignClass,
579 42
                        $property,
580 42
                        $primaryKeyColumns,
581 42
                        $addedFks,
582 42
                        $blacklistedFks
583
                    );
584
585 42
                    $theJoinTable->setPrimaryKey($primaryKeyColumns);
586
587 183
                    break;
588
            }
589
        }
590 255
    }
591
592
    /**
593
     * Gets the class metadata that is responsible for the definition of the referenced column name.
594
     *
595
     * Previously this was a simple task, but with DDC-117 this problem is actually recursive. If its
596
     * not a simple field, go through all identifier field names that are associations recursively and
597
     * find that referenced column name.
598
     *
599
     * TODO: Is there any way to make this code more pleasing?
600
     *
601
     * @param ClassMetadata $class
602
     * @param string        $referencedColumnName
603
     *
604
     * @return mixed[] (ClassMetadata, referencedFieldName)
605
     */
606 183
    private function getDefiningClass($class, $referencedColumnName)
607
    {
608 183
        if (isset($class->fieldNames[$referencedColumnName])) {
609 183
            $propertyName = $class->fieldNames[$referencedColumnName];
610
611 183
            if ($class->hasField($propertyName)) {
612 183
                return [$class, $propertyName];
613
            }
614
        }
615
616 9
        $idColumns        = $class->getIdentifierColumns($this->em);
617 9
        $idColumnNameList = array_keys($idColumns);
618
619 9
        if (! in_array($referencedColumnName, $idColumnNameList, true)) {
620
            return null;
621
        }
622
623
        // it seems to be an entity as foreign key
624 9
        foreach ($class->getIdentifierFieldNames() as $fieldName) {
625 9
            $property = $class->getProperty($fieldName);
626
627 9
            if (! ($property instanceof AssociationMetadata)) {
628 5
                continue;
629
            }
630
631 9
            $joinColumns = $property->getJoinColumns();
0 ignored issues
show
Bug introduced by
The method getJoinColumns() does not exist on Doctrine\ORM\Mapping\Property. It seems like you code against a sub-type of Doctrine\ORM\Mapping\Property such as Doctrine\ORM\Mapping\ToOneAssociationMetadata. ( Ignorable by Annotation )

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

631
            /** @scrutinizer ignore-call */ 
632
            $joinColumns = $property->getJoinColumns();
Loading history...
Bug introduced by
The method getJoinColumns() does not exist on Doctrine\ORM\Mapping\AssociationMetadata. It seems like you code against a sub-type of Doctrine\ORM\Mapping\AssociationMetadata such as Doctrine\ORM\Mapping\ToOneAssociationMetadata. ( Ignorable by Annotation )

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

631
            /** @scrutinizer ignore-call */ 
632
            $joinColumns = $property->getJoinColumns();
Loading history...
632
633 9
            if (count($joinColumns) > 1) {
634
                throw MappingException::noSingleAssociationJoinColumnFound($class->getClassName(), $fieldName);
635
            }
636
637 9
            $joinColumn = reset($joinColumns);
638
639 9
            if ($joinColumn->getColumnName() === $referencedColumnName) {
640 9
                $targetEntity = $this->em->getClassMetadata($property->getTargetEntity());
641
642 9
                return $this->getDefiningClass($targetEntity, $joinColumn->getReferencedColumnName());
0 ignored issues
show
Bug introduced by
$targetEntity of type Doctrine\Common\Persistence\Mapping\ClassMetadata is incompatible with the type Doctrine\ORM\Mapping\ClassMetadata expected by parameter $class of Doctrine\ORM\Tools\SchemaTool::getDefiningClass(). ( Ignorable by Annotation )

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

642
                return $this->getDefiningClass(/** @scrutinizer ignore-type */ $targetEntity, $joinColumn->getReferencedColumnName());
Loading history...
643
            }
644
        }
645
646
        return null;
647
    }
648
649
    /**
650
     * Gathers columns and fk constraints that are required for one part of relationship.
651
     *
652
     * @param JoinColumnMetadata[] $joinColumns
653
     * @param Table                $theJoinTable
654
     * @param ClassMetadata        $class
655
     * @param AssociationMetadata  $mapping
656
     * @param string[]             $primaryKeyColumns
657
     * @param mixed[][]            $addedFks
658
     * @param bool[]               $blacklistedFks
659
     *
660
     * @throws ORMException
661
     */
662 183
    private function gatherRelationJoinColumns(
663
        $joinColumns,
664
        $theJoinTable,
665
        $class,
666
        $mapping,
667
        &$primaryKeyColumns,
668
        &$addedFks,
669
        &$blacklistedFks
670
    ) {
671 183
        $localColumns      = [];
672 183
        $foreignColumns    = [];
673 183
        $fkOptions         = [];
674 183
        $foreignTableName  = $class->table->getQuotedQualifiedName($this->platform);
675 183
        $uniqueConstraints = [];
676
677 183
        foreach ($joinColumns as $joinColumn) {
678 183
            list($definingClass, $referencedFieldName) = $this->getDefiningClass(
679 183
                $class,
680 183
                $joinColumn->getReferencedColumnName()
681
            );
682
683 183
            if (! $definingClass) {
684
                throw new ORMException(sprintf(
685
                    'Column name "%s" referenced for relation from %s towards %s does not exist.',
686
                    $joinColumn->getReferencedColumnName(),
687
                    $mapping->getSourceEntity(),
688
                    $mapping->getTargetEntity()
689
                ));
690
            }
691
692 183
            $quotedColumnName           = $this->platform->quoteIdentifier($joinColumn->getColumnName());
693 183
            $quotedReferencedColumnName = $this->platform->quoteIdentifier($joinColumn->getReferencedColumnName());
694
695 183
            $primaryKeyColumns[] = $quotedColumnName;
696 183
            $localColumns[]      = $quotedColumnName;
697 183
            $foreignColumns[]    = $quotedReferencedColumnName;
698
699 183
            if (! $theJoinTable->hasColumn($quotedColumnName)) {
700
                // Only add the column to the table if it does not exist already.
701
                // It might exist already if the foreign key is mapped into a regular
702
                // property as well.
703 181
                $property  = $definingClass->getProperty($referencedFieldName);
704 181
                $columnDef = null;
705
706 181
                if (! empty($joinColumn->getColumnDefinition())) {
707
                    $columnDef = $joinColumn->getColumnDefinition();
708 181
                } elseif ($property->getColumnDefinition()) {
709 1
                    $columnDef = $property->getColumnDefinition();
710
                }
711
712 181
                $columnType    = $property->getTypeName();
713
                $columnOptions = [
714 181
                    'notnull'          => ! $joinColumn->isNullable(),
715 181
                    'columnDefinition' => $columnDef,
716
                ];
717
718 181
                if ($property->getOptions()) {
719
                    $columnOptions['options'] = $property->getOptions();
720
                }
721
722 181
                switch ($columnType) {
723 181
                    case 'string':
724 7
                        $columnOptions['length'] = is_int($property->getLength()) ? $property->getLength() : 255;
725 7
                        break;
726
727 179
                    case 'decimal':
728
                        $columnOptions['scale']     = $property->getScale();
729
                        $columnOptions['precision'] = $property->getPrecision();
730
                        break;
731
                }
732
733 181
                $theJoinTable->addColumn($quotedColumnName, $columnType, $columnOptions);
734
            }
735
736 183
            if ($joinColumn->isUnique()) {
737 59
                $uniqueConstraints[] = ['columns' => [$quotedColumnName]];
738
            }
739
740 183
            if (! empty($joinColumn->getOnDelete())) {
741 183
                $fkOptions['onDelete'] = $joinColumn->getOnDelete();
742
            }
743
        }
744
745
        // Prefer unique constraints over implicit simple indexes created for foreign keys.
746
        // Also avoids index duplication.
747 183
        foreach ($uniqueConstraints as $indexName => $unique) {
748 59
            $theJoinTable->addUniqueIndex($unique['columns'], is_numeric($indexName) ? null : $indexName);
749
        }
750
751 183
        $compositeName = $theJoinTable->getName() . '.' . implode('', $localColumns);
752
753 183
        if (isset($addedFks[$compositeName])
754 1
            && ($foreignTableName !== $addedFks[$compositeName]['foreignTableName']
755 183
            || 0 < count(array_diff($foreignColumns, $addedFks[$compositeName]['foreignColumns'])))
756
        ) {
757 1
            foreach ($theJoinTable->getForeignKeys() as $fkName => $key) {
758 1
                if (count(array_diff($key->getLocalColumns(), $localColumns)) === 0
759 1
                    && (($key->getForeignTableName() !== $foreignTableName)
760 1
                    || 0 < count(array_diff($key->getForeignColumns(), $foreignColumns)))
761
                ) {
762 1
                    $theJoinTable->removeForeignKey($fkName);
763 1
                    break;
764
                }
765
            }
766
767 1
            $blacklistedFks[$compositeName] = true;
768 183
        } elseif (! isset($blacklistedFks[$compositeName])) {
769 183
            $addedFks[$compositeName] = [
770 183
                'foreignTableName' => $foreignTableName,
771 183
                'foreignColumns'   => $foreignColumns,
772
            ];
773
774 183
            $theJoinTable->addForeignKeyConstraint(
775 183
                $foreignTableName,
776 183
                $localColumns,
777 183
                $foreignColumns,
778 183
                $fkOptions
779
            );
780
        }
781 183
    }
782
783
    /**
784
     * Drops the database schema for the given classes.
785
     *
786
     * In any way when an exception is thrown it is suppressed since drop was
787
     * issued for all classes of the schema and some probably just don't exist.
788
     *
789
     * @param ClassMetadata[] $classes
790
     */
791 8
    public function dropSchema(array $classes)
792
    {
793 8
        $dropSchemaSql = $this->getDropSchemaSQL($classes);
794 8
        $conn          = $this->em->getConnection();
795
796 8
        foreach ($dropSchemaSql as $sql) {
797
            try {
798 8
                $conn->executeQuery($sql);
799 8
            } catch (\Throwable $e) {
800
                // ignored
801
            }
802
        }
803 8
    }
804
805
    /**
806
     * Drops all elements in the database of the current connection.
807
     */
808
    public function dropDatabase()
809
    {
810
        $dropSchemaSql = $this->getDropDatabaseSQL();
811
        $conn          = $this->em->getConnection();
812
813
        foreach ($dropSchemaSql as $sql) {
814
            $conn->executeQuery($sql);
815
        }
816
    }
817
818
    /**
819
     * Gets the SQL needed to drop the database schema for the connections database.
820
     *
821
     * @return string[]
822
     */
823
    public function getDropDatabaseSQL()
824
    {
825
        $sm     = $this->em->getConnection()->getSchemaManager();
826
        $schema = $sm->createSchema();
827
828
        $visitor = new DropSchemaSqlCollector($this->platform);
829
        $schema->visit($visitor);
830
831
        return $visitor->getQueries();
832
    }
833
834
    /**
835
     * Gets SQL to drop the tables defined by the passed classes.
836
     *
837
     * @param ClassMetadata[] $classes
838
     *
839
     * @return string[]
840
     */
841 8
    public function getDropSchemaSQL(array $classes)
842
    {
843 8
        $visitor = new DropSchemaSqlCollector($this->platform);
844 8
        $schema  = $this->getSchemaFromMetadata($classes);
845
846 8
        $sm         = $this->em->getConnection()->getSchemaManager();
847 8
        $fullSchema = $sm->createSchema();
848
849 8
        foreach ($fullSchema->getTables() as $table) {
850 8
            if (! $schema->hasTable($table->getName())) {
851 6
                foreach ($table->getForeignKeys() as $foreignKey) {
852
                    /* @var $foreignKey \Doctrine\DBAL\Schema\ForeignKeyConstraint */
853
                    if ($schema->hasTable($foreignKey->getForeignTableName())) {
854 6
                        $visitor->acceptForeignKey($table, $foreignKey);
855
                    }
856
                }
857
            } else {
858 8
                $visitor->acceptTable($table);
859 8
                foreach ($table->getForeignKeys() as $foreignKey) {
860 8
                    $visitor->acceptForeignKey($table, $foreignKey);
861
                }
862
            }
863
        }
864
865 8
        if ($this->platform->supportsSequences()) {
866
            foreach ($schema->getSequences() as $sequence) {
867
                $visitor->acceptSequence($sequence);
868
            }
869
870
            foreach ($schema->getTables() as $table) {
871
                /* @var $sequence Table */
872
                if ($table->hasPrimaryKey()) {
873
                    $columns = $table->getPrimaryKey()->getColumns();
874
                    if (count($columns) === 1) {
875
                        $checkSequence = $table->getName() . '_' . $columns[0] . '_seq';
876
                        if ($fullSchema->hasSequence($checkSequence)) {
877
                            $visitor->acceptSequence($fullSchema->getSequence($checkSequence));
878
                        }
879
                    }
880
                }
881
            }
882
        }
883
884 8
        return $visitor->getQueries();
885
    }
886
887
    /**
888
     * Updates the database schema of the given classes by comparing the ClassMetadata
889
     * instances to the current database schema that is inspected.
890
     *
891
     * @param ClassMetadata[] $classes
892
     * @param bool            $saveMode If TRUE, only performs a partial update
893
     *                                  without dropping assets which are scheduled for deletion.
894
     */
895
    public function updateSchema(array $classes, $saveMode = false)
896
    {
897
        $updateSchemaSql = $this->getUpdateSchemaSql($classes, $saveMode);
898
        $conn            = $this->em->getConnection();
899
900
        foreach ($updateSchemaSql as $sql) {
901
            $conn->executeQuery($sql);
902
        }
903
    }
904
905
    /**
906
     * Gets the sequence of SQL statements that need to be performed in order
907
     * to bring the given class mappings in-synch with the relational schema.
908
     *
909
     * @param ClassMetadata[] $classes  The classes to consider.
910
     * @param bool            $saveMode If TRUE, only generates SQL for a partial update
911
     *                                  that does not include SQL for dropping assets which are scheduled for deletion.
912
     *
913
     * @return string[] The sequence of SQL statements.
914
     */
915 1
    public function getUpdateSchemaSql(array $classes, $saveMode = false)
916
    {
917 1
        $sm = $this->em->getConnection()->getSchemaManager();
918
919 1
        $fromSchema = $sm->createSchema();
920 1
        $toSchema   = $this->getSchemaFromMetadata($classes);
921
922 1
        $comparator = new Comparator();
923 1
        $schemaDiff = $comparator->compare($fromSchema, $toSchema);
924
925 1
        if ($saveMode) {
926
            return $schemaDiff->toSaveSql($this->platform);
927
        }
928
929 1
        return $schemaDiff->toSql($this->platform);
930
    }
931
}
932