Skip to content

Commit b55bdc6

Browse files
committed
PHPORM-238 Add support for withCount using a subquery
1 parent 2b2c70a commit b55bdc6

File tree

4 files changed

+268
-5
lines changed

4 files changed

+268
-5
lines changed

Diff for: src/Eloquent/Builder.php

+90
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
88
use Illuminate\Database\Eloquent\Collection;
99
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Database\Eloquent\Relations\Relation;
11+
use Illuminate\Support\Str;
12+
use InvalidArgumentException;
1013
use MongoDB\BSON\Document;
1114
use MongoDB\Builder\Type\QueryInterface;
1215
use MongoDB\Builder\Type\SearchOperatorInterface;
@@ -15,15 +18,20 @@
1518
use MongoDB\Laravel\Connection;
1619
use MongoDB\Laravel\Helpers\QueriesRelationships;
1720
use MongoDB\Laravel\Query\AggregationBuilder;
21+
use MongoDB\Laravel\Relations\EmbedsOneOrMany;
22+
use MongoDB\Laravel\Relations\HasMany;
1823
use MongoDB\Model\BSONDocument;
1924

2025
use function array_key_exists;
2126
use function array_merge;
2227
use function collect;
28+
use function count;
29+
use function explode;
2330
use function is_array;
2431
use function is_object;
2532
use function iterator_to_array;
2633
use function property_exists;
34+
use function sprintf;
2735

2836
/**
2937
* @method \MongoDB\Laravel\Query\Builder toBase()
@@ -34,6 +42,9 @@ class Builder extends EloquentBuilder
3442
private const DUPLICATE_KEY_ERROR = 11000;
3543
use QueriesRelationships;
3644

45+
/** @var array{relation: Relation, function: string, constraints: array, column: string, alias: string}[] */
46+
private array $withAggregate = [];
47+
3748
/**
3849
* The methods that should be returned from query builder.
3950
*
@@ -294,6 +305,85 @@ public function createOrFirst(array $attributes = [], array $values = [])
294305
}
295306
}
296307

308+
public function withAggregate($relations, $column, $function = null)
309+
{
310+
if (empty($relations)) {
311+
return $this;
312+
}
313+
314+
$relations = is_array($relations) ? $relations : [$relations];
315+
316+
foreach ($this->parseWithRelations($relations) as $name => $constraints) {
317+
// For "count" and "exist" we can use the embedded list of ids
318+
// for embedded relations, everything can be computed directly using a projection.
319+
$segments = explode(' ', $name);
320+
321+
$name = $segments[0];
322+
$alias = (count($segments) === 3 && Str::lower($segments[1]) === 'as' ? $segments[2] : Str::snake($name) . '_count');
323+
324+
$relation = $this->getRelationWithoutConstraints($name);
325+
326+
if ($relation instanceof EmbedsOneOrMany) {
327+
switch ($function) {
328+
case 'count':
329+
$this->project([$alias => ['$size' => ['$ifNull' => ['$' . $relation->getQualifiedForeignKeyName(), []]]]]);
330+
break;
331+
case 'exists':
332+
$this->project([$alias => ['$exists' => '$' . $relation->getQualifiedForeignKeyName()]]);
333+
break;
334+
default:
335+
throw new InvalidArgumentException(sprintf('Invalid aggregate function "%s"', $function));
336+
}
337+
} else {
338+
$this->withAggregate[$alias] = [
339+
'relation' => $relation,
340+
'function' => $function,
341+
'constraints' => $constraints,
342+
'column' => $column,
343+
'alias' => $alias,
344+
];
345+
}
346+
347+
// @todo HasMany ?
348+
349+
// Otherwise, we need to store the aggregate request to run during "eagerLoadRelation"
350+
// after the root results are retrieved.
351+
}
352+
353+
return $this;
354+
}
355+
356+
public function eagerLoadRelations(array $models)
357+
{
358+
if ($this->withAggregate) {
359+
$modelIds = collect($models)->pluck($this->model->getKeyName())->all();
360+
361+
foreach ($this->withAggregate as $withAggregate) {
362+
if ($withAggregate['relation'] instanceof HasMany) {
363+
$results = $withAggregate['relation']->newQuery()
364+
->where($withAggregate['constraints'])
365+
->whereIn($withAggregate['relation']->getForeignKeyName(), $modelIds)
366+
->groupBy($withAggregate['relation']->getForeignKeyName())
367+
->aggregate($withAggregate['function'], [$withAggregate['column'] ?? $withAggregate['relation']->getPrimaryKeyName()]);
368+
369+
foreach ($models as $model) {
370+
$value = $withAggregate['function'] === 'count' ? 0 : null;
371+
foreach ($results as $result) {
372+
if ($model->getKey() === $result->{$withAggregate['relation']->getForeignKeyName()}) {
373+
$value = $result->aggregate;
374+
break;
375+
}
376+
}
377+
378+
$model->setAttribute($withAggregate['alias'], $value);
379+
}
380+
}
381+
}
382+
}
383+
384+
return parent::eagerLoadRelations($models);
385+
}
386+
297387
/**
298388
* Add the "updated at" column to an array of values.
299389
* TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e

Diff for: src/Query/Builder.php

+5-5
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ public function toMql(): array
346346
if ($this->aggregate) {
347347
$function = $this->aggregate['function'];
348348

349-
foreach ($this->aggregate['columns'] as $column) {
349+
foreach ((array) $this->aggregate['columns'] as $column) {
350350
// Add unwind if a subdocument array should be aggregated
351351
// column: subarray.price => {$unwind: '$subarray'}
352352
$splitColumns = explode('.*.', $column);
@@ -355,9 +355,9 @@ public function toMql(): array
355355
$column = implode('.', $splitColumns);
356356
}
357357

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

360-
if ($column === '*' && $function === 'count' && ! $this->groups) {
360+
if (in_array('*', $aggregations) && $function === 'count' && empty($group['_id'])) {
361361
$options = $this->inheritConnectionOptions($this->options);
362362

363363
return ['countDocuments' => [$wheres, $options]];
@@ -506,11 +506,11 @@ public function getFresh($columns = [], $returnLazy = false)
506506
// here to either the passed columns, or the standard default of retrieving
507507
// all of the columns on the table using the "wildcard" column character.
508508
if ($this->columns === null) {
509-
$this->columns = $columns;
509+
$this->columns = (array) $columns;
510510
}
511511

512512
// Drop all columns if * is present, MongoDB does not work this way.
513-
if (in_array('*', $this->columns)) {
513+
if (in_array('*', (array) $this->columns)) {
514514
$this->columns = [];
515515
}
516516

Diff for: tests/Eloquent/EloquentWithCountTest.php

+168
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+
}

Diff for: tests/HybridRelationsTest.php

+5
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)