【问题标题】:Query without WHILE Loop不带 WHILE 循环的查询
【发布时间】:2020-06-12 21:52:55
【问题描述】:

我们有如下所示的预约表。每个约会都需要分类为“新”或“后续”。首次预约(该患者)后 30 天内的任何预约(针对该患者)均为随访。 30 天后,约会再次“新”。 30 天内的任何约会都将成为“跟进”。

我目前正在通过键入 while 循环来执行此操作。
如何在没有 WHILE 循环的情况下实现这一点?

表格

CREATE TABLE #Appt1 (ApptID INT, PatientID INT, ApptDate DATE)
INSERT INTO #Appt1
SELECT  1,101,'2020-01-05' UNION
SELECT  2,505,'2020-01-06' UNION
SELECT  3,505,'2020-01-10' UNION
SELECT  4,505,'2020-01-20' UNION
SELECT  5,101,'2020-01-25' UNION
SELECT  6,101,'2020-02-12'  UNION
SELECT  7,101,'2020-02-20'  UNION
SELECT  8,101,'2020-03-30'  UNION
SELECT  9,303,'2020-01-28' UNION
SELECT  10,303,'2020-02-02' 

【问题讨论】:

  • 我看不到你的图片,但我想确认一下,如果有 3 个约会,每 20 天相隔一次,最后一个仍然是 '跟进' 对,因为即使它是距离第一次超过30天,距离中间还有不到20天。这是真的吗?
  • @pwilcox 否。如图所示,第三个将是新约会
  • 虽然循环 fast_forward 光标可能是您最好的选择,但性能方面。

标签: sql sql-server tsql sql-server-2016


【解决方案1】:

你需要使用递归查询。

30 天的周期从 prev 开始计算(不,没有递归/古怪的更新/循环是不可能的)。这就是为什么所有仅使用 ROW_NUMBER 的现有答案都失败了。

WITH f AS (
  SELECT *, rn = ROW_NUMBER() OVER(PARTITION BY PatientId ORDER BY ApptDate) 
  FROM Appt1
), rec AS (
  SELECT Category = CAST('New' AS NVARCHAR(20)), ApptId, PatientId, ApptDate, rn, startDate = ApptDate
  FROM f
  WHERE rn = 1
  UNION ALL
  SELECT CAST(CASE WHEN DATEDIFF(DAY,  rec.startDate,f.ApptDate) <= 30 THEN N'FollowUp' ELSE N'New' END AS NVARCHAR(20)), 
         f.ApptId,f.PatientId,f.ApptDate, f.rn,
         CASE WHEN DATEDIFF(DAY, rec.startDate, f.ApptDate) <= 30 THEN rec.startDate ELSE f.ApptDate END
  FROM rec
  JOIN f
    ON rec.rn = f.rn - 1
   AND rec.PatientId = f.PatientId
)
SELECT ApptId, PatientId, ApptDate, Category
FROM rec
ORDER BY PatientId, ApptDate;  

db<>fiddle demo

输出:

+---------+------------+-------------+----------+
| ApptId  | PatientId  |  ApptDate   | Category |
+---------+------------+-------------+----------+
|      1  |       101  | 2020-01-05  | New      |
|      5  |       101  | 2020-01-25  | FollowUp |
|      6  |       101  | 2020-02-12  | New      |
|      7  |       101  | 2020-02-20  | FollowUp |
|      8  |       101  | 2020-03-30  | New      |
|      9  |       303  | 2020-01-28  | New      |
|     10  |       303  | 2020-02-02  | FollowUp |
|      2  |       505  | 2020-01-06  | New      |
|      3  |       505  | 2020-01-10  | FollowUp |
|      4  |       505  | 2020-01-20  | FollowUp |
+---------+------------+-------------+----------+

它是如何工作的:

  1. f - 获取起点(锚点 - 每个 PatientId)
  2. rec - 递归部分,如果当前值与 prev 之间的差异 > 30,则在 PatientId 的上下文中更改类别和起点
  3. 主要 - 显示排序结果集

类似的类:

