-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
PHPORM-155 Fluent aggregation builder (#2738)
- Loading branch information
Showing
7 changed files
with
288 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |