별도의 범위를 가능한 가장 큰 연속 범위로 결합


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, int8rangedaterange모두는 하한을 포함하고 상한을 제외하는 표준 형식을 사용합니다. 즉 [).

    다른 유형 (예 : 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 LAST(와 NULLS LAST)으로 두 번째 단계에서 창을 정렬하면 완전히 역순으로 정렬됩니다. 이 값은보다 저렴하고 (제작하기 쉽고, 제안 된 색인의 정렬 순서와 완벽하게 일치), 코너 케이스의 경우 정확해야 합니다 rank IS NULL.

설명

a:로 주문하는 동안 창 함수 를 사용하여 상한 ( ) 의 최대 값range계산하십시오 . NULL 경계 (무 한계)를 +/-로 바꾸면 간단하게됩니다 (특별한 NULL 경우는 없음).enddate
infinity

b: 같은 정렬 순서에서 이전 enddate이 이전보다 startdate빠르면 간격이 있고 새 범위 ( step)가 시작 됩니다.
상한은 항상 제외됩니다.

c: grp다른 창 기능으로 단계를 계산하여 그룹 ( )을 형성합니다.

외부 SELECT빌드에서 각 그룹의 하한에서 상한에 이릅니다. 보일라
자세한 설명과 함께 SO에 밀접하게 관련된 답변 :

plpgsql을 사용한 절차 적 솔루션

모든 테이블 / 열 이름에서 작동하지만 type에서만 작동합니다 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 바이올린.

관련 :

동적 SQL에서 사용자 입력을 처리하기위한 일반적인 드릴 :

색인

이러한 각 솔루션에 대해 일반 (기본) btree 인덱스 range는 큰 테이블에서 성능을 발휘하는 데 도움이됩니다.

CREATE INDEX foo on test (range);

btree 인덱스는 범위 유형에 제한적으로 사용 되지만 미리 정렬 된 데이터와 인덱스 전용 스캔을 얻을 수 있습니다.


@Villiers : 각 솔루션이 데이터를 처리하는 방식에 매우 관심이 있습니다. 테스트 결과 및 테이블 디자인 및 카디널리티에 대한 정보가 포함 된 다른 답변을 게시 할 수 있습니까? 최고 EXPLAIN ( ANALYZE, TIMING OFF)5와 최고를 비교하십시오.
Erwin Brandstetter

이러한 종류의 문제의 핵심은 정렬 된 행의 값을 비교하는 지연 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 : 범위를 기준으로 정렬 할 때 상한이 지속적으로 커지는 경우가 있습니다. 이는 일부 데이터 분포에 대해 보장 될 수 있으며 제안한대로 단순화 할 수 있습니다. 예 : 고정 길이 범위. 그러나 임의의 길이 범위의 경우 다음 범위는 더 큰 하한을 가질 수 있지만 여전히 더 낮은 상한을 가질 수 있습니다. 따라서 지금까지 모든 범위의 최대 상한이 필요합니다.
Erwin Brandstetter

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 :). 동시에, 나는 왜 나의 대답이 당신의 것보다 더 많은 투표를했는지 궁금합니다. 이것은 아마도 이해하기가 더 쉬울 것입니다. 그래서, 미래의 유권자들 : Erwin의 대답은 나의 것보다 우수합니다! 거기에 투표하십시오!
dezso

3

몇 년 전에 Teradata 시스템에서 겹치는 기간을 병합하기 위해 다른 솔루션 (@ErwinBrandstetter의 솔루션과 유사한 솔루션)을 테스트했으며 가장 효율적인 솔루션은 다음과 같습니다 (분석 기능 사용, 최신 버전의 Teradata는 그 작업).

  1. 시작 날짜별로 행을 정렬
  2. 모든 이전 행의 최대 종료 날짜를 찾으십시오. maxEnddate
  3. 이 날짜가 현재 시작 날짜보다 작 으면 차이가있는 것입니다. 해당 행과 PARTITION 내의 첫 번째 행 (NULL로 표시) 만 유지하고 다른 모든 행을 필터링하십시오. 이제 각 범위의 시작 날짜와 이전 범위의 종료 날짜를 얻습니다.
  4. 그런 다음 단순히 다음 행을 maxEnddate사용 LEAD하면 거의 끝납니다. 마지막 행에 대해서만을 LEAD반환 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과 동일한 지 모르겠습니다. 실제 성능 수치를 얻는 것이 좋을 것입니다.


범위 시작으로 만 주문하면 충분합니까? 시작이 동일하지만 끝이 다른 세 개의 범위가있는 경우 작동합니까?
Salman A

1
시작 날짜에서만 작동하며 내림차순으로 정렬 된 종료 날짜를 추가 할 필요가 없습니다 (격차 만 확인하므로 주어진 날짜의 첫 번째 행이 일치
하는지 여부

-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').
Erwin Brandstetter
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.