vendor/doctrine/orm/lib/Doctrine/ORM/Tools/SchemaValidator.php line 102

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace Doctrine\ORM\Tools;
  4. use Doctrine\DBAL\Types\Type;
  5. use Doctrine\Deprecations\Deprecation;
  6. use Doctrine\ORM\EntityManagerInterface;
  7. use Doctrine\ORM\Mapping\ClassMetadata;
  8. use Doctrine\ORM\Mapping\ClassMetadataInfo;
  9. use function array_diff;
  10. use function array_key_exists;
  11. use function array_search;
  12. use function array_values;
  13. use function class_exists;
  14. use function class_parents;
  15. use function count;
  16. use function get_class;
  17. use function implode;
  18. use function in_array;
  19. /**
  20. * Performs strict validation of the mapping schema
  21. *
  22. * @link www.doctrine-project.com
  23. */
  24. class SchemaValidator
  25. {
  26. /** @var EntityManagerInterface */
  27. private $em;
  28. public function __construct(EntityManagerInterface $em)
  29. {
  30. $this->em = $em;
  31. }
  32. /**
  33. * Checks the internal consistency of all mapping files.
  34. *
  35. * There are several checks that can't be done at runtime or are too expensive, which can be verified
  36. * with this command. For example:
  37. *
  38. * 1. Check if a relation with "mappedBy" is actually connected to that specified field.
  39. * 2. Check if "mappedBy" and "inversedBy" are consistent to each other.
  40. * 3. Check if "referencedColumnName" attributes are really pointing to primary key columns.
  41. *
  42. * @psalm-return array<string, list<string>>
  43. */
  44. public function validateMapping()
  45. {
  46. $errors = [];
  47. $cmf = $this->em->getMetadataFactory();
  48. $classes = $cmf->getAllMetadata();
  49. foreach ($classes as $class) {
  50. $ce = $this->validateClass($class);
  51. if ($ce) {
  52. $errors[$class->name] = $ce;
  53. }
  54. }
  55. return $errors;
  56. }
  57. /**
  58. * Validates a single class of the current.
  59. *
  60. * @return string[]
  61. * @psalm-return list<string>
  62. */
  63. public function validateClass(ClassMetadataInfo $class)
  64. {
  65. if (! $class instanceof ClassMetadata) {
  66. Deprecation::trigger(
  67. 'doctrine/orm',
  68. 'https://github.com/doctrine/orm/pull/249',
  69. 'Passing an instance of %s to %s is deprecated, please pass a ClassMetadata instance instead.',
  70. get_class($class),
  71. __METHOD__,
  72. ClassMetadata::class
  73. );
  74. }
  75. $ce = [];
  76. $cmf = $this->em->getMetadataFactory();
  77. foreach ($class->fieldMappings as $fieldName => $mapping) {
  78. if (! Type::hasType($mapping['type'])) {
  79. $ce[] = "The field '" . $class->name . '#' . $fieldName . "' uses a non-existent type '" . $mapping['type'] . "'.";
  80. }
  81. }
  82. if ($class->isEmbeddedClass && count($class->associationMappings) > 0) {
  83. $ce[] = "Embeddable '" . $class->name . "' does not support associations";
  84. return $ce;
  85. }
  86. foreach ($class->associationMappings as $fieldName => $assoc) {
  87. if (! class_exists($assoc['targetEntity']) || $cmf->isTransient($assoc['targetEntity'])) {
  88. $ce[] = "The target entity '" . $assoc['targetEntity'] . "' specified on " . $class->name . '#' . $fieldName . ' is unknown or not an entity.';
  89. return $ce;
  90. }
  91. if ($assoc['mappedBy'] && $assoc['inversedBy']) {
  92. $ce[] = 'The association ' . $class . '#' . $fieldName . ' cannot be defined as both inverse and owning.';
  93. }
  94. $targetMetadata = $cmf->getMetadataFor($assoc['targetEntity']);
  95. if (isset($assoc['id']) && $targetMetadata->containsForeignIdentifier) {
  96. $ce[] = "Cannot map association '" . $class->name . '#' . $fieldName . ' as identifier, because ' .
  97. "the target entity '" . $targetMetadata->name . "' also maps an association as identifier.";
  98. }
  99. if ($assoc['mappedBy']) {
  100. if ($targetMetadata->hasField($assoc['mappedBy'])) {
  101. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the owning side ' .
  102. 'field ' . $assoc['targetEntity'] . '#' . $assoc['mappedBy'] . ' which is not defined as association, but as field.';
  103. }
  104. if (! $targetMetadata->hasAssociation($assoc['mappedBy'])) {
  105. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the owning side ' .
  106. 'field ' . $assoc['targetEntity'] . '#' . $assoc['mappedBy'] . ' which does not exist.';
  107. } elseif ($targetMetadata->associationMappings[$assoc['mappedBy']]['inversedBy'] === null) {
  108. $ce[] = 'The field ' . $class->name . '#' . $fieldName . ' is on the inverse side of a ' .
  109. 'bi-directional relationship, but the specified mappedBy association on the target-entity ' .
  110. $assoc['targetEntity'] . '#' . $assoc['mappedBy'] . ' does not contain the required ' .
  111. "'inversedBy=\"" . $fieldName . "\"' attribute.";
  112. } elseif ($targetMetadata->associationMappings[$assoc['mappedBy']]['inversedBy'] !== $fieldName) {
  113. $ce[] = 'The mappings ' . $class->name . '#' . $fieldName . ' and ' .
  114. $assoc['targetEntity'] . '#' . $assoc['mappedBy'] . ' are ' .
  115. 'inconsistent with each other.';
  116. }
  117. }
  118. if ($assoc['inversedBy']) {
  119. if ($targetMetadata->hasField($assoc['inversedBy'])) {
  120. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the inverse side ' .
  121. 'field ' . $assoc['targetEntity'] . '#' . $assoc['inversedBy'] . ' which is not defined as association.';
  122. }
  123. if (! $targetMetadata->hasAssociation($assoc['inversedBy'])) {
  124. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' refers to the inverse side ' .
  125. 'field ' . $assoc['targetEntity'] . '#' . $assoc['inversedBy'] . ' which does not exist.';
  126. } elseif ($targetMetadata->associationMappings[$assoc['inversedBy']]['mappedBy'] === null) {
  127. $ce[] = 'The field ' . $class->name . '#' . $fieldName . ' is on the owning side of a ' .
  128. 'bi-directional relationship, but the specified inversedBy association on the target-entity ' .
  129. $assoc['targetEntity'] . '#' . $assoc['inversedBy'] . ' does not contain the required ' .
  130. "'mappedBy=\"" . $fieldName . "\"' attribute.";
  131. } elseif ($targetMetadata->associationMappings[$assoc['inversedBy']]['mappedBy'] !== $fieldName) {
  132. $ce[] = 'The mappings ' . $class->name . '#' . $fieldName . ' and ' .
  133. $assoc['targetEntity'] . '#' . $assoc['inversedBy'] . ' are ' .
  134. 'inconsistent with each other.';
  135. }
  136. // Verify inverse side/owning side match each other
  137. if (array_key_exists($assoc['inversedBy'], $targetMetadata->associationMappings)) {
  138. $targetAssoc = $targetMetadata->associationMappings[$assoc['inversedBy']];
  139. if ($assoc['type'] === ClassMetadata::ONE_TO_ONE && $targetAssoc['type'] !== ClassMetadata::ONE_TO_ONE) {
  140. $ce[] = 'If association ' . $class->name . '#' . $fieldName . ' is one-to-one, then the inversed ' .
  141. 'side ' . $targetMetadata->name . '#' . $assoc['inversedBy'] . ' has to be one-to-one as well.';
  142. } elseif ($assoc['type'] === ClassMetadata::MANY_TO_ONE && $targetAssoc['type'] !== ClassMetadata::ONE_TO_MANY) {
  143. $ce[] = 'If association ' . $class->name . '#' . $fieldName . ' is many-to-one, then the inversed ' .
  144. 'side ' . $targetMetadata->name . '#' . $assoc['inversedBy'] . ' has to be one-to-many.';
  145. } elseif ($assoc['type'] === ClassMetadata::MANY_TO_MANY && $targetAssoc['type'] !== ClassMetadata::MANY_TO_MANY) {
  146. $ce[] = 'If association ' . $class->name . '#' . $fieldName . ' is many-to-many, then the inversed ' .
  147. 'side ' . $targetMetadata->name . '#' . $assoc['inversedBy'] . ' has to be many-to-many as well.';
  148. }
  149. }
  150. }
  151. if ($assoc['isOwningSide']) {
  152. if ($assoc['type'] === ClassMetadata::MANY_TO_MANY) {
  153. $identifierColumns = $class->getIdentifierColumnNames();
  154. foreach ($assoc['joinTable']['joinColumns'] as $joinColumn) {
  155. if (! in_array($joinColumn['referencedColumnName'], $identifierColumns, true)) {
  156. $ce[] = "The referenced column name '" . $joinColumn['referencedColumnName'] . "' " .
  157. "has to be a primary key column on the target entity class '" . $class->name . "'.";
  158. break;
  159. }
  160. }
  161. $identifierColumns = $targetMetadata->getIdentifierColumnNames();
  162. foreach ($assoc['joinTable']['inverseJoinColumns'] as $inverseJoinColumn) {
  163. if (! in_array($inverseJoinColumn['referencedColumnName'], $identifierColumns, true)) {
  164. $ce[] = "The referenced column name '" . $inverseJoinColumn['referencedColumnName'] . "' " .
  165. "has to be a primary key column on the target entity class '" . $targetMetadata->name . "'.";
  166. break;
  167. }
  168. }
  169. if (count($targetMetadata->getIdentifierColumnNames()) !== count($assoc['joinTable']['inverseJoinColumns'])) {
  170. $ce[] = "The inverse join columns of the many-to-many table '" . $assoc['joinTable']['name'] . "' " .
  171. "have to contain to ALL identifier columns of the target entity '" . $targetMetadata->name . "', " .
  172. "however '" . implode(', ', array_diff($targetMetadata->getIdentifierColumnNames(), array_values($assoc['relationToTargetKeyColumns']))) .
  173. "' are missing.";
  174. }
  175. if (count($class->getIdentifierColumnNames()) !== count($assoc['joinTable']['joinColumns'])) {
  176. $ce[] = "The join columns of the many-to-many table '" . $assoc['joinTable']['name'] . "' " .
  177. "have to contain to ALL identifier columns of the source entity '" . $class->name . "', " .
  178. "however '" . implode(', ', array_diff($class->getIdentifierColumnNames(), array_values($assoc['relationToSourceKeyColumns']))) .
  179. "' are missing.";
  180. }
  181. } elseif ($assoc['type'] & ClassMetadata::TO_ONE) {
  182. $identifierColumns = $targetMetadata->getIdentifierColumnNames();
  183. foreach ($assoc['joinColumns'] as $joinColumn) {
  184. if (! in_array($joinColumn['referencedColumnName'], $identifierColumns, true)) {
  185. $ce[] = "The referenced column name '" . $joinColumn['referencedColumnName'] . "' " .
  186. "has to be a primary key column on the target entity class '" . $targetMetadata->name . "'.";
  187. }
  188. }
  189. if (count($identifierColumns) !== count($assoc['joinColumns'])) {
  190. $ids = [];
  191. foreach ($assoc['joinColumns'] as $joinColumn) {
  192. $ids[] = $joinColumn['name'];
  193. }
  194. $ce[] = "The join columns of the association '" . $assoc['fieldName'] . "' " .
  195. "have to match to ALL identifier columns of the target entity '" . $targetMetadata->name . "', " .
  196. "however '" . implode(', ', array_diff($targetMetadata->getIdentifierColumnNames(), $ids)) .
  197. "' are missing.";
  198. }
  199. }
  200. }
  201. if (isset($assoc['orderBy']) && $assoc['orderBy'] !== null) {
  202. foreach ($assoc['orderBy'] as $orderField => $orientation) {
  203. if (! $targetMetadata->hasField($orderField) && ! $targetMetadata->hasAssociation($orderField)) {
  204. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' is ordered by a foreign field ' .
  205. $orderField . ' that is not a field on the target entity ' . $targetMetadata->name . '.';
  206. continue;
  207. }
  208. if ($targetMetadata->isCollectionValuedAssociation($orderField)) {
  209. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' is ordered by a field ' .
  210. $orderField . ' on ' . $targetMetadata->name . ' that is a collection-valued association.';
  211. continue;
  212. }
  213. if ($targetMetadata->isAssociationInverseSide($orderField)) {
  214. $ce[] = 'The association ' . $class->name . '#' . $fieldName . ' is ordered by a field ' .
  215. $orderField . ' on ' . $targetMetadata->name . ' that is the inverse side of an association.';
  216. continue;
  217. }
  218. }
  219. }
  220. }
  221. if (
  222. ! $class->isInheritanceTypeNone()
  223. && ! $class->isRootEntity()
  224. && ($class->reflClass !== null && ! $class->reflClass->isAbstract())
  225. && ! $class->isMappedSuperclass
  226. && array_search($class->name, $class->discriminatorMap, true) === false
  227. ) {
  228. $ce[] = "Entity class '" . $class->name . "' is part of inheritance hierarchy, but is " .
  229. "not mapped in the root entity '" . $class->rootEntityName . "' discriminator map. " .
  230. 'All subclasses must be listed in the discriminator map.';
  231. }
  232. foreach ($class->subClasses as $subClass) {
  233. if (! in_array($class->name, class_parents($subClass), true)) {
  234. $ce[] = "According to the discriminator map class '" . $subClass . "' has to be a child " .
  235. "of '" . $class->name . "' but these entities are not related through inheritance.";
  236. }
  237. }
  238. return $ce;
  239. }
  240. /**
  241. * Checks if the Database Schema is in sync with the current metadata state.
  242. *
  243. * @return bool
  244. */
  245. public function schemaInSyncWithMetadata()
  246. {
  247. return count($this->getUpdateSchemaList()) === 0;
  248. }
  249. /**
  250. * Returns the list of missing Database Schema updates.
  251. *
  252. * @return array<string>
  253. */
  254. public function getUpdateSchemaList(): array
  255. {
  256. $schemaTool = new SchemaTool($this->em);
  257. $allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
  258. return $schemaTool->getUpdateSchemaSql($allMetadata, true);
  259. }
  260. }