填补 SQL Server 日期范围中的空白

Posted

技术标签:

【中文标题】填补 SQL Server 日期范围中的空白【英文标题】:Fill gaps in SQL Server dates ranges 【发布时间】:2020-08-14 04:00:00 【问题描述】:

在 SQL Server 2014 中,我有一个 Periods 表,如下所示:

| PeriodId | PeriodStart | PeriodEnd  |
---------------------------------------
| 202005   | 2020-05-01  | 2020-05-31 |
| 202006   | 2020-06-01  | 2020-06-30 |

期间并不总是从每月的第一天到最后一天。

然后我有一个Activities 表,其中包含用户编程的一些活动:

| ActivityId | UserId | ActivityStart | ActivityEnd |
-----------------------------------------------------
| 1          | A      | 2020-05-20    | 2020-06-05  |
| 2          | A      | 2020-06-15    | 2020-06-18  |
| 3          | B      | 2020-06-10    | 2020-06-25  |

用户的活动之间可能存在间隙,但同一个用户永远不会有重叠的活动。

现在我需要一个查询,将活动日期范围限制为期间的开始和结束,并填补空白以完成期间。我将始终按PeriodId 进行过滤,所以我将把示例结果放在PeriodId = 202006 中:

| PeriodId | UserId | ActivityId | NewActivityStart | NewActivityEnd |
----------------------------------------------------------------------
| 202006   | A      | 1          | 2020-06-01       | 2020-06-05     |  --Part of ActivityId 1
| 202006   | A      | NULL       | 2020-06-06       | 2020-06-14     |  --Fill between activities 1 and 2
| 202006   | A      | 2          | 2020-06-15       | 2020-06-18     |
| 202006   | A      | NULL       | 2020-06-19       | 2020-06-30     |  --Fill until end of period
| 202006   | B      | NULL       | 2020-06-01       | 2020-06-09     |  --Fill from start of period
| 202006   | B      | 3          | 2020-06-10       | 2020-06-25     |
| 202006   | B      | NULL       | 2020-06-26       | 2020-06-30     |  --Fill until end of period

我已经能够通过以下查询包含该期间内的活动日期:

SELECT p.PeriodId, a.UserId, a.ActivityId
       IIF(p.PeriodStart > a.ActivityStart, p.PeriodStart, a.ActivityStart) AS NewActivityStart,
       IIF(p.PeriodEnd < a.ActivityEnd, p.PeriodEnd, a.ActivityEnd) AS NewActivityEnd
FROM Periods p
JOIN Activities a ON a.ActivityStart <= p.PeriodEnd AND a.ActivityEnd >= p.PeriodStart

但我无法填补范围内的空白。我尝试过使用相关日期表和/或 LAG/LEAD 等窗口函数。

我觉得 Window Functions 可能是解决方案,我尝试关注 examples 来了解间隙/孤岛,但我无法充分理解它们以使其发挥作用。

有没有办法完成查询以填补缺失的空白?还有其他方法可以在查询中实现这一点吗?

【问题讨论】:

理想情况下,您需要一个日历表。这将有助于轻松生成此类结果 @VenkataramanR 我尝试使用数字表,将它们添加到PeriodStart 列,如dateadd(day, cn.Number, p.PeriodStart),但我还没有弄清楚如何正确使用窗口函数从那到填补空白。 我认为您想要的结果中有错字,倒数第二行应该有 ActivityId = 3,而不是 2。 为什么要在“月末”而不是每个周期末得到结果? 谢谢,我修正了 ID 和月份/期间的拼写错误 【参考方案1】:

您可以使用各种技术解决此问题。在下面的示例中,我使用了一种方法,因为代码是 SQL 例程的主体。

所以,这是你的日期:

DECLARE @Periods TABLE
(
    [PeriodId] INT
   ,[PeriodStart] DATE
   ,[PeriodEnd] DATE
);

INSERT INTO @Periods ([PeriodId], [PeriodStart], [PeriodEnd])
VALUES ('202005', '2020-05-01', '2020-05-31')
      ,('202006', '2020-06-01', '2020-06-30');

DECLARE @Activities  TABLE
(
    [ActivityId] INT
   ,[UserId] CHAR(1)
   ,[ActivityStart] DATE
   ,[ActivityEnd] DATE
);

