vendor/doctrine/orm/src/Tools/SchemaValidator.php line 121

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