Skip to content

Commit 7d9e6cf

Browse files
committed
Merge 4.2
2 parents 64768a6 + b7d4fc2 commit 7d9e6cf

File tree

46 files changed

+873
-348
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+873
-348
lines changed

docs/guides/computed-field.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
3636

3737
// Extract the desired sort direction ('asc' or 'desc') from the parameter's value.
3838
// IMPORTANT: 'totalQuantity' here MUST match the alias defined in Cart::handleLinks.
39-
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC');
39+
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue() ?? 'ASC');
4040
}
4141

4242
/**

docs/guides/create-a-custom-doctrine-filter.php

Lines changed: 49 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,97 +7,80 @@
77
// tags: doctrine, expert
88
// ---
99

10-
// Custom filters can be written by implementing the `ApiPlatform\Metadata\FilterInterface` interface.
10+
// Custom filters allow you to execute specific logic directly on the Doctrine QueryBuilder.
1111
//
12-
// API Platform provides a convenient way to create Doctrine ORM and MongoDB ODM filters. If you use [custom state providers](/docs/guide/state-providers), you can still create filters by implementing the previously mentioned interface, but - as API Platform isn't aware of your persistence system's internals - you have to create the filtering logic by yourself.
12+
// While API Platform provides many built-in filters (Search, Date, Range...), you often need to implement custom business logic. The recommended way is to implement the `ApiPlatform\Metadata\FilterInterface` and link it to a `QueryParameter`.
1313
//
14-
// Doctrine ORM filters have access to the context created from the HTTP request and to the `QueryBuilder` instance used to retrieve data from the database. They are only applied to collections. If you want to deal with the DQL query generated to retrieve items, [extensions](/docs/core/extensions/) are the way to go.
14+
// A Doctrine ORM filter has access to the `QueryBuilder` and the `QueryParameter` context.
1515
//
16-
// A Doctrine ORM filter is basically a class implementing the `ApiPlatform\Doctrine\Orm\Filter\FilterInterface`. API Platform includes a convenient abstract class implementing this interface and providing utility methods: `ApiPlatform\Doctrine\Orm\Filter\AbstractFilter`.
17-
//
18-
// Note: Doctrine MongoDB ODM filters have access to the context created from the HTTP request and to the [aggregation builder](https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/aggregation-builder.html) instance used to retrieve data from the database and to execute [complex operations on data](https://docs.mongodb.com/manual/aggregation/). They are only applied to collections. If you want to deal with the aggregation pipeline generated to retrieve items, [extensions](/docs/core/extensions/) are the way to go.
19-
//
20-
// A Doctrine MongoDB ODM filter is basically a class implementing the `ApiPlatform\Doctrine\Odm\Filter\FilterInterface`. API Platform includes a convenient abstract class implementing this interface and providing utility methods: `ApiPlatform\Doctrine\Odm\Filter\AbstractFilter`.
21-
//
22-
// In this example, we create a class to filter a collection by applying a regular expression to a property. The `REGEXP` DQL function used in this example can be found in the [DoctrineExtensions](https://github.com/beberlei/DoctrineExtensions) library. This library must be properly installed and registered to use this example (works only with MySQL).
16+
// In this example, we create a `MinLengthFilter` that filters resources where the length of a property is greater than or equal to a specific value. We map this filter to specific API parameters using the `#[QueryParameter]` attribute on our resource.
2317

2418
namespace App\Filter {
25-
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
19+
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
2620
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
2721
use ApiPlatform\Metadata\Operation;
2822
use Doctrine\ORM\QueryBuilder;
2923

30-
final class RegexpFilter extends AbstractFilter
24+
final class MinLengthFilter implements FilterInterface
3125
{
32-
/*
33-
* Filtered properties is accessible through getProperties() method: property => strategy
34-
*/
35-
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
26+
//The `apply` method is where the filtering logic happens.
27+
//We retrieve the parameter definition and its value from the context.
28+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
3629
{
37-
/*
38-
* Otherwise this filter is applied to order and page as well.
39-
*/
40-
if (
41-
!$this->isPropertyEnabled($property, $resourceClass)
42-
|| !$this->isPropertyMapped($property, $resourceClass)
43-
) {
30+
$parameter = $context['parameter'] ?? null;
31+
$value = $parameter?->getValue();
32+
33+
//If the value is missing or invalid, we skip the filter.
34+
if (!$value) {
35+
return;
36+
}
37+
38+
// We determine which property to filter on.
39+
// The `QueryParameter` attribute provides the property name (explicitly or inferred).
40+
$property = $parameter->getProperty();
41+
if (!$property) {
4442
return;
4543
}
4644

47-
/*
48-
* Generate a unique parameter name to avoid collisions with other filters.
49-
*/
45+
// Generate a unique parameter name to avoid collisions in the DQL.
5046
$parameterName = $queryNameGenerator->generateParameterName($property);
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
5149
$queryBuilder
52-
->andWhere(sprintf('REGEXP(o.%s, :%s) = 1', $property, $parameterName))
50+
->andWhere(sprintf('LENGTH(%s.%s) >= :%s', $alias, $property, $parameterName))
5351
->setParameter($parameterName, $value);
5452
}
5553

56-
/*
57-
* This function is only used to hook in documentation generators (supported by Swagger and Hydra).
58-
*/
54+
// Note: The `getDescription` method is no longer needed when using `QueryParameter`
55+
// because the documentation is handled by the attribute itself.
5956
public function getDescription(string $resourceClass): array
6057
{
61-
if (!$this->properties) {
62-
return [];
63-
}
64-
65-
$description = [];
66-
foreach ($this->properties as $property => $strategy) {
67-
$description["regexp_$property"] = [
68-
'property' => $property,
69-
'type' => 'string',
70-
'required' => false,
71-
'description' => 'Filter using a regex. This will appear in the OpenAPI documentation!',
72-
'openapi' => [
73-
'example' => 'Custom example that will be in the documentation and be the default value of the sandbox',
74-
/*
75-
* If true, query parameters will be not percent-encoded
76-
*/
77-
'allowReserved' => false,
78-
'allowEmptyValue' => true,
79-
/*
80-
* To be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product[]=blue&product[]=green
81-
*/
82-
'explode' => false,
83-
],
84-
];
85-
}
86-
87-
return $description;
58+
return [];
8859
}
8960
}
9061
}
9162

