diff --git a/composer.json b/composer.json index fbc082a..58bfb3c 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 1f707e9..893de03 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -15,6 +15,7 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Driver\Cursor; use RuntimeException; /** @@ -215,27 +216,21 @@ 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]]]] + * + * @return array */ - public function getFresh($columns = [], $returnLazy = false) + public function toMql(): 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; - } + $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 +249,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 +269,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 ['count' => [$wheres, []]]; } elseif ($function == 'count') { // Translate count into sum. $group['aggregate'] = ['$sum' => 1]; @@ -348,34 +327,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 +363,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 +377,62 @@ 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) + { + // 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; + } + + // Drop all columns if * is present, MongoDB does not work this way. + if (in_array('*', $this->columns)) { + $this->columns = []; + } + + $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); } /** diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php new file mode 100644 index 0000000..17ce184 --- /dev/null +++ b/tests/Query/BuilderTest.php @@ -0,0 +1,85 @@ +assertInstanceOf(Builder::class, $builder); + $mql = $builder->toMql(); + + // Operations that return a Cursor expect a "typeMap" option. + if (isset($expected['find'][1])) { + $expected['find'][1]['typeMap'] = ['root' => 'array', 'document' => 'array']; + } + if (isset($expected['aggregate'][1])) { + $expected['aggregate'][1]['typeMap'] = ['root' => 'array', 'document' => 'array']; + } + + // Compare with assertEquals because the query can contain BSON objects. + $this->assertEquals($expected, $mql, var_export($mql, true)); + } + + public static function provideQueryBuilderToMql(): iterable + { + /** + * Builder::aggregate() and Builder::count() cannot be tested because they return the result, + * without modifying the builder. + */ + $date = new DateTimeImmutable('2016-07-12 15:30:00'); + + yield 'find' => [ + ['find' => [['foo' => 'bar'], []]], + fn (Builder $builder) => $builder->where('foo', 'bar'), + ]; + + yield 'find > date' => [ + ['find' => [['foo' => ['$gt' => new UTCDateTime($date)]], []]], + fn (Builder $builder) => $builder->where('foo', '>', $date), + ]; + + yield 'find in array' => [ + ['find' => [['foo' => ['$in' => ['bar', 'baz']]], []]], + fn (Builder $builder) => $builder->whereIn('foo', ['bar', 'baz']), + ]; + + yield 'find limit offset select' => [ + ['find' => [[], ['limit' => 10, 'skip' => 5, 'projection' => ['foo' => 1, 'bar' => 1]]]], + fn (Builder $builder) => $builder->limit(10)->offset(5)->select('foo', 'bar'), + ]; + + yield 'distinct' => [ + ['distinct' => ['foo', [], []]], + fn (Builder $builder) => $builder->distinct('foo'), + ]; + + yield 'groupBy' => [ + ['aggregate' => [[['$group' => ['_id' => ['foo' => '$foo'], 'foo' => ['$last' => '$foo']]]], []]], + fn (Builder $builder) => $builder->groupBy('foo'), + ]; + } + + private static function getBuilder(): Builder + { + $connection = m::mock(Connection::class); + $processor = m::mock(Processor::class); + $connection->shouldReceive('getSession')->andReturn(null); + + return new Builder($connection, $processor); + } +} diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 92c1bbe..d2356d2 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -144,8 +144,7 @@ public function testFindWithTimeout() { $id = DB::collection('users')->insertGetId(['name' => 'John Doe']); - $subscriber = new class implements CommandSubscriber - { + $subscriber = new class implements CommandSubscriber { public function commandStarted(CommandStartedEvent $event) { if ($event->getCommandName() !== 'find') { @@ -830,7 +829,7 @@ public function testValue() public function testHintOptions() { DB::collection('items')->insert([ - ['name' => 'fork', 'tags' => ['sharp', 'pointy']], + ['name' => 'fork', 'tags' => ['sharp', 'pointy']], ['name' => 'spork', 'tags' => ['sharp', 'pointy', 'round', 'bowl']], ['name' => 'spoon', 'tags' => ['round', 'bowl']], ]);