Completed
Push — master ( a0071b...e33605 )
by Michael
12s
created

DatabaseDriver::getFieldNameForColumn()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3.2098

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 3
dl 0
loc 14
ccs 5
cts 7
cp 0.7143
crap 3.2098
rs 9.4285
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\Util\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
18
/**
19
 * The DatabaseDriver reverse engineers the mapping metadata from a database.
20
 */
21
class DatabaseDriver implements MappingDriver
22
{
23
    /**
24
     * @var AbstractSchemaManager
25
     */
26
    private $sm;
27
28
    /**
29
     * @var Table[]|null
30
     */
31
    private $tables;
32
33
    /**
34
     * @var string[]
35
     */
36
    private $classToTableNames = [];
37
38
    /**
39
     * @var Table[]
40
     */
41
    private $manyToManyTables = [];
42
43
    /**
44
     * @var Table[]
45
     */
46
    private $classNamesForTables = [];
47
48
    /**
49
     * @var Table[]
50
     */
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)
79
    {
80
        return true;
81
    }
82
83
    /**
84
     * {@inheritDoc}
85
     */
86 2
    public function getAllClassNames()
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\ClassMetadata $metadata,
144
        Mapping\ClassMetadataBuildingContext $metadataBuildingContext
145
    ) {
146 2
        $this->reverseEngineerMappingFromDatabase();
147
148 2
        if (! isset($this->classToTableNames[$className])) {
149
            throw new \InvalidArgumentException('Unknown class ' . $className);
150
        }
151
152
        // @todo guilhermeblanco This should somehow disappear... =)
153 2
        $metadata->setClassName($className);
154
155 2
        $this->buildTable($metadata);
156 2
        $this->buildFieldMappings($metadata);
157 2
        $this->buildToOneAssociationMappings($metadata);
158
159 2
        $loweredTableName = strtolower($metadata->getTableName());
160
161 2
        foreach ($this->manyToManyTables as $manyTable) {
162 1
            foreach ($manyTable->getForeignKeys() as $foreignKey) {
163
                // foreign key maps to the table of the current entity, many to many association probably exists
164 1
                if (! ($loweredTableName === strtolower($foreignKey->getForeignTableName()))) {
165 1
                    continue;
166
                }
167
168 1
                $myFk    = $foreignKey;
169 1
                $otherFk = null;
170
171 1
                foreach ($manyTable->getForeignKeys() as $manyTableForeignKey) {
172 1
                    if ($manyTableForeignKey !== $myFk) {
173
                        $otherFk = $manyTableForeignKey;
174
175 1
                        break;
176
                    }
177
                }
178
179 1
                if (! $otherFk) {
180
                    // the definition of this many to many table does not contain
181
                    // enough foreign key information to continue reverse engineering.
182 1
                    continue;
183
                }
184
185
                $localColumn = current($myFk->getColumns());
186
187
                $associationMapping                 = [];
188
                $associationMapping['fieldName']    = $this->getFieldNameForColumn($manyTable->getName(), current($otherFk->getColumns()), true);
189
                $associationMapping['targetEntity'] = $this->getClassNameForTable($otherFk->getForeignTableName());
190
191
                if (current($manyTable->getColumns())->getName() === $localColumn) {
192
                    $associationMapping['inversedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
193
                    $associationMapping['joinTable']  = new Mapping\JoinTableMetadata();
194
195
                    $joinTable = $associationMapping['joinTable'];
196
                    $joinTable->setName(strtolower($manyTable->getName()));
197
198
                    $fkCols = $myFk->getForeignColumns();
199
                    $cols   = $myFk->getColumns();
200
201
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
202
                        $joinColumn = new Mapping\JoinColumnMetadata();
203
204
                        $joinColumn->setColumnName($cols[$i]);
205
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
206
207
                        $joinTable->addJoinColumn($joinColumn);
208
                    }
209
210
                    $fkCols = $otherFk->getForeignColumns();
211
                    $cols   = $otherFk->getColumns();
212
213
                    for ($i = 0, $l = count($cols); $i < $l; $i++) {
214
                        $joinColumn = new Mapping\JoinColumnMetadata();
215
216
                        $joinColumn->setColumnName($cols[$i]);
217
                        $joinColumn->setReferencedColumnName($fkCols[$i]);
218
219
                        $joinTable->addInverseJoinColumn($joinColumn);
220
                    }
221
                } else {
222
                    $associationMapping['mappedBy'] = $this->getFieldNameForColumn($manyTable->getName(), current($myFk->getColumns()), true);
223
                }
224
225
                $metadata->addProperty($associationMapping);
0 ignored issues
show
Bug introduced by
$associationMapping of type string[]|array<mixed,str...ping\JoinTableMetadata> 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

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

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

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

Loading history...
350 2
            $ids[0]->setValueGenerator(new Mapping\ValueGeneratorMetadata(Mapping\GeneratorType::AUTO));
351
        }
352 2
    }
353
354
    /**
355
     * Parse the given Column as FieldMetadata
356
     *
357
     * @return Mapping\FieldMetadata
358
     */
359 2
    private function convertColumnAnnotationToFieldMetadata(string $tableName, Column $column, string $fieldName)
360
    {
361 2
        $options       = [];
362 2
        $fieldMetadata = new Mapping\FieldMetadata($fieldName);
363
364 2
        $fieldMetadata->setType($column->getType());
365 2
        $fieldMetadata->setTableName($tableName);
366 2
        $fieldMetadata->setColumnName($column->getName());
367
368
        // Type specific elements
369 2
        switch ($column->getType()->getName()) {
370
            case Type::TARRAY:
371
            case Type::BLOB:
372
            case Type::GUID:
373
            case Type::JSON_ARRAY:
374
            case Type::OBJECT:
375
            case Type::SIMPLE_ARRAY:
376
            case Type::STRING:
377
            case Type::TEXT:
378 1
                if ($column->getLength()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $column->getLength() of type null|integer 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...
379
                    $fieldMetadata->setLength($column->getLength());
380
                }
381
382 1
                $options['fixed'] = $column->getFixed();
383 1
                break;
384
385
            case Type::DECIMAL:
386
            case Type::FLOAT:
387
                $fieldMetadata->setScale($column->getScale());
388
                $fieldMetadata->setPrecision($column->getPrecision());
389
                break;
390
391
            case Type::INTEGER:
392
            case Type::BIGINT:
393
            case Type::SMALLINT:
394 2
                $options['unsigned'] = $column->getUnsigned();
395 2
                break;
396
        }
397
398
        // Comment
399 2
        $comment = $column->getComment();
400 2
        if ($comment !== null) {
401
            $options['comment'] = $comment;
402
        }
403
404
        // Default
405 2
        $default = $column->getDefault();
406 2
        if ($default !== null) {
407
            $options['default'] = $default;
408
        }
409
410 2
        $fieldMetadata->setOptions($options);
411
412 2
        return $fieldMetadata;
413
    }
414
415
    /**
416
     * Build to one (one to one, many to one) association mapping from class metadata.
417
     */
418 2
    private function buildToOneAssociationMappings(Mapping\ClassMetadata $metadata)
419
    {
420 2
        $tableName   = $metadata->getTableName();
421 2
        $primaryKeys = $this->getTablePrimaryKeys($this->tables[$tableName]);
422 2
        $foreignKeys = $this->getTableForeignKeys($this->tables[$tableName]);
423
424 2
        foreach ($foreignKeys as $foreignKey) {
425
            $foreignTableName   = $foreignKey->getForeignTableName();
426
            $fkColumns          = $foreignKey->getColumns();
427
            $fkForeignColumns   = $foreignKey->getForeignColumns();
428
            $localColumn        = current($fkColumns);
429
            $associationMapping = [
430
                'fieldName'    => $this->getFieldNameForColumn($tableName, $localColumn, true),
431
                'targetEntity' => $this->getClassNameForTable($foreignTableName),
432
            ];
433
434
            if ($metadata->getProperty($associationMapping['fieldName'])) {
435
                $associationMapping['fieldName'] .= '2'; // "foo" => "foo2"
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
436
            }
437
438
            if ($primaryKeys && in_array($localColumn, $primaryKeys)) {
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...
439
                $associationMapping['id'] = true;
440
            }
441
442
            for ($i = 0, $l = count($fkColumns); $i < $l; $i++) {
443
                $joinColumn = new Mapping\JoinColumnMetadata();
444
445
                $joinColumn->setColumnName($fkColumns[$i]);
446
                $joinColumn->setReferencedColumnName($fkForeignColumns[$i]);
447
448
                $associationMapping['joinColumns'][] = $joinColumn;
449
            }
450
451
            // Here we need to check if $fkColumns are the same as $primaryKeys
452
            if (! array_diff($fkColumns, $primaryKeys)) {
453
                $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

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

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