Skip to content

Commit 491dbc2

Browse files
committed
Remove pipeline creation from query builder and add execution methods
1 parent 06da294 commit 491dbc2

File tree

4 files changed

+402
-919
lines changed

4 files changed

+402
-919
lines changed

src/Query/AggregationBuilder.php

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,28 @@
55
namespace MongoDB\Laravel\Query;
66

77
use Illuminate\Support\Collection as LaravelCollection;
8+
use Illuminate\Support\LazyCollection;
9+
use InvalidArgumentException;
10+
use Iterator;
811
use MongoDB\Builder\BuilderEncoder;
912
use MongoDB\Builder\Stage\FluentFactoryTrait;
1013
use MongoDB\Collection as MongoDBCollection;
14+
use MongoDB\Driver\CursorInterface;
1115
use MongoDB\Laravel\Collection as LaravelMongoDBCollection;
1216

1317
use function array_replace;
1418
use function collect;
19+
use function sprintf;
20+
use function str_starts_with;
1521

16-
final class AggregationBuilder
22+
class AggregationBuilder
1723
{
1824
use FluentFactoryTrait;
1925

2026
public function __construct(
2127
private MongoDBCollection|LaravelMongoDBCollection $collection,
22-
array $pipeline = [],
23-
private array $options = [],
28+
private readonly array $options = [],
2429
) {
25-
$this->pipeline = $pipeline;
2630
}
2731

2832
/**
@@ -31,6 +35,10 @@ public function __construct(
3135
*/
3236
public function addRawStage(string $operator, mixed $value): static
3337
{
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+
3442
$this->pipeline[] = [$operator => $value];
3543

3644
return $this;
@@ -39,7 +47,42 @@ public function addRawStage(string $operator, mixed $value): static
3947
/**
4048
* Execute the aggregation pipeline and return the results.
4149
*/
42-
public function get(array $options = []): LaravelCollection
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+
->cursor($options)
79+
->first();
80+
}
81+
82+
/**
83+
* Execute the aggregation pipeline and return MongoDB cursor.
84+
*/
85+
private function execute(array $options): CursorInterface&Iterator
4386
{
4487
$encoder = new BuilderEncoder();
4588
$pipeline = $encoder->encode($this->getPipeline());
@@ -50,6 +93,6 @@ public function get(array $options = []): LaravelCollection
5093
$options,
5194
);
5295

53-
return collect($this->collection->aggregate($pipeline, $options)->toArray());
96+
return $this->collection->aggregate($pipeline, $options);
5497
}
5598
}

src/Query/Builder.php

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@
2121
use MongoDB\BSON\ObjectID;
2222
use MongoDB\BSON\Regex;
2323
use MongoDB\BSON\UTCDateTime;
24-
use MongoDB\Builder\Accumulator;
25-
use MongoDB\Builder\Expression\FieldPath;
2624
use MongoDB\Builder\Stage\FluentFactoryTrait;
27-
use MongoDB\Builder\Variable;
2825
use MongoDB\Driver\Cursor;
2926
use Override;
3027
use RuntimeException;
@@ -284,59 +281,6 @@ public function dump(mixed ...$args)
284281
return $this;
285282
}
286283

287-
private function getAggregationBuilder(): AggregationBuilder
288-
{
289-
if (! trait_exists(FluentFactoryTrait::class)) {
290-
throw new BadMethodCallException('Aggregation builder requires package mongodb/builder 0.2+');
291-
}
292-
293-
$agg = new AggregationBuilder($this->collection, [], $this->options);
294-
295-
$wheres = $this->compileWheres();
296-
297-
if (count($wheres)) {
298-
$agg->match(...$wheres);
299-
}
300-
301-
// Distinct query
302-
if ($this->distinct) {
303-
// Return distinct results directly
304-
$column = $this->columns[0] ?? '_id';
305-
306-
$agg->group(
307-
_id: \MongoDB\Builder\Expression::fieldPath($column),
308-
_document: Accumulator::first(Variable::root()),
309-
);
310-
$agg->replaceRoot(
311-
newRoot: new FieldPath('_document'),
312-
);
313-
}
314-
315-
if ($this->orders) {
316-
$agg->sort(...$this->orders);
317-
}
318-
319-
if ($this->offset) {
320-
$agg->skip($this->offset);
321-
}
322-
323-
if ($this->limit) {
324-
$agg->limit($this->limit);
325-
}
326-
327-
// Normal query
328-
// Add custom projections.
329-
if ($this->projections || $this->columns) {
330-
$columns = in_array('*', $this->columns) ? [] : $this->columns;
331-
$projection = array_fill_keys($columns, true) + $this->projections;
332-
if ($projection) {
333-
$agg->project(...$projection);
334-
}
335-
}
336-
337-
return $agg;
338-
}
339-
340284
/**
341285
* Return the MongoDB query to be run in the form of an element array like ['method' => [arguments]].
342286
*
@@ -597,14 +541,23 @@ public function generateCacheKey()
597541
}
598542

599543
/** @return ($function is null ? AggregationBuilder : mixed) */
600-
public function aggregate($function = null, $columns = [])
544+
public function aggregate($function = null, $columns = ['*'])
601545
{
602546
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+
603552
if ($columns !== [] && $columns !== ['*']) {
604553
throw new InvalidArgumentException('Columns cannot be specified to create an aggregation builder. Add a $project stage instead.');
605554
}
606555

607-
return $this->getAggregationBuilder();
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);
608561
}
609562

