Skip to content

Nested properties when only using mapped resources do not work #8185

@Dooij

Description

@Dooij

API Platform version(s) affected: 4.3.3
Description
When moving away from using #[ApiResouce] annotations on entities, but only using resource classes the nested properties on filters are broken. Even though the nested property has its own resource, the nested_properties_info does not get filled, not allowing doctrine to make the needed joins. Ending with the error:

[Semantical Error] line 0, col 54 near 'name) LIKE LOWER(:account_name_p1)': Error: Class App\Entity\User has no field or association named account.name

How to reproduce

#[ApiResource(
    shortName: 'User',
    operations: [
        new GetCollection(
            uriTemplate: '/users',
            security: 'is_granted("ROLE_ADMIN")',
            parameters: [
                'email' => new QueryParameter(
                    filter: new PartialSearchFilter(),
                ),
                'name' => new QueryParameter(
                    filter: new PartialSearchFilter(),
                    property: 'account.name',
                ),
            ],
        ),
    ],
    stateOptions: new Options(entityClass: User::class),
)]
#[Map(source: User::class)]
class UserResource
{
    public int $id;

    public string $email;

    public string|null $name;
}

class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[Assert\NotBlank]
    #[Assert\Email]
    #[ORM\Column(length: 180, nullable: false)]
    private string $email;
    
    #[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'users')]
    #[ORM\JoinColumn(nullable: false)]
    private Account $account;

    public function getId(): int|null
    {
        return $this->id;
    }

    public function getEmail(): string|null
    {
        return mb_strtolower($this->email);
    }

    public function setEmail(string $email): void
    {
        $this->email = mb_strtolower($email);
    }

    public function getAccount(): Account
    {
        return $this->account;
    }

    public function setAccount(Account $account): void
    {
        $this->account = $account;
    }

    public function getName(): string|null
    {
        return $this->account->getName();
    }
}
class Account
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;
    #[ORM\Column(nullable: true)]
    private string|null $contactName = null;

    /** @var Collection<int, User> */
    #[ORM\OneToMany(targetEntity: User::class, mappedBy: 'account')]
    private Collection $users;

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string|null
    {
        return $this->name;
    }

    /** @return Collection<int, User> */
    public function getUsers(): Collection
    {
        return $this->users;
    }
}
#[ApiResource(
    shortName: 'Account',
    operations: [
        new Get(
            uriTemplate: '/accounts/{id}',
            security: 'is_granted("ROLE_ADMIN") or is_granted("ACCOUNT_ACCESS", object)',
        ),
    ],
    stateOptions: new Options(entityClass: Account::class),
)]
#[Map(source: Account::class)]
class AccountResource
{
    public int $id;

    public string|null $name;
}

Call /api/users?name=test

Possible Solution
I currently have a decorator filling the nested_property_info if the resource has an stateOption defining the class it represents.

#[AsDecorator('api_platform.metadata.resource.metadata_collection_factory', priority: 999)]
final class NestedEntityPropertyParameterFactory implements ResourceMetadataCollectionFactoryInterface
{
    public function __construct(
        private readonly ResourceMetadataCollectionFactoryInterface $decorated,
        private readonly ManagerRegistry $managerRegistry,
    ) {
    }

    public function create(string $resourceClass): ResourceMetadataCollection
    {
        $collection = $this->decorated->create($resourceClass);

        foreach ($collection as $i => $resourceMetadata) {
            $operations = $resourceMetadata->getOperations();
            if ($operations === null) {
                continue;
            }

            $changed = false;
            foreach ($operations as $name => $operation) {
                if (!$this->supports($operation)) {
                    continue;
                }

                $enriched = $this->enrichOperation($operation);
                if ($enriched === $operation) {
                    continue;
                }

                $operations->add($name, $enriched);
                $changed = true;
            }

            if ($changed) {
                $collection[$i] = $resourceMetadata->withOperations($operations);
            }
        }

        return $collection;
    }

