Skip to content

Commit

Permalink
PHPORM-155 Fluent aggregation builder (#2738)
Browse files Browse the repository at this point in the history
  • Loading branch information
GromNaN authored Mar 26, 2024
1 parent 00018fb commit fdfb5e5
Show file tree
Hide file tree
Showing 7 changed files with 288 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@
"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",
"doctrine/coding-standard": "12.0.x-dev",
"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"
Expand Down
15 changes: 14 additions & 1 deletion src/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = [])
{
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
use function uniqid;
use function var_export;

/** @mixin Builder */
abstract class Model extends BaseModel
{
use HybridRelations;
Expand Down
98 changes: 98 additions & 0 deletions src/Query/AggregationBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace MongoDB\Laravel\Query;

use Illuminate\Support\Collection as LaravelCollection;
use Illuminate\Support\LazyCollection;
use InvalidArgumentException;
use Iterator;
use MongoDB\Builder\BuilderEncoder;
use MongoDB\Builder\Stage\FluentFactoryTrait;
use MongoDB\Collection as MongoDBCollection;
use MongoDB\Driver\CursorInterface;
use MongoDB\Laravel\Collection as LaravelMongoDBCollection;

use function array_replace;
use function collect;
use function sprintf;
use function str_starts_with;

class AggregationBuilder
{
use FluentFactoryTrait;

public function __construct(
private MongoDBCollection|LaravelMongoDBCollection $collection,
private readonly array $options = [],
) {
}

/**
* Add a stage without using the builder. Necessary if the stage is built
* outside the builder, or it is not yet supported by the library.
*/
public function addRawStage(string $operator, mixed $value): static
{
if (! str_starts_with($operator, '$')) {
throw new InvalidArgumentException(sprintf('The stage name "%s" is invalid. It must start with a "$" sign.', $operator));
}

$this->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);
}
}
27 changes: 23 additions & 4 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -74,7 +76,7 @@ class Builder extends BaseBuilder
/**
* The database collection.
*
* @var \MongoDB\Collection
* @var \MongoDB\Laravel\Collection
*/
protected $collection;

Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
147 changes: 147 additions & 0 deletions tests/Query/AggregationBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

declare(strict_types=1);

namespace MongoDB\Laravel\Tests\Query;

use BadMethodCallException;
use DateTimeImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use InvalidArgumentException;
use MongoDB\BSON\Document;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Builder\BuilderEncoder;
use MongoDB\Builder\Expression;
use MongoDB\Builder\Pipeline;
use MongoDB\Builder\Type\Sort;
use MongoDB\Collection as MongoDBCollection;
use MongoDB\Laravel\Query\AggregationBuilder;
use MongoDB\Laravel\Tests\Models\User;
use MongoDB\Laravel\Tests\TestCase;

class AggregationBuilderTest extends TestCase
{
public function tearDown(): void
{
User::truncate();

parent::tearDown();
}

public function testCreateAggregationBuilder(): void
{
User::insert([
['name' => '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);
}
}

0 comments on commit fdfb5e5

Please sign in to comment.