Skip to content

Commit 9fbf27a

Browse files
committed
Fix filters on nested associations and embedded properties
1 parent 640e870 commit 9fbf27a

4 files changed

Lines changed: 94 additions & 64 deletions

File tree

config/services.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@
330330

331331
->set(EntityFilterConfigurator::class)
332332
->arg(0, new Reference(AdminUrlGenerator::class))
333+
->arg(1, service(EntityRepository::class))
333334

334335
->set(LanguageFilterConfigurator::class)
335336

src/Dto/FilterDataDto.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
final class FilterDataDto
99
{
1010
private int $index;
11-
private string $entityAlias;
11+
/** @var array{entity_dto: EntityDto, entity_alias: string, property_name: string} */
12+
private array $resolvedProperty;
1213
private FilterDto $filterDto;
1314
/** @var string */
1415
private $comparison;
@@ -20,14 +21,15 @@ private function __construct()
2021
}
2122

2223
/**
23-
* @param array{comparison: string, value: mixed, value2?: mixed} $formData
24+
* @param array{comparison: string, value: mixed, value2?: mixed} $formData
25+
* @param array{entity_dto: EntityDto, entity_alias: string, property_name: string} $resolvedProperty
2426
*/
25-
public static function new(int $index, FilterDto $filterDto, string $entityAlias, array $formData): self
27+
public static function new(int $index, FilterDto $filterDto, array $resolvedProperty, array $formData): self
2628
{
2729
$filterData = new self();
2830
$filterData->index = $index;
2931
$filterData->filterDto = $filterDto;
30-
$filterData->entityAlias = $entityAlias;
32+
$filterData->resolvedProperty = $resolvedProperty;
3133
$filterData->comparison = $formData['comparison'];
3234
$filterData->value = $formData['value'];
3335
$filterData->value2 = $formData['value2'] ?? null;
@@ -37,12 +39,12 @@ public static function new(int $index, FilterDto $filterDto, string $entityAlias
3739

3840
public function getEntityAlias(): string
3941
{
40-
return $this->entityAlias;
42+
return $this->resolvedProperty['entity_alias'];
4143
}
4244

4345
public function getProperty(): string
4446
{
45-
return $this->filterDto->getProperty();
47+
return $this->resolvedProperty['property_name'];
4648
}
4749

4850
public function getFormTypeOption(string $optionName): mixed
@@ -67,11 +69,11 @@ public function getValue2(): mixed
6769

6870
public function getParameterName(): string
6971
{
70-
return sprintf('%s_%d', str_replace('.', '_', $this->getProperty()), $this->index);
72+
return sprintf('%s_%d', str_replace('.', '_', $this->filterDto->getProperty()), $this->index);
7173
}
7274

7375
public function getParameter2Name(): string
7476
{
75-
return sprintf('%s_%d', str_replace('.', '_', $this->getProperty()), $this->index + 1);
77+
return sprintf('%s_%d', str_replace('.', '_', $this->filterDto->getProperty()), $this->index + 1);
7678
}
7779
}

src/Filter/Configurator/EntityConfigurator.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDto;
1111
use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
1212
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudAutocompleteType;
13+
use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository;
1314
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
1415

1516
/**
@@ -20,6 +21,7 @@ final class EntityConfigurator implements FilterConfiguratorInterface
2021
{
2122
public function __construct(
2223
private AdminUrlGeneratorInterface $adminUrlGenerator,
24+
private EntityRepository $entityRepository,
2325
) {
2426
}
2527

@@ -31,12 +33,12 @@ public function supports(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $e
3133
public function configure(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): void
3234
{
3335
$propertyName = $filterDto->getProperty();
34-
if (!$entityDto->getClassMetadata()->hasAssociation($propertyName)) {
35-
return;
36-
}
36+
37+
$resolvedProperty = $this->entityRepository->resolveNestedAssociations(null, $entityDto, $filterDto->getProperty(), true);
38+
$entityDto = $resolvedProperty['entity_dto'];
3739

3840
// TODO: add the 'em' form type option too?
39-
$filterDto->setFormTypeOptionIfNotSet('value_type_options.class', $entityDto->getClassMetadata()->getAssociationTargetClass($propertyName));
41+
$filterDto->setFormTypeOptionIfNotSet('value_type_options.class', $entityDto->getFqcn());
4042
$filterDto->setFormTypeOptionIfNotSet('value_type_options.multiple', $entityDto->getClassMetadata()->isCollectionValuedAssociation($propertyName));
4143
$filterDto->setFormTypeOptionIfNotSet('value_type_options.attr.data-ea-widget', 'ea-autocomplete');
4244

src/Orm/EntityRepository.php

Lines changed: 77 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
2222
use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory;
2323
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
24+
use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
2425
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\ComparisonType;
2526
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FiltersFormType;
2627
use Symfony\Component\Uid\Ulid;
@@ -30,14 +31,17 @@
3031
/**
3132
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
3233
*/
33-
final readonly class EntityRepository implements EntityRepositoryInterface
34+
final class EntityRepository implements EntityRepositoryInterface
3435
{
36+
/** @var array<string, string> */
37+
private array $associationAlreadyJoined = [];
38+
3539
public function __construct(
36-
private AdminContextProviderInterface $adminContextProvider,
37-
private ManagerRegistry $doctrine,
38-
private EntityFactory $entityFactory,
39-
private FormFactory $formFactory,
40-
private EventDispatcherInterface $eventDispatcher,
40+
private readonly AdminContextProviderInterface $adminContextProvider,
41+
private readonly ManagerRegistry $doctrine,
42+
private readonly EntityFactory $entityFactory,
43+
private readonly FormFactory $formFactory,
44+
private readonly EventDispatcherInterface $eventDispatcher,
4145
) {
4246
}
4347

@@ -232,10 +236,9 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt
232236
];
233237
}
234238

