Skip to content

Commit 3b1ecad

Browse files
committed
feature Support association path filters with automatic joins
1 parent 05ce8f1 commit 3b1ecad

13 files changed

+236
-40
lines changed

src/Factory/FilterFactory.php

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace EasyCorp\Bundle\EasyAdminBundle\Factory;
44

55
use Doctrine\DBAL\Types\Types;
6-
use Doctrine\ORM\Mapping\FieldMapping;
76
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
87
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
98
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface;
@@ -67,14 +66,20 @@ public function __construct(
6766
public function create(FilterConfigDto $filterConfig, FieldCollection $fields, EntityDto $entityDto): FilterCollection
6867
{
6968
$builtFilters = [];
69+
$flattenedFilters = $this->flattenFilterArray($filterConfig->all());
70+
7071
/** @var FilterInterface|string $filter */
71-
foreach ($filterConfig->all() as $property => $filter) {
72+
foreach ($flattenedFilters as $property => $filter) {
7273
if (\is_string($filter)) {
7374
$guessedFilterClass = $this->guessFilterClass($entityDto, $property);
7475
/** @var FilterInterface $filter */
7576
$filter = $guessedFilterClass::new($property);
7677
}
7778

79+
if (!$filter instanceof FilterInterface) {
80+
continue;
81+
}
82+
7883
$filterDto = $filter->getAsDto();
7984

8085
$context = $this->adminContextProvider->getContext();
@@ -94,8 +99,37 @@ public function create(FilterConfigDto $filterConfig, FieldCollection $fields, E
9499
return FilterCollection::new($builtFilters);
95100
}
96101

102+
/**
103+
* Flattens nested arrays created by KeyValueStore's dot notation handling.
104+
* For example, ['author' => ['country' => FilterObject]] becomes ['author.country' => FilterObject].
105+
*
106+
* @param array<string, mixed> $filters
107+
*
108+
* @return array<string, FilterInterface|string>
109+
*/
110+
private function flattenFilterArray(array $filters, string $prefix = ''): array
111+
{
112+
$flattened = [];
113+
114+
foreach ($filters as $key => $value) {
115+
$fullKey = '' === $prefix ? $key : $prefix.'.'.$key;
116+
117+
if (\is_array($value)) {
118+
$flattened = array_merge($flattened, $this->flattenFilterArray($value, $fullKey));
119+
} else {
120+
$flattened[$fullKey] = $value;
121+
}
122+
}
123+
124+
return $flattened;
125+
}
126+
97127
private function guessFilterClass(EntityDto $entityDto, string $propertyName): string
98128
{
129+
if (str_contains($propertyName, '.')) {
130+
return TextFilter::class;
131+
}
132+
99133
if ($entityDto->getClassMetadata()->hasAssociation($propertyName)) {
100134
return EntityFilter::class;
101135
}

src/Filter/ArrayFilter.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,27 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
7373

7474
$useQuotes = Types::SIMPLE_ARRAY === $fieldDto->getDoctrineMetadata()->get('type');
7575

76+
$aliasToUse = $alias;
77+
$propertyToUse = $property;
78+
79+
if (str_contains($property, '.')) {
80+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
81+
$aliasToUse = $joinAlias;
82+
$propertyToUse = $propertyPath;
83+
}
84+
7685
if (null === $value || [] === $value) {
77-
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
86+
$queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison));
7887
} else {
7988
$clause = ComparisonType::CONTAINS_ALL === $comparison ? new Andx() : new Orx();
8089
$comparison = ComparisonType::CONTAINS_ALL === $comparison ? 'LIKE' : $comparison;
8190
foreach ($value as $key => $item) {
8291
$itemParameterName = sprintf('%s_%s', $parameterName, $key);
83-
$clause->add(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $itemParameterName));
92+
$clause->add(sprintf('%s.%s %s :%s', $aliasToUse, $propertyToUse, $comparison, $itemParameterName));
8493
$queryBuilder->setParameter($itemParameterName, $useQuotes ? '%"'.$item.'"%' : '%'.$item.'%');
8594
}
8695
if (ComparisonType::NOT_CONTAINS === $comparison) {
87-
$clause->add(sprintf('%s.%s IS NULL', $alias, $property));
96+
$clause->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse));
8897
}
8998
$queryBuilder->andWhere($clause);
9099
}

src/Filter/BooleanFilter.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,19 @@ public static function new(string $propertyName, $label = null): self
3333

