Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions src/Repository/MongoDBRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@

private DocumentHydrator $hydrator;

private HydratorWithContext $viewHydrator;

/** @param DocumentMetadata<T> $metadata */
public function __construct(
private Database $database,
private DocumentMetadata $metadata,
HydratorWithContext $hydrator,
) {
$this->hydrator = new DocumentHydrator($hydrator, $metadata);
$this->viewHydrator = $hydrator;
$this->collection = $this->database->selectCollection($this->metadata->collection);
}

Expand Down Expand Up @@ -228,6 +231,26 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec
return $this->hydrator->hydrate($this->metadata->className, $data);
}

/**
* @param list<array<string, mixed>> $pipeline
* @param class-string<V> $into
*
* @return iterable<V>
*
* @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<string, mixed> $document */
yield $this->viewHydrator->hydrate($into, $document);
}
}

public function count(): int
{
return $this->collection->countDocuments();
Expand Down
20 changes: 20 additions & 0 deletions src/Repository/RangoRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@

private DocumentHydrator $hydrator;

private HydratorWithContext $viewHydrator;

/** @param DocumentMetadata<T> $metadata */
public function __construct(
private Database $database,
private DocumentMetadata $metadata,
HydratorWithContext $hydrator,
) {
$this->hydrator = new DocumentHydrator($hydrator, $metadata);
$this->viewHydrator = $hydrator;
$this->collection = $this->database->getCollection($this->metadata->collection);
}

Expand Down Expand Up @@ -221,6 +224,23 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec
return $this->hydrator->hydrate($this->metadata->className, $data);
}

/**
* @param list<array<string, mixed>> $pipeline
* @param class-string<V> $into
*
* @return iterable<V>
*
* @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();
Expand Down
10 changes: 10 additions & 0 deletions src/Repository/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ public function findBy(
*/
public function findOneBy(array $filter = [], array|null $orderBy = null): object|null;

/**
* @param list<array<string, mixed>> $pipeline
* @param class-string<V> $into
*
* @return iterable<V>
*
* @template V of object
*/
public function aggregate(array $pipeline, string $into): iterable;

public function count(): int;

public function has(string $id): bool;
Expand Down
14 changes: 14 additions & 0 deletions tests/Integration/Fixtures/ProfileSummary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Tests\Integration\Fixtures;

final readonly class ProfileSummary
{
public function __construct(
public string $name,
public Status $status,
) {
}
}
14 changes: 14 additions & 0 deletions tests/Integration/Fixtures/SkillPopularity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Tests\Integration\Fixtures;

final readonly class SkillPopularity
{
public function __construct(
public string $skill,
public int $count,
) {
}
}
26 changes: 26 additions & 0 deletions tests/Integration/MongoDBRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@

use MongoDB\Client;
use Patchlevel\ODM\Repository\MongoDBRepositoryManager;
use Patchlevel\ODM\Tests\Integration\Fixtures\Profile;
use Patchlevel\ODM\Tests\Integration\Fixtures\SkillPopularity;

use function getenv;
use function iterator_to_array;

final class MongoDBRepositoryTest extends RepositoryTestCase
{
Expand All @@ -23,4 +26,27 @@ public function createRepositoryManager(): MongoDBRepositoryManager

return MongoDBRepositoryManager::create($client);
}

public function testAggregateGroupIntoView(): void
{
$repository = $this->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);
}
}
36 changes: 36 additions & 0 deletions tests/Integration/RepositoryTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading