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    │
                       │           │          ╲│           │
                       └───────────┘           └───────────┘

                 Created with Monodraw

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

In this article we will build this structure (a replacement for Rails STI). Many frameworks will only use columns that can be identified as foreign keys to ensure DB integrity - therefore, we will build this using DB structures that are supported by Rails, Lucky and Phoenix and probably most frameworks.

                   ┌─────────────┐
                   │   Contact   │
                   │  relations* │
                   │+display_name│
                   └─────────────┘
                          ┼
                          │
          ┌───────────────┴────────────┐
          │                            │
         ╱│╲                          ╱│╲
    ┌─────────────┐             ┌─────────────┐
    │  Business   │╲            │    Person   │
    │ -legal_name │─○──────────┼│ -full_name  │
    │+display_name│╱            │+display_name│
    └─────────────┘             └─────────────┘
  + array: supplier, reseller, customer, sales-rep
  * virtual attribute (public method)

A contact could be either a person or a business - but must be one or the other.

Migration and Relationships

Rails doesn’t have a built-in array migration, so we use string and then we change the migration:

bin/rails g scaffold Contact functions:string business:references person:references

Now update the migration to ensure we have a functions as an array & relations as Foreign keys (but optional). Since there we only want/need one of the two foreign_keys at a time they must be nullable and we need to change roles to an array - so now:

# db/migrate/20210519205042_create_contacts.rb
class CreateContacts < ActiveRecord::Migration[6.1]
  def change
    create_table :contacts do |t|
      t.string :functions, array: true, null: false, default: []
      t.references :business, foreign_key: true
      t.references :person, foreign_key: true

      t.timestamps
    end
  end
end

update the Contact model with the validations & flexible relations - we also want to be able to refer to the sub-model by one name we will call that contactable - so now the model will look like:

# app/models/contact.rb
class Contact < ApplicationRecord
  belongs_to :business, optional: true
  belongs_to :person, optional: true

  VALID_FUNCTIONS_LIST = %w(supplier reseller customer sales-rep)

  validate :validate_relationship_functions
  validate :validate_belongs_to_one_and_only_one_foreign_key

  def contactable
    business || person
  end

  private

  # be sure we have the variable, it is an Array & all elements are in the valid list
  def validate_relationship_functions
    return if functions.present? && functions.is_a?(Array)
              functions.all? { |role| VALID_FUNCTIONS_LIST.include?(role.to_s) }

    errors.add :functions, "must be ONE or MORE of the following options: #{VALID_FUNCTIONS_LIST.join(',')}"
  end

  # 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

update the Person model and relations and enforce every person is a member of the contact list - with a contact role:

# app/models/person.rb
class Person < ApplicationRecord
  has_one :contact
  belongs_to :business, optional: true

  validates :contact, presence: true
  validates :full_name, presence: true
end

update the business model and relations and enforce every business is a member of the contact list - with a contact role:

# # app/models/business.rb
class Business < ApplicationRecord
  has_one :contact
  has_many :people

  validates :contact, presence: true
  validates :legal_name, presence: true
end

Lets check the seed with:

bin/rails db:migrate

If we go to a person or business we can no longer make changes - they need to have an associated Contact. We’ll start by rolling back the last migration and fixing it with (we can use the logic in the seeds to guide us in the Business/Person creation controller):

bin/rails db:rollback

we need to fix the old relations in the migration (or simply drop the database and reseed it) - but given this is to article is find cross-framework – ‘real-world’ techniques - let’s be sure the existing records stay useful. We will assume a business is a supplier, a person associated with a business is a sales-rep, and unassociated people are customers.

# db/migrate/20210519205042_create_contacts.rb
class CreateContacts < ActiveRecord::Migration[6.1]
  def change
    create_table :contacts do |t|
      t.string :functions, array: true, null: false, default: []
      t.references :business, foreign_key: true
      t.references :person, foreign_key: true

      t.timestamps
    end

    # add a contact for each existing company
    businesses = Business.joins(:people)
                         .group('businesses.id')
                         .select('businesses.*, count(people.id) as people_count')
    businesses.each do |business|
      functions = if business.people_count < 10
                    ['supplier']
                  elsif business.people_count < 20
                    ['reseller']
                  elsif business.people_count < 30
                    ['supplier', 'reseller']
                  end
      Contact.create!(functions: functions, business: business)
    end

    # add a contact for each existing person
    Person.all.each do |person|
      functions = if person.business
                    ['sales_rep']
                  else
                    ['customer']
                  end
      Contact.create!(functions: functions, person: person)
    end
  end
