From b55bdc6244f2421b5d39b16a1777e83cdc9d5737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 17 Oct 2024 18:42:43 +0200 Subject: [PATCH 01/11] PHPORM-238 Add support for withCount using a subquery --- src/Eloquent/Builder.php | 90 ++++++++++++ src/Query/Builder.php | 10 +- tests/Eloquent/EloquentWithCountTest.php | 168 +++++++++++++++++++++++ tests/HybridRelationsTest.php | 5 + 4 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 tests/Eloquent/EloquentWithCountTest.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index afe968e4b..815c02ba7 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -7,6 +7,9 @@ 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; @@ -15,15 +18,20 @@ use MongoDB\Laravel\Connection; 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 function array_key_exists; use function array_merge; use function collect; +use function count; +use function explode; use function is_array; use function is_object; use function iterator_to_array; use function property_exists; +use function sprintf; /** * @method \MongoDB\Laravel\Query\Builder toBase() @@ -34,6 +42,9 @@ class Builder extends EloquentBuilder private const DUPLICATE_KEY_ERROR = 11000; use QueriesRelationships; + /** @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 +305,85 @@ public function createOrFirst(array $attributes = [], array $values = []) } } + public function withAggregate($relations, $column, $function = null) + { + if (empty($relations)) { + return $this; + } + + $relations = is_array($relations) ? $relations : [$relations]; + + foreach ($this->parseWithRelations($relations) as $name => $constraints) { + // For "count" and "exist" we can use the embedded list of ids + // for embedded relations, everything can be computed directly using a projection. + $segments = explode(' ', $name); + + $name = $segments[0]; + $alias = (count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[2] : Str::snake($name) . '_count'); + + $relation = $this->getRelationWithoutConstraints($name); + + if ($relation instanceof EmbedsOneOrMany) { + switch ($function) { + case 'count': + $this->project([$alias => ['$size' => ['$ifNull' => ['$' . $relation->getQualifiedForeignKeyName(), []]]]]); + break; + case 'exists': + $this->project([$alias => ['$exists' => '$' . $relation->getQualifiedForeignKeyName()]]); + break; + default: + throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function)); + } + } else { + $this->withAggregate[$alias] = [ + 'relation' => $relation, + 'function' => $function, + 'constraints' => $constraints, + 'column' => $column, + 'alias' => $alias, + ]; + } + + // @todo HasMany ? + + // Otherwise, we need to store the aggregate request to run during "eagerLoadRelation" + // after the root results are retrieved. + } + + 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'] ?? $withAggregate['relation']->getPrimaryKeyName()]); + + 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); + } + } + } + } + + 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..644bcbddf 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -346,7 +346,7 @@ public function toMql(): array if ($this->aggregate) { $function = $this->aggregate['function']; - foreach ($this->aggregate['columns'] as $column) { + foreach ((array) $this->aggregate['columns'] as $column) { // Add unwind if a subdocument array should be aggregated // column: subarray.price => {$unwind: '$subarray'} $splitColumns = explode('.*.', $column); @@ -355,9 +355,9 @@ public function toMql(): array $column = implode('.', $splitColumns); } - $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; + $aggregations = blank($this->aggregate['columns']) ? [] : (array) $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]]; @@ -506,11 +506,11 @@ public function getFresh($columns = [], $returnLazy = false) // here to either the passed columns, or the standard default of retrieving // all of the columns on the table using the "wildcard" column character. if ($this->columns === null) { - $this->columns = $columns; + $this->columns = (array) $columns; } // Drop all columns if * is present, MongoDB does not work this way. - if (in_array('*', $this->columns)) { + if (in_array('*', (array) $this->columns)) { $this->columns = []; } diff --git a/tests/Eloquent/EloquentWithCountTest.php b/tests/Eloquent/EloquentWithCountTest.php new file mode 100644 index 000000000..6c42cdaaa --- /dev/null +++ b/tests/Eloquent/EloquentWithCountTest.php @@ -0,0 +1,168 @@ + 123]); + $two = $one->twos()->create(['value' => 456]); + $two->threes()->create(); + + $results = EloquentWithCountModel1::withCount([ + 'twos' => function ($query) { + $query->where('value', '>=', 456); + }, + ]); + + $this->assertEquals([ + ['id' => 123, 'twos_count' => 1], + ], $results->get()->toArray()); + } + + public function testWithMultipleResults() + { + $ones = [ + EloquentWithCountModel1::create(['id' => 1]), + EloquentWithCountModel1::create(['id' => 2]), + EloquentWithCountModel1::create(['id' => 3]), + ]; + + $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]); + + $results = EloquentWithCountModel1::withCount([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ]); + + $this->assertEquals([ + ['id' => 1, 'twos_count' => 2], + ['id' => 2, 'twos_count' => 0], + ['id' => 3, 'twos_count' => 1], + ], $results->get()->toArray()); + } + + public function testGlobalScopes() + { + $one = EloquentWithCountModel1::create(); + $one->fours()->create(); + + $result = EloquentWithCountModel1::withCount('fours')->first(); + $this->assertEquals(0, $result->fours_count); + + $result = EloquentWithCountModel1::withCount('allFours')->first(); + $this->assertEquals(1, $result->all_fours_count); + } + + public function testSortingScopes() + { + $one = EloquentWithCountModel1::create(); + $one->twos()->create(); + + $query = EloquentWithCountModel1::withCount('twos')->getQuery(); + + $this->assertNull($query->orders); + $this->assertSame([], $query->getRawBindings()['order']); + } +} + +class EloquentWithCountModel1 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'one'; + public $timestamps = false; + protected $guarded = []; + + public function twos() + { + return $this->hasMany(EloquentWithCountModel2::class, 'one_id'); + } + + public function fours() + { + return $this->hasMany(EloquentWithCountModel4::class, 'one_id'); + } + + public function allFours() + { + return $this->fours()->withoutGlobalScopes(); + } +} + +class EloquentWithCountModel2 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'two'; + public $timestamps = false; + protected $guarded = []; + protected $withCount = ['threes']; + + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->latest(); + }); + } + + public function threes() + { + return $this->hasMany(EloquentWithCountModel3::class, 'two_id'); + } +} + +class EloquentWithCountModel3 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'three'; + public $timestamps = false; + protected $guarded = []; + + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->where('id', '>', 0); + }); + } +} + +class EloquentWithCountModel4 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'four'; + public $timestamps = false; + protected $guarded = []; + + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->where('id', '>', 1); + }); + } +} diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 71958d27d..cd6f6862b 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -157,6 +157,7 @@ public function testHybridWhereHas() public function testHybridWith() { + DB::connection('mongodb')->enableQueryLog(); $user = new SqlUser(); $otherUser = new SqlUser(); $this->assertInstanceOf(SqlUser::class, $user); @@ -206,6 +207,10 @@ public function testHybridWith() ->each(function ($user) { $this->assertEquals($user->id, $user->books->count()); }); + SqlUser::withCount('books')->get() + ->each(function ($user) { + $this->assertEquals($user->id, $user->books_count); + }); SqlUser::whereHas('sqlBooks', function ($query) { return $query->where('title', 'LIKE', 'Harry%'); From 2cf1828b006123480f2a288c142c1f1b3bd743d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 17 Oct 2024 19:10:16 +0200 Subject: [PATCH 02/11] Ensure Hydratation of _count is done in a single query --- tests/Eloquent/EloquentWithCountTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Eloquent/EloquentWithCountTest.php b/tests/Eloquent/EloquentWithCountTest.php index 6c42cdaaa..0785eaf45 100644 --- a/tests/Eloquent/EloquentWithCountTest.php +++ b/tests/Eloquent/EloquentWithCountTest.php @@ -2,9 +2,12 @@ namespace MongoDB\Laravel\Tests\Eloquent; +use Illuminate\Support\Facades\DB; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Tests\TestCase; +use function count; + /** Copied from {@see \Illuminate\Tests\Integration\Database\EloquentWithCountTest\EloquentWithCountTest} */ class EloquentWithCountTest extends TestCase { @@ -37,6 +40,7 @@ public function testItBasic() public function testWithMultipleResults() { + $connection = DB::connection('mongodb'); $ones = [ EloquentWithCountModel1::create(['id' => 1]), EloquentWithCountModel1::create(['id' => 2]), @@ -50,6 +54,7 @@ public function testWithMultipleResults() $ones[2]->twos()->create(['value' => 1]); $ones[2]->twos()->create(['value' => 2]); + $connection->enableQueryLog(); $results = EloquentWithCountModel1::withCount([ 'twos' => function ($query) { $query->where('value', '>=', 2); @@ -61,6 +66,9 @@ public function testWithMultipleResults() ['id' => 2, 'twos_count' => 0], ['id' => 3, 'twos_count' => 1], ], $results->get()->toArray()); + + $connection->disableQueryLog(); + $this->assertEquals(2, count($connection->getQueryLog())); } public function testGlobalScopes() From 428588021eabf44915362c3ead23d6ab84025817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 17 Oct 2024 19:43:51 +0200 Subject: [PATCH 03/11] Validate arg type and avoid subsequent error --- src/Eloquent/Builder.php | 2 +- src/Query/Builder.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 815c02ba7..778dea6bd 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -364,7 +364,7 @@ public function eagerLoadRelations(array $models) ->where($withAggregate['constraints']) ->whereIn($withAggregate['relation']->getForeignKeyName(), $modelIds) ->groupBy($withAggregate['relation']->getForeignKeyName()) - ->aggregate($withAggregate['function'], [$withAggregate['column'] ?? $withAggregate['relation']->getPrimaryKeyName()]); + ->aggregate($withAggregate['function'], [$withAggregate['column']]); foreach ($models as $model) { $value = $withAggregate['function'] === 'count' ? 0 : null; diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 644bcbddf..0c0007dc1 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -346,7 +346,7 @@ public function toMql(): array if ($this->aggregate) { $function = $this->aggregate['function']; - foreach ((array) $this->aggregate['columns'] as $column) { + foreach ($this->aggregate['columns'] as $column) { // Add unwind if a subdocument array should be aggregated // column: subarray.price => {$unwind: '$subarray'} $splitColumns = explode('.*.', $column); @@ -355,7 +355,7 @@ public function toMql(): array $column = implode('.', $splitColumns); } - $aggregations = blank($this->aggregate['columns']) ? [] : (array) $this->aggregate['columns']; + $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; if (in_array('*', $aggregations) && $function === 'count' && empty($group['_id'])) { $options = $this->inheritConnectionOptions($this->options); @@ -506,11 +506,11 @@ public function getFresh($columns = [], $returnLazy = false) // here to either the passed columns, or the standard default of retrieving // all of the columns on the table using the "wildcard" column character. if ($this->columns === null) { - $this->columns = (array) $columns; + $this->columns = $columns; } // Drop all columns if * is present, MongoDB does not work this way. - if (in_array('*', (array) $this->columns)) { + if (in_array('*', $this->columns)) { $this->columns = []; } From f86f52e6da9b886ff673ad9f73635a5939ed20ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 18 Oct 2024 17:31:27 +0200 Subject: [PATCH 04/11] Add more tests --- src/Query/Builder.php | 10 ++++++++- tests/HybridRelationsTest.php | 8 +++---- tests/QueryBuilderTest.php | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 0c0007dc1..190d22671 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -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/HybridRelationsTest.php b/tests/HybridRelationsTest.php index cd6f6862b..975b58a30 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -207,10 +207,10 @@ public function testHybridWith() ->each(function ($user) { $this->assertEquals($user->id, $user->books->count()); }); - SqlUser::withCount('books')->get() - ->each(function ($user) { - $this->assertEquals($user->id, $user->books_count); - }); + //SqlUser::withCount('books')->get() + // ->each(function ($user) { + // $this->assertEquals($user->id, $user->books_count); + // }); SqlUser::whereHas('sqlBooks', function ($query) { return $query->where('title', 'LIKE', 'Harry%'); diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 01f937915..a46569803 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(); } @@ -575,6 +576,12 @@ public function testAggregate() $this->assertEquals(3, DB::table('items')->min('amount')); $this->assertEquals(34, DB::table('items')->max('amount')); $this->assertEquals(17.75, DB::table('items')->avg('amount')); + $this->assertTrue(DB::table('items')->exists()); + $this->assertTrue(DB::table('items')->where('name', 'knife')->exists()); + $this->assertFalse(DB::table('items')->where('name', 'ladle')->exists()); + $this->assertFalse(DB::table('items')->doesntExist()); + $this->assertFalse(DB::table('items')->where('name', 'knife')->doesntExist()); + $this->assertTrue(DB::table('items')->where('name', 'ladle')->doesntExist()); $this->assertEquals(2, DB::table('items')->where('name', 'spoon')->count('amount')); $this->assertEquals(14, DB::table('items')->where('name', 'spoon')->max('amount')); @@ -1155,4 +1162,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()); + } } From e77c16d8417b5a21f60d60305855b89df5440077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 21 Jan 2025 18:19:46 +0100 Subject: [PATCH 05/11] Fix withAggregate --- src/Eloquent/Builder.php | 3 +- tests/Eloquent/EloquentWithAggregateTest.php | 263 +++++++++++++++++++ tests/Eloquent/EloquentWithCountTest.php | 176 ------------- 3 files changed, 265 insertions(+), 177 deletions(-) create mode 100644 tests/Eloquent/EloquentWithAggregateTest.php delete mode 100644 tests/Eloquent/EloquentWithCountTest.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 778dea6bd..a18432265 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -319,7 +319,7 @@ public function withAggregate($relations, $column, $function = null) $segments = explode(' ', $name); $name = $segments[0]; - $alias = (count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[2] : Str::snake($name) . '_count'); + $alias = (count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[2] : Str::snake($name) . '_' . $function); $relation = $this->getRelationWithoutConstraints($name); @@ -335,6 +335,7 @@ public function withAggregate($relations, $column, $function = null) throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function)); } } else { + // @todo support "exists" $this->withAggregate[$alias] = [ 'relation' => $relation, 'function' => $function, diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php new file mode 100644 index 000000000..7a2ed97f3 --- /dev/null +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -0,0 +1,263 @@ + 1]); + $one = EloquentWithCountModel1::create(['id' => 2]); + $one->twos()->create(['value' => 4]); + $one->twos()->create(['value' => 6]); + + $results = EloquentWithCountModel1::withCount('twos')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_count' => 2], + ], $results->get()->toArray()); + + $results = EloquentWithCountModel1::withMax('twos', 'value')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_max' => 6], + ], $results->get()->toArray()); + + $results = EloquentWithCountModel1::withMin('twos', 'value')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_min' => 4], + ], $results->get()->toArray()); + + $results = EloquentWithCountModel1::withAvg('twos', 'value')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_avg' => 5.0], + ], $results->get()->toArray()); + } + + public function testWithAggregateFiltered() + { + EloquentWithCountModel1::create(['id' => 1]); + $one = EloquentWithCountModel1::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 = EloquentWithCountModel1::withCount(['twos' => $filter])->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_count' => 2], + ], $results->get()->toArray()); + + $results = EloquentWithCountModel1::withMax(['twos' => $filter], 'value')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_max' => 6], + ], $results->get()->toArray()); + + $results = EloquentWithCountModel1::withMin(['twos' => $filter], 'value')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_min' => 4], + ], $results->get()->toArray()); + + $results = EloquentWithCountModel1::withAvg(['twos' => $filter], 'value')->where('id', 2); + $this->assertSame([ + ['id' => 2, 'twos_avg' => 5.0], + ], $results->get()->toArray()); + } + + public function testWithAggregateMultipleResults() + { + $connection = DB::connection('mongodb'); + $ones = [ + EloquentWithCountModel1::create(['id' => 1]), + EloquentWithCountModel1::create(['id' => 2]), + EloquentWithCountModel1::create(['id' => 3]), + EloquentWithCountModel1::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 = EloquentWithCountModel1::withCount([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ]); + + $this->assertSame([ + ['id' => 1, 'twos_count' => 2], + ['id' => 2, 'twos_count' => 0], + ['id' => 3, 'twos_count' => 1], + ['id' => 4, 'twos_count' => 0], + ], $results->get()->toArray()); + + $this->assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + + // Max + $results = EloquentWithCountModel1::withMax([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ], 'value'); + + $this->assertSame([ + ['id' => 1, 'twos_max' => 3], + ['id' => 2, 'twos_max' => null], + ['id' => 3, 'twos_max' => 2], + ['id' => 4, 'twos_max' => null], + ], $results->get()->toArray()); + + $this->assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + + // Min + $results = EloquentWithCountModel1::withMin([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ], 'value'); + + $this->assertSame([ + ['id' => 1, 'twos_min' => 2], + ['id' => 2, 'twos_min' => null], + ['id' => 3, 'twos_min' => 2], + ['id' => 4, 'twos_min' => null], + ], $results->get()->toArray()); + + $this->assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + + // Avg + $results = EloquentWithCountModel1::withAvg([ + 'twos' => function ($query) { + $query->where('value', '>=', 2); + }, + ], 'value'); + + $this->assertSame([ + ['id' => 1, 'twos_avg' => 2.5], + ['id' => 2, 'twos_avg' => null], + ['id' => 3, 'twos_avg' => 2.0], + ['id' => 4, 'twos_avg' => null], + ], $results->get()->toArray()); + + $this->assertSame(2, count($connection->getQueryLog())); + $connection->flushQueryLog(); + } + + public function testGlobalScopes() + { + $one = EloquentWithCountModel1::create(); + $one->fours()->create(); + + $result = EloquentWithCountModel1::withCount('fours')->first(); + $this->assertSame(0, $result->fours_count); + + $result = EloquentWithCountModel1::withCount('allFours')->first(); + $this->assertSame(1, $result->all_fours_count); + } +} + +class EloquentWithCountModel1 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'one'; + public $timestamps = false; + protected $guarded = []; + + public function twos() + { + return $this->hasMany(EloquentWithCountModel2::class, 'one_id'); + } + + public function fours() + { + return $this->hasMany(EloquentWithCountModel4::class, 'one_id'); + } + + public function allFours() + { + return $this->fours()->withoutGlobalScopes(); + } +} + +class EloquentWithCountModel2 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'two'; + public $timestamps = false; + protected $guarded = []; + protected $withCount = ['threes']; + + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->latest(); + }); + } + + public function threes() + { + return $this->hasMany(EloquentWithCountModel3::class, 'two_id'); + } +} + +class EloquentWithCountModel3 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'three'; + public $timestamps = false; + protected $guarded = []; + + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->where('id', '>', 0); + }); + } +} + +class EloquentWithCountModel4 extends Model +{ + protected $connection = 'mongodb'; + public $table = 'four'; + public $timestamps = false; + protected $guarded = []; + + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->where('id', '>', 1); + }); + } +} diff --git a/tests/Eloquent/EloquentWithCountTest.php b/tests/Eloquent/EloquentWithCountTest.php deleted file mode 100644 index 0785eaf45..000000000 --- a/tests/Eloquent/EloquentWithCountTest.php +++ /dev/null @@ -1,176 +0,0 @@ - 123]); - $two = $one->twos()->create(['value' => 456]); - $two->threes()->create(); - - $results = EloquentWithCountModel1::withCount([ - 'twos' => function ($query) { - $query->where('value', '>=', 456); - }, - ]); - - $this->assertEquals([ - ['id' => 123, 'twos_count' => 1], - ], $results->get()->toArray()); - } - - public function testWithMultipleResults() - { - $connection = DB::connection('mongodb'); - $ones = [ - EloquentWithCountModel1::create(['id' => 1]), - EloquentWithCountModel1::create(['id' => 2]), - EloquentWithCountModel1::create(['id' => 3]), - ]; - - $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(); - $results = EloquentWithCountModel1::withCount([ - 'twos' => function ($query) { - $query->where('value', '>=', 2); - }, - ]); - - $this->assertEquals([ - ['id' => 1, 'twos_count' => 2], - ['id' => 2, 'twos_count' => 0], - ['id' => 3, 'twos_count' => 1], - ], $results->get()->toArray()); - - $connection->disableQueryLog(); - $this->assertEquals(2, count($connection->getQueryLog())); - } - - public function testGlobalScopes() - { - $one = EloquentWithCountModel1::create(); - $one->fours()->create(); - - $result = EloquentWithCountModel1::withCount('fours')->first(); - $this->assertEquals(0, $result->fours_count); - - $result = EloquentWithCountModel1::withCount('allFours')->first(); - $this->assertEquals(1, $result->all_fours_count); - } - - public function testSortingScopes() - { - $one = EloquentWithCountModel1::create(); - $one->twos()->create(); - - $query = EloquentWithCountModel1::withCount('twos')->getQuery(); - - $this->assertNull($query->orders); - $this->assertSame([], $query->getRawBindings()['order']); - } -} - -class EloquentWithCountModel1 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'one'; - public $timestamps = false; - protected $guarded = []; - - public function twos() - { - return $this->hasMany(EloquentWithCountModel2::class, 'one_id'); - } - - public function fours() - { - return $this->hasMany(EloquentWithCountModel4::class, 'one_id'); - } - - public function allFours() - { - return $this->fours()->withoutGlobalScopes(); - } -} - -class EloquentWithCountModel2 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'two'; - public $timestamps = false; - protected $guarded = []; - protected $withCount = ['threes']; - - protected static function boot() - { - parent::boot(); - - static::addGlobalScope('app', function ($builder) { - $builder->latest(); - }); - } - - public function threes() - { - return $this->hasMany(EloquentWithCountModel3::class, 'two_id'); - } -} - -class EloquentWithCountModel3 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'three'; - public $timestamps = false; - protected $guarded = []; - - protected static function boot() - { - parent::boot(); - - static::addGlobalScope('app', function ($builder) { - $builder->where('id', '>', 0); - }); - } -} - -class EloquentWithCountModel4 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'four'; - public $timestamps = false; - protected $guarded = []; - - protected static function boot() - { - parent::boot(); - - static::addGlobalScope('app', function ($builder) { - $builder->where('id', '>', 1); - }); - } -} From 29ff22a0fabfebce86276458cb82cf6007d702e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 21 Jan 2025 21:29:36 +0100 Subject: [PATCH 06/11] Implement withAggregate for embedded relations --- src/Eloquent/Builder.php | 8 +- tests/Eloquent/EloquentWithAggregateTest.php | 219 +++++++++++++------ 2 files changed, 160 insertions(+), 67 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index a18432265..dfee642d0 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -326,10 +326,12 @@ public function withAggregate($relations, $column, $function = null) if ($relation instanceof EmbedsOneOrMany) { switch ($function) { case 'count': - $this->project([$alias => ['$size' => ['$ifNull' => ['$' . $relation->getQualifiedForeignKeyName(), []]]]]); + $this->project([$alias => ['$size' => ['$ifNull' => ['$' . $name, []]]]]); break; - case 'exists': - $this->project([$alias => ['$exists' => '$' . $relation->getQualifiedForeignKeyName()]]); + case 'min': + case 'max': + case 'avg': + $this->project([$alias => ['$' . $function => '$' . $name . '.' . $column]]); break; default: throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function)); diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php index 7a2ed97f3..80162522b 100644 --- a/tests/Eloquent/EloquentWithAggregateTest.php +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -3,56 +3,86 @@ namespace MongoDB\Laravel\Tests\Eloquent; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Tests\TestCase; use function count; +use function ksort; class EloquentWithAggregateTest extends TestCase { protected function tearDown(): void { - EloquentWithCountModel1::truncate(); - EloquentWithCountModel2::truncate(); - EloquentWithCountModel3::truncate(); - EloquentWithCountModel4::truncate(); + EloquentWithAggregateModel1::truncate(); + EloquentWithAggregateModel2::truncate(); + EloquentWithAggregateModel3::truncate(); + EloquentWithAggregateModel4::truncate(); parent::tearDown(); } public function testWithAggregate() { - EloquentWithCountModel1::create(['id' => 1]); - $one = EloquentWithCountModel1::create(['id' => 2]); + EloquentWithAggregateModel1::create(['id' => 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); $one->twos()->create(['value' => 4]); $one->twos()->create(['value' => 6]); - $results = EloquentWithCountModel1::withCount('twos')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withCount('twos')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_count' => 2], - ], $results->get()->toArray()); + ], $results->get()); - $results = EloquentWithCountModel1::withMax('twos', 'value')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withMax('twos', 'value')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_max' => 6], - ], $results->get()->toArray()); + ], $results->get()); - $results = EloquentWithCountModel1::withMin('twos', 'value')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withMin('twos', 'value')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_min' => 4], - ], $results->get()->toArray()); + ], $results->get()); - $results = EloquentWithCountModel1::withAvg('twos', 'value')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withAvg('twos', 'value')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_avg' => 5.0], - ], $results->get()->toArray()); + ], $results->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() { - EloquentWithCountModel1::create(['id' => 1]); - $one = EloquentWithCountModel1::create(['id' => 2]); + EloquentWithAggregateModel1::create(['id' => 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); $one->twos()->create(['value' => 4]); $one->twos()->create(['value' => 6]); $one->twos()->create(['value' => 8]); @@ -60,35 +90,69 @@ public function testWithAggregateFiltered() $query->where('value', '<=', 6); }; - $results = EloquentWithCountModel1::withCount(['twos' => $filter])->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withCount(['twos' => $filter])->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_count' => 2], - ], $results->get()->toArray()); + ], $results->get()); - $results = EloquentWithCountModel1::withMax(['twos' => $filter], 'value')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withMax(['twos' => $filter], 'value')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_max' => 6], - ], $results->get()->toArray()); + ], $results->get()); - $results = EloquentWithCountModel1::withMin(['twos' => $filter], 'value')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withMin(['twos' => $filter], 'value')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_min' => 4], - ], $results->get()->toArray()); + ], $results->get()); - $results = EloquentWithCountModel1::withAvg(['twos' => $filter], 'value')->where('id', 2); - $this->assertSame([ + $results = EloquentWithAggregateModel1::withAvg(['twos' => $filter], 'value')->where('id', 2); + self::assertSameResults([ ['id' => 2, 'twos_avg' => 5.0], - ], $results->get()->toArray()); + ], $results->get()); + } + + public function testWithAggregateEmbedFiltered() + { + self::markTestSkipped('EmbedsMany does not support filtering. $filter requires an expression but the Query Builder generates query predicates.'); + + EloquentWithAggregateModel1::create(['id' => 1]); + $one = EloquentWithAggregateModel1::create(['id' => 2]); + $one->embeddeds()->create(['value' => 4]); + $one->embeddeds()->create(['value' => 6]); + $one->embeddeds()->create(['value' => 8]); + $filter = static function (Builder $query) { + $query->where('value', '<=', 6); + }; + + $results = EloquentWithAggregateModel1::withCount(['embeddeds' => $filter])->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_count' => 2], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMax(['embeddeds' => $filter], 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_max' => 6], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withMin(['embeddeds' => $filter], 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_min' => 4], + ], $results->get()); + + $results = EloquentWithAggregateModel1::withAvg(['embeddeds' => $filter], 'value')->where('id', 2); + self::assertSameResults([ + ['id' => 2, 'embeddeds_avg' => 5.0], + ], $results->get()); } public function testWithAggregateMultipleResults() { $connection = DB::connection('mongodb'); $ones = [ - EloquentWithCountModel1::create(['id' => 1]), - EloquentWithCountModel1::create(['id' => 2]), - EloquentWithCountModel1::create(['id' => 3]), - EloquentWithCountModel1::create(['id' => 4]), + EloquentWithAggregateModel1::create(['id' => 1]), + EloquentWithAggregateModel1::create(['id' => 2]), + EloquentWithAggregateModel1::create(['id' => 3]), + EloquentWithAggregateModel1::create(['id' => 4]), ]; $ones[0]->twos()->create(['value' => 1]); @@ -101,88 +165,103 @@ public function testWithAggregateMultipleResults() $connection->enableQueryLog(); // Count - $results = EloquentWithCountModel1::withCount([ + $results = EloquentWithAggregateModel1::withCount([ 'twos' => function ($query) { $query->where('value', '>=', 2); }, ]); - $this->assertSame([ + self::assertSameResults([ ['id' => 1, 'twos_count' => 2], ['id' => 2, 'twos_count' => 0], ['id' => 3, 'twos_count' => 1], ['id' => 4, 'twos_count' => 0], - ], $results->get()->toArray()); + ], $results->get()); - $this->assertSame(2, count($connection->getQueryLog())); + self::assertSame(2, count($connection->getQueryLog())); $connection->flushQueryLog(); // Max - $results = EloquentWithCountModel1::withMax([ + $results = EloquentWithAggregateModel1::withMax([ 'twos' => function ($query) { $query->where('value', '>=', 2); }, ], 'value'); - $this->assertSame([ + self::assertSameResults([ ['id' => 1, 'twos_max' => 3], ['id' => 2, 'twos_max' => null], ['id' => 3, 'twos_max' => 2], ['id' => 4, 'twos_max' => null], - ], $results->get()->toArray()); + ], $results->get()); - $this->assertSame(2, count($connection->getQueryLog())); + self::assertSame(2, count($connection->getQueryLog())); $connection->flushQueryLog(); // Min - $results = EloquentWithCountModel1::withMin([ + $results = EloquentWithAggregateModel1::withMin([ 'twos' => function ($query) { $query->where('value', '>=', 2); }, ], 'value'); - $this->assertSame([ + self::assertSameResults([ ['id' => 1, 'twos_min' => 2], ['id' => 2, 'twos_min' => null], ['id' => 3, 'twos_min' => 2], ['id' => 4, 'twos_min' => null], - ], $results->get()->toArray()); + ], $results->get()); - $this->assertSame(2, count($connection->getQueryLog())); + self::assertSame(2, count($connection->getQueryLog())); $connection->flushQueryLog(); // Avg - $results = EloquentWithCountModel1::withAvg([ + $results = EloquentWithAggregateModel1::withAvg([ 'twos' => function ($query) { $query->where('value', '>=', 2); }, ], 'value'); - $this->assertSame([ + 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()->toArray()); + ], $results->get()); - $this->assertSame(2, count($connection->getQueryLog())); + self::assertSame(2, count($connection->getQueryLog())); $connection->flushQueryLog(); } public function testGlobalScopes() { - $one = EloquentWithCountModel1::create(); + $one = EloquentWithAggregateModel1::create(); $one->fours()->create(); - $result = EloquentWithCountModel1::withCount('fours')->first(); - $this->assertSame(0, $result->fours_count); + $result = EloquentWithAggregateModel1::withCount('fours')->first(); + self::assertSame(0, $result->fours_count); + + $result = EloquentWithAggregateModel1::withCount('allFours')->first(); + self::assertSame(1, $result->all_fours_count); + } + + private static function assertSameResults(array $expected, Collection $collection) + { + $actual = $collection->toArray(); + + foreach ($actual as &$item) { + ksort($item); + } + + foreach ($expected as &$item) { + ksort($item); + } - $result = EloquentWithCountModel1::withCount('allFours')->first(); - $this->assertSame(1, $result->all_fours_count); + self::assertSame($expected, $actual); } } -class EloquentWithCountModel1 extends Model +class EloquentWithAggregateModel1 extends Model { protected $connection = 'mongodb'; public $table = 'one'; @@ -191,21 +270,26 @@ class EloquentWithCountModel1 extends Model public function twos() { - return $this->hasMany(EloquentWithCountModel2::class, 'one_id'); + return $this->hasMany(EloquentWithAggregateModel2::class, 'one_id'); } public function fours() { - return $this->hasMany(EloquentWithCountModel4::class, 'one_id'); + return $this->hasMany(EloquentWithAggregateModel4::class, 'one_id'); } public function allFours() { return $this->fours()->withoutGlobalScopes(); } + + public function embeddeds() + { + return $this->embedsMany(EloquentWithAggregateEmbeddedModel::class); + } } -class EloquentWithCountModel2 extends Model +class EloquentWithAggregateModel2 extends Model { protected $connection = 'mongodb'; public $table = 'two'; @@ -224,11 +308,11 @@ protected static function boot() public function threes() { - return $this->hasMany(EloquentWithCountModel3::class, 'two_id'); + return $this->hasMany(EloquentWithAggregateModel3::class, 'two_id'); } } -class EloquentWithCountModel3 extends Model +class EloquentWithAggregateModel3 extends Model { protected $connection = 'mongodb'; public $table = 'three'; @@ -245,7 +329,7 @@ protected static function boot() } } -class EloquentWithCountModel4 extends Model +class EloquentWithAggregateModel4 extends Model { protected $connection = 'mongodb'; public $table = 'four'; @@ -261,3 +345,10 @@ protected static function boot() }); } } + +class EloquentWithAggregateEmbeddedModel extends Model +{ + protected $connection = 'mongodb'; + public $timestamps = false; + protected $guarded = []; +} From 303ceb7c1cd39abf0aa423c9112b7f78fd218f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 22 Jan 2025 16:50:35 +0100 Subject: [PATCH 07/11] Fix CS --- src/Eloquent/Builder.php | 4 +- tests/Eloquent/EloquentWithAggregateTest.php | 97 +------------------ .../EloquentWithAggregateEmbeddedModel.php | 12 +++ .../Models/EloquentWithAggregateModel1.php | 33 +++++++ .../Models/EloquentWithAggregateModel2.php | 28 ++++++ .../Models/EloquentWithAggregateModel3.php | 22 +++++ .../Models/EloquentWithAggregateModel4.php | 22 +++++ 7 files changed, 123 insertions(+), 95 deletions(-) create mode 100644 tests/Eloquent/Models/EloquentWithAggregateEmbeddedModel.php create mode 100644 tests/Eloquent/Models/EloquentWithAggregateModel1.php create mode 100644 tests/Eloquent/Models/EloquentWithAggregateModel2.php create mode 100644 tests/Eloquent/Models/EloquentWithAggregateModel3.php create mode 100644 tests/Eloquent/Models/EloquentWithAggregateModel4.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index dfee642d0..d000a67e2 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -326,12 +326,12 @@ public function withAggregate($relations, $column, $function = null) if ($relation instanceof EmbedsOneOrMany) { switch ($function) { case 'count': - $this->project([$alias => ['$size' => ['$ifNull' => ['$' . $name, []]]]]); + $this->getQuery()->project([$alias => ['$size' => ['$ifNull' => ['$' . $name, []]]]]); break; case 'min': case 'max': case 'avg': - $this->project([$alias => ['$' . $function => '$' . $name . '.' . $column]]); + $this->getQuery()->project([$alias => ['$' . $function => '$' . $name . '.' . $column]]); break; default: throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function)); diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php index 80162522b..07365f04c 100644 --- a/tests/Eloquent/EloquentWithAggregateTest.php +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -5,7 +5,10 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use MongoDB\Laravel\Eloquent\Model; +use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel1; +use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel2; +use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel3; +use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel4; use MongoDB\Laravel\Tests\TestCase; use function count; @@ -260,95 +263,3 @@ private static function assertSameResults(array $expected, Collection $collectio self::assertSame($expected, $actual); } } - -class EloquentWithAggregateModel1 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'one'; - public $timestamps = false; - protected $guarded = []; - - public function twos() - { - return $this->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); - } -} - -class EloquentWithAggregateModel2 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'two'; - public $timestamps = false; - protected $guarded = []; - protected $withCount = ['threes']; - - protected static function boot() - { - parent::boot(); - - static::addGlobalScope('app', function ($builder) { - $builder->latest(); - }); - } - - public function threes() - { - return $this->hasMany(EloquentWithAggregateModel3::class, 'two_id'); - } -} - -class EloquentWithAggregateModel3 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'three'; - public $timestamps = false; - protected $guarded = []; - - protected static function boot() - { - parent::boot(); - - static::addGlobalScope('app', function ($builder) { - $builder->where('id', '>', 0); - }); - } -} - -class EloquentWithAggregateModel4 extends Model -{ - protected $connection = 'mongodb'; - public $table = 'four'; - public $timestamps = false; - protected $guarded = []; - - protected static function boot() - { - parent::boot(); - - static::addGlobalScope('app', function ($builder) { - $builder->where('id', '>', 1); - }); - } -} - -class EloquentWithAggregateEmbeddedModel extends Model -{ - protected $connection = 'mongodb'; - public $timestamps = false; - protected $guarded = []; -} 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); + } +} 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); + }); + } +} From 299d5ef505b8073719c0cf59bd5fef06ad7b6428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 22 Jan 2025 18:01:36 +0100 Subject: [PATCH 08/11] Add exception for non-supported withAggregate for hybrid relationships --- src/Eloquent/Builder.php | 20 +++++---- tests/Eloquent/EloquentWithAggregateTest.php | 42 ++++++++----------- .../EloquentWithAggregateHybridModel.php | 13 ++++++ .../Models/EloquentWithAggregateModel1.php | 5 +++ 4 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 tests/Eloquent/Models/EloquentWithAggregateHybridModel.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index d000a67e2..3bc4d39b0 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -16,6 +16,7 @@ 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; @@ -314,8 +315,6 @@ public function withAggregate($relations, $column, $function = null) $relations = is_array($relations) ? $relations : [$relations]; foreach ($this->parseWithRelations($relations) as $name => $constraints) { - // For "count" and "exist" we can use the embedded list of ids - // for embedded relations, everything can be computed directly using a projection. $segments = explode(' ', $name); $name = $segments[0]; @@ -323,7 +322,18 @@ public function withAggregate($relations, $column, $function = null) $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, []]]]]); @@ -337,7 +347,6 @@ public function withAggregate($relations, $column, $function = null) throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function)); } } else { - // @todo support "exists" $this->withAggregate[$alias] = [ 'relation' => $relation, 'function' => $function, @@ -346,11 +355,6 @@ public function withAggregate($relations, $column, $function = null) 'alias' => $alias, ]; } - - // @todo HasMany ? - - // Otherwise, we need to store the aggregate request to run during "eagerLoadRelation" - // after the root results are retrieved. } return $this; diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php index 07365f04c..31c1773c0 100644 --- a/tests/Eloquent/EloquentWithAggregateTest.php +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use InvalidArgumentException; use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel1; use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel2; use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel3; @@ -116,36 +117,16 @@ public function testWithAggregateFiltered() public function testWithAggregateEmbedFiltered() { - self::markTestSkipped('EmbedsMany does not support filtering. $filter requires an expression but the Query Builder generates query predicates.'); - - EloquentWithAggregateModel1::create(['id' => 1]); - $one = EloquentWithAggregateModel1::create(['id' => 2]); - $one->embeddeds()->create(['value' => 4]); - $one->embeddeds()->create(['value' => 6]); - $one->embeddeds()->create(['value' => 8]); + EloquentWithAggregateModel1::create(['id' => 2]); $filter = static function (Builder $query) { $query->where('value', '<=', 6); }; - $results = EloquentWithAggregateModel1::withCount(['embeddeds' => $filter])->where('id', 2); - self::assertSameResults([ - ['id' => 2, 'embeddeds_count' => 2], - ], $results->get()); - - $results = EloquentWithAggregateModel1::withMax(['embeddeds' => $filter], 'value')->where('id', 2); - self::assertSameResults([ - ['id' => 2, 'embeddeds_max' => 6], - ], $results->get()); - - $results = EloquentWithAggregateModel1::withMin(['embeddeds' => $filter], 'value')->where('id', 2); - self::assertSameResults([ - ['id' => 2, 'embeddeds_min' => 4], - ], $results->get()); + // @see https://jira.mongodb.org/browse/PHPORM-292 + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Constraints are not supported for embedded relations'); - $results = EloquentWithAggregateModel1::withAvg(['embeddeds' => $filter], 'value')->where('id', 2); - self::assertSameResults([ - ['id' => 2, 'embeddeds_avg' => 5.0], - ], $results->get()); + EloquentWithAggregateModel1::withCount(['embeddeds' => $filter])->where('id', 2)->get(); } public function testWithAggregateMultipleResults() @@ -248,6 +229,17 @@ public function testGlobalScopes() 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(); diff --git a/tests/Eloquent/Models/EloquentWithAggregateHybridModel.php b/tests/Eloquent/Models/EloquentWithAggregateHybridModel.php new file mode 100644 index 000000000..a8b31fe0b --- /dev/null +++ b/tests/Eloquent/Models/EloquentWithAggregateHybridModel.php @@ -0,0 +1,13 @@ +embedsMany(EloquentWithAggregateEmbeddedModel::class); } + + public function hybrids() + { + return $this->hasMany(EloquentWithAggregateHybridModel::class, 'one_id'); + } } From 1d4699edb959f4c68926e4055ecaa24c2ace31e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 23 Jan 2025 22:48:42 +0100 Subject: [PATCH 09/11] Review --- src/Eloquent/Builder.php | 34 ++++++++++++++++++-- tests/Eloquent/EloquentWithAggregateTest.php | 1 + tests/HybridRelationsTest.php | 5 --- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 3bc4d39b0..ed1525bb6 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -22,14 +22,19 @@ 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; @@ -43,7 +48,11 @@ class Builder extends EloquentBuilder private const DUPLICATE_KEY_ERROR = 11000; use QueriesRelationships; - /** @var array{relation: Relation, function: string, constraints: array, column: string, alias: string}[] */ + /** + * 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 = []; /** @@ -306,19 +315,37 @@ 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]) => $segments[2], + default => throw new InvalidArgumentException(sprintf('Invalid relation name format. Expected "relation as alias" or "relation", got "%s"', $name)), + }; $name = $segments[0]; - $alias = (count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[2] : Str::snake($name) . '_' . $function); $relation = $this->getRelationWithoutConstraints($name); @@ -347,6 +374,7 @@ public function withAggregate($relations, $column, $function = null) 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, @@ -384,6 +412,8 @@ public function eagerLoadRelations(array $models) $model->setAttribute($withAggregate['alias'], $value); } + } else { + throw new RuntimeException(sprintf('Unsupported relation type for aggregation', $withAggregate['relation']::class)); } } } diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php index 31c1773c0..117a6f6db 100644 --- a/tests/Eloquent/EloquentWithAggregateTest.php +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -162,6 +162,7 @@ public function testWithAggregateMultipleResults() ['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(); diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 975b58a30..71958d27d 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -157,7 +157,6 @@ public function testHybridWhereHas() public function testHybridWith() { - DB::connection('mongodb')->enableQueryLog(); $user = new SqlUser(); $otherUser = new SqlUser(); $this->assertInstanceOf(SqlUser::class, $user); @@ -207,10 +206,6 @@ public function testHybridWith() ->each(function ($user) { $this->assertEquals($user->id, $user->books->count()); }); - //SqlUser::withCount('books')->get() - // ->each(function ($user) { - // $this->assertEquals($user->id, $user->books_count); - // }); SqlUser::whereHas('sqlBooks', function ($query) { return $query->where('title', 'LIKE', 'Harry%'); From 42053d05fd3e8500c9bb0af0c03845d5609806d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 29 Jan 2025 13:38:22 +0100 Subject: [PATCH 10/11] Fix aggregation alias --- src/Eloquent/Builder.php | 4 +- tests/Eloquent/EloquentWithAggregateTest.php | 40 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index ed1525bb6..dce0d8274 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -342,7 +342,7 @@ public function withAggregate($relations, $column, $function = null) $alias = match (true) { count($segments) === 1 => Str::snake($segments[0]) . '_' . $function, - count($segments) === 3 && Str::lower($segments[1]) => $segments[2], + 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]; @@ -413,7 +413,7 @@ public function eagerLoadRelations(array $models) $model->setAttribute($withAggregate['alias'], $value); } } else { - throw new RuntimeException(sprintf('Unsupported relation type for aggregation', $withAggregate['relation']::class)); + throw new RuntimeException(sprintf('Unsupported relation type for aggregation: %s', $withAggregate['relation']::class)); } } } diff --git a/tests/Eloquent/EloquentWithAggregateTest.php b/tests/Eloquent/EloquentWithAggregateTest.php index 117a6f6db..749b68f05 100644 --- a/tests/Eloquent/EloquentWithAggregateTest.php +++ b/tests/Eloquent/EloquentWithAggregateTest.php @@ -11,6 +11,7 @@ use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel3; use MongoDB\Laravel\Tests\Eloquent\Models\EloquentWithAggregateModel4; use MongoDB\Laravel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use function count; use function ksort; @@ -55,6 +56,45 @@ public function testWithAggregate() ], $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]); From 3bcfe7c6dfc68d0fc83f426ef7eb3c2e465c0576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 29 Jan 2025 21:09:03 +0100 Subject: [PATCH 11/11] Remove tests on exists and doesntExist --- tests/QueryBuilderTest.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index a46569803..b4f4f2d16 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -576,12 +576,6 @@ public function testAggregate() $this->assertEquals(3, DB::table('items')->min('amount')); $this->assertEquals(34, DB::table('items')->max('amount')); $this->assertEquals(17.75, DB::table('items')->avg('amount')); - $this->assertTrue(DB::table('items')->exists()); - $this->assertTrue(DB::table('items')->where('name', 'knife')->exists()); - $this->assertFalse(DB::table('items')->where('name', 'ladle')->exists()); - $this->assertFalse(DB::table('items')->doesntExist()); - $this->assertFalse(DB::table('items')->where('name', 'knife')->doesntExist()); - $this->assertTrue(DB::table('items')->where('name', 'ladle')->doesntExist()); $this->assertEquals(2, DB::table('items')->where('name', 'spoon')->count('amount')); $this->assertEquals(14, DB::table('items')->where('name', 'spoon')->max('amount'));