Skip to content

Commit b4f6e50

Browse files
feat(doctrine): doctrine filters like laravel eloquent filters (#6775)
* feat(doctrine): doctrine filters like laravel eloquent filters * fix: allow multiple validation with :property placeholder * fix: correct escape filter condition * fix: remove duplicated block --------- Co-authored-by: soyuka <[email protected]>
1 parent 198a0b3 commit b4f6e50

File tree

9 files changed

+191
-40
lines changed

9 files changed

+191
-40
lines changed

Extension/ParameterExtension.php

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
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;
2325
use Psr\Container\ContainerInterface;
26+
use Symfony\Bridge\Doctrine\ManagerRegistry;
2427

2528
/**
2629
* Reads operation parameters and execute its filter.
@@ -31,8 +34,10 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que
3134
{
3235
use ParameterValueExtractorTrait;
3336

34-
public function __construct(private readonly ContainerInterface $filterLocator)
35-
{
37+
public function __construct(
38+
private readonly ContainerInterface $filterLocator,
39+
private readonly ?ManagerRegistry $managerRegistry = null,
40+
) {
3641
}
3742

3843
/**
@@ -41,7 +46,8 @@ public function __construct(private readonly ContainerInterface $filterLocator)
4146
private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
4247
{
4348
foreach ($operation?->getParameters() ?? [] as $parameter) {
44-
if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
49+
// TODO: remove the null equality as a parameter can have a null value
50+
if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
4551
continue;
4652
}
4753

@@ -50,12 +56,38 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter
5056
continue;
5157
}
5258

53-
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
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+
5465
if (!$filter instanceof FilterInterface) {
5566
throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass));
5667
}
5768

58-
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context);
69+
if ($this->managerRegistry && $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+
76+
if (str_contains($propertyKey, ':property')) {
77+
$extraProperties = $parameter->getExtraProperties()['_properties'] ?? [];
78+
foreach (array_keys($extraProperties) as $property) {
79+
$properties[$property] = $parameter->getFilterContext();
80+
}
81+
} else {
82+
$properties = [$propertyKey => $parameter->getFilterContext()];
83+
}
84+
85+
$filter->setProperties($properties ?? []);
86+
}
87+
88+
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation,
89+
['filters' => $values, 'parameter' => $parameter] + $context
90+
);
5991
}
6092
}
6193

Filter/AbstractFilter.php

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,31 @@
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;
1920
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
21+
use ApiPlatform\Metadata\Exception\RuntimeException;
2022
use ApiPlatform\Metadata\Operation;
2123
use Doctrine\ORM\QueryBuilder;
2224
use Doctrine\Persistence\ManagerRegistry;
2325
use Psr\Log\LoggerInterface;
2426
use Psr\Log\NullLogger;
2527
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2628

27-
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
29+
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface
2830
{
2931
use OrmPropertyHelperTrait;
3032
use PropertyHelperTrait;
3133
protected LoggerInterface $logger;
3234

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

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

56-
protected function getManagerRegistry(): ManagerRegistry
62+
public function hasManagerRegistry(): bool
63+
{
64+
return $this->managerRegistry instanceof ManagerRegistry;
65+
}
66+
67+
public function getManagerRegistry(): ManagerRegistry
5768
{
69+
if (!$this->hasManagerRegistry()) {
70+
throw new RuntimeException('ManagerRegistry must be initialized before accessing it.');
71+
}
72+
5873
return $this->managerRegistry;
5974
}
6075

61-
protected function getProperties(): ?array
76+
public function setManagerRegistry(ManagerRegistry $managerRegistry): void
6277
{
63-
return $this->properties;
78+
$this->managerRegistry = $managerRegistry;
6479
}
6580

66-
protected function getLogger(): LoggerInterface
81+
public function getProperties(): ?array
6782
{
68-
return $this->logger;
83+
return $this->properties;
6984
}
7085

7186
/**
72-
* @param string[] $properties
87+
* @param array<string, mixed> $properties
7388
*/
7489
public function setProperties(array $properties): void
7590
{
7691
$this->properties = $properties;
7792
}
7893

94+
protected function getLogger(): LoggerInterface
95+
{
96+
return $this->logger;
97+
}
98+
7999
/**
80100
* Determines whether the given property is enabled.
81101
*/

Filter/BooleanFilter.php

Lines changed: 11 additions & 1 deletion
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 <[email protected]>
107109
* @author Teoh Han Hui <[email protected]>
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 ['type' => 'boolean'];
157+
}
148158
}

Filter/DateFilter.php

