📢
Development

GraphQL The Rails Way: Part 2 - Writing standard and custom mutations

Arnaud Lachaume
Arnaud LachaumeLink to author's LinkedIn profile
Calendar
June 21, 2021
icon timer
7
min

In this episode you will learn how to create custom GraphQL mutations using graphql-ruby as well as reusable mutations to easily create, update and delete your API resources.

Illustration of GraphQL
Table of Content

TL;DR; Thinking GraphQL mutations are a bit too verbose to define? A bit of introspection can help define base mutations for common operations. Need to do something else? Writing your own custom mutations is simple, really.

In the last episode we covered how to dynamically define queryable resources using graphql-ruby, making it almost a one-liner to get fully functional API resources.

In today's episode we are going to talk about mutations and how to define them The Rails Way™.

Mutations you say? Yes. If you're familiar with REST, you know how CRUD operations are codified. One uses POST for create actions, PUT or PATCH for update actions and DELETE for destroy actions.

This HTTP verb mapping always makes developers wonder whether they should use POST, PUT or PATCH when actions fall outside of traditional CRUD operations. For example, let's consider a REST action which is "approve a transaction". Which verb should you use? Well it depends.

If you consider that the transaction is getting updated, you should use PUT or PATCH. Now if you consider that you create an approval, which in turns updates the transaction then you should use POST.

I dislike these dilemmas because each developer will have a different view on it. And as more developers work on your API well...inconsistencies will spawn across your REST actions.

With GraphQL there are no such questions. You keep hitting the /graphql endpoint with POST requests!

A mutation is simple: it's an operation which leads to a write, somewhere. It can be anything, such as:

  • Create, update or delete a record
  • Approve a transaction
  • Rollback a record
  • Order a pizza
  • Bulk update a series of records
  • Post an image on Imgur
  • Etc.

So let's see how to define mutations with graphql-ruby and - more importantly - how to make them reusable ;)

In this episode I will be reusing the Book(name, page_size, user_id) and User(name, email) models we defined in the previous episode

Defining mutations

All mutations must be registered in the Types::MutationType file, the same way queryable resources must be declared in the Types::QueryType file.

Mutations can be declared in block form inside the Types::MutationType file but that's kind of messy. We'll use a proper mutation class to define our mutation.

First, let's register our mutation. The class doesn't exist yet but we'll get it soon.

# app/graphql/types/mutation_type.rb
# frozen_string_literal: true
module Types
class MutationType < Types::BaseObject
# Map the name of the mutation to the class handling the
# actual mutation logic.
field :update_book, mutation: Mutations::UpdateBook
end
end

Nothing complicate here. It's fairly straightforward.

Now to write a mutation, we need to define four aspects:

  1. The required (e.g. ID) and accepted arguments (e.g. update attributes)
  2. The return field, similar to a queryable resource
  3. The authorization logic - Is the user allowed to perform the action
  4. The actual mutation logic and return value

That sounds quite reasonable, you would expect this sequence from any controller action. So let's see what the mutation looks like.

# app/graphql/mutations/update_book.rb
# frozen_string_literal: true
module Mutations
# Update attributes on a book
class UpdateBook < BaseMutation
# Require an ID to be provided
argument :id, ID, required: true
# Allow the following fields to be updated. Each is optional.
argument :pages, Integer, required: false
argument :name, String, required: false
# Return fields. A standard approach is to return the mutation
# state (success & errors) as well as the updated object.
field :success, Boolean, null: false
field :errors, [String], null: false
field :book, Types::BookType, null: true
# Check if the user is authorized to perform the action
#
# Considering we just delegate to the parent method we could just
# remove this method.
#
# In a real-world scenario you would invoke a policy to check if
# the current user is allowed to update the book.
def authorized?(**args)
super
# E.g. with Pundit
# super &&
# BookPolicy.new(context[:current_user], Record.find_by(id: args[:id])).update?
end
# Mutation logic and return value. The model id is extracted
# to find the book.
#
# The rest of the keyword arguments are directly
# passed to the update method. Because GraphQL is strongly typed we
# know the rest of the arguments are safe to pass directly to the
# update method (= in the list of accepted arguments)
def resolve(id:, **args)
record = Book.find(id)
if record.update(args)
{ success: true, book: record, errors: [] }
else
{ success: false, book: nil, errors: record.errors.full_messages }
end
rescue ActiveRecord::RecordNotFound
return { success: false, book: nil, errors: ['record-not-found'] }
end
end
end

