diff --git a/features/doctrine/numeric_filter.feature b/features/doctrine/numeric_filter.feature index ec449ae0be7..abecb20160e 100644 --- a/features/doctrine/numeric_filter.feature +++ b/features/doctrine/numeric_filter.feature @@ -193,7 +193,7 @@ Feature: Numeric filter on collections "@type": {"pattern": "^IriTemplateMapping$"}, "variable": { "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, + {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte|ne)?\\])?$"}, {"pattern": "^order\\[name_converted\\]$"} ] }, @@ -203,8 +203,8 @@ Feature: Numeric filter on collections "required": ["@type", "variable", "property", "required"], "additionalProperties": false }, - "minItems": 8, - "maxItems": 8, + "minItems": 9, + "maxItems": 9, "uniqueItems": true } }, diff --git a/features/doctrine/order_filter.feature b/features/doctrine/order_filter.feature index 285f8557906..25443022282 100644 --- a/features/doctrine/order_filter.feature +++ b/features/doctrine/order_filter.feature @@ -964,7 +964,7 @@ Feature: Order filter on collections "variable": { "oneOf": [ {"pattern": "^order\\[name_converted\\]$"}, - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"} + {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte|ne)?\\])?$"} ] }, "property": {"pattern": "^name_converted$"}, @@ -973,8 +973,8 @@ Feature: Order filter on collections "required": ["@type", "variable", "property", "required"], "additionalProperties": false }, - "minItems": 8, - "maxItems": 8, + "minItems": 9, + "maxItems": 9, "uniqueItems": true } }, diff --git a/features/doctrine/range_filter.feature b/features/doctrine/range_filter.feature index 9a9ec12d074..2e3ccce8b1b 100644 --- a/features/doctrine/range_filter.feature +++ b/features/doctrine/range_filter.feature @@ -396,6 +396,49 @@ Feature: Range filter on collections } """ + Scenario: Filter for entities by range (not equal to) + When I send a "GET" request to "/dummies?dummyPrice[ne]=12.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": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/1$"}, + {"pattern": "^/dummies/3$"}, + {"pattern": "^/dummies/4$"} + ] + } + } + }, + "minItems": 3, + "maxItems": 3, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "number", "minimum": 22, "maximum": 22}, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?dummyPrice%5Bne%5D=12.99"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + Scenario: Filter for entities within an impossible range When I send a "GET" request to "/dummies?dummyPrice[gt]=19.99" Then the response status code should be 200 @@ -471,7 +514,7 @@ Feature: Range filter on collections "type": "object", "properties": { "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted\\[between\\],name_converted\\[gt\\],name_converted\\[gte\\],name_converted\\[lt\\],name_converted\\[lte\\].*\\}$"}, + "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted\\[between\\],name_converted\\[gt\\],name_converted\\[gte\\],name_converted\\[lt\\],name_converted\\[lte\\],name_converted\\[ne\\].*\\}$"}, "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, "hydra:mapping": { "type": "array", @@ -481,7 +524,7 @@ Feature: Range filter on collections "@type": {"pattern": "^IriTemplateMapping$"}, "variable": { "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, + {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte|ne)?\\])?$"}, {"pattern": "^order\\[name_converted\\]$"} ] }, @@ -491,8 +534,8 @@ Feature: Range filter on collections "required": ["@type", "variable", "property", "required"], "additionalProperties": false }, - "minItems": 8, - "maxItems": 8, + "minItems": 9, + "maxItems": 9, "uniqueItems": true } }, diff --git a/features/main/crud.feature b/features/main/crud.feature index 1fc310d63bb..bbb1c9de56b 100644 --- a/features/main/crud.feature +++ b/features/main/crud.feature @@ -143,7 +143,7 @@ Feature: Create-Retrieve-Update-Delete "hydra:totalItems": 1, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?dummyBoolean,relatedDummy.embeddedDummy.dummyBoolean,dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[strictly_before],relatedDummy.dummyDate[after],relatedDummy.dummyDate[strictly_after],exists[alias],exists[description],exists[relatedDummy.name],exists[dummyBoolean],exists[relatedDummy],exists[relatedDummies],dummyFloat,dummyFloat[],dummyPrice,dummyPrice[],order[id],order[name],order[description],order[relatedDummy.name],order[relatedDummy.symfony],order[dummyDate],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,relatedDummy.thirdLevel.level,relatedDummy.thirdLevel.level[],relatedDummy.thirdLevel.fourthLevel.level,relatedDummy.thirdLevel.fourthLevel.level[],relatedDummy.thirdLevel.badFourthLevel.level,relatedDummy.thirdLevel.badFourthLevel.level[],relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level,relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level[],name_converted,properties[]}", + "hydra:template": "/dummies{?dummyBoolean,relatedDummy.embeddedDummy.dummyBoolean,dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[strictly_before],relatedDummy.dummyDate[after],relatedDummy.dummyDate[strictly_after],exists[alias],exists[description],exists[relatedDummy.name],exists[dummyBoolean],exists[relatedDummy],exists[relatedDummies],dummyFloat,dummyFloat[],dummyPrice,dummyPrice[],order[id],order[name],order[description],order[relatedDummy.name],order[relatedDummy.symfony],order[dummyDate],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyFloat[ne],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyPrice[ne],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,relatedDummy.thirdLevel.level,relatedDummy.thirdLevel.level[],relatedDummy.thirdLevel.fourthLevel.level,relatedDummy.thirdLevel.fourthLevel.level[],relatedDummy.thirdLevel.badFourthLevel.level,relatedDummy.thirdLevel.badFourthLevel.level[],relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level,relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level[],name_converted,properties[]}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -332,6 +332,12 @@ Feature: Create-Retrieve-Update-Delete "property": "dummyFloat", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat[ne]", + "property": "dummyFloat", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "dummyPrice[between]", @@ -362,6 +368,12 @@ Feature: Create-Retrieve-Update-Delete "property": "dummyPrice", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[ne]", + "property": "dummyPrice", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "id", diff --git a/src/Doctrine/Common/Filter/RangeFilterInterface.php b/src/Doctrine/Common/Filter/RangeFilterInterface.php index 2ea0db988b3..6abdcbf19fd 100644 --- a/src/Doctrine/Common/Filter/RangeFilterInterface.php +++ b/src/Doctrine/Common/Filter/RangeFilterInterface.php @@ -26,4 +26,5 @@ interface RangeFilterInterface public const PARAMETER_GREATER_THAN_OR_EQUAL = 'gte'; public const PARAMETER_LESS_THAN = 'lt'; public const PARAMETER_LESS_THAN_OR_EQUAL = 'lte'; + public const PARAMETER_NOT_EQUAL = 'ne'; } diff --git a/src/Doctrine/Common/Filter/RangeFilterTrait.php b/src/Doctrine/Common/Filter/RangeFilterTrait.php index 6dbadab2e80..9badd94f6b1 100644 --- a/src/Doctrine/Common/Filter/RangeFilterTrait.php +++ b/src/Doctrine/Common/Filter/RangeFilterTrait.php @@ -49,6 +49,7 @@ public function getDescription(string $resourceClass): array $description += $this->getFilterDescription($property, self::PARAMETER_GREATER_THAN_OR_EQUAL); $description += $this->getFilterDescription($property, self::PARAMETER_LESS_THAN); $description += $this->getFilterDescription($property, self::PARAMETER_LESS_THAN_OR_EQUAL); + $description += $this->getFilterDescription($property, self::PARAMETER_NOT_EQUAL); } return $description; @@ -78,7 +79,7 @@ protected function getFilterDescription(string $fieldName, string $operator): ar private function normalizeValues(array $values, string $property): ?array { - $operators = [self::PARAMETER_BETWEEN, self::PARAMETER_GREATER_THAN, self::PARAMETER_GREATER_THAN_OR_EQUAL, self::PARAMETER_LESS_THAN, self::PARAMETER_LESS_THAN_OR_EQUAL]; + $operators = [self::PARAMETER_BETWEEN, self::PARAMETER_GREATER_THAN, self::PARAMETER_GREATER_THAN_OR_EQUAL, self::PARAMETER_LESS_THAN, self::PARAMETER_LESS_THAN_OR_EQUAL, self::PARAMETER_NOT_EQUAL]; foreach ($values as $operator => $value) { if (!\in_array($operator, $operators, true)) { diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index 16982b9f2c1..c29b73a1bb4 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -121,6 +121,15 @@ protected function addMatch(Builder $aggregationBuilder, string $field, string $ $aggregationBuilder->match()->field($matchField)->lte($value); + break; + case self::PARAMETER_NOT_EQUAL: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $aggregationBuilder->match()->field($matchField)->notEqual($value); + break; } } diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index 5b8511d613b..eddfbaad33e 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -139,6 +139,17 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf ->andWhere(sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); + break; + case self::PARAMETER_NOT_EQUAL: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $queryBuilder + ->andWhere(sprintf('%s.%s <> :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + break; } } diff --git a/tests/Doctrine/Common/Filter/RangeFilterTestTrait.php b/tests/Doctrine/Common/Filter/RangeFilterTestTrait.php index 3940b4cabe7..911a845ca0d 100644 --- a/tests/Doctrine/Common/Filter/RangeFilterTestTrait.php +++ b/tests/Doctrine/Common/Filter/RangeFilterTestTrait.php @@ -134,6 +134,22 @@ private function provideApplyTestArguments(): array ], ], ], + 'ne' => [ + null, + [ + 'dummyPrice' => [ + 'ne' => '9.99', + ], + ], + ], + 'ne (non-numeric)' => [ + null, + [ + 'dummyPrice' => [ + 'ne' => '127.0.0.1', + ], + ], + ], ]; } } diff --git a/tests/Doctrine/Odm/Filter/RangeFilterTest.php b/tests/Doctrine/Odm/Filter/RangeFilterTest.php index bd3678d917d..2adc2feb19b 100644 --- a/tests/Doctrine/Odm/Filter/RangeFilterTest.php +++ b/tests/Doctrine/Odm/Filter/RangeFilterTest.php @@ -60,6 +60,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'id[ne]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], 'name[between]' => [ 'property' => 'name', 'type' => 'string', @@ -85,6 +90,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'name[ne]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + ], 'alias[between]' => [ 'property' => 'alias', 'type' => 'string', @@ -110,6 +120,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'alias[ne]' => [ + 'property' => 'alias', + 'type' => 'string', + 'required' => false, + ], 'description[between]' => [ 'property' => 'description', 'type' => 'string', @@ -135,6 +150,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'description[ne]' => [ + 'property' => 'description', + 'type' => 'string', + 'required' => false, + ], 'dummy[between]' => [ 'property' => 'dummy', 'type' => 'string', @@ -160,6 +180,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummy[ne]' => [ + 'property' => 'dummy', + 'type' => 'string', + 'required' => false, + ], 'dummyDate[between]' => [ 'property' => 'dummyDate', 'type' => 'string', @@ -185,6 +210,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyDate[ne]' => [ + 'property' => 'dummyDate', + 'type' => 'string', + 'required' => false, + ], 'dummyFloat[between]' => [ 'property' => 'dummyFloat', 'type' => 'string', @@ -210,6 +240,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyFloat[ne]' => [ + 'property' => 'dummyFloat', + 'type' => 'string', + 'required' => false, + ], 'dummyPrice[between]' => [ 'property' => 'dummyPrice', 'type' => 'string', @@ -235,6 +270,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyPrice[ne]' => [ + 'property' => 'dummyPrice', + 'type' => 'string', + 'required' => false, + ], 'jsonData[between]' => [ 'property' => 'jsonData', 'type' => 'string', @@ -260,6 +300,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'jsonData[ne]' => [ + 'property' => 'jsonData', + 'type' => 'string', + 'required' => false, + ], 'arrayData[between]' => [ 'property' => 'arrayData', 'type' => 'string', @@ -285,6 +330,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'arrayData[ne]' => [ + 'property' => 'arrayData', + 'type' => 'string', + 'required' => false, + ], 'nameConverted[between]' => [ 'property' => 'nameConverted', 'type' => 'string', @@ -310,6 +360,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'nameConverted[ne]' => [ + 'property' => 'nameConverted', + 'type' => 'string', + 'required' => false, + ], 'dummyBoolean[between]' => [ 'property' => 'dummyBoolean', 'type' => 'string', @@ -335,6 +390,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyBoolean[ne]' => [ + 'property' => 'dummyBoolean', + 'type' => 'string', + 'required' => false, + ], 'relatedDummy[between]' => [ 'property' => 'relatedDummy', 'type' => 'string', @@ -360,6 +420,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'relatedDummy[ne]' => [ + 'property' => 'relatedDummy', + 'type' => 'string', + 'required' => false, + ], 'relatedDummies[between]' => [ 'property' => 'relatedDummies', 'type' => 'string', @@ -385,6 +450,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'relatedDummies[ne]' => [ + 'property' => 'relatedDummies', + 'type' => 'string', + 'required' => false, + ], 'relatedOwnedDummy[between]' => [ 'property' => 'relatedOwnedDummy', 'type' => 'string', @@ -410,6 +480,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'relatedOwnedDummy[ne]' => [ + 'property' => 'relatedOwnedDummy', + 'type' => 'string', + 'required' => false, + ], 'relatedOwningDummy[between]' => [ 'property' => 'relatedOwningDummy', 'type' => 'string', @@ -435,6 +510,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'relatedOwningDummy[ne]' => [ + 'property' => 'relatedOwningDummy', + 'type' => 'string', + 'required' => false, + ], ], $filter->getDescription($this->resourceClass)); } @@ -547,6 +627,20 @@ public function provideApplyTestData(): array ], ], ], + 'ne' => [ + [ + [ + '$match' => [ + 'dummyPrice' => [ + '$ne' => 9.99, + ], + ], + ], + ], + ], + 'ne (non-numeric)' => [ + [], + ], ] ); } diff --git a/tests/Doctrine/Orm/Filter/RangeFilterTest.php b/tests/Doctrine/Orm/Filter/RangeFilterTest.php index d27a01e0837..930549960af 100644 --- a/tests/Doctrine/Orm/Filter/RangeFilterTest.php +++ b/tests/Doctrine/Orm/Filter/RangeFilterTest.php @@ -57,6 +57,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'id[ne]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], 'name[between]' => [ 'property' => 'name', 'type' => 'string', @@ -82,6 +87,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'name[ne]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + ], 'alias[between]' => [ 'property' => 'alias', 'type' => 'string', @@ -107,6 +117,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'alias[ne]' => [ + 'property' => 'alias', + 'type' => 'string', + 'required' => false, + ], 'description[between]' => [ 'property' => 'description', 'type' => 'string', @@ -132,6 +147,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'description[ne]' => [ + 'property' => 'description', + 'type' => 'string', + 'required' => false, + ], 'dummy[between]' => [ 'property' => 'dummy', 'type' => 'string', @@ -157,6 +177,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummy[ne]' => [ + 'property' => 'dummy', + 'type' => 'string', + 'required' => false, + ], 'dummyDate[between]' => [ 'property' => 'dummyDate', 'type' => 'string', @@ -182,6 +207,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyDate[ne]' => [ + 'property' => 'dummyDate', + 'type' => 'string', + 'required' => false, + ], 'dummyFloat[between]' => [ 'property' => 'dummyFloat', 'type' => 'string', @@ -207,6 +237,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyFloat[ne]' => [ + 'property' => 'dummyFloat', + 'type' => 'string', + 'required' => false, + ], 'dummyPrice[between]' => [ 'property' => 'dummyPrice', 'type' => 'string', @@ -232,6 +267,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyPrice[ne]' => [ + 'property' => 'dummyPrice', + 'type' => 'string', + 'required' => false, + ], 'jsonData[between]' => [ 'property' => 'jsonData', 'type' => 'string', @@ -257,6 +297,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'jsonData[ne]' => [ + 'property' => 'jsonData', + 'type' => 'string', + 'required' => false, + ], 'arrayData[between]' => [ 'property' => 'arrayData', 'type' => 'string', @@ -282,6 +327,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'arrayData[ne]' => [ + 'property' => 'arrayData', + 'type' => 'string', + 'required' => false, + ], 'nameConverted[between]' => [ 'property' => 'nameConverted', 'type' => 'string', @@ -307,6 +357,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'nameConverted[ne]' => [ + 'property' => 'nameConverted', + 'type' => 'string', + 'required' => false, + ], 'dummyBoolean[between]' => [ 'property' => 'dummyBoolean', 'type' => 'string', @@ -332,6 +387,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyBoolean[ne]' => [ + 'property' => 'dummyBoolean', + 'type' => 'string', + 'required' => false, + ], ], $filter->getDescription($this->resourceClass)); } @@ -382,6 +442,12 @@ public function provideApplyTestData(): array 'lte + gte' => [ sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1 AND o.dummyPrice <= :dummyPrice_p2', Dummy::class), ], + 'ne' => [ + sprintf('SELECT o FROM %s o WHERE o.dummyPrice <> :dummyPrice_p1', Dummy::class), + ], + 'ne (non-numeric)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], ] ); }