Skip to content

Commit 97669be

Browse files
committed
Add escaping in the query builder
1 parent 75a1459 commit 97669be

15 files changed

+457
-12
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- Detection of syntax error from Redis response
1818
- Allow multiple level of fuzziness
19+
- Escape values in the query builder
1920

2021
## [1.1.0]
2122

src/Helper/EscapeHelper.php

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Copyright MacFJA
7+
*
8+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
9+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
10+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
11+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
14+
* Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
17+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
namespace MacFJA\RediSearch\Helper;
23+
24+
use function preg_quote;
25+
use function preg_replace;
26+
use function sprintf;
27+
use function str_split;
28+
29+
class EscapeHelper
30+
{
31+
private const NOT_ESCAPED_CHAR_REGEX_PATTERN = '/(?<!\\\\)((?:\\\\\\\\)*)(%s)/';
32+
33+
private const CHAR_AT_WORD_BEGIN_REGEX_PATTERN = '/(^|\s+)(%s)/';
34+
35+
public static function escapeFieldName(string $text): string
36+
{
37+
$text = self::escapeCommon($text);
38+
39+
return self::escapeCharAtWordBegin($text, '-');
40+
}
41+
42+
public static function escapeWord(string $text): string
43+
{
44+
return self::escapeCommon($text);
45+
}
46+
47+
public static function escapeExactMatch(string $text): string
48+
{
49+
return self::escapeCommon($text);
50+
}
51+
52+
public static function escapeFuzzy(string $text): string
53+
{
54+
return self::escapeCommon($text);
55+
}
56+
57+
public static function escapeNegation(string $text): string
58+
{
59+
$text = self::escapeCommon($text);
60+
$text = self::escapeCharAtWordBegin($text, '-');
61+
62+
for ($number = 0; $number < 10; $number++) {
63+
$text = self::escapeCharAtWordBegin($text, (string) $number);
64+
}
65+
66+
return $text;
67+
}
68+
69+
public static function escapeOptional(string $text): string
70+
{
71+
return self::escapeCommon($text);
72+
}
73+
74+
/**
75+
* @codeCoverageIgnore
76+
*/
77+
private static function escapeCharAtWordBegin(string $inText, string $char): string
78+
{
79+
return preg_replace(
80+
sprintf(self::CHAR_AT_WORD_BEGIN_REGEX_PATTERN, preg_quote($char, '/')),
81+
'$1\\\\$2',
82+
$inText
83+
) ?? $inText;
84+
}
85+
86+
/**
87+
* @codeCoverageIgnore
88+
*/
89+
private static function escapeChar(string $inText, string $char): string
90+
{
91+
return preg_replace(
92+
sprintf(self::NOT_ESCAPED_CHAR_REGEX_PATTERN, preg_quote($char, '/')),
93+
'$1\\\\$2',
94+
$inText
95+
) ?? $inText;
96+
}
97+
98+
/**
99+
* @codeCoverageIgnore
100+
*/
101+
private static function escapeCommon(string $inText): string
102+
{
103+
$anywhere = str_split(',.<>{}[]"\':;!@#$%^&*()-+=~|');
104+
105+
foreach ($anywhere as $char) {
106+
$inText = self::escapeChar($inText, $char);
107+
}
108+
109+
return self::unescapeNumber($inText);
110+
}
111+
112+
/**
113+
* @codeCoverageIgnore
114+
*/
115+
private static function unescapeNumber(string $inText): string
116+
{
117+
return preg_replace('/(?:\\s|^)\\\\(-\\d)/', '$1', $inText) ?? $inText;
118+
}
119+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Copyright MacFJA
7+
*
8+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
9+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
10+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
11+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
14+
* Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
17+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
namespace MacFJA\RediSearch\Search\Exception;
23+
24+
use OutOfRangeException;
25+
use function sprintf;
26+
use Throwable;
27+
28+
class OutOfRangeLevenshteinDistanceException extends OutOfRangeException
29+
{
30+
public function __construct(int $providedDistance, int $code = 0, ?Throwable $previous = null)
31+
{
32+
parent::__construct(sprintf('The Levenshtein distance should be between 1 and 3 (%d provided)', $providedDistance), $code, $previous);
33+
}
34+
}

src/Search/QueryBuilder/ExactMatch.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

24+
use MacFJA\RediSearch\Helper\EscapeHelper;
2425
use function sprintf;
2526

2627
class ExactMatch implements PartialQuery
@@ -35,7 +36,7 @@ public function __construct(string $match)
3536

3637
public function render(): string
3738
{
38-
return sprintf('"%s"', $this->match);
39+
return sprintf('"%s"', EscapeHelper::escapeExactMatch($this->match));
3940
}
4041

4142
public function includeSpace(): bool

src/Search/QueryBuilder/FuzzyWord.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

24+
use MacFJA\RediSearch\Helper\EscapeHelper;
2425
use MacFJA\RediSearch\Search\Exception\OutOfRangeLevenshteinDistanceException;
2526
use function sprintf;
2627
use function str_repeat;
@@ -44,7 +45,7 @@ public function __construct(string $word, int $levenshteinDistance = 1)
4445

4546
public function render(): string
4647
{
47-
return sprintf('%1$s%2$s%1$s', str_repeat('%%', $this->levenshteinDistance), $this->word);
48+
return sprintf('%1$s%2$s%1$s', str_repeat('%%', $this->levenshteinDistance), EscapeHelper::escapeFuzzy($this->word));
4849
}
4950

5051
public function includeSpace(): bool

src/Search/QueryBuilder/GeoFacet.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
use function in_array;
2525
use MacFJA\RediSearch\Helper\DataHelper;
26+
use MacFJA\RediSearch\Helper\EscapeHelper;
2627
use MacFJA\RediSearch\Search\Exception\UnknownUnitException;
2728
use MacFJA\RediSearch\Search\GeoFilter;
2829
use function sprintf;
@@ -59,7 +60,7 @@ public function __construct(string $fieldName, float $lon, float $lat, int $radi
5960

6061
public function render(): string
6162
{
62-
return sprintf('@%s:[%f %f %f %s]', $this->fieldName, $this->lon, $this->lat, $this->radius, $this->unit);
63+
return sprintf('@%s:[%f %f %f %s]', EscapeHelper::escapeFieldName($this->fieldName), $this->lon, $this->lat, $this->radius, $this->unit);
6364
}
6465

6566
public function includeSpace(): bool

src/Search/QueryBuilder/Negation.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

24+
use function ctype_digit;
2425
use function sprintf;
26+
use function substr;
2527

2628
class Negation implements PartialQuery
2729
{
@@ -39,8 +41,10 @@ public function __construct(PartialQuery $expression)
3941

4042
public function render(): string
4143
{
44+
$withParentheses = $this->expression->includeSpace() || ctype_digit(substr($this->expression->render(), 0, 1));
45+
4246
return sprintf(
43-
$this->expression->includeSpace() ? self::WITH_SPACE_PATTERN : self::WITHOUT_SPACE_PATTERN,
47+
true === $withParentheses ? self::WITH_SPACE_PATTERN : self::WITHOUT_SPACE_PATTERN,
4448
$this->expression->render()
4549
);
4650
}

src/Search/QueryBuilder/NumericFacet.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

2424
use function is_numeric;
25+
use MacFJA\RediSearch\Helper\EscapeHelper;
2526
use function sprintf;
2627

2728
class NumericFacet implements PartialQuery
@@ -87,7 +88,7 @@ public function render(): string
8788
$max = ($this->isMaxInclusive ? '' : '(').$this->max;
8889
}
8990

90-
return sprintf('@%s:[%s %s]', $this->fieldName, $min, $max);
91+
return sprintf('@%s:[%s %s]', EscapeHelper::escapeFieldName($this->fieldName), $min, $max);
9192
}
9293

9394
public function includeSpace(): bool

src/Search/QueryBuilder/OrGroup.php

+17
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

2424
use function array_map;
25+
use function count;
2526
use function implode;
2627
use function sprintf;
28+
use function substr;
2729
use function usort;
2830

2931
class OrGroup implements GroupPartialQuery
@@ -61,4 +63,19 @@ public function priority(): int
6163
{
6264
return self::PRIORITY_NORMAL;
6365
}
66+
67+
public static function renderNoParentheses(PartialQuery ...$queries): string
68+
{
69+
if (0 === count($queries)) {
70+
return '';
71+
}
72+
73+
$group = new self();
74+
foreach ($queries as $query) {
75+
$group->addExpression($query);
76+
}
77+
$rendered = $group->render();
78+
79+
return substr($rendered, 1, -1) ?: $rendered;
80+
}
6481
}
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Copyright MacFJA
7+
*
8+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
9+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
10+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
11+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
14+
* Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
17+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
namespace MacFJA\RediSearch\Search\QueryBuilder;
23+
24+
class RawExpression implements PartialQuery
25+
{
26+
/** @var string */
27+
private $content;
28+
29+
public function __construct(string $content)
30+
{
31+
$this->content = $content;
32+
}
33+
34+
public function render(): string
35+
{
36+
return $this->content;
37+
}
38+
39+
public function includeSpace(): bool
40+
{
41+
return true;
42+
}
43+
44+
public function priority(): int
45+
{
46+
return self::PRIORITY_NORMAL;
47+
}
48+
}

