feat(db-tool): hectic-inheritance: bootstrap hectic.created_at / hectic.updated_at inheritance hook
Adds a SQL bundle plus event triggers that enforce `INHERITS (hectic.created_at)`
on every user CREATE TABLE and auto-attach a BEFORE UPDATE row trigger when a
table inherits `hectic.updated_at`. Always-exempt: `hectic`, `information_schema`,
`pg_*`, declarative partitions, temp tables. Per-DB opt-out via the GUC
`hectic.inheritance_extra_excluded_schemas`.
Exposed three ways:
* `pkgs.hectic.hectic-inheritance` — derivation with the SQL at
$out/share/hectic/hectic-inheritance.sql
* `self.lib.hecticInheritance.{sql,path}` — pkgs-free Nix surface
* `postgres-init` opt-in via `PG_HECTIC_INHERITANCE=1` (HECTIC_INHERITANCE_SQL
overrides the default)
Test postgres-init-hectic-inheritance covers all six branches: bootstrap,
non-inheriting reject, accepting inheritance, auto updated_at trigger fires,
GUC exclusion, declarative partition exemption.
This commit is contained in:
@@ -32,6 +32,8 @@ These variables must be set for `db-tool` to function.
|
||||
| `PG_CONF_FILE` | (unset) | Path to a `postgresql.conf` file. When set, replaces the script-generated config entirely on fresh init. `port` and `unix_socket_directories` are still appended at runtime (always overridden). When set, `PG_DISABLE_LOGGING` and `PG_SHARED_PRELOAD_LIBRARIES` are ignored. |
|
||||
| `PG_SHARED_PRELOAD_LIBRARIES` | `pg_cron` | Comma-separated `shared_preload_libraries` value. Set to empty string to disable. Ignored when `PG_CONF_FILE` is set. |
|
||||
| `PG_DISABLE_LOGGING` | `0` | Set to `1` to disable PostgreSQL logging collector. Ignored when `PG_CONF_FILE` is set. |
|
||||
| `PG_HECTIC_INHERITANCE` | `0` | Set to `1` to apply the [`hectic` inheritance bundle](#hectic-inheritance-bundle) to the target database after init. |
|
||||
| `HECTIC_INHERITANCE_SQL` | (auto) | Override path to the SQL file applied by `PG_HECTIC_INHERITANCE=1`. Defaults to the SQL shipped with `postgres-init`. |
|
||||
| `PATCH_LOG` | (stdout) | Path to log the output of database patches. |
|
||||
| `HYDRATE_LOG` | (stdout) | Path to log the output of database hydration. |
|
||||
|
||||
@@ -107,6 +109,54 @@ 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:
|
||||
|
||||
- `hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — every
|
||||
user table must `INHERITS (hectic.created_at)`. The event trigger
|
||||
`hectic_enforce_created_at_inheritance` raises an exception on `CREATE TABLE`
|
||||
otherwise.
|
||||
- `hectic.updated_at(updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — optional.
|
||||
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`.
|
||||
|
||||
Always-exempt schemas: `hectic`, `information_schema`, anything matching
|
||||
`pg_*`. Declarative partitions (`relispartition = true`) and temporary tables
|
||||
are also auto-exempt.
|
||||
|
||||
Per-database opt-out for additional schemas via the
|
||||
`hectic.inheritance_extra_excluded_schemas` GUC (comma-separated):
|
||||
|
||||
```sql
|
||||
ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl';
|
||||
```
|
||||
|
||||
### Apply via `postgres-init`
|
||||
|
||||
```sh
|
||||
export PG_HECTIC_INHERITANCE=1
|
||||
postgres-init
|
||||
```
|
||||
|
||||
### Apply via `migrator` or any psql pipeline
|
||||
|
||||
```nix
|
||||
# in your devshell
|
||||
shellHook = ''
|
||||
export HECTIC_INHERITANCE_SQL=${pkgs.hectic.hectic-inheritance}/share/hectic/hectic-inheritance.sql
|
||||
'';
|
||||
```
|
||||
|
||||
```sh
|
||||
psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$HECTIC_INHERITANCE_SQL"
|
||||
```
|
||||
|
||||
The SQL is also exposed via `self.lib.hecticInheritance.sql` (string) and
|
||||
`self.lib.hecticInheritance.path` (Nix path) for inline pipelines.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
{ dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib }:
|
||||
{ dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib, runCommand }:
|
||||
let
|
||||
shell = "${dash}/bin/dash";
|
||||
|
||||
hecticInheritanceSqlPath = ./sql/hectic-inheritance.sql;
|
||||
|
||||
hecticInheritance = runCommand "hectic-inheritance" { } ''
|
||||
mkdir -p "$out/share/hectic"
|
||||
cp ${hecticInheritanceSqlPath} "$out/share/hectic/hectic-inheritance.sql"
|
||||
'';
|
||||
|
||||
mkDatabase =
|
||||
{ postgresql ? postgresql_17 }:
|
||||
hectic.writeShellApplication {
|
||||
@@ -37,7 +44,11 @@ let
|
||||
name = "postgres-init";
|
||||
runtimeInputs = [ postgresql coreutils ];
|
||||
|
||||
text = builtins.readFile ./postgres-init.sh;
|
||||
text = ''
|
||||
HECTIC_INHERITANCE_SQL_DEFAULT="${hecticInheritance}/share/hectic/hectic-inheritance.sql"
|
||||
export HECTIC_INHERITANCE_SQL_DEFAULT
|
||||
${builtins.readFile ./postgres-init.sh}
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Initialize local PostgreSQL instance";
|
||||
@@ -62,7 +73,8 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
"db-tool" = lib.makeOverridable mkDatabase { };
|
||||
"postgres-init" = lib.makeOverridable mkPostgresInit { };
|
||||
"postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { };
|
||||
"db-tool" = lib.makeOverridable mkDatabase { };
|
||||
"postgres-init" = lib.makeOverridable mkPostgresInit { };
|
||||
"postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { };
|
||||
"hectic-inheritance" = hecticInheritance;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,16 @@ postgres_init_main() {
|
||||
fi
|
||||
psql -h "$sockdir" -p "$PG_PORT" -d "$db" -v ON_ERROR_STOP=1 -c 'select 1;' || return 1
|
||||
|
||||
if [ "${PG_HECTIC_INHERITANCE:-0}" = "1" ]; then
|
||||
sql_file="${HECTIC_INHERITANCE_SQL:-${HECTIC_INHERITANCE_SQL_DEFAULT:-}}"
|
||||
if [ -z "$sql_file" ]; then
|
||||
printf '%s\n' 'postgres-init: PG_HECTIC_INHERITANCE=1 but no SQL file resolved (set HECTIC_INHERITANCE_SQL)' >&2
|
||||
return 3
|
||||
fi
|
||||
[ -r "$sql_file" ] || { printf '%s\n' "postgres-init: hectic-inheritance SQL not readable: $sql_file" >&2; return 1; }
|
||||
psql -h "$sockdir" -p "$PG_PORT" -U "$user" -d "$db" -v ON_ERROR_STOP=1 -f "$sql_file" || return 1
|
||||
fi
|
||||
|
||||
export POSTGRESQL_HOST="$sockdir" POSTGRESQL_PORT="$PG_PORT" POSTGRESQL_USER="$user" POSTGRESQL_DATABASE="$db"
|
||||
_pg_url="postgresql://${POSTGRESQL_USER}@/${POSTGRESQL_DATABASE}?host=${POSTGRESQL_HOST}&port=${POSTGRESQL_PORT}"
|
||||
case $PG_URL_VAR in ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]* ) printf '%s\n' 'postgres-init: invalid PG_URL_VAR' >&2; return 1 ;; esac
|
||||
|
||||
155
package/db-tool/sql/hectic-inheritance.sql
Normal file
155
package/db-tool/sql/hectic-inheritance.sql
Normal file
@@ -0,0 +1,155 @@
|
||||
-- hectic.created_at / hectic.updated_at 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())
|
||||
-- * function hectic.set_updated_at() -- BEFORE UPDATE row trigger function
|
||||
-- * GUC hectic.inheritance_extra_excluded_schemas
|
||||
-- (text, comma-separated list of schemas the enforcement trigger skips)
|
||||
-- * 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.
|
||||
--
|
||||
-- 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()
|
||||
);
|
||||
|
||||
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$;
|
||||
|
||||
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"."_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"();
|
||||
@@ -145,6 +145,7 @@ in {
|
||||
"db-tool" = dbToolPkgs."db-tool";
|
||||
"postgres-init" = dbToolPkgs."postgres-init";
|
||||
"postgres-cleanup" = dbToolPkgs."postgres-cleanup";
|
||||
"hectic-inheritance" = dbToolPkgs."hectic-inheritance";
|
||||
nbt2json = pkgs.callPackage ./nbt2json {};
|
||||
hemar-parser = pkgs.callPackage ./hemar/parser {};
|
||||
AstroTuxLauncher = pkgs.callPackage ./AstroTuxLauncher.nix {};
|
||||
|
||||
Reference in New Issue
Block a user