Как объединить временные интервалы в SQL Server - PullRequest
5 голосов
/ 05 ноября 2011

Предположим, у меня есть следующая таблица событий с personId, startDate и endDate.

Я хочу знать, сколько времени человек Х потратил на выполнение события (события могут перекрывать друг друга).

Если у человека только 1 событие, это просто: datediff(dd, startDate, endDate)

Если у человека есть 2 события, это становится хитрым.

Я задам несколько сценариев для ожидаемых результатов.

Сценарий 1

startDate endDate
1         4
3         5

Это означает, что результаты должны быть датированы от 1 до 5

Сценарий 2

startDate endDate
1         3
6         9

это означает, что результаты должны быть некоторыми из datediff(dd,1,3) и datediff(dd,6,9)

Как я могу получить этот результат в SQL-запросе? Я могу думать только о куче операторов if, но один и тот же человек может иметь n событий, поэтому запрос будет действительно запутанным.

Shredder Edit: Я хотел бы добавить третий сценарий:

startDate endDate
1       5
4       8
11      15

Желаемый результат для сценария Шредер:

(1,5) и (4,8) объединяются в (1,8), так как они перекрываются, тогда нам нужно datediff(1,8) + datediff(11,15) => 7 + 4 => 11

Ответы [ 7 ]

10 голосов
/ 05 ноября 2011

Вы можете использовать рекурсивный CTE для создания списка дат и затем подсчитывать различные даты.

declare @T table
(
  startDate date,
  endDate date
);

insert into @T values
('2011-01-01', '2011-01-05'),
('2011-01-04', '2011-01-08'),
('2011-01-11', '2011-01-15');

with C as
(
  select startDate,
         endDate
  from @T
  union all
  select dateadd(day, 1, startDate),
         endDate
  from C
  where dateadd(day, 1, startDate) < endDate       
)
select count(distinct startDate) as DayCount
from C
option (MAXRECURSION 0)

Результат:

DayCount
-----------
11

Или вы можете использовать таблицу чисел. Здесь я использую master..spt_values:

declare @MinStartDate date
select @MinStartDate = min(startDate)
from @T

select count(distinct N.number)
from @T as T
  inner join master..spt_values as N
    on dateadd(day, N.Number, @MinStartDate) between T.startDate and dateadd(day, -1, T.endDate)
where N.type = 'P'    
2 голосов
/ 05 ноября 2011

Вот решение, в котором используется идея Tally table (о которой я впервые услышал в статье Ицка Бен-Гана - я все еще вырезаю и вставляю его код всякий раз, когда поднимается тема).Идея состоит в том, чтобы сгенерировать список возрастающих целых чисел, объединить исходные данные по диапазону с числами, а затем подсчитать количество различных чисел следующим образом.(Этот код использует синтаксис SQL Server 2008, но с незначительными изменениями будет работать в SQL 2005.)

Сначала настройте некоторые данные тестирования:

CREATE TABLE #EventTable
 (
   PersonId   int  not null
  ,startDate  datetime  not null
  ,endDate    datetime  not null
 )

INSERT #EventTable
 values (1, 'Jan 1, 2011', 'Jan 4, 2011')
       ,(1, 'Jan 3, 2011', 'Jan 5, 2011')
       ,(2, 'Jan 1, 2011', 'Jan 3, 2011')
       ,(2, 'Jan 6, 2011', 'Jan 9, 2011')

Определите некоторые начальные значения

DECLARE @Interval bigint, @ FirstDay datetime, @ PersonId int = 1 - (или что-то еще)

Получите первый день и максимально возможное количество дат (чтобы сохранить cte от генерирования дополнительных значений):

SELECT
   @Interval = datediff(dd, min(startDate), max(endDate)) + 1
  ,@FirstDay = min(startDate)
 from #EventTable
 where PersonId = @PersonId

Вырежьте и вставьте одну подпрограмму, измените и протестируйте ее, чтобы она возвращала столько целых чисел, сколько нам потребуется:

/*
;WITH
  Pass0 as (select 1 as C union all select 1), --2 rows
  Pass1 as (select 1 as C from Pass0 as A, Pass0 as B),--4 rows
  Pass2 as (select 1 as C from Pass1 as A, Pass1 as B),--16 rows
  Pass3 as (select 1 as C from Pass2 as A, Pass2 as B),--256 rows
  Pass4 as (select 1 as C from Pass3 as A, Pass3 as B),--65536 rows
  Pass5 as (select 1 as C from Pass4 as A, Pass4 as B),--4,294,967,296 rows
  Tally as (select row_number() over(order by C) as Number from Pass5)
 select Number from Tally where Number <= @Interval
*/

