From b55bdc6244f2421b5d39b16a1777e83cdc9d5737 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?=
 <jerome.tamarelle@mongodb.com>
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent;
+
+use MongoDB\Laravel\Eloquent\Model;
+use MongoDB\Laravel\Tests\TestCase;
+
+/** Copied from {@see \Illuminate\Tests\Integration\Database\EloquentWithCountTest\EloquentWithCountTest} */
+class EloquentWithCountTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        EloquentWithCountModel1::truncate();
+        EloquentWithCountModel2::truncate();
+        EloquentWithCountModel3::truncate();
+        EloquentWithCountModel4::truncate();
+
+        parent::tearDown();
+    }
+
+    public function testItBasic()
+    {
+        $one = EloquentWithCountModel1::create(['id' => 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?=
 <jerome.tamarelle@mongodb.com>
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?=
 <jerome.tamarelle@mongodb.com>
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?=
 <jerome.tamarelle@mongodb.com>
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?=
 <jerome.tamarelle@mongodb.com>
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+use MongoDB\Laravel\Eloquent\Model;
+use MongoDB\Laravel\Tests\TestCase;
+
+use function count;
+
+class EloquentWithAggregateTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        EloquentWithCountModel1::truncate();
+        EloquentWithCountModel2::truncate();
+        EloquentWithCountModel3::truncate();
+        EloquentWithCountModel4::truncate();
+
+        parent::tearDown();
+    }
+
+    public function testWithAggregate()
+    {
+        EloquentWithCountModel1::create(['id' => 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 @@
-<?php
-
-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
-{
-    protected function tearDown(): void
-    {
-        EloquentWithCountModel1::truncate();
-        EloquentWithCountModel2::truncate();
-        EloquentWithCountModel3::truncate();
-        EloquentWithCountModel4::truncate();
-
-        parent::tearDown();
-    }
-
-    public function testItBasic()
-    {
-        $one = EloquentWithCountModel1::create(['id' => 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?=
 <jerome.tamarelle@mongodb.com>
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?=
 <jerome.tamarelle@mongodb.com>
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent\Models;
+
+use MongoDB\Laravel\Eloquent\Model;
+
+class EloquentWithAggregateEmbeddedModel extends Model
+{
+    protected $connection = 'mongodb';
+    public $timestamps = false;
+    protected $guarded = [];
+}
diff --git a/tests/Eloquent/Models/EloquentWithAggregateModel1.php b/tests/Eloquent/Models/EloquentWithAggregateModel1.php
new file mode 100644
index 000000000..9f3c8a5ed
--- /dev/null
+++ b/tests/Eloquent/Models/EloquentWithAggregateModel1.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent\Models;
+
+use MongoDB\Laravel\Eloquent\Model;
+
+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);
+    }
+}
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent\Models;
+
+use MongoDB\Laravel\Eloquent\Model;
+
+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');
+    }
+}
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent\Models;
+
+use MongoDB\Laravel\Eloquent\Model;
+
+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);
+        });
+    }
+}
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent\Models;
+
+use MongoDB\Laravel\Eloquent\Model;
+
+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);
+        });
+    }
+}

From 299d5ef505b8073719c0cf59bd5fef06ad7b6428 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?=
 <jerome.tamarelle@mongodb.com>
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 @@
+<?php
+
+namespace MongoDB\Laravel\Tests\Eloquent\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class EloquentWithAggregateHybridModel extends Model
+{
+    protected $connection = 'sqlite';
+    public $table = 'hybrid';
+    public $timestamps = false;
+    protected $guarded = [];
+}
diff --git a/tests/Eloquent/Models/EloquentWithAggregateModel1.php b/tests/Eloquent/Models/EloquentWithAggregateModel1.php
index 9f3c8a5ed..a028eebe2 100644
--- a/tests/Eloquent/Models/EloquentWithAggregateModel1.php
+++ b/tests/Eloquent/Models/EloquentWithAggregateModel1.php
@@ -30,4 +30,9 @@ public function embeddeds()
     {
         return $this->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?=
 <jerome.tamarelle@mongodb.com>
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?=
 <jerome.tamarelle@mongodb.com>
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?=
 <jerome.tamarelle@mongodb.com>
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'));