Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit fc7f5f1

Browse files
committedNov 7, 2024··
feat(doctrine): doctrine filters like laravel eloquent filters
1 parent b458b55 commit fc7f5f1

30 files changed

+1141
-54
lines changed
 

‎src/Doctrine/Common/Filter/BooleanFilterTrait.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public function getDescription(string $resourceClass): array
6161
return $description;
6262
}
6363

64-
abstract protected function getProperties(): ?array;
64+
abstract public function getProperties(): ?array;
6565

6666
abstract protected function getLogger(): LoggerInterface;
6767

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Filter;
15+
16+
use Doctrine\Persistence\ManagerRegistry;
17+
18+
interface ManagerRegistryAwareInterface
19+
{
20+
public function hasManagerRegistry(): bool;
21+
22+
public function getManagerRegistry(): ManagerRegistry;
23+
24+
public function setManagerRegistry(?ManagerRegistry $managerRegistry): void;
25+
}

‎src/Doctrine/Odm/Extension/ParameterExtension.php

+41-11
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@
1313

1414
namespace ApiPlatform\Doctrine\Odm\Extension;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1617
use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait;
18+
use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter;
1719
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
1820
use ApiPlatform\Metadata\Operation;
1921
use ApiPlatform\State\ParameterNotFound;
22+
use Doctrine\Bundle\MongoDBBundle\ManagerRegistry;
2023
use Doctrine\ODM\MongoDB\Aggregation\Builder;
24+
use Psr\Container\ContainerExceptionInterface;
2125
use Psr\Container\ContainerInterface;
26+
use Psr\Container\NotFoundExceptionInterface;
2227

2328
/**
2429
* Reads operation parameters and execute its filter.
@@ -29,14 +34,20 @@ final class ParameterExtension implements AggregationCollectionExtensionInterfac
2934
{
3035
use ParameterValueExtractorTrait;
3136

32-
public function __construct(private readonly ContainerInterface $filterLocator)
33-
{
37+
public function __construct(
38+
private readonly ContainerInterface $filterLocator,
39+
private readonly ?ManagerRegistry $managerRegistry = null,
40+
) {
3441
}
3542

43+
/**
44+
* @throws ContainerExceptionInterface
45+
* @throws NotFoundExceptionInterface
46+
*/
3647
private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void
3748
{
3849
foreach ($operation->getParameters() ?? [] as $parameter) {
39-
if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
50+
if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
4051
continue;
4152
}
4253

@@ -45,14 +56,33 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass
4556
continue;
4657
}
4758

48-
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
49-
if ($filter instanceof FilterInterface) {
50-
$filterContext = ['filters' => $values, 'parameter' => $parameter];
51-
$filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext);
52-
// update by reference
53-
if (isset($filterContext['mongodb_odm_sort_fields'])) {
54-
$context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields'];
55-
}
59+
$filter = match (true) {
60+
$filterId instanceof FilterInterface => $filterId,
61+
\is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId),
62+
default => null,
63+
};
64+
65+
if (!($filter instanceof FilterInterface)) {
66+
return;
67+
}
68+
69+
if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) {
70+
$filter->setManagerRegistry($this->managerRegistry);
71+
}
72+
73+
if ($filter instanceof AbstractFilter && !$filter->getProperties()) {
74+
$propertyKey = $parameter->getProperty() ?? $parameter->getKey();
75+
$filterContext = $parameter->getFilterContext();
76+
77+
$properties = \is_array($filterContext) ? $filterContext : [$propertyKey => $filterContext];
78+
$filter->setProperties($properties);
79+
}
80+
81+
$filterContext = ['filters' => $values, 'parameter' => $parameter];
82+
$filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext);
83+
// update by reference
84+
if (isset($filterContext['mongodb_odm_sort_fields'])) {
85+
$context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields'];
5686
}
5787
}
5888
}

‎src/Doctrine/Odm/Filter/AbstractFilter.php

+28-6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Odm\Filter;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1617
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
1718
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
1819
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
@@ -30,14 +31,18 @@
3031
*
3132
* @author Alan Poulain <contact@alanpoulain.eu>
3233
*/
33-
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
34+
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface
3435
{
3536
use MongoDbOdmPropertyHelperTrait;
3637
use PropertyHelperTrait;
3738
protected LoggerInterface $logger;
3839

39-
public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null)
40-
{
40+
public function __construct(
41+
protected ?ManagerRegistry $managerRegistry = null,
42+
?LoggerInterface $logger = null,
43+
protected ?array $properties = null,
44+
protected ?NameConverterInterface $nameConverter = null,
45+
) {
4146
$this->logger = $logger ?? new NullLogger();
4247
}
4348

@@ -56,18 +61,35 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
5661
*/
5762
abstract protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void;
5863

59-
protected function getManagerRegistry(): ManagerRegistry
64+
public function hasManagerRegistry(): bool
65+
{
66+
return $this->managerRegistry instanceof ManagerRegistry;
67+
}
68+
69+
public function getManagerRegistry(): ManagerRegistry
6070
{
71+
if (!$this->hasManagerRegistry()) {
72+
throw new \RuntimeException('ManagerRegistry must be initialized before accessing it.');
73+
}
74+
6175
return $this->managerRegistry;
6276
}
6377

64-
protected function getProperties(): ?array
78+
public function setManagerRegistry(?ManagerRegistry $managerRegistry): void
79+
{
80+
$this->managerRegistry = $managerRegistry;
81+
}
82+
83+
/**
84+
* @return array<string, mixed>|null
85+
*/
86+
public function getProperties(): ?array
6587
{
6688
return $this->properties;
6789
}
6890

6991
/**
70-
* @param string[] $properties
92+
* @param array<string, mixed> $properties
7193
*/
7294
public function setProperties(array $properties): void
7395
{

‎src/Doctrine/Odm/Filter/BooleanFilter.php

+11-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
namespace ApiPlatform\Doctrine\Odm\Filter;
1515

1616
use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait;
17+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
1718
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\Metadata\Parameter;
1820
use Doctrine\ODM\MongoDB\Aggregation\Builder;
1921
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
2022

@@ -104,7 +106,7 @@
104106
* @author Teoh Han Hui <teohhanhui@gmail.com>
105107
* @author Alan Poulain <contact@alanpoulain.eu>
106108
*/
107-
final class BooleanFilter extends AbstractFilter
109+
final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface
108110
{
109111
use BooleanFilterTrait;
110112

@@ -139,4 +141,12 @@ protected function filterProperty(string $property, $value, Builder $aggregation
139141

140142
$aggregationBuilder->match()->field($matchField)->equals($value);
141143
}
144+
145+
/**
146+
* @return array<string, string>
147+
*/
148+
public function getSchema(Parameter $parameter): array
149+
{
150+
return $parameter->getSchema() ?? ['type' => 'boolean'];
151+
}
142152
}

‎src/Doctrine/Odm/Filter/DateFilter.php

+27-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait;
1818
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
19+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
20+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
1921
use ApiPlatform\Metadata\Operation;
22+
use ApiPlatform\Metadata\Parameter;
23+
use ApiPlatform\Metadata\QueryParameter;
24+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2025
use Doctrine\ODM\MongoDB\Aggregation\Builder;
2126
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
2227

@@ -117,7 +122,7 @@
117122
* @author Théo FIDRY <theo.fidry@gmail.com>
118123
* @author Alan Poulain <contact@alanpoulain.eu>
119124
*/
120-
final class DateFilter extends AbstractFilter implements DateFilterInterface
125+
final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
121126
{
122127
use DateFilterTrait;
123128

@@ -237,4 +242,25 @@ private function addMatch(Builder $aggregationBuilder, string $field, string $op
237242

238243
$aggregationBuilder->match()->addAnd($aggregationBuilder->matchExpr()->field($field)->operator($operatorValue[$operator], $value));
239244
}
245+
246+
/**
247+
* @return array<string, string>
248+
*/
249+
public function getSchema(Parameter $parameter): array
250+
{
251+
return ['type' => 'date'];
252+
}
253+
254+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
255+
{
256+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
257+
$key = $parameter->getKey();
258+
259+
return [
260+
new OpenApiParameter(name: $key.'[after]', in: $in),
261+
new OpenApiParameter(name: $key.'[before]', in: $in),
262+
new OpenApiParameter(name: $key.'[strictly_after]', in: $in),
263+
new OpenApiParameter(name: $key.'[strictly_before]', in: $in),
264+
];
265+
}
240266
}

‎src/Doctrine/Odm/Filter/RangeFilter.php

+18-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
1819
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\Metadata\QueryParameter;
22+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
1923
use Doctrine\ODM\MongoDB\Aggregation\Builder;
2024

2125
/**
@@ -104,7 +108,7 @@
104108
* @author Lee Siong Chan <ahlee2326@me.com>
105109
* @author Alan Poulain <contact@alanpoulain.eu>
106110
*/
107-
final class RangeFilter extends AbstractFilter implements RangeFilterInterface
111+
final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface
108112
{
109113
use RangeFilterTrait;
110114

@@ -204,4 +208,17 @@ protected function addMatch(Builder $aggregationBuilder, string $field, string $
204208
break;
205209
}
206210
}
211+
212+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
213+
{
214+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
215+
$key = $parameter->getKey();
216+
217+
return [
218+
new OpenApiParameter(name: $key.'[gt]', in: $in),
219+
new OpenApiParameter(name: $key.'[lt]', in: $in),
220+
new OpenApiParameter(name: $key.'[gte]', in: $in),
221+
new OpenApiParameter(name: $key.'[lte]', in: $in),
222+
];
223+
}
207224
}

‎src/Doctrine/Odm/PropertyHelperTrait.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
*/
2828
trait PropertyHelperTrait
2929
{
30-
abstract protected function getManagerRegistry(): ManagerRegistry;
30+
abstract protected function getManagerRegistry(): ?ManagerRegistry;
3131

3232
/**
3333
* Splits the given property into parts.
@@ -39,9 +39,9 @@ abstract protected function splitPropertyParts(string $property, string $resourc
3939
*/
4040
protected function getClassMetadata(string $resourceClass): ClassMetadata
4141
{
42-
$manager = $this
43-
->getManagerRegistry()
44-
->getManagerForClass($resourceClass);
42+
/** @var ?ManagerRegistry $managerRegistry */
43+
$managerRegistry = $this->getManagerRegistry();
44+
$manager = $managerRegistry?->getManagerForClass($resourceClass);
4545

4646
if ($manager) {
4747
return $manager->getClassMetadata($resourceClass);

‎src/Doctrine/Orm/Extension/ParameterExtension.php

+35-6
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@
1313

1414
namespace ApiPlatform\Doctrine\Orm\Extension;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1617
use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait;
18+
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
1719
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
1820
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1921
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
2022
use ApiPlatform\Metadata\Operation;
2123
use ApiPlatform\State\ParameterNotFound;
2224
use Doctrine\ORM\QueryBuilder;
25+
use Psr\Container\ContainerExceptionInterface;
2326
use Psr\Container\ContainerInterface;
27+
use Psr\Container\NotFoundExceptionInterface;
28+
use Symfony\Bridge\Doctrine\ManagerRegistry;
2429

2530
/**
2631
* Reads operation parameters and execute its filter.
@@ -31,17 +36,22 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que
3136
{
3237
use ParameterValueExtractorTrait;
3338

34-
public function __construct(private readonly ContainerInterface $filterLocator)
35-
{
39+
public function __construct(
40+
private readonly ContainerInterface $filterLocator,
41+
private readonly ?ManagerRegistry $managerRegistry = null,
42+
) {
3643
}
3744

3845
/**
3946
* @param array<string, mixed> $context
47+
*
48+
* @throws ContainerExceptionInterface
49+
* @throws NotFoundExceptionInterface
4050
*/
4151
private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
4252
{
4353
foreach ($operation?->getParameters() ?? [] as $parameter) {
44-
if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
54+
if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
4555
continue;
4656
}
4757

@@ -50,12 +60,31 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter
5060
continue;
5161
}
5262

53-
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
54-
if (!$filter instanceof FilterInterface) {
63+
$filter = match (true) {
64+
$filterId instanceof FilterInterface => $filterId,
65+
\is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId),
66+
default => null,
67+
};
68+
69+
if (!($filter instanceof FilterInterface)) {
5570
throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass));
5671
}
5772

58-
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context);
73+
if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) {
74+
$filter->setManagerRegistry($this->managerRegistry);
75+
}
76+
77+
if ($filter instanceof AbstractFilter && !$filter->getProperties()) {
78+
$propertyKey = $parameter->getProperty() ?? $parameter->getKey();
79+
$filterContext = $parameter->getFilterContext();
80+
81+
$properties = \is_array($filterContext) ? $filterContext : [$propertyKey => $filterContext];
82+
$filter->setProperties($properties);
83+
}
84+
85+
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation,
86+
['filters' => $values, 'parameter' => $parameter] + $context
87+
);
5988
}
6089
}
6190

