個別の範囲を可能な限り最大の連続する範囲に結合する


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、離散型で、すべての範囲は、デフォルト持って[)境界を。 ドキュメントごと:

    組み込みの範囲タイプint4rangeint8rangeおよび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;

または、サブクエリレベルを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;
  • 2番目のステップでウィンドウをORDER BY range DESC NULLS LAST(でNULLS LAST)ソートして、ソート順序を完全に逆にします。これは、より安価で(作成が容易で、推奨インデックスのソート順と完全に一致する)、でのコーナーケースで正確でなければなりませんrank IS NULL

説明する

a:で並べ替えながら、ウィンドウ関数で上限()の最大値range計算します。 NULL境界(無制限)を+/-に置き換えて、単純化するだけです(特別なNULLケースはありません)。enddate
infinity

b:同じソート順で、前のものenddatestartdateギャップよりも早く、新しい範囲を開始する場合(step)。
上限は常に除外されることに注意してください。

cgrp別のウィンドウ関数でステップをカウントすることにより、グループ()を形成します。

外側のSELECTビルドでは、各グループの下限から上限までの範囲です。ボイラ。
SOに関する密接に関連した回答と詳細な説明:

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ソリューションに似ていますが、1回のパスで対応できます。

SQLフィドル。

関連:

動的SQLでユーザー入力を処理するための通常のドリル:

索引

これらの各ソリューションではrange、大きなテーブルでのパフォーマンスを向上させるために、プレーン(デフォルト)のbtreeインデックスが役立ちます。

CREATE INDEX foo on test (range);

btreeインデックスは範囲タイプに対して限定的に使用されますが、事前にソートされたデータを取得でき、インデックスのみのスキャンでさえ取得できます。


@Villiers:これらの各ソリューションがあなたのデータでどのように機能するか非常に興味があります。たぶん、あなたはテスト結果とあなたのテーブルデザインとカーディナリティに関するいくつかの情報で別の答えを投稿できますか?ベスト持つEXPLAIN ( ANALYZE, TIMING OFF)と5の最高を比較します。
アーウィンブランドステッター

この種の問題の鍵は、ソートされた行の値を比較する遅延SQL関数(リードも使用可能)です。これにより、重複する範囲を単一の範囲にマージするために使用できる自己結合の必要性がなくなりました。範囲の代わりに、some_star、some_endの2つの列に関連する問題では、この戦略を使用できます。
ケミン周

@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()オープン範囲が存在することができる場合は特に、行ごとに...
アーウィンBrandstetter

@ErwinBrandstetterはい、それはテストしたい問題です(最初の極端な結果が9999-12-31だった後:)。同時に、なぜ私の答えがあなたよりも多くの賛成票を持っているのか疑問に思っています。これはおそらく理解しやすいかもしれません...だから、将来の有権者:アーウィンの答えは私のものよりも優れています!投票してください!
-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でも同じかどうかはわかりませんが、実際のパフォーマンスの数値を取得できたらうれしいです。


範囲の開始のみで注文するだけで十分ですか?開始が同じで終了が異なる3つの範囲がある場合は機能しますか?
サルマンA

1
開始日のみで機能します。降順でソートされた終了日を追加する必要はありません(ギャップのみをチェックするため、特定の日付の最初の行は一致します)
dnoeth

-1

楽しみのために、私はそれにショットをしました。これが、これを行うための最速かつクリーンな方法であることがわかりました。まず、オーバーラップがある場合、または2つの入力が隣接している場合にマージする関数を定義します。オーバーラップまたは隣接がない場合は、最初の日付範囲を返すだけです。ヒント+は、範囲のコンテキストにおける範囲結合です。

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
ウィンドウ関数は、一度に2つの隣接する値のみを考慮し、チェーンを逃します。でお試しください('2015-01-01', '2015-01-03'), ('2015-01-03', '2015-01-05'), ('2015-01-05', '2015-01-06')
アーウィンブランドステッター
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.