Rails STI association with subclasses
Asked Answered
O

4

8

I'm getting some strange behaviour when fetching collections from a has_many association with rails 3 when using STI. I have:

class Branch < ActiveRecord::Base
   has_many :employees, class_name: 'User::Employee'
   has_many :admins, class_name: 'User::BranchAdmin'
end

class User < ActiveRecord::Base
end

class User::Employee < User
  belongs_to :branch
end

class User::BranchAdmin < User::Employee
end

The desired behaviour is that branch.employees returns all employees including branch admins. The branch admins only seem to be 'loaded' under this collection when they have been accessed by branch.admins, this is output from the console:

Branch.first.employees.count
=> 2

Branch.first.admins.count
=> 1

Branch.first.employees.count
=> 3

This can be seen in the generated SQL, the first time:

SELECT COUNT(*) FROM "users" WHERE "users"."type" IN ('User::Employee') AND "users"."branch_id" = 1

and the second time:

SELECT COUNT(*) FROM "users" WHERE "users"."type" IN ('User::Employee', 'User::BranchAdmin') AND "users"."branch_id" = 1

I could solve this problem by just specifying:

class Branch < ActiveRecord::Base
   has_many :employees, class_name: 'User'
   has_many :admins, class_name: 'User::BranchAdmin'
end

since they all be found from their branch_id but this creates problems in the controller if I want to do branch.employees.build then the class will default to User and I have to hack at the type column somewhere. I have got around this for now with:

  has_many :employees, class_name: 'User::Employee', 
    finder_sql: Proc.new{
      %Q(SELECT users.* FROM users WHERE users.type IN          ('User::Employee','User::BranchAdmin') AND users.branch_id = #{id})
    },
    counter_sql: Proc.new{
      %Q(SELECT COUNT(*) FROM "users" WHERE "users"."type" IN ('User::Employee', 'User::BranchAdmin') AND "users"."branch_id" = #{id})
    }

but I would really like to avoid this if possible. Anyone, any ideas?

EDIT:

The finder_sql and counter_sql haven't really solved it for me because it seems that parent associations don't use this and so organisation.employees that has_many :employees, through: :branches will again only include the User::Employee class in the selection.

Ornithorhynchus answered 20/6, 2012 at 11:21 Comment(0)
R
21

Basically, the problem only exists in the development environment where classes are loaded as needed. (In production, classes are loaded and kept available.)

The problem comes in due to the interpreter not having seen yet that Admins are a type of Employee when you first run the Employee.find, etc. call.

(Notice that it later uses IN ('User::Employee', 'User::BranchAdmin'))

This happens with every use of model classes that are more than one level deep, but only in dev-mode.

Subclasses always autoload their parent hierarchy. Base classes don't autoload their child hierachies.

Hack-fix:

You can force the correct behaviour in dev-mode by explicitly requiring all your child classes from the base class rb file.

Riband answered 25/11, 2012 at 13:50 Comment(2)
This is a great catch, thanks. The model structure was actually changed anyway so the problem disappeared but I don't think I would have even considered it being an effect of the environment!Ornithorhynchus
Are you sure that this is just in dev mode? I believe I've encountered this behavior in production. Running rails 5.2Sienna
S
2

Can you use :conditions?

class Branch < ActiveRecord::Base
   has_many :employees, class_name: 'User::Employee', :conditions => {:type => "User::Employee"}
   has_many :admins, class_name: 'User::BranchAdmin', :conditions => {:type => "User::BranchAdmin"}
end

This would be my preferred method. One other way to do it might be to add a default scope to the polymorphic models.

class User::BranchAdmin < User::Employee
  default_scope where("type = ?", name)
end
Signorino answered 20/9, 2012 at 22:4 Comment(1)
I did try using conditions but had problems with this too. The structure of the app has changed now so I did not need to worry about this, this may have been fixed in rails 3.2.7.Ornithorhynchus
M
1

A similar problem continues to exist in Rails 6.

This link outlines the issue and workaround. It contains the following explanation and code snippet:

Active Record needs to have STI hierarchies fully loaded in order to generate correct SQL. Preloading in Zeitwerk was designed for this use case:

By preloading the leaves of the tree, autoloading will take care of the entire hierarchy upwards following superclasses.

These files are going to be preloaded on boot, and on each reload.

# config/initializers/preload_vehicle_sti.rb

autoloader = Rails.autoloaders.main
sti_leaves = %w(car motorbike truck)

sti_leaves.each do |leaf|
  autoloader.preload("#{Rails.root}/app/models/#{leaf}.rb")
end

You may require a spring stop for the configuration changes to take.

Martyrology answered 5/10, 2021 at 15:55 Comment(0)
S
0

Indeed, that was the plan in the early days of the gem, but it was abandoned soon (in 2019, before Rails 6 was out). Preloading has been deprecated for a long time, and has been deleted in the forthcoming Zeitwerk 2.5.

In a Rails application you can do it this way:

# config/initializers/preload_vehicle_sti.rb
Rails.application.config.to_prepare do
  Car
  Motorbike
  Truck
end

That is, you "preload" just by using the constants in a to_prepare block.

Shifra answered 12/10, 2021 at 17:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.