# Gemfile
gem 'graphql'
# Installation
# rails generate graphql:install
# app/graphql/types/user_type.rb
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: false
field :bio, String, null: true
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
# Association
field :posts, [Types::PostType], null: false
# Computed field
field :posts_count, Integer, null: false do
description "Total number of posts by user"
end
def posts_count
object.posts.count
end
# Field with arguments
field :recent_posts, [Types::PostType], null: false do
argument :limit, Integer, required: false, default_value: 10
end
def recent_posts(limit:)
object.posts.order(created_at: :desc).limit(limit)
end
# Authorized field
field :private_data, String, null: true do
description "Visible only to user themselves"
end
def private_data
return nil unless context[:current_user]&.id == object.id
object.private_data
end
end
end
# app/graphql/types/post_type.rb
module Types
class PostType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :content, String, null: false
field :published, Boolean, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :user, Types::UserType, null: false
field :comments, [Types::CommentType], null: false
# Custom resolver
field :excerpt, String, null: false
def excerpt
object.content.truncate(100)
end
end
end
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
# Fetch single user
field :user, Types::UserType, null: true do
description "Find a user by ID"
argument :id, ID, required: true
end
def user(id:)
User.find(id)
end
# Fetch all users with filtering
field :users, [Types::UserType], null: false do
argument :role, String, required: false
argument :search, String, required: false
end
def users(role: nil, search: nil)
users = User.all
users = users.where(role: role) if role.present?
users = users.where("name ILIKE ?", "%#{search}%") if search.present?
users
end
# Current user
field :current_user, Types::UserType, null: true
def current_user
context[:current_user]
end
# Posts with pagination
field :posts, Types::PostType.connection_type, null: false do
argument :published_only, Boolean, required: false, default_value: false
end
def posts(published_only:)
posts = Post.all
posts = posts.where(published: true) if published_only
posts
end
end
end
# app/graphql/mutations/create_post.rb
module Mutations
class CreatePost < BaseMutation
description "Create a new post"
# Input arguments
argument :title, String, required: true
argument :content, String, required: true
argument :published, Boolean, required: false, default_value: false
# Return fields
field :post, Types::PostType, null: true
field :errors, [String], null: false
def resolve(title:, content:, published:)
user = context[:current_user]
unless user
return {
post: nil,
errors: ["Must be logged in"]
}
end
post = user.posts.build(
title: title,
content: content,
published: published
)
if post.save
{
post: post,
errors: []
}
else
{
post: nil,
errors: post.errors.full_messages
}
end
end
end
end
# app/graphql/mutations/update_post.rb
module Mutations
class UpdatePost < BaseMutation
argument :id, ID, required: true
argument :title, String, required: false
argument :content, String, required: false
argument :published, Boolean, required: false
field :post, Types::PostType, null: true
field :errors, [String], null: false
def resolve(id:, **attributes)
post = Post.find(id)
user = context[:current_user]
unless user&.id == post.user_id
return {
post: nil,
errors: ["Not authorized"]
}
end
if post.update(attributes.compact)
{ post: post, errors: [] }
else
{ post: nil, errors: post.errors.full_messages }
end
end
end
end
# app/graphql/types/mutation_type.rb
module Types
class MutationType < Types::BaseObject
field :create_post, mutation: Mutations::CreatePost
field :update_post, mutation: Mutations::UpdatePost
field :delete_post, mutation: Mutations::DeletePost
end
end
# GraphQL controller
class GraphqlController < ApplicationController
def execute
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user,
pundit_user: current_user
}
result = MyAppSchema.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 prepare_variables(variables_param)
case variables_param
when String
JSON.parse(variables_param) || {}
when Hash
variables_param
when ActionController::Parameters
variables_param.to_unsafe_hash
when nil
{}
else
raise ArgumentError, "Unexpected variables: #{variables_param}"
end
end
end
# Example GraphQL queries
# Query single user:
# {
# user(id: 1) {
# id
# name
# email
# postsCount
# posts {
# id
# title
# excerpt
# }
# }
# }
# Mutation to create post:
# mutation {
# createPost(input: {
# title: "My Post"
# content: "Post content here"
# published: true
# }) {
# post {
# id
# title
# }
# errors
# }
# }
# Gemfile
gem 'graphql-batch'
# Setup in schema
class MyAppSchema < GraphQL::Schema
use GraphQL::Batch
end
# app/graphql/loaders/record_loader.rb
class RecordLoader < GraphQL::Batch::Loader
def initialize(model)
@model = model
end
def perform(ids)
records = @model.where(id: ids).index_by(&:id)
ids.each { |id| fulfill(id, records[id]) }
end
end
# app/graphql/loaders/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
def initialize(model, association_name)
@model = model
@association_name = association_name
end
def perform(records)
# Preload association for all records at once
records_with_association = @model
.where(id: records.map(&:id))
.includes(@association_name)
.index_by(&:id)
records.each do |record|
record_with_association = records_with_association[record.id]
fulfill(record, record_with_association.public_send(@association_name))
end
end
end
# Using loaders in types
module Types
class UserType < Types::BaseObject
field :posts, [Types::PostType], null: false
def posts
AssociationLoader.for(User, :posts).load(object)
end
end
class PostType < Types::BaseObject
field :user, Types::UserType, null: false
def user
RecordLoader.for(User).load(object.user_id)
end
field :comments, [Types::CommentType], null: false
def comments
AssociationLoader.for(Post, :comments).load(object)
end
end
end
# This batches database queries:
# Without loader: N+1 queries
# posts.each { |post| post.user } # Queries for each post
# With loader: 2 queries total
# SELECT * FROM posts
# SELECT * FROM users WHERE id IN (1,2,3,4,5)
# Custom count loader
class CountLoader < GraphQL::Batch::Loader
def initialize(association_name, scope = nil)
@association_name = association_name
@scope = scope
end
def perform(records)
counts = records
.group_by(&:class)
.flat_map { |klass, records|
scope = klass.joins(@association_name)
.where(id: records.map(&:id))
.group("#{klass.table_name}.id")
scope = scope.merge(@scope) if @scope
scope.count
}
records.each do |record|
fulfill(record, counts[record.id] || 0)
end
end
end
# Usage
field :published_posts_count, Integer, null: false
def published_posts_count
CountLoader
.for(:posts, Post.where(published: true))
.load(object)
end
GraphQL enables clients to request exactly the data they need. The graphql-ruby gem implements GraphQL servers in Rails. Types define data structures—ObjectTypes for models, InputTypes for mutations. Queries fetch data; mutations modify data. Resolvers contain business logic for field resolution. I use GraphQL for flexible APIs—mobile apps request different fields than web. DataLoader batches and caches database queries, preventing N+1s. GraphQL introspection enables GraphiQL playground for testing. Subscriptions push real-time updates via ActionCable. Understanding GraphQL's query language and type system is essential. GraphQL reduces API versioning needs—add fields without breaking clients. Testing uses GraphQL execution with mocked contexts.