Preserve exact SearchFilter behaviour for Doctrine collection associations with QueryParameter
Maintainer thường phản hồi trong vòng 3 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 48/100
- Loại issue
- Tính năng
- Độ rõ ràng
- Khá rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- php
- Lĩnh vực
- backend-api-design
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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!
- Ngôn ngữ chính
- PHP
- Star
- 2.6k
- Fork
- 988
- Merge trung bình
- 4 ngày 13 giờ
- Pull request đã merge (30 ngày)
- 96
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của api-platform/core
-
API Platform ignores array shapes in the JSON SchemaCó thể đã có người làm @afraca đã nhận 10 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
api-platform/core#8766 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
api-platform/core#8744 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
JSON:API: resource schemas (`*.jsonapi`) have `properties` but no `type: object`Có thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở
Độ khó 2/5 Dưới một giờ Mức phù hợp với người mới 80/100
api-platform/core#8701 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 30/100
api-platform/core#8791 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
[Object Mapper] IriFilter / IriConverterParameterProvider fails to find items when filtering mapped resourcesCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
api-platform/core#8771 ·
Maintainer thường phản hồi trong vòng 3 ngày
Tất cả issue của api-platform/core
Issue tương tự
-
jira-created
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
-
No Code Attached Yet
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 90/100
joomla/joomla-cms#48786 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Icinga/icingaweb2-module-director#3188 ·
Maintainer thường phản hồi trong vòng 4 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
mollie/WooCommerce#1327 ·
-
bug ui
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
hashtopolis/server#2614 ·
Maintainer thường phản hồi trong vòng 1 ngày