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,17 @@ 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+ $ resolvedProperty = [
243+ 'entity_dto ' => $ entityDto ,
244+ 'entity_alias ' => current ($ queryBuilder ->getRootAliases ()),
245+ 'property_name ' => $ originalPropertyName ,
246+ ];
247+ }
237248
238- $ filterDataDto = FilterDataDto::new ($ i , $ filter , $ rootAlias , $ submittedData );
249+ $ filterDataDto = FilterDataDto::new ($ i , $ filter , $ resolvedProperty , $ submittedData );
239250 $ filter ->apply ($ queryBuilder , $ filterDataDto , $ fields ->getByProperty ($ originalPropertyName ), $ entityDto );
240251
241252 ++$ i ;
@@ -263,47 +274,11 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
263274 $ configuredSearchableProperties = $ searchDto ->getSearchableProperties ();
264275 $ searchableProperties = (null === $ configuredSearchableProperties || 0 === \count ($ configuredSearchableProperties )) ? $ entityDto ->getClassMetadata ()->getFieldNames () : $ configuredSearchableProperties ;
265276
266- $ entitiesAlreadyJoined = [];
267277 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- }
278+ $ resolvedProperty = $ this ->resolveNestedAssociations ($ queryBuilder , $ entityDto , $ searchableProperty );
304279
305280 // In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties
306- $ fieldMapping = $ parentEntityDto ->getClassMetadata ()->getFieldMapping ($ associatedPropertyName );
281+ $ fieldMapping = $ resolvedProperty [ ' entity_dto ' ] ->getClassMetadata ()->getFieldMapping ($ resolvedProperty [ ' property_name ' ] );
307282 // In Doctrine ORM 2.x, getFieldMapping() returns an array
308283 /** @phpstan-ignore-next-line function.impossibleType */
309284 if (\is_array ($ fieldMapping )) {
@@ -332,16 +307,16 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
332307 && !$ isUlidProperty
333308 && !$ isJsonProperty
334309 ) {
335- $ entityFqcn = $ parentEntityDto ->getFqcn ();
310+ $ entityFqcn = $ resolvedProperty [ ' entity_dto ' ] ->getFqcn ();
336311
337312 /** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */
338313 $ idClassType = null ;
339314 $ reflectionClass = new \ReflectionClass ($ entityFqcn );
340315
341316 // this is needed to handle inherited properties
342317 while (false !== $ reflectionClass ) {
343- if ($ reflectionClass ->hasProperty ($ associatedPropertyName )) {
344- $ reflection = $ reflectionClass ->getProperty ($ associatedPropertyName );
318+ if ($ reflectionClass ->hasProperty ($ resolvedProperty [ ' property_name ' ] )) {
319+ $ reflection = $ reflectionClass ->getProperty ($ resolvedProperty [ ' property_name ' ] );
345320 $ idClassType = $ reflection ->getType ();
346321 break ;
347322 }
@@ -360,9 +335,9 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
360335 }
361336
362337 $ searchablePropertiesConfig [] = [
363- 'entity_name ' => $ parentEntityAlias ,
338+ 'entity_name ' => $ resolvedProperty [ ' entity_alias ' ] ,
364339 'property_data_type ' => $ propertyDataType ,
365- 'property_name ' => $ associatedPropertyName ,
340+ 'property_name ' => $ resolvedProperty [ ' property_name ' ] ,
366341 'is_boolean ' => $ isBoolean ,
367342 'is_small_integer ' => $ isSmallIntegerProperty ,
368343 'is_integer ' => $ isIntegerProperty ,
@@ -377,6 +352,63 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
377352 return $ searchablePropertiesConfig ;
378353 }
379354
355+ /**
356+ * Support arbitrarily nested associations (e.g. foo.bar.baz.qux).
357+ *
358+ * @return array{
359+ * entity_dto: EntityDto,
360+ * entity_alias: string,
361+ * property_name: string,
362+ * }
363+ */
364+ public function resolveNestedAssociations (?QueryBuilder $ queryBuilder , EntityDto $ rootEntityDto , string $ propertyName , bool $ mustEndWithAssociation = false ): array
365+ {
366+ $ associatedProperties = explode ('. ' , $ propertyName );
367+ $ numAssociatedProperties = \count ($ associatedProperties );
368+ $ resolvedEntityDto = $ rootEntityDto ;
369+ $ parentEntityAlias = 'entity ' ;
370+ $ fullPropertyName = $ compoundPropertyName = $ resolvedPropertyName = '' ;
371+
372+ for ($ i = 0 ; $ i < $ numAssociatedProperties ; ++$ i ) {
373+ $ resolvedPropertyName = trim ($ compoundPropertyName .'. ' .$ associatedProperties [$ i ], '. ' );
374+ $ fullPropertyName = trim ($ fullPropertyName .'. ' .$ resolvedPropertyName , '. ' );
375+
376+ if ($ this ->isAssociation ($ resolvedEntityDto , $ resolvedPropertyName )) {
377+ if ($ i === $ numAssociatedProperties - 1 ) {
378+ if (!$ mustEndWithAssociation ) {
379+ 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 ));
380+ }
381+
382+ // Skip join when the last property is an association
383+ continue ;
384+ }
385+
386+ if (isset ($ queryBuilder ) && !isset ($ this ->associationAlreadyJoined [$ fullPropertyName ])) {
387+ $ aliasIndex = \count ($ this ->associationAlreadyJoined );
388+ $ this ->associationAlreadyJoined [$ fullPropertyName ] ??= Escaper::escapeDqlAlias ($ resolvedPropertyName .(0 === $ aliasIndex ? '' : $ aliasIndex ));
389+ $ queryBuilder ->leftJoin (Escaper::escapeDqlAlias ($ parentEntityAlias ).'. ' .$ resolvedPropertyName , $ this ->associationAlreadyJoined [$ fullPropertyName ]);
390+ }
391+
392+ $ parentEntityAlias = $ this ->associationAlreadyJoined [$ fullPropertyName ] ?? null ;
393+ $ resolvedEntityDto = $ this ->entityFactory ->create ($ resolvedEntityDto ->getClassMetadata ()->getAssociationTargetClass ($ resolvedPropertyName ));
394+ $ compoundPropertyName = '' ;
395+ } else {
396+ // Normal & Embedded class properties
397+ $ compoundPropertyName = $ resolvedPropertyName ;
398+ }
399+ }
400+
401+ if (!$ mustEndWithAssociation && !isset ($ resolvedEntityDto ->getClassMetadata ()->fieldMappings [$ resolvedPropertyName ])) {
402+ throw new \InvalidArgumentException (sprintf ('The "%s" property is not valid. The field "%s" does not exist in "%s". ' , $ propertyName , $ resolvedPropertyName , $ propertyName ));
403+ }
404+
405+ return [
406+ 'entity_dto ' => $ resolvedEntityDto ,
407+ 'entity_alias ' => $ parentEntityAlias ,
408+ 'property_name ' => $ resolvedPropertyName ,
409+ ];
410+ }
411+
380412 private function isAssociation (EntityDto $ entityDto , string $ propertyName ): bool
381413 {
382414 $ propertyNameParts = explode ('. ' , $ propertyName , 2 );
0 commit comments