diff options
Diffstat (limited to 'vendor/doctrine/orm/src/Tools/SchemaValidator.php')
-rw-r--r-- | vendor/doctrine/orm/src/Tools/SchemaValidator.php | 443 |
1 files changed, 443 insertions, 0 deletions
diff --git a/vendor/doctrine/orm/src/Tools/SchemaValidator.php b/vendor/doctrine/orm/src/Tools/SchemaValidator.php new file mode 100644 index 0000000..fdfc003 --- /dev/null +++ b/vendor/doctrine/orm/src/Tools/SchemaValidator.php | |||
@@ -0,0 +1,443 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Tools; | ||
6 | |||
7 | use BackedEnum; | ||
8 | use Doctrine\DBAL\Types\AsciiStringType; | ||
9 | use Doctrine\DBAL\Types\BigIntType; | ||
10 | use Doctrine\DBAL\Types\BooleanType; | ||
11 | use Doctrine\DBAL\Types\DecimalType; | ||
12 | use Doctrine\DBAL\Types\FloatType; | ||
13 | use Doctrine\DBAL\Types\GuidType; | ||
14 | use Doctrine\DBAL\Types\IntegerType; | ||
15 | use Doctrine\DBAL\Types\JsonType; | ||
16 | use Doctrine\DBAL\Types\SimpleArrayType; | ||
17 | use Doctrine\DBAL\Types\SmallIntType; | ||
18 | use Doctrine\DBAL\Types\StringType; | ||
19 | use Doctrine\DBAL\Types\TextType; | ||
20 | use Doctrine\DBAL\Types\Type; | ||
21 | use Doctrine\ORM\EntityManagerInterface; | ||
22 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
23 | use Doctrine\ORM\Mapping\FieldMapping; | ||
24 | use ReflectionEnum; | ||
25 | use ReflectionNamedType; | ||
26 | |||
27 | use function array_diff; | ||
28 | use function array_filter; | ||
29 | use function array_key_exists; | ||
30 | use function array_map; | ||
31 | use function array_push; | ||
32 | use function array_search; | ||
33 | use function array_values; | ||
34 | use function assert; | ||
35 | use function class_exists; | ||
36 | use function class_parents; | ||
37 | use function count; | ||
38 | use function implode; | ||
39 | use function in_array; | ||
40 | use function interface_exists; | ||
41 | use function is_a; | ||
42 | use function sprintf; | ||
43 | |||
44 | /** | ||
45 | * Performs strict validation of the mapping schema | ||
46 | * | ||
47 | * @link www.doctrine-project.com | ||
48 | */ | ||
49 | class SchemaValidator | ||
50 | { | ||
51 | /** | ||
52 | * It maps built-in Doctrine types to PHP types | ||
53 | */ | ||
54 | private const BUILTIN_TYPES_MAP = [ | ||
55 | AsciiStringType::class => ['string'], | ||
56 | BigIntType::class => ['int', 'string'], | ||
57 | BooleanType::class => ['bool'], | ||
58 | DecimalType::class => ['string'], | ||
59 | FloatType::class => ['float'], | ||
60 | GuidType::class => ['string'], | ||
61 | IntegerType::class => ['int'], | ||
62 | JsonType::class => ['array'], | ||
63 | SimpleArrayType::class => ['array'], | ||
64 | SmallIntType::class => ['int'], | ||
65 | StringType::class => ['string'], | ||
66 | TextType::class => ['string'], | ||
67 | ]; | ||
68 | |||
69 | public function __construct( | ||
70 | private readonly EntityManagerInterface $em, | ||
71 | private readonly bool $validatePropertyTypes = true, | ||
72 | ) { | ||
73 | } | ||
74 | |||
75 | /** | ||
76 | * Checks the internal consistency of all mapping files. | ||
77 | * | ||
78 | * There are several checks that can't be done at runtime or are too expensive, which can be verified | ||
79 | * with this command. For example: | ||
80 | * | ||
81 | * 1. Check if a relation with "mappedBy" is actually connected to that specified field. | ||
82 | * 2. Check if "mappedBy" and "inversedBy" are consistent to each other. | ||
83 | * 3. Check if "referencedColumnName" attributes are really pointing to primary key columns. | ||
84 | * | ||
85 | * @psalm-return array<string, list<string>> | ||
86 | */ | ||
87 | public function validateMapping(): array | ||
88 | { | ||
89 | $errors = []; | ||
90 | $cmf = $this->em->getMetadataFactory(); | ||
91 | $classes = $cmf->getAllMetadata(); | ||
92 | |||
93 | foreach ($classes as $class) { | ||
94 | $ce = $this->validateClass($class); | ||
95 | if ($ce) { | ||
96 | $errors[$class->name] = $ce; | ||
97 | } | ||
98 | } | ||
99 | |||
100 | return $errors; | ||
101 | } | ||
102 | |||
103 | /** | ||
104 | * Validates a single class of the current. | ||
105 | * | ||
106 | * @return string[] | ||
107 | * @psalm-return list<string> | ||
108 | */ | ||
109 | public function validateClass(ClassMetadata $class): array | ||
110 | { | ||
111 | $ce = []; | ||
112 | $cmf = $this->em->getMetadataFactory(); | ||
113 | |||
114 | foreach ($class->fieldMappings as $fieldName => $mapping) { | ||
115 | if (! Type::hasType($mapping->type)) { | ||
116 | $ce[] = "The field '" . $class->name . '#' . $fieldName . "' uses a non-existent type '" . $mapping->type . "'."; | ||
117 | } | ||
118 | } | ||
119 | |||
120 | if ($this->validatePropertyTypes) { | ||
121 | array_push($ce, ...$this->validatePropertiesTypes($class)); | ||
122 | } | ||
123 | |||
124 | foreach ($class->associationMappings as $fieldName => $assoc) { | ||
125 | if (! class_exists($assoc->targetEntity) || $cmf->isTransient($assoc->targetEntity)) { | ||
126 | $ce[] = "The target entity '" . $assoc->targetEntity . "' specified on " . $class->name . '#' . $fieldName . ' is unknown or not an entity.'; | ||
127 | |||
128 | return $ce; | ||
129 | } | ||
130 | |||
131 | $targetMetadata = $cmf->getMetadataFor($assoc->targetEntity); | ||
132 | |||
133 | if ($targetMetadata->isMappedSuperclass) { | ||
134 | $ce[] = "The target entity '" . $assoc->targetEntity . "' specified on " . $class->name . '#' . $fieldName . ' is a mapped superclass. This is not possible since there is no table that a foreign key could refer to.'; | ||
135 | |||
136 | return $ce; | ||
137 | } | ||
138 | |||
139 | if (isset($assoc->id) && $targetMetadata->containsForeignIdentifier) { | ||
140 | $ce[] = "Cannot map association '" . $class->name . '#' . $fieldName . ' as identifier, because ' . | ||
141 | "the target entity '" . $targetMetadata->name . "' also maps an association as identifier."; | ||
142 | } | ||
143 | |||
144 | if (! $assoc->isOwningSide()) { | ||
145 | if ($targetMetadata->hasField($assoc->mappedBy)) { | ||
146 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the owning side ' . | ||
147 | 'field ' . $assoc->targetEntity . '#' . $assoc->mappedBy . ' which is not defined as association, but as field.'; | ||
148 | } | ||
149 | |||
150 | if (! $targetMetadata->hasAssociation($assoc->mappedBy)) { | ||
151 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the owning side ' . | ||
152 | 'field ' . $assoc->targetEntity . '#' . $assoc->mappedBy . ' which does not exist.'; | ||
153 | } elseif ($targetMetadata->associationMappings[$assoc->mappedBy]->inversedBy === null) { | ||
154 | $ce[] = 'The field ' . $class->name . '#' . $fieldName . ' is on the inverse side of a ' . | ||
155 | 'bi-directional relationship, but the specified mappedBy association on the target-entity ' . | ||
156 | $assoc->targetEntity . '#' . $assoc->mappedBy . ' does not contain the required ' . | ||
157 | "'inversedBy=\"" . $fieldName . "\"' attribute."; | ||
158 | } elseif ($targetMetadata->associationMappings[$assoc->mappedBy]->inversedBy !== $fieldName) { | ||
159 | $ce[] = 'The mappings ' . $class->name . '#' . $fieldName . ' and ' . | ||
160 | $assoc->targetEntity . '#' . $assoc->mappedBy . ' are ' . | ||
161 | 'inconsistent with each other.'; | ||
162 | } | ||
163 | } | ||
164 | |||
165 | if ($assoc->isOwningSide() && $assoc->inversedBy !== null) { | ||
166 | if ($targetMetadata->hasField($assoc->inversedBy)) { | ||
167 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the inverse side ' . | ||
168 | 'field ' . $assoc->targetEntity . '#' . $assoc->inversedBy . ' which is not defined as association.'; | ||
169 | } | ||
170 | |||
171 | if (! $targetMetadata->hasAssociation($assoc->inversedBy)) { | ||
172 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the inverse side ' . | ||
173 | 'field ' . $assoc->targetEntity . '#' . $assoc->inversedBy . ' which does not exist.'; | ||
174 | } elseif ($targetMetadata->associationMappings[$assoc->inversedBy]->isOwningSide()) { | ||
175 | $ce[] = 'The field ' . $class->name . '#' . $fieldName . ' is on the owning side of a ' . | ||
176 | 'bi-directional relationship, but the specified inversedBy association on the target-entity ' . | ||
177 | $assoc->targetEntity . '#' . $assoc->inversedBy . ' does not contain the required ' . | ||
178 | "'mappedBy=\"" . $fieldName . "\"' attribute."; | ||
179 | } elseif ($targetMetadata->associationMappings[$assoc->inversedBy]->mappedBy !== $fieldName) { | ||
180 | $ce[] = 'The mappings ' . $class->name . '#' . $fieldName . ' and ' . | ||
181 | $assoc->targetEntity . '#' . $assoc->inversedBy . ' are ' . | ||
182 | 'inconsistent with each other.'; | ||
183 | } | ||
184 | |||
185 | // Verify inverse side/owning side match each other | ||
186 | if (array_key_exists($assoc->inversedBy, $targetMetadata->associationMappings)) { | ||
187 | $targetAssoc = $targetMetadata->associationMappings[$assoc->inversedBy]; | ||
188 | if ($assoc->isOneToOne() && ! $targetAssoc->isOneToOne()) { | ||
189 | $ce[] = 'If association ' . $class->name . '#' . $fieldName . ' is one-to-one, then the inversed ' . | ||
190 | 'side ' . $targetMetadata->name . '#' . $assoc->inversedBy . ' has to be one-to-one as well.'; | ||
191 | } elseif ($assoc->isManyToOne() && ! $targetAssoc->isOneToMany()) { | ||
192 | $ce[] = 'If association ' . $class->name . '#' . $fieldName . ' is many-to-one, then the inversed ' . | ||
193 | 'side ' . $targetMetadata->name . '#' . $assoc->inversedBy . ' has to be one-to-many.'; | ||
194 | } elseif ($assoc->isManyToMany() && ! $targetAssoc->isManyToMany()) { | ||
195 | $ce[] = 'If association ' . $class->name . '#' . $fieldName . ' is many-to-many, then the inversed ' . | ||
196 | 'side ' . $targetMetadata->name . '#' . $assoc->inversedBy . ' has to be many-to-many as well.'; | ||
197 | } | ||
198 | } | ||
199 | } | ||
200 | |||
201 | if ($assoc->isOwningSide()) { | ||
202 | if ($assoc->isManyToManyOwningSide()) { | ||
203 | $identifierColumns = $class->getIdentifierColumnNames(); | ||
204 | foreach ($assoc->joinTable->joinColumns as $joinColumn) { | ||
205 | if (! in_array($joinColumn->referencedColumnName, $identifierColumns, true)) { | ||
206 | $ce[] = "The referenced column name '" . $joinColumn->referencedColumnName . "' " . | ||
207 | "has to be a primary key column on the target entity class '" . $class->name . "'."; | ||
208 | break; | ||
209 | } | ||
210 | } | ||
211 | |||
212 | $identifierColumns = $targetMetadata->getIdentifierColumnNames(); | ||
213 | foreach ($assoc->joinTable->inverseJoinColumns as $inverseJoinColumn) { | ||
214 | if (! in_array($inverseJoinColumn->referencedColumnName, $identifierColumns, true)) { | ||
215 | $ce[] = "The referenced column name '" . $inverseJoinColumn->referencedColumnName . "' " . | ||
216 | "has to be a primary key column on the target entity class '" . $targetMetadata->name . "'."; | ||
217 | break; | ||
218 | } | ||
219 | } | ||
220 | |||
221 | if (count($targetMetadata->getIdentifierColumnNames()) !== count($assoc->joinTable->inverseJoinColumns)) { | ||
222 | $ce[] = "The inverse join columns of the many-to-many table '" . $assoc->joinTable->name . "' " . | ||
223 | "have to contain to ALL identifier columns of the target entity '" . $targetMetadata->name . "', " . | ||
224 | "however '" . implode(', ', array_diff($targetMetadata->getIdentifierColumnNames(), array_values($assoc->relationToTargetKeyColumns))) . | ||
225 | "' are missing."; | ||
226 | } | ||
227 | |||
228 | if (count($class->getIdentifierColumnNames()) !== count($assoc->joinTable->joinColumns)) { | ||
229 | $ce[] = "The join columns of the many-to-many table '" . $assoc->joinTable->name . "' " . | ||
230 | "have to contain to ALL identifier columns of the source entity '" . $class->name . "', " . | ||
231 | "however '" . implode(', ', array_diff($class->getIdentifierColumnNames(), array_values($assoc->relationToSourceKeyColumns))) . | ||
232 | "' are missing."; | ||
233 | } | ||
234 | } elseif ($assoc->isToOneOwningSide()) { | ||
235 | $identifierColumns = $targetMetadata->getIdentifierColumnNames(); | ||
236 | foreach ($assoc->joinColumns as $joinColumn) { | ||
237 | if (! in_array($joinColumn->referencedColumnName, $identifierColumns, true)) { | ||
238 | $ce[] = "The referenced column name '" . $joinColumn->referencedColumnName . "' " . | ||
239 | "has to be a primary key column on the target entity class '" . $targetMetadata->name . "'."; | ||
240 | } | ||
241 | } | ||
242 | |||
243 | if (count($identifierColumns) !== count($assoc->joinColumns)) { | ||
244 | $ids = []; | ||
245 | |||
246 | foreach ($assoc->joinColumns as $joinColumn) { | ||
247 | $ids[] = $joinColumn->name; | ||
248 | } | ||
249 | |||
250 | $ce[] = "The join columns of the association '" . $assoc->fieldName . "' " . | ||
251 | "have to match to ALL identifier columns of the target entity '" . $targetMetadata->name . "', " . | ||
252 | "however '" . implode(', ', array_diff($targetMetadata->getIdentifierColumnNames(), $ids)) . | ||
253 | "' are missing."; | ||
254 | } | ||
255 | } | ||
256 | } | ||
257 | |||
258 | if ($assoc->isOrdered()) { | ||
259 | foreach ($assoc->orderBy() as $orderField => $orientation) { | ||
260 | if (! $targetMetadata->hasField($orderField) && ! $targetMetadata->hasAssociation($orderField)) { | ||
261 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' is ordered by a foreign field ' . | ||
262 | $orderField . ' that is not a field on the target entity ' . $targetMetadata->name . '.'; | ||
263 | continue; | ||
264 | } | ||
265 | |||
266 | if ($targetMetadata->isCollectionValuedAssociation($orderField)) { | ||
267 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' is ordered by a field ' . | ||
268 | $orderField . ' on ' . $targetMetadata->name . ' that is a collection-valued association.'; | ||
269 | continue; | ||
270 | } | ||
271 | |||
272 | if ($targetMetadata->isAssociationInverseSide($orderField)) { | ||
273 | $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' is ordered by a field ' . | ||
274 | $orderField . ' on ' . $targetMetadata->name . ' that is the inverse side of an association.'; | ||
275 | continue; | ||
276 | } | ||
277 | } | ||
278 | } | ||
279 | } | ||
280 | |||
281 | if ( | ||
282 | ! $class->isInheritanceTypeNone() | ||
283 | && ! $class->isRootEntity() | ||
284 | && ($class->reflClass !== null && ! $class->reflClass->isAbstract()) | ||
285 | && ! $class->isMappedSuperclass | ||
286 | && array_search($class->name, $class->discriminatorMap, true) === false | ||
287 | ) { | ||
288 | $ce[] = "Entity class '" . $class->name . "' is part of inheritance hierarchy, but is " . | ||
289 | "not mapped in the root entity '" . $class->rootEntityName . "' discriminator map. " . | ||
290 | 'All subclasses must be listed in the discriminator map.'; | ||
291 | } | ||
292 | |||
293 | foreach ($class->subClasses as $subClass) { | ||
294 | if (! in_array($class->name, class_parents($subClass), true)) { | ||
295 | $ce[] = "According to the discriminator map class '" . $subClass . "' has to be a child " . | ||
296 | "of '" . $class->name . "' but these entities are not related through inheritance."; | ||
297 | } | ||
298 | } | ||
299 | |||
300 | return $ce; | ||
301 | } | ||
302 | |||
303 | /** | ||
304 | * Checks if the Database Schema is in sync with the current metadata state. | ||
305 | */ | ||
306 | public function schemaInSyncWithMetadata(): bool | ||
307 | { | ||
308 | return count($this->getUpdateSchemaList()) === 0; | ||
309 | } | ||
310 | |||
311 | /** | ||
312 | * Returns the list of missing Database Schema updates. | ||
313 | * | ||
314 | * @return array<string> | ||
315 | */ | ||
316 | public function getUpdateSchemaList(): array | ||
317 | { | ||
318 | $schemaTool = new SchemaTool($this->em); | ||
319 | |||
320 | $allMetadata = $this->em->getMetadataFactory()->getAllMetadata(); | ||
321 | |||
322 | return $schemaTool->getUpdateSchemaSql($allMetadata); | ||
323 | } | ||
324 | |||
325 | /** @return list<string> containing the found issues */ | ||
326 | private function validatePropertiesTypes(ClassMetadata $class): array | ||
327 | { | ||
328 | return array_values( | ||
329 | array_filter( | ||
330 | array_map( | ||
331 | function (FieldMapping $fieldMapping) use ($class): string|null { | ||
332 | $fieldName = $fieldMapping->fieldName; | ||
333 | assert(isset($class->reflFields[$fieldName])); | ||
334 | $propertyType = $class->reflFields[$fieldName]->getType(); | ||
335 | |||
336 | // If the field type is not a built-in type, we cannot check it | ||
337 | if (! Type::hasType($fieldMapping->type)) { | ||
338 | return null; | ||
339 | } | ||
340 | |||
341 | // If the property type is not a named type, we cannot check it | ||
342 | if (! ($propertyType instanceof ReflectionNamedType) || $propertyType->getName() === 'mixed') { | ||
343 | return null; | ||
344 | } | ||
345 | |||
346 | $metadataFieldType = $this->findBuiltInType(Type::getType($fieldMapping->type)); | ||
347 | |||
348 | //If the metadata field type is not a mapped built-in type, we cannot check it | ||
349 | if ($metadataFieldType === null) { | ||
350 | return null; | ||
351 | } | ||
352 | |||
353 | $propertyType = $propertyType->getName(); | ||
354 | |||
355 | // If the property type is the same as the metadata field type, we are ok | ||
356 | if (in_array($propertyType, $metadataFieldType, true)) { | ||
357 | return null; | ||
358 | } | ||
359 | |||
360 | if (is_a($propertyType, BackedEnum::class, true)) { | ||
361 | $backingType = (string) (new ReflectionEnum($propertyType))->getBackingType(); | ||
362 | |||
363 | if (! in_array($backingType, $metadataFieldType, true)) { | ||
364 | return sprintf( | ||
365 | "The field '%s#%s' has the property type '%s' with a backing type of '%s' that differs from the metadata field type '%s'.", | ||
366 | $class->name, | ||
367 | $fieldName, | ||
368 | $propertyType, | ||
369 | $backingType, | ||
370 | implode('|', $metadataFieldType), | ||
371 | ); | ||
372 | } | ||
373 | |||
374 | if (! isset($fieldMapping->enumType) || $propertyType === $fieldMapping->enumType) { | ||
375 | return null; | ||
376 | } | ||
377 | |||
378 | return sprintf( | ||
379 | "The field '%s#%s' has the property type '%s' that differs from the metadata enumType '%s'.", | ||
380 | $class->name, | ||
381 | $fieldName, | ||
382 | $propertyType, | ||
383 | $fieldMapping->enumType, | ||
384 | ); | ||
385 | } | ||
386 | |||
387 | if ( | ||
388 | isset($fieldMapping->enumType) | ||
389 | && $propertyType !== $fieldMapping->enumType | ||
390 | && interface_exists($propertyType) | ||
391 | && is_a($fieldMapping->enumType, $propertyType, true) | ||
392 | ) { | ||
393 | $backingType = (string) (new ReflectionEnum($fieldMapping->enumType))->getBackingType(); | ||
394 | |||
395 | if (in_array($backingType, $metadataFieldType, true)) { | ||
396 | return null; | ||
397 | } | ||
398 | |||
399 | return sprintf( | ||
400 | "The field '%s#%s' has the metadata enumType '%s' with a backing type of '%s' that differs from the metadata field type '%s'.", | ||
401 | $class->name, | ||
402 | $fieldName, | ||
403 | $fieldMapping->enumType, | ||
404 | $backingType, | ||
405 | implode('|', $metadataFieldType), | ||
406 | ); | ||
407 | } | ||
408 | |||
409 | if ( | ||
410 | $fieldMapping->type === 'json' | ||
411 | && in_array($propertyType, ['string', 'int', 'float', 'bool', 'true', 'false', 'null'], true) | ||
412 | ) { | ||
413 | return null; | ||
414 | } | ||
415 | |||
416 | return sprintf( | ||
417 | "The field '%s#%s' has the property type '%s' that differs from the metadata field type '%s' returned by the '%s' DBAL type.", | ||
418 | $class->name, | ||
419 | $fieldName, | ||
420 | $propertyType, | ||
421 | implode('|', $metadataFieldType), | ||
422 | $fieldMapping->type, | ||
423 | ); | ||
424 | }, | ||
425 | $class->fieldMappings, | ||
426 | ), | ||
427 | ), | ||
428 | ); | ||
429 | } | ||
430 | |||
431 | /** | ||
432 | * The exact DBAL type must be used (no subclasses), since consumers of doctrine/orm may have their own | ||
433 | * customization around field types. | ||
434 | * | ||
435 | * @return list<string>|null | ||
436 | */ | ||
437 | private function findBuiltInType(Type $type): array|null | ||
438 | { | ||
439 | $typeName = $type::class; | ||
440 | |||
441 | return self::BUILTIN_TYPES_MAP[$typeName] ?? null; | ||
442 | } | ||
443 | } | ||