feat(db-tool): hectic-inheritance: add hectic.immutable + diff coverage
Add a hectic.immutable parent table. Tables inheriting it get auto-attached BEFORE INSERT/UPDATE/DELETE/TRUNCATE row+statement triggers that block DML unless the session sets hectic.migration_mode='on' (intended use: SET LOCAL inside a migration transaction). Same exemptions as the rest of the bundle apply (hectic schema, partitions, temp tables, GUC-excluded schemas). database diff now appends an --- IMMUTABLE TABLE DATA --- section to its output, with per-table unified row diffs of every table inheriting hectic.immutable, surfacing drift in 'frozen' reference data alongside schema drift. Subcommand exits non-zero when either schema or data differs. Test postgres-init-hectic-inheritance extended to 10 cases covering immutable triggers, DML blocked outside migration_mode, SET LOCAL allowing DML inside a transaction, GUC not leaking past COMMIT, and TRUNCATE under migration_mode.
This commit is contained in:
@@ -112,7 +112,7 @@ To use `db-tool` in a Nix development shell, add the following to your `flake.ni
|
||||
## hectic Inheritance Bundle
|
||||
|
||||
`pkgs.hectic.hectic-inheritance` ships a SQL artifact that bootstraps a `hectic`
|
||||
schema with two parent tables and DDL event triggers:
|
||||
schema with three parent tables and DDL event triggers:
|
||||
|
||||
- `hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — every
|
||||
user table must `INHERITS (hectic.created_at)`. The event trigger
|
||||
@@ -122,6 +122,20 @@ schema with two parent tables and DDL event triggers:
|
||||
Any table that inherits from it automatically gets a `BEFORE UPDATE FOR EACH
|
||||
ROW` trigger calling `hectic.set_updated_at()` attached by
|
||||
`hectic_attach_updated_at_trigger`.
|
||||
- `hectic.immutable()` — pure marker. Tables inheriting it are blocked from
|
||||
`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE` outside migration mode by triggers
|
||||
attached by `hectic_attach_immutable_triggers`. Useful for reference data
|
||||
that must only change via migrations. To allow DML inside a migration, wrap
|
||||
it in a transaction:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL hectic.migration_mode = 'on';
|
||||
INSERT INTO public.frozen (id, label) VALUES (1, 'x');
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
`SET LOCAL` is required so the permission cannot leak past `COMMIT`.
|
||||
|
||||
Always-exempt schemas: `hectic`, `information_schema`, anything matching
|
||||
`pg_*`. Declarative partitions (`relispartition = true`) and temporary tables
|
||||
@@ -134,6 +148,16 @@ Per-database opt-out for additional schemas via the
|
||||
ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl';
|
||||
```
|
||||
|
||||
### `db-tool diff` and immutable tables
|
||||
|
||||
`database diff` already includes immutable tables in its schema-level
|
||||
comparison (via `pg_dump --schema-only`). On top of that, when a `hectic`
|
||||
schema is present in either side, it appends an
|
||||
`--- IMMUTABLE TABLE DATA ---` section to the diff with a per-table unified
|
||||
diff of the rows of every table inheriting `hectic.immutable`. Drift in
|
||||
"frozen" reference data therefore surfaces in the same pager view as schema
|
||||
drift, and the subcommand exits non-zero when either differs.
|
||||
|
||||
### Apply via `postgres-init`
|
||||
|
||||
Applied automatically. Set `PG_HECTIC_INHERITANCE=0` to opt out.
|
||||
|
||||
@@ -1197,6 +1197,78 @@ ___diff_dump_schema() {
|
||||
fi
|
||||
}
|
||||
|
||||
___diff_immutable_tables() {
|
||||
local socket_dir="$1"
|
||||
local port="$2"
|
||||
psql -h "$socket_dir" -p "$port" -d testdb -tAv ON_ERROR_STOP=1 -c "$(cat <<'SQL'
|
||||
SELECT n.nspname || '.' || c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE i.inhparent = 'hectic.immutable'::regclass
|
||||
AND c.relkind = 'r'
|
||||
ORDER BY 1
|
||||
SQL
|
||||
)" 2>/dev/null
|
||||
}
|
||||
|
||||
___diff_immutable_data() {
|
||||
local sock1="$1"
|
||||
local port1="$2"
|
||||
local sock2="$3"
|
||||
local port2="$4"
|
||||
local out_file="$5"
|
||||
|
||||
if ! psql -h "$sock1" -p "$port1" -d testdb -tAc \
|
||||
"SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname='immutable';" \
|
||||
>/dev/null 2>&1
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tables1 tables2 tables
|
||||
tables1=$(___diff_immutable_tables "$sock1" "$port1" || true)
|
||||
tables2=$(___diff_immutable_tables "$sock2" "$port2" || true)
|
||||
tables=$(printf '%s\n%s\n' "$tables1" "$tables2" | sort -u | sed '/^$/d')
|
||||
|
||||
if [ -z "$tables" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log notice "diffing data of tables inheriting hectic.immutable"
|
||||
|
||||
printf '\n--- IMMUTABLE TABLE DATA ---\n' >> "$out_file"
|
||||
|
||||
local data1 data2 differs=0
|
||||
data1=$(mktemp)
|
||||
data2=$(mktemp)
|
||||
trap 'rm -f "$data1" "$data2"' EXIT INT HUP
|
||||
|
||||
for tbl in $tables; do
|
||||
log info " $tbl"
|
||||
: > "$data1"
|
||||
: > "$data2"
|
||||
pg_dump -h "$sock1" -p "$port1" testdb \
|
||||
--data-only --no-owner --no-privileges --column-inserts -t "$tbl" \
|
||||
> "$data1" 2>/dev/null || :
|
||||
pg_dump -h "$sock2" -p "$port2" testdb \
|
||||
--data-only --no-owner --no-privileges --column-inserts -t "$tbl" \
|
||||
> "$data2" 2>/dev/null || :
|
||||
{
|
||||
printf '\n=== %s ===\n' "$tbl"
|
||||
if diff --color=always -u "$data1" "$data2"; then
|
||||
printf '(no differences)\n'
|
||||
else
|
||||
differs=1
|
||||
fi
|
||||
} >> "$out_file"
|
||||
done
|
||||
|
||||
rm -f "$data1" "$data2"
|
||||
trap - EXIT INT HUP
|
||||
return $differs
|
||||
}
|
||||
|
||||
help_check() {
|
||||
# shellcheck disable=SC2059
|
||||
printf "$(cat <<EOF
|
||||
@@ -1451,9 +1523,22 @@ subcommand_diff() {
|
||||
if diff --color=always -u "$DIFF_DUMP1" "$DIFF_DUMP2" \
|
||||
> "$DIFF_TMPDIR/diff"
|
||||
then
|
||||
log notice "no schema differences found"
|
||||
schema_differs=0
|
||||
else
|
||||
log notice "schema differences found"
|
||||
schema_differs=1
|
||||
fi
|
||||
|
||||
___diff_immutable_data \
|
||||
"$DIFF_PGDATA1/sock" "5432" \
|
||||
"$DIFF_PGDATA2/sock" "5432" \
|
||||
"$DIFF_TMPDIR/diff"
|
||||
data_status=$?
|
||||
|
||||
if [ "$schema_differs" = 0 ] && [ "$data_status" = 0 ]; then
|
||||
log notice "no differences found"
|
||||
else
|
||||
log notice "differences found"
|
||||
"$PAGER_OR_CAT" "$DIFF_TMPDIR/diff"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
-- hectic.created_at / hectic.updated_at inheritance machinery.
|
||||
-- hectic.created_at / hectic.updated_at / hectic.immutable inheritance machinery.
|
||||
--
|
||||
-- Provides:
|
||||
-- * schema hectic
|
||||
-- * tables hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())
|
||||
-- hectic.updated_at(updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())
|
||||
-- hectic.immutable() -- pure marker
|
||||
-- * function hectic.set_updated_at() -- BEFORE UPDATE row trigger function
|
||||
-- * function hectic.block_immutable_dml()
|
||||
-- BEFORE INSERT/UPDATE/DELETE/TRUNCATE row+statement trigger function;
|
||||
-- allows DML iff current_setting('hectic.migration_mode', true) = 'on'.
|
||||
-- * GUC hectic.inheritance_extra_excluded_schemas
|
||||
-- (text, comma-separated list of schemas the enforcement trigger skips)
|
||||
-- * GUC hectic.migration_mode
|
||||
-- (text, 'on' enables DML on tables inheriting hectic.immutable.
|
||||
-- Intended use: SET LOCAL inside a migration transaction.)
|
||||
-- * event trigger hectic_enforce_created_at_inheritance
|
||||
-- RAISE EXCEPTION on CREATE TABLE that does not inherit hectic.created_at
|
||||
-- * event trigger hectic_attach_updated_at_trigger
|
||||
-- auto-attaches BEFORE UPDATE row trigger calling hectic.set_updated_at()
|
||||
-- on any new table that inherits hectic.updated_at and lacks one.
|
||||
-- * event trigger hectic_attach_immutable_triggers
|
||||
-- auto-attaches BEFORE INSERT/UPDATE/DELETE FOR EACH ROW and BEFORE
|
||||
-- TRUNCATE FOR EACH STATEMENT triggers calling hectic.block_immutable_dml()
|
||||
-- on any new table that inherits hectic.immutable and lacks them.
|
||||
--
|
||||
-- Idempotent: safe to run on an already-bootstrapped database.
|
||||
|
||||
@@ -25,6 +36,8 @@ CREATE TABLE IF NOT EXISTS "hectic"."updated_at" (
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "hectic"."immutable" ();
|
||||
|
||||
DO $bootstrap$
|
||||
BEGIN
|
||||
PERFORM set_config('hectic.inheritance_extra_excluded_schemas',
|
||||
@@ -35,6 +48,16 @@ EXCEPTION WHEN undefined_object THEN
|
||||
END
|
||||
$bootstrap$;
|
||||
|
||||
DO $bootstrap_mm$
|
||||
BEGIN
|
||||
PERFORM set_config('hectic.migration_mode',
|
||||
current_setting('hectic.migration_mode', true),
|
||||
false);
|
||||
EXCEPTION WHEN undefined_object THEN
|
||||
PERFORM set_config('hectic.migration_mode', '', false);
|
||||
END
|
||||
$bootstrap_mm$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."set_updated_at"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN
|
||||
@@ -43,6 +66,29 @@ BEGIN
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."block_immutable_dml"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
mm text;
|
||||
BEGIN
|
||||
BEGIN
|
||||
mm := current_setting('hectic.migration_mode', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
mm := '';
|
||||
END;
|
||||
IF mm = 'on' THEN
|
||||
IF TG_LEVEL = 'STATEMENT' THEN RETURN NULL; END IF;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION
|
||||
'hectic: table %.% inherits hectic.immutable; % blocked outside migration_mode',
|
||||
quote_ident(TG_TABLE_SCHEMA), quote_ident(TG_TABLE_NAME), TG_OP
|
||||
USING HINT = 'wrap the statement in a migration transaction with '
|
||||
|| 'SET LOCAL hectic.migration_mode = ''on''';
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."_is_excluded_schema"(p_schema text) RETURNS boolean
|
||||
LANGUAGE plpgsql STABLE AS $fn$
|
||||
DECLARE
|
||||
@@ -153,3 +199,59 @@ CREATE EVENT TRIGGER "hectic_attach_updated_at_trigger"
|
||||
ON ddl_command_end
|
||||
WHEN TAG IN ('CREATE TABLE')
|
||||
EXECUTE FUNCTION "hectic"."attach_updated_at_trigger"();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."attach_immutable_triggers"() RETURNS event_trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
obj record;
|
||||
rel pg_class;
|
||||
schema_name text;
|
||||
parent_oid oid;
|
||||
has_row boolean;
|
||||
has_trunc boolean;
|
||||
BEGIN
|
||||
parent_oid := 'hectic.immutable'::regclass;
|
||||
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'CREATE TABLE'
|
||||
LOOP
|
||||
SELECT * INTO rel FROM pg_class WHERE oid = obj.objid;
|
||||
IF NOT FOUND THEN CONTINUE; END IF;
|
||||
IF rel.relpersistence = 't' THEN CONTINUE; END IF;
|
||||
IF rel.relispartition THEN CONTINUE; END IF;
|
||||
SELECT nspname INTO schema_name FROM pg_namespace WHERE oid = rel.relnamespace;
|
||||
IF schema_name = 'hectic' THEN CONTINUE; END IF;
|
||||
IF NOT "hectic"."_table_inherits"(rel.oid, parent_oid) THEN CONTINUE; END IF;
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgrelid = rel.oid
|
||||
AND tgname = 'hectic_block_immutable_dml'
|
||||
AND NOT tgisinternal
|
||||
) INTO has_row;
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgrelid = rel.oid
|
||||
AND tgname = 'hectic_block_immutable_truncate'
|
||||
AND NOT tgisinternal
|
||||
) INTO has_trunc;
|
||||
IF NOT has_row THEN
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON %I.%I '
|
||||
|| 'FOR EACH ROW EXECUTE FUNCTION "hectic"."block_immutable_dml"()',
|
||||
'hectic_block_immutable_dml', schema_name, rel.relname
|
||||
);
|
||||
END IF;
|
||||
IF NOT has_trunc THEN
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON %I.%I '
|
||||
|| 'FOR EACH STATEMENT EXECUTE FUNCTION "hectic"."block_immutable_dml"()',
|
||||
'hectic_block_immutable_truncate', schema_name, rel.relname
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
DROP EVENT TRIGGER IF EXISTS "hectic_attach_immutable_triggers";
|
||||
CREATE EVENT TRIGGER "hectic_attach_immutable_triggers"
|
||||
ON ddl_command_end
|
||||
WHEN TAG IN ('CREATE TABLE')
|
||||
EXECUTE FUNCTION "hectic"."attach_immutable_triggers"();
|
||||
|
||||
@@ -35,8 +35,8 @@ run_sql_expect_fail() {
|
||||
log notice "case 1: hectic schema and parent tables exist"
|
||||
got=$(run_sql "SELECT count(*) FROM pg_namespace WHERE nspname='hectic';") || exit 1
|
||||
[ "$got" = 1 ] || { log error "hectic schema missing"; exit 1; }
|
||||
got=$(run_sql "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname IN ('created_at','updated_at');") || exit 1
|
||||
[ "$got" = 2 ] || { log error "parent tables missing"; exit 1; }
|
||||
got=$(run_sql "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname IN ('created_at','updated_at','immutable');") || exit 1
|
||||
[ "$got" = 3 ] || { log error "parent tables missing"; exit 1; }
|
||||
|
||||
log notice "case 2: CREATE TABLE without inheritance is rejected"
|
||||
if ! run_sql_expect_fail 'CREATE TABLE public.bad_table (id int);'; then
|
||||
@@ -90,4 +90,54 @@ psql "$pgurl" -v ON_ERROR_STOP=1 -c "CREATE TABLE parts.events_us PARTITION OF p
|
||||
exit 1
|
||||
}
|
||||
|
||||
log notice "case 7: hectic.immutable inheritors are blocked from DML outside migration_mode"
|
||||
run_sql 'CREATE TABLE public.frozen (id int, label text) INHERITS ("hectic"."created_at", "hectic"."immutable");' || exit 1
|
||||
|
||||
got=$(run_sql "SELECT count(*) FROM pg_trigger WHERE tgrelid='public.frozen'::regclass AND tgname IN ('hectic_block_immutable_dml','hectic_block_immutable_truncate') AND NOT tgisinternal;") || exit 1
|
||||
[ "$got" = 2 ] || { log error "immutable triggers missing on public.frozen (got: $got)"; exit 1; }
|
||||
|
||||
if ! run_sql_expect_fail "INSERT INTO public.frozen (id, label) VALUES (1, 'x');"; then
|
||||
log error "INSERT on immutable table accepted outside migration_mode"
|
||||
exit 1
|
||||
fi
|
||||
if ! run_sql_expect_fail "UPDATE public.frozen SET label='y' WHERE id=1;"; then
|
||||
log error "UPDATE on immutable table accepted outside migration_mode"
|
||||
exit 1
|
||||
fi
|
||||
if ! run_sql_expect_fail "DELETE FROM public.frozen WHERE id=1;"; then
|
||||
log error "DELETE on immutable table accepted outside migration_mode"
|
||||
exit 1
|
||||
fi
|
||||
if ! run_sql_expect_fail "TRUNCATE public.frozen;"; then
|
||||
log error "TRUNCATE on immutable table accepted outside migration_mode"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "case 8: SET LOCAL hectic.migration_mode='on' allows DML"
|
||||
psql "$pgurl" -v ON_ERROR_STOP=1 <<'SQL' || { log error "migration_mode tx failed"; exit 1; }
|
||||
BEGIN;
|
||||
SET LOCAL hectic.migration_mode = 'on';
|
||||
INSERT INTO public.frozen (id, label) VALUES (1, 'x');
|
||||
UPDATE public.frozen SET label = 'y' WHERE id = 1;
|
||||
COMMIT;
|
||||
SQL
|
||||
got=$(run_sql "SELECT label FROM public.frozen WHERE id=1;") || exit 1
|
||||
[ "$got" = y ] || { log error "expected label=y after migration tx, got: $got"; exit 1; }
|
||||
|
||||
log notice "case 9: GUC does not leak past COMMIT"
|
||||
if ! run_sql_expect_fail "INSERT INTO public.frozen (id, label) VALUES (2, 'z');"; then
|
||||
log error "INSERT accepted after migration_mode tx committed (GUC leaked)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log notice "case 10: TRUNCATE allowed under migration_mode"
|
||||
psql "$pgurl" -v ON_ERROR_STOP=1 <<'SQL' || { log error "truncate under migration_mode failed"; exit 1; }
|
||||
BEGIN;
|
||||
SET LOCAL hectic.migration_mode = 'on';
|
||||
TRUNCATE public.frozen;
|
||||
COMMIT;
|
||||
SQL
|
||||
got=$(run_sql "SELECT count(*) FROM public.frozen;") || exit 1
|
||||
[ "$got" = 0 ] || { log error "frozen not truncated"; exit 1; }
|
||||
|
||||
log notice "test passed"
|
||||
|
||||
Reference in New Issue
Block a user