That's all you need. Now let's try to update a book using GraphiQL. You'll note that, once again, our mutation is properly documented on the right side :)

Update book page size

Now let's try to update a book that doesn't exist:

Update non-existing book

Finally let's add some validation on the pages attribute of our ActiveRecord model:

# app/models/book.rb
# frozen_string_literal: true
class Book < ApplicationRecord
belongs_to :user
# Ensure pages is a positive integer
validates :pages, presence: true, numericality: { only_integer: true, greater_than: 0 }
# Always eager load the associated user when books
# get queried on the API.
scope :graphql_scope, -> { eager_load(:user) }
end

Now let's try to update our book with a negative page size:

Invalid book update

As expected, the update is rejected and we get a proper message describing the error.

All in all I find that defining mutations is quite an easy process, much more than defining queryable resources. This is mainly due to their atomic nature, where each mutation does only one thing. On the other side queryable resources have a greater complexity due to the many functionalities we expect from them (pagination, filtering, sorting, embedded resources etc.)

Now as you can guess, we can simplify the definition of mutations for CUD operations with some metaprogramming. The gain is not going to be as obvious as with queryable resources, but it's still there - especially because we'll standardize the way they are defined and returned.

As part of this simplification process, we'll also improve error formatting. Returning error messages is a bit rough. Having a message, a code and a path (= which input triggered the error) would be much nicer.

Let's get to work!

Generated mutations

In order to standardize our mutations we'll need to define three things:

  1. A standard type for mutation errors
  2. A standard return type for our mutations
  3. Base classes for CUD operations (Create / Update / Delete)

The mutation error type

If you've read our previous episode you should be comfortable with types already.

The mutation error type is defined as follow:

# app/graphql/types/mutation_error_type.rb
# frozen_string_literal: true
module Types
class MutationErrorType < BaseObject
description 'An error related to a record operation'
field :code, String, null: false, description: 'A code for the error'
field :message, String, null: false, description: 'A description of the error'
field :path, [String], null: true, description: 'Which input value this error came from'
end
end

We're done here. Let's use that new type in our standard return type for mutations.

Standard type for mutations

All mutations should inherit from Mutations::BaseMutation.

You can then define child base classes for each mutation flavor (e.g. create/update/delete). But all should be inheriting from Mutations::BaseMutation - this way all your mutations will be consistent.

So what do we want to enforce in all our mutations? The following concepts:

  • A success field returning whether the operation was successful or not
  • An errors array returning details about what failed during the operation
  • Some default authorization logic
  • A helper to format ActiveRecord errors into Types::MutationErrorType

With the above in mind, here is what our Mutations::BaseMutation class looks like:

# app/graphql/mutations/base_mutation.rb
# # frozen_string_literal: true
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
#========================================================
# This was automatically generated by the graphql-ruby
# generator.
#========================================================
# Changing this requires some fairly advanced knowledge
# of graphql-ruby but in case you want to read more about
# it, here is some good starting documentation:
# https://graphql-ruby.org/type_definitions/extensions.html
argument_class Types::BaseArgument
field_class Types::BaseField
input_object_class Types::BaseInputObject
object_class Types::BaseObject
#========================================================
# Added: Enforce presence of success and errors fields
#========================================================
field :success, Boolean, null: false, description: 'Flag indicating if the mutation was performed'
field :errors, [Types::MutationErrorType], null: false, description: 'List of errors (if any) that occurred during the mutation'
#========================================================
# Added: Default authorization logic
#========================================================
def authorized?(**args)
super
# You may want to at least enforce that a user is logged in
#
# Note that we use a custom error when the user is not authenticated instead
# of - for instance - Pundit::NotAuthorizedError.
#
# It is usually a good idea to return 401 (unauthorized) when the user is
# not authenticated and 403 (forbidden) when the user is not authorized.
# This helps consumers (e.g. your JS frontend) decide whether it should
# display an error or trigger a relogin for the user.
#
# super &&
# (
# context[:current_user] ||
# raise(Errors::NotAuthenticated)
# )
end
#========================================================
# Added: Error formatting helpers
#========================================================
# Format ActiveRecord errors into a [Types::MutationErrorType] array
def format_errors(record)
unless record
# Types::MutationErrorType object
return [{
path: ['record'],
message: 'record was not found',
code: :not_found
}]
end
# Generate a list of errors with attribute, code and message
# E.g.
# [
# { attribute: 'pages', code: 'pages-blank', message: "pages can't be blank"},
# { attribute: 'pages', code: 'pages-not-a-number', message: 'pages is not a number' }
# ]
error_list = record.errors.keys.map do |a|
[record.errors.details[a].map { |e| e[:error] }, record.errors[a]]
.transpose
.map do |e|
{
attribute: a.to_s.camelize(:lower),
code: format_error_code(a, e[0]),
message: [a.to_s, e[1]].join(' ')
}
end
end.flatten
# Generate error objects
error_list.map do |error|
# This is the GraphQL argument which corresponds to the validation error:
path = ['attributes', error[:attribute]]
# Types::MutationErrorType object
{
path: path,
message: error[:message],
code: error[:code]
}
end
end
#
# Generate an error code from a rails validation errors
#
# @param [String] attribute The errored attribute
#
# @param [Hash] error The error object
#
# @return [String] The generated code
#
def format_error_code(attribute, error)
[attribute, error].join(' ').dasherize.parameterize
end
end
end

Most of the complicated code is related to error formatting, which requires a bit of array hula hoop. That code aside, we're really just enforcing standard return fields (success and errors).

Let's tackle our CUD operations now.

Standard create mutation and how to use it

The create mutation is going to handle the following aspects:

  • Authorization - is your user allowed to perform this object creation?
  • Creation logic
  • Expand the Mutations::BaseMutation return fields (success, errors) with the model field (e.g. book or user) to provide the created object.

What it is NOT going to handle is:

  • Define which fields are required/accepted for the creation of the resource. This is something we will define in the resource-specific mutation sub-class.

The use of Pundit (or similar framework) is optional but strongly recommended. Look at the authorized? method and adapt based on your needs.

The base create mutation looks like this:

