Skip to main content

How do I find a records based on contents of a has_many :through join?

Rails • Asked by Peter Marcano

I think I've been at this problem for too long and the obvious answer has evaded me.

Say you have students, classes (klass for ruby's sake), and enrollements as the join between them.

class Student < ApplicationRecord
    has_many :enrollments
    has_many :klasses, through: enrollments
end

class Enrollment < ApplicationRecord
    belongs_to :student
    belongs_to :klass
end

class Klass < ApplicationRecord
    has_many :enrollments
    has_many :students, through: enrollments
end

So far just a simple many-to-many.

Let's give some data and a scenario.

Students:

  • Alice
  • Bob
  • Charlie

Classes:

  • Math
  • Science
  • Literature
  • Phys Ed.

Alice is enrolled in Math, Science, and Phys Ed.
Bob is enrolled in Math, Science, and Literature.
Charlie is enrolled in Science and Phys Ed.

How could I construct a query to say "Who is enrolled in Math & Science?" and have the query return unique records "Alice & Bob".

I started going down the path of something like this, but keep getting tripped up somewhere:

Student.joins(:enrollments).joins(:klasses).where(klass: { id: [math.id, science.id] }).uniq

But since Charlie is enrolled in Science as well, he gets thrown into my results.

Again, I think I've overthought it and I am doing something insanely stupid. At this point I am assuming the answer is probably clear to everyone but me 🤣

Thanks for your help!


I think the basic issue is that you want an AND query, but what you're getting is an OR query. IOW, the query may actually be 'where klass.id is math.id OR science.id' .
I think it's doing a 'where klass.id IN (math.id, science.id)...' but I don't have time to check right now.


Hm. This is straightforward if you use scopes but I'm not sure that's what you're looking for.

class Student < ApplicationRecord

  has_many :enrollments

  has_many :klasses, through: :enrollments

  scope :all_in_klass, ->(k){ joins(:klasses).joins(:enrollments).where(enrollments: {klass: k} ).distinct }

end

then:

in_math = Student.all_in_klass(math)
=> Student Load (0.2ms)  SELECT  DISTINCT "students".* FROM "students" INNER JOIN "enrollments" ON "enrollments"."student_id" = "students"."id" INNER JOIN "klasses" ON "klasses"."id" = "enrollments"."klass_id" INNER JOIN "enrollments" "enrollments_students" ON "enrollments_students"."student_id" = "students"."id" WHERE "enrollments"."klass_id" = ? LIMIT ?  [["klass_id", 1], ["LIMIT", 11]]
#<ActiveRecord::Relation [#<Student id: 1, name: "Alice", created_at: "2019-02-22 00:05:20", updated_at: "2019-02-22 00:05:20">, #<Student id: 2, name: "Bob", created_at: "2019-02-22 00:05:29", updated_at: "2019-02-22 00:05:29">, #<Student id: 3, name: "Charlie", created_at: "2019-02-22 00:05:36", updated_at: "2019-02-22 00:05:36">]>

in_sci = Student.all_in_klass(science)
  Student Load (0.2ms)  SELECT  DISTINCT "students".* FROM "students" INNER JOIN "enrollments" ON "enrollments"."student_id" = "students"."id" INNER JOIN "klasses" ON "klasses"."id" = "enrollments"."klass_id" INNER JOIN "enrollments" "enrollments_students" ON "enrollments_students"."student_id" = "students"."id" WHERE "enrollments"."klass_id" = ? LIMIT ?  [["klass_id", 2], ["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Student id: 1, name: "Alice", created_at: "2019-02-22 00:05:20", updated_at: "2019-02-22 00:05:20">, #<Student id: 2, name: "Bob", created_at: "2019-02-22 00:05:29", updated_at: "2019-02-22 00:05:29">]>


in_math_and_sci = in_math & in_sci
=> [#<Student id: 1, name: "Alice", created_at: "2019-02-22 00:05:20", updated_at: "2019-02-22 00:05:20">, #<Student id: 2, name: "Bob", created_at: "2019-02-22 00:05:29", updated_at: "2019-02-22 00:05:29">]

Well well well! That passed one of my Rspec tests but not the other! (So much further than I got before so thank you so much!)

Test 1 basically finds Charlie by passing in Phys Ed and Science eliminating the other two students.

Test 2 finds Alice and Bob as described above. But only Alice gets put out because of what I imagine is the distinct?

Sorry, this is a little confusing because I am translating my client's problem to a different domain of school children so may be unclear when testing your approach and returning the results!


HEY I GOT BOTH TESTS TO PASS!! THANKS SO MUCH ASHLEY!

Current solution until I find a better way to refactor it (and hopefully into one query since this method will likely be called a lot)...

class Student < ApplicationRecord

  has_many :enrollments
  has_many :klasses, through: :enrollments

  scope :all_in_klass, ->(k){ joins(:enrollments).where(enrollments: {klass: k} )}
end
def find_students_who_share_classes
    in_math = Student.all_in_klass(@math)
    in_sci = Student.all_in_klass(@sci)
    in_math.merge(in_sci)
    return in_math.distinct
end

Glad to have helped! :-)
(Some folks would rather write that scope as a class method, of course)
If the number of "classes" varies, then you can call that scope/method as many times as needed, of course.

And ya -- I know what it's like to have to translate a domain/problem so that you can talk about it, while not losing any important information in translation either way.


Ugh... turns out this isn't solved! I am getting all kids enrolled in science even if they don't take math!

I added more data and tested a few more scenarios and found out it wasn't working properly.

I will return with an update, but until then, if anyone has any recommendations I'd love to hear them!


Peter --
I didn't notice it before, but in your code:

def find_students_who_share_classes
    in_math = Student.all_in_klass(@math)
    in_sci = Student.all_in_klass(@sci)
    in_math.merge(in_sci)
    return in_math.distinct
end

you're using a merge which will give you an AND and not the OR that you want.


I think I finally figured it out. Took long enough but I figured I'd share it.

class Student < ApplicationRecord

  has_many :enrollments
  has_many :klasses, through: :enrollments

  scope :all_in_klass, ->(klasses) { 
        joins(:enrollments)
            .where(enrollments: {klass_id: klasses} )
            .having('COUNT(*) = ?', klasses.size)
            .group(:id)
    }
end

Student.all_in_klass([@sci.id, @math.id])

So the trick I have here is to query the join table for matching ID's (does with WHERE IN when passing arrays), then group them by the student ID and avoid duplicates. Before grouping, the HAVING clause filters out results which don't share the amount of classes in the array (two classes in this case).

Further testing required as I am worried about that last HAVING clause. Not entirely sure I have a good understanding of how the SQL works but RSpec is all green so I am going with it.


Login or Create An Account to join the conversation.

Subscribe to the newsletter

Join 20,000+ developers who get early access to new screencasts, articles, guides, updates, and more.

    By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

    More of a social being? We're also on Twitter and YouTube.