'racine de rattachement', // Référentiel partagé : « français (France) » est identique pour tout le // monde, et le dupliquer par organisation ferait diverger les catégories // CLDR, donc la validation des pluriels. Locale::class => 'référentiel global partagé', ]; public function testEveryEntityIsReachableFromAnOrganization(): void { self::bootKernel(); $entityManager = self::getContainer()->get(EntityManagerInterface::class); self::assertInstanceOf(EntityManagerInterface::class, $entityManager); /** @var list> $allMetadata */ $allMetadata = $entityManager->getMetadataFactory()->getAllMetadata(); $byName = []; foreach ($allMetadata as $metadata) { $byName[$metadata->getName()] = $metadata; } self::assertNotEmpty($byName, 'Aucune entité mappée : la configuration Doctrine est cassée.'); $orphans = []; foreach ($byName as $name => $metadata) { if (!$this->reachesOrganization($name, $byName, [])) { $orphans[] = $name; } } self::assertSame([], $orphans, sprintf( "Ces entités ne sont rattachées à aucune organisation, ni directement ni par une chaîne d'associations obligatoires :\n - %s\n\n". "Ajoutez-leur une association `organization` non nullable, ou un ManyToOne non nullable vers une entité déjà rattachée. ". "Si l'entité est réellement globale, inscrivez-la dans self::GLOBAL_ENTITIES avec sa justification.", implode("\n - ", $orphans), )); } /** * Une entité est isolée si elle porte `organization`, si elle figure dans les * exemptions, ou si elle possède un ManyToOne OBLIGATOIRE vers une entité * elle-même isolée. * * L'association doit être non nullable : une relation optionnelle ne * garantit rien, puisque la ligne peut exister sans parent. * * @param array> $byName * @param list $visited protection contre les cycles (namespace -> parent) */ private function reachesOrganization(string $name, array $byName, array $visited): bool { if (isset(self::GLOBAL_ENTITIES[$name])) { return true; } if (\in_array($name, $visited, true)) { return false; } $metadata = $byName[$name] ?? null; if (null === $metadata) { return false; } if ($metadata->hasAssociation('organization')) { return true; } $visited[] = $name; foreach ($metadata->getAssociationMappings() as $field => $mapping) { if (!$metadata->isSingleValuedAssociation($field)) { continue; } if (!$this->isMandatory($metadata, $field)) { continue; } $target = $metadata->getAssociationTargetClass($field); if ($this->reachesOrganization($target, $byName, $visited)) { return true; } } return false; } /** * @param ClassMetadata $metadata */ private function isMandatory(ClassMetadata $metadata, string $field): bool { $mapping = $metadata->getAssociationMapping($field); // Accès par propriété et non par ArrayAccess : ce dernier est déprécié et // disparaîtra en Doctrine ORM 4. foreach ($mapping->joinColumns ?? [] as $joinColumn) { // Une colonne de jointure est nullable par défaut chez Doctrine : // l'absence de la propriété équivaut donc à « optionnelle ». if (false === ($joinColumn->nullable ?? true)) { return true; } } return false; } }