# app/graphql/mutations/base_create_mutation.rb
# frozen_string_literal: true
module Mutations
# A generic Mutation used to handle record creation. Creation mutations
# can inherit this class.
class BaseCreateMutation < BaseMutation
null true
#---------------------------------------
# Class Methods
#---------------------------------------
# Setter used on classes to define the return field and optionally its type
def self.mutation_field(field_name = nil, type_name: nil)
@record_field_name = field_name&.to_sym
@entity_type_name = type_name
field(record_field_name, entity_type, null: true)
end
# Name of the field used in return values
def self.record_field_name
@record_field_name ||= entity_klass_name.to_s.underscore.to_sym
end
# Return the entity class name
def self.entity_klass_name
@entity_klass_name ||= to_s.demodulize.gsub('Create', '')
end
# Return the entity type used in the return value
def self.entity_type_name
@entity_type_name ||= entity_klass_name
end
# Return the GraphQL class type
def self.entity_type
@entity_type ||= "Types::#{entity_type_name}Type".constantize
end
# Return the underlying active record model class
# Can be overridden in the child mutation if the entity name is
# different from the model name.
def self.model_klass
@model_klass ||= entity_klass_name.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
# Check user authorization through Pundit (if defined)
def authorized?(**args)
super &&
(
!defined?(Pundit) ||
(
Pundit.policy(current_user, self.class.model_klass.new(create_args(args))).create? ||
raise(Pundit::NotAuthorizedError)
)
)
end
# After create hook. Called upon successful save of the record.
#
# @param record [Any] A mutation record.
#
def after_create(_record)
true
end
#
# The attributes to use to create the model. May be overridden
# by child classes to pass a reference to the current user (for example).
#
# @param [Hash<String,Any>] **args The arguments.
#
# @return [Hash<String,Any>] The model create arguments.
#
def create_args(**args)
args
end
# Create the new record
def resolve(**args)
record = self.class.model_klass.new(create_args(args))
if record.save
# Invoke overridable hook
after_create(record)
# Mutation status
{ success: true, self.class.record_field_name => record, errors: [] }
else
{ success: false, self.class.record_field_name => nil, errors: format_errors(record) }
end
rescue ActiveRecord::RecordNotUnique
# Handle specific case where attribute uniqueness is handled at database level instead
# of being handled in the model. This is often the case when you need to leverage
# create_or_find.
record_not_unique_error
end
# Format ActiveRecord errors into a [Types::MutationErrorType] array
# Specific implimentation for unique key violations
def record_not_unique_error
{
success: false,
errors: [
{
code: 'record-not-unique',
path: [self.class.name.demodulize.camelize(:lower)],
message: 'record not unique'
}
],
self.class.record_field_name => nil
}
end
end
end

It looks like a lot of code but it doesn't do that much. Think about our previous updateBook mutation - the rest is just metaprogramming overhead.

Now let's implement the actual create mutation for our book model.

# app/graphql/mutations/create_book.rb
# frozen_string_literal: true
module Mutations
# Mutation used to create books
class CreateBook < BaseCreateMutation
mutation_field
argument :name, String, required: true, description: 'The name of the book.'
argument :pages, Integer, required: true, description: 'The number of pages in the books.'
argument :user_id, ID, required: true, description: 'The ID of the user owning the book.'
end
end

And register our create mutation:

# app/graphql/types/mutation_type.rb
# frozen_string_literal: true
module Types
class MutationType < Types::BaseObject
# Book Resource
field :create_book, mutation: Mutations::CreateBook
end
end

You can now create books via GraphiQL:

Book creation

If validation happens to fail, you'll get nicely structured errors:

Book creation error

The Mutations::BaseCreateMutation comes with a few additional goodies. Let's say you need to set the book ownership to the current user and perform some post-create actions.

You could rework your book mutation the following way. Of course it assumes that the GraphQL context has a current_user attribute defined. You should read our previous episode to know more about this.

# app/graphql/mutations/create_book.rb
# frozen_string_literal: true
module Mutations
# Mutation used to create books
class CreateBook < BaseCreateMutation
mutation_field
argument :name, String, required: true, description: 'The name of the book.'
argument :pages, Integer, required: true, description: 'The number of pages in the books.'
# No need to specify this argument - it is inferred from context
# argument :user_id, ID, required: true, description: 'The ID of the user owning the book.'
#
# This method is used in BaseCreateMutation to get the
# actual creation attributes.
#
# It can be overridden to inject contextual attributes.
#
def create_args(**args)
args.merge(user_id: current_user&.id)
end
#
# This hook is invoked upon successful creation
# of the record
#
def after_create(record)
logger.info("Book #{record.name} was just created!")
end
end
end

The base update and delete mutations will be very similar. Let's go over them briefly.

Standard update mutation and how to use it

