Lighthouse: Possible to re-use existing model methods?

Created on 2 Dec 2019  路  8Comments  路  Source: nuwave/lighthouse

Is your feature request related to a problem? Please describe.

I've been looking at a way for my mutations to leverage some existing model methods, either through using @field or @method but so far I've come up short - but I could be approaching this all wrong!

Essentially, say I have a user model

class User extends Model
{
  public function promote($levels = 1): User
  {
    return tap($this, function ($instance) {
      $instance->level = $instance-> level + $levels;
      $instance->save();
    }
  }
}

And I wanted to expose this via GraphQL

extend type Mutation {
  promoteUser(id: ID! @call(name: "promote")): User
}

and then from my front-end

mutation promoteUser(id: 10) {
  name
  level
}

Additionally, the ability to add arguments would be great but this adds complexity, and my initial implementation was just to perform self-contained mutations in the backend (as opposed to query -> transform -> mutate on the frontend).


I have tried a custom resolver (with @field), but this looks like overkill and I'm not sure if I can simply extend the existing default resolvers to add my method on? Perhaps an example of this could help?

I tried @method briefly but I didn't understand the implementation.

The other I thought of, was a custom directive, but it seems odd to create a custom directive for every method I have already implemented and would end up with lots of single-use directives.

Am I approaching this wrong? Any advice?

Thanks, and I love the work you're doing!

question

Most helpful comment

@enzonotario i really appreciate the effort. I think your english is quite good, you are always doing a great job of communicating with the community in issues like these :)

All 8 comments

You could make a generic custom directive, like this (you will need to modify it as your needs)

<?php

namespace App\Graphql\Directives;

use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Nuwave\Lighthouse\Exceptions\DirectiveException;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;

class CallDirective extends BaseDirective implements FieldResolver
{
    /**
     * Name of the directive.
     *
     * @return string
     */
    public function name()
    {
        return 'call';
    }

    /**
     * Resolve the field directive.
     *
     * @param FieldValue $value
     *
     * @return FieldValue
     * @throws DirectiveException
     */
    public function resolveField(FieldValue $value)
    {
        $model = $this->getModel();
        $method = $this->directiveArgValue('method');

        return $value->setResolver(function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($model, $method) {
            $instance = $model::findOrFail(Arr::get($args, 'input.id'));
            return $instance->{$method}();
        });
    }

    private function getModel()
    {
        $model = $this->directiveArgValue('model');

        // Fallback to using information from the schema definition as the model name
        if (! $model) {
            $model = ASTHelper::getUnderlyingTypeName($this->definitionNode);

            // Cut the added type suffix to get the base model class name
            $model = Str::before($model, 'Collection');
            $model = Str::before($model, 'Paginator');
            $model = Str::before($model, 'Connection');
        }

        if (! $model) {
            throw new DirectiveException(
                "A `model` argument must be assigned to the '{$this->name()}' directive on '{$this->definitionNode->name->value}"
            );
        }

        return $this->namespaceModelClass($model);
    }
}

A generic custom directive sounds like a good way to go.

Would I still need the @update directive for the mutation?

If I do have @update, this doesn't seem to be called at all, and if I don't, I get Could not locate a default resolver for the field foo.

Thanks for your help.

You can't have two field resolver directives. You has to use it like:

input PromoteUserInput {
  id: ID
}
extend type Mutation {
  promoteUser(input: PromoteUserInput!):  User @call(method: "promote")
}

@enzonotario great solution. Maybe we can enhance the docs with a guide for using custom directives.

Sure! It would be great but for me is so hard to write english... I will try to at least write something in spanish and then translate it by parts

@enzonotario i really appreciate the effort. I think your english is quite good, you are always doing a great job of communicating with the community in issues like these :)

Just want to say this was really helpful and gave me a really good starting point 馃憤

I was able to get this example working as a field resolver:

<?php

namespace App\Graphql\Directives;

use Illuminate\Support\Str;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Exceptions\DirectiveException;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;

class CallDirective extends BaseDirective implements FieldResolver
{
    /**
     * Name of the directive.
     *
     * @return string
     */
    public function name()
    {
        return 'call';
    }

    /**
     * Resolve the field directive.
     *
     * @param FieldValue $value
     *
     * @return FieldValue
     * @throws DirectiveException
     */
    public function resolveField(FieldValue $value)
    {
        $model = $this->getModel();
        $method = $this->directiveArgValue('method');
        $arguments = $this->directiveArgValue('args') ?? [];

        return $value->setResolver(function ($root, array $args) use ($model, $method, $arguments) {
            $instance = $model::where($args)->firstOrFail();

            $instance->{$method}(...$arguments);

            return $instance->refresh();
        });
    }

    /**
     * Resolve the model name
     *
     * @return string
     */
    protected function getModel()
    {
        $model = $this->directiveArgValue('model');

        // Fallback to using information from the schema definition as the model name
        if (! $model) {
            $model = ASTHelper::getUnderlyingTypeName($this->definitionNode);

            // Cut the added type suffix to get the base model class name
            $model = Str::before($model, 'Collection');
            $model = Str::before($model, 'Paginator');
            $model = Str::before($model, 'Connection');
        }

        if (! $model) {
            throw new DirectiveException(
                "A `model` argument must be assigned to the '{$this->name()}' directive on '{$this->definitionNode->name->value}"
            );
        }

        return $this->namespaceModelClass($model);
    }
}

This will find the first model, call the provided method on that model instance and return a freshly resolved model in the response:

type Mutation {
  promoteUser(id: ID! @eq): User @call(name: "promote")
}

It'll work with any number of input params as well:

type Mutation {
  promoteUser(
    id: ID @eq
    username: String @eq
  ): User @call(name: "promote")
}

You can even set static arguments that should passed to the method:

type Mutation {
  promoteUser(id: ID! @eq): User @call(name: "setStatus", args: ["promoted"])
}

Thanks @enzonotario for the great work!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

spawnia picture spawnia  路  4Comments

let-aurn picture let-aurn  路  3Comments

nguyentrongbang picture nguyentrongbang  路  3Comments

wimski picture wimski  路  3Comments

mehranabi picture mehranabi  路  3Comments