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