Rails: includes with polymorphic association
Asked Answered
A

6

43

I read this interesting article about Using Polymorphism to Make a Better Activity Feed in Rails.

We end up with something like

class Activity < ActiveRecord::Base
  belongs_to :subject, polymorphic: true
end

Now, if two of those subjects are for example:

class Event < ActiveRecord::Base
  has_many :guests
  after_create :create_activities
  has_one :activity, as: :subject, dependent: :destroy 
end

class Image < ActiveRecord::Base
  has_many :tags
  after_create :create_activities
  has_one :activity, as: :subject, dependent: :destroy 
end

With create_activities defined as

def create_activities
  Activity.create(subject: self)
end

And with guests and tags defined as:

class Guest < ActiveRecord::Base
  belongs_to :event
end

class Tag < ActiveRecord::Base
  belongs_to :image
end

If we query the last 20 activities logged, we can do:

Activity.order(created_at: :desc).limit(20)

We have a first N+1 query issue that we can solve with:

Activity.includes(:subject).order(created_at: :desc).limit(20)

But then, when we call guests or tags, we have another N+1 query problem.

What's the proper way to solve that in order to be able to use pagination ?

Ajmer answered 25/2, 2014 at 11:27 Comment(4)
what are guests and tags ? please provide the relevant codePuduns
@Puduns done, but I'm pretty sure you had guessed what they areAjmer
yes what I can't see is how Event, Image, Guest and Tag are connected to ActivityPuduns
@Puduns Done and removed user to simplifyAjmer
P
9

This will hopefully be fixed in rails 5.0. There is already an issue and a pull request for it.

https://github.com/rails/rails/pull/17479

https://github.com/rails/rails/issues/8005

I have forked rails and applied the patch to 4.2-stable and it works for me. Feel free to use my fork, even though I cannot guarantee to sync with upstream on a regular basis.

https://github.com/ttosch/rails/tree/4-2-stable

Profit answered 19/1, 2015 at 13:15 Comment(3)
Related pull request is still open. So it will not be fixed in Rails 5.0 or 5.1.Aubervilliers
github.com/rails/rails/pull/32655#issuecomment-383227906 is the final PR merged to rails master on Apr 21, 2018. However in Change Log at the bottom 5-2-stable is mentioned so I assume the feature should be available in Rails 5.2.x and later versions.Scammon
Unless I am missing something this didn't make it into 5.2.3.Pique
A
21

Edit 2: I'm now using rails 4.2 and eager loading polymorphism is now a feature :)

Edit: This seemed to work in the console, but for some reason, my suggestion of use with the partials below still generates N+1 Query Stack warnings with the bullet gem. I need to investigate...

Ok, I found the solution ([edit] or did I ?), but it assumes that you know all subjects types.

class Activity < ActiveRecord::Base
  belongs_to :subject, polymorphic: true

  belongs_to :event, -> { includes(:activities).where(activities: { subject_type: 'Event' }) }, foreign_key: :subject_id
  belongs_to :image, -> { includes(:activities).where(activities: { subject_type: 'Image' }) }, foreign_key: :subject_id
end

And now you can do

Activity.includes(:part, event: :guests, image: :tags).order(created_at: :desc).limit(10)

But for eager loading to work, you must use for example

activity.event.guests.first

and not

activity.part.guests.first

So you can probably define a method to use instead of subject

def eager_loaded_subject
  public_send(subject.class.to_s.underscore)
end

So now you can have a view with

render partial: :subject, collection: activity

A partial with

# _activity.html.erb
render :partial => 'activities/' + activity.subject_type.underscore, object: activity.eager_loaded_subject

And two (dummy) partials

# _event.html.erb
<p><%= event.guests.map(&:name).join(', ') %></p>

