Avoid N+1?

I’m building a social networking application, users be able to have some posts and other users able to like and comments on those posts.

class Post < ActiveRecord::Base
   belongs_to :user
   has_many :likes
   has_many :comments
end

class Comment < ActiveRecord::Base
  belongs :user
  belongs :post, counter_cache: true
end

class Like < ActiveRecord::Base
  belongs :user
  belongs :post, counter_cache: true
end

class PostSerializer < ActiveModel::Serializer
  attributes :id, :body, :likes_count, :comments_count, is_liked

  def likes_count
    object.likes.size   # should pull from counter_cache
  end

  def comments_count
    object.comments.size  # should pull from counter_cache
  end

  # did current_user already like this post
  def is_liked
    object.likes(user_id: scope.id).exists?  # N+1
  end
end

Another way to prevent N+1 this issue is to use Post.recent.includes(:likes, :comments), but I think there is still a problem with scaling because it has to load likes and comments and we are not interested much on detail of those associations at all.

Do you to have any suggestion solution? Or other ways to modeling this problem?

I haven’t tried this, so it might not work, but what about something like:

Post.joins(:likes).where(user_id: scope.id).exists?

@zamith I’m not sure, it looks like your code will return true or false if the user ever like a post. The problem with my version is just this code object.likes(user_id: scope.id).exists? will run for every post.

What about:

Post.joins(:likes).where(id: object.id, "likes.user_id" => scope.id).exists?

@zamith here is in my controller:

def recent
  posts = Post.recent.page(params[:page])
  render json: posts, meta: { total_pages: posts.total_pages, current_page: posts.current_page }
end

And it should call PostSerializer with each post. Every post it has to run another query to check if the current_user is already like the post in is_liked method. that’s what I try to avoid N+1.

Maybe:

Post.joins(:likes).where(id: object.id, "likes.user_id" => scope.id).recent.page(params[:page])

@zamith I think you are confusing here. In controller we want to return recent posts in the whole app. They are not return only recent posts of current_user. And object.id, object here is a reference to individual post of active model collection inside serializer class, so we can’t access in controller.

I’m using GitHub - rails-api/active_model_serializers: ActiveModel::Serializer implementation and Rails hooks

Yes, I know, I left it there because of copy & paste.

You want to get the recent posts for all the app and also from those you want to get the ones that were already liked by the user? Am I right?

Just recents posts by any users in the app, but we need to add another attributes is_liked to each post in json, so mobile app knows if the current user already liked some posts in the json response.

I don’t think you can do it in one query, but I’m not a DB expert. You can remove the N+1 query, though:

recent_posts = Post.recent.page(params[:page])
liked_posts = Post.joins(:likes).where(id: posts.ids, "likes.user_id" => current_user.id)

recent_posts.map! do |recent_post|
  if liked_posts.include? recent_post
    serialize(recent_post).merge({is_liked: true})
  else
    serialize(recent_post).merge({is_liked: false})
  end
end

Basically you get two sets and have to run through them. Don’t know how this works in terms of performance, but it should do the job with only two queries.

I’m not sure how to serialize a post, but in order to merge that has to be an hash, if you have an attr_accessor for liked in the model, then you can just set that. Also it is common practice to used liked= and liked? for boolean fields, instead of is_liked.

@zamith thanks for your help. Your solution looks interesting.