Eager Load Depending on Type of Association in Ruby on Rails
Asked Answered
P

7

12

I have a polymorphic association (belongs_to :resource, polymorphic: true) where resource can be a variety of different models. To simplify the question assume it can be either a Order or a Customer.

If it is a Order I'd like to preload the order, and preload the Address. If it is a customer I'd like to preload the Customer and preload the Location.

The code using these associations does something like:

<%- @issues.each do |issue| -%>
<%- case issue.resource -%>
<%- when Customer -%>
<%= issue.resource.name %> <%= issue.resource.location.name %>
<%- when Order -%>
<%= issue.resource.number %> <%= issue.resource.address.details %>
<%- end -%>

Currently my preload uses:

@issues.preload(:resource)

However I still see n-plus-one issues for loading the conditional associations:

SELECT "addresses".* WHERE "addresses"."order_id" = ...
SELECT "locations".* WHERE "locations"."customer_id" = ...
...

What's a good way to fix this? Is it possible to manually preload an association?

Propitiatory answered 13/3, 2017 at 21:0 Comment(5)
I no longer use polymorphic associations for reasons like the problem you are facing. (They also make data integrity more difficult).Dhahran
Rails is quite intelligent. Have you tried @issues.preloads(resouce: [:location, :address])?Broncobuster
@MarcRohloff it raises an exception ActiveRecord::AssociationNotFoundError. Doesn't look like that'll work (although a syntax like that'd be ideal).Propitiatory
@WizardofOgz I suppose you gain and loose some integrity. Using separate columns you can have the case where none of the columns are set - or all of the columns are set - or some of the columns are set. That said - it does make setting up a foreign key constraint easier...Propitiatory
@stussa you can (and should) add database constraints to only allow one column to be set. Here is an example of the constraints used by my dev team. It ensures that exactly one of a set of FKs is set CONSTRAINT owner_mutex_required CHECK (1 = (license_id IS NOT NULL)::integer + (contract_id IS NOT NULL)::integer + (policy_id IS NOT NULL)::integer)Dhahran
W
12

You can do that with the help of ActiveRecord::Associations::Preloader class. Here is the code:

@issues = Issue.all # Or whatever query
ActiveRecord::Associations::Preloader.new.preload(@issues.select { |i| i.resource_type == "Order" }, { resource: :address })
ActiveRecord::Associations::Preloader.new.preload(@issues.select { |i| i.resource_type == "Customer" }, { resource: :location })

You can use different approach when filtering the collection. For example, in my project I am using group_by

groups = sale_items.group_by(&:item_type)
groups.each do |type, items|
  conditions = case type
  when "Product" then :item
  when "Service" then { item: { service: [:group] } }
end

ActiveRecord::Associations::Preloader.new.preload(items, conditions)

You can easily wrap this code in some helper class and use it in different parts of your app.

Woodbine answered 17/3, 2017 at 23:6 Comment(0)
P
1

This is now working in Rails v6.0.0.rc1: https://github.com/rails/rails/pull/32655

You can do .includes(resource: [:address, :location])

Patriciapatrician answered 12/6, 2019 at 15:32 Comment(0)
D
0

You can break out your polymorphic association into individual associations. I have followed this and been extremely pleased at how it has simplified my applications.

class Issue
  belongs_to :order
  belongs_to :customer

  # You should validate that one and only one of order and customer is present.

  def resource
    order || customer
  end
end

Issue.preload(order: :address, customer: :location)

I have actually written a gem which wraps up this pattern so that the syntax becomes

class Issue
  has_owner :order, :customer, as: :resource
end

and sets up the associations and validations appropriately. Unfortunately that implementation is not open or public. However, it is not difficult to do yourself.

Dhahran answered 13/3, 2017 at 21:48 Comment(1)
Thanks for the answer. Unfortunately, I am looking for a solution that works with polymorphic associations and don't have the option of changing my association to be non-polymorphic (in the traditional sense).Propitiatory
W
0

You need to define associations in models like this:

class Issue < ActiveRecord::Base
  belongs_to :resource, polymorphic: true

  belongs_to :order, -> { includes(:issues).where(issues: { resource_type: 'Order' }) }, foreign_key: :resource_id
  belongs_to :customer, -> { includes(:issues).where(issues: { resource_type: 'Customer' }) }, foreign_key: :resource_id
end

class Order < ActiveRecord::Base
  belongs_to :address
  has_many :issues, as: :resource
end

class Customer < ActiveRecord::Base
  belongs_to :location
  has_many :issues, as: :resource
end

Now you may do required preload:

Issue.includes(order: :address, customer: :location).all

In views you should use explicit relation name:

<%- @issues.each do |issue| -%>
<%- case issue.resource -%>
<%- when Customer -%>
<%= issue.customer.name %> <%= issue.customer.location.name %>
<%- when Order -%>
<%= issue.order.number %> <%= issue.order.address.details %>
<%- end -%>

That's all, no more n-plus-one queries.

Wound answered 19/3, 2017 at 8:9 Comment(0)
D
0

I would like to share one of my query that i have used for conditional eager loading but not sure if this might help you, which i am not sure but its worth a try.

i have an address model, which is polymorphic to user and property. So i just check the addressable_type manually and then call the appropriate query as shown below:-

after getting either user or property,i get the address to with eager loading required models

##@record can be user or property instance
if @record.class.to_s == "Property"
     Address.includes(:addressable=>[:dealers,:property_groups,:details]).where(:addressable_type=>"Property").joins(:property).where(properties:{:status=>"active"})
else if @record.class.to_s == "User"
     Address.includes(:addressable=>[:pictures,:friends,:ratings,:interests]).where(:addressable_type=>"User").joins(:user).where(users:{is_guest:=>true})
end

The above query is a small snippet of actual query, but you can get an idea about how to use it for eager loading using joins because its a polymorphic table.

Hope it helps.

Deese answered 20/3, 2017 at 2:29 Comment(0)
U
0

If you instantiate the associated object as the object in question, e.g. call it the variable @object or some such. Then the render should handle the determination of the correct view via the object's class. This is a Rails convention, i.e. rails' magic.

I personally hate it because it's so hard to debug the current scope of a bug without something like byebug or pry but I can attest that it does work, as we use it here at my employer to solve a similar problem.

Instead of faster via preloading, I think the speed issue is better solved through this method and rails caching.

Underpart answered 20/3, 2017 at 20:31 Comment(0)
M
-1

I've come up with a viable solution for myself when I was stuck in this problem. What I followed was to iterate through each type of implementations and concatenate it into an array.

To start with it, we will first note down what attributes will be loaded for a particular type.

ATTRIBS = {
  'Order' => [:address],
  'Customer' => [:location]
}.freeze

AVAILABLE_TYPES = %w(Order Customer).freeze

The above lists out the associations to load eagerly for the available implementation types.

Now in our code, we will simply iterate through AVAILABLE_TYPES and then load the required associations.

issues = []

AVAILABLE_TYPES.each do |type|
  issues += @issues.where(resource_type: type).includes(resource: ATTRIBS[type])
end

Through this, we have a managed way to preload the associations based on the type. If you've another type, just add it to the AVAILABLE_TYPES, and the attributes to ATTRIBS, and you'll be done.

Marquesan answered 18/3, 2017 at 8:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.