feat(sql): Add history table and triggers for iam_scope
This adds a history table for `iam_scope`. It also includes reusable triggers to maintain the included history table and future history tables. This also includes a test suite that runs on any table ending in `_hst`.
This commit is contained in:
1 parent
327d950a1e
commit
8ae658a570
6 files changed
+513
No files matched your search
@@ -0,0 +1,126 @@
|
||||
-- Copyright (c) HashiCorp, Inc.
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
begin;
|
||||
|
||||
create function hst_table_name(table_schema name, operational_table_name name) returns text
|
||||
as $$
|
||||
select quote_ident(table_schema) || '.' || quote_ident(operational_table_name) || '_hst';
|
||||
$$ language sql
|
||||
immutable
|
||||
parallel safe -- all of the functions called are parallel safe
|
||||
cost 1 -- all of the functions called are cost 1
|
||||
strict; -- means the function returns null on null input
|
||||
comment on function hst_table_name is
|
||||
'Returns the history table name of the operational table';
|
||||
|
||||
create function hst_columns(table_schema name, operational_table_name name) returns setof text
|
||||
as $$
|
||||
declare
|
||||
_opr_tbl regclass := quote_ident(table_schema) || '.' || quote_ident(operational_table_name);
|
||||
_hst_tbl regclass := hst_table_name(table_schema, operational_table_name);
|
||||
begin
|
||||
return query
|
||||
select quote_ident(attname)
|
||||
from pg_attribute
|
||||
where attrelid = _opr_tbl
|
||||
and not attisdropped -- no dropped (dead) columns
|
||||
and attnum > 0 -- no system columns
|
||||
intersect
|
||||
select quote_ident(attname)
|
||||
from pg_attribute
|
||||
where attrelid = _hst_tbl
|
||||
and not attisdropped -- no dropped (dead) columns
|
||||
and attnum > 0; -- no system columns
|
||||
end;
|
||||
$$ language plpgsql
|
||||
stable
|
||||
parallel safe -- all of the functions called are parallel safe
|
||||
-- cost ? -- cost is unknown since it executes a query
|
||||
strict; -- means the function returns null on null input
|
||||
comment on function hst_columns is
|
||||
'Returns the intersection of columns between an operational table and it''s history table. '
|
||||
'Raises an error if the operational table or history table do not exist.';
|
||||
|
||||
create function hst_on_insert() returns trigger
|
||||
as $$
|
||||
declare
|
||||
_hst_tbl regclass := hst_table_name(tg_table_schema, tg_table_name);
|
||||
_cols text;
|
||||
_vals text;
|
||||
begin
|
||||
|
||||
select into _cols, _vals
|
||||
string_agg(quote_ident(hst_columns), ', '), string_agg('x.' || quote_ident(hst_columns), ', ')
|
||||
from hst_columns(tg_table_schema, tg_table_name);
|
||||
|
||||
execute format('
|
||||
insert into %s (%s)
|
||||
select %s
|
||||
from (select ($1).*) x', _hst_tbl, _cols, _vals)
|
||||
using new;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
comment on function hst_on_insert is
|
||||
'hst_on_insert is an after insert trigger for any operational table that has a history table.';
|
||||
|
||||
create function hst_on_delete() returns trigger
|
||||
as $$
|
||||
declare
|
||||
_hst_tbl regclass := hst_table_name(tg_table_schema, tg_table_name);
|
||||
begin
|
||||
|
||||
execute format('
|
||||
update %s
|
||||
set valid_range = tstzrange(lower(valid_range), current_timestamp)
|
||||
where public_id = $1.public_id
|
||||
and valid_range @> current_timestamp', _hst_tbl)
|
||||
using old;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
comment on function hst_on_delete is
|
||||
'hst_on_delete is an after delete trigger for any operational table that has a history table.';
|
||||
|
||||
create function hst_on_update() returns trigger
|
||||
as $$
|
||||
declare
|
||||
_hst_tbl regclass := hst_table_name(tg_table_schema, tg_table_name);
|
||||
_cols text;
|
||||
_vals text;
|
||||
_new_values record;
|
||||
_old_values record;
|
||||
begin
|
||||
|
||||
select into _cols, _vals
|
||||
string_agg(quote_ident(hst_columns), ', '), string_agg('x.' || quote_ident(hst_columns), ', ')
|
||||
from hst_columns(tg_table_schema, tg_table_name);
|
||||
|
||||
execute format('select %s from (select ($1).*) x', _cols, _vals) into _new_values using new;
|
||||
execute format('select %s from (select ($1).*) x', _cols, _vals) into _old_values using old;
|
||||
if _new_values is distinct from _old_values then
|
||||
-- if one column has changed,
|
||||
-- same query as on_delete
|
||||
execute format('
|
||||
update %s
|
||||
set valid_range = tstzrange(lower(valid_range), current_timestamp)
|
||||
where public_id = $1.public_id
|
||||
and valid_range @> current_timestamp', _hst_tbl)
|
||||
using new;
|
||||
-- same query as on_insert
|
||||
execute format('
|
||||
insert into %s (%s)
|
||||
select %s
|
||||
from (select ($1).*) x', _hst_tbl, _cols, _vals)
|
||||
using new;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
comment on function hst_on_update is
|
||||
'hst_on_update is an after update trigger for any operational table that has a history table.';
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Copyright (c) HashiCorp, Inc.
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
begin;
|
||||
|
||||
create extension if not exists btree_gist;
|
||||
|
||||
create table iam_scope_hst (
|
||||
public_id wt_scope_id not null,
|
||||
name text null,
|
||||
description text null,
|
||||
type text not null
|
||||
constraint iam_scope_type_enm_fkey
|
||||
references iam_scope_type_enm (string)
|
||||
on delete restrict
|
||||
on update cascade,
|
||||
parent_id text null,
|
||||
primary_auth_method_id wt_public_id null,
|
||||
history_id wt_url_safe_id default wt_url_safe_id() primary key,
|
||||
valid_range tstzrange not null default tstzrange(current_timestamp, null),
|
||||
constraint iam_scope_hst_valid_range_excl
|
||||
exclude using gist (public_id with =, valid_range with &&)
|
||||
);
|
||||
comment on table iam_scope_hst is
|
||||
'iam_scope_hst is a history table where each row contains the values from a row '
|
||||
'in the iam_scope table during the time range in the valid_range column.';
|
||||
|
||||
create trigger hst_on_insert after insert on iam_scope
|
||||
for each row execute function hst_on_insert();
|
||||
create trigger hst_on_update after update on iam_scope
|
||||
for each row execute function hst_on_update();
|
||||
create trigger hst_on_delete after delete on iam_scope
|
||||
for each row execute function hst_on_delete();
|
||||
|
||||
insert into iam_scope_hst
|
||||
(public_id, name, description, type, parent_id, primary_auth_method_id)
|
||||
select public_id, name, description, type, parent_id, primary_auth_method_id
|
||||
from iam_scope;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Copyright (c) HashiCorp, Inc.
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
begin;
|
||||
|
||||
create function get_history_tables() returns setof name
|
||||
as $$
|
||||
select c.relname
|
||||
from pg_catalog.pg_class c
|
||||
where c.relkind in ('r')
|
||||
and c.relname operator(pg_catalog.~) '^(.*hst)$' collate pg_catalog.default
|
||||
and pg_catalog.pg_table_is_visible(c.oid);
|
||||
$$ language sql;
|
||||
|
||||
create function op_table(history_table_name name) returns text
|
||||
as $$
|
||||
select trim(trailing '_hst' from history_table_name);
|
||||
$$ language sql;
|
||||
|
||||
create function has_operational_table(history_table_name name) returns text
|
||||
as $$
|
||||
select has_table(op_table(history_table_name));
|
||||
$$ language sql;
|
||||
|
||||
create function get_col_type(table_name name, column_name name) returns text
|
||||
as $$
|
||||
select pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||
from pg_catalog.pg_attribute a
|
||||
join pg_catalog.pg_class c on a.attrelid = c.oid
|
||||
where pg_catalog.pg_table_is_visible(c.oid)
|
||||
and c.relname = table_name
|
||||
and a.attname = column_name
|
||||
and attnum > 0
|
||||
and not a.attisdropped;
|
||||
$$ language sql;
|
||||
|
||||
create function col_types_equal(history_table_name name, column_name name) returns text
|
||||
as $$
|
||||
declare
|
||||
want_type text;
|
||||
begin
|
||||
want_type := get_col_type(op_table(history_table_name), column_name);
|
||||
return col_type_is(history_table_name, column_name, want_type);
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
create function migrations_have_run(history_table_name name) returns text
|
||||
as $$
|
||||
declare
|
||||
result text;
|
||||
begin
|
||||
execute format('select is(count(opt.*), count(hst.*)) '
|
||||
'from %I opt, %I hst', op_table(history_table_name), history_table_name)
|
||||
into result;
|
||||
return result;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
-- tests the tables exist and follow the required naming pattern
|
||||
create function has_correct_tables(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
has_table(history_table_name),
|
||||
has_operational_table(history_table_name),
|
||||
migrations_have_run(history_table_name)
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- tests the public_id column
|
||||
create function has_public_id(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
has_column(history_table_name, 'public_id'),
|
||||
col_not_null(history_table_name, 'public_id'),
|
||||
col_types_equal(history_table_name, 'public_id'), -- should be the same type as the operational table
|
||||
col_hasnt_default(history_table_name, 'public_id')
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- tests the history_id column
|
||||
create function has_history_id(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
has_column(history_table_name, 'history_id'),
|
||||
col_is_pk(history_table_name, 'history_id'),
|
||||
col_type_is(history_table_name, 'history_id', 'wt_url_safe_id'),
|
||||
col_has_default(history_table_name, 'history_id'),
|
||||
col_default_is(history_table_name, 'history_id', 'wt_url_safe_id()')
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- tests the valid_range column
|
||||
create function has_valid_range(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
has_column(history_table_name, 'valid_range'),
|
||||
col_not_null(history_table_name, 'valid_range'),
|
||||
col_type_is(history_table_name, 'valid_range', 'tstzrange'),
|
||||
col_has_default(history_table_name, 'valid_range'),
|
||||
col_default_is(history_table_name, 'valid_range', 'tstzrange(CURRENT_TIMESTAMP, NULL::timestamp with time zone)')
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- tests for an exclusion index
|
||||
create function has_exclusion_index(history_table_name name) returns text
|
||||
as $$
|
||||
select has_index(history_table_name, history_table_name || '_valid_range_excl', array['public_id', 'valid_range']);
|
||||
$$ language sql;
|
||||
|
||||
-- tests to verify certain columns are not in the history table
|
||||
create function hasnt_operational_columns(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
hasnt_column(history_table_name, 'create_time'),
|
||||
hasnt_column(history_table_name, 'update_time'),
|
||||
hasnt_column(history_table_name, 'version')
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- tests that the operational table has the history triggers
|
||||
create function has_history_triggers(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
has_trigger(op_table(history_table_name), 'hst_on_insert'),
|
||||
trigger_is(op_table(history_table_name), 'hst_on_insert', 'hst_on_insert'),
|
||||
has_trigger(op_table(history_table_name), 'hst_on_update'),
|
||||
trigger_is(op_table(history_table_name), 'hst_on_update', 'hst_on_update'),
|
||||
has_trigger(op_table(history_table_name), 'hst_on_delete'),
|
||||
trigger_is(op_table(history_table_name), 'hst_on_delete', 'hst_on_delete')
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- runs all the tests on a single history table
|
||||
create function test_history_table(history_table_name name) returns text
|
||||
as $$
|
||||
select * from collect_tap(
|
||||
has_correct_tables(history_table_name),
|
||||
has_public_id(history_table_name),
|
||||
has_history_id(history_table_name),
|
||||
has_valid_range(history_table_name),
|
||||
has_exclusion_index(history_table_name),
|
||||
hasnt_operational_columns(history_table_name),
|
||||
has_history_triggers(history_table_name)
|
||||
);
|
||||
$$ language sql;
|
||||
|
||||
-- 27 tests for each history table
|
||||
select plan(a.table_count::integer)
|
||||
from (
|
||||
select 27 * count(*) as table_count
|
||||
from get_history_tables()
|
||||
) as a;
|
||||
|
||||
select test_history_table(a)
|
||||
from get_history_tables() a;
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Copyright (c) HashiCorp, Inc.
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
begin;
|
||||
select plan(6);
|
||||
|
||||
-- Verify the function exists and is declared properly
|
||||
select has_function('hst_columns', array['name', 'name']);
|
||||
select volatility_is('hst_columns', 'stable');
|
||||
select is_strict('hst_columns');
|
||||
|
||||
select is(count(*), 0::bigint, 'null input should return 0 rows') from hst_columns(null, null);
|
||||
|
||||
-- close to the iam_user table
|
||||
create table test_r1 (
|
||||
public_id wt_user_id primary key,
|
||||
create_time wt_timestamp,
|
||||
update_time wt_timestamp,
|
||||
name text,
|
||||
description text,
|
||||
scope_id wt_scope_id not null
|
||||
references iam_scope(public_id)
|
||||
on delete cascade
|
||||
on update cascade,
|
||||
unique(name, scope_id),
|
||||
version wt_version
|
||||
);
|
||||
|
||||
create table test_r1_hst (
|
||||
public_id wt_user_id not null,
|
||||
name text,
|
||||
description text,
|
||||
scope_id text not null,
|
||||
history_id wt_url_safe_id default wt_url_safe_id() primary key,
|
||||
valid_range tstzrange not null default tstzrange(current_timestamp, null)
|
||||
);
|
||||
|
||||
select is(count(*), 4::bigint) from hst_columns('public'::name, 'test_r1'::name);
|
||||
|
||||
create table test_r2 (
|
||||
public_id wt_user_id primary key,
|
||||
create_time wt_timestamp,
|
||||
update_time wt_timestamp,
|
||||
name text,
|
||||
description text,
|
||||
scope_id wt_scope_id not null
|
||||
references iam_scope(public_id)
|
||||
on delete cascade
|
||||
on update cascade,
|
||||
version wt_version
|
||||
);
|
||||
|
||||
prepare select_no_history_table as select hst_columns('public'::name, 'test_r2'::name);
|
||||
select throws_like('select_no_history_table', '%relation "public.test_r2_hst" does not exist', 'an operational table without a history table should raise an error');
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Copyright (c) HashiCorp, Inc.
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
begin;
|
||||
select plan(5);
|
||||
|
||||
-- Verify the function exists and is declared properly
|
||||
select has_function('hst_table_name', array['name', 'name']);
|
||||
select volatility_is('hst_table_name', 'immutable');
|
||||
select is_strict('hst_table_name');
|
||||
|
||||
select is(hst_table_name('public', 'iam_scope'), 'public.iam_scope_hst');
|
||||
select is(hst_table_name(null, null), null);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
@@ -0,0 +1,116 @@
|
||||
-- Copyright (c) HashiCorp, Inc.
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
begin;
|
||||
select plan(28);
|
||||
|
||||
-- Verify the trigger functions exist and are declared properly
|
||||
select has_function('hst_on_insert');
|
||||
select volatility_is('hst_on_insert', 'volatile');
|
||||
select isnt_strict('hst_on_insert');
|
||||
|
||||
select has_function('hst_on_delete');
|
||||
select volatility_is('hst_on_delete', 'volatile');
|
||||
select isnt_strict('hst_on_delete');
|
||||
|
||||
select has_function('hst_on_update');
|
||||
select volatility_is('hst_on_update', 'volatile');
|
||||
select isnt_strict('hst_on_update');
|
||||
|
||||
select has_trigger('iam_scope', 'hst_on_insert');
|
||||
select has_trigger('iam_scope', 'hst_on_update');
|
||||
select has_trigger('iam_scope', 'hst_on_delete');
|
||||
|
||||
select is(count(a.*), count(b.*)) from iam_scope a, iam_scope_hst b;
|
||||
|
||||
-- test on_delete trigger
|
||||
select is(count(*), 1::bigint)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
select is(upper(valid_range), null)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
select ok(isempty(valid_range) = false)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
delete
|
||||
from iam_scope
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
select is(count(*), 1::bigint)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
select is(upper(valid_range), now())
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
select ok(isempty(valid_range) = false)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____rcolors';
|
||||
|
||||
-- test on_update trigger
|
||||
select is(count(*), 1::bigint)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
update iam_scope
|
||||
set description = 'updated description'
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
select is(count(*), 2::bigint)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
select is(count(*), 1::bigint)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____bcolors'
|
||||
and upper(valid_range) = now();
|
||||
|
||||
update iam_scope
|
||||
set version = version + 1
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
select is(count(*), 2::bigint, 'update to version column should not add a new history record')
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
-- test a delete happening within the same transaction as an update
|
||||
delete
|
||||
from iam_scope
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
select is(count(*), 2::bigint, 'delete of scope should not add a new history record')
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____bcolors';
|
||||
|
||||
-- Doing an update and a delete in the same transaction produces a row in the
|
||||
-- history table with a valid_range value of 'empty'.
|
||||
select is(count(*), 1::bigint, 'an update and delete in the same transaction should result in an empty valid_range')
|
||||
from iam_scope_hst
|
||||
where public_id = 'p____bcolors'
|
||||
and isempty(valid_range);
|
||||
|
||||
-- test on_insert trigger
|
||||
insert into iam_scope
|
||||
(parent_id, type, public_id, name)
|
||||
values
|
||||
('o_____colors', 'project', 'p_______test', 'Test Color');
|
||||
|
||||
select is(count(*), 1::bigint)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p_______test';
|
||||
|
||||
select is(lower(valid_range), now())
|
||||
from iam_scope_hst
|
||||
where public_id = 'p_______test';
|
||||
|
||||
select is(upper(valid_range), null)
|
||||
from iam_scope_hst
|
||||
where public_id = 'p_______test';
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
Reference in new issue
Block a user