See all articles
GraphQL Part 4 - API with Ruby on Rails

GraphQL Part 4 - API with Ruby on Rails

It’s time to get started with Part 4 of our GraphQL series. This time we will build an API with Ruby on Rails. This will provide the same features and functionality as an API built with Node.js, as we covered in part 2.

Setting up our data

Let’s start with a blank `api` project:

rails new -d sqlite3 --api talent-macher-api

Then we’ll add some initial models and dummy data. We will create the same models and relationships we had in our Node.js version for continuity.

$ bundle rails g model Candidate fullname
...
$ bundle rails g model Project name
...
$ bundle exec rails g model Skill name experience:integer
...
$ bundle exec rails g model CandidatesSkill candidate:references skill:references
...
$ bundle exec rails g model ProjectsSkill project:references skill:references
...
$ bundle exec rails g migration create_candidates_projects

We should also add unique indexes to the `candidates_skills` and `projects_skills` tables to ensure we won’t assign the same skill to a project or candidate more than once:

# db/migrate/2018...create_dandidates_skills.rb
class CreateCandidatesSkills < ActiveRecord::Migration[5.1]
  def change
    ...
    add_index :candidates_skills, %i[candidate_id skill_id], unique: true
  end
end
# db/migrate/2018...create_projects_skills.rb
class CreateProjectsSkills < ActiveRecord::Migration[5.1]
  def change
    ...
    add_index :projects_skills, %i[project_id skill_id], unique: true
  end
end

We also need to create a view to fetch the best candidates based on the project’s skill requirements (we will use the same code as in part 2):

# db/migrate/2018...create_candidates_projects.rb
class CreateCandidatesProjects < ActiveRecord::Migration[5.1]
  def up
    execute <<-SQL
      CREATE VIEW candidates_projects AS
      SELECT candidates_skills.candidate_id,
             projects_skills.project_id,
             COUNT(*) AS matched_skills_no,
             GROUP_CONCAT(skills.name) AS matched_skills,
             SUM(candidates_skills.experience) AS experience
      FROM candidates_skills
      INNER JOIN projects_skills
      ON candidates_skills.skill_id = projects_skills.skill_id
      INNER JOIN skills
      ON candidates_skills.skill_id = skills.id
      GROUP BY candidate_id, project_id
    SQL
  end
  def down
    execute 'DROP view candidates_projects;'
  end
end

Now let’s open each of those models and create the associations between them:

# app/models/candidate.rb
class Candidate < ApplicationRecord
  has_many :candidates_skills, inverse_of: :candidate, dependent: :destroy
  has_many :skills, through: :candidates_skills
  has_many :candidates_projects, inverse_of: :candidate, dependent: :destroy
  has_many :projects, through: :candidates_projects
end
# app/models/candidates_project.rb
class CandidatesProject < ApplicationRecord
  belongs_to :candidate, inverse_of: :candidates_projects
  belongs_to :project, inverse_of: :candidates_projects
end
# app/models/candidates_skill.rb
class CandidatesSkill < ApplicationRecord
  belongs_to :candidate, inverse_of: :candidates_skills
  belongs_to :skill, inverse_of: :candidates_skills
end
# app/models/project.rb
class Project < ApplicationRecord
  has_many :projects_skills, inverse_of: :project, dependent: :destroy
  has_many :skills, through: :projects_skills
end
# app/models/projects_skill.rb
class ProjectsSkill < ApplicationRecord
  belongs_to :project, inverse_of: :projects_skills
  belongs_to :skill, inverse_of: :projects_skills
end
# app/models/skill.rb
class Skill < ApplicationRecord
  has_many :candidates_skills, inverse_of: :skill, dependent: :destroy
  has_many :candidates, through: :candidates_skills
  has_many :projects_skills, inverse_of: :skill, dependent: :destroy
  has_many :projects, through: :projects_skills
end

So far we have declared our models with all the necessary relationships. Now we can create some seeds so that we will be able to test our data to ensure our database setup is correctly configured.

Since we will have seeds for multiple models let’s store them in separate files:

$ tree db/seeds/
db/seeds/
├── 01_skills.rb
├── 02_projects.rb
└── 03_candidates.rb
0 directories, 3 files

Then we can simply include those files in the `db/seeds.rb` file:

Dir[Rails.root.join('db', 'seeds', '**', '*.rb')].sort.each do |f|
  require f
end

We are doing `sort` to ensure the files are included alphabetically, as they are order dependent.

Before we proceed, let’s add ffaker gem to the `Gemfile` to help us with generating some data in the seed files.

Now we can write our dummy data:

# db/seeds/01_skills.rb
skill_names = ['Ruby', 'Ruby on Rails', 'Node.js',
               'Elixir', 'Phoenix', 'React', 'Vue.js', 'Ember']
skill_names.each do |skill_name|
  Skill.find_or_create_by(name: skill_name)
end
# db/seeds/02_projects.rb
skills = Skill.all
5.times do
  project = Project.create(name: FFaker::Product.product)
  skills.sample(5).each do |skill|
    project.skills << skill
  end
end
# db/seeds/03_candidates.rb
skills = Skill.all
10.times do
  candidate = Candidate.create(fullname: FFaker::Name.name)
  skills.sample(5).each do |skill|
    candidate.candidates_skills.create(skill: skill, experience: rand(5))
  end
end

Finally we are ready to setup our database (create it, migrate it and seed it with pre-defined data):

bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake db:seed

Setup a GraphQL API endpoint

Since our models are ready, we can focus on building a GraphQL endpoint.

We will use a couple of additional gems:

Let’s add all those libraries to our Gemfile and run `bundle install`. After that we can setup our GraphQL API:

