Failed Conditions
Pull Request — master (#7867)
by
unknown
10:32
created

DatabaseDriver::loadMetadataForClass()   C

Complexity

Conditions 15
Paths 19

Size

Total Lines 100
Code Lines 55

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 78.0828

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 15
eloc 55
c 1
b 0
f 0
nc 19
nop 3
dl 0
loc 100
ccs 19
cts 55
cp 0.3454
crap 78.0828
rs 5.9166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ORM\Mapping\Driver;
6
7
use Doctrine\Common\Inflector\Inflector;
8
use Doctrine\DBAL\Schema\AbstractSchemaManager;
9
use Doctrine\DBAL\Schema\Column;
10
use Doctrine\DBAL\Schema\ForeignKeyConstraint;
11
use Doctrine\DBAL\Schema\Identifier;
12
use Doctrine\DBAL\Schema\Index;
13
use Doctrine\DBAL\Schema\SchemaException;
14
use Doctrine\DBAL\Schema\Table;
15
use Doctrine\DBAL\Types\Type;
16
use Doctrine\ORM\Mapping;
17
use Doctrine\ORM\Sequencing\Generator;
18
use InvalidArgumentException;
19
use function array_diff;
20
use function array_keys;
21
use function array_merge;
22
use function count;
23
use function current;
24
use function in_array;
25
use function reset;
26
use function sort;
27
use function str_replace;
28
use function strtolower;
29
30
/**
31
 * The DatabaseDriver reverse engineers the mapping metadata from a database.
32
 */
33
class DatabaseDriver implements MappingDriver
34
{
35
    /** @var AbstractSchemaManager */
36
    private $sm;
37
38
    /** @var Table[]|null */
39
    private $tables;
40
41
    /** @var string[] */
42
    private $classToTableNames = [];
43
44
    /** @var Table[] */
45
    private $manyToManyTables = [];
46
47
    /** @var Table[] */
48
    private $classNamesForTables = [];
49
50
    /** @var Table[][] */
51
    private $fieldNamesForColumns = [];
52
53
    /**
54
     * The namespace for the generated entities.
55
     *
56
     * @var string|null
57
     */
58
    private $namespace;
59
60 2
    public function __construct(AbstractSchemaManager $schemaManager)
61
    {
62 2
        $this->sm = $schemaManager;
63 2
    }
64
65
    /**
66
     * Set the namespace for the generated entities.
67
     *
68
     * @param string $namespace
69
     */
70
    public function setNamespace($namespace)
71
    {
72
        $this->namespace = $namespace;
73
    }
74
75
    /**
76
     * {@inheritDoc}
77
     */
78
    public function isTransient($className) : bool
79
    {
80
        return true;
81
    }
82
83
    /**
84
     * {@inheritDoc}
85
     */
86 2
    public function getAllClassNames() : array
87
    {
88 2
        $this->reverseEngineerMappingFromDatabase();
89
90 2
        return array_keys($this->classToTableNames);
91
    }
92
93
    /**
94
     * Sets class name for a table.
95
     *
96
     * @param string $tableName
97
     * @param string $className
98
     */
99
    public function setClassNameForTable($tableName, $className)
100
    {
101
        $this->classNamesForTables[$tableName] = $className;
102
    }
103
104
    /**
105
     * Sets field name for a column on a specific table.
106
     *
107
     * @param string $tableName
108
     * @param string $columnName
109
     * @param string $fieldName
110
     */
111
    public function setFieldNameForColumn($tableName, $columnName, $fieldName)
112
    {
113
        $this->fieldNamesForColumns[$tableName][$columnName] = $fieldName;
114
    }
115
116
    /**
117
     * Sets tables manually instead of relying on the reverse engineering capabilities of SchemaManager.
118
     *
119
     * @param Table[] $entityTables
120
     * @param Table[] $manyToManyTables
121
     */
122 2
    public function setTables($entityTables, $manyToManyTables)
123
    {
124 2
        $this->tables = $this->manyToManyTables = $this->classToTableNames = [];
125
126 2
        foreach ($entityTables as $table) {
127 2
            $className = $this->getClassNameForTable($table->getName());
128
129 2
            $this->classToTableNames[$className] = $table->getName();
130 2
            $this->tables[$table->getName()]     = $table;
131
        }
132
133 2
        foreach ($manyToManyTables as $table) {
134 1
            $this->manyToManyTables[$table->getName()] = $table;
135
        }
136 2
    }
137
138
    /**
139
     * {@inheritDoc}
140
     */
141 2
    public function loadMetadataForClass(
142
        string $className,
143
        ?Mapping\ComponentMetadata $parent,
144
        Mapping\ClassMetadataBuildingContext $metadataBuildingContext
145
    ) : Mapping\ComponentMetadata {
146 2
        $this->reverseEngineerMappingFromDatabase();
147
148 2
        if (! isset($this->classToTableNames[$className])) {
149
            throw new InvalidArgumentException('Unknown class ' . $className);
150
        }
151
152 2
        $metadata = new Mapping\ClassMetadata($className, $parent);
153
154 2
        $this->buildTable($metadata);
155 2
        $this->buildFieldMappings($metadata);
156 2
        $this->buildToOneAssociationMappings($metadata);
157
158 2
        $loweredTableName = strtolower($metadata->getTableName());
159
160 2
        foreach ($this->manyToManyTables as $manyTable) {
161 1
            foreach ($manyTable->getForeignKeys() as $foreignKey) {
162
                // foreign key maps to the table of the current entity, many to many association probably exists
163 1
                if ($loweredTableName !== strtolower($foreignKey->getForeignTableName())) {
164 1
                    continue;
165
                }
166
167 1
                $myFk    = $foreignKey;
168 1
                $otherFk = null;
169
170 1
                foreach ($manyTable->getForeignKeys() as $manyTableForeignKey) {
171 1
                    if ($manyTableForeignKey !== $myFk) {
172
                        $otherFk = $manyTableForeignKey;
173
174
                        break;
175
                    }
176
                }
177
178 1
                if (! $otherFk) {
179
                    // the definition of this many to many table does not contain
180
                    // enough foreign key information to continue reverse engineering.
181 1
                    continue;
182
                }
183
184
                $localColumn = current($myFk->getColumns());
185
186
                $associationMapping                 = [];
187
                $associationMapping['fieldName']    = $this->getFieldNameForColumn($manyTable->getName(), current($otherFk->getColumns()), true);
188
                $associationMapping['targetEntity'] = $this->getClassNameForTable($otherFk->getForeignTableName());
189
190
		if (isset($metadata->fieldMappings[$associationMapping['fieldName']])
0 ignored issues
show
Bug introduced by
The property fieldMappings does not seem to exist on Doctrine\ORM\Mapping\ClassMetadata.
Loading history...
191
                        || isset($metadata->associationMappings[$associationMapping['fieldName']])) {
0 ignored issues
show
Bug introduced by
The property associationMappings does not seem to exist on Doctrine\ORM\Mapping\ClassMetadata.
Loading history...
192
                        $ii=2;
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned correctly; expected 1 space but found 0 spaces
Loading history...
193
                        while ( isset($metadata->fieldMappings[$associationMapping['fieldName'].(string)$ii]) ||
0 ignored issues
show
Coding Style introduced by
Concat operator must be surrounded by a single space
Loading history...
Coding Style introduced by
Expected 1 space(s) after cast statement; 0 found
Loading history...
194
                                isset($metadata->associationMappings[$associationMapping['fieldName'].(string)$ii])) {
0 ignored issues
show
Coding Style introduced by
Concat operator must be surrounded by a single space
Loading history...
Coding Style introduced by
Expected 1 space(s) after cast statement; 0 found
Loading history...
195
                                $ii++;
196
                        }
197
                        $associationMapping['fieldName'] .= (string)$ii; 
0 ignored issues
show
Coding Style introduced by
Expected 1 space(s) after cast statement; 0 found
Loading history...
198
                }
0 ignored issues
show
Coding Style introduced by
Closing brace indented incorrectly; expected 8 spaces, found 16
Loading history...
199
200
                if (current($manyTable->getColumns())->getName() === $localColumn) {
201
                    $associationMapping['inversedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
202
                    $associationMapping['joinTable']  = new Mapping\JoinTableMetadata();
203
204
                    $joinTable = $associationMapping['joinTable'];
205
                    $joinTable->setName(strtolower($manyTable->getName()));
206
207
                    $fkCols = $myFk->getForeignColumns();
208
                    $cols   = $myFk->getColumns();
209
210
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
211
                        $joinColumn = new Mapping\JoinColumnMetadata();
212
213
                        $joinColumn->setColumnName($cols[$i]);
214
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
215
216
                        $joinTable->addJoinColumn($joinColumn);
217
                    }
218
219
                    $fkCols = $otherFk->getForeignColumns();
220
                    $cols   = $otherFk->getColumns();
221
222
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
223
                        $joinColumn = new Mapping\JoinColumnMetadata();
224
225
                        $joinColumn->setColumnName($cols[$i]);
226
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
227
228
                        $joinTable->addInverseJoinColumn($joinColumn);
229
                    }
230
                } else {
231
                    $associationMapping['mappedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
232
                }
233
234
                $metadata->addProperty($associationMapping);
0 ignored issues
show
Bug introduced by
$associationMapping of type array is incompatible with the type Doctrine\ORM\Mapping\Property expected by parameter $property of Doctrine\ORM\Mapping\ClassMetadata::addProperty(). ( Ignorable by Annotation )

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

234
                $metadata->addProperty(/** @scrutinizer ignore-type */ $associationMapping);
Loading history...
235
236
                break;
237
            }
238
        }
239
240 2
        return $metadata;
241
    }
242
243
    /**
244
     * @throws Mapping\MappingException
245
     */
246 2
    private function reverseEngineerMappingFromDatabase()
247
    {
248 2
        if ($this->tables !== null) {
249 2
            return;
250
        }
251
252
        $tables = [];
253
254
        foreach ($this->sm->listTableNames() as $tableName) {
255
            $tables[$tableName] = $this->sm->listTableDetails($tableName);
256
        }
257
258
        $this->tables = $this->manyToManyTables = $this->classToTableNames = [];
259
260
        foreach ($tables as $tableName => $table) {
261
            $foreignKeys = $this->sm->getDatabasePlatform()->supportsForeignKeyConstraints()
262
                ? $table->getForeignKeys()
263
                : [];
264
265
            $allForeignKeyColumns = [];
266
267
            foreach ($foreignKeys as $foreignKey) {
268
                $allForeignKeyColumns = array_merge($allForeignKeyColumns, $foreignKey->getLocalColumns());
269
            }
270
271
            if (! $table->hasPrimaryKey()) {
272
                throw new Mapping\MappingException(
273
                    'Table ' . $table->getName() . ' has no primary key. Doctrine does not ' .
274
                    "support reverse engineering from tables that don't have a primary key."
275
                );
276
            }
277
278
            $pkColumns = $table->getPrimaryKey()->getColumns();
279
280
            sort($pkColumns);
281
            sort($allForeignKeyColumns);
282
283
            if ($pkColumns === $allForeignKeyColumns && count($foreignKeys) === 2) {
284
                $this->manyToManyTables[$tableName] = $table;
285
            } else {
286
                // lower-casing is necessary because of Oracle Uppercase Tablenames,
287
                // assumption is lower-case + underscore separated.
288
                $className = $this->getClassNameForTable($tableName);
289
290
                $this->tables[$tableName]            = $table;
291
                $this->classToTableNames[$className] = $tableName;
292
            }
293
        }
294
    }
295
296
    /**
297
     * Build table from a class metadata.
298
     */
299 2
    private function buildTable(Mapping\ClassMetadata $metadata)
300
    {
301 2
        $tableName     = $this->classToTableNames[$metadata->getClassName()];
302 2
        $indexes       = $this->tables[$tableName]->getIndexes();
303 2
        $tableMetadata = new Mapping\TableMetadata();
304
305 2
        $tableMetadata->setName($this->classToTableNames[$metadata->getClassName()]);
306
307 2
        foreach ($indexes as $index) {
308
            /** @var Index $index */
309 2
            if ($index->isPrimary()) {
310 2
                continue;
311
            }
312
313 1
            $tableMetadata->addIndex([
314 1
                'name'    => $index->getName(),
315 1
                'columns' => $index->getColumns(),
316 1
                'unique'  => $index->isUnique(),
317 1
                'options' => $index->getOptions(),
318 1
                'flags'   => $index->getFlags(),
319
            ]);
320
        }
321
322 2
        $metadata->setTable($tableMetadata);
323 2
    }
324
325
    /**
326
     * Build field mapping from class metadata.
327
     */
328 2
    private function buildFieldMappings(Mapping\ClassMetadata $metadata)
329
    {
330 2
        $tableName      = $metadata->getTableName();
331 2
        $columns        = $this->tables[$tableName]->getColumns();
332 2
        $primaryKeys    = $this->getTablePrimaryKeys($this->tables[$tableName]);
333 2
        $foreignKeys    = $this->getTableForeignKeys($this->tables[$tableName]);
334 2
        $allForeignKeys = [];
335
336 2
        foreach ($foreignKeys as $foreignKey) {
337
            $allForeignKeys = array_merge($allForeignKeys, $foreignKey->getLocalColumns());
338
        }
339
340 2
        $ids = [];
341
342 2
        foreach ($columns as $column) {
343 2
            if (in_array($column->getName(), $allForeignKeys, true)) {
344
                continue;
345
            }
346
347 2
            $fieldName     = $this->getFieldNameForColumn($tableName, $column->getName(), false);
348 2
            $fieldMetadata = $this->convertColumnAnnotationToFieldMetadata($tableName, $column, $fieldName);
0 ignored issues
show
Bug introduced by
It seems like $tableName can also be of type null; however, parameter $tableName of Doctrine\ORM\Mapping\Dri...tationToFieldMetadata() 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

348
            $fieldMetadata = $this->convertColumnAnnotationToFieldMetadata(/** @scrutinizer ignore-type */ $tableName, $column, $fieldName);
Loading history...
349
350 2
            if ($primaryKeys && in_array($column->getName(), $primaryKeys, true)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $primaryKeys of type Doctrine\DBAL\Schema\Identifier[] 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...
351 2
                $fieldMetadata->setPrimaryKey(true);
352
353 2
                $ids[] = $fieldMetadata;
354
            }
355
356 2
            $metadata->addProperty($fieldMetadata);
357
        }
358
359
        // We need to check for the columns here, because we might have associations as id as well.
360 2
        if ($ids && count($primaryKeys) === 1) {
361 2
            $fieldMetadata = reset($ids);
362 2
            $generator     = $fieldMetadata->getTypeName() === 'bigint'
363
                ? new Generator\BigIntegerIdentityGenerator()
364 2
                : new Generator\IdentityGenerator();
365
366 2
            $valueGenerator = new Mapping\ValueGeneratorMetadata(Mapping\GeneratorType::IDENTITY, $generator);
367
368 2
            $ids[0]->setValueGenerator($valueGenerator);
369
        }
370 2
    }
371
372
    /**
373
     * Parse the given Column as FieldMetadata
374
     *
375
     * @return Mapping\FieldMetadata
376
     */
377 2
    private function convertColumnAnnotationToFieldMetadata(string $tableName, Column $column, string $fieldName)
378
    {
379 2
        $options       = [];
380 2
        $fieldMetadata = new Mapping\FieldMetadata($fieldName);
381
382 2
        $fieldMetadata->setType($column->getType());
383 2
        $fieldMetadata->setTableName($tableName);
384 2
        $fieldMetadata->setColumnName($column->getName());
385
386
        // Type specific elements
387 2
        switch ($column->getType()->getName()) {
388
            case Type::TARRAY:
389
            case Type::BLOB:
390
            case Type::GUID:
391
            case Type::JSON_ARRAY:
392
            case Type::OBJECT:
393
            case Type::SIMPLE_ARRAY:
394
            case Type::STRING:
395
            case Type::TEXT:
396 1
                if ($column->getLength()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $column->getLength() of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. 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 integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
397
                    $fieldMetadata->setLength($column->getLength());
398
                }
399
400 1
                $options['fixed'] = $column->getFixed();
401 1
                break;
402
403
            case Type::DECIMAL:
404
            case Type::FLOAT:
405
                $fieldMetadata->setScale($column->getScale());
406
                $fieldMetadata->setPrecision($column->getPrecision());
407
                break;
408
409
            case Type::INTEGER:
410
            case Type::BIGINT:
411
            case Type::SMALLINT:
412 2
                $options['unsigned'] = $column->getUnsigned();
413 2
                break;
414
        }
415
416
        // Comment
417 2
        $comment = $column->getComment();
418 2
        if ($comment !== null) {
419
            $options['comment'] = $comment;
420
        }
421
422
        // Default
423 2
        $default = $column->getDefault();
424 2
        if ($default !== null) {
425
            $options['default'] = $default;
426
        }
427
428 2
        $fieldMetadata->setOptions($options);
429
430 2
        return $fieldMetadata;
431
    }
432
433
    /**
434
     * Build to one (one to one, many to one) association mapping from class metadata.
435
     */
436 2
    private function buildToOneAssociationMappings(Mapping\ClassMetadata $metadata)
437
    {
438 2
        $tableName   = $metadata->getTableName();
439 2
        $primaryKeys = $this->getTablePrimaryKeys($this->tables[$tableName]);
440 2
        $foreignKeys = $this->getTableForeignKeys($this->tables[$tableName]);
441
442 2
        foreach ($foreignKeys as $foreignKey) {
443
            $foreignTableName   = $foreignKey->getForeignTableName();
444
            $fkColumns          = $foreignKey->getColumns();
445
            $fkForeignColumns   = $foreignKey->getForeignColumns();
446
            $localColumn        = current($fkColumns);
447
            $associationMapping = [
448
                'fieldName'    => $this->getFieldNameForColumn($tableName, $localColumn, true),
449
                'targetEntity' => $this->getClassNameForTable($foreignTableName),
450
            ];
451
452
            if ($metadata->getProperty($associationMapping['fieldName'])) {
453
                $associationMapping['fieldName'] .= '2'; // "foo" => "foo2"
454
            }
455
456
            if ($primaryKeys && in_array($localColumn, $primaryKeys, true)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $primaryKeys of type Doctrine\DBAL\Schema\Identifier[] 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...
457
                $associationMapping['id'] = true;
458
            }
459
460
            for ($i = 0, $l = count($fkColumns); $i < $l; $i++) {
461
                $joinColumn = new Mapping\JoinColumnMetadata();
462
463
                $joinColumn->setColumnName($fkColumns[$i]);
464
                $joinColumn->setReferencedColumnName($fkForeignColumns[$i]);
465
466
                $associationMapping['joinColumns'][] = $joinColumn;
467
            }
468
469
            // Here we need to check if $fkColumns are the same as $primaryKeys
470
            if (! array_diff($fkColumns, $primaryKeys)) {
471
                $metadata->addProperty($associationMapping);
0 ignored issues
show
Bug introduced by
$associationMapping of type array<string,string> is incompatible with the type Doctrine\ORM\Mapping\Property expected by parameter $property of Doctrine\ORM\Mapping\ClassMetadata::addProperty(). ( Ignorable by Annotation )

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

471
                $metadata->addProperty(/** @scrutinizer ignore-type */ $associationMapping);
Loading history...
472
            } else {
473
                $metadata->addProperty($associationMapping);
474
            }
475
        }
476 2
    }
477
478
    /**
479
     * Retrieve schema table definition foreign keys.
480
     *
481
     * @return ForeignKeyConstraint[]
482
     */
483 2
    private function getTableForeignKeys(Table $table)
484
    {
485 2
        return $this->sm->getDatabasePlatform()->supportsForeignKeyConstraints()
486
            ? $table->getForeignKeys()
487 2
            : [];
488
    }
489
490
    /**
491
     * Retrieve schema table definition primary keys.
492
     *
493
     * @return Identifier[]
494
     */
495 2
    private function getTablePrimaryKeys(Table $table)
496
    {
497
        try {
498 2
            return $table->getPrimaryKey()->getColumns();
499
        } catch (SchemaException $e) {
500
            // Do nothing
501
        }
502
503
        return [];
504
    }
505
506
    /**
507
     * Returns the mapped class name for a table if it exists. Otherwise return "classified" version.
508
     *
509
     * @param string $tableName
510
     *
511
     * @return string
512
     */
513 2
    private function getClassNameForTable($tableName)
514
    {
515 2
        return $this->namespace . (
516 2
            $this->classNamesForTables[$tableName]
0 ignored issues
show
Bug introduced by
Are you sure $this->classNamesForTabl...strtolower($tableName)) of type Doctrine\DBAL\Schema\Table|string can be used in concatenation? ( Ignorable by Annotation )

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

516
            /** @scrutinizer ignore-type */ $this->classNamesForTables[$tableName]
Loading history...
517 2
                ?? Inflector::classify(strtolower($tableName))
518
        );
519
    }
520
521
    /**
522
     * Return the mapped field name for a column, if it exists. Otherwise return camelized version.
523
     *
524
     * @param string $tableName
525
     * @param string $columnName
526
     * @param bool   $fk         Whether the column is a foreignkey or not.
527
     *
528
     * @return string
529
     */
530 2
    private function getFieldNameForColumn($tableName, $columnName, $fk = false)
531
    {
532 2
        if (isset($this->fieldNamesForColumns[$tableName], $this->fieldNamesForColumns[$tableName][$columnName])) {
533
            return $this->fieldNamesForColumns[$tableName][$columnName];
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->fieldNames...tableName][$columnName] returns the type Doctrine\DBAL\Schema\Table which is incompatible with the documented return type string.
Loading history...
534
        }
535
536 2
        $columnName = strtolower($columnName);
537
538
        // Replace _id if it is a foreignkey column
539 2
        if ($fk) {
540
            $columnName = str_replace('_id', '', $columnName);
541
        }
542
543 2
        return Inflector::camelize($columnName);
544
    }
545
}
546