diff --git a/src/Execution/ModelsLoader/PaginatedModelsLoader.php b/src/Execution/ModelsLoader/PaginatedModelsLoader.php index c251d77f86..72c90321f5 100644 --- a/src/Execution/ModelsLoader/PaginatedModelsLoader.php +++ b/src/Execution/ModelsLoader/PaginatedModelsLoader.php @@ -48,6 +48,20 @@ public function extract(Model $model): mixed */ protected function loadRelatedModels(EloquentCollection $parents): EloquentCollection { + if ($this->paginationArgs->first === null) { + $relation = $this->relationInstance($parents); + $relation->addEagerConstraints($parents->all()); + + ($this->decorateBuilder)($relation); + + if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) { + $select = Utils::callProtected($relation, 'shouldSelect', ['*']); + $relation->addSelect($select); + } + + return $relation->get(); + } + $relations = $parents->toBase() ->map(function (Model $model) use ($parents): Relation { $relation = $this->relationInstance($parents); @@ -184,9 +198,13 @@ protected function convertRelationToPaginator(EloquentCollection $parents): void foreach ($parents as $model) { $total = CountModelsLoader::extractCount($model, $this->relation); - $paginator = $first === 0 - ? new ZeroPerPageLengthAwarePaginator($total, $page) - : new LengthAwarePaginator($model->getRelation($this->relation), $total, $first, $page); + if ($first === null) { + $paginator = new LengthAwarePaginator($model->getRelation($this->relation), $total, max(1, $total), $page); + } elseif ($first === 0) { + $paginator = new ZeroPerPageLengthAwarePaginator($total, $page); + } else { + $paginator = new LengthAwarePaginator($model->getRelation($this->relation), $total, $first, $page); + } $model->setRelation($this->relation, $paginator); } diff --git a/src/Pagination/PaginationArgs.php b/src/Pagination/PaginationArgs.php index a06316c1b2..388bbffedd 100644 --- a/src/Pagination/PaginationArgs.php +++ b/src/Pagination/PaginationArgs.php @@ -3,10 +3,12 @@ namespace Nuwave\Lighthouse\Pagination; use GraphQL\Error\Error; -use Illuminate\Contracts\Pagination\Paginator; +use Illuminate\Contracts\Pagination\Paginator as PaginatorContract; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Laravel\Scout\Builder as ScoutBuilder; use Nuwave\Lighthouse\Cache\CacheDirective; @@ -17,7 +19,7 @@ class PaginationArgs { public function __construct( public int $page, - public int $first, + public ?int $first, public PaginationType $type, ) {} @@ -28,7 +30,7 @@ public function __construct( */ public static function extractArgs(array $args, ResolveInfo $resolveInfo, PaginationType $proposedPaginationType, ?int $paginateMaxCount): self { - $first = $args['first']; + $first = $args['first'] ?? null; $page = $proposedPaginationType->isConnection() ? self::calculateCurrentPage( @@ -38,13 +40,14 @@ public static function extractArgs(array $args, ResolveInfo $resolveInfo, Pagina // Handles cases "paginate" and "simple", which both take the same args. : Arr::get($args, 'page') ?? 1; - if ($first < 0) { + if ($first !== null && $first < 0) { throw new Error(self::requestedLessThanZeroItems($first)); } // Make sure the maximum pagination count is not exceeded if ( - $paginateMaxCount !== null + $first !== null + && $paginateMaxCount !== null && $first > $paginateMaxCount ) { throw new Error(self::requestedTooManyItems($paginateMaxCount, $first)); @@ -66,7 +69,7 @@ public static function requestedTooManyItems(int $maxCount, int $actualCount): s } /** Calculate the current page to inform the user about the pagination state. */ - protected static function calculateCurrentPage(int $first, int $after, int $defaultPage = 1): int + protected static function calculateCurrentPage(?int $first, int $after, int $defaultPage = 1): int { return $first && $after ? (int) floor(($first + $after) / $first) @@ -108,8 +111,18 @@ protected static function optimalPaginationType(PaginationType $proposedType, Re * * @return \Illuminate\Contracts\Pagination\Paginator */ - public function applyToBuilder(QueryBuilder|ScoutBuilder|EloquentBuilder|Relation $builder): Paginator + public function applyToBuilder(QueryBuilder|ScoutBuilder|EloquentBuilder|Relation $builder): PaginatorContract { + if ($this->first === null) { + $results = $builder->get(); + $count = $results->count(); + + // @phpstan-ignore return.type (generic type does not matter) + return $this->type->isSimple() + ? new Paginator($results, max(1, $count), $this->page) + : new LengthAwarePaginator($results, $count, max(1, $count), $this->page); + } + if ($this->first === 0) { if ($this->type->isSimple()) { return new ZeroPerPagePaginator($this->page); // @phpstan-ignore return.type (generic type does not matter) diff --git a/src/Pagination/PaginationManipulator.php b/src/Pagination/PaginationManipulator.php index 71d6cbe0cc..f32a963ea1 100644 --- a/src/Pagination/PaginationManipulator.php +++ b/src/Pagination/PaginationManipulator.php @@ -250,7 +250,7 @@ protected static function countArgument(?int $defaultCount = null, ?int $maxCoun $description .= "\"\n"; - $definition = 'first: Int!'; + $definition = 'first: Int'; if ($defaultCount) { $definition .= " = {$defaultCount}"; } diff --git a/tests/Integration/Pagination/PaginateDirectiveDBTest.php b/tests/Integration/Pagination/PaginateDirectiveDBTest.php index b49ac5e718..5143bce752 100644 --- a/tests/Integration/Pagination/PaginateDirectiveDBTest.php +++ b/tests/Integration/Pagination/PaginateDirectiveDBTest.php @@ -1051,4 +1051,211 @@ public function testSimplePaginationWithNullPageUsesDefaultPage(): void ], ])->assertJsonCount(2, 'data.users.data'); } + + public function testPaginateWithoutFirst(): void + { + factory(User::class, 3)->create(); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + users: [User!]! @paginate + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + { + users { + paginatorInfo { + count + total + } + data { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'users' => [ + 'paginatorInfo' => [ + 'count' => 3, + 'total' => 3, + ], + ], + ], + ])->assertJsonCount(3, 'data.users.data'); + } + + public function testPaginateWithFirstNull(): void + { + factory(User::class, 3)->create(); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + users: [User!]! @paginate + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + { + users(first: null) { + paginatorInfo { + count + total + } + data { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'users' => [ + 'paginatorInfo' => [ + 'count' => 3, + 'total' => 3, + ], + ], + ], + ])->assertJsonCount(3, 'data.users.data'); + } + + public function testConnectionWithoutFirst(): void + { + factory(User::class, 3)->create(); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + users: [User!]! @paginate(type: CONNECTION) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + { + users { + pageInfo { + count + total + hasNextPage + } + edges { + node { + id + } + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'users' => [ + 'pageInfo' => [ + 'count' => 3, + 'total' => 3, + 'hasNextPage' => false, + ], + ], + ], + ])->assertJsonCount(3, 'data.users.edges'); + } + + public function testNestedConnectionWithoutFirst(): void + { + $user = factory(User::class)->create(); + $posts = factory(Post::class, 3)->make(); + $user->posts()->saveMany($posts); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Post { + id: ID! + } + + type User { + id: ID! + posts: [Post!]! @hasMany(type: CONNECTION) + } + + type Query { + user(id: ID! @eq): User @find + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<id}) { + posts { + pageInfo { + count + total + } + edges { + node { + id + } + } + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'user' => [ + 'posts' => [ + 'pageInfo' => [ + 'count' => 3, + 'total' => 3, + ], + ], + ], + ], + ])->assertJsonCount(3, 'data.user.posts.edges'); + } + + public function testPaginateSimpleWithoutFirst(): void + { + factory(User::class, 3)->create(); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + users: [User!]! @paginate(type: SIMPLE) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + { + users { + paginatorInfo { + count + hasMorePages + } + data { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'users' => [ + 'paginatorInfo' => [ + 'count' => 3, + 'hasMorePages' => false, + ], + ], + ], + ])->assertJsonCount(3, 'data.users.data'); + } } diff --git a/tests/Unit/Pagination/PaginateDirectiveTest.php b/tests/Unit/Pagination/PaginateDirectiveTest.php index 9ff5a74024..8c874e245b 100644 --- a/tests/Unit/Pagination/PaginateDirectiveTest.php +++ b/tests/Unit/Pagination/PaginateDirectiveTest.php @@ -170,7 +170,7 @@ public function testManipulatesPaginator(): void type Query { users( "Limits number of fetched items." - first: Int! + first: Int "The offset from which items are returned." page: Int @@ -209,7 +209,7 @@ public function testManipulatesSimplePaginator(): void type Query { users( "Limits number of fetched items." - first: Int! + first: Int "The offset from which items are returned." page: Int @@ -248,7 +248,7 @@ public function testManipulatesConnection(): void type Query { users( "Limits number of fetched items." - first: Int! + first: Int "A cursor after which elements are returned." after: String @@ -545,16 +545,16 @@ public function testCountExplicitlyRequiredFromDirective(): void { config(['lighthouse.pagination.default_count' => 2]); - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + $this->schema = /** @lang GraphQL */ <<qualifyTestResolver('returnPaginatedDataInsteadOfBuilder')}") } - GRAPHQL; +GRAPHQL; $this ->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' @@ -566,7 +566,16 @@ public function testCountExplicitlyRequiredFromDirective(): void } } GRAPHQL) - ->assertGraphQLErrorMessage('Field "users" argument "first" of type "Int!" is required but not provided.'); + ->assertJson([ + 'data' => [ + 'users' => [ + 'data' => [ + ['id' => 1], + ['id' => 2], + ], + ], + ], + ]); } public function testThrowsWhenPaginationWithNegativeCountIsRequested(): void @@ -644,17 +653,17 @@ public function testAllowsMultiplePaginatedFieldsOfTheSameModel(): void $this->assertCount(1, $ast->directives); } - public function testDisallowFirstNull(): void + public function testAllowFirstNull(): void { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + $this->schema = /** @lang GraphQL */ <<qualifyTestResolver('returnPaginatedDataInsteadOfBuilder')}") } - GRAPHQL; +GRAPHQL; $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' { @@ -664,7 +673,16 @@ public function testDisallowFirstNull(): void } } } - GRAPHQL)->assertGraphQLErrorMessage('Expected value of type "Int!", found null.'); + GRAPHQL)->assertJson([ + 'data' => [ + 'users' => [ + 'data' => [ + ['id' => 1], + ['id' => 2], + ], + ], + ], + ]); } public function testQueriesFirst0SimplePaginator(): void @@ -697,12 +715,14 @@ public function testQueriesFirst0SimplePaginator(): void } /** - * @param array{first: int} $args + * @param array{first?: int|null} $args * * @return \Illuminate\Pagination\LengthAwarePaginator */ public static function returnPaginatedDataInsteadOfBuilder(mixed $root, array $args): LengthAwarePaginator { + $first = $args['first'] ?? 2; + return new LengthAwarePaginator([ // @phpstan-ignore return.type (pagination generics changed between Laravel versions) [ 'id' => 1, @@ -710,7 +730,7 @@ public static function returnPaginatedDataInsteadOfBuilder(mixed $root, array $a [ 'id' => 2, ], - ], 2, $args['first']); + ], 2, $first ?: 2); } public function testPaginatorResolver(): void