end

Lets the existing models now:

bin/rails db:migrate

OK - we are in business lets update our seed file too:

# db/seed.rb
# create small business w/o employees
20.times do |num|
  business = Business.create(legal_name: "Business #{num}",
                             contact: Contact.new(functions: ['supplier']))
end

# create individuals
20.times do |num|
  person = Person.create(full_name: "Individual #{num}",
                            contact: Contact.new(functions: ['customer']))
end

# create big companies with employees
20.times do |bus_num|
  functions = if bus_num < 3
                ['supplier']
              elsif bus_num< 5
                ['reseller']
              elsif bus_num < 8
                ['supplier', 'reseller']
              else
                %w[supplier reseller customer]
              end
  company  = Business.create(legal_name: "Company #{bus_num}",
                             contact: Contact.new(functions: functions))

  bus_num.times do |emp_num|
    Person.create(full_name: "Employee #{bus_num}-#{emp_num}",
                  business: company,
                  contact: Contact.new(functions: ['sales-rep']))
  end
end

Lets check the seed with:

bin/rails db:seed

Great all works!

Lets make the index page more useful

When we visit the contacts page we would like more than the ids - but we need a unified way to present that info so let’s add a display_name so we can show the name of the primary model, if a person we would like to know the associated business if present and if a company we would like the employee_count so we will delegate these to the sub-models.

Let’s update contact first by adding:

  # this references our existing contactable
  delegate :display_name, :associated_business_name, :employee_count,
           to: :contactable

  def contactable
    business || person
  end

So now the contact model will look like (with validations)

# app/models/contact.rb
class Contact < ApplicationRecord
  belongs_to :business, optional: true
  belongs_to :person, optional: true

  VALID_FUNCTIONS_LIST = %w(supplier reseller customer sales-rep)

  validate :validate_relationship_functions
  validate :validate_belongs_to_one_and_only_one_foreign_key

  delegate :display_name, :associated_business_name, :employee_count,
           to: :contactable

  def contactable
    business || person
    # would memoize be valuable here?
    # @contactable ||= (business || person)
  end

  private

  # be sure we have the variable, it is an Array & all elements are in the valid list
  def validate_relationship_functions
    return if functions.present? && functions.is_a?(Array)
              functions.all? { |role| VALID_FUNCTIONS_LIST.include?(role.to_s) }

    errors.add :functions, "must be ONE or MORE of the following options: #{VALID_FUNCTIONS_LIST.join(',')}"
  end

  # exclusive or (XOR) is true if one or the other is true, but not when both are true
  # we could get a model (or possibly an id)
  def validate_belongs_to_one_and_only_one_foreign_key
    return if business.present? ^ person.present? ^ business_id.present? ^ person_id.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 :contactable, 'must belong to a business or a person'
  end
end

Lets update the models to provide the needed info

Business now will look like:

# app/models/business.rb
class Business < ApplicationRecord
  has_one :contact
  has_many :people

  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

And person will look like:

# app/models/person.rb
class Person < ApplicationRecord
  has_one :contact
  belongs_to :business, optional: true

  validates :contact, presence: true
  validates :full_name, presence: true

  def display_name
    full_name
  end

  def employee_count
    nil  # person count has no meaning under person
  end

  def associated_business_name
    business&.display_name
  end
end

Now lets update the index view to show our new info:

<h1>Contacts</h1>

<table>
  <thead>
    <tr>
      <th>Person/Business</th>
      <th>Employee Count</th>
      <th>Contact Name</th>
      <th>Business Name</th>
      <th>Relationships</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @contacts.each do |contact| %>
    <tr>
      <td><%= contact.contactable.class.name %></td>
      <td><%= contact.employee_count %></td>
      <td><%= contact.display_name %></td>
      <td><%= contact.associated_business_name %></td>
      <td><%= contact.functions %></td>
      <td><%= link_to 'Show', contact %></td>
      <td><%= link_to 'Edit', edit_contact_path(contact) %></td>
      <td><%= link_to 'Destroy', contact, method: :delete, data: { confirm: 'Are you sure?' } %></td>
    </tr>
    <% end %>
  </tbody>
</table>

Now we see another n+1 query - we will fix the main part - but not the employee count this time:

class ContactsController < ApplicationController
  def index
    # @contacts = Contact.all
    @contacts = Contact.includes(:business).includes(:person).all
  end

Cool now the page is usable (a bit long but we will ignore that)

Lets be sure we can create new contacts

