From 4b9aeae639eb0ec77e49adfbebdfdb329e04ccda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 9 Jan 2025 23:56:42 +0100 Subject: [PATCH] [11.x] Support DB aggregate by group (new methods) (#53679) * Support aggregation by group * Move group columns after aggregate result for safe backward compatibility * formatting * formatting --------- Co-authored-by: Taylor Otwell --- src/Illuminate/Database/Query/Builder.php | 70 ++++++++++++++++++ .../Database/Query/Grammars/Grammar.php | 14 +++- tests/Database/DatabaseQueryBuilderTest.php | 71 +++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index f166b28bbbfe..f2f171b4bc93 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -3561,6 +3561,17 @@ public function count($columns = '*') return (int) $this->aggregate(__FUNCTION__, Arr::wrap($columns)); } + /** + * Retrieve the "count" of the distinct results of a given column for each group. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $columns + * @return \Illuminate\Support\Collection + */ + public function countByGroup($columns = '*') + { + return $this->aggregateByGroup('count', Arr::wrap($columns)); + } + /** * Retrieve the minimum value of a given column. * @@ -3572,6 +3583,17 @@ public function min($column) return $this->aggregate(__FUNCTION__, [$column]); } + /** + * Retrieve the minimum value of a given column by group. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return \Illuminate\Support\Collection + */ + public function minByGroup($column) + { + return $this->aggregateByGroup('min', [$column]); + } + /** * Retrieve the maximum value of a given column. * @@ -3583,6 +3605,17 @@ public function max($column) return $this->aggregate(__FUNCTION__, [$column]); } + /** + * Retrieve the maximum value of a given column by group. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return \Illuminate\Support\Collection + */ + public function maxByGroup($column) + { + return $this->aggregateByGroup('max', [$column]); + } + /** * Retrieve the sum of the values of a given column. * @@ -3596,6 +3629,17 @@ public function sum($column) return $result ?: 0; } + /** + * Retrieve the sum of the values of a given column by group. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return \Illuminate\Support\Collection + */ + public function sumByGroup($column) + { + return $this->aggregateByGroup('sum', [$column]); + } + /** * Retrieve the average of the values of a given column. * @@ -3607,6 +3651,17 @@ public function avg($column) return $this->aggregate(__FUNCTION__, [$column]); } + /** + * Retrieve the average of the values of a given column by group. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return \Illuminate\Support\Collection + */ + public function avgByGroup($column) + { + return $this->aggregateByGroup('avg', [$column]); + } + /** * Alias for the "avg" method. * @@ -3637,6 +3692,21 @@ public function aggregate($function, $columns = ['*']) } } + /** + * Execute an aggregate function for each group. + * + * @param string $function + * @param array $columns + * @return \Illuminate\Support\Collection + */ + public function aggregateByGroup(string $function, array $columns = ['*']) + { + return $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) + ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) + ->setAggregate($function, $columns) + ->get($columns); + } + /** * Execute a numeric aggregate function on the database. * diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index 64e8f916d45c..a4cd649858ac 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -139,7 +139,15 @@ protected function compileAggregate(Builder $query, $aggregate) $column = 'distinct '.$column; } - return 'select '.$aggregate['function'].'('.$column.') as aggregate'; + $sql = 'select '; + + $sql .= $aggregate['function'].'('.$column.') as aggregate'; + + if ($query->groups) { + $sql .= ', '.$this->columnize($query->groups); + } + + return $sql; } /** @@ -1131,10 +1139,12 @@ protected function wrapUnion($sql) protected function compileUnionAggregate(Builder $query) { $sql = $this->compileAggregate($query, $query->aggregate); + $groups = $query->groups ? ' '.$this->compileGroups($query, $query->groups) : ''; $query->aggregate = null; + $query->groups = null; - return $sql.' from ('.$this->compileSelect($query).') as '.$this->wrapTable('temp_table'); + return $sql.' from ('.$this->compileSelect($query).') as '.$this->wrapTable('temp_table').$groups; } /** diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 096b82450d25..5f07e97ae10d 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -25,6 +25,7 @@ use Illuminate\Pagination\Cursor; use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\Tests\Database\Fixtures\Enums\Bar; use InvalidArgumentException; use Mockery as m; @@ -1804,6 +1805,38 @@ public function testGroupBys() $this->assertEquals(['whereRawBinding', 'groupByRawBinding', 'havingRawBinding'], $builder->getBindings()); } + public function testAggregateByGroup() + { + $builder = $this->getBuilder(); + + $queryResults = [['aggregate' => 2, 'role' => 'admin', 'city' => 'NY'], ['aggregate' => 5, 'role' => 'user', 'city' => 'LA']]; + $builder->getConnection() + ->shouldReceive('select')->once() + ->with('select count(*) as aggregate, "role", "city" from "users" group by "role", "city"', [], true) + ->andReturn($queryResults); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $builder->from('users')->groupBy('role', 'city'); + $builder->aggregate = ['function' => 'count', 'columns' => ['*']]; + $results = $builder->get(); + $this->assertEquals($queryResults, $results->toArray()); + } + + public function testUnionAndAggregateByGroup() + { + $builder = $this->getBuilder(); + + $queryResults = [['aggregate' => 2, 'role' => 'admin'], ['aggregate' => 5, 'role' => 'user']]; + $builder->getConnection() + ->shouldReceive('select')->once() + ->with('select count(*) as aggregate, "role" from ((select * from "users") union (select * from "members")) as "temp_table" group by "role"', [], true) + ->andReturn($queryResults); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $results = $builder->from('users') + ->union($this->getBuilder()->select('*')->from('members')) + ->groupBy('role')->aggregateByGroup('count'); + $this->assertEquals($queryResults, $results->toArray()); + } + public function testOrderBys() { $builder = $this->getBuilder(); @@ -3464,6 +3497,44 @@ public function testAggregateFunctions() $this->assertEquals(1, $results); } + public function testAggregateFunctionsWithGroupBy() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate, "role" from "users" group by "role"', [], true)->andReturn([['role' => 'admin', 'aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $results = $builder->from('users')->groupBy('role')->countByGroup(); + $this->assertInstanceOf(Collection::class, $results); + $this->assertEquals([['role' => 'admin', 'aggregate' => 1]], $results->toArray()); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select max("id") as aggregate, "role" from "users" group by "role"', [], true)->andReturn([['role' => 'admin', 'aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $results = $builder->from('users')->groupBy('role')->maxByGroup('id'); + $this->assertInstanceOf(Collection::class, $results); + $this->assertEquals([['role' => 'admin', 'aggregate' => 1]], $results->toArray()); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select min("id") as aggregate, "role" from "users" group by "role"', [], true)->andReturn([['role' => 'admin', 'aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $results = $builder->from('users')->groupBy('role')->minByGroup('id'); + $this->assertInstanceOf(Collection::class, $results); + $this->assertEquals([['role' => 'admin', 'aggregate' => 1]], $results->toArray()); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as aggregate, "role" from "users" group by "role"', [], true)->andReturn([['role' => 'admin', 'aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $results = $builder->from('users')->groupBy('role')->sumByGroup('id'); + $this->assertInstanceOf(Collection::class, $results); + $this->assertEquals([['role' => 'admin', 'aggregate' => 1]], $results->toArray()); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as aggregate, "role" from "users" group by "role"', [], true)->andReturn([['role' => 'admin', 'aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(fn ($builder, $results) => $results); + $results = $builder->from('users')->groupBy('role')->avgByGroup('id'); + $this->assertInstanceOf(Collection::class, $results); + $this->assertEquals([['role' => 'admin', 'aggregate' => 1]], $results->toArray()); + } + public function testSqlServerExists() { $builder = $this->getSqlServerBuilder();