web-dev-qa-db-ja.com

個別の範囲を組み合わせて、可能な最大の連続範囲にする

私は複数の日付範囲(私の負荷は最大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 |           ==
--+---------------------------
  | ====== == ===============>
21

仮定/説明

  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;

Orサブクエリレベルが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 LASTNULLS LASTを使用)で並べ替えて、 perfectly 逆の並べ替え順序を取得します。これはより安く(作成が簡単で、提案されたインデックスのソート順を完全に一致させます)、rank IS NULL。のコーナーケースでは accurate

説明する

arangeで並べ替えるときに、実行最大値ウィンドウ関数での上限(enddate)の。
単純化するために、NULL境界(無制限)を+/- infinityに置き換えます(特別なNULLケースはありません)。

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

c:別のウィンドウ関数で歩数をカウントすることにより、グループ(grp)を形成します。

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

Plpgsqlによる手続き型ソリューション

任意のテーブル/列名で機能しますが、タイプdaterangeでのみ機能します。
ループを使用した手続き型のソリューションは通常遅いですbutこの特殊なケースでは、関数は実質的に =)速い単一の順次スキャンのみが必要なため

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インデックスは範囲タイプの使用が制限されています ですが、事前にソートされたデータや、おそらくインデックスのみのスキャンも取得できます。

23

私はこれを思いつきました:

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に置き換えて、無限の上限を取得します
6
dezso

数年前、Teradataシステムで重複する期間をマージするために(@ErwinBrandstetterのソリューションと同様のいくつかのソリューションの中で)さまざまなソリューションをテストし、次の最も効率的なソリューションを見つけました(分析関数を使用して、Teradataの新しいバージョンには組み込み関数があります)そのタスク)。

  1. 開始日で行を並べ替える
  2. 以前のすべての行の最大終了日を見つける:maxEnddate
  3. この日付が現在の開始日より小さい場合、ギャップが見つかりました。これらの行と最初の行と(NULLで示される)PARTITION内の行のみを保持し、他のすべての行をフィルターに掛けます。ここで、各範囲の開始日と前の範囲の終了日を取得します。
  4. 次に、maxEnddateを使用して次の行のLEADを取得するだけで、ほとんど完了です。最後の行についてのみLEADNULLを返します。これを解決するには、手順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
dnoeth