Moving from direct has_many to has_many :through

ianvaughan

Ian Vaughan

Posted on January 8, 2021

Moving from direct has_many to has_many :through

A common Rails model association is a one-to-many, has_many Association.

This allows a Company to have many Users, but a User can only be in one Company. This looked like this in our app:

class Company < ApplicationRecord 
  has_many :users, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode
class User < ApplicationRecord 
  belongs_to :company
end
Enter fullscreen mode Exit fullscreen mode

We also have a nice little extra has_many defined on the same relationship that is scoped to a particular type of user.

class Company < ApplicationRecord 
  has_many :directors, -> { where(role: 'director') }, class_name: 'User'
end
Enter fullscreen mode Exit fullscreen mode

Changes

We wanted to allow one user to belong to many companies, and still keep one company can have many users. This is called a many-to-many association, and there a few ways to do this with Rails.
I think the most complex but most powerful is has_many :through Association. Its quite well documented above and other places, but here is what I have working:

class Company < ApplicationRecord 
  has_many :_company_users, dependent: :destroy
  has_many :users, through: :_company_users
end
Enter fullscreen mode Exit fullscreen mode
class User < ApplicationRecord 
  has_many :_company_users
  has_many :companies, through: :_company_users
end
Enter fullscreen mode Exit fullscreen mode
class CompanyUser < ApplicationRecord
  belongs_to :company
  belongs_to :user, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode

Note: I've declared the intermediate relationships with an underscore, eg _company_users, to denote they are internal plumbing and not for general usage.
That way when looking at the classes public methods they are not grouped with the normal accessors.
Although they can still be used as normal.

The CompanyUser is the join table, which can house other attributes as you need, like user permissions etc.

The difficulty came when addressing the scoped association. First query is to get the join table results for the scope (_directors_users), and then we need a named accessor that uses that to get the end result (directors). What was needed was the source to get the SQL correct :

class Company < ApplicationRecord 
  has_many :_directors_users, -> { joins(:user).where(role: 'director') }, class_name: 'CompanyUser'
  has_many :directors, through: :_directors_users, source: :user
end
Enter fullscreen mode Exit fullscreen mode

The schema now looks like this:

Alt Text

 Playground

I wanted to explore how has_many / with scope / through all worked, so for future me and anyone else interested, here are a few combinations and the resulting SQL.

  has_many :directors_users, -> { where(id: '1') }, class_name: 'User'
  # SELECT "users".* FROM "users" WHERE "users"."company_id" = 1685 AND "users"."id" = 1

  has_many :directors_users, -> { where(role: 'director') }, class_name: 'User'
  # SELECT "users".* FROM "users" WHERE "users"."company_id" = 1686 AND "users"."role" = 'director'

  has_many :directors_users, -> { joins(:company_users).where(role: 'director') }, class_name: 'User'
  # SELECT "users".* FROM "users" INNER JOIN "company_users" 
  #                               ON "company_users"."user_id" = "users"."id"
  #                               WHERE "users"."company_id" = 1683 AND "users"."role" = 'director'"

  has_many :directors_users, -> { joins(:company_users).where(role: 'director') }
  # NameError: uninitialized constant Company::DirectorsUser

  has_many :directors_users, -> { joins(:company_users).where(role: 'director') }, class_name: 'CompanyUser'
  # ActiveRecord::ConfigurationError: Can't join 'CompanyUser' to association named 'company_users'; perhaps you misspelled it?

  has_many :directors_users, -> { where(id: '1') }, class_name: 'User', source: :users
  # SELECT "users".* FROM "users" WHERE "users"."company_id" = 1691 AND "users"."id" = 1

  has_many :directors_users, -> { joins(:user).where(role: 'director') }, class_name: 'CompanyUser'
  # SELECT "company_users".* FROM "company_users" INNER JOIN "users" 
  #                                               ON "users"."id" = "company_users"."user_id"
  #                                               WHERE "company_users"."company_id" = 1690 
  #                                               AND "company_users"."role" = 'director'"
  has_many :directors_users, -> { joins(:user).where('users.role': 'director') }, class_name: 'CompanyUser'
  # SELECT "company_users".* FROM "company_users" INNER JOIN "users" 
  #                                               ON "users"."id" = "company_users"."user_id" 
  #                                               WHERE "company_users"."company_id" = 1694 
  #                                               AND "users"."role" = 'director'
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
ianvaughan
Ian Vaughan

Posted on January 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related