The base update mutation is a bit more complex than the base create mutation because:

  1. It needs to find the record. By default we assume it is by ID.
  2. To find the record, we leverage Pundit scopes (if defined) to check if the record is actually visible to the user.
  3. Once found, we assign the attributes to the record to check whether the user is allowed to perform the actual update
  4. We perform the update inside a transaction

The base update mutation looks like this:

# app/graphql/mutations/base_update_mutation.rb
# frozen_string_literal: true
module Mutations
# A generic Mutation used to handle record update
class BaseUpdateMutation < BaseMutation
null true
#---------------------------------------
# Class Methods
#---------------------------------------
# Setter used on classes to define the return field and optionally its type
def self.mutation_field(field_name = nil, type_name: nil)
@record_field_name = field_name&.to_sym
@entity_type_name = type_name
field(record_field_name, entity_type, null: true)
end
# Name of the field used in return values
def self.record_field_name
@record_field_name ||= entity_klass_name.to_s.underscore.to_sym
end
# Return the entity class name
def self.entity_klass_name
@entity_klass_name ||= to_s.demodulize.gsub('Update', '')
end
# Return the entity type used in the return value
def self.entity_type_name
@entity_type_name ||= entity_klass_name
end
# Return the GraphQL class type
def self.entity_type
@entity_type ||= "Types::#{entity_type_name}Type".constantize
end
# Return the underlying active record model class
# Can be overridden in the child mutation if the entity name is
# different from the model name.
def self.model_klass
@model_klass ||= entity_klass_name.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
# Return the instantiated resource scope via Pundit
def pundit_scope
if defined?(Pundit)
self.class.pundit_scope_klass.new(current_user, self.class.model_klass).resolve
else
self.class.model_klass
end
end
# The method used to lookup the record
def find_record(id:, **_args)
pundit_scope.find_by(id: id)
end
#
# Check user authorization through Pundit.
#
# Policy check:
# - The original record goes through policies as it is fetched via Pundit scope
# - The *modified* record goes through policies via the `action?` rule
#
# Null record:
# If the record is not found, the action is allowed to proceed so as to let the
# resolve method format the API errors.
#
#
def authorized?(**args)
# Retrieve record via user-specific scope
record = find_record(**args)
return super if record.nil?
# Get modified version of the record before action policy is checked
record.assign_attributes(args)
# Check policy
super &&
(
!defined?(Pundit) ||
(
Pundit.policy(current_user, record).update? ||
raise(Pundit::NotAuthorizedError)
)
)
end
#
# After update hook. Called upon successful save of the record.
#
# @param record [Any] A mutation record.
#
def after_update(_record)
true
end
# Update the new record
def resolve(**args)
ActiveRecord::Base.transaction do
# Get locked record
record = find_record(**args)&.lock!
# Update
if record&.update(args)
# Invoke post-update hook
after_update(record)
# Return result
{ success: true, self.class.record_field_name => record, errors: [] }
else
{ success: false, self.class.record_field_name => nil, errors: format_errors(record) }
end
end
end
end
end

And our book update mutation can now be reworked like this:

# app/graphql/mutations/update_book.rb
# frozen_string_literal: true
module Mutations
# Update attributes on a book
class UpdateBook < BaseUpdateMutation
mutation_field
# Require an ID to be provided
argument :id, ID, required: true
# Allow the following fields to be updated. Each is optional.
argument :pages, Integer, required: false
argument :name, String, required: false
#
# The lookup method can be overridden
# Make sure the field you use for record lookup is defined
# as a required argument in lieu of :id.
#
# def find_record(some_other_field:, **_args)
# pundit_scope.find_by(some_other_field: some_other_field)
# end
#
# There is also a hook invoked after successful update
#
# def after_update(record)
# ... do something after update ...
# end
end
end

Do not forget to register your book update mutation:

