Failed Conditions
Push — master ( 7c9ab7...fa4d3b )
by Marco
13:03
created

lib/Doctrine/ORM/Tools/SchemaValidator.php (1 issue)

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

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

62
            $ce = $this->validateClass(/** @scrutinizer ignore-type */ $class);
Loading history...
63
64 6
            if ($ce) {
65 6
                $errors[$class->getClassName()] = $ce;
66
            }
67
        }
68
69 6
        return $errors;
70
    }
71
72
    /**
73
     * Validates a single class of the current.
74
     *
75
     * @return string[]
76
     */
77 50
    public function validateClass(ClassMetadata $class)
78
    {
79 50
        $ce = [];
80
81 50
        foreach ($class->getDeclaredPropertiesIterator() as $fieldName => $association) {
82 50
            if (! ($association instanceof AssociationMetadata)) {
83 49
                continue;
84
            }
85
86 47
            $ce = array_merge($ce, $this->validateAssociation($class, $association));
87
        }
88
89 50
        foreach ($class->getSubClasses() as $subClass) {
90 13
            if (! in_array($class->getClassName(), class_parents($subClass), true)) {
91
                $message = "According to the discriminator map class, '%s' has to be a child of '%s', but these entities are not related through inheritance.";
92
93 13
                $ce[] = sprintf($message, $subClass, $class->getClassName());
94
            }
95
        }
96
97 50
        return $ce;
98
    }
99
100
    /**
101
     * @return string[]
102
     */
103 47
    private function validateAssociation(ClassMetadata $class, AssociationMetadata $association)
104
    {
105 47
        $metadataFactory = $this->em->getMetadataFactory();
106 47
        $fieldName       = $association->getName();
107 47
        $targetEntity    = $association->getTargetEntity();
108
109 47
        if (! class_exists($targetEntity) || $metadataFactory->isTransient($targetEntity)) {
110
            $message = "The target entity '%s' specified on %s#%s is unknown or not an entity.";
111
112
            return [sprintf($message, $targetEntity, $class->getClassName(), $fieldName)];
113
        }
114
115 47
        $mappedBy   = $association->getMappedBy();
116 47
        $inversedBy = $association->getInversedBy();
117
118 47
        $ce = [];
119
120 47
        if ($mappedBy && $inversedBy) {
121
            $message = 'The association %s#%s cannot be defined as both inverse and owning.';
122
123
            $ce[] = sprintf($message, $class, $fieldName);
124
        }
125
126
        /** @var ClassMetadata $targetMetadata */
127 47
        $targetMetadata    = $metadataFactory->getMetadataFor($targetEntity);
128
        $containsForeignId = array_filter($targetMetadata->identifier, static function ($identifier) use ($targetMetadata) {
129 47
            $targetProperty = $targetMetadata->getProperty($identifier);
130
131 47
            return $targetProperty instanceof AssociationMetadata;
132 47
        });
133
134 47
        if ($association->isPrimaryKey() && count($containsForeignId)) {
135 1
            $message = "Cannot map association %s#%s as identifier, because the target entity '%s' also maps an association as identifier.";
136
137 1
            $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity);
138
        }
139
140 47
        if ($mappedBy) {
141
            /** @var AssociationMetadata $targetAssociation */
142 40
            $targetAssociation = $targetMetadata->getProperty($mappedBy);
143
144 40
            if (! $targetAssociation) {
145
                $message = 'The association %s#%s refers to the owning side property %s#%s which does not exist.';
146
147
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy);
148 40
            } elseif ($targetAssociation instanceof FieldMetadata) {
149
                $message = 'The association %s#%s refers to the owning side property %s#%s which is not defined as association, but as field.';
150
151
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy);
152 40
            } elseif ($targetAssociation->getInversedBy() === null) {
153
                $message = 'The property %s#%s is on the inverse side of a bi-directional relationship, but the '
154 1
                    . "specified mappedBy association on the target-entity %s#%s does not contain the required 'inversedBy=\"%s\"' attribute.";
155
156 1
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy, $fieldName);
157 39
            } elseif ($targetAssociation->getInversedBy() !== $fieldName) {
158
                $message = 'The mapping between %s#%s and %s#%s are inconsistent with each other.';
159
160
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy);
161
            }
