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

resolve method can use dependency injection #520

Merged
merged 3 commits into from
Nov 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG
### Added
- Allow passing through an instance of a `Field` [\#521 / georgeboot](https://github.com/rebing/graphql-laravel/pull/521/files)
- Add the ability to alias query and mutations arguments as well as input objects [\#517 / crissi](https://github.com/rebing/graphql-laravel/pull/517/files)
- Classes can now be injected in the Resolve method from the query/mutation similarly to Laravel controller methods [\#520 / crissi](https://github.com/rebing/graphql-laravel/pull/520/files)
### Fixed
- Fix validation rules for non-null list of non-null objects [\#511 / crissi](https://github.com/rebing/graphql-laravel/pull/511/files)
- Add morph type to returned models [\#503 / crissi](https://github.com/rebing/graphql-laravel/pull/503)
Expand Down
75 changes: 70 additions & 5 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ To work this around:
- [Creating a mutation](#creating-a-mutation)
- [Adding validation to a mutation](#adding-validation-to-a-mutation)
- [File uploads](#file-uploads)
- [Resolve method](#resolve-method)
- [Authorization](#authorization)
- [Privacy](#privacy)
- [Query Variables](#query-variables)
Expand Down Expand Up @@ -596,6 +597,70 @@ class UserProfilePhotoMutation extends Mutation

Note: You can test your file upload implementation using [Altair](https://altair.sirmuel.design/) as explained [here](https://sirmuel.design/working-with-file-uploads-using-altair-graphql-d2f86dc8261f).


### Resolve method
The resolve method is used in both queries and mutations and it here the response are created.

The first three parameters to the resolve method are hard-coded:
1. The `$root` object this resolve method belongs to (can be `null`)
2. The arguments passed as `array $args` (can be an empty array)
3. The query specific GraphQL context, can be customized by overriding `\Rebing\GraphQL\GraphQLController::queryContext`

Arguments here after will be attempted to be injected, similar to how controller methods works in Laravel.

You can typehint any class that you will need an instance of.

There are two hardcoded classes which depend on the local data for the query:
- `GraphQL\Type\Definition\ResolveInfo` has information useful for field resolution process.
- `Rebing\GraphQL\Support\SelectFields` allows eager loading of related models, see [Eager loading relationships](#eager-loading-relationships).

Example:
```php
<?php

namespace App\GraphQL\Queries;

use Closure;
use App\User;
use GraphQL;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\ResolveInfo;
use Rebing\GraphQL\Support\SelectFields;
use Rebing\GraphQL\Support\Query;
use SomeClassNamespace\SomeClassThatDoLogging;

class UsersQuery extends Query
{
protected $attributes = [
'name' => 'User query'
];

public function type(): Type
{
return Type::listOf(GraphQL::type('user'));
}

public function args(): array
{
return [
'id' => ['name' => 'id', 'type' => Type::string()]
];
}

public function resolve($root, $args, $context, ResolveInfo $info, SelectFields $fields, SomeClassThatDoLogging $logging)
{
$logging->log('fetched user');

$select = $fields->getSelect();
$with = $fields->getRelations();

$users = User::select($select)->with($with);

return $users->get();
}
}
```

### Authorization

For authorization similar to Laravel's Request (or middleware) functionality, we can override the `authorize()` function in a Query or Mutation.
Expand Down Expand Up @@ -941,13 +1006,13 @@ class UserType extends GraphQLType

### Eager loading relationships

The fifth argument passed to a query's resolve method is a Closure which returns
an instance of `Rebing\GraphQL\Support\SelectFields` which you can use to retrieve keys
from the request. The following is an example of using this information
to eager load related Eloquent models.
The `Rebing\GraphQL\Support\SelectFields` class allows to eager load related Eloquent models.

Only the required fields will be queried from the database.

This way only the required fields will be queried from the database.
The class can be instanciated by typehinting `SelectFields $selectField` in your resolve method.

You can also construct the class by typehinting a `Closure`.
The Closure accepts an optional parameter for the depth of the query to analyse.

Your Query would look like:
Expand Down
53 changes: 42 additions & 11 deletions src/Support/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;
use Rebing\GraphQL\Error\AuthorizationError;
use Rebing\GraphQL\Error\ValidationError;
use Rebing\GraphQL\Support\AliasArguments\AliasArguments;
use ReflectionMethod;

/**
* @property string $name
Expand Down Expand Up @@ -204,26 +206,55 @@ protected function getResolver(): ?Closure
}
}

// Add the 'selects and relations' feature as 5th arg
if (isset($arguments[3])) {
$arguments[] = function (int $depth = null) use ($arguments): SelectFields {
$ctx = $arguments[2] ?? null;

return new SelectFields($arguments[3], $this->type(), $arguments[1], $depth ?? 5, $ctx);
};
}

$arguments[1] = $this->getArgs($arguments);

// Authorize
if (call_user_func_array($authorize, $arguments) != true) {
if (true != call_user_func_array($authorize, $arguments)) {
throw new AuthorizationError('Unauthorized');
}

return call_user_func_array($resolver, $arguments);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just calling the app()->call() directly in here?

return app()->call([ $this, 'resolve' ], [
   'root' => $root,
    'args' => $args, 
    'ctx' => $ctx,
    'resolveInfo' => $arguments[3],
    'selectFields' => $this->instanciateSelectFields($arguments),
]);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷‍♀️

You can always try making a PR!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mfn I just notice this because I had implement this feature for auto injecting anything I pass in by myself, so on this PR I notice my solution wouldn't be needed anymore as I could just use this, however I solved it with just a few lines by calling app()->call() and this implementation is trying to do alot.... Which makes me wonder if I missed something.. Or my suggestion would be enough.. Would it work?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it work?

I can't say, didn't author it /cc @crissi

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like it would bound to specific variable names instead of now where only the 3 first are required arguments and you can use what ever variable name there after as long as the Class name is correct.

$method = new ReflectionMethod($this, 'resolve');

$additionalParams = array_slice($method->getParameters(), 3);

$additionalArguments = array_map(function ($param) use ($arguments) {
$className = null !== $param->getClass() ? $param->getClass()->getName() : null;

if (null === $className) {
throw new InvalidArgumentException("'{$param->name}' could not be injected");
}

if (Closure::class === $param->getType()->getName()) {
return function (int $depth = null) use ($arguments): SelectFields {
return $this->instanciateSelectFields($arguments, $depth);
};
}

if (SelectFields::class === $className) {
return $this->instanciateSelectFields($arguments);
}

if (ResolveInfo::class === $className) {
return $arguments[3];
}

return app()->make($className);
}, $additionalParams);

return call_user_func_array($resolver, array_merge(
[$arguments[0], $arguments[1], $arguments[2]],
$additionalArguments
));
};
}

private function instanciateSelectFields(array $arguments, int $depth = null): SelectFields
{
$ctx = $arguments[2] ?? null;

return new SelectFields($arguments[3], $this->type(), $arguments[1], $depth ?? 5, $ctx);
}

protected function aliasArgs(array $arguments): array
{
return (new AliasArguments())->get($this->args(), $arguments[1]);
Expand Down
93 changes: 93 additions & 0 deletions tests/Database/SelectFieldsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use Rebing\GraphQL\Tests\Support\Models\Post;
use Rebing\GraphQL\Tests\Support\Queries\PostNonNullWithSelectFieldsAndModelQuery;
use Rebing\GraphQL\Tests\Support\Queries\PostQuery;
use Rebing\GraphQL\Tests\Support\Queries\PostQueryWithNonInjectableTypehintsQuery;
use Rebing\GraphQL\Tests\Support\Queries\PostQueryWithSelectFieldsClassInjectionQuery;
use Rebing\GraphQL\Tests\Support\Queries\PostsListOfWithSelectFieldsAndModelQuery;
use Rebing\GraphQL\Tests\Support\Queries\PostsNonNullAndListAndNonNullOfWithSelectFieldsAndModelQuery;
use Rebing\GraphQL\Tests\Support\Queries\PostsNonNullAndListOfWithSelectFieldsAndModelQuery;
Expand Down Expand Up @@ -67,6 +69,95 @@ public function testWithoutSelectFields(): void
$this->assertEquals($expectedResult, $response->json());
}

public function testWithSelectFieldsClassInjection(): void
{
$post = factory(Post::class)->create([
'title' => 'Title of the post',
]);

$graphql = <<<GRAQPHQL
{
postWithSelectFieldClassInjection(id: $post->id) {
id
title
}
}
GRAQPHQL;

$this->sqlCounterReset();

$response = $this->call('GET', '/graphql', [
'query' => $graphql,
]);

$this->assertSqlQueries(
<<<'SQL'
select "id", "title" from "posts" where "posts"."id" = ? limit 1;
SQL
);

$expectedResult = [
'data' => [
'postWithSelectFieldClassInjection' => [
'id' => "$post->id",
'title' => 'Title of the post',
],
],
];

$this->assertEquals($response->getStatusCode(), 200);
$this->assertEquals($expectedResult, $response->json());
}

public function testWithSelectFieldsNonInjectableTypehints(): void
{
$post = factory(Post::class)->create([
'title' => 'Title of the post',
]);

$graphql = <<<GRAQPHQL
{
postQueryWithNonInjectableTypehints(id: $post->id) {
id
title
}
}
GRAQPHQL;

$this->sqlCounterReset();

$result = $this->graphql($graphql, [
'expectErrors' => true,
]);

unset($result['errors'][0]['trace']);

$expectedResult = [
'errors' => [
[
'debugMessage' => "'coolNumber' could not be injected",
'message' => 'Internal server error',
'extensions' => [
'category' => 'internal',
],
'locations' => [
[
'line' => 2,
'column' => 3,
],
],
'path' => [
'postQueryWithNonInjectableTypehints',
],
],
],
'data' => [
'postQueryWithNonInjectableTypehints' => null,
],
];
$this->assertEquals($expectedResult, $result);
}

public function testWithSelectFieldsAndModel(): void
{
$post = factory(Post::class)->create([
Expand Down Expand Up @@ -445,6 +536,8 @@ protected function getEnvironmentSetUp($app)
PostWithSelectFieldsAndModelQuery::class,
PostWithSelectFieldsNoModelQuery::class,
PostWithSelectFieldsAndModelAndAliasCallbackQuery::class,
PostQueryWithSelectFieldsClassInjectionQuery::class,
PostQueryWithNonInjectableTypehintsQuery::class,
],
]);

Expand Down
9 changes: 9 additions & 0 deletions tests/Support/Objects/ClassToInject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php
crissi marked this conversation as resolved.
Show resolved Hide resolved

declare(strict_types=1);

namespace Rebing\GraphQL\Tests\Support\Objects;

class ClassToInject
{
}
38 changes: 38 additions & 0 deletions tests/Support/Queries/PostQueryWithNonInjectableTypehintsQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Rebing\GraphQL\Tests\Support\Queries;

use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Query;
use Rebing\GraphQL\Support\SelectFields;
use Rebing\GraphQL\Tests\Support\Models\Post;

class PostQueryWithNonInjectableTypehintsQuery extends Query
{
protected $attributes = [
'name' => 'postQueryWithNonInjectableTypehints',
];

public function type(): Type
{
return GraphQL::type('Post');
}

public function args(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::id()),
],
];
}

public function resolve($root, $args, $ctx, SelectFields $fields, int $coolNumber)
{
return Post::select($fields->getSelect())
->findOrFail($args['id']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Rebing\GraphQL\Tests\Support\Queries;

use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Query;
use Rebing\GraphQL\Support\SelectFields;
use Rebing\GraphQL\Tests\Support\Models\Post;
use Rebing\GraphQL\Tests\Support\Objects\ClassToInject;

class PostQueryWithSelectFieldsClassInjectionQuery extends Query
{
protected $attributes = [
'name' => 'postWithSelectFieldClassInjection',
];

public function type(): Type
{
return GraphQL::type('Post');
}

public function args(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::id()),
],
];
}

public function resolve($root, $args, $ctx, SelectFields $fields, ResolveInfo $info, Closure $selectFields, ClassToInject $class)
{
$selectClass = $selectFields(5);

return Post::select($fields->getSelect())
->findOrFail($args['id']);
}
}