Step 2: Has Many

View the Code

We’ll be adding the database table positions:

id employee_id title active historical_index created_at updated_at
1 900 Engineer true 1 2018-09-04 2018-09-04
2 900 Intern true 2 2018-09-04 2018-09-04
3 800 Manager true 1 2018-09-04 2018-09-04

Because this table tracks all historical positions, we have the historical_index column. This tells the order the employee moved through each position, where 1 is most recent.

The Rails Stuff 🚂

Generate the Position model:

$ bin/rails g model Position title:string active:boolean historical_index:integer employee:belongs_to
$ bin/rails db:migrate

Update the Employee model with the association, too:

# app/models/employee.rb
has_many :positions

And update our seed data:

# db/seeds.rb
[Employee, Position].each(&:delete_all)

100.times do
  employee = Employee.create! first_name: Faker::Name.first_name,
    last_name: Faker::Name.last_name,
    age: rand(20..80)

  (1..2).each do |i|
    employee.positions.create! title: Faker::Job.title,
      historical_index: i,
      active: i == 1
  end
end
$ bin/rails db:seed

The Graphiti Stuff 🎨

Let’s start by running the same command as before to create PositionResource:

$ bin/rails g graphiti:resource Position title:string active:boolean

We’ll need to add the association, just like ActiveRecord:

# app/resources/employee_resource.rb
has_many :positions

…and a corresponding filter:

# app/resources/position_resource.rb
filter :employee_id, :integer

If you visit /api/v1/employees, you’ll see a number of HTTP Links that allow lazy-loading positions. Or, if you visit /api/v1/employees?include=positions, you’ll load the employees and positions in a single request. We’ll dig a bit deeper into this logic in the section below.

Before we get there, let’s revisit the historical_index column. For now, let’s treat this as an implementation detail that the API should not expose - let’s say we want to support sorting on this attribute but nothing else:

attribute :historical_index, :integer, only: [:sortable]

We’re almost done, but if you run your tests you’ll see two outstanding errors. This is because Rails 5 belongs_to associations are required by default. We can’t save a Position without its corresponding Employee.

We can solve this in three ways:

  • Turn this off globally, with config.active_record.belongs_to_required_by_default. You may want to do this in test-mode only.
  • Turn this off for the specific association: belongs_to :employee, optional: true.
  • Associate an Employee as part of the API request.

We’ll take for the last option. Look at spec/resources/position/writes_spec.rb:

RSpec.describe PositionResource, type: :resource do
  describe 'creating' do
    let(:payload) do
      {
        data: {
          type: 'positions',
          attributes: { }
        }
      }
    end

    let(:instance) do
      PositionResource.build(payload)
    end

    it 'works' do
      expect {
        expect(instance.save).to eq(true)
      }.to change { Position.count }.by(1)
    end
  end
end

When running our tests, let’s make sure the historical_index column reflects the order we created the positions. This code recalculates everything after a record is saved:

# spec/factories/position.rb
FactoryBot.define do
  factory :position do
    employee

    title { Faker::Job.title }

    after(:create) do |position|
      unless position.historical_index
        scope = Position
          .where(employee_id: position.employee.id)
          .order(created_at: :desc)
        scope.each_with_index do |p, index|
          p.update_attribute(:historical_index, index + 1)
        end
      end
    end
  end
end

Let’s associate an Employee. Start by seeding the data:

let!(:employee) { create(:employee) }

And associate via relationships:

let(:payload) do
  {
    data: {
      type: 'positions',
      attributes: { },
      relationships: {
        employee: {
          data: {
            id: employee.id.to_s,
            type: 'employees'
          }
        }
      }
    }
  }
end

To ensure the PositionResource will process this relationship, the last step is to add it:

# app/resources/position_resource.rb
belongs_to :employee

This will associate the Position to the Employee as part of the creation process. The test should now pass - make the same change to spec/api/v1/positions/create_spec.rb to get a fully-passing test suite.

Digging Deeper 🧐

Why did we need the employee_id filter above? To explain that, let’s dive deeper into the logic connecting Resources.

If you hit /api/v1/employees, you’ll see a number of Links in the response. These are useful for lazy-loading, but the same logic applies to eager loading. Let’s take a look at a Link to see how these Resources connect together:

{
  ...
  relationships: {
    positions: {
      links: {
        related: "http://localhost:3000/api/v1/positions?filter[employee_id]=1"
      }
    }
  }
  ...
}

The salient bit: /positions?filter[employee_id]=1. In other words, fetch all Positions for the given Employee id.That means, whether we’re lazy-loading data in separate requests or eager-loading in a single request, the same logic fires under-the-hood:

PositionResource.all({
  filter: { employee_id: 1 }
})

This means we need filter :employee_id, :integer to satisfy the query.

We can customize the logic connecting Resources in a few different ways. First some simple options:

has_many :positions, foreign_key: :emp_id, primary_key: :eid

So far so good. The logic, and corresponding Link, both update as you’d expect (though we’d of course need a corresponding filter :emp_id, :integer on PositionResource).

Those options are just simple versions of parameter customization. You can customize parameters connecting Resources with the params block:

has_many :positions do
  params do |hash, employees|
    hash[:filter] # => { employee_id: employees.map(&:id) }
    hash[:filter][:active] = true
    hash[:sort] = '-created_at'
  end
end

Customizing these params affects the Link as well as the eager-load logic. Remember the parameters here should reflect the JSON:API specification, or anything PositionResource.all accepts.

These are the most common options, but there’s a bunch more. Check out the Resource Relationships Guide to dig even deeper.