Skip to content

Commit f322383

Browse files
committed
feat(state): skip the filter for empty parameter values
Add a skipFilterOnEmpty option on Parameter, defaulting to true. An empty string value is kept, so validation still runs and can return a 422, but the Doctrine ORM/ODM ParameterExtension does not call the filter for it, avoiding driver-specific errors (e.g. PostgreSQL rejecting '' for an integer column). Set it to false to call the filter with the raw value. ValueCaster no longer casts empty strings (reverted): an empty value is a present-but-valueless parameter, distinct from a typed zero/false and from an absent parameter.
1 parent 859c3e5 commit f322383

7 files changed

Lines changed: 47 additions & 36 deletions

File tree

src/Doctrine/Odm/Extension/ParameterExtension.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass
5555
continue;
5656
}
5757

58+
// An empty value is left for validation to handle (e.g. a 422); by default the filter
59+
// itself is not called for it. Set skipFilterOnEmpty to false to call it anyway.
60+
if ('' === $v && false !== $parameter->getSkipFilterOnEmpty()) {
61+
continue;
62+
}
63+
5864
$values = $this->extractParameterValue($parameter, $v);
5965
if (null === ($filterId = $parameter->getFilter())) {
6066
continue;

src/Doctrine/Orm/Extension/ParameterExtension.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter
5555
continue;
5656
}
5757

58+
// An empty value is left for validation to handle (e.g. a 422); by default the filter
59+
// itself is not called for it. Set skipFilterOnEmpty to false to call it anyway.
60+
if ('' === $v && false !== $parameter->getSkipFilterOnEmpty()) {
61+
continue;
62+
}
63+
5864
$values = $this->extractParameterValue($parameter, $v);
5965
if (null === ($filterId = $parameter->getFilter())) {
6066
continue;

src/Metadata/Parameter.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ abstract class Parameter
4242
* @param ?bool $castToNativeType whether API Platform should cast your parameter to the nativeType declared
4343
* @param ?callable(mixed): mixed $castFn the closure used to cast your parameter, this gets called only when $castToNativeType is set
4444
* @param ?string $filterClass the class to use when resolving filter properties (from stateOptions)
45+
* @param ?bool $skipFilterOnEmpty when true (the default), the filter is not called for an empty string value (validation still runs); set to false to call the filter with the raw empty value (the filter must then handle it)
4546
*
4647
* @phpstan-param array<string, mixed>|null $schema
4748
*
@@ -70,6 +71,7 @@ public function __construct(
7071
protected mixed $castFn = null,
7172
protected mixed $default = null,
7273
protected ?string $filterClass = null,
74+
protected ?bool $skipFilterOnEmpty = null,
7375
) {
7476
}
7577

@@ -369,6 +371,19 @@ public function withCastToNativeType(bool $castToNativeType): self
369371
return $self;
370372
}
371373

374+
public function getSkipFilterOnEmpty(): ?bool
375+
{
376+
return $this->skipFilterOnEmpty;
377+
}
378+
379+
public function withSkipFilterOnEmpty(bool $skipFilterOnEmpty): self
380+
{
381+
$self = clone $this;
382+
$self->skipFilterOnEmpty = $skipFilterOnEmpty;
383+
384+
return $self;
385+
}
386+
372387
public function getCastFn(): ?callable
373388
{
374389
return $this->castFn;

src/State/Parameter/ValueCaster.php

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static function toBool(mixed $v): mixed
3030

3131
return match (strtolower($v)) {
3232
'1', 'true' => true,
33-
'0', 'false', '' => false,
33+
'0', 'false' => false,
3434
default => $v,
3535
};
3636
}
@@ -41,10 +41,6 @@ public static function toInt(mixed $v): mixed
4141
return $v;
4242
}
4343

44-
if ('' === $v) {
45-
return 0;
46-
}
47-
4844
$value = filter_var($v, \FILTER_VALIDATE_INT);
4945

5046
return false === $value ? $v : $value;
@@ -56,10 +52,6 @@ public static function toFloat(mixed $v): mixed
5652
return $v;
5753
}
5854

59-
if ('' === $v) {
60-
return 0.0;
61-
}
62-
6355
$value = filter_var($v, \FILTER_VALIDATE_FLOAT);
6456

6557
return false === $value ? $v : $value;

