-
Notifications
You must be signed in to change notification settings - Fork 1.5k
PHPORM-155 Fluent aggregation builder #2738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
8a982fd
b1696f4
e247fe6
f9a44f0
6ad5a56
706365f
5b3d1cc
19542dd
ef935d7
4aba26d
55f05f4
ec251c7
cbd6284
363de83
f6b207c
4f0894f
a30d529
28ab37f
defb21f
8ebd6bb
8a0937e
a3ec4a4
554a1d7
d9a3273
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,7 @@ | |
use function uniqid; | ||
use function var_export; | ||
|
||
/** @mixin Builder */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Allows completions such as |
||
abstract class Model extends BaseModel | ||
{ | ||
use HybridRelations; | ||
|
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there any reason to allow I do realize OK to leave as-is. I just wanted to bring this up since it makes the API more permissive and I don't expect users would be constructing this on their own anyway. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I expect |
||
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]; | ||
GromNaN marked this conversation as resolved.
Show resolved
Hide resolved
jmikola marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was concerned of potential conflict with a stage name, but |
||
{ | ||
return (clone $this) | ||
->limit(1) | ||
->cursor($options) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you're going to apply |
||
->first(); | ||
} | ||
|
||
/** | ||
* Execute the aggregation pipeline and return MongoDB cursor. | ||
*/ | ||
private function execute(array $options): CursorInterface&Iterator | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Even though this is internal, I suppose you can't use Unrelated to this PR, but this made me wonder if we need to keep Nothing for you here. I merely wanted to cross-reference. |
||
{ | ||
$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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
jmikola marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
protected $collection; | ||
|
||
|
@@ -83,7 +85,7 @@ class Builder extends BaseBuilder | |
* | ||
* @var array | ||
*/ | ||
public $projections; | ||
public $projections = []; | ||
jmikola marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* 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) { | ||
GromNaN marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 !== [] && $columns !== ['*']) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there any reason to allow |
||
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, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace MongoDB\Laravel\Tests\Query; | ||
|
||
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']); | ||
jmikola marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
$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']); | ||
} | ||
|
||
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); | ||
jmikola marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.