src/Search/QueryBuilder/TagFacet.php

+7-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

24-
use function implode;
24+
use function array_map;
25+
use MacFJA\RediSearch\Helper\EscapeHelper;
2526
use function sprintf;
2627

2728
class TagFacet implements PartialQuery
@@ -40,7 +41,11 @@ public function __construct(string $field, string ...$orValues)
4041

4142
public function render(): string
4243
{
43-
return sprintf('@%s:{%s}', $this->field, implode(' | ', $this->orValues));
44+
$terms = OrGroup::renderNoParentheses(...array_map(function (string $orValue) {
45+
return new Word($orValue);
46+
}, $this->orValues));
47+
48+
return sprintf('@%s:{%s}', EscapeHelper::escapeFieldName($this->field), $terms);
4449
}
4550

4651
public function includeSpace(): bool

src/Search/QueryBuilder/TextFacet.php

+9-4
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121

2222
namespace MacFJA\RediSearch\Search\QueryBuilder;
2323

24+
use function array_map;
2425
use function count;
25-
use function implode;
26+
use MacFJA\RediSearch\Helper\EscapeHelper;
2627
use function reset;
2728
use function sprintf;
2829
use function strpos;
@@ -48,15 +49,19 @@ public function __construct(string $field, string ...$orValues)
4849
public function render(): string
4950
{
5051
if (count($this->orValues) > 1) {
51-
return sprintf(self::WITH_SPACE_PATTERN, $this->field, implode(' | ', $this->orValues));
52+
$terms = OrGroup::renderNoParentheses(...array_map(function (string $orValue) {
53+
return new Word($orValue);
54+
}, $this->orValues));
55+
56+
return sprintf(self::WITH_SPACE_PATTERN, EscapeHelper::escapeFieldName($this->field), $terms);
5257
}
5358

5459
$value = reset($this->orValues) ?: '';
5560
if (false === strpos($value, ' ')) {
56-
return sprintf(self::WITHOUT_SPACE_PATTERN, $this->field, $value);
61+
return sprintf(self::WITHOUT_SPACE_PATTERN, EscapeHelper::escapeFieldName($this->field), EscapeHelper::escapeWord($value));
5762
}
5863

59-
return sprintf(self::WITH_SPACE_PATTERN, $this->field, $value);
64+
return sprintf(self::WITH_SPACE_PATTERN, EscapeHelper::escapeFieldName($this->field), EscapeHelper::escapeWord($value));
6065
}
6166

6267
public function includeSpace(): bool

0 commit comments

Comments
 (0)