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
15endMigrations
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
31endUsing 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
33endSource 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_articlesNested 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 cityCallbacks 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
22endValidations 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
26endQuerying 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!
