diff --git a/config/services.php b/config/services.php index 4977c91ddb..c2fbd26f49 100644 --- a/config/services.php +++ b/config/services.php @@ -378,6 +378,7 @@ ->arg(4, new Reference(FieldFactory::class)) ->arg(5, new Reference(AuthorizationChecker::class)) ->arg(6, service(AdminContextFactory::class)) + ->arg(7, service(EntityRepository::class)) ->tag('kernel.reset', ['method' => 'reset']) ->set(AvatarConfigurator::class) @@ -446,6 +447,7 @@ ->arg(2, service(ControllerFactory::class)) ->arg(3, new Reference(FieldFactory::class)) ->arg(4, service(AdminContextProvider::class)) + ->arg(5, service(EntityRepository::class)) ->set(SlugConfigurator::class) diff --git a/src/Contracts/Factory/EntityFactoryInterface.php b/src/Contracts/Factory/EntityFactoryInterface.php new file mode 100644 index 0000000000..7af151b7fb --- /dev/null +++ b/src/Contracts/Factory/EntityFactoryInterface.php @@ -0,0 +1,32 @@ +|null $entityInstances + */ + public function createCollection(EntityDto $entityDto, ?iterable $entityInstances): EntityCollection; + + /** + * @template TEntity of object + * + * @param class-string $entityFqcn + * + * @return ClassMetadata + */ + public function getEntityMetadata(string $entityFqcn): ClassMetadata; +} diff --git a/src/Contracts/Orm/EntityRepositoryInterface.php b/src/Contracts/Orm/EntityRepositoryInterface.php index 11019d2f0f..5f61d2438d 100644 --- a/src/Contracts/Orm/EntityRepositoryInterface.php +++ b/src/Contracts/Orm/EntityRepositoryInterface.php @@ -14,4 +14,13 @@ interface EntityRepositoryInterface { public function createQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder; + + /** + * @return array{ + * entity_dto: EntityDto, + * entity_alias: string, + * property_name: string, + * } + */ + public function resolveNestedAssociations(?QueryBuilder $queryBuilder, EntityDto $rootEntityDto, string $propertyName, bool $mustEndWithAssociation = false): array; } diff --git a/src/Factory/EntityFactory.php b/src/Factory/EntityFactory.php index 0d2b459342..d83d2b9194 100644 --- a/src/Factory/EntityFactory.php +++ b/src/Factory/EntityFactory.php @@ -7,6 +7,7 @@ use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\Proxy; use EasyCorp\Bundle\EasyAdminBundle\Collection\EntityCollection; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Factory\EntityFactoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Event\AfterEntityBuiltEvent; use EasyCorp\Bundle\EasyAdminBundle\Exception\EntityNotFoundException; @@ -18,7 +19,7 @@ /** * @author Javier Eguiluz */ -final readonly class EntityFactory +final readonly class EntityFactory implements EntityFactoryInterface { public function __construct( private AuthorizationCheckerInterface $authorizationChecker, diff --git a/src/Field/Configurator/AssociationConfigurator.php b/src/Field/Configurator/AssociationConfigurator.php index c30b90b339..3ea987172a 100644 --- a/src/Field/Configurator/AssociationConfigurator.php +++ b/src/Field/Configurator/AssociationConfigurator.php @@ -22,6 +22,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudAutocompleteType; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudFormType; +use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository as EAEntityRepository; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface; use EasyCorp\Bundle\EasyAdminBundle\Security\Permission; use Symfony\Component\HttpFoundation\Request; @@ -48,6 +49,7 @@ public function __construct( private readonly FieldFactory $fieldFactory, private readonly AuthorizationCheckerInterface $authorizationChecker, private readonly AdminContextFactory $adminContextFactory, + private EAEntityRepository $entityRepository, ) { } @@ -64,6 +66,11 @@ public function supports(FieldDto $field, EntityDto $entityDto): bool public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void { $propertyName = $field->getProperty(); + $resolvedProperty = $this->entityRepository->resolveNestedAssociations(null, $entityDto, $propertyName, true); + /** @var EntityDto $entityDtoResolved */ + $entityDtoResolved = $resolvedProperty['entity_dto']; + /** @var string $resolvedProperty */ + $resolvedProperty = $resolvedProperty['property_name']; if (!$this->isAssociation($entityDto->getClassMetadata(), $propertyName)) { throw new \RuntimeException(sprintf('The "%s" field is not a Doctrine association, so it cannot be used as an association field.', $propertyName)); @@ -71,7 +78,7 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c // the target CRUD controller can be NULL; in that case, field value doesn't link to the related entity $targetCrudControllerFqcn = $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER) - ?? $context->getAdminControllers()->findCrudControllerByEntity($entityDto->getClassMetadata()->getAssociationTargetClass($propertyName)); + ?? $context->getAdminControllers()->findCrudControllerByEntity($entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)); if (true === $field->getCustomOption(AssociationField::OPTION_RENDER_AS_EMBEDDED_FORM)) { if (false === $entityDto->getClassMetadata()->isSingleValuedAssociation($propertyName)) { diff --git a/src/Field/Configurator/CollectionConfigurator.php b/src/Field/Configurator/CollectionConfigurator.php index 5b9b47ebce..55f7b0930c 100644 --- a/src/Field/Configurator/CollectionConfigurator.php +++ b/src/Field/Configurator/CollectionConfigurator.php @@ -9,6 +9,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA; use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityRepositoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto; @@ -36,6 +37,7 @@ public function __construct( private ControllerFactory $controllerFactory, private FieldFactory $fieldFactory, private AdminContextProviderInterface $adminContextProvider, + private EntityRepositoryInterface $entityRepository, ) { } @@ -142,8 +144,14 @@ private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, Ad return; } + $resolvedProperty = $this->entityRepository->resolveNestedAssociations(null, $entityDto, $fieldDto->getProperty(), true); + /** @var EntityDto $entityDtoResolved */ + $entityDtoResolved = $resolvedProperty['entity_dto']; + /** @var string $resolvedProperty */ + $resolvedProperty = $resolvedProperty['property_name']; + if (true === $fieldDto->getCustomOption(CollectionField::OPTION_ENTRY_USES_CRUD_FORM)) { - if (!$entityDto->getClassMetadata()->hasAssociation($fieldDto->getProperty())) { + if (!$entityDtoResolved->getClassMetadata()->hasAssociation($resolvedProperty)) { throw new \RuntimeException(sprintf('The "%s" collection field of "%s" cannot use the "useEntryCrudForm()" method because it is not a Doctrine association.', $fieldDto->getProperty(), $context->getCrud()?->getControllerFqcn())); } @@ -152,14 +160,14 @@ private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, Ad } $targetCrudControllerFqcn = $fieldDto->getCustomOption(CollectionField::OPTION_ENTRY_CRUD_CONTROLLER_FQCN) - ?? $context->getAdminControllers()->findCrudControllerByEntity($entityDto->getClassMetadata()->getAssociationTargetClass($fieldDto->getProperty())); + ?? $context->getAdminControllers()->findCrudControllerByEntity($entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)); if (null === $targetCrudControllerFqcn) { - throw new \RuntimeException(sprintf('The "%s" collection field of "%s" wants to render its entries using an EasyAdmin CRUD form. However, no CRUD form was found related to this field. You can either create a CRUD controller for the entity "%s" or pass the CRUD controller to use as the first argument of the "useEntryCrudForm()" method.', $fieldDto->getProperty(), $context->getCrud()?->getControllerFqcn(), $entityDto->getClassMetadata()->getAssociationTargetClass($fieldDto->getProperty()))); + throw new \RuntimeException(sprintf('The "%s" collection field of "%s" wants to render its entries using an EasyAdmin CRUD form. However, no CRUD form was found related to this field. You can either create a CRUD controller for the entity "%s" or pass the CRUD controller to use as the first argument of the "useEntryCrudForm()" method.', $fieldDto->getProperty(), $context->getCrud()?->getControllerFqcn(), $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty))); } } elseif (null === $fieldDto->getFormTypeOption('entry_type') - && $entityDto->getClassMetadata()->hasAssociation($fieldDto->getProperty())) { - $targetCrudControllerFqcn = $context->getAdminControllers()->findCrudControllerByEntity($entityDto->getClassMetadata()->getAssociationTargetClass($fieldDto->getProperty())); + && $entityDtoResolved->getClassMetadata()->hasAssociation($resolvedProperty)) { + $targetCrudControllerFqcn = $context->getAdminControllers()->findCrudControllerByEntity($entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)); if (null === $targetCrudControllerFqcn) { return; @@ -168,7 +176,7 @@ private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, Ad return; } - $targetEntityFqcn = $entityDto->getClassMetadata()->getAssociationTargetClass($fieldDto->getProperty()); + $targetEntityFqcn = $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty); $editEntityDto = $this->createEntityDto( $targetEntityFqcn, diff --git a/src/Filter/Configurator/EntityConfigurator.php b/src/Filter/Configurator/EntityConfigurator.php index 9c5f074231..1f641ee0e4 100644 --- a/src/Filter/Configurator/EntityConfigurator.php +++ b/src/Filter/Configurator/EntityConfigurator.php @@ -5,12 +5,12 @@ use Doctrine\ORM\Mapping\JoinColumnMapping; use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityRepositoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDto; use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudAutocompleteType; -use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface; /** @@ -21,7 +21,7 @@ final class EntityConfigurator implements FilterConfiguratorInterface { public function __construct( private AdminUrlGeneratorInterface $adminUrlGenerator, - private EntityRepository $entityRepository, + private EntityRepositoryInterface $entityRepository, ) { } diff --git a/src/Orm/EntityRepository.php b/src/Orm/EntityRepository.php index 88057d25d7..b77e7e4fad 100644 --- a/src/Orm/EntityRepository.php +++ b/src/Orm/EntityRepository.php @@ -5,7 +5,6 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\ORM\Query\Expr\Orx; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -13,13 +12,13 @@ use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SortOrder; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Factory\EntityFactoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityRepositoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Event\AfterEntitySearchEvent; -use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter; @@ -40,7 +39,7 @@ final class EntityRepository implements EntityRepositoryInterface public function __construct( private readonly AdminContextProviderInterface $adminContextProvider, private readonly ManagerRegistry $doctrine, - private readonly EntityFactory $entityFactory, + private readonly EntityFactoryInterface $entityFactory, private readonly FormFactory $formFactory, private readonly EventDispatcherInterface $eventDispatcher, ) { diff --git a/tests/Functional/Apps/AdminRouteApp/config/reference.php b/tests/Functional/Apps/AdminRouteApp/config/reference.php index b38485fb83..a1e30364f9 100644 --- a/tests/Functional/Apps/AdminRouteApp/config/reference.php +++ b/tests/Functional/Apps/AdminRouteApp/config/reference.php @@ -127,7 +127,7 @@ * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array @@ -1227,7 +1227,7 @@ * }, * } * @psalm-type TwigComponentConfig = array{ - * defaults?: array, @@ -1236,7 +1236,6 @@ * enabled?: bool|Param, // Default: "%kernel.debug%" * collect_components?: bool|Param, // Collect components instances // Default: true * }, - * controllers_json?: scalar|Param|null, // Deprecated: The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0. // Default: null * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, diff --git a/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/Developer.php b/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/Developer.php index fd523e7eda..875b6a21d4 100644 --- a/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/Developer.php +++ b/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/Developer.php @@ -2,6 +2,8 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\DefaultApp\Entity\ProjectDomain; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] @@ -18,6 +20,14 @@ class Developer implements \Stringable #[ORM\ManyToOne(inversedBy: 'favouriteProjectOf')] private ?Project $favouriteProject = null; + #[ORM\OneToMany(targetEntity: ProjectIssue::class, mappedBy: 'assignedDeveloper')] + private Collection $issues; + + public function __construct() + { + $this->issues = new ArrayCollection(); + } + public function __toString(): string { return $this->name; @@ -51,4 +61,14 @@ public function setFavouriteProject(?Project $favouriteProject): static return $this; } + + public function getIssues(): Collection + { + return $this->issues; + } + + public function setIssues(Collection $issues): void + { + $this->issues = $issues; + } } diff --git a/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/ProjectIssue.php b/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/ProjectIssue.php index a587a25ae8..06f8f80db1 100644 --- a/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/ProjectIssue.php +++ b/tests/Functional/Apps/DefaultApp/src/Entity/ProjectDomain/ProjectIssue.php @@ -19,6 +19,9 @@ class ProjectIssue implements \Stringable #[ORM\JoinColumn(nullable: false)] private ?Project $project = null; + #[ORM\ManyToOne(targetEntity: Developer::class, inversedBy: 'issues')] + private ?Developer $assignedDeveloper = null; + public function __toString(): string { return $this->name; @@ -52,4 +55,14 @@ public function setProject(?Project $project): static return $this; } + + public function getAssignedDeveloper(): ?Developer + { + return $this->assignedDeveloper; + } + + public function setAssignedDeveloper(?Developer $assignedDeveloper): void + { + $this->assignedDeveloper = $assignedDeveloper; + } } diff --git a/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php b/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php index 2d040435ac..f1fe3336e4 100644 --- a/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php +++ b/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php @@ -18,6 +18,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\AssociationConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface; use EasyCorp\Bundle\EasyAdminBundle\Security\Permission; use EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\DefaultApp\Controller\ProjectDomain\DeveloperCrudController; @@ -55,6 +56,7 @@ protected function setUp(): void static::getContainer()->get(FieldFactory::class), static::getContainer()->get(AuthorizationCheckerInterface::class), static::getContainer()->get(AdminContextFactory::class), + static::getContainer()->get(EntityRepository::class), ); } @@ -111,6 +113,16 @@ public function testNestedAssociationWithCrudControllerSet(): void $this->assertSame(ProjectReleaseCategory::class, $fieldDto->getFormTypeOption('class')); } + public function testNestedAssociationWithAutoConfiguration(): void + { + $field = AssociationField::new('latestRelease.category'); + + $fieldDto = $this->configure($field); + + $this->assertSame(EntityType::class, $fieldDto->getFormType()); + $this->assertSame(ProjectReleaseCategory::class, $fieldDto->getFormTypeOption('class')); + } + /** * @dataProvider failsIfPropertyIsNotAssociation */ @@ -266,6 +278,7 @@ private function buildConfigurator( static::getContainer()->get(FieldFactory::class), $authChecker, static::getContainer()->get(AdminContextFactory::class), + static::getContainer()->get(EntityRepository::class), ); } diff --git a/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php b/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php index f5edbcd93d..b37887225b 100644 --- a/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php +++ b/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php @@ -48,6 +48,20 @@ public static function fields(): \Generator yield [CollectionField::new('projectIssues')]; yield [CollectionField::new('favouriteProjectOf')]; yield [CollectionField::new('projectTags')]; + yield [CollectionField::new('metaData')]; + } + + public function testNestedCollections(): void + { + $field = CollectionField::new('leadDeveloper.issues'); + $field->setCustomOption(CollectionField::OPTION_ENTRY_USES_CRUD_FORM, true); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'The "leadDeveloper.issues" collection field of "EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\DefaultApp\Controller\ProjectDomain\ProjectCrudController" wants to render its entries using an EasyAdmin CRUD form. However, no CRUD form was found related to this field. You can either create a CRUD controller for the entity "EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\DefaultApp\Entity\ProjectDomain\ProjectIssue" or pass the CRUD controller to use as the first argument of the "useEntryCrudForm()" method.' + ); + + $this->configure($field, pageName: Crud::PAGE_EDIT, controllerFqcn: ProjectCrudController::class); } /** diff --git a/tests/Unit/Filter/Configurator/EntityConfiguratorTest.php b/tests/Unit/Filter/Configurator/EntityConfiguratorTest.php new file mode 100644 index 0000000000..dd8191c156 --- /dev/null +++ b/tests/Unit/Filter/Configurator/EntityConfiguratorTest.php @@ -0,0 +1,64 @@ +entityRepository = $this->createMock(EntityRepositoryInterface::class); + } + + public function testConfigureResolvesNestedAssociation(): void + { + $adminUrlGenerator = $this->createMock(AdminUrlGeneratorInterface::class); + + $configurator = new EntityConfigurator($adminUrlGenerator, $this->entityRepository); + + $filterDto = new FilterDto(); + $filterDto->setProperty('parent.category'); + + $rootEntityDto = new EntityDto('App\Entity\Parent', $this->createMock(ClassMetadata::class)); + + $parentCategoryClassMetadata = $this->createMock(ClassMetadata::class); + $parentCategoryClassMetadata->expects(self::once()) + ->method('getAssociationTargetClass') + ->with('category') + ->willReturn('App\Entity\Category'); + + $categoryEntityDto = new EntityDto('App\Entity\Category', $parentCategoryClassMetadata); + + $this->entityRepository->expects(self::once()) + ->method('resolveNestedAssociations') + ->with(null, $rootEntityDto, 'parent.category', true) + ->willReturn([ + 'entity_dto' => $categoryEntityDto, + 'entity_alias' => 'category_parent', + 'property_name' => 'category', + ]); + + $configurator->configure($filterDto, null, $rootEntityDto, new AdminContext( + RequestContext::forTesting(), + CrudContext::forTesting(), + DashboardContext::forTesting(), + I18nContext::forTesting(), + )); + + self::assertSame('App\Entity\Category', $filterDto->getFormTypeOption('value_type_options.class')); + } +} diff --git a/tests/Unit/Orm/EntityRepositoryTest.php b/tests/Unit/Orm/EntityRepositoryTest.php index f7a964b39f..effb5447ad 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -8,11 +8,11 @@ use Doctrine\Persistence\ManagerRegistry; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Factory\EntityFactoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; -use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository; @@ -26,22 +26,20 @@ class EntityRepositoryTest extends TestCase private ManagerRegistry $doctrine; private EventDispatcherInterface $eventDispatcher; private EntityRepository $entityRepository; + private EntityFactoryInterface $entityFactory; protected function setUp(): void { $this->adminContextProvider = $this->createMock(AdminContextProviderInterface::class); $this->doctrine = $this->createMock(ManagerRegistry::class); $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); - - // use reflection to create EntityRepository without needing to mock final classes - // entityFactory and FormFactory are only used in specific scenarios - $entityFactory = $this->createEntityFactoryStub(); + $this->entityFactory = $this->createMock(EntityFactoryInterface::class); $formFactory = $this->createFormFactoryStub(); $this->entityRepository = new EntityRepository( $this->adminContextProvider, $this->doctrine, - $entityFactory, + $this->entityFactory, $formFactory, $this->eventDispatcher ); @@ -229,7 +227,7 @@ public function testCreateQueryBuilderWithSearchQueryAttemptsToGetConnection(): public function testCustomSortByExposedSortableFieldIsApplied(): void { - $entityDto = $this->createEntityDto(['displayedField']); + $entityDto = $this->createEntityDto(['displayedField' => ['type' => 'string']]); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField' => 'ASC']); @@ -247,7 +245,7 @@ public function testCustomSortByFieldAbsentFromFieldCollectionIsIgnored(): void { // simulates ?sort[hiddenField]=ASC against a controller whose // configureFields(INDEX) doesn't expose `hiddenField` - $entityDto = $this->createEntityDto(['hiddenField']); + $entityDto = $this->createEntityDto(['hiddenField' => ['type' => 'string']]); $fields = new FieldCollection([]); $searchDto = $this->createSearchDtoForSort(customSort: ['hiddenField' => 'ASC']); @@ -261,7 +259,7 @@ public function testCustomSortByFieldAbsentFromFieldCollectionIsIgnored(): void public function testCustomSortByExplicitlyNonSortableFieldIsIgnored(): void { - $entityDto = $this->createEntityDto(['displayedField']); + $entityDto = $this->createEntityDto(['displayedField' => ['type' => 'string']]); $fields = new FieldCollection([$this->createField('displayedField', false)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField' => 'ASC']); @@ -276,7 +274,7 @@ public function testCustomSortByExplicitlyNonSortableFieldIsIgnored(): void public function testCustomSortKeyContainingCommaIsIgnored(): void { // ?sort[name,entity.email]=ASC — comma would smuggle an extra ORDER BY column - $entityDto = $this->createEntityDto(['displayedField']); + $entityDto = $this->createEntityDto(['displayedField' => ['type' => 'string']]); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField,entity.hiddenField' => 'ASC']); @@ -293,7 +291,7 @@ public function testCustomSortKeyContainingDotIsIgnored(): void // ?sort[customer.secretField]=ASC — multi-segment keys reach the unfiltered // multi-segment branch of applyOrderClause; URL-based association sort is // supported via single-segment keys + AssociationField::setSortProperty() - $entityDto = $this->createEntityDto([], ['customer']); + $entityDto = $this->createEntityDto([], ['customer' => 'App\Entity\Customer']); $fields = new FieldCollection([$this->createField('customer', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['customer.secretField' => 'ASC']); @@ -311,7 +309,7 @@ public function testCustomSortWithNonAscDescValueIsIgnored(): void // ?sort[displayedField]=ASC,%20entity.hiddenField%20DESC — Expr\OrderBy // concatenates "$property $direction", so an unvalidated direction smuggles // a second OrderByItem that the DQL parser happily accepts - $entityDto = $this->createEntityDto(['displayedField']); + $entityDto = $this->createEntityDto(['displayedField' => ['type' => 'string']]); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField' => 'ASC, entity.hiddenField DESC']); @@ -327,7 +325,7 @@ public function testInvalidCustomSortFallsBackToDefaultSortForSameKey(): void { // ?sort[hiddenField]=ASC must not suppress setDefaultSort(['hiddenField' => 'DESC']): // the customSort entry is rejected, the defaultSort entry still applies - $entityDto = $this->createEntityDto(['hiddenField']); + $entityDto = $this->createEntityDto(['hiddenField' => ['type' => 'string']]); $fields = new FieldCollection([]); $searchDto = $this->createSearchDtoForSort( customSort: ['hiddenField' => 'ASC'], @@ -347,7 +345,7 @@ public function testInvalidCustomSortFallsBackToDefaultSortForSameKey(): void public function testDefaultSortByFieldAbsentFromFieldCollectionIsStillApplied(): void { // developer-supplied default sort is trusted unconditionally - $entityDto = $this->createEntityDto(['createdAt']); + $entityDto = $this->createEntityDto(['createdAt' => ['type' => 'date_time']]); $fields = new FieldCollection([]); $searchDto = $this->createSearchDtoForSort(defaultSort: ['createdAt' => 'DESC']); @@ -363,7 +361,7 @@ public function testDefaultSortByFieldAbsentFromFieldCollectionIsStillApplied(): public function testValidCustomSortOverridesDefaultSortForSameKey(): void { - $entityDto = $this->createEntityDto(['displayedField']); + $entityDto = $this->createEntityDto(['displayedField' => ['type' => 'string']]); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort( customSort: ['displayedField' => 'ASC'], @@ -380,6 +378,91 @@ public function testValidCustomSortOverridesDefaultSortForSameKey(): void $this->entityRepository->createQueryBuilder($searchDto, $entityDto, $fields, new FilterCollection()); } + public function testResolveNestedAssociationsWithSimpleProperty(): void + { + $rootEntityDto = $this->createEntityDto(['title' => ['type' => 'string']], [], 'App\Entity\Post'); + + $resolved = $this->entityRepository->resolveNestedAssociations(null, $rootEntityDto, 'title'); + + self::assertSame($rootEntityDto, $resolved['entity_dto']); + self::assertSame('entity', $resolved['entity_alias']); + self::assertSame('title', $resolved['property_name']); + } + + public function testResolveNestedAssociationsWithNestedProperty(): void + { + $authorEntityDto = $this->createEntityDto(['name' => ['type' => 'string']], [], 'App\Entity\User'); + $rootEntityDto = $this->createEntityDto([], ['author' => 'App\Entity\User'], 'App\Entity\Post'); + + $this->entityFactory->expects(self::once()) + ->method('create') + ->with('App\Entity\User') + ->willReturn($authorEntityDto); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('leftJoin') + ->with('entity.author', 'author'); + + $resolved = $this->entityRepository->resolveNestedAssociations($queryBuilder, $rootEntityDto, 'author.name'); + + self::assertSame($authorEntityDto, $resolved['entity_dto']); + self::assertSame('author', $resolved['entity_alias']); + self::assertSame('name', $resolved['property_name']); + } + + public function testResolveNestedAssociationsEndingWithAssociation(): void + { + $categoryEntityDto = $this->createEntityDto([], ['parent' => 'App\Entity\Category'], 'App\Entity\Category'); + $rootEntityDto = $this->createEntityDto([], ['category' => 'App\Entity\Category'], 'App\Entity\Post'); + + $this->entityFactory->expects(self::once()) + ->method('create') + ->with('App\Entity\Category') + ->willReturn($categoryEntityDto); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('leftJoin') + ->with('entity.category', 'category'); + + $resolved = $this->entityRepository->resolveNestedAssociations( + $queryBuilder, + $rootEntityDto, + 'category.parent', + true + ); + + self::assertSame($categoryEntityDto, $resolved['entity_dto']); + self::assertSame('category', $resolved['entity_alias']); + self::assertSame('parent', $resolved['property_name']); + } + + public function testResolveNestedAssociationsDoesNotDuplicateJoins(): void + { + $authorEntityDto = $this->createEntityDto(['name' => ['type' => 'string']], [], 'App\Entity\User'); + $rootEntityDto = $this->createEntityDto([], ['author' => 'App\Entity\User'], 'App\Entity\Post'); + + $this->entityFactory->method('create')->willReturn($authorEntityDto); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once())->method('leftJoin'); + + // This is called 2 times with the same parameters on purpose to verify it's only joined once. + $this->entityRepository->resolveNestedAssociations($queryBuilder, $rootEntityDto, 'author.name'); + $this->entityRepository->resolveNestedAssociations($queryBuilder, $rootEntityDto, 'author.name'); + } + + public function testResolveNestedAssociationsThrowsOnInvalidProperty(): void + { + $rootEntityDto = $this->createEntityDto(['title' => ['type' => 'string']], [], 'App\Entity\Post'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "invalid" property is not valid'); + + $this->entityRepository->resolveNestedAssociations(null, $rootEntityDto, 'invalid'); + } + private function createSearchDto(string $query = '', array $sort = [], ?array $appliedFilters = []): SearchDto { return new SearchDto( @@ -401,20 +484,22 @@ private function createSearchDtoForSort(array $customSort = [], array $defaultSo return new SearchDto(new Request(), null, '', $defaultSort, $customSort, []); } - /** - * @param list $mappedFields - * @param list $mappedAssociations - */ - private function createEntityDto(array $mappedFields = [], array $mappedAssociations = []): EntityDto + private function createEntityDto(array $mappedFields = [], array $mappedAssociations = [], string $fqcn = 'App\Entity\Product'): EntityDto { $metadata = $this->createMock(ClassMetadata::class); $metadata->method('getSingleIdentifierFieldName')->willReturn('id'); - $metadata->method('hasField')->willReturnCallback(static fn (string $name): bool => \in_array($name, $mappedFields, true)); - $metadata->method('hasAssociation')->willReturnCallback(static fn (string $name): bool => \in_array($name, $mappedAssociations, true)); - $metadata->method('getFieldNames')->willReturn($mappedFields); - $metadata->fieldMappings = array_fill_keys($mappedFields, []); + $metadata->method('hasField')->willReturnCallback(static fn (string $name): bool => isset($mappedFields[$name])); + $metadata->method('hasAssociation')->willReturnCallback(static fn (string $name): bool => isset($mappedAssociations[$name])); + $metadata->method('getFieldNames')->willReturn(array_keys($mappedFields)); + $metadata->method('getFieldMapping')->willReturnCallback( + static fn (string $name): array => $mappedFields[$name] ?? throw new \InvalidArgumentException() + ); + $metadata->method('getAssociationTargetClass')->willReturnCallback( + static fn (string $name): string => $mappedAssociations[$name] ?? throw new \InvalidArgumentException() + ); + $metadata->fieldMappings = $mappedFields; - return new EntityDto('App\Entity\Product', $metadata); + return new EntityDto($fqcn, $metadata); } private function createField(string $property, bool $sortable): FieldInterface @@ -446,15 +531,6 @@ private function stubEntityManager(QueryBuilder $queryBuilder): void $this->doctrine->method('getManagerForClass')->willReturn($entityManager); } - /** - * Creates a stub for EntityFactory using reflection since it's a final class. - */ - private function createEntityFactoryStub(): EntityFactory - { - return (new \ReflectionClass(EntityFactory::class)) - ->newInstanceWithoutConstructor(); - } - /** * Creates a stub for FormFactory using reflection since it's a final class. */