235-
/** @var string $rootAlias */
236-
$rootAlias = current($queryBuilder->getRootAliases());
239+
$resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $originalPropertyName, EntityFilter::class === $filter->getFqcn());
237240

238-
$filterDataDto = FilterDataDto::new($i, $filter, $rootAlias, $submittedData);
241+
$filterDataDto = FilterDataDto::new($i, $filter, $resolvedProperty, $submittedData);
239242
$filter->apply($queryBuilder, $filterDataDto, $fields->getByProperty($originalPropertyName), $entityDto);
240243

241244
++$i;
@@ -263,47 +266,11 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
263266
$configuredSearchableProperties = $searchDto->getSearchableProperties();
264267
$searchableProperties = (null === $configuredSearchableProperties || 0 === \count($configuredSearchableProperties)) ? $entityDto->getClassMetadata()->getFieldNames() : $configuredSearchableProperties;
265268

266-
$entitiesAlreadyJoined = [];
267269
foreach ($searchableProperties as $searchableProperty) {
268-
// support arbitrarily nested associations (e.g. foo.bar.baz.qux)
269-
$associatedProperties = explode('.', $searchableProperty);
270-
$numAssociatedProperties = \count($associatedProperties);
271-
$parentEntityDto = $entityDto;
272-
$parentEntityAlias = 'entity';
273-
$fullPropertyName = $parentPropertyName = $associatedPropertyName = '';
274-
275-
for ($i = 0; $i < $numAssociatedProperties; ++$i) {
276-
$associatedPropertyName = $associatedProperties[$i];
277-
$fullPropertyName = trim($fullPropertyName.'.'.$associatedPropertyName, '.');
278-
279-
if ($this->isAssociation($parentEntityDto, $associatedPropertyName)) {
280-
if ($i === $numAssociatedProperties - 1) {
281-
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));
282-
}
283-
284-
$associatedEntityDto = $this->entityFactory->create($parentEntityDto->getClassMetadata()->getAssociationTargetClass($associatedPropertyName));
285-
286-
if (!isset($entitiesAlreadyJoined[$fullPropertyName])) {
287-
$aliasIndex = \count($entitiesAlreadyJoined);
288-
$entitiesAlreadyJoined[$fullPropertyName] ??= Escaper::escapeDqlAlias($associatedPropertyName.(0 === $aliasIndex ? '' : $aliasIndex));
289-
$queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityAlias).'.'.$associatedPropertyName, $entitiesAlreadyJoined[$fullPropertyName]);
290-
}
291-
292-
$parentEntityDto = $associatedEntityDto;
293-
$parentEntityAlias = $entitiesAlreadyJoined[$fullPropertyName];
294-
$parentPropertyName = '';
295-
} else {
296-
// Normal & Embedded class properties
297-
$associatedPropertyName = $parentPropertyName = trim($parentPropertyName.'.'.$associatedPropertyName, '.');
298-
}
299-
}
300-
301-
if (!isset($parentEntityDto->getClassMetadata()->fieldMappings[$associatedPropertyName])) {
302-
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));
303-
}
270+
$resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $searchableProperty);
304271

305272
// In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties
306-
$fieldMapping = $parentEntityDto->getClassMetadata()->getFieldMapping($associatedPropertyName);
273+
$fieldMapping = $resolvedProperty['entity_dto']->getClassMetadata()->getFieldMapping($resolvedProperty['property_name']);
307274
// In Doctrine ORM 2.x, getFieldMapping() returns an array
308275
/** @phpstan-ignore-next-line function.impossibleType */
309276
if (\is_array($fieldMapping)) {
@@ -332,16 +299,16 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
332299
&& !$isUlidProperty
333300
&& !$isJsonProperty
334301
) {
335-
$entityFqcn = $parentEntityDto->getFqcn();
302+
$entityFqcn = $resolvedProperty['entity_dto']->getFqcn();
336303

337304
/** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */
338305
$idClassType = null;
339306
$reflectionClass = new \ReflectionClass($entityFqcn);
340307

341308
// this is needed to handle inherited properties
342309
while (false !== $reflectionClass) {
343-
if ($reflectionClass->hasProperty($associatedPropertyName)) {
344-
$reflection = $reflectionClass->getProperty($associatedPropertyName);
310+
if ($reflectionClass->hasProperty($resolvedProperty['property_name'])) {
311+
$reflection = $reflectionClass->getProperty($resolvedProperty['property_name']);
345312
$idClassType = $reflection->getType();
346313
break;
347314
}
@@ -360,9 +327,9 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
360327
}
361328

362329
$searchablePropertiesConfig[] = [
363-
'entity_name' => $parentEntityAlias,
330+
'entity_name' => $resolvedProperty['entity_alias'],
364331
'property_data_type' => $propertyDataType,
365-
'property_name' => $associatedPropertyName,
332+
'property_name' => $resolvedProperty['property_name'],
366333
'is_boolean' => $isBoolean,
367334
'is_small_integer' => $isSmallIntegerProperty,
368335
'is_integer' => $isIntegerProperty,
@@ -377,6 +344,64 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
377344
return $searchablePropertiesConfig;
378345
}
379346

347+
/**
348+
* Support arbitrarily nested associations (e.g. foo.bar.baz.qux).
349+
*
350+
* @return array{
351+
* entity_dto: EntityDto,
352+
* entity_alias: string,
353+
* property_name: string,
354+
* }
355+
*/
356+
public function resolveNestedAssociations(?QueryBuilder $queryBuilder, EntityDto $rootEntityDto, string $propertyName, bool $mustEndWithAssociation = false): array
357+
{
358+
$associatedProperties = explode('.', $propertyName);
359+
$numAssociatedProperties = \count($associatedProperties);
360+
$resolvedEntityDto = $rootEntityDto;
361+
$parentEntityAlias = 'entity';
362+
$fullPropertyName = $compoundPropertyName = $associatedPropertyName = '';
363+
364+
for ($i = 0; $i < $numAssociatedProperties; ++$i) {
365+
$associatedPropertyName = trim($compoundPropertyName.'.'.$associatedProperties[$i], '.');
366+
$fullPropertyName = trim($fullPropertyName.'.'.$associatedPropertyName, '.');
367+
368+
if ($this->isAssociation($resolvedEntityDto, $associatedPropertyName)) {
369+
$resolvedEntityDto = $this->entityFactory->create($resolvedEntityDto->getClassMetadata()->getAssociationTargetClass($associatedPropertyName));
370+
371+
if ($i === $numAssociatedProperties - 1) {
372+
if (!$mustEndWithAssociation) {
373+
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));
374+
}
375+
376+
// Skip join when the last property is an association
377+
continue;
378+
}
379+
380+
if (isset($queryBuilder) && !isset($this->associationAlreadyJoined[$fullPropertyName])) {
381+
$aliasIndex = \count($this->associationAlreadyJoined);
382+
$this->associationAlreadyJoined[$fullPropertyName] ??= Escaper::escapeDqlAlias($associatedPropertyName.(0 === $aliasIndex ? '' : $aliasIndex));
383+
$queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityAlias).'.'.$associatedPropertyName, $this->associationAlreadyJoined[$fullPropertyName]);
384+
}
385+
386+
$parentEntityAlias = $this->associationAlreadyJoined[$fullPropertyName] ?? null;
387+
$compoundPropertyName = '';
388+
} else {
389+
// Normal & Embedded class properties
390+
$compoundPropertyName = $associatedPropertyName;
391+
}
392+
}
393+
394+
if (!$mustEndWithAssociation && !isset($resolvedEntityDto->getClassMetadata()->fieldMappings[$associatedPropertyName])) {
395+
throw new \InvalidArgumentException(sprintf('The "%s" property is not valid. The field "%s" does not exist in "%s".', $propertyName, $associatedPropertyName, $propertyName));
396+
}
397+
398+
return [
399+
'entity_dto' => $resolvedEntityDto,
400+
'entity_alias' => $parentEntityAlias,
401+
'property_name' => $associatedPropertyName,
402+
];
403+
}
404+
380405
private function isAssociation(EntityDto $entityDto, string $propertyName): bool
381406
{
382407
$propertyNameParts = explode('.', $propertyName, 2);

0 commit comments

Comments
 (0)