162
        }
163
164 47
        if ($inversedBy) {
165
            /** @var AssociationMetadata $targetAssociation */
166 37
            $targetAssociation = $targetMetadata->getProperty($inversedBy);
167
168 37
            if (! $targetAssociation) {
169
                $message = 'The association %s#%s refers to the inverse side property %s#%s which does not exist.';
170
171
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $inversedBy);
172 37
            } elseif ($targetAssociation instanceof FieldMetadata) {
173
                $message = 'The association %s#%s refers to the inverse side property %s#%s which is not defined as association, but as field.';
174
175
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $inversedBy);
176 37
            } elseif ($targetAssociation->getMappedBy() === null) {
177
                $message = 'The property %s#%s is on the owning side of a bi-directional relationship, but the '
178
                    . "specified mappedBy association on the target-entity %s#%s does not contain the required 'inversedBy=\"%s\"' attribute.";
179
180
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $mappedBy, $fieldName);
181 37
            } elseif ($targetAssociation->getMappedBy() !== $fieldName) {
182
                $message = 'The mapping between %s#%s and %s#%s are inconsistent with each other.';
183
184
                $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetEntity, $inversedBy);
185
            }
186
187
            // Verify inverse side/owning side match each other
188 37
            if ($targetAssociation) {
189 37
                if ($association instanceof OneToOneAssociationMetadata && ! $targetAssociation instanceof OneToOneAssociationMetadata) {
190
                    $message = 'If association %s#%s is one-to-one, then the inversed side %s#%s has to be one-to-one as well.';
191
192
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetMetadata->getClassName(), $inversedBy);
193 37
                } elseif ($association instanceof ManyToOneAssociationMetadata && ! $targetAssociation instanceof OneToManyAssociationMetadata) {
194
                    $message = 'If association %s#%s is many-to-one, then the inversed side %s#%s has to be one-to-many.';
195
196
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetMetadata->getClassName(), $inversedBy);
197 37
                } elseif ($association instanceof ManyToManyAssociationMetadata && ! $targetAssociation instanceof ManyToManyAssociationMetadata) {
198
                    $message = 'If association %s#%s is many-to-many, then the inversed side %s#%s has to be many-to-many as well.';
199
200
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $targetMetadata->getClassName(), $inversedBy);
201
                }
202
            }
203
        }
