Failed Conditions
Pull Request — master (#7506)
by
unknown
09:28
created

SchemaValidator::validateColumns()   B

Complexity

Conditions 7
Paths 2

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 18.1823

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 2
nop 1
dl 0
loc 30
ccs 7
cts 18
cp 0.3889
crap 18.1823
rs 8.8333
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ORM\Tools;
6
7
use Doctrine\DBAL\Schema\Column;
8
use Doctrine\ORM\EntityManagerInterface;
9
use Doctrine\ORM\Mapping\AssociationMetadata;
10
use Doctrine\ORM\Mapping\ClassMetadata;
11
use Doctrine\ORM\Mapping\FieldMetadata;
12
use Doctrine\ORM\Mapping\InheritanceType;
13
use Doctrine\ORM\Mapping\JoinColumnMetadata;
14
use Doctrine\ORM\Mapping\ManyToManyAssociationMetadata;
15
use Doctrine\ORM\Mapping\ManyToOneAssociationMetadata;
16
use Doctrine\ORM\Mapping\OneToManyAssociationMetadata;
17
use Doctrine\ORM\Mapping\OneToOneAssociationMetadata;
18
use Doctrine\ORM\Mapping\ToManyAssociationMetadata;
19
use Doctrine\ORM\Mapping\ToOneAssociationMetadata;
20
use function array_diff;
21
use function array_filter;
22
use function array_keys;
23
use function array_map;
24
use function array_merge;
25
use function class_exists;
26
use function class_parents;
27
use function count;
28
use function implode;
29
use function in_array;
30
use function sprintf;
31
32
/**
33
 * Performs strict validation of the mapping schema
34
 */
35
class SchemaValidator
36
{
37
    /** @var EntityManagerInterface */
38
    private $em;
39
40 43
    public function __construct(EntityManagerInterface $em)
41
    {
42 43
        $this->em = $em;
43 43
    }
44
45
    /**
46
     * Checks the internal consistency of all mapping files.
47
     *
48
     * There are several checks that can't be done at runtime or are too expensive, which can be verified
49
     * with this command. For example:
50
     *
51
     * 1. Check if a relation with "mappedBy" is actually connected to that specified field.
52
     * 2. Check if "mappedBy" and "inversedBy" are consistent to each other.
53
     * 3. Check if "referencedColumnName" attributes are really pointing to primary key columns.
54
     *
55
     * @return string[]
56
     */
57
    public function validateMapping()
58
    {
59
        $errors  = [];
60
        $cmf     = $this->em->getMetadataFactory();
61
        $classes = $cmf->getAllMetadata();
62
63
        foreach ($classes as $class) {
64
            $ce = $this->validateClass($class);
65
66
            if ($ce) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ce of type string[] 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...
67
                $errors[$class->getClassName()] = $ce;
0 ignored issues
show
Bug introduced by
The method getClassName() does not exist on Doctrine\Common\Persistence\Mapping\ClassMetadata. ( Ignorable by Annotation )

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

67
                $errors[$class->/** @scrutinizer ignore-call */ getClassName()] = $ce;

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
68
            }
69
        }
70
71
        return $errors;
72
    }
73
74
    /**
75
     * Validates a single class of the current.
76
     *
77
     * @return string[]
78
     */
79 43
    public function validateClass(ClassMetadata $class)
80
    {
81 43
        $ce = [];
82
83 43
        foreach ($class->getDeclaredPropertiesIterator() as $fieldName => $association) {
84 43
            if (! ($association instanceof AssociationMetadata)) {
85 42
                continue;
86
            }
87
88 40
            $ce = array_merge($ce, $this->validateAssociation($class, $association));
89
        }
90
91 43
        foreach ($class->getSubClasses() as $subClass) {
92 6
            if (! in_array($class->getClassName(), class_parents($subClass), true)) {
93
                $message = "According to the discriminator map class, '%s' has to be a child of '%s', but these entities are not related through inheritance.";
94
95 6
                $ce[] = sprintf($message, $subClass, $class->getClassName());
96
            }
97
        }
98
99
        // Validate column definitions
100 43
        $ce = array_merge($ce, $this->validateColumns($class));
101
102 43
        return $ce;
103
    }
104
105
    /**
106
     * Validates the columns of the given class
107
     *
108
     * @return string[] An array of error messages
109
     */
110 43
    private function validateColumns(ClassMetadata $class) : array
111
    {
112 43
        $messages = [];
113 43
        if ($class->inheritanceType === InheritanceType::SINGLE_TABLE) {
114
            /** @var Column[] $columns */
115 4
            $columns = [];
116
117
            /** @var Column $column */
118 4
            foreach ($class->getColumnsIterator() as $column) {
119 4
                $columns[$column->getName()] = $column;
120
            }
121
122
            foreach ($class->getSubClasses() as $subClassName) {
123
                $subClassMetadata = $this->em->getClassMetadata($subClassName);
124
125
                foreach ($subClassMetadata->getColumnsIterator() as $column) {
0 ignored issues
show
Bug introduced by
The method getColumnsIterator() does not exist on Doctrine\Common\Persistence\Mapping\ClassMetadata. ( Ignorable by Annotation )

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

125
                foreach ($subClassMetadata->/** @scrutinizer ignore-call */ getColumnsIterator() as $column) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
126
                    if (isset($columns[$column->getName()]) && $columns[$column->getName()]->getType() !== $column->getType()) {
127
                        $message    = "The column '%s' defined in class %s with type %s was already defined with type %s";
128
                        $messages[] = sprintf(
129
                            $message,
130
                            $column->getName(),
131
                            $subClassName->name,
0 ignored issues
show
Bug introduced by
The property name does not exist on string.
Loading history...
132
                            $column->getType()->getName(),
133
                            $column[$column->getName()]->getType()->getName()
134
                        );
135
                    }
136
                }
137
            }
138
        }
139 43
        return $messages;
140
    }
141
142
    /**
143
     * @return string[]
144
     */
145 40
    private function validateAssociation(ClassMetadata $class, AssociationMetadata $association)
146
    {
147 40
        $metadataFactory = $this->em->getMetadataFactory();
148 40
        $fieldName       = $association->getName();
149 40
        $targetEntity    = $association->getTargetEntity();
150
151 40
        if (! class_exists($targetEntity) || $metadataFactory->isTransient($targetEntity)) {
152
            $message = "The target entity '%s' specified on %s#%s is unknown or not an entity.";
153
154
            return [sprintf($message, $targetEntity, $class->getClassName(), $fieldName)];
155
        }
156
157 40
        $mappedBy   = $association->getMappedBy();
158 40
        $inversedBy = $association->getInversedBy();
159
160 40
        $ce = [];
161
162 40
        if ($mappedBy && $inversedBy) {
163
            $message = 'The association %s#%s cannot be defined as both inverse and owning.';
164
165
            $ce[] = sprintf($message, $class, $fieldName);
166
        }
167
168
        /** @var ClassMetadata $targetMetadata */
169 40
        $targetMetadata    = $metadataFactory->getMetadataFor($targetEntity);
170
        $containsForeignId = array_filter($targetMetadata->identifier, static function ($identifier) use ($targetMetadata) {
171 40
            $targetProperty = $targetMetadata->getProperty($identifier);
172
173 40
            return $targetProperty instanceof AssociationMetadata;
174 40
        });
175
176 40
        if ($association->isPrimaryKey() && count($containsForeignId)) {
177 1
            $message = "Cannot map association %s#%s as identifier, because the target entity '%s' also maps an association as identifier.";
178
179 1
            $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity);
180
        }
181
182 40
        if ($mappedBy) {
183
            /** @var AssociationMetadata $targetAssociation */
184 33
            $targetAssociation = $targetMetadata->getProperty($mappedBy);
185
186 33
            if (! $targetAssociation) {
0 ignored issues
show
introduced by
$targetAssociation is of type Doctrine\ORM\Mapping\AssociationMetadata, thus it always evaluated to true.
Loading history...
187
                $message = 'The association %s#%s refers to the owning side property %s#%s which does not exist.';
188
189
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy);
190 33
            } elseif ($targetAssociation instanceof FieldMetadata) {
0 ignored issues
show
introduced by
$targetAssociation is never a sub-type of Doctrine\ORM\Mapping\FieldMetadata.
Loading history...
191
                $message = 'The association %s#%s refers to the owning side property %s#%s which is not defined as association, but as field.';
192
193
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy);
194 33
            } elseif ($targetAssociation->getInversedBy() === null) {
195
                $message = 'The property %s#%s is on the inverse side of a bi-directional relationship, but the '
196 1
                    . "specified mappedBy association on the target-entity %s#%s does not contain the required 'inversedBy=\"%s\"' attribute.";
197
198 1
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy, $fieldName);
199 32
            } elseif ($targetAssociation->getInversedBy() !== $fieldName) {
200
                $message = 'The mapping between %s#%s and %s#%s are inconsistent with each other.';
201
202
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy);
203
            }
204
        }
205
206 40
        if ($inversedBy) {
207
            /** @var AssociationMetadata $targetAssociation */
208 30
            $targetAssociation = $targetMetadata->getProperty($inversedBy);
209
210 30
            if (! $targetAssociation) {
0 ignored issues
show
introduced by
$targetAssociation is of type Doctrine\ORM\Mapping\AssociationMetadata, thus it always evaluated to true.
Loading history...
211
                $message = 'The association %s#%s refers to the inverse side property %s#%s which does not exist.';
212
213
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $inversedBy);
214 30
            } elseif ($targetAssociation instanceof FieldMetadata) {
0 ignored issues
show
introduced by
$targetAssociation is never a sub-type of Doctrine\ORM\Mapping\FieldMetadata.
Loading history...
215
                $message = 'The association %s#%s refers to the inverse side property %s#%s which is not defined as association, but as field.';
216
217
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $inversedBy);
218 30
            } elseif ($targetAssociation->getMappedBy() === null) {
219
                $message = 'The property %s#%s is on the owning side of a bi-directional relationship, but the '
220
                    . "specified mappedBy association on the target-entity %s#%s does not contain the required 'inversedBy=\"%s\"' attribute.";
221
222
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy, $fieldName);
223 30
            } elseif ($targetAssociation->getMappedBy() !== $fieldName) {
224
                $message = 'The mapping between %s#%s and %s#%s are inconsistent with each other.';
225
226
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $inversedBy);
227
            }
228
229
            // Verify inverse side/owning side match each other
230 30
            if ($targetAssociation) {
0 ignored issues
show
introduced by
$targetAssociation is of type Doctrine\ORM\Mapping\AssociationMetadata, thus it always evaluated to true.
Loading history...
231 30
                if ($association instanceof OneToOneAssociationMetadata && ! $targetAssociation instanceof OneToOneAssociationMetadata) {
232
                    $message = 'If association %s#%s is one-to-one, then the inversed side %s#%s has to be one-to-one as well.';
233
234
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetMetadata->getClassName(), $inversedBy);
235 30
                } elseif ($association instanceof ManyToOneAssociationMetadata && ! $targetAssociation instanceof OneToManyAssociationMetadata) {
236
                    $message = 'If association %s#%s is many-to-one, then the inversed side %s#%s has to be one-to-many.';
237
238
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetMetadata->getClassName(), $inversedBy);
239 30
                } elseif ($association instanceof ManyToManyAssociationMetadata && ! $targetAssociation instanceof ManyToManyAssociationMetadata) {
240
                    $message = 'If association %s#%s is many-to-many, then the inversed side %s#%s has to be many-to-many as well.';
241
242
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetMetadata->getClassName(), $inversedBy);
243
                }
244
            }
245
        }
246
247 40
        if ($association->isOwningSide()) {
248 36
            if ($association instanceof ManyToManyAssociationMetadata) {
249 16
                $classIdentifierColumns  = array_keys($class->getIdentifierColumns($this->em));
250 16
                $targetIdentifierColumns = array_keys($targetMetadata->getIdentifierColumns($this->em));
251 16
                $joinTable               = $association->getJoinTable();
252
253 16
                foreach ($joinTable->getJoinColumns() as $joinColumn) {
254 16
                    if (! in_array($joinColumn->getReferencedColumnName(), $classIdentifierColumns, true)) {
255
                        $message = "The referenced column name '%s' has to be a primary key column on the target entity class '%s'.";
256
257
                        $ce[] = sprintf($message, $joinColumn->getReferencedColumnName(), $class->getClassName());
258 16
                        break;
259
                    }
260
                }
261
262 16
                foreach ($joinTable->getInverseJoinColumns() as $inverseJoinColumn) {
263 16
                    if (! in_array($inverseJoinColumn->getReferencedColumnName(), $targetIdentifierColumns, true)) {
264 1
                        $message = "The referenced column name '%s' has to be a primary key column on the target entity class '%s'.";
265
266 1
                        $ce[] = sprintf($message, $inverseJoinColumn->getReferencedColumnName(), $targetMetadata->getClassName());
267 16
                        break;
268
                    }
269
                }
270
271 16
                if (count($targetIdentifierColumns) !== count($joinTable->getInverseJoinColumns())) {
272 1
                    $columnNames = array_map(
273
                        static function (JoinColumnMetadata $joinColumn) {
274 1
                            return $joinColumn->getReferencedColumnName();
275 1
                        },
276 1
                        $joinTable->getInverseJoinColumns()
277
                    );
278
279 1
                    $columnString = implode("', '", array_diff($targetIdentifierColumns, $columnNames));
280
                    $message      = "The inverse join columns of the many-to-many table '%s' have to contain to ALL "
281 1
                        . "identifier columns of the target entity '%s', however '%s' are missing.";
282
283 1
                    $ce[] = sprintf($message, $joinTable->getName(), $targetMetadata->getClassName(), $columnString);
284
                }
285
286 16
                if (count($classIdentifierColumns) !== count($joinTable->getJoinColumns())) {
287 1
                    $columnNames = array_map(
288
                        static function (JoinColumnMetadata $joinColumn) {
289 1
                            return $joinColumn->getReferencedColumnName();
290 1
                        },
291 1
                        $joinTable->getJoinColumns()
292
                    );
293
294 1
                    $columnString = implode("', '", array_diff($classIdentifierColumns, $columnNames));
295
                    $message      = "The join columns of the many-to-many table '%s' have to contain to ALL "
296 1
                        . "identifier columns of the source entity '%s', however '%s' are missing.";
297
298 16
                    $ce[] = sprintf($message, $joinTable->getName(), $class->getClassName(), $columnString);
299
                }
300 31
            } elseif ($association instanceof ToOneAssociationMetadata) {
301 31
                $identifierColumns = array_keys($targetMetadata->getIdentifierColumns($this->em));
302 31
                $joinColumns       = $association->getJoinColumns();
303
304 31
                foreach ($joinColumns as $joinColumn) {
305 31
                    if (! in_array($joinColumn->getReferencedColumnName(), $identifierColumns, true)) {
306 2
                        $message = "The referenced column name '%s' has to be a primary key column on the target entity class '%s'.";
307
308 31
                        $ce[] = sprintf($message, $joinColumn->getReferencedColumnName(), $targetMetadata->getClassName());
309
                    }
310
                }
311
312 31
                if (count($identifierColumns) !== count($joinColumns)) {
313 1
                    $ids = [];
314
315 1
                    foreach ($joinColumns as $joinColumn) {
316 1
                        $ids[] = $joinColumn->getColumnName();
317
                    }
318
319 1
                    $columnString = implode("', '", array_diff($identifierColumns, $ids));
320
                    $message      = "The join columns of the association '%s' have to match to ALL "
321 1
                        . "identifier columns of the target entity '%s', however '%s' are missing.";
322
323 1
                    $ce[] = sprintf($message, $fieldName, $targetMetadata->getClassName(), $columnString);
324
                }
325
            }
326
        }
