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

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. * @phpstan-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 $em, bool $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. * @phpstan-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. * @phpstan-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'], $identifierColumns, true)) {
  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'], $identifierColumns, true)) {
  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'], $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. 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->discriminatorMap, true) === 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->name, class_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($allMetadata, true);
  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, $metadataFieldType, true)) {
  340. return null;
  341. }
  342. if (is_a($propertyType, BackedEnum::class, true)) {
  343. $backingType = (string) (new ReflectionEnum($propertyType))->getBackingType();
  344. if (! in_array($backingType, $metadataFieldType, true)) {
  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'], $propertyType, true)
  370. ) {
  371. $backingType = (string) (new ReflectionEnum($fieldMapping['enumType']))->getBackingType();
  372. if (in_array($backingType, $metadataFieldType, true)) {
  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. }