Skip to content

Commit cb8d9d1

Browse files
committed
PHPORM-238 Add support for withCount using a subquery
1 parent dd5283a commit cb8d9d1

File tree

4 files changed

+273
-5
lines changed

4 files changed

+273
-5
lines changed

src/Eloquent/Builder.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,39 @@
55
namespace MongoDB\Laravel\Eloquent;
66

77
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
8+
use Illuminate\Database\Eloquent\Relations\Relation;
9+
use Illuminate\Support\Str;
10+
use InvalidArgumentException;
811
use MongoDB\BSON\Document;
912
use MongoDB\Driver\CursorInterface;
1013
use MongoDB\Driver\Exception\WriteException;
1114
use MongoDB\Laravel\Connection;
1215
use MongoDB\Laravel\Helpers\QueriesRelationships;
1316
use MongoDB\Laravel\Query\AggregationBuilder;
17+
use MongoDB\Laravel\Relations\EmbedsOneOrMany;
18+
use MongoDB\Laravel\Relations\HasMany;
1419
use MongoDB\Model\BSONDocument;
1520

1621
use function array_key_exists;
1722
use function array_merge;
1823
use function collect;
24+
use function count;
25+
use function explode;
1926
use function is_array;
2027
use function is_object;
2128
use function iterator_to_array;
2229
use function property_exists;
30+
use function sprintf;
2331

