Joining two models with many-to-many relationship in Rails

69 Views Asked by At

I have a ruby on rails api which has a many to many relationship between the following models:

class Course < ApplicationRecord
  has_many :student_courses
  has_many :students, through: :student_courses
end

class Student < ApplicationRecord
  has_many :student_courses
  has_many :courses, through: :student_courses
end

class StudentCourse < ApplicationRecord
  belongs_to :student
  belongs_to :courses
end

I want to serve json in the following format:

[
  {
    "course": "English",
    "students": [
      "John",
      "Sarah"
    ]
  },
  {
    "course": "Maths",
    "students": [
      "John",
      "Ella",
      "Lee"
    ]
  },
  {
    "course": "Classics",
    "students": [
      "Una",
      "Tom"
    ]
  }
]

At the moment I'm doing this using a loop:

def index
  @courses = Course.all

  output = []
  @courses.each do |course|
    course_hash = {
      course: course.name,
      students: course.students.map { |student| student.name }
    }
    output << course_hash
  end

  render json: output.to_json
end

Is there a more efficient way to do this using active record object relational mapping?

2

There are 2 best solutions below

1
On BEST ANSWER

In your example, iterating Course.all.each and then calling course.students within each iteration will lead to an N+1 problem. Which means there will be one database query to get all courses and the N additional database queries to load the students for each individual course in the list.

To avoid N+1 queries, Ruby on Rails allows to eager load students together with the courses in one or maximum two queries by using includes

Another optimization could be to reduce memory consumption by reusing the already existing array with Enumerable#map instead of iterating the array with each and coping the transformed data into a new array.

Putting it together:

def index
  courses_with_students = Course.includes(:students).map do |course|
    { course: course.name, students: course.students.map(&:name) }
  end

  render json: courses_with_students.to_json
end
0
On

You don't need to reinvent the JSON serialization wheel. ActiveModel has you covered.

def index
  @courses = Course.eager_load(:students) # prevents a N+1 query issue
                   .all
  render json: @courses, 
    include: {
      students: { 
        only: [:name] 
      }
    }
end

If you need to any more advanced serialization or want to avoid bloating the controller there are gems such as ActiveModelSerializers, jBuilder, and a whole plethora of gems that do jsonapi.org style serialization.

Note that the JSON produced is slightly different:

[
  {
    "id": 1,
    "name": "Ruby",
    "created_at": "2023-10-16T14:24:48.356Z",
    "updated_at": "2023-10-16T14:24:48.356Z",
    "students": [
      {
        "name": "Bob"
      },
      {
        "name": "Melinda"
      },
      {
        "name": "Havier"
      }
    ]
  }
]

But this is actually a good thing as you might want to include other attributes of the students in the response in the near future and this will let you do this without breaking existing frontend code.