Skip to content

PHPORM-286 Add Query::countByGroup() and other aggregateByGroup() functions #3243

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 13, 2025
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/build-ci.yml
Original file line number Diff line number Diff line change
@@ -11,6 +11,8 @@ jobs:
name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}"

strategy:
# Tests with Atlas fail randomly
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making an open ticket with the "Build Failure" type so you don't lose track of this. Assuming it's something that can be researched further down the line.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fail-fast: false
matrix:
os:
- "ubuntu-latest"
48 changes: 44 additions & 4 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@
use Override;
use RuntimeException;
use stdClass;
use TypeError;

use function array_fill_keys;
use function array_filter;
@@ -314,6 +315,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) {
@@ -324,8 +326,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);

@@ -349,15 +353,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];
}
@@ -384,6 +395,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)];
@@ -559,6 +574,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
@@ -599,13 +616,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()
{
55 changes: 55 additions & 0 deletions tests/QueryBuilderTest.php
Original file line number Diff line number Diff line change
@@ -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;
@@ -32,6 +33,7 @@
use function count;
use function key;
use function md5;
use function method_exists;
use function sort;
use function strlen;

@@ -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')
Loading