patternsqlMinor
Updating table of versioned rows with historical records in PostgreSQL
Viewed 0 times
postgresqlrowswithupdatingrecordshistoricalversionedtable
Problem
I have a master table of versioned rows:
The
Given a second table of updated records, along with new dates on which each record was valid:
I would like to insert/update the master table to wind up with something like this:
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)
*/The
rec_id is a the record's natural key, the valid_on is an array of dates on which the record was valid, and the valid_during is a date range describing the interval during which the record is valid. (The upper bound on the valid_during is 'infinity' if there is no record with the same rec_id with a more recent valid_on value.)Given a second table of updated records, along with new dates on which each record was valid:
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
*/I would like to insert/update the master table to wind up with something like this:
Solution
1st case
You seem to forget the
I assume the whole possible date range is always covered for each existing
After installing the additional module
The GiST index this is implemented with is also a perfect match for the query. Details:
2nd / 3rd case
Assuming that every date range starts with the smallest date in the (now sorted!) array:
Here we need to create one or two new rows
In the 2nd case it is enough to shrink the range of the old row and insert one new row
In the 3rd case we update the old row with the left half of array and range, insert the new row and finally insert the with the right half of array and range.
Helper functions
To keep it simple I introduce a new constraint: every array is sorted. Use this helper function:
We don't need your helper function
Two more to get the left and right half of an array split at a given element:
Query
This does all the rest:
db<>fiddle here
Old sqlfiddle
Notes
You need to understand the concept of data-modifying CTEs (writeable CTEs), before you touch this. Judging from the code you provided, you know your way around Postgres.
I took a piece of paper and drew a timeline so not to get lost in all of this.
Each row is only updated / inserted once, and operations are simple and roughly optimized. No expensive window functions. This should perform well. Much faster than your previous approach in any case.
It would be a bit less confusing if you'd use distinct column names for
I compute the right half of the split array in the
You seem to forget the
valid_during range. As your third case suggests, there can be multiple entries per (rec_id, val), so you must select the right one: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];I assume the whole possible date range is always covered for each existing
rec_id and valid_during shall not overlap per rec_id, or you'd have to do more.After installing the additional module
btree_gist, add an exclusion constraint to rule out overlapping date ranges if you don't have one, yet:ALTER TABLE master ADD CONSTRAINT EXCLUDE
USING gist (rec_id WITH =, valid_during WITH &&) -- disallow overlapThe GiST index this is implemented with is also a perfect match for the query. Details:
- Preventing adjacent/overlapping entries with EXCLUDE in PostgreSQL
- Store the day of the week and time?
2nd / 3rd case
Assuming that every date range starts with the smallest date in the (now sorted!) array:
lower(m.valid_during) = m.valid_on[1]. I would enforce that with a CHECK constraint.Here we need to create one or two new rows
In the 2nd case it is enough to shrink the range of the old row and insert one new row
In the 3rd case we update the old row with the left half of array and range, insert the new row and finally insert the with the right half of array and range.
Helper functions
To keep it simple I introduce a new constraint: every array is sorted. Use this helper function:
CREATE OR REPLACE FUNCTION f_array_sort(anyarray)
RETURNS anyarray LANGUAGE sql IMMUTABLE AS
$SELECT ARRAY(SELECT unnest($1) ORDER BY 1)$;We don't need your helper function
arraymin() any more, but it could be simplified to:CREATE OR REPLACE FUNCTION f_array_min(anyarray)
RETURNS anyelement LANGUAGE sql IMMUTABLE AS
$SELECT min(a) FROM unnest($1) a$;Two more to get the left and right half of an array split at a given element:
-- 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)$;Query
This does all the rest:
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);db<>fiddle here
Old sqlfiddle
Notes
You need to understand the concept of data-modifying CTEs (writeable CTEs), before you touch this. Judging from the code you provided, you know your way around Postgres.
- Are SELECT type queries the only type that can be nested?
FOR UPDATE is to avoid race conditions with concurrent write access. If you are the only user writing to the tables, you don't need it.I took a piece of paper and drew a timeline so not to get lost in all of this.
Each row is only updated / inserted once, and operations are simple and roughly optimized. No expensive window functions. This should perform well. Much faster than your previous approach in any case.
It would be a bit less confusing if you'd use distinct column names for
u.valid_on and m.valid_on, which are related but different things.I compute the right half of the split array in the
RETURNING clause of CTE upd2: f_array_right(u.valid_on, u.u_valid_on) AS arr_right, because I neeCode Snippets
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];ALTER TABLE master ADD CONSTRAINT EXCLUDE
USING gist (rec_id WITH =, valid_during WITH &&) -- disallow overlapCREATE OR REPLACE FUNCTION f_array_sort(anyarray)
RETURNS anyarray LANGUAGE sql IMMUTABLE AS
$$SELECT ARRAY(SELECT unnest($1) ORDER BY 1)$$;CREATE OR REPLACE FUNCTION f_array_min(anyarray)
RETURNS anyelement LANGUAGE sql IMMUTABLE AS
$$SELECT min(a) FROM unnest($1) a$$;-- 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)$$;Context
StackExchange Database Administrators Q#95845, answer score: 2
Revisions (0)
No revisions yet.