    private function supports(Operation $operation): bool
    {
        if ($operation->getParameters() === null) {
            return false;
        }

        $stateOptions = $operation->getStateOptions();
        if (!$stateOptions instanceof Options) {
            return false;
        }

        $entityClass = $stateOptions->getEntityClass();
        if ($entityClass === null || !class_exists($entityClass)) {
            return false;
        }

        return $this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface;
    }

    private function enrichOperation(Operation $operation): Operation
    {
        $parameters = $operation->getParameters();
        assert($parameters instanceof Parameters);
        $stateOptions = $operation->getStateOptions();
        assert($stateOptions instanceof Options);
        /** @var class-string $entityClass */
        $entityClass = $stateOptions->getEntityClass();
        $manager     = $this->managerRegistry->getManagerForClass($entityClass);
        assert($manager instanceof EntityManagerInterface);

        $changed = false;
        foreach ($parameters as $key => $parameter) {
            $enriched = $this->enrichParameter($parameter, $manager, $entityClass);
            if ($enriched === null) {
                continue;
            }

            $parameters->add($key, $enriched);
            $changed = true;
        }

        return $changed ? $operation->withParameters($parameters) : $operation;
    }

    /** @param class-string $entityClass */
    private function enrichParameter(
        Parameter $parameter,
        EntityManagerInterface $manager,
        string $entityClass,
    ): Parameter|null {
        $property = $parameter->getProperty();
        if ($property === null || !str_contains($property, '.')) {
            return null;
        }

        $extraProperties = $parameter->getExtraProperties();
        $nested          = $extraProperties['nested_properties_info'] ?? null;
        $existing        = is_array($nested) ? $nested : [];
        if (isset($existing[$property])) {
            return null;
        }

        $info = $this->walkPath($manager, $entityClass, $property);
        if ($info === null) {
            return null;
        }

        $existing[$property]                       = $info;
        $extraProperties['nested_properties_info'] = $existing;

        return $parameter->withExtraProperties($extraProperties);
    }

    /**
     * @param class-string $rootClass
     *
     * @return array{
     *     relation_segments: list<string>,
     *     converted_relation_segments: list<string>,
     *     relation_classes: list<class-string>,
     *     leaf_property: string,
     *     leaf_class: class-string,
     * }|null
     */
    private function walkPath(EntityManagerInterface $manager, string $rootClass, string $path): array|null
    {
        $parts            = explode('.', $path);
        $relationSegments = [];
        /** @var list<class-string> $relationClasses */
        $relationClasses = [];
        $currentClass    = $rootClass;

        foreach ($parts as $i => $part) {
            $isLast = $i === count($parts) - 1;

            try {
                $classMetadata = $manager->getClassMetadata($currentClass);
            } catch (Throwable) {
                return null;
            }

            if ($isLast) {
                if (!$classMetadata->hasField($part) && !$classMetadata->hasAssociation($part)) {
                    return null;
                }

                return [
                    'relation_segments'           => $relationSegments,
                    'converted_relation_segments' => $relationSegments,
                    'relation_classes'            => $relationClasses,
                    'leaf_property'               => $part,
                    'leaf_class'                  => $currentClass,
                ];
            }

            if (!$classMetadata->hasAssociation($part)) {
                return null;
            }

            $association  = $classMetadata->getAssociationMapping($part);
            $targetEntity = $association['targetEntity'] ?? null;
            if (!is_string($targetEntity) || !class_exists($targetEntity)) {
                return null;
            }

            $relationSegments[] = $part;
            $relationClasses[]  = $currentClass;
            $currentClass       = $targetEntity;
        }

        return null;
    }
}

Additional Context
Perhaps this needs an additional check to see if the nested property does have a form of apiresource, ie, find the AccountResource for it, which I have neglected in my setup. If I have overengineerd this and theres a simpler option I would very much like to know as well.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions