将 PostgreSQL 递归 CTE 转换为 SQL Server

Posted

技术标签:

【中文标题】将 PostgreSQL 递归 CTE 转换为 SQL Server【英文标题】:Converting PostgreSQL recursive CTE to SQL Server 【发布时间】:2021-10-14 03:18:26 【问题描述】:

我在将一些递归 CTE 代码从 PostgreSQL 调整到 SQL Server 时遇到问题,来自《用数据对抗流失》一书

这是工作的 PostgreSQL 代码:

with recursive
    active_period_params as (
        select interval '30 days'  as allowed_gap,
        '2021-09-30'::date as calc_date
    ),
    active as (
        -- anchor
        select distinct account_id, min(start_date) as start_date    
        from subscription inner join active_period_params 
            on start_date <= calc_date    
            and (end_date > calc_date or end_date is null)
        group by account_id
    
        UNION
        
        -- recursive
        select s.account_id, s.start_date  
        from subscription s 
        cross join active_period_params 
        inner join active e on s.account_id=e.account_id  
            and s.start_date < e.start_date  
            and s.end_date >= (e.start_date-allowed_gap)::date  
    )
select account_id, min(start_date) as start_date
from active
group by account_id

这是我转换为 SQL Server 的尝试。它陷入了一个循环。我认为这个问题与 SQL Server 所需的 UNION ALL 有关。

with
    active_period_params as (
        select 30 as allowed_gap,
        cast('2021-09-30' as date) as calc_date
    ),
    active as (
        -- anchor
        select distinct account_id, min(start_date) as start_date    
        from subscription inner join active_period_params 
            on start_date <= calc_date    
            and (end_date > calc_date or end_date is null)
        group by account_id
    
        UNION ALL
        
        -- recursive
        select s.account_id, s.start_date  
        from subscription s 
        cross join active_period_params 
        inner join active e on s.account_id=e.account_id  
            and s.start_date < e.start_date  
            and s.end_date >= dateadd(day, -allowed_gap, e.start_date)
    )
select account_id, min(start_date) as start_date
from active
group by account_id

订阅表是属于客户的订阅列表。客户可以拥有多个日期重叠或日期之间有间隔的订阅。 null end_date 表示订阅当前处于活动状态并且没有定义的 end_date。下面是单个客户 (account_id = 15) 的示例数据:

subscription
 ---------------------------------------------------
|  id  |  account_id  |  start_date  |   end_date   |
 ---------------------------------------------------
|   6  |      15      |  01/06/2021  |    null    |
|   5  |      15      |  01/01/2021  |    null    |
|   4  |      15      |  01/06/2020  | 01/02/2021 |
|   3  |      15      |  01/04/2020  | 15/05/2020 |
|   2  |      15      |  01/03/2020  | 15/05/2020 |
|   1  |      15      |  01/06/2019  | 01/01/2020 |

预期的查询结果(由 PostgreSQL 代码产生):

 ------------------------------
|  account_id  |  start_date  |
 ------------------------------
|      15      |  01/03/2020  |

问题: 上面的 SQL Server 代码卡在一个循环中,没有产生结果。

PostgreSQL 代码说明:

    锚块查找在 calc_date (30/09/2021) (id 5 & 6) 处于活动状态的 subs,并返回 min start_date (01/01/2021) 然后,递归块查找在 allowed_gap 中存在的任何较早的 subs,这是在 1) 中找到的 min_start 日期前 30 天。 id 4 符合此条件,因此新的最小 start_date 为 01/06/2020 递归重复并在 allowed_gap (01/06/2020 - 30 天) 内找到两个子项。在这些潜艇(id 2 和 3)中,新的最小 start_date 是 01/03/2020 递归未能在 allowed_gap 内找到更早的子(01/03/2020 - 30 天) 查询返回 account_id 15 的开始日期 01/03/2020

任何帮助表示赞赏!

【问题讨论】:

请提供minimal reproducible example,即样本数据、您想要的结果和您的尝试。 我可以看到至少有一个区别dateadd(day, allowed_gap, e.start_date) 应该是dateadd(day, -allowed_gap, e.start_date)。两个系统之间的一大区别是 Postgres 能够区分 rCTE,而 SQL Server 不能,因为它逐行处理它。让我感到震惊的是,这种递归可能使用计数表之类的东西做得更好,但如果没有minimal reproducible example,就很难判断。 @Charlieface 感谢您注意到错误 - 已修复 注意 - 将 DISTINCT 与 GROUP BY 子句一起使用几乎总是毫无意义的。 GROUP BY 将保证每组分组列都有一行。添加 distinct 没有任何用处。 【参考方案1】:

问题似乎与 SQL Server 处理递归 CTE 的方式有关。

这是一种gaps-and-islands问题,实际上不需要递归。

有很多解决方案,这里是一个。鉴于您的要求,可能有更有效的方法,但这应该可以帮助您入门。

使用LAG,我们可以识别在下一行的指定间隙内的行 我们使用运行中的COUNT 为每组连续的行指定一个 ID 我们按该 ID 分组,取最小的start_date,过滤掉不符合条件的组 再次分组以获得每个帐户的最小值
DECLARE @allowed_gap int = 30,
        @calc_date datetime = cast('2021-09-30' as date);

WITH PrevValues AS (
    SELECT *,
      IsStart = CASE WHEN ISNULL(LAG(end_date) OVER (PARTITION BY account_id
                     ORDER BY start_date), '2099-01-01') < DATEADD(day, -@allowed_gap, start_date)
                     THEN 1 END
    FROM subscription
),
Groups AS (
    SELECT *,
      GroupId = COUNT(IsStart) OVER (PARTITION BY account_id
                     ORDER BY start_date ROWS UNBOUNDED PRECEDING)
    FROM PrevValues
),
ByGroup AS (
    SELECT
      account_id,
      GroupId,
      start_date = MIN(start_date)
    FROM Groups
    GROUP BY account_id, GroupId
    HAVING COUNT(CASE WHEN start_date <= @calc_date    
            and (end_date > @calc_date or end_date is null) THEN 1 END) > 0
)
SELECT
  account_id,
  start_date = MIN(start_date)
FROM ByGroup
GROUP BY account_id;

db<>fiddle

【讨论】:

以上是关于将 PostgreSQL 递归 CTE 转换为 SQL Server的主要内容,如果未能解决你的问题,请参考以下文章

PostgreSQL递归查询示例

PostgreSQL——查询优化——生成路径2

PostgreSQL——查询优化——生成路径2

PostgreSQL——查询优化——生成路径2

这个递归 CTE 有啥问题,更重要的是,我无法理解啥一般概念?

如何将 CTE 用作循环?