diff --git a/CHANGELOG.md b/CHANGELOG.md index 944f78b5a..101e160c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add `@bind` directive as a GraphQL analogue for Laravel's Route Model Binding https://github.com/nuwave/lighthouse/pull/2645 + ## v6.47.1 ### Fixed diff --git a/composer.json b/composer.json index 414d9a858..87680e369 100644 --- a/composer.json +++ b/composer.json @@ -111,6 +111,7 @@ "Nuwave\\Lighthouse\\LighthouseServiceProvider", "Nuwave\\Lighthouse\\Async\\AsyncServiceProvider", "Nuwave\\Lighthouse\\Auth\\AuthServiceProvider", + "Nuwave\\Lighthouse\\Bind\\BindServiceProvider", "Nuwave\\Lighthouse\\Cache\\CacheServiceProvider", "Nuwave\\Lighthouse\\GlobalId\\GlobalIdServiceProvider", "Nuwave\\Lighthouse\\OrderBy\\OrderByServiceProvider", diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index c39bf6b0c..9a6db440c 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -460,6 +460,158 @@ type RoleEdge { } ``` +## @bind + +```graphql +""" +Automatically inject (model) instances directly into a resolver's arguments. For example, instead of +injecting a user's ID, you can inject the entire User model instance that matches the given ID. + +This is a GraphQL analogue for Laravel's Route Binding. +""" +directive @bind( + """ + Specify the class name of the binding to use. This can be either an Eloquent + model or callable class to bind any other instance than a model. + """ + class: String! + + """ + Specify the column name of a unique identifier to use when binding Eloquent models. + By default, "id" is used the the primary key column. + """ + column: String! = "id" + + """ + Specify the relations to eager-load when binding Eloquent models. + """ + with: [String!]! = [] + + """ + Specify whether the binding should be considered required. When required, a validation error will be thrown for + the argument or any item in the argument (when the argument is an array) for which a binding instance could not + be resolved. The field resolver will not be invoked in this case. When optional, the argument value will resolve + as null or, when the argument is an array, any item in the argument value will be filtered out of the collection. + """ + required: Boolean! = true +) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +``` + +Resolver model binding (like [Route Model Binding](https://laravel.com/docs/routing#route-model-binding)) allows to +inject instances directly into a resolver's arguments instead of the provided scalar identifier, eliminating the need to +manually query for the instance inside the resolver. + +### Basic usage + +```graphql +type Mutation { + addUserToCompany( + user: ID! @bind(class: "App\\Models\\User") + company: ID! @bind(class: "App\\Models\\Company") + ): Boolean! +} +``` + +```php +namespace App\GraphQL\Mutations; + +final class AddUserToCompany +{ + /** + * @param array{ + * user: \App\Models\User, + * company: \App\Models\Company, + * } $args + */ + public function __invoke(mixed $root, array $args): bool + { + $user = $args['user']; + $user->associate($args['company']); + + return $user->save(); + } +} +``` + +### Binding instances that are not Eloquent models + +To bind instances that are not Eloquent models, callable classes can be used instead: + +```graphql +type Mutation { + updateCompanyInfo( + company: ID! @bind(class: "App\\Http\\GraphQL\\Bindings\\CompanyBinding") + ): Boolean! +} +``` + +```php +namespace App\GraphQL\Bindings; + +use App\External\Company; +use App\External\CompanyRepository; +use Nuwave\Lighthouse\Bind\BindDefinition; + +final class CompanyBinding +{ + public function __construct( + private CompanyRepository $companyRepository, + ) {} + + public function __invoke(string $value, BindDefinition $definition): ?Company + { + if ($definition->required) { + return $this->companyRepository->findOrFail($value); + } + + return $this->companyRepository->find($value); + } +} +``` + +### Binding a collection of instances + +When the `@bind` directive is defined on an argument or input field with an array value, it can be used to resolve a +collection of instances. + +```graphql +type Mutation { + addUsersToCompany( + users: [ID!]! @bind(class: "App\\Models\\User") + company: ID! @bind(class: "App\\Models\\Company") + ): [User!]! +} +``` + +```php +namespace App\GraphQL\Mutations; + +use App\Models\User; + +final class AddUsersToCompany +{ + /** + * @param array{ + * users: \Illuminate\Database\Eloquent\Collection<\App\Models\User>, + * company: \App\Models\Company, + * } $args + * @return \Illuminate\Database\Eloquent\Collection<\App\Models\User> + */ + public function __invoke(mixed $root, array $args): Collection + { + return $args['users'] + ->map(function (User $user) use ($args): ?User { + $user->associate($args['company']); + + return $user->save() + ? $user + : null; + }) + ->filter(); + } +} +``` + ## @broadcast ```graphql diff --git a/src/Bind/BindDefinition.php b/src/Bind/BindDefinition.php new file mode 100644 index 000000000..ebf307508 --- /dev/null +++ b/src/Bind/BindDefinition.php @@ -0,0 +1,54 @@ + */ + public string $class, + public string $column, + /** @var array */ + public array $with, + public bool $required, + ) {} + + public function validate( + InputValueDefinitionNode $definitionNode, + FieldDefinitionNode|InputObjectTypeDefinitionNode $parentNode, + ): void { + $nodeName = $definitionNode->name->value; + $parentNodeName = $parentNode->name->value; + + if (! class_exists($this->class)) { + throw new DefinitionException( + "@bind argument `class` defined on `{$parentNodeName}.{$nodeName}` must be an existing class, received `{$this->class}`.", + ); + } + + if ($this->isModelBinding()) { + return; + } + + if (method_exists($this->class, '__invoke')) { + return; + } + + $modelClass = Model::class; + throw new DefinitionException( + "@bind argument `class` defined on `{$parentNodeName}.{$nodeName}` must extend {$modelClass} or define the method `__invoke`, but `{$this->class}` does neither.", + ); + } + + public function isModelBinding(): bool + { + return is_subclass_of($this->class, Model::class); + } +} diff --git a/src/Bind/BindDirective.php b/src/Bind/BindDirective.php new file mode 100644 index 000000000..dd521415a --- /dev/null +++ b/src/Bind/BindDirective.php @@ -0,0 +1,133 @@ +|null */ + protected ?BindDefinition $definition = null; + + protected mixed $binding; + + public function __construct( + protected Container $container, + ) { + $this->binding = new PendingBinding(); + } + + public static function definition(): string + { + return /** @lang GraphQL */ <<<'GRAPHQL' +""" +Automatically inject (model) instances directly into a resolver's arguments. For example, instead of +injecting a user's ID, you can inject the entire User model instance that matches the given ID. + +This is a GraphQL analogue for Laravel's Route Binding. +""" +directive @bind( + """ + Specify the class name of the binding to use. This can be either an Eloquent + model or callable class to bind any other instance than a model. + """ + class: String! + + """ + Specify the column name of a unique identifier to use when binding Eloquent models. + By default, "id" is used the the primary key column. + """ + column: String! = "id" + + """ + Specify the relations to eager-load when binding Eloquent models. + """ + with: [String!]! = [] + + """ + Specify whether the binding should be considered required. When required, a validation error will be thrown for + the argument or any item in the argument (when the argument is an array) for which a binding instance could not + be resolved. The field resolver will not be invoked in this case. When optional, the argument value will resolve + as null or, when the argument is an array, any item in the argument value will be filtered out of the collection. + """ + required: Boolean! = true +) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +GRAPHQL; + } + + /** @return \Nuwave\Lighthouse\Bind\BindDefinition */ + protected function bindDefinition(): BindDefinition + { + return $this->definition ??= new BindDefinition( + class: $this->directiveArgValue('class'), + column: $this->directiveArgValue('column', 'id'), + with: $this->directiveArgValue('with', []), + required: $this->directiveArgValue('required', true), + ); + } + + public function manipulateArgDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$argDefinition, + FieldDefinitionNode &$parentField, + ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType, + ): void { + $this->bindDefinition()->validate($argDefinition, $parentField); + } + + public function manipulateInputFieldDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$inputField, + InputObjectTypeDefinitionNode &$parentInput, + ): void { + $this->bindDefinition()->validate($inputField, $parentInput); + } + + public function rules(): array + { + return $this->bindDefinition()->required + ? [new BindingExists($this)] + : []; + } + + public function messages(): array + { + return []; + } + + public function attribute(): ?string + { + return null; + } + + public function transform(mixed $argumentValue): mixed + { + // When validating required bindings, the \Nuwave\Lighthouse\Bind\Validation\BindingExists validation rule + // should call transform() before it is called by the directive resolver. To avoid resolving the bindings + // multiple times, we should remember the resolved binding and reuse it every time transform() is called. + if (! $this->binding instanceof PendingBinding) { + return $this->binding; + } + + $definition = $this->bindDefinition(); + + $bind = $definition->isModelBinding() + ? $this->container->make(ModelBinding::class) + : $this->container->make($definition->class); + + return $this->binding = $bind($argumentValue, $definition); + } +} diff --git a/src/Bind/BindServiceProvider.php b/src/Bind/BindServiceProvider.php new file mode 100644 index 000000000..cf2c43b4c --- /dev/null +++ b/src/Bind/BindServiceProvider.php @@ -0,0 +1,15 @@ +listen(RegisterDirectiveNamespaces::class, static fn (): string => __NAMESPACE__); + } +} diff --git a/src/Bind/ModelBinding.php b/src/Bind/ModelBinding.php new file mode 100644 index 000000000..3d3b23d93 --- /dev/null +++ b/src/Bind/ModelBinding.php @@ -0,0 +1,68 @@ + $value + * @param \Nuwave\Lighthouse\Bind\BindDefinition<\Illuminate\Database\Eloquent\Model> $definition + * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|null + */ + public function __invoke(int|string|array $value, BindDefinition $definition): Model|EloquentCollection|null + { + $binding = $definition->class::query() + ->with($definition->with) + ->whereIn($definition->column, Arr::wrap($value)) + ->get(); + + if (is_array($value)) { + return $this->modelCollection($binding, IlluminateCollection::make($value), $definition); + } + + return $this->modelInstance($binding); + } + + /** @param \Illuminate\Database\Eloquent\Collection $results */ + protected function modelInstance(EloquentCollection $results): ?Model + { + // While "too few records" errors are handled as (client-safe) validation errors by applying + // the `BindingExists` rule on the BindDirective depending on whether the binding is required, + // "too many records" should be considered as (non-client-safe) configuration errors as it + // means the binding was not resolved using a unique identifier. + if ($results->count() > 1) { + throw new MultipleRecordsFoundException($results->count()); + } + + return $results->first(); + } + + /** + * Binding collections should be returned with the original values + * as keys to allow validating the binding when required. + * @see \Nuwave\Lighthouse\Bind\BindDirective::rules() + * + * @param \Illuminate\Database\Eloquent\Collection $results + * @param \Illuminate\Support\Collection $values + * @param \Nuwave\Lighthouse\Bind\BindDefinition<\Illuminate\Database\Eloquent\Model> $definition + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function modelCollection( + EloquentCollection $results, + IlluminateCollection $values, + BindDefinition $definition, + ): EloquentCollection { + /** @see self::modelInstance() */ + if ($results->count() > $values->unique()->count()) { + throw new MultipleRecordsFoundException($results->count()); + } + + return $results->keyBy($definition->column); + } +} diff --git a/src/Bind/PendingBinding.php b/src/Bind/PendingBinding.php new file mode 100644 index 000000000..74cc1b71e --- /dev/null +++ b/src/Bind/PendingBinding.php @@ -0,0 +1,5 @@ +directive->transform($value); + + if ($binding === null) { + $fail('validation.exists')->translate(); + + return; + } + + if (! is_array($value)) { + return; + } + + foreach ($value as $key => $scalarValue) { + if ($binding->has($scalarValue)) { + continue; + } + + $this->validator?->addFailure("{$attribute}.{$key}", 'exists'); + } + } + + /** + * Because of backwards compatibility with Laravel 9, typehints for this method + * must be set through PHPDoc as the interface did not include typehints. + * @link https://laravel.com/docs/9.x/validation#custom-validation-rules + * + * @param \Illuminate\Validation\Validator $validator + */ + public function setValidator($validator): self + { + $this->validator = $validator; + + return $this; + } +} diff --git a/tests/Integration/Bind/BindDirectiveTest.php b/tests/Integration/Bind/BindDirectiveTest.php new file mode 100644 index 000000000..836177afa --- /dev/null +++ b/tests/Integration/Bind/BindDirectiveTest.php @@ -0,0 +1,1252 @@ +schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user(user: ID! @bind(class: "NotAClass")): User! @mock + } + GRAPHQL; + + $this->expectExceptionObject(new DefinitionException( + '@bind argument `class` defined on `user.user` must be an existing class, received `NotAClass`.' + )); + + $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + user(user: "1") { + id + } + } + GRAPHQL); + } + + public function testSchemaValidationFailsWhenClassArgumentDefinedOnInputFieldIsNotAClass(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input RemoveUsersInput { + users: [ID!]! @bind(class: "NotAClass") + } + + type Mutation { + removeUsers(input: RemoveUsersInput!): Boolean! @mock + } + GRAPHQL; + + $this->expectExceptionObject(new DefinitionException( + '@bind argument `class` defined on `RemoveUsersInput.users` must be an existing class, received `NotAClass`.' + )); + + $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: RemoveUsersInput!) { + removeUsers(input: $input) + } + GRAPHQL, + [ + 'input' => [ + 'users' => ['1'], + ], + ], + ); + } + + public function testSchemaValidationFailsWhenClassArgumentDefinedOnFieldArgumentIsNotAModelOrCallableClass(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user(user: ID! @bind(class: "stdClass")): User! @mock + } + GRAPHQL; + + $this->expectExceptionObject(new DefinitionException( + '@bind argument `class` defined on `user.user` must extend Illuminate\Database\Eloquent\Model or define the method `__invoke`, but `stdClass` does neither.' + )); + + $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + user(user: "1") { + id + } + } + GRAPHQL); + } + + public function testSchemaValidationFailsWhenClassArgumentDefinedOnInputFieldIsNotAModelOrCallableClass(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input RemoveUsersInput { + users: [ID!]! @bind(class: "stdClass") + } + + type Mutation { + removeUsers(input: RemoveUsersInput!): Boolean! @mock + } + GRAPHQL; + + $this->expectExceptionObject(new DefinitionException( + '@bind argument `class` defined on `RemoveUsersInput.users` must extend Illuminate\Database\Eloquent\Model or define the method `__invoke`, but `stdClass` does neither.' + )); + + $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: RemoveUsersInput!) { + removeUsers(input: $input) + } + GRAPHQL, + [ + 'input' => [ + 'users' => ['1'], + ], + ], + ); + } + + public function testModelBindingOnFieldArgument(): void + { + $user = factory(User::class)->create(); + $this->mockResolver(fn (mixed $root, array $args): User => $args['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user(user: ID! @bind(class: "Tests\\Utils\\Models\\User")): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => $user->getKey()], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + } + + public function testMissingModelBindingOnFieldArgument(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user(user: ID! @bind(class: "Tests\\Utils\\Models\\User")): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + user(user: "1") { + id + } + } + GRAPHQL); + + $response->assertOk(); + $response->assertGraphQLValidationError('user', trans('validation.exists', ['attribute' => 'user'])); + } + + public function testMissingOptionalModelBindingOnFieldArgument(): void + { + $this->mockResolver(fn (mixed $root, array $args) => $args['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: ID! @bind(class: "Tests\\Utils\\Models\\User", required: false) + ): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => '1'], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testModelBindingByColumnOnFieldArgument(): void + { + $user = factory(User::class)->create(); + $this->mockResolver(fn (mixed $root, array $args) => $args['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: String! @bind(class: "Tests\\Utils\\Models\\User", column: "email") + ): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($email: String!) { + user(user: $email) { + id + } + } + GRAPHQL, + ['email' => $user->email], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + } + + public function testModelBindingWithEagerLoadingOnFieldArgument(): void + { + $user = factory(User::class)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return $args['user']; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: ID! @bind(class: "Tests\\Utils\\Models\\User", with: ["company"]) + ): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => $user->getKey()], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + $this->assertInstanceOf(User::class, $resolverArgs['user']); + $this->assertTrue($resolverArgs['user']->relationLoaded('company')); + } + + public function testModelBindingWithTooManyResultsOnFieldArgument(): void + { + $this->rethrowGraphQLErrors(); + $users = factory(User::class, 2)->create(['name' => 'John Doe']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: String! @bind(class: "Tests\\Utils\\Models\\User", column: "name") + ): User @mock + } + GRAPHQL; + + $makeRequest = fn () => $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($name: String!) { + user(user: $name) { + id + } + } + GRAPHQL, + ['name' => $users->first()->name], + ); + + $this->assertThrowsMultipleRecordsFoundException($makeRequest, $users->count()); + } + + public function testModelCollectionBindingOnFieldArgument(): void + { + $users = factory(User::class, 2)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return true; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Mutation { + removeUsers( + users: [ID!]! @bind(class: "Tests\\Utils\\Models\\User") + ): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($users: [ID!]!) { + removeUsers(users: $users) + } + GRAPHQL, + ['users' => $users->map(fn (User $user): int => $user->getKey())], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'removeUsers' => true, + ], + ]); + $this->assertArrayHasKey('users', $resolverArgs); + $this->assertCount($users->count(), $resolverArgs['users']); + $users->each(function (User $user) use ($resolverArgs): void { + $this->assertTrue($user->is($resolverArgs['users'][$user->getKey()])); + }); + } + + public function testMissingModelCollectionBindingOnFieldArgument(): void + { + $user = factory(User::class)->create(); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Mutation { + removeUsers( + users: [ID!]! @bind(class: "Tests\\Utils\\Models\\User") + ): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($users: [ID!]!) { + removeUsers(users: $users) + } + GRAPHQL, + [ + 'users' => [$user->getKey(), '10'], + ], + ); + + $response->assertOk(); + $response->assertGraphQLValidationError('users.1', trans('validation.exists', ['attribute' => 'users.1'])); + } + + public function testMissingOptionalModelCollectionBindingOnFieldArgument(): void + { + $user = factory(User::class)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return true; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Mutation { + removeUsers( + users: [ID!]! @bind(class: "Tests\\Utils\\Models\\User", required: false) + ): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($users: [ID!]!) { + removeUsers(users: $users) + } + GRAPHQL, + [ + 'users' => [$user->getKey(), '10'], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'removeUsers' => true, + ], + ]); + $this->assertArrayHasKey('users', $resolverArgs); + $this->assertCount(1, $resolverArgs['users']); + $this->assertTrue($user->is($resolverArgs['users'][$user->getKey()])); + } + + public function testModelCollectionBindingWithTooManyResultsOnFieldArgument(): void + { + $this->rethrowGraphQLErrors(); + $users = factory(User::class, 2)->create(['name' => 'John Doe']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Mutation { + removeUsers( + users: [String!]! @bind(class: "Tests\\Utils\\Models\\User", column: "name") + ): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $makeRequest = fn () => $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($users: [String!]!) { + removeUsers(users: $users) + } + GRAPHQL, + [ + 'users' => [$users->first()->name], + ], + ); + + $this->assertThrowsMultipleRecordsFoundException($makeRequest, $users->count()); + } + + public function testModelBindingOnInputField(): void + { + $user = factory(User::class)->create(); + $this->mockResolver(fn (mixed $root, array $args): User => $args['input']['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Models\\User") + } + + type Query { + user(input: UserInput!): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => $user->getKey(), + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + } + + public function testMissingModelBindingOnInputField(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Models\\User") + } + + type Query { + user(input: UserInput!): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => '1', + ], + ], + ); + + $response->assertOk(); + $response->assertGraphQLValidationError('input.user', trans('validation.exists', [ + 'attribute' => 'input.user', + ])); + } + + public function testMissingOptionalModelBindingOnInputField(): void + { + $this->mockResolver(fn (mixed $root, array $args) => $args['input']['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Models\\User", required: false) + } + + type Query { + user(input: UserInput!): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => '1', + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testModelBindingByColumnOnInputField(): void + { + $user = factory(User::class)->create(); + $this->mockResolver(fn (mixed $root, array $args) => $args['input']['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: String! @bind(class: "Tests\\Utils\\Models\\User", column: "email") + } + + type Query { + user(input: UserInput!): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => $user->email, + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->id, + ], + ], + ]); + } + + public function testModelBindingWithEagerLoadingOnInputField(): void + { + $user = factory(User::class)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return $args['input']['user']; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Models\\User", with: ["company"]) + } + + type Query { + user(input: UserInput!): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => $user->getKey(), + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + $this->assertInstanceOf(User::class, $resolverArgs['input']['user']); + $this->assertTrue($resolverArgs['input']['user']->relationLoaded('company')); + } + + public function testModelBindingWithTooManyResultsOnInputField(): void + { + $this->rethrowGraphQLErrors(); + $users = factory(User::class, 2)->create(['name' => 'Jane Doe']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: String! @bind(class: "Tests\\Utils\\Models\\User", column: "name") + } + + type Query { + user(input: UserInput!): User @mock + } + GRAPHQL; + + $makeRequest = fn () => $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => $users->first()->name, + ], + ], + ); + + $this->assertThrowsMultipleRecordsFoundException($makeRequest, $users->count()); + } + + public function testModelCollectionBindingOnInputField(): void + { + $users = factory(User::class, 2)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return true; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input RemoveUsersInput { + users: [ID!]! @bind(class: "Tests\\Utils\\Models\\User") + } + + type Mutation { + removeUsers(input: RemoveUsersInput!): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: RemoveUsersInput!) { + removeUsers(input: $input) + } + GRAPHQL, + [ + 'input' => [ + 'users' => $users->map(fn (User $user): int => $user->getKey()), + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'removeUsers' => true, + ], + ]); + $this->assertArrayHasKey('input', $resolverArgs); + $this->assertArrayHasKey('users', $resolverArgs['input']); + $this->assertCount($users->count(), $resolverArgs['input']['users']); + $users->each(function (User $user) use ($resolverArgs): void { + $this->assertTrue($user->is($resolverArgs['input']['users'][$user->getKey()])); + }); + } + + public function testMissingModelCollectionBindingOnInputField(): void + { + $user = factory(User::class)->create(); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input RemoveUsersInput { + users: [ID!]! @bind(class: "Tests\\Utils\\Models\\User") + } + + type Mutation { + removeUsers(input: RemoveUsersInput!): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: RemoveUsersInput!) { + removeUsers(input: $input) + } + GRAPHQL, + [ + 'input' => [ + 'users' => [$user->getKey(), '10'], + ], + ], + ); + + $response->assertOk(); + $response->assertGraphQLValidationError('input.users.1', trans('validation.exists', [ + 'attribute' => 'input.users.1', + ])); + } + + public function testMissingOptionalModelCollectionBindingOnInputField(): void + { + $user = factory(User::class)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return true; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input RemoveUsersInput { + users: [ID!]! @bind(class: "Tests\\Utils\\Models\\User", required: false) + } + + type Mutation { + removeUsers(input: RemoveUsersInput!): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: RemoveUsersInput!) { + removeUsers(input: $input) + } + GRAPHQL, + [ + 'input' => [ + 'users' => [$user->getKey(), '10'], + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'removeUsers' => true, + ], + ]); + $this->assertArrayHasKey('input', $resolverArgs); + $this->assertArrayHasKey('users', $resolverArgs['input']); + $this->assertCount(1, $resolverArgs['input']['users']); + $this->assertTrue($user->is($resolverArgs['input']['users'][$user->getKey()])); + } + + public function testModelCollectionBindingWithTooManyResultsOnInputField(): void + { + $this->rethrowGraphQLErrors(); + $users = factory(User::class, 2)->create(['name' => 'Jane Doe']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input RemoveUsersInput { + users: [String!]! @bind(class: "Tests\\Utils\\Models\\User", column: "name") + } + + type Mutation { + removeUsers(input: RemoveUsersInput!): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $makeRequest = fn () => $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: RemoveUsersInput!) { + removeUsers(input: $input) + } + GRAPHQL, + [ + 'input' => [ + 'users' => [$users->first()->name], + ], + ], + ); + + $this->assertThrowsMultipleRecordsFoundException($makeRequest, $users->count()); + } + + public function testCallableClassBindingOnFieldArgument(): void + { + $user = factory(User::class)->make(['id' => 1]); + $this->instance(SpyCallableClassBinding::class, new SpyCallableClassBinding($user)); + $this->mockResolver(fn (mixed $root, array $args): User => $args['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user(user: ID! @bind(class: "Tests\\Utils\\Bind\\SpyCallableClassBinding")): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => $user->getKey()], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + } + + public function testMissingCallableClassBindingOnFieldArgument(): void + { + $this->instance(SpyCallableClassBinding::class, new SpyCallableClassBinding(null)); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: ID! @bind(class: "Tests\\Utils\\Bind\\SpyCallableClassBinding") + ): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => '1'], + ); + + $response->assertOk(); + $response->assertGraphQLValidationError('user', trans('validation.exists', ['attribute' => 'user'])); + } + + public function testMissingOptionalCallableClassBindingOnFieldArgument(): void + { + $this->instance(SpyCallableClassBinding::class, new SpyCallableClassBinding(null)); + $this->mockResolver(fn (mixed $root, array $args) => $args['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: ID! @bind(class: "Tests\\Utils\\Bind\\SpyCallableClassBinding", required: false) + ): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => '1'], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testCallableClassBindingWithDirectiveArgumentsOnFieldArgument(): void + { + $callableClassBinding = new SpyCallableClassBinding(null); + $this->instance(SpyCallableClassBinding::class, $callableClassBinding); + $this->mockResolver(fn (mixed $root, array $args) => $args['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Query { + user( + user: ID! @bind( + class: "Tests\\Utils\\Bind\\SpyCallableClassBinding" + column: "uid" + with: ["relation"] + required: false + ) + ): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($id: ID!) { + user(user: $id) { + id + } + } + GRAPHQL, + ['id' => '1'], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + $callableClassBinding->assertCalledWith( + '1', + new BindDefinition(SpyCallableClassBinding::class, 'uid', ['relation'], false), + ); + } + + public function testCallableClassBindingOnInputField(): void + { + $user = factory(User::class)->make(['id' => 1]); + $this->instance(SpyCallableClassBinding::class, new SpyCallableClassBinding($user)); + $this->mockResolver(fn (mixed $root, array $args): User => $args['input']['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Bind\\SpyCallableClassBinding") + } + + type Query { + user(input: UserInput!): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => $user->getKey(), + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => [ + 'id' => $user->getKey(), + ], + ], + ]); + } + + public function testMissingCallableClassBindingOnInputField(): void + { + $this->instance(SpyCallableClassBinding::class, new SpyCallableClassBinding(null)); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Bind\\SpyCallableClassBinding") + } + + type Query { + user(input: UserInput!): User! @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => '1', + ], + ], + ); + + $response->assertOk(); + $response->assertGraphQLValidationError('input.user', trans('validation.exists', ['attribute' => 'input.user'])); + } + + public function testMissingOptionalCallableClassBindingOnInputField(): void + { + $this->instance(SpyCallableClassBinding::class, new SpyCallableClassBinding(null)); + $this->mockResolver(fn (mixed $root, array $args) => $args['input']['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind(class: "Tests\\Utils\\Bind\\SpyCallableClassBinding", required: false) + } + + type Query { + user(input: UserInput!): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => '1', + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testCallableClassBindingWithDirectiveArgumentsOnInputField(): void + { + $callableClassBinding = new SpyCallableClassBinding(null); + $this->instance(SpyCallableClassBinding::class, $callableClassBinding); + $this->mockResolver(fn (mixed $root, array $args) => $args['input']['user']); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + input UserInput { + user: ID! @bind( + class: "Tests\\Utils\\Bind\\SpyCallableClassBinding" + column: "uid" + with: ["relation"] + required: false + ) + } + + type Query { + user(input: UserInput!): User @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + query ($input: UserInput!) { + user(input: $input) { + id + } + } + GRAPHQL, + [ + 'input' => [ + 'user' => '1', + ], + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + $callableClassBinding->assertCalledWith( + '1', + new BindDefinition(SpyCallableClassBinding::class, 'uid', ['relation'], false), + ); + } + + public function testMultipleBindingsInSameRequest(): void + { + $user = factory(User::class)->create(); + $company = factory(Company::class)->create(); + $resolverArgs = []; + $this->mockResolver(function ($_, array $args) use (&$resolverArgs) { + $resolverArgs = $args; + return true; + }); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + } + + type Company { + id: ID! + } + + type Mutation { + addUserToCompany( + user: ID! @bind(class: "Tests\\Utils\\Models\\User") + company: ID! @bind(class: "Tests\\Utils\\Models\\Company") + ): Boolean! @mock + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + mutation ($user: ID!, $company: ID!) { + addUserToCompany(user: $user, company: $company) + } + GRAPHQL, + [ + 'user' => $user->getKey(), + 'company' => $company->getKey(), + ], + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'addUserToCompany' => true, + ], + ]); + $this->assertArrayHasKey('user', $resolverArgs); + $this->assertTrue($user->is($resolverArgs['user'])); + $this->assertArrayHasKey('company', $resolverArgs); + $this->assertTrue($company->is($resolverArgs['company'])); + } + + private function assertThrowsMultipleRecordsFoundException(Closure $makeRequest, int $count): void + { + try { + $makeRequest(); + } catch (Error $error) { + $this->assertInstanceOf(MultipleRecordsFoundException::class, $error->getPrevious()); + $this->assertEquals(new MultipleRecordsFoundException($count), $error->getPrevious()); + + return; + } + + $this->fail('Request did not throw an exception.'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index a7f57cd35..a51bde2ab 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,6 +13,7 @@ use Laravel\Scout\ScoutServiceProvider as LaravelScoutServiceProvider; use Nuwave\Lighthouse\Async\AsyncServiceProvider; use Nuwave\Lighthouse\Auth\AuthServiceProvider as LighthouseAuthServiceProvider; +use Nuwave\Lighthouse\Bind\BindServiceProvider; use Nuwave\Lighthouse\Cache\CacheServiceProvider; use Nuwave\Lighthouse\CacheControl\CacheControlServiceProvider; use Nuwave\Lighthouse\GlobalId\GlobalIdServiceProvider; @@ -81,6 +82,7 @@ protected function getPackageProviders($app): array LighthouseServiceProvider::class, AsyncServiceProvider::class, LighthouseAuthServiceProvider::class, + BindServiceProvider::class, CacheServiceProvider::class, CacheControlServiceProvider::class, GlobalIdServiceProvider::class, diff --git a/tests/Utils/Bind/SpyCallableClassBinding.php b/tests/Utils/Bind/SpyCallableClassBinding.php new file mode 100644 index 000000000..978275c1e --- /dev/null +++ b/tests/Utils/Bind/SpyCallableClassBinding.php @@ -0,0 +1,39 @@ +|null */ + private ?BindDefinition $definition = null; + + public function __construct( + /** @var TReturn */ + private mixed $return = null, + ) {} + + /** + * @param \Nuwave\Lighthouse\Bind\BindDefinition $definition + * @return TReturn + */ + public function __invoke(mixed $value, BindDefinition $definition): mixed + { + $this->value = $value; + $this->definition = $definition; + + return $this->return; + } + + /** @param \Nuwave\Lighthouse\Bind\BindDefinition $definition */ + public function assertCalledWith(mixed $value, BindDefinition $definition): void + { + Assert::assertSame($value, $this->value); + Assert::assertEquals($definition, $this->definition); + } +}