ruby-on-railsjoinactiverecordassociationssingle-table-inheritance

Set up many to many associations between classes in single inheritance table and another table


I have a join table named Relations in a many to many relationship between departments and researchers.

I want to be able to get a list of students by doing Department.find(1).students but I am getting ActiveRecord::HasManyThroughSourceAssociationNotFoundError (Could not find the source association(s) :students in model Researcher. Try 'has_many :students, :through => :researchers, :source => <name>'.)

Why isn't it using the scope from the table Researcher?

class Department < ApplicationRecord
  has_many :relations
  has_many :researchers, through: :relations
  has_many :students, source: :students, through: :researchers
  has_many :advisors, source: :advisors, through: :researchers
end

class Relation < ApplicationRecord
  belongs_to :researcher
  belongs_to :department
end

class Reseacher < ApplicationRecord
  scope :students, -> { where(type: 'Student') }
  scope :advisors, -> { where(type: 'Advisor') }
end

class Student < Researcher
  has_many :relations, foreign_key: :department_id
  has_many :departments, through: :relations
end

class Advisor < Researcher
  has_many :relations, foreign_key: :department_id
  has_many :departments, through: :relations
end

Solution

  • source: option expects an association as argument. Internally, rails runs a reflection on the argument, like:

    # source: :students, through: :researchers
    >> Researcher.reflect_on_association(:students)
    => nil
    

    Before fixing has_many :students association, a few things to note:

    has_many :students,     # will look for `students` association in the intermediate
                            # model unless source is specified; intermediate model is
                            # determined by reflecting on through option `:researchers`
                            #
                            #   reflect_on_association(:researchers).klass # => Researcher
    
      through: :researchers # can't go through `researchers`; already there.
                            # `Student` is a `Researcher`.
    
      source: :students,    # there is no `students` association in `Researcher` class.
                            #
                            #   reflect_on_association(:researchers).klass
                            #     .reflect_on_association(:students) # => nil
    

    To fix the association we can use scope argument of has_many method:

    has_many(name, scope = nil, **options, &extension)
    #              ^ pass a proc as a second argument
    
    class Department < ApplicationRecord
      # NOTE: add `dependent: :destroy` option to destroy corresponding Relations
      #       when destroying a Department 
      has_many :relations, dependent: :destroy
      has_many :researchers, through: :relations
    
      has_many :students, 
        -> { where(type: "Student") }, # scope the associated model
    
        through: :relations,           # relevant association is in Relation model
    
        source:  :researcher           # look for `researcher` association in Relation.
                                       # instead of `student`
    
      # NOTE: use existing scope from another model
      has_many :advisors,
        -> { advisors },               # this runs in the source class.
        through: :relations,           #                    |
        source:  :researcher           # <------------------'
                                       # Researcher has `advisors` class method,
                                       # defined by `scope: :advisors`.
    end
    

    Now, we need to fix the association between Relation and Researcher:

    # NOTE: what if you need another "relation" class to make another many-to-many association.
    # TODO: call this something a bit more descriptive like `DepartmentStaff`
    #       or use the conventional `DepartmentResearcher`.
    class Relation < ApplicationRecord
      belongs_to :researcher
      belongs_to :department
    end
    
    class Researcher < ApplicationRecord
      scope :students, -> { where(type: "Student") }
      scope :advisors, -> { where(type: "Advisor") }
    
      # NOTE: `has_many :relations` is the opposite of `belongs_to :researcher`
      #       `foreign_key` is `researcher_id` which is the default and
      #       should not be changed.
      # has_many :relations, foreign_key: :department_id
    
      has_many :relations, dependent: :destroy        # <--.
      has_many :departments, through: :relations      # <--|
    end                                               #    |
                                                      #    |
    class Student < Researcher                        #    |
      # NOTE: no need to duplicate these; put it in the parent class.
      # has_many :relations
      # has_many :departments, through: :relations
    end
    
    class Advisor < Researcher
    end
    
    >> Relation.create!([{researcher: Student.new, department: Department.create},{researcher: Advisor.new, department: Department.first}])
    
    >> Department.first.students
    => [#<Student:0x00007f7f78ae5f98 id: 1, type: "Student">]
    
    >> Department.first.advisors        
    => [#<Advisor:0x00007f7f789a9b20 id: 2, type: "Advisor">]
    
    >> Department.first.researchers                                                            
    => [#<Student:0x00007f7f786bdc90 id: 1, type: "Student">, #<Advisor:0x00007f7f786bd858 id: 2, type: "Advisor">]
    

    You can also let rails do the work by defining additional associations in Relation, no scope required:

    class Relation < ApplicationRecord
      belongs_to :researcher
      belongs_to :department
    
      belongs_to :student, foreign_key: :researcher_id, optional: true
      belongs_to :advisor, foreign_key: :researcher_id, optional: true
    end
    
    class Department < ApplicationRecord
      has_many :relations, dependent: :destroy
      has_many :researchers, through: :relations
    
      has_many :students, through: :relations
      has_many :advisors, through: :relations
    end
    

    https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association

    https://api.rubyonrails.org/classes/ActiveRecord/Scoping/Named/ClassMethods.html#method-i-scope

    https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many

    https://api.rubyonrails.org/classes/ActiveRecord/Reflection/ClassMethods.html#method-i-reflect_on_association