From fd50f8b3cfa19b4d90c892143deb342ef3447074 Mon Sep 17 00:00:00 2001 From: Nathan Pesneau <129308244+NathanPesneau@users.noreply.github.com> Date: Wed, 11 Sep 2024 17:14:16 +0200 Subject: [PATCH] feat(laravel): eloquent filters date range (#6606) * feat(laravel): eloquent filters date range * cs * parameter * fix(laravel): eloquent test * fix test * fix(laravel): corrections * fix(larevel): eloquent test filters * fix(laravel): date filter const * fix(laravel): range filter const --------- Co-authored-by: Nathan Co-authored-by: soyuka --- src/Laravel/ApiPlatformProvider.php | 3 +- src/Laravel/Eloquent/Filter/DateFilter.php | 72 ++++- src/Laravel/Eloquent/Filter/OrFilter.php | 10 +- src/Laravel/Eloquent/Filter/OrderFilter.php | 26 +- src/Laravel/Eloquent/Filter/RangeFilter.php | 67 +++++ .../Tests/Eloquent/Filter/DateFilterTest.php | 29 ++ src/Laravel/Tests/EloquentTest.php | 278 ++++++++++++++++-- src/Laravel/workbench/app/Models/Book.php | 5 +- ...face.php => JsonSchemaFilterInterface.php} | 2 +- ...hp => OpenApiParameterFilterInterface.php} | 7 +- src/Metadata/Parameter.php | 13 +- ...meterResourceMetadataCollectionFactory.php | 10 +- src/OpenApi/Factory/OpenApiFactory.php | 28 +- src/Serializer/Filter/PropertyFilter.php | 8 +- 14 files changed, 495 insertions(+), 63 deletions(-) create mode 100644 src/Laravel/Eloquent/Filter/RangeFilter.php create mode 100644 src/Laravel/Tests/Eloquent/Filter/DateFilterTest.php rename src/Metadata/{HasSchemaFilterInterface.php => JsonSchemaFilterInterface.php} (92%) rename src/Metadata/{HasOpenApiParameterFilterInterface.php => OpenApiParameterFilterInterface.php} (63%) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 43e10941714..7dd2d33e37b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -79,6 +79,7 @@ use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface; use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter; use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter; +use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory; @@ -389,7 +390,7 @@ public function register(): void $this->app->bind(OperationMetadataFactoryInterface::class, OperationMetadataFactory::class); - $this->app->tag([EqualsFilter::class, PartialSearchFilter::class, DateFilter::class, OrderFilter::class], EloquentFilterInterface::class); + $this->app->tag([EqualsFilter::class, PartialSearchFilter::class, DateFilter::class, OrderFilter::class, RangeFilter::class], EloquentFilterInterface::class); $this->app->bind(FilterQueryExtension::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class)); diff --git a/src/Laravel/Eloquent/Filter/DateFilter.php b/src/Laravel/Eloquent/Filter/DateFilter.php index cb763765848..67665dce87f 100644 --- a/src/Laravel/Eloquent/Filter/DateFilter.php +++ b/src/Laravel/Eloquent/Filter/DateFilter.php @@ -13,28 +13,67 @@ namespace ApiPlatform\Laravel\Eloquent\Filter; -use ApiPlatform\Metadata\HasSchemaFilterInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -final class DateFilter implements FilterInterface, HasSchemaFilterInterface +final class DateFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use QueryPropertyTrait; + private const OPERATOR_VALUE = [ + 'eq' => '=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + ]; + /** * @param Builder $builder * @param array $context */ public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder { - if (!\is_string($values)) { + if (!\is_array($values)) { + return $builder; + } + + $values = array_intersect_key($values, self::OPERATOR_VALUE); + + if (!$values) { + return $builder; + } + + if (true === ($parameter->getFilterContext()['include_nulls'] ?? false)) { + foreach ($values as $key => $value) { + $datetime = $this->getDateTime($value); + if (null === $datetime) { + continue; + } + $builder->{$context['whereClause'] ?? 'where'}(function (Builder $query) use ($parameter, $datetime, $key): void { + $queryProperty = $this->getQueryProperty($parameter); + $query->whereDate($queryProperty, self::OPERATOR_VALUE[$key], $datetime) + ->orWhereNull($queryProperty); + }); + } + return $builder; } - $datetime = new \DateTimeImmutable($values); + foreach ($values as $key => $value) { + $datetime = $this->getDateTime($value); + if (null === $datetime) { + continue; + } + $builder = $builder->{($context['whereClause'] ?? 'where').'Date'}($this->getQueryProperty($parameter), self::OPERATOR_VALUE[$key], $datetime); + } - return $builder->{($context['whereClause'] ?? 'where').'Date'}($this->getQueryProperty($parameter), $datetime); + return $builder; } /** @@ -44,4 +83,27 @@ public function getSchema(Parameter $parameter): array { return ['type' => 'date']; } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[eq]', in: $in), + new OpenApiParameter(name: $key.'[gt]', in: $in), + new OpenApiParameter(name: $key.'[lt]', in: $in), + new OpenApiParameter(name: $key.'[gte]', in: $in), + new OpenApiParameter(name: $key.'[lte]', in: $in), + ]; + } + + private function getDateTime(string $value): ?\DateTimeImmutable + { + try { + return new \DateTimeImmutable($value); + } catch (\DateMalformedStringException|\Exception) { + return null; + } + } } diff --git a/src/Laravel/Eloquent/Filter/OrFilter.php b/src/Laravel/Eloquent/Filter/OrFilter.php index f576d5c8ce8..6c9e7f833b2 100644 --- a/src/Laravel/Eloquent/Filter/OrFilter.php +++ b/src/Laravel/Eloquent/Filter/OrFilter.php @@ -13,14 +13,14 @@ namespace ApiPlatform\Laravel\Eloquent\Filter; -use ApiPlatform\Metadata\HasOpenApiParameterFilterInterface; -use ApiPlatform\Metadata\HasSchemaFilterInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -final readonly class OrFilter implements FilterInterface, HasSchemaFilterInterface, HasOpenApiParameterFilterInterface +final readonly class OrFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { public function __construct(private FilterInterface $filter) { @@ -44,12 +44,12 @@ public function apply(Builder $builder, mixed $values, Parameter $parameter, arr */ public function getSchema(Parameter $parameter): array { - $schema = $this->filter instanceof HasSchemaFilterInterface ? $this->filter->getSchema($parameter) : ['type' => 'string']; + $schema = $this->filter instanceof JsonSchemaFilterInterface ? $this->filter->getSchema($parameter) : ['type' => 'string']; return ['type' => 'array', 'items' => $schema]; } - public function getOpenApiParameter(Parameter $parameter): ?OpenApiParameter + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null { return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); } diff --git a/src/Laravel/Eloquent/Filter/OrderFilter.php b/src/Laravel/Eloquent/Filter/OrderFilter.php index fab06c53b83..90315f3d083 100644 --- a/src/Laravel/Eloquent/Filter/OrderFilter.php +++ b/src/Laravel/Eloquent/Filter/OrderFilter.php @@ -13,14 +13,14 @@ namespace ApiPlatform\Laravel\Eloquent\Filter; -use ApiPlatform\Metadata\HasOpenApiParameterFilterInterface; -use ApiPlatform\Metadata\HasSchemaFilterInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -final class OrderFilter implements FilterInterface, HasSchemaFilterInterface, HasOpenApiParameterFilterInterface +final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use QueryPropertyTrait; @@ -37,7 +37,6 @@ public function apply(Builder $builder, mixed $values, Parameter $parameter, arr if (!isset($properties[$key])) { continue; } - $builder = $builder->orderBy($properties[$key], $value); } @@ -52,22 +51,19 @@ public function apply(Builder $builder, mixed $values, Parameter $parameter, arr */ public function getSchema(Parameter $parameter): array { - if (str_contains($parameter->getKey(), ':property')) { - $properties = []; - foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { - $properties[$property] = ['type' => 'string', 'enum' => ['asc', 'desc']]; - } - - return ['type' => 'object', 'properties' => $properties, 'required' => []]; - } - return ['type' => 'string', 'enum' => ['asc', 'desc']]; } - public function getOpenApiParameter(Parameter $parameter): ?OpenApiParameter + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null { if (str_contains($parameter->getKey(), ':property')) { - return new OpenApiParameter(name: str_replace('[:property]', '', $parameter->getKey()), in: 'query', style: 'deepObject', explode: true); + $parameters = []; + $key = str_replace('[:property]', '', $parameter->getKey()); + foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { + $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); + } + + return $parameters; } return null; diff --git a/src/Laravel/Eloquent/Filter/RangeFilter.php b/src/Laravel/Eloquent/Filter/RangeFilter.php new file mode 100644 index 00000000000..82f7e4457ec --- /dev/null +++ b/src/Laravel/Eloquent/Filter/RangeFilter.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Eloquent\Filter; + +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +final class RangeFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +{ + use QueryPropertyTrait; + + private const OPERATOR_VALUE = [ + 'lt' => '<', + 'gt' => '>', + 'lte' => '<=', + 'gte' => '>=', + ]; + + /** + * @param Builder $builder + * @param array $context + */ + public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder + { + $queryProperty = $this->getQueryProperty($parameter); + + foreach ($values as $key => $value) { + $builder = $builder->{$context['whereClause'] ?? 'where'}($queryProperty, self::OPERATOR_VALUE[$key], $value); + } + + return $builder; + } + + public function getSchema(Parameter $parameter): array + { + return ['type' => 'number']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[gt]', in: $in), + new OpenApiParameter(name: $key.'[lt]', in: $in), + new OpenApiParameter(name: $key.'[gte]', in: $in), + new OpenApiParameter(name: $key.'[lte]', in: $in), + ]; + } +} diff --git a/src/Laravel/Tests/Eloquent/Filter/DateFilterTest.php b/src/Laravel/Tests/Eloquent/Filter/DateFilterTest.php new file mode 100644 index 00000000000..2cac345483a --- /dev/null +++ b/src/Laravel/Tests/Eloquent/Filter/DateFilterTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Eloquent\Filter; + +use ApiPlatform\Laravel\Eloquent\Filter\DateFilter; +use ApiPlatform\Metadata\QueryParameter; +use Illuminate\Database\Eloquent\Builder; +use PHPUnit\Framework\TestCase; + +class DateFilterTest extends TestCase +{ + public function testOperator(): void + { + $f = new DateFilter(); + $builder = $this->createStub(Builder::class); + $this->assertEquals($builder, $f->apply($builder, ['neq' => '2020-02-02'], new QueryParameter(key: 'date', property: 'date'))); + } +} diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index 3b2886f91af..757fe083e6b 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -26,28 +26,28 @@ class EloquentTest extends TestCase public function testSearchFilter(): void { - $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; - $response = $this->get('/api/books?isbn='.$book['isbn'], ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books?isbn='.$book['isbn'], ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['member'][0], $book); } public function testValidateSearchFilter(): void { - $response = $this->get('/api/books?isbn=a', ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books?isbn=a', ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['detail'], 'The isbn field must be at least 2 characters.'); } public function testSearchFilterRelation(): void { - $response = $this->get('/api/books?author=1', ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books?author=1', ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['member'][0]['author'], '/api/authors/1'); } public function testPropertyFilter(): void { - $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; $response = $this->get(\sprintf('%s.jsonld?properties[]=author', $book['@id'])); @@ -60,7 +60,7 @@ public function testPropertyFilter(): void public function testPartialSearchFilter(): void { - $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; if (!isset($book['name'])) { @@ -70,52 +70,296 @@ public function testPartialSearchFilter(): void $end = strpos($book['name'], ' ') ?: 3; $name = substr($book['name'], 0, $end); - $response = $this->get('/api/books?name='.$name, ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books?name='.$name, ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['member'][0], $book); } - public function testDateSearchFilter(): void + public function testDateFilterEqual(): void { - $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; $updated = $this->patchJson( $book['@id'], ['publicationDate' => '2024-02-18 00:00:00'], [ - 'accept' => ['application/ld+json'], + 'Accept' => ['application/ld+json'], 'Content-Type' => ['application/merge-patch+json'], ] ); - $response = $this->get('/api/books?publicationDate='.$updated['publicationDate'], ['accept' => ['application/ld+json']]); + $response = $this->get('/api/books?publicationDate[eq]='.$updated['publicationDate'], ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['member'][0]['@id'], $book['@id']); } + public function testDateFilterIncludeNull(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $book = $response->json()['member'][0]; + $updated = $this->patchJson( + $book['@id'], + ['publicationDate' => null], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('/api/books?publicationWithNulls[gt]=9999-12-31', ['Accept' => ['application/ld+json']]); + $this->assertGreaterThan(0, $response->json()['totalItems']); + } + + public function testDateFilterExcludeNull(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $book = $response->json()['member'][0]; + $updated = $this->patchJson( + $book['@id'], + ['publicationDate' => null], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('/api/books?publicationDate[gt]=9999-12-31', ['Accept' => ['application/ld+json']]); + $this->assertSame(0, $response->json()['totalItems']); + } + + public function testDateFilterGreaterThan(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $bookBefore = $response->json()['member'][0]; + $updated = $this->patchJson( + $bookBefore['@id'], + ['publicationDate' => '9998-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $bookAfter = $response->json()['member'][1]; + $this->patchJson( + $bookAfter['@id'], + ['publicationDate' => '9999-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('/api/books?publicationDate[gt]='.$updated['publicationDate'], ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $bookAfter['@id']); + $this->assertSame($response->json()['totalItems'], 1); + } + + public function testDateFilterLowerThanEqual(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $bookBefore = $response->json()['member'][0]; + $updated = $this->patchJson( + $bookBefore['@id'], + ['publicationDate' => '0001-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $bookAfter = $response->json()['member'][1]; + $this->patchJson( + $bookAfter['@id'], + ['publicationDate' => '0002-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('/api/books?publicationDate[lte]=0002-02-18', ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']); + $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']); + $this->assertSame($response->json()['totalItems'], 2); + } + + public function testDateFilterBetween(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $book = $response->json()['member'][0]; + $updated = $this->patchJson( + $book['@id'], + ['publicationDate' => '0001-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $book2 = $response->json()['member'][1]; + $this->patchJson( + $book2['@id'], + ['publicationDate' => '0002-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $book3 = $response->json()['member'][2]; + $updated3 = $this->patchJson( + $book3['@id'], + ['publicationDate' => '0003-02-18 00:00:00'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('/api/books?publicationDate[gte]='.substr($updated['publicationDate'], 0, 10).'&publicationDate[lt]='.substr($updated3['publicationDate'], 0, 10), ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $book['@id']); + $this->assertSame($response->json()['member'][1]['@id'], $book2['@id']); + $this->assertSame($response->json()['totalItems'], 2); + } + public function testSearchFilterWithPropertyPlaceholder(): void { - $response = $this->get('/api/authors', ['accept' => ['application/ld+json']])->json(); + $response = $this->get('/api/authors', ['Accept' => ['application/ld+json']])->json(); $author = $response['member'][0]; - $test = $this->get('/api/authors?name='.explode(' ', $author['name'])[0], ['accept' => ['application/ld+json']])->json(); + $test = $this->get('/api/authors?name='.explode(' ', $author['name'])[0], ['Accept' => ['application/ld+json']])->json(); $this->assertSame($test['member'][0]['id'], $author['id']); - $test = $this->get('/api/authors?id='.$author['id'], ['accept' => ['application/ld+json']])->json(); + $test = $this->get('/api/authors?id='.$author['id'], ['Accept' => ['application/ld+json']])->json(); $this->assertSame($test['member'][0]['id'], $author['id']); } public function testOrderFilterWithPropertyPlaceholder(): void { - $res = $this->get('/api/authors?order[id]=desc', ['accept' => ['application/ld+json']]); + $res = $this->get('/api/authors?order[id]=desc', ['Accept' => ['application/ld+json']]); $this->assertSame($res['member'][0]['id'], 10); } public function testOrFilter(): void { - $response = $this->get('/api/books', ['accept' => ['application/ld+json']])->json()['member']; + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']])->json()['member']; $book = $response[0]; $book2 = $response[1]; - $res = $this->get(\sprintf('/api/books?name2[]=%s&name2[]=%s', $book['name'], $book2['name']), ['accept' => ['application/ld+json']])->json(); + $res = $this->get(\sprintf('/api/books?name2[]=%s&name2[]=%s', $book['name'], $book2['name']), ['Accept' => ['application/ld+json']])->json(); $this->assertSame($res['totalItems'], 2); } + + public function testRangeLowerThanFilter(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $bookBefore = $response->json()['member'][0]; + $this->patchJson( + $bookBefore['@id'], + ['isbn' => '12'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $bookAfter = $response->json()['member'][1]; + $updated = $this->patchJson( + $bookAfter['@id'], + ['isbn' => '15'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('api/books?isbn_range[lt]='.$updated['isbn'], ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']); + $this->assertSame($response->json()['totalItems'], 1); + } + + public function testRangeLowerThanEqualFilter(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $bookBefore = $response->json()['member'][0]; + $this->patchJson( + $bookBefore['@id'], + ['isbn' => '12'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $bookAfter = $response->json()['member'][1]; + $updated = $this->patchJson( + $bookAfter['@id'], + ['isbn' => '15'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('api/books?isbn_range[lte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']); + $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']); + $this->assertSame($response->json()['totalItems'], 2); + } + + public function testRangeGreaterThanFilter(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $bookBefore = $response->json()['member'][0]; + $updated = $this->patchJson( + $bookBefore['@id'], + ['isbn' => '999999999999998'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $bookAfter = $response->json()['member'][1]; + $this->patchJson( + $bookAfter['@id'], + ['isbn' => '999999999999999'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('api/books?isbn_range[gt]='.$updated['isbn'], ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $bookAfter['@id']); + $this->assertSame($response->json()['totalItems'], 1); + } + + public function testRangeGreaterThanEqualFilter(): void + { + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); + $bookBefore = $response->json()['member'][0]; + $updated = $this->patchJson( + $bookBefore['@id'], + ['isbn' => '999999999999998'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $bookAfter = $response->json()['member'][1]; + $this->patchJson( + $bookAfter['@id'], + ['isbn' => '999999999999999'], + [ + 'Accept' => ['application/ld+json'], + 'Content-Type' => ['application/merge-patch+json'], + ] + ); + + $response = $this->get('api/books?isbn_range[gte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]); + $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']); + $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']); + $this->assertSame($response->json()['totalItems'], 2); + } } diff --git a/src/Laravel/workbench/app/Models/Book.php b/src/Laravel/workbench/app/Models/Book.php index 92851799647..c458cb68c49 100644 --- a/src/Laravel/workbench/app/Models/Book.php +++ b/src/Laravel/workbench/app/Models/Book.php @@ -17,6 +17,7 @@ use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter; use ApiPlatform\Laravel\Eloquent\Filter\OrFilter; use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter; +use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; @@ -48,7 +49,9 @@ #[QueryParameter(key: 'isbn', filter: PartialSearchFilter::class, constraints: 'min:2')] #[QueryParameter(key: 'name', filter: PartialSearchFilter::class)] #[QueryParameter(key: 'author', filter: EqualsFilter::class)] -#[QueryParameter(key: 'publicationDate', filter: DateFilter::class)] +#[QueryParameter(key: 'publicationDate', filter: DateFilter::class, property: 'publication_date')] +#[QueryParameter(key: 'publicationDateWithNulls', filter: DateFilter::class, property: 'publication_date', filterContext: ['include_nulls' => true])] +#[QueryParameter(key: 'isbn_range', filter: RangeFilter::class, property: 'isbn')] #[QueryParameter( key: 'name2', filter: new OrFilter(new EqualsFilter()), diff --git a/src/Metadata/HasSchemaFilterInterface.php b/src/Metadata/JsonSchemaFilterInterface.php similarity index 92% rename from src/Metadata/HasSchemaFilterInterface.php rename to src/Metadata/JsonSchemaFilterInterface.php index cd5aebad7b0..3bad4a1f241 100644 --- a/src/Metadata/HasSchemaFilterInterface.php +++ b/src/Metadata/JsonSchemaFilterInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Metadata; -interface HasSchemaFilterInterface +interface JsonSchemaFilterInterface { /** * @return array diff --git a/src/Metadata/HasOpenApiParameterFilterInterface.php b/src/Metadata/OpenApiParameterFilterInterface.php similarity index 63% rename from src/Metadata/HasOpenApiParameterFilterInterface.php rename to src/Metadata/OpenApiParameterFilterInterface.php index c2b24b380d8..9593785a19b 100644 --- a/src/Metadata/HasOpenApiParameterFilterInterface.php +++ b/src/Metadata/OpenApiParameterFilterInterface.php @@ -15,7 +15,10 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; -interface HasOpenApiParameterFilterInterface +interface OpenApiParameterFilterInterface { - public function getOpenApiParameter(Parameter $parameter): ?OpenApiParameter; + /** + * @return OpenApiParameter|OpenApiParameter[]|null + */ + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null; } diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index b393516f226..73a1c8cae35 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Metadata; use ApiPlatform\OpenApi; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ParameterProviderInterface; use Symfony\Component\Validator\Constraint; @@ -33,7 +34,7 @@ abstract class Parameter public function __construct( protected ?string $key = null, protected ?array $schema = null, - protected OpenApi\Model\Parameter|false|null $openApi = null, + protected OpenApiParameter|array|false|null $openApi = null, protected mixed $provider = null, protected mixed $filter = null, protected ?string $property = null, @@ -62,7 +63,10 @@ public function getSchema(): ?array return $this->schema; } - public function getOpenApi(): OpenApi\Model\Parameter|bool|null + /** + * @return OpenApi\Model\Parameter[]|OpenApi\Model\Parameter|bool|null + */ + public function getOpenApi(): OpenApiParameter|array|bool|null { return $this->openApi; } @@ -170,7 +174,10 @@ public function withSchema(array $schema): static return $self; } - public function withOpenApi(OpenApi\Model\Parameter $openApi): static + /** + * @param OpenApi\Model\Parameter[]|OpenApi\Model\Parameter|bool $openApi + */ + public function withOpenApi(OpenApiParameter|array|bool $openApi): static { $self = clone $this; $self->openApi = $openApi; diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 0071a2d73da..df3be17fe24 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -15,9 +15,9 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\FilterInterface; -use ApiPlatform\Metadata\HasOpenApiParameterFilterInterface; -use ApiPlatform\Metadata\HasSchemaFilterInterface; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -157,14 +157,14 @@ private function addFilterMetadata(Parameter $parameter): Parameter return $parameter; } - if (null === $parameter->getSchema() && $filter instanceof HasSchemaFilterInterface) { + if (null === $parameter->getSchema() && $filter instanceof JsonSchemaFilterInterface) { if ($schema = $filter->getSchema($parameter)) { $parameter = $parameter->withSchema($schema); } } - if (null === $parameter->getOpenApi() && $filter instanceof HasOpenApiParameterFilterInterface) { - if ($openApiParameter = $filter->getOpenApiParameter($parameter)) { + if (null === $parameter->getOpenApi() && $filter instanceof OpenApiParameterFilterInterface) { + if ($openApiParameter = $filter->getOpenApiParameters($parameter)) { $parameter = $parameter->withOpenApi($openApiParameter); } } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 1db4ed650ba..d5c4e2f6edb 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -268,17 +268,37 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; - $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + $defaultParameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + + $linkParameter = $p->getOpenApi(); + if (null === $linkParameter) { + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $defaultParameter)) { + $openapiParameters[$i] = $this->mergeParameter($defaultParameter, $operationParameter); + } else { + $openapiParameters[] = $defaultParameter; + } - if ($linkParameter = $p->getOpenApi()) { - $parameter = $this->mergeParameter($parameter, $linkParameter); + continue; + } + + if (\is_array($linkParameter)) { + foreach ($linkParameter as $lp) { + $parameter = $this->mergeParameter($defaultParameter, $lp); + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); + continue; + } + + $openapiParameters[] = $parameter; + } + continue; } + $parameter = $this->mergeParameter($defaultParameter, $linkParameter); if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); continue; } - $openapiParameters[] = $parameter; } diff --git a/src/Serializer/Filter/PropertyFilter.php b/src/Serializer/Filter/PropertyFilter.php index fc7d9741046..188dba648e2 100644 --- a/src/Serializer/Filter/PropertyFilter.php +++ b/src/Serializer/Filter/PropertyFilter.php @@ -13,8 +13,8 @@ namespace ApiPlatform\Serializer\Filter; -use ApiPlatform\Metadata\HasOpenApiParameterFilterInterface; -use ApiPlatform\Metadata\HasSchemaFilterInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter as MetadataParameter; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Parameter; @@ -118,7 +118,7 @@ * * @author Baptiste Meyer */ -final class PropertyFilter implements FilterInterface, HasOpenApiParameterFilterInterface, HasSchemaFilterInterface +final class PropertyFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface { private ?array $whitelist; @@ -277,7 +277,7 @@ public function getSchema(MetadataParameter $parameter): array ]; } - public function getOpenApiParameter(MetadataParameter $parameter): Parameter + public function getOpenApiParameters(MetadataParameter $parameter): Parameter|array|null { $example = \sprintf( '%1$s[]={propertyName}&%1$s[]={anotherPropertyName}',