I usually use an input model (for more flexibility), but for now I will use nested_params. A few articles on nested params and nested fields:

To start we will tell the contacts model that it can create nested models with do by adding:

  accepts_nested_attributes_for :business
  accepts_nested_attributes_for :person

so now now the contact model looks like:

# app/models/contact.rb
class Contact < ApplicationRecord
  belongs_to :business, optional: true
  belongs_to :person, optional: true

  accepts_nested_attributes_for :business
  accepts_nested_attributes_for :person

  VALID_FUNCTIONS_LIST = %w(supplier reseller customer sales-rep)

  validate :validate_relationship_functions
  validate :validate_belongs_to_one_and_only_one_foreign_key

  delegate :display_name, :associated_business_name, :employee_count,
           to: :contactable

  def contactable
    business || person
    # would memoize be valuable here?
    # @contactable ||= (business || person)
  end

  private

  # be sure we have the variable, it is an Array & all elements are in the valid list
  def validate_relationship_functions
    return if functions.present? && functions.is_a?(Array)
              functions.all? { |role| VALID_FUNCTIONS_LIST.include?(role.to_s) }

    errors.add :functions, "must be ONE or MORE of the following options: #{VALID_FUNCTIONS_LIST.join(',')}"
  end

  # exclusive or (XOR) is true if one or the other is true, but not when both are true
  # we could get a model (or possibly an id)
  def validate_belongs_to_one_and_only_one_foreign_key
    return if business.present? ^ person.present? ^ business_id.present? ^ person_id.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 :contactable, 'must belong to a business or a person'
  end
end

In the controller we need to create models as part of @contact to allow nested-fields - which feed the nested attributes. to allow the new information in via strong params:

# app/controllers/contacts_controller.rb
  def new
    @contact = Contact.new
    # add empty sub-models for our form
    @contact.person = Person.new
    @contact.business = Business.new
  end

  # update strong params to accept the sub-model attributes
  # sub-models from nested-forms feeding nested_atttributes in the model
  # take the form <model_name>_attributes
  # `functions` is an empty array since it is taking a list of values
  # person_attributes & business_attributes - need to include the list of attributes to accept!
  # so in our case:
  def contact_params
    contact_attribs = params.require(:contact)
                            .permit(functions: [],  # is empty - takes a list of values
                                    person_attributes: [:full_name],  # needs to include the list of attributes to accept
                                    business_attributes: [:legal_name])
  end

update the contact form to tie this all together by adding our nested forms:

  <div class="field-group">
    <h2>Create your Contact: a Person or a Business</h2>

    <h3>Business</h3>
    <%= form.fields_for :business, Business.new do |f| %>
      <%= f.label :legal_name %>
      <%= f.text_field :legal_name %>
    <% end %>

    <h3>Person</h3>
    <%= form.fields_for :person, Person.new do |f| %>
      <%= f.label :full_name %>
      <%= f.text_field :full_name %>
    <% end %>
  </div>

We will also need to make the list of possible relationship functions a multi-select - I always forget the format – so remember BOTH {} are required when using multi-select!! The first one is for normal drop-down select options – like include_blank, the second one is where the multi-select must go!

This looks like:

  <div class="field">
    <%= form.label :functions %>
    <%= form.select :functions,
                    options_for_select(Contact::VALID_FUNCTIONS_LIST,
                                      selected: Contact::VALID_FUNCTIONS_LIST.second),
                                      {}, #{:include_blank => 'None'},
                                      {:multiple => true, size: 3} %>
  </div>

so now the template looks like:

# app/views/contacts/_form.html.erb
<%= form_with(model: contact) do |form| %>
  <% if contact.errors.any? %>
  <div id="error_explanation">
    <h2><%= pluralize(contact.errors.count, "error") %> prohibited this contact from being saved:</h2>

    <ul>
      <% contact.errors.each do |error| %>
      <li><%= error.full_message %></li>
      <% end %>
    </ul>
  </div>
  <% end %>

  <div class="field">
    <%= form.label :functions %>
    <%= form.select :functions,
                    options_for_select(Contact::VALID_FUNCTIONS_LIST,
                                      selected: Contact::VALID_FUNCTIONS_LIST.second),
                                      {}, #{:include_blank => 'None'},
                                      {:multiple => true, size: 3} %>
  </div>

  <div class="field-group">
    <h2>Create your Contact: a Person or a Business</h2>

    <h3>Business</h3>
    <%= form.fields_for :business, Business.new do |f| %>
      <%= f.label :legal_name %>
      <%= f.text_field :legal_name %>
    <% end %>

    <h3>Person</h3>
    <%= form.fields_for :person, Person.new do |f| %>
      <%= f.label :full_name %>
      <%= f.text_field :full_name %>
    <% end %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

