Об'єднання окремих діапазонів у найбільші можливі суміжні діапазони


20

Я намагаюся поєднувати декілька діапазонів дат (моє завантаження становить приблизно макс. 500, більшість випадків 10), які можуть або не можуть перетинатися на найбільші можливі суміжні діапазони дат. Наприклад:

Дані:

CREATE TABLE test (
  id SERIAL PRIMARY KEY NOT NULL,
  range DATERANGE
);

INSERT INTO test (range) VALUES 
  (DATERANGE('2015-01-01', '2015-01-05')),
  (DATERANGE('2015-01-01', '2015-01-03')),
  (DATERANGE('2015-01-03', '2015-01-06')),
  (DATERANGE('2015-01-07', '2015-01-09')),
  (DATERANGE('2015-01-08', '2015-01-09')),
  (DATERANGE('2015-01-12', NULL)),
  (DATERANGE('2015-01-10', '2015-01-12')),
  (DATERANGE('2015-01-10', '2015-01-12'));

Таблиця виглядає так:

 id |          range
----+-------------------------
  1 | [2015-01-01,2015-01-05)
  2 | [2015-01-01,2015-01-03)
  3 | [2015-01-03,2015-01-06)
  4 | [2015-01-07,2015-01-09)
  5 | [2015-01-08,2015-01-09)
  6 | [2015-01-12,)
  7 | [2015-01-10,2015-01-12)
  8 | [2015-01-10,2015-01-12)
(8 rows)

Бажані результати:

         combined
--------------------------
 [2015-01-01, 2015-01-06)
 [2015-01-07, 2015-01-09)
 [2015-01-10, )

Візуальне представлення:

1 | =====
2 | ===
3 |    ===
4 |        ==
5 |         =
6 |             =============>
7 |           ==
8 |           ==
--+---------------------------
  | ====== == ===============>

Відповіді:


22

Припущення / уточнення

  1. Не потрібно розрізняти infinityта відкривати верхню межу ( upper(range) IS NULL). (Ви можете мати його в будь-якому випадку, але це простіше.)

  2. Оскільки dateце дискретний тип, всі діапазони мають [)межі за замовчуванням . За документацію:

    Вбудовані типи дальності int4range, int8rangeі daterangeбудь-яке використання канонічної формі , яка включає в себе нижню межу і НЕ включає верхню межу; тобто [).

    Для інших типів (як tsrange!) Я б застосував те саме, якщо можливо:

Рішення з чистим SQL

З CTE для наочності:

WITH a AS (
   SELECT range
        , COALESCE(lower(range),'-infinity') AS startdate
        , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
   FROM   test
   )
, b AS (
   SELECT *, lag(enddate) OVER (ORDER BY range) < startdate OR NULL AS step
   FROM   a
   )
, c AS (
   SELECT *, count(step) OVER (ORDER BY range) AS grp
   FROM   b
   )
SELECT daterange(min(startdate), max(enddate)) AS range
FROM   c
GROUP  BY grp
ORDER  BY 1;

Або те саме, що підзапроси, швидше, але менш легко читати:

SELECT daterange(min(startdate), max(enddate)) AS range
FROM  (
   SELECT *, count(step) OVER (ORDER BY range) AS grp
   FROM  (
      SELECT *, lag(enddate) OVER (ORDER BY range) < startdate OR NULL AS step
      FROM  (
         SELECT range
              , COALESCE(lower(range),'-infinity') AS startdate
              , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
         FROM   test
         ) a
      ) b
   ) c
GROUP  BY grp
ORDER  BY 1;

Або з одним менш рівним запитом, але гортаючи сортування:

SELECT daterange(min(COALESCE(lower(range), '-infinity')), max(enddate)) AS range
FROM  (
   SELECT *, count(nextstart > enddate OR NULL) OVER (ORDER BY range DESC NULLS LAST) AS grp
   FROM  (
      SELECT range
           , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
           , lead(lower(range)) OVER (ORDER BY range) As nextstart
      FROM   test
      ) a
   ) b
GROUP  BY grp
ORDER  BY 1;
  • Сортуйте вікно на другому кроці за допомогою ORDER BY range DESC NULLS LASTNULLS LAST), щоб отримати ідеально перевернутий порядок сортування. Це повинно бути дешевшим (простіше у виробництві, відмінно відповідає порядку упорядкованого індексу) та точним для кутових справ rank IS NULL.

Поясніть

a: Під час замовлення rangeобчислюйте максимум виконання верхньої межі ( enddate) за допомогою віконної функції.
Замініть NULL межі (без обмежень) на +/-infinity просто для спрощення (жодних спеціальних випадків NULL).

b: У такому ж порядку сортування, якщо попередній enddateраніше, ніж у startdateнас, буде розрив і запустити новий діапазон ( step).
Пам'ятайте, верхня межа завжди виключається.

c: Групи форм (grp ), рахуючи кроки з іншою функцією вікна.

У зовнішній SELECTзбірці діапазон від нижньої до верхньої межі кожної групи. Войла.
Тісно пов’язана відповідь на ТА з додатковими поясненнями:

Процедурний розчин з plpgsql

Працює для будь-якої таблиці таблиці / стовпців, але лише для типу daterange.
Процедурні рішення з циклами, як правило, повільніше, але в цьому спеціальному випадку я очікую, що функція буде значно швидшою, оскільки для цього потрібне лише одне послідовне сканування :

CREATE OR REPLACE FUNCTION f_range_agg(_tbl text, _col text)
  RETURNS SETOF daterange AS
$func$
DECLARE
   _lower     date;
   _upper     date;
   _enddate   date;
   _startdate date;
BEGIN
   FOR _lower, _upper IN EXECUTE
      format($$SELECT COALESCE(lower(t.%2$I),'-infinity')  -- replace NULL with ...
                    , COALESCE(upper(t.%2$I), 'infinity')  -- ... +/- infinity
               FROM   %1$I t
               ORDER  BY t.%2$I$$
            , _tbl, _col)
   LOOP
      IF _lower > _enddate THEN     -- return previous range
         RETURN NEXT daterange(_startdate, _enddate);
         SELECT _lower, _upper  INTO _startdate, _enddate;

      ELSIF _upper > _enddate THEN  -- expand range
         _enddate := _upper;

      -- do nothing if _upper <= _enddate (range already included) ...

      ELSIF _enddate IS NULL THEN   -- init 1st round
         SELECT _lower, _upper  INTO _startdate, _enddate;
      END IF;
   END LOOP;

   IF FOUND THEN                    -- return last row
      RETURN NEXT daterange(_startdate, _enddate);
   END IF;
END
$func$  LANGUAGE plpgsql;

Виклик:

SELECT * FROM f_range_agg('test', 'range');  -- table and column name

Логіка схожа на рішення SQL, але ми можемо зробити це за один прохід.

SQL Fiddle.

Пов'язані:

Звичайна дриль для обробки вводу користувача в динамічному SQL:

Покажчик

Для кожного з цих рішень звичайний (за замовчуванням) індекс btree rangeбуде важливим для роботи у великих таблицях:

CREATE INDEX foo on test (range);

Індекс btree має обмежене використання для типів діапазону , але ми можемо отримати попередньо відсортовані дані та, можливо, навіть сканування лише з індексом.


@Villiers: Мені буде дуже цікаво, як працює кожне з цих рішень з вашими даними. Можливо, ви можете опублікувати ще одну відповідь з результатами тестування та деякою інформацією про дизайн та кардинальності вашого столу? Найкраще EXPLAIN ( ANALYZE, TIMING OFF)і порівняйте найкраще з п'яти.
Ервін Брандстеттер

Ключовим фактором подібного роду проблем є затримка SQL-функції (також можна використовувати відведення), яка порівнює значення відсортованих рядків. Це усувало необхідність самостійного з'єднання, яке також можна використовувати для об'єднання діапазонів, що перекриваються, в один діапазон. Замість діапазону будь-яка проблема із залученням двох стовпців some_star, some_end може використовувати цю стратегію.
Kemin Zhou

@ErwinBrandstetter Ей, я намагаюся зрозуміти цей запит (той, що має CTE), але я не можу зрозуміти, що таке (CTE A) max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate? Не може бути просто COALESCE(upper(range), 'infinity') as enddate? AFAIK max() + over (order by range)повернеться саме upper(range)сюди.
user606521

1
@ user606521: Ви спостерігаєте, що верхня межа постійно зростає при сортуванні за діапазоном, що може бути гарантовано для деяких розподілів даних, а потім ви можете спростити, як ви запропонуєте. Приклад: діапазони фіксованої довжини. Але для діапазонів довільної довжини наступний діапазон може мати більшу нижню межу, але все ж нижню верхню межу. Тому нам потрібна найбільша верхня межа всіх діапазонів поки що.
Ервін Брандштеттер

6

Я придумав це:

DO $$                                                                             
DECLARE 
    i date;
    a daterange := 'empty';
    day_as_range daterange;
    extreme_value date := '2100-12-31';
BEGIN
    FOR i IN 
        SELECT DISTINCT 
             generate_series(
                 lower(range), 
                 COALESCE(upper(range) - interval '1 day', extreme_value), 
                 interval '1 day'
             )::date
        FROM rangetest 
        ORDER BY 1
    LOOP
        day_as_range := daterange(i, i, '[]');
        BEGIN
            IF isempty(a)
            THEN a := day_as_range;
            ELSE a = a + day_as_range;
            END IF;
        EXCEPTION WHEN data_exception THEN
            RAISE INFO '%', a;
            a = day_as_range;
        END;
    END LOOP;

    IF upper(a) = extreme_value + interval '1 day'
    THEN a := daterange(lower(a), NULL);
    END IF;

    RAISE INFO '%', a;
END;
$$;

Ще потрібно трохи відточити, але ідея така:

  1. підірвати діапазони до окремих дат
  2. роблячи це, замініть нескінченну верхню межу якоюсь крайньою величиною
  3. виходячи з впорядкування з (1), починайте будувати діапазони
  4. коли союз (+ ) виходить з ладу, поверніть вже побудований діапазон і повторно ініціалізуйтеся
  5. нарешті, поверніть решту - якщо буде досягнуто попередньо визначеного крайнього значення, замініть його на NULL, щоб отримати нескінченну верхню межу

Мені здається, що бігати generate_series()за кожним рядом мені досить дорого , особливо якщо там можуть бути відкриті діапазони ...
Ервін Брандстеттер

@ErwinBrandstetter так, це питання, яке я хотів перевірити (після моєї першої крайності було 9999-12-31 :). У той же час мені цікаво, чому моя відповідь має більше результатів, ніж ваша. Це можливо легше зрозуміти ... Отже, майбутні виборці: відповідь Ервіна вища за мою! Голосуйте там!
дезсо

3

Деякі роки тому я перевіряв різні рішення (серед інших подібні до тих, що були у @ErwinBrandstetter) для об'єднання періодів перекриття в системі Teradata, і я знайшов наступний найбільш ефективний (використовуючи аналітичні функції, новіша версія Teradata має вбудовані функції для це завдання).

  1. сортуйте рядки за датою початку
  2. знайти максимальну дату закінчення всіх попередніх рядків: maxEnddate
  3. якщо ця дата менша, ніж поточна дата початку, ви виявили розрив. Зберігайте лише ті рядки плюс перший рядок у розділі PARTITION (який позначений NULL) та фільтруйте всі інші рядки. Тепер ви отримуєте дату початку для кожного діапазону та дату закінчення попереднього діапазону.
  4. Тоді ви просто отримаєте наступний Роу , maxEnddateвикористовуючи LEADі ви майже закінчили. Тільки для останнього рядка LEADповертається a NULL, щоб вирішити цю обчислення максимальної дати закінчення всіх рядків розділу на кроці 2 і COALESCEце.

Чому це було швидше? Залежно від фактичних даних, крок №2 може значно зменшити кількість рядків, тому наступний крок повинен працювати лише на невеликому підмножині, додатково він видаляє агрегацію.

скрипка

SELECT
   daterange(startdate
            ,COALESCE(LEAD(maxPrevEnddate) -- next row's end date
                      OVER (ORDER BY startdate) 
                     ,maxEnddate)          -- or maximum end date
            ) AS range

FROM
 (
   SELECT
      range
     ,COALESCE(LOWER(range),'-infinity') AS startdate

   -- find the maximum end date of all previous rows
   -- i.e. the END of the previous range
     ,MAX(COALESCE(UPPER(range), 'infinity'))
      OVER (ORDER BY range
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS maxPrevEnddate

   -- maximum end date of this partition
   -- only needed for the last range
     ,MAX(COALESCE(UPPER(range), 'infinity'))
      OVER () AS maxEnddate
   FROM test
 ) AS dt
WHERE maxPrevEnddate < startdate -- keep the rows where a range start
   OR maxPrevEnddate IS NULL     -- and keep the first row
ORDER BY 1;  

Оскільки це було найшвидшим на Teradata, я не знаю, чи це те саме для PostgreSQL, було б непогано отримати деякі фактичні показники продуктивності.


Чи достатньо замовити лише на початку асортименту? Чи працює це, якщо у вас є три діапазони кожен з однаковим початком, але різним кінцем?
Салман А

1
Він працює лише з датою початку, не потрібно додавати дату закінчення, відсортовану за спаданням (ви перевіряєте лише пробіл, тож, який би не був перший рядок для даної дати)
dnoeth

-1

Для задоволення я дав це зняти. Я вважав, що це найшвидший і найчистіший метод зробити це. Спочатку ми визначаємо функцію, яка об'єднується, якщо є перекриття або якщо два входи суміжні, якщо немає перекриття чи суміжності, ми просто повертаємо перший діапазон дат. Підказка +- це об'єднання діапазону в контексті діапазонів.

CREATE FUNCTION merge_if_adjacent_or_overlaps (d1 daterange, d2 daterange)
RETURNS daterange AS $$
  SELECT
    CASE WHEN d1 && d2 OR d1 -|- d2
    THEN d1 + d2
    ELSE d1
    END;
$$ LANGUAGE sql
IMMUTABLE;

Тоді ми використовуємо це так,

SELECT DISTINCT ON (lower(cumrange)) cumrange
FROM (
  SELECT merge_if_adjacent_or_overlaps(
    t1.range,
    lag(t1.range) OVER (ORDER BY t1.range)
  ) AS cumrange
  FROM test AS t1
) AS t
ORDER BY lower(cumrange)::date, upper(cumrange)::date DESC NULLS first;

1
Функція вікна враховує одночасно два суміжних значення та пропускає ланцюги. Спробуйте з ('2015-01-01', '2015-01-03'), ('2015-01-03', '2015-01-05'), ('2015-01-05', '2015-01-06').
Ервін Брандстеттер
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.