INSERT INTO @Activities ([ActivityId], [UserId], [ActivityStart], [ActivityEnd])
VALUES (1, 'A', '2020-05-20', '2020-06-05')
      ,(2, 'A', '2020-06-15', '2020-06-18')
      ,(3, 'B', '2020-06-10', '2020-06-25');

然后,假设我们有一个输入参数@PeriodID,并通过它提取相应的开始和结束日期:

DECLARE @PeriodID INT
       ,@PeriodDateStart DATE
       ,@PeriodDateEnd DATE;

SET @PeriodID = 202006;

SELECT @PeriodDateStart = [PeriodStart]
      ,@PeriodDateEnd = [PeriodEnd]
FROM @Periods 
WHERE [PeriodId] = @PeriodID;

然后,让我们创建一个缓冲区表,我们将在其中计算activityperiod 表之间的匹配,并在需要时添加startend 周期记录:

DECLARE @Buffer TABLE
(
    [ActivityId] INT
   ,[UserId] CHAR(1)
   ,[ActivityStart] DATE
   ,[ActivityEnd] DATE
);

WITH DataSource AS
(
    SELECT A.[ActivityId]
          ,A.[UserId]
          ,A.[ActivityStart]
          ,A.[ActivityEnd]
    FROM @Activities A
    INNER JOIN @Periods P
        ON A.[ActivityStart] <= P.[PeriodEnd]
        AND A.[ActivityEnd] >= P.[PeriodStart]
    WHERE P.PeriodId = @PeriodID
)
INSERT INTO @Buffer ([ActivityId], [UserId], [ActivityStart], [ActivityEnd])
SELECT [ActivityId]
      ,[UserId]
      ,IIF([ActivityStart] < @PeriodDateStart, @PeriodDateStart, [ActivityStart]) AS [ActivityStart]
      ,[ActivityEnd]
FROM DataSource 
UNION ALL
SELECT NULL
      ,[UserId]
      ,DATEADD(DAY, 1, MAX([ActivityEnd]))
      ,@PeriodDateEnd
FROM DataSource
GROUP BY [UserId]
HAVING DATEADD(DAY, 1, MAX([ActivityEnd])) < @PeriodDateEnd
UNION ALL
SELECT NULL
      ,[UserId]
      ,@PeriodDateStart
      ,DATEADD(DAY, -1, MIN([ActivityStart]))
FROM DataSource
GROUP BY [UserId]
HAVING DATEADD(DAY, -1, MIN([ActivityStart])) > @PeriodDateStart;

这很简单。在公用表表达式中,我使用了您的代码。然后,我们只需检查是否需要在特定用户的期限之前或之后添加记录。

现在,我们已经准备好计算差距了,对吧?这里有很多变种。我正在使用LEAD 函数来计算每一行的missing 句点。声明如下:

SELECT *
      ,DATEADD(DAY, 1, [ActivityEnd]) AS [MissingPeriodStart]
      ,DATEADD(DAY, -1, LEAD([ActivityStart]) OVER (PARTITION BY [UserID] ORDER BY [ActivityStart] ASC)) AS [MissingPeriodEnd]
FROM @Buffer
ORDER BY USERID, ActivityStart;

输出是这样的:

因此,您可能会看到我们如何为每一行生成missing periods 日期,最后一行除外。现在,我们只需要获取其中的一些missing periods。是这样的:

WITH DataSource AS
(
    SELECT *
          ,DATEADD(DAY, 1, [ActivityEnd]) AS [MissingPeriodStart]
          ,DATEADD(DAY, -1, LEAD([ActivityStart]) OVER (PARTITION BY [UserID] ORDER BY [ActivityStart] ASC)) AS [MissingPeriodEnd]
    FROM @Buffer
)
SELECT @PeriodID AS [PeriodID]
      ,[UserId]
      ,[ActivityId]
      ,[ActivityStart]
      ,[ActivityEnd]
FROM DataSource
UNION ALL 
SELECT @PeriodID AS [PeriodID]
      ,[UserId]
      ,NULL
      ,[MissingPeriodStart]
      ,[MissingPeriodEnd]
FROM DataSource
WHERE NOT EXISTS 
(
    SELECT 1 
    FROM DataSource DS
    WHERE [MissingPeriodStart] = DS.[ActivityStart]
        AND [UserID] = DS.[UserID]
)
    AND [MissingPeriodStart] < [MissingPeriodEnd]
ORDER BY [UserId]
        ,[ActivityStart];