Now when we try /contacts we notice one more problem - it is always invalid - rails automatically add a leading "" in an array input list :( - so we will have to clean this up in the strong params. In this case we are working with param objects not a hash so we will do an in-place update (removal of “”) using:

  contact_attribs["functions"].reject! {|f| f.blank? }
  contact_attribs

we also need to be sure in our case we only send the params of the business or the person, but not both - since we are only creating one. So we will remove whichever one is empty - also with an in-place update - using:

    # find and set to nil the model without params
    if contact_attribs["person_attributes"]
      # since we only have one param we can do
      contact_attribs["person_attributes"] = nil if contact_attribs["person_attributes"]["full_name"].blank?
    end

    if contact_attribs["business_attributes"]
      # assuming we had multiple params the test is easier with:
      contact_attribs["business_attributes"] = nil if contact_attribs["business_attributes"].to_h.all? {|key,value| value.blank?}
    end

    # remove the nested attributes set to nil so contact will only create the desired associated model
    contact_attribs.reject! {|key, value| value.blank? }
    contact_attribs

So now the full controller looks like:

class ContactsController < ApplicationController
  before_action :set_contact, only: %i[ show edit update destroy ]

  def index
    # @contacts = Contact.all
    @contacts = Contact.includes(:business).includes(:person).all
  end

  def show
  end

  def new
    @contact = Contact.new
    @contact.person = Person.new
    @contact.business = Business.new
  end

  def edit
  end

  def create
    @contact = Contact.new(contact_params)

    respond_to do |format|
      if @contact.save
        format.html { redirect_to @contact, notice: "Contact was successfully created." }
        format.json { render :show, status: :created, location: @contact }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @contact.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @contact.update(contact_params)
        format.html { redirect_to @contact, notice: "Contact was successfully updated." }
        format.json { render :show, status: :ok, location: @contact }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @contact.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @contact.destroy
    respond_to do |format|
      format.html { redirect_to contacts_url, notice: "Contact was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_contact
    @contact = Contact.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def contact_params
    # update strong params to accept the sub-model attributes
    # sub-models from nested-forms feeding nested_atttributes in the model
    # take the form <model_name>_attributes
    # `functions` is an empty array since it is taking a list of values
    # person_attributes & business_attributes - need to include the list of attributes to accept!
    # so in our case:
    contact_attribs = params.require(:contact)
                            .permit(functions: [],
                                    person_attributes: [:full_name],
                                    business_attributes: [:legal_name])
    # cleanup array - always delivers with [''] - :(
    # https://stackoverflow.com/questions/51341912/empty-array-value-being-input-with-simple-form-entries

    # easiest way in in-place replacement (given that params is now objects and not a hash), but that always makes me a bit nervous
    # https://stackoverflow.com/questions/20164354/rails-strong-parameters-with-empty-arrays
    # reject and replace in place
    contact_attribs["functions"].reject! {|f| f.blank? }

    # remove empty model attributes
    # contact_attribs["person_attributes"].reject {|key,value| value.blank?}
    if contact_attribs["person_attributes"]
      contact_attribs["person_attributes"] = nil if contact_attribs["person_attributes"]["full_name"].blank?
    end

    if contact_attribs["business_attributes"]
      contact_attribs["business_attributes"] = nil if contact_attribs["business_attributes"].to_h.all? {|key,value| value.blank?}
    end

    # have to remove nil attributes for models so nested attributes works correctly
    contact_attribs.reject! {|key, value| value.blank? }

    # return the attributes with the tidied array
    contact_attribs
  end
end

now when we try again:

bin/rails s
open localhost:3000/contacts/new

Cool - it works. We could now do the same for the /business/new and /people/new, but we won’t do that here in the article. Lets snapshot:

git add .
git commit -m "created person possibly related to the model"

Polymorphic

In the next article we will explore the following in (part 3)[post_ruby_rails/rails_6_x_agnostic_associations_3/]

┌───────────┐             ┌───────────┐
│           │╲            │           │
│ Business  │─○──────────┼│  Person   │
│           │╱            │           │
└───────────┘             └───────────┘
      ┼                         ┼
      │                         │
      └────────────┬────────────┘
                   │
                  ╱│╲
             ┌───────────┐
             │           │
             │  Remark   │
             │           │
             └───────────┘
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature