GraphQL The Rails Way: Part 1 - Exposing your resources for querying

alachaum

Arnaud

Posted on November 4, 2021

GraphQL The Rails Way: Part 1 - Exposing your resources for querying

TL;DR; GraphQL is a good way of making your API more flexible and less resource consuming. But if you think that type-definition is cumbersome then read on. With the modules we provide you'll be able to expose fully functional resources with one line of code.

For those who haven't followed the GraphQL trend launched by Facebook, it's a fancy way of mixing API and SQL concepts together.

Instead of making calls to a properly structured endpoint with parameters like with REST APIs, GraphQL makes you build syntactic queries that you send to one endpoint.

The benefit of GraphQL? A properly defined standard for:

  • Making multiple queries as once
  • Forcing consumers to select the fields they need
  • Fetching related resources as part of parent resources
  • Paginating resources and sub-resources (using relay-style pagination)
  • Strongly-typing the resources you expose
  • Documenting your API without the immediate need for a separate documentation website

Couldn't a REST API do the above? Of course it could. But GraphQL has defined a standard for all these and many clients are already out there providing out of the box functionalities for interacting with GraphQL APIs. So...why not give it a try?

If you need more convincing you can read GitHub's blog article explaining why they switched.

When it comes to implementing a GraphQL server in Rails, one can use the excellent GraphQL Ruby gem.

The gem provides all the foundations for building your API. But the implementation is still very much manual, with lots of boilerplate code to provide.

In this article I will guide you through the steps of bootstrapping GraphQL Ruby then show you how - with a bit of introspection - you can easily expose your resources the Rails Way™ (= with one line of code).

First steps with graphql-ruby

Let's dive into graphql-ruby and see how we can go from zero to first query.

Installing graphql-ruby

First add the graphql gem to your Gemfile:

# GraphQL API functionalities
gem "graphql", "~> 1.12.12"
Enter fullscreen mode Exit fullscreen mode

Then run the install generator:

rails generate graphql:install
Enter fullscreen mode Exit fullscreen mode

The generator will create the GraphQL controller, setup the base types and update your routes.

That's it for the install part. Now let's see how we can expose resources to query.

Defining and exposing models

The first important file to look at is the Types::QueryType file. This class defines all the attributes which can be queried on your GraphQL API.

For the purpose of demonstrating how records get exposed, let's generate a User and a Book model.

# Generate a basic user model
rails g model User email:string name:string

# Generate a basic book model with an ownership link to our user model
rails g model Book name:string pages:integer user:references

# Run the migrations
rake db:migrate
Enter fullscreen mode Exit fullscreen mode

We'll expose these two classes for querying on our GraphQL API. To do so we need to define their type.

We'll start by defining a base type for common record attributes. These kind of base classes can help keep your type classes more focused.

# app/graphql/types/record_type.rb
# frozen_string_literal: true

module Types
  # Define common attributes used by our records
  module RecordType
    include Types::BaseInterface

    field :id, ID, null: false, description: 'The unique identifier of the resource.'
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The date and time that the resource was created.'
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The date and time that the resource was last updated.'
  end
end
Enter fullscreen mode Exit fullscreen mode

Then let's define GraphQL types for our models.

This is the User type:

# app/graphql/types/user_type.rb
# frozen_string_literal: true

module Types
  class UserType < Types::BaseObject
    implements Types::RecordType
    description 'A user'

    field :email, String, null: false, description: 'The email address of the user.'
    field :name, String, null: false, description: 'The name of the user.'
  end
end
Enter fullscreen mode Exit fullscreen mode

This is the Book type. You'll notice that the user field reuses the User type.

# app/graphql/types/book_type.rb
# frozen_string_literal: true

module Types
  class BookType < Types::BaseObject
    implements Types::RecordType
    description 'A book'

    field :name, String, null: false, description: 'The name of the book.'
    field :pages, Integer, null: false, description: 'The number of pages in the book'
    field :user, UserType, null: false, description: 'The owner of the book'
  end
end
Enter fullscreen mode Exit fullscreen mode

Now that we have defined our types we need to plug them to the GraphQL Query API. This plumbing happens in the Types::QueryType class.

Here is the generated Types::QueryType class that we have expanded a bit to expose our collections. We use connection_type instead of arrays on the Book and User types so as to automatically benefit from relay-style pagination.

# app/graphql/types/query_type.rb
# frozen_string_literal: true

module Types
  class QueryType < Types::BaseObject
    # Add `node(id: ID!) and `nodes(ids: [ID!]!)`
    include GraphQL::Types::Relay::HasNodeField
    include GraphQL::Types::Relay::HasNodesField

    #==============================
    # Fields
    #==============================
    # TODO: Test field. remove me
    field :test_field, String, null: false, description: "An example field added by the generator"

    # Record fields
    field :books, BookType.connection_type, null: false, description: "The list of books"
    field :users, UserType.connection_type, null: false, description: "The list of users"

    #==============================
    # Field logic
    #==============================
    def test_field
      "Hello World!"
    end

    def books
      Book.order(:created_at)
    end

    def users
      User.order(:created_at)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's see how we can use our API now.

Querying the GraphQL API

The easiest way to query your GraphQL API is to use GraphiQL.

Good news though, the GraphQL gem generator automatically adds the graphiql-rails gem to your gemfile. After running bundle install you should be able to access GraphiQL on http://localhost:3000/graphiql

You might encounter a precompilation error. In that case update your manifest.js and add the GraphiQL assets.


// app/assets/config/manifest.js

// GraphiQL assets
//= link graphiql/rails/application.css
//= link graphiql/rails/application.js

// Your assets
//= link_tree ../images
//= link_directory ../stylesheets .css
Enter fullscreen mode Exit fullscreen mode

If you prefer, you can also install GraphiQL as a standalone app. See this link for more info.

When you open GraphiQL, the first thing you should look at is the docs section. You'll notice that all your models and fields are properly documented there. That's neat.
GraphiQL docs

Let's create some test records via the Rails console:

# Create users
u1 = User.create(email: "john.doe@example.net", name: "John Doe")
u2 = User.create(email: "fanny.blue@example.net", name: "Fanny Blue")

# Create books
Book.create(name: "The great story", pages: 100, user: u1)
Book.create(name: "The awesome tale", pages: 200, user: u2)
Enter fullscreen mode Exit fullscreen mode

Cool. Now we can perform our query.
GraphiQL docs

Note how GraphQL allows us to perform multiple queries at once. That's really sweet.

Adding filtering attributes to your collections

It would be nice to have filters on our collections. The gem allows us to do that via field block definitions.

Here is a concrete example of adding a filter on page size.

# app/graphql/types/query_type.rb
# frozen_string_literal: true

module Types
  class QueryType < Types::BaseObject
    # Add `node(id: ID!) and `nodes(ids: [ID!]!)`
    include GraphQL::Types::Relay::HasNodeField
    include GraphQL::Types::Relay::HasNodesField

    #==============================
    # Fields
    #==============================
    # TODO: Test field. remove me
    field :test_field, String, null: false, description: "An example field added by the generator"

    # Books
    field :books, BookType.connection_type, null: false do 
      description "The list of books"

      # We define a filter argument on the collection attribute
      argument :size_greater_than, Integer, required: false
    end

    # Users
    field :users, UserType.connection_type, null: false, description: "The list of users"

    #==============================
    # Field logic
    #==============================
    def test_field
      "Hello World!"
    end

    # The filter argument is passed to our method and conditionally
    # used to refine the query scope.
    def books(size_greater_than: nil)
      rel = Book.order(:created_at)
      rel = rel.where("pages >= ?", size_greater_than) if size_greater_than
      rel
    end

    def users
      User.order(:created_at)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now you can easily filter on book size.
GraphiQL docs

Nice! But I'm used to Rails where everything is inferred out of the box. Right now it looks quite cumbersome to define all these collections and filters. Isn't there a way to automatically generate those?

Of course there is. Time to use GraphQL custom resolvers with a bit of introspection!

Automatically defining resources and filters

In order to automatically build resources and their corresponding filters we'll need three things:

  • A GraphQL helper to expose Active Record resources
  • A custom resolver authorizing and querying our collections
  • An Active Record helper to evaluate the query filters received from GraphQL.

The modules below are configured to use Pundit - if present - to scope access to records. Pundit is really just given as an example - any scoping framework would work, even custom policy classes.

Active Record query helpers

Let's start with the Active Record helper.

Add the following concern to your application. This concern allows collections to be filtered using underscore notation (e.g. created_at_gte for created_at >=) and sorting using dot notation (e.g. created_at.desc).

# app/models/concerns/graphql_query_scopes.rb
# frozen_string_literal: true

module GraphqlQueryScopes
  extend ActiveSupport::Concern

  # List of SQL operators supported by the with_api_filters scope
  SQL_OPERATORS = {
    eq: '= ?',
    gt: '> ?',
    gte: '>= ?',
    lt: '< ?',
    lte: '<= ?',
    in: 'IN (?)',
    nin: 'NOT IN (?)'
  }.freeze

  class_methods do
    # If you use Postgres or any database storing date with millisecond precision
    # then you might want to uncomment the body of this method.
    #
    # Millisecond precision makes timestamp equality and less than filters almost 
    # useless.
    #
    # Format field for SQL queries. Truncate dates to second precision.
    # Used to build filtering queries based on attributes coming from the API.
    def loose_precision_field_wrapper(field)
      "#{table_name}.#{field}"

      # if columns_hash[field.to_s].type == :datetime
      #   "date_trunc('second', #{table_name}.#{field})"
      # else
      #   "#{table_name}.#{field}"
      # end
    end
  end

  included do
    # Sort by created_at to have consistent pagination.
    # This is particularly important when using UUID for IDs
    default_scope { order(created_at: :asc, id: :asc) }

    # This scopes aims at being overriden in children models
    # This scope should typically specify eager loaded associations
    # e.g. scope :graphql_scope { includes(:owner, :team) }
    scope :graphql_scope, -> { all }

    # Allow sorting using a 'dot' syntax (e.g. name.asc). 
    # Supports underscore and camelized attributes. 
    # This scope is typically used on the API
    scope :with_sorting, lambda { |sort_by|
      return all if sort_by.blank?

      # Extract attributes
      sort_attr, sort_dir = sort_by.split('.')

      # Format attributes
      sort_attr = sort_attr.underscore
      sort_dir = 'asc' unless %w[asc desc].include?(sort_dir)

      # Order scope or return self if the attribute does not exist
      column_names.include?(sort_attr) ? unscope(:order).order(sort_attr => sort_dir) : all
    }

    # Allow filtering using attribute-level operators coming from the API.
    # E.g.
    # - created_at_gte => created_at greater than or equal to value
    # - id_in => ID in list of values
    #
    # The list of operators is:
    # *_gt => strictly greater than
    # *_gte => greater than or equal
    # *_lt => strictly less than
    # *_lte => less than or equal
    # *_in => value in array
    # *_nin => value not in array
    scope :with_api_filters, lambda { |args_hash|
      # Build a SQL fragment for each argument
      # Array is first build as [['table.field1 > ?', 123], [['table.field2 < ?', 400]]]
      # then transposed into [['table.field1 > ?', 'table.field2 < ?'], [[123, 400]]]
      sql_fragments, values = args_hash.map do |k, v|
        # Capture the field and the operator
        if column_names.include?(k.to_s)
          field = k
          operator = :eq
        else
          field, _, operator = k.to_s.rpartition('_')
        end

        # Sanitize the field and operator
        raise ActiveRecord::StatementInvalid, "invalid operator #{k}" unless column_names.include?(field.to_s) && SQL_OPERATORS[operator.to_sym]

        # Build SQL fragment
        field_fragment = "#{loose_precision_field_wrapper(field)} #{SQL_OPERATORS[operator.to_sym]}"

        # Return fragment and value
        [field_fragment, v]
      end.compact.transpose

      # Combine regular args and SQL fragments to form the final scope
      where(Array(sql_fragments).join(' AND '), *values)
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

Use this concern in your ApplicationRecord base class.

# app/models/application_record.rb
# frozen_string_literal: true

class ApplicationRecord < ActiveRecord::Base
  include GraphqlQueryScopes

  self.abstract_class = true
end
Enter fullscreen mode Exit fullscreen mode

Great! Now you can filter and sort records this way:


> Book.with_api_filters(pages_gte: 130).with_sorting('created.asc')
# Book Load (0.4ms)  SELECT "books".* FROM "books" WHERE (books.pages >= 130) ORDER BY "books"."created_at" ASC, "books"."id" ASC LIMIT ?  [["LIMIT", 11]]
#=> #<ActiveRecord::Relation [#<Book id: 2, name: "The awesome tale", pages: 200, user_id: 2, created_at: "2021-06-07 12:50:22.002122000 +0000", updated_at: "2021-06-07 12:50:22.002122000 +0000">]>
Enter fullscreen mode Exit fullscreen mode

The concern also defines a default graphql_scope, which is used by our resolvers. This scope can be overridden on each model to define API-specific eager loading strategies.

Here is an example with our book model.

# app/models/book.rb
# frozen_string_literal: true

class Book < ApplicationRecord
  belongs_to :user

  # Always eager load the associated user when books
  # get queried on the API.
  scope :graphql_scope, -> { eager_load(:user) }
end
Enter fullscreen mode Exit fullscreen mode

GraphQL custom resolvers for collection and find queries

Now let's add a custom resolver to dynamically support our collections and corresponding filters. The resolver looks at all the fields defined on the model type and automatically generate filters for fields which are database queriable.

# app/graphql/resolvers/collection_query.rb
# frozen_string_literal: true

module Resolvers
  # Parameterized Class used to generate resolvers finding multiple records via
  # filtering attributes
  #
  # Example:
  # Generate resolver for Types::MyClassType which is assumed to use the 'MyClass'
  # ActiveRecord model under the hood:
  # field :my_class, resolver: CollectionQuery.for(Types::MyClassType)
  #
  # Generate resolver for an association where the association name can be inferred from
  # the type class
  # field :posts, resolver: CollectionQuery.for(Types::PostType)
  #
  # Generate resolver for an association where the association cannot be inferred
  # from the type class passed to the resolver
  # field :published_posts, resolver: CollectionQuery.for(Types::MyClassType, relation: :published_posts)
  #
  class CollectionQuery < GraphQL::Schema::Resolver
    # Class insteance variables that can be inherited by child classes
    class_attribute :base_type, :resolver_opts

    #---------------------------------------
    # Constants
    #---------------------------------------
    # Define the operators accepted for each field type
    FILTERING_OPERATORS = {
      GraphQL::Types::ID => %i[in nin],
      GraphQL::Types::String => %i[in nin],
      GraphQL::Schema::Enum => %i[in nin],
      GraphQL::Types::ISO8601DateTime => %i[gt gte lt lte in nin],
      GraphQL::Types::Float => %i[gt gte lt lte in nin],
      GraphQL::Types::Int => %i[gt gte lt lte in nin]
    }.freeze

    #---------------------------------------
    # Class Methods
    #---------------------------------------
    # Return a child resolver class configured for the specified entity type
    def self.for(entity_type, **args)
      Class.new(self).setup(entity_type, args)
    end

    # Setup method used to configure the class
    def self.setup(entity_type, **args)
      # Configure class
      use_base_type entity_type
      use_resolver_opts args

      # Set resolver type
      type [entity_type], null: false

      # Define each entity field as a filtering argument
      filter_fields.each do |field_name, field_type|
        argument field_name, field_type, required: false
      end

      # Sort field
      argument :sort_by, String, required: false, description: 'Use dot notation to sort by a specific field. E.g. `createdAt.asc` or `createdAt.desc`.'

      # Return class for chaining
      self
    end

    # Set the base entity type
    def self.use_base_type(type_klass = nil)
      self.base_type = type_klass
    end

    # Set the resolver options
    def self.use_resolver_opts(opts = nil)
      self.resolver_opts = HashWithIndifferentAccess.new(opts)
    end

    #
    # Return all base fields that can be used to generate filters
    #
    # @return [Hash] A hash of Field Name => GraphQL Field Type
    #
    def self.queriable_fields
      native_queriable_fields.merge(association_queriable_fields)
    end

    #
    # Return the list of native fields that can be used for filtering
    #
    # @return [Hash] A hash of field name => field type
    #
    def self.native_queriable_fields
      base_type
        .fields
        .select { |k, _v| model_klass.column_names.include?(k.to_s.underscore) }
        .select { |_k, v| v.type.unwrap.kind.input? && !v.type.list? }
        .map { |k, v| [k, v.type.unwrap] }
        .to_h
    end

    #
    # Return the list of belongs_to fields that can be used for filtering
    #
    # @return [Hash] A hash of field name => field type
    #
    def self.association_queriable_fields
      base_type
        .fields
        .values
        .select { |v| v.type.unwrap.kind.object? }
        .map { |v| model_klass.reflect_on_all_associations(:belongs_to).find { |e| e.name.to_s == v.name.to_s } }
        .compact
        .map { |e| [e.foreign_key, GraphQL::Types::ID] }
        .to_h
    end

    # Return the list of fields accepted as filters (including operators)
    def self.filter_fields
      # Used queriable fields as equality filters
      equality_fields = queriable_fields

      # For each queriable field, find the list of operators applicable for the field class
      operator_fields = equality_fields.map do |field_name, field_type|
        # Find applicable operators by looking up the field type ancestors
        operators = FILTERING_OPERATORS.find { |klass, _| field_type <= klass }&.last
        next unless operators

        # Generate all operator fields
        operators.map do |o|
          arg_type = %i[in nin].include?(o) ? [field_type] : field_type
          ["#{field_name.underscore}_#{o}".to_sym, arg_type]
        end
      end.compact.flatten(1).to_h

      # Return equality and operator-based fields
      equality_fields.merge(operator_fields)
    end

    # Return the underlying ActiveRecord model class
    def self.model_klass
      @model_klass ||= (resolver_opts[:model_name] || base_type.to_s.demodulize.gsub(/Type$/, '')).constantize
    end

    # Return the model Pundit Policy class
    def self.pundit_scope_klass
      @pundit_scope_klass ||= "#{model_klass}Policy::Scope".constantize
    end

    #---------------------------------------
    # Instance Methods
    #---------------------------------------
    # Retrieve the current user from the GraphQL context.
    # This current user must be injected in context inside the GraphqlController.
    def current_user
      @current_user ||= context[:current_user]
    end

    # Reject request if the user is not authenticated
    def authorized?(**args)
      super && (!defined?(Pundit) || current_user || raise(Pundit::NotAuthorizedError))
    end

    # Return the name of the association that should be defined on the parent
    # object
    def parent_association_name
      self.class.resolver_opts[:relation] ||
        self.class.model_klass.to_s.underscore.pluralize
    end

    # Return the instantiated resource scope via Pundit
    # If a parent object is defined then it is assumed that the resolver is
    # called within the context of an association
    def pundit_scope
      base_scope = object ? object.send(parent_association_name) : self.class.model_klass

      # Enforce Pundit control if the gem is present
      # This current user must be injected in context inside the GraphqlController.
      if defined?(Pundit)
        self.class.pundit_scope_klass.new(current_user, base_scope.graphql_scope).resolve
      else
        base_scope.graphql_scope
      end
    end

    # Actual resolver method performing the ActiveRecord filtering query
    #
    # The resolver supports filtering via a range of operators:
    # * => field equal to value
    # *_gt => strictly greater than
    # *_gte => greater than or equal
    # *_lt => strictly less than
    # *_lte => less than or equal
    # *_in => value in array
    # *_nin => value not in array
    # > See ApplicationRecord#with_api_filters for the underlying filtering logic
    #
    # The resolver supports sorting via 'dot' syntax:
    # sortBy: 'createdAt.desc'
    # > See ApplicationRecord#with_sorting for the underlying sorting logic
    #
    def resolve(sort_by: nil, **args)
      pundit_scope.with_api_filters(args).with_sorting(sort_by)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's also add a custom resolver to support fetching model by unique attribute. Any field your define as ID on your model types will be exposed as a primary key for single record fetching purpose.

# app/graphql/resolvers/record_query.rb
# frozen_string_literal: true

module Resolvers
  # Parameterized Class used to generate resolvers finding a single record
  # using one of its ID keys
  #
  # Example:
  # Generate resolver for Types::MyClassType which is assumed to use the 'MyClass'
  # ActiveRecord model under the hood
  # > RecordQuery.for(Types::MyClassType)
  class RecordQuery < GraphQL::Schema::Resolver
    # Class insteance variables that can be inherited by child classes
    class_attribute :base_type, :resolver_opts

    #---------------------------------------
    # Class Methods
    #---------------------------------------
    # Return a child resolver class configured for the specified entity type
    def self.for(entity_type, **args)
      Class.new(self).setup(entity_type, args)
    end

    # Setup method used to configure the class
    def self.setup(entity_type, **args)
      # Set base type
      use_base_type entity_type
      use_resolver_opts args

      # Set resolver type
      type [entity_type], null: false

      # Define argument for each primary key
      id_fields.each do |f|
        argument f.name, GraphQL::Types::ID, required: false
      end

      # Return class for chaining
      self
    end

    # Set the base entity type
    def self.use_base_type(type_klass = nil)
      self.base_type = type_klass
    end

    # Set the resolver options
    def self.use_resolver_opts(opts = nil)
      self.resolver_opts = HashWithIndifferentAccess.new(opts)
    end

    # Return the list of ID fields
    def self.id_fields
      base_type.fields.values.select { |f| f.type.unwrap == GraphQL::Types::ID }
    end

    # Return the underlying ActiveRecord model class
    def self.entity_klass
      @entity_klass ||= base_type.to_s.demodulize.gsub(/Type$/, '').constantize
    end

    # Return the model Pundit Policy class
    def self.pundit_scope_klass
      @pundit_scope_klass ||= "#{entity_klass}Policy::Scope".constantize
    end

    #---------------------------------------
    # Instance Methods
    #---------------------------------------
    # Retrieve the current user from the GraphQL context.
    # This current user must be injected in context inside the GraphqlController.
    def current_user
      @current_user ||= context[:current_user]
    end

    # Reject request if the user is not authenticated
    def authorized?(**args)
      super && (!defined?(Pundit) || current_user || raise(Pundit::NotAuthorizedError))
    end

    # Return the name of the association that should be defined on the parent
    # object
    def parent_association_name
      self.class.resolver_opts[:relation] ||
        self.class.entity_klass.to_s.underscore.pluralize
    end

    # Return the instantiated resource scope via Pundit
    # If a parent object is defined then it is assumed that the resolver is
    # called within the context of an association
    def pundit_scope
      base_scope = object ? object.send(parent_association_name) : self.class.entity_klass

      # Enforce Pundit control if the gem is present
      # This current user must be injected in context inside the GraphqlController.
      if defined?(Pundit)
        self.class.pundit_scope_klass.new(current_user, base_scope.graphql_scope).resolve
      else
        base_scope.graphql_scope
      end
    end

    # Actual resolver method performing the ActiveRecord find query
    def resolve(**args)
      # Avoid finding by nil value
      return nil if (args_hash = args.compact).blank?

      pundit_scope.find_by(args_hash)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

In both resolvers I've made Pundit optional. But I strongly recommend using it or any similar framework. You should read the comments above each pundit_ method in the resolvers and adapt based on your needs.

For authorization purpose, you can inject a current_user attribute inside the GraphQL context by modifying your GraphqlController. Here is an example:

class GraphqlController < ApplicationController
  # ...

  def execute
    variables = prepare_variables(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]

    # ==> Specify your GraphQL context here <==
    context = {
      current_user: current_user,
    }

    result = GraphqlRailsSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue StandardError => e
    raise e unless Rails.env.development?
    handle_error_in_development(e)
  end

  private

  def current_user
    # ... Devise or Custom logic for retrieving the current user
  end

  # ...
end
Enter fullscreen mode Exit fullscreen mode

GraphQL base object to define resources and has_many

We have custom resolvers to handle the GraphQL query logic and model-level helpers to translate these into database-compatible filters. The last missing piece is a helper allowing us to declare our GraphQL resources.

To do this, add the following helper methods to your Types::BaseObject class.

# app/graphql/types/base_object.rb
# frozen_string_literal: true

module Types
  class BaseObject < GraphQL::Schema::Object
    edge_type_class(Types::BaseEdge)
    connection_type_class(Types::BaseConnection)
    field_class Types::BaseField

    #--------------------------------------------
    # Helpers
    #--------------------------------------------
    # Automatically generate find and list queries for a given resource
    def self.resource(entity, **args)
      entity_type = "Types::#{entity.to_s.singularize.classify}Type".constantize
      record_resolver = args.delete(:record_resolver) || Resolvers::RecordQuery.for(entity_type)
      collection_resolver = args.delete(:collection_resolver) || Resolvers::CollectionQuery.for(entity_type, args)

      # Generate root field for entity find
      field entity.to_s.singularize.to_sym, entity_type,
            null: true,
            resolver: record_resolver,
            description: "Find #{entity.to_s.singularize.camelize}."

      # Generate root field for entity list with filtering
      field entity.to_s.pluralize.to_sym, entity_type.connection_type,
            null: false,
            resolver: collection_resolver,
            description: "Query #{entity.to_s.pluralize.camelize} with filters."
    end

    # Define a has many relationship
    # E.g. inferred type
    # has_many :posts
    #
    # E.g. explicit type
    # has_many :published_posts, type: Type::PostType
    def self.has_many(rel_name, **args)
      inferred_type = rel_name.to_s.singularize.camelize
      model_klass_name = args.delete(:model_name) || inferred_type.classify
      entity_type = args[:type] || "Types::#{inferred_type}Type".constantize
      relation_name = args.delete(:relation) || rel_name
      resolver_klass = args.delete(:resolver_class) || Resolvers::CollectionQuery

      # Generate root field for entity list with filtering
      field rel_name, entity_type.connection_type,
            null: false,
            resolver: resolver_klass.for(entity_type, relation: relation_name, model_name: model_klass_name),
            description: "Query related #{rel_name.to_s.pluralize.camelize} with filters."
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

These helpers provide:

  • resource: a helper to be used inside Types::QueryType to expose an Active Record model for collection querying and record fetching.
  • has_many: a way to define sub-collections on a type.

You can now rewrite your Types::QueryType class the following way:

# app/graphql/types/query_type.rb
# frozen_string_literal: true

module Types
  class QueryType < Types::BaseObject
    # Add `node(id: ID!) and `nodes(ids: [ID!]!)`
    include GraphQL::Types::Relay::HasNodeField
    include GraphQL::Types::Relay::HasNodesField

    resource :books
    resource :users
  end
end
Enter fullscreen mode Exit fullscreen mode

Also let's add a has_many books on our User model and type:

# Active Record Model
# app/models/user.rb
class User < ApplicationRecord
  has_many :books
end

# GraphQL type
# app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    implements Types::RecordType
    description 'A book'

    field :email, String, null: false, description: 'The email address of the user.'
    field :name, String, null: false, description: 'The name of the user.'

    has_many :books
  end
end
Enter fullscreen mode Exit fullscreen mode

Querying our newly implemented resources

We're ready. Let's see how this works now.

GraphiQL docs

As you can see on the right-hand side, all our collection filters are properly generated. We can also fetch records individually by ID field (id or any other ID field on the type). Finally, we can fetch sub-resources on parent records, such as user books.

Wrapping up

A bit of metaprogramming makes the whole GraphQL-Rails experience way easier than it was originally advertised. Now all we need to do is define model types and declare resources in our Types::QueryType.

But there is more we can do. In the next episodes we'll see how to do similar things for mutations (create/update/delete) and subscriptions (via Pusher as a specific example).

💖 💪 🙅 🚩
alachaum
Arnaud

Posted on November 4, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related