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();