diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 2cfdf5abcfa..ee5c3a040d1 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -96,6 +96,7 @@ public function thereIsDummyObjects($nb) $dummy = new Dummy(); $dummy->setName('Dummy #'.$i); $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); $dummy->setDescription($descriptions[($i - 1) % 2]); $this->manager->persist($dummy); diff --git a/features/crud.feature b/features/crud.feature index e11345aabe0..d5b84c4bd7b 100644 --- a/features/crud.feature +++ b/features/crud.feature @@ -116,7 +116,7 @@ Feature: Create-Retrieve-Update-Delete "hydra:totalItems": 1, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyPrice}", + "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyPrice}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -173,6 +173,12 @@ Feature: Create-Retrieve-Update-Delete "property": "relatedDummies", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "dummy", + "property": "dummy", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[id]", diff --git a/features/doctrine/date-filter.feature b/features/doctrine/date-filter.feature index 62e6bfa66d9..cc74f79bcc6 100644 --- a/features/doctrine/date-filter.feature +++ b/features/doctrine/date-filter.feature @@ -269,7 +269,7 @@ Feature: Date filter on collections }, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "\/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyPrice}", + "hydra:template": "\/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyPrice}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -326,6 +326,12 @@ Feature: Date filter on collections "property": "relatedDummies", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "dummy", + "property": "dummy", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[id]", @@ -414,4 +420,4 @@ Feature: Date filter on collections } } """ - \ No newline at end of file + diff --git a/features/doctrine/search-filter.feature b/features/doctrine/search-filter.feature index 1ecb27d6adf..f1eddf5edd3 100644 --- a/features/doctrine/search-filter.feature +++ b/features/doctrine/search-filter.feature @@ -42,6 +42,35 @@ Feature: Search filter on collections } """ + Scenario: Search collection by name (partial case insensitive) + Given there is "30" dummy objects + When I send a "GET" request to "/dummies?dummy=somedummytest1" + 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" + 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": { + "dummy": { + "pattern": "^SomeDummyTest\\d{1,2}$" + } + } + } + } + } + } + """ + Scenario: Search collection by alias (start) When I send a "GET" request to "/dummies?alias=Ali" Then the response status code should be 200 diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index bfcd886ec88..d8b6cd255cd 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -55,6 +55,7 @@ class SearchFilter extends AbstractFilter private $requestStack; private $iriConverter; private $propertyAccessor; + private $caseSensitive; /** * @param ManagerRegistry $managerRegistry @@ -112,57 +113,66 @@ public function apply(QueryBuilder $queryBuilder, string $resourceClass, string $metadata = $this->getClassMetadata($resourceClass); } - if ($metadata->hasField($field)) { - $values = $this->normalizeValues((array) $value); + $values = $this->normalizeValues((array) $value); - if (empty($values)) { - continue; - } + if (empty($values)) { + continue; + } + $this->caseSensitive = true; + + if ($metadata->hasField($field)) { if ('id' === $field) { $values = array_map([$this, 'getFilterValueFromUrl'], $values); } $strategy = $this->properties[$property] ?? self::STRATEGY_EXACT; + // prefixing the strategy with i makes it case insensitive + if (strpos($strategy, 'i') === 0) { + $strategy = substr($strategy, 1); + $this->caseSensitive = false; + } + if (1 === count($values)) { $this->addWhereByStrategy($strategy, $queryBuilder, $alias, $field, $values[0]); - } else { - if (self::STRATEGY_EXACT !== $strategy) { - continue; - } - - $valueParameter = QueryNameGenerator::generateParameterName($field); - - $queryBuilder - ->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $values); + continue; } - } elseif ($metadata->hasAssociation($field)) { - $values = $this->normalizeValues((array) $value); - if (empty($values)) { + // there are many values, as we translate those to an IN clause, strategy must be exact + if (self::STRATEGY_EXACT !== $strategy) { continue; } - $values = array_map([$this, 'getFilterValueFromUrl'], $values); - - $association = $field; - $associationAlias = QueryNameGenerator::generateJoinAlias($association); - $valueParameter = QueryNameGenerator::generateParameterName($association); + $valueParameter = QueryNameGenerator::generateParameterName($field); $queryBuilder - ->join(sprintf('%s.%s', $alias, $association), $associationAlias); + ->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $values); + } - if (1 === count($values)) { - $queryBuilder - ->andWhere(sprintf('%s.id = :%s', $associationAlias, $valueParameter)) - ->setParameter($valueParameter, $values[0]); - } else { - $queryBuilder - ->andWhere(sprintf('%s.id IN (:%s)', $associationAlias, $valueParameter)) - ->setParameter($valueParameter, $values); - } + // metadata doesn't have the field, nor an association on the field + if (!$metadata->hasAssociation($field)) { + continue; + } + + $values = array_map([$this, 'getFilterValueFromUrl'], $values); + + $association = $field; + $associationAlias = QueryNameGenerator::generateJoinAlias($association); + $valueParameter = QueryNameGenerator::generateParameterName($association); + + $queryBuilder + ->join(sprintf('%s.%s', $alias, $association), $associationAlias); + + if (1 === count($values)) { + $queryBuilder + ->andWhere(sprintf('%s.id = :%s', $associationAlias, $valueParameter)) + ->setParameter($valueParameter, $values[0]); + } else { + $queryBuilder + ->andWhere(sprintf('%s.id IN (:%s)', $associationAlias, $valueParameter)) + ->setParameter($valueParameter, $values); } } } @@ -186,31 +196,34 @@ private function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder case null: case self::STRATEGY_EXACT: $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(sprintf($this->caseWrap('%s.%s').' = '.$this->caseWrap(':%s'), $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; case self::STRATEGY_PARTIAL: $queryBuilder - ->andWhere(sprintf('%s.%s LIKE :%s', $alias, $field, $valueParameter)) + ->andWhere(sprintf($this->caseWrap('%s.%s').' LIKE '.$this->caseWrap(':%s'), $alias, $field, $valueParameter)) ->setParameter($valueParameter, sprintf('%%%s%%', $value)); break; case self::STRATEGY_START: $queryBuilder - ->andWhere(sprintf('%s.%s LIKE :%s', $alias, $field, $valueParameter)) + ->andWhere(sprintf($this->caseWrap('%s.%s').' LIKE '.$this->caseWrap(':%s'), $alias, $field, $valueParameter)) ->setParameter($valueParameter, sprintf('%s%%', $value)); break; case self::STRATEGY_END: $queryBuilder - ->andWhere(sprintf('%s.%s LIKE :%s', $alias, $field, $valueParameter)) + ->andWhere(sprintf($this->caseWrap('%s.%s').' LIKE '.$this->caseWrap(':%s'), $alias, $field, $valueParameter)) ->setParameter($valueParameter, sprintf('%%%s', $value)); break; case self::STRATEGY_WORD_START: + $andWhere = $this->caseWrap('%1$s.%2$s').' LIKE '.$this->caseWrap(':%3$s_1'); + $andWhere .= ' OR '.$this->caseWrap('%1$s.%2$s').' LIKE '.$this->caseWrap(':%3$s_2'); + $queryBuilder - ->andWhere(sprintf('%1$s.%2$s LIKE :%3$s_1 OR %1$s.%2$s LIKE :%3$s_2', $alias, $field, $valueParameter)) + ->andWhere(sprintf($andWhere, $alias, $field, $valueParameter)) ->setParameter(sprintf('%s_1', $valueParameter), sprintf('%s%%', $value)) ->setParameter(sprintf('%s_2', $valueParameter), sprintf('%% %s%%', $value)); break; @@ -220,6 +233,23 @@ private function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder } } + /** + * Wraps a string with a doctrine expression according to the current case status + * Example: $this->caseWrap('o.id') becomes LOWER(o.id) when $this->caseSensitive is true. + * + * @param string $string + * + * @return string + */ + private function caseWrap(string $string): string + { + if (false !== $this->caseSensitive) { + return $string; + } + + return sprintf('LOWER(%s)', $string); + } + /** * {@inheritdoc} */ diff --git a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php index ed68017c003..7be9a809a63 100644 --- a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php +++ b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php @@ -403,6 +403,21 @@ public function filterProvider() ], ], ], + // Exact case insensitive + [ + [ + 'properties' => ['id' => null, 'name' => 'iexact'], + ], + [ + 'name' => 'exact', + ], + [ + 'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) = LOWER(:name_123456abcdefg)', Dummy::class), + 'parameters' => [ + 'name_123456abcdefg' => 'exact', + ], + ], + ], // invalid values [ [ @@ -447,6 +462,21 @@ public function filterProvider() ], ], ], + // partial case insensitive + [ + [ + 'properties' => ['id' => null, 'name' => 'ipartial'], + ], + [ + 'name' => 'partial', + ], + [ + 'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg)', Dummy::class), + 'parameters' => [ + 'name_123456abcdefg' => '%partial%', + ], + ], + ], [ [ 'properties' => ['id' => null, 'name' => 'start'], @@ -461,6 +491,21 @@ public function filterProvider() ], ], ], + // start case insensitive + [ + [ + 'properties' => ['id' => null, 'name' => 'istart'], + ], + [ + 'name' => 'partial', + ], + [ + 'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg)', Dummy::class), + 'parameters' => [ + 'name_123456abcdefg' => 'partial%', + ], + ], + ], [ [ 'properties' => ['id' => null, 'name' => 'end'], @@ -475,6 +520,21 @@ public function filterProvider() ], ], ], + // end case insensitive + [ + [ + 'properties' => ['id' => null, 'name' => 'iend'], + ], + [ + 'name' => 'partial', + ], + [ + 'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg)', Dummy::class), + 'parameters' => [ + 'name_123456abcdefg' => '%partial', + ], + ], + ], [ [ 'properties' => ['id' => null, 'name' => 'word_start'], @@ -490,6 +550,21 @@ public function filterProvider() ], ], ], + [ + [ + 'properties' => ['id' => null, 'name' => 'iword_start'], + ], + [ + 'name' => 'partial', + ], + [ + 'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg_1) OR LOWER(o.name) like LOWER(:name_123456abcdefg_2)', Dummy::class), + 'parameters' => [ + 'name_123456abcdefg_1' => 'partial%', + 'name_123456abcdefg_2' => '% partial%', + ], + ], + ], // relations [ [ diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index 4812a7486b2..0f2af277c86 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -234,4 +234,14 @@ public function setDummyBoolean($dummyBoolean) { $this->dummyBoolean = $dummyBoolean; } + + public function setDummy($dummy = null) + { + $this->dummy = $dummy; + } + + public function getDummy() + { + return $this->dummy; + } } diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index b1c09701da9..782b3cb1e4a 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -76,7 +76,7 @@ services: app.my_dummy_resource.search_filter: parent: 'api_platform.doctrine.orm.search_filter' - arguments: [ { 'id': 'exact', 'name': 'partial', 'alias': 'start', 'description': 'word_start', 'relatedDummy.name': 'exact', 'relatedDummies': 'exact' } ] + arguments: [ { 'id': 'exact', 'name': 'partial', 'alias': 'start', 'description': 'word_start', 'relatedDummy.name': 'exact', 'relatedDummies': 'exact', 'dummy': 'ipartial' } ] tags: [ { name: 'api_platform.filter', id: 'my_dummy.search' } ] app.my_dummy_resource.order_filter: