From b60c335b0d70180fefa361751f6608b7555e9508 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 7 Apr 2022 11:30:43 +0200 Subject: [PATCH 1/3] Make scopes work with polymorphic relation directives Resolves https://github.com/nuwave/lighthouse/issues/2106 --- src/Schema/Directives/MorphToDirective.php | 76 ++++++++++++++++- src/Schema/Directives/RelationDirective.php | 5 +- .../Directives/RelationDirectiveHelpers.php | 3 +- .../Directives/MorphToDirectiveTest.php | 83 +++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/Schema/Directives/MorphToDirective.php b/src/Schema/Directives/MorphToDirective.php index 80e8caebb0..51142b5845 100644 --- a/src/Schema/Directives/MorphToDirective.php +++ b/src/Schema/Directives/MorphToDirective.php @@ -2,6 +2,11 @@ namespace Nuwave\Lighthouse\Schema\Directives; +use Closure; +use GraphQL\Type\Definition\ResolveInfo; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\MorphTo; + class MorphToDirective extends RelationDirective { public static function definition(): string @@ -20,8 +25,77 @@ public static function definition(): string """ Apply scopes to the underlying query. """ - scopes: [String!] + scopes: [MorphToScopes!] ) on FIELD_DEFINITION + +""" +Options for the `scopes` argument on `@morphTo`. +""" +input MorphToScopes { + """ + Base or full class name of the related model the scope applies to. + """ + model: String! + + """ + Names of the scopes to apply. + """ + scopes: [String!]! +} GRAPHQL; } + + protected function scopes(): array + { + return []; + } + + protected function makeBuilderDecorator(ResolveInfo $resolveInfo): Closure + { + return function (object $builder) use ($resolveInfo) { + (parent::makeBuilderDecorator($resolveInfo))($builder); + + $scopes = []; + foreach ($this->directiveArgValue('scopes') ?? [] as $scopesForModel) { + $scopes[$this->namespaceModelClass($scopesForModel['model'])] = function (Builder $builder) use ($scopesForModel): void { + foreach ($scopesForModel['scopes'] as $scope) { + $builder->{$scope}(); + } + }; + } + + assert($builder instanceof MorphTo); + $builder->constrain($scopes); + }; + } + + /** + * @param array $args + * + * @return array + */ + protected function qualifyPath(array $args, ResolveInfo $resolveInfo): array + { + // Includes the field we are loading the relation for + $path = $resolveInfo->path; + + // In case we have no args, we can combine eager loads that are the same + if ([] === $args) { + array_pop($path); + } + + // Each relation must be loaded separately + $path[] = $this->relation(); + + $scopes = []; + foreach ($this->directiveArgValue('scopes') ?? [] as $scopesForModel) { + $scopes []= $scopesForModel['model']; + foreach ($scopesForModel['scopes'] as $scope) { + $scopes[]= $scope; + } + } + + // Scopes influence the result of the query + return array_merge($path, $scopes); + } } diff --git a/src/Schema/Directives/RelationDirective.php b/src/Schema/Directives/RelationDirective.php index 0ddf05368a..cc56b77380 100644 --- a/src/Schema/Directives/RelationDirective.php +++ b/src/Schema/Directives/RelationDirective.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry; use Nuwave\Lighthouse\Execution\BatchLoader\RelationBatchLoader; @@ -44,8 +45,8 @@ public function resolveField(FieldValue $fieldValue): FieldValue $decorateBuilder = $this->makeBuilderDecorator($resolveInfo); $paginationArgs = $this->paginationArgs($args); - /** @var \Illuminate\Database\Eloquent\Relations\Relation $relation */ $relation = $parent->{$relationName}(); + assert($relation instanceof Relation); // We can shortcut the resolution if the client only queries for a foreign key // that we know to be present on the parent model. @@ -71,7 +72,6 @@ public function resolveField(FieldValue $fieldValue): FieldValue // Batch loading joins across both models, thus only works if they are on the same connection && $relation->getParent()->getConnectionName() === $relation->getRelated()->getConnectionName() ) { - /** @var \Nuwave\Lighthouse\Execution\BatchLoader\RelationBatchLoader $relationBatchLoader */ $relationBatchLoader = BatchLoaderRegistry::instance( $this->qualifyPath($args, $resolveInfo), function () use ($relationName, $decorateBuilder, $paginationArgs): RelationBatchLoader { @@ -82,6 +82,7 @@ function () use ($relationName, $decorateBuilder, $paginationArgs): RelationBatc return new RelationBatchLoader($modelsLoader); } ); + assert($relationBatchLoader instanceof RelationBatchLoader); return $relationBatchLoader->load($parent); } diff --git a/src/Schema/Directives/RelationDirectiveHelpers.php b/src/Schema/Directives/RelationDirectiveHelpers.php index fe363fd565..986fc3fa0c 100644 --- a/src/Schema/Directives/RelationDirectiveHelpers.php +++ b/src/Schema/Directives/RelationDirectiveHelpers.php @@ -4,6 +4,7 @@ use Closure; use GraphQL\Type\Definition\ResolveInfo; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; trait RelationDirectiveHelpers @@ -33,10 +34,10 @@ protected function relation(): string protected function makeBuilderDecorator(ResolveInfo $resolveInfo): Closure { return function (object $builder) use ($resolveInfo): void { - /** @var \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $builder */ if ($builder instanceof Relation) { $builder = $builder->getQuery(); } + assert($builder instanceof Builder); $resolveInfo->argumentSet->enhanceBuilder( $builder, diff --git a/tests/Integration/Schema/Directives/MorphToDirectiveTest.php b/tests/Integration/Schema/Directives/MorphToDirectiveTest.php index e6978075a2..5de5fb3a39 100644 --- a/tests/Integration/Schema/Directives/MorphToDirectiveTest.php +++ b/tests/Integration/Schema/Directives/MorphToDirectiveTest.php @@ -2,6 +2,8 @@ namespace Tests\Integration\Schema\Directives; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Tests\DBTestCase; use Tests\Utils\Models\Image; use Tests\Utils\Models\Post; @@ -126,6 +128,87 @@ public function testResolveMorphToWithCustomName(): void ]); } + public function testResolveMorphToWithScopes(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + + $task = factory(Task::class)->make(); + assert($task instanceof Task); + $task->user()->associate($user); + $task->save(); + + $image = factory(Image::class)->make(); + assert($image instanceof Image); + $image->imageable()->associate($task); + $image->save(); + + $this->schema = /** @lang GraphQL */ ' + type Image { + id: ID! + imageable: Task @morphTo(scopes: [ + { model: "Task", scopes: ["completed"] } + ]) + } + + type Task { + id: ID! + name: String! + } + + type Query { + image ( + id: ID! @eq + ): Image @find + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + image(id: $id) { + id + imageable { + id + } + } + } + ', [ + 'id' => $image->id, + ])->assertJson([ + 'data' => [ + 'image' => [ + 'id' => $image->id, + 'imageable' => null, + ], + ], + ]); + + $task->completed_at = Carbon::now(); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + image(id: $id) { + id + imageable { + id + } + } + } + ', [ + 'id' => $image->id, + ])->assertJson([ + 'data' => [ + 'image' => [ + 'id' => $image->id, + 'imageable' => [ + 'id' => $task->id, + ], + ], + ], + ]); + } + public function testResolveMorphToUsingInterfaces(): void { $user = factory(User::class)->create(); From 4342b316f99130133450f218adffd385c39d6355 Mon Sep 17 00:00:00 2001 From: spawnia Date: Thu, 7 Apr 2022 09:36:39 +0000 Subject: [PATCH 2/3] Apply php-cs-fixer changes --- src/Schema/Directives/MorphToDirective.php | 4 ++-- tests/Integration/Schema/Directives/MorphToDirectiveTest.php | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Schema/Directives/MorphToDirective.php b/src/Schema/Directives/MorphToDirective.php index 51142b5845..cae2882a2b 100644 --- a/src/Schema/Directives/MorphToDirective.php +++ b/src/Schema/Directives/MorphToDirective.php @@ -89,9 +89,9 @@ protected function qualifyPath(array $args, ResolveInfo $resolveInfo): array $scopes = []; foreach ($this->directiveArgValue('scopes') ?? [] as $scopesForModel) { - $scopes []= $scopesForModel['model']; + $scopes[] = $scopesForModel['model']; foreach ($scopesForModel['scopes'] as $scope) { - $scopes[]= $scope; + $scopes[] = $scope; } } diff --git a/tests/Integration/Schema/Directives/MorphToDirectiveTest.php b/tests/Integration/Schema/Directives/MorphToDirectiveTest.php index 5de5fb3a39..7c84cdaa47 100644 --- a/tests/Integration/Schema/Directives/MorphToDirectiveTest.php +++ b/tests/Integration/Schema/Directives/MorphToDirectiveTest.php @@ -3,7 +3,6 @@ namespace Tests\Integration\Schema\Directives; use Carbon\Carbon; -use Illuminate\Database\Eloquent\Builder; use Tests\DBTestCase; use Tests\Utils\Models\Image; use Tests\Utils\Models\Post; From 98012af03c5b22a6d4ce2e915fa08ff526e05258 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 7 Apr 2022 12:18:35 +0200 Subject: [PATCH 3/3] Use polymorphic GraphQL type --- src/Schema/Directives/RelationDirective.php | 5 +- .../Directives/RelationDirectiveHelpers.php | 3 +- .../Directives/MorphToDirectiveTest.php | 106 +++++++++++++++--- 3 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/Schema/Directives/RelationDirective.php b/src/Schema/Directives/RelationDirective.php index cc56b77380..0ddf05368a 100644 --- a/src/Schema/Directives/RelationDirective.php +++ b/src/Schema/Directives/RelationDirective.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry; use Nuwave\Lighthouse\Execution\BatchLoader\RelationBatchLoader; @@ -45,8 +44,8 @@ public function resolveField(FieldValue $fieldValue): FieldValue $decorateBuilder = $this->makeBuilderDecorator($resolveInfo); $paginationArgs = $this->paginationArgs($args); + /** @var \Illuminate\Database\Eloquent\Relations\Relation $relation */ $relation = $parent->{$relationName}(); - assert($relation instanceof Relation); // We can shortcut the resolution if the client only queries for a foreign key // that we know to be present on the parent model. @@ -72,6 +71,7 @@ public function resolveField(FieldValue $fieldValue): FieldValue // Batch loading joins across both models, thus only works if they are on the same connection && $relation->getParent()->getConnectionName() === $relation->getRelated()->getConnectionName() ) { + /** @var \Nuwave\Lighthouse\Execution\BatchLoader\RelationBatchLoader $relationBatchLoader */ $relationBatchLoader = BatchLoaderRegistry::instance( $this->qualifyPath($args, $resolveInfo), function () use ($relationName, $decorateBuilder, $paginationArgs): RelationBatchLoader { @@ -82,7 +82,6 @@ function () use ($relationName, $decorateBuilder, $paginationArgs): RelationBatc return new RelationBatchLoader($modelsLoader); } ); - assert($relationBatchLoader instanceof RelationBatchLoader); return $relationBatchLoader->load($parent); } diff --git a/src/Schema/Directives/RelationDirectiveHelpers.php b/src/Schema/Directives/RelationDirectiveHelpers.php index 986fc3fa0c..fe363fd565 100644 --- a/src/Schema/Directives/RelationDirectiveHelpers.php +++ b/src/Schema/Directives/RelationDirectiveHelpers.php @@ -4,7 +4,6 @@ use Closure; use GraphQL\Type\Definition\ResolveInfo; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; trait RelationDirectiveHelpers @@ -34,10 +33,10 @@ protected function relation(): string protected function makeBuilderDecorator(ResolveInfo $resolveInfo): Closure { return function (object $builder) use ($resolveInfo): void { + /** @var \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $builder */ if ($builder instanceof Relation) { $builder = $builder->getQuery(); } - assert($builder instanceof Builder); $resolveInfo->argumentSet->enhanceBuilder( $builder, diff --git a/tests/Integration/Schema/Directives/MorphToDirectiveTest.php b/tests/Integration/Schema/Directives/MorphToDirectiveTest.php index 7c84cdaa47..183c052340 100644 --- a/tests/Integration/Schema/Directives/MorphToDirectiveTest.php +++ b/tests/Integration/Schema/Directives/MorphToDirectiveTest.php @@ -142,19 +142,38 @@ public function testResolveMorphToWithScopes(): void $image->imageable()->associate($task); $image->save(); + $post = factory(Post::class)->make(); + assert($post instanceof Post); + $post->user()->associate($user->id); + $post->save(); + + $postImage = factory(Image::class)->make(); + assert($postImage instanceof Image); + $postImage->imageable()->associate($post); + $postImage->save(); + $this->schema = /** @lang GraphQL */ ' - type Image { + interface Imageable { id: ID! - imageable: Task @morphTo(scopes: [ - { model: "Task", scopes: ["completed"] } - ]) } - type Task { + type Task implements Imageable { id: ID! name: String! } + type Post implements Imageable { + id: ID! + title: String! + } + + type Image { + id: ID! + imageable: Imageable @morphTo(scopes: [ + { model: "Task", scopes: ["completed"] } + ]) + } + type Query { image ( id: ID! @eq @@ -163,22 +182,50 @@ public function testResolveMorphToWithScopes(): void '; $this->graphQL(/** @lang GraphQL */ ' - query ($id: ID!) { - image(id: $id) { + query ($taskImage: ID!, $postImage: ID!){ + taskImage: image(id: $taskImage) { id imageable { - id + ... on Task { + id + name + } + ... on Post { + id + title + } + } + } + postImage: image(id: $postImage) { + id + imageable { + ... on Task { + id + name + } + ... on Post { + id + title + } } } } ', [ - 'id' => $image->id, + 'taskImage' => $image->id, + 'postImage' => $postImage->id, ])->assertJson([ 'data' => [ - 'image' => [ + 'taskImage' => [ 'id' => $image->id, 'imageable' => null, ], + 'postImage' => [ + 'id' => $postImage->id, + 'imageable' => [ + 'id' => $post->id, + 'title' => $post->title, + ], + ], ], ]); @@ -186,22 +233,51 @@ public function testResolveMorphToWithScopes(): void $task->save(); $this->graphQL(/** @lang GraphQL */ ' - query ($id: ID!) { - image(id: $id) { + query ($taskImage: ID!, $postImage: ID!){ + taskImage: image(id: $taskImage) { id imageable { - id + ... on Task { + id + name + } + ... on Post { + id + title + } + } + } + postImage: image(id: $postImage) { + id + imageable { + ... on Task { + id + name + } + ... on Post { + id + title + } } } } ', [ - 'id' => $image->id, + 'taskImage' => $image->id, + 'postImage' => $postImage->id, ])->assertJson([ 'data' => [ - 'image' => [ + 'taskImage' => [ 'id' => $image->id, 'imageable' => [ 'id' => $task->id, + 'name' => $task->name, + ], + ], + 'postImage' => [ + 'id' => $postImage->id, + 'imageable' => [ + 'id' => $post->id, + 'title' => $post->title, ], ], ],