Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @bind directive as a GraphQL analogue for Laravel's Route Model Binding #2645

Merged
merged 34 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a7a0952
Setup validation for `@bind` directive schema definitions
remipelhate Dec 21, 2024
b41f64b
Implement simple model binding
remipelhate Dec 22, 2024
145a247
Restructure to separate Nuwave\Lighthouse\Bind namespace
remipelhate Dec 22, 2024
d407329
Test optional model binding
remipelhate Dec 22, 2024
3018a72
Handle optional model bindings
remipelhate Dec 23, 2024
518df4c
Add missing tests for binding models by column
remipelhate Dec 24, 2024
ce51095
Add missing tests for binding models with eager loading
remipelhate Dec 24, 2024
8f981f7
Add missing tests for too many results when resolving a binding
remipelhate Dec 24, 2024
2f46f5e
Handle missing required bindings a validation errors
remipelhate Dec 25, 2024
371749f
Rename `optional` argument to `required` on BindDirective
remipelhate Dec 25, 2024
a03666e
Cleanup exception assetions in BindDirectiveTest
remipelhate Dec 25, 2024
6c6e4e4
Fail schema validation when `@bind` directive is defined on unsupport…
remipelhate Dec 26, 2024
a291ef2
Ensure `@bind` directive can be used multiple times in the same request
remipelhate Dec 27, 2024
aa54257
Add missing tests for binding instances using callable classes
remipelhate Dec 27, 2024
8080404
Add docs
remipelhate Dec 27, 2024
f0ddbeb
Fix PHPStan errors
remipelhate Dec 27, 2024
23dd711
Fix backwards compatibility with Laravel 9
remipelhate Dec 27, 2024
6030bd4
Update changelog
remipelhate Dec 29, 2024
17597f9
review | Remove Laravel version in docs reference
remipelhate Jan 3, 2025
dd87182
review | Update examples in docs
remipelhate Jan 3, 2025
e64fff9
review | Remove function imports
remipelhate Jan 3, 2025
baa7b21
review | Adhere to coding standards
remipelhate Jan 3, 2025
b685580
review | Use placeholder query in BindDirectiveTest
remipelhate Jan 3, 2025
67d3d6d
review | Remove SpyResolver in favour of inspecting resolver args by …
remipelhate Jan 3, 2025
2ef000c
Fix BindDefinition constructor docblock
remipelhate Jan 3, 2025
c543ee9
Fix BindDirective schema validation tests
remipelhate Jan 3, 2025
b52dc07
review | Reuse existing underlying type name resolution
remipelhate Jan 3, 2025
87c0a72
review | Don’t validate value types on which `@bind` is defined
remipelhate Jan 3, 2025
10a7580
review | Add comment for context on handling “too few” vs “too many” …
remipelhate Jan 3, 2025
28cdfb3
review | Update examples in directives docs
remipelhate Jan 3, 2025
541e796
review | Remove redundant docblock from BindDefinition
remipelhate Jan 3, 2025
c6df8c8
review | Cleanup internals in accordance to coding standard
remipelhate Jan 3, 2025
fd530f6
review | Use protected methods and properties instead of private
remipelhate Jan 3, 2025
2ba7be4
Merge branch 'master' into feature/bind-directive
spawnia Jan 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
152 changes: 152 additions & 0 deletions docs/master/api-reference/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/Bind/BindDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php declare(strict_types=1);

namespace Nuwave\Lighthouse\Bind;

use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
use GraphQL\Language\AST\InputValueDefinitionNode;
use Illuminate\Database\Eloquent\Model;
use Nuwave\Lighthouse\Exceptions\DefinitionException;

/** @template-covariant TClass of object */
class BindDefinition
{
public function __construct(
/** @var class-string<TClass> */
public string $class,
public string $column,
/** @var array<string> */
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()) {
spawnia marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
133 changes: 133 additions & 0 deletions src/Bind/BindDirective.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php declare(strict_types=1);

namespace Nuwave\Lighthouse\Bind;

use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
use GraphQL\Language\AST\InputValueDefinitionNode;
use GraphQL\Language\AST\InterfaceTypeDefinitionNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use Illuminate\Contracts\Container\Container;
use Nuwave\Lighthouse\Bind\Validation\BindingExists;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgDirectiveForArray;
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
use Nuwave\Lighthouse\Support\Contracts\ArgTransformerDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgumentValidation;
use Nuwave\Lighthouse\Support\Contracts\InputFieldManipulator;

class BindDirective extends BaseDirective implements ArgumentValidation, ArgTransformerDirective, ArgDirectiveForArray, ArgManipulator, InputFieldManipulator
{
/** @var \Nuwave\Lighthouse\Bind\BindDefinition<object>|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<object> */
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);
}
}
15 changes: 15 additions & 0 deletions src/Bind/BindServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types=1);

namespace Nuwave\Lighthouse\Bind;

use Illuminate\Contracts\Events\Dispatcher as EventsDispatcher;
use Illuminate\Support\ServiceProvider;
use Nuwave\Lighthouse\Events\RegisterDirectiveNamespaces;

class BindServiceProvider extends ServiceProvider
{
public function boot(EventsDispatcher $dispatcher): void
{
$dispatcher->listen(RegisterDirectiveNamespaces::class, static fn (): string => __NAMESPACE__);
}
}
Loading
Loading