diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1fe6c95..edd119625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. ## [unreleased] +* New aggregation pipeline builder by @GromNaN in [#2738](https://github.com/mongodb/laravel-mongodb/pull/2738) ## [4.2.0] - 2024-12-14 diff --git a/composer.json b/composer.json index d19c1149a..3769bdfe6 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "mongodb/mongodb": "^1.15" }, "require-dev": { + "mongodb/builder": "^0.2", "phpunit/phpunit": "^10.3", "orchestra/testbench": "^8.0|^9.0", "mockery/mockery": "^1.4.4", @@ -38,6 +39,9 @@ "spatie/laravel-query-builder": "^5.6", "phpstan/phpstan": "^1.10" }, + "suggest": { + "mongodb/builder": "Provides a fluent aggregation builder for MongoDB pipelines" + }, "minimum-stability": "dev", "replace": { "jenssegers/mongodb": "self.version" diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 7ea18dfa9..aa01bee6c 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -11,6 +11,7 @@ use MongoDB\Laravel\Collection; use MongoDB\Laravel\Helpers\QueriesRelationships; use MongoDB\Laravel\Internal\FindAndModifyCommandSubscriber; +use MongoDB\Laravel\Query\AggregationBuilder; use MongoDB\Model\BSONDocument; use MongoDB\Operation\FindOneAndUpdate; @@ -56,6 +57,18 @@ class Builder extends EloquentBuilder 'tomql', ]; + /** + * @return ($function is null ? AggregationBuilder : self) + * + * @inheritdoc + */ + public function aggregate($function = null, $columns = ['*']) + { + $result = $this->toBase()->aggregate($function, $columns); + + return $result ?: $this; + } + /** @inheritdoc */ public function update(array $values, array $options = []) { @@ -215,7 +228,7 @@ public function createOrFirst(array $attributes = [], array $values = []): Model $document = $collection->findOneAndUpdate( $attributes, // Before MongoDB 5.0, $setOnInsert requires a non-empty document. - // This is should not be an issue as $values includes the query filter. + // This should not be an issue as $values includes the query filter. ['$setOnInsert' => (object) $values], [ 'upsert' => true, diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 78999bce8..de5ddc3ea 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -49,6 +49,7 @@ use function uniqid; use function var_export; +/** @mixin Builder */ abstract class Model extends BaseModel { use HybridRelations; diff --git a/src/Query/AggregationBuilder.php b/src/Query/AggregationBuilder.php new file mode 100644 index 000000000..ad0c195d4 --- /dev/null +++ b/src/Query/AggregationBuilder.php @@ -0,0 +1,98 @@ +pipeline[] = [$operator => $value]; + + return $this; + } + + /** + * Execute the aggregation pipeline and return the results. + */ + public function get(array $options = []): LaravelCollection|LazyCollection + { + $cursor = $this->execute($options); + + return collect($cursor->toArray()); + } + + /** + * Execute the aggregation pipeline and return the results in a lazy collection. + */ + public function cursor($options = []): LazyCollection + { + $cursor = $this->execute($options); + + return LazyCollection::make(function () use ($cursor) { + foreach ($cursor as $item) { + yield $item; + } + }); + } + + /** + * Execute the aggregation pipeline and return the first result. + */ + public function first(array $options = []): mixed + { + return (clone $this) + ->limit(1) + ->get($options) + ->first(); + } + + /** + * Execute the aggregation pipeline and return MongoDB cursor. + */ + private function execute(array $options): CursorInterface&Iterator + { + $encoder = new BuilderEncoder(); + $pipeline = $encoder->encode($this->getPipeline()); + + $options = array_replace( + ['typeMap' => ['root' => 'array', 'document' => 'array']], + $this->options, + $options, + ); + + return $this->collection->aggregate($pipeline, $options); + } +} diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 0f05f4577..89faa4b17 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -21,6 +21,7 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Builder\Stage\FluentFactoryTrait; use MongoDB\Driver\Cursor; use Override; use RuntimeException; @@ -65,6 +66,7 @@ use function strlen; use function strtolower; use function substr; +use function trait_exists; use function var_export; class Builder extends BaseBuilder @@ -74,7 +76,7 @@ class Builder extends BaseBuilder /** * The database collection. * - * @var \MongoDB\Collection + * @var \MongoDB\Laravel\Collection */ protected $collection; @@ -83,7 +85,7 @@ class Builder extends BaseBuilder * * @var array */ - public $projections; + public $projections = []; /** * The maximum amount of seconds to allow the query to run. @@ -538,9 +540,26 @@ public function generateCacheKey() return md5(serialize(array_values($key))); } - /** @inheritdoc */ - public function aggregate($function, $columns = []) + /** @return ($function is null ? AggregationBuilder : mixed) */ + public function aggregate($function = null, $columns = ['*']) { + if ($function === null) { + if (! trait_exists(FluentFactoryTrait::class)) { + // This error will be unreachable when the mongodb/builder package will be merged into mongodb/mongodb + throw new BadMethodCallException('Aggregation builder requires package mongodb/builder 0.2+'); + } + + if ($columns !== ['*']) { + throw new InvalidArgumentException('Columns cannot be specified to create an aggregation builder. Add a $project stage instead.'); + } + + if ($this->wheres) { + throw new BadMethodCallException('Aggregation builder does not support previous query-builder instructions. Use a $match stage instead.'); + } + + return new AggregationBuilder($this->collection, $this->options); + } + $this->aggregate = [ 'function' => $function, 'columns' => $columns, diff --git a/tests/Query/AggregationBuilderTest.php b/tests/Query/AggregationBuilderTest.php new file mode 100644 index 000000000..b3828597d --- /dev/null +++ b/tests/Query/AggregationBuilderTest.php @@ -0,0 +1,147 @@ + 'John Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1989-01-01'))], + ['name' => 'Jane Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1990-01-01'))], + ]); + + // Create the aggregation pipeline from the query builder + $pipeline = User::aggregate(); + + $this->assertInstanceOf(AggregationBuilder::class, $pipeline); + + $pipeline + ->match(name: 'John Doe') + ->limit(10) + ->addFields( + // Requires MongoDB 5.0+ + year: Expression::year( + Expression::dateFieldPath('birthday'), + ), + ) + ->sort(year: Sort::Desc, name: Sort::Asc) + ->unset('birthday'); + + // Compare with the expected pipeline + $expected = [ + ['$match' => ['name' => 'John Doe']], + ['$limit' => 10], + [ + '$addFields' => [ + 'year' => ['$year' => ['date' => '$birthday']], + ], + ], + ['$sort' => ['year' => -1, 'name' => 1]], + ['$unset' => ['birthday']], + ]; + + $this->assertSamePipeline($expected, $pipeline->getPipeline()); + + // Execute the pipeline and validate the results + $results = $pipeline->get(); + $this->assertInstanceOf(Collection::class, $results); + $this->assertCount(1, $results); + $this->assertInstanceOf(ObjectId::class, $results->first()['_id']); + $this->assertSame('John Doe', $results->first()['name']); + $this->assertIsInt($results->first()['year']); + $this->assertArrayNotHasKey('birthday', $results->first()); + + // Execute the pipeline and validate the results in a lazy collection + $results = $pipeline->cursor(); + $this->assertInstanceOf(LazyCollection::class, $results); + + // Execute the pipeline and return the first result + $result = $pipeline->first(); + $this->assertIsArray($result); + $this->assertInstanceOf(ObjectId::class, $result['_id']); + $this->assertSame('John Doe', $result['name']); + } + + public function testAddRawStage(): void + { + $collection = $this->createMock(MongoDBCollection::class); + + $pipeline = new AggregationBuilder($collection); + $pipeline + ->addRawStage('$match', ['name' => 'John Doe']) + ->addRawStage('$limit', 10) + ->addRawStage('$replaceRoot', (object) ['newRoot' => '$$ROOT']); + + $expected = [ + ['$match' => ['name' => 'John Doe']], + ['$limit' => 10], + ['$replaceRoot' => ['newRoot' => '$$ROOT']], + ]; + + $this->assertSamePipeline($expected, $pipeline->getPipeline()); + } + + public function testAddRawStageInvalid(): void + { + $collection = $this->createMock(MongoDBCollection::class); + + $pipeline = new AggregationBuilder($collection); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The stage name "match" is invalid. It must start with a "$" sign.'); + $pipeline->addRawStage('match', ['name' => 'John Doe']); + } + + public function testColumnsCannotBeSpecifiedToCreateAnAggregationBuilder(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Columns cannot be specified to create an aggregation builder.'); + User::aggregate(null, ['name']); + } + + public function testAggrecationBuilderDoesNotSupportPreviousQueryBuilderInstructions(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Aggregation builder does not support previous query-builder instructions.'); + User::where('name', 'John Doe')->aggregate(); + } + + private static function assertSamePipeline(array $expected, Pipeline $pipeline): void + { + $expected = Document::fromPHP(['pipeline' => $expected])->toCanonicalExtendedJSON(); + + $codec = new BuilderEncoder(); + $actual = $codec->encode($pipeline); + // Normalize with BSON round-trip + $actual = Document::fromPHP(['pipeline' => $actual])->toCanonicalExtendedJSON(); + + self::assertJsonStringEqualsJsonString($expected, $actual); + } +}