# app/graphql/types/mutation_type.rb
# frozen_string_literal: true
module Types
class MutationType < Types::BaseObject
# Book Resource
field :create_book, mutation: Mutations::CreateBook
field :update_book, mutation: Mutations::UpdateBook
end
end

We can now update our book via GraphiQL again. The only difference with our previous approach is that the errors field is now an array of objects.

Book update using new mutation

Standard delete mutation and how to use it

The base delete mutation is very similar to the base update one. The only difference is:

  • Only one argument is required on the child class (usually the id)
  • The destroy method can be customized

The base delete mutation looks like this:

# app/graphql/mutations/base_delete_mutation.rb
# frozen_string_literal: true
module Mutations
# A generic Mutation used to handle record deletion. Deletion mutations
# can inherit this class.
class BaseDeleteMutation < BaseMutation
null true
# The default method to use on record to destroy them
DEFAULT_DESTROY_METHOD = :destroy
#---------------------------------------
# Class Methods
#---------------------------------------
# Setter used on classes to define the return field and optionally its type
def self.mutation_field(field_name = nil, type_name: nil)
@record_field_name = field_name&.to_sym
@entity_type_name = type_name
field(record_field_name, entity_type, null: true)
end
# Name of the field used in return values
def self.record_field_name
@record_field_name ||= entity_klass_name.to_s.underscore.to_sym
end
# Return the entity class name
def self.entity_klass_name
@entity_klass_name ||= to_s.demodulize.gsub('Delete', '')
end
# Return the entity type used in the return value
def self.entity_type_name
@entity_type_name ||= entity_klass_name
end
# Return the GraphQL class type
def self.entity_type
@entity_type ||= "Types::#{entity_type_name}Type".constantize
end
# Return the underlying active record model class
# Can be overridden in the child mutation if the entity name is
# different from the model name.
def self.model_klass
@model_klass ||= entity_klass_name.constantize
end
# Return the model Pundit Policy class
def self.pundit_scope_klass
@pundit_scope_klass ||= "#{model_klass}Policy::Scope".constantize
end
#
# Set the destroy method
#
# @param [String, Symbol] meth The destroy method to use on the record
#
# rubocop:disable Style/TrivialAccessors
def self.use_destroy_method(meth)
@use_destroy_method = meth
end
# rubocop:enable Style/TrivialAccessors
#
# Return the destroy method to use on the record
#
# @return [String, Symbo] The destroy method
#
def self.destroy_method
@use_destroy_method.presence || DEFAULT_DESTROY_METHOD
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
# 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
if defined?(Pundit)
self.class.pundit_scope_klass.new(current_user, self.class.model_klass).resolve
else
self.class.model_klass
end
end
# The method used to lookup the record
def find_record(id:, **_args)
pundit_scope.find_by(id: id)
end
# Check user authorization through Pundit
def authorized?(**args)
# Retrieve record via user-specific scope
record = find_record(**args)
return super if record.nil?
# Check policy
super &&
(
!defined?(Pundit) ||
(
Pundit.policy(current_user, record).destroy? ||
raise(Pundit::NotAuthorizedError)
)
)
end
# After delete hook. Called upon successful deletion of the record.
#
# @param record [Any] A mutation record.
def after_delete(_record)
true
end
# Delete record
def resolve(**args)
ActiveRecord::Base.transaction do
record = find_record(**args)&.lock!
if record&.send(self.class.destroy_method)
# Invoke post-delete hook
after_delete(record)
# Return result
{ success: true, self.class.record_field_name => record, errors: [] }
else
{ success: false, self.class.record_field_name => nil, errors: format_errors(record) }
end
end
end
end
end

And the actual delete mutation looks like this:

