Skip to content

Commit 18c6ca4

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

2 files changed

Lines changed: 81 additions & 59 deletions

File tree

src/Dto/FilterDataDto.php

Lines changed: 9 additions & 7 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;
@@ -21,13 +22,14 @@ private function __construct()
2122

2223
/**
2324
* @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/Orm/EntityRepository.php

Lines changed: 72 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,17 @@
3030
/**
3131
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
3232
*/
33-
final readonly class EntityRepository implements EntityRepositoryInterface
33+
final class EntityRepository implements EntityRepositoryInterface
3434
{
35+
/** @var array<string, string> */
36+
private array $associationAlreadyJoined = [];
37+
3538
public function __construct(
36-
private AdminContextProviderInterface $adminContextProvider,
37-
private ManagerRegistry $doctrine,
38-
private EntityFactory $entityFactory,
39-
private FormFactory $formFactory,
40-
private EventDispatcherInterface $eventDispatcher,
39+
private readonly AdminContextProviderInterface $adminContextProvider,
40+
private readonly ManagerRegistry $doctrine,
41+
private readonly EntityFactory $entityFactory,
42+
private readonly FormFactory $formFactory,
43+
private readonly EventDispatcherInterface $eventDispatcher,
4144
) {
4245
}
4346

@@ -232,10 +235,9 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt
232235
];
233236
}
234237

235-
/** @var string $rootAlias */
236-
$rootAlias = current($queryBuilder->getRootAliases());
238+
$resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $originalPropertyName);
237239

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

241243
++$i;
@@ -263,47 +265,11 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
263265
$configuredSearchableProperties = $searchDto->getSearchableProperties();
264266
$searchableProperties = (null === $configuredSearchableProperties || 0 === \count($configuredSearchableProperties)) ? $entityDto->getClassMetadata()->getFieldNames() : $configuredSearchableProperties;
265267

266-
$entitiesAlreadyJoined = [];
267268
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-
}
269+
$resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $searchableProperty);
304270

305271
// In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties
306-
$fieldMapping = $parentEntityDto->getClassMetadata()->getFieldMapping($associatedPropertyName);
272+
$fieldMapping = $resolvedProperty['entity_dto']->getClassMetadata()->getFieldMapping($resolvedProperty['property_name']);
307273
// In Doctrine ORM 2.x, getFieldMapping() returns an array
308274
/** @phpstan-ignore-next-line function.impossibleType */
309275
if (\is_array($fieldMapping)) {
@@ -332,16 +298,16 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
332298
&& !$isUlidProperty
333299
&& !$isJsonProperty
334300
) {
335-
$entityFqcn = $parentEntityDto->getFqcn();
301+
$entityFqcn = $resolvedProperty['entity_dto']->getFqcn();
336302

337303
/** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */
338304
$idClassType = null;
339305
$reflectionClass = new \ReflectionClass($entityFqcn);
340306

341307
// this is needed to handle inherited properties
342308
while (false !== $reflectionClass) {
343-
if ($reflectionClass->hasProperty($associatedPropertyName)) {
344-
$reflection = $reflectionClass->getProperty($associatedPropertyName);
309+
if ($reflectionClass->hasProperty($resolvedProperty['property_name'])) {
310+
$reflection = $reflectionClass->getProperty($resolvedProperty['property_name']);
345311
$idClassType = $reflection->getType();
346312
break;
347313
}
@@ -360,9 +326,9 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
360326
}
361327

362328
$searchablePropertiesConfig[] = [
363-
'entity_name' => $parentEntityAlias,
329+
'entity_name' => $resolvedProperty['entity_alias'],
364330
'property_data_type' => $propertyDataType,
365-
'property_name' => $associatedPropertyName,
331+
'property_name' => $resolvedProperty['property_name'],
366332
'is_boolean' => $isBoolean,
367333
'is_small_integer' => $isSmallIntegerProperty,
368334
'is_integer' => $isIntegerProperty,
@@ -377,6 +343,60 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
377343
return $searchablePropertiesConfig;
378344
}
379345

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

0 commit comments

Comments
 (0)