【问题标题】:How do I select average of aggregated subquery in Rails如何在 Rails 中选择聚合子查询的平均值
【发布时间】:2021-01-15 11:50:17
【问题描述】:

我有以下返回预期结果的数据和 SQL 查询。我现在正尝试使用 Groupdate(和 ActiveMedian)在 Rails 中实现相同的目标。

user_id points created_at
1 5 2020-10-01
1 15 2020-11-01
1 20 2020-11-02
2 33 2020-11-01
SELECT
  AVG(points) AS points,
  DATE_TRUNC('MONTH', created_at) AS period
FROM
  (
    SELECT
      SUM(points) AS points,
      DATE_TRUNC('MONTH', created_at) AS created_at
    FROM
      user_points
    WHERE
      created_at IS NOT NULL
    GROUP BY
      user_id,
      DATE_TRUNC('MONTH', created_at)
  ) points_per_user_per_period
GROUP BY
  DATE_TRUNC('MONTH', created_at)

我期待

points period
5 2020-10-01
34 2020-11-01

结果

UserPoints.from(
  UserPoints.group(:user_id).group_by_month(:created_at).sum(:points)
).group_by_month(:created_at).average(:points)

但由于.sum(:points) 立即执行,它不起作用。我可以用另一种方式制定查询或使sum 不立即执行吗?还有其他想法吗?

总结结果应该是什么;用户在一段时间内获得的平均积分数。

【问题讨论】:

  • 如果 SQL 查询正常,您可以使用ActiveRecord::Base.connection.execute(sql_string) 执行查询。它作为一个元组返回,所以如果你在返回时调用values,你会得到结果
  • 这就是我目前正在做的事情,但我想让它更通用,更像 Rails :)
  • 有时这就是 Rails 的方式。您可能已经在这个用例中发现了 AR 的局限性。我以前必须使用这样的原始 SQL 以确保尽可能优化,然后在结果上使用 rails/ruby,但尽可能多地在数据库上做繁重的工作。

标签: ruby-on-rails postgresql activerecord


【解决方案1】:

如果我们删除 Rails 的一个层,并使用支持 Active Record 的关系代数 (Arel),这是可能的。

在这种方法中,我们将亲自向 Arel 教授 date_trunc 函数*,然后为内部求和构建一个嵌套聚合查询,该查询不会立即执行,而是合并到外部平均聚合:

class UserPoints
  def self.averages
    period = Arel::Nodes::NamedFunction.new('date_trunc', [
      Arel::Nodes::Quoted.new('month'),
      arel_table[:created_at]
    ])
    points = arel_table[:points].sum
    
    # The per-user aggregate sum subquery, as an abstract relational structure
    subquery = select(points.as("points"), period.as("period")).group(:user_id, :period)

    # Execute
    from(subquery, quoted_table_name).group(:period).average(:points)
  end
end

这种方法用途广泛。适应范围关系的组合;例如,如果你想写UserPoints.where(created_at: Time.current.all_year).averages,那么在最后一行插入一个合适的unscope,变成:

from(subquery, quoted_table_name).unscope(:where).group(:period).average(:points)

同样,要与 Groupdate 库结合使用,至少对于外部查询*,请尝试:

from(subquery, quoted_table_name).group_by_month(:period).average(:points)

甚至可以通过省略最终的聚合表达式将其重构为 scope 声明,从而获得使用其他表达式的灵活性。

现在需要注意的是:Arel 是 Rails 内部 API,这意味着如果您需要文档,则需要阅读其源代码,即使在次要版本中也可能会发生重大更改。这实际上很少见,如果您的代码佩戴了适当的安全装备,则可以使用 Arel(很多人都这样做),这当然是一个合适的测试用例。


* 我没有将 Groupdate gem 用于内部聚合查询,因为它缺少命名结果列的方法。

【讨论】:

    【解决方案2】:

    解决方案 #2,这不是解决方案

    这个额外的答案也使用了 Arel 和 Groupdate gem,但它实际上比我的 recommended approach 更危险。我将它作为一个单独的答案包含在内,因为 a) 它有效,并且 b) 它 看起来 所以 优雅

    class UserPoints < ApplicationRecord
      def self.averages_by_month
        # average of points = total points / # of distinct users
        points_avg = arel_table[:points].sum / arel_table[:user_id].count(true)
    
        # execute, grouped by month
        group_by_month(:created_at).calculate(:itself, points_avg)
      end
    end
    

    这给出了正确的结果!至少,在编写 Rails 6 时是这样。

    不幸的是,骗局正在酝酿之中;这种方法依赖于更多的 Active Record 内部知识,而不仅仅是 Arel API。

    讨论,或者为什么这是不好的

    #calculate method's documented parameters 是:

    relation.calculate(operation, column_name)
    

    虽然支持在聚合计算was added intentionally 中使用 Arel 表达式,但它没有记录在公共 API 中。单独来说,这可能不是那么糟糕,但是这个方法依赖于一个实现细节:Rails 在调用 Arel 表示时使用 operation 参数作为方法名称来构造完整的聚合表达式列参数,从而获得聚合表达式作为回报。通过传递:itself,我劫持了框架层之间的这种内部通信,并导致points_avg 在内部往返期间通过Kernel#itself 返回自身。

    这是一个元编程技巧,就像所有特技一样,演示它很有趣,但不应该成为任何人的生产代码的一部分,至少不是,除非有一天 #calculate 方法被记录为接受裸 Arel 表达式,因为我们'否则取决于对 Rails 内部的非常深入的了解,即这是一个维护禁忌。

    除此之外,还有一些更相关的假设,关于分组聚合表达式评估的核心中的其他元素,例如期望列别名只处理它提供的任何内容。这也行得通,所以有些人可能会说这也证明了 Rails 的强大和多功能性,但它肯定是在测试合理假设的边界。

    推荐

    总体而言,尽管此解决方案明显简洁和优雅,但它比我准备推荐的生产用途更模糊。相反,我将其呈现并解释为一种有趣的新奇事物。

    谁知道呢,有朝一日这甚至可能会被明确支持。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多