2121use EasyCorp \Bundle \EasyAdminBundle \Factory \EntityFactory ;
2222use EasyCorp \Bundle \EasyAdminBundle \Factory \FormFactory ;
2323use EasyCorp \Bundle \EasyAdminBundle \Field \AssociationField ;
24+ use EasyCorp \Bundle \EasyAdminBundle \Filter \EntityFilter ;
2425use EasyCorp \Bundle \EasyAdminBundle \Form \Type \ComparisonType ;
2526use EasyCorp \Bundle \EasyAdminBundle \Form \Type \FiltersFormType ;
2627use Symfony \Component \Uid \Ulid ;
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,18 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt
232236 ];
233237 }
234238
235- /** @var string $rootAlias */
236- $ rootAlias = current ($ queryBuilder ->getRootAliases ());
239+ try {
240+ $ resolvedProperty = $ this ->resolveNestedAssociations ($ queryBuilder , $ entityDto , $ originalPropertyName , EntityFilter::class === $ filter ->getFqcn ());
241+ } catch (\InvalidArgumentException ) {
242+ // Fallback to support custom filters with unmapped property names
243+ $ resolvedProperty = [
244+ 'entity_dto ' => $ entityDto ,
245+ 'entity_alias ' => current ($ queryBuilder ->getRootAliases ()),
246+ 'property_name ' => $ originalPropertyName ,
247+ ];
248+ }
237249
238- $ filterDataDto = FilterDataDto::new ($ i , $ filter , $ rootAlias , $ submittedData );
250+ $ filterDataDto = FilterDataDto::new ($ i , $ filter , $ resolvedProperty , $ submittedData );
239251 $ filter ->apply ($ queryBuilder , $ filterDataDto , $ fields ->getByProperty ($ originalPropertyName ), $ entityDto );
240252
241253 ++$ i ;
@@ -263,47 +275,11 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
263275 $ configuredSearchableProperties = $ searchDto ->getSearchableProperties ();
264276 $ searchableProperties = (null === $ configuredSearchableProperties || 0 === \count ($ configuredSearchableProperties )) ? $ entityDto ->getClassMetadata ()->getFieldNames () : $ configuredSearchableProperties ;
265277
266- $ entitiesAlreadyJoined = [];
267278 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- }
279+ $ resolvedProperty = $ this ->resolveNestedAssociations ($ queryBuilder , $ entityDto , $ searchableProperty );
304280
305281 // In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties
306- $ fieldMapping = $ parentEntityDto ->getClassMetadata ()->getFieldMapping ($ associatedPropertyName );
282+ $ fieldMapping = $ resolvedProperty [ ' entity_dto ' ] ->getClassMetadata ()->getFieldMapping ($ resolvedProperty [ ' property_name ' ] );
307283 // In Doctrine ORM 2.x, getFieldMapping() returns an array
308284 /** @phpstan-ignore-next-line function.impossibleType */
309285 if (\is_array ($ fieldMapping )) {
@@ -332,16 +308,16 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
332308 && !$ isUlidProperty
333309 && !$ isJsonProperty
334310 ) {
335- $ entityFqcn = $ parentEntityDto ->getFqcn ();
311+ $ entityFqcn = $ resolvedProperty [ ' entity_dto ' ] ->getFqcn ();
336312
337313 /** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */
338314 $ idClassType = null ;
339315 $ reflectionClass = new \ReflectionClass ($ entityFqcn );
340316
341317 // this is needed to handle inherited properties
342318 while (false !== $ reflectionClass ) {
343- if ($ reflectionClass ->hasProperty ($ associatedPropertyName )) {
344- $ reflection = $ reflectionClass ->getProperty ($ associatedPropertyName );
319+ if ($ reflectionClass ->hasProperty ($ resolvedProperty [ ' property_name ' ] )) {
320+ $ reflection = $ reflectionClass ->getProperty ($ resolvedProperty [ ' property_name ' ] );
345321 $ idClassType = $ reflection ->getType ();
346322 break ;
347323 }
@@ -360,9 +336,9 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
360336 }
361337
362338 $ searchablePropertiesConfig [] = [
363- 'entity_name ' => $ parentEntityAlias ,
339+ 'entity_name ' => $ resolvedProperty [ ' entity_alias ' ] ,
364340 'property_data_type ' => $ propertyDataType ,
365- 'property_name ' => $ associatedPropertyName ,
341+ 'property_name ' => $ resolvedProperty [ ' property_name ' ] ,
366342 'is_boolean ' => $ isBoolean ,
367343 'is_small_integer ' => $ isSmallIntegerProperty ,
368344 'is_integer ' => $ isIntegerProperty ,
@@ -377,6 +353,64 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
377353 return $ searchablePropertiesConfig ;
378354 }
379355
356+ /**
357+ * Support arbitrarily nested associations (e.g. foo.bar.baz.qux).
358+ *
359+ * @return array{
360+ * entity_dto: EntityDto,
361+ * entity_alias: string,
362+ * property_name: string,
363+ * }
364+ */
365+ public function resolveNestedAssociations (?QueryBuilder $ queryBuilder , EntityDto $ rootEntityDto , string $ propertyName , bool $ mustEndWithAssociation = false ): array
366+ {
367+ $ associatedProperties = explode ('. ' , $ propertyName );
368+ $ numAssociatedProperties = \count ($ associatedProperties );
369+ $ resolvedEntityDto = $ rootEntityDto ;
370+ $ parentEntityAlias = 'entity ' ;
371+ $ fullPropertyName = $ compoundPropertyName = $ resolvedPropertyName = '' ;
372+
373+ for ($ i = 0 ; $ i < $ numAssociatedProperties ; ++$ i ) {
374+ $ resolvedPropertyName = trim ($ compoundPropertyName .'. ' .$ associatedProperties [$ i ], '. ' );
375+ $ fullPropertyName = trim ($ fullPropertyName .'. ' .$ resolvedPropertyName , '. ' );
376+
377+ if ($ this ->isAssociation ($ resolvedEntityDto , $ resolvedPropertyName )) {
378+ $ resolvedEntityDto = $ this ->entityFactory ->create ($ resolvedEntityDto ->getClassMetadata ()->getAssociationTargetClass ($ resolvedPropertyName ));
379+
380+ if ($ i === $ numAssociatedProperties - 1 ) {
381+ if (!$ mustEndWithAssociation ) {
382+ 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 ));
383+ }
384+
385+ // Skip join when the last property is an association
386+ continue ;
387+ }
388+
389+ if (isset ($ queryBuilder ) && !isset ($ this ->associationAlreadyJoined [$ fullPropertyName ])) {
390+ $ aliasIndex = \count ($ this ->associationAlreadyJoined );
391+ $ this ->associationAlreadyJoined [$ fullPropertyName ] ??= Escaper::escapeDqlAlias ($ resolvedPropertyName .(0 === $ aliasIndex ? '' : $ aliasIndex ));
392+ $ queryBuilder ->leftJoin (Escaper::escapeDqlAlias ($ parentEntityAlias ).'. ' .$ resolvedPropertyName , $ this ->associationAlreadyJoined [$ fullPropertyName ]);
393+ }
394+
395+ $ parentEntityAlias = $ this ->associationAlreadyJoined [$ fullPropertyName ] ?? null ;
396+ $ compoundPropertyName = '' ;
397+ } else {
398+ // Normal & Embedded class properties
399+ $ compoundPropertyName = $ resolvedPropertyName ;
400+ }
401+ }
402+
403+ if (!$ mustEndWithAssociation && !isset ($ resolvedEntityDto ->getClassMetadata ()->fieldMappings [$ resolvedPropertyName ])) {
404+ throw new \InvalidArgumentException (sprintf ('The "%s" property is not valid. The field "%s" does not exist in "%s". ' , $ propertyName , $ resolvedPropertyName , $ propertyName ));
405+ }
406+
407+ return [
408+ 'entity_dto ' => $ resolvedEntityDto ,
409+ 'entity_alias ' => $ parentEntityAlias ,
410+ 'property_name ' => $ resolvedPropertyName ,
411+ ];
412+ }
413+
380414 private function isAssociation (EntityDto $ entityDto , string $ propertyName ): bool
381415 {
382416 $ propertyNameParts = explode ('. ' , $ propertyName , 2 );
0 commit comments