diff --git a/package/db-tool/README.md b/package/db-tool/README.md index b5c2248..71f8d3d 100644 --- a/package/db-tool/README.md +++ b/package/db-tool/README.md @@ -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. diff --git a/package/db-tool/database.sh b/package/db-tool/database.sh index 173a57c..57a7fb4 100644 --- a/package/db-tool/database.sh +++ b/package/db-tool/database.sh @@ -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 < "$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 diff --git a/package/db-tool/sql/hectic-inheritance.sql b/package/db-tool/sql/hectic-inheritance.sql index 0eb3506..de6dcbf 100644 --- a/package/db-tool/sql/hectic-inheritance.sql +++ b/package/db-tool/sql/hectic-inheritance.sql @@ -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"(); diff --git a/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh b/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh index 7d2361a..df0d12a 100644 --- a/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh +++ b/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh @@ -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"