Introduction

In database design, many-to-many relationships occur when each record in one table can be related to multiple records in another, and vice versa. Rails provides two main approaches to model such relationships: has_and_belongs_to_many and has_many :through.

has_and_belongs_to_many(name, scope = nil, **options, &extension)

Specifies a many-to-many relationship with another class. This associates two classes via an intermediate join table. Unless the join table is explicitly specified as an option, it is guessed using the lexical order of the class names. So a join between Developer and Project will give the default join table name of “developers_projects” because “D” precedes “P” alphabetically. Note that this precedence is calculated using the < operator for String. This means that if the strings are of different lengths, and the strings are equal when compared up to the shortest length, then the longer string is considered of higher lexical precedence than the shorter one. For example, one would expect the tables “paper_boxes” and “papers” to generate a join table name of “papers_paper_boxes” because of the length of the name “paper_boxes”, but it in fact generates a join table name of “paper_boxes_papers”. Be aware of this caveat, and use the custom :join_table option if you need to. If your tables share a common prefix, it will only appear once at the beginning. For example, the tables “catalog_categories” and “catalog_products” generate a join table name of “catalog_categories_products”.
The join table should not have a primary key or a model associated with it. You must manually generate the join table with a migration such as this:

class CreateDevelopersProjectsJoinTable < ActiveRecord::Migration[7.1]
  def change
    create_join_table :developers, :projects
  end
end

It’s also a good idea to add indexes to each of those columns to speed up the joins process. However, in MySQL it is advised to add a compound index for both of the columns as MySQL only uses one index per table during the lookup.
Adds the following methods for retrieval and query:
collection is a placeholder for the symbol passed as the name argument, so has_and_belongs_to_many :categories would add among others categories.empty?.

collection
Returns a Relation of all the associated objects. An empty Relation is returned if none are found.

collection<<(object, …)
Adds one or more objects to the collection by creating associations in the join table (collection.push and collection.concat are aliases to this method). Note that this operation instantly fires update SQL without waiting for the save or update call on the parent object, unless the parent object is a new record.

collection.delete(object, …)
Removes one or more objects from the collection by removing their associations from the join table. This does not destroy the objects.

collection.destroy(object, …)
Removes one or more objects from the collection by running destroy on each association in the join table, overriding any dependent option. This does not destroy the objects.

collection=objects
Replaces the collection’s content by deleting and adding objects as appropriate.

collection_singular_ids
Returns an array of the associated objects’ ids.

collection_singular_ids=ids
Replace the collection by the objects identified by the primary keys in ids.

collection.clear
Removes every object from the collection. This does not destroy the objects.

collection.empty?
Returns true if there are no associated objects.

collection.size
Returns the number of associated objects.

collection.find(id)
Finds an associated object responding to the id and that meets the condition that it has to be associated with this object. Uses the same rules as ActiveRecord::FinderMethods#find.

collection.exists?(…)
Checks whether an associated object with the given conditions exists. Uses the same rules as ActiveRecord::FinderMethods#exists?.

collection.build(attributes = {})
Returns a new object of the collection type that has been instantiated with attributes and linked to this object through the join table, but has not yet been saved.

collection.create(attributes = {})
Returns a new object of the collection type that has been instantiated wit attributes, linked to this object through the join table, and that has already been saved (if it passed the validation).

collection.reload
Returns a Relation of all of the associated objects, forcing a database read. An empty Relation is returned if none are found.

Example

class Developer < ActiveRecord::Base
  has_and_belongs_to_many :projects
end
Declaring <b>has_and_belongs_to_many :projects</b> adds the following methods (and more):
developer = Developer.find(11)
project   = Project.find(9)

developer.projects
developer.projects << project
developer.projects.delete(project)
developer.projects.destroy(project)
developer.projects = [project]
developer.project_ids
developer.project_ids = [9]
developer.projects.clear
developer.projects.empty?
developer.projects.size
developer.projects.find(9)
developer.projects.exists?(9)
developer.projects.build  # similar to Project.new(developer_id: 11)
developer.projects.create # similar to Project.create(developer_id: 11)
developer.projects.reload

Scopes

You can pass a second argument scope as a callable (i.e. proc or lambda) to retrieve a specific set of records or customize the generated query when you access the associated collection.

Scope examples:

has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) }
has_and_belongs_to_many :categories, ->(post) {
  where("default_category = ?", post.default_category)
}

Extensions

The extension argument allows you to pass a block into a has_and_belongs_to_many association. This is useful for adding new finders, creators, and other factory-type methods to be used as part of the association.

# app/models/developer.rb
class Developer < ApplicationRecord
  has_and_belongs_to_many :projects, extension: DeveloperExtension
end

# app/models/project.rb
class Project < ApplicationRecord
  has_and_belongs_to_many :developers, extension: DeveloperExtension
end

# lib/developer_extension.rb
module DeveloperExtension
  def total_experience
    projects.sum(:experience_years)
  end
end

In this example:

  • The Developer and Project models are associated through has_and_belongs_to_many, and the DeveloperExtension module is included using the extension option.
  • The DeveloperExtension module defines a method called total_experience, which calculates the total experience of a developer based on the experience_years attribute of associated projects.
  • Now, you can use the total_experience method on instances of Developer:
developer = Developer.first
puts "Total experience of #{developer.name}: #{developer.total_experience} years"

This example demonstrates how you can use the :extension option to encapsulate and reuse functionality related to an association. The total_experience method is now neatly organized in a separate module, enhancing modularity and maintainability.

Options

:class_name option:
Specifies the class name of the association. This is useful when the class name cannot be inferred from the association name.

class Enrollment < ApplicationRecord
belongs_to :student
belongs_to :course
end
class Student < ApplicationRecord
has_and_belongs_to_many :courses, class_name: 'Enrollment'
end
class Course < ApplicationRecord
has_and_belongs_to_many :students, class_name: 'Enrollment'
end

:join_table option:
Specifies the name of the join table that represents the many-to-many relationship. If not specified, Rails will automatically generate the table name.

class Student < ApplicationRecord
has_and_belongs_to_many :courses, join_table: 'enrollments'
end

class Course < ApplicationRecord
has_and_belongs_to_many :students, join_table: 'enrollments'
end

:foreign_key option:
Specifies the foreign key used in the join table. By default, Rails uses the singular form of the association name appended with _id.

class Student < ApplicationRecord
has_and_belongs_to_many :courses, foreign_key: 'student_id'
end

class Course < ApplicationRecord
has_and_belongs_to_many :students, foreign_key: 'student_id'
end

:association_foreign_key option:
Specifies the foreign key used in the join table for the associated model. By default, Rails uses the singular form of the associated model's name appended with _id.

class Student < ApplicationRecord
has_and_belongs_to_many :courses, association_foreign_key: 'course_id'
end

class Course < ApplicationRecord
has_and_belongs_to_many :students, association_foreign_key: 'course_id'
end

:validate option:
If set to false, it skips the validation of the associated records. Default is true.

class Student < ApplicationRecord
has_and_belongs_to_many :courses, validate: false
end

class Course < ApplicationRecord
has_and_belongs_to_many :students, validate: false
end

:autosave option:
The :autosave option determines whether associated objects should be saved automatically when the parent object is saved. This is useful when you want to save both the parent and the associated objects in a single call.

class Student < ApplicationRecord
has_and_belongs_to_many :courses, autosave: true
end

class Course < ApplicationRecord
  	has_and_belongs_to_many :students, autosave: true
end

In this example, when you save a Student instance that has associated Course instances, both the student and the associated courses will be saved in a single call to save. The same applies when saving a Course instance with associated Student instances.

:strict_loading option:
Introduced in Rails 6.1, the :strict_loading option helps enforce strict loading for associations, preventing the accidental loading of associations when querying the database.

class Student < ApplicationRecord
  	has_and_belongs_to_many :courses, strict_loading: true
end
class Course < ApplicationRecord
  	has_and_belongs_to_many :students, strict_loading: true
end

With strict_loading: true, Rails will raise an error if an association is accessed without being explicitly loaded. This is useful in ensuring that developers are conscious about when and how associations are loaded, helping to prevent performance issues related to the N+1 query problem.
Please note that :strict_loading is available starting from Rails 6.1, so make sure your Rails version supports this option if you intend to use it.

The has_one :through Association

A has_one :through association sets up a one-to-one connection with another model. This association indicates that the declaring model can be matched with one instance of another model by proceeding through a third model. For example, if each supplier has one account, and each account is associated with one account history, then the supplier model could look like this:

class Supplier < ApplicationRecord
  has_one :account
  has_one :account_history, through: :account
end
class Account < ApplicationRecord
  belongs_to :supplier
  has_one :account_history
end
class AccountHistory < ApplicationRecord
  belongs_to :account
End

The corresponding migration might look like this:
class CreateAccountHistories < ActiveRecord::Migration[7.1]
  def change
    create_table :suppliers do |t|
      t.string :name
      t.timestamps
    end

    create_table :accounts do |t|
      t.belongs_to :supplier
      t.string :account_number
      t.timestamps
    end

    create_table :account_histories do |t|
      t.belongs_to :account
      t.integer :credit_rating
      t.timestamps
    end
  end
end

Choosing Between has_many :through and as_and_belongs_to_many

The simplest rule of thumb is that you should set up a has_many :through relationship if you need to work with the relationship model as an independent entity. If you don't need to do anything with the relationship model, it may be simpler to set up a has_and_belongs_to_manyrelationship (though you'll need to remember to create the joining table in the database).

You should use has_many :through if you need validations, callbacks, or extra attributes on the join model.

While has_and_belongs_to_many suggests creating a join table with no primary key via id: false, consider using a composite primary key for the join table in the relationship.

Support On Demand!

Ruby on Rails