DROP SCHEMA IF EXISTS metagration CASCADE; CREATE SCHEMA metagration; CREATE SCHEMA IF NOT EXISTS metagration_scripts; CREATE TABLE metagration.script ( revision bigserial PRIMARY KEY, is_current boolean DEFAULT false, script_schema text NOT null DEFAULT 'metagration_scripts', up_script text, down_script text, test_script text, args jsonb, comment text ); COMMENT ON TABLE metagration.script IS 'Table for metagration scripts.'; CREATE UNIQUE INDEX ON metagration.script (is_current) WHERE is_current = true; CREATE OR REPLACE FUNCTION metagration.check_script_trigger() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE max_revision bigint; BEGIN SELECT max(revision) INTO max_revision FROM metagration.script; IF new.revision <= max_revision THEN RAISE 'Cannot insert script with revision <= %', max_revision; END IF; RETURN new; END; $$; CREATE TRIGGER before_insert_script_trigger BEFORE INSERT ON metagration.script FOR EACH ROW EXECUTE PROCEDURE metagration.check_script_trigger(); -- 0 is the "base" revision, which means no scripts applied. INSERT INTO metagration.script (revision, is_current) VALUES (0, true); CREATE TABLE metagration.log ( revision_start bigint REFERENCES metagration.script (revision), revision_end bigint REFERENCES metagration.script (revision), migration_start timestamptz not null, migration_end timestamptz, migration_args jsonb, txid bigint, restore_point text, restore_point_lsn pg_lsn, PRIMARY KEY (revision_start, revision_end, migration_start) ); COMMENT ON TABLE metagration.script IS 'Log of metagrations that have been applied, when and their restore points.'; CREATE OR REPLACE FUNCTION metagration.current_revision() RETURNS bigint LANGUAGE sql AS $$ SELECT revision FROM metagration.script WHERE is_current; $$; COMMENT ON FUNCTION metagration.current_revision() IS 'Returns the current revision or null if no revisions applied.'; CREATE OR REPLACE FUNCTION metagration.previous_revision(from_revision bigint=null) RETURNS bigint LANGUAGE sql AS $$ SELECT revision FROM metagration.script WHERE revision < coalesce(from_revision, metagration.current_revision()) ORDER BY revision DESC LIMIT 1; $$; COMMENT ON FUNCTION metagration.previous_revision(bigint) IS 'Returns the previons revision or null if no previous revision to the one supplied. If no revision is supplied, default to the current revision'; CREATE OR REPLACE FUNCTION metagration.next_revision(from_revision bigint=null) RETURNS bigint LANGUAGE sql AS $$ SELECT revision FROM metagration.script WHERE revision > coalesce(from_revision, metagration.current_revision()) ORDER BY revision ASC LIMIT 1; $$; COMMENT ON FUNCTION metagration.next_revision(bigint) IS 'Returns the next revision or null if no next revision to the one supplied. If no revision is supplied, default to the current revision'; CREATE OR REPLACE PROCEDURE metagration.run_up( revision_start bigint, revision_end bigint, args jsonb='{}') LANGUAGE plpgsql AS $$ DECLARE current_script metagration.script; BEGIN FOR current_script IN SELECT * FROM metagration.script WHERE revision > revision_start AND revision <= revision_end ORDER BY revision ASC LOOP EXECUTE format( 'CALL %I.%I($1)', current_script.script_schema, current_script.up_script) USING current_script.args || args; UPDATE metagration.script SET is_current = false WHERE is_current; UPDATE metagration.script SET is_current = true WHERE revision = current_script.revision; END LOOP; END; $$; COMMENT ON PROCEDURE metagration.run_up(bigint, bigint, jsonb) IS 'Apply up scripts from start to end revisions.'; CREATE OR REPLACE PROCEDURE metagration.run_down( revision_start bigint, revision_end bigint, args jsonb='{}') LANGUAGE plpgsql AS $$ DECLARE current_script metagration.script; BEGIN FOR current_script IN SELECT * FROM metagration.script WHERE revision <= revision_start AND revision > revision_end ORDER BY revision DESC LOOP IF current_script.down_script IS null THEN RAISE 'No down script for revision %', current_script.revision; END IF; EXECUTE format('CALL %I.%I($1)', current_script.script_schema, current_script.down_script) USING current_script.args || args; UPDATE metagration.script SET is_current = false WHERE is_current; UPDATE metagration.script SET is_current = true WHERE revision = metagration.previous_revision(current_script.revision); END LOOP; END; $$; COMMENT ON PROCEDURE metagration.run_down(bigint, bigint, jsonb) IS 'Apply down scripts from start to end revisions.'; CREATE OR REPLACE PROCEDURE metagration.run(run_to bigint=null, args jsonb='{}') LANGUAGE plpgsql AS $$ DECLARE current_revision bigint; revision_start bigint; revision_end bigint; clock_now timestamptz; restore_point text; restore_point_lsn pg_lsn; BEGIN LOCK TABLE metagration.script IN SHARE MODE; current_revision = metagration.current_revision(); IF run_to = 0 THEN IF current_revision is null THEN RAISE 'No starting revision available.'; END IF; CALL metagration.run_down(current_revision, 0, args); RETURN; END IF; IF run_to IS null THEN SELECT max(revision) INTO run_to FROM metagration.script; END IF; SELECT revision INTO revision_end FROM metagration.script WHERE revision = run_to; IF revision_end IS null THEN RAISE 'no revision %', run_to; END IF; SELECT revision INTO revision_start FROM metagration.script WHERE is_current; IF revision_start IS null THEN revision_start = 0; END IF; IF revision_start = revision_end THEN RAISE '% is already the current revision', run_to; END IF; SELECT clock_timestamp() INTO clock_now; restore_point = format('%s|%s|%s', revision_start, revision_end, replace(clock_now::text, ' ', '|')); SELECT pg_create_restore_point(restore_point) INTO restore_point_lsn; IF revision_start < revision_end THEN CALL metagration.run_up(revision_start, revision_end, args); ELSE CALL metagration.run_down(revision_start, revision_end, args); END IF; INSERT INTO metagration.log ( revision_start, revision_end, migration_start, migration_end, migration_args, txid, restore_point, restore_point_lsn) VALUES ( revision_start, revision_end, clock_now, clock_timestamp(), args, txid_current(), restore_point, restore_point_lsn); END; $$; COMMENT ON PROCEDURE metagration.run(bigint, jsonb) IS 'Run from thecurrent revision, forwards or backwards to the target revision.'; CREATE OR REPLACE PROCEDURE metagration.run(run_to text, args jsonb='{}') LANGUAGE plpgsql AS $$ DECLARE revision_start bigint; revision_end bigint; delta bigint = run_to::bigint; BEGIN revision_start = metagration.current_revision(); EXECUTE format($f$ SELECT revision FROM metagration.script WHERE CASE WHEN $1 < 0 THEN revision < $2 ELSE revision > $2 END ORDER BY revision %s LIMIT 1 OFFSET %s $f$, CASE WHEN delta < 0 THEN 'desc' ELSE 'asc' END, abs(delta)-1) INTO revision_end USING delta, revision_start; IF revision_end IS null THEN RAISE 'No revision % away', run_to; END IF; CALL metagration.run(revision_end, args); END; $$; COMMENT ON PROCEDURE metagration.run(text, jsonb) IS 'Run from the current revision, forwards or backwards to the target revision using relative notation -1 to go back one, +3 to go forward 3, etc...'; CREATE OR REPLACE FUNCTION metagration._proc_body(script_declare text, script text) RETURNS text LANGUAGE plpgsql AS $$ DECLARE buffer text = ''; BEGIN IF script_declare IS NOT null THEN buffer = buffer || format($f$ DECLARE %s; $f$, script_declare); END IF; buffer = buffer || format($f$ BEGIN %s; RETURN; END;$f$, script); RETURN buffer; END;$$; CREATE OR REPLACE FUNCTION metagration._build_proc( use_schema text, script_name text, script_body text) RETURNS text LANGUAGE plpgsql AS $$ BEGIN RETURN format( $f$ CREATE OR REPLACE PROCEDURE %I.%I (args jsonb='{}') LANGUAGE plpgsql AS $%s$ %s $%s$; $f$, use_schema, script_name, script_name, script_body, script_name); END; $$; CREATE OR REPLACE FUNCTION metagration.new_script( up_script text, down_script text=null, up_declare text=null, down_declare text=null, args jsonb='{}', use_schema text='metagration_scripts', comment text=null) RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE this metagration.script; up_name text; down_name text = null; BEGIN INSERT INTO metagration.script (args, script_schema, comment) VALUES (args, use_schema, comment) RETURNING * INTO this; up_name = '_' || this.revision || '_' || 'up'; if down_script IS NOT null THEN down_name = '_' || this.revision || '_' || 'down'; EXECUTE metagration._build_proc( use_schema, down_name, metagration._proc_body(down_declare, down_script)); END IF; EXECUTE metagration._build_proc( use_schema, up_name, metagration._proc_body(up_declare, up_script)); UPDATE metagration.script SET up_script = up_name, down_script = down_name WHERE revision = this.revision; RETURN this.revision; END; $$; CREATE OR REPLACE FUNCTION metagration._get_sourcedef( proc_schema text, proc_name text) RETURNS text LANGUAGE sql AS $$ SELECT pg_get_functiondef(p.oid) || ';' FROM pg_proc p, pg_namespace n WHERE p.pronamespace = n.oid AND p.proname=proc_name AND n.nspname=proc_schema; $$; CREATE OR REPLACE FUNCTION metagration.export( replace_scripts boolean=false, transactional boolean=false, run_migrations boolean=false) RETURNS text LANGUAGE plpgsql AS $$ DECLARE current_script metagration.script; buffer text=''; proc_source text; proc_language text; BEGIN IF transactional THEN buffer = buffer || ' BEGIN;'; END IF; IF replace_scripts THEN buffer = buffer || format( $f$ TRUNCATE metagration.script CASCADE; INSERT INTO metagration.script (revision, is_current) VALUES (0, true); $f$); END IF; FOR current_script IN SELECT * FROM metagration.script WHERE revision > 0 ORDER BY revision LOOP buffer = buffer || metagration._get_sourcedef( current_script.script_schema, current_script.up_script); IF current_script.down_script IS NOT null THEN buffer = buffer || metagration._get_sourcedef( current_script.script_schema, current_script.down_script); END IF; IF replace_scripts THEN buffer = buffer || format( $f$ INSERT INTO metagration.script (revision, script_schema, up_script, down_script, args, comment) VALUES (%L, %L, %L, %L, %L, %L); $f$, current_script.revision, current_script.script_schema, current_script.up_script, current_script.down_script, current_script.args, current_script.comment); END IF; END LOOP; IF run_migrations THEN buffer = buffer || ' CALL metagration.run();'; END IF; IF transactional THEN buffer = buffer || ' COMMIT;'; END IF; RETURN buffer; END; $$; COMMENT ON FUNCTION metagration.export(boolean, boolean, boolean) IS 'Export metagration scripts as SQL file that can be loaded into fresh database. ';