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..b57ed566b4 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,21 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt ]; } - /** @var string $rootAlias */ - $rootAlias = current($queryBuilder->getRootAliases()); + if (false !== $filterForm->getConfig()->getOption('mapped')) { + try { + $resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $originalPropertyName, EntityFilter::class === $filter->getFqcn()); + } catch (\InvalidArgumentException $exception) { + throw new \InvalidArgumentException(sprintf('%s If your filter is unmapped, you must set the "mapped" option to false.', $exception->getMessage())); + } + } else { + $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 +278,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 +311,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 +319,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 +339,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 +356,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);