From 11010c6808d2cabc02e2087a884908a422778257 Mon Sep 17 00:00:00 2001 From: Saifallah AZZABI Date: Mon, 13 Apr 2026 17:54:35 +0200 Subject: [PATCH 1/2] feat: add elasticsearch RangeFilter support --- src/Elasticsearch/Filter/RangeFilter.php | 131 ++++++++++++++++++ .../Bundle/Resources/config/elasticsearch.php | 7 + 2 files changed, 138 insertions(+) create mode 100644 src/Elasticsearch/Filter/RangeFilter.php diff --git a/src/Elasticsearch/Filter/RangeFilter.php b/src/Elasticsearch/Filter/RangeFilter.php new file mode 100644 index 00000000000..098a2730772 --- /dev/null +++ b/src/Elasticsearch/Filter/RangeFilter.php @@ -0,0 +1,131 @@ + + * + * 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\Elasticsearch\Filter; + +/** + * The range filter allows to find resources that [range](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html) the specified text on full text fields. + * + * Syntax: `?property[]=value`. + * + *
+ * + * ```php + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * book.range_filter + * + * + * + * + * + * ``` + * + *
+ * + * Given that the collection endpoint is `/books`, you can filter books by title content with the following query: `/books?title=Foundation`. + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html + * + * @author Saifallah Azzabi + */ +final class RangeFilter extends AbstractSearchFilter +{ + public const GT = 'gt'; + + public const GTE = 'gte'; + + public const LT = 'lt'; + + public const LTE = 'lte'; + /** + * {@inheritdoc} + */ + protected function getQuery(string $property, array $values, ?string $nestedPath): array + { + $rangeQuery = ['range' => [$property => []]]; + + foreach ($values as $operator => $value) { + if (\in_array($operator, [self::GT, self::GTE, self::LT, self::LTE], true)) { + $rangeQuery['range'][$property][$operator] = $value; + } + } + + if (null !== $nestedPath) { + return ['nested' => ['path' => $nestedPath, 'query' => $rangeQuery]]; + } + + return $rangeQuery; + } +} diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 212fa22343b..8db74e70ea9 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -18,6 +18,7 @@ use ApiPlatform\Elasticsearch\Extension\SortFilterExtension; use ApiPlatform\Elasticsearch\Filter\MatchFilter; use ApiPlatform\Elasticsearch\Filter\OrderFilter; +use ApiPlatform\Elasticsearch\Filter\RangeFilter; use ApiPlatform\Elasticsearch\Filter\TermFilter; use ApiPlatform\Elasticsearch\Metadata\Resource\Factory\ElasticsearchProviderResourceMetadataCollectionFactory; use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; @@ -93,6 +94,12 @@ $services->alias(MatchFilter::class, 'api_platform.elasticsearch.match_filter'); + $services->set('api_platform.elasticsearch.range_filter', RangeFilter::class) + ->abstract() + ->parent('api_platform.elasticsearch.search_filter'); + + $services->alias(RangeFilter::class, 'api_platform.elasticsearch.range_filter'); + $services->set('api_platform.elasticsearch.order_filter', OrderFilter::class) ->abstract() ->args([ From a7173ac3f3c1fb87d3daf8e84ca84e1a64afac10 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 27 Apr 2026 14:55:49 +0200 Subject: [PATCH 2/2] test --- features/elasticsearch/range_filter.feature | 133 ++++++++++++++++++ src/Elasticsearch/Filter/RangeFilter.php | 18 ++- .../Elasticsearch/Fixtures/product.json | 32 +++++ .../Elasticsearch/Mappings/product.json | 20 +++ .../Fixtures/Elasticsearch/Model/Product.php | 39 +++++ 5 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 features/elasticsearch/range_filter.feature create mode 100644 tests/Fixtures/Elasticsearch/Fixtures/product.json create mode 100644 tests/Fixtures/Elasticsearch/Mappings/product.json create mode 100644 tests/Fixtures/Elasticsearch/Model/Product.php diff --git a/features/elasticsearch/range_filter.feature b/features/elasticsearch/range_filter.feature new file mode 100644 index 00000000000..c0ba44c7b2e --- /dev/null +++ b/features/elasticsearch/range_filter.feature @@ -0,0 +1,133 @@ +@elasticsearch +Feature: Range filter on collections from Elasticsearch + In order to filter resources by a numeric or date range from Elasticsearch + As a client software developer + I need to query for resources matching range comparison operators + + Scenario: Range filter using the gt operator + When I send a "GET" request to "/products?price%5Bgt%5D=20" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Product$"}, + "@id": {"pattern": "^/products$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "price": {"type": "integer", "minimum": 21} + }, + "required": ["price"] + } + } + } + } + """ + + Scenario: Range filter combining gte and lte (bounded range) + When I send a "GET" request to "/products?price%5Bgte%5D=10&price%5Blte%5D=30" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Product$"}, + "@id": {"pattern": "^/products$"}, + "hydra:member": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "price": {"type": "integer", "minimum": 10, "maximum": 30} + }, + "required": ["price"] + } + } + } + } + """ + + Scenario: Range filter using the lt operator + When I send a "GET" request to "/products?price%5Blt%5D=20" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "hydra:member": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "properties": { + "price": {"type": "integer", "maximum": 19} + }, + "required": ["price"] + } + } + } + } + """ + + Scenario: Range filter on a date property using gte + When I send a "GET" request to "/products?releaseDate%5Bgte%5D=2023-01-01" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2 + } + } + } + """ + + Scenario: Range filter ignores unknown operators + When I send a "GET" request to "/products?price%5Bgte%5D=30&price%5Bunknown%5D=99" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "hydra:member": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "price": {"type": "integer", "minimum": 30} + }, + "required": ["price"] + } + } + } + } + """ diff --git a/src/Elasticsearch/Filter/RangeFilter.php b/src/Elasticsearch/Filter/RangeFilter.php index 098a2730772..b5d371c408c 100644 --- a/src/Elasticsearch/Filter/RangeFilter.php +++ b/src/Elasticsearch/Filter/RangeFilter.php @@ -14,9 +14,11 @@ namespace ApiPlatform\Elasticsearch\Filter; /** - * The range filter allows to find resources that [range](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html) the specified text on full text fields. + * The range filter allows to find resources matching a numeric or date [range](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html) + * on the given properties. * - * Syntax: `?property[]=value`. + * Syntax: `?property[]=value` where `` is one of `gt`, `gte`, `lt`, `lte`. + * Operators can be combined to express bounded ranges. * *
* @@ -28,7 +30,7 @@ * use ApiPlatform\Elasticsearch\Filter\RangeFilter; * * #[ApiResource] - * #[ApiFilter(RangeFilter::class, properties: ['title'])] + * #[ApiFilter(RangeFilter::class, properties: ['price'])] * class Book * { * // ... @@ -40,7 +42,7 @@ * services: * book.range_filter: * parent: 'api_platform.elasticsearch.range_filter' - * arguments: [ { title: ~ } ] + * arguments: [ { price: ~ } ] * tags: [ 'api_platform.filter' ] * # The following are mandatory only if a _defaults section is defined with inverted values. * # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section) @@ -68,7 +70,7 @@ * * * - * + * * * * @@ -94,7 +96,10 @@ * *
* - * Given that the collection endpoint is `/books`, you can filter books by title content with the following query: `/books?title=Foundation`. + * Given that the collection endpoint is `/books`, you can filter books priced between 10 and 50 + * with the following query: `/books?price[gte]=10&price[lte]=50`. + * + * Unknown operators are ignored, so only `gt`, `gte`, `lt` and `lte` ever reach the underlying query. * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html * @@ -109,6 +114,7 @@ final class RangeFilter extends AbstractSearchFilter public const LT = 'lt'; public const LTE = 'lte'; + /** * {@inheritdoc} */ diff --git a/tests/Fixtures/Elasticsearch/Fixtures/product.json b/tests/Fixtures/Elasticsearch/Fixtures/product.json new file mode 100644 index 00000000000..16fb164f8e4 --- /dev/null +++ b/tests/Fixtures/Elasticsearch/Fixtures/product.json @@ -0,0 +1,32 @@ +[ + { + "id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a601", + "name": "Bookmark", + "price": 10, + "releaseDate": "2020-01-01" + }, + { + "id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a602", + "name": "Compass", + "price": 20, + "releaseDate": "2021-06-15" + }, + { + "id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a603", + "name": "Notebook", + "price": 30, + "releaseDate": "2022-03-10" + }, + { + "id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a604", + "name": "Headlamp", + "price": 40, + "releaseDate": "2023-08-20" + }, + { + "id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a605", + "name": "Tent", + "price": 50, + "releaseDate": "2024-01-01" + } +] diff --git a/tests/Fixtures/Elasticsearch/Mappings/product.json b/tests/Fixtures/Elasticsearch/Mappings/product.json new file mode 100644 index 00000000000..08c55a26634 --- /dev/null +++ b/tests/Fixtures/Elasticsearch/Mappings/product.json @@ -0,0 +1,20 @@ +{ + "mappings": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "price": { + "type": "integer" + }, + "releaseDate": { + "type": "date", + "format": "yyyy-MM-dd" + } + }, + "dynamic": "strict" + } +} diff --git a/tests/Fixtures/Elasticsearch/Model/Product.php b/tests/Fixtures/Elasticsearch/Model/Product.php new file mode 100644 index 00000000000..9ac169a35be --- /dev/null +++ b/tests/Fixtures/Elasticsearch/Model/Product.php @@ -0,0 +1,39 @@ + + * + * 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\Tests\Fixtures\Elasticsearch\Model; + +use ApiPlatform\Elasticsearch\Filter\RangeFilter; +use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(normalizationContext: ['groups' => ['product:read']], stateOptions: new Options(index: 'product'))] +#[ApiFilter(RangeFilter::class, properties: ['price', 'releaseDate'])] +class Product +{ + #[Groups(['product:read'])] + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Groups(['product:read'])] + public ?string $name = null; + + #[Groups(['product:read'])] + public ?int $price = null; + + #[Groups(['product:read'])] + public ?\DateTimeImmutable $releaseDate = null; +}