core icon indicating copy to clipboard operation
core copied to clipboard

Please provide a migration path for the ApiFilter attribute => query parameter.

Open VincentLanglet opened this issue 2 months ago • 1 comments

Description
Hi @soyuka @vinceAmstoutz

I'm currently trying to migrate my ApiFilter attribute filters to avoid the futur deprecation, as explained in https://api-platform.com/docs/core/doctrine-filters/#introduction

But I'm currently having big trouble to understand

  • How to migrate some of my filters
  • What's the benefit

And I feel like I'm maybe not the only one, looking at some recent issue/discussions.

Also I think this Upgrade note could explain with more details why this changes, cause currently I didn't found satisfying explanations. I feel like the ApiFilter attribute will be replaced by

  • The new filters classes (great)
  • Declaring filter in the yaml/php config (why ? I hate having to maintain a config)
  • Creating new dedicated filter (why having to maintain my own filter while ApiPlatform filters was doing the trick ?)

I understand writing doc is difficult and I'm open to do PR to improve it, but first I need to understand it ^^'

Example

I would expect a big list of BEFORE/AFTER example. I'm unsure how to write it currently, based on the issues I encounter.

Problem I still have

  1. I have an OrderFilter
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]

that's I tried to migrated

parameters: ['order[:property]' => new QueryParameter(filter: new OrderFilter(), properties: ['name', 'createdAt'])],

it worked fine BUT got the same issue than https://github.com/api-platform/core/issues/7361

I follow the suggestion from soyuka (https://github.com/api-platform/core/issues/7361#issuecomment-3243315889) to use service id with .instance

parameters: ['order[:property]' => new QueryParameter(filter: 'api_platform.doctrine.orm.order_filter.instance', properties: ['name', 'createdAt'])],

but then the filter does nothing as reported by https://github.com/api-platform/core/issues/7361#issuecomment-3240681163

Looking at https://github.com/api-platform/core/issues/7361#issuecomment-3243835860 I feel like I'll have to declare a new service in my config. Is it the only way ? Is it plan to introduce a new OrderFilter ?

  1. I have a filter with nested property
#[ApiFilter(SearchFilter::class, properties: ['languagePair.target' => 'exact'])]

As reported https://github.com/api-platform/core/pull/7121#issuecomment-3575382644 and answered by soyuka, nested properties are (voluntary) not supported anymore for those filters. This is already discussed in https://github.com/api-platform/core/issues/7526

Filtering on nested properties requires joins which is hard to predict and may lead to unwanted behavior / performance issues, can't you create your own filter?

I have a big trouble understanding

  • What was the issue before (since it works)
  • How my own filter will avoid this issue (since I'll have to make my own JOIN !)

Wouldn't it be possible to still support nested properties ? Or to do somehting like OrFilter:

new NestedFilter(new ExactFilter())

Also soyuka told me I can still use the legacy (not recommended) SearchFilter but I got the same issue than the OrderFilter, something like

parameters: [
                'targetLanguage' => new QueryParameter(
                    filter: 'api_platform.doctrine.orm.search_filter.instance',
                    property: 'languagePair.target'
                ),
            ]

does nothing...

Problem solved that might help someone else...

  1. I have a BooleanFilter on a live property I tried to migrate to
parameters: ['live' => new QueryParameter(filter: new BooleanFilter(), property: 'live')],

it worked fine BUT got the same issue than https://github.com/api-platform/core/issues/7361

I'm finally solve my issue with

parameters: ['live' => new QueryParameter(filter: new ExactFilter(), property: 'live')],

but I'm not sure of the changes.

  1. I have a SearchFilter
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]

that I migrated into

parameters: ['name' => new QueryParameter(filter: new PartialSearchFilter(), property: 'name')],

I feel like it's the right way to do it.

VincentLanglet avatar Nov 25 '25 17:11 VincentLanglet

OrderFilter

I have a POC with the following filter

Custom OrderFilter
class MyOrderFilter implements OrderFilterInterface, FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
{
    use LoggerAwareTrait;
    use ManagerRegistryAwareTrait;
    use PropertyPlaceholderOpenApiParameterTrait;

    /**
     * @param array<string, mixed> $context
     */
    public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
    {
        $filter = new ApiPlatformOrderFilter(
            $this->getManagerRegistry(),
            'order',
            $this->getLogger(),
        );

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

    public function getDescription(string $resourceClass): array
    {
        return [];
    }

    /**
     * @return array<string, mixed>
     */
    public function getSchema(Parameter $parameter): array
    {
        return new ApiPlatformOrderFilter()->getSchema($parameter);
    }
}

Which allows me to write

parameters: ['order[:property]' => new QueryParameter(filter: new MyOrderFilter(), properties: ['name', 'createdAt'])],

without having to declare a custom filter in service.yaml.

Maybe it's possible for ApiPlatform to expose a new OrderFilter which does not extract AbstractFilter ?

Nested property

For those interested I currently have a solution with a custom NestedFilter

Custom NestedFilter
class NestedFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface, OpenApiParameterFilterInterface
{
    use LoggerAwareTrait;
    use ManagerRegistryAwareTrait;
    use OrmPropertyHelperTrait;
    use PropertyHelperTrait;

    public function __construct(
        private readonly FilterInterface $filter,
    ) {
    }

    /**
     * @param array<string, mixed> $context
     */
    public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
    {
        if ($this->filter instanceof ManagerRegistryAwareInterface) {
            $this->filter->setManagerRegistry($this->getManagerRegistry());
        }

        if ($this->filter instanceof LoggerAwareInterface) {
            $this->filter->setLogger($this->getLogger());
        }

        /** @var Parameter $parameter */
        $parameter = $context['parameter'];

        $property = $parameter->getProperty();
        if (null === $property) {
            throw new \InvalidArgumentException('Nested filter only supports single property.');
        }

        // Associations are camelCase but the property might be in snake case.
        $camelCaseProperty = implode('.', array_map(
            static fn (string $s) => new UnicodeString($s)->camel()->toString(),
            explode('.', $property),
        ));

        $alias = $queryBuilder->getRootAliases()[0];
        if ($this->isPropertyNested($camelCaseProperty, $resourceClass)) {
            [$alias, $camelCaseProperty] = $this->addJoinsForNestedProperty(
                $camelCaseProperty,
                $alias,
                $queryBuilder,
                $queryNameGenerator,
                $resourceClass,
                Join::INNER_JOIN,
            );

            $property = new UnicodeString($camelCaseProperty)->snake()->toString();
        }

        $qb = clone $queryBuilder;
        $qb->resetDQLPart('from');
        $qb->from(self::class, $alias); // Override the root alias of the queryBuilder
        $qb->resetDQLPart('where');
        $qb->setParameters(new ArrayCollection());

        $newParameter = $parameter->withProperty($property)->withProperties([$property]);
        $this->filter->apply(
            $qb,
            $queryNameGenerator,
            $resourceClass,
            $operation,
            ['parameter' => $newParameter] + $context
        );

        $queryBuilder->andWhere($qb->getDQLPart('where'));
        $parameters = $queryBuilder->getParameters();

        foreach ($qb->getParameters() as $p) {
            $parameters->add($p);
        }

        $queryBuilder->setParameters($parameters);
    }

    public function getDescription(string $resourceClass): array
    {
        return [];
    }

    public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
    {
        return $this->filter instanceof OpenApiParameterFilterInterface
            ? $this->filter->getOpenApiParameters($parameter)
            : null;
    }
}

but it feels hacky.

VincentLanglet avatar Nov 27 '25 19:11 VincentLanglet