Skip
Arish's avatar

29. Has Many Through


has_many :through Association

has_many :through sets up a many-to-many connection via a join model. This is one of the most powerful and commonly used associations in Rails.

When to Use has_many :through

Use it when:

  • You need a many-to-many relationship
  • The join table needs additional attributes
  • You want to work with the join model independently

Basic Example

ruby
1# A doctor has many patients through appointments
2class Doctor < ApplicationRecord
3  has_many :appointments
4  has_many :patients, through: :appointments
5end
6
7class Appointment < ApplicationRecord
8  belongs_to :doctor
9  belongs_to :patient
10end
11
12class Patient < ApplicationRecord
13  has_many :appointments
14  has_many :doctors, through: :appointments
15end

Migrations

ruby
1class CreateDoctors < ActiveRecord::Migration[7.1]
2  def change
3    create_table :doctors do |t|
4      t.string :name
5      t.string :specialization
6      t.timestamps
7    end
8  end
9end
10
11class CreatePatients < ActiveRecord::Migration[7.1]
12  def change
13    create_table :patients do |t|
14      t.string :name
15      t.date :birth_date
16      t.timestamps
17    end
18  end
19end
20
21class CreateAppointments < ActiveRecord::Migration[7.1]
22  def change
23    create_table :appointments do |t|
24      t.references :doctor, null: false, foreign_key: true
25      t.references :patient, null: false, foreign_key: true
26      t.datetime :scheduled_at
27      t.text :notes
28      t.timestamps
29    end
30  end
31end

Using has_many :through

ruby
1doctor = Doctor.find(1)
2patient = Patient.find(1)
3
4# Access through association
5doctor.patients                    # All patients
6patient.doctors                    # All doctors
7
8# Access the join model
9doctor.appointments                # All appointments
10patient.appointments               # All appointments
11
12# Create through association
13doctor.patients << patient         # Creates appointment
14doctor.patients.create(name: "John Doe")
15
16# Create with join model attributes
17doctor.appointments.create(
18  patient: patient,
19  scheduled_at: 1.week.from_now,
20  notes: "Annual checkup"
21)
22
23# Query through association
24doctor.patients.where(active: true)
25patient.doctors.where(specialization: "Cardiology")
26
27# Check existence
28doctor.patients.include?(patient)
29doctor.patients.exists?(patient.id)

Real-World Examples

Articles and Tags

ruby
1class Article < ApplicationRecord
2  has_many :taggings
3  has_many :tags, through: :taggings
4end
5
6class Tagging < ApplicationRecord
7  belongs_to :article
8  belongs_to :tag
9end
10
11class Tag < ApplicationRecord
12  has_many :taggings
13  has_many :articles, through: :taggings
14end
15
16# Usage
17article = Article.find(1)
18article.tags                       # All tags
19article.tag_ids                    # Array of tag IDs
20
21# Add tags
22article.tags << Tag.find_by(name: "ruby")
23article.tags.create(name: "rails")
24
25# Find articles by tag
26Tag.find_by(name: "ruby").articles
27Article.joins(:tags).where(tags: { name: "ruby" })

Users and Roles

ruby
1class User < ApplicationRecord
2  has_many :user_roles
3  has_many :roles, through: :user_roles
4  
5  def has_role?(role_name)
6    roles.exists?(name: role_name)
7  end
8  
9  def add_role(role_name)
10    role = Role.find_or_create_by(name: role_name)
11    roles << role unless roles.include?(role)
12  end
13end
14
15class UserRole < ApplicationRecord
16  belongs_to :user
17  belongs_to :role
18  
19  # Additional attributes on the join
20  # granted_by_id, granted_at, expires_at
21end
22
23class Role < ApplicationRecord
24  has_many :user_roles
25  has_many :users, through: :user_roles
26end
27
28# Usage
29user.roles.pluck(:name)           # => ["admin", "editor"]
30user.has_role?("admin")           # => true
31user.add_role("moderator")

Courses and Students

ruby
1class Course < ApplicationRecord
2  has_many :enrollments
3  has_many :students, through: :enrollments
4  
5  def enroll(student)
6    enrollments.create(student: student, enrolled_at: Time.current)
7  end
8  
9  def active_students
10    students.joins(:enrollments)
11            .where(enrollments: { status: 'active' })
12  end
13end
14
15class Enrollment < ApplicationRecord
16  belongs_to :course
17  belongs_to :student
18  
19  # Additional attributes
20  # status, enrolled_at, completed_at, grade
21  
22  validates :student_id, uniqueness: { scope: :course_id }
23end
24
25class Student < ApplicationRecord
26  has_many :enrollments
27  has_many :courses, through: :enrollments
28  
29  def completed_courses
30    courses.joins(:enrollments)
31           .where(enrollments: { status: 'completed' })
32  end
33end

Source and Source Type

When the association name doesn't match the class name:

ruby
1class User < ApplicationRecord
2  has_many :subscriptions
3  has_many :subscribed_articles, through: :subscriptions, source: :article
4end
5
6class Subscription < ApplicationRecord
7  belongs_to :user
8  belongs_to :article
9end
10
11# Without source, Rails would look for 'subscribed_article' on Subscription
12user.subscribed_articles

Nested has_many :through

ruby
1class Country < ApplicationRecord
2  has_many :states
3  has_many :cities, through: :states
4  has_many :restaurants, through: :cities
5end
6
7class State < ApplicationRecord
8  belongs_to :country
9  has_many :cities
10  has_many :restaurants, through: :cities
11end
12
13class City < ApplicationRecord
14  belongs_to :state
15  has_many :restaurants
16end
17
18class Restaurant < ApplicationRecord
19  belongs_to :city
20end
21
22# Usage
23country.restaurants                # All restaurants in country
24state.restaurants                  # All restaurants in state
25city.restaurants                   # All restaurants in city

Callbacks on Join Model

ruby
1class Enrollment < ApplicationRecord
2  belongs_to :course
3  belongs_to :student
4  
5  after_create :send_welcome_email
6  after_create :increment_student_count
7  before_destroy :send_unenroll_notification
8  
9  private
10  
11  def send_welcome_email
12    CourseMailer.welcome(student, course).deliver_later
13  end
14  
15  def increment_student_count
16    course.increment!(:students_count)
17  end
18  
19  def send_unenroll_notification
20    CourseMailer.unenrolled(student, course).deliver_later
21  end
22end

Validations on Join Model

ruby
1class Enrollment < ApplicationRecord
2  belongs_to :course
3  belongs_to :student
4  
5  validates :student_id, uniqueness: { 
6    scope: :course_id,
7    message: "is already enrolled in this course"
8  }
9  
10  validate :course_not_full
11  validate :student_eligible
12  
13  private
14  
15  def course_not_full
16    if course.enrollments.count >= course.max_students
17      errors.add(:base, "Course is full")
18    end
19  end
20  
21  def student_eligible
22    unless student.verified?
23      errors.add(:student, "must be verified to enroll")
24    end
25  end
26end

Querying Through Associations

ruby
1# Find all students enrolled in Ruby courses
2Student.joins(:courses).where(courses: { name: "Ruby 101" })
3
4# Find all courses a student is enrolled in with status
5Student.find(1).courses.joins(:enrollments)
6              .where(enrollments: { status: 'active' })
7
8# Count enrollments per course
9Course.joins(:enrollments)
10      .group(:id)
11      .count
12
13# Recent enrollments
14Enrollment.includes(:student, :course)
15          .where("created_at > ?", 1.week.ago)
16          .order(created_at: :desc)

has_many :through is essential for modeling real-world many-to-many relationships!