Skip to content

Commit

Permalink
PHPORM-53 Fix and test like and regex operators (#17)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
GromNaN authored and alcaeus committed Aug 22, 2023
1 parent d5f1bb9 commit ea89e86
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 55 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down
117 changes: 64 additions & 53 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
*/
class Builder extends BaseBuilder
{
private const REGEX_DELIMITERS = ['/', '#', '~'];

/**
* The database collection.
*
Expand Down Expand Up @@ -91,6 +93,7 @@ class Builder extends BaseBuilder
'all',
'size',
'regex',
'not regex',
'text',
'slice',
'elemmatch',
Expand All @@ -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',
];

/**
Expand Down Expand Up @@ -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']];
}
}

Expand Down Expand Up @@ -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]];
}
Expand Down Expand Up @@ -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);
Expand Down
81 changes: 80 additions & 1 deletion tests/Query/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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', [], []]],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down
21 changes: 21 additions & 0 deletions tests/QueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down

0 comments on commit ea89e86

Please sign in to comment.