web-dev-qa-db-ja.com

PostgreSQLの履歴レコードでバージョン管理された行のテーブルを更新する

バージョン管理された行のマスターテーブルがあります。

CREATE TABLE master (
    id SERIAL PRIMARY KEY,
    rec_id integer, 
    val text, 
    valid_on date[], 
    valid_during daterange
);

INSERT INTO master (rec_id, val, valid_on, valid_during) VALUES
    (1, 'a', '{2015-01-01,2015-01-05}', '[2015-01-01,infinity)'),
    (2, 'b', '{2015-01-01,2015-01-05}', '[2015-01-01,infinity)'),
    (3, 'c', '{2015-01-01,2015-01-05}', '[2015-01-01,infinity)');

SELECT * FROM master ORDER BY rec_id, id;
/*
     id | rec_id | val |        valid_on         |     valid_during
    ----+--------+-----+-------------------------+-----------------------
      1 |      1 | a   | {2015-01-01,2015-01-05} | [2015-01-01,infinity)
      2 |      2 | b   | {2015-01-01,2015-01-05} | [2015-01-01,infinity)
      3 |      3 | c   | {2015-01-01,2015-01-05} | [2015-01-01,infinity)
*/

rec_idはレコードの自然キー、valid_onはレコードが有効だった日付の配列、valid_duringはレコードが存在する期間を表す日付範囲です有効。 (同じrec_idで最新のvalid_on値を持つレコードがない場合、valid_duringの上限は「無限大」です。)

更新されたレコードの2番目のテーブルと、各レコードが有効であった新しい日付を指定します。

CREATE TABLE updates (id SERIAL PRIMARY KEY, rec_id integer, val text, valid_on date); 
INSERT INTO updates (rec_id, val, valid_on) VALUES
(1, 'a', '2015-01-03'), -- (1) same "val" for id 1, just add valid_on date
(2, 'd', '2015-01-06'), -- (2) different val for id 2,
(3, 'e', '2015-01-03'); -- (3) different val for id 3 with new date 
                        --     intersecting old date range

SELECT * FROM updates;
/*
     id | rec_id | val |  valid_on
    ----+--------+-----+------------
      1 |      1 | a   | 2015-01-03
      2 |      2 | d   | 2015-01-06
      3 |      3 | e   | 2015-01-03
*/

マスターテーブルを挿入/更新して、次のようなものに仕上げたいと思います:

-- The goal
SELECT rec_id, val, valid_on, valid_during FROM master ORDER BY rec_id, id;
/*
     rec_id | val |        valid_on                    |     valid_during
    --------+-----+------------------------------------+-----------------------
      1     | a   | {2015-01-01,2015-01-05,2015-01-03} | [2015-01-01,infinity)
      2     | b   | {2015-01-01,2015-01-05}            | [2015-01-01,2015-01-06)
      2     | d   | {2015-01-06}                       | [2015-01-06,infinity)
      3     | c   | {2015-01-01}                       | [2015-01-01,2015-01-03)
      3     | e   | {2015-01-03}                       | [2015-01-03,2015-01-05)
      3     | c   | {2015-01-05}                       | [2015-01-05,infinity)
*/

具体的には:

  • 新しいレコードのrec_idが同じvalでマスターテーブルに存在するが、新しいvalid_onの日付がマスターのvalid_on配列にない場合は、単にマスターテーブルのvalid_onフィールドの新しい日付(rec_id 1を参照)
  • 新しいレコードのrec_idに別のvalが存在する場合は、新しいレコードをマスターテーブルに挿入します。マスターテーブルの古いレコードのvalid_during値は、新しいレコードのvalid_onの日付で終了する必要があります(rec_id 2を参照)
  • 新しいレコードのvalid_onの日付が古いレコードのvalid_duringの範囲と交差する場合、古いレコードは更新されたレコードの両方の「側」に表示されます(rec_id 3を参照)

mostがそこにあります。最初のケースは簡単です。マスターテーブルのvalid_onフィールドを更新するだけです(valid_duringフィールドについては、別のステップで一時的に心配します)。

UPDATE master m
SET valid_on = m.valid_on || u.valid_on
FROM updates u
WHERE m.rec_id = u.rec_id 
    AND m.val = u.val 
    AND NOT m.valid_on @> ARRAY[u.valid_on];

SELECT * FROM master ORDER BY rec_id, id;
/*
     id | rec_id | val |              valid_on              |     valid_during
    ----+--------+-----+------------------------------------+-----------------------
      1 |      1 | a   | {2015-01-01,2015-01-05,2015-01-03} | [2015-01-01,infinity)
      2 |      2 | b   | {2015-01-01,2015-01-05}            | [2015-01-01,infinity)
      3 |      3 | c   | {2015-01-01,2015-01-05}            | [2015-01-01,infinity)
*/

ケース#2の場合、単純な挿入を実行できます。

INSERT INTO master (rec_id, val, valid_on)
SELECT u.rec_id, u.val, ARRAY[u.valid_on]
FROM updates u 
    LEFT JOIN master m ON u.rec_id = m.rec_id AND u.val = m.val
WHERE m.id IS NULL;

SELECT * FROM master ORDER BY rec_id, id;
/*
     id | rec_id | val |              valid_on              |     valid_during
    ----+--------+-----+------------------------------------+-----------------------
      1 |      1 | a   | {2015-01-01,2015-01-05,2015-01-03} | [2015-01-01,infinity)
      2 |      2 | b   | {2015-01-01,2015-01-05}            | [2015-01-01,infinity)
      4 |      2 | d   | {2015-01-06}                       |
      3 |      3 | c   | {2015-01-01,2015-01-05}            | [2015-01-01,infinity)
      5 |      3 | e   | {2015-01-03}                       |
*/

これで、同じvalid_duringを持つレコードの次の有効な日付をチェックするウィンドウ関数を使用するサブクエリに参加することにより、1回のパスでrec_idの範囲を修正できます。

-- Helper function...
CREATE OR REPLACE FUNCTION arraymin(anyarray) 
RETURNS anyelement AS $$ 
    SELECT min($1[i]) 
    FROM generate_series(array_lower($1,1), array_upper($1,1)) g(i); 
$$ language sql immutable strict; 


UPDATE master m
SET valid_during = daterange(arraymin(valid_on), new_valid_until)
FROM (
    SELECT
        id,
        lead(arraymin(valid_on), 1, 'infinity'::date)
        OVER (partition by rec_id ORDER BY arraymin(valid_on)) AS new_valid_until
    FROM master ) t
WHERE
    m.id = t.id;

SELECT * FROM master ORDER BY rec_id, id;
/*
     id | rec_id | val |              valid_on              |      valid_during
    ----+--------+-----+------------------------------------+-------------------------
      1 |      1 | a   | {2015-01-01,2015-01-05,2015-01-03} | [2015-01-01,infinity)
      2 |      2 | b   | {2015-01-01,2015-01-05}            | [2015-01-01,2015-01-06)
      4 |      2 | d   | {2015-01-06}                       | [2015-01-06,infinity)
      3 |      3 | c   | {2015-01-01,2015-01-05}            | [2015-01-01,2015-01-03)
      5 |      3 | e   | {2015-01-03}                       | [2015-01-03,infinity)
*/

そして、ここで私は行き詰まっています:rec_id 1と2はまさに私が欲しいものですが、rec_id 3を再度挿入する必要があります。その挿入を実行するために配列操作に頭を抱えているようには見えません。マスターテーブルのネストを解除しないアプローチについての考えはありますか?それとも、これが唯一の/最善のアプローチですか?

私はPostgreSQL 9.3を使用しています(ただし、新しいバージョンでこれを行うための適切な方法がある場合は、幸いにも9.4にアップグレードします)。

2
danpelota

最初のケース

valid_duringの範囲を忘れているようです。 3番目のケースが示すように、(rec_id, val)ごとに複数のエントリが存在する可能性があるため、正しいエントリを選択する必要があります。

UPDATE master m
SET    valid_on = f_array_sort(m.valid_on || u.valid_on) -- sorted array, see below
FROM   updates u
WHERE  m.rec_id = u.rec_id 
AND    m.valid_during @> u.valid_on  -- additional check
AND    m.val = u.val 
AND    NOT m.valid_on @> ARRAY[u.valid_on];

whole可能な日付範囲は常に既存のrec_idごとにカバーされており、valid_duringrec_idごとに重複してはならないか、それ以上する必要があります。

追加モジュール btree_Gist をインストールした後、重複する日付範囲がない場合は 除外制約 を追加して重複する日付範囲を除外します。

ALTER TABLE master ADD CONSTRAINT EXCLUDE
USING Gist (rec_id WITH =, valid_during WITH &&)  -- disallow overlap

これが実装されている要旨インデックスもクエリに完全に一致します。詳細:

2番目/ 3番目のケース

everyの日付範囲は、(現在ソートされている)配列の最小の日付から始まると仮定します:lower(m.valid_during) = m.valid_on[1]CHECK制約でそれを強制します。

ここでは、1つまたは2つの新しい行を作成する必要があります。2番目のケースでは、古い行の範囲を縮小して1つの新しい行を挿入するだけで十分です。3番目のケースでは、配列と範囲の左半分で古い行を更新し、新しい行と最後に、配列と範囲の右半分を挿入します。

ヘルパー関数

簡単にするために、新しい制約を導入します。すべての配列がソートされます。このヘルパー関数を使用する

-- sort array
CREATE OR REPLACE FUNCTION f_array_sort(anyarray) 
  RETURNS anyarray LANGUAGE sql IMMUTABLE AS
$$SELECT ARRAY (SELECT unnest($1) ORDER BY 1)$$;

ヘルパー関数arraymin()はもう必要ありませんが、次のように簡略化できます。

CREATE OR REPLACE FUNCTION f_array_min(anyarray) 
  RETURNS anyelement LANGUAGE sql IMMUTABLE AS
$$SELECT min(a) FROM unnest($1) a$$;

配列の左半分と右半分を特定の要素で分割するためにさらに2つ:

-- split left array at given element
CREATE OR REPLACE FUNCTION f_array_left(anyarray, anyelement) 
  RETURNS anyarray LANGUAGE sql IMMUTABLE AS
$$SELECT ARRAY (SELECT * FROM unnest($1) a WHERE a < $2 ORDER BY 1)$$;

-- split right array at given element
CREATE OR REPLACE FUNCTION f_array_right(anyarray, anyelement) 
  RETURNS anyarray LANGUAGE sql IMMUTABLE AS
$$SELECT ARRAY (SELECT * FROM unnest($1) a WHERE a >= $2 ORDER BY 1)$$;

クエリ

これはall残りを行います:

WITH u AS (  -- identify candidates
   SELECT m.id, rec_id, m.val, m.valid_on, m.valid_during
        , u.val AS u_val, u.valid_on AS u_valid_on
   FROM   master  m
   JOIN   updates u USING (rec_id)
   WHERE  m.val <> u.val
   AND    m.valid_during @> u.valid_on
   FOR    UPDATE  -- lock for update
   )
, upd1 AS (  -- case 2: no overlap, no split
   UPDATE master m  -- shrink old row
   SET    valid_during = daterange(lower(u.valid_during), u.u_valid_on)
   FROM   u
   WHERE  u.id = m.id
   AND    u.u_valid_on > m.valid_on[array_upper(m.valid_on, 1)]
   RETURNING m.id
   )
, ins1 AS (  -- insert new row
   INSERT INTO master (rec_id, val, valid_on, valid_during)
   SELECT u.rec_id, u.u_val, ARRAY[u.u_valid_on]
        , daterange(u.u_valid_on, upper(u.valid_during))
   FROM   upd1
   JOIN   u USING (id)
   )
, upd2 AS (  -- case 3: overlap, need to split row
   UPDATE master m  -- shrink to first half
   SET    valid_during = daterange(lower(u.valid_during), u.u_valid_on)
        , valid_on = f_array_left(u.valid_on, u.u_valid_on)
   FROM   u
   LEFT   JOIN upd1 USING (id)
   WHERE  upd1.id IS NULL  -- all others
   AND    u.id = m.id
   RETURNING m.id, f_array_right(u.valid_on, u.u_valid_on) AS arr_right
   )
INSERT INTO master (rec_id, val, valid_on, valid_during)
          -- new row
SELECT u.rec_id, u.u_val, ARRAY[u.u_valid_on]
     , daterange(u.u_valid_on, upd2.arr_right[1])
FROM   upd2
JOIN   u USING (id)
UNION ALL  -- second half of old row
SELECT u.rec_id, u.val, upd2.arr_right
     , daterange(upd2.arr_right[1], upper(u.valid_during))
FROM   upd2
JOIN   u USING (id);

SQLフィドル。

ノート

  • これに触れる前に、 data-modifying CTEs (writeable CTEs)の概念を理解する必要があります。あなたが提供したコードから判断すると、Postgresの使い方はわかります。

  • FOR UPDATEは、同時書き込みアクセスによる競合状態を回避するためのものです。あなたがテーブルに書き込む唯一のユーザーであれば、それは必要ありません。

  • 私は一枚の紙を取り、タイムラインを描きました。

  • 各行は更新/挿入onceのみであり、操作は単純で、大まかに最適化されています。高価なウィンドウ関数はありません。これはperformとなるはずです。いずれにしても、以前のアプローチよりもはるかに高速です。

  • u.valid_onm.valid_ondistinct column namesを使用すると、関連はありますが異なるものになりますが、少し混乱しにくくなります。

  • CTE upd2f_array_right(u.valid_on, u.u_valid_on) AS arr_rightRETURNING句で分割配列の右半分を計算します。これは、次のステップで数回必要になるためです。これは、もう1つのCTEを節約する(法的)トリックです。

  • don't involve unnesting the master tableのソリューションの場合:少なくとも並べ替えられていない限り、配列を分割するために、配列valid_on/ネスト解除する必要があります。また、ヘルパー関数arraymin()は、すでにそれをネスト解除しています。

1