Failed Conditions
Pull Request — master (#7867)
by
unknown
09:27
created

DatabaseDriver   F

Complexity

Total Complexity 77

Size/Duplication

Total Lines 509
Duplicated Lines 0 %

Test Coverage

Coverage 53.02%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 212
c 3
b 0
f 0
dl 0
loc 509
ccs 114
cts 215
cp 0.5302
rs 2.24
wmc 77

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
A isTransient() 0 3 1
A setNamespace() 0 3 1
A setFieldNameForColumn() 0 3 1
A setClassNameForTable() 0 3 1
A getAllClassNames() 0 5 1
A setTables() 0 13 3
B buildFieldMappings() 0 41 9
C convertColumnAnnotationToFieldMetadata() 0 54 17
A getFieldNameForColumn() 0 14 3
A getTableForeignKeys() 0 5 2
A getTablePrimaryKeys() 0 9 2
B reverseEngineerMappingFromDatabase() 0 46 9
A buildTable() 0 24 3
A getClassNameForTable() 0 5 1
B buildToOneAssociationMappings() 0 38 7
C loadMetadataForClass() 0 98 15

How to fix   Complexity   

Complex Class

Complex classes like DatabaseDriver often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DatabaseDriver, and based on these observations, apply Extract Interface, too.

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']]) || 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...
Bug introduced by
The property fieldMappings does not seem to exist on Doctrine\ORM\Mapping\ClassMetadata.
Loading history...
191
                        $ii =2;
192
                    while (isset($metadata->fieldMappings[$associationMapping['fieldName'] . (string) $ii]) || isset($metadata->associationMappings[$associationMapping['fieldName'] . (string) $ii])) {
193
                            $ii++;
194
                    }
195
                        $associationMapping['fieldName'] .= (string) $ii;
196
                }
197
198
                if (current($manyTable->getColumns())->getName() === $localColumn) {
199
                    $associationMapping['inversedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
200
                    $associationMapping['joinTable']  = new Mapping\JoinTableMetadata();
201
202
                    $joinTable = $associationMapping['joinTable'];
203
                    $joinTable->setName(strtolower($manyTable->getName()));
204
205
                    $fkCols = $myFk->getForeignColumns();
206
                    $cols   = $myFk->getColumns();
207
208
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
209
                        $joinColumn = new Mapping\JoinColumnMetadata();
210
211
                        $joinColumn->setColumnName($cols[$i]);
212
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
213
214
                        $joinTable->addJoinColumn($joinColumn);
215
                    }
216
217
                    $fkCols = $otherFk->getForeignColumns();
218
                    $cols   = $otherFk->getColumns();
219
220
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
221
                        $joinColumn = new Mapping\JoinColumnMetadata();
222
223
                        $joinColumn->setColumnName($cols[$i]);
224
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
225
226
                        $joinTable->addInverseJoinColumn($joinColumn);
227
                    }
228
                } else {
229
                    $associationMapping['mappedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
230
                }
231
232
                $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

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

346
            $fieldMetadata = $this->convertColumnAnnotationToFieldMetadata(/** @scrutinizer ignore-type */ $tableName, $column, $fieldName);
Loading history...
347
348 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...
349 2
                $fieldMetadata->setPrimaryKey(true);
350
351 2
                $ids[] = $fieldMetadata;
352
            }
353
354 2
            $metadata->addProperty($fieldMetadata);
355
        }
356
357
        // We need to check for the columns here, because we might have associations as id as well.
358 2
        if ($ids && count($primaryKeys) === 1) {
359 2
            $fieldMetadata = reset($ids);
360 2
            $generator     = $fieldMetadata->getTypeName() === 'bigint'
361
                ? new Generator\BigIntegerIdentityGenerator()
362 2
                : new Generator\IdentityGenerator();
363
364 2
            $valueGenerator = new Mapping\ValueGeneratorMetadata(Mapping\GeneratorType::IDENTITY, $generator);
365
366 2
            $ids[0]->setValueGenerator($valueGenerator);
367
        }
368 2
    }
369
370
    /**
371
     * Parse the given Column as FieldMetadata
372
     *
373
     * @return Mapping\FieldMetadata
374
     */
375 2
    private function convertColumnAnnotationToFieldMetadata(string $tableName, Column $column, string $fieldName)
376
    {
377 2
        $options       = [];
378 2
        $fieldMetadata = new Mapping\FieldMetadata($fieldName);
379
380 2
        $fieldMetadata->setType($column->getType());
381 2
        $fieldMetadata->setTableName($tableName);
382 2
        $fieldMetadata->setColumnName($column->getName());
383
384
        // Type specific elements
385 2
        switch ($column->getType()->getName()) {
386
            case Type::TARRAY:
387
            case Type::BLOB:
388
            case Type::GUID:
389
            case Type::JSON_ARRAY:
390
            case Type::OBJECT:
391
            case Type::SIMPLE_ARRAY:
392
            case Type::STRING:
393
            case Type::TEXT:
394 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...
395
                    $fieldMetadata->setLength($column->getLength());
396
                }
397
398 1
                $options['fixed'] = $column->getFixed();
399 1
                break;
400
401
            case Type::DECIMAL:
402
            case Type::FLOAT:
403
                $fieldMetadata->setScale($column->getScale());
404
                $fieldMetadata->setPrecision($column->getPrecision());
405
                break;
406
407
            case Type::INTEGER:
408
            case Type::BIGINT:
409
            case Type::SMALLINT:
410 2
                $options['unsigned'] = $column->getUnsigned();
411 2
                break;
412
        }
413
414
        // Comment
415 2
        $comment = $column->getComment();
416 2
        if ($comment !== null) {
417
            $options['comment'] = $comment;
418
        }
419
420
        // Default
421 2
        $default = $column->getDefault();
422 2
        if ($default !== null) {
423
            $options['default'] = $default;
424
        }
425
426 2
        $fieldMetadata->setOptions($options);
427
428 2
        return $fieldMetadata;
429
    }
430
431
    /**
432
     * Build to one (one to one, many to one) association mapping from class metadata.
433
     */
434 2
    private function buildToOneAssociationMappings(Mapping\ClassMetadata $metadata)
435
    {
436 2
        $tableName   = $metadata->getTableName();
437 2
        $primaryKeys = $this->getTablePrimaryKeys($this->tables[$tableName]);
438 2
        $foreignKeys = $this->getTableForeignKeys($this->tables[$tableName]);
439
440 2
        foreach ($foreignKeys as $foreignKey) {
441
            $foreignTableName   = $foreignKey->getForeignTableName();
442
            $fkColumns          = $foreignKey->getColumns();
443
            $fkForeignColumns   = $foreignKey->getForeignColumns();
444
            $localColumn        = current($fkColumns);
445
            $associationMapping = [
446
                'fieldName'    => $this->getFieldNameForColumn($tableName, $localColumn, true),
447
                'targetEntity' => $this->getClassNameForTable($foreignTableName),
448
            ];
449
450
            if ($metadata->getProperty($associationMapping['fieldName'])) {
451
                $associationMapping['fieldName'] .= '2'; // "foo" => "foo2"
452
            }
453
454
            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...
455
                $associationMapping['id'] = true;
456
            }
457
458
            for ($i = 0, $l = count($fkColumns); $i < $l; $i++) {
459
                $joinColumn = new Mapping\JoinColumnMetadata();
460
461
                $joinColumn->setColumnName($fkColumns[$i]);
462
                $joinColumn->setReferencedColumnName($fkForeignColumns[$i]);
463
464
                $associationMapping['joinColumns'][] = $joinColumn;
465
            }
466
467
            // Here we need to check if $fkColumns are the same as $primaryKeys
468
            if (! array_diff($fkColumns, $primaryKeys)) {
469
                $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

469
                $metadata->addProperty(/** @scrutinizer ignore-type */ $associationMapping);
Loading history...
470
            } else {
471
                $metadata->addProperty($associationMapping);
472
            }
473
        }
474 2
    }
475
476
    /**
477
     * Retrieve schema table definition foreign keys.
478
     *
479
     * @return ForeignKeyConstraint[]
480
     */
481 2
    private function getTableForeignKeys(Table $table)
482
    {
483 2
        return $this->sm->getDatabasePlatform()->supportsForeignKeyConstraints()
484
            ? $table->getForeignKeys()
485 2
            : [];
486
    }
487
488
    /**
489
     * Retrieve schema table definition primary keys.
490
     *
491
     * @return Identifier[]
492
     */
493 2
    private function getTablePrimaryKeys(Table $table)
494
    {
495
        try {
496 2
            return $table->getPrimaryKey()->getColumns();
497
        } catch (SchemaException $e) {
498
            // Do nothing
499
        }
500
501
        return [];
502
    }
503
504
    /**
505
     * Returns the mapped class name for a table if it exists. Otherwise return "classified" version.
506
     *
507
     * @param string $tableName
508
     *
509
     * @return string
510
     */
511 2
    private function getClassNameForTable($tableName)
512
    {
513 2
        return $this->namespace . (
514 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

514
            /** @scrutinizer ignore-type */ $this->classNamesForTables[$tableName]
Loading history...
515 2
                ?? Inflector::classify(strtolower($tableName))
516
        );
517
    }
518
519
    /**
520
     * Return the mapped field name for a column, if it exists. Otherwise return camelized version.
521
     *
522
     * @param string $tableName
523
     * @param string $columnName
524
     * @param bool   $fk         Whether the column is a foreignkey or not.
525
     *
526
     * @return string
527
     */
528 2
    private function getFieldNameForColumn($tableName, $columnName, $fk = false)
529
    {
530 2
        if (isset($this->fieldNamesForColumns[$tableName], $this->fieldNamesForColumns[$tableName][$columnName])) {
531
            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...
532
        }
533
534 2
        $columnName = strtolower($columnName);
535
536
        // Replace _id if it is a foreignkey column
537 2
        if ($fk) {
538
            $columnName = str_replace('_id', '', $columnName);
539
        }
540
541 2
        return Inflector::camelize($columnName);
542
    }
543
}
544