Failed Conditions
Pull Request — master (#6735)
by Matthias
09:54
created

DatabaseDriver::buildToOneAssociationMappings()   B

Complexity

Conditions 7
Paths 17

Size

Total Lines 38
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 30.4863

Importance

Changes 0
Metric Value
cc 7
eloc 24
nc 17
nop 1
dl 0
loc 38
ccs 5
cts 23
cp 0.2174
crap 30.4863
rs 8.6026
c 0
b 0
f 0
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 InvalidArgumentException;
18
use function array_diff;
19
use function array_keys;
20
use function array_merge;
21
use function count;
22
use function current;
23
use function in_array;
24
use function sort;
25
use function str_replace;
26
use function strtolower;
27
28
/**
29
 * The DatabaseDriver reverse engineers the mapping metadata from a database.
30
 */
31
class DatabaseDriver implements MappingDriver
32
{
33
    /** @var AbstractSchemaManager */
34
    private $sm;
35
36
    /** @var Table[]|null */
37
    private $tables;
38
39
    /** @var string[] */
40
    private $classToTableNames = [];
41
42
    /** @var Table[] */
43
    private $manyToManyTables = [];
44
45
    /** @var Table[] */
46
    private $classNamesForTables = [];
47
48
    /** @var Table[][] */
49
    private $fieldNamesForColumns = [];
50
51
    /**
52
     * The namespace for the generated entities.
53
     *
54
     * @var string|null
55
     */
56
    private $namespace;
57
58 2
    public function __construct(AbstractSchemaManager $schemaManager)
59
    {
60 2
        $this->sm = $schemaManager;
61 2
    }
62
63
    /**
64
     * Set the namespace for the generated entities.
65
     *
66
     * @param string $namespace
67
     */
68
    public function setNamespace($namespace)
69
    {
70
        $this->namespace = $namespace;
71
    }
72
73
    /**
74
     * {@inheritDoc}
75
     */
76
    public function isTransient($className)
77
    {
78
        return true;
79
    }
80
81
    /**
82
     * {@inheritDoc}
83
     */
84 2
    public function getAllClassNames()
85
    {
86 2
        $this->reverseEngineerMappingFromDatabase();
87
88 2
        return array_keys($this->classToTableNames);
89
    }
90
91
    /**
92
     * Sets class name for a table.
93
     *
94
     * @param string $tableName
95
     * @param string $className
96
     */
97
    public function setClassNameForTable($tableName, $className)
98
    {
99
        $this->classNamesForTables[$tableName] = $className;
100
    }
101
102
    /**
103
     * Sets field name for a column on a specific table.
104
     *
105
     * @param string $tableName
106
     * @param string $columnName
107
     * @param string $fieldName
108
     */
109
    public function setFieldNameForColumn($tableName, $columnName, $fieldName)
110
    {
111
        $this->fieldNamesForColumns[$tableName][$columnName] = $fieldName;
112
    }
113
114
    /**
115
     * Sets tables manually instead of relying on the reverse engineering capabilities of SchemaManager.
116
     *
117
     * @param Table[] $entityTables
118
     * @param Table[] $manyToManyTables
119
     */
120 2
    public function setTables($entityTables, $manyToManyTables)
121
    {
122 2
        $this->tables = $this->manyToManyTables = $this->classToTableNames = [];
123
124 2
        foreach ($entityTables as $table) {
125 2
            $className = $this->getClassNameForTable($table->getName());
126
127 2
            $this->classToTableNames[$className] = $table->getName();
128 2
            $this->tables[$table->getName()]     = $table;
129
        }
130
131 2
        foreach ($manyToManyTables as $table) {
132 1
            $this->manyToManyTables[$table->getName()] = $table;
133
        }
134 2
    }
135
136
    /**
137
     * {@inheritDoc}
138
     */
139 2
    public function loadMetadataForClass(
140
        string $className,
141
        Mapping\ClassMetadata $metadata,
142
        Mapping\ClassMetadataBuildingContext $metadataBuildingContext
143
    ) {
144 2
        $this->reverseEngineerMappingFromDatabase();
145
146 2
        if (! isset($this->classToTableNames[$className])) {
147
            throw new InvalidArgumentException('Unknown class ' . $className);
148
        }
149
150
        // @todo guilhermeblanco This should somehow disappear... =)
151 2
        $metadata->setClassName($className);
152
153 2
        $this->buildTable($metadata);
154 2
        $this->buildFieldMappings($metadata);
155 2
        $this->buildToOneAssociationMappings($metadata);
156
157 2
        $loweredTableName = strtolower($metadata->getTableName());
158
159 2
        foreach ($this->manyToManyTables as $manyTable) {
160 1
            foreach ($manyTable->getForeignKeys() as $foreignKey) {
161
                // foreign key maps to the table of the current entity, many to many association probably exists
162 1
                if ($loweredTableName !== strtolower($foreignKey->getForeignTableName())) {
163 1
                    continue;
164
                }
165
166 1
                $myFk    = $foreignKey;
167 1
                $otherFk = null;
168
169 1
                foreach ($manyTable->getForeignKeys() as $manyTableForeignKey) {
170 1
                    if ($manyTableForeignKey !== $myFk) {
171
                        $otherFk = $manyTableForeignKey;
172
173
                        break;
174
                    }
175
                }
176
177 1
                if (! $otherFk) {
178
                    // the definition of this many to many table does not contain
179
                    // enough foreign key information to continue reverse engineering.
180 1
                    continue;
181
                }
182
183
                $localColumn = current($myFk->getColumns());
184
185
                $associationMapping                 = [];
186
                $associationMapping['fieldName']    = $this->getFieldNameForColumn($manyTable->getName(), current($otherFk->getColumns()), true);
187
                $associationMapping['targetEntity'] = $this->getClassNameForTable($otherFk->getForeignTableName());
188
189
                if (current($manyTable->getColumns())->getName() === $localColumn) {
190
                    $associationMapping['inversedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
191
                    $associationMapping['joinTable']  = new Mapping\JoinTableMetadata();
192
193
                    $joinTable = $associationMapping['joinTable'];
194
                    $joinTable->setName(strtolower($manyTable->getName()));
195
196
                    $fkCols = $myFk->getForeignColumns();
197
                    $cols   = $myFk->getColumns();
198
199
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
200
                        $joinColumn = new Mapping\JoinColumnMetadata();
201
202
                        $joinColumn->setColumnName($cols[$i]);
203
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
204
205
                        $joinTable->addJoinColumn($joinColumn);
206
                    }
207
208
                    $fkCols = $otherFk->getForeignColumns();
209
                    $cols   = $otherFk->getColumns();
210
211
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
212
                        $joinColumn = new Mapping\JoinColumnMetadata();
213
214
                        $joinColumn->setColumnName($cols[$i]);
215
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
216
217
                        $joinTable->addInverseJoinColumn($joinColumn);
218
                    }
219
                } else {
220
                    $associationMapping['mappedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
221
                }
222
223
                $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

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

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

451
                $metadata->addProperty(/** @scrutinizer ignore-type */ $associationMapping);
Loading history...
452
            } else {
453
                $metadata->addProperty($associationMapping);
454
            }
455
        }
456 2
    }
457
458
    /**
459
     * Retrieve schema table definition foreign keys.
460
     *
461
     * @return ForeignKeyConstraint[]
462
     */
463 2
    private function getTableForeignKeys(Table $table)
464
    {
465 2
        return $this->sm->getDatabasePlatform()->supportsForeignKeyConstraints()
466
            ? $table->getForeignKeys()
467 2
            : [];
468
    }
469
470
    /**
471
     * Retrieve schema table definition primary keys.
472
     *
473
     * @return Identifier[]
474
     */
475 2
    private function getTablePrimaryKeys(Table $table)
476
    {
477
        try {
478 2
            return $table->getPrimaryKey()->getColumns();
479
        } catch (SchemaException $e) {
480
            // Do nothing
481
        }
482
483
        return [];
484
    }
485
486
    /**
487
     * Returns the mapped class name for a table if it exists. Otherwise return "classified" version.
488
     *
489
     * @param string $tableName
490
     *
491
     * @return string
492
     */
493 2
    private function getClassNameForTable($tableName)
494
    {
495 2
        return $this->namespace . (
496 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

496
            /** @scrutinizer ignore-type */ $this->classNamesForTables[$tableName]
Loading history...
497 2
                ?? Inflector::classify(strtolower($tableName))
498
        );
499
    }
500
501
    /**
502
     * Return the mapped field name for a column, if it exists. Otherwise return camelized version.
503
     *
504
     * @param string $tableName
505
     * @param string $columnName
506
     * @param bool   $fk         Whether the column is a foreignkey or not.
507
     *
508
     * @return string
509
     */
510 2
    private function getFieldNameForColumn($tableName, $columnName, $fk = false)
511
    {
512 2
        if (isset($this->fieldNamesForColumns[$tableName], $this->fieldNamesForColumns[$tableName][$columnName])) {
513
            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...
514
        }
515
516 2
        $columnName = strtolower($columnName);
517
518
        // Replace _id if it is a foreignkey column
519 2
        if ($fk) {
520
            $columnName = str_replace('_id', '', $columnName);
521
        }
522
523 2
        return Inflector::camelize($columnName);
524
    }
525
}
526