summaryrefslogtreecommitdiff
path: root/vendor/doctrine/orm/src/Tools/SchemaValidator.php
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/doctrine/orm/src/Tools/SchemaValidator.php')
-rw-r--r--vendor/doctrine/orm/src/Tools/SchemaValidator.php443
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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Tools;
6
7use BackedEnum;
8use Doctrine\DBAL\Types\AsciiStringType;
9use Doctrine\DBAL\Types\BigIntType;
10use Doctrine\DBAL\Types\BooleanType;
11use Doctrine\DBAL\Types\DecimalType;
12use Doctrine\DBAL\Types\FloatType;
13use Doctrine\DBAL\Types\GuidType;
14use Doctrine\DBAL\Types\IntegerType;
15use Doctrine\DBAL\Types\JsonType;
16use Doctrine\DBAL\Types\SimpleArrayType;
17use Doctrine\DBAL\Types\SmallIntType;
18use Doctrine\DBAL\Types\StringType;
19use Doctrine\DBAL\Types\TextType;
20use Doctrine\DBAL\Types\Type;
21use Doctrine\ORM\EntityManagerInterface;
22use Doctrine\ORM\Mapping\ClassMetadata;
23use Doctrine\ORM\Mapping\FieldMapping;
24use ReflectionEnum;
25use ReflectionNamedType;
26
27use function array_diff;
28use function array_filter;
29use function array_key_exists;
30use function array_map;
31use function array_push;
32use function array_search;
33use function array_values;
34use function assert;
35use function class_exists;
36use function class_parents;
37use function count;
38use function implode;
39use function in_array;
40use function interface_exists;
41use function is_a;
42use function sprintf;
43
44/**
45 * Performs strict validation of the mapping schema
46 *
47 * @link www.doctrine-project.com
48 */
49class 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}