2432
/** @method \MongoDB\Laravel\Query\Builder toBase() */
2533
class Builder extends EloquentBuilder
2634
{
2735
private const DUPLICATE_KEY_ERROR = 11000;
2836
use QueriesRelationships;
2937

38+
/** @var array{relation: Relation, function: string, constraints: array, column: string, alias: string}[] */
39+
private array $withAggregate = [];
40+
3041
/**
3142
* The methods that should be returned from query builder.
3243
*
@@ -239,6 +250,85 @@ public function createOrFirst(array $attributes = [], array $values = [])
239250
}
240251
}
241252

253+
public function withAggregate($relations, $column, $function = null)
254+
{
255+
if (empty($relations)) {
256+
return $this;
257+
}
258+
259+
$relations = is_array($relations) ? $relations : [$relations];
260+
261+
foreach ($this->parseWithRelations($relations) as $name => $constraints) {
262+
// For "count" and "exist" we can use the embedded list of ids
263+
// for embedded relations, everything can be computed directly using a projection.
264+
$segments = explode(' ', $name);
265+
266+
$name = $segments[0];
267+
$alias = (count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[2] : Str::snake($name) . '_count');
268+
269+
$relation = $this->getRelationWithoutConstraints($name);
270+
271+
if ($relation instanceof EmbedsOneOrMany) {
272+
switch ($function) {
273+
case 'count':
274+
$this->project([$alias => ['$size' => ['$ifNull' => ['$' . $relation->getQualifiedForeignKeyName(), []]]]]);
275+
break;
276+
case 'exists':
277+
$this->project([$alias => ['$exists' => '$' . $relation->getQualifiedForeignKeyName()]]);
278+
break;
279+
default:
280+
throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function));
281+
}
282+
} else {
283+
$this->withAggregate[$alias] = [
284+
'relation' => $relation,
285+
'function' => $function,
286+
'constraints' => $constraints,
287+
'column' => $column,
288+
'alias' => $alias,
289+
];
290+
}
291+
292+
// @todo HasMany ?
293+
294+
// Otherwise, we need to store the aggregate request to run during "eagerLoadRelation"
295+
// after the root results are retrieved.
296+
}
297+
298+
return $this;
299+
}
300+
301+
public function eagerLoadRelations(array $models)
302+
{
303+
if ($this->withAggregate) {
304+
$modelIds = collect($models)->pluck($this->model->getKeyName())->all();
305+
306+
foreach ($this->withAggregate as $withAggregate) {
307+
if ($withAggregate['relation'] instanceof HasMany) {
308+
$results = $withAggregate['relation']->newQuery()
309+
->where($withAggregate['constraints'])
310+
->whereIn($withAggregate['relation']->getForeignKeyName(), $modelIds)
311+
->groupBy($withAggregate['relation']->getForeignKeyName())
312+
->aggregate($withAggregate['function'], $withAggregate['column'] ?? [$withAggregate['relation']->getPrimaryKeyName()]);
313+
314+
foreach ($models as $model) {
315+
$value = $withAggregate['function'] === 'count' ? 0 : null;
316+
foreach ($results as $result) {
317+
if ($model->getKey() === $result->{$withAggregate['relation']->getForeignKeyName()}) {
318+
$value = $result->aggregate;
319+
break;
320+
}
321+
}
322+
323+
$model->setAttribute($withAggregate['alias'], $value);
324+
}
325+
}
326+
}
327+
}
328+
329+
return parent::eagerLoadRelations($models);
330+
}
331+
242332
/**
243333
* Add the "updated at" column to an array of values.
244334
* TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e

src/Query/Builder.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ public function toMql(): array
335335
if ($this->aggregate) {
336336
$function = $this->aggregate['function'];
337337

338-
foreach ($this->aggregate['columns'] as $column) {
338+
foreach ((array) $this->aggregate['columns'] as $column) {
339339
// Add unwind if a subdocument array should be aggregated
340340
// column: subarray.price => {$unwind: '$subarray'}
341341
$splitColumns = explode('.*.', $column);
@@ -344,9 +344,9 @@ public function toMql(): array
344344
$column = implode('.', $splitColumns);
345345
}
346346

347-
$aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns'];
347+
$aggregations = blank($this->aggregate['columns']) ? [] : (array) $this->aggregate['columns'];
348348

349-
if (in_array('*', $aggregations) && $function === 'count') {
349+
if (in_array('*', $aggregations) && $function === 'count' && empty($group['_id'])) {
350350
$options = $this->inheritConnectionOptions($this->options);
351351

352352
return ['countDocuments' => [$wheres, $options]];
@@ -484,11 +484,11 @@ public function getFresh($columns = [], $returnLazy = false)
484484
// here to either the passed columns, or the standard default of retrieving
485485
// all of the columns on the table using the "wildcard" column character.
486486
if ($this->columns === null) {
487-
$this->columns = $columns;
487+
$this->columns = (array) $columns;
488488
}
489489

490490
// Drop all columns if * is present, MongoDB does not work this way.
491-
if (in_array('*', $this->columns)) {
491+
if (in_array('*', (array) $this->columns)) {
492492
$this->columns = [];
493493
}
494494

@@ -596,6 +596,11 @@ public function aggregate($function = null, $columns = ['*'])
596596
$this->columns = $previousColumns;
597597
$this->bindings['select'] = $previousSelectBindings;
598598

599+
// When the aggregation is per group, we return the results as is.
600+
if ($this->groups) {
601+
return $results;
602+
}
603+
599604
if (isset($results[0])) {
600605
$result = (array) $results[0];
601606

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Tests\Eloquent;
4+
5+
use MongoDB\Laravel\Eloquent\Model;
6+
use MongoDB\Laravel\Tests\TestCase;
7+
8+
/** Copied from {@see \Illuminate\Tests\Integration\Database\EloquentWithCountTest\EloquentWithCountTest} */
9+
class EloquentWithCountTest extends TestCase
10+
{
11+
protected function tearDown(): void
12+
{
13+
EloquentWithCountModel1::truncate();
14+
EloquentWithCountModel2::truncate();
15+
EloquentWithCountModel3::truncate();
16+
EloquentWithCountModel4::truncate();
17+
18+
parent::tearDown();
19+
}
20+
21+
public function testItBasic()
22+
{
23+
$one = EloquentWithCountModel1::create(['id' => 123]);
24+
$two = $one->twos()->create(['value' => 456]);
25+
$two->threes()->create();
26+
27+
$results = EloquentWithCountModel1::withCount([
28+
'twos' => function ($query) {
29+
$query->where('value', '>=', 456);
30+
},
31+
]);
32+
33+
$this->assertEquals([
34+
['id' => 123, 'twos_count' => 1],
35+
], $results->get()->toArray());
36+
}
37+
38+
public function testWithMultipleResults()
39+
{
40+
$ones = [
41+
EloquentWithCountModel1::create(['id' => 1]),
42+
EloquentWithCountModel1::create(['id' => 2]),
43+
EloquentWithCountModel1::create(['id' => 3]),
44+
];
45+
46+
$ones[0]->twos()->create(['value' => 1]);
47+
$ones[0]->twos()->create(['value' => 2]);
48+
$ones[0]->twos()->create(['value' => 3]);
49+
$ones[0]->twos()->create(['value' => 1]);
50+
$ones[2]->twos()->create(['value' => 1]);
51+
$ones[2]->twos()->create(['value' => 2]);
52+
53+
$results = EloquentWithCountModel1::withCount([
54+
'twos' => function ($query) {
55+
$query->where('value', '>=', 2);
56+
},
57+
]);
58+
59+
$this->assertEquals([
60+
['id' => 1, 'twos_count' => 2],
61+
['id' => 2, 'twos_count' => 0],
62+
['id' => 3, 'twos_count' => 1],
63+
], $results->get()->toArray());
64+
}
65+
66+
public function testGlobalScopes()
67+
{
68+
$one = EloquentWithCountModel1::create();
69+
$one->fours()->create();
70+
71+
$result = EloquentWithCountModel1::withCount('fours')->first();
72+
$this->assertEquals(0, $result->fours_count);
73+
74+
$result = EloquentWithCountModel1::withCount('allFours')->first();
75+
$this->assertEquals(1, $result->all_fours_count);
76+
}
77+
78+
public function testSortingScopes()
79+
{
80+
$one = EloquentWithCountModel1::create();
81+
$one->twos()->create();
82+
83+
$query = EloquentWithCountModel1::withCount('twos')->getQuery();
84+
85+
$this->assertNull($query->orders);
86+
$this->assertSame([], $query->getRawBindings()['order']);
87+
}
88+
}
89+
90+
class EloquentWithCountModel1 extends Model
91+
{
92+
protected $connection = 'mongodb';
93+
public $table = 'one';
94+
public $timestamps = false;
95+
protected $guarded = [];
96+
97+
public function twos()
98+
{
99+
return $this->hasMany(EloquentWithCountModel2::class, 'one_id');
100+
}
101+
102+
public function fours()
103+
{
104+
return $this->hasMany(EloquentWithCountModel4::class, 'one_id');
105+
}
106+
107+
public function allFours()
108+
{
109+
return $this->fours()->withoutGlobalScopes();
110+
}
111+
}
112+
113+
class EloquentWithCountModel2 extends Model
114+
{
115+
protected $connection = 'mongodb';
116+
public $table = 'two';
117+
public $timestamps = false;
118+
protected $guarded = [];
119+
protected $withCount = ['threes'];
120+
121+
protected static function boot()
122+
{
123+
parent::boot();
124+
125+
static::addGlobalScope('app', function ($builder) {
126+
$builder->latest();
127+
});
128+
}
129+
130+
public function threes()
131+
{
132+
return $this->hasMany(EloquentWithCountModel3::class, 'two_id');
133+
}
134+
}
135+
136+
class EloquentWithCountModel3 extends Model
137+
{
138+
protected $connection = 'mongodb';
139+
public $table = 'three';
140+
public $timestamps = false;
141+
protected $guarded = [];
142+
143+
protected static function boot()
144+
{
145+
parent::boot();
146+
147+
static::addGlobalScope('app', function ($builder) {
148+
$builder->where('id', '>', 0);
149+
});
150+
}
151+
}
152+
153+
class EloquentWithCountModel4 extends Model
154+
{
155+
protected $connection = 'mongodb';
156+
public $table = 'four';
157+
public $timestamps = false;
158+
protected $guarded = [];
159+
160+
protected static function boot()
161+
{
162+
parent::boot();
163+
164+
static::addGlobalScope('app', function ($builder) {
165+
$builder->where('id', '>', 1);
166+
});
167+
}
168+
}

tests/HybridRelationsTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ public function testHybridWhereHas()
157157

158158
public function testHybridWith()
159159
{
160+
DB::connection('mongodb')->enableQueryLog();
160161
$user = new SqlUser();
161162
$otherUser = new SqlUser();
162163
$this->assertInstanceOf(SqlUser::class, $user);
@@ -206,6 +207,10 @@ public function testHybridWith()
206207
->each(function ($user) {
207208
$this->assertEquals($user->id, $user->books->count());
208209
});
210+
SqlUser::withCount('books')->get()
211+
->each(function ($user) {
212+
$this->assertEquals($user->id, $user->books_count);
213+
});
209214

210215
SqlUser::whereHas('sqlBooks', function ($query) {
211216
return $query->where('title', 'LIKE', 'Harry%');

0 commit comments

Comments
 (0)