Conditional SUM on Oracle - 加盖窗口函数

Session window (Azure Stream Analytics)

Running Total until specific condition is true - 奇怪的更新


附录

永远不要在生产中使用此代码!

但是,除了使用 cte 之外,另一个值得一提的选择是使用临时表并在“轮次”中更新

它可以在“单”轮中完成(古怪的更新):

CREATE TABLE Appt_temp (ApptID INT , PatientID INT, ApptDate DATE, Category NVARCHAR(10))

INSERT INTO Appt_temp(ApptId, PatientId, ApptDate)
SELECT ApptId, PatientId, ApptDate
FROM Appt1;

CREATE CLUSTERED INDEX Idx_appt ON Appt_temp(PatientID, ApptDate);

查询:

DECLARE @PatientId INT = 0,
        @PrevPatientId INT,
        @FirstApptDate DATE = NULL;

UPDATE Appt_temp
SET  @PrevPatientId = @PatientId
    ,@PatientId     = PatientID 
    ,@FirstApptDate = CASE WHEN @PrevPatientId <> @PatientId THEN ApptDate
                           WHEN DATEDIFF(DAY, @FirstApptDate, ApptDate)>30 THEN ApptDate
                           ELSE @FirstApptDate
                      END
    ,Category       = CASE WHEN @PrevPatientId <> @PatientId THEN 'New'
                           WHEN @FirstApptDate = ApptDate THEN 'New'
                           ELSE 'FollowUp' 
                      END
FROM Appt_temp WITH(INDEX(Idx_appt))
OPTION (MAXDOP 1);

SELECT * FROM  Appt_temp ORDER BY PatientId, ApptDate;

db<>fiddle Quirky update

【讨论】:

  • 你的逻辑和我的很相似。您能描述任何显着差异吗?
  • @pwilcox 当我写这个答案时,每个现有的答案都在使用不起作用的简单 row_number,这就是我提供自己版本的原因
  • 是的,我回答得太快了。感谢您对此发表评论。
  • 我相信 rcte 是唯一的解决方案,直到 SQL 服务器正确实现 RANGE x PRECEDING 子句。
  • @LCJ Quirky 更新基于“无证”行为,可能随时更改,恕不另行通知(red-gate.com/simple-talk/sql/learn-sql-server/…)
【解决方案2】:

您可以使用递归 cte 来做到这一点。您应该首先在每个患者中按 apptDate 订购。这可以通过普通的 cte 来实现。

然后,在递归 cte 的锚点部分,为每个患者选择第一个排序,将状态标记为“新”,并将 apptDate 标记为最近的“新”记录的日期。

在递归 cte 的递归部分,递增到下一个约会,计算当前约会与最近的“新”约会日期之间的天数差。如果超过 30 天,请将其标记为“新”并重置最近的新约会日期。否则,将其标记为“跟进”,并沿新约会日期以来的现有天数传递。

最后,在基本查询中,只需选择您想要的列。

with orderings as (

    select       *, 
                 rn = row_number() over(
                     partition by patientId 
                     order by apptDate
                 ) 
    from         #appt1 a

),

markings as (

    select       apptId, 
                 patientId, 
                 apptDate, 
                 rn, 
                 type = convert(varchar(10),'new'),
                 dateOfNew = apptDate
    from         orderings 
    where        rn = 1

    union all
    select       o.apptId, o.patientId, o.apptDate, o.rn,
                 type = convert(varchar(10),iif(ap.daysSinceNew > 30, 'new', 'follow up')),
                 dateOfNew = iif(ap.daysSinceNew > 30, o.apptDate, m.dateOfNew)
    from         markings m
    join         orderings o 
                     on m.patientId = o.patientId 
                     and m.rn + 1 = o.rn
    cross apply  (select daysSinceNew = datediff(day, m.dateOfNew, o.apptDate)) ap

)

select    apptId, patientId, apptDate, type
from      markings
order by  patientId, rn;

我应该提到,我最初删除了这个答案,因为 Abhijeet Khandagale 的答案似乎通过更简单的查询满足了您的需求(经过稍微修改后)。但是,根据您对他的业务需求和添加的示例数据的评论,我取消了我的删除,因为相信这符合您的需求。

