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,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