SQL - 计算状态在日期范围内保持的日期

SQL - Count dates that a status was held in a date range

我正在尝试确定一个人在某个日期范围内拥有特定身份的日期数。我有三个具有以下(简化)结构的 table:

Table             Fields
 Calendar          Date
 DateRange         RangeID, StartDate, EndDate
 StatusHistory     PersonID, Status, Date

日历 table 包含我要考虑计数的日期列表。一个人的状态变化可能在该范围之前、之后或中间被记录下来,或者可能在该范围内多次状态切换。

我愿意:

select PersonID, RangeID, Status, count(*) as DateCount

或者至少有一个具有该结构的结果集。

我在 DB2 for IBM i 上使用 SQL。

使用示例数据编辑

DateRange table(包含我要考虑的范围)

 RangeID  StartDate    EndDate
+--------+------------+------------+
| A      | 2015-01-01 | 2015-01-31 |
| B      | 2015-02-06 | 2015-03-05 |
| C      | 2015-03-07 | 2015-04-30 |
+--------+------------+------------+

日历table(包含我要计算的日期)

 Date            RangeID (not in Calendar table, but shown here for clarity)
+------------+   ----
| 2015-01-05 |
| 2015-01-06 |    A
| 2015-01-07 |
| 2015-01-08 |
                 ----
| 2015-02-05 |
                 ----
| 2015-02-06 |
| 2015-02-07 |    B
| 2015-02-08 |
| 2015-03-05 |
                 ----
| 2015-03-06 |
                 ---- 
| 2015-03-07 |
| 2015-03-08 |
| 2015-04-05 |    C
| 2015-04-06 |
| 2015-04-07 |
| 2015-04-08 |
+------------+   ----

StatusHistory table(包含输入或更改人员状态的日期)

 PersonID Status  Date
+--------+-------+------------+      Edit for clarification:
| 1      | HAPPY | 2015-01-05 |      While there's only one date  
| 1      | SAD   | 2015-02-07 |      in each of these records, 
| 1      | HAPPY | 2015-04-06 |      a date range is implied. That is,
| 2      | HAPPY | 2015-01-07 |      Person 1 is HAPPY from 2015-01-05
| 3      | SAD   | 2014-10-31 |      to 2015-02-07, then SAD 'til
| 3      | SAD   | 2015-01-07 |      2015-04-06 and HAPPY from then on.
| 3      | HAPPY | 2015-04-05 |
| 3      | SAD   | 2015-04-06 |
| 3      | SAD   | 2015-04-07 |
+--------+-------+------------+

结果集

 PersonID RangeID Status  DateCount
+--------+-------+-------+---------+
| 1      | A     | HAPPY | 4       |
| 1      | B     | HAPPY | 1       |
| 1      | B     | SAD   | 3       |
| 1      | C     | HAPPY | 3       |
| 1      | C     | SAD   | 3       |
| 2      | A     | HAPPY | 2       |
| 2      | B     | HAPPY | 4       |
| 2      | C     | HAPPY | 6       |
| 3      | A     | SAD   | 4       |
| 3      | B     | SAD   | 4       |
| 3      | C     | HAPPY | 1       |
| 3      | C     | SAD   | 5       |
+--------+-------+-------+---------+

这里有两个解决方案:

  1. 计算所有的组合并计数,所以显示0
  2. 按分组只显示计数 > 0 的组合

获得正确状态的想法是在 <= 日历日期的日期加入 StatusHistory,但不存在比具有相同 PersonID 且 <= 日历的状态的日期大的日期日期。所以基本上这个技巧 select 是一个人(如果有的话)在给定日历日的最后一个现有状态。

版本 1:已在 PostgreSQL 和 Oracle (SQL Fiddle) 上测试。

SELECT
   p.PersonID,
   r.RangeID,
   s.Status,
   (SELECT COUNT(*) FROM Calendar c WHERE c.Date_ BETWEEN r.StartDate AND r.EndDate AND
      EXISTS(SELECT * FROM StatusHistory h WHERE
         h.PersonID = p.PersonID AND h.Status = s.Status AND h.Date_ <= c.Date_ AND
         NOT EXISTS(SELECT * FROM StatusHistory z WHERE
            z.PersonID = p.PersonID AND z.Date_ <= c.Date_ AND z.Date_ > h.Date_))
   ) AS Amount
FROM
   (SELECT DISTINCT PersonID FROM StatusHistory) p,
   (SELECT RangeID, StartDate, EndDate FROM DateRange) r,
   (SELECT DISTINCT Status FROM StatusHistory) s
;

版本 2:或者,如果您不想要 0,可以修改旧的解决方案 (SQL Fiddle):

SELECT
   h.PersonID,
   r.RangeID,
   h.Status,
   COUNT(*)
FROM
   Calendar c,
   DateRange r,
   StatusHistory h
WHERE
   c.Date_ BETWEEN r.StartDate AND r.EndDate AND
   h.Date_ <= c.Date_ AND
   NOT EXISTS (SELECT s.Date_ FROM StatusHistory s WHERE
      s.Date_ <= c.Date_ AND s.Date_ > h.Date_ AND s.PersonID = h.PersonID)
GROUP BY
   h.PersonID,
   r.RangeID,
   h.Status
;

如果您在第一个查询 MINUS 第二个查询中进行查询,您将看到实际上只有计数 = 0 的行被 return 编辑,因为除了 0 之外查询应该 return 相同的行。

select 已经正确,所需要的只是分组并正确加入/过滤表格。需要分组,因为计数是一个聚合函数(如总和、最小值、最大值等)并且它们在组上工作。您可以想象您只查看 group by 中指定的列,并且它们相同的列被放在一组中,对于其他列,您必须使用聚合函数(您不能在一个单元格中存储多行,除非您使用group_concat (mysql) 或 listagg (oracle) 也是聚合函数。

虽然您通常以平等的方式加入,但这不是必需的。

在你的情况下,你需要使用 BETWEEN

select PersonID, RangeID, Status, count(*) as DateCount
from Calendar c
     join DateRange d on c.date between d.StartDate and d.EndDate
     join StatusHistory s on s.date between d.StartDate and d.EndDate
group by s.PersonID, d.RangeID, s.Status  

应该给你想要的..

如果您在 LUW 上,并且可以访问 LEAD(window 函数很好),我们会更轻松一些,但我们只需要模拟它。

您需要问的第一件事是一个概念性问题:您要计算什么?答案是“天”——是的,你有条件,但这就是你要计算的。所以你的初始 table(FROM 中的那个)实际上是你的日历 table.

接下来我们需要做的是获取 StatusHistory 的下一个范围的开始(请注意,这将是一个独占上限。始终使用 dates/times/timestamps 查询一个独占的上限...事实上,如果你假装 BETWEEN does not exist) 会更好。 i 上没有 LEAD,我们将不得不模仿它。首先,我们需要对条目进行索引,为每个人重新开始,并按他们的条目排序:

StatusHistoryIndex (personId, status, startDate, index) 
                AS (SELECT personId, status, startDate,
                           ROW_NUMBER() OVER (PARTITION BY personId ORDER BY startDate) 
                    FROM StatusHistory)

...接下来,我们需要使用它通过生成的索引将“当前”行与“下一个”行连接起来:

StatusHistoryRange (personId, status, startDate, endDate)
                AS (SELECT Curr.personId, Curr.status, Curr.startDate,
                           Nxt.startDate
                    FROM StatusHistoryIndex Curr
                    LEFT JOIN StatusHistoryIndex Nxt
                           ON Nxt.personId = Curr.personId
                              AND Nxt.index = Curr.index + 1)   

... 因为我们有一个开放的上限 - 我们 运行 直到“最后一个可能的条目”,并且我们 没有 “最后”条目 - 我们需要 LEFT JOIN 表示 Nxt(下一个),并且结束日期(重要 - 下一个状态的开始!)对于最后一个条目将为空。这种逻辑是包装在视图中的主要候选者(以提供完整范围的外观 table),并且如果性能是一个问题,则可能构建 MQT。

从这里开始,就很简单了。我们不必担心重复 - 我们加入的方式会解决这个问题 - 范围也会自动重叠。

快速演示:
给定一个看起来像这样的日历 table -

2015-01-01
2015-01-02
2015-01-03
2015-01-04
2015-01-05

... 和这样的范围 table -

2015-01-02   2015-01-05

... 那么加入只能限制选择的行,就好像是WHERE子句:

SELECT date
FROM Calendar
JOIN Range
  ON Calendar.date >= Range.start
     AND Calendar.date < Range.end

会产生:

2015-01-02
2015-01-03
2015-01-04

在排除的行中,2015-01-01 被忽略,因为它小于范围的开头,2015-01-05 被忽略,因为它比范围的结尾 greater-than/equal。将更多次与其他相似范围连接起来只会进一步限制所选数据。我们拥有所需的所有零件。


完整的语句最终看起来像这样:

WITH StatusHistoryIndex (personId, status, startDate, index) 
                     AS (SELECT personId, status, startDate,
                                ROW_NUMBER() OVER (PARTITION BY personId ORDER BY startDate) 
                     FROM StatusHistory),
     StatusHistoryRange (personId, status, startDate, endDate)
                     AS (SELECT Curr.personId, Curr.status, Curr.startDate,
                                Nxt.startDate
                         FROM StatusHistoryIndex Curr
                         LEFT JOIN StatusHistoryIndex Nxt
                                ON Nxt.personId = Curr.personId
                                   AND Nxt.index = Curr.index + 1)
                     
SELECT SHR.personId, DateRange.id, SHR.status, COUNT(*)
FROM Calendar
JOIN DateRange
  ON Calendar.calendarDate >= DateRange.startRange
     AND Calendar.calendarDate < DateRange.endRange
JOIN StatusHistoryRange SHR
  ON Calendar.calendarDate >= SHR.startDate
     AND (Calendar.calendarDate < SHR.endDate OR SHR.endDate IS NULL)
GROUP BY SHR.personId, DateRange.id, SHR.status
ORDER BY SHR.personId, DateRange.id, SHR.status

SQL Fiddle Example
(请注意,我的数字与您的示例结果有很大不同。鉴于起始数据,我相信我得到的数字是正确的结果,但如果我遗漏了什么,请告诉我)

你没有指定,但我把 DateRange 中的结束日期视为唯一的上限,你可能需要调整(你 应该 在此处存储独占上限)。
我也没有限制状态的结束日期。据推测,这将是 CURRENT_DATE,尽管您的测试数据的 none 达到了那个程度。可以将 COALESCE(Nxt.startDate, CURRENT_DATE) 放在 CTE 范围内,但这留作 reader.

的练习