Skip to content

ExactFilter combined with DateFilter on same property doesn't work after the migration #8407

Description

@ES-Six

Hello API Platform team,

API Platform version(s) affected: 4.3+

Description
Before upgrade I was using SearchFilter in combinaision with DateFilter for a same property. It was working despite different filters use the same property because generated filters are using a different QueryParemeter in the final URL.

Exemple how it worked before migration :

Exact filter expect a list array and date filter expect an associative array with specific keys.

Exemple what is expected after migration :

Without the FilterApi, ExactFilter always triggers when an associative array is passed as value, but it should trigger only for list array values (like the old SearchFilter behaviour).

How to reproduce
First create a QueryParameter metadata to allow combining the filters on same property :

<?php

namespace App\Metadata;

use ApiPlatform\Metadata\QueryParameter;

/**
 * API Platform deduplicates QueryParameter metadata by key + class pair
 */
final class MergedDateFilterQueryParameter extends QueryParameter
{
}

Then declare filters like this example on the Doctrine entity you want :

parameters: [
  'birthdateExactFilter' => new QueryParameter(key: 'birthdate', filter: new ExactFilter(), property: 'birthdate', description: ''),
  'birthdateDateFilter' => new MergedDateFilterQueryParameter(key: 'birthdate', filter: new DateFilter(properties: ['birthdate' => null]), property: 'birthdate', description: '')
]

You will obtain the correct swagger doc, but date filter will not work as ExactFilter will always apply :

Exact filter in the doc :

Image

Date filter in the doc :
Image

As they generates different http keys, they could be combined like the old behaviour of the SearchFilter that allows that.

Possible Solution
In ExactFilter code :

<?php

/*
 * This file is part of the API Platform project.
 *
 * (c) Kévin Dunglas <dunglas@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;

/**
 * @author Vincent Amstoutz <vincent.amstoutz.dev@gmail.com>
 */
final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface
{
    use BackwardCompatibleFilterDescriptionTrait;
    use NestedPropertyHelperTrait;
    use OpenApiFilterTrait;

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

        // ---> This can fix the ExactFilter
        if (is_array($value) && !array_is_list($value)) { // Exclude associative arrays as they are not part of the ExactFilter contract (only list array are)
          return;
        }

        if (null === $parameter->getProperty()) {
            throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey()));
        }

        $property = $parameter->getProperty();
        $alias = $queryBuilder->getRootAliases()[0];
        $parameterName = $queryNameGenerator->generateParameterName($property);

        [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);

        if (\is_array($value)) {
            $queryBuilder
                ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName));
        } else {
            $operator = $context['operator'] ?? '=';
            if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) {
                throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator));
            }
            $queryBuilder
                ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s %s :%s', $alias, $property, $operator, $parameterName));
        }

        $queryBuilder->setParameter($parameterName, $value);
    }
}

Additional Context

Their is a workaround if I create a custom filter like this but it's not elegant :

<?php
// src/Filter/ScalarExactFilter.php
namespace App\Filter;

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

final class ExactExcludingAssociativeArrayFilter implements FilterInterface, OpenApiParameterFilterInterface
{
    private ExactFilter $exactFilter;

    public function __construct()
    {
        $this->exactFilter = new ExactFilter();
    }

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

        // This filter add the ExactFilter missing guard when it's combined with DateFilter
        if (is_array($value) && !array_is_list($value)) {
            return;
        }

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

    public function getOpenApiParameters(Parameter $parameter): OpenApiParameter
    {
        return $this->exactFilter->getOpenApiParameters($parameter);
    }

    /**
     * Backward compatibility fonction is required before API Platform v5.0
     * Don't use this.
     */
    public function getDescription(string $resourceClass): array
    {
        return [];
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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