Skip to content

Commit 8f29cc9

Browse files
[11.x] Support eager loading with limit (#49695)
* Support eager loading with limit * Fix style * formatting --------- Co-authored-by: Taylor Otwell <[email protected]>
1 parent ec6e169 commit 8f29cc9

File tree

11 files changed

+534
-1
lines changed

11 files changed

+534
-1
lines changed

src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot;
1212
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
1313
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable;
14+
use Illuminate\Database\Query\Grammars\MySqlGrammar;
1415
use Illuminate\Database\UniqueConstraintViolationException;
1516
use Illuminate\Support\Str;
1617
use InvalidArgumentException;
@@ -1347,6 +1348,42 @@ public function getRelationExistenceQueryForSelfJoin(Builder $query, Builder $pa
13471348
return parent::getRelationExistenceQuery($query, $parentQuery, $columns);
13481349
}
13491350

1351+
/**
1352+
* Alias to set the "limit" value of the query.
1353+
*
1354+
* @param int $value
1355+
* @return $this
1356+
*/
1357+
public function take($value)
1358+
{
1359+
return $this->limit($value);
1360+
}
1361+
1362+
/**
1363+
* Set the "limit" value of the query.
1364+
*
1365+
* @param int $value
1366+
* @return $this
1367+
*/
1368+
public function limit($value)
1369+
{
1370+
if ($this->parent->exists) {
1371+
$this->query->limit($value);
1372+
} else {
1373+
$column = $this->getExistenceCompareKey();
1374+
1375+
$grammar = $this->query->getQuery()->getGrammar();
1376+
1377+
if ($grammar instanceof MySqlGrammar && $grammar->useLegacyGroupLimit($this->query->getQuery())) {
1378+
$column = 'pivot_'.last(explode('.', $column));
1379+
}
1380+
1381+
$this->query->groupLimit($value, $column);
1382+
}
1383+
1384+
return $this;
1385+
}
1386+
13501387
/**
13511388
* Get the key for comparing against the parent key in "has" query.
13521389
*

src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Illuminate\Database\Eloquent\ModelNotFoundException;
1111
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
1212
use Illuminate\Database\Eloquent\SoftDeletes;
13+
use Illuminate\Database\Query\Grammars\MySqlGrammar;
1314
use Illuminate\Database\UniqueConstraintViolationException;
1415

1516
class HasManyThrough extends Relation
@@ -762,6 +763,42 @@ public function getRelationExistenceQueryForThroughSelfRelation(Builder $query,
762763
);
763764
}
764765

766+
/**
767+
* Alias to set the "limit" value of the query.
768+
*
769+
* @param int $value
770+
* @return $this
771+
*/
772+
public function take($value)
773+
{
774+
return $this->limit($value);
775+
}
776+
777+
/**
778+
* Set the "limit" value of the query.
779+
*
780+
* @param int $value
781+
* @return $this
782+
*/
783+
public function limit($value)
784+
{
785+
if ($this->farParent->exists) {
786+
$this->query->limit($value);
787+
} else {
788+
$column = $this->getQualifiedFirstKeyName();
789+
790+
$grammar = $this->query->getQuery()->getGrammar();
791+
792+
if ($grammar instanceof MySqlGrammar && $grammar->useLegacyGroupLimit($this->query->getQuery())) {
793+
$column = 'laravel_through_key';
794+
}
795+
796+
$this->query->groupLimit($value, $column);
797+
}
798+
799+
return $this;
800+
}
801+
765802
/**
766803
* Get the qualified foreign key on the related model.
767804
*

src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,34 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder
453453
);
454454
}
455455

456+
/**
457+
* Alias to set the "limit" value of the query.
458+
*
459+
* @param int $value
460+
* @return $this
461+
*/
462+
public function take($value)
463+
{
464+
return $this->limit($value);
465+
}
466+
467+
/**
468+
* Set the "limit" value of the query.
469+
*
470+
* @param int $value
471+
* @return $this
472+
*/
473+
public function limit($value)
474+
{
475+
if ($this->parent->exists) {
476+
$this->query->limit($value);
477+
} else {
478+
$this->query->groupLimit($value, $this->getExistenceCompareKey());
479+
}
480+
481+
return $this;
482+
}
483+
456484
/**
457485
* Get the key for comparing against the parent key in "has" query.
458486
*

src/Illuminate/Database/Query/Builder.php

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ class Builder implements BuilderContract
152152
*/
153153
public $limit;
154154

155+
/**
156+
* The maximum number of records to return per group.
157+
*
158+
* @var array
159+
*/
160+
public $groupLimit;
161+
155162
/**
156163
* The number of records to skip.
157164
*
@@ -2444,6 +2451,22 @@ public function limit($value)
24442451
return $this;
24452452
}
24462453

2454+
/**
2455+
* Add a "group limit" clause to the query.
2456+
*
2457+
* @param int $value
2458+
* @param string $column
2459+
* @return $this
2460+
*/
2461+
public function groupLimit($value, $column)
2462+
{
2463+
if ($value >= 0) {
2464+
$this->groupLimit = compact('value', 'column');
2465+
}
2466+
2467+
return $this;
2468+
}
2469+
24472470
/**
24482471
* Set the limit and offset for a given page.
24492472
*
@@ -2737,9 +2760,13 @@ public function soleValue($column)
27372760
*/
27382761
public function get($columns = ['*'])
27392762
{
2740-
return collect($this->onceWithColumns(Arr::wrap($columns), function () {
2763+
$items = collect($this->onceWithColumns(Arr::wrap($columns), function () {
27412764
return $this->processor->processSelect($this, $this->runSelect());
27422765
}));
2766+
2767+
return isset($this->groupLimit)
2768+
? $this->withoutGroupLimitKeys($items)
2769+
: $items;
27432770
}
27442771

27452772
/**
@@ -2754,6 +2781,32 @@ protected function runSelect()
27542781
);
27552782
}
27562783

2784+
/**
2785+
* Remove the group limit keys from the results in the collection.
2786+
*
2787+
* @param \Illuminate\Support\Collection $items
2788+
* @return \Illuminate\Support\Collection
2789+
*/
2790+
protected function withoutGroupLimitKeys($items)
2791+
{
2792+
$keysToRemove = ['laravel_row'];
2793+
2794+
if (is_string($this->groupLimit['column'])) {
2795+
$column = last(explode('.', $this->groupLimit['column']));
2796+
2797+
$keysToRemove[] = '@laravel_group := '.$this->grammar->wrap($column);
2798+
$keysToRemove[] = '@laravel_group := '.$this->grammar->wrap('pivot_'.$column);
2799+
}
2800+
2801+
$items->each(function ($item) use ($keysToRemove) {
2802+
foreach ($keysToRemove as $key) {
2803+
unset($item->$key);
2804+
}
2805+
});
2806+
2807+
return $items;
2808+
}
2809+
27572810
/**
27582811
* Paginate the given query into a simple paginator.
27592812
*

src/Illuminate/Database/Query/Grammars/Grammar.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ public function compileSelect(Builder $query)
6060
return $this->compileUnionAggregate($query);
6161
}
6262

63+
// If a "group limit" is in place, we will need to compile the SQL to use a
64+
// different syntax. This primarily supports limits on eager loads using
65+
// Eloquent. We'll also set the columns if they have not been defined.
66+
if (isset($query->groupLimit)) {
67+
if (is_null($query->columns)) {
68+
$query->columns = ['*'];
69+
}
70+
71+
return $this->compileGroupLimit($query);
72+
}
73+
6374
// If the query does not have any columns set, we'll set the columns to the
6475
// * character to just get all of the columns from the database. Then we
6576
// can build the query and concatenate all the pieces together as one.
@@ -917,6 +928,66 @@ protected function compileLimit(Builder $query, $limit)
917928
return 'limit '.(int) $limit;
918929
}
919930

931+
/**
932+
* Compile a group limit clause.
933+
*
934+
* @param \Illuminate\Database\Query\Builder $query
935+
* @return string
936+
*/
937+
protected function compileGroupLimit(Builder $query)
938+
{
939+
$selectBindings = array_merge($query->getRawBindings()['select'], $query->getRawBindings()['order']);
940+
941+
$query->setBindings($selectBindings, 'select');
942+
$query->setBindings([], 'order');
943+
944+
$limit = (int) $query->groupLimit['value'];
945+
$offset = $query->offset;
946+
947+
if (isset($offset)) {
948+
$offset = (int) $offset;
949+
$limit += $offset;
950+
951+
$query->offset = null;
952+
}
953+
954+
$components = $this->compileComponents($query);
955+
956+
$components['columns'] .= $this->compileRowNumber(
957+
$query->groupLimit['column'],
958+
$components['orders'] ?? ''
959+
);
960+
961+
unset($components['orders']);
962+
963+
$table = $this->wrap('laravel_table');
964+
$row = $this->wrap('laravel_row');
965+
966+
$sql = $this->concatenate($components);
967+
968+
$sql = 'select * from ('.$sql.') as '.$table.' where '.$row.' <= '.$limit;
969+
970+
if (isset($offset)) {
971+
$sql .= ' and '.$row.' > '.$offset;
972+
}
973+
974+
return $sql.' order by '.$row;
975+
}
976+
977+
/**
978+
* Compile a row number clause.
979+
*
980+
* @param string $partition
981+
* @param string $orders
982+
* @return string
983+
*/
984+
protected function compileRowNumber($partition, $orders)
985+
{
986+
$over = trim('partition by '.$this->wrap($partition).' '.$orders);
987+
988+
return ', row_number() over ('.$over.') as '.$this->wrap('laravel_row');
989+
}
990+
920991
/**
921992
* Compile the "offset" portions of the query.
922993
*

src/Illuminate/Database/Query/Grammars/MySqlGrammar.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Database\Query\Builder;
66
use Illuminate\Support\Str;
7+
use PDO;
78

89
class MySqlGrammar extends Grammar
910
{
@@ -94,6 +95,82 @@ protected function compileIndexHint(Builder $query, $indexHint)
9495
};
9596
}
9697

98+
/**
99+
* Compile a group limit clause.
100+
*
101+
* @param \Illuminate\Database\Query\Builder $query
102+
* @return string
103+
*/
104+
protected function compileGroupLimit(Builder $query)
105+
{
106+
return $this->useLegacyGroupLimit($query)
107+
? $this->compileLegacyGroupLimit($query)
108+
: parent::compileGroupLimit($query);
109+
}
110+
111+
/**
112+
* Determine whether to use a legacy group limit clause for MySQL < 8.0.
113+
*
114+
* @param \Illuminate\Database\Query\Builder $query
115+
* @return bool
116+
*/
117+
public function useLegacyGroupLimit(Builder $query)
118+
{
119+
$version = $query->getConnection()->getReadPdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
120+
121+
return ! $query->getConnection()->isMaria() && version_compare($version, '8.0.11') < 0;
122+
}
123+
124+
/**
125+
* Compile a group limit clause for MySQL < 8.0.
126+
*
127+
* Derived from https://softonsofa.com/tweaking-eloquent-relations-how-to-get-n-related-models-per-parent/.
128+
*
129+
* @param \Illuminate\Database\Query\Builder $query
130+
* @return string
131+
*/
132+
protected function compileLegacyGroupLimit(Builder $query)
133+
{
134+
$limit = (int) $query->groupLimit['value'];
135+
$offset = $query->offset;
136+
137+
if (isset($offset)) {
138+
$offset = (int) $offset;
139+
$limit += $offset;
140+
141+
$query->offset = null;
142+
}
143+
144+
$column = last(explode('.', $query->groupLimit['column']));
145+
$column = $this->wrap($column);
146+
147+
$partition = ', @laravel_row := if(@laravel_group = '.$column.', @laravel_row + 1, 1) as `laravel_row`';
148+
$partition .= ', @laravel_group := '.$column;
149+
150+
$orders = (array) $query->orders;
151+
152+
array_unshift($orders, [
153+
'column' => $query->groupLimit['column'],
154+
'direction' => 'asc'
155+
]);
156+
157+
$query->orders = $orders;
158+
159+
$components = $this->compileComponents($query);
160+
161+
$sql = $this->concatenate($components);
162+
163+
$from = '(select @laravel_row := 0, @laravel_group := 0) as `laravel_vars`, ('.$sql.') as `laravel_table`';
164+
165+
$sql = 'select `laravel_table`.*'.$partition.' from '.$from.' having `laravel_row` <= '.$limit;
166+
167+
if (isset($offset)) {
168+
$sql .= ' and `laravel_row` > '.$offset;
169+
}
170+
171+
return $sql.' order by `laravel_row`';
172+
}
173+
97174
/**
98175
* Compile an insert ignore statement into SQL.
99176
*

0 commit comments

Comments
 (0)