Skip to content

Commit 68f88f6

Browse files
committed
Support pagination with unlimited results
Users can now specify `first: null` for paginated relationships, which returns the full set of records more efficiently than `first: <large number>` for nested relationships. Closes #2734.
1 parent d909fb2 commit 68f88f6

5 files changed

Lines changed: 283 additions & 25 deletions

File tree

src/Execution/ModelsLoader/PaginatedModelsLoader.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ public function extract(Model $model): mixed
4848
*/
4949
protected function loadRelatedModels(EloquentCollection $parents): EloquentCollection
5050
{
51+
if ($this->paginationArgs->first === null) {
52+
$relation = $this->relationInstance($parents);
53+
$relation->addEagerConstraints($parents->all());
54+
55+
($this->decorateBuilder)($relation);
56+
57+
if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) {
58+
$select = Utils::callProtected($relation, 'shouldSelect', ['*']);
59+
$relation->addSelect($select);
60+
}
61+
62+
return $relation->get();
63+
}
64+
5165
$relations = $parents->toBase()
5266
->map(function (Model $model) use ($parents): Relation {
5367
$relation = $this->relationInstance($parents);
@@ -184,9 +198,13 @@ protected function convertRelationToPaginator(EloquentCollection $parents): void
184198
foreach ($parents as $model) {
185199
$total = CountModelsLoader::extractCount($model, $this->relation);
186200

187-
$paginator = $first === 0
188-
? new ZeroPerPageLengthAwarePaginator($total, $page)
189-
: new LengthAwarePaginator($model->getRelation($this->relation), $total, $first, $page);
201+
if ($first === null) {
202+
$paginator = new LengthAwarePaginator($model->getRelation($this->relation), $total, max(1, $total), $page);
203+
} elseif ($first === 0) {
204+
$paginator = new ZeroPerPageLengthAwarePaginator($total, $page);
205+
} else {
206+
$paginator = new LengthAwarePaginator($model->getRelation($this->relation), $total, $first, $page);
207+
}
190208

191209
$model->setRelation($this->relation, $paginator);
192210
}

src/Pagination/PaginationArgs.php

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace Nuwave\Lighthouse\Pagination;
44

55
use GraphQL\Error\Error;
6-
use Illuminate\Contracts\Pagination\Paginator;
6+
use Illuminate\Contracts\Pagination\Paginator as PaginatorContract;
77
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
88
use Illuminate\Database\Eloquent\Relations\Relation;
99
use Illuminate\Database\Query\Builder as QueryBuilder;
10+
use Illuminate\Pagination\LengthAwarePaginator;
11+
use Illuminate\Pagination\Paginator;
1012
use Illuminate\Support\Arr;
1113
use Laravel\Scout\Builder as ScoutBuilder;
1214
use Nuwave\Lighthouse\Cache\CacheDirective;
@@ -17,7 +19,7 @@ class PaginationArgs
1719
{
1820
public function __construct(
1921
public int $page,
20-
public int $first,
22+
public ?int $first,
2123
public PaginationType $type,
2224
) {}
2325

@@ -28,7 +30,7 @@ public function __construct(
2830
*/
2931
public static function extractArgs(array $args, ResolveInfo $resolveInfo, PaginationType $proposedPaginationType, ?int $paginateMaxCount): self
3032
{
31-
$first = $args['first'];
33+
$first = $args['first'] ?? null;
3234

3335
$page = $proposedPaginationType->isConnection()
3436
? self::calculateCurrentPage(
@@ -38,13 +40,14 @@ public static function extractArgs(array $args, ResolveInfo $resolveInfo, Pagina
3840
// Handles cases "paginate" and "simple", which both take the same args.
3941
: Arr::get($args, 'page') ?? 1;
4042

41-
if ($first < 0) {
43+
if ($first !== null && $first < 0) {
4244
throw new Error(self::requestedLessThanZeroItems($first));
4345
}
4446

4547
// Make sure the maximum pagination count is not exceeded
4648
if (
47-
$paginateMaxCount !== null
49+
$first !== null
50+
&& $paginateMaxCount !== null
4851
&& $first > $paginateMaxCount
4952
) {
5053
throw new Error(self::requestedTooManyItems($paginateMaxCount, $first));
@@ -66,7 +69,7 @@ public static function requestedTooManyItems(int $maxCount, int $actualCount): s
6669
}
6770

6871
/** Calculate the current page to inform the user about the pagination state. */
69-
protected static function calculateCurrentPage(int $first, int $after, int $defaultPage = 1): int
72+
protected static function calculateCurrentPage(?int $first, int $after, int $defaultPage = 1): int
7073
{
7174
return $first && $after
7275
? (int) floor(($first + $after) / $first)
@@ -108,8 +111,18 @@ protected static function optimalPaginationType(PaginationType $proposedType, Re
108111
*
109112
* @return \Illuminate\Contracts\Pagination\Paginator<array-key, TModel>
110113
*/
111-
public function applyToBuilder(QueryBuilder|ScoutBuilder|EloquentBuilder|Relation $builder): Paginator
114+
public function applyToBuilder(QueryBuilder|ScoutBuilder|EloquentBuilder|Relation $builder): PaginatorContract
112115
{
116+
if ($this->first === null) {
117+
$results = $builder->get();
118+
$count = $results->count();
119+
120+
// @phpstan-ignore return.type (generic type does not matter)
121+
return $this->type->isSimple()
122+
? new Paginator($results, max(1, $count), $this->page)
123+
: new LengthAwarePaginator($results, $count, max(1, $count), $this->page);
124+
}
125+
113126
if ($this->first === 0) {
114127
if ($this->type->isSimple()) {
115128
return new ZeroPerPagePaginator($this->page); // @phpstan-ignore return.type (generic type does not matter)

src/Pagination/PaginationManipulator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ protected static function countArgument(?int $defaultCount = null, ?int $maxCoun
250250

251251
$description .= "\"\n";
252252

253-
$definition = 'first: Int!';
253+
$definition = 'first: Int';
254254
if ($defaultCount) {
255255
$definition .= " = {$defaultCount}";
256256
}

tests/Integration/Pagination/PaginateDirectiveDBTest.php

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,4 +1051,211 @@ public function testSimplePaginationWithNullPageUsesDefaultPage(): void
10511051
],
10521052
])->assertJsonCount(2, 'data.users.data');
10531053
}
1054+
1055+
public function testPaginateWithoutFirst(): void
1056+
{
1057+
factory(User::class, 3)->create();
1058+
1059+
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
1060+
type User {
1061+
id: ID!
1062+
}
1063+
1064+
type Query {
1065+
users: [User!]! @paginate
1066+
}
1067+
GRAPHQL;
1068+
1069+
$this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL'
1070+
{
1071+
users {
1072+
paginatorInfo {
1073+
count
1074+
total
1075+
}
1076+
data {
1077+
id
1078+
}
1079+
}
1080+
}
1081+
GRAPHQL)->assertJson([
1082+
'data' => [
1083+
'users' => [
1084+
'paginatorInfo' => [
1085+
'count' => 3,
1086+
'total' => 3,
1087+
],
1088+
],
1089+
],
1090+
])->assertJsonCount(3, 'data.users.data');
1091+
}
1092+
1093+
public function testPaginateWithFirstNull(): void
1094+
{
1095+
factory(User::class, 3)->create();
1096+
1097+
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
1098+
type User {
1099+
id: ID!
1100+
}
1101+
1102+
type Query {
1103+
users: [User!]! @paginate
1104+
}
1105+
GRAPHQL;
1106+
1107+
$this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL'
1108+
{
1109+
users(first: null) {
1110+
paginatorInfo {
1111+
count
1112+
total
1113+
}
1114+
data {
1115+
id
1116+
}
1117+
}
1118+
}
1119+
GRAPHQL)->assertJson([
1120+
'data' => [
1121+
'users' => [
1122+
'paginatorInfo' => [
1123+
'count' => 3,
1124+
'total' => 3,
1125+
],
1126+
],
1127+
],
1128+
])->assertJsonCount(3, 'data.users.data');
1129+
}
1130+
1131+
public function testConnectionWithoutFirst(): void
1132+
{
1133+
factory(User::class, 3)->create();
1134+
1135+
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
1136+
type User {
1137+
id: ID!
1138+
}
1139+
1140+
type Query {
1141+
users: [User!]! @paginate(type: CONNECTION)
1142+
}
1143+
GRAPHQL;
1144+
1145+
$this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL'
1146+
{
1147+
users {
1148+
pageInfo {
1149+
count
1150+
total
1151+
hasNextPage
1152+
}
1153+
edges {
1154+
node {
1155+
id
1156+
}
1157+
}
1158+
}
1159+
}
1160+
GRAPHQL)->assertJson([
1161+
'data' => [
1162+
'users' => [
1163+
'pageInfo' => [
1164+
'count' => 3,
1165+
'total' => 3,
1166+
'hasNextPage' => false,
1167+
],
1168+
],
1169+
],
1170+
])->assertJsonCount(3, 'data.users.edges');
1171+
}
1172+
1173+
public function testNestedConnectionWithoutFirst(): void
1174+
{
1175+
$user = factory(User::class)->create();
1176+
$posts = factory(Post::class, 3)->make();
1177+
$user->posts()->saveMany($posts);
1178+
1179+
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
1180+
type Post {
1181+
id: ID!
1182+
}
1183+
1184+
type User {
1185+
id: ID!
1186+
posts: [Post!]! @hasMany(type: CONNECTION)
1187+
}
1188+
1189+
type Query {
1190+
user(id: ID! @eq): User @find
1191+
}
1192+
GRAPHQL;
1193+
1194+
$this->graphQL(/** @lang GraphQL */ <<<GRAPHQL
1195+
{
1196+
user(id: {$user->id}) {
1197+
posts {
1198+
pageInfo {
1199+
count
1200+
total
1201+
}
1202+
edges {
1203+
node {
1204+
id
1205+
}
1206+
}
1207+
}
1208+
}
1209+
}
1210+
GRAPHQL)->assertJson([
1211+
'data' => [
1212+
'user' => [
1213+
'posts' => [
1214+
'pageInfo' => [
1215+
'count' => 3,
1216+
'total' => 3,
1217+
],
1218+
],
1219+
],
1220+
],
1221+
])->assertJsonCount(3, 'data.user.posts.edges');
1222+
}
1223+
1224+
public function testPaginateSimpleWithoutFirst(): void
1225+
{
1226+
factory(User::class, 3)->create();
1227+
1228+
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
1229+
type User {
1230+
id: ID!
1231+
}
1232+
1233+
type Query {
1234+
users: [User!]! @paginate(type: SIMPLE)
1235+
}
1236+
GRAPHQL;
1237+
1238+
$this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL'
1239+
{
1240+
users {
1241+
paginatorInfo {
1242+
count
1243+
hasMorePages
1244+
}
1245+
data {
1246+
id
1247+
}
1248+
}
1249+
}
1250+
GRAPHQL)->assertJson([
1251+
'data' => [
1252+
'users' => [
1253+
'paginatorInfo' => [
1254+
'count' => 3,
1255+
'hasMorePages' => false,
1256+
],
1257+
],
1258+
],
1259+
])->assertJsonCount(3, 'data.users.data');
1260+
}
10541261
}

0 commit comments

Comments
 (0)