610563
$this->aggregate = [

tests/Query/AggregationBuilderTest.php

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use DateTimeImmutable;
88
use Illuminate\Support\Collection;
9+
use Illuminate\Support\LazyCollection;
10+
use InvalidArgumentException;
911
use MongoDB\BSON\Document;
1012
use MongoDB\BSON\ObjectId;
1113
use MongoDB\BSON\UTCDateTime;
@@ -23,24 +25,25 @@ class AggregationBuilderTest extends TestCase
2325
public function tearDown(): void
2426
{
2527
User::truncate();
28+
29+
parent::tearDown();
2630
}
2731

28-
public function testCreateFromQueryBuilder(): void
32+
public function testCreateAggregationBuilder(): void
2933
{
3034
User::insert([
3135
['name' => 'John Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1989-01-01'))],
3236
['name' => 'Jane Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1990-01-01'))],
3337
]);
3438

3539
// Create the aggregation pipeline from the query builder
36-
$pipeline = User::where('name', 'John Doe')
37-
->limit(10)
38-
->offset(0)
39-
->aggregate();
40+
$pipeline = User::aggregate();
4041

4142
$this->assertInstanceOf(AggregationBuilder::class, $pipeline);
4243

4344
$pipeline
45+
->match(name: 'John Doe')
46+
->limit(10)
4447
->addFields(
4548
// Requires MongoDB 5.0+
4649
year: Expression::year(
@@ -67,13 +70,22 @@ public function testCreateFromQueryBuilder(): void
6770

6871
// Execute the pipeline and validate the results
6972
$results = $pipeline->get();
70-
7173
$this->assertInstanceOf(Collection::class, $results);
7274
$this->assertCount(1, $results);
7375
$this->assertInstanceOf(ObjectId::class, $results->first()['_id']);
7476
$this->assertSame('John Doe', $results->first()['name']);
7577
$this->assertIsInt($results->first()['year']);
7678
$this->assertArrayNotHasKey('birthday', $results->first());
79+
80+
// Execute the pipeline and validate the results in a lazy collection
81+
$results = $pipeline->cursor();
82+
$this->assertInstanceOf(LazyCollection::class, $results);
83+
84+
// Execute the pipeline and return the first result
85+
$result = $pipeline->first();
86+
$this->assertIsArray($result);
87+
$this->assertInstanceOf(ObjectId::class, $result['_id']);
88+
$this->assertSame('John Doe', $result['name']);
7789
}
7890

7991
public function testAddRawStage(): void
@@ -95,47 +107,15 @@ public function testAddRawStage(): void
95107
$this->assertSamePipeline($expected, $pipeline->getPipeline());
96108
}
97109

98-
public function testDistinct(): void
110+
public function testAddRawStageInvalid(): void
99111
{
100-
User::insert([
101-
['name' => 'Jane Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1991-01-01'))],
102-
['name' => 'John Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1989-01-01'))],
103-
['name' => 'John Doe', 'birthday' => new UTCDateTime(new DateTimeImmutable('1990-01-01'))],
104-
]);
105-
106-
// Create the aggregation pipeline from the query builder
107-
$pipeline = User::orderBy('name')
108-
->distinct('name')
109-
->select('name', 'birthday')
110-
->aggregate();
111-
112-
$expected = [
113-
[
114-
'$group' => [
115-
'_id' => '$name',
116-
'_document' => ['$first' => '$$ROOT'],
117-
],
118-
],
119-
[
120-
'$replaceRoot' => ['newRoot' => '$_document'],
121-
],
122-
[
123-
'$sort' => ['name' => 1],
124-
],
125-
[
126-
'$project' => ['birthday' => true, 'name' => true],
127-
],
128-
];
129-
130-
$this->assertSamePipeline($expected, $pipeline->getPipeline());
112+
$collection = $this->createMock(MongoDBCollection::class);
131113

132-
$results = $pipeline->get();
114+
$pipeline = new AggregationBuilder($collection);
133115

134-
$this->assertCount(2, $results);
135-
$this->assertSame('Jane Doe', $results[0]['name']);
136-
$this->assertSame('1991-01-01', $results[0]['birthday']->toDateTime()->format('Y-m-d'));
137-
$this->assertSame('John Doe', $results[1]['name']);
138-
$this->assertSame('1989-01-01', $results[1]['birthday']->toDateTime()->format('Y-m-d'));
116+
$this->expectException(InvalidArgumentException::class);
117+
$this->expectExceptionMessage('The stage name "match" is invalid. It must start with a "$" sign.');
118+
$pipeline->addRawStage('match', ['name' => 'John Doe']);
139119
}
140120

141121
private static function assertSamePipeline(array $expected, Pipeline $pipeline): void

0 commit comments

Comments
 (0)