Skip to content

Commit 3f6ca0e

Browse files
committed
Merge pull request #548 from soyuka/caseinsensitive
Case insensitive search filter
2 parents 7e278be + c638d3d commit 3f6ca0e

File tree

8 files changed

+199
-42
lines changed

8 files changed

+199
-42
lines changed

features/bootstrap/FeatureContext.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ public function thereIsDummyObjects($nb)
9696
$dummy = new Dummy();
9797
$dummy->setName('Dummy #'.$i);
9898
$dummy->setAlias('Alias #'.($nb - $i));
99+
$dummy->setDummy('SomeDummyTest'.$i);
99100
$dummy->setDescription($descriptions[($i - 1) % 2]);
100101

101102
$this->manager->persist($dummy);

features/crud.feature

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Feature: Create-Retrieve-Update-Delete
116116
"hydra:totalItems": 1,
117117
"hydra:search": {
118118
"@type": "hydra:IriTemplate",
119-
"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}",
119+
"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}",
120120
"hydra:variableRepresentation": "BasicRepresentation",
121121
"hydra:mapping": [
122122
{
@@ -173,6 +173,12 @@ Feature: Create-Retrieve-Update-Delete
173173
"property": "relatedDummies",
174174
"required": false
175175
},
176+
{
177+
"@type": "IriTemplateMapping",
178+
"variable": "dummy",
179+
"property": "dummy",
180+
"required": false
181+
},
176182
{
177183
"@type": "IriTemplateMapping",
178184
"variable": "order[id]",

features/doctrine/date-filter.feature

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ Feature: Date filter on collections
269269
},
270270
"hydra:search": {
271271
"@type": "hydra:IriTemplate",
272-
"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}",
272+
"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}",
273273
"hydra:variableRepresentation": "BasicRepresentation",
274274
"hydra:mapping": [
275275
{
@@ -326,6 +326,12 @@ Feature: Date filter on collections
326326
"property": "relatedDummies",
327327
"required": false
328328
},
329+
{
330+
"@type": "IriTemplateMapping",
331+
"variable": "dummy",
332+
"property": "dummy",
333+
"required": false
334+
},
329335
{
330336
"@type": "IriTemplateMapping",
331337
"variable": "order[id]",
@@ -414,4 +420,4 @@ Feature: Date filter on collections
414420
}
415421
}
416422
"""
417-
423+

features/doctrine/search-filter.feature

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,35 @@ Feature: Search filter on collections
4242
}
4343
"""
4444

45+
Scenario: Search collection by name (partial case insensitive)
46+
Given there is "30" dummy objects
47+
When I send a "GET" request to "/dummies?dummy=somedummytest1"
48+
Then the response status code should be 200
49+
And the response should be in JSON
50+
And the header "Content-Type" should be equal to "application/ld+json"
51+
And the JSON should be valid according to this schema:
52+
"""
53+
{
54+
"type": "object",
55+
"properties": {
56+
"@context": {"pattern": "^/contexts/Dummy$"},
57+
"@id": {"pattern": "^/dummies$"},
58+
"@type": {"pattern": "^hydra:Collection$"},
59+
"hydra:member": {
60+
"type": "array",
61+
"items": {
62+
"type": "object",
63+
"properties": {
64+
"dummy": {
65+
"pattern": "^SomeDummyTest\\d{1,2}$"
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
"""
73+
4574
Scenario: Search collection by alias (start)
4675
When I send a "GET" request to "/dummies?alias=Ali"
4776
Then the response status code should be 200

src/Bridge/Doctrine/Orm/Filter/SearchFilter.php

Lines changed: 68 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class SearchFilter extends AbstractFilter
5555
private $requestStack;
5656
private $iriConverter;
5757
private $propertyAccessor;
58+
private $caseSensitive;
5859

5960
/**
6061
* @param ManagerRegistry $managerRegistry
@@ -112,57 +113,66 @@ public function apply(QueryBuilder $queryBuilder, string $resourceClass, string
112113
$metadata = $this->getClassMetadata($resourceClass);
113114
}
114115

115-
if ($metadata->hasField($field)) {
116-
$values = $this->normalizeValues((array) $value);
116+
$values = $this->normalizeValues((array) $value);
117117

118-
if (empty($values)) {
119-
continue;
120-
}
118+
if (empty($values)) {
119+
continue;
120+
}
121121

122+
$this->caseSensitive = true;
123+
124+
if ($metadata->hasField($field)) {
122125
if ('id' === $field) {
123126
$values = array_map([$this, 'getFilterValueFromUrl'], $values);
124127
}
125128

126129
$strategy = $this->properties[$property] ?? self::STRATEGY_EXACT;
127130

131+
// prefixing the strategy with i makes it case insensitive
132+
if (strpos($strategy, 'i') === 0) {
133+
$strategy = substr($strategy, 1);
134+
$this->caseSensitive = false;
135+
}
136+
128137
if (1 === count($values)) {
129138
$this->addWhereByStrategy($strategy, $queryBuilder, $alias, $field, $values[0]);
130-
} else {
131-
if (self::STRATEGY_EXACT !== $strategy) {
132-
continue;
133-
}
134-
135-
$valueParameter = QueryNameGenerator::generateParameterName($field);
136-
137-
$queryBuilder
138-
->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter))
139-
->setParameter($valueParameter, $values);
139+
continue;
140140
}
141-
} elseif ($metadata->hasAssociation($field)) {
142-
$values = $this->normalizeValues((array) $value);
143141

144-
if (empty($values)) {
142+
// there are many values, as we translate those to an IN clause, strategy must be exact
143+
if (self::STRATEGY_EXACT !== $strategy) {
145144
continue;
146145
}
147146

148-
$values = array_map([$this, 'getFilterValueFromUrl'], $values);
149-
150-
$association = $field;
151-
$associationAlias = QueryNameGenerator::generateJoinAlias($association);
152-
$valueParameter = QueryNameGenerator::generateParameterName($association);
147+
$valueParameter = QueryNameGenerator::generateParameterName($field);
153148

154149
$queryBuilder
155-
->join(sprintf('%s.%s', $alias, $association), $associationAlias);
150+
->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter))
151+
->setParameter($valueParameter, $values);
152+
}
156153

157-
if (1 === count($values)) {
158-
$queryBuilder
159-
->andWhere(sprintf('%s.id = :%s', $associationAlias, $valueParameter))
160-
->setParameter($valueParameter, $values[0]);
161-
} else {
162-
$queryBuilder
163-
->andWhere(sprintf('%s.id IN (:%s)', $associationAlias, $valueParameter))
164-
->setParameter($valueParameter, $values);
165-
}
154+
// metadata doesn't have the field, nor an association on the field
155+
if (!$metadata->hasAssociation($field)) {
156+
continue;
157+
}
158+
159+
$values = array_map([$this, 'getFilterValueFromUrl'], $values);
160+
161+
$association = $field;
162+
$associationAlias = QueryNameGenerator::generateJoinAlias($association);
163+
$valueParameter = QueryNameGenerator::generateParameterName($association);
164+
165+
$queryBuilder
166+
->join(sprintf('%s.%s', $alias, $association), $associationAlias);
167+
168+
if (1 === count($values)) {
169+
$queryBuilder
170+
->andWhere(sprintf('%s.id = :%s', $associationAlias, $valueParameter))
171+
->setParameter($valueParameter, $values[0]);
172+
} else {
173+
$queryBuilder
174+
->andWhere(sprintf('%s.id IN (:%s)', $associationAlias, $valueParameter))
175+
->setParameter($valueParameter, $values);
166176
}
167177
}
168178
}
@@ -186,31 +196,34 @@ private function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder
186196
case null:
187197
case self::STRATEGY_EXACT:
188198
$queryBuilder
189-
->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter))
199+
->andWhere(sprintf($this->caseWrap('%s.%s').' = '.$this->caseWrap(':%s'), $alias, $field, $valueParameter))
190200
->setParameter($valueParameter, $value);
191201
break;
192202

193203
case self::STRATEGY_PARTIAL:
194204
$queryBuilder
195-
->andWhere(sprintf('%s.%s LIKE :%s', $alias, $field, $valueParameter))
205+
->andWhere(sprintf($this->caseWrap('%s.%s').' LIKE '.$this->caseWrap(':%s'), $alias, $field, $valueParameter))
196206
->setParameter($valueParameter, sprintf('%%%s%%', $value));
197207
break;
198208

199209
case self::STRATEGY_START:
200210
$queryBuilder
201-
->andWhere(sprintf('%s.%s LIKE :%s', $alias, $field, $valueParameter))
211+
->andWhere(sprintf($this->caseWrap('%s.%s').' LIKE '.$this->caseWrap(':%s'), $alias, $field, $valueParameter))
202212
->setParameter($valueParameter, sprintf('%s%%', $value));
203213
break;
204214

205215
case self::STRATEGY_END:
206216
$queryBuilder
207-
->andWhere(sprintf('%s.%s LIKE :%s', $alias, $field, $valueParameter))
217+
->andWhere(sprintf($this->caseWrap('%s.%s').' LIKE '.$this->caseWrap(':%s'), $alias, $field, $valueParameter))
208218
->setParameter($valueParameter, sprintf('%%%s', $value));
209219
break;
210220

211221
case self::STRATEGY_WORD_START:
222+
$andWhere = $this->caseWrap('%1$s.%2$s').' LIKE '.$this->caseWrap(':%3$s_1');
223+
$andWhere .= ' OR '.$this->caseWrap('%1$s.%2$s').' LIKE '.$this->caseWrap(':%3$s_2');
224+
212225
$queryBuilder
213-
->andWhere(sprintf('%1$s.%2$s LIKE :%3$s_1 OR %1$s.%2$s LIKE :%3$s_2', $alias, $field, $valueParameter))
226+
->andWhere(sprintf($andWhere, $alias, $field, $valueParameter))
214227
->setParameter(sprintf('%s_1', $valueParameter), sprintf('%s%%', $value))
215228
->setParameter(sprintf('%s_2', $valueParameter), sprintf('%% %s%%', $value));
216229
break;
@@ -220,6 +233,23 @@ private function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder
220233
}
221234
}
222235

236+
/**
237+
* Wraps a string with a doctrine expression according to the current case status
238+
* Example: $this->caseWrap('o.id') becomes LOWER(o.id) when $this->caseSensitive is true.
239+
*
240+
* @param string $string
241+
*
242+
* @return string
243+
*/
244+
private function caseWrap(string $string): string
245+
{
246+
if (false !== $this->caseSensitive) {
247+
return $string;
248+
}
249+
250+
return sprintf('LOWER(%s)', $string);
251+
}
252+
223253
/**
224254
* {@inheritdoc}
225255
*/

tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,21 @@ public function filterProvider()
403403
],
404404
],
405405
],
406+
// Exact case insensitive
407+
[
408+
[
409+
'properties' => ['id' => null, 'name' => 'iexact'],
410+
],
411+
[
412+
'name' => 'exact',
413+
],
414+
[
415+
'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) = LOWER(:name_123456abcdefg)', Dummy::class),
416+
'parameters' => [
417+
'name_123456abcdefg' => 'exact',
418+
],
419+
],
420+
],
406421
// invalid values
407422
[
408423
[
@@ -447,6 +462,21 @@ public function filterProvider()
447462
],
448463
],
449464
],
465+
// partial case insensitive
466+
[
467+
[
468+
'properties' => ['id' => null, 'name' => 'ipartial'],
469+
],
470+
[
471+
'name' => 'partial',
472+
],
473+
[
474+
'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg)', Dummy::class),
475+
'parameters' => [
476+
'name_123456abcdefg' => '%partial%',
477+
],
478+
],
479+
],
450480
[
451481
[
452482
'properties' => ['id' => null, 'name' => 'start'],
@@ -461,6 +491,21 @@ public function filterProvider()
461491
],
462492
],
463493
],
494+
// start case insensitive
495+
[
496+
[
497+
'properties' => ['id' => null, 'name' => 'istart'],
498+
],
499+
[
500+
'name' => 'partial',
501+
],
502+
[
503+
'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg)', Dummy::class),
504+
'parameters' => [
505+
'name_123456abcdefg' => 'partial%',
506+
],
507+
],
508+
],
464509
[
465510
[
466511
'properties' => ['id' => null, 'name' => 'end'],
@@ -475,6 +520,21 @@ public function filterProvider()
475520
],
476521
],
477522
],
523+
// end case insensitive
524+
[
525+
[
526+
'properties' => ['id' => null, 'name' => 'iend'],
527+
],
528+
[
529+
'name' => 'partial',
530+
],
531+
[
532+
'dql' => sprintf('SELECT o FROM %s o WHERE LOWER(o.name) like LOWER(:name_123456abcdefg)', Dummy::class),
533+
'parameters' => [
534+
'name_123456abcdefg' => '%partial',
535+
],
536+
],
537+
],
478538
[
479539
[
480540
'properties' => ['id' => null, 'name' => 'word_start'],
@@ -490,6 +550,21 @@ public function filterProvider()
490550
],
491551
],
492552
],
553+
[
554+
[
555+
'properties' => ['id' => null, 'name' => 'iword_start'],
556+
],
557+
[
558+
'name' => 'partial',
559+
],
560+
[
561+
'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),
562+
'parameters' => [
563+
'name_123456abcdefg_1' => 'partial%',
564+
'name_123456abcdefg_2' => '% partial%',
565+
],
566+
],
567+
],
493568
// relations
494569
[
495570
[

tests/Fixtures/TestBundle/Entity/Dummy.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,14 @@ public function setDummyBoolean($dummyBoolean)
234234
{
235235
$this->dummyBoolean = $dummyBoolean;
236236
}
237+
238+
public function setDummy($dummy = null)
239+
{
240+
$this->dummy = $dummy;
241+
}
242+
243+
public function getDummy()
244+
{
245+
return $this->dummy;
246+
}
237247
}

0 commit comments

Comments
 (0)