Skip to content

Nested property filters fail to generate JOINs when relation target entity is not directly declared as ApiResource #7916

@or-benjamin

Description

@or-benjamin

@ -0,0 +1,118 @@
API Platform version(s) affected: 4.3.3

Description

When using stateOptions with entityClass to map an API Resource to a Doctrine entity, nested property filters (PartialSearchFilter, OrFilter, FreeTextQueryFilter) fail to generate JOINs for relations that point to an entity which is not directly declared as an #[ApiResource].

The root cause is in ParameterResourceMetadataCollectionFactory::getProperties(). When traversing a dotted property path (e.g. user.name), the method calls getClassNameFromProperty() which internally calls isResourceClass() on the relation's target class. If the target entity class is not registered as an API resource (even if another API Resource references it via stateOptions), isResourceClass() returns false, getClassNameFromProperty() returns null, and the traversal stops — meaning nested_properties_info is never populated.

Without nested_properties_info, the NestedPropertyHelperTrait::addNestedParameterJoins() returns early without adding any JOIN, producing invalid DQL like WHERE LOWER(o.user.name) LIKE ... instead of JOIN o.user u WHERE LOWER(u.name) LIKE ....

How to reproduce

// Entity (not declared as ApiResource)
#[ORM\Entity]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(length: 180, unique: true)]
    private ?string $email = null;

    // getters/setters...
}

// Entity with relation to User
#[ORM\Entity]
class UserAction
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 50)]
    private string $action;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;

    // getters/setters...
}

// API Resource using stateOptions
#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/user-actions',
            parameters: [
                'user' => new QueryParameter(
                    filter: new OrFilter(new PartialSearchFilter()),
                    properties: ['user.name', 'user.email'],
                ),
            ],
        ),
    ],
    stateOptions: new Options(entityClass: UserAction::class),
)]
class UserActionResource
{
    public ?int $id = null;
    public ?string $action = null;
    // ...
}

// Separate API Resource for User exists with stateOptions
#[ApiResource(
    stateOptions: new Options(entityClass: User::class),
)]
class UserResource
{
    public ?int $id = null;
    public ?string $name = null;
    // ...
}

Requesting GET /user-actions?user=john produces:

[Semantical Error] Class UserAction has no field or association named user.name

Generated DQL:

SELECT o FROM UserAction o WHERE LOWER(o.user.name) LIKE LOWER(:p1)

Expected DQL:

SELECT o FROM UserAction o JOIN o.user u WHERE LOWER(u.name) LIKE LOWER(:p1)

Workaround

Add #[ApiResource(operations: [])] on the target entity to register it as a resource class without exposing any routes:

#[ApiResource(operations: [])]
#[ORM\Entity]
class User implements UserInterface
{
    // ...
}

Additional Context

  • The filterClass parameter on QueryParameter does not help because it only affects the starting class of the traversal, not the isResourceClass check on the relation target.
  • This issue affects all nested property filters: PartialSearchFilter, ExactFilter, OrFilter, FreeTextQueryFilter.
  • Discovered while using the ObjectMapper pattern (separate API Resource class + stateOptions(entityClass: ...)) which is the recommended approach in API Platform 4.x.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions