Rails 6.x - Framework Agnostic Associations - part 1
Basic Associations: belongs_to and has_many
Purpose
In the interest of coding Rails in a way to work well with other code bases, I looking at ways to do complex database relations in a framework agnostic way. In particular, this article will primarily explore Polymorphic Relationships.
This is the second article in the series. This article is followed up with (part 2)[post_ruby_rails/rails_6_x_agnostic_associations_2/]
Overview
In this case, I want to model a contact list of businesses and people. Some people will be associated with a company. Additionally, we will track transactions with each person and business.
The basic model will then look something like:
┌───────────┐ ┌───────────┐
│ │╲ ╱│ │
┌──────────────○┼│ Contact │───────────│UserContact│
│ │ │╱ ╲│ │
│ └───────────┘ └───────────┘
│ ┼ ╲│╱
│ ○ │
│ │ │
│ │ │
╱│╲ ╱│╲ │
┌───────────┐ ┌───────────┐ │
│ │╲ │ │ │
│ Business │─○───────┼│ Person │ │
│ │╱ │ │ │
└───────────┘ └───────────┘ │
╲│╱ ╲│╱ │
│ │ │
│ │ │
│ ○ │
│ ┼ ╱│╲
│ ┌───────────┐ ┌───────────┐
│ │ │ ╱│ │
└──────────────○┼│ Remark │┼──────────│ User │
│ │ ╲│ │
└───────────┘ └───────────┘
Created with Monodraw
Create a default Rails app
rails new rails_poly
cd rails_poly
bin/rails db:create
bin/rails db:migrate
git add .
git commit -m "initial commit"
Starting Simple - optional relations
Build Businesses
Lets start with the simple relationship between businesses and people:
┌────────────┐ ┌───────────┐
│ │╲ 1 │ │
│ Business │─○──────────┼│ Person │
│-legal_name │╱0..* │-full_name │
└────────────┘ └───────────┘
For expedience, I’ll use scaffolds:
Generating a simple business model.
rails g scaffold Business legal_name
Lets adjust the migration to require the business’ legal name, by adding null: false
to the name:
# db/migrate/20210516080420_create_businesses.rb
class CreateBusinesses < ActiveRecord::Migration[6.1]
def change
create_table :businesses do |t|
t.string :legal_name, null: false
t.timestamps
end
end
end
Now we will validate the business’ name in the model:
# app/models/business.rb
class Business < ApplicationRecord
validates :legal_name, presence: true
end
Now lets be sure we can migrate:
bin/rails db:migrate
lets use seed to quickly check our models and relations (& get an idea of how to use them):
# db/seeds.rb
business = Business.create(legal_name: "Business")
Lets check the seed with:
bin/rails db:seed
Assuming this works, let’s see the “/businesses” page:
bin/rails s
open localhost:3000/businesses/
Great - lets snapshot:
git add .
git commit -m "created business model"
Build People
Now let’s build the person model and its relations to businesses.
rails g scaffold Person full_name business:references
In this case we want the person to optionally be a member of a business, so lets update the both the models and the migration. Starting with the migration, we need to remove null: false
in the foreign key, and add that to the name - so it should now look like:
# db/migrate/20210516080414_create_people.rb
class CreatePeople < ActiveRecord::Migration[6.1]
def change
create_table :people do |t|
t.string :full_name, null: false
t.references :company, foreign_key: true
t.timestamps
end
end
end
Now lets adjust the person model - we’ll make the relation optional with optional: true
and require the name with the validation validates :full_name, presence: true
, so it should now look like:
# app/models/person.rb
class Person < ApplicationRecord
belongs_to :company, optional: true
validates :full_name, presence: true
end
And lets let the Business know it can have lots of people with has_many :people
- now the model will look like:
# app/models/business.rb
class Business < ApplicationRecord
has_many :people
validates :legal_name, presence: true
end
Lets check the migrations work:
bin/rails db:migrate
lets use seed a couple of people too - so it now looks like:
# db/seed.rb
business = Business.create(legal_name: "Business")
company = Business.create(legal_name: "Company")
company.build_person(full_name: "Company Man")
company.save
person = Person.create(full_name: "Own Person")
Lets check the seed with:
bin/rails db:seed
Now lets check our pages again:
bin/rails s
open localhost:3000
Lets check the index pages
On the business page it would be nice to see how many employees - so we can update the model with:
# app/models/business.rb
class Business < ApplicationRecord
has_many :people
validates :legal_name, presence: true
def people_count
people.count
end
end
And now people_count
is added as a virtual attribute (as well as all other business fields because of 'businesses.*
) - now we can use in our view using = <td><%= business.people_count %></td>
so now it would look something like:
# app/views/businesses/index.html.erb
<h1>Businesses</h1>
<table>
<thead>
<tr>
<th>Legal name</th>
<th>Employee Count</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @businesses.each do |business| %>
<tr>
<td><%= business.legal_name %></td>
<td><%= business.people_count %></td>
<td><%= link_to 'Show', business %></td>
<td><%= link_to 'Edit', edit_business_path(business) %></td>
<td><%= link_to 'Destroy', business, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
</table>
and on the ‘/people’ page it would be nice to see there business name instead of id.
so in the model:
# app/model/person.rb
class Person < ApplicationRecord
belongs_to :business, optional: true
validates :full_name, presence: true
def associated_business_name
business&.legal_name
end
end
and in the index view:
# app/views/people/index.html.erb
<table>
<thead>
<tr>
<th>Full name</th>
<th>Business</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @people.each do |person| %>
<tr>
<td><%= person.full_name %></td>
<td><%= person.associated_business_name %></td>
<td><%= link_to 'Show', person %></td>
<td><%= link_to 'Edit', edit_person_path(person) %></td>
<td><%= link_to 'Destroy', person, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
</table>
to show all employees on the business show page we can do:
# app/views/businesses/show.html.erb
<p>
<strong>Legal name:</strong>
<%= @business.legal_name %>
</p>
<table>
<thead>
<tr>
<th>Employee</th>
</tr>
</thead>
<tbody>
<% @business.people.each do |person| %>
<tr><td>person.full_name</td></tr>
<% end %>
</tbody>
</table>
And now lets look for n+1 queries - to do that we will create many records in the seeds file:
# db/seeds.rb
business = Business.create(legal_name: "Business")
company = Business.create(legal_name: "Company")
boss_man = Person.create(full_name: "Company Man", business: company)
person = Person.create(full_name: "Own Person")
# larger numbers (look for n+1 lookups)
50.times do |business_number|
company = Business.create(legal_name: "Company #{business_number}")
business_number.times do |employee_number|
Person.create(full_name: "Employee #{employee_number}",
business: company)
end
end
Now when we visit ‘/people’ we see an n+1 (to look up the business to get the business name) - this is an easy fix with a pre-load in the controller - just add .include(:business)
to the query - now the index method will look like
# app/controllers/people_controller.rb
class PeopleController < ApplicationController
def index
@people = Person.include(:business).all
end
Fix n+1 lookups - for the business employee count is a bit trickier - to avoid lots of look ups we need the db to do the count and add the count as a virtual attribute - this is done with the following query:
# app/controllers/people_controller.rb
class BusinessController < ApplicationController
def index
# businesses = Business.all # (N+1 when using referring to people)
# select must go last or it gets lost / overwritten
@businesses = Business.joins(:people)
.group('businesses.id')
.select('businesses.*, count(people.id) as people_count')
end
to avoid confusion - lets rename the method in the class to employee_count
:
# app/models/business.rb
class Business < ApplicationRecord
has_many :people
validates :legal_name, presence: true
def employee_count
people.count
end
end
lets run the seeds again:
bin/rails db:seed
cool now when we look at the log we just have one query instead of many!
Now let’s make the people form to associate a business by name instead of the id!
# app/views/people/_form.html.erb
<%= form_with(model: person) do |form| %>
<% if person.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(person.errors.count, "error") %> prohibited this person from being saved:</h2>
<ul>
<% person.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :full_name %>
<%= form.text_field :full_name %>
</div>
<div class="field">
<%= form.label :business %>
<%= form.select :business_id,
Business.all.collect { |b| [ b.legal_name, b.id ] },
prompt: "Select One", include_blank: true %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
Great - lets snapshot:
git add .
git commit -m "created person related to businesses - w/o n+1"
Polymorphic (STI) - sometime called inverse polymorphic
┌───────────────┐
│ Contact │
│ (functions)+ │ + supplier, reseller, customer, sales-rep
│(display_name)*│ * virtual attribute
└───────────────┘
┼ 1
┌────────────┴─────────────┐
╱│╲ * * ╱│╲
┌───────────────┐ ┌───────────────┐ │ Business │╲ 1 │ Person │ │ (legal_name) │ ○ ─ ─ ─ ┼│ (full_name) │ │(display_name)│╱ 0.. │(display_name)*│ └───────────────┘ └───────────────┘
A contact could be either a person or a business - but must be one or the other.
This is implemented in (part 2)[post_ruby_rails/rails_6_x_agnostic_associations_2/]
Polymorphic
a model associated with several different models - serving a similar purpose in both cases
┌────────────┐ ┌───────────┐
│ │╲ 1 │ │
│ Business │ ○ ─ ─ ─ ┼│ Person │
│ │╱ 0..* │ │
└────────────┘ └───────────┘
╲│╱ * * ╲│╱
└───────────┬──────────┘
┼ 1
┌──────────────┐
│ │
│ Transaction │
│ │
└──────────────┘
A contact could be either a person or a business - but must be one or the other.
bin/rails g Contact roles:array business:references person:references
update the migration to ensure we have a role provided & relations:
#
update the Contact model with the validations & flexible relations:
# contact.rb
update the Person model and relations:
# person.rb
update the Business model and relations:
# business.rb
lets use seed a couple of people too - so it now looks like:
# db/seed.rb
business = Business.create(legal_name: "Business")
company = Business.create(legal_name: "Company")
company.build_person(full_name: "Company Man")
company.save
person = Person.create(full_name: "Own Person")
Lets check the seed with:
bin/rails db:seed
Assuming this works, let’s see the “/people” page:
bin/rails s
open localhost:3000/businesses/
Great - lets snapshot:
git add .
git commit -m "created person possibly related to the model"