Save 36% for Black Friday! Learn more

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

Ask A Question

Notifications

You’re not receiving notifications from this thread.

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

Peter Marcano asked in Rails

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.

Join the discussion

Want to stay up-to-date with Ruby on Rails?

Join 39,376+ developers who get early access to new tutorials, screencasts, articles, and more.

    We care about the protection of your data. Read our Privacy Policy.

    logo Created with Sketch.

    Ruby on Rails tutorials, guides, and screencasts for web developers learning Ruby, Rails, Javascript, Turbolinks, Stimulus.js, Vue.js, and more. Icons by Icons8

    © 2020 GoRails, LLC. All rights reserved.