How can I access an ActiveRecord grandparent assoc

2020-07-11 07:54发布

问题:

I have a situation where I would like access to an associated grandparent before the parent object is saved. I can think of several hacks, but I'm searching for a clean way to accomplish this. Take the following code as an illustration of my problem:

class Company < ActiveRecord::Base
  has_many :departments
  has_many :custom_fields
  has_many :employees, :through => :departments
end
class Department < ActiveRecord::Base
  belongs_to :company
  has_many :employees
end
class Employee < ActiveRecord::Base
  belongs_to :department
  delegate :company, :to => :department
end

company = Company.find(1)           # => <Company id: 1>
dept = company.departments.build    # => <Department id: nil, company_id: 1>
empl = dept.employees.build         # => <Employee id: nil, department_id: nil>

empl.company   # => Employee#company delegated to department.company, but department is nil

I'm using Rails 3.2.15. I understand what is happening here, and I understand why empl.department_id is nil; though I wish Rails held a direct reference to the prospective association prior to calling save, such that the last line could be delegated through the unsaved department object. Is there a clean work around?

UPDATE: I've tried this in Rails 4 as well, here is a console session:

2.0.0-p247 :001 > company = Company.find(1)
  Company Load (1.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = ? LIMIT 1  [["id", 1]]
 => #<Company id: 1, name: nil, created_at: "2013-10-24 03:36:11", updated_at: "2013-10-24 03:36:11"> 
2.0.0-p247 :002 > dept = company.departments.build
 => #<Department id: nil, name: nil, company_id: 1, created_at: nil, updated_at: nil> 
2.0.0-p247 :003 > empl = dept.employees.build
 => #<Employee id: nil, name: nil, department_id: nil, created_at: nil, updated_at: nil> 
2.0.0-p247 :004 > empl.company
RuntimeError: Employee#company delegated to department.company, but department is nil: #<Employee id: nil, name: nil, department_id: nil, created_at: nil, updated_at: nil>
2.0.0-p247 :005 > empl.department
 => nil 

UPDATE 2: Here is a test project on github.

回答1:

Please look into the :inverse_of option for belongs_to and has_many. This option will handle two-way assignments when building and fetching associated records in different cases.

From Bi-directional associations for ActiveRecord::Associations::ClassMethods in the docs:

Specifying the :inverse_of option on associations lets you tell Active Record about inverse relationships and it will optimise object loading.



回答2:

I don't love this solution, but this seems to solve the problem:

empl = dept.employees.build { |e| e.association(:department).target = dept}

It turns out you can pass a block to build, and ActiveRecord will yield to the block with the newly created record. Who knows what ActiveRecord weirdness such will bring. I'm leaving the question open for now to see if there are better solutions.



回答3:

After some console experimentation--you can just say employee.department.company, even if department is not yet saved. department_id may be nil, but the department association is there.

2.0.0-p195 :041 > c = Company.create 
   (0.4ms)  begin transaction
  SQL (0.9ms)  INSERT INTO "companies" DEFAULT VALUES
   (486.4ms)  commit transaction
 => #<Company id: 4, department: nil, custom_fields: nil> 
2.0.0-p195 :042 > d = c.departments.build
 => #<Department id: nil, company_id: 4, employee_id: nil> 
2.0.0-p195 :043 > e = d.employees.build
 => #<Employee id: nil, department_id: nil> 
2.0.0-p195 :044 > e.department === d
 => true 
2.0.0-p195 :045 > e.department.company === c
 => true

Edit: so, this didn't work on a different machine with another clean Rails 4 app. However, it still works on my laptop...also in a clean Rails 4 app. Let's try and figure out what's different!

e.method(:department)
=> #<Method: Employee(Employee::GeneratedFeatureMethods)#department> 

e.method(:department).source_location
=> ["/home/neil/.rvm/gems/ruby-2.0.0-p195/gems/activerecord-  
    4.0.0/lib/active_record/associations/builder/association.rb", 69] 

Which leads us here:

def define_readers
  mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}(*args)
      association(:#{name}).reader(*args)
    end
  CODE
end

No surprises really, this defines a method called :department

def department *args
  association(:department).reader(*args)
end

That call to reader just returns us the @target of the association if it is present, or tries to read it if it has an id at hand. In my case the @target is set to department d. To discover the point at which @target is set, we can intercept target= in ActiveRecord::Associations::Association:

class ActiveRecord::Associations::Association 
  alias :_target= :target=
  def target= t
    puts "#{caller} set the target!"
    _target = t
  end
end

Now when we call d.employees.build we get this...

"/home/neil/.rvm/gems/ruby-2.0.0-p195/gems/activerecord-4.0.0/lib/active_record/associations/association.rb:112:in `set_inverse_instance'", 
"/home/neil/.rvm/gems/ruby-2.0.0-p195/gems/activerecord-4.0.0/lib/active_record/associations/collection_association.rb:376:in `add_to_target'",
"/home/neil/.rvm/gems/ruby-2.0.0-p195/gems/activerecord-4.0.0/lib/active_record/associations/collection_association.rb:114:in `build'"

set_inverse_instance is checking invertible_for?(record), (where record is our new Employee instance.) This just calls reflection.inverse_of, and this has to return a truthy value in order for target to be set.

def inverse_of
  return unless inverse_name

  @inverse_of ||= klass.reflect_on_association inverse_name
end

So let's try that out...

2.0.0-p195 :055 > Employee.reflect_on_association :department
 => #<ActiveRecord::Reflection::AssociationReflection:0xa881788 @macro=:belongs_to, @name=:department, @scope=nil, @options={}, @active_record=Employee(id: integer, department_id: integer), @plural_name="departments", @collection=false, @class_name="Department", @foreign_key="department_id"> 

That's non-nil, so @target will be set in my association when I call d.employee.build, so I can call e.department, and so on. So why is it non-nil here, but nil for you (and over on my other machine?) If I call Employee.reflections, I get the following:

> Employee.reflections
 => {:department=>#<ActiveRecord::Reflection::AssociationReflection:0x9a04598 @macro=:belongs_to, @name=:department, @scope=nil, @options={}, @active_record=Employee(id: integer, department_id: integer), @plural_name="departments", @collection=false, @class_name="Department", @foreign_key="department_id">} 

This is the product of the belongs_to method--it's got to be there if you look. So why (in your case) doesn't set_inverse_instance find it?