Skip to main content

Step 2

Step 2: Has Manyโ€‹

View the Code

We'll be adding the database table positions:

idemployee_idtitleactivehistorical_indexcreated_atupdated_at
1900Engineertrue12018-09-042018-09-04
2900Interntrue22018-09-042018-09-04
3800Managertrue12018-09-042018-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 requires belongs_to associations 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.

NEXT - Step 3: Belongs To ยป