3434
public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
3535
{
36-
$queryBuilder
37-
->andWhere(sprintf('%s.%s %s :%s', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty(), $filterDataDto->getComparison(), $filterDataDto->getParameterName()))
38-
->setParameter($filterDataDto->getParameterName(), $filterDataDto->getValue());
36+
$alias = $filterDataDto->getEntityAlias();
37+
$property = $filterDataDto->getProperty();
38+
$comparison = $filterDataDto->getComparison();
39+
$parameterName = $filterDataDto->getParameterName();
40+
$value = $filterDataDto->getValue();
41+
42+
if (str_contains($property, '.')) {
43+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
44+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName));
45+
} else {
46+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName));
47+
}
48+
49+
$queryBuilder->setParameter($parameterName, $value);
3950
}
4051
}

src/Filter/ChoiceFilter.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,22 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
7777
$value = $filterDataDto->getValue();
7878
$isMultiple = (bool) $filterDataDto->getFormTypeOption('value_type_options.multiple');
7979

80+
$aliasToUse = $alias;
81+
$propertyToUse = $property;
82+
83+
if (str_contains($property, '.')) {
84+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
85+
$aliasToUse = $joinAlias;
86+
$propertyToUse = $propertyPath;
87+
}
88+
8089
if (null === $value || ($isMultiple && 0 === \count($value))) {
81-
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
90+
$queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison));
8291
} else {
8392
$orX = new Orx();
84-
$orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName));
93+
$orX->add(sprintf('%s.%s %s (:%s)', $aliasToUse, $propertyToUse, $comparison, $parameterName));
8594
if (ComparisonType::NEQ === $comparison || 'NOT IN' === $comparison) {
86-
$orX->add(sprintf('%s.%s IS NULL', $alias, $property));
95+
$orX->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse));
8796
}
8897
$queryBuilder->andWhere($orX)
8998
->setParameter($parameterName, $value);

src/Filter/ComparisonFilter.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
3939
$parameterName = $filterDataDto->getParameterName();
4040
$value = $filterDataDto->getValue();
4141

42-
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
43-
->setParameter($parameterName, $value);
42+
if (str_contains($property, '.')) {
43+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
44+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName));
45+
} else {
46+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName));
47+
}
48+
49+
$queryBuilder->setParameter($parameterName, $value);
4450
}
4551
}

src/Filter/DateTimeFilter.php

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,26 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
4242
$value = $filterDataDto->getValue();
4343
$value2 = $filterDataDto->getValue2();
4444

45-
if (ComparisonType::BETWEEN === $comparison) {
46-
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
47-
->setParameter($parameterName, $value)
48-
->setParameter($parameter2Name, $value2);
45+
if (str_contains($property, '.')) {
46+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
47+
48+
if (ComparisonType::BETWEEN === $comparison) {
49+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name))
50+
->setParameter($parameterName, $value)
51+
->setParameter($parameter2Name, $value2);
52+
} else {
53+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
54+
->setParameter($parameterName, $value);
55+
}
4956
} else {
50-
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
51-
->setParameter($parameterName, $value);
57+
if (ComparisonType::BETWEEN === $comparison) {
58+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
59+
->setParameter($parameterName, $value)
60+
->setParameter($parameter2Name, $value2);
61+
} else {
62+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
63+
->setParameter($parameterName, $value);
64+
}
5265
}
5366
}
5467
}

src/Filter/EntityFilter.php

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,37 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
6161
$value = $filterDataDto->getValue();
6262
$isMultiple = (bool) $filterDataDto->getFormTypeOption('value_type_options.multiple');
6363

64-
if ($entityDto->getClassMetadata()->isCollectionValuedAssociation($property)) {
64+
$aliasToUse = $alias;
65+
$propertyToUse = $property;
66+
67+
if (str_contains($property, '.')) {
68+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
69+
$aliasToUse = $joinAlias;
70+
$propertyToUse = $propertyPath;
71+
}
72+
73+
$classMetadata = $entityDto->getClassMetadata();
74+
if (str_contains($property, '.')) {
75+
$em = $queryBuilder->getEntityManager();
76+
$metadata = $classMetadata;
77+
$parts = explode('.', $property);
78+
$lastProperty = array_pop($parts);
79+
foreach ($parts as $association) {
80+
if (!$metadata->hasAssociation($association)) {
81+
break;
82+
}
83+
$targetClass = $metadata->getAssociationTargetClass($association);
84+
$metadata = $em->getClassMetadata($targetClass);
85+
}
86+
$classMetadata = $metadata;
87+
$propertyToUse = $lastProperty;
88+
}
89+
90+
if ($classMetadata->hasAssociation($propertyToUse) && $classMetadata->isCollectionValuedAssociation($propertyToUse)) {
6591
// the 'ea_' prefix is needed to avoid errors when using reserved words as assocAlias ('order', 'group', etc.)
6692
// see https://github.com/EasyCorp/EasyAdminBundle/pull/4344
6793
$assocAlias = 'ea_'.$filterDataDto->getParameterName();
68-
$queryBuilder->leftJoin(sprintf('%s.%s', $alias, $property), $assocAlias);
94+
$queryBuilder->leftJoin(sprintf('%s.%s', $aliasToUse, $propertyToUse), $assocAlias);
6995

7096
if (0 === \count($value)) {
7197
$queryBuilder->andWhere(sprintf('%s %s', $assocAlias, $comparison));
@@ -79,12 +105,12 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
79105
->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value));
80106
}
81107
} elseif (null === $value || ($isMultiple && 0 === \count($value))) {
82-
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
108+
$queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison));
83109
} else {
84110
$orX = new Orx();
85-
$orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName));
111+
$orX->add(sprintf('%s.%s %s (:%s)', $aliasToUse, $propertyToUse, $comparison, $parameterName));
86112
if (ComparisonType::NEQ === $comparison) {
87-
$orX->add(sprintf('%s.%s IS NULL', $alias, $property));
113+
$orX->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse));
88114
}
89115
$queryBuilder->andWhere($orX)
90116
->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value));