‎src/Doctrine/Orm/Filter/AbstractFilter.php

+28-9
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Orm\Filter;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1617
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
1718
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
1819
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
@@ -24,14 +25,18 @@
2425
use Psr\Log\NullLogger;
2526
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2627

27-
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
28+
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface
2829
{
2930
use OrmPropertyHelperTrait;
3031
use PropertyHelperTrait;
3132
protected LoggerInterface $logger;
3233

33-
public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null)
34-
{
34+
public function __construct(
35+
protected ?ManagerRegistry $managerRegistry = null,
36+
?LoggerInterface $logger = null,
37+
protected ?array $properties = null,
38+
protected ?NameConverterInterface $nameConverter = null,
39+
) {
3540
$this->logger = $logger ?? new NullLogger();
3641
}
3742

@@ -53,29 +58,43 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
5358
*/
5459
abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void;
5560

56-
protected function getManagerRegistry(): ManagerRegistry
61+
public function hasManagerRegistry(): bool
62+
{
63+
return $this->managerRegistry instanceof ManagerRegistry;
64+
}
65+
66+
public function getManagerRegistry(): ManagerRegistry
5767
{
68+
if (!$this->hasManagerRegistry()) {
69+
throw new \RuntimeException('ManagerRegistry must be initialized before accessing it.');
70+
}
71+
5872
return $this->managerRegistry;
5973
}
6074

61-
protected function getProperties(): ?array
75+
public function setManagerRegistry(?ManagerRegistry $managerRegistry): void
6276
{
63-
return $this->properties;
77+
$this->managerRegistry = $managerRegistry;
6478
}
6579

66-
protected function getLogger(): LoggerInterface
80+
public function getProperties(): ?array
6781
{
68-
return $this->logger;
82+
return $this->properties;
6983
}
7084

7185
/**
72-
* @param string[] $properties
86+
* @param array<string, mixed> $properties
7387
*/
7488
public function setProperties(array $properties): void
7589
{
7690
$this->properties = $properties;
7791
}
7892

93+
protected function getLogger(): LoggerInterface
94+
{
95+
return $this->logger;
96+
}
97+
7998
/**
8099
* Determines whether the given property is enabled.
81100
*/

‎src/Doctrine/Orm/Filter/BooleanFilter.php

+11-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait;
1717
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
1819
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
1921
use Doctrine\DBAL\Types\Types;
2022
use Doctrine\ORM\Query\Expr\Join;
2123
use Doctrine\ORM\QueryBuilder;
@@ -106,7 +108,7 @@
106108
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
107109
* @author Teoh Han Hui <teohhanhui@gmail.com>
108110
*/
109-
final class BooleanFilter extends AbstractFilter
111+
final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface
110112
{
111113
use BooleanFilterTrait;
112114

@@ -145,4 +147,12 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB
145147
->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter))
146148
->setParameter($valueParameter, $value);
147149
}
150+
151+
/**
152+
* @return array<string, string>
153+
*/
154+
public function getSchema(Parameter $parameter): array
155+
{
156+
return $parameter->getSchema() ?? ['type' => 'boolean'];
157+
}
148158
}

‎src/Doctrine/Orm/Filter/DateFilter.php

+27-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait;
1818
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1919
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
20+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
21+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
2022
use ApiPlatform\Metadata\Operation;
23+
use ApiPlatform\Metadata\Parameter;
24+
use ApiPlatform\Metadata\QueryParameter;
25+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2126
use Doctrine\DBAL\Types\Type as DBALType;
2227
use Doctrine\DBAL\Types\Types;
2328
use Doctrine\ORM\Query\Expr\Join;
@@ -120,7 +125,7 @@
120125
* @author Kévin Dunglas <dunglas@gmail.com>
121126
* @author Théo FIDRY <theo.fidry@gmail.com>
122127
*/
123-
final class DateFilter extends AbstractFilter implements DateFilterInterface
128+
final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
124129
{
125130
use DateFilterTrait;
126131

@@ -269,4 +274,25 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf
269274

270275
$queryBuilder->setParameter($valueParameter, $value, $type);
271276
}
277+
278+
/**
279+
* @return array<string, string>
280+
*/
281+
public function getSchema(Parameter $parameter): array
282+
{
283+
return ['type' => 'date'];
284+
}
285+
286+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
287+
{
288+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
289+
$key = $parameter->getKey();
290+
291+
return [
292+
new OpenApiParameter(name: $key.'[after]', in: $in),
293+
new OpenApiParameter(name: $key.'[before]', in: $in),
294+
new OpenApiParameter(name: $key.'[strictly_after]', in: $in),
295+
new OpenApiParameter(name: $key.'[strictly_before]', in: $in),
296+
];
297+
}
272298
}

‎src/Doctrine/Orm/Filter/RangeFilter.php

+18-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait;
1818
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
19+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
1920
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Metadata\Parameter;
22+
use ApiPlatform\Metadata\QueryParameter;
23+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2024
use Doctrine\ORM\Query\Expr\Join;
2125
use Doctrine\ORM\QueryBuilder;
2226

@@ -105,7 +109,7 @@
105109
*
106110
* @author Lee Siong Chan <ahlee2326@me.com>
107111
*/
108-
final class RangeFilter extends AbstractFilter implements RangeFilterInterface
112+
final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface
109113
{
110114
use RangeFilterTrait;
111115

@@ -222,4 +226,17 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf
222226
break;
223227
}
224228
}
229+
230+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
231+
{
232+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
233+
$key = $parameter->getKey();
234+
235+
return [
236+
new OpenApiParameter(name: $key.'[gt]', in: $in),
237+
new OpenApiParameter(name: $key.'[lt]', in: $in),
238+
new OpenApiParameter(name: $key.'[gte]', in: $in),
239+
new OpenApiParameter(name: $key.'[lte]', in: $in),
240+
];
241+
}
225242
}

‎src/Doctrine/Orm/PropertyHelperTrait.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
*/
3030
trait PropertyHelperTrait
3131
{
32-
abstract protected function getManagerRegistry(): ManagerRegistry;
32+
abstract protected function getManagerRegistry(): ?ManagerRegistry;
3333

3434
/**
3535
* Splits the given property into parts.

‎src/Laravel/ApiPlatformProvider.php

+1
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ public function register(): void
360360
new ParameterResourceMetadataCollectionFactory(
361361
$this->app->make(PropertyNameCollectionFactoryInterface::class),
362362
$this->app->make(PropertyMetadataFactoryInterface::class),
363+
$this->app->make(LoggerInterface::class),
363364
new AlternateUriResourceMetadataCollectionFactory(
364365
new FiltersResourceMetadataCollectionFactory(
365366
new FormatsResourceMetadataCollectionFactory(

‎src/Metadata/Parameter.php

+10-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public function __construct(
4545
protected string|\Stringable|null $security = null,
4646
protected ?string $securityMessage = null,
4747
protected ?array $extraProperties = [],
48-
protected ?array $filterContext = null,
48+
protected array|string|null $filterContext = null,
4949
) {
5050
}
5151

@@ -138,7 +138,7 @@ public function getExtraProperties(): array
138138
return $this->extraProperties;
139139
}
140140

141-
public function getFilterContext(): ?array
141+
public function getFilterContext(): array|string|null
142142
{
143143
return $this->filterContext;
144144
}
@@ -203,6 +203,14 @@ public function withFilter(mixed $filter): static
203203
return $self;
204204
}
205205

206+
public function withFilterContext(array|string $filterContext): static
207+
{
208+
$self = clone $this;
209+
$self->filterContext = $filterContext;
210+
211+
return $self;
212+
}
213+
206214
public function withProperty(string $property): static
207215
{
208216
$self = clone $this;

‎src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

+34-7
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2929
use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface;
3030
use Psr\Container\ContainerInterface;
31+
use Psr\Log\LoggerInterface;
3132
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
3233

3334
/**
@@ -42,6 +43,7 @@ final class ParameterResourceMetadataCollectionFactory implements ResourceMetada
4243
public function __construct(
4344
private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
4445
private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory,
46+
private readonly LoggerInterface $logger,
4547
private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null,
4648
private readonly ?ContainerInterface $filterLocator = null,
4749
private readonly ?NameConverterInterface $nameConverter = null,
@@ -184,12 +186,6 @@ private function setDefaults(string $key, Parameter $parameter, string $resource
184186
$parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider');
185187
}
186188

187-
// Read filter description to populate the Parameter
188-
$description = $filter instanceof FilterInterface ? $filter->getDescription($this->getFilterClass($operation)) : [];
189-
if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) {
190-
$parameter = $parameter->withSchema($schema);
191-
}
192-
193189
$currentKey = $key;
194190
if (null === $parameter->getProperty() && isset($properties[$key])) {
195191
$parameter = $parameter->withProperty($key);
@@ -204,11 +200,42 @@ private function setDefaults(string $key, Parameter $parameter, string $resource
204200
$parameter = $parameter->withExtraProperties(['_query_property' => $eloquentRelation['foreign_key']] + $parameter->getExtraProperties());
205201
}
206202

203+
$parameter = $this->addFilterMetadata($parameter);
204+
205+
if ($filter instanceof FilterInterface) {
206+
try {
207+
return $this->getLegacyFilterMetadata($parameter, $operation, $filter);
208+
} catch (\RuntimeException $exception) {
209+
$this->logger->alert($exception->getMessage(), ['exception' => $exception]);
210+
211+
return $parameter;
212+
}
213+
}
214+
215+
return $parameter;
216+
}
217+
218+
private function getLegacyFilterMetadata(Parameter $parameter, Operation $operation, FilterInterface $filter): Parameter
219+
{
220+
$description = $filter->getDescription($this->getFilterClass($operation));
221+
$key = $parameter->getKey();
222+
if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) {
223+
$parameter = $parameter->withSchema($schema);
224+
}
225+
226+
if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) {
227+
$parameter = $parameter->withProperty($property);
228+
}
229+
207230
if (null === $parameter->getRequired() && ($required = $description[$key]['required'] ?? null)) {
208231
$parameter = $parameter->withRequired($required);
209232
}
210233

211-
return $this->addFilterMetadata($parameter);
234+
if (null === $parameter->getOpenApi() && ($openApi = $description[$key]['openapi'] ?? null) && $openApi instanceof OpenApiParameter) {
235+
$parameter = $parameter->withOpenApi($openApi);
236+
}
237+
238+
return $parameter;
212239
}
213240

214241
private function getFilterClass(Operation $operation): ?string

‎src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php

+9
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Metadata\Tests\Resource\Factory;
1515

16+
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
1617
use ApiPlatform\Metadata\FilterInterface;
1718
use ApiPlatform\Metadata\Parameters;
1819
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
@@ -22,16 +23,23 @@
2223
use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory;
2324
use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\WithParameter;
2425
use ApiPlatform\OpenApi\Model\Parameter;
26+
use PHPUnit\Framework\MockObject\Exception;
2527
use PHPUnit\Framework\TestCase;
2628
use Psr\Container\ContainerInterface;
29+
use Psr\Log\LoggerInterface;
2730

2831
class ParameterResourceMetadataCollectionFactoryTests extends TestCase
2932
{
33+
/**
34+
* @throws Exception
35+
* @throws ResourceClassNotFoundException
36+
*/
3037
public function testParameterFactory(): void
3138
{
3239
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
3340
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
3441
$filterLocator = $this->createStub(ContainerInterface::class);
42+
$logger = $this->createStub(LoggerInterface::class);
3543
$filterLocator->method('has')->willReturn(true);
3644
$filterLocator->method('get')->willReturn(new class implements FilterInterface {
3745
public function getDescription(string $resourceClass): array
@@ -55,6 +63,7 @@ public function getDescription(string $resourceClass): array
5563
$parameter = new ParameterResourceMetadataCollectionFactory(
5664
$nameCollection,
5765
$propertyMetadata,
66+
$logger,
5867
new AttributesResourceMetadataCollectionFactory(),
5968
$filterLocator
6069
);

‎src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137

138138
<service id="api_platform.doctrine_mongodb.odm.extension.parameter_extension" class="ApiPlatform\Doctrine\Odm\Extension\ParameterExtension" public="false">
139139
<argument type="service" id="api_platform.filter_locator" />
140-
140+
<argument type="service" id="doctrine_mongodb" on-invalid="null"/>
141141
<tag name="api_platform.doctrine_mongodb.odm.aggregation_extension.collection" priority="32" />
142142
<tag name="api_platform.doctrine_mongodb.odm.aggregation_extension.item"/>
143143
</service>

‎src/Symfony/Bundle/Resources/config/doctrine_orm.xml

+1
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150

151151
<service id="api_platform.doctrine.orm.extension.parameter_extension" class="ApiPlatform\Doctrine\Orm\Extension\ParameterExtension" public="false">
152152
<argument type="service" id="api_platform.filter_locator" />
153+
<argument type="service" id="doctrine" on-invalid="null"/>
153154
<tag name="api_platform.doctrine.orm.query_extension.collection" priority="-16" />
154155
<tag name="api_platform.doctrine.orm.query_extension.item" priority="-9" />
155156
</service>

‎src/Symfony/Bundle/Resources/config/metadata/resource.xml

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
<service id="api_platform.metadata.resource.metadata_collection_factory.parameter" class="ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" public="false" decoration-priority="1000">
8383
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
8484
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
85+
<argument type="service" id="logger" />
8586
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.parameter.inner" />
8687
<argument type="service" id="api_platform.filter_locator" />
8788
</service>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\QueryParameter;
20+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
21+
22+
#[ApiResource]
23+
#[GetCollection(
24+
parameters: [
25+
'active' => new QueryParameter(
26+
filter: new BooleanFilter(),
27+
),
28+
'enabled' => new QueryParameter(
29+
filter: new BooleanFilter(),
30+
property: 'active',
31+
),
32+
],
33+
)]
34+
#[ODM\Document]
35+
class FilteredBooleanParameter
36+
{
37+
public function __construct(
38+
#[ODM\Id(type: 'int', strategy: 'INCREMENT')]
39+
public ?int $id = null,
40+
41+
#[ODM\Field(type: 'bool', nullable: true)]
42+
public ?bool $active = null,
43+
) {
44+
}
45+
46+
public function getId(): ?int
47+
{
48+
return $this->id;
49+
}
50+
51+
public function isActive(): bool
52+
{
53+
return $this->active;
54+
}
55+
56+
public function setActive(?bool $active): void
57+
{
58+
$this->active = $active;
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
17+
use ApiPlatform\Doctrine\Odm\Filter\DateFilter;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
22+
23+
#[ApiResource]
24+
#[GetCollection(
25+
paginationItemsPerPage: 5,
26+
parameters: [
27+
'createdAt' => new QueryParameter(
28+
filter: new DateFilter(),
29+
),
30+
'date' => new QueryParameter(
31+
filter: new DateFilter(),
32+
property: 'createdAt',
33+
),
34+
'date_include_null_always' => new QueryParameter(
35+
filter: new DateFilter(),
36+
property: 'createdAt',
37+
filterContext: DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER,
38+
),
39+
'date_old_way' => new QueryParameter(
40+
filter: new DateFilter(),
41+
property: 'createdAt',
42+
filterContext: ['createdAt' => DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER],
43+
),
44+
],
45+
)]
46+
#[ODM\Document]
47+
class FilteredDateParameter
48+
{
49+
public function __construct(
50+
#[ODM\Id(type: 'int', strategy: 'INCREMENT')]
51+
public ?int $id = null,
52+
53+
#[ODM\Field(type: 'date_immutable', nullable: true)]
54+
public ?\DateTimeImmutable $createdAt = null,
55+
) {
56+
}
57+
58+
public function getId(): ?int
59+
{
60+
return $this->id;
61+
}
62+
63+
public function getCreatedAt(): ?\DateTimeImmutable
64+
{
65+
return $this->createdAt;
66+
}
67+
68+
public function setCreatedAt(?\DateTimeImmutable $createdAt): void
69+
{
70+
$this->createdAt = $createdAt;
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\RangeFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\QueryParameter;
20+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
21+
22+
#[ApiResource]
23+
#[GetCollection(
24+
paginationItemsPerPage: 5,
25+
parameters: [
26+
'quantity' => new QueryParameter(
27+
filter: new RangeFilter(),
28+
),
29+
'amount' => new QueryParameter(
30+
filter: new RangeFilter(),
31+
property: 'quantity',
32+
),
33+
],
34+
)]
35+
#[ODM\Document]
36+
class FilteredRangeParameter
37+
{
38+
public function __construct(
39+
#[ODM\Id(type: 'int', strategy: 'INCREMENT')]
40+
public ?int $id = null,
41+
42+
#[ODM\Field(type: 'int', nullable: true)]
43+
public ?int $quantity = null,
44+
) {
45+
}
46+
47+
public function getId(): ?int
48+
{
49+
return $this->id;
50+
}
51+
52+
public function getQuantity(): ?int
53+
{
54+
return $this->quantity;
55+
}
56+
57+
public function setQuantity(?int $quantity): void
58+
{
59+
$this->quantity = $quantity;
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\QueryParameter;
20+
use Doctrine\ORM\Mapping as ORM;
21+
22+
#[ApiResource]
23+
#[GetCollection(
24+
parameters: [
25+
'active' => new QueryParameter(
26+
filter: new BooleanFilter(),
27+
),
28+
'enabled' => new QueryParameter(
29+
filter: new BooleanFilter(),
30+
property: 'active',
31+
),
32+
],
33+
)]
34+
#[ORM\Entity]
35+
class FilteredBooleanParameter
36+
{
37+
public function __construct(
38+
#[ORM\Column]
39+
#[ORM\Id]
40+
#[ORM\GeneratedValue(strategy: 'AUTO')]
41+
public ?int $id = null,
42+
43+
#[ORM\Column(nullable: true)]
44+
public ?bool $active = null,
45+
) {
46+
}
47+
48+
public function getId(): ?int
49+
{
50+
return $this->id;
51+
}
52+
53+
public function isActive(): bool
54+
{
55+
return $this->active;
56+
}
57+
58+
public function setActive(?bool $isActive): void
59+
{
60+
$this->active = $isActive;
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
17+
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use Doctrine\ORM\Mapping as ORM;
22+
23+
#[ApiResource]
24+
#[GetCollection(
25+
paginationItemsPerPage: 5,
26+
parameters: [
27+
'createdAt' => new QueryParameter(
28+
filter: new DateFilter(),
29+
),
30+
'date' => new QueryParameter(
31+
filter: new DateFilter(),
32+
property: 'createdAt',
33+
),
34+
'date_include_null_always' => new QueryParameter(
35+
filter: new DateFilter(),
36+
property: 'createdAt',
37+
filterContext: DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER,
38+
),
39+
'date_old_way' => new QueryParameter(
40+
filter: new DateFilter(),
41+
property: 'createdAt',
42+
filterContext: ['createdAt' => DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER],
43+
),
44+
],
45+
)]
46+
#[ORM\Entity]
47+
class FilteredDateParameter
48+
{
49+
public function __construct(
50+
#[ORM\Column]
51+
#[ORM\Id]
52+
#[ORM\GeneratedValue(strategy: 'AUTO')]
53+
public ?int $id = null,
54+
55+
#[ORM\Column(nullable: true)]
56+
public ?\DateTimeImmutable $createdAt = null,
57+
) {
58+
}
59+
60+
public function getId(): ?int
61+
{
62+
return $this->id;
63+
}
64+
65+
public function getCreatedAt(): ?\DateTimeImmutable
66+
{
67+
return $this->createdAt;
68+
}
69+
70+
public function setCreatedAt(?\DateTimeImmutable $createdAt): void
71+
{
72+
$this->createdAt = $createdAt;
73+
}
74+
}
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 <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\QueryParameter;
20+
use Doctrine\ORM\Mapping as ORM;
21+
22+
#[ApiResource]
23+
#[GetCollection(
24+
paginationItemsPerPage: 5,
25+
parameters: [
26+
'quantity' => new QueryParameter(
27+
filter: new RangeFilter(),
28+
),
29+
'amount' => new QueryParameter(
30+
filter: new RangeFilter(),
31+
property: 'quantity',
32+
),
33+
],
34+
)]
35+
#[ORM\Entity]
36+
class FilteredRangeParameter
37+
{
38+
public function __construct(
39+
#[ORM\Column]
40+
#[ORM\Id]
41+
#[ORM\GeneratedValue(strategy: 'AUTO')]
42+
public ?int $id = null,
43+
44+
#[ORM\Column(nullable: true)]
45+
public ?int $quantity = null,
46+
) {
47+
}
48+
49+
public function getId(): ?int
50+
{
51+
return $this->id;
52+
}
53+
54+
public function getQuantity(): ?int
55+
{
56+
return $this->quantity;
57+
}
58+
59+
public function setQuantity(?int $quantity): void
60+
{
61+
$this->quantity = $quantity;
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredBooleanParameter as FilteredBooleanParameterDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredBooleanParameter;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
use Doctrine\ODM\MongoDB\MongoDBException;
22+
use PHPUnit\Framework\Attributes\DataProvider;
23+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
24+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
25+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
28+
29+
final class BooleanFilterTest extends ApiTestCase
30+
{
31+
use RecreateSchemaTrait;
32+
use SetupClassResourcesTrait;
33+
34+
/**
35+
* @return class-string[]
36+
*/
37+
public static function getResources(): array
38+
{
39+
return [FilteredBooleanParameter::class];
40+
}
41+
42+
/**
43+
* @throws MongoDBException
44+
* @throws \Throwable
45+
*/
46+
protected function setUp(): void
47+
{
48+
$entityClass = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class;
49+
50+
$this->recreateSchema([$entityClass]);
51+
$this->loadFixtures($entityClass);
52+
}
53+
54+
/**
55+
* @throws TransportExceptionInterface
56+
* @throws ServerExceptionInterface
57+
* @throws RedirectionExceptionInterface
58+
* @throws DecodingExceptionInterface
59+
* @throws ClientExceptionInterface
60+
*/
61+
#[DataProvider('booleanFilterScenariosProvider')]
62+
public function testBooleanFilterResponses(string $url, int $expectedCount): void
63+
{
64+
$response = self::createClient()->request('GET', $url);
65+
$this->assertResponseIsSuccessful();
66+
67+
$responseData = $response->toArray();
68+
$filteredItems = $responseData['hydra:member'];
69+
70+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
71+
72+
$expectedValue = str_contains($url, '=true') || str_contains($url, '=1');
73+
foreach ($filteredItems as $item) {
74+
$errorMessage = \sprintf("Expected 'active' to be %s", $expectedValue ? 'true' : 'false');
75+
$this->assertSame($expectedValue, $item['active'], $errorMessage);
76+
}
77+
}
78+
79+
public static function booleanFilterScenariosProvider(): \Generator
80+
{
81+
yield 'active_true' => ['/filtered_boolean_parameters?active=true', 2];
82+
yield 'active_false' => ['/filtered_boolean_parameters?active=false', 1];
83+
yield 'active_numeric_1' => ['/filtered_boolean_parameters?active=1', 2];
84+
yield 'active_numeric_0' => ['/filtered_boolean_parameters?active=0', 1];
85+
yield 'enabled_alias_true' => ['/filtered_boolean_parameters?enabled=true', 2];
86+
yield 'enabled_alias_false' => ['/filtered_boolean_parameters?enabled=false', 1];
87+
yield 'enabled_alias_numeric_1' => ['/filtered_boolean_parameters?enabled=1', 2];
88+
yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1];
89+
}
90+
91+
/**
92+
* @throws RedirectionExceptionInterface
93+
* @throws DecodingExceptionInterface
94+
* @throws ClientExceptionInterface
95+
* @throws TransportExceptionInterface
96+
* @throws ServerExceptionInterface
97+
*/
98+
#[DataProvider('booleanFilterNullAndEmptyScenariosProvider')]
99+
public function testBooleanFilterWithNullAndEmptyValues(string $url): void
100+
{
101+
$response = self::createClient()->request('GET', $url);
102+
$this->assertResponseIsSuccessful();
103+
104+
$responseData = $response->toArray();
105+
$filteredItems = $responseData['hydra:member'];
106+
107+
$expectedCount = 3;
108+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
109+
}
110+
111+
public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator
112+
{
113+
yield 'active_null_value' => ['/filtered_boolean_parameters?active=null'];
114+
yield 'active_empty_value' => ['/filtered_boolean_parameters?active=', 3];
115+
yield 'enabled_alias_null_value' => ['/filtered_boolean_parameters?enabled=null'];
116+
yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=', 3];
117+
}
118+
119+
/**
120+
* @throws \Throwable
121+
* @throws MongoDBException
122+
*/
123+
private function loadFixtures(string $entityClass): void
124+
{
125+
$manager = $this->getManager();
126+
127+
$booleanStates = [true, true, false, null];
128+
foreach ($booleanStates as $activeValue) {
129+
$entity = new $entityClass(active: $activeValue);
130+
$manager->persist($entity);
131+
}
132+
133+
$manager->flush();
134+
}
135+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredDateParameter as FilteredDateParameterDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredDateParameter;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
use Doctrine\ODM\MongoDB\MongoDBException;
22+
use PHPUnit\Framework\Attributes\DataProvider;
23+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
24+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
25+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
28+
29+
final class DateFilterTest extends ApiTestCase
30+
{
31+
use RecreateSchemaTrait;
32+
use SetupClassResourcesTrait;
33+
34+
/**
35+
* @return class-string[]
36+
*/
37+
public static function getResources(): array
38+
{
39+
return [FilteredDateParameter::class];
40+
}
41+
42+
/**
43+
* @throws \Throwable
44+
*/
45+
protected function setUp(): void
46+
{
47+
$entityClass = $this->isMongoDB() ? FilteredDateParameterDocument::class : FilteredDateParameter::class;
48+
49+
$this->recreateSchema([$entityClass]);
50+
$this->loadFixtures($entityClass);
51+
}
52+
53+
/**
54+
* @throws TransportExceptionInterface
55+
* @throws ServerExceptionInterface
56+
* @throws RedirectionExceptionInterface
57+
* @throws DecodingExceptionInterface
58+
* @throws ClientExceptionInterface
59+
*/
60+
#[DataProvider('dateFilterScenariosProvider')]
61+
public function testDateFilterResponses(string $url, int $expectedCount): void
62+
{
63+
$response = self::createClient()->request('GET', $url);
64+
$this->assertResponseIsSuccessful();
65+
66+
$responseData = $response->toArray();
67+
$filteredItems = $responseData['hydra:member'];
68+
69+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
70+
}
71+
72+
public static function dateFilterScenariosProvider(): \Generator
73+
{
74+
yield 'created_at_after' => ['/filtered_date_parameters?createdAt[after]=2024-01-01', 3];
75+
yield 'created_at_before' => ['/filtered_date_parameters?createdAt[before]=2024-12-31', 3];
76+
yield 'created_at_before_single_result' => ['/filtered_date_parameters?createdAt[before]=2024-01-02', 1];
77+
yield 'created_at_strictly_after' => ['/filtered_date_parameters?createdAt[strictly_after]=2024-01-01', 2];
78+
yield 'created_at_strictly_before' => ['/filtered_date_parameters?createdAt[strictly_before]=2024-12-31T23:59:59Z', 3];
79+
yield 'date_alias_after' => ['/filtered_date_parameters?date[after]=2024-01-01', 3];
80+
yield 'date_alias_before' => ['/filtered_date_parameters?date[before]=2024-12-31', 3];
81+
yield 'date_alias_before_first' => ['/filtered_date_parameters?date[before]=2024-01-02', 1];
82+
yield 'date_alias_strictly_after' => ['/filtered_date_parameters?date[strictly_after]=2024-01-01', 2];
83+
yield 'date_alias_strictly_before' => ['/filtered_date_parameters?date[strictly_before]=2024-12-31T23:59:59Z', 3];
84+
yield 'date_alias_include_null_always_after_date' => ['/filtered_date_parameters?date_include_null_always[after]=2024-06-15', 3];
85+
yield 'date_alias_include_null_always_before_date' => ['/filtered_date_parameters?date_include_null_always[before]=2024-06-14', 2];
86+
yield 'date_alias_include_null_always_before_all_date' => ['/filtered_date_parameters?date_include_null_always[before]=2024-12-31', 4];
87+
yield 'date_alias_old_way' => ['/filtered_date_parameters?date_old_way[before]=2024-06-14', 2];
88+
yield 'date_alias_old_way_after_last_one' => ['/filtered_date_parameters?date_old_way[after]=2024-12-31', 1];
89+
}
90+
91+
/**
92+
* @throws RedirectionExceptionInterface
93+
* @throws DecodingExceptionInterface
94+
* @throws ClientExceptionInterface
95+
* @throws TransportExceptionInterface
96+
* @throws ServerExceptionInterface
97+
*/
98+
#[DataProvider('dateFilterNullAndEmptyScenariosProvider')]
99+
public function testDateFilterWithNullAndEmptyValues(string $url, int $expectedCount): void
100+
{
101+
$response = self::createClient()->request('GET', $url);
102+
$this->assertResponseIsSuccessful();
103+
104+
$responseData = $response->toArray();
105+
$filteredItems = $responseData['hydra:member'];
106+
107+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
108+
}
109+
110+
public static function dateFilterNullAndEmptyScenariosProvider(): \Generator
111+
{
112+
yield 'created_at_null_value' => ['/filtered_date_parameters?createdAt=null', 4];
113+
yield 'created_at_empty_value' => ['/filtered_date_parameters?createdAt=', 4];
114+
yield 'date_null_value_alias' => ['/filtered_date_parameters?date=null', 4];
115+
yield 'date_empty_value_alias' => ['/filtered_date_parameters?date=', 4];
116+
yield 'date_alias__include_null_always_with_null_alias' => ['/filtered_date_parameters?date_include_null_always=null', 4];
117+
yield 'date__alias_include_null_always_with_empty_alias' => ['/filtered_date_parameters?date_include_null_always=', 4];
118+
yield 'date_alias_old_way_with_null_alias' => ['/filtered_date_parameters?date_old_way=null', 4];
119+
yield 'date__alias_old_way_with_empty_alias' => ['/filtered_date_parameters?date_old_way=', 4];
120+
}
121+
122+
/**
123+
* @throws \Throwable
124+
* @throws MongoDBException
125+
*/
126+
private function loadFixtures(string $entityClass): void
127+
{
128+
$manager = $this->getManager();
129+
130+
$dates = [
131+
new \DateTimeImmutable('2024-01-01'),
132+
new \DateTimeImmutable('2024-06-15'),
133+
new \DateTimeImmutable('2024-12-25'),
134+
null,
135+
];
136+
137+
foreach ($dates as $createdAtValue) {
138+
$entity = new $entityClass(createdAt: $createdAtValue);
139+
$manager->persist($entity);
140+
}
141+
142+
$manager->flush();
143+
}
144+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredRangeParameter as FilteredRangeParameterDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredRangeParameter;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
use Doctrine\ODM\MongoDB\MongoDBException;
22+
use PHPUnit\Framework\Attributes\DataProvider;
23+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
24+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
25+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
28+
29+
final class RangeFilterTest extends ApiTestCase
30+
{
31+
use RecreateSchemaTrait;
32+
use SetupClassResourcesTrait;
33+
34+
/**
35+
* @return class-string[]
36+
*/
37+
public static function getResources(): array
38+
{
39+
return [FilteredRangeParameter::class];
40+
}
41+
42+
/**
43+
* @throws \Throwable
44+
*/
45+
protected function setUp(): void
46+
{
47+
$entityClass = $this->isMongoDB() ? FilteredRangeParameterDocument::class : FilteredRangeParameter::class;
48+
49+
$this->recreateSchema([$entityClass]);
50+
$this->loadFixtures($entityClass);
51+
}
52+
53+
/**
54+
* Tests range filter with different scenarios on `quantity` field.
55+
*
56+
* @throws TransportExceptionInterface
57+
* @throws ServerExceptionInterface
58+
* @throws RedirectionExceptionInterface
59+
* @throws DecodingExceptionInterface
60+
* @throws ClientExceptionInterface
61+
*/
62+
#[DataProvider('rangeFilterScenariosProvider')]
63+
public function testRangeFilterResponses(string $url, int $expectedCount): void
64+
{
65+
$response = self::createClient()->request('GET', $url);
66+
$this->assertResponseIsSuccessful();
67+
68+
$responseData = $response->toArray();
69+
$filteredItems = $responseData['hydra:member'];
70+
71+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
72+
}
73+
74+
public static function rangeFilterScenariosProvider(): \Generator
75+
{
76+
yield 'quantity_greater_than' => ['/filtered_range_parameters?quantity[gt]=10', 3];
77+
yield 'quantity_less_than' => ['/filtered_range_parameters?quantity[lt]=50', 3];
78+
yield 'quantity_between' => ['/filtered_range_parameters?quantity[between]=15..40', 2];
79+
yield 'quantity_gte' => ['/filtered_range_parameters?quantity[gte]=20', 3];
80+
yield 'quantity_lte' => ['/filtered_range_parameters?quantity[lte]=30', 3];
81+
yield 'quantity_greater_than_and_less_than' => ['/filtered_range_parameters?quantity[gt]=10&quantity[lt]=50', 2];
82+
yield 'quantity_gte_and_lte' => ['/filtered_range_parameters?quantity[gte]=20&quantity[lte]=30', 2];
83+
yield 'quantity_gte_and_less_than' => ['/filtered_range_parameters?quantity[gte]=15&quantity[lt]=50', 2];
84+
yield 'quantity_between_and_lte' => ['/filtered_range_parameters?quantity[between]=15..40&quantity[lte]=30', 2];
85+
yield 'amount_alias_greater_than' => ['/filtered_range_parameters?amount[gt]=10', 3];
86+
yield 'amount_alias_less_than' => ['/filtered_range_parameters?amount[lt]=50', 3];
87+
yield 'amount_alias_between' => ['/filtered_range_parameters?amount[between]=15..40', 2];
88+
yield 'amount_alias_gte' => ['/filtered_range_parameters?amount[gte]=20', 3];
89+
yield 'amount_alias_lte' => ['/filtered_range_parameters?amount[lte]=30', 3];
90+
yield 'amount_alias_gte_and_lte' => ['/filtered_range_parameters?amount[gte]=20&amount[lte]=30', 2];
91+
yield 'amount_alias_greater_than_and_less_than' => ['/filtered_range_parameters?amount[gt]=10&amount[lt]=50', 2];
92+
yield 'amount_alias_between_and_gte' => ['/filtered_range_parameters?amount[between]=15..40&amount[gte]=20', 2];
93+
yield 'amount_alias_lte_and_between' => ['/filtered_range_parameters?amount[lte]=30&amount[between]=15..40', 2];
94+
}
95+
96+
/**
97+
* @throws TransportExceptionInterface
98+
* @throws ServerExceptionInterface
99+
* @throws RedirectionExceptionInterface
100+
* @throws DecodingExceptionInterface
101+
* @throws ClientExceptionInterface
102+
*/
103+
#[DataProvider('nullAndEmptyScenariosProvider')]
104+
public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void
105+
{
106+
$response = self::createClient()->request('GET', $url);
107+
$this->assertResponseIsSuccessful();
108+
109+
$responseData = $response->toArray();
110+
$filteredItems = $responseData['hydra:member'];
111+
112+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
113+
}
114+
115+
public static function nullAndEmptyScenariosProvider(): \Generator
116+
{
117+
yield 'quantity_null_value' => ['/filtered_range_parameters?quantity=null', 4];
118+
yield 'quantity_empty_value' => ['/filtered_range_parameters?quantity=', 4];
119+
yield 'amont_alias_null_value' => ['/filtered_range_parameters?amount=null', 4];
120+
yield 'amount_alias_empty_value' => ['/filtered_range_parameters?amount=', 4];
121+
}
122+
123+
/**
124+
* @throws \Throwable
125+
* @throws MongoDBException
126+
*/
127+
private function loadFixtures(string $entityClass): void
128+
{
129+
$manager = $this->getManager();
130+
131+
foreach ([10, 20, 30, 50] as $quantity) {
132+
$entity = new $entityClass(quantity: $quantity);
133+
$manager->persist($entity);
134+
}
135+
136+
$manager->flush();
137+
}
138+
}

0 commit comments

Comments
 (0)
Please sign in to comment.