【问题标题】:With DATE_TRUNC('week'), Return a record if the week's .sum(:points) has 0?使用 DATE_TRUNC('week'),如果周的 .sum(:points) 为 0,则返回一条记录?
【发布时间】:2017-06-21 03:21:02
【问题描述】:

我有以下 Rails+Postgres 查询来查询数据库的总点数并按周分组:

Log Table: id, user_id, points, created_at

points_by_week = Log.where(user_id: user_id).group("DATE_TRUNC('year', created_at)", "DATE_TRUNC('week', created_at)").sum(:points)

这样返回结果:

{ [2017-01-01 00:00:00 UTC, 2017-04-17 00:00:00 UTC]=>10, 
  [2017-01-01 00:00:00 UTC, 2017-05-15 00:00:00 UTC]=>110, 
  [2017-01-01 00:00:00 UTC, 2017-06-19 00:00:00 UTC]=>1185 }

问题是这仅在每周存在 1 个或更多点时才返回记录。

我该怎么做:

  1. 如果一周包含 0 点,则返回一周结果。例如,对于上面的返回,2017-05-08 的周是缺失的,因为它有 0 分。我希望它返回上面。所以当我绘制这个结果时,周显示为 0。
  2. 指定要返回多少周?并确保如果一周包含 0 分,则按照上面的 #1 输出。

谢谢

【问题讨论】:

  • 一周内2017-05-08,是积分0还是nil
  • nil,在这种情况下,该周没有记录...
  • 为了明确没有返回该周的结果,它只是跳过了该周,如我提供的返回结果中所示。

标签: ruby-on-rails ruby postgresql ruby-on-rails-5


【解决方案1】:

我不同意这足以保证使用 SQL 或 Arel。你需要的结果的 95% 就在那里,剩下的应该是微不足道的。

方法一

如果您只想从哈希中获取值,请考虑在哈希上定义一个默认值,如下所示:

points_by_week =
  Hash.
  new(0).
  merge(
    Log.
    where(user_id: user_id).
    group("DATE_TRUNC('year', created_at)", "DATE_TRUNC('week', created_at)").
    sum(:points)
  )

然后,无论何时调用:

points_by_week[x]

...如果 x 不在散列中,则返回 0。

如果不存在的键需要 nil,那么您将传递 nil 而不是 0 到 Hash.new

方法二

或者,如果您确实需要在散列中填充整个值集,您可以根据范围(我使用过此处为 0 到 100 的整数):

Hash[*(0..5).to_a.zip(Array.new(6,0)).flatten]
 => {0=>0, 1=>0, 2=>0, 3=>0, 4=>0, 5=>0} 

...或...

Hash[*(0..5).to_a.zip(Array.new(6)).flatten]
 => {0=>nil, 1=>nil, 2=>nil, 3=>nil, 4=>nil, 5=>nil} 

(也许有更方便的方法),然后#reverse_merge 使用您当前的哈希值。

例如

2.2.5 :036 > x = {1 => 1, 3 => 3}
 => {1=>1, 3=>3} 
2.2.5 :037 > y = Hash[*(0..5).to_a.zip(Array.new(6,0)).flatten]
 => {0=>0, 1=>0, 2=>0, 3=>0, 4=>0, 5=>0} 
2.2.5 :038 > z = x.reverse_merge(y)
 => {0=>0, 1=>1, 2=>0, 3=>3, 4=>0, 5=>0} 

星期一日期的数组可以通过以下方式生成:

mondays = (Date.parse("2017-04-17")..(Date.parse("2017-04-17")+20.weeks)).map(&:to_time).select(&:monday?)

... 并转换为 [year,date] 对 ...

year_monday_pairs = mondays.map{|d| [d.beginning_of_year, d] }

... 并转换为 #reverse_merge 所需的哈希 ...

Hash[*year_monday_pairs.zip(Array.new(year_monday_pairs.size, 0)).flatten(1)]
 => {[2017-01-01 00:00:00 +0000, 2017-04-17 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-04-24 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-05-01 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-05-08 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-05-15 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-05-22 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-05-29 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-06-05 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-06-12 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-06-19 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-06-26 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-07-03 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-07-10 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-07-17 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-07-24 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-07-31 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-08-07 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-08-14 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-08-21 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-08-28 00:00:00 +0100]=>0, [2017-01-01 00:00:00 +0000, 2017-09-04 00:00:00 +0100]=>0} 

【讨论】:

  • 因为 nil 是预期的结果 Hash.new(0) 并不是真正需要的(虽然它看起来更正确)但我同意我可能过度认为解决方案是对原始答案的反应.作为旁注,可能会更改您的查询以解决第 2 点。此外,解释如何为迭代目的构建一系列“周”可能会很有用
  • @engineersmnky 是的,注意到了。更新以反映您有用的 cmets。
  • 已经投票,否则我会再次投票。非常干净简单的解决方案
【解决方案2】:

首先,我强烈建议开始学习并使用纯 SQL 来执行此类任务。 Active Record(与任何其他 ORM 一样)仅适用于简单任务。迟早您需要深入研究 Postgres 日志并找出您的 RoR AR 生成的确切 SQL 查询,为什么它们不是最佳的,或者为什么这些查询现在可以提供您想要的。

现在关于您的问题本身。我会这样做,使用 LEFT JOIN 和 CTE(参见 https://www.postgresql.org/docs/current/static/queries-with.html):

with dates(period) as (
  select generate_series
  from generate_series(
    '2017-01-01'::timestamp,
    now(),
    interval '1 week'
  )
)
select
  period,
  points
from
  dates
left join log_table on date_trunc('week', created_at) = date_trunc('week', period)
group by 1
order by 1
;

CTE 是标准的 SQL 特性,非常值得学习和使用。

要处理数据库中的数据,请考虑首先使用数据库 (SQL)。

甚至还有一种越来越受欢迎的意识形态——#DatabaseFirst。

如果您有疑问,这里有一些与该主题相关的好文章:

http://database-programmer.blogspot.co.at/2010/12/historical-perspective-of-orm-and.html?m=1

http://tapoueh.org/blog/2017/06/sql-and-business-logic/

http://tapoueh.org/blog/2017/06/how-to-write-sql/

【讨论】:

  • 解决方案很好,但是如果您从 2017 年 1 月 1 日开始,该连接将不起作用,因为 generate_seriesdate_trunc 生成的时间戳将不匹配。你需要date_trunc('week'你的开始日期。
  • @Nick 我发现像这样的 ORM 中的普通 SQL 不合适。 Arel rails/activerecord 的底层查询组装器可以使用代码而不是纯字符串组装任何有效的 SQL。此外,在大多数情况下,它还允许您将结果合并到真正的 AR 对象中
  • @engineersmnky 谢谢。有没有办法做到这一点?
  • @nick 我很感激你的回复,但我很想找到一种 Rails 的方式来做到这一点....
  • @AnnaSm 我已经发布了一个答案,尽管我确信它可能会令人困惑,它应该返回请求的结果。 Nick 感谢您提供的建议 SQL,当我不必弄清楚如何生成周数时,它使创建查询变得更简单(我不经常使用 postgres)
【解决方案3】:

请注意,我没有花时间重新创建您的结构。

对其他读者的警告

旧版本的 Arel 不汇编 CTE 语句。 (我遇到了

对于这种类型的功能,我要做的第一件事是创建一个服务对象来处理这种特定情况。 (所以让我们从那里开始)

class UserPointsByWeek
  attr_reader :user


  def initialize(user)
    @user = user
  end
end

现在是构建适当查询的繁重工作(顺便说一句,感谢@Nick 的贡献,因为我根据他的建议和@ŁukaszKamiński 评论构建了这个)

class UserPointsByWeek
  attr_accessor :weeks_back
  # ...
  # results returned as an Array of Hashes 
  # [{week: ####-##-##, weekly_points: N},{week: ####-##-##, weekly_points: N}]
  def weekly_points
    @points_by_week ||= ActiveRecord::Base.connection.exec_query(to_sql).to_hash
  end
  #Show the SQL generated
  def to_sql
    result.to_sql
  end


  private
    #This is the actual query that will be executed
    def result
      all_weeks.project(
        all_weeks[:week],
        log_table[:points].sum.as('weekly_points')
      ).with([first_week_table,all_weeks_table]).
      join(log_table,Arel::Nodes::OuterJoin).on(
        all_weeks[:week].eq(
          Arel::Nodes::NamedFunction.new('DATE_TRUNC',
            [
              Arel::Nodes::SqlLiteral.new("'week'"),
              log_table[:created_at]
            ])
          ).and(log_table[:user_id].eq(@user.id))
      ).group(
        all_weeks[:week]
      )
    end

    def rollback
      if @weeks_back 
        Arel::Nodes::NamedFunction.new('DATE_TRUNC',[
             Arel::Nodes::SqlLiteral.new("'week'"),
             Arel::Nodes::SqlLiteral.new("'#{@weeks_back.weeks.ago.strftime("%Y-%m-%d")}'::timestamp")
         ]) 
      else 
         first_week.project(first_week[:week].minimum)
      end
    end

    # the logs table that actually exists in your database
    def log_table
      Log.arel_table
    end

    # just a table alias
    def first_week 
      Arel::Table.new('first_week')
    end
    # definition for the table alias
    def first_week_table
      Arel::Nodes::As.new(first_week,first_week_definition)
    end

    # definition for finding @user's weeks on record
    # we will call use MIN later on to determine the First Week
    def first_week_definition
      log_table.project(
        Arel::Nodes::NamedFunction.new('DATE_TRUNC',[
            Arel::Nodes::SqlLiteral.new("'week'"),
            log_table[:created_at]
          ]).as('week')
      ).where(log_table[:user_id].eq(@user.id))
    end

    #just a table alias
    def all_weeks 
      Arel::Table.new('all_weeks')
    end

    #a definition for this alias
    def all_weeks_table
      Arel::Nodes::As.new(all_weeks,series_table)
    end

    # series generation as suggested by @Nick
    # used to build all_weeks_table
    def series_table
      Arel::SelectManager.new(Arel::Table.engine,series_definition)
        .project(Arel::Nodes::SqlLiteral.new("gs").as('week'))
    end

    # the actual series being generated
    # starts on the @user's first week and continues to now on 1 week intervals
    def series_definition
      Arel::Nodes::NamedFunction.new('generate_series',
        [ rollback,
          Arel::Nodes::SqlLiteral.new("now()"),
          Arel::Nodes::SqlLiteral.new("interval '1 week'")
        ]
      ).as('_(gs)')
    end
end

生成的 SQL 将如下所示

UserPointsByWeek.new(@user).to_sql # Assuming user.id == 1
#=> "WITH first_week AS (
        SELECT DATE_TRUNC('week', logs.created_at) AS week 
        FROM logs 
        WHERE logs.user_id = 1
    ), all_weeks AS (
        SELECT gs AS week 
        FROM generate_series(
            (SELECT MIN(first_week.week) FROM first_week), 
            now(), 
            interval '1 week') AS _(gs)
   ) 
   SELECT 
       all_weeks.week, 
       SUM(logs.points) AS weekly_points 
   FROM 
       all_weeks 
       LEFT OUTER JOIN logs ON all_weeks.week = DATE_TRUNC('week', logs.created_at) 
         AND logs.user_id = 1    
   GROUP BY 
       all_weeks.week"

#weekly_points 将在用户的第一周和当前周之间的每一周返回一个 ArrayHash 对象,其中有两个键 :week:weekly_points。作为旁注,user 也仍然可以访问。

【讨论】:

  • @Nick 确实更难阅读,但确保 SQL 转义、可扩展性(可以分离成通用组件以用于其他查询)并且更容易调试
  • "send" / "execute" 与整个 SQL 和 SQL 转义不是相互排斥的东西。虽然将可读性好的 SQL 拆分成块会使其不可读并增加维护成本(要分析和优化 SQL,您需要 grep 日志,以某种方式跟踪正确的查询,然后最终处理普通 SQL)。
  • “更容易调试”——对不起,不。以这种方法调试 SQL 及其性能是一场噩梦。
  • @Nick 每个人都有。我已经构建了基于Arel 组合(在rails 之外)的大量报告库,这些库具有极强的可扩展性和可维护性,但感谢您的参与
猜你喜欢
  • 2016-05-22
  • 2022-01-11
  • 2014-06-17
  • 1970-01-01
  • 2018-03-24
  • 1970-01-01
  • 1970-01-01
  • 2018-05-22
  • 1970-01-01
相关资源
最近更新 更多