reverseEngineerMappingFromDatabase()   B
last analyzed

Complexity

Conditions 9
Paths 27

Size

Total Lines 46
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 65.0693

Importance

Changes 0
Metric Value
cc 9
eloc 25
nc 27
nop 0
dl 0
loc 46
ccs 3
cts 26
cp 0.1154
crap 65.0693
rs 8.0555
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\Types;
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 (current($manyTable->getColumns())->getName() === $localColumn) {
191
                    $associationMapping['inversedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
192
                    $associationMapping['joinTable']  = new Mapping\JoinTableMetadata();
193
194
                    $joinTable = $associationMapping['joinTable'];
195
                    $joinTable->setName(strtolower($manyTable->getName()));
196
197
                    $fkCols = $myFk->getForeignColumns();
198
                    $cols   = $myFk->getColumns();
199
200
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
201
                        $joinColumn = new Mapping\JoinColumnMetadata();
202
203
                        $joinColumn->setColumnName($cols[$i]);
204
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
205
206
                        $joinTable->addJoinColumn($joinColumn);
207
                    }
208
209
                    $fkCols = $otherFk->getForeignColumns();
210
                    $cols   = $otherFk->getColumns();
211
212
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
213
                        $joinColumn = new Mapping\JoinColumnMetadata();
214
215
                        $joinColumn->setColumnName($cols[$i]);
216
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
217
218
                        $joinTable->addInverseJoinColumn($joinColumn);
219
                    }
220
                } else {
221
                    $associationMapping['mappedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
222
                }
223
224
                $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

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

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

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

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