src/State/Tests/Parameter/ValueCasterTest.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ public function testToInt(mixed $value, mixed $expected): void
3434
public static function intProvider(): \Generator
3535
{
3636
yield 'integer string' => ['10', 10];
37-
yield 'empty string' => ['', 0];
3837
yield 'invalid string' => ['string', 'string'];
38+
yield 'empty string is not cast' => ['', ''];
3939
yield 'null string is not cast' => ['null', 'null'];
4040
yield 'int passthrough' => [10, 10];
4141
}
@@ -49,8 +49,8 @@ public function testToFloat(mixed $value, mixed $expected): void
4949
public static function floatProvider(): \Generator
5050
{
5151
yield 'float string' => ['1.5', 1.5];
52-
yield 'empty string' => ['', 0.0];
5352
yield 'invalid string' => ['string', 'string'];
53+
yield 'empty string is not cast' => ['', ''];
5454
yield 'null string is not cast' => ['null', 'null'];
5555
yield 'float passthrough' => [1.5, 1.5];
5656
}
@@ -61,12 +61,10 @@ public static function boolProvider(): \Generator
6161
yield 'numeric 1' => ['1', true];
6262
yield 'false string' => ['false', false];
6363
yield 'numeric 0' => ['0', false];
64-
// An empty value casts to false deterministically across drivers, instead of
65-
// reaching the database as a raw string that each driver coerces differently.
66-
yield 'empty string' => ['', false];
67-
// Genuinely invalid values (including "null") are returned untouched so constraint
68-
// validation rejects them; "null" is not a supported boolean input.
64+
// Unrecognized values (including empty and "null" strings) are returned untouched;
65+
// emptiness is handled at the filter layer via skipFilterOnEmpty, not by the caster.
6966
yield 'invalid string' => ['string', 'string'];
67+
yield 'empty string is not cast' => ['', ''];
7068
yield 'null string is not cast' => ['null', 'null'];
7169
yield 'non-string passthrough' => [true, true];
7270
}

tests/Functional/Parameters/BooleanFilterTest.php

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,24 +77,19 @@ public static function booleanFilterScenariosProvider(): \Generator
7777
}
7878

7979
/**
80-
* With the canonical ExactFilter, an empty boolean value casts to false (deterministically
81-
* across drivers), so it matches the inactive rows — unlike the deprecated BooleanFilter
82-
* which skipped filtering (returning all rows, covered by the legacy regression suite).
80+
* An empty value does not call the filter (skipFilterOnEmpty defaults to true) while the value
81+
* is still validated, so the result matches the unfiltered collection.
8382
*/
8483
#[DataProvider('booleanFilterEmptyScenariosProvider')]
8584
public function testBooleanFilterWithEmptyValues(string $url): void
8685
{
87-
$response = self::createClient()->request('GET', $url);
88-
$this->assertResponseIsSuccessful();
89-
90-
$responseData = $response->toArray();
91-
$filteredItems = $responseData['hydra:member'];
86+
$client = self::createClient();
87+
$unfilteredCount = \count($client->request('GET', '/filtered_boolean_parameters')->toArray()['hydra:member']);
9288

93-
$this->assertCount(1, $filteredItems, \sprintf('Expected one inactive item for URL %s', $url));
89+
$response = $client->request('GET', $url);
90+
$this->assertResponseIsSuccessful();
9491

95-
foreach ($filteredItems as $item) {
96-
$this->assertFalse($item['active'], 'Expected an empty boolean value to cast to false');
97-
}
92+
$this->assertCount($unfilteredCount, $response->toArray()['hydra:member'], \sprintf('Expected the filter to be skipped for URL %s', $url));
9893
}
9994

10095
public static function booleanFilterEmptyScenariosProvider(): \Generator

tests/Functional/Parameters/NumericFilterTest.php

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,19 @@ public static function rangeFilterScenariosProvider(): \Generator
6767
}
6868

6969
/**
70-
* With the canonical ExactFilter, an empty value casts to 0/0.0 (deterministically across
71-
* drivers) and matches no row in this fixture — unlike the deprecated NumericFilter which
72-
* skipped filtering (returning all rows, covered by the legacy regression suite).
70+
* An empty value does not call the filter (skipFilterOnEmpty defaults to true) while the value
71+
* is still validated, so the result matches the unfiltered collection.
7372
*/
7473
#[DataProvider('emptyScenariosProvider')]
7574
public function testRangeFilterWithEmptyValues(string $url): void
7675
{
77-
$response = self::createClient()->request('GET', $url);
78-
$this->assertResponseIsSuccessful();
76+
$client = self::createClient();
77+
$unfilteredCount = \count($client->request('GET', '/filtered_numeric_parameters')->toArray()['hydra:member']);
7978

80-
$responseData = $response->toArray();
81-
$filteredItems = $responseData['hydra:member'];
79+
$response = $client->request('GET', $url);
80+
$this->assertResponseIsSuccessful();
8281

83-
$this->assertCount(0, $filteredItems, \sprintf('Expected no item for URL %s', $url));
82+
$this->assertCount($unfilteredCount, $response->toArray()['hydra:member'], \sprintf('Expected the filter to be skipped for URL %s', $url));
8483
}
8584

8685
public static function emptyScenariosProvider(): \Generator

0 commit comments

Comments
 (0)