feat: start impliment postgres hooks
This commit is contained in:
@@ -100,14 +100,37 @@ in {
|
||||
# -- Cargo.toml --
|
||||
cargoToml = src: (builtins.fromTOML (builtins.readFile "${src}/Cargo.toml"));
|
||||
|
||||
# SQL bundle bootstrapping `hectic.created_at` / `hectic.updated_at` inheritance enforcement.
|
||||
# Consumers can either:
|
||||
# * read the SQL string for inline pipelines: `self.lib.hecticInheritance.sql`
|
||||
# * reference the source path: `self.lib.hecticInheritance.path`
|
||||
# * use the per-system package: `pkgs.hectic.hectic-inheritance` (provides
|
||||
# `$out/share/hectic/hectic-inheritance.sql`)
|
||||
# Consolidated SQL bundles for the `hectic` schema. Single source of truth
|
||||
# for everything that creates objects in the `hectic` namespace, used by
|
||||
# migrator (init-time), db-tool (postgres-init), and pkgs.hectic.postgres-secrets.
|
||||
#
|
||||
# The whole hectic system shares one `versionString`; `hectic-version.sql`
|
||||
# registers (`'hectic'`, versionString) into `hectic.version` and raises an
|
||||
# exception on mismatch. Per-hook version rows are intentionally absent.
|
||||
#
|
||||
# Each entry exposes:
|
||||
# * .sql — file contents as a string, with @HECTIC_VERSION@ substituted
|
||||
# * .path — Nix store path (only on entries that need no substitution)
|
||||
hectic = let
|
||||
versionString = lib.fileContents ./hook/sql/HECTIC_VERSION;
|
||||
static = path: { inherit path; sql = builtins.readFile path; };
|
||||
templated = path: {
|
||||
sql = builtins.replaceStrings
|
||||
[ "@HECTIC_VERSION@" ]
|
||||
[ versionString ]
|
||||
(builtins.readFile path);
|
||||
};
|
||||
in {
|
||||
inherit versionString;
|
||||
version = templated ./hook/sql/hectic-version.sql;
|
||||
secret = static ./hook/sql/hectic-secret.sql;
|
||||
migration = static ./hook/sql/hectic-migration.sql;
|
||||
inheritance = static ./hook/sql/hectic-inheritance.sql;
|
||||
};
|
||||
|
||||
# Back-compat alias. Prefer `self.lib.hectic.inheritance`.
|
||||
hecticInheritance = let
|
||||
path = ../package/db-tool/sql/hectic-inheritance.sql;
|
||||
path = ./hook/sql/hectic-inheritance.sql;
|
||||
in {
|
||||
inherit path;
|
||||
sql = builtins.readFile path;
|
||||
|
||||
1
lib/hook/sql/HECTIC_VERSION
Normal file
1
lib/hook/sql/HECTIC_VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
257
lib/hook/sql/hectic-inheritance.sql
Normal file
257
lib/hook/sql/hectic-inheritance.sql
Normal file
@@ -0,0 +1,257 @@
|
||||
-- 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.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS "hectic";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "hectic"."created_at" (
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
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',
|
||||
current_setting('hectic.inheritance_extra_excluded_schemas', true),
|
||||
false);
|
||||
EXCEPTION WHEN undefined_object THEN
|
||||
PERFORM set_config('hectic.inheritance_extra_excluded_schemas', '', false);
|
||||
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
|
||||
NEW."updated_at" := NOW();
|
||||
RETURN NEW;
|
||||
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
|
||||
extra text;
|
||||
s text;
|
||||
BEGIN
|
||||
IF p_schema = 'hectic'
|
||||
OR p_schema = 'information_schema'
|
||||
OR p_schema LIKE 'pg\_%' ESCAPE '\'
|
||||
THEN
|
||||
RETURN true;
|
||||
END IF;
|
||||
BEGIN
|
||||
extra := current_setting('hectic.inheritance_extra_excluded_schemas', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
extra := '';
|
||||
END;
|
||||
IF extra IS NULL OR extra = '' THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
FOREACH s IN ARRAY string_to_array(extra, ',') LOOP
|
||||
IF btrim(s) = p_schema THEN
|
||||
RETURN true;
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN false;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."_table_inherits"(p_oid oid, p_parent regclass) RETURNS boolean
|
||||
LANGUAGE sql STABLE AS $fn$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhrelid = p_oid AND inhparent = p_parent
|
||||
);
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."enforce_created_at_inheritance"() RETURNS event_trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
obj record;
|
||||
rel pg_class;
|
||||
schema_name text;
|
||||
parent_oid oid;
|
||||
BEGIN
|
||||
parent_oid := 'hectic.created_at'::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 "hectic"."_is_excluded_schema"(schema_name) THEN CONTINUE; END IF;
|
||||
IF NOT "hectic"."_table_inherits"(rel.oid, parent_oid) THEN
|
||||
RAISE EXCEPTION
|
||||
'hectic: table %.% must INHERITS (hectic.created_at)',
|
||||
quote_ident(schema_name), quote_ident(rel.relname)
|
||||
USING HINT = 'add INHERITS ("hectic"."created_at") to the CREATE TABLE statement, '
|
||||
|| 'or add the schema to hectic.inheritance_extra_excluded_schemas';
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."attach_updated_at_trigger"() RETURNS event_trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
obj record;
|
||||
rel pg_class;
|
||||
schema_name text;
|
||||
parent_oid oid;
|
||||
trigger_name text;
|
||||
has_trigger boolean;
|
||||
BEGIN
|
||||
parent_oid := 'hectic.updated_at'::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;
|
||||
trigger_name := 'hectic_set_updated_at';
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgrelid = rel.oid AND tgname = trigger_name AND NOT tgisinternal
|
||||
) INTO has_trigger;
|
||||
IF has_trigger THEN CONTINUE; END IF;
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION "hectic"."set_updated_at"()',
|
||||
trigger_name, schema_name, rel.relname
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
DROP EVENT TRIGGER IF EXISTS "hectic_enforce_created_at_inheritance";
|
||||
CREATE EVENT TRIGGER "hectic_enforce_created_at_inheritance"
|
||||
ON ddl_command_end
|
||||
WHEN TAG IN ('CREATE TABLE')
|
||||
EXECUTE FUNCTION "hectic"."enforce_created_at_inheritance"();
|
||||
|
||||
DROP EVENT TRIGGER IF EXISTS "hectic_attach_updated_at_trigger";
|
||||
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"();
|
||||
51
lib/hook/sql/hectic-migration.sql
Normal file
51
lib/hook/sql/hectic-migration.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
DO $bootstrap$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = 'hectic' AND t.typname = 'migration_name'
|
||||
) THEN
|
||||
CREATE DOMAIN "hectic"."migration_name" AS TEXT
|
||||
CHECK (VALUE ~ '^[0-9]{14}-.*');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = 'hectic' AND t.typname = 'sha256'
|
||||
) THEN
|
||||
CREATE DOMAIN "hectic"."sha256" AS CHAR(64)
|
||||
CHECK (VALUE ~ '^[0-9a-f]{64}$');
|
||||
END IF;
|
||||
END
|
||||
$bootstrap$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."sha256_lower"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN
|
||||
NEW."hash" := lower(NEW."hash");
|
||||
RETURN NEW;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "hectic"."migration" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"name" "hectic"."migration_name" UNIQUE NOT NULL,
|
||||
"hash" "hectic"."sha256" UNIQUE NOT NULL,
|
||||
"applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
DO $trg$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'hectic_t_sha256_lower'
|
||||
AND tgrelid = '"hectic"."migration"'::regclass
|
||||
AND NOT tgisinternal
|
||||
) THEN
|
||||
CREATE TRIGGER "hectic_t_sha256_lower"
|
||||
BEFORE INSERT OR UPDATE ON "hectic"."migration"
|
||||
FOR EACH ROW EXECUTE FUNCTION "hectic"."sha256_lower"();
|
||||
END IF;
|
||||
END
|
||||
$trg$;
|
||||
51
lib/hook/sql/hectic-secret.sql
Normal file
51
lib/hook/sql/hectic-secret.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE IF NOT EXISTS "hectic"."secret" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"key" TEXT UNIQUE NOT NULL,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."load_secrets_from_env"(env_content TEXT)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
line TEXT;
|
||||
k TEXT;
|
||||
v TEXT;
|
||||
BEGIN
|
||||
TRUNCATE TABLE "hectic"."secret";
|
||||
|
||||
FOR line IN
|
||||
SELECT regexp_split_to_table(env_content, E'\n')
|
||||
LOOP
|
||||
line := btrim(line);
|
||||
|
||||
IF line = '' OR line LIKE '#%' THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
k := split_part(line, '=', 1);
|
||||
v := substring(line FROM position('=' IN line) + 1);
|
||||
|
||||
k := btrim(k);
|
||||
v := btrim(v);
|
||||
|
||||
IF v ~ '^".*"$' OR v ~ '^''.*''$' THEN
|
||||
v := substring(v FROM 2 FOR char_length(v) - 2);
|
||||
END IF;
|
||||
|
||||
INSERT INTO "hectic"."secret" ("key", "value") VALUES (k, v);
|
||||
END LOOP;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "hectic"."get_secret"(k TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT "value"
|
||||
FROM "hectic"."secret"
|
||||
WHERE "key" = k
|
||||
);
|
||||
END
|
||||
$fn$;
|
||||
26
lib/hook/sql/hectic-version.sql
Normal file
26
lib/hook/sql/hectic-version.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
CREATE SCHEMA IF NOT EXISTS "hectic";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "hectic"."version" (
|
||||
"name" TEXT PRIMARY KEY,
|
||||
"version" TEXT NOT NULL,
|
||||
"installed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
DO $check$
|
||||
DECLARE
|
||||
existing TEXT;
|
||||
BEGIN
|
||||
SELECT "version" INTO existing
|
||||
FROM "hectic"."version"
|
||||
WHERE "name" = 'hectic';
|
||||
|
||||
IF existing IS NULL THEN
|
||||
INSERT INTO "hectic"."version" ("name", "version")
|
||||
VALUES ('hectic', '@HECTIC_VERSION@');
|
||||
ELSIF existing <> '@HECTIC_VERSION@' THEN
|
||||
RAISE EXCEPTION
|
||||
'hectic schema version mismatch: database has %, code expects %',
|
||||
existing, '@HECTIC_VERSION@';
|
||||
END IF;
|
||||
END
|
||||
$check$;
|
||||
Reference in New Issue
Block a user