9263
namespace App\Entity {
93-
use ApiPlatform\Metadata\ApiFilter;
9464
use ApiPlatform\Metadata\ApiResource;
95-
use App\Filter\RegexpFilter;
65+
use ApiPlatform\Metadata\GetCollection;
66+
use ApiPlatform\Metadata\QueryParameter;
67+
use App\Filter\MinLengthFilter;
9668
use Doctrine\ORM\Mapping as ORM;
9769

9870
#[ORM\Entity]
99-
#[ApiResource]
100-
#[ApiFilter(RegexpFilter::class, properties: ['title'])]
71+
#[ApiResource(
72+
operations: [
73+
new GetCollection(
74+
parameters: [
75+
// We define a parameter 'min_length' that filters on the `title` and the `author` property using our custom logic.
76+
'min_length[:property]' => new QueryParameter(
77+
filter: MinLengthFilter::class,
78+
properties: ['title', 'author'],
79+
),
80+
]
81+
)
82+
]
83+
)]
10184
class Book
10285
{
10386
#[ORM\Column(type: 'integer')]
@@ -109,7 +92,6 @@ class Book
10992
public string $title;
11093

11194
#[ORM\Column]
112-
#[ApiFilter(RegexpFilter::class)]
11395
public string $author;
11496
}
11597
}
@@ -119,7 +101,7 @@ class Book
119101

