diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad3e0e..30413ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Add classes to cast ObjectId and UUID instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus). +- Add classes to cast `ObjectId` and `UUID` instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus). - 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). - 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). - 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. - Accept operators prefixed by `$` in `Query\Builder::orWhere` [#20](https://github.com/GromNaN/laravel-mongodb-private/pull/20) by [@GromNaN](https://github.com/GromNaN). - 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). - 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). +- Support `%` and `_` in `like` expression [#17](https://github.com/GromNaN/laravel-mongodb-private/pull/17) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 574bf8f..dd448ed 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -24,6 +24,8 @@ */ class Builder extends BaseBuilder { + private const REGEX_DELIMITERS = ['/', '#', '~']; + /** * The database collection. * @@ -91,6 +93,7 @@ class Builder extends BaseBuilder 'all', 'size', 'regex', + 'not regex', 'text', 'slice', 'elemmatch', @@ -113,13 +116,22 @@ class Builder extends BaseBuilder * @var array */ protected $conversion = [ - '=' => '=', - '!=' => '$ne', - '<>' => '$ne', - '<' => '$lt', - '<=' => '$lte', - '>' => '$gt', - '>=' => '$gte', + '!=' => 'ne', + '<>' => 'ne', + '<' => 'lt', + '<=' => 'lte', + '>' => 'gt', + '>=' => 'gte', + 'regexp' => 'regex', + 'not regexp' => 'not regex', + 'ilike' => 'like', + 'elemmatch' => 'elemMatch', + 'geointersects' => 'geoIntersects', + 'geowithin' => 'geoWithin', + 'nearsphere' => 'nearSphere', + 'maxdistance' => 'maxDistance', + 'centersphere' => 'centerSphere', + 'uniquedocs' => 'uniqueDocs', ]; /** @@ -932,20 +944,9 @@ protected function compileWheres(): array if (isset($where['operator'])) { $where['operator'] = strtolower($where['operator']); - // Operator conversions - $convert = [ - 'regexp' => 'regex', - 'elemmatch' => 'elemMatch', - 'geointersects' => 'geoIntersects', - 'geowithin' => 'geoWithin', - 'nearsphere' => 'nearSphere', - 'maxdistance' => 'maxDistance', - 'centersphere' => 'centerSphere', - 'uniquedocs' => 'uniqueDocs', - ]; - - if (array_key_exists($where['operator'], $convert)) { - $where['operator'] = $convert[$where['operator']]; + // Convert aliased operators + if (isset($this->conversion[$where['operator']])) { + $where['operator'] = $this->conversion[$where['operator']]; } } @@ -1036,45 +1037,55 @@ protected function compileWhereBasic(array $where): array // Replace like or not like with a Regex instance. if (in_array($operator, ['like', 'not like'])) { - if ($operator === 'not like') { - $operator = 'not'; - } else { - $operator = '='; - } - - // Convert to regular expression. - $regex = preg_replace('#(^|[^\\\])%#', '$1.*', preg_quote($value)); - - // Convert like to regular expression. - if (! Str::startsWith($value, '%')) { - $regex = '^'.$regex; - } - if (! Str::endsWith($value, '%')) { - $regex .= '$'; - } + $regex = preg_replace( + [ + // Unescaped % are converted to .* + // Group consecutive % + '#(^|[^\\\])%+#', + // Unescaped _ are converted to . + // Use positive lookahead to replace consecutive _ + '#(?<=^|[^\\\\])_#', + // Escaped \% or \_ are unescaped + '#\\\\\\\(%|_)#', + ], + ['$1.*', '$1.', '$1'], + // Escape any regex reserved characters, so they are matched + // All backslashes are converted to \\, which are needed in matching regexes. + preg_quote($value), + ); + $value = new Regex('^'.$regex.'$', 'i'); + + // For inverse like operations, we can just use the $not operator with the Regex + $operator = $operator === 'like' ? '=' : 'not'; + } - $value = new Regex($regex, 'i'); - } // Manipulate regexp operations. - elseif (in_array($operator, ['regexp', 'not regexp', 'regex', 'not regex'])) { + // Manipulate regex operations. + elseif (in_array($operator, ['regex', 'not regex'])) { // Automatically convert regular expression strings to Regex objects. - if (! $value instanceof Regex) { - $e = explode('/', $value); - $flag = end($e); - $regstr = substr($value, 1, -(strlen($flag) + 1)); - $value = new Regex($regstr, $flag); + if (is_string($value)) { + // Detect the delimiter and validate the preg pattern + $delimiter = substr($value, 0, 1); + if (! in_array($delimiter, self::REGEX_DELIMITERS)) { + throw new \LogicException(sprintf('Missing expected starting delimiter in regular expression "%s", supported delimiters are: %s', $value, implode(' ', self::REGEX_DELIMITERS))); + } + $e = explode($delimiter, $value); + // We don't try to detect if the last delimiter is escaped. This would be an invalid regex. + if (count($e) < 3) { + throw new \LogicException(sprintf('Missing expected ending delimiter "%s" in regular expression "%s"', $delimiter, $value)); + } + // Flags are after the last delimiter + $flags = end($e); + // Extract the regex string between the delimiters + $regstr = substr($value, 1, -1 - strlen($flags)); + $value = new Regex($regstr, $flags); } - // For inverse regexp operations, we can just use the $not operator - // and pass it a Regex instence. - if (Str::startsWith($operator, 'not')) { - $operator = 'not'; - } + // For inverse regex operations, we can just use the $not operator with the Regex + $operator = $operator === 'regex' ? '=' : 'not'; } if (! isset($operator) || $operator == '=') { $query = [$column => $value]; - } elseif (array_key_exists($operator, $this->conversion)) { - $query = [$column => [$this->conversion[$operator] => $value]]; } else { $query = [$column => ['$'.$operator => $value]]; } @@ -1133,7 +1144,7 @@ protected function compileWhereNull(array $where): array */ protected function compileWhereNotNull(array $where): array { - $where['operator'] = '!='; + $where['operator'] = 'ne'; $where['value'] = null; return $this->compileWhereBasic($where); diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index bc06449..f346422 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -11,6 +11,7 @@ use Jenssegers\Mongodb\Query\Builder; use Jenssegers\Mongodb\Query\Processor; use Mockery as m; +use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use PHPUnit\Framework\TestCase; @@ -578,6 +579,72 @@ function (Builder $builder) { ->orWhereNotBetween('id', collect([3, 4])), ]; + yield 'where like' => [ + ['find' => [['name' => new Regex('^acme$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', 'acme'), + ]; + + yield 'where ilike' => [ // Alias for like + ['find' => [['name' => new Regex('^acme$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'ilike', 'acme'), + ]; + + yield 'where like escape' => [ + ['find' => [['name' => new Regex('^\^ac\.me\$$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', '^ac.me$'), + ]; + + yield 'where like unescaped \% \_' => [ + ['find' => [['name' => new Regex('^a%cm_e$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', 'a\%cm\_e'), + ]; + + yield 'where like %' => [ + ['find' => [['name' => new Regex('^.*ac.*me.*$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', '%ac%%me%'), + ]; + + yield 'where like _' => [ + ['find' => [['name' => new Regex('^.ac..me.$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', '_ac__me_'), + ]; + + $regex = new Regex('^acme$', 'si'); + yield 'where BSON\Regex' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', $regex), + ]; + + yield 'where regexp' => [ // Alias for regex + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'), + ]; + + yield 'where regex delimiter /' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'), + ]; + + yield 'where regex delimiter #' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$#si'), + ]; + + yield 'where regex delimiter ~' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$#si'), + ]; + + yield 'where regex with escaped characters' => [ + ['find' => [['name' => new Regex('a\.c\/m\+e', '')], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '/a\.c\/m\+e/'), + ]; + + yield 'where not regex' => [ + ['find' => [['name' => ['$not' => $regex]], []]], + fn (Builder $builder) => $builder->where('name', 'not regex', '/^acme$/si'), + ]; + /** @see DatabaseQueryBuilderTest::testBasicSelectDistinct */ yield 'distinct' => [ ['distinct' => ['foo', [], []]], @@ -647,7 +714,7 @@ public function testException($class, $message, \Closure $build): void $this->expectException($class); $this->expectExceptionMessage($message); - $build($builder); + $build($builder)->toMQL(); } public static function provideExceptions(): iterable @@ -694,6 +761,18 @@ public static function provideExceptions(): iterable 'Too few arguments to function Jenssegers\Mongodb\Query\Builder::where("foo"), 1 passed and at least 2 expected when the 1st is a string', fn (Builder $builder) => $builder->where('foo'), ]; + + yield 'where regex not starting with /' => [ + \LogicException::class, + 'Missing expected starting delimiter in regular expression "^ac/me$", supported delimiters are: / # ~', + fn (Builder $builder) => $builder->where('name', 'regex', '^ac/me$'), + ]; + + yield 'where regex not ending with /' => [ + \LogicException::class, + 'Missing expected ending delimiter "/" in regular expression "/foo#bar"', + fn (Builder $builder) => $builder->where('name', 'regex', '/foo#bar'), + ]; } /** @dataProvider getEloquentMethodsNotSupported */ diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 4179748..754f204 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -70,6 +70,21 @@ public function testAndWhere(): void $this->assertCount(2, $users); } + public function testRegexp(): void + { + User::create(['name' => 'Simple', 'company' => 'acme']); + User::create(['name' => 'With slash', 'company' => 'oth/er']); + + $users = User::where('company', 'regexp', '/^acme$/')->get(); + $this->assertCount(1, $users); + + $users = User::where('company', 'regexp', '/^ACME$/i')->get(); + $this->assertCount(1, $users); + + $users = User::where('company', 'regexp', '/^oth\/er$/')->get(); + $this->assertCount(1, $users); + } + public function testLike(): void { $users = User::where('name', 'like', '%doe')->get(); @@ -83,6 +98,12 @@ public function testLike(): void $users = User::where('name', 'like', 't%')->get(); $this->assertCount(1, $users); + + $users = User::where('name', 'like', 'j___ doe')->get(); + $this->assertCount(2, $users); + + $users = User::where('name', 'like', '_oh_ _o_')->get(); + $this->assertCount(1, $users); } public function testNotLike(): void