Skip to content

Commit

Permalink
Add @bind directive as a GraphQL analogue for Laravel's Route Model…
Browse files Browse the repository at this point in the history
… Binding (#2645)
  • Loading branch information
remipelhate authored Jan 9, 2025
1 parent 3b4f85e commit fe23e87
Show file tree
Hide file tree
Showing 12 changed files with 1,788 additions and 0 deletions.
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()) {
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

0 comments on commit fe23e87

Please sign in to comment.