# app/graphql/mutations/delete_book.rb
# frozen_string_literal: true
module Mutations
# Delete a book
class DeleteBook < BaseDeleteMutation
mutation_field
# You can specify a different destroy method
# Default is: :destroy
# use_destroy_method :burn_to_ashes
# Require an ID to be provided
argument :id, ID, required: true
#
# The lookup method can be overridden
# Make sure the field you use for record lookup is defined
# as a required argument in lieu of :id.
#
# def find_record(some_other_field:, **_args)
# pundit_scope.find_by(some_other_field: some_other_field)
# end
#
# There is also a hook invoked after successful destroy
#
# def after_delete(record)
# ... do something after destroy ...
# end
end
end

The mutation type now has all the CUD operations defined:

# app/graphql/types/mutation_type.rb
# frozen_string_literal: true
module Types
class MutationType < Types::BaseObject
# Book Resource
field :create_book, mutation: Mutations::CreateBook
field :update_book, mutation: Mutations::UpdateBook
field :delete_book, mutation: Mutations::DeleteBook
end
end

Now let's destroy a book via GraphiQL:

Book deletion

🎉 Tada! 🎉

What if I want to do a custom mutation?

Remember that mutations can be any mutating action.

CUD operations will be covered by the standard mutations we have written but in case you need to do something different, just go back to Mutations::BaseMutation - don't try to bend our standard CUD mutations.

This base mutation simply defines the response style of your mutations. It is responsible for making your API consistent. In the end all you need to do is:

  1. Define arguments
  2. Define the main field (e.g. book), which is returned on top of success and errors.
  3. Define the authorized? method
  4. Define the resolve method

Let's create a "copy book" mutation for instance. This mutation doesn't really fit within the scope of our standard mutations so we'll write it from scratch.

# app/graphql/mutations/copy_book.rb
# frozen_string_literal: true
module Mutations
# Mutation used to copy a book
class CopyBook < BaseMutation
# Argument
argument :id, ID, required: true, description: 'The ID of the book to copy'
# Returned field. This field will be returned ON TOP of the success and errors
# fields
field :book, Types::BookType, null: true, description: 'The copied book'
# Is the user authorized to copy the book
def authorized?(id:, **_args)
@record = Book.find_by(id: id)
# Check policy
super && (
!defined?(Pundit) ||
BookPolicy.new(current_user, @record).copy?
)
end
# Copy the book
def resolve(**_args)
dup_record = @record&.dup
if dup_record&.save
{ success: true, book: dup_record, errors: [] }
else
{ success: false, book: nil, errors: format_errors(dup_record) }
end
end
end
end
view raw 16_copy_book.rb hosted with ❤ by GitHub

Once again, do not forget to update your mutation type:

# app/graphql/types/mutation_type.rb
# frozen_string_literal: true
module Types
class MutationType < Types::BaseObject
# Book Resource
field :create_book, mutation: Mutations::CreateBook
field :update_book, mutation: Mutations::UpdateBook
field :delete_book, mutation: Mutations::DeleteBook
field :copy_book, mutation: Mutations::CopyBook
end
end

And now copy your book!

Book copy

Sign-up and accelerate your engineering organization today !

Wrapping up

I have come to really like GraphQL mutations because I find them simple and consistent. One defines the input, output, authorization and business logic and you're done. There is no real magic - it's very readable.

The metaprogramming we presented above does not do much in the end, but it does save you a lot of boilerplate code when you need to expose a lot of resources - especially if you are working on an existing project.

The key takeaway here? Standard CUD mutations are nice but if you need to do anything specific, just go back to using Mutations::BaseMutation. Bending your standard mutations to fit exotic use cases will just make your code difficult to read.

Mutations are simple and atomic, keep them that way.

About us

Keypup's SaaS solution allows engineering teams and all software development stakeholders to gain a better understanding of their engineering efforts by combining real-time insights from their development and project management platforms. The solution integrates multiple data sources into a unified database along with a user-friendly dashboard and insights builder interface. Keypup users can customize tried-and-true templates or create their own reports, insights and dashboards to get a full picture of their development operations at a glance, tailored to their specific needs.

---

Code snippets hosted with ❤ by GitHub