src/Filter/FilterTrait.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,46 @@ public function getAsDto(): FilterDto
9393
{
9494
return $this->dto;
9595
}
96+
97+
/**
98+
* Creates JOIN clauses for association field filters (e.g., "author.country").
99+
* Handles nested associations by creating multiple joins if needed.
100+
*
101+
* @param QueryBuilder $queryBuilder The query builder instance
102+
* @param string $rootAlias The root entity alias (e.g., "entity")
103+
* @param string $propertyPath The full property path (e.g., "author.country" or "author.address.city")
104+
* @param string $parameterName Unique parameter name for the filter
105+
*
106+
* @return array{0: string, 1: string} Returns [joinAlias, finalProperty]
107+
*/
108+
protected function createJoinForAssociationFilter(QueryBuilder $queryBuilder, string $rootAlias, string $propertyPath, string $parameterName): array
109+
{
110+
$parts = explode('.', $propertyPath);
111+
$finalProperty = array_pop($parts);
112+
$currentAlias = $rootAlias;
113+
114+
foreach ($parts as $index => $associationName) {
115+
$joinAlias = sprintf('%s_%s_%d', $associationName, $parameterName, $index);
116+
$joinPath = sprintf('%s.%s', $currentAlias, $associationName);
117+
$existingJoins = $queryBuilder->getDQLPart('join');
118+
$joinExists = false;
119+
120+
foreach ($existingJoins as $joins) {
121+
foreach ($joins as $join) {
122+
if ($join->getJoin() === $joinPath && $join->getAlias() === $joinAlias) {
123+
$joinExists = true;
124+
break 2;
125+
}
126+
}
127+
}
128+
129+
if (!$joinExists) {
130+
$queryBuilder->leftJoin($joinPath, $joinAlias);
131+
}
132+
133+
$currentAlias = $joinAlias;
134+
}
135+
136+
return [$currentAlias, $finalProperty];
137+
}
96138
}

src/Filter/NullFilter.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,16 @@ public function setChoiceLabels(string|TranslatableInterface $nullChoiceLabel, s
5959

6060
public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
6161
{
62+
$alias = $filterDataDto->getEntityAlias();
63+
$property = $filterDataDto->getProperty();
64+
$parameterName = $filterDataDto->getParameterName();
6265
$comparison = self::CHOICE_VALUE_NULL === $filterDataDto->getValue() ? 'IS' : 'IS NOT';
63-
$queryBuilder
64-
->andWhere(sprintf('%s.%s %s NULL', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty(), $comparison));
66+
67+
if (str_contains($property, '.')) {
68+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
69+
$queryBuilder->andWhere(sprintf('%s.%s %s NULL', $joinAlias, $propertyPath, $comparison));
70+
} else {
71+
$queryBuilder->andWhere(sprintf('%s.%s %s NULL', $alias, $property, $comparison));
72+
}
6573
}
6674
}

src/Filter/NumericFilter.php

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,26 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
5050
$value2 *= $divisor;
5151
}
5252

53-
if (ComparisonType::BETWEEN === $comparison) {
54-
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
55-
->setParameter($parameterName, $value)
56-
->setParameter($parameter2Name, $value2);
53+
if (str_contains($property, '.')) {
54+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
55+
56+
if (ComparisonType::BETWEEN === $comparison) {
57+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name))
58+
->setParameter($parameterName, $value)
59+
->setParameter($parameter2Name, $value2);
60+
} else {
61+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
62+
->setParameter($parameterName, $value);
63+
}
5764
} else {
58-
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
59-
->setParameter($parameterName, $value);
65+
if (ComparisonType::BETWEEN === $comparison) {
66+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
67+
->setParameter($parameterName, $value)
68+
->setParameter($parameter2Name, $value2);
69+
} else {
70+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
71+
->setParameter($parameterName, $value);
72+
}
6073
}
6174
}
6275
}

0 commit comments

Comments
 (0)