Skip to content

Commit ab21802

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

13 files changed

+267
-40
lines changed

src/Factory/FilterFactory.php

Lines changed: 44 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,23 @@ public function __construct(
6766
public function create(FilterConfigDto $filterConfig, FieldCollection $fields, EntityDto $entityDto): FilterCollection
6867
{
6968
$builtFilters = [];
69+
70+
// First, flatten any nested arrays created by KeyValueStore's dot notation handling
71+
$flattenedFilters = $this->flattenFilterArray($filterConfig->all());
72+
7073
/** @var FilterInterface|string $filter */
71-
foreach ($filterConfig->all() as $property => $filter) {
74+
foreach ($flattenedFilters as $property => $filter) {
7275
if (\is_string($filter)) {
7376
$guessedFilterClass = $this->guessFilterClass($entityDto, $property);
7477
/** @var FilterInterface $filter */
7578
$filter = $guessedFilterClass::new($property);
7679
}
7780

81+
// Skip if filter is not a valid FilterInterface instance
82+
if (!$filter instanceof FilterInterface) {
83+
continue;
84+
}
85+
7886
$filterDto = $filter->getAsDto();
7987

8088
$context = $this->adminContextProvider->getContext();
@@ -94,8 +102,42 @@ public function create(FilterConfigDto $filterConfig, FieldCollection $fields, E
94102
return FilterCollection::new($builtFilters);
95103
}
96104

105+
/**
106+
* Flattens nested arrays created by KeyValueStore's dot notation handling.
107+
* For example, ['author' => ['country' => FilterObject]] becomes ['author.country' => FilterObject].
108+
*
109+
* @param array<string, mixed> $filters
110+
*
111+
* @return array<string, FilterInterface|string>
112+
*/
113+
private function flattenFilterArray(array $filters, string $prefix = ''): array
114+
{
115+
$flattened = [];
116+
117+
foreach ($filters as $key => $value) {
118+
$fullKey = '' === $prefix ? $key : $prefix.'.'.$key;
119+
120+
if (\is_array($value)) {
121+
// Recursively flatten nested arrays
122+
$flattened = array_merge($flattened, $this->flattenFilterArray($value, $fullKey));
123+
} else {
124+
// This is a filter (string or FilterInterface)
125+
$flattened[$fullKey] = $value;
126+
}
127+
}
128+
129+
return $flattened;
130+
}
131+
97132
private function guessFilterClass(EntityDto $entityDto, string $propertyName): string
98133
{
134+
// Handle association field filters like "author.country"
135+
// For nested properties with dot notation, we default to TextFilter
136+
// The actual filter type should be explicitly specified by the developer
137+
if (str_contains($propertyName, '.')) {
138+
return TextFilter::class;
139+
}
140+
99141
if ($entityDto->getClassMetadata()->hasAssociation($propertyName)) {
100142
return EntityFilter::class;
101143
}

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: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,27 @@ 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+
// Handle association field filters (e.g., "author.createdAt")
46+
if (str_contains($property, '.')) {
47+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
48+
49+
if (ComparisonType::BETWEEN === $comparison) {
50+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name))
51+
->setParameter($parameterName, $value)
52+
->setParameter($parameter2Name, $value2);
53+
} else {
54+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
55+
->setParameter($parameterName, $value);
56+
}
4957
} else {
50-
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
51-
->setParameter($parameterName, $value);
58+
if (ComparisonType::BETWEEN === $comparison) {
59+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
60+
->setParameter($parameterName, $value)
61+
->setParameter($parameter2Name, $value2);
62+
} else {
63+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
64+
->setParameter($parameterName, $value);
65+
}
5266
}
5367
}
5468
}

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,52 @@ 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); // The actual property to filter on
112+
$currentAlias = $rootAlias;
113+
114+
// Create joins for each association in the path
115+
foreach ($parts as $index => $associationName) {
116+
// Create a unique alias for this join using the parameter name to avoid conflicts
117+
$joinAlias = sprintf('%s_%s_%d', $associationName, $parameterName, $index);
118+
$joinPath = sprintf('%s.%s', $currentAlias, $associationName);
119+
120+
// Check if this join already exists to avoid duplicate joins
121+
$existingJoins = $queryBuilder->getDQLPart('join');
122+
$joinExists = false;
123+
124+
foreach ($existingJoins as $joins) {
125+
foreach ($joins as $join) {
126+
if ($join->getJoin() === $joinPath && $join->getAlias() === $joinAlias) {
127+
$joinExists = true;
128+
break 2;
129+
}
130+
}
131+
}
132+
133+
// Only add the join if it doesn't already exist
134+
if (!$joinExists) {
135+
$queryBuilder->leftJoin($joinPath, $joinAlias);
136+
}
137+
138+
// Update current alias for the next iteration
139+
$currentAlias = $joinAlias;
140+
}
141+
142+
return [$currentAlias, $finalProperty];
143+
}
96144
}

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: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,27 @@ 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+
// Handle association field filters (e.g., "author.age")
54+
if (str_contains($property, '.')) {
55+
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
56+
57+
if (ComparisonType::BETWEEN === $comparison) {
58+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name))
59+
->setParameter($parameterName, $value)
60+
->setParameter($parameter2Name, $value2);
61+
} else {
62+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
63+
->setParameter($parameterName, $value);
64+
}
5765
} else {
58-
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
59-
->setParameter($parameterName, $value);
66+
if (ComparisonType::BETWEEN === $comparison) {
67+
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
68+
->setParameter($parameterName, $value)
69+
->setParameter($parameter2Name, $value2);
70+
} else {
71+
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
72+
->setParameter($parameterName, $value);
73+
}
6074
}
6175
}
6276
}

0 commit comments

Comments
 (0)