diff --git a/docs/repository.md b/docs/repository.md index f73bb3a..19b1836 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -187,6 +187,49 @@ Every property in a filter or sort must exist on the document. An unknown path r `UnknownPropertyPath`, which lists the properties that are available at that level. ::: +## Aggregations + +`aggregate()` runs an aggregation pipeline against the collection and hydrates every result document +into a class you choose. This gives you a read-only "view" model: a plain class shaped like the +pipeline output, with no `#[Document]` attribute and no id. + +```php +final readonly class SkillPopularity +{ + public function __construct( + public string $skill, + public int $count, + ) { + } +} + +$views = iterator_to_array( + $repository->aggregate([ + ['$unwind' => '$skills'], + ['$group' => ['_id' => '$skills', 'count' => ['$sum' => 1]]], + ['$project' => ['_id' => 0, 'skill' => '$_id', 'count' => 1]], + ['$sort' => ['skill' => 1]], + ], SkillPopularity::class), + false, +); +``` + +Like `findBy()`, this returns a generator, so wrap it in `iterator_to_array()` when you need an +array. Each document is hydrated with the same hydrator used for documents, so normalizers and value +objects on the result class work as usual. + +:::warning +Unlike `findBy()`, the pipeline is passed to the backend untouched. Its stages use the **stored +field names**, not property names, and there is no mapping through the [field mapping](field-mapping.md). +Shape the output with a `$project` stage so its keys match the properties of your result class. +::: + +:::note +MongoDB and [Rango](https://github.com/patchlevel/rango/) share a common subset of pipeline stages +(`$match`, `$sort`, `$limit`, `$skip`, `$project`, `$unwind`, `$group`, `$lookup`). Stages or +operators beyond that subset only work on MongoDB. +::: + ## Removing `remove()` deletes documents by id and accepts one or many ids. diff --git a/src/Repository/MongoDBRepository.php b/src/Repository/MongoDBRepository.php index e0db8b6..f5b4815 100644 --- a/src/Repository/MongoDBRepository.php +++ b/src/Repository/MongoDBRepository.php @@ -27,6 +27,8 @@ private DocumentHydrator $hydrator; + private HydratorWithContext $viewHydrator; + /** @param DocumentMetadata $metadata */ public function __construct( private Database $database, @@ -34,6 +36,7 @@ public function __construct( HydratorWithContext $hydrator, ) { $this->hydrator = new DocumentHydrator($hydrator, $metadata); + $this->viewHydrator = $hydrator; $this->collection = $this->database->selectCollection($this->metadata->collection); } @@ -228,6 +231,26 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec return $this->hydrator->hydrate($this->metadata->className, $data); } + /** + * @param list> $pipeline + * @param class-string $into + * + * @return iterable + * + * @template V of object + */ + public function aggregate(array $pipeline, string $into): iterable + { + $cursor = $this->collection->aggregate($pipeline, [ + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]); + + foreach ($cursor as $document) { + /** @var array $document */ + yield $this->viewHydrator->hydrate($into, $document); + } + } + public function count(): int { return $this->collection->countDocuments(); diff --git a/src/Repository/RangoRepository.php b/src/Repository/RangoRepository.php index c90b8ac..c0aaa88 100644 --- a/src/Repository/RangoRepository.php +++ b/src/Repository/RangoRepository.php @@ -26,6 +26,8 @@ private DocumentHydrator $hydrator; + private HydratorWithContext $viewHydrator; + /** @param DocumentMetadata $metadata */ public function __construct( private Database $database, @@ -33,6 +35,7 @@ public function __construct( HydratorWithContext $hydrator, ) { $this->hydrator = new DocumentHydrator($hydrator, $metadata); + $this->viewHydrator = $hydrator; $this->collection = $this->database->getCollection($this->metadata->collection); } @@ -221,6 +224,23 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec return $this->hydrator->hydrate($this->metadata->className, $data); } + /** + * @param list> $pipeline + * @param class-string $into + * + * @return iterable + * + * @template V of object + */ + public function aggregate(array $pipeline, string $into): iterable + { + $cursor = $this->collection->aggregate($pipeline); + + foreach ($cursor as $document) { + yield $this->viewHydrator->hydrate($into, $document); + } + } + public function count(): int { return $this->collection->countDocuments(); diff --git a/src/Repository/Repository.php b/src/Repository/Repository.php index f23de2b..d14df7f 100644 --- a/src/Repository/Repository.php +++ b/src/Repository/Repository.php @@ -49,6 +49,16 @@ public function findBy( */ public function findOneBy(array $filter = [], array|null $orderBy = null): object|null; + /** + * @param list> $pipeline + * @param class-string $into + * + * @return iterable + * + * @template V of object + */ + public function aggregate(array $pipeline, string $into): iterable; + public function count(): int; public function has(string $id): bool; diff --git a/tests/Integration/Fixtures/ProfileSummary.php b/tests/Integration/Fixtures/ProfileSummary.php new file mode 100644 index 0000000..57d70a6 --- /dev/null +++ b/tests/Integration/Fixtures/ProfileSummary.php @@ -0,0 +1,14 @@ +repositoryManager->get(Profile::class); + + $repository->collection()->insertMany([ + ['_id' => 'r-1', 'name' => 'Rango', 'status' => 'active', 'skills' => ['php', 'go']], + ['_id' => 'r-2', 'name' => 'Beans', 'status' => 'active', 'skills' => ['php']], + ['_id' => 'r-3', 'name' => 'Elsa', 'status' => 'inactive', 'skills' => ['go']], + ]); + + $views = iterator_to_array($repository->aggregate([ + ['$unwind' => '$skills'], + ['$group' => ['_id' => '$skills', 'count' => ['$sum' => 1]]], + ['$project' => ['_id' => 0, 'skill' => '$_id', 'count' => 1]], + ['$sort' => ['skill' => 1]], + ], SkillPopularity::class), false); + + self::assertEquals([ + new SkillPopularity('go', 2), + new SkillPopularity('php', 2), + ], $views); + } } diff --git a/tests/Integration/RepositoryTestCase.php b/tests/Integration/RepositoryTestCase.php index a9f0b6e..7434481 100644 --- a/tests/Integration/RepositoryTestCase.php +++ b/tests/Integration/RepositoryTestCase.php @@ -8,6 +8,7 @@ use Patchlevel\ODM\Repository\MongoDBRepositoryManager; use Patchlevel\ODM\Repository\RangoRepositoryManager; use Patchlevel\ODM\Tests\Integration\Fixtures\Profile; +use Patchlevel\ODM\Tests\Integration\Fixtures\ProfileSummary; use Patchlevel\ODM\Tests\Integration\Fixtures\Skill; use Patchlevel\ODM\Tests\Integration\Fixtures\Status; use Patchlevel\ODM\Tests\Integration\Fixtures\UniqueProfile; @@ -365,6 +366,41 @@ public function testNotFindOne(): void self::assertNull($result); } + public function testAggregateIntoView(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Elsa', + 'status' => 'active', + 'skills' => ['go'], + ]); + + $views = iterator_to_array($repository->aggregate([ + ['$match' => ['status' => 'active']], + ['$project' => ['name' => 1, 'status' => 1]], + ['$sort' => ['name' => 1]], + ], ProfileSummary::class), false); + + self::assertCount(2, $views); + self::assertContainsOnlyInstancesOf(ProfileSummary::class, $views); + self::assertEquals(new ProfileSummary('Elsa', Status::ACTIVE), $views[0]); + self::assertEquals(new ProfileSummary('Rango', Status::ACTIVE), $views[1]); + } + public function testRemove(): void { $repository = $this->repositoryManager->get(Profile::class);