GraphQL

5 mins read

GraphQL is a tool to effectively fetch resources. There is a spec available.

Been to ramen shops in Japan? You buy food tickets from the machine. 2000 yen in. Click Ramen, egg, and chashu. You get 3 tickets out for each item. This is REST. Nowadays there is modern machine that you buy 3 things. You’ll get 3 things printed on a single ticket. This is GraphQL.

You run a Ramen shop. You build a website for people to know about your Ramen.

# a GraphQL query that asks what is
# name, price, defaultToppings of tsukemen
query = <<~QUERY
query {
  ramen(type: 'tsukemen') {
    name
    price
  }
}
QUERY

class RamenSchema < GraphQL::Schema
  mutation(Types::MutationType) # <--- All Writes
  query(Types::QueryType) # <--- All Reads
end

RamenSchema.execute(query)

# QueryType is the entry to find all types in your system
module Types
  class QueryType < Types::BaseObject
    field :ramen, resolver: Queries::Ramen
  end

  class Ramen < GraphQL::Schema::Object
    field :id, type: ID, null: false # <-- ID from graphql gem
    field :name, type: String, null: false
    field :price, type: Integer, null: false
  end
end

# field: `null: false`
# argument: `required: false`

# Resolver for Ramen
module Queries
  class Ramen < GraphQL::Schema::Resolver
    argument :type, String, required: true
    type Types::Ramen

    def resolve(type:)
      # Fetch from your PostgreSQL server, external service, anything.
      Ramen.find_by(type: type)
    end
  end
end

This is the idea, put together into a rails app. We’re missing a controller to execute the query from a POST request:

class GraphqlController < ActionController::API
  # POST /graphql => "graphql#execute"
  def execute
    context = { current_user: Guest.new }
    variables = EnsureHash.call(params[:variables])

    result = RamenSchema.execute(
      query,
      variables: variables,
      context: context,
    )
    render json: result
  rescue StandardError => exception
    error = GraphqlError.call(exception)
    render json: error, status: 500
  end
end

module GraphqlError
  def self.call(exception)
    Logger.error(exception.message)
    {
      error: {
        message: exception.message,
        backtrace: exception.backtrace,
      }
    }
  end
end

module EnsureHash
  def self.call(maybe_hash)
    return {} unless maybe_hash

    case maybe_hash
    when String
      maybe_hash.present? ? call(JSON.parse(maybe_hash)) : {}
    when Hash, ActionController::Parameters
      maybe_hash
    else
      raise ArgumentError, "Unexpected argument: #{maybe_hash}"
    end
  end
end

You write a query, POST to your GraphqlController. Schema executes your query. Schema dispatches your query to a write (mutation) or read (query). Find the right class to resolve your query.

Your Query
│
└──> GraphqlController
      └──> AppSchema
            ├── MutationType
            │   └── ramenUpdate
            └── QueryType
                └── RamenType
                    ├── id
                    ├── name
                    └── price

Files are organized under app/graphql in 3 folders.

RecrodLoader
class RecordLoader < GraphQL::Batch::Loader
  def initialize(model, column: model.primary_key, where: nil)
    @model = model
    @column = column.to_s
    @column_type = model.type_for_attribute(@column)
    @where = where
  end

  def load(key)
    super(@column_type.cast(key))
  end

  def perform(keys)
    query(keys).each { |record| fulfill(record.public_send(@column), record) }
    keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
  end

  private

  def query(keys)
    scope = @model
    scope = scope.where(@where) if @where
    scope.where(@column => keys)
  end
end
AssociationLoader
class AssociationLoader < GraphQL::Batch::Loader
  def self.validate(model, association_name)
    new(model, association_name)
    nil
  end

  def initialize(model, association_name)
    @model = model
    @association_name = association_name
    validate
  end

  def load(record)
    raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
    return Promise.resolve(read_association(record)) if association_loaded?(record)

    super
  end

  # We want to load the associations on all records, even if they have the same id
  def cache_key(record)
    record.object_id
  end

  def perform(records)
    preload_association(records)
    records.each { |record| fulfill(record, read_association(record)) }
  end

  private

  def validate
    return if @model.reflect_on_association(@association_name)

    raise ArgumentError, "No association #{@association_name} on #{@model}"
  end

  def preload_association(records)
    ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
  end

  def read_association(record)
    record.public_send(@association_name)
  end

  def association_loaded?(record)
    record.association(@association_name).loaded?
  end
end

ForeignKeyLoader
class ForeignKeyLoader < GraphQL::Batch::Loader
  attr_reader :model, :foreign_key, :scopes

  def self.loader_key_for(*group_args)
    # avoiding including the `scopes` lambda in loader key
    # each lambda is unique which defeats the purpose of
    # grouping queries together
    [self].concat(group_args.slice(0, 2))
  end

  def initialize(model, foreign_key, scopes: nil)
    @model = model
    @foreign_key = foreign_key
    @scopes = scopes
  end

  def perform(foreign_ids)
    # find all the records
    filtered = model.where(foreign_key => foreign_ids)
    filtered = filtered.merge(scopes) if scopes.present?
    records = filtered.to_a

    foreign_ids.each do |foreign_id|
      # find the records required to fulfill each promise
      matching_records = records.select do |r|
        foreign_id == r.send(foreign_key)
      end
      fulfill(foreign_id, matching_records)
    end
  end
end

RecordLoader to load a single record. AssociationLoader to load associated objects. ForeignKeyLoader to load objects through foreign keys.

def category
  # Bad: Book.find(object.book_id)
  RecordLoader.for(Category).load(object.book_id)
end

def labels
  # Bad N+1: Label.where(book_id: object.id)
  ForeignKeyLoader.for(::Label, :book_id).load(object.id)
end

def tags
  # Bad: object.tags
  AssociationLoader.new(::Book, :tags).load(object)
end

A contrived example to show you how to combine loaders and applied scopes:

scopes = -> { includes(:labels).includes(:sale_items) }
RecordLoader.new(::Category, column: :name).load(category_name).then do |category|
  ForeignKeyLoader.for(::Book, :category_id, scopes: scopes).load(category.id).then do |books|
    books
  end
end

All errors coming from GraphQL API under name space of CustomGraphQLError.

Example:

raise(
  CustomGraphQLError::ArgumentError,
  "Cannot find #{category_name} for books.",
)