Lines changed: 39 additions & 13 deletions
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 <[email protected]>
121126
* @author Théo FIDRY <[email protected]>
122127
*/
123-
final class DateFilter extends AbstractFilter implements DateFilterInterface
128+
final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
124129
{
125130
use DateFilterTrait;
126131

@@ -138,11 +143,11 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface
138143
/**
139144
* {@inheritdoc}
140145
*/
141-
protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
146+
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
142147
{
143-
// Expect $values to be an array having the period as keys and the date value as values
148+
// Expect $value to be an array having the period as keys and the date value as values
144149
if (
145-
!\is_array($values)
150+
!\is_array($value)
146151
|| !$this->isPropertyEnabled($property, $resourceClass)
147152
|| !$this->isPropertyMapped($property, $resourceClass)
148153
|| !$this->isDateField($property, $resourceClass)
@@ -153,7 +158,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query
153158
$alias = $queryBuilder->getRootAliases()[0];
154159
$field = $property;
155160

156-
if ($this->isPropertyNested($property, $resourceClass) && \count($values) > 0) {
161+
if ($this->isPropertyNested($property, $resourceClass) && \count($value) > 0) {
157162
[$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN);
158163
}
159164

@@ -164,53 +169,53 @@ protected function filterProperty(string $property, $values, QueryBuilder $query
164169
$queryBuilder->andWhere($queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field)));
165170
}
166171

167-
if (isset($values[self::PARAMETER_BEFORE])) {
172+
if (isset($value[self::PARAMETER_BEFORE])) {
168173
$this->addWhere(
169174
$queryBuilder,
170175
$queryNameGenerator,
171176
$alias,
172177
$field,
173178
self::PARAMETER_BEFORE,
174-
$values[self::PARAMETER_BEFORE],
179+
$value[self::PARAMETER_BEFORE],
175180
$nullManagement,
176181
$type
177182
);
178183
}
179184

180-
if (isset($values[self::PARAMETER_STRICTLY_BEFORE])) {
185+
if (isset($value[self::PARAMETER_STRICTLY_BEFORE])) {
181186
$this->addWhere(
182187
$queryBuilder,
183188
$queryNameGenerator,
184189
$alias,
185190
$field,
186191
self::PARAMETER_STRICTLY_BEFORE,
187-
$values[self::PARAMETER_STRICTLY_BEFORE],
192+
$value[self::PARAMETER_STRICTLY_BEFORE],
188193
$nullManagement,
189194
$type
190195
);
191196
}
192197

193-
if (isset($values[self::PARAMETER_AFTER])) {
198+
if (isset($value[self::PARAMETER_AFTER])) {
194199
$this->addWhere(
195200
$queryBuilder,
196201
$queryNameGenerator,
197202
$alias,
198203
$field,
199204
self::PARAMETER_AFTER,
200-
$values[self::PARAMETER_AFTER],
205+
$value[self::PARAMETER_AFTER],
201206
$nullManagement,
202207
$type
203208
);
204209
}
205210

206-
if (isset($values[self::PARAMETER_STRICTLY_AFTER])) {
211+
if (isset($value[self::PARAMETER_STRICTLY_AFTER])) {
207212
$this->addWhere(
208213
$queryBuilder,
209214
$queryNameGenerator,
210215
$alias,
211216
$field,
212217
self::PARAMETER_STRICTLY_AFTER,
213-
$values[self::PARAMETER_STRICTLY_AFTER],
218+
$value[self::PARAMETER_STRICTLY_AFTER],
214219
$nullManagement,
215220
$type
216221
);
@@ -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
}

Filter/ExistsFilter.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait;
1819
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
1920
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
21+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
22+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
2023
use ApiPlatform\Metadata\Operation;
24+
use ApiPlatform\Metadata\Parameter;
2125
use Doctrine\ORM\Mapping\AssociationMapping;
2226
use Doctrine\ORM\Mapping\ClassMetadata;
2327
use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
@@ -113,11 +117,12 @@
113117
*
114118
* @author Teoh Han Hui <[email protected]>
115119
*/
116-
final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface
120+
final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
117121
{
118122
use ExistsFilterTrait;
123+
use PropertyPlaceholderOpenApiParameterTrait;
119124

120-
public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null)
125+
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null)
121126
{
122127
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
123128

@@ -129,6 +134,14 @@ public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $
129134
*/
130135
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
131136
{
137+
$parameter = $context['parameter'] ?? null;
138+
139+
if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) {
140+
$this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
141+
142+
return;
143+
}
144+
132145
foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) {
133146
$this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
134147
}
@@ -263,4 +276,12 @@ private function isAssociationNullable(AssociationMapping|array $associationMapp
263276

264277
return true;
265278
}
279+
280+
/**
281+
* @return array<string, mixed>
282+
*/
283+
public function getSchema(Parameter $parameter): array
284+
{
285+
return ['type' => 'boolean'];
286+
}
266287
}

0 commit comments

Comments
 (0)