【讨论】:

    【解决方案3】:

    我不确定这正是您实施的。但是除了使用 cte 之外,另一个值得一提的选择是使用临时表并在“轮次”中更新。因此,我们将在所有状态都未正确设置的情况下更新临时表,并以迭代方式构建结果。我们可以使用简单的局部变量来控制迭代次数。

    所以我们将每次迭代分为两个阶段。

    1. 设置所有靠近新记录的后续值。只需使用正确的过滤器,这很容易做到。
    2. 对于其他没有设置状态的记录,我们可以选择具有相同 PatientID 的组中的第一个。并说它们是新的,因为它们没有经过第一阶段的处理。

    所以

    CREATE TABLE #Appt2 (ApptID INT, PatientID INT, ApptDate DATE, AppStatus nvarchar(100))
    
    select * from #Appt1
    insert into #Appt2 (ApptID, PatientID, ApptDate, AppStatus)
    select a1.ApptID, a1.PatientID, a1.ApptDate, null from #Appt1 a1
    declare @limit int = 0;
    
    while (exists(select * from #Appt2 where AppStatus IS NULL) and @limit < 1000)
    begin
      set @limit = @limit+1;
      update a2
      set
        a2.AppStatus = IIF(exists(
            select * 
            from #Appt2 a 
            where 
              0 > DATEDIFF(day, a2.ApptDate, a.ApptDate) 
              and DATEDIFF(day, a2.ApptDate, a.ApptDate) > -30 
              and a.ApptID != a2.ApptID 
              and a.PatientID = a2.PatientID
              and a.AppStatus = 'New'
              ), 'Followup', a2.AppStatus)
      from #Appt2 a2
    
      --select * from #Appt2
    
      update a2
      set a2.AppStatus = 'New'
      from #Appt2 a2 join (select a.*, ROW_NUMBER() over (Partition By PatientId order by ApptId) rn from (select * from #Appt2 where AppStatus IS NULL) a) ar
      on a2.ApptID = ar.ApptID
      and ar.rn = 1
    
      --select * from #Appt2
    
    end
    
    select * from #Appt2 order by PatientID, ApptDate
    
    drop table #Appt1
    drop table #Appt2
    

    更新。阅读 Lukasz 提供的评论。这是迄今为止更聪明的方式。我留下我的答案只是一个想法。

    【讨论】:

      【解决方案4】:

      我相信递归通用表达式是优化查询避免循环的好方法,但在某些情况下,它可能会导致性能下降,应尽可能避免。

      我使用下面的代码来解决问题并测试它是否会产生更多价值,但也鼓励您使用真实数据进行测试。

      WITH DataSource AS
      (
          SELECT *
                ,CEILING(DATEDIFF(DAY, MIN([ApptDate]) OVER (PARTITION BY [PatientID]), [ApptDate]) * 1.0 / 30 + 0.000001) AS [GroupID]
          FROM #Appt1
      )
      SELECT *
           ,IIF(ROW_NUMBER() OVER (PARTITION BY [PatientID], [GroupID] ORDER BY [ApptDate]) = 1, 'New', 'Followup')
      FROM DataSource
      ORDER BY [PatientID]
              ,[ApptDate];
      

      这个想法很简单 - 我想将记录分成组(30天),其中最小的记录是new,其他的是follow ups。检查语句是如何构建的:

      SELECT *
            ,DATEDIFF(DAY, MIN([ApptDate]) OVER (PARTITION BY [PatientID]), [ApptDate])
            ,DATEDIFF(DAY, MIN([ApptDate]) OVER (PARTITION BY [PatientID]), [ApptDate]) * 1.0 / 30
            ,CEILING(DATEDIFF(DAY, MIN([ApptDate]) OVER (PARTITION BY [PatientID]), [ApptDate]) * 1.0 / 30 + 0.000001) 
      FROM #Appt1
      ORDER BY [PatientID]
              ,[ApptDate];
      

      所以:

      1. 首先,我们获取每个组的第一个日期,并计算与当前组的天数差异
      2. 那么,我们想要获取群组 - 添加* 1.0 / 30
      3. 至于30、60、90等天,我们得到整数,我们想开始一个新的时期,我已经添加了+ 0.000001;另外,我们正在使用吊顶函数来获取smallest integer greater than, or equal to, the specified numeric expression

      就是这样。有了这样的组,我们只需使用ROW_NUMBER 来查找我们的开始日期并将其设为new,其余的设为follow ups

      【讨论】:

      • 嗯,问题有点不同,这个方法过于简单化了。但这是一个很好的例子,如何实现 tumbling window
      • 这也与性能有关。我相信递归应该更慢。
      【解决方案5】:

      尊重每一个人,恕我直言,

      There is not much difference between While LOOP and Recursive CTE in terms of RBAR
      

      同时使用Recursive CTEWindow Partition function 并没有太大的性能提升。

      Appid 应该是 int identity(1,1) ,或者应该不断增加 clustered index

      除了其他好处之外,它还确保该患者的所有连续行APPDate 必须更大。

      通过这种方式,您可以轻松地在查询中使用APPID,这比将inequality 运算符(如>、inequality 运算符如 >,

      表中也应该有两个日期列

      APPDateTime datetime2(0) not null,
      Appdate date not null
      

      因为这些是最重要的表中最重要的列,所以没有太多的转换,转换。

      所以Non clustered index可以在Appdate上创建

      Create NonClustered index ix_PID_AppDate_App  on APP (patientid,APPDate) include(other column which is not i predicate except APPID)
      

      用其他示例数据测试我的脚本,然后让我知道它不适用于哪些示例数据。 即使它不起作用,我相信它可以在我的脚本逻辑本身中修复。

      CREATE TABLE #Appt1 (ApptID INT, PatientID INT, ApptDate DATE)
      INSERT INTO #Appt1
      SELECT  1,101,'2020-01-05'  UNION ALL
      SELECT  2,505,'2020-01-06'  UNION ALL
      SELECT  3,505,'2020-01-10'  UNION ALL
      SELECT  4,505,'2020-01-20'  UNION ALL
      SELECT  5,101,'2020-01-25'  UNION ALL
      SELECT  6,101,'2020-02-12'  UNION ALL
      SELECT  7,101,'2020-02-20'  UNION ALL
      SELECT  8,101,'2020-03-30'  UNION ALL
      SELECT  9,303,'2020-01-28'  UNION ALL
      SELECT  10,303,'2020-02-02' 
      
      ;With CTE as
      (
      select a1.* ,a2.ApptDate as NewApptDate
      from #Appt1 a1
      outer apply(select top 1 a2.ApptID ,a2.ApptDate
      from #Appt1 A2 
      where a1.PatientID=a2.PatientID and a1.ApptID>a2.ApptID 
      and DATEDIFF(day,a2.ApptDate, a1.ApptDate)>30
      order by a2.ApptID desc )A2
      )
      ,CTE1 as
      (
      select a1.*, a2.ApptDate as FollowApptDate
      from CTE A1
      outer apply(select top 1 a2.ApptID ,a2.ApptDate
      from #Appt1 A2 
      where a1.PatientID=a2.PatientID and a1.ApptID>a2.ApptID 
      and DATEDIFF(day,a2.ApptDate, a1.ApptDate)<=30
      order by a2.ApptID desc )A2
      )
      select  * 
      ,case when FollowApptDate is null then 'New' 
      when NewApptDate is not null and FollowApptDate is not null 
      and DATEDIFF(day,NewApptDate, FollowApptDate)<=30 then 'New'
      else 'Followup' end
       as Category
      from cte1 a1
      order by a1.PatientID
      
      drop table #Appt1
      

      【讨论】:

        【解决方案6】:

        虽然问题中没有明确说明,但很容易发现约会日期不能简单地按 30 天组进行分类。这没有任何商业意义。而且你也不能使用 appt id。今天可以为2020-09-06 重新预约。 这是我解决这个问题的方法。首先,获取第一个约会,然后计算每个约会与第一个约会的日期差。如果为 0,则设置为“新建”。如果 30,则设置为“未决定”并进行下一轮检查,直到不再有“未决定”。为此,您确实需要一个 while 循环,但它不会循环遍历每个约会日期,而只是循环几个数据集。我检查了执行计划。尽管只有 10 行,但查询成本明显低于使用递归 CTE,但不如 Lukasz Szozda 的附录方法。

        IF OBJECT_ID('tempdb..#TEMPTABLE') IS NOT NULL DROP TABLE #TEMPTABLE
        SELECT ApptID, PatientID, ApptDate
            ,CASE WHEN (DATEDIFF(DAY, MIN(ApptDate) OVER (PARTITION BY PatientID), ApptDate) = 0) THEN 'New' 
            WHEN (DATEDIFF(DAY, MIN(ApptDate) OVER (PARTITION BY PatientID), ApptDate) <= 30) THEN 'Followup'
            ELSE 'Undecided' END AS Category
        INTO #TEMPTABLE
        FROM #Appt1
        
        WHILE EXISTS(SELECT TOP 1 * FROM #TEMPTABLE WHERE Category = 'Undecided') BEGIN
            ;WITH CTE AS (
                SELECT ApptID, PatientID, ApptDate 
                    ,CASE WHEN (DATEDIFF(DAY, MIN(ApptDate) OVER (PARTITION BY PatientID), ApptDate) = 0) THEN 'New' 
                    WHEN (DATEDIFF(DAY, MIN(ApptDate) OVER (PARTITION BY PatientID), ApptDate) <= 30) THEN 'Followup'
                    ELSE 'Undecided' END AS Category    
                FROM #TEMPTABLE
                WHERE Category = 'Undecided'
            )
            UPDATE #TEMPTABLE
            SET Category = CTE.Category
            FROM #TEMPTABLE t
                LEFT JOIN CTE ON CTE.ApptID = t.ApptID
            WHERE t.Category = 'Undecided'
        END
        
        SELECT ApptID, PatientID, ApptDate, Category 
        FROM #TEMPTABLE
        

        【讨论】:

          【解决方案7】:

          希望对你有帮助。

          WITH CTE AS
          (
              SELECT #Appt1.*, RowNum = ROW_NUMBER() OVER (PARTITION BY PatientID ORDER BY ApptDate, ApptID) FROM #Appt1
          )
          
          SELECT A.ApptID , A.PatientID , A.ApptDate ,
          Expected_Category = CASE WHEN (DATEDIFF(MONTH, B.ApptDate, A.ApptDate) > 0) THEN 'New' 
          WHEN (DATEDIFF(DAY, B.ApptDate, A.ApptDate) <= 30) then 'Followup' 
          ELSE 'New' END
          FROM CTE A
          LEFT OUTER JOIN CTE B on A.PatientID = B.PatientID 
          AND A.rownum = B.rownum + 1
          ORDER BY A.PatientID, A.ApptDate
          

          【讨论】:

          • 感谢 @x00 以可读格式编辑代码,我正在使用手机发布答案,因此无法给出适当的缩进。
          • 我认为这本质上是正确的答案。但这是一个质量很差的答案,因为它没有被解释,并且当内部部分的修改会很好时,代码有一个不必要的外部查询。如果你能解决这些问题,我很乐意为你投票。
          • @pwilcox,感谢您的宝贵建议,我已经编辑了答案并立即发布。由于我正在旅行并且我没有随身携带笔记本电脑,因此我将在一两天内发布解释。
          • @AbhijeetKhandagale 这不完全满足业务需求。我在问题中添加了一个失败的场景。对于 303 号患者,2 月 2 日的预约应为随访;但你的查询告诉它是“新的”
          【解决方案8】:

          您可以使用Case statement

          select 
                *, 
                CASE 
                    WHEN DATEDIFF(d,A1.ApptDate,A2.ApptDate)>30 THEN 'New' 
                    ELSE 'FollowUp' 
                END 'Category'
          from 
                (SELECT PatientId, MIN(ApptId) 'ApptId', MIN(ApptDate) 'ApptDate' FROM #Appt1 GROUP BY PatientID)  A1, 
                #Appt1 A2 
          where 
               A1.PatientID=A2.PatientID AND A1.ApptID<A2.ApptID
          

          问题是,这个类别应该根据最初的任命,还是之前的任命?也就是说,如果患者有过三次预约,我们应该将第三次预约与第一次还是第二次进行比较?

          你的问题是第一个,这就是我的回答。如果不是这种情况,您将需要使用lag

          另外,请记住,DateDiff 周末也不例外。如果这应该只是工作日,您需要创建自己的标量值函数。

          【讨论】:

          • 这不会链接两个连续的约会,这会将 appt 1 链接到所有后续约会并计算所有约会之间的天数。这样会返回太多记录,因为 appt 1 现在与 2、3、4 有关系,appt 2 与 3、4 有关系 ...
          • 它没有给出预期的结果。 2 月 20 日的约会应该是“跟进”
          • 问题不清楚......海报描述是这样的:“在第一次预约(该患者)后 30 天内的任何预约(针对患者)都是后续行动。之后30 天,约会又是“新”。30 天内的任何约会都变成“跟进”。 1月5日肯定距离2月20日还有30多天,即New。然而,距离 2 月 12 日还不到 30 天。我为他写的内容提供了一个解决方案,而不是提供的表格。如果用户想与表格提供的内容保持一致,他们应该使用滞后。他们还应该澄清......
          【解决方案9】:

          使用滞后功能

          
          select  apptID, PatientID , Apptdate ,  
              case when date_diff IS NULL THEN 'NEW' 
                   when date_diff < 30 and (date_diff_2 IS NULL or date_diff_2 < 30) THEN  'Follow Up'
                   ELSE 'NEW'
              END AS STATUS FROM 
          (
          select 
          apptID, PatientID , Apptdate , 
          DATEDIFF (day,lag(Apptdate) over (PARTITION BY PatientID order by ApptID asc),Apptdate) date_diff ,
          DATEDIFF(day,lag(Apptdate,2) over (PARTITION BY PatientID order by ApptID asc),Apptdate) date_diff_2
            from #Appt1
          ) SRC
          

          演示 --> https://rextester.com/TNW43808

          【讨论】:

          • 这适用于当前的样本数据,但在给定不同的样本数据时可能会产生错误的结果。即使您使用apptDate 作为lag 函数的order by 列(您确实应该这样做,因为 id 并不能保证任何事情),通过引入更多后续约会仍然可以很容易地破坏它。例如,请参阅this Rextester demo。不错的尝试,不过...
          • 谢谢。应该使用日期而不是 ID。但是为什么 apptID = 6 25.01.2020 - 12.02.2020 --> 18 天 --> 跟进是错误的。
          • 因为它应该是New 而不是FollowUp。距该患者的第一次预约已超过 30 天...您应该从每个New 预约算起 30 天,然后再次使用New...
          • 是的。谢谢你。 :( 需要创建一个新的来检查有效日期。
          【解决方案10】:
          with cte
          as
          (
          select 
          tmp.*, 
          IsNull(Lag(ApptDate) Over (partition by PatientID Order by  PatientID,ApptDate),ApptDate) PriorApptDate
           from #Appt1 tmp
          )
          select 
          PatientID, 
          ApptDate, 
          PriorApptDate, 
          DateDiff(d,PriorApptDate,ApptDate) Elapsed,
          Case when DateDiff(d,PriorApptDate,ApptDate)>30 
          or DateDiff(d,PriorApptDate,ApptDate)=0 then 'New' else 'Followup'   end Category   from cte
          

          我是正确的。作者有误,见已过

          【讨论】:

            猜你喜欢
            • 2017-09-01
            • 2013-03-11
            • 2015-02-08
            • 1970-01-01
            • 1970-01-01
            • 2013-01-15
            • 2013-03-19
            • 2013-07-01
            • 1970-01-01
            相关资源
            最近更新 更多