Preserve exact SearchFilter behaviour for Doctrine collection associations with QueryParameter
Los mantenedores suelen responder en 3 días
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 48/100
- Tipo de issue
- Nueva funcionalidad
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- php
- Área
- backend-api-design
Línea de trabajo
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.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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:
- IRI values: resolve each IRI into its corresponding resource, then delegate to
IriFilter. - 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 becauseExactFiltercannot directly filter collection-valued associations. - 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
SearchFilterand the newQueryParameter-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!
- Lenguaje dominante
- PHP
- Estrellas
- 2.6k
- Forks
- 988
- Merge medio
- 4 d 13 h
- PR fusionados (30 d)
- 96
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de api-platform/core
-
API Platform ignores array shapes in the JSON SchemaPosiblemente ocupada @afraca la tomó hace 10 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
api-platform/core#8766 ·
Los mantenedores suelen responder en 3 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
api-platform/core#8744 ·
Los mantenedores suelen responder en 3 días
-
JSON:API: resource schemas (`*.jsonapi`) have `properties` but no `type: object`Posiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 Menos de una hora Aptitud para principiantes 80/100
api-platform/core#8701 ·
Los mantenedores suelen responder en 3 días
-
[RFC] Resources defined outside of PHP code (runtime / external metadata sources)Posiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 5/5 Más de una semana Aptitud para principiantes 30/100
api-platform/core#8791 ·
Los mantenedores suelen responder en 3 días
-
[Object Mapper] IriFilter / IriConverterParameterProvider fails to find items when filtering mapped resourcesPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
api-platform/core#8771 ·
Los mantenedores suelen responder en 3 días
Todos los issues de api-platform/core
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Automattic/blocks-engine#4621 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
YunoHost-Apps/snappymail_ynh#205 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 77/100
Automattic/static-site-importer#2653 ·
Los mantenedores suelen responder en 1 día
-
Bug (unconfirmed) Search :mag:
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
FreshRSS/FreshRSS#9533 · 1 reacción ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 83/100
ExpressionEngine/ExpressionEngine#5453 ·
Los mantenedores suelen responder en 1 día