120102
function request(): Request
121103
{
122-
return Request::create('/books.jsonld?regexp_title=^[Found]', 'GET');
104+
return Request::create('/books.jsonld?min_length[title]=10', 'GET');
123105
}
124106
}
125107

@@ -147,25 +129,25 @@ final class BookTest extends ApiTestCase
147129

148130
public function testAsAnonymousICanAccessTheDocumentation(): void
149131
{
150-
static::createClient()->request('GET', '/books.jsonld?regexp_title=^[Found]');
132+
static::createClient()->request('GET', '/books.jsonld?min_length[title]=10');
151133

152134
$this->assertResponseIsSuccessful();
153135
$this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection');
154136
$this->assertJsonContains([
155137
'search' => [
156138
'@type' => 'IriTemplate',
157-
'template' => '/books.jsonld{?regexp_title,regexp_author}',
139+
'template' => '/books.jsonld{?min_length[title],min_length[author]}',
158140
'variableRepresentation' => 'BasicRepresentation',
159141
'mapping' => [
160142
[
161143
'@type' => 'IriTemplateMapping',
162-
'variable' => 'regexp_title',
144+
'variable' => 'min_length[title]',
163145
'property' => 'title',
164146
'required' => false,
165147
],
166148
[
167149
'@type' => 'IriTemplateMapping',
168-
'variable' => 'regexp_author',
150+
'variable' => 'min_length[author]',
169151
'property' => 'author',
170152
'required' => false,
171153
],

phpstan.neon.dist

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,6 @@ parameters:
103103
# Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130
104104
- '#^Call to (static )?method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#'
105105

106-
# Unsealed array shapes not supported
107-
-
108-
message: '#^Parameter &\$context by\-ref type of method ApiPlatform\\Doctrine\\Odm\\Extension\\ParameterExtension\:\:applyFilter\(\) expects array\<string, mixed\>, array(.*) given\.$#'
109-
identifier: parameterByRef.type
110-
count: 5
111-
path: src/Doctrine/Odm/Extension/ParameterExtension.php
112-
113106
# Level 6
114107
-
115108
identifier: missingType.iterableValue

src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414
namespace ApiPlatform\Doctrine\Common\Filter;
1515

1616
/**
17+
* TODO: 5.x uncomment method.
18+
*
1719
* @author Antoine Bluchet <[email protected]>
1820
*
21+
* @method ?array getProperties()
22+
*
1923
* @experimental
2024
*/
2125
interface PropertyAwareFilterInterface
@@ -24,4 +28,9 @@ interface PropertyAwareFilterInterface
2428
* @param string[] $properties
2529
*/
2630
public function setProperties(array $properties): void;
31+
32+
// /**
33+
// * @return string[]
34+
// */
35+
// public function getProperties(): ?array;
2736
}

src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,6 @@ trait PropertyPlaceholderOpenApiParameterTrait
2323
*/
2424
public function getOpenApiParameters(Parameter $parameter): ?array
2525
{
26-
if (str_contains($parameter->getKey(), ':property')) {
27-
$parameters = [];
28-
$key = str_replace('[:property]', '', $parameter->getKey());
29-
foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) {
30-
$parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query');
31-
}
32-
33-
return $parameters;
34-
}
35-
36-
return null;
26+
return [new OpenApiParameter(name: $parameter->getKey(), in: 'query')];
3727
}
3828
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Common;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface;
17+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
18+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
19+
use ApiPlatform\Metadata\Parameter;
20+
use Doctrine\Persistence\ManagerRegistry;
21+
use Psr\Container\ContainerInterface;
22+
use Psr\Log\LoggerInterface;
23+
24+
trait ParameterExtensionTrait
25+
{
26+
use ParameterValueExtractorTrait;
27+
28+
protected ContainerInterface $filterLocator;
29+
protected ?ManagerRegistry $managerRegistry = null;
30+
protected ?LoggerInterface $logger = null;
31+
32+
/**
33+
* @param object $filter the filter instance to configure
34+
* @param Parameter $parameter the operation parameter associated with the filter
35+
*/
36+
private function configureFilter(object $filter, Parameter $parameter): void
37+
{
38+
if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) {
39+
$filter->setManagerRegistry($this->managerRegistry);
40+
}
41+
42+
if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) {
43+
$filter->setLogger($this->logger);
44+
}
45+
46+
if ($filter instanceof PropertyAwareFilterInterface) {
47+
$properties = [];
48+
// Check if the filter has getProperties method (e.g., if it's an AbstractFilter)
49+
if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface
50+
$properties = $filter->getProperties() ?? [];
51+
}
52+
53+
$propertyKey = $parameter->getProperty() ?? $parameter->getKey();
54+
foreach ($parameter->getProperties() ?? [$propertyKey] as $property) {
55+
if (!isset($properties[$property])) {
56+
$properties[$property] = $parameter->getFilterContext();
57+
}
58+
}
59+
60+
$filter->setProperties($properties);
61+
}
62+
}
63+
}

src/Doctrine/Common/ParameterValueExtractorTrait.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@ trait ParameterValueExtractorTrait
2323
private function extractParameterValue(Parameter $parameter, mixed $value): array
2424
{
2525
$key = $parameter->getProperty() ?? $parameter->getKey();
26-
if (!str_contains($key, ':property')) {
27-
return [$key => $value];
28-
}
2926

30-
return [str_replace('[:property]', '', $key) => $value];
27+
return [$key => $value];
3128
}
3229
}

src/Doctrine/Odm/Extension/ParameterExtension.php

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@
1313

1414
namespace ApiPlatform\Doctrine\Odm\Extension;
1515

16-
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface;
17-
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
18-
use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait;
19-
use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter;
20-
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
16+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
17+
use ApiPlatform\Doctrine\Common\ParameterExtensionTrait;
18+
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; // Explicitly import PropertyAwareFilterInterface
2119
use ApiPlatform\Metadata\Operation;
2220
use ApiPlatform\State\ParameterNotFound;
2321
use Doctrine\Bundle\MongoDBBundle\ManagerRegistry;
@@ -32,13 +30,16 @@
3230
*/
3331
final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface
3432
{
35-
use ParameterValueExtractorTrait;
33+
use ParameterExtensionTrait;
3634

3735
public function __construct(
38-
private readonly ContainerInterface $filterLocator,
39-
private readonly ?ManagerRegistry $managerRegistry = null,
40-
private readonly ?LoggerInterface $logger = null,
36+
ContainerInterface $filterLocator,
37+
?ManagerRegistry $managerRegistry = null,
38+
?LoggerInterface $logger = null,
4139
) {
40+
$this->filterLocator = $filterLocator;
41+
$this->managerRegistry = $managerRegistry;
42+
$this->logger = $logger;
4243
}
4344

4445
/**
@@ -66,28 +67,7 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass
6667
continue;
6768
}
6869

69-
if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) {
70-
$filter->setManagerRegistry($this->managerRegistry);
71-
}
72-
73-
if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) {
74-
$filter->setLogger($this->logger);
75-
}
76-
77-
if ($filter instanceof AbstractFilter && !$filter->getProperties()) {
78-
$propertyKey = $parameter->getProperty() ?? $parameter->getKey();
79-
80-
if (str_contains($propertyKey, ':property')) {
81-
$extraProperties = $parameter->getExtraProperties()['_properties'] ?? [];
82-
foreach (array_keys($extraProperties) as $property) {
83-
$properties[$property] = $parameter->getFilterContext();
84-
}
85-
} else {
86-
$properties = [$propertyKey => $parameter->getFilterContext()];
87-
}
88-
89-
$filter->setProperties($properties ?? []);
90-
}
70+
$this->configureFilter($filter, $parameter);
9171

9272
$context['filters'] = $values;
9373
$context['parameter'] = $parameter;

0 commit comments

Comments
 (0)