diff --git a/composer.json b/composer.json index fbc082a8..58bfb3c6 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ ], "license": "MIT", "require": { + "ext-mongodb": "^1.15", "illuminate/support": "^10.0", "illuminate/container": "^10.0", "illuminate/database": "^10.0", diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 06641273..e94450c6 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -2,6 +2,7 @@ namespace Jenssegers\Mongodb\Query; +use Carbon\CarbonPeriod; use Closure; use DateTimeInterface; use Illuminate\Database\Query\Builder as BaseBuilder; @@ -15,6 +16,7 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Driver\Cursor; use RuntimeException; /** @@ -81,6 +83,7 @@ class Builder extends BaseBuilder 'not like', 'between', 'ilike', + 'not ilike', '&', '|', '^', @@ -215,27 +218,27 @@ public function cursor($columns = []) } /** - * Execute the query as a fresh "select" statement. + * Return the MongoDB query to be run in the form of an element array like ['method' => [arguments]]. * - * @param array $columns - * @param bool $returnLazy - * @return array|static[]|Collection|LazyCollection + * Example: ['find' => [['name' => 'John Doe'], ['projection' => ['birthday' => 1]]]] + * + * @param $columns + * @return array */ - public function getFresh($columns = [], $returnLazy = false) + public function toMql($columns = []): array { // If no columns have been specified for the select statement, we will set them // here to either the passed columns, or the standard default of retrieving // all of the columns on the table using the "wildcard" column character. - if ($this->columns === null) { - $this->columns = $columns; + if ($this->columns !== null) { + $columns = $this->columns; } // Drop all columns if * is present, MongoDB does not work this way. - if (in_array('*', $this->columns)) { - $this->columns = []; + if (in_array('*', $columns)) { + $columns = []; } - // Compile wheres $wheres = $this->compileWheres(); // Use MongoDB's aggregation framework when using grouping or aggregation functions. @@ -254,7 +257,7 @@ public function getFresh($columns = [], $returnLazy = false) } // Do the same for other columns that are selected. - foreach ($this->columns as $column) { + foreach ($columns as $column) { $key = str_replace('.', '_', $column); $group[$key] = ['$last' => '$'.$column]; @@ -274,26 +277,10 @@ public function getFresh($columns = [], $returnLazy = false) $column = implode('.', $splitColumns); } - // Null coalense only > 7.2 - $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; if (in_array('*', $aggregations) && $function == 'count') { - // When ORM is paginating, count doesnt need a aggregation, just a cursor operation - // elseif added to use this only in pagination - // https://docs.mongodb.com/manual/reference/method/cursor.count/ - // count method returns int - - $totalResults = $this->collection->count($wheres); - // Preserving format expected by framework - $results = [ - [ - '_id' => null, - 'aggregate' => $totalResults, - ], - ]; - - return new Collection($results); + return ['countDocuments' => [$wheres, []]]; } elseif ($function == 'count') { // Translate count into sum. $group['aggregate'] = ['$sum' => 1]; @@ -348,34 +335,23 @@ public function getFresh($columns = [], $returnLazy = false) $options = $this->inheritConnectionOptions($options); - // Execute aggregation - $results = iterator_to_array($this->collection->aggregate($pipeline, $options)); - - // Return results - return new Collection($results); + return ['aggregate' => [$pipeline, $options]]; } // Distinct query elseif ($this->distinct) { // Return distinct results directly - $column = isset($this->columns[0]) ? $this->columns[0] : '_id'; + $column = isset($columns[0]) ? $columns[0] : '_id'; $options = $this->inheritConnectionOptions(); - // Execute distinct - $result = $this->collection->distinct($column, $wheres ?: [], $options); - - return new Collection($result); + return ['distinct' => [$column, $wheres ?: [], $options]]; } // Normal query else { - $columns = []; - // Convert select columns to simple projections. - foreach ($this->columns as $column) { - $columns[$column] = true; - } + $projection = array_fill_keys($columns, true); // Add custom projections. if ($this->projections) { - $columns = array_merge($columns, $this->projections); + $projection = array_merge($projection, $this->projections); } $options = []; @@ -395,8 +371,8 @@ public function getFresh($columns = [], $returnLazy = false) if ($this->hint) { $options['hint'] = $this->hint; } - if ($columns) { - $options['projection'] = $columns; + if ($projection) { + $options['projection'] = $projection; } // Fix for legacy support, converts the results to arrays instead of objects. @@ -409,22 +385,50 @@ public function getFresh($columns = [], $returnLazy = false) $options = $this->inheritConnectionOptions($options); - // Execute query and get MongoCursor - $cursor = $this->collection->find($wheres, $options); + return ['find' => [$wheres, $options]]; + } + } - if ($returnLazy) { - return LazyCollection::make(function () use ($cursor) { - foreach ($cursor as $item) { - yield $item; - } - }); - } + /** + * Execute the query as a fresh "select" statement. + * + * @param array $columns + * @param bool $returnLazy + * @return array|static[]|Collection|LazyCollection + */ + public function getFresh($columns = [], $returnLazy = false) + { + $command = $this->toMql($columns); + assert(count($command) >= 1, 'At least one method call is required to execute a query'); + + $result = $this->collection; + foreach ($command as $method => $arguments) { + $result = call_user_func_array([$result, $method], $arguments); + } + + // countDocuments method returns int, wrap it to the format expected by the framework + if (is_int($result)) { + $result = [ + [ + '_id' => null, + 'aggregate' => $result, + ], + ]; + } - // Return results as an array with numeric keys - $results = iterator_to_array($cursor, false); + if ($returnLazy) { + return LazyCollection::make(function () use ($result) { + foreach ($result as $item) { + yield $item; + } + }); + } - return new Collection($results); + if ($result instanceof Cursor) { + $result = $result->toArray(); } + + return new Collection($result); } /** @@ -505,11 +509,21 @@ public function distinct($column = false) /** * @inheritdoc + * @param int|string $direction */ public function orderBy($column, $direction = 'asc') { if (is_string($direction)) { - $direction = (strtolower($direction) == 'asc' ? 1 : -1); + switch (strtolower($direction)) { + case 'asc': + $direction = 1; + break; + case 'desc': + $direction = -1; + break; + default: + throw new \InvalidArgumentException('Order direction must be "asc" or "desc"'); + } } if ($column == 'natural') { @@ -871,7 +885,7 @@ protected function performUpdate($query, array $options = []) $options = $this->inheritConnectionOptions($options); $wheres = $this->compileWheres(); - $result = $this->collection->UpdateMany($wheres, $query, $options); + $result = $this->collection->updateMany($wheres, $query, $options); if (1 == (int) $result->isAcknowledged()) { return $result->getModifiedCount() ? $result->getModifiedCount() : $result->getUpsertedCount(); } @@ -970,20 +984,31 @@ protected function compileWheres(): array if (is_array($where['value'])) { array_walk_recursive($where['value'], function (&$item, $key) { if ($item instanceof DateTimeInterface) { - $item = new UTCDateTime($item->format('Uv')); + $item = new UTCDateTime($item); } }); } else { if ($where['value'] instanceof DateTimeInterface) { - $where['value'] = new UTCDateTime($where['value']->format('Uv')); + $where['value'] = new UTCDateTime($where['value']); } } } elseif (isset($where['values'])) { - array_walk_recursive($where['values'], function (&$item, $key) { - if ($item instanceof DateTimeInterface) { - $item = new UTCDateTime($item->format('Uv')); - } - }); + if (is_array($where['values'])) { + array_walk_recursive($where['values'], function (&$item) { + if ($item instanceof DateTimeInterface) { + $item = new UTCDateTime($item); + } + }); + } + } + + $not = false; + if ($where['boolean'] === 'and not') { + $where['boolean'] = 'and'; + $not = true; + } elseif ($where['boolean'] === 'or not') { + $where['boolean'] = 'or'; + $not = true; } // The next item in a "chain" of wheres devices the boolean of the @@ -997,6 +1022,10 @@ protected function compileWheres(): array $method = "compileWhere{$where['type']}"; $result = $this->{$method}($where); + if ($not) { + $result = ['$not' => $result]; + } + // Wrap the where with an $or operator. if ($where['boolean'] == 'or') { $result = ['$or' => [$result]]; @@ -1035,12 +1064,9 @@ protected function compileWhereBasic(array $where): array extract($where); // Replace like or not like with a Regex instance. - if (in_array($operator, ['like', 'not like'])) { - if ($operator === 'not like') { - $operator = 'not'; - } else { - $operator = '='; - } + if (in_array($operator, ['like', 'not like', 'ilike', 'not ilike'])) { + $flags = str_ends_with($operator, 'ilike') ? 'i' : ''; + $operator = str_starts_with($operator, 'not') ? 'not' : '='; // Convert to regular expression. $regex = preg_replace('#(^|[^\\\])%#', '$1.*', preg_quote($value)); @@ -1053,7 +1079,7 @@ protected function compileWhereBasic(array $where): array $regex .= '$'; } - $value = new Regex($regex, 'i'); + $value = new Regex($regex, $flags); } // Manipulate regexp operations. elseif (in_array($operator, ['regexp', 'not regexp', 'regex', 'not regex'])) { // Automatically convert regular expression strings to Regex objects. @@ -1101,7 +1127,7 @@ protected function compileWhereIn(array $where): array { extract($where); - return [$column => ['$in' => array_values($values)]]; + return [$column => ['$in' => Arr::flatten($values)]]; } /** @@ -1147,6 +1173,12 @@ protected function compileWhereBetween(array $where): array { extract($where); + if ($values instanceof CarbonPeriod) { + $values = [new UTCDateTime($values->start), new UTCDateTime($values->end)]; + } else { + $values = Arr::flatten($values); + } + if ($not) { return [ '$or' => [ @@ -1287,4 +1319,64 @@ public function __call($method, $parameters) return parent::__call($method, $parameters); } + + /** @see Builder::toMql() */ + public function toSql() + { + return $this->toMql(); + } + + /** @internal This method is not supported by MongoDB. */ + public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function whereFullText($columns, $value, array $options = [], $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function groupByRaw($sql, array $bindings = []) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function orderByRaw($sql, $bindings = []) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function unionAll($query) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function union($query, $all = false) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function having($column, $operator = null, $value = null, $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function havingRaw($sql, array $bindings = [], $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function havingBetween($column, iterable $values, $boolean = 'and', $not = false) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } } diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 9cb3af40..3bf85409 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -8,12 +8,15 @@ use DateTimeImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; -use Illuminate\Support\LazyCollection; use Illuminate\Testing\Assert; +use Illuminate\Tests\Database\DatabaseQueryBuilderTest; use Jenssegers\Mongodb\Collection; +use Jenssegers\Mongodb\Connection; use Jenssegers\Mongodb\Query\Builder; +use Jenssegers\Mongodb\Query\Processor; use Jenssegers\Mongodb\Tests\Models\Item; use Jenssegers\Mongodb\Tests\Models\User; +use Mockery as m; use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; @@ -593,19 +596,19 @@ public function testUpdateSubdocument() public function testDates() { DB::collection('users')->insert([ - ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00')->format('Uv'))], - ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00')->format('Uv'))], - ['name' => 'Mark Moe', 'birthday' => new UTCDateTime(Date::parse('1983-01-01 00:00:00.1')->format('Uv'))], - ['name' => 'Frank White', 'birthday' => new UTCDateTime(Date::parse('1960-01-01 12:12:12.1')->format('Uv'))], + ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00'))], + ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00'))], + ['name' => 'Mark Moe', 'birthday' => new UTCDateTime(Date::parse('1983-01-01 00:00:00.1'))], + ['name' => 'Frank White', 'birthday' => new UTCDateTime(Date::parse('1960-01-01 12:12:12.1'))], ]); $user = DB::collection('users') - ->where('birthday', new UTCDateTime(Date::parse('1980-01-01 00:00:00')->format('Uv'))) + ->where('birthday', new UTCDateTime(Date::parse('1980-01-01 00:00:00'))) ->first(); $this->assertEquals('John Doe', $user['name']); $user = DB::collection('users') - ->where('birthday', new UTCDateTime(Date::parse('1960-01-01 12:12:12.1')->format('Uv'))) + ->where('birthday', new UTCDateTime(Date::parse('1960-01-01 12:12:12.1'))) ->first(); $this->assertEquals('Frank White', $user['name']); @@ -622,8 +625,8 @@ public function testDates() public function testImmutableDates() { DB::collection('users')->insert([ - ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00')->format('Uv'))], - ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00')->format('Uv'))], + ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00'))], + ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00'))], ]); $users = DB::collection('users')->where('birthday', '=', new DateTimeImmutable('1980-01-01 00:00:00'))->get(); @@ -840,20 +843,721 @@ public function testHintOptions() $this->assertEquals('fork', $results[0]['name']); } - public function testCursor() + public function testOrderBy() { - $data = [ - ['name' => 'fork', 'tags' => ['sharp', 'pointy']], - ['name' => 'spork', 'tags' => ['sharp', 'pointy', 'round', 'bowl']], - ['name' => 'spoon', 'tags' => ['round', 'bowl']], - ]; - DB::collection('items')->insert($data); + DB::collection('items')->insert([ + ['name' => 'alpha'], + ['name' => 'gamma'], + ['name' => 'beta'], + ]); + $result = DB::collection('items')->orderBy('name', 'desc')->get(); - $results = DB::collection('items')->orderBy('_id', 'asc')->cursor(); + $result = $result->map(function ($item) { + return $item['name']; + }); - $this->assertInstanceOf(LazyCollection::class, $results); - foreach ($results as $i => $result) { - $this->assertEquals($data[$i]['name'], $result['name']); - } + $this->assertSame(['gamma', 'beta', 'alpha'], $result->toArray()); + } + + public function testLimitOffset() + { + DB::collection('items')->insert([ + ['name' => 'alpha'], + ['name' => 'gamma'], + ['name' => 'beta'], + ]); + + // Offset only + $result = DB::collection('items')->orderBy('name')->offset(1)->get(); + $this->assertSame(['beta', 'gamma'], $result->map(function ($item) { return $item['name']; })->toArray()); + + // Limit only + $result = DB::collection('items')->orderBy('name')->limit(2)->get(); + $this->assertSame(['alpha', 'beta'], $result->map(function ($item) { return $item['name']; })->toArray()); + + // Limit and offset + $result = DB::collection('items')->orderBy('name')->limit(1)->offset(1)->get(); + $this->assertSame(['beta'], $result->map(function ($item) { return $item['name']; })->toArray()); + + // Empty result + $result = DB::collection('items')->orderBy('name')->offset(5)->get(); + $this->assertSame([], $result->toArray()); + } + + + /** @dataProvider getEloquentMethodsNotSupported */ + public function testEloquentMethodsNotSupported(\Closure $callback) + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('This method is not supported by MongoDB'); + + $builder = $this->getBuilder(); + call_user_func($callback, $builder); + } + + public static function getEloquentMethodsNotSupported() + { + // Most of this methods can be implemented using aggregation framework + // whereInRaw, whereNotInRaw, orWhereInRaw, orWhereNotInRaw, whereBetweenColumns + + /** @see DatabaseQueryBuilderTest::testBasicWhereColumn() */ + /** @see DatabaseQueryBuilderTest::testArrayWhereColumn() */ + yield 'whereColumn' => [fn (Builder $builder) => $builder->whereColumn('first_name', 'last_name')]; + yield 'orWhereColumn' => [fn (Builder $builder) => $builder->orWhereColumn('first_name', 'last_name')]; + + /** @see DatabaseQueryBuilderTest::testWhereFulltextMySql() */ + yield 'whereFulltext' => [fn (Builder $builder) => $builder->whereFulltext('body', 'Hello World')]; + + /** @see DatabaseQueryBuilderTest::testGroupBys() */ + yield 'groupByRaw' => [fn (Builder $builder) => $builder->groupByRaw('DATE(created_at)')]; + + /** @see DatabaseQueryBuilderTest::testOrderBys() */ + yield 'orderByRaw' => [fn (Builder $builder) => $builder->orderByRaw('"age" ? desc', ['foo'])]; + + /** @see DatabaseQueryBuilderTest::testInRandomOrderMySql */ + yield 'inRandomOrder' => [fn (Builder $builder) => $builder->inRandomOrder()]; + + yield 'union' => [fn (Builder $builder) => $builder->union($builder)]; + yield 'unionAll' => [fn (Builder $builder) => $builder->unionAll($builder)]; + + /** @see DatabaseQueryBuilderTest::testRawHavings */ + yield 'havingRaw' => [fn (Builder $builder) => $builder->havingRaw('user_foo < user_bar')]; + yield 'having' => [fn (Builder $builder) => $builder->having('baz', '=', 1)]; + yield 'havingBetween' => [fn (Builder $builder) => $builder->havingBetween('last_login_date', ['2018-11-16', '2018-12-16'])]; + yield 'orHavingRaw' => [fn (Builder $builder) => $builder->orHavingRaw('user_foo < user_bar')]; + } + + /** @see DatabaseQueryBuilderTest::testBasicSelectDistinctOnColumns() */ + public function testBasicSelectDistinctOnColumns() + { + $builder = $this->getBuilder(); + $builder->distinct('foo')->select('foo', 'bar'); + $this->assertSame(['distinct' => ['foo', [], []]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testAddingSelects() */ + public function testAddingSelects() + { + $builder = $this->getBuilder(); + $builder->select('foo')->addSelect('bar')->addSelect(['baz', 'boom'])->addSelect('bar'); + $this->assertSame(['find' => [ + [], + ['projection' => ['foo' => true, 'bar' => true, 'baz' => true, 'boom' => true], 'typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicSelectDistinct() */ + public function testBasicSelectDistinct() + { + $builder = $this->getBuilder(); + $builder->distinct()->select('foo', 'bar'); + $this->assertSame(['distinct' => ['foo', [], []]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicWheres() */ + public function testBasicWheres() + { + $builder = $this->getBuilder(); + $builder->where('id', '=', 1); + $this->assertSame(['find' => [ + ['id' => 1], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '!=', 1); + $this->assertSame(['find' => [ + ['id' => ['$ne' => 1]], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicWhereNot() */ + public function testBasicWhereNot() + { + $builder = $this->getBuilder(); + $builder->whereNot('name', 'foo')->whereNot('name', '<>', 'bar'); + $this->assertSame(['find' => [ + ['$and' => [['$not' => ['name' => 'foo']], ['$not' => ['name' => ['$ne' => 'bar']]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testWheresWithArrayValue() */ + public function testWheresWithArrayValue() + { + $builder = $this->getBuilder(); + $builder->where('id', [12]); + $this->assertSame(['find' => [ + ['id' => [12]], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', [12, 30]); + $this->assertSame(['find' => [ + ['id' => [12, 30]], // @todo Eloquent asserts ['id' => 12] + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '!=', [12, 30]); + $this->assertSame(['find' => [ + ['id' => ['$ne' => [12, 30]]], // @todo Eloquent asserts ['id' => ['$ne' => 12]] + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '<>', [12, 30]); + $this->assertSame(['find' => [ + ['id' => ['$ne' => [12, 30]]], // @todo Eloquent asserts ['id' => ['$ne' => 12]] + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', [[12, 30]]); + $this->assertSame(['find' => [ + ['id' => [[12, 30]]], // @todo Eloquent asserts ['id' => 12] + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testWhereLikePostgres() */ + public function testWhereLike() + { + $builder = $this->getBuilder(); + $builder->where('id', 'like', '1'); + $this->assertEquals(['find' => [ + ['id' => new Regex('^1$')], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', 'like', '%{#}%'); + $this->assertEquals(['find' => [ + ['id' => new Regex('.*\{\#\}.*')], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', 'LIKE', '1'); + $this->assertEquals(['find' => [ + ['id' => new Regex('^1$')], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', 'ilike', '1'); + $this->assertEquals(['find' => [ + ['id' => new Regex('^1$', 'i')], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', 'not like', '1'); + $this->assertEquals(['find' => [ + ['id' => ['$not' => new Regex('^1$')]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', 'not ilike', '1'); + $this->assertEquals(['find' => [ + ['id' => ['$not' => new Regex('^1$', 'i')]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testWhereBetweens() */ + public function testWhereBetweens() + { + $builder = $this->getBuilder(); + $builder->whereBetween('id', [1, 2]); + $this->assertSame(['find' => [ + ['id' => ['$gte' => 1, '$lte' => 2]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->whereBetween('id', [[1, 2, 3]]); + $this->assertSame(['find' => [ + ['id' => ['$gte' => 1, '$lte' => 2]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->whereBetween('id', [[1], [2, 3]]); + $this->assertSame(['find' => [ + ['id' => ['$gte' => 1, '$lte' => 2]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->whereNotBetween('id', [1, 2]); + $this->assertSame(['find' => [ + ['$or' => [['id' => ['$lte' => 1]], ['id' => ['$gte' => 2]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $period = now()->toPeriod(now()->addDay()); + $builder->whereBetween('created_at', $period); + $this->assertEquals(['find' => [ + ['created_at' => ['$gte' => new UTCDateTime($period->start), '$lte' => new UTCDateTime($period->end)]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + // custom long carbon period date + $builder = $this->getBuilder(); + $period = now()->toPeriod(now()->addMonth()); + $builder->whereBetween('created_at', $period); + $this->assertEquals(['find' => [ + ['created_at' => ['$gte' => new UTCDateTime($period->start), '$lte' => new UTCDateTime($period->end)]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->whereBetween('id', collect([1, 2])); + $this->assertSame(['find' => [ + ['id' => ['$gte' => 1, '$lte' => 2]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testOrWhereBetween() */ + public function testOrWhereBetween() + { + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereBetween('id', [3, 5]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$gte' => 3, '$lte' => 5]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereBetween('id', [[3, 4, 5]]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$gte' => 3, '$lte' => 4]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereBetween('id', [[3, 5]]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$gte' => 3, '$lte' => 5]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereBetween('id', [[4], [6, 8]]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$gte' => 4, '$lte' => 6]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereBetween('id', collect([3, 4])); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$gte' => 3, '$lte' => 4]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testOrWhereNotBetween() */ + public function testOrWhereNotBetween() + { + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotBetween('id', [3, 5]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['$or' => [['id' => ['$lte' => 3]], ['id' => ['$gte' => 5]]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotBetween('id', [[3, 4, 5]]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['$or' => [['id' => ['$lte' => 3]], ['id' => ['$gte' => 4]]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotBetween('id', [[3, 5]]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['$or' => [['id' => ['$lte' => 3]], ['id' => ['$gte' => 5]]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotBetween('id', [[4], [6, 8]]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['$or' => [['id' => ['$lte' => 4]], ['id' => ['$gte' => 6]]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotBetween('id', collect([3, 4])); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['$or' => [['id' => ['$lte' => 3]], ['id' => ['$gte' => 4]]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicOrWheres() */ + public function testBasicOrWheres() + { + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhere('email', '=', 'foo'); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['email' => 'foo']]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicOrWhereNot() */ + public function testBasicOrWhereNot() + { + $builder = $this->getBuilder(); + $builder->orWhereNot('name', 'foo')->orWhereNot('name', '<>', 'bar'); + $this->assertSame(['find' => [ + // @todo bugfix: incorrect query: ['$and' => [['name' => 'foo'], ['name' => ['$ne' => 'bar']]]], + ['$or' => [['$not' => ['name' => 'foo']], ['$not' => ['name' => ['$ne' => 'bar']]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicWhereIns() */ + public function testBasicWhereIns() + { + $builder = $this->getBuilder(); + $builder->whereIn('id', [1, 2, 3]); + $this->assertSame(['find' => [ + ['id' => ['$in' => [1, 2, 3]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + // associative arrays as values: + $builder = $this->getBuilder(); + $builder->whereIn('id', [ + 'issue' => 45582, + 'id' => 2, + 3, + ]); + $this->assertSame(['find' => [ + ['id' => ['$in' => [45582, 2, 3]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + // can accept some nested arrays as values. + $builder = $this->getBuilder(); + $builder->whereIn('id', [ + ['issue' => 45582], + ['id' => 2], + [3], + ]); + $this->assertSame(['find' => [ + ['id' => ['$in' => [45582, 2, 3]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereIn('id', [1, 2, 3]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$in' => [1, 2, 3]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicWhereInsException() */ + public function testBasicWhereInsException() + { + $this->expectException(\InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->whereIn('id', [ + ['a' => 1, 'b' => 1], + ['c' => 2], + [3], + ]); + } + + /** @see DatabaseQueryBuilderTest::testBasicWhereNotIns() */ + public function testBasicWhereNotIns() + { + $builder = $this->getBuilder(); + $builder->whereNotIn('id', [1, 2, 3]); + $this->assertSame(['find' => [ + ['id' => ['$nin' => [1, 2, 3]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotIn('id', [1, 2, 3]); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$nin' => [1, 2, 3]]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testEmptyWhereIns() */ + public function testEmptyWhereIns() + { + $builder = $this->getBuilder(); + $builder->whereIn('id', []); + $this->assertSame(['find' => [ + ['id' => ['$in' => []]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereIn('id', []); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$in' => []]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testEmptyWhereNotIns() */ + public function testEmptyWhereNotIns() + { + $builder = $this->getBuilder(); + $builder->whereNotIn('id', []); + $this->assertSame(['find' => [ + ['id' => ['$nin' => []]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNotIn('id', []); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => ['$nin' => []]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicWhereNulls() */ + public function testBasicWhereNulls() + { + $builder = $this->getBuilder(); + $builder->whereNull('id'); + $this->assertSame(['find' => [ + ['id' => null], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('id', '=', 1)->orWhereNull('id'); + $this->assertSame(['find' => [ + ['$or' => [['id' => 1], ['id' => null]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testJsonWhereNullMysql() */ + public function testSubfieldWhereNotNull() + { + $builder = $this->getBuilder(); + $builder->whereNotNull('items.id'); + $this->assertSame(['find' => [ + ['items.id' => ['$ne' => null]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testArrayWhereNulls() */ + public function testArrayWhereNulls() + { + $builder = $this->getBuilder(); + $builder->whereNull(['_id', 'expires_at']); + $this->assertSame(['find' => [ + ['$and' => [['_id' => null], ['expires_at' => null]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('_id', '=', 1)->orWhereNull(['_id', 'expires_at']); + $this->assertSame(['find' => [ + ['$or' => [['_id' => 1], ['_id' => null], ['expires_at' => null]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]] + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testBasicWhereNotNulls() */ + public function testBasicWhereNotNulls() + { + $builder = $this->getBuilder(); + $builder->whereNotNull('_id'); + $this->assertSame(['find' => [ + ['_id' => ['$ne' => null]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('_id', '>', 1)->orWhereNotNull('_id'); + $this->assertSame(['find' => [ + ['$or' => [['_id' => ['$gt' => 1]], ['_id' => ['$ne' => null]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testArrayWhereNotNulls() */ + public function testArrayWhereNotNulls() + { + $builder = $this->getBuilder(); + $builder->whereNotNull(['_id', 'expires_at']); + $this->assertSame(['find' => [ + ['$and' => [['_id' => ['$ne' => null]], ['expires_at' => ['$ne' => null]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->where('_id', '>', 1)->orWhereNotNull(['_id', 'expires_at']); + // @todo This assertion from Eloquent tests fails + $this->assertSame(['find' => [ + ['$or' => [['_id' => ['$gt' => 1]], ['_id' => ['$ne' => null]], ['expires_at' => ['$ne' => null]]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']]], + ], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testGroupBys() */ + public function testGroupBys() + { + $builder = $this->getBuilder(); + $builder->groupBy('email'); + $this->assertSame(['aggregate' => [ + [['$group' => ['_id' => ['email' => '$email'], 'email' => ['$last' => '$email']]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->groupBy('_id', 'email'); + $this->assertSame(['aggregate' => [ + [['$group' => ['_id' => ['$last' => '$_id', 'email' => '$email'], 'email' => ['$last' => '$email']]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->groupBy(['_id', 'email']); + $this->assertSame(['aggregate' => [ + [['$group' => ['_id' => ['$last' => '$_id', 'email' => '$email'], 'email' => ['$last' => '$email']]]], + ['typeMap' => ['root' => 'array', 'document' => 'array']], + ]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testOrderBys() */ + public function testOrderBys() + { + $builder = $this->getBuilder(); + $builder->orderBy('email')->orderBy('age', 'desc'); + $this->assertSame(['find' => [[], ['sort' => ['email' => 1, 'age' => -1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder->orders = null; + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder->orders = []; + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder->orderBy('email', -1)->orderBy('age', 1); + $this->assertSame(['find' => [[], ['sort' => ['email' => -1, 'age' => 1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testLatest() */ + public function testLatest() + { + $builder = $this->getBuilder(); + $builder->latest(); + $this->assertSame(['find' => [[], ['sort' => ['created_at' => -1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->latest()->limit(1); + $this->assertSame(['find' => [[], ['sort' => ['created_at' => -1], 'limit' => 1, 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->latest('updated_at'); + $this->assertSame(['find' => [[], ['sort' => ['updated_at' => -1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testOldest() */ + public function testOldest() + { + $builder = $this->getBuilder(); + $builder->oldest(); + $this->assertSame(['find' => [[], ['sort' => ['created_at' => 1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->oldest()->limit(1); + $this->assertSame(['find' => [[], ['sort' => ['created_at' => 1], 'limit' => 1, 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->oldest('updated_at'); + $this->assertSame(['find' => [[], ['sort' => ['updated_at' => 1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testReorder() */ + public function testReorder() + { + $builder = $this->getBuilder(); + $builder->orderBy('name'); + $this->assertSame(['find' => [[], ['sort' => ['name' => 1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + $builder->reorder(); + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->orderBy('name'); + $this->assertSame(['find' => [[], ['sort' => ['name' => 1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + $builder->reorder('email', 'desc'); + $this->assertSame(['find' => [[], ['sort' => ['email' => -1], 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + } + + /** @see DatabaseQueryBuilderTest::testOrderByInvalidDirectionParam() */ + public function testOrderByInvalidDirectionParam() + { + $this->expectException(\InvalidArgumentException::class); + + $builder = $this->getBuilder(); + $builder->orderBy('age', 'asec'); + } + + /** @see DatabaseQueryBuilderTest::testLimitsAndOffsets() */ + public function testLimitsAndOffsets() + { + $builder = $this->getBuilder(); + $builder->select('*')->offset(5)->limit(10); + $this->assertSame(['find' => [[], ['skip' => 5, 'limit' => 10, 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->limit(10)->limit(null); + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->limit(0); + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->skip(5)->take(10); + $this->assertSame(['find' => [[], ['skip' => 5, 'limit' => 10, 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->skip(0)->take(0); + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->skip(-5)->take(-10); + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->skip(null)->take(null); + $this->assertSame(['find' => [[], ['typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + + $builder = $this->getBuilder(); + $builder->select('*')->skip(5)->take(null); + $this->assertSame(['find' => [[], ['skip' => 5, 'typeMap' => ['root' => 'array', 'document' => 'array']]]], $builder->toMql()); + } + + protected function getBuilder() + { + $connection = m::mock(Connection::class); + $processor = m::mock(Processor::class); + $connection->shouldReceive('getSession')->andReturn(null); + + return new Builder($connection, $processor); } }