Skip to content

Commit 2319d53

Browse files
authored
PHPORM-53 Fix and test like and regex operators (mongodb#17)
- Fix support for % and _ in like expression and escaped \% and \_ - Keep ilike and regexp operators as aliases for like and regex - Allow /, # and ~ as regex delimiters - Add functional tests on regexp and not regexp - Add support for not regex
1 parent 3a46876 commit 2319d53

File tree

4 files changed

+167
-55
lines changed

4 files changed

+167
-55
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file.
33

44
## [Unreleased]
55

6-
- Add classes to cast ObjectId and UUID instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus).
6+
- Add classes to cast `ObjectId` and `UUID` instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus).
77
- Add `Query\Builder::toMql()` to simplify comprehensive query tests [#6](https://github.com/GromNaN/laravel-mongodb-private/pull/6) by [@GromNaN](https://github.com/GromNaN).
88
- Fix `Query\Builder::whereNot` to use MongoDB [`$not`](https://www.mongodb.com/docs/manual/reference/operator/query/not/) operator [#13](https://github.com/GromNaN/laravel-mongodb-private/pull/13) by [@GromNaN](https://github.com/GromNaN).
99
- Fix `Query\Builder::whereBetween` to accept `Carbon\Period` object [#10](https://github.com/GromNaN/laravel-mongodb-private/pull/10) by [@GromNaN](https://github.com/GromNaN).
@@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.
1515
- Accept operators prefixed by `$` in `Query\Builder::orWhere` [#20](https://github.com/GromNaN/laravel-mongodb-private/pull/20) by [@GromNaN](https://github.com/GromNaN).
1616
- Remove `Query\Builder::whereAll($column, $values)`. Use `Query\Builder::where($column, 'all', $values)` instead. [#16](https://github.com/GromNaN/laravel-mongodb-private/pull/16) by [@GromNaN](https://github.com/GromNaN).
1717
- Fix validation of unique values when the validated value is found as part of an existing value. [#21](https://github.com/GromNaN/laravel-mongodb-private/pull/21) by [@GromNaN](https://github.com/GromNaN).
18+
- Support `%` and `_` in `like` expression [#17](https://github.com/GromNaN/laravel-mongodb-private/pull/17) by [@GromNaN](https://github.com/GromNaN).
1819

1920
## [3.9.2] - 2022-09-01
2021

src/Query/Builder.php

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
*/
2525
class Builder extends BaseBuilder
2626
{
27+
private const REGEX_DELIMITERS = ['/', '#', '~'];
28+
2729
/**
2830
* The database collection.
2931
*
@@ -91,6 +93,7 @@ class Builder extends BaseBuilder
9193
'all',
9294
'size',
9395
'regex',
96+
'not regex',
9497
'text',
9598
'slice',
9699
'elemmatch',
@@ -113,13 +116,22 @@ class Builder extends BaseBuilder
113116
* @var array
114117
*/
115118
protected $conversion = [
116-
'=' => '=',
117-
'!=' => '$ne',
118-
'<>' => '$ne',
119-
'<' => '$lt',
120-
'<=' => '$lte',
121-
'>' => '$gt',
122-
'>=' => '$gte',
119+
'!=' => 'ne',
120+
'<>' => 'ne',
121+
'<' => 'lt',
122+
'<=' => 'lte',
123+
'>' => 'gt',
124+
'>=' => 'gte',
125+
'regexp' => 'regex',
126+
'not regexp' => 'not regex',
127+
'ilike' => 'like',
128+
'elemmatch' => 'elemMatch',
129+
'geointersects' => 'geoIntersects',
130+
'geowithin' => 'geoWithin',
131+
'nearsphere' => 'nearSphere',
132+
'maxdistance' => 'maxDistance',
133+
'centersphere' => 'centerSphere',
134+
'uniquedocs' => 'uniqueDocs',
123135
];
124136

125137
/**
@@ -932,20 +944,9 @@ protected function compileWheres(): array
932944
if (isset($where['operator'])) {
933945
$where['operator'] = strtolower($where['operator']);
934946

935-
// Operator conversions
936-
$convert = [
937-
'regexp' => 'regex',
938-
'elemmatch' => 'elemMatch',
939-
'geointersects' => 'geoIntersects',
940-
'geowithin' => 'geoWithin',
941-
'nearsphere' => 'nearSphere',
942-
'maxdistance' => 'maxDistance',
943-
'centersphere' => 'centerSphere',
944-
'uniquedocs' => 'uniqueDocs',
945-
];
946-
947-
if (array_key_exists($where['operator'], $convert)) {
948-
$where['operator'] = $convert[$where['operator']];
947+
// Convert aliased operators
948+
if (isset($this->conversion[$where['operator']])) {
949+
$where['operator'] = $this->conversion[$where['operator']];
949950
}
950951
}
951952

@@ -1036,45 +1037,55 @@ protected function compileWhereBasic(array $where): array
10361037

10371038
// Replace like or not like with a Regex instance.
10381039
if (in_array($operator, ['like', 'not like'])) {
1039-
if ($operator === 'not like') {
1040-
$operator = 'not';
1041-
} else {
1042-
$operator = '=';
1043-
}
1044-
1045-
// Convert to regular expression.
1046-
$regex = preg_replace('#(^|[^\\\])%#', '$1.*', preg_quote($value));
1047-
1048-
// Convert like to regular expression.
1049-
if (! Str::startsWith($value, '%')) {
1050-
$regex = '^'.$regex;
1051-
}
1052-
if (! Str::endsWith($value, '%')) {
1053-
$regex .= '$';
1054-
}
1040+
$regex = preg_replace(
1041+
[
1042+
// Unescaped % are converted to .*
1043+
// Group consecutive %
1044+
'#(^|[^\\\])%+#',
1045+
// Unescaped _ are converted to .
1046+
// Use positive lookahead to replace consecutive _
1047+
'#(?<=^|[^\\\\])_#',
1048+
// Escaped \% or \_ are unescaped
1049+
'#\\\\\\\(%|_)#',
1050+
],
1051+
['$1.*', '$1.', '$1'],
1052+
// Escape any regex reserved characters, so they are matched
1053+
// All backslashes are converted to \\, which are needed in matching regexes.
1054+
preg_quote($value),
1055+
);
1056+
$value = new Regex('^'.$regex.'$', 'i');
1057+
1058+
// For inverse like operations, we can just use the $not operator with the Regex
1059+
$operator = $operator === 'like' ? '=' : 'not';
1060+
}
10551061

1056-
$value = new Regex($regex, 'i');
1057-
} // Manipulate regexp operations.
1058-
elseif (in_array($operator, ['regexp', 'not regexp', 'regex', 'not regex'])) {
1062+
// Manipulate regex operations.
1063+
elseif (in_array($operator, ['regex', 'not regex'])) {
10591064
// Automatically convert regular expression strings to Regex objects.
1060-
if (! $value instanceof Regex) {
1061-
$e = explode('/', $value);
1062-
$flag = end($e);
1063-
$regstr = substr($value, 1, -(strlen($flag) + 1));
1064-
$value = new Regex($regstr, $flag);
1065+
if (is_string($value)) {
1066+
// Detect the delimiter and validate the preg pattern
1067+
$delimiter = substr($value, 0, 1);
1068+
if (! in_array($delimiter, self::REGEX_DELIMITERS)) {
1069+
throw new \LogicException(sprintf('Missing expected starting delimiter in regular expression "%s", supported delimiters are: %s', $value, implode(' ', self::REGEX_DELIMITERS)));
1070+
}
1071+
$e = explode($delimiter, $value);
1072+
// We don't try to detect if the last delimiter is escaped. This would be an invalid regex.
1073+
if (count($e) < 3) {
1074+
throw new \LogicException(sprintf('Missing expected ending delimiter "%s" in regular expression "%s"', $delimiter, $value));
1075+
}
1076+
// Flags are after the last delimiter
1077+
$flags = end($e);
1078+
// Extract the regex string between the delimiters
1079+
$regstr = substr($value, 1, -1 - strlen($flags));
1080+
$value = new Regex($regstr, $flags);
10651081
}
10661082

1067-
// For inverse regexp operations, we can just use the $not operator
1068-
// and pass it a Regex instence.
1069-
if (Str::startsWith($operator, 'not')) {
1070-
$operator = 'not';
1071-
}
1083+
// For inverse regex operations, we can just use the $not operator with the Regex
1084+
$operator = $operator === 'regex' ? '=' : 'not';
10721085
}
10731086

10741087
if (! isset($operator) || $operator == '=') {
10751088
$query = [$column => $value];
1076-
} elseif (array_key_exists($operator, $this->conversion)) {
1077-
$query = [$column => [$this->conversion[$operator] => $value]];
10781089
} else {
10791090
$query = [$column => ['$'.$operator => $value]];
10801091
}
@@ -1133,7 +1144,7 @@ protected function compileWhereNull(array $where): array
11331144
*/
11341145
protected function compileWhereNotNull(array $where): array
11351146
{
1136-
$where['operator'] = '!=';
1147+
$where['operator'] = 'ne';
11371148
$where['value'] = null;
11381149

11391150
return $this->compileWhereBasic($where);

tests/Query/BuilderTest.php

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Jenssegers\Mongodb\Query\Builder;
1212
use Jenssegers\Mongodb\Query\Processor;
1313
use Mockery as m;
14+
use MongoDB\BSON\Regex;
1415
use MongoDB\BSON\UTCDateTime;
1516
use PHPUnit\Framework\TestCase;
1617

@@ -578,6 +579,72 @@ function (Builder $builder) {
578579
->orWhereNotBetween('id', collect([3, 4])),
579580
];
580581

582+
yield 'where like' => [
583+
['find' => [['name' => new Regex('^acme$', 'i')], []]],
584+
fn (Builder $builder) => $builder->where('name', 'like', 'acme'),
585+
];
586+
587+
yield 'where ilike' => [ // Alias for like
588+
['find' => [['name' => new Regex('^acme$', 'i')], []]],
589+
fn (Builder $builder) => $builder->where('name', 'ilike', 'acme'),
590+
];
591+
592+
yield 'where like escape' => [
593+
['find' => [['name' => new Regex('^\^ac\.me\$$', 'i')], []]],
594+
fn (Builder $builder) => $builder->where('name', 'like', '^ac.me$'),
595+
];
596+
597+
yield 'where like unescaped \% \_' => [
598+
['find' => [['name' => new Regex('^a%cm_e$', 'i')], []]],
599+
fn (Builder $builder) => $builder->where('name', 'like', 'a\%cm\_e'),
600+
];
601+
602+
yield 'where like %' => [
603+
['find' => [['name' => new Regex('^.*ac.*me.*$', 'i')], []]],
604+
fn (Builder $builder) => $builder->where('name', 'like', '%ac%%me%'),
605+
];
606+
607+
yield 'where like _' => [
608+
['find' => [['name' => new Regex('^.ac..me.$', 'i')], []]],
609+
fn (Builder $builder) => $builder->where('name', 'like', '_ac__me_'),
610+
];
611+
612+
$regex = new Regex('^acme$', 'si');
613+
yield 'where BSON\Regex' => [
614+
['find' => [['name' => $regex], []]],
615+
fn (Builder $builder) => $builder->where('name', 'regex', $regex),
616+
];
617+
618+
yield 'where regexp' => [ // Alias for regex
619+
['find' => [['name' => $regex], []]],
620+
fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'),
621+
];
622+
623+
yield 'where regex delimiter /' => [
624+
['find' => [['name' => $regex], []]],
625+
fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'),
626+
];
627+
628+
yield 'where regex delimiter #' => [
629+
['find' => [['name' => $regex], []]],
630+
fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$#si'),
631+
];
632+
633+
yield 'where regex delimiter ~' => [
634+
['find' => [['name' => $regex], []]],
635+
fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$#si'),
636+
];
637+
638+
yield 'where regex with escaped characters' => [
639+
['find' => [['name' => new Regex('a\.c\/m\+e', '')], []]],
640+
fn (Builder $builder) => $builder->where('name', 'regex', '/a\.c\/m\+e/'),
641+
];
642+
643+
yield 'where not regex' => [
644+
['find' => [['name' => ['$not' => $regex]], []]],
645+
fn (Builder $builder) => $builder->where('name', 'not regex', '/^acme$/si'),
646+
];
647+
581648
/** @see DatabaseQueryBuilderTest::testBasicSelectDistinct */
582649
yield 'distinct' => [
583650
['distinct' => ['foo', [], []]],
@@ -647,7 +714,7 @@ public function testException($class, $message, \Closure $build): void
647714

648715
$this->expectException($class);
649716
$this->expectExceptionMessage($message);
650-
$build($builder);
717+
$build($builder)->toMQL();
651718
}
652719

653720
public static function provideExceptions(): iterable
@@ -694,6 +761,18 @@ public static function provideExceptions(): iterable
694761
'Too few arguments to function Jenssegers\Mongodb\Query\Builder::where("foo"), 1 passed and at least 2 expected when the 1st is a string',
695762
fn (Builder $builder) => $builder->where('foo'),
696763
];
764+
765+
yield 'where regex not starting with /' => [
766+
\LogicException::class,
767+
'Missing expected starting delimiter in regular expression "^ac/me$", supported delimiters are: / # ~',
768+
fn (Builder $builder) => $builder->where('name', 'regex', '^ac/me$'),
769+
];
770+
771+
yield 'where regex not ending with /' => [
772+
\LogicException::class,
773+
'Missing expected ending delimiter "/" in regular expression "/foo#bar"',
774+
fn (Builder $builder) => $builder->where('name', 'regex', '/foo#bar'),
775+
];
697776
}
698777

699778
/** @dataProvider getEloquentMethodsNotSupported */

tests/QueryTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ public function testAndWhere(): void
7070
$this->assertCount(2, $users);
7171
}
7272

73+
public function testRegexp(): void
74+
{
75+
User::create(['name' => 'Simple', 'company' => 'acme']);
76+
User::create(['name' => 'With slash', 'company' => 'oth/er']);
77+
78+
$users = User::where('company', 'regexp', '/^acme$/')->get();
79+
$this->assertCount(1, $users);
80+
81+
$users = User::where('company', 'regexp', '/^ACME$/i')->get();
82+
$this->assertCount(1, $users);
83+
84+
$users = User::where('company', 'regexp', '/^oth\/er$/')->get();
85+
$this->assertCount(1, $users);
86+
}
87+
7388
public function testLike(): void
7489
{
7590
$users = User::where('name', 'like', '%doe')->get();
@@ -83,6 +98,12 @@ public function testLike(): void
8398

8499
$users = User::where('name', 'like', 't%')->get();
85100
$this->assertCount(1, $users);
101+
102+
$users = User::where('name', 'like', 'j___ doe')->get();
103+
$this->assertCount(2, $users);
104+
105+
$users = User::where('name', 'like', '_oh_ _o_')->get();
106+
$this->assertCount(1, $users);
86107
}
87108

88109
public function testNotLike(): void

0 commit comments

Comments
 (0)