327
328 40
        if ($association instanceof ToManyAssociationMetadata && $association->getOrderBy()) {
329 5
            foreach ($association->getOrderBy() as $orderField => $orientation) {
330 5
                $targetProperty = $targetMetadata->getProperty($orderField);
331
332 5
                if ($targetProperty instanceof FieldMetadata) {
333 3
                    continue;
334
                }
335
336 3
                if (! ($targetProperty instanceof AssociationMetadata)) {
337 1
                    $message = "The association %s#%s is ordered by a property '%s' that is non-existing field on the target entity '%s'.";
338
339 1
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $orderField, $targetMetadata->getClassName());
340 1
                    continue;
341
                }
342
343 2
                if ($targetProperty instanceof ToManyAssociationMetadata) {
344 1
                    $message = "The association %s#%s is ordered by a property '%s' on '%s' that is a collection-valued association.";
345
346 1
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $orderField, $targetMetadata->getClassName());
347 1
                    continue;
348
                }
349
350 2
                if ($targetProperty instanceof AssociationMetadata && ! $targetProperty->isOwningSide()) {
351 1
                    $message = "The association %s#%s is ordered by a property '%s' on '%s' that is the inverse side of an association.";
352
353 1
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $orderField, $targetMetadata->getClassName());
354 2
                    continue;
355
                }
356
            }
357
        }
358
359 40
        return $ce;
360
    }
361
362
    /**
363
     * Checks if the Database Schema is in sync with the current metadata state.
364
     *
365
     * @return bool
366
     */
367
    public function schemaInSyncWithMetadata()
368
    {
369
        $schemaTool  = new SchemaTool($this->em);
370
        $allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
371
372
        return count($schemaTool->getUpdateSchemaSql($allMetadata, true)) === 0;
373
    }
374
}
375