bundle exec rails generate graphql:install

We need to modify our `routes.rb` file to mount GraphiQL:

# config/routes.rb
Rails.application.routes.draw do
  post '/graphql', to: 'graphql#execute'
  if Rails.env.development?
    mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql'
    root to: redirect('/graphiql')
  end
end

Since our Rails app is in the API mode, we need to uncomment `require "sprockets/railtie"` in the `config/application.rb` so GraphiQL can correctly load for us:

# config/application.rb
...
require "sprockets/railtie" if Rails.env.development?
...

Now we ready to starting writing our first query - getting all projects.

Projects query

We will start with the Project type definition for our app:

# app/graphql/types/project_type.rb
Types::ProjectType = GraphQL::ObjectType.define do
  name 'Project'
  backed_by_model :project do
    attr :id
    attr :name
  end
end

We are using the `backed_by_model` function from `graphql-activerecord` for easy mapping between the ActiveRecord model’s attributes and GraphQL fields.

We need to add our `projects` fields to the `query` type:

# app/graphql/types/query_type.rb
Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'
  field :projects, !types[Types::ProjectType], resolve: ->(_obj, _args, ctx) {
    Project.all
  }
end

And then include `query` in our main schema:

# app/graphql/talent_matcher_api_schema.rb
TalentMacherApiSchema = GraphQL::Schema.define do
  query(Types::QueryType)
end

After that we are ready to test our query using the built in Graphiql extension. Let’s start the server (`rails server`) and open `http://localhost:3000/graphiql` to run the query:

query {
  projects {
    id,
    name
  }
}

Projects with skills

Let’s modify our `projects` query so it supports returning projects with skills.

We will start with defining the type for our `Skill` model:

# app/graphq/types/skill_type.rb
Types::SkillType = GraphQL::ObjectType.define do
  name 'Skill'
  backed_by_model :skill do
    attr :id
    attr :name
  end
end

We are using `backed_by_model` once again.

Now we need to modify our `Types::ProjectType` class to include the `skills` field:

# app/graphql/types/project_type.rb
Types::ProjectType = GraphQL::ObjectType.define do
  name 'Project'
  backed_by_model :project do
    attr :id
    attr :name
  end
  field :skills, types[Types::SkillType], resolve: (proc do |obj|
    AssociationLoader.for(Project, :skills).load(obj)
  end)
end

We are using the `AssociationLoader` class for loading the project’s skills. By using this helper class to load the model’s association we prevent the N+1 queries.

Here is the code for the `AssociationLoader` class (code is mostly taken from the graphql-batch example):

# app/graphql/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
  def initialize(model, association_name)
    @model = model
    @association_name = association_name
  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
  def cache_key(record)
    record.object_id
  end
  def perform(records)
    preload_association(records)
    records.each { |record| fulfill(record, read_association(record)) }
  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

It basically preloads associations (if they are not yet preloaded) for a group of records (batch) using `ActiveRecord::Associations::Preloader` class. By using `object_id` as a `cache_key`, we ensure that associations are loaded for all records (even multiple instances of a record with the same id).

Because we are returning Promise for the `skills` field we also need to modify our main schema to support it:

# app/graphql/talent_matcher_api_schema.rb
TalentMacherApiSchema = GraphQL::Schema.define do
  # Set up the graphql-batch gem
  lazy_resolve(Promise, :sync)
  use GraphQL::Batch
  query(Types::QueryType)
end

With all the code from above we should be able to get projects with required skills:

query {
  projects {
    id,
    name,
    skills {
      id,
      name
    }
  }
}

Project candidates

Now we can work on the `candidates` query to find the best candidates for a project based on the number of matched skills and sum of experience in those skills.

We will start with defining our candidate type:

# app/graphql/types/candidate_type.rb
Types::CandidateType = GraphQL::ObjectType.define do
  name 'Candidate'
  field :id, !types.ID, property: :candidate_id
  field :fullname, types.String do
    resolve(proc do |obj|
      RecordLoader.for(Candidate).load(obj.candidate_id)
                  .then(&::fullname)
    end)
  end
  field :matchedSkills, types[types.String] do
    resolve(proc { |obj| obj.matched_skills.split(',') })
  end
  field :matchedSkillsNo, types.Int, property: :matched_skills_no
  field :experience, types.Int
end

Since the candidate that we sent to the client is represented by 2 database models (`Candidate` and `CandidatesProject`) we have to manually define field resolvers (`backed_by_model` won’t work in this case).

We are also using the `RecordLoader` class for preventing n+1 queries when loading associated the records (for one-to-many relationships). Here is how this class might look like (it’s also taken from the graphql-batch example):

class RecordLoader < GraphQL::Batch::Loader
  def initialize(model)
    @model = model
  end
  def perform(ids)
    @model.where(id: ids).each { |record| fulfill(record.id, record) }
    ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
  end
end

We also included `skills` association for `candidates`. Now we can modify our query:

# app/graphql/types/query_type.rb
Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'
  field :candidates, types[Types::CandidateType], resolve: ->(_obj, args, _ctx) {
    project = Project.find(args['projectId'])
    project.candidates_project
  }
  ...
end

Let’s run our query to fetch best the candidates for the project:

query {
  candidates(projectId: 1) {
    id,
    fullname,
    matchedSkills,
    matchedSkillsNo,
    experience
  }
}

That’s all for today. We hope you enjoyed seeing it all in action! Full code for the tutorial can be found here.

If you’d like help with your Ruby on Rails projects then speak to the experts at iRonin. Our senior team can accelerate your development, adding the functionality you need with the scale you desire. Email us to find out more!

Read Similar Articles