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
Bug
introduced
by
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 |