204
205 47
        if ($association->isOwningSide()) {
206 43
            if ($association instanceof ManyToManyAssociationMetadata) {
207 23
                $classIdentifierColumns  = array_keys($class->getIdentifierColumns($this->em));
208 23
                $targetIdentifierColumns = array_keys($targetMetadata->getIdentifierColumns($this->em));
209 23
                $joinTable               = $association->getJoinTable();
210
211 23
                foreach ($joinTable->getJoinColumns() as $joinColumn) {
212 23
                    if (! in_array($joinColumn->getReferencedColumnName(), $classIdentifierColumns, true)) {
213
                        $message = "The referenced column name '%s' has to be a primary key column on the target entity class '%s'.";
214
215
                        $ce[] = sprintf($message, $joinColumn->getReferencedColumnName(), $class->getClassName());
216 23
                        break;
217
                    }
218
                }
219
220 23
                foreach ($joinTable->getInverseJoinColumns() as $inverseJoinColumn) {
221 23
                    if (! in_array($inverseJoinColumn->getReferencedColumnName(), $targetIdentifierColumns, true)) {
222 1
                        $message = "The referenced column name '%s' has to be a primary key column on the target entity class '%s'.";
223
224 1
                        $ce[] = sprintf($message, $inverseJoinColumn->getReferencedColumnName(), $targetMetadata->getClassName());
225 23
                        break;
226
                    }
227
                }
228
229 23
                if (count($targetIdentifierColumns) !== count($joinTable->getInverseJoinColumns())) {
230 1
                    $columnNames = array_map(
231
                        static function (JoinColumnMetadata $joinColumn) {
232 1
                            return $joinColumn->getReferencedColumnName();
233 1
                        },
234 1
                        $joinTable->getInverseJoinColumns()
235
                    );
236
237 1
                    $columnString = implode("', '", array_diff($targetIdentifierColumns, $columnNames));
238
                    $message      = "The inverse join columns of the many-to-many table '%s' have to contain to ALL "
239 1
                        . "identifier columns of the target entity '%s', however '%s' are missing.";
240
241 1
                    $ce[] = sprintf($message, $joinTable->getName(), $targetMetadata->getClassName(), $columnString);
242
                }
243
244 23
                if (count($classIdentifierColumns) !== count($joinTable->getJoinColumns())) {
245 1
                    $columnNames = array_map(
246
                        static function (JoinColumnMetadata $joinColumn) {
247 1
                            return $joinColumn->getReferencedColumnName();
248 1
                        },
249 1
                        $joinTable->getJoinColumns()
250
                    );
251
252 1
                    $columnString = implode("', '", array_diff($classIdentifierColumns, $columnNames));
253
                    $message      = "The join columns of the many-to-many table '%s' have to contain to ALL "
254 1
                        . "identifier columns of the source entity '%s', however '%s' are missing.";
255
256 23
                    $ce[] = sprintf($message, $joinTable->getName(), $class->getClassName(), $columnString);
257
                }
258 38
            } elseif ($association instanceof ToOneAssociationMetadata) {
259 38
                $identifierColumns = array_keys($targetMetadata->getIdentifierColumns($this->em));
260 38
                $joinColumns       = $association->getJoinColumns();
261
262 38
                foreach ($joinColumns as $joinColumn) {
263 38
                    if (! in_array($joinColumn->getReferencedColumnName(), $identifierColumns, true)) {
264 2
                        $message = "The referenced column name '%s' has to be a primary key column on the target entity class '%s'.";
265
266 38
                        $ce[] = sprintf($message, $joinColumn->getReferencedColumnName(), $targetMetadata->getClassName());
267
                    }
268
                }
269
270 38
                if (count($identifierColumns) !== count($joinColumns)) {
271 1
                    $ids = [];
272
273 1
                    foreach ($joinColumns as $joinColumn) {
274 1
                        $ids[] = $joinColumn->getColumnName();
275
                    }
276
277 1
                    $columnString = implode("', '", array_diff($identifierColumns, $ids));
278
                    $message      = "The join columns of the association '%s' have to match to ALL "
279 1
                        . "identifier columns of the target entity '%s', however '%s' are missing.";
280
281 1
                    $ce[] = sprintf($message, $fieldName, $targetMetadata->getClassName(), $columnString);
282
                }
283
            }
284
        }
285
286 47
        if ($association instanceof ToManyAssociationMetadata && $association->getOrderBy()) {
287 11
            foreach ($association->getOrderBy() as $orderField => $orientation) {
288 11
                $targetProperty = $targetMetadata->getProperty($orderField);
289
290 11
                if ($targetProperty instanceof FieldMetadata) {
291 9
                    continue;
292
                }
293
294 3
                if (! ($targetProperty instanceof AssociationMetadata)) {
295 1
                    $message = "The association %s#%s is ordered by a property '%s' that is non-existing field on the target entity '%s'.";
296
297 1
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $orderField, $targetMetadata->getClassName());
298 1
                    continue;
299
                }
300
301 2
                if ($targetProperty instanceof ToManyAssociationMetadata) {
302 1
                    $message = "The association %s#%s is ordered by a property '%s' on '%s' that is a collection-valued association.";
303
304 1
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $orderField, $targetMetadata->getClassName());
305 1
                    continue;
306
                }
307
308 2
                if ($targetProperty instanceof AssociationMetadata && ! $targetProperty->isOwningSide()) {
309 1
                    $message = "The association %s#%s is ordered by a property '%s' on '%s' that is the inverse side of an association.";
310
311 1
                    $ce[] = sprintf($message, $class->getClassName(), $fieldName, $orderField, $targetMetadata->getClassName());
312 2
                    continue;
313
                }
314
            }
315
        }
316
317 47
        return $ce;
318
    }
319
320
    /**
321
     * Checks if the Database Schema is in sync with the current metadata state.
322
     *
323
     * @return bool
324
     */
325
    public function schemaInSyncWithMetadata()
326
    {
327
        $schemaTool  = new SchemaTool($this->em);
328
        $allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
329
330
        return count($schemaTool->getUpdateSchemaSql($allMetadata, true)) === 0;
331
    }
332
}
333