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
考虑以下模型:
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