Eliminating Resolvers in GraphQL Ruby
In this follow-up to my post from last month about Converting GraphQL Ruby Resolvers to the Class-based API I’m going to show how I took the advice of the GraphQL gem’s documentation on Resolvers and started replacing the GraphQL-specific Resolver classes with plain old Ruby classes to facilitate easier testing and code reuse.
The current documentation for the GraphQL::Schema::Resolver
class essentially recommends that it not be used, except for cases with specific requirements as detailed in the documentation.
Do you really need a Resolver? Putting logic in a Resolver has some downsides:
Since it’s coupled to GraphQL, it’s harder to test than a plain ol’ Ruby object in your app Since the base class comes from GraphQL-Ruby, it’s subject to upstream changes which may require updates in your code
Here are a few alternatives to consider:
- Put display logic (sorting, filtering, etc.) into a plain ol’ Ruby class in your app, and test that class
- Hook up that object with a method
I found that I was indeed having trouble testing my Resolvers that inherited from GraphQL::Schema::Resolver
due to the GraphQL-specific overhead and context that they contained. Fortunately, it turned out to be a pretty simple process to convert a Resolver class to a plain Ruby class and test it with RSpec.
This was my starting point:
# app/graphql/resolvers/instructor_names.rb
module Resolvers
# Return collections of instructor names based on query arguments
class InstructorNames < Resolvers::Base
type [String], null: false
argument :semester, Inputs::SemesterInput, required: true
argument :past_years, Integer, 'Include instructors for this number of past years', required: false
def resolve(semester:, past_years: 0)
term_year_range = determine_term_year_range(semester, past_years)
CourseInstructor
.where(term_year: term_year_range)
.group(:first_name, :last_name)
.pluck(:first_name, :last_name)
.map { |name| name.join(' ') }
end
private
def determine_term_year_range(semester, past_years)
term_year_max = semester[:term_year]
term_year_min = term_year_max - past_years
term_year_min..term_year_max
end
end
end
I started the conversion process by rewriting my Resolvers::InstructorNames
class to be a plain Ruby object:
# app/graphql/resolvers/instructor_names.rb
module Resolvers
class InstructorNames
def self.run(semester:, past_years:)
term_year_range = determine_term_year_range(semester, past_years)
CourseInstructor
.where(term_year: term_year_range)
.group(:first_name, :last_name)
.pluck(:first_name, :last_name)
.map { |name| name.join(' ') }
end
def self.determine_term_year_range(semester, past_years)
term_year_max = semester[:term_year]
term_year_min = term_year_max - past_years
term_year_min..term_year_max
end
end
end
The removal of all GraphQL-specific code made this an easy class to test with RSpec:
# spec/graphql/resolvers/instructor_names_spec.rb
require 'rails_helper'
module Resolvers
RSpec.describe InstructorNames do
let!(:instructors_2018) { create_pair(:course_instructor, term_year: semester_2018[:term_year]) }
let!(:instructors_2019) { create_pair(:course_instructor, term_year: semester_2019[:term_year]) }
let(:outcome) { described_class.run(inputs) }
let(:semester_2018) { { term_year: 2018 } }
let(:semester_2019) { { term_year: 2019 } }
context 'with a single year' do
let(:inputs) { { semester: semester_2019, past_years: 0 } }
it 'returns the expected list of instructor names' do
expect(outcome).to match_array(instructors_2019.map(&:full_name))
end
end
context 'with multiple years' do
let(:inputs) { { semester: semester_2019, past_years: 1 } }
let(:instructors) { instructors_2018 + instructors_2019 }
it 'returns the expected list of instructor names' do
expect(outcome).to match_array(instructors.map(&:full_name))
end
end
end
end
Finally, I updated my query type to hook up the GraphQL field with the return value of the new plain InstructorNames
class:
Old QueryType
:
# app/graphql/types/query_type.rb
class Types::QueryType < Types::BaseObject
description 'Queries'
field :instructor_names,
description: 'Returns a collection of instructor names for a given range of years',
resolver: Resolvers::InstructorNames
end
New QueryType
:
# app/graphql/types/query_type.rb
module Types
class Query < Types::BaseObject
description 'Queries'
field :instructor_names, [String], null: false, description: 'Returns a collection of instructor names for a given range of years' do
argument :semester, Types::Inputs::Semester, required: true
argument :past_years, Integer, 'Include instructors for this number of past years', required: false
end
def instructor_names(semester:, past_years: 0)
Resolvers::InstructorNames.run(semester: semester, past_years: past_years)
end
end
Note that the instructor_names
method matches the instructor_names
field definition, and is responsible for providing the value returned by that field. The argument and field type definitions have been moved out of the Resolver (because it no longer contains anything specific to GraphQL) and into the field definition.
I considered moving my updated “Resolver” logic out of the app/graphql/
hierarchy entirely, and that might have made more sense if I anticipated wanting to reuse that code elsewhere in my application. But since this particular Rails application is running in API mode and really only exists to serve the GraphQL API, I decided to leave it in place and maintain the naming convention while removing the actual GraphQL inheritance. For a larger application it might make sense to move these files into a directory under lib/
.
Comments