Rails 3.2 无1+n查询的条件关联统计方法

Rails 3.2 way to count conditional associations without 1+n queries

考虑以下模型:

User < ActiveRecord::Base
  has_many :posts
end

Post < ActiveRecord::Base
  belongs_to :user
end

现在我想显示用户在过去 24 小时 .

中的帖子数量

显然 counter_cache 在这里不起作用,因为我只想计算符合条件 created_at > 24.hours.ago

的记录

在控制器中我会有这个:

@users = User.order(:name)

在视图中我会有这个

<table>
  <tr>
    <th>Name</th>
    <th>Recent posts</th>
  </tr>
  <% @users.each do |user| %>
  <tr>
    <td><%= user.name %></td>
    <td><%= user.posts.where('created_at > ?', 24.hours.ago).count %></td>
  </tr>
  <% end %>
</table>

现在这显然对每个用户进行查询,导致可怕的 1+n 查询问题。由于计数是有条件的,因此在控制器中添加 .includes(:posts) 没有任何效果。

获取原始 SQL 的结果是微不足道的。获得这些结果的正确 Rails 方法 是什么?最好在某种程度上也适用于旧的 3.2 版本。

我只想到2个解决方案:

解决方案 1:先预加载,select 条件为

的结果

在控制器中:

@users = User.includes(:posts).order(:name)

在视图中:

<% @users.each do |user| %>
  <tr>
    <td><%= user.name %></td>
    <td><%= user.posts.to_a.count{ |post| post.created_at > 24.hours.ago } %></td>
  </tr>
<% end %>

解决方案 2:自定义查询:

在控制器中:

@users = User.joins("LEFT OUTER JOIN (SELECT user_id, COUNT(*) as posts_count
                                      FROM posts
                                      WHERE created_at > '#{24.hours.ago}'
                                      GROUP BY user_id
                                     ) AS temp ON temp.user_id = users.id")
             .order(:name)
             .select('users.*, COALESCE(temp.posts_count, 0) AS posts_count')

解释查询:

  • 我们使用LEFT OUTER JOIN是因为有些帖子不匹配WHERE子句,所以在子查询中会被排除,但是在join之后是temp.posts_count 将为空
  • temp.posts_count使用COALESCE,如果是nil,则视为0

在视图中:

<% @users.each do |user| %>
  <tr>
    <td><%= user.name %></td>
    <td><%= user.posts_count %></td>
  </tr>
<% end %>

顺便说一句,1.day.ago = 24.hours.ago,我们可以用它来代替,因为它是一个较短的版本:|

我建议内部加入 posts 并让数据库在 group by 的帮助下计数。那么你不需要实例化 posts。 SQL 应该看起来像:

SELECT users.*, count(posts.id) AS number_posts 
  FROM users
  LEFT OUTER JOIN posts
    ON posts.user_id = users.id
    AND posts.created_at > '2016-02-14 08:31:29' 
  GROUP BY users.id

此外,您还可以利用 select,它会动态添加计数的 post 作为附加属性。您只能使用AREL 来实现扩展的JOIN 条件。 您应该将其推入命名范围,例如:

User < ActiveRecord::Base
  has_many :posts
  scope :with_counted_posts(time=1.day.ago) -> {
    post_table = Post.arel_table
    join = Arel::Nodes::On.new(Arel::Nodes::Equality                            
      .new(post_table[:user_id], self.arel_table[:id])
      .and(Arel::Nodes::GreaterThan.new(post_table[:created_at], time))
    )                                                                           
    joins(Arel::Nodes::OuterJoin.new(post_table, join))              
      .group('users.id')                                               
      .select('users.*, count(posts.id) AS number_posts')  
  }
end

当然有优化和提取的潜力,但出于一些理解原因,我做得更广泛。 然后在控制器中:

@users = User.with_counted_posts.order(:name)

users/index.html.erb 视图可能如下所示:

<table>
  <tr>
    <th>Name</th>
    <th>Recent posts</th>
  </tr>
  <% @users.each do |user| %>
  <tr>
    <td><%= user.name %></td>
    <td><%= user.number_posts %></td>
  </tr>
  <% end %>
</table>

尽管我强烈建议利用 render :collection 方法。 users/index.html.erb 再次:

<table>
  <tr>
    <th>Name</th>
    <th>Recent posts</th>
  </tr>
  <%= render @users %>
</table>

users/_user.html.erb 部分:

  <tr>
    <td><%= user.name %></td>
    <td><%= user.number_posts %></td>
  </tr>

我还写了一篇关于 N+1 problem and ARel

的博客 post