Rails 6.x - Framework Agnostic Associations - part 2
Aggregating different, but related Data models (Rails STI alternative)
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 builds on (part 1)[post_ruby_rails/rails_6_x_agnostic_associations_1/]
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 │
│ │ ╲│ │
└───────────┘ └───────────┘
Rails app and first Models
┌────────────┐ ┌───────────┐
│ │╲ 1 │ │
│ Business │─○──────────┼│ Person │
│-legal_name │╱0..* │-full_name │
└────────────┘ └───────────┘
We discussed/explained in (part 1)[post_ruby_rails/rails_6_x_agnostic_associations_1/]
Polymorphic (STI) - sometime called inverse polymorphic
┌─────────────┐
│ Contact │
│ relations* │
│+display_name│
└─────────────┘
┼
│
┌───────────────┴────────────┐
│ │
╱│╲ ╱│╲
┌─────────────┐ ┌─────────────┐
│ Business │╲ │ Person │
│ -legal_name │─○──────────┼│ -full_name │
│+display_name│╱ │+display_name│
└─────────────┘ └─────────────┘
+ array: supplier, reseller, customer, sales-rep
* virtual attribute (public method)
We disucssed/explained this in (part 2)[post_ruby_rails/rails_6_x_agnostic_associations_2/]
Polymorphic Modeling
Is a model that can be associated with several different models - serving a similar purpose in all cases. For example perhaps we would like to leave remarks on our interactions with various other business partners as shown below.
┌───────────┐ ┌───────────┐
│ │╲ │ │
│ Business │─○───────┼│ Person │
│ │╱ │ │
└───────────┘ └───────────┘
╲│╱ ╲│╱
│ │
│ │
│ ○
│ ┼
│ ┌───────────┐ ┌───────────┐
│ │ │ ╱│ │
└──────────────○┼│ Remark │┼──────────│ User │
│ │ ╲│ │
└───────────┘ └───────────┘
A Remark could be either associated with either a person or a business - this is called polymorphism. For ubiquitous things like comments, pictures, etc. this is a common approach.
The standard rails way - is convenient (only uses 2 columns for any number of models), but lacks a foreign key so the DB can’t ensure Data integrity. For this reason, many other frameworks do not encourage this approach. So we will use an approach accepted by all frameworks.
Models and relationships
Lets build the user model first so we have all the models needed by Remark.
bin/rails g model User email:string:uniq
Let’s add an email validation to match the DB settings (and case insensitive):
# app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true,
uniqueness: { case_sensitive: false }
end
Given the simplicity of this model we can just continue. lets build Remark now.
bin/rails g model Remark note:text user:references business:references person:references
Like contact we will need to update the migration to allow null in the Business and Person foreign keys, but not for user. Then we will update the models too.
update the migration to ensure we have a note and user, and allow either a business or person associated with each remark:
# db/migrate/20210530104742_create_remarks.rb
class CreateRemarks < ActiveRecord::Migration[6.1]
def change
create_table :remarks do |t|
t.text :note, null: false
t.references :business, foreign_key: true
t.references :person, foreign_key: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
Now we will update the User, Business and Person models to know they could have many remarks with has_many :remarks
:
# app/models/user.rb
class User < ApplicationRecord
has_many :remarks
validates :email, presence: true,
uniqueness: { case_sensitive: false }
end
# app/models/person.rb
class Person < ApplicationRecord
has_one :contact
has_many :remarks
belongs_to :business, optional: true
validates :contact, presence: true
validates :full_name, presence: true
def display_name
full_name
end
def employee_count
nil
end
def associated_business_name
business&.display_name
end
end
# app/models/business.rb
class Business < ApplicationRecord
has_one :contact
has_many :people
has_many :remarks
accepts_nested_attributes_for :contact
validates :contact, presence: true
validates :legal_name, presence: true
def display_name
legal_name
end
def employee_count
people.count
end
def associated_business_name
""
end
end
update the Remark model with the validations to enforce relations:
# app/models/remark.rb
class Remark < ApplicationRecord
belongs_to :user
belongs_to :person, optional: true
belongs_to :business, optional: true
validates :user, presence: true
validates :note, presence: true
# validate :validate_remarkable_belongs_to_one_and_only_one_foreign_key
def remarkable
business || person
end
private
# exclusive or (XOR) is true if one or the other is true, but both
# if un-persisted we could get a model w/o an id
# if persisted we could have a model and an id
def validate_remarkable_belongs_to_one_and_only_one_foreign_key
return if (business_id.present? ^ person_id.present?) ||
(business.present? ^ person.present?)
# add to base since, some forms may not have the person/business fields
errors.add :base, 'must belong to ONE business or person, but not both'
# errors.add :remarkable, 'must belong to a business or a person'
end
end
Lets be sure this migrates:
bin/rails db:migrate
Now lets add the following to the end of our seed file:
# db/seed.rb
# We will create a few users
require 'securerandom'
10.times do
username = SecureRandom.alphanumeric(10) # or use SecureRandom.uuid
User.create!(email: "#{username}@example.ch")
end
# Lets add a remark to most People and Business (using a random user)
users = User.all
Person.all.each_with_index do |person, index|
next if rand(1..3) == 1 # skip one in 3 people
user = users.sample
Remark.create!(person: person, user: user,
note: "some note about #{person.display_name}, by user: #{user.email}")
end
Business.all.each_with_index do |business, index|
next if rand(1..4) == 1 # skip one in 4 businesses
user = users.sample
Remark.create!(business: business, user: user,
note: "some note about #{business.display_name}, by user: #{user.email}")
end
Lets check the seed with:
bin/rails db:seed
Cool that works!
lets snapshot:
git add .
git commit -m "polymorphic remark relations created"
Views
Comming soon …
Assuming this works, let’s see the “/people” page:
bin/rails s
open localhost:3000/businesses/
N+1 checks
Great - lets snapshot:
git add .
git commit -m "created an agnostic polymorphic model with data integrity enforced"