Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit ea89e86

Browse files
GromNaNalcaeus
authored andcommitted
PHPORM-53 Fix and test like and regex operators (#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 d5f1bb9 commit ea89e86

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)

AltStyle によって変換されたページ (->オリジナル) /