diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 271e62cc1ecc..ad5803e4e018 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -1107,9 +1107,10 @@ public function newModelInstance($attributes = []) * Parse a list of relations into individuals. * * @param array $relations + * @param bool $selectConstraints * @return array */ - protected function parseWithRelations(array $relations) + protected function parseWithRelations(array $relations, $selectConstraints = true) { $results = []; @@ -1120,7 +1121,7 @@ protected function parseWithRelations(array $relations) if (is_numeric($name)) { $name = $constraints; - [$name, $constraints] = Str::contains($name, ':') + [$name, $constraints] = $selectConstraints && Str::contains($name, ':') ? $this->createSelectWithConstraint($name) : [$name, function () { // diff --git a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php index be143214a141..02cb1ac13589 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php @@ -188,7 +188,74 @@ public function orWhereDoesntHave($relation, Closure $callback = null) * @param mixed $relations * @return $this */ - public function withCount($relations) + public function withCount(...$relations) + { + return $this->withAggregate('count', ...$relations); + } + + /** + * Add subselect queries to retrieve the minimum value of a column on the relations. + * + * @param mixed $relations + * @return $this + */ + public function withMin(...$relations) + { + return $this->withAggregate('min', ...$relations); + } + + /** + * Add subselect queries to retrieve the maximum value of a column on the relations. + * + * @param mixed $relations + * @return $this + */ + public function withMax(...$relations) + { + return $this->withAggregate('max', ...$relations); + } + + /** + * Add subselect queries to retrieve the sum the value of a column on the relations. + * + * @param mixed $relations + * @return $this + */ + public function withSum(...$relations) + { + return $this->withAggregate('sum', ...$relations); + } + + /** + * Add subselect queries to retrieve the average the value of a column on the relations. + * + * @param mixed $relations + * @return $this + */ + public function withAvg(...$relations) + { + return $this->withAggregate('avg', ...$relations); + } + + /** + * Alias for the "withAvg" method. + * + * @param mixed $relations + * @return $this + */ + public function withAverage(...$relations) + { + return $this->withAvg(...$relations); + } + + /** + * Add subselect queries to retrieve the aggregate function of a column on the relations. + * + * @param string $function + * @param mixed $relations + * @return $this + */ + public function withAggregate($function, $relations) { if (empty($relations)) { return $this; @@ -198,12 +265,12 @@ public function withCount($relations) $this->query->select([$this->query->from.'.*']); } - $relations = is_array($relations) ? $relations : func_get_args(); + $relations = is_array($relations) ? $relations : array_slice(func_get_args(), 1); - foreach ($this->parseWithRelations($relations) as $name => $constraints) { + foreach ($this->parseWithRelations($relations, false) as $name => $constraints) { // First we will determine if the name has been aliased using an "as" clause on the name // and if it has we will extract the actual relationship name and the desired name of - // the resulting column. This allows multiple counts on the same relationship name. + // the resulting column. This allows multiple aggregates for the same relationship. $segments = explode(' ', $name); unset($alias); @@ -212,13 +279,22 @@ public function withCount($relations) [$name, $alias] = [$segments[0], $segments[2]]; } + // Determine column to do the aggregate function + $segments = explode(':', $name); + + unset($aggregateColumn); + + if (count($segments) === 2) { + [$name, $aggregateColumn] = $segments; + } + $relation = $this->getRelationWithoutConstraints($name); - // Here we will get the relationship count query and prepare to add it to the main query - // as a sub-select. First, we'll get the "has" query and use that to get the relation - // count query. We will normalize the relation name then append _count as the name. - $query = $relation->getRelationExistenceCountQuery( - $relation->getRelated()->newQuery(), $this + // Here we will get the relationship aggregate query and prepare to add it to the main query + // as a sub-select. First we'll get the "has" query and use that to retrieve the relation + // aggregate query. We normalize the relation name, and append _$column_$aggregateName. + $query = $relation->getRelationExistenceAggregateQuery( + $relation->getRelated()->newQuery(), $this, $function, $aggregateColumn ?? '*' ); $query->callScope($constraints); @@ -232,7 +308,8 @@ public function withCount($relations) // Finally we will add the proper result column alias to the query and run the subselect // statement against the query builder. Then we will return the builder instance back // to the developer for further constraint chaining that needs to take place on it. - $column = $alias ?? Str::snake($name.'_count'); + $suffix = isset($aggregateColumn) ? '_'.$aggregateColumn : null; + $column = $alias ?? Str::snake($name.$suffix.'_'.$function); $this->selectSub($query, $column); } @@ -240,6 +317,23 @@ public function withCount($relations) return $this; } + /** + * Apply a set of aggregate functions of related model. + * + * @param array $aggregates + * @return $this + */ + public function withAggregates($aggregates) + { + foreach ($aggregates as $function => $columns) { + foreach ((array) $columns as $column) { + $this->withAggregate($function, $column); + } + } + + return $this; + } + /** * Add the "has" condition where clause to the query. * diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 6cbfd4e18f65..1e95e407bc9d 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -78,6 +78,13 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab */ protected $withCount = []; + /** + * The relationship aggregates that should be eager loaded on every query. + * + * @var array + */ + protected $withAggregates = []; + /** * The number of models to return for pagination. * @@ -987,7 +994,8 @@ public function newQueryWithoutScopes() { return $this->newModelQuery() ->with($this->with) - ->withCount($this->withCount); + ->withCount($this->withCount) + ->withAggregates($this->withAggregates); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/Relation.php b/src/Illuminate/Database/Eloquent/Relations/Relation.php index 671d21127a50..178546286d0a 100755 --- a/src/Illuminate/Database/Eloquent/Relations/Relation.php +++ b/src/Illuminate/Database/Eloquent/Relations/Relation.php @@ -191,8 +191,24 @@ public function rawUpdate(array $attributes = []) */ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery) { + return $this->getRelationExistenceAggregateQuery($query, $parentQuery, 'count', '*'); + } + + /** + * Add the constraints for a relationship aggregate query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param string $function + * @param string $column + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceAggregateQuery(Builder $query, Builder $parentQuery, $function, $column) + { + $wrappedColumn = $query->getQuery()->getGrammar()->wrap($column); + return $this->getRelationExistenceQuery( - $query, $parentQuery, new Expression('count(*)') + $query, $parentQuery, new Expression($function.'('.$wrappedColumn.')') )->setBindings([], 'select'); } diff --git a/tests/Database/DatabaseEloquentHasOneTest.php b/tests/Database/DatabaseEloquentHasOneTest.php index 319919c26f7c..00e4575806d9 100755 --- a/tests/Database/DatabaseEloquentHasOneTest.php +++ b/tests/Database/DatabaseEloquentHasOneTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Expression; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Query\Grammars\Grammar; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Query\Builder as BaseBuilder; @@ -206,9 +207,14 @@ public function testRelationCountQueryCanBeBuilt() $parentQuery = m::mock(BaseBuilder::class); $parentQuery->from = 'two'; - $builder->shouldReceive('getQuery')->once()->andReturn($baseQuery); + $grammar = m::mock(Grammar::class); + + $builder->shouldReceive('getQuery')->twice()->andReturn($baseQuery); $builder->shouldReceive('getQuery')->once()->andReturn($parentQuery); + $grammar->shouldReceive('wrap')->once()->with('*')->andReturn('*'); + $baseQuery->shouldReceive('getGrammar')->once()->andReturn($grammar); + $builder->shouldReceive('select')->once()->with(m::type(Expression::class))->andReturnSelf(); $relation->getParent()->shouldReceive('qualifyColumn')->andReturn('table.id'); $builder->shouldReceive('whereColumn')->once()->with('table.id', '=', 'table.foreign_key')->andReturn($baseQuery); diff --git a/tests/Integration/Database/EloquentWithAggregateTest.php b/tests/Integration/Database/EloquentWithAggregateTest.php new file mode 100644 index 000000000000..2f6981a18754 --- /dev/null +++ b/tests/Integration/Database/EloquentWithAggregateTest.php @@ -0,0 +1,148 @@ +increments('id'); + }); + + Schema::create('two', function ($table) { + $table->increments('id'); + $table->integer('one_id'); + $table->integer('quantity'); + }); + + Schema::create('three', function ($table) { + $table->increments('id'); + }); + + Schema::create('four', function ($table) { + $table->increments('id'); + $table->integer('three_id'); + $table->integer('quantity'); + }); + } + + public function testSingleColumn() + { + $one = Model1::create(); + $one->twos()->createMany([ + ['quantity' => 3], + ['quantity' => 4], + ['quantity' => 2], + ]); + + $result = Model1::withSum('twos:quantity')->first(); + + $this->assertEquals(9, $result->twos_quantity_sum); + } + + public function testMultipleColumns() + { + $one = Model1::create(); + $one->twos()->createMany([ + ['quantity' => 8], + ['quantity' => 1], + ]); + + $result = Model1::withMax('twos:quantity', 'twos:id')->first(); + + $this->assertEquals(8, $result->twos_quantity_max); + $this->assertEquals(2, $result->twos_id_max); + } + + public function testWithConstraintsAndAlias() + { + $one = Model1::create(); + $one->twos()->createMany([ + ['quantity' => 3], + ['quantity' => 1], + ['quantity' => 0], + ['quantity' => 5], + ['quantity' => 1], + ]); + + $result = Model1::withAvg(['twos:quantity as avg_quantity' => function ($q) { + $q->where('quantity', '>', 2); + }])->first(); + + $this->assertEquals(4, $result->avg_quantity); + } + + public function testAttributeEagerLoading() + { + $three = Model3::create(); + $three->fours()->createMany([ + ['quantity' => 5], + ['quantity' => 3], + ['quantity' => 1], + ]); + + $result = Model3::first(); + + $this->assertEquals([ + 'id' => 1, + 'fours_quantity_avg' => 3, + 'fours_quantity_max' => 5, + 'fours_id_max' => 3, + ], $result->toArray()); + } +} + +class Model1 extends Model +{ + public $table = 'one'; + public $timestamps = false; + protected $guarded = ['id']; + + public function twos() + { + return $this->hasMany(Model2::class, 'one_id'); + } +} + +class Model2 extends Model +{ + public $table = 'two'; + public $timestamps = false; + protected $guarded = ['id']; +} + +class Model3 extends Model +{ + public $table = 'three'; + public $timestamps = false; + protected $guarded = ['id']; + protected $withAggregates = [ + 'avg' => 'fours:quantity', + 'max' => [ + 'fours:quantity', + 'fours:id', + ], + ]; + + public function fours() + { + return $this->hasMany(Model4::class, 'three_id'); + } +} + +class Model4 extends Model +{ + public $table = 'four'; + public $timestamps = false; + protected $guarded = ['id']; +}