Preserve exact SearchFilter behaviour for Doctrine collection associations with QueryParameter
维护者通常 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:
- 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!
- 主要语言
- PHP
- 星标
- 2.6k
- 派生
- 988
- 平均合并
- 4 天 13 小时
- 30 天内合并 PR
- 96
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
api-platform/core 的其他 Issue
-
API Platform ignores array shapes in the JSON Schema可能已有人在做 @afraca 于 9 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 78/100
api-platform/core#8766 ·
维护者通常 3 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
api-platform/core#8744 ·
维护者通常 3 天内回复
-
JSON:API: resource schemas (`*.jsonapi`) have `properties` but no `type: object`可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 2/5 1 小时以内 新手友好度 80/100
api-platform/core#8701 ·
维护者通常 3 天内回复
-
难度 5/5 一周以上 新手友好度 30/100
api-platform/core#8791 ·
维护者通常 3 天内回复
-
[Object Mapper] IriFilter / IriConverterParameterProvider fails to find items when filtering mapped resources可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 4/5 3-5 天 新手友好度 48/100
api-platform/core#8771 ·
维护者通常 3 天内回复
查看 api-platform/core 的全部 Issue
相似的 Issue
-
No Code Attached Yet
难度 1/5 1 小时以内 新手友好度 90/100
joomla/joomla-cms#48786 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
Icinga/icingaweb2-module-director#3188 ·
维护者通常 4 天内回复
-
bug ui
难度 2/5 1-3 小时 新手友好度 62/100
hashtopolis/server#2614 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
-
sync-en
难度 1/5 1-3 小时 新手友好度 84/100
维护者通常 1 天内回复