Skip to content

Commit 8829052

Browse files
authored
PHPORM-286 Add Query::countByGroup() and other aggregateByGroup() 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
1 parent 35f4699 commit 8829052

File tree

3 files changed

+101
-4
lines changed

3 files changed

+101
-4
lines changed

Diff for: .github/workflows/build-ci.yml

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jobs:
1111
name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}"
1212

1313
strategy:
14+
# Tests with Atlas fail randomly
15+
fail-fast: false
1416
matrix:
1517
os:
1618
- "ubuntu-latest"

Diff for: src/Query/Builder.php

+44-4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Override;
3232
use RuntimeException;
3333
use stdClass;
34+
use TypeError;
3435

3536
use function array_fill_keys;
3637
use function array_filter;
@@ -315,6 +316,7 @@ public function toMql(): array
315316
if ($this->groups || $this->aggregate) {
316317
$group = [];
317318
$unwinds = [];
319+
$set = [];
318320

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

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

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

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

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

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

363+
// "aggregate" is the name of the field that will hold the aggregated value.
359364
if ($function === 'count') {
360-
// Translate count into sum.
361-
$group['aggregate'] = ['$sum' => 1];
365+
if ($column === '*' || $aggregations === []) {
366+
// Translate count into sum.
367+
$group['aggregate'] = ['$sum' => 1];
368+
} else {
369+
// Count the number of distinct values.
370+
$group['aggregate'] = ['$addToSet' => '$' . $column];
371+
$set['aggregate'] = ['$size' => '$aggregate'];
372+
}
362373
} else {
363374
$group['aggregate'] = ['$' . $function => '$' . $column];
364375
}
@@ -385,6 +396,10 @@ public function toMql(): array
385396
$pipeline[] = ['$group' => $group];
386397
}
387398

399+
if ($set) {
400+
$pipeline[] = ['$set' => $set];
401+
}
402+
388403
// Apply order and limit
389404
if ($this->orders) {
390405
$pipeline[] = ['$sort' => $this->aliasIdForQuery($this->orders)];
@@ -560,6 +575,8 @@ public function generateCacheKey()
560575
/** @return ($function is null ? AggregationBuilder : mixed) */
561576
public function aggregate($function = null, $columns = ['*'])
562577
{
578+
assert(is_array($columns), new TypeError(sprintf('Argument #2 ($columns) must be of type array, %s given', get_debug_type($columns))));
579+
563580
if ($function === null) {
564581
if (! trait_exists(FluentFactoryTrait::class)) {
565582
// This error will be unreachable when the mongodb/builder package will be merged into mongodb/mongodb
@@ -600,13 +617,36 @@ public function aggregate($function = null, $columns = ['*'])
600617
$this->columns = $previousColumns;
601618
$this->bindings['select'] = $previousSelectBindings;
602619

620+
// When the aggregation is per group, we return the results as is.
621+
if ($this->groups) {
622+
return $results->map(function (object $result) {
623+
unset($result->id);
624+
625+
return $result;
626+
});
627+
}
628+
603629
if (isset($results[0])) {
604630
$result = (array) $results[0];
605631

606632
return $result['aggregate'];
607633
}
608634
}
609635

636+
/**
637+
* {@inheritDoc}
638+
*
639+
* @see \Illuminate\Database\Query\Builder::aggregateByGroup()
640+
*/
641+
public function aggregateByGroup(string $function, array $columns = ['*'])
642+
{
643+
if (count($columns) > 1) {
644+
throw new InvalidArgumentException('Aggregating by group requires zero or one columns.');
645+
}
646+
647+
return $this->aggregate($function, $columns);
648+
}
649+
610650
/** @inheritdoc */
611651
public function exists()
612652
{

Diff for: tests/QueryBuilderTest.php

+55
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Carbon\Carbon;
88
use DateTime;
99
use DateTimeImmutable;
10+
use Illuminate\Support\Collection as LaravelCollection;
1011
use Illuminate\Support\Facades\Date;
1112
use Illuminate\Support\Facades\DB;
1213
use Illuminate\Support\LazyCollection;
@@ -32,6 +33,7 @@
3233
use function count;
3334
use function key;
3435
use function md5;
36+
use function method_exists;
3537
use function sort;
3638
use function strlen;
3739

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

622+
public function testAggregateGroupBy()
623+
{
624+
DB::table('users')->insert([
625+
['name' => 'John Doe', 'role' => 'admin', 'score' => 1, 'active' => true],
626+
['name' => 'Jane Doe', 'role' => 'admin', 'score' => 2, 'active' => true],
627+
['name' => 'Robert Roe', 'role' => 'user', 'score' => 4],
628+
]);
629+
630+
$results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count');
631+
$this->assertInstanceOf(LaravelCollection::class, $results);
632+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray());
633+
634+
$results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count', ['active']);
635+
$this->assertInstanceOf(LaravelCollection::class, $results);
636+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 0]], $results->toArray());
637+
638+
$results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('max', ['score']);
639+
$this->assertInstanceOf(LaravelCollection::class, $results);
640+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());
641+
642+
if (! method_exists(Builder::class, 'countByGroup')) {
643+
$this->markTestSkipped('*byGroup functions require Laravel v11.38+');
644+
}
645+
646+
$results = DB::table('users')->groupBy('role')->orderBy('role')->countByGroup();
647+
$this->assertInstanceOf(LaravelCollection::class, $results);
648+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray());
649+
650+
$results = DB::table('users')->groupBy('role')->orderBy('role')->maxByGroup('score');
651+
$this->assertInstanceOf(LaravelCollection::class, $results);
652+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());
653+
654+
$results = DB::table('users')->groupBy('role')->orderBy('role')->minByGroup('score');
655+
$this->assertInstanceOf(LaravelCollection::class, $results);
656+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());
657+
658+
$results = DB::table('users')->groupBy('role')->orderBy('role')->sumByGroup('score');
659+
$this->assertInstanceOf(LaravelCollection::class, $results);
660+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 3], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());
661+
662+
$results = DB::table('users')->groupBy('role')->orderBy('role')->avgByGroup('score');
663+
$this->assertInstanceOf(LaravelCollection::class, $results);
664+
$this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1.5], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray());
665+
}
666+
667+
public function testAggregateByGroupException(): void
668+
{
669+
$this->expectException(InvalidArgumentException::class);
670+
$this->expectExceptionMessage('Aggregating by group requires zero or one columns.');
671+
672+
DB::table('users')->aggregateByGroup('max', ['foo', 'bar']);
673+
}
674+
620675
public function testUpdateWithUpsert()
621676
{
622677
DB::table('items')->where('name', 'knife')

0 commit comments

Comments
 (0)