Preserve exact SearchFilter behaviour for Doctrine collection associations with QueryParameter

Offen
#8,790 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Maintainer antworten meist innerhalb von 2 Tagen

Dieses Issue hat noch niemand übernommen.

Bewertung

Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Anfängerfreundlichkeit
48/100
Issue-Typ
Feature
Klarheit
Größtenteils klar
Aktivitätsstatus
Aktiv
Tech-Stack
php

Rechercherichtung

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.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Beschreibung

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!

Vorherrschende Sprache
PHP
Sterne
2.6k
Forks
988
Ø Merge
4 T. 10 Std.
Gemergte PRs (30 T.)
109

Entwicklungsumgebung

Erste Schritte

  1. Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
  3. Forken Sie das Repository und arbeiten Sie in einem Branch.
  4. Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.

Mehr aus api-platform/core

Alle Issues in api-platform/core

Ähnliche Issues

Weitere Issues zu PHP

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.