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

Decorating types #73

Closed
theodorton opened this issue Dec 15, 2015 · 3 comments
Closed

Decorating types #73

theodorton opened this issue Dec 15, 2015 · 3 comments

Comments

@theodorton
Copy link

Is there a good (read: D.R.Y.) way to Decorate objects?

Right now, we have some (ugly) code looking like this:

UserType = ::GraphQL::ObjectType.define do
  name "User"
  description "I am user"

  ...
  field :profile_picture_url do
    type !types.String
    argument :size, !types.Int, default_value: 50
    resolve -> (obj, args, ctx) {
      UserDecorator.new(obj).profile_picture_url(size: args[:size])
    }
  end
  ...
end

So I'm thinking something like coerce, but for object types:

UserType = ::GraphQL::ObjectType.define do
  name "User"
  description "I am user"
  decorate -> (obj) { UserDecorator.new(obj) }

  ...
  field :profile_picture_url do
    type !types.String
    argument :size, !types.Int, default_value: 50
    resolve -> (obj, args, ctx) {
      obj.profile_picture_url(size: args[:size])
    }
  end
  ...
end

I've naively tried to do this using coerce, but it doesn't seem to work for object types.

@rmosolgo
Copy link
Owner

I though of a few options, do any of them strike your fancy?

  • Don't decorate, use function objects instead. If you need a value which is a function of a user and some other data, create an object which takes those inputs and returns the value. Eg,

    prof_pic_url = ProfilePictureUrl.new(user, args[:size)
    prof_pic_url.to_s # returns the URL 

    I like this pattern because the objects have small, testable interfaces (unlike decorators, which often take an already-large public API and make it larger). But of course, if you already have a bunch of decorators, this doesn't do ya much good

  • Wrap the field's resolve procs. This is how my connection helper works in my Relay library.

    # Wrap each of `type`'s fields to inject a decorated object 
    def decorate_type(decorator_class, type)
      type.fields.each do |name, field_defn| 
        inner_resolve = field_defn.resolve_proc
        outer_resolve = wrap_resolve_with_decorator(decorator_class, inner_resolve)
        field_defn.resolve_proc = outer_resolve 
      end 
      type 
    end 
    
    # This new proc makes a decorated object and passes it to the inner proc
    def wrap_resolve_with_decorator(decorator_class, inner_resolve)
      -> (obj, args, ctx) {
        decorated_obj = decorator_class.new(obj)
        inner_resolve.call(decorated_obj, args, ctx)
       }
    end
    
    # After defining a type, pass it to `decorate_type` with a decorator
    UserType = decorate_type UserDecorator, GraphQL::ObjectType.define { ... } 

    This works with minimal boilerplate, but the implementation is so wasteful! It makes a new instance of the decorator for each resolved field.

  • Decorate in the parent type. Instead of decorating in UserType, decorate in any field which returns a UserType.

    TeamType = GraphQL::ObjectType.define do 
       field :leader, UserType do 
         resolve -> (obj, args, ctx) { UserDecorator.new(obj) } 
       end 
    
       field :members, types[UserType] do 
         resolve -> (obj, args, ctx) { obj.map { |user| UserDecorator.new(obj) }
       end 
    end 

    Again, not too much code. But you might lose some power in ListType fields. Now, it returns an Array instead of an ActiveRecord::Relation, so pagination & filtering is much less efficient.

  • Decorate with middleware. After resolving a a field, check if the value should be decorated.

    class DecorationMiddleware 
      # decorator_map matches object classes to their decorators. 
      # not sure if keys should be classes or strings, depends on Rails reloading stuffs
      #
      # You might not need this if you can fetch decorators by name, eg `"#{record.class.name}Decorator".constantize` 
      def initialize(decorator_map) 
         @decorator_map = decorator_map 
      end 
    
      def call(parent_type, parent_object, field_definition, field_args, query_context, next_middleware)
        # let the field resolve: 
        result = next_middleware.call 
        # fetch & apply decorator 
        decorator_class = @decorator_map[result.class]
        if decorator_class
          result = decorator_class.new(result)
        end 
        # return the maybe-decorated result 
        result 
      end
    end

    Add the middleware to your schema:

    MySchema = GraphQL::Schema.new # ...
    MySchema.middleware << DecorationMiddleware.new({
      User => UserDecorator,
      Team => TeamDecorator
    })

    That's not too much code and stays out of the way. I've never tried a middleware like that but it seems like it would work!

@theodorton
Copy link
Author

Thanks for the thorough and insightful response :) I was hoping I could use the middleware approach, just wasn't sure how.

I'll try and apply these to my project and see which approach fits me best.

@choznerol
Copy link
Contributor

choznerol commented Dec 15, 2020

I tried the middleware approach but couldn't make it work. From #2479 (comment) I found Field Extension is an alternative.

The Field Extension approach would be something like:

# decoration_extension.rb
class DecorationExtension < GraphQL::Schema::FieldExtension
  def after_resolve(value:, **options)
    decorator_class = options[:decorator_class] || value.decorator_class

    # If you're using Draper. Use `comments.map(&:decorate)` instead of the `comments.decorate` shorthand otherwise AssociationLoader won't work properly.
    if value.respond_to? :map
      value.map{|v| decorator_class.new(v) }
    else
      decorator_class.new(value)
    end
  end
end


# types/article_type.rb
module Types
  class ArticleType < Types::BaseObject
    field :comments, [Types::CommentType], 'All comments of this article', extensions: [DecorationExtension]
    field :likes, [Types::LikeType], 'All comments of this article' do
      extension(DecorationExtension, decorator_class: LikeDecorator)
    end

    # if you are using graphql-batch
    def comments
      Dataloader::AssociationLoader.for(Article, :comments).load(object)
    end
  end
end


# types/comment_type.rb
module Types
  class ArticleType < Types::BaseObject
    field :a_method_of_comment, ...
    field :a_medhot_of_comment_decorator, ...
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants