Skip to content
This repository was archived by the owner on Aug 22, 2023. It is now read-only.

Commit 40d56c6

Browse files
committed
PHPORM-53 Improve like and regex operators
1 parent 2824dc4 commit 40d56c6

File tree

3 files changed

+137
-36
lines changed

3 files changed

+137
-36
lines changed

CHANGELOG.md

Lines changed: 3 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).
@@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file.
1414
- Remove call to deprecated `Collection::count` for `countDocuments` [#18](https://github.com/GromNaN/laravel-mongodb-private/pull/18) by [@GromNaN](https://github.com/GromNaN).
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).
17+
- Fix support for `%` and `_` in `like` expression [#17](https://github.com/GromNaN/laravel-mongodb-private/pull/17) by [@GromNaN](https://github.com/GromNaN).
18+
- Remove `ilike` and `regexp` operators, use `like` and `regex` instead [#17](https://github.com/GromNaN/laravel-mongodb-private/pull/17) by [@GromNaN](https://github.com/GromNaN).
1719

1820
## [3.9.2] - 2022-09-01
1921

src/Query/Builder.php

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,20 @@ class Builder extends BaseBuilder
7575
'like',
7676
'not like',
7777
'between',
78-
'ilike',
7978
'&',
8079
'|',
8180
'^',
8281
'<<',
8382
'>>',
8483
'rlike',
85-
'regexp',
86-
'not regexp',
8784
'exists',
8885
'type',
8986
'mod',
9087
'where',
9188
'all',
9289
'size',
9390
'regex',
91+
'not regex',
9492
'text',
9593
'slice',
9694
'elemmatch',
@@ -122,6 +120,13 @@ class Builder extends BaseBuilder
122120
'>=' => '$gte',
123121
];
124122

123+
private $replacementOperators = [
124+
'regexp' => 'regex',
125+
'not regexp' => 'not regex',
126+
'ilike' => 'like',
127+
'not ilike' => 'not like',
128+
];
129+
125130
/**
126131
* @inheritdoc
127132
*/
@@ -934,7 +939,6 @@ protected function compileWheres(): array
934939

935940
// Operator conversions
936941
$convert = [
937-
'regexp' => 'regex',
938942
'elemmatch' => 'elemMatch',
939943
'geointersects' => 'geoIntersects',
940944
'geowithin' => 'geoWithin',
@@ -1034,41 +1038,39 @@ protected function compileWhereBasic(array $where): array
10341038
{
10351039
extract($where);
10361040

1037-
// Replace like or not like with a Regex instance.
1041+
// Replace like with a Regex instance.
10381042
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-
}
1043+
// Convert % and _ to regex, and unescape \% and \_
1044+
$regex = preg_replace(
1045+
['#(^|[^\\\])%#', '#(^|[^\\\])_#', '#\\\\\\\(%|_)#'],
1046+
['$1.*', '$1.', '$1'],
1047+
preg_quote($value),
1048+
);
1049+
$value = new Regex('^'.$regex.'$', 'i');
1050+
1051+
// For inverse like operations, we can just use the $not operator and pass it a Regex instance.
1052+
$operator = $operator === 'like' ? '=' : 'not';
1053+
}
10551054

1056-
$value = new Regex($regex, 'i');
1057-
} // Manipulate regexp operations.
1058-
elseif (in_array($operator, ['regexp', 'not regexp', 'regex', 'not regex'])) {
1055+
// Manipulate regex operations.
1056+
if (in_array($operator, ['regex', 'not regex'])) {
10591057
// 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);
1058+
if (is_string($value)) {
1059+
$delimiter = substr($value, 0, 1);
1060+
if (! in_array($delimiter, ['/', '#', '~'])) {
1061+
throw new \LogicException(sprintf('Regular expressions must be surrounded by delimiter "/". Got "%s"', $value));
1062+
}
1063+
$e = explode($delimiter, $value);
1064+
if (count($e) < 3) {
1065+
throw new \LogicException(sprintf('Regular expressions must be surrounded by delimiter "%s". Got "%s"', $delimiter, $value));
1066+
}
1067+
$flags = end($e);
1068+
$regstr = substr($value, 1, -1 - strlen($flags));
1069+
$value = new Regex($regstr, $flags);
10651070
}
10661071

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-
}
1072+
// For inverse regex operations, we can just use the $not operator and pass it a Regex instance.
1073+
$operator = $operator === 'regex' ? '=' : 'not';
10721074
}
10731075

10741076
if (! isset($operator) || $operator == '=') {
@@ -1251,6 +1253,15 @@ protected function compileWhereRaw(array $where): mixed
12511253
return $where['sql'];
12521254
}
12531255

1256+
protected function invalidOperator($operator)
1257+
{
1258+
if (is_string($operator) && isset($this->replacementOperators[$operator = strtolower($operator)])) {
1259+
throw new \InvalidArgumentException(sprintf('Operator "%s" is not supported. Use "%s" instead.', $operator, $this->replacementOperators[$operator]));
1260+
}
1261+
1262+
return parent::invalidOperator($operator);
1263+
}
1264+
12541265
/**
12551266
* Set custom options for the query.
12561267
*

tests/Query/BuilderTest.php

Lines changed: 89 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,62 @@ 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 like escape' => [
588+
['find' => [['name' => new Regex('^\^ac\.me\$$', 'i')], []]],
589+
fn (Builder $builder) => $builder->where('name', 'like', '^ac.me$'),
590+
];
591+
592+
yield 'where like unescaped \% \_' => [
593+
['find' => [['name' => new Regex('^a%cm_e$', 'i')], []]],
594+
fn (Builder $builder) => $builder->where('name', 'like', 'a\%cm\_e'),
595+
];
596+
597+
yield 'where like %' => [
598+
['find' => [['name' => new Regex('^.*ac.*me.*$', 'i')], []]],
599+
fn (Builder $builder) => $builder->where('name', 'like', '%ac%me%'),
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+
$regex = new Regex('^acme$', 'si');
608+
yield 'where BSON\Regex' => [
609+
['find' => [['name' => $regex], []]],
610+
fn (Builder $builder) => $builder->where('name', 'regex', $regex),
611+
];
612+
613+
yield 'where regex delimiter /' => [
614+
['find' => [['name' => $regex], []]],
615+
fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'),
616+
];
617+
618+
yield 'where regex delimiter #' => [
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 escaped delimiter' => [
629+
['find' => [['name' => new Regex('ac\/me', '')], []]],
630+
fn (Builder $builder) => $builder->where('name', 'regex', '/ac\/me/'),
631+
];
632+
633+
yield 'where not regex' => [
634+
['find' => [['name' => ['$not' => $regex]], []]],
635+
fn (Builder $builder) => $builder->where('name', 'not regex', '/^acme$/si'),
636+
];
637+
581638
/** @see DatabaseQueryBuilderTest::testBasicSelectDistinct */
582639
yield 'distinct' => [
583640
['distinct' => ['foo', [], []]],
@@ -647,7 +704,7 @@ public function testException($class, $message, \Closure $build): void
647704

648705
$this->expectException($class);
649706
$this->expectExceptionMessage($message);
650-
$build($builder);
707+
$build($builder)->toMQL();
651708
}
652709

653710
public static function provideExceptions(): iterable
@@ -689,11 +746,42 @@ public static function provideExceptions(): iterable
689746
fn (Builder $builder) => $builder->whereBetween('id', ['min' => 1, 'max' => 2]),
690747
];
691748

749+
692750
yield 'find with single string argument' => [
693751
\ArgumentCountError::class,
694752
'Too few arguments to function Jenssegers\Mongodb\Query\Builder::where("foo"), 1 passed and at least 2 expected when the 1st is a string',
695753
fn (Builder $builder) => $builder->where('foo'),
696754
];
755+
756+
yield 'where regex not starting with /' => [
757+
\LogicException::class,
758+
'Regular expressions must be surrounded by delimiter "/". Got "^ac/me$"',
759+
fn (Builder $builder) => $builder->where('name', 'regex', '^ac/me$'),
760+
];
761+
762+
yield 'where regex not ending with /' => [
763+
\LogicException::class,
764+
'Regular expressions must be surrounded by delimiter "/". Got "/^acme$"',
765+
fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$'),
766+
];
767+
768+
yield 'where regex not ending with #' => [
769+
\LogicException::class,
770+
'Regular expressions must be surrounded by delimiter "#". Got "#^acme$"',
771+
fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$'),
772+
];
773+
774+
yield 'where regexp not supported' => [
775+
\LogicException::class,
776+
'Operator "regexp" is not supported. Use "regex" instead.',
777+
fn (Builder $builder) => $builder->where('name', 'regexp', '/^acme$/'),
778+
];
779+
780+
yield 'where ilike not supported' => [
781+
\LogicException::class,
782+
'Operator "ilike" is not supported. Use "like" instead.',
783+
fn (Builder $builder) => $builder->where('name', 'ilike', 'acme'),
784+
];
697785
}
698786

699787
/** @dataProvider getEloquentMethodsNotSupported */

0 commit comments

Comments
 (0)