# _image.html.erb
<p><%= image.tags.first.map(&:name).join(', ') %></p>
Ajmer answered 28/2, 2014 at 23:8 Comment(4)
Brilliant! Thank you for this.Alexandria
Just to emphasize a point made -- you need to access the nested resource using the custom belongs_to scope you made (here, activity.event ...). If you are confused why Rails seemed to preload your associations but still likes to N+1, this is most certainly why.Rubella
I was missing that includes in the :event and :tag associations. Thanks! You should mark that as the correct answer.Fusibility
@Fusibility Glad it helped you! It's so old I don't remember why I accepted the other answer, so I'll just trust my old self that there was a good reason ^^Ajmer
P
9

This will hopefully be fixed in rails 5.0. There is already an issue and a pull request for it.

https://github.com/rails/rails/pull/17479

https://github.com/rails/rails/issues/8005

I have forked rails and applied the patch to 4.2-stable and it works for me. Feel free to use my fork, even though I cannot guarantee to sync with upstream on a regular basis.

https://github.com/ttosch/rails/tree/4-2-stable

Profit answered 19/1, 2015 at 13:15 Comment(3)
Related pull request is still open. So it will not be fixed in Rails 5.0 or 5.1.Aubervilliers
github.com/rails/rails/pull/32655#issuecomment-383227906 is the final PR merged to rails master on Apr 21, 2018. However in Change Log at the bottom 5-2-stable is mentioned so I assume the feature should be available in Rails 5.2.x and later versions.Scammon
Unless I am missing something this didn't make it into 5.2.3.Pique
C
4

You can use ActiveRecord::Associations::Preloader to preload guests and tags linked, respectively, to each of the event and image objects that are associated as a subject with the collection of activities.

class ActivitiesController < ApplicationController
  def index
    activities = current_user.activities.page(:page)

    @activities = Activities::PreloadForIndex.new(activities).run
  end
end

class Activities::PreloadForIndex
  def initialize(activities)
    @activities = activities
  end

  def run
    preload_for event(activities), subject: :guests
    preload_for image(activities), subject: :tags
    activities
  end

  private

  def preload_for(activities, associations)
    ActiveRecord::Associations::Preloader.new.preload(activities, associations)
  end

  def event(activities)
    activities.select &:event?
  end

  def image(activities)
    activities.select &:image?
  end
end
Cogitate answered 28/11, 2016 at 13:37 Comment(2)
Your link is not available anymore :-(Sydney
@SvenR. apparently that website no longer exists. Since the answer contains complete information, that link is not necessary. So I deleted it.Cogitate
D
2
image_activities = Activity.where(:subject_type => 'Image').includes(:subject => :tags).order(created_at: :desc).limit(20)
event_activities = Activity.where(:subject_type => 'Event').includes(:subject => :guests).order(created_at: :desc).limit(20)
activities = (image_activities + event_activities).sort_by(&:created_at).reverse.first(20)
Draghound answered 27/2, 2014 at 13:7 Comment(0)
P
1

I would suggest adding the polymorphic association to your Event and Guest models. polymorphic doc

class Event < ActiveRecord::Base
  has_many :guests
  has_many :subjects
  after_create :create_activities
end

class Image < ActiveRecord::Base
  has_many :tags
  has_many :subjects
  after_create :create_activities
end

and then try doing

Activity.includes(:subject => [:event, :guest]).order(created_at: :desc).limit(20)
Puduns answered 25/2, 2014 at 11:58 Comment(1)
I edited the question again. Event and Image only have one activity, created when they are created. What you wrote won't work through, you're calling event on event. And even (subject: :guests) wouldn't work (rather, it would break for Images).Ajmer
U
1

Does this generate a valid SQL query or does it fail because events can't be JOINed with tags and images can't be JOINed with guests?

class Activity < ActiveRecord::Base
  self.per_page = 10

  def self.feed
    includes(subject: [:guests, :tags]).order(created_at: :desc)
  end
end

# in the controller

Activity.feed.paginate(page: params[:page])

This would use will_paginate.

Unship answered 28/2, 2014 at 19:10 Comment(1)
That was my guess too but it doesn't work. If the first result is an Event, you will get an error "ActiveRecord::ConfigurationError: Association named 'tags' was not found on Event"Ajmer

© 2022 - 2024 — McMap. All rights reserved.