Могу ли я использовать SQL Server CTE для объединения пересекающихся дат? - PullRequest
4 голосов
/ 03 декабря 2011

Я пишу приложение, которое обрабатывает расписание для некоторых наших сотрудников. В связи с этим мне нужно рассчитать, сколько минут в течение дня они запросили.

В первой версии этого инструмента мы запретили перекрывающиеся запросы на отключение, потому что мы хотели иметь возможность просто суммировать StartTime минус EndTime для всех запросов. Предотвращение перекрытий делает этот расчет очень быстрым.

Это стало проблематично, потому что менеджеры теперь хотят планировать встречи команды, но не могут этого сделать, когда кто-то уже попросил выходной.

Итак, в новой версии инструмента у нас есть требование разрешать перекрывающиеся запросы.

Вот пример набора данных, таких как у нас:

UserId | StartDate | EndDate
----------------------------
 1     | 2:00      | 4:00
 1     | 3:00      | 5:00
 1     | 3:45      | 9:00
 2     | 6:00      | 9:00
 2     | 7:00      | 8:00
 3     | 2:00      | 3:00
 3     | 4:00      | 5:00
 4     | 1:00      | 7:00

Результат, который мне нужно получить максимально эффективно, таков:

UserId | StartDate | EndDate
----------------------------
 1     | 2:00      | 9:00
 2     | 6:00      | 9:00
 3     | 2:00      | 3:00
 3     | 4:00      | 5:00
 4     | 1:00      | 7:00

Мы можем легко обнаружить совпадения с помощью этого запроса:

select
    *
from
    requests r1
cross join
    requests r2
where
    r1.RequestId < r2.RequestId
  and
    r1.StartTime < r2.EndTime
  and
    r2.StartTime < r1.EndTime

Фактически именно так мы первоначально выявляли и предотвращали проблемы.

Сейчас мы пытаемся объединить перекрывающиеся элементы, но я достигаю пределов своих навыков ниндзя в SQL.

Было бы не сложно придумать метод, использующий временные таблицы, но мы хотим избежать этого, если это вообще возможно.

Существует ли основанный на множестве способ объединения перекрывающихся строк?

<Ч />

Edit:

Также было бы приемлемо, чтобы все ряды обнаруживались, пока они были свернуты в свое время. Например, если кто-то хочет от трех до пяти и от четырех до шести, для него было бы приемлемо иметь два ряда, один от трех до пяти, а затем от пяти до шести ИЛИ от трех до четырех, и следующий с четырех до шести.

Также вот небольшой тестовый стенд:

DECLARE @requests TABLE
(
    UserId int,
    StartDate time,
    EndDate time
)

INSERT INTO @requests (UserId, StartDate, EndDate) VALUES
(1, '2:00', '4:00'),
(1, '3:00', '5:00'),
(1, '3:45', '9:00'),
(2, '6:00', '9:00'),
(2, '7:00', '8:00'),
(3, '2:00', '3:00'),
(3, '4:00', '5:00'),
(4, '1:00', '7:00');

Ответы [ 3 ]

4 голосов
/ 03 декабря 2011

Хорошо, это можно сделать с CTE. Я не знал, как их использовать в начале ночи, но вот результаты моего исследования:

Рекурсивный CTE состоит из 2 частей: оператора привязки и оператора рекурсии.

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

Так, например, если мы хотим использовать CTE для получения полного списка раз для этих пользователей, мы могли бы использовать что-то вроде этого:

WITH
  sorted_requests as (
    SELECT
        UserId, StartDate, EndDate,
        ROW_NUMBER() OVER (PARTITION BY UserId ORDER BY StartDate, EndDate DESC) Instance
    FROM @requests
  ),
  no_overlap(UserId, StartDate, EndDate, Instance) as (
    SELECT *
    FROM sorted_requests
    WHERE Instance = 1

    UNION ALL

    SELECT s.*
    FROM sorted_requests s
    INNER JOIN no_overlap n
    ON s.UserId = n.UserId
    AND s.Instance = n.Instance + 1
  )
SELECT *
FROM no_overlap

Здесь оператор "привязки" - это только первый экземпляр для каждого пользователя, WHERE Instance = 1.

"Рекурсивный" оператор соединяет каждую строку со следующей строкой в ​​наборе, используя s.UserId = n.UserId AND s.Instance = n.Instance + 1

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

Используя этот запрос:

WITH
  sorted_requests as (
    SELECT
        UserId, StartDate, EndDate,
        ROW_NUMBER() OVER (PARTITION BY UserId ORDER BY StartDate, EndDate DESC) Instance
    FROM
        @requests
  ),
  no_overlap(UserId, StartDate, EndDate, Instance, ConnectedGroup) as (
    SELECT
        UserId,
        StartDate,
        EndDate,
        Instance,
        Instance as ConnectedGroup
    FROM sorted_requests
    WHERE Instance = 1

    UNION ALL

    SELECT
        s.UserId,
        s.StartDate,
        CASE WHEN n.EndDate >= s.EndDate
            THEN n.EndDate
            ELSE s.EndDate
        END EndDate,
        s.Instance,
        CASE WHEN n.EndDate >= s.StartDate
            THEN n.ConnectedGroup
            ELSE s.Instance
        END ConnectedGroup
    FROM sorted_requests s
    INNER JOIN no_overlap n
    ON s.UserId = n.UserId AND s.Instance = n.Instance + 1
  )
SELECT
    UserId,
    MIN(StartDate) StartDate,
    MAX(EndDate) EndDate
FROM no_overlap
GROUP BY UserId, ConnectedGroup
ORDER BY UserId

Мы группируем по вышеупомянутой «первой пересекающейся строке» (называемой ConnectedGroup в этом запросе) и находим минимальное время начала и максимальное время окончания в этой группе.

Первая пересекающаяся строка распространяется с помощью этого оператора:

CASE WHEN n.EndDate >= s.StartDate
    THEN n.ConnectedGroup
    ELSE s.Instance
END ConnectedGroup

Что в основном гласит: «если эта строка пересекается с предыдущей строкой (на основании того, что мы отсортированы по дате начала), то считается, что эта строка имеет ту же« группировку строк », что и предыдущая строка. В противном случае используйте собственную строку номер строки как «группировка строк» ​​для себя. "

Это дает нам именно то, что мы искали.

EDIT

Когда я первоначально придумал это на своей доске, я знал, что мне придется продвигать EndDate каждой строки, чтобы она пересекалась со следующей строкой, если какая-либо из предыдущих строк в подключенной группа бы пересеклась. Я случайно пропустил это. Это было исправлено.

3 голосов
/ 03 декабря 2011

Complete Rewrite:

;WITH new_grp AS (
   SELECT r1.UserId, r1.StartTime
   FROM   @requests r1
   WHERE  NOT EXISTS (
          SELECT *
          FROM   @requests r2
          WHERE  r1.UserId = r2.UserId
          AND    r2.StartTime <  r1.StartTime
          AND    r2.EndTime   >= r1.StartTime)
   GROUP  BY r1.UserId, r1.StartTime -- there can be > 1
   ),r AS (
   SELECT r.RequestId, r.UserId, r.StartTime, r.EndTime
         ,count(*) AS grp -- guaranteed to be 1+
   FROM   @requests r
   JOIN   new_grp n ON n.UserId = r.UserId AND n.StartTime <= r.StartTime
   GROUP  BY r.RequestId, r.UserId, r.StartTime, r.EndTime
   )
SELECT min(RequestId) AS RequestId
      ,UserId
      ,min(StartTime) AS StartTime
      ,max(EndTime)   AS EndTime
FROM   r
GROUP  BY UserId, grp
ORDER  BY UserId, grp

Теперь выдает запрошенный результат, а действительно покрывает все возможные случаи, включая дизъюнктные подгруппы и дубликаты.Посмотрите комментарии к тестовым данным в рабочей демонстрации на data.SE .

  • CTE 1
    Найдите(уникальные!) моменты времени, когда начинается новая группа перекрывающихся интервалов.

  • CTE 2
    Считать начало новой группы вплоть до (и включая) каждый отдельный интервал, тем самымформирование уникального номера группы для каждого пользователя.

  • Окончательный ВЫБОР
    Объединение групп, начало и конец для групп.

Я столкнулся с некоторыми трудностями, потому что оконные функции T-SQL max() или sum() не принимают предложение ORDER BY в окне.Они могут вычислять только одно значение на раздел, что делает невозможным вычисление текущей суммы / количества на раздел.Будет работать в PostgreSQL или Oracle (но, конечно, не в MySQL - в нем нет ни оконных функций, ни CTE).

Окончательное решение использует один дополнительный CTE и должно быть таким же быстрым.

1 голос
/ 03 декабря 2011

Это работает для postgres.Microsoft, возможно, потребуются некоторые модификации.

SET search_path='tmp';

DROP TABLE tmp.schedule CASCADE;

CREATE TABLE tmp.schedule
        ( person_id INTEGER NOT NULL
        , dt_from timestamp with time zone
        , dt_to timestamp with time zone
        );
INSERT INTO schedule( person_id, dt_from, dt_to) VALUES
          ( 1, '2011-12-03 02:00:00' , '2011-12-03 04:00:00' )
        , ( 1, '2011-12-03 03:00:00' , '2011-12-03 05:00:00' )
        , ( 1, '2011-12-03 03:45:00' , '2011-12-03 09:00:00' )
        , ( 2, '2011-12-03 06:00:00' , '2011-12-03 09:00:00' )
        , ( 2, '2011-12-03 07:00:00' , '2011-12-03 08:00:00' )
        , ( 3, '2011-12-03 02:00:00' , '2011-12-03 03:00:00' )
        , ( 3, '2011-12-03 04:00:00' , '2011-12-03 05:00:00' )
        , ( 4, '2011-12-03 01:00:00' , '2011-12-03 07:00:00' );

ALTER TABLE schedule ADD PRIMARY KEY (person_id,dt_from)
        ;
CREATE UNIQUE INDEX ON schedule (person_id,dt_to);

SELECT * FROM schedule ORDER BY person_id, dt_from;

WITH RECURSIVE ztree AS (
    -- Terminal part
    SELECT p1.person_id AS person_id
       , p1.dt_from AS dt_from
       , p1.dt_to AS dt_to
    FROM schedule p1
    UNION
    -- Recursive part
    SELECT p2.person_id AS person_id
       , LEAST(p2.dt_from, zzt.dt_from) AS dt_from
       , GREATEST(p2.dt_to, zzt.dt_to) AS dt_to
    FROM ztree AS zzt
       , schedule AS p2
    WHERE 1=1
    AND p2.person_id = zzt.person_id
    AND (p2.dt_from < zzt.dt_from AND p2.dt_to >= zzt.dt_from)
    )
SELECT *
FROM ztree zt
WHERE NOT EXISTS (
   SELECT * FROM ztree nx
   WHERE nx.person_id = zt.person_id
           -- the recursive query returns *all possible combinations of
           -- touching or overlapping intervals
           -- we'll have to filter, keeping only the biggest ones
           -- (the ones for which there is no bigger overlapping interval)
   AND     ( (nx.dt_from <= zt.dt_from AND nx.dt_to > zt.dt_to)
          OR (nx.dt_from < zt.dt_from AND nx.dt_to >= zt.dt_to)
          )
      )
ORDER BY zt.person_id,zt.dt_from
    ;

Результат:

DROP TABLE
CREATE TABLE
INSERT 0 8
NOTICE:  ALTER TABLE / ADD PRIMARY KEY will create implicit index "schedule_pkey"  for table "schedule"
ALTER TABLE
CREATE INDEX
 person_id |        dt_from         |         dt_to          
-----------+------------------------+------------------------
         1 | 2011-12-03 02:00:00+01 | 2011-12-03 04:00:00+01
         1 | 2011-12-03 03:00:00+01 | 2011-12-03 05:00:00+01
         1 | 2011-12-03 03:45:00+01 | 2011-12-03 09:00:00+01
         2 | 2011-12-03 06:00:00+01 | 2011-12-03 09:00:00+01
         2 | 2011-12-03 07:00:00+01 | 2011-12-03 08:00:00+01
         3 | 2011-12-03 02:00:00+01 | 2011-12-03 03:00:00+01
         3 | 2011-12-03 04:00:00+01 | 2011-12-03 05:00:00+01
         4 | 2011-12-03 01:00:00+01 | 2011-12-03 07:00:00+01
(8 rows)

 person_id |        dt_from         |         dt_to          
-----------+------------------------+------------------------
         1 | 2011-12-03 02:00:00+01 | 2011-12-03 09:00:00+01
         2 | 2011-12-03 06:00:00+01 | 2011-12-03 09:00:00+01
         3 | 2011-12-03 02:00:00+01 | 2011-12-03 03:00:00+01
         3 | 2011-12-03 04:00:00+01 | 2011-12-03 05:00:00+01
         4 | 2011-12-03 01:00:00+01 | 2011-12-03 07:00:00+01
(5 rows)
...