From 1be57a5ba8cd743ca2c098417c8e6dcd98cbd119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alfaiate?= Date: Thu, 12 Mar 2026 11:01:11 +0700 Subject: [PATCH 01/11] Fix filters on nested associations and embedded properties --- config/services.php | 1 + src/Dto/FilterDataDto.php | 18 ++- .../Configurator/EntityConfigurator.php | 9 +- src/Orm/EntityRepository.php | 137 +++++++++++------- 4 files changed, 101 insertions(+), 64 deletions(-) diff --git a/config/services.php b/config/services.php index b620fde529..a1b92bc6fe 100644 --- a/config/services.php +++ b/config/services.php @@ -330,6 +330,7 @@ ->set(EntityFilterConfigurator::class) ->arg(0, new Reference(AdminUrlGenerator::class)) + ->arg(1, service(EntityRepository::class)) ->set(LanguageFilterConfigurator::class) diff --git a/src/Dto/FilterDataDto.php b/src/Dto/FilterDataDto.php index cf8163eb21..624a8d9039 100644 --- a/src/Dto/FilterDataDto.php +++ b/src/Dto/FilterDataDto.php @@ -8,7 +8,8 @@ final class FilterDataDto { private int $index; - private string $entityAlias; + /** @var array{entity_dto: EntityDto, entity_alias: string, property_name: string} */ + private array $resolvedProperty; private FilterDto $filterDto; /** @var string */ private $comparison; @@ -20,14 +21,15 @@ private function __construct() } /** - * @param array{comparison: string, value: mixed, value2?: mixed} $formData + * @param array{comparison: string, value: mixed, value2?: mixed} $formData + * @param array{entity_dto: EntityDto, entity_alias: string, property_name: string} $resolvedProperty */ - public static function new(int $index, FilterDto $filterDto, string $entityAlias, array $formData): self + public static function new(int $index, FilterDto $filterDto, array $resolvedProperty, array $formData): self { $filterData = new self(); $filterData->index = $index; $filterData->filterDto = $filterDto; - $filterData->entityAlias = $entityAlias; + $filterData->resolvedProperty = $resolvedProperty; $filterData->comparison = $formData['comparison']; $filterData->value = $formData['value']; $filterData->value2 = $formData['value2'] ?? null; @@ -37,12 +39,12 @@ public static function new(int $index, FilterDto $filterDto, string $entityAlias public function getEntityAlias(): string { - return $this->entityAlias; + return $this->resolvedProperty['entity_alias']; } public function getProperty(): string { - return $this->filterDto->getProperty(); + return $this->resolvedProperty['property_name']; } public function getFormTypeOption(string $optionName): mixed @@ -67,11 +69,11 @@ public function getValue2(): mixed public function getParameterName(): string { - return sprintf('%s_%d', str_replace('.', '_', $this->getProperty()), $this->index); + return sprintf('%s_%d', str_replace('.', '_', $this->filterDto->getProperty()), $this->index); } public function getParameter2Name(): string { - return sprintf('%s_%d', str_replace('.', '_', $this->getProperty()), $this->index + 1); + return sprintf('%s_%d', str_replace('.', '_', $this->filterDto->getProperty()), $this->index + 1); } } diff --git a/src/Filter/Configurator/EntityConfigurator.php b/src/Filter/Configurator/EntityConfigurator.php index 4effb80682..9c5f074231 100644 --- a/src/Filter/Configurator/EntityConfigurator.php +++ b/src/Filter/Configurator/EntityConfigurator.php @@ -10,6 +10,7 @@ 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; /** @@ -20,6 +21,7 @@ final class EntityConfigurator implements FilterConfiguratorInterface { public function __construct( private AdminUrlGeneratorInterface $adminUrlGenerator, + private EntityRepository $entityRepository, ) { } @@ -30,10 +32,9 @@ public function supports(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $e public function configure(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): void { - $propertyName = $filterDto->getProperty(); - if (!$entityDto->getClassMetadata()->hasAssociation($propertyName)) { - return; - } + $resolvedProperty = $this->entityRepository->resolveNestedAssociations(null, $entityDto, $filterDto->getProperty(), true); + $entityDto = $resolvedProperty['entity_dto']; + $propertyName = $resolvedProperty['property_name']; // TODO: add the 'em' form type option too? $filterDto->setFormTypeOptionIfNotSet('value_type_options.class', $entityDto->getClassMetadata()->getAssociationTargetClass($propertyName)); diff --git a/src/Orm/EntityRepository.php b/src/Orm/EntityRepository.php index a848e726db..2ab28abdc0 100644 --- a/src/Orm/EntityRepository.php +++ b/src/Orm/EntityRepository.php @@ -21,6 +21,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; +use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\ComparisonType; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FiltersFormType; use Symfony\Component\Uid\Ulid; @@ -30,14 +31,17 @@ /** * @author Javier Eguiluz */ -final readonly class EntityRepository implements EntityRepositoryInterface +final class EntityRepository implements EntityRepositoryInterface { + /** @var array */ + private array $associationAlreadyJoined = []; + public function __construct( - private AdminContextProviderInterface $adminContextProvider, - private ManagerRegistry $doctrine, - private EntityFactory $entityFactory, - private FormFactory $formFactory, - private EventDispatcherInterface $eventDispatcher, + private readonly AdminContextProviderInterface $adminContextProvider, + private readonly ManagerRegistry $doctrine, + private readonly EntityFactory $entityFactory, + private readonly FormFactory $formFactory, + private readonly EventDispatcherInterface $eventDispatcher, ) { } @@ -232,10 +236,18 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt ]; } - /** @var string $rootAlias */ - $rootAlias = current($queryBuilder->getRootAliases()); + try { + $resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $originalPropertyName, EntityFilter::class === $filter->getFqcn()); + } catch (\InvalidArgumentException) { + // Fallback to support custom filters with unmapped property names + $resolvedProperty = [ + 'entity_dto' => $entityDto, + 'entity_alias' => current($queryBuilder->getRootAliases()), + 'property_name' => $originalPropertyName, + ]; + } - $filterDataDto = FilterDataDto::new($i, $filter, $rootAlias, $submittedData); + $filterDataDto = FilterDataDto::new($i, $filter, $resolvedProperty, $submittedData); $filter->apply($queryBuilder, $filterDataDto, $fields->getByProperty($originalPropertyName), $entityDto); ++$i; @@ -263,47 +275,11 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc $configuredSearchableProperties = $searchDto->getSearchableProperties(); $searchableProperties = (null === $configuredSearchableProperties || 0 === \count($configuredSearchableProperties)) ? $entityDto->getClassMetadata()->getFieldNames() : $configuredSearchableProperties; - $entitiesAlreadyJoined = []; foreach ($searchableProperties as $searchableProperty) { - // support arbitrarily nested associations (e.g. foo.bar.baz.qux) - $associatedProperties = explode('.', $searchableProperty); - $numAssociatedProperties = \count($associatedProperties); - $parentEntityDto = $entityDto; - $parentEntityAlias = 'entity'; - $fullPropertyName = $parentPropertyName = $associatedPropertyName = ''; - - for ($i = 0; $i < $numAssociatedProperties; ++$i) { - $associatedPropertyName = $associatedProperties[$i]; - $fullPropertyName = trim($fullPropertyName.'.'.$associatedPropertyName, '.'); - - if ($this->isAssociation($parentEntityDto, $associatedPropertyName)) { - if ($i === $numAssociatedProperties - 1) { - throw new \InvalidArgumentException(sprintf('The "%s" property included in the setSearchFields() method is not a valid search field. When using associated properties in search, you must also define the exact field used in the search (e.g. \'%s.id\', \'%s.name\', etc.)', $searchableProperty, $searchableProperty, $searchableProperty)); - } - - $associatedEntityDto = $this->entityFactory->create($parentEntityDto->getClassMetadata()->getAssociationTargetClass($associatedPropertyName)); - - if (!isset($entitiesAlreadyJoined[$fullPropertyName])) { - $aliasIndex = \count($entitiesAlreadyJoined); - $entitiesAlreadyJoined[$fullPropertyName] ??= Escaper::escapeDqlAlias($associatedPropertyName.(0 === $aliasIndex ? '' : $aliasIndex)); - $queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityAlias).'.'.$associatedPropertyName, $entitiesAlreadyJoined[$fullPropertyName]); - } - - $parentEntityDto = $associatedEntityDto; - $parentEntityAlias = $entitiesAlreadyJoined[$fullPropertyName]; - $parentPropertyName = ''; - } else { - // Normal & Embedded class properties - $associatedPropertyName = $parentPropertyName = trim($parentPropertyName.'.'.$associatedPropertyName, '.'); - } - } - - if (!isset($parentEntityDto->getClassMetadata()->fieldMappings[$associatedPropertyName])) { - throw new \InvalidArgumentException(sprintf('The "%s" property included in the setSearchFields() method is not a valid search field. The field "%s" does not exist in "%s".', $searchableProperty, $associatedPropertyName, $searchableProperty)); - } + $resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $searchableProperty); // In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties - $fieldMapping = $parentEntityDto->getClassMetadata()->getFieldMapping($associatedPropertyName); + $fieldMapping = $resolvedProperty['entity_dto']->getClassMetadata()->getFieldMapping($resolvedProperty['property_name']); // In Doctrine ORM 2.x, getFieldMapping() returns an array /** @phpstan-ignore-next-line function.impossibleType */ if (\is_array($fieldMapping)) { @@ -332,7 +308,7 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc && !$isUlidProperty && !$isJsonProperty ) { - $entityFqcn = $parentEntityDto->getFqcn(); + $entityFqcn = $resolvedProperty['entity_dto']->getFqcn(); /** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */ $idClassType = null; @@ -340,8 +316,8 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc // this is needed to handle inherited properties while (false !== $reflectionClass) { - if ($reflectionClass->hasProperty($associatedPropertyName)) { - $reflection = $reflectionClass->getProperty($associatedPropertyName); + if ($reflectionClass->hasProperty($resolvedProperty['property_name'])) { + $reflection = $reflectionClass->getProperty($resolvedProperty['property_name']); $idClassType = $reflection->getType(); break; } @@ -360,9 +336,9 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc } $searchablePropertiesConfig[] = [ - 'entity_name' => $parentEntityAlias, + 'entity_name' => $resolvedProperty['entity_alias'], 'property_data_type' => $propertyDataType, - 'property_name' => $associatedPropertyName, + 'property_name' => $resolvedProperty['property_name'], 'is_boolean' => $isBoolean, 'is_small_integer' => $isSmallIntegerProperty, 'is_integer' => $isIntegerProperty, @@ -377,6 +353,63 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc return $searchablePropertiesConfig; } + /** + * Support arbitrarily nested associations (e.g. foo.bar.baz.qux). + * + * @return array{ + * entity_dto: EntityDto, + * entity_alias: string, + * property_name: string, + * } + */ + public function resolveNestedAssociations(?QueryBuilder $queryBuilder, EntityDto $rootEntityDto, string $propertyName, bool $mustEndWithAssociation = false): array + { + $associatedProperties = explode('.', $propertyName); + $numAssociatedProperties = \count($associatedProperties); + $resolvedEntityDto = $rootEntityDto; + $parentEntityAlias = 'entity'; + $fullPropertyName = $compoundPropertyName = $resolvedPropertyName = ''; + + for ($i = 0; $i < $numAssociatedProperties; ++$i) { + $resolvedPropertyName = trim($compoundPropertyName.'.'.$associatedProperties[$i], '.'); + $fullPropertyName = trim($fullPropertyName.'.'.$resolvedPropertyName, '.'); + + if ($this->isAssociation($resolvedEntityDto, $resolvedPropertyName)) { + if ($i === $numAssociatedProperties - 1) { + if (!$mustEndWithAssociation) { + throw new \InvalidArgumentException(sprintf('The "%s" property is not valid. When using associated properties, you must also define the exact field to target (e.g. "%s.id", "%s.name", etc.)', $propertyName, $propertyName, $propertyName)); + } + + // Skip join when the last property is an association + continue; + } + + if (isset($queryBuilder) && !isset($this->associationAlreadyJoined[$fullPropertyName])) { + $aliasIndex = \count($this->associationAlreadyJoined); + $this->associationAlreadyJoined[$fullPropertyName] ??= Escaper::escapeDqlAlias($resolvedPropertyName.(0 === $aliasIndex ? '' : $aliasIndex)); + $queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityAlias).'.'.$resolvedPropertyName, $this->associationAlreadyJoined[$fullPropertyName]); + } + + $parentEntityAlias = $this->associationAlreadyJoined[$fullPropertyName] ?? null; + $resolvedEntityDto = $this->entityFactory->create($resolvedEntityDto->getClassMetadata()->getAssociationTargetClass($resolvedPropertyName)); + $compoundPropertyName = ''; + } else { + // Normal & Embedded class properties + $compoundPropertyName = $resolvedPropertyName; + } + } + + if (!$mustEndWithAssociation && !isset($resolvedEntityDto->getClassMetadata()->fieldMappings[$resolvedPropertyName])) { + throw new \InvalidArgumentException(sprintf('The "%s" property is not valid. The field "%s" does not exist in "%s".', $propertyName, $resolvedPropertyName, $propertyName)); + } + + return [ + 'entity_dto' => $resolvedEntityDto, + 'entity_alias' => $parentEntityAlias, + 'property_name' => $resolvedPropertyName, + ]; + } + private function isAssociation(EntityDto $entityDto, string $propertyName): bool { $propertyNameParts = explode('.', $propertyName, 2); From 5d228a11882e56f8cc1a1214e5555e399c60a510 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 11:51:13 +0200 Subject: [PATCH 02/11] Add tests for EntityRepository and EntityConfigurator --- .../Factory/EntityFactoryInterface.php | 15 ++ .../Orm/EntityRepositoryInterface.php | 1 + src/Factory/EntityFactory.php | 3 +- .../Configurator/EntityConfigurator.php | 4 +- src/Orm/EntityRepository.php | 5 +- .../Configurator/EntityConfiguratorTest.php | 64 +++++++++ tests/Unit/Orm/EntityRepositoryTest.php | 128 +++++++++++++++--- 7 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 src/Contracts/Factory/EntityFactoryInterface.php create mode 100644 tests/Unit/Filter/Configurator/EntityConfiguratorTest.php diff --git a/src/Contracts/Factory/EntityFactoryInterface.php b/src/Contracts/Factory/EntityFactoryInterface.php new file mode 100644 index 0000000000..be4a7e3c52 --- /dev/null +++ b/src/Contracts/Factory/EntityFactoryInterface.php @@ -0,0 +1,15 @@ + */ -final readonly class EntityFactory +final readonly class EntityFactory implements EntityFactoryInterface { public function __construct( private AuthorizationCheckerInterface $authorizationChecker, 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 2ab28abdc0..d4595391e3 100644 --- a/src/Orm/EntityRepository.php +++ b/src/Orm/EntityRepository.php @@ -5,20 +5,19 @@ 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; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; +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; @@ -39,7 +38,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/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 bbb302a7bb..da55cea9de 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -8,6 +8,7 @@ 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\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; @@ -24,6 +25,7 @@ class EntityRepositoryTest extends TestCase private ManagerRegistry $doctrine; private EventDispatcherInterface $eventDispatcher; private EntityRepository $entityRepository; + private EntityFactoryInterface $entityFactory; protected function setUp(): void { @@ -33,13 +35,13 @@ protected function setUp(): void // 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 ); @@ -225,6 +227,108 @@ public function testCreateQueryBuilderWithSearchQueryAttemptsToGetConnection(): $this->assertSame($queryBuilder, $result); } + public function testResolveNestedAssociationsWithSimpleProperty(): void + { + $rootEntityDto = $this->createEntityDto('App\Entity\Post', ['title' => ['type' => 'string']], []); + + $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('App\Entity\User', ['name' => ['type' => 'string']], []); + $rootEntityDto = $this->createEntityDto('App\Entity\Post', [], ['author' => 'App\Entity\User']); + + $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('App\Entity\Category', [], ['parent' => 'App\Entity\Category']); + $rootEntityDto = $this->createEntityDto('App\Entity\Post', [], ['category' => 'App\Entity\Category']); + + $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('App\Entity\User', ['name' => ['type' => 'string']], []); + $rootEntityDto = $this->createEntityDto('App\Entity\Post', [], ['author' => 'App\Entity\User']); + + $this->entityFactory->method('create')->willReturn($authorEntityDto); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once())->method('leftJoin'); + + $this->entityRepository->resolveNestedAssociations($queryBuilder, $rootEntityDto, 'author.name'); + $this->entityRepository->resolveNestedAssociations($queryBuilder, $rootEntityDto, 'author.name'); + } + + public function testResolveNestedAssociationsThrowsOnInvalidProperty(): void + { + $rootEntityDto = $this->createEntityDto('App\Entity\Post', ['title' => ['type' => 'string']], []); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "invalid" property is not valid'); + + $this->entityRepository->resolveNestedAssociations(null, $rootEntityDto, 'invalid'); + } + + private function createEntityDto(string $fqcn = 'App\Entity\Product', array $fieldMappings = [], array $associations = []): EntityDto + { + $classMetadata = $this->createMock(ClassMetadata::class); + $classMetadata->fieldMappings = $fieldMappings; + $classMetadata->method('getFieldNames')->willReturn(array_keys($fieldMappings)); + $classMetadata->method('getFieldMapping')->willReturnCallback( + static fn (string $name): array => $fieldMappings[$name] ?? throw new \InvalidArgumentException() + ); + $classMetadata->method('hasAssociation')->willReturnCallback( + static fn (string $name): bool => isset($associations[$name]) + ); + $classMetadata->method('getAssociationTargetClass')->willReturnCallback( + static fn (string $name): string => $associations[$name] ?? throw new \InvalidArgumentException() + ); + + return new EntityDto($fqcn, $classMetadata); + } + private function createSearchDto(string $query = '', array $sort = [], ?array $appliedFilters = []): SearchDto { return new SearchDto( @@ -237,26 +341,6 @@ private function createSearchDto(string $query = '', array $sort = [], ?array $a ); } - private function createEntityDto(): EntityDto - { - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getSingleIdentifierFieldName')->willReturn('id'); - $metadata->method('hasAssociation')->willReturn(false); - $metadata->method('getFieldNames')->willReturn([]); - $metadata->fieldMappings = []; - - return new EntityDto('App\Entity\Product', $metadata); - } - - /** - * 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. */ From 2ab74f41f47fbbc9c7a96bcf50b4d53afbfa9450 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 12:01:48 +0200 Subject: [PATCH 03/11] Import #7584 into main PR --- config/services.php | 1 + src/Field/Configurator/AssociationConfigurator.php | 9 ++++++++- .../Configurator/AssociationConfiguratorTest.php | 12 ++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/config/services.php b/config/services.php index a1b92bc6fe..01a66b8cc4 100644 --- a/config/services.php +++ b/config/services.php @@ -372,6 +372,7 @@ ->arg(2, service('request_stack')) ->arg(3, service(ControllerFactory::class)) ->arg(4, new Reference(FieldFactory::class)) + ->arg(5, service(EntityRepository::class)) ->set(AvatarConfigurator::class) diff --git a/src/Field/Configurator/AssociationConfigurator.php b/src/Field/Configurator/AssociationConfigurator.php index 0d8c5d9f97..3c52f7e395 100644 --- a/src/Field/Configurator/AssociationConfigurator.php +++ b/src/Field/Configurator/AssociationConfigurator.php @@ -20,6 +20,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 Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; @@ -38,6 +39,7 @@ public function __construct( private RequestStack $requestStack, private ControllerFactory $controllerFactory, private FieldFactory $fieldFactory, + private EAEntityRepository $entityRepository, ) { } @@ -49,6 +51,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)); @@ -56,7 +63,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/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php b/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php index a672ca2629..1162877564 100644 --- a/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php +++ b/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php @@ -13,6 +13,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\Tests\Functional\Apps\DefaultApp\Controller\ProjectDomain\DeveloperCrudController; use EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\DefaultApp\Controller\ProjectDomain\ProjectCrudController; @@ -43,6 +44,7 @@ protected function setUp(): void static::getContainer()->get(RequestStack::class), static::getContainer()->get(ControllerFactory::class), static::getContainer()->get(FieldFactory::class), + static::getContainer()->get(EntityRepository::class), ); } @@ -90,6 +92,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 */ From 74470dca99d1377efa6485eff15ded1cf41b68b0 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 12:49:57 +0200 Subject: [PATCH 04/11] Import #7585 into main PR --- config/services.php | 1 + .../Configurator/CollectionConfigurator.php | 24 +++++++++++++------ .../src/Entity/ProjectDomain/Developer.php | 20 ++++++++++++++++ .../src/Entity/ProjectDomain/ProjectIssue.php | 13 ++++++++++ .../CollectionConfiguratorTest.php | 14 +++++++++++ 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/config/services.php b/config/services.php index 01a66b8cc4..a45ccb7354 100644 --- a/config/services.php +++ b/config/services.php @@ -433,6 +433,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/Field/Configurator/CollectionConfigurator.php b/src/Field/Configurator/CollectionConfigurator.php index 6dcdb623ea..3201851c90 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, ) { } @@ -125,8 +127,15 @@ private function countNumElements(mixed $collection): int private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): void { + + $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())); } @@ -135,14 +144,15 @@ 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) + && $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)) { + $targetCrudControllerFqcn = $context->getAdminControllers()->findCrudControllerByEntity($entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)); if (null === $targetCrudControllerFqcn) { return; @@ -152,14 +162,14 @@ private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, Ad } $editEntityDto = $this->createEntityDto( - $entityDto->getClassMetadata()->getAssociationTargetClass($fieldDto->getProperty()), + $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty), $targetCrudControllerFqcn, Action::EDIT, $fieldDto->getCustomOption(CollectionField::OPTION_ENTRY_CRUD_EDIT_PAGE_NAME) ?? Crud::PAGE_EDIT, Crud::PAGE_EDIT, ); $newEntityDto = $this->createEntityDto( - $entityDto->getClassMetadata()->getAssociationTargetClass($fieldDto->getProperty()), + $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty), $targetCrudControllerFqcn, Action::NEW, $fieldDto->getCustomOption(CollectionField::OPTION_ENTRY_CRUD_NEW_PAGE_NAME) ?? Crud::PAGE_NEW, 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/CollectionConfiguratorTest.php b/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php index 02f5b03f1c..51692912f8 100644 --- a/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php +++ b/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php @@ -47,6 +47,20 @@ public static function fields(): \Generator yield [CollectionField::new('projectIssues')]; yield [CollectionField::new('favouriteProjectOf')]; yield [CollectionField::new('projectTags')]; + yield [CollectionField::new('metaData')]; + } + + public function testA(): 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, controllerFqcn: ProjectCrudController::class); } /** From b633b5d3b95a71b1b1d4ae8732d9a73ba6ebe4d1 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 13:38:33 +0200 Subject: [PATCH 05/11] Fix tests after merge --- .../Apps/AdminRouteApp/config/reference.php | 5 ++--- .../Configurator/AssociationConfiguratorTest.php | 1 + .../Configurator/CollectionConfiguratorTest.php | 4 ++-- tests/Unit/Orm/EntityRepositoryTest.php | 14 ++++++++------ 4 files changed, 13 insertions(+), 11 deletions(-) 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/Unit/Field/Configurator/AssociationConfiguratorTest.php b/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php index f7f5417517..f1fe3336e4 100644 --- a/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php +++ b/tests/Unit/Field/Configurator/AssociationConfiguratorTest.php @@ -278,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 2b458b0402..b37887225b 100644 --- a/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php +++ b/tests/Unit/Field/Configurator/CollectionConfiguratorTest.php @@ -51,7 +51,7 @@ public static function fields(): \Generator yield [CollectionField::new('metaData')]; } - public function testA(): void + public function testNestedCollections(): void { $field = CollectionField::new('leadDeveloper.issues'); $field->setCustomOption(CollectionField::OPTION_ENTRY_USES_CRUD_FORM, true); @@ -61,7 +61,7 @@ public function testA(): void '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, controllerFqcn: ProjectCrudController::class); + $this->configure($field, pageName: Crud::PAGE_EDIT, controllerFqcn: ProjectCrudController::class); } /** diff --git a/tests/Unit/Orm/EntityRepositoryTest.php b/tests/Unit/Orm/EntityRepositoryTest.php index 0e11dd8f16..24af18d870 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -466,19 +466,21 @@ public function testResolveNestedAssociationsThrowsOnInvalidProperty(): void $this->entityRepository->resolveNestedAssociations(null, $rootEntityDto, 'invalid'); } - private function createEntityDto(string $fqcn = 'App\Entity\Product', array $fieldMappings = [], array $associations = []): EntityDto + private function createEntityDto(string $fqcn = 'App\Entity\Product', array $mappedFields = [], array $mappedAssociations = []): EntityDto { $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->fieldMappings = $fieldMappings; - $classMetadata->method('getFieldNames')->willReturn(array_keys($fieldMappings)); + $classMetadata->fieldMappings = $mappedFields; + $classMetadata->method('getSingleIdentifierFieldName')->willReturn('id'); + $classMetadata->method('hasField')->willReturnCallback(static fn (string $name): bool => \in_array($name, $mappedFields, true)); + $classMetadata->method('getFieldNames')->willReturn(array_keys($mappedFields)); $classMetadata->method('getFieldMapping')->willReturnCallback( - static fn (string $name): array => $fieldMappings[$name] ?? throw new \InvalidArgumentException() + static fn (string $name): array => $mappedFields[$name] ?? throw new \InvalidArgumentException() ); $classMetadata->method('hasAssociation')->willReturnCallback( - static fn (string $name): bool => isset($associations[$name]) + static fn (string $name): bool => isset($mappedAssociations[$name]) ); $classMetadata->method('getAssociationTargetClass')->willReturnCallback( - static fn (string $name): string => $associations[$name] ?? throw new \InvalidArgumentException() + static fn (string $name): string => $mappedAssociations[$name] ?? throw new \InvalidArgumentException() ); return new EntityDto($fqcn, $classMetadata); From 662033fb6478891fff3ebeb9113591aed68db8a2 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 14:17:41 +0200 Subject: [PATCH 06/11] remove outdated comment, change parameter order for createEntityDto and moved it back down --- tests/Unit/Orm/EntityRepositoryTest.php | 77 ++++++++++++------------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/tests/Unit/Orm/EntityRepositoryTest.php b/tests/Unit/Orm/EntityRepositoryTest.php index 24af18d870..e6c9fcc038 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -34,9 +34,6 @@ 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 $this->entityFactory = $this->createMock(EntityFactoryInterface::class); $formFactory = $this->createFormFactoryStub(); @@ -231,7 +228,7 @@ public function testCreateQueryBuilderWithSearchQueryAttemptsToGetConnection(): public function testCustomSortByExposedSortableFieldIsApplied(): void { - $entityDto = $this->createEntityDto('App\Entity\Product', ['displayedField']); + $entityDto = $this->createEntityDto(['displayedField']); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField' => 'ASC']); @@ -249,7 +246,7 @@ public function testCustomSortByFieldAbsentFromFieldCollectionIsIgnored(): void { // simulates ?sort[hiddenField]=ASC against a controller whose // configureFields(INDEX) doesn't expose `hiddenField` - $entityDto = $this->createEntityDto('App\Entity\Product', ['hiddenField']); + $entityDto = $this->createEntityDto(['hiddenField']); $fields = new FieldCollection([]); $searchDto = $this->createSearchDtoForSort(customSort: ['hiddenField' => 'ASC']); @@ -263,7 +260,7 @@ public function testCustomSortByFieldAbsentFromFieldCollectionIsIgnored(): void public function testCustomSortByExplicitlyNonSortableFieldIsIgnored(): void { - $entityDto = $this->createEntityDto('App\Entity\Product', ['displayedField']); + $entityDto = $this->createEntityDto(['displayedField']); $fields = new FieldCollection([$this->createField('displayedField', false)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField' => 'ASC']); @@ -278,7 +275,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('App\Entity\Product', ['displayedField']); + $entityDto = $this->createEntityDto(['displayedField']); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField,entity.hiddenField' => 'ASC']); @@ -295,7 +292,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('App\Entity\Product', [], ['customer']); + $entityDto = $this->createEntityDto([], ['customer']); $fields = new FieldCollection([$this->createField('customer', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['customer.secretField' => 'ASC']); @@ -313,7 +310,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('App\Entity\Product', ['displayedField']); + $entityDto = $this->createEntityDto(['displayedField']); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort(customSort: ['displayedField' => 'ASC, entity.hiddenField DESC']); @@ -329,7 +326,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('App\Entity\Product', ['hiddenField']); + $entityDto = $this->createEntityDto(['hiddenField']); $fields = new FieldCollection([]); $searchDto = $this->createSearchDtoForSort( customSort: ['hiddenField' => 'ASC'], @@ -349,7 +346,7 @@ public function testInvalidCustomSortFallsBackToDefaultSortForSameKey(): void public function testDefaultSortByFieldAbsentFromFieldCollectionIsStillApplied(): void { // developer-supplied default sort is trusted unconditionally - $entityDto = $this->createEntityDto('App\Entity\Product', ['createdAt']); + $entityDto = $this->createEntityDto(['createdAt']); $fields = new FieldCollection([]); $searchDto = $this->createSearchDtoForSort(defaultSort: ['createdAt' => 'DESC']); @@ -365,7 +362,7 @@ public function testDefaultSortByFieldAbsentFromFieldCollectionIsStillApplied(): public function testValidCustomSortOverridesDefaultSortForSameKey(): void { - $entityDto = $this->createEntityDto('App\Entity\Product', ['displayedField']); + $entityDto = $this->createEntityDto(['displayedField']); $fields = new FieldCollection([$this->createField('displayedField', true)]); $searchDto = $this->createSearchDtoForSort( customSort: ['displayedField' => 'ASC'], @@ -384,7 +381,7 @@ public function testValidCustomSortOverridesDefaultSortForSameKey(): void public function testResolveNestedAssociationsWithSimpleProperty(): void { - $rootEntityDto = $this->createEntityDto('App\Entity\Post', ['title' => ['type' => 'string']], []); + $rootEntityDto = $this->createEntityDto(['title' => ['type' => 'string']], [], 'App\Entity\Post'); $resolved = $this->entityRepository->resolveNestedAssociations(null, $rootEntityDto, 'title'); @@ -395,8 +392,8 @@ public function testResolveNestedAssociationsWithSimpleProperty(): void public function testResolveNestedAssociationsWithNestedProperty(): void { - $authorEntityDto = $this->createEntityDto('App\Entity\User', ['name' => ['type' => 'string']], []); - $rootEntityDto = $this->createEntityDto('App\Entity\Post', [], ['author' => 'App\Entity\User']); + $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') @@ -417,8 +414,8 @@ public function testResolveNestedAssociationsWithNestedProperty(): void public function testResolveNestedAssociationsEndingWithAssociation(): void { - $categoryEntityDto = $this->createEntityDto('App\Entity\Category', [], ['parent' => 'App\Entity\Category']); - $rootEntityDto = $this->createEntityDto('App\Entity\Post', [], ['category' => 'App\Entity\Category']); + $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') @@ -444,8 +441,8 @@ public function testResolveNestedAssociationsEndingWithAssociation(): void public function testResolveNestedAssociationsDoesNotDuplicateJoins(): void { - $authorEntityDto = $this->createEntityDto('App\Entity\User', ['name' => ['type' => 'string']], []); - $rootEntityDto = $this->createEntityDto('App\Entity\Post', [], ['author' => 'App\Entity\User']); + $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); @@ -458,7 +455,7 @@ public function testResolveNestedAssociationsDoesNotDuplicateJoins(): void public function testResolveNestedAssociationsThrowsOnInvalidProperty(): void { - $rootEntityDto = $this->createEntityDto('App\Entity\Post', ['title' => ['type' => 'string']], []); + $rootEntityDto = $this->createEntityDto(['title' => ['type' => 'string']], [], 'App\Entity\Post'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The "invalid" property is not valid'); @@ -466,26 +463,6 @@ public function testResolveNestedAssociationsThrowsOnInvalidProperty(): void $this->entityRepository->resolveNestedAssociations(null, $rootEntityDto, 'invalid'); } - private function createEntityDto(string $fqcn = 'App\Entity\Product', array $mappedFields = [], array $mappedAssociations = []): EntityDto - { - $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->fieldMappings = $mappedFields; - $classMetadata->method('getSingleIdentifierFieldName')->willReturn('id'); - $classMetadata->method('hasField')->willReturnCallback(static fn (string $name): bool => \in_array($name, $mappedFields, true)); - $classMetadata->method('getFieldNames')->willReturn(array_keys($mappedFields)); - $classMetadata->method('getFieldMapping')->willReturnCallback( - static fn (string $name): array => $mappedFields[$name] ?? throw new \InvalidArgumentException() - ); - $classMetadata->method('hasAssociation')->willReturnCallback( - static fn (string $name): bool => isset($mappedAssociations[$name]) - ); - $classMetadata->method('getAssociationTargetClass')->willReturnCallback( - static fn (string $name): string => $mappedAssociations[$name] ?? throw new \InvalidArgumentException() - ); - - return new EntityDto($fqcn, $classMetadata); - } - private function createSearchDto(string $query = '', array $sort = [], ?array $appliedFilters = []): SearchDto { return new SearchDto( @@ -507,6 +484,26 @@ private function createSearchDtoForSort(array $customSort = [], array $defaultSo return new SearchDto(new Request(), null, '', $defaultSort, $customSort, []); } + private function createEntityDto(array $mappedFields = [], array $mappedAssociations = [], string $fqcn = 'App\Entity\Product'): EntityDto + { + $classMetadata = $this->createMock(ClassMetadata::class); + $classMetadata->fieldMappings = $mappedFields; + $classMetadata->method('getSingleIdentifierFieldName')->willReturn('id'); + $classMetadata->method('hasField')->willReturnCallback(static fn (string $name): bool => \in_array($name, $mappedFields, true)); + $classMetadata->method('getFieldNames')->willReturn(array_keys($mappedFields)); + $classMetadata->method('getFieldMapping')->willReturnCallback( + static fn (string $name): array => $mappedFields[$name] ?? throw new \InvalidArgumentException() + ); + $classMetadata->method('hasAssociation')->willReturnCallback( + static fn (string $name): bool => isset($mappedAssociations[$name]) + ); + $classMetadata->method('getAssociationTargetClass')->willReturnCallback( + static fn (string $name): string => $mappedAssociations[$name] ?? throw new \InvalidArgumentException() + ); + + return new EntityDto($fqcn, $classMetadata); + } + private function createField(string $property, bool $sortable): FieldInterface { $field = Field::new($property); From a144fba6d69ba441495101e2e9acec679ebc1748 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 15:10:42 +0200 Subject: [PATCH 07/11] fix parameters for createEntityDto and reorder it --- tests/Unit/Orm/EntityRepositoryTest.php | 47 ++++++++++--------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/tests/Unit/Orm/EntityRepositoryTest.php b/tests/Unit/Orm/EntityRepositoryTest.php index e6c9fcc038..3d34058649 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -228,7 +228,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']); @@ -246,7 +246,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']); @@ -260,7 +260,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']); @@ -275,7 +275,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']); @@ -292,7 +292,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']); @@ -310,7 +310,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']); @@ -326,7 +326,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'], @@ -346,7 +346,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']); @@ -362,7 +362,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'], @@ -486,22 +486,20 @@ private function createSearchDtoForSort(array $customSort = [], array $defaultSo private function createEntityDto(array $mappedFields = [], array $mappedAssociations = [], string $fqcn = 'App\Entity\Product'): EntityDto { - $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->fieldMappings = $mappedFields; - $classMetadata->method('getSingleIdentifierFieldName')->willReturn('id'); - $classMetadata->method('hasField')->willReturnCallback(static fn (string $name): bool => \in_array($name, $mappedFields, true)); - $classMetadata->method('getFieldNames')->willReturn(array_keys($mappedFields)); - $classMetadata->method('getFieldMapping')->willReturnCallback( + $metadata = $this->createMock(ClassMetadata::class); + $metadata->method('getSingleIdentifierFieldName')->willReturn('id'); + $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() ); - $classMetadata->method('hasAssociation')->willReturnCallback( - static fn (string $name): bool => isset($mappedAssociations[$name]) - ); - $classMetadata->method('getAssociationTargetClass')->willReturnCallback( + $metadata->method('getAssociationTargetClass')->willReturnCallback( static fn (string $name): string => $mappedAssociations[$name] ?? throw new \InvalidArgumentException() ); + $metadata->fieldMappings = $mappedFields; - return new EntityDto($fqcn, $classMetadata); + return new EntityDto($fqcn, $metadata); } private function createField(string $property, bool $sortable): FieldInterface @@ -533,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. */ From 4cdded87af7ce49901df5bee423836a2bdc3d4ee Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 15:35:24 +0200 Subject: [PATCH 08/11] minor cleanup --- src/Filter/Configurator/EntityConfigurator.php | 1 - tests/Unit/Orm/EntityRepositoryTest.php | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Filter/Configurator/EntityConfigurator.php b/src/Filter/Configurator/EntityConfigurator.php index 3823b87581..1f641ee0e4 100644 --- a/src/Filter/Configurator/EntityConfigurator.php +++ b/src/Filter/Configurator/EntityConfigurator.php @@ -11,7 +11,6 @@ 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; /** diff --git a/tests/Unit/Orm/EntityRepositoryTest.php b/tests/Unit/Orm/EntityRepositoryTest.php index 3d34058649..6dddde5726 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -449,6 +449,7 @@ public function testResolveNestedAssociationsDoesNotDuplicateJoins(): void $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'); } From 4d2ab8c22a0fb8c9ca9c8bc9123f71e3a0faaef8 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 15:46:06 +0200 Subject: [PATCH 09/11] Fix Phpstan issues --- src/Contracts/Factory/EntityFactoryInterface.php | 14 ++++++++++++++ src/Contracts/Orm/EntityRepositoryInterface.php | 8 ++++++++ src/Field/Configurator/CollectionConfigurator.php | 3 +-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Contracts/Factory/EntityFactoryInterface.php b/src/Contracts/Factory/EntityFactoryInterface.php index be4a7e3c52..3360f5ae42 100644 --- a/src/Contracts/Factory/EntityFactoryInterface.php +++ b/src/Contracts/Factory/EntityFactoryInterface.php @@ -9,7 +9,21 @@ interface EntityFactoryInterface { + /** + * @param class-string $entityFqcn + */ public function create(string $entityFqcn, mixed $entityId = null, string|Expression|null $entityPermission = null): EntityDto; + public function createForEntityInstance(object $entityInstance): EntityDto; + /** + * @param iterable|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 f6007af346..5f61d2438d 100644 --- a/src/Contracts/Orm/EntityRepositoryInterface.php +++ b/src/Contracts/Orm/EntityRepositoryInterface.php @@ -14,5 +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/Field/Configurator/CollectionConfigurator.php b/src/Field/Configurator/CollectionConfigurator.php index ef9f8aab04..4d5fa6513f 100644 --- a/src/Field/Configurator/CollectionConfigurator.php +++ b/src/Field/Configurator/CollectionConfigurator.php @@ -167,8 +167,7 @@ private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, Ad 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') - && $entityDtoResolved->getClassMetadata()->hasAssociation($resolvedProperty) - && $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)) { + && $entityDtoResolved->getClassMetadata()->hasAssociation($resolvedProperty)) { $targetCrudControllerFqcn = $context->getAdminControllers()->findCrudControllerByEntity($entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty)); if (null === $targetCrudControllerFqcn) { From f9add62e3c7f4d6ea5d389ce67a333ff99622eba Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 15:48:45 +0200 Subject: [PATCH 10/11] Fix PHP-Linter (I hope) --- src/Field/Configurator/CollectionConfigurator.php | 2 -- tests/Unit/Orm/EntityRepositoryTest.php | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Field/Configurator/CollectionConfigurator.php b/src/Field/Configurator/CollectionConfigurator.php index 4d5fa6513f..55f7b0930c 100644 --- a/src/Field/Configurator/CollectionConfigurator.php +++ b/src/Field/Configurator/CollectionConfigurator.php @@ -144,7 +144,6 @@ 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']; @@ -179,7 +178,6 @@ private function configureEntryType(FieldDto $fieldDto, EntityDto $entityDto, Ad $targetEntityFqcn = $entityDtoResolved->getClassMetadata()->getAssociationTargetClass($resolvedProperty); - $editEntityDto = $this->createEntityDto( $targetEntityFqcn, $targetCrudControllerFqcn, diff --git a/tests/Unit/Orm/EntityRepositoryTest.php b/tests/Unit/Orm/EntityRepositoryTest.php index 6dddde5726..effb5447ad 100644 --- a/tests/Unit/Orm/EntityRepositoryTest.php +++ b/tests/Unit/Orm/EntityRepositoryTest.php @@ -8,12 +8,11 @@ use Doctrine\Persistence\ManagerRegistry; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; -use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; 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; @@ -449,7 +448,7 @@ public function testResolveNestedAssociationsDoesNotDuplicateJoins(): void $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 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'); } From 624061bc3b07a9f854668ce6684f76fd58a8f598 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Sat, 4 Jul 2026 15:58:21 +0200 Subject: [PATCH 11/11] Add missing linebreaks --- src/Contracts/Factory/EntityFactoryInterface.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Contracts/Factory/EntityFactoryInterface.php b/src/Contracts/Factory/EntityFactoryInterface.php index 3360f5ae42..7af151b7fb 100644 --- a/src/Contracts/Factory/EntityFactoryInterface.php +++ b/src/Contracts/Factory/EntityFactoryInterface.php @@ -13,11 +13,14 @@ interface EntityFactoryInterface * @param class-string $entityFqcn */ public function create(string $entityFqcn, mixed $entityId = null, string|Expression|null $entityPermission = null): EntityDto; + public function createForEntityInstance(object $entityInstance): EntityDto; + /** * @param iterable|null $entityInstances */ public function createCollection(EntityDto $entityDto, ?iterable $entityInstances): EntityCollection; + /** * @template TEntity of object *