diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index afe968e4b..dce0d8274 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -7,23 +7,37 @@ use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Str; +use InvalidArgumentException; use MongoDB\BSON\Document; use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; use MongoDB\Laravel\Connection; +use MongoDB\Laravel\Eloquent\Model as DocumentModel; use MongoDB\Laravel\Helpers\QueriesRelationships; use MongoDB\Laravel\Query\AggregationBuilder; +use MongoDB\Laravel\Relations\EmbedsOneOrMany; +use MongoDB\Laravel\Relations\HasMany; use MongoDB\Model\BSONDocument; +use RuntimeException; +use TypeError; use function array_key_exists; use function array_merge; +use function assert; use function collect; +use function count; +use function explode; +use function get_debug_type; use function is_array; use function is_object; +use function is_string; use function iterator_to_array; use function property_exists; +use function sprintf; /** * @method \MongoDB\Laravel\Query\Builder toBase() @@ -34,6 +48,13 @@ class Builder extends EloquentBuilder private const DUPLICATE_KEY_ERROR = 11000; use QueriesRelationships; + /** + * List of aggregations on the related models after the main query. + * + * @var array{relation: Relation, function: string, constraints: array, column: string, alias: string}[] + */ + private array $withAggregate = []; + /** * The methods that should be returned from query builder. * @@ -294,6 +315,112 @@ public function createOrFirst(array $attributes = [], array $values = []) } } + /** + * Add subsequent queries to include an aggregate value for a relationship. + * For embedded relations, a projection is used to calculate the aggregate. + * + * @see \Illuminate\Database\Eloquent\Concerns\QueriesRelationships::withAggregate() + * + * @param mixed $relations Name of the relationship or an array of relationships to closure for constraint + * @param string $column Name of the field to aggregate + * @param string $function Required aggregation function name (count, min, max, avg) + * + * @return $this + */ + public function withAggregate($relations, $column, $function = null) + { + if (empty($relations)) { + return $this; + } + + assert(is_string($function), new TypeError('Argument 3 ($function) passed to withAggregate must be of the type string, ' . get_debug_type($function) . ' given')); + + $relations = is_array($relations) ? $relations : [$relations]; + + foreach ($this->parseWithRelations($relations) as $name => $constraints) { + $segments = explode(' ', $name); + + $alias = match (true) { + count($segments) === 1 => Str::snake($segments[0]) . '_' . $function, + count($segments) === 3 && Str::lower($segments[1]) === 'as' => $segments[2], + default => throw new InvalidArgumentException(sprintf('Invalid relation name format. Expected "relation as alias" or "relation", got "%s"', $name)), + }; + $name = $segments[0]; + + $relation = $this->getRelationWithoutConstraints($name); + + if (! DocumentModel::isDocumentModel($relation->getRelated())) { + throw new InvalidArgumentException('WithAggregate does not support hybrid relations'); + } + + if ($relation instanceof EmbedsOneOrMany) { + $subQuery = $this->newQuery(); + $constraints($subQuery); + if ($subQuery->getQuery()->wheres) { + // @see https://jira.mongodb.org/browse/PHPORM-292 + throw new InvalidArgumentException('Constraints are not supported for embedded relations'); + } + + switch ($function) { + case 'count': + $this->getQuery()->project([$alias => ['$size' => ['$ifNull' => ['$' . $name, []]]]]); + break; + case 'min': + case 'max': + case 'avg': + $this->getQuery()->project([$alias => ['$' . $function => '$' . $name . '.' . $column]]); + break; + default: + throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function)); + } + } else { + // The aggregation will be performed after the main query, during eager loading. + $this->withAggregate[$alias] = [ + 'relation' => $relation, + 'function' => $function, + 'constraints' => $constraints, + 'column' => $column, + 'alias' => $alias, + ]; + } + } + + return $this; + } + + public function eagerLoadRelations(array $models) + { + if ($this->withAggregate) { + $modelIds = collect($models)->pluck($this->model->getKeyName())->all(); + + foreach ($this->withAggregate as $withAggregate) { + if ($withAggregate['relation'] instanceof HasMany) { + $results = $withAggregate['relation']->newQuery() + ->where($withAggregate['constraints']) + ->whereIn($withAggregate['relation']->getForeignKeyName(), $modelIds) + ->groupBy($withAggregate['relation']->getForeignKeyName()) + ->aggregate($withAggregate['function'], [$withAggregate['column']]); + + foreach ($models as $model) { + $value = $withAggregate['function'] === 'count' ? 0 : null; + foreach ($results as $result) { + if ($model->getKey() === $result->{$withAggregate['relation']->getForeignKeyName()}) { + $value = $result->aggregate; + break; + } + } + + $model->setAttribute($withAggregate['alias'], $value); + } + } else { + throw new RuntimeException(sprintf('Unsupported relation type for aggregation: %s', $withAggregate['relation']::class)); + } + } + } + + return parent::eagerLoadRelations($models); + } + /** * Add the "updated at" column to an array of values. * TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 4c7c8513f..190d22671 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -357,7 +357,7 @@ public function toMql(): array $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; - if ($column === '*' && $function === 'count' && ! $this->groups) { + if (in_array('*', $aggregations) && $function === 'count' && empty($group['_id'])) { $options = $this->inheritConnectionOptions($this->options); return ['countDocuments' => [$wheres, $options]]; @@ -611,7 +611,7 @@ public function aggregate($function = null, $columns = ['*']) $this->bindings['select'] = []; - $results = $this->get($columns); + $results = $this->get(); // Once we have executed the query, we will reset the aggregate property so // that more select queries can be executed against the database without @@ -650,6 +650,14 @@ public function aggregateByGroup(string $function, array $columns = ['*']) return $this->aggregate($function, $columns); } + public function count($columns = '*') + { + // Can be removed when available in Laravel: https://github.com/laravel/framework/pull/53209 + $results = $this->aggregate(__FUNCTION__, Arr::wrap($columns)); + + return $results instanceof Collection ? $results : (int) $results; + } + /** @inheritdoc */ public function exists() { diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php new file mode 100644 index 000000000..749b68f05 --- /dev/null +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -0,0 +1,298 @@ + 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); + $one->twos()->create(['value' => 4]); + $one->twos()->create(['value' => 6]); + + $results = EloquentWithAggregateModel1::withCount('twos')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_count' => 2], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMax('twos', 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_max' => 6], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMin('twos', 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_min' => 4], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withAvg('twos', 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_avg' => 5.0], + ], $results->get()); + } + + public function testWithAggregateAlias() + { + EloquentWithAggregateModel1::create(['id' => 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); + $one->twos()->create(['value' => 4]); + $one->twos()->create(['value' => 6]); + + $results = EloquentWithAggregateModel1::withCount('twos as result')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'result' => 2], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMax('twos as result', 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'result' => 6], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMin('twos as result', 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'result' => 4], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withAvg('twos as result', 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'result' => 5.0], + ], $results->get()); + } + + #[TestWith(['withCount'])] + #[TestWith(['withMax'])] + #[TestWith(['withMin'])] + #[TestWith(['withAvg'])] + public function testWithAggregateInvalidAlias(string $method) + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Expected "relation as alias" or "relation", got "twos foo result"'); + EloquentWithAggregateModel1::{$method}('twos foo result', 'value')->get(); + } + + public function testWithAggregateEmbed() + { + EloquentWithAggregateModel1::create(['id' => 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); + $one->embeddeds()->create(['value' => 4]); + $one->embeddeds()->create(['value' => 6]); + + $results = EloquentWithAggregateModel1::withCount('embeddeds')->select('id')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_count' => 2], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMax('embeddeds', 'value')->select('id')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_max' => 6], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMin('embeddeds', 'value')->select('id')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_min' => 4], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withAvg('embeddeds', 'value')->select('id')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_avg' => 5.0], + ], $results->get()); + } + + public function testWithAggregateFiltered() + { + EloquentWithAggregateModel1::create(['id' => 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); + $one->twos()->create(['value' => 4]); + $one->twos()->create(['value' => 6]); + $one->twos()->create(['value' => 8]); + $filter = static function (Builder $query) { + $query->where('value', '<=', 6); + }; + + $results = EloquentWithAggregateModel1::withCount(['twos' => $filter])->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_count' => 2], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMax(['twos' => $filter], 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_max' => 6], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMin(['twos' => $filter], 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_min' => 4], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withAvg(['twos' => $filter], 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'twos_avg' => 5.0], + ], $results->get()); + } + + public function testWithAggregateEmbedFiltered() + { + EloquentWithAggregateModel1::create(['id' => 2]); + $filter = static function (Builder $query) { + $query->where('value', '<=', 6); + }; + + // @see https://jira.mongodb.org/browse/PHPORM-292 + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Constraints are not supported for embedded relations'); + + EloquentWithAggregateModel1::withCount(['embeddeds' => $filter])->where('id', 2)->get(); + } + + public function testWithAggregateMultipleResults() + { + $connection = DB::connection('mongodb'); + $ones = [ + EloquentWithAggregateModel1::create(['id' => 1]), + EloquentWithAggregateModel1::create(['id' => 2]), + EloquentWithAggregateModel1::create(['id' => 3]), + EloquentWithAggregateModel1::create(['id' => 4]), + ]; + + $ones[0]->twos()->create(['value' => 1]); + $ones[0]->twos()->create(['value' => 2]); + $ones[0]->twos()->create(['value' => 3]); + $ones[0]->twos()->create(['value' => 1]); + $ones[2]->twos()->create(['value' => 1]); + $ones[2]->twos()->create(['value' => 2]); + + $connection->enableQueryLog(); + + // Count + $results = EloquentWithAggregateModel1::withCount([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ]); + + self::assertSameResults([ + ['id' => 1, 'twos_count' => 2], + ['id' => 2, 'twos_count' => 0], + ['id' => 3, 'twos_count' => 1], + ['id' => 4, 'twos_count' => 0], + ], $results->get()); + + // Only 2 queries should be executed: the main query and the aggregate grouped by foreign id + self::assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + + // Max + $results = EloquentWithAggregateModel1::withMax([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ], 'value'); + + self::assertSameResults([ + ['id' => 1, 'twos_max' => 3], + ['id' => 2, 'twos_max' => null], + ['id' => 3, 'twos_max' => 2], + ['id' => 4, 'twos_max' => null], + ], $results->get()); + + self::assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + + // Min + $results = EloquentWithAggregateModel1::withMin([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ], 'value'); + + self::assertSameResults([ + ['id' => 1, 'twos_min' => 2], + ['id' => 2, 'twos_min' => null], + ['id' => 3, 'twos_min' => 2], + ['id' => 4, 'twos_min' => null], + ], $results->get()); + + self::assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + + // Avg + $results = EloquentWithAggregateModel1::withAvg([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ], 'value'); + + self::assertSameResults([ + ['id' => 1, 'twos_avg' => 2.5], + ['id' => 2, 'twos_avg' => null], + ['id' => 3, 'twos_avg' => 2.0], + ['id' => 4, 'twos_avg' => null], + ], $results->get()); + + self::assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + } + + public function testGlobalScopes() + { + $one = EloquentWithAggregateModel1::create(); + $one->fours()->create(); + + $result = EloquentWithAggregateModel1::withCount('fours')->first(); + self::assertSame(0, $result->fours_count); + + $result = EloquentWithAggregateModel1::withCount('allFours')->first(); + self::assertSame(1, $result->all_fours_count); + } + + public function testHybridNotSupported() + { + EloquentWithAggregateModel1::create(['id' => 2]); + + // @see https://jira.mongodb.org/browse/PHPORM-292 + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('WithAggregate does not support hybrid relations'); + + EloquentWithAggregateModel1::withCount('hybrids')->where('id', 2)->get(); + } + + private static function assertSameResults(array $expected, Collection $collection) + { + $actual = $collection->toArray(); + + foreach ($actual as &$item) { + ksort($item); + } + + foreach ($expected as &$item) { + ksort($item); + } + + self::assertSame($expected, $actual); + } +} diff --git a/tests/Eloquent/Models/EloquentWithAggregateEmbeddedModel.php b/tests/Eloquent/Models/EloquentWithAggregateEmbeddedModel.php new file mode 100644 index 000000000..609c078b2 --- /dev/null +++ b/tests/Eloquent/Models/EloquentWithAggregateEmbeddedModel.php @@ -0,0 +1,12 @@ +hasMany(EloquentWithAggregateModel2::class, 'one_id'); + } + + public function fours() + { + return $this->hasMany(EloquentWithAggregateModel4::class, 'one_id'); + } + + public function allFours() + { + return $this->fours()->withoutGlobalScopes(); + } + + public function embeddeds() + { + return $this->embedsMany(EloquentWithAggregateEmbeddedModel::class); + } + + public function hybrids() + { + return $this->hasMany(EloquentWithAggregateHybridModel::class, 'one_id'); + } +} diff --git a/tests/Eloquent/Models/EloquentWithAggregateModel2.php b/tests/Eloquent/Models/EloquentWithAggregateModel2.php new file mode 100644 index 000000000..3d72fd922 --- /dev/null +++ b/tests/Eloquent/Models/EloquentWithAggregateModel2.php @@ -0,0 +1,28 @@ +latest(); + }); + } + + public function threes() + { + return $this->hasMany(EloquentWithAggregateModel3::class, 'two_id'); + } +} diff --git a/tests/Eloquent/Models/EloquentWithAggregateModel3.php b/tests/Eloquent/Models/EloquentWithAggregateModel3.php new file mode 100644 index 000000000..da649065f --- /dev/null +++ b/tests/Eloquent/Models/EloquentWithAggregateModel3.php @@ -0,0 +1,22 @@ +where('id', '>', 0); + }); + } +} diff --git a/tests/Eloquent/Models/EloquentWithAggregateModel4.php b/tests/Eloquent/Models/EloquentWithAggregateModel4.php new file mode 100644 index 000000000..75ae296a4 --- /dev/null +++ b/tests/Eloquent/Models/EloquentWithAggregateModel4.php @@ -0,0 +1,22 @@ +where('id', '>', 1); + }); + } +} diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 01f937915..b4f4f2d16 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -41,6 +41,7 @@ class QueryBuilderTest extends TestCase { public function tearDown(): void { + DB::table('books')->truncate(); DB::table('users')->truncate(); DB::table('items')->truncate(); } @@ -1155,4 +1156,37 @@ public function testIdAlias($insertId, $queryId): void $result = DB::table('items')->where($queryId, '=', 'abc')->delete(); $this->assertSame(1, $result); } + + public function testAggregateFunctionsWithGroupBy() + { + DB::table('users')->insert([ + ['name' => 'John Doe', 'role' => 'admin', 'score' => 1], + ['name' => 'Jane Doe', 'role' => 'admin', 'score' => 2], + ['name' => 'Robert Roe', 'role' => 'user', 'score' => 4], + ]); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->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')->max('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')->min('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')->sum('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')->avg('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1.5], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->average('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1.5], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + } }