Graphql-ruby: Question: Does it support Polymorphic types?

Created on 7 Mar 2016  路  14Comments  路  Source: rmosolgo/graphql-ruby

Hey @rmosolgo,

I saw that you added Enum, Union and other types into the library. Can it be used for polymorphic associations? For example: A Post model and Comment model both can be voted using belongs_to :votable polymorphic association.

In normal situation, we can use Rails helpers to cleanly address this, but how can this be achieved with Relay mutations. In mutations, we need to return the fields (Post or Comment) connection when a mutation occurs. Currently, I have to create two mutations to achieve this: PostVoteMutation and CommentVoteMutation to return type: return_field :post, PostType and return_field :comment, CommentType

Is it possible to use the new types you introduced that will help in reducing code duplication?

Most helpful comment

@rmosolgo, could you possibly give an example on how polymorphism in mutations would be implemented on the server? For example an upvoteMutation that can be used for both a Post and Comment?

All 14 comments

This is one of those things I've also been thinking about. We have a lot of polymorphic relations in our data model. Currently, I only use a single mutation in our graphql server when creating the related child object, but with multiple optional return types. Something like:

VoteMutation = GraphQL::Relay::Mutation.define do
  ...
  return_field :post, PostType
  return_field :comment, CommentType
  ...
  resolve -> (inputs, ctx) {
    obj = NodeIdentification.object_from_id(inputs[:id], ctx)
    obj.votes.create!(...)
    { :"#{obj.class.to_s.downcase}" => obj }
  }
end

But I haven't really figured how to do it in a good way in the relay client. As of now, I have defined multiple Relay.Mutations for each possible related parent object. Perhaps using class inheritance would solve this in a better way but I haven't digged any deeper into it yet.

Yes, actually this behavior has been here all along! I just pushed docs for the last part of the puzzle, the resolve_type proc.

The Interface or Union must be able to determine the GraphQL type to use for a given object. You can pass a resolve_type proc or use the default definition.

Then, you can use polymorphic types as described in the spec. For example, given

field :searchMedia, types[SearchResultUnion]

you could query:

searchMedia(name: "abc") {
  ... on Photo {
   height
   width
  }
  ... on Video {
    height
    width
    duration 
  }
  ... on Audio {
    duration 
    key
  }
}

@rmosolgo Awesome! thank you :+1:

Hey @kristianm thanks for pointing out :+1: I kind of came onto same conclusion until Robert confirmed docs for new types that could be used instead - resolve_type looks much better.

For client side, you could use inline props while invoking mutation to send type to mutation and perform mutation query accordingly. Here is vote mutation class for your reference (needs refactoring): https://github.com/gauravtiwari/relay-rails-blog/blob/master/client/app/bundles/Mutations/VoteMutations.js

Thanks @gauravtiwari for your samples (and for your blog, was very useful to me when becoming introduced to graphql/relay with rails). I'm not so fond of those if .. else if .. setups when dealing with polymorphic logic, but it currently seems like the only way. I've tried to declare a superclass which extends Relay.Mutation and contains all shared code, and then inherit from this class for all polymorphic types which often just declare the type name. Unfortunately relay does a lot of js magic which seems to break this kind of setup.

Wouldn't this work where you declare a Votable GraphQL Type and have the Post and Comment implement that interface? Then have the properties related to voting on the Votable type and have the Mutations work on the Votable type? I'm looking to do something similar and that is my plan for the approach, will that not work?

@rmosolgo, could you possibly give an example on how polymorphism in mutations would be implemented on the server? For example an upvoteMutation that can be used for both a Post and Comment?

@mathiasjakobsen Did you ever find out how to implement such mutation?

@fullofcaffeine, no I did not.

After having used this library for a bit, the way I would implement this is not directly through GraphQL type checking. But the "upvoteMutation" should accept a generic Node ID (which is essentially just an encoded Classname and ID) then you test the object returned to see what class it is and if it's in the allowed list. If not, you throw a GraphQL error so clients get the notification. But if you're in control of the clients, you should never call it on anything other than a Post or Comment.

I have a simple loader

module GraphLoader
  def self.can_create(shouldBeType, ctx)
    user = ctx[:current_user] || User.new
    raise Exceptions::GraphExecutionError.new('User does not have permission to perform operation') unless user.can?(:create, shouldBeType)
  end

  def self.load(objectId, shouldBeType, ctx, permission, must_exist = true)
    user = ctx[:current_user] || User.new
    obj = GraphSchema.object_from_id(objectId, ctx)

    if must_exist && obj.nil?
      raise Exceptions::GraphExecutionError.new('Object does not exist')
    end

    if obj.present?
      raise Exceptions::GraphExecutionError.new('ID did not return the proper type') unless obj.class == shouldBeType
      raise Exceptions::GraphExecutionError.new('User does not have permission to perform operation') unless user.can?(permission, obj)
    end
    obj
  end

  def self.load_by_slug(slug, shouldBeType, ctx, permission, must_exist = true)
    self.load_by(:slug, slug, shouldBeType, ctx, permission, must_exist)
  end

  def self.load_by(key, key_value, shouldBeType, ctx, permission, must_exist = true)
    user = ctx[:current_user] || User.new
    obj = shouldBeType.find_by(key => key_value)

    if must_exist && obj.nil?
      raise Exceptions::GraphExecutionError.new('Object does not exist')
    end

    if obj.present?
      raise Exceptions::GraphExecutionError.new('Key did not return the proper type') unless obj.class == shouldBeType
      raise Exceptions::GraphExecutionError.new('User does not have permission to perform operation') unless user.can?(permission, obj)
    end
    obj
  end
end

I'm using CanCan::Ability where there's delegates on the User model.

delegate :can?, :cannot?, to: :ability

Then my usage looks like this:

field :experience do
    type ExperienceGraph
    description 'Find an experience'
    argument :slug, !types.String
    resolve -> (obj, args, ctx) {
      GraphLoader.load_by_slug(args[:slug], Experience, ctx, :read)
    }
end

For my needs, I never needed to check an incoming Node ID type against anything other than just one. But this code could be adapted to accept an array as the shouldBeType.

You should definitely do type checking of some sort to see if the returned object support what you want to do or whether the user has permission to do such a thing.

You can simply use the

obj = GraphSchema.object_from_id(objectId, ctx)

to fetch the object, but you need to absolutely check that someone isn't hacking your system by loading another type of object. But the above call will return an object with the GraphQL Object ID, then you just need some instrumentation around making sure the user making that request should be able to do whatever is about to happen.

Your code would look something like this:

Upvote = GraphQL::Relay::Mutation.define do
    name 'Upvote'

    input_field :ID, !types.ID

    return_field :success, !types.Boolean

    resolve -> (obj, args, ctx) {
      to_up = GraphSchema.object_from_id(args[:ID], ctx)
      if to_up.present? && (to_up.is_a?(Post) || to_up.is_a?(Comment))
           to_up.perform_upvote
      else
           raise Exceptions::GraphExecutionError.new('Cannot upvote this class') # only happens if being hacked or if a new class is able to be upvoted. 
      end
      {success: true}
    }
  end

If you implement the upvoting as a module or similar, you could just test the returned object that it can respond to the appropriate call, if it can, then send it through rather than explicitly naming every type.

  if to_up.present? && (to_up.is_a?(Post) || to_up.is_a?(Comment))

becomes

 if to_up.present? && to_up.respond_to?(:perform_upvote)

:+1: to @nuclearspike 's solution. If you're using class-based mutations, you get basically the same behavior with loads:

class Mutations::Upvote < Mutations::Base 
  argument :id, ID, required: true, loads: Types::Upvotable, as: :upvotable 

  def resolve(upvotable:)
    # `upvotable` will be loaded with `Schema.object_from_id` and checked using `Upvotable.resolve_type`. 
    # If the load fails, an error is returned to the client 
  end 
end 

Thanks for including this info! I have been using this gem long enough and haven't been following the best practices for the better ways to do stuff that I didn't even know mutation classes were a thing. Is there any integration with cancan/cancancan to let you define the action on the argument to have it auto-check if you have permission to do what's about to happen? The idea is that it takes current user and checks if it has the permission defined by "can:" With cancan/cancancan, old-fashioned controller updates happen via

   load_and_authorize_resource

or breaking it up if using a different id, like

  load_resource find_by: :slug
  authorize_resource

But rounding out this gem's offering, it would be great to have ability-integration. Does that already exist? Or is it up to the dev to check before performing an operation?

Something like:

class Mutations::Upvote < Mutations::Base 
  argument :id, ID, required: true, loads: Types::Upvotable, as: :upvotable, can: :upvote

  def resolve(upvotable:)
    # `upvotable` will be loaded with `Schema.object_from_id` and checked using `Upvotable.resolve_type`. 
    # If the load fails, an error is returned to the client 
  end 
end 

Where the ability.rb file would have similar to (prevents user from upvoting their own post/comment):

if @user.has_account?
  can :upvote, [Post, Comment] do |upvotable| 
     upvotable.user != @user 
  end
  ...
end

A cancan integration is offered as part of GraphQL-Pro (https://graphql-ruby.org/authorization/can_can_integration), but it's built on top of the core authorization system (http://graphql-ruby.org/authorization/authorization.html), so you could just as well build one yourself, too!

I've also seen similar third-party libraries:

Was this page helpful?
0 / 5 - 0 ratings