Skip to content
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

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

Merged
merged 3 commits into from
Jan 13, 2025
Merged
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
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
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"
Expand Down
48 changes: 44 additions & 4 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use Override;
use RuntimeException;
use stdClass;
use TypeError;

use function array_fill_keys;
use function array_filter;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);

Expand All @@ -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];
}
Expand All @@ -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)];
Expand Down Expand Up @@ -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
jmikola marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -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);
jmikola marked this conversation as resolved.
Show resolved Hide resolved

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');
jmikola marked this conversation as resolved.
Show resolved Hide resolved
$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
Loading