А теперь исправьте ее, сначала присоединившисьдо интервалов, определенных в каждой строке источника, а затем подсчитайте каждое найденное отдельное значение:

;WITH
  Pass0 as (select 1 as C union all select 1), --2 rows
  Pass1 as (select 1 as C from Pass0 as A, Pass0 as B),--4 rows
  Pass2 as (select 1 as C from Pass1 as A, Pass1 as B),--16 rows
  Pass3 as (select 1 as C from Pass2 as A, Pass2 as B),--256 rows
  Pass4 as (select 1 as C from Pass3 as A, Pass3 as B),--65536 rows
  Pass5 as (select 1 as C from Pass4 as A, Pass4 as B),--4,294,967,296 rows
  Tally as (select row_number() over(order by C) as Number from Pass5)
SELECT PersonId, count(distinct Number) EventDays
 from #EventTable et
  inner join Tally
   on dateadd(dd, Tally.Number - 1, @FirstDay) between et.startDate and et.endDate
 where et.PersonId = @PersonId
  and Number <= @Interval
 group by PersonId

Выньте фильтр @PersonId, и вы получите его для всех людей.А с небольшими изменениями вы можете сделать это за любой промежуток времени, а не только за дни (именно поэтому я настроил таблицу Tally для генерации очень больших чисел.)

2 голосов
/ 05 ноября 2011

Следующий SQL относится к трем сценариям, которые вы описали

with sampleData 
AS (


    SELECT       1 personid,1 startDate,4 endDate
    UNION SELECT 1,3,5
    UNION SELECT 2,1,3
    UNION SELECT 2,6,9
    UNION SELECT 3,1,5 
    UNION SELECT 3,4,8
    UNION SELECT 3,11, 15

), 
     cte 
     AS (SELECT personid, 
                startdate, 
                enddate, 
                Row_number() OVER(ORDER BY personid, startdate) AS rn 
         FROM   sampledata), 
     overlaps 
     AS (SELECT a.personid, 
                a.startdate, 
                b.enddate, 
                a.rn id1, 
                b.rn id2 
         FROM   cte a 
                INNER JOIN cte b 
                  ON a.personid = b.personid 
                     AND a.enddate > b.startdate 
                     AND a.rn = b.rn - 1), 
     nooverlaps 
     AS (SELECT a.personid, 
                a.startdate, 
                a.enddate 
         FROM   cte a 
                LEFT JOIN overlaps b 
                  ON a.rn = b.id1 
                      OR a.rn = b.id2 
         WHERE  b.id1 IS NULL) 
SELECT personid, 
       SUM(timespent) timespent 
FROM   (SELECT personid, 
               enddate - startdate timespent 
        FROM   nooverlaps 
        UNION 
        SELECT personid, 
               enddate - startdate 
        FROM   overlaps) t 
GROUP  BY personid 

Создает этот результат

Personid    timeSpent
----------- -----------
1           4
2           5
3           11

Примечания: я использовал простые целые числа, но DateDiffs тоже должен работать

Проблема правильности Существует проблема правильности, если ваши данные могут иметь многократные совпадения, как отмечал Cheran S, результаты не будут правильными, и вы должны вместо этого использовать один из других ответов.Его пример использовал [1,5], [4,8], [7,11] для одного и того же человека ID

1 голос
/ 05 ноября 2011
;WITH cte(gap)
AS
(
    SELECT sum(b-a) from xxx GROUP BY uid
)

SELECT * FROM cte
1 голос
/ 05 ноября 2011

Алгебра. Если B-n - это время окончания n-го события, а A-n - время начала n-го события, тогда сумма разностей - это разница сумм. Так что вы можете написать

select everything else, sum(cast(endDate as int)) - sum(cast(startDate as int)) as daysSpent

Если ваши даты не имеют временной составляющей, это работает. В противном случае вы могли бы использовать реальный.

1 голос
/ 05 ноября 2011

Попробуйте что-то вроде этого

select 
    personId, 
    sum(DateDuration) as TotalDuration
from
(
    select personId, datediff(dd, startDate, endDate) as DateDuration
    from yourEventTable
) a
group by personId
0 голосов
/ 05 ноября 2011

Редактировать 1 : я изменил оба решения, чтобы получить правильные результаты.

Редактировать 2 : Я провел сравнительные тесты, используя решения, предложенные Микаэлем Эрикссоном, Конрадом Фриксом, Филипом Келли и мной. Все тесты используют EventTable со следующей структурой:

CREATE TABLE EventTable
(
     EventID    INT IDENTITY PRIMARY KEY
    ,PersonId   INT NOT NULL
    ,StartDate  DATETIME NOT NULL
    ,EndDate    DATETIME NOT NULL
    ,CONSTRAINT CK_StartDate_Before_EndDate CHECK(StartDate < EndDate)
);

Кроме того, все тесты используют теплый буфер (без DBCC DROPCLEANBUFFERS) и холодный [плановый] кэш (я выполнял DBCC FREEPROCCACHE перед каждым тестом). Поскольку некоторые решения используют фильтр (PersonId = 1), а другие нет, я вставил в EventTable строки только для одного человека (INSERT ...(PersonId,...) VALUES (1,...)).

Вот результаты: enter image description here

В моих решениях используются рекурсивные CTE .

Решение 1:

WITH BaseCTE
AS
(
    SELECT   e.StartDate
            ,e.EndDate
            ,e.PersonId
            ,ROW_NUMBER() OVER(PARTITION BY e.PersonId ORDER BY e.StartDate, e.EndDate) RowNumber
    FROM    EventTable e
),  RecursiveCTE
AS
(
    SELECT   b.PersonId
            ,b.RowNumber

            ,b.StartDate
            ,b.EndDate
            ,b.EndDate AS MaxEndDate
            ,1 AS PseudoDenseRank
    FROM    BaseCTE b
    WHERE   b.RowNumber = 1
    UNION ALL
    SELECT   crt.PersonId
            ,crt.RowNumber

            ,crt.StartDate
            ,crt.EndDate
            ,CASE WHEN crt.EndDate > prev.MaxEndDate THEN crt.EndDate ELSE prev.MaxEndDate END
            ,CASE WHEN crt.StartDate <= prev.MaxEndDate THEN prev.PseudoDenseRank ELSE prev.PseudoDenseRank + 1 END
    FROM    RecursiveCTE prev
    INNER JOIN BaseCTE crt ON prev.PersonId = crt.PersonId
    AND     prev.RowNumber + 1 = crt.RowNumber
),  SumDaysPerPersonAndInterval
AS
(
    SELECT   src.PersonId
            ,src.PseudoDenseRank --Interval ID
            ,DATEDIFF(DAY, MIN(src.StartDate), MAX(src.EndDate)) Days
    FROM    RecursiveCTE src
    GROUP BY src.PersonId, src.PseudoDenseRank
)
SELECT  x.PersonId, SUM( x.Days ) DaysPerPerson
FROM    SumDaysPerPersonAndInterval x
GROUP BY x.PersonId
OPTION(MAXRECURSION 32767);

Решение 2:

DECLARE @Base TABLE --or a temporary table: CREATE TABLE #Base (...) 
(
     PersonID   INT NOT NULL
    ,StartDate  DATETIME NOT NULL
    ,EndDate    DATETIME NOT NULL
    ,RowNumber  INT NOT NULL
    ,PRIMARY KEY(PersonID, RowNumber)
);
INSERT  @Base (PersonID, StartDate, EndDate, RowNumber)
SELECT   e.PersonId
        ,e.StartDate
        ,e.EndDate
        ,ROW_NUMBER() OVER(PARTITION BY e.PersonID ORDER BY e.StartDate, e.EndDate) RowNumber
FROM    EventTable e;

WITH RecursiveCTE
AS
(
    SELECT   b.PersonId
            ,b.RowNumber

            ,b.StartDate
            ,b.EndDate
            ,b.EndDate AS MaxEndDate
            ,1 AS PseudoDenseRank
    FROM    @Base b
    WHERE   b.RowNumber = 1
    UNION ALL
    SELECT   crt.PersonId
            ,crt.RowNumber

            ,crt.StartDate
            ,crt.EndDate
            ,CASE WHEN crt.EndDate > prev.MaxEndDate THEN crt.EndDate ELSE prev.MaxEndDate END
            ,CASE WHEN crt.StartDate <= prev.MaxEndDate THEN prev.PseudoDenseRank ELSE prev.PseudoDenseRank + 1 END
    FROM    RecursiveCTE prev
    INNER JOIN @Base crt ON prev.PersonId = crt.PersonId
    AND     prev.RowNumber + 1 = crt.RowNumber
),  SumDaysPerPersonAndInterval
AS
(
    SELECT   src.PersonId
            ,src.PseudoDenseRank --Interval ID
            ,DATEDIFF(DAY, MIN(src.StartDate), MAX(src.EndDate)) Days
    FROM    RecursiveCTE src
    GROUP BY src.PersonId, src.PseudoDenseRank
)
SELECT  x.PersonId, SUM( x.Days ) DaysPerPerson
FROM    SumDaysPerPersonAndInterval x
GROUP BY x.PersonId
OPTION(MAXRECURSION 32767);
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...