diff --git a/src/Factory/FilterFactory.php b/src/Factory/FilterFactory.php index 9d7336815f..5ddebfd0f6 100644 --- a/src/Factory/FilterFactory.php +++ b/src/Factory/FilterFactory.php @@ -3,7 +3,6 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Factory; use Doctrine\DBAL\Types\Types; -use Doctrine\ORM\Mapping\FieldMapping; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface; @@ -67,14 +66,20 @@ public function __construct( public function create(FilterConfigDto $filterConfig, FieldCollection $fields, EntityDto $entityDto): FilterCollection { $builtFilters = []; + $flattenedFilters = $this->flattenFilterArray($filterConfig->all()); + /** @var FilterInterface|string $filter */ - foreach ($filterConfig->all() as $property => $filter) { + foreach ($flattenedFilters as $property => $filter) { if (\is_string($filter)) { $guessedFilterClass = $this->guessFilterClass($entityDto, $property); /** @var FilterInterface $filter */ $filter = $guessedFilterClass::new($property); } + if (!$filter instanceof FilterInterface) { + continue; + } + $filterDto = $filter->getAsDto(); $context = $this->adminContextProvider->getContext(); @@ -94,8 +99,37 @@ public function create(FilterConfigDto $filterConfig, FieldCollection $fields, E return FilterCollection::new($builtFilters); } + /** + * Flattens nested arrays created by KeyValueStore's dot notation handling. + * For example, ['author' => ['country' => FilterObject]] becomes ['author.country' => FilterObject]. + * + * @param array $filters + * + * @return array + */ + private function flattenFilterArray(array $filters, string $prefix = ''): array + { + $flattened = []; + + foreach ($filters as $key => $value) { + $fullKey = '' === $prefix ? $key : $prefix.'.'.$key; + + if (\is_array($value)) { + $flattened = array_merge($flattened, $this->flattenFilterArray($value, $fullKey)); + } else { + $flattened[$fullKey] = $value; + } + } + + return $flattened; + } + private function guessFilterClass(EntityDto $entityDto, string $propertyName): string { + if (str_contains($propertyName, '.')) { + return TextFilter::class; + } + if ($entityDto->getClassMetadata()->hasAssociation($propertyName)) { return EntityFilter::class; } diff --git a/src/Filter/ArrayFilter.php b/src/Filter/ArrayFilter.php index c9de5900ca..33fcedec9c 100644 --- a/src/Filter/ArrayFilter.php +++ b/src/Filter/ArrayFilter.php @@ -73,18 +73,27 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $useQuotes = Types::SIMPLE_ARRAY === $fieldDto->getDoctrineMetadata()->get('type'); + $aliasToUse = $alias; + $propertyToUse = $property; + + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $aliasToUse = $joinAlias; + $propertyToUse = $propertyPath; + } + if (null === $value || [] === $value) { - $queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison)); + $queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison)); } else { $clause = ComparisonType::CONTAINS_ALL === $comparison ? new Andx() : new Orx(); $comparison = ComparisonType::CONTAINS_ALL === $comparison ? 'LIKE' : $comparison; foreach ($value as $key => $item) { $itemParameterName = sprintf('%s_%s', $parameterName, $key); - $clause->add(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $itemParameterName)); + $clause->add(sprintf('%s.%s %s :%s', $aliasToUse, $propertyToUse, $comparison, $itemParameterName)); $queryBuilder->setParameter($itemParameterName, $useQuotes ? '%"'.$item.'"%' : '%'.$item.'%'); } if (ComparisonType::NOT_CONTAINS === $comparison) { - $clause->add(sprintf('%s.%s IS NULL', $alias, $property)); + $clause->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse)); } $queryBuilder->andWhere($clause); } diff --git a/src/Filter/BooleanFilter.php b/src/Filter/BooleanFilter.php index 75e5ce7df5..fbeb26bcb5 100644 --- a/src/Filter/BooleanFilter.php +++ b/src/Filter/BooleanFilter.php @@ -33,8 +33,19 @@ public static function new(string $propertyName, $label = null): self public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void { - $queryBuilder - ->andWhere(sprintf('%s.%s %s :%s', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty(), $filterDataDto->getComparison(), $filterDataDto->getParameterName())) - ->setParameter($filterDataDto->getParameterName(), $filterDataDto->getValue()); + $alias = $filterDataDto->getEntityAlias(); + $property = $filterDataDto->getProperty(); + $comparison = $filterDataDto->getComparison(); + $parameterName = $filterDataDto->getParameterName(); + $value = $filterDataDto->getValue(); + + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName)); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)); + } + + $queryBuilder->setParameter($parameterName, $value); } } diff --git a/src/Filter/ChoiceFilter.php b/src/Filter/ChoiceFilter.php index 456d817406..54412c9bd9 100644 --- a/src/Filter/ChoiceFilter.php +++ b/src/Filter/ChoiceFilter.php @@ -77,13 +77,22 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $value = $filterDataDto->getValue(); $isMultiple = (bool) $filterDataDto->getFormTypeOption('value_type_options.multiple'); + $aliasToUse = $alias; + $propertyToUse = $property; + + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $aliasToUse = $joinAlias; + $propertyToUse = $propertyPath; + } + if (null === $value || ($isMultiple && 0 === \count($value))) { - $queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison)); + $queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison)); } else { $orX = new Orx(); - $orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName)); + $orX->add(sprintf('%s.%s %s (:%s)', $aliasToUse, $propertyToUse, $comparison, $parameterName)); if (ComparisonType::NEQ === $comparison || 'NOT IN' === $comparison) { - $orX->add(sprintf('%s.%s IS NULL', $alias, $property)); + $orX->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse)); } $queryBuilder->andWhere($orX) ->setParameter($parameterName, $value); diff --git a/src/Filter/ComparisonFilter.php b/src/Filter/ComparisonFilter.php index 910b374acf..d842dd1299 100644 --- a/src/Filter/ComparisonFilter.php +++ b/src/Filter/ComparisonFilter.php @@ -39,7 +39,13 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $parameterName = $filterDataDto->getParameterName(); $value = $filterDataDto->getValue(); - $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) - ->setParameter($parameterName, $value); + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName)); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)); + } + + $queryBuilder->setParameter($parameterName, $value); } } diff --git a/src/Filter/DateTimeFilter.php b/src/Filter/DateTimeFilter.php index 6ae22e81d2..8c5e03005a 100644 --- a/src/Filter/DateTimeFilter.php +++ b/src/Filter/DateTimeFilter.php @@ -42,13 +42,26 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $value = $filterDataDto->getValue(); $value2 = $filterDataDto->getValue2(); - if (ComparisonType::BETWEEN === $comparison) { - $queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name)) - ->setParameter($parameterName, $value) - ->setParameter($parameter2Name, $value2); + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + + if (ComparisonType::BETWEEN === $comparison) { + $queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name)) + ->setParameter($parameterName, $value) + ->setParameter($parameter2Name, $value2); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName)) + ->setParameter($parameterName, $value); + } } else { - $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) - ->setParameter($parameterName, $value); + if (ComparisonType::BETWEEN === $comparison) { + $queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name)) + ->setParameter($parameterName, $value) + ->setParameter($parameter2Name, $value2); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) + ->setParameter($parameterName, $value); + } } } } diff --git a/src/Filter/EntityFilter.php b/src/Filter/EntityFilter.php index 2dc2fc44ea..52e6cf80b8 100644 --- a/src/Filter/EntityFilter.php +++ b/src/Filter/EntityFilter.php @@ -61,11 +61,37 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $value = $filterDataDto->getValue(); $isMultiple = (bool) $filterDataDto->getFormTypeOption('value_type_options.multiple'); - if ($entityDto->getClassMetadata()->isCollectionValuedAssociation($property)) { + $aliasToUse = $alias; + $propertyToUse = $property; + + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $aliasToUse = $joinAlias; + $propertyToUse = $propertyPath; + } + + $classMetadata = $entityDto->getClassMetadata(); + if (str_contains($property, '.')) { + $em = $queryBuilder->getEntityManager(); + $metadata = $classMetadata; + $parts = explode('.', $property); + $lastProperty = array_pop($parts); + foreach ($parts as $association) { + if (!$metadata->hasAssociation($association)) { + break; + } + $targetClass = $metadata->getAssociationTargetClass($association); + $metadata = $em->getClassMetadata($targetClass); + } + $classMetadata = $metadata; + $propertyToUse = $lastProperty; + } + + if ($classMetadata->hasAssociation($propertyToUse) && $classMetadata->isCollectionValuedAssociation($propertyToUse)) { // the 'ea_' prefix is needed to avoid errors when using reserved words as assocAlias ('order', 'group', etc.) // see https://github.com/EasyCorp/EasyAdminBundle/pull/4344 $assocAlias = 'ea_'.$filterDataDto->getParameterName(); - $queryBuilder->leftJoin(sprintf('%s.%s', $alias, $property), $assocAlias); + $queryBuilder->leftJoin(sprintf('%s.%s', $aliasToUse, $propertyToUse), $assocAlias); if (0 === \count($value)) { $queryBuilder->andWhere(sprintf('%s %s', $assocAlias, $comparison)); @@ -79,12 +105,12 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value)); } } elseif (null === $value || ($isMultiple && 0 === \count($value))) { - $queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison)); + $queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison)); } else { $orX = new Orx(); - $orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName)); + $orX->add(sprintf('%s.%s %s (:%s)', $aliasToUse, $propertyToUse, $comparison, $parameterName)); if (ComparisonType::NEQ === $comparison) { - $orX->add(sprintf('%s.%s IS NULL', $alias, $property)); + $orX->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse)); } $queryBuilder->andWhere($orX) ->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value)); diff --git a/src/Filter/FilterTrait.php b/src/Filter/FilterTrait.php index 78f4c6709e..c389af3185 100644 --- a/src/Filter/FilterTrait.php +++ b/src/Filter/FilterTrait.php @@ -93,4 +93,46 @@ public function getAsDto(): FilterDto { return $this->dto; } + + /** + * Creates JOIN clauses for association field filters (e.g., "author.country"). + * Handles nested associations by creating multiple joins if needed. + * + * @param QueryBuilder $queryBuilder The query builder instance + * @param string $rootAlias The root entity alias (e.g., "entity") + * @param string $propertyPath The full property path (e.g., "author.country" or "author.address.city") + * @param string $parameterName Unique parameter name for the filter + * + * @return array{0: string, 1: string} Returns [joinAlias, finalProperty] + */ + protected function createJoinForAssociationFilter(QueryBuilder $queryBuilder, string $rootAlias, string $propertyPath, string $parameterName): array + { + $parts = explode('.', $propertyPath); + $finalProperty = array_pop($parts); + $currentAlias = $rootAlias; + + foreach ($parts as $index => $associationName) { + $joinAlias = sprintf('%s_%s_%d', $associationName, $parameterName, $index); + $joinPath = sprintf('%s.%s', $currentAlias, $associationName); + $existingJoins = $queryBuilder->getDQLPart('join'); + $joinExists = false; + + foreach ($existingJoins as $joins) { + foreach ($joins as $join) { + if ($join->getJoin() === $joinPath && $join->getAlias() === $joinAlias) { + $joinExists = true; + break 2; + } + } + } + + if (!$joinExists) { + $queryBuilder->leftJoin($joinPath, $joinAlias); + } + + $currentAlias = $joinAlias; + } + + return [$currentAlias, $finalProperty]; + } } diff --git a/src/Filter/NullFilter.php b/src/Filter/NullFilter.php index 1972675c3a..a37bd4cf8b 100644 --- a/src/Filter/NullFilter.php +++ b/src/Filter/NullFilter.php @@ -59,8 +59,16 @@ public function setChoiceLabels(string|TranslatableInterface $nullChoiceLabel, s public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void { + $alias = $filterDataDto->getEntityAlias(); + $property = $filterDataDto->getProperty(); + $parameterName = $filterDataDto->getParameterName(); $comparison = self::CHOICE_VALUE_NULL === $filterDataDto->getValue() ? 'IS' : 'IS NOT'; - $queryBuilder - ->andWhere(sprintf('%s.%s %s NULL', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty(), $comparison)); + + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $queryBuilder->andWhere(sprintf('%s.%s %s NULL', $joinAlias, $propertyPath, $comparison)); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s NULL', $alias, $property, $comparison)); + } } } diff --git a/src/Filter/NumericFilter.php b/src/Filter/NumericFilter.php index c70844b234..781e11eff0 100644 --- a/src/Filter/NumericFilter.php +++ b/src/Filter/NumericFilter.php @@ -50,13 +50,26 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $value2 *= $divisor; } - if (ComparisonType::BETWEEN === $comparison) { - $queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name)) - ->setParameter($parameterName, $value) - ->setParameter($parameter2Name, $value2); + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + + if (ComparisonType::BETWEEN === $comparison) { + $queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name)) + ->setParameter($parameterName, $value) + ->setParameter($parameter2Name, $value2); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName)) + ->setParameter($parameterName, $value); + } } else { - $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) - ->setParameter($parameterName, $value); + if (ComparisonType::BETWEEN === $comparison) { + $queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name)) + ->setParameter($parameterName, $value) + ->setParameter($parameter2Name, $value2); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) + ->setParameter($parameterName, $value); + } } } } diff --git a/src/Filter/TextFilter.php b/src/Filter/TextFilter.php index ca894158f2..bc42a752e6 100644 --- a/src/Filter/TextFilter.php +++ b/src/Filter/TextFilter.php @@ -39,7 +39,13 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $parameterName = $filterDataDto->getParameterName(); $value = $filterDataDto->getValue(); - $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) - ->setParameter($parameterName, $value); + if (str_contains($property, '.')) { + [$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName); + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName)) + ->setParameter($parameterName, $value); + } else { + $queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName)) + ->setParameter($parameterName, $value); + } } } diff --git a/src/Form/Type/FiltersFormType.php b/src/Form/Type/FiltersFormType.php index e56be31c6a..983d3a4b90 100644 --- a/src/Form/Type/FiltersFormType.php +++ b/src/Form/Type/FiltersFormType.php @@ -15,10 +15,17 @@ class FiltersFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + $nameMapping = []; + /** @var FilterDto $filter */ - foreach ($options['ea_filters'] as $filter) { - $builder->add($filter->getProperty(), $filter->getFormType(), $filter->getFormTypeOptions()); + foreach ($options['ea_filters'] as $filterName => $filter) { + $normalizedName = str_replace('.', '_', $filterName); + $nameMapping[$normalizedName] = $filterName; + + $builder->add($normalizedName, $filter->getFormType(), $filter->getFormTypeOptions()); } + + $builder->setAttribute('ea_filter_name_mapping', $nameMapping); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Orm/EntityRepository.php b/src/Orm/EntityRepository.php index a5cd54243a..2f5222b417 100644 --- a/src/Orm/EntityRepository.php +++ b/src/Orm/EntityRepository.php @@ -207,13 +207,16 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt } $appliedFilters = $searchDto->getAppliedFilters(); + $nameMapping = $filtersForm->getConfig()->getAttribute('ea_filter_name_mapping', []); + $i = 0; foreach ($filtersForm as $filterForm) { - $propertyName = $filterForm->getName(); + $normalizedName = $filterForm->getName(); + $originalFilterName = $nameMapping[$normalizedName] ?? $normalizedName; - $filter = $configuredFilters->get($propertyName); + $filter = $configuredFilters->get($originalFilterName); // this filter is not defined or not applied - if (null === $filter || !isset($appliedFilters[$propertyName])) { + if (null === $filter || (!isset($appliedFilters[$normalizedName]) && !isset($appliedFilters[$originalFilterName]))) { continue; } @@ -234,7 +237,16 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt $rootAlias = current($queryBuilder->getRootAliases()); $filterDataDto = FilterDataDto::new($i, $filter, $rootAlias, $submittedData); - $filter->apply($queryBuilder, $filterDataDto, $fields->getByProperty($propertyName), $entityDto); + $filterProperty = $filter->getProperty(); + $fieldDto = null; + if (str_contains($filterProperty, '.')) { + $baseProperty = explode('.', $filterProperty)[0]; + $fieldDto = $fields->getByProperty($baseProperty); + } else { + $fieldDto = $fields->getByProperty($filterProperty); + } + + $filter->apply($queryBuilder, $filterDataDto, $fieldDto, $entityDto); ++$i; }