Skip to content

Commit fdfb5e5

Browse files
authored
PHPORM-155 Fluent aggregation builder (#2738)
1 parent 00018fb commit fdfb5e5

File tree

7 files changed

+288
-5
lines changed

7 files changed

+288
-5
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file.
33

44
## [unreleased]
55

6+
* New aggregation pipeline builder by @GromNaN in [#2738](https://github.com/mongodb/laravel-mongodb/pull/2738)
67

78
## [4.2.0] - 2024-12-14
89

Diff for: composer.json

+4
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@
3131
"mongodb/mongodb": "^1.15"
3232
},
3333
"require-dev": {
34+
"mongodb/builder": "^0.2",
3435
"phpunit/phpunit": "^10.3",
3536
"orchestra/testbench": "^8.0|^9.0",
3637
"mockery/mockery": "^1.4.4",
3738
"doctrine/coding-standard": "12.0.x-dev",
3839
"spatie/laravel-query-builder": "^5.6",
3940
"phpstan/phpstan": "^1.10"
4041
},
42+
"suggest": {
43+
"mongodb/builder": "Provides a fluent aggregation builder for MongoDB pipelines"
44+
},
4145
"minimum-stability": "dev",
4246
"replace": {
4347
"jenssegers/mongodb": "self.version"

Diff for: src/Eloquent/Builder.php

+14-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use MongoDB\Laravel\Collection;
1212
use MongoDB\Laravel\Helpers\QueriesRelationships;
1313
use MongoDB\Laravel\Internal\FindAndModifyCommandSubscriber;
14+
use MongoDB\Laravel\Query\AggregationBuilder;
1415
use MongoDB\Model\BSONDocument;
1516
use MongoDB\Operation\FindOneAndUpdate;
1617

@@ -56,6 +57,18 @@ class Builder extends EloquentBuilder
5657
'tomql',
5758
];
5859

60+
/**
61+
* @return ($function is null ? AggregationBuilder : self)
62+
*
63+
* @inheritdoc
64+
*/
65+
public function aggregate($function = null, $columns = ['*'])
66+
{
67+
$result = $this->toBase()->aggregate($function, $columns);
68+
69+
return $result ?: $this;
70+
}
71+
5972
/** @inheritdoc */
6073
public function update(array $values, array $options = [])
6174
{
@@ -215,7 +228,7 @@ public function createOrFirst(array $attributes = [], array $values = []): Model
215228
$document = $collection->findOneAndUpdate(
216229
$attributes,
217230
// Before MongoDB 5.0, $setOnInsert requires a non-empty document.
218-
// This is should not be an issue as $values includes the query filter.
231+
// This should not be an issue as $values includes the query filter.
219232
['$setOnInsert' => (object) $values],
220233
[
221234
'upsert' => true,

Diff for: src/Eloquent/Model.php

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use function uniqid;
5050
use function var_export;
5151

52+
/** @mixin Builder */
5253
abstract class Model extends BaseModel
5354
{
5455
use HybridRelations;

Diff for: src/Query/AggregationBuilder.php

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Query;
6+
7+
use Illuminate\Support\Collection as LaravelCollection;
8+
use Illuminate\Support\LazyCollection;
9+
use InvalidArgumentException;
10+
use Iterator;
11+
use MongoDB\Builder\BuilderEncoder;
12+
use MongoDB\Builder\Stage\FluentFactoryTrait;
13+
use MongoDB\Collection as MongoDBCollection;
14+
use MongoDB\Driver\CursorInterface;
15+
use MongoDB\Laravel\Collection as LaravelMongoDBCollection;
16+
17+
use function array_replace;
18+
use function collect;
19+
use function sprintf;
20+
use function str_starts_with;
21+
22+
class AggregationBuilder
23+
{
24+
use FluentFactoryTrait;
25+
26+
public function __construct(
27+
private MongoDBCollection|LaravelMongoDBCollection $collection,
28+
private readonly array $options = [],
29+
) {
30+
}
31+
32+
/**
33+
* Add a stage without using the builder. Necessary if the stage is built
34+
* outside the builder, or it is not yet supported by the library.
35+
*/
36+
public function addRawStage(string $operator, mixed $value): static
37+
{
38+
if (! str_starts_with($operator, '$')) {
39+
throw new InvalidArgumentException(sprintf('The stage name "%s" is invalid. It must start with a "$" sign.', $operator));
40+
}
41+
42+
$this->pipeline[] = [$operator => $value];
43+
44+
return $this;
45+
}
46+
47+
/**
48+
* Execute the aggregation pipeline and return the results.
49+
*/
50+
public function get(array $options = []): LaravelCollection|LazyCollection
51+
{
52+
$cursor = $this->execute($options);
53+
54+
return collect($cursor->toArray());
55+
}
56+
57+
/**
58+
* Execute the aggregation pipeline and return the results in a lazy collection.
59+
*/
60+
public function cursor($options = []): LazyCollection
61+
{
62+
$cursor = $this->execute($options);
63+
64+
return LazyCollection::make(function () use ($cursor) {
65+
foreach ($cursor as $item) {
66+
yield $item;
67+
}
68+
});
69+
}
70+
71+
/**
72+
* Execute the aggregation pipeline and return the first result.
73+
*/
74+
public function first(array $options = []): mixed
75+
{
76+
return (clone $this)
77+
->limit(1)
78+
->get($options)
79+
->first();
80+
}
81+
82+
/**
83+
* Execute the aggregation pipeline and return MongoDB cursor.
84+
*/
85+
private function execute(array $options): CursorInterface&Iterator
86+
{
87+
$encoder = new BuilderEncoder();
88+
$pipeline = $encoder->encode($this->getPipeline());
89+
90+
$options = array_replace(
91+
['typeMap' => ['root' => 'array', 'document' => 'array']],
92+
$this->options,
93+
$options,
94+
);
95+
96+
return $this->collection->aggregate($pipeline, $options);
97+
}
98+
}

Diff for: src/Query/Builder.php

+23-4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use MongoDB\BSON\ObjectID;
2222
use MongoDB\BSON\Regex;
2323
use MongoDB\BSON\UTCDateTime;
24+
use MongoDB\Builder\Stage\FluentFactoryTrait;
2425
use MongoDB\Driver\Cursor;
2526
use Override;
2627
use RuntimeException;
@@ -65,6 +66,7 @@
6566
use function strlen;
6667
use function strtolower;
6768
use function substr;
69+
use function trait_exists;
6870
use function var_export;
6971

7072
class Builder extends BaseBuilder
@@ -74,7 +76,7 @@ class Builder extends BaseBuilder
7476
/**
7577
* The database collection.
7678
*
77-
* @var \MongoDB\Collection
79+
* @var \MongoDB\Laravel\Collection
7880
*/
7981
protected $collection;
8082

@@ -83,7 +85,7 @@ class Builder extends BaseBuilder
8385
*
8486
* @var array
8587
*/
86-
public $projections;
88+
public $projections = [];
8789

8890
/**
8991
* The maximum amount of seconds to allow the query to run.
@@ -538,9 +540,26 @@ public function generateCacheKey()
538540
return md5(serialize(array_values($key)));
539541
}
540542

541-
/** @inheritdoc */
542-
public function aggregate($function, $columns = [])
543+
/** @return ($function is null ? AggregationBuilder : mixed) */
544+
public function aggregate($function = null, $columns = ['*'])
543545
{
546+
if ($function === null) {
547+
if (! trait_exists(FluentFactoryTrait::class)) {
548+
// This error will be unreachable when the mongodb/builder package will be merged into mongodb/mongodb
549+
throw new BadMethodCallException('Aggregation builder requires package mongodb/builder 0.2+');
550+
}
551+
552+
if ($columns !== ['*']) {
553+
throw new InvalidArgumentException('Columns cannot be specified to create an aggregation builder. Add a $project stage instead.');
554+
}
555+
556+
if ($this->wheres) {
557+
throw new BadMethodCallException('Aggregation builder does not support previous query-builder instructions. Use a $match stage instead.');
558+
}
559+
560+
return new AggregationBuilder($this->collection, $this->options);
561+
}
562+
544563
$this->aggregate = [
545564
'function' => $function,
546565
'columns' => $columns,

Diff for: tests/Query/AggregationBuilderTest.php

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tests\Query;
6+
7+
use BadMethodCallException;
8+
use DateTimeImmutable;
9+
use Illuminate\Support\Collection;
10+
use Illuminate\Support\LazyCollection;
11+
use InvalidArgumentException;
12+
use MongoDB\BSON\Document;
13+
use MongoDB\BSON\ObjectId;
14+
use MongoDB\BSON\UTCDateTime;
15+
use MongoDB\Builder\BuilderEncoder;
16+
use MongoDB\Builder\Expression;
17+
use MongoDB\Builder\Pipeline;
18+
use MongoDB\Builder\Type\Sort;
19+
use MongoDB\Collection as MongoDBCollection;
20+
use MongoDB\Laravel\Query\AggregationBuilder;
21+
use MongoDB\Laravel\Tests\Models\User;
22+
use MongoDB\Laravel\Tests\TestCase;
23+
24+
class AggregationBuilderTest extends TestCase
25+
{
26+
public function tearDown(): void
27+
{
28+
User::truncate();
29+
30+
parent::tearDown();
31+
}
32+
33+
public function testCreateAggregationBuilder(): void
34+
{
35+
User::insert([
36+
['name' => 'John Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1989-01-01'))],
37+
['name' => 'Jane Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1990-01-01'))],
38+
]);
39+
40+
// Create the aggregation pipeline from the query builder
41+
$pipeline = User::aggregate();
42+
43+
$this->assertInstanceOf(AggregationBuilder::class, $pipeline);
44+
45+
$pipeline
46+
->match(name: 'John Doe')
47+
->limit(10)
48+
->addFields(
49+
// Requires MongoDB 5.0+
50+
year: Expression::year(
51+
Expression::dateFieldPath('birthday'),
52+
),
53+
)
54+
->sort(year: Sort::Desc, name: Sort::Asc)
55+
->unset('birthday');
56+
57+
// Compare with the expected pipeline
58+
$expected = [
59+
['$match' => ['name' => 'John Doe']],
60+
['$limit' => 10],
61+
[
62+
'$addFields' => [
63+
'year' => ['$year' => ['date' => '$birthday']],
64+
],
65+
],
66+
['$sort' => ['year' => -1, 'name' => 1]],
67+
['$unset' => ['birthday']],
68+
];
69+
70+
$this->assertSamePipeline($expected, $pipeline->getPipeline());
71+
72+
// Execute the pipeline and validate the results
73+
$results = $pipeline->get();
74+
$this->assertInstanceOf(Collection::class, $results);
75+
$this->assertCount(1, $results);
76+
$this->assertInstanceOf(ObjectId::class, $results->first()['_id']);
77+
$this->assertSame('John Doe', $results->first()['name']);
78+
$this->assertIsInt($results->first()['year']);
79+
$this->assertArrayNotHasKey('birthday', $results->first());
80+
81+
// Execute the pipeline and validate the results in a lazy collection
82+
$results = $pipeline->cursor();
83+
$this->assertInstanceOf(LazyCollection::class, $results);
84+
85+
// Execute the pipeline and return the first result
86+
$result = $pipeline->first();
87+
$this->assertIsArray($result);
88+
$this->assertInstanceOf(ObjectId::class, $result['_id']);
89+
$this->assertSame('John Doe', $result['name']);
90+
}
91+
92+
public function testAddRawStage(): void
93+
{
94+
$collection = $this->createMock(MongoDBCollection::class);
95+
96+
$pipeline = new AggregationBuilder($collection);
97+
$pipeline
98+
->addRawStage('$match', ['name' => 'John Doe'])
99+
->addRawStage('$limit', 10)
100+
->addRawStage('$replaceRoot', (object) ['newRoot' => '$$ROOT']);
101+
102+
$expected = [
103+
['$match' => ['name' => 'John Doe']],
104+
['$limit' => 10],
105+
['$replaceRoot' => ['newRoot' => '$$ROOT']],
106+
];
107+
108+
$this->assertSamePipeline($expected, $pipeline->getPipeline());
109+
}
110+
111+
public function testAddRawStageInvalid(): void
112+
{
113+
$collection = $this->createMock(MongoDBCollection::class);
114+
115+
$pipeline = new AggregationBuilder($collection);
116+
117+
$this->expectException(InvalidArgumentException::class);
118+
$this->expectExceptionMessage('The stage name "match" is invalid. It must start with a "$" sign.');
119+
$pipeline->addRawStage('match', ['name' => 'John Doe']);
120+
}
121+
122+
public function testColumnsCannotBeSpecifiedToCreateAnAggregationBuilder(): void
123+
{
124+
$this->expectException(InvalidArgumentException::class);
125+
$this->expectExceptionMessage('Columns cannot be specified to create an aggregation builder.');
126+
User::aggregate(null, ['name']);
127+
}
128+
129+
public function testAggrecationBuilderDoesNotSupportPreviousQueryBuilderInstructions(): void
130+
{
131+
$this->expectException(BadMethodCallException::class);
132+
$this->expectExceptionMessage('Aggregation builder does not support previous query-builder instructions.');
133+
User::where('name', 'John Doe')->aggregate();
134+
}
135+
136+
private static function assertSamePipeline(array $expected, Pipeline $pipeline): void
137+
{
138+
$expected = Document::fromPHP(['pipeline' => $expected])->toCanonicalExtendedJSON();
139+
140+
$codec = new BuilderEncoder();
141+
$actual = $codec->encode($pipeline);
142+
// Normalize with BSON round-trip
143+
$actual = Document::fromPHP(['pipeline' => $actual])->toCanonicalExtendedJSON();
144+
145+
self::assertJsonStringEqualsJsonString($expected, $actual);
146+
}
147+
}

0 commit comments

Comments
 (0)