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.
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_infodoes not get filled, not allowing doctrine to make the needed joins. Ending with the error:How to reproduce
Call
/api/users?name=testPossible Solution
I currently have a decorator filling the nested_property_info if the resource has an stateOption defining the class it represents.
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.