Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rails 4.2: Eager-loading has_many relation with STI

Let's say I have a relation in Rails to a table that uses STI like:

class Vehicle < ActiveRecord::Base; end

class Car < Vehicle; end

class Truck < Vehicle; end

class Person < ActiveRecord::Base
  has_many :cars
  has_many :trucks
  has_many :vehicles
end

... and I want to load a Person and all of its cars and trucks in one query. This doesn't work:

# Generates three queries
p = Person.includes([:cars, trucks]).first

... and this is close, but no luck here:

# Preloads vehicles in one query
p = Person.includes(:vehicles).first
# and this has the correct class (Car or Truck)
p.vehicles.first
# but this still runs another query
p.cars

I could do something like this in person.rb:

def cars
  vehicles.find_all { |v| v.is_a? Car }
end

but then Person#cars isn't a collection proxy anymore, and I like collection proxies.

Is there an elegant solution to this?

EDIT: Adding this to Person gives me the items I want in arrays with one query; it's really pretty close to what I want:

def vehicle_hash
  @vehicle_hash ||= vehicles.group_by {|v|
    v.type.tableize
  }
end

%w(cars trucks).each do |assoc|
  define_method "#{assoc}_from_hash".to_sym do
    vehicle_hash[assoc] || []
  end
end

and now I can do Person.first.cars_from_hash (or find a better name for my non-synthetic use case).

like image 473
Nate Avatar asked Sep 29 '22 16:09

Nate


1 Answers

When you use includes, it stores those loaded records in the association_cache, which you can look at in the console. When you do p = Person.includes(:vehicles), it stores those records as an association under the key :vehicles. It uses whatever key you pass it in the includes.

So then when you call p.cars, it notices that it doesn't have a :cars key in the association_cache and has to go look them up. It doesn't realize that Cars are mixed into the :vehicles key.

To be able to access cached cars as either through p.vehicles OR p.cars would require caching them under both of those keys.

And what it stores is not just a simple array—it's a Relation. So you can't just manually store records in the Hash.

Of the solutions you proposed, I think including each key is probably the simplest—code-wise. Person.includes(:cars, :trucks) 3 SQL statements aren't so bad if you're only doing it once per request.

If performance is an issue, I think the simplest solution would be a lot like what you suggested. I would probably write a new method find_all_cars instead of overwriting the relation method.

Although, I would probably overwrite vehicles and allow it to take a type argument:

def vehicles(sti_type=nil)
  return super unless sti_type
  super.find_all { |v| v.type == sti_type }
end

EDIT

You can get vehicles cached by Rails, so you probably can just rely on that. Your define_methods could also do:

%w(cars trucks).each do |assoc|
  define_method "preloaded_#{assoc}" do
    klass = self.class.reflect_on_all_associations.detect { |assn| assn.name.to_s == assoc }.klass
    vehicles.select { |a| a.is_a? klass }
  end
end

Even if you don't use includes, the first time you call it, it will cache the association—because you're selecting, not whereing. You still won't get a Relation back, of course.

It's not really that pretty, but I like that it's contained to one method that doesn't depend on any other ones.

like image 70
evanbikes Avatar answered Oct 03 '22 09:10

evanbikes