Preserve exact SearchFilter behaviour for Doctrine collection associations with QueryParameter

未关闭
#8,790 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 3 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
48/100
Issue 类型
功能
描述清晰度
基本清楚
活跃度
活跃
技术栈
php

调研方向

Start by reading the filtering system ADR mentioned in the issue, then inspect the native ExactFilter and IriFilter implementations and their Doctrine ORM tests. The report describes the collection association failure and a working custom filter, but leaves the intended API and behavior open for design discussion. Done means deciding whether and how native filters or filter composition should preserve legacy exact filtering for identifiers and IRIs on collection associations.

由索引模型根据 Issue 内容生成。

描述

Description

Hi,

First of all, thanks for the work on the new parameter and filtering system. I'm currently migrating an application from API Platform 4.3 to 4.4, replacing the deprecated #[ApiFilter] declarations with the new #[QueryParameter] approach.

I've encountered a difficulty when migrating exact filters on Doctrine relations, particularly on collection-valued associations (ManyToMany and OneToMany).

I believe this is worth documenting because the migration can introduce a behavioural regression compared to the legacy SearchFilter, and the solution is not immediately obvious when using the new native filters.

Previous behaviour

With the legacy SearchFilter, using the exact strategy on a relation allowed filtering with either a numeric identifier or an IRI.

For example:

#[ApiFilter(SearchFilter::class, properties: ['sites' => 'exact'])]

Both of these worked:

GET /api/quotations?sites=2
GET /api/quotations?sites=/api/sites/2

The same behaviour was available for single-valued associations (ManyToOne / OneToOne) and collection-valued associations (ManyToMany / OneToMany).

Multiple values were also supported.

Behaviour with QueryParameter

When migrating to the new API, I initially tried the following:

#[QueryParameter(key: 'sites', filter: new ExactFilter())]

This works for numeric identifiers on single-valued associations, but it does not work for collection-valued associations.

For a ManyToMany association, ExactFilter generates a comparison against the association itself, resulting in a Doctrine error similar to:

[Semantical Error] line 0, col 45 near 'sites = :sites_p1':
Error: Invalid PathExpression.
StateFieldPathExpression or SingleValuedAssociationField expected.

Using IriFilter instead:

#[QueryParameter(key: 'sites', filter: IriFilter::class)]

works with IRIs, including collection-valued associations, but does not accept raw numeric identifiers.

So neither native filter alone seems to provide the same behaviour as the former SearchFilter with the exact strategy.

Expected behaviour

Ideally, migrating an exact relation filter should preserve the existing behaviour:

Association Input Expected result
ManyToOne / OneToOne Numeric identifier Filter by related entity
ManyToOne / OneToOne IRI Filter by related entity
ManyToMany / OneToMany Numeric identifier Filter by related entity
ManyToMany / OneToMany IRI Filter by related entity
Collection association Multiple identifiers Match any of the related entities
Collection association Multiple IRIs Match any of the related entities

In particular, I would expect the following to be equivalent:

GET /api/quotations?sites[]=2&sites[]=3
GET /api/quotations?sites[]=/api/sites/2&sites[]=/api/sites/3

Current workaround

I ended up implementing a custom filter that inspects the Doctrine association mapping and delegates to the appropriate native filter.

The logic is:

  1. IRI values: resolve each IRI into its corresponding resource, then delegate to IriFilter.
  2. Numeric identifiers on collection-valued associations: resolve the identifiers into Doctrine entities using the association's target class, then delegate to IriFilter. This is necessary because ExactFilter cannot directly filter collection-valued associations.
  3. Numeric identifiers on single-valued associations: delegate directly to ExactFilter.

Invalid or non-existing values are ignored individually. If no valid resource can be resolved, the filter adds a condition that returns no results. Empty values are ignored as well.

Here is the complete implementation:

<?php

namespace App\Filter;

use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

final class ExactOrIriFilter implements FilterInterface, OpenApiParameterFilterInterface
{
    use BackwardCompatibleFilterDescriptionTrait;

    private ExactFilter $exactFilter;
    private IriFilter $iriFilter;

    public function __construct(
        private readonly IriConverterInterface $iriConverter,
        private readonly ManagerRegistry $managerRegistry,
    ) {
        $this->exactFilter = new ExactFilter();
        $this->iriFilter = new IriFilter();
    }

    public function apply(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        ?Operation $operation = null,
        array $context = [],
    ): void {
        $parameter = $context['parameter'] ?? null;
        $value = $parameter?->getValue();

        if (!is_string($value) && !is_array($value)) {
            return;
        }

        $values = is_array($value) ? $value : [$value];

        // Ignore empty values, including those potentially sent by Swagger UI.
        $values = array_values(array_filter(
            $values,
            static fn ($item) => null !== $item && '' !== $item,
        ));

        if ([] === $values) {
            return;
        }

        $isIri = str_starts_with((string) reset($values), '/');

        // All values for a given parameter must use the same format.
        foreach ($values as $item) {
            if (!is_string($item) || str_starts_with($item, '/') !== $isIri) {
                throw new \InvalidArgumentException(
                    'All filter values must use the same format (integer identifiers or IRIs).'
                );
            }
        }

        if ($isIri) {
            $resources = [];

            foreach ($values as $iri) {
                try {
                    $resources[] = $this->iriConverter->getResourceFromIri($iri);
                } catch (\Exception) {
                    // Ignore IRIs that don't resolve to an existing resource.
                }
            }

            if ([] === $resources) {
                $queryBuilder->andWhere('1 = 0');

                return;
            }

            $parameter->setValue($resources);

            $this->iriFilter->apply(
                $queryBuilder,
                $queryNameGenerator,
                $resourceClass,
                $operation,
                $context,
            );

            return;
        }

        $property = $parameter->getProperty() ?? $parameter->getKey();

        if (
            null !== $property
            && $this->isCollectionValuedAssociation($resourceClass, $property)
            && ctype_digit((string) reset($values))
        ) {
            $resources = $this->resolveIdentifiers($resourceClass, $property, $values);

            if ([] === $resources) {
                $queryBuilder->andWhere('1 = 0');

                return;
            }

            $parameter->setValue($resources);

            $this->iriFilter->apply(
                $queryBuilder,
                $queryNameGenerator,
                $resourceClass,
                $operation,
                $context,
            );

            return;
        }

        $this->exactFilter->apply(
            $queryBuilder,
            $queryNameGenerator,
            $resourceClass,
            $operation,
            $context,
        );
    }

    private function isCollectionValuedAssociation(string $resourceClass, string $property): bool
    {
        $manager = $this->managerRegistry->getManagerForClass($resourceClass);

        if (null === $manager) {
            return false;
        }

        $segments = explode('.', $property);
        $currentClass = $resourceClass;

        foreach ($segments as $index => $segment) {
            $metadata = $manager->getClassMetadata($currentClass);

            if (!$metadata->hasAssociation($segment)) {
                return false;
            }

            if ($index === array_key_last($segments)) {
                return $metadata->isCollectionValuedAssociation($segment);
            }

            $currentClass = $metadata->getAssociationTargetClass($segment);
        }

        return false;
    }

    private function resolveIdentifiers(
        string $resourceClass,
        string $property,
        array $values,
    ): array {
        $manager = $this->managerRegistry->getManagerForClass($resourceClass);

        if (null === $manager) {
            return [];
        }

        $currentClass = $resourceClass;

        foreach (explode('.', $property) as $segment) {
            $metadata = $manager->getClassMetadata($currentClass);

            if (!$metadata->hasAssociation($segment)) {
                return [];
            }

            $currentClass = $metadata->getAssociationTargetClass($segment);
        }

        $resources = [];

        foreach ($values as $id) {
            $resource = $manager->find($currentClass, $id);

            if (null !== $resource) {
                $resources[] = $resource;
            }
        }

        return $resources;
    }

    public function getOpenApiParameters(Parameter $parameter): array
    {
        $key = $parameter->getKey();

        return [
            new OpenApiParameter(
                name: $key,
                in: 'query',
                allowEmptyValue: true,
                explode: false,
                allowReserved: false,
            ),
            new OpenApiParameter(
                name: $key.'[]',
                in: 'query',
                allowEmptyValue: true,
                schema: [
                    'type' => 'array',
                    'items' => ['type' => 'string'],
                ],
                explode: true,
                allowReserved: false,
            ),
        ];
    }
}

The filter is used like this:

#[QueryParameter(key: 'sites', filter: ExactOrIriFilter::class)]

This currently restores the expected behaviour in my application, including nested association paths.

Questions

I would be interested to know:

  • Is this difference in behaviour between the legacy SearchFilter and the new QueryParameter-based filters intentional?
  • Is there already a recommended way to preserve this behaviour without implementing a custom filter?
  • Would it make sense for the native filters, or for a filter composition mechanism, to support this use case directly?

I noticed that the filtering system ADR already discusses the idea of applying multiple filters to a single query parameter, notably to support both IRIs and identifiers. This use case seems closely related, but collection-valued Doctrine associations introduce an additional complication: numeric identifiers need to be resolved into entities before they can be passed to IriFilter.

I hope this example can be useful to other users migrating from ApiFilter to QueryParameter, and perhaps help clarify the recommended migration path.

Thanks for your time and for your work on API Platform!

主要语言
PHP
星标
2.6k
派生
988
平均合并
4 天 13 小时
30 天内合并 PR
96

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

api-platform/core 的其他 Issue

查看 api-platform/core 的全部 Issue

相似的 Issue

更多 PHP Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。