结果是:

当然,这是一个想法。您可能需要更改或调整它以便与您的真实数据一起使用。我希望它会给你一个开始。

【讨论】:

谢谢!真正完整的答案。答案的复杂性无疑让我质疑我应该在查询中还是作为预处理来执行此操作,但目前,正如问题中所述,我希望在查询中实现这一点。跨度> @FercoCQ 如果需要,您可以轻松地将 table_variable 替换为 CTE 表达式。【参考方案2】:

这不是我见过的最疯狂的间隙问题,但它是一个很好的问题。

DECLARE @PeriodId int = 202006;

DECLARE @ps date, @pe date;
SELECT @ps = PeriodStart, @pe = PeriodEnd FROM dbo.Periods
   WHERE PeriodId = @PeriodId;
   
;WITH dates(rn,dt) AS 
(
    SELECT 1, @ps UNION ALL SELECT rn + 1, DATEADD(DAY, rn, @ps) 
    FROM dates WHERE dt < @pe
)
groups(UserId, dt, ActivityId, grp) AS
(
  SELECT u.UserId, d.dt, r.ActivityId, 
    d.rn - DENSE_RANK() OVER (PARTITION BY u.UserId, r.ActivityStart ORDER BY d.dt)
  FROM dates AS d CROSS JOIN (SELECT DISTINCT UserId FROM dbo.Activities 
    WHERE @pe >= ActivityStart AND @ps <= ActivityEnd) AS u
  LEFT OUTER JOIN dbo.Activities AS r
  ON u.UserId = r.UserId AND d.dt >= r.ActivityStart AND d.dt <= r.ActivityEnd
)
SELECT PeriodId = @PeriodId, UserId, ActivityId,
  NewActivityStart = MIN(dt),
  NewActivityEnd   = MAX(dt)
FROM groups 
GROUP BY UserId, ActivityId, grp
ORDER BY UserId, NewActivityStart;

如果一个周期可以超过100天,你需要MAXRECURSION结尾:

OPTION (MAXRECURSION 32767);  

如果一个周期可以超过 32,767 天,请将 32767 更改为 0

更新小提琴here。

【讨论】:

【参考方案3】:

我认为这并不复杂。如果您将期间扩展为单独的日期并执行left join,那么这将成为一个差距和岛屿问题:

with dates as (
      select periodid, periodstart as dte, periodend
      from periods
      union all
      select periodid, dateadd(day, 1, dte), periodend
      from dates
      where dte < periodend
     )
select userid, activityid, min(dte), max(dte)
from (select d.dte, d.periodid, u.userid, a.activityid,
             row_number() over (partition by u.userid, a.activityid order by d.dte) as seqnum
      from dates d cross join
           (select distinct userid from activities) u left join
           activities a
           on a.userid = u.userid and
              a.activitystart <= d.dte and a.activityend >= d.dte
     ) da
group by userid, activityid, periodid, dateadd(day, -seqnum, dte)
order by userid, min(dte);

Here 是一个 dbfiddle。

注意:这会为所有用户和所有时间段生成结果——根据您的描述,这似乎是合理的。修改以过滤掉在给定时间段内没有活动的用户非常简单。

此外,这不会到月底。相反,它包括完整的时期。我不明白为什么几个月会起作用——除了混淆问题——例如,考虑两个时期是否在同一个月有几天。

【讨论】:

谢谢,我修正了月份/期间的错字。我检查了小提琴结果,但它混合了来自不同用户的活动时段。例如,用户 B 应该只有 3 个“最终”日期范围(仅查看 6 月):1 日至 9 日(填充)、10 日至 25 日(活动)和 26 日至 30 日(填充),但它将它们与从用户 A 的活动。 @FercoCQ 。 . .糟糕,代码缺少用户的加入条件。它是固定的。 @FercoCQ 。 . .我现在看到第二句话有点笑了。显然left join 在我第一次尝试时对我来说太复杂了。 ;)

以上是关于填补 SQL Server 日期范围中的空白的主要内容,如果未能解决你的问题,请参考以下文章

填补 MultiIndex Pandas Dataframe 中的日期空白

填补熊猫数据框中的日期空白

复制记录组以填补 Google BigQuery 中的多个日期空白

用基于优先级的集合来填补空白

复制记录以填补 Google BigQuery 中日期之间的空白

SQL Server 2008 中的 While 循环遍历日期范围,然后插入