Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

‎src/Eloquent/Builder.php‎

Lines changed: 90 additions & 0 deletions
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

‎src/Query/Builder.php‎

Lines changed: 5 additions & 5 deletions
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

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)

AltStyle によって変換されたページ (->オリジナル) /