Skip to content

Commit 5b59c6c

Browse files
committed
feat(doctrine): add [between] to ComparisonFilter, deprecate RangeFilter
ComparisonFilter now supports the full range syntax: alongside gt/gte/lt/lte/ne it parses [between]=X..Y, emitting a single BETWEEN clause on ORM (so the SQL optimizer treats it as a bounded range; X..X collapses to =) and the native gte/lte pair on ODM. ExactFilter gains the matching BETWEEN branch for the ORM path; the branch only fires for the 'between' operator, leaving normal usage untouched. This gives ComparisonFilter full parity with RangeFilter, whose operators are now a strict subset. RangeFilter is therefore redundant and reclassified from "5.0 overlay survivor" to doomed: deprecated now, removed in 6.0, codemod rewrites it to ComparisonFilter. The [between] URL syntax is preserved by ComparisonFilter, so there is no consumer-facing breaking change. The new-style FilteredRangeParameter fixtures (ORM + ODM) move to ComparisonFilter, which then serves as the functional spec for [between].
1 parent 380bf26 commit 5b59c6c

7 files changed

Lines changed: 102 additions & 8 deletions

File tree

src/Doctrine/Odm/Filter/ComparisonFilter.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI
4545
'ne' => 'notEqual',
4646
];
4747

48+
/**
49+
* Friendly range syntax: `?price[between]=10..100`. MongoDB has no BETWEEN keyword, so a range
50+
* is expressed as the native `gte`/`lte` pair on the field.
51+
*/
52+
public const OPERATOR_BETWEEN = 'between';
53+
4854
public function __construct(private readonly FilterInterface $filter)
4955
{
5056
}
@@ -74,6 +80,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
7480
continue;
7581
}
7682

83+
if (self::OPERATOR_BETWEEN === $operator) {
84+
$this->applyBetween($aggregationBuilder, $resourceClass, $operation, $context, $parameter, $value);
85+
86+
continue;
87+
}
88+
7789
if (isset(self::OPERATORS[$operator])) {
7890
$this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value);
7991
}
@@ -91,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array
91103
new OpenApiParameter(name: "{$key}[lt]", in: $in),
92104
new OpenApiParameter(name: "{$key}[lte]", in: $in),
93105
new OpenApiParameter(name: "{$key}[ne]", in: $in),
106+
new OpenApiParameter(name: "{$key}[between]", in: $in),
94107
];
95108
}
96109

@@ -109,6 +122,7 @@ public function getSchema(Parameter $parameter): array
109122
'lt' => $innerSchema,
110123
'lte' => $innerSchema,
111124
'ne' => $innerSchema,
125+
'between' => ['type' => 'string'],
112126
],
113127
];
114128
}
@@ -131,4 +145,25 @@ private function applyOperator(Builder $aggregationBuilder, string $resourceClas
131145
$context['match'] = $newContext['match'];
132146
}
133147
}
148+
149+
/**
150+
* @param array<string, mixed> $context
151+
*
152+
* @param-out array<string, mixed> $context
153+
*/
154+
private function applyBetween(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation, array &$context, Parameter $parameter, mixed $value): void
155+
{
156+
if (!\is_string($value)) {
157+
return;
158+
}
159+
160+
$bounds = explode('..', $value, 2);
161+
if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) {
162+
return;
163+
}
164+
165+
// MongoDB range = native gte/lte pair (coerce bounds to numbers)
166+
$this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'gte', $bounds[0] + 0);
167+
$this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'lte', $bounds[1] + 0);
168+
}
134169
}

src/Doctrine/Odm/Filter/RangeFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
* @author Lee Siong Chan <ahlee2326@me.com>
109109
* @author Alan Poulain <contact@alanpoulain.eu>
110110
*
111-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
111+
* @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`.
112112
*/
113113
final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface
114114
{

src/Doctrine/Orm/Filter/ComparisonFilter.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI
4848

4949
public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>'];
5050

51+
/**
52+
* Friendly range syntax: `?price[between]=10..100`. Translates to a single BETWEEN clause
53+
* (or `=` when both bounds are equal), letting the SQL optimizer treat it as a bounded range.
54+
*/
55+
public const OPERATOR_BETWEEN = 'between';
56+
5157
public function __construct(private readonly FilterInterface $filter)
5258
{
5359
}
@@ -74,6 +80,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
7480
continue;
7581
}
7682

83+
if (self::OPERATOR_BETWEEN === $operator) {
84+
$this->applyBetween($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, $value);
85+
86+
continue;
87+
}
88+
7789
if (isset(self::OPERATORS[$operator])) {
7890
$this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value);
7991
}
@@ -91,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array
91103
new OpenApiParameter(name: "{$key}[lt]", in: $in),
92104
new OpenApiParameter(name: "{$key}[lte]", in: $in),
93105
new OpenApiParameter(name: "{$key}[ne]", in: $in),
106+
new OpenApiParameter(name: "{$key}[between]", in: $in),
94107
];
95108
}
96109

@@ -109,6 +122,7 @@ public function getSchema(Parameter $parameter): array
109122
'lt' => $innerSchema,
110123
'lte' => $innerSchema,
111124
'ne' => $innerSchema,
125+
'between' => ['type' => 'string'],
112126
],
113127
];
114128
}
@@ -131,4 +145,29 @@ private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInt
131145
['operator' => $operator, 'parameter' => $subParameter] + $context
132146
);
133147
}
148+
149+
/**
150+
* @param array<string,mixed> $context
151+
*/
152+
private function applyBetween(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, mixed $value): void
153+
{
154+
if (!\is_string($value)) {
155+
return;
156+
}
157+
158+
$bounds = explode('..', $value, 2);
159+
if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) {
160+
return;
161+
}
162+
163+
// coerce to int|float so the bound is bound as a number, not a string
164+
$subParameter = (clone $parameter)->setValue([$bounds[0] + 0, $bounds[1] + 0]);
165+
$this->filter->apply(
166+
$queryBuilder,
167+
$queryNameGenerator,
168+
$resourceClass,
169+
$operation,
170+
['operator' => self::OPERATOR_BETWEEN, 'parameter' => $subParameter] + $context
171+
);
172+
}
134173
}

src/Doctrine/Orm/Filter/ExactFilter.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
4646

4747
[$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);
4848

49+
if (ComparisonFilter::OPERATOR_BETWEEN === ($context['operator'] ?? null)) {
50+
$whereClause = $context['whereClause'] ?? 'andWhere';
51+
52+
// equal bounds collapse to an equality so the optimizer skips the range scan
53+
if ($value[0] === $value[1]) {
54+
$queryBuilder->{$whereClause}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
55+
->setParameter($parameterName, $value[0]);
56+
57+
return;
58+
}
59+
60+
$queryBuilder->{$whereClause}(\sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $property, $parameterName))
61+
->setParameter($parameterName.'_1', $value[0])
62+
->setParameter($parameterName.'_2', $value[1]);
63+
64+
return;
65+
}
66+
4967
if (\is_array($value)) {
5068
$queryBuilder
5169
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName));

src/Doctrine/Orm/Filter/RangeFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
*
110110
* @author Lee Siong Chan <ahlee2326@me.com>
111111
*
112-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
112+
* @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`.
113113
*/
114114
final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface
115115
{

tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
1515

16-
use ApiPlatform\Doctrine\Odm\Filter\RangeFilter;
16+
use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter;
17+
use ApiPlatform\Doctrine\Odm\Filter\ExactFilter;
1718
use ApiPlatform\Metadata\ApiResource;
1819
use ApiPlatform\Metadata\GetCollection;
1920
use ApiPlatform\Metadata\QueryParameter;
@@ -25,11 +26,11 @@
2526
paginationItemsPerPage: 5,
2627
parameters: [
2728
'quantity' => new QueryParameter(
28-
filter: new RangeFilter(),
29+
filter: new ComparisonFilter(new ExactFilter()),
2930
openApi: new Parameter('quantity', 'query', allowEmptyValue: true)
3031
),
3132
'amount' => new QueryParameter(
32-
filter: new RangeFilter(),
33+
filter: new ComparisonFilter(new ExactFilter()),
3334
property: 'quantity',
3435
openApi: new Parameter('amount', 'query', allowEmptyValue: true)
3536
),

tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
1515

16-
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
16+
use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter;
17+
use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
1718
use ApiPlatform\Metadata\ApiResource;
1819
use ApiPlatform\Metadata\GetCollection;
1920
use ApiPlatform\Metadata\QueryParameter;
@@ -25,11 +26,11 @@
2526
paginationItemsPerPage: 5,
2627
parameters: [
2728
'quantity' => new QueryParameter(
28-
filter: new RangeFilter(),
29+
filter: new ComparisonFilter(new ExactFilter()),
2930
openApi: new Parameter('quantity', 'query', allowEmptyValue: true)
3031
),
3132
'amount' => new QueryParameter(
32-
filter: new RangeFilter(),
33+
filter: new ComparisonFilter(new ExactFilter()),
3334
property: 'quantity',
3435
openApi: new Parameter('amount', 'query', allowEmptyValue: true)
3536
),

0 commit comments

Comments
 (0)