Skip to content

Commit

Permalink
PHPORM-286 Add Query::countByGroup() and other aggregateByGroup()
Browse files Browse the repository at this point in the history
… functions (#3243)

* PHPORM-286 Add Query::countByGroup and other aggregateByGroup functions
* Support counting distinct values with aggregate by group
* Disable fail-fast due to Atlas issues
  • Loading branch information
GromNaN authored Jan 13, 2025
1 parent 35f4699 commit 8829052
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}"

strategy:
# Tests with Atlas fail randomly
fail-fast: false
matrix:
os:
- "ubuntu-latest"
Expand Down
48 changes: 44 additions & 4 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Override;
use RuntimeException;
use stdClass;
use TypeError;

use function array_fill_keys;
use function array_filter;
Expand Down Expand Up @@ -315,6 +316,7 @@ public function toMql(): array
if ($this->groups || $this->aggregate) {
$group = [];
$unwinds = [];
$set = [];

// Add grouping columns to the $group part of the aggregation pipeline.
if ($this->groups) {
Expand All @@ -325,8 +327,10 @@ public function toMql(): array
// this mimics SQL's behaviour a bit.
$group[$column] = ['$last' => '$' . $column];
}
}

// Do the same for other columns that are selected.
// Add the last value of each column when there is no aggregate function.
if ($this->groups && ! $this->aggregate) {
foreach ($columns as $column) {
$key = str_replace('.', '_', $column);

Expand All @@ -350,15 +354,22 @@ public function toMql(): array

$aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns'];

if (in_array('*', $aggregations) && $function === 'count') {
if ($column === '*' && $function === 'count' && ! $this->groups) {
$options = $this->inheritConnectionOptions($this->options);

return ['countDocuments' => [$wheres, $options]];
}

// "aggregate" is the name of the field that will hold the aggregated value.
if ($function === 'count') {
// Translate count into sum.
$group['aggregate'] = ['$sum' => 1];
if ($column === '*' || $aggregations === []) {
// Translate count into sum.
$group['aggregate'] = ['$sum' => 1];
} else {
// Count the number of distinct values.
$group['aggregate'] = ['$addToSet' => '$' . $column];
$set['aggregate'] = ['$size' => '$aggregate'];
}
} else {
$group['aggregate'] = ['$' . $function => '$' . $column];
}
Expand All @@ -385,6 +396,10 @@ public function toMql(): array
$pipeline[] = ['$group' => $group];
}

if ($set) {
$pipeline[] = ['$set' => $set];
}

// Apply order and limit
if ($this->orders) {
$pipeline[] = ['$sort' => $this->aliasIdForQuery($this->orders)];
Expand Down Expand Up @@ -560,6 +575,8 @@ public function generateCacheKey()
/** @return ($function is null ? AggregationBuilder : mixed) */
public function aggregate($function = null, $columns = ['*'])
{
assert(is_array($columns), new TypeError(sprintf('Argument #2 ($columns) must be of type array, %s given', get_debug_type($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
Expand Down Expand Up @@ -600,13 +617,36 @@ public function aggregate($function = null, $columns = ['*'])
$this->columns = $previousColumns;
$this->bindings['select'] = $previousSelectBindings;

// When the aggregation is per group, we return the results as is.
if ($this->groups) {
return $results->map(function (object $result) {
unset($result->id);

return $result;
});
}

if (isset($results[0])) {
$result = (array) $results[0];

return $result['aggregate'];
}
}

/**
* {@inheritDoc}
*
* @see \Illuminate\Database\Query\Builder::aggregateByGroup()
*/
public function aggregateByGroup(string $function, array $columns = ['*'])
{
if (count($columns) > 1) {
throw new InvalidArgumentException('Aggregating by group requires zero or one columns.');
}

return $this->aggregate($function, $columns);
}

/** @inheritdoc */
public function exists()
{
Expand Down
55 changes: 55 additions & 0 deletions tests/QueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Carbon\Carbon;
use DateTime;
use DateTimeImmutable;
use Illuminate\Support\Collection as LaravelCollection;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
Expand All @@ -32,6 +33,7 @@
use function count;
use function key;
use function md5;
use function method_exists;
use function sort;
use function strlen;

Expand Down Expand Up @@ -617,6 +619,59 @@ public function testSubdocumentArrayAggregate()
$this->assertEquals(12, DB::table('items')->avg('amount.*.hidden'));
}

public function testAggregateGroupBy()
{
DB::table('users')->insert([
['name' => 'John Doe', 'role' => 'admin', 'score' => 1, 'active' => true],
['name' => 'Jane Doe', 'role' => 'admin', 'score' => 2, 'active' => true],
['name' => 'Robert Roe', 'role' => 'user', 'score' => 4],
]);

$results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count');
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray());

$results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count', ['active']);
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 0]], $results->toArray());

$results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('max', ['score']);
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());

if (! method_exists(Builder::class, 'countByGroup')) {
$this->markTestSkipped('*byGroup functions require Laravel v11.38+');
}

$results = DB::table('users')->groupBy('role')->orderBy('role')->countByGroup();
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray());

$results = DB::table('users')->groupBy('role')->orderBy('role')->maxByGroup('score');
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());

$results = DB::table('users')->groupBy('role')->orderBy('role')->minByGroup('score');
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());

$results = DB::table('users')->groupBy('role')->orderBy('role')->sumByGroup('score');
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 3], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());

$results = DB::table('users')->groupBy('role')->orderBy('role')->avgByGroup('score');
$this->assertInstanceOf(LaravelCollection::class, $results);
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1.5], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());
}

public function testAggregateByGroupException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Aggregating by group requires zero or one columns.');

DB::table('users')->aggregateByGroup('max', ['foo', 'bar']);
}

public function testUpdateWithUpsert()
{
DB::table('items')->where('name', 'knife')
Expand Down

0 comments on commit 8829052

Please sign in to comment.