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.
-
Types
app/graphql/types
Maps the Model.
app/graphql/types/query_type.rb
is the entry to defines where to find mutations and resolvers. -
Mutation
app/graphql/mutations
Defines how to change data.
-
Resolver
app/graphql/resolvers
Defines how to fetch data.
-
graphql
(the rubygem) -
graphiql-rails
(for interactive webpage) - graphql-batch (DataLoader pattern to avoid N+1)
- batch-loader (Batch loading to avoid N+1)
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.",
)
-
Use
camelCase
name forfield
s -
Order
field
alphabetically -
Put all
field
s of custom type on topmodule Types class BookType < Types::BaseObject field :tags, [TagType], null: true, cache: { expiry: 3600 } field :categoryName, String, null: false end end
-
Reference
app/models/*.rb
by prefixing::
, e.g.,::Book
instead ofBook
. -
Avoid proc literals See More Familiarity
-
Use explicit required instead of
!
# Good argument :id, ID, required: true # Bad argument :id, ID!
-
The query must use
""
for string// Good books(categoryName: "tech") { } // Error! books(categoryName: 'tech') { }
- The author of GraphQL Ruby: Robert Mosolgo
- GitLab’s GraphQL API guide