From 44dc3ac39e74639add3b63a48a6697e036606b14 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Thu, 21 May 2026 16:47:22 -0400 Subject: [PATCH 01/11] Add DB support for Plan Bounds - Adds a trigger to cascade boundary changes - Allows snapshots to track and restore plan bounds --- .../functions/merlin/merging/begin_merge.sql | 21 +++++++- .../merging/merge_request_state_functions.sql | 23 ++++++-- .../merlin/snapshots/create_snapshot.sql | 9 +++- .../snapshots/restore_from_snapshot.sql | 14 +++-- .../activity_directive/activity_directive.sql | 1 + .../sql/tables/merlin/plan.sql | 52 +++++++++++++++++++ .../tables/merlin/snapshot/plan_snapshot.sql | 6 +++ 7 files changed, 116 insertions(+), 10 deletions(-) diff --git a/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql b/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql index 3ff2e98c9e..580e0ebb09 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql @@ -29,6 +29,10 @@ create procedure merlin.begin_merge(_merge_request_id integer, review_username t snapshot_id_supplying integer; plan_id_receiving integer; merge_base_id integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; begin -- validate id and status select id, status @@ -50,6 +54,22 @@ begin where id = _merge_request_id into plan_id_receiving, snapshot_id_supplying; + -- ensure that the plans cover the same boundaries + select start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into start_time_receiving, duration_receiving; + + select plan_start_time, plan_duration + from merlin.plan_snapshot ps + where ps.snapshot_id = snapshot_id_supplying + into start_time_supplying, duration_supplying; + + if start_time_receiving is distinct from start_time_supplying or + duration_receiving is distinct from duration_supplying then + raise exception 'Cannot begin merge request between plans with different bounds'; + end if; + -- ensure the plan receiving changes isn't locked if (select is_locked from merlin.plan where plan.id=plan_id_receiving) then raise exception 'Cannot begin merge request. Plan to receive changes is locked.'; @@ -71,7 +91,6 @@ begin reviewer_username = review_username where id = _merge_request_id; - -- perform diff between mb and s_sc (s_diff) -- delete is B minus A on key -- add is A minus B on key diff --git a/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql b/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql index 10219bb9ec..63fc7e969e 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql @@ -8,15 +8,27 @@ declare merge_request_id integer; model_id_receiving integer; model_id_supplying integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; begin if plan_id_receiving = plan_id_supplying then raise exception 'Cannot create a merge request between a plan and itself.'; end if; - select id from merlin.plan where plan.id = plan_id_receiving into validate_planIds; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into validate_planIds, model_id_receiving, start_time_receiving, duration_receiving; if validate_planIds is null then raise exception 'Plan receiving changes (Plan %) does not exist.', plan_id_receiving; end if; - select id from merlin.plan where plan.id = plan_id_supplying into validate_planIds; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_supplying + into validate_planIds, model_id_supplying, start_time_supplying, duration_supplying; if validate_planIds is null then raise exception 'Plan supplying changes (Plan %) does not exist.', plan_id_supplying; end if; @@ -28,12 +40,15 @@ begin raise exception 'Cannot create merge request between unrelated plans.'; end if; - select model_id from merlin.plan where plan.id = plan_id_receiving into model_id_receiving; - select model_id from merlin.plan where plan.id = plan_id_supplying into model_id_supplying; if model_id_receiving is distinct from model_id_supplying then raise exception 'Cannot create merge request: plan supplying changes is using a different model (%) than the receiving plan (%)', model_id_supplying, model_id_receiving; end if; + if (start_time_receiving is distinct from start_time_supplying) or + (duration_receiving is distinct from duration_supplying) then + raise exception 'Cannot create merge request between plans with different bounds'; + end if; + insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) returning id into merge_request_id; diff --git a/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql b/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql index 6e97611fa7..fef5be3153 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql @@ -19,10 +19,13 @@ begin raise exception 'Plan % does not exist.', _plan_id; end if; - insert into merlin.plan_snapshot(plan_id, model_id, revision, snapshot_name, description, taken_by) - select id, model_id, revision, _snapshot_name, _description, _user + insert into merlin.plan_snapshot(plan_id, model_id, revision, plan_start_time, plan_duration, + snapshot_name, description, taken_by) + select id, model_id, revision, start_time, duration, + _snapshot_name, _description, _user from merlin.plan where id = _plan_id returning snapshot_id into inserted_snapshot_id; + insert into merlin.plan_snapshot_activities( snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_at, last_modified_by, start_offset, type, @@ -33,10 +36,12 @@ begin last_modified_at, last_modified_by, start_offset, type, arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start from merlin.activity_directive where activity_directive.plan_id = _plan_id; + insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) select ptd.preset_id, ptd.activity_id, inserted_snapshot_id from merlin.preset_to_directive ptd where ptd.plan_id = _plan_id; + insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) select inserted_snapshot_id, directive_id, tag_id from tags.activity_directive_tags adt diff --git a/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql b/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql index 9b65939e9d..a79a72f3bb 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql @@ -4,6 +4,8 @@ create procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id int _snapshot_name text; _plan_name text; _model_id integer; + _plan_start_time timestamptz; + _plan_duration interval; begin -- Input Validation select name from merlin.plan where id = _plan_id into _plan_name; @@ -23,7 +25,11 @@ create procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id int _snapshot_id, _plan_name, _plan_id; end if; end if; - select model_id from merlin.plan_snapshot where snapshot_id = _snapshot_id into _model_id; + + select model_id, plan_start_time, plan_duration + from merlin.plan_snapshot + where snapshot_id = _snapshot_id + into _model_id, _plan_start_time, _plan_duration; if not exists(select from merlin.mission_model m where m.id = _model_id) then raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; end if; @@ -31,9 +37,11 @@ create procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id int -- Catch Plan_Locked call merlin.plan_locked_exception(_plan_id); - -- Update model_id of the plan + -- Update model_id and bounds of the plan update merlin.plan - set model_id = _model_id + set model_id = _model_id, + start_time = _plan_start_time, + duration = _plan_duration where id = _plan_id; -- Record the Union of Activities in Plan and Snapshot diff --git a/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql b/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql index 5ddafbb641..0ca7a0ce38 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql @@ -129,6 +129,7 @@ end$$; create trigger increment_plan_revision_on_directive_update_trigger after update on merlin.activity_directive for each row +when (pg_trigger_depth() < 1) execute function merlin.increment_plan_revision_on_directive_update(); create function merlin.increment_plan_revision_on_directive_delete() diff --git a/deployment/postgres-init-db/sql/tables/merlin/plan.sql b/deployment/postgres-init-db/sql/tables/merlin/plan.sql index f6a6e756f2..4219201fd9 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/plan.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/plan.sql @@ -134,6 +134,58 @@ for each row when (pg_trigger_depth() < 1) execute function util_functions.increment_revision_update(); +create function merlin.cascade_plan_bounds_update() + returns trigger + language plpgsql as $$ +begin + -- prevent adjustment if the plan is locked + if old.is_locked then + raise exception 'Cannot adjust bounds of locked plan.'; + end if; + + -- Take a backup snapshot + perform merlin.create_snapshot( + old.id, + 'Plan Bound Adjustment', + 'Automatic snapshot made before adjusting plan bounds from ' || + '['|| old.start_time ||' - '|| old.start_time + old.duration || '] to ' || + '[' || new.start_time || ' - ' || new.start_time + new.duration || ']', + null); + + -- Update activities that are anchored to the plan bounds + update merlin.activity_directive ad + set start_offset = start_offset + (new.start_time - old.start_time) + where anchor_id is null + and anchored_to_start -- anchored to plan start + and ad.plan_id = old.id; + + update merlin.activity_directive ad + set start_offset = start_offset + (new.duration - old.duration) + where anchor_id is null + and not anchored_to_start -- anchored to plan end + and ad.plan_id = old.id; + + -- Update associated dataset offsets (simulation and plan) + update merlin.simulation_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) + from merlin.simulation sim_spec + where simulation_id = sim_spec.id + and sim_spec.plan_id = old.id; + + update merlin.plan_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) + where plan_id = old.id; + + return new; +end; +$$; + +create trigger cascade_plan_bounds_on_update + before update on merlin.plan + for each row + when (old.start_time is distinct from new.start_time or old.duration is distinct from new.duration) +execute function merlin.cascade_plan_bounds_update(); + -- Delete Triggers create function merlin.cleanup_on_delete() diff --git a/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql b/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql index 424186c09b..41ed03418e 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql @@ -12,6 +12,8 @@ create table merlin.plan_snapshot( references merlin.mission_model on delete set null, revision integer not null, + plan_start_time timestamptz not null, + plan_duration interval not null, snapshot_name text, description text, @@ -31,6 +33,10 @@ comment on column merlin.plan_snapshot.model_id is e'' 'The model that this plan was using at the time the snapshot was taken.'; comment on column merlin.plan_snapshot.revision is e'' 'The revision of the plan at the time the snapshot was taken.'; +comment on column merlin.plan_snapshot.plan_start_time is e'' + 'The start time of the plan at the time the snapshot was taken.'; +comment on column merlin.plan_snapshot.plan_duration is e'' + 'The duration of the plan at the time the snapshot was taken.'; comment on column merlin.plan_snapshot.snapshot_name is e'' 'A human-readable name for the snapshot.'; comment on column merlin.plan_snapshot.description is e'' From 9499b8e81c9dd089443a8d1d5892a7a8c27492ba Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 22 May 2026 09:02:41 -0400 Subject: [PATCH 02/11] Create DB migration --- .../Aerie/35_change_plan_bounds/down.sql | 542 +++++++++++++++ .../Aerie/35_change_plan_bounds/up.sql | 650 ++++++++++++++++++ .../sql/applied_migrations.sql | 1 + 3 files changed, 1193 insertions(+) create mode 100644 deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql create mode 100644 deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql new file mode 100644 index 0000000000..2fb7d257fc --- /dev/null +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql @@ -0,0 +1,542 @@ +-- Restore plan merge functions +create or replace procedure merlin.begin_merge(_merge_request_id integer, review_username text) + language plpgsql as $$ +declare + validate_id integer; + validate_status merlin.merge_request_status; + validate_non_no_op_status merlin.activity_change_type; + snapshot_id_supplying integer; + plan_id_receiving integer; + merge_base_id integer; +begin + -- validate id and status + select id, status + from merlin.merge_request + where _merge_request_id = id + into validate_id, validate_status; + + if validate_id is null then + raise exception 'Request ID % is not present in merge_request table.', _merge_request_id; + end if; + + if validate_status != 'pending' then + raise exception 'Cannot begin request. Merge request % is not in pending state.', _merge_request_id; + end if; + + -- select from merge-request the snapshot_sc (s_sc) and plan_rc (p_rc) ids + select plan_id_receiving_changes, snapshot_id_supplying_changes + from merlin.merge_request + where id = _merge_request_id + into plan_id_receiving, snapshot_id_supplying; + + -- ensure the plan receiving changes isn't locked + if (select is_locked from merlin.plan where plan.id=plan_id_receiving) then + raise exception 'Cannot begin merge request. Plan to receive changes is locked.'; + end if; + + -- lock plan_rc + update merlin.plan + set is_locked = true + where plan.id = plan_id_receiving; + + -- get merge base (mb) + select merlin.get_merge_base(plan_id_receiving, snapshot_id_supplying) + into merge_base_id; + + -- update the status to "in progress" + update merlin.merge_request + set status = 'in-progress', + merge_base_snapshot_id = merge_base_id, + reviewer_username = review_username + where id = _merge_request_id; + + + -- perform diff between mb and s_sc (s_diff) + -- delete is B minus A on key + -- add is A minus B on key + -- A intersect B is no op + -- A minus B on everything except everything currently in the table is modify + create temp table supplying_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'none' + from( + select psa.id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, snapshot_id_supplying), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'modify' + from( + select id as activity_id from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id or snapshot_id = snapshot_id_supplying + except + select activity_id from supplying_diff) a; + + -- perform diff between mb and p_rc (r_diff) + create temp table receiving_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'none' + from( + select id as activity_id, name, tags.tag_ids_activity_snapshot(id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_directive(id, plan_id_receiving), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.activity_directive ad + where ad.plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'modify' + from ( + (select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + union + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) + except + select activity_id + from receiving_diff) a; + + + -- perform diff between s_diff and r_diff + -- upload the non-conflicts into merge_staging_area + -- upload conflict into conflicting_activities + create temp table diff_diff( + activity_id integer, + change_type_supplying merlin.activity_change_type not null, + change_type_receiving merlin.activity_change_type not null + ); + + -- this is going to require us to do the "none" operation again on the remaining modifies + -- but otherwise we can just dump the 'adds' and 'none' into the merge staging area table + + -- 'delete' against a 'delete' does not enter the merge staging area table + -- receiving 'delete' against supplying 'none' does not enter the merge staging area table + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- 'adds' can go directly into the merge staging area table + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(s_diff.activity_id, psa.snapshot_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + from supplying_diff as s_diff + join merlin.plan_snapshot_activities psa + on s_diff.activity_id = psa.id + where snapshot_id = snapshot_id_supplying and change_type = 'add' + union + -- an 'add' between the receiving plan and merge base is actually a 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(r_diff.activity_id, ad.plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none'::merlin.activity_change_type + from receiving_diff as r_diff + join merlin.activity_directive ad + on r_diff.activity_id = ad.id + where plan_id = plan_id_receiving and change_type = 'add'; + + -- put the rest in diff_diff + insert into diff_diff (activity_id, change_type_supplying, change_type_receiving) + select activity_id, supplying_diff.change_type as change_type_supplying, receiving_diff.change_type as change_type_receiving + from receiving_diff + join supplying_diff using (activity_id) + where receiving_diff.change_type != 'add' or supplying_diff.change_type != 'add'; + + -- ...except for that which is not recorded + delete from diff_diff + where (change_type_receiving = 'delete' and change_type_supplying = 'delete') + or (change_type_receiving = 'delete' and change_type_supplying = 'none'); + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- receiving 'none' and 'modify' against 'none' in the supplying side go into the merge staging area as 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none' + from diff_diff + join merlin.activity_directive + on activity_id=id + where plan_id = plan_id_receiving + and change_type_supplying = 'none' + and (change_type_receiving = 'modify' or change_type_receiving = 'none') + union + -- supplying 'modify' against receiving 'none' go into the merge staging area as 'modify' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(diff_diff.activity_id, snapshot_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.plan_snapshot_activities p + on diff_diff.activity_id = p.id + where snapshot_id = snapshot_id_supplying + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'modify') + union + -- supplying 'delete' against receiving 'none' go into the merge staging area as 'delete' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.activity_directive p + on diff_diff.activity_id = p.id + where plan_id = plan_id_receiving + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'delete'); + + -- 'modify' against a 'modify' must be checked for equality first. + with false_modify as ( + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, psa.snapshot_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + join diff_diff dd + on dd.activity_id = psa.id + where psa.snapshot_id = snapshot_id_supplying + and (dd.change_type_receiving = 'modify' and dd.change_type_supplying = 'modify') + intersect + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, ad.plan_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from diff_diff dd + join merlin.activity_directive ad + on dd.activity_id = ad.id + where ad.plan_id = plan_id_receiving + and (dd.change_type_supplying = 'modify' and dd.change_type_receiving = 'modify')) + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type) + select _merge_request_id, ad.id, ad.name, tags, ad.source_scheduling_goal_id, ad.source_scheduling_goal_invocation_id, + ad.created_at, ad.created_by, ad.last_modified_by, ad.start_offset, ad.type, ad.arguments, ad.metadata, + ad.anchor_id, ad.anchored_to_start, 'none' + from false_modify fm + left join merlin.activity_directive ad + on (ad.plan_id, ad.id) = (plan_id_receiving, fm.activity_id); + + -- 'modify' against 'delete' and inequal 'modify' against 'modify' goes into conflict table (aka everything left in diff_diff) + insert into merlin.conflicting_activities (merge_request_id, activity_id, change_type_supplying, change_type_receiving) + select begin_merge._merge_request_id, activity_id, change_type_supplying, change_type_receiving + from (select begin_merge._merge_request_id, activity_id + from diff_diff + except + select msa.merge_request_id, activity_id + from merlin.merge_staging_area msa) a + join diff_diff using (activity_id); + + -- Fail if there are no differences between the snapshot and the plan getting merged + validate_non_no_op_status := null; + select change_type_receiving + from merlin.conflicting_activities + where merge_request_id = _merge_request_id + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + select change_type + from merlin.merge_staging_area msa + where merge_request_id = _merge_request_id + and msa.change_type != 'none' + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + raise exception 'Cannot begin merge. The contents of the two plans are identical.'; + end if; + end if; + + + -- clean up + drop table supplying_diff; + drop table receiving_diff; + drop table diff_diff; +end +$$; + +create or replace function merlin.create_merge_request(plan_id_supplying integer, plan_id_receiving integer, request_username text) + returns integer + language plpgsql as $$ +declare + merge_base_snapshot_id integer; + validate_planIds integer; + supplying_snapshot_id integer; + merge_request_id integer; + model_id_receiving integer; + model_id_supplying integer; +begin + if plan_id_receiving = plan_id_supplying then + raise exception 'Cannot create a merge request between a plan and itself.'; + end if; + select id from merlin.plan where plan.id = plan_id_receiving into validate_planIds; + if validate_planIds is null then + raise exception 'Plan receiving changes (Plan %) does not exist.', plan_id_receiving; + end if; + select id from merlin.plan where plan.id = plan_id_supplying into validate_planIds; + if validate_planIds is null then + raise exception 'Plan supplying changes (Plan %) does not exist.', plan_id_supplying; + end if; + + select merlin.create_snapshot(plan_id_supplying) into supplying_snapshot_id; + + select merlin.get_merge_base(plan_id_receiving, supplying_snapshot_id) into merge_base_snapshot_id; + if merge_base_snapshot_id is null then + raise exception 'Cannot create merge request between unrelated plans.'; + end if; + + select model_id from merlin.plan where plan.id = plan_id_receiving into model_id_receiving; + select model_id from merlin.plan where plan.id = plan_id_supplying into model_id_supplying; + if model_id_receiving is distinct from model_id_supplying then + raise exception 'Cannot create merge request: plan supplying changes is using a different model (%) than the receiving plan (%)', model_id_supplying, model_id_receiving; + end if; + + insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) + values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) + returning id into merge_request_id; + return merge_request_id; +end +$$; + +-- Restore "Update Plan Revision on Directive Change" trigger behavior +create or replace trigger increment_plan_revision_on_directive_update_trigger + after update on merlin.activity_directive + for each row +execute function merlin.increment_plan_revision_on_directive_update(); + +-- Drop new triggers +drop trigger cascade_plan_bounds_on_update on merlin.plan; +drop function merlin.cascade_plan_bounds_update(); + +-- Restore Snapshot Creation and Restoration Functions +create or replace procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id integer) + language plpgsql as $$ +declare + _snapshot_name text; + _plan_name text; + _model_id integer; +begin + -- Input Validation + select name from merlin.plan where id = _plan_id into _plan_name; + if _plan_name is null then + raise exception 'Cannot Restore: Plan with ID % does not exist.', _plan_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where snapshot_id = _snapshot_id) then + raise exception 'Cannot Restore: Snapshot with ID % does not exist.', _snapshot_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where _snapshot_id = snapshot_id and _plan_id = plan_id ) then + select snapshot_name from merlin.plan_snapshot where snapshot_id = _snapshot_id into _snapshot_name; + if _snapshot_name is not null then + raise exception 'Cannot Restore: Snapshot ''%'' (ID %) is not a snapshot of Plan ''%'' (ID %)', + _snapshot_name, _snapshot_id, _plan_name, _plan_id; + else + raise exception 'Cannot Restore: Snapshot % is not a snapshot of Plan ''%'' (ID %)', + _snapshot_id, _plan_name, _plan_id; + end if; + end if; + select model_id from merlin.plan_snapshot where snapshot_id = _snapshot_id into _model_id; + if not exists(select from merlin.mission_model m where m.id = _model_id) then + raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; + end if; + + -- Catch Plan_Locked + call merlin.plan_locked_exception(_plan_id); + + -- Update model_id of the plan + update merlin.plan + set model_id = _model_id + where id = _plan_id; + + -- Record the Union of Activities in Plan and Snapshot + -- and note which ones have been added since the Snapshot was taken (in_snapshot = false) + create temp table diff( + activity_id integer, + in_snapshot boolean not null + ); + insert into diff(activity_id, in_snapshot) + select id as activity_id, true + from merlin.plan_snapshot_activities where snapshot_id = _snapshot_id; + + insert into diff (activity_id, in_snapshot) + select activity_id, false + from( + select id as activity_id + from merlin.activity_directive + where plan_id = _plan_id + except + select activity_id + from diff) a; + + -- Remove any added activities + delete from merlin.activity_directive ad + using diff d + where (ad.id, ad.plan_id) = (d.activity_id, _plan_id) + and d.in_snapshot is false; + + -- Upsert the rest + insert into merlin.activity_directive ( + id, plan_id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_at, last_modified_by, + start_offset, type, arguments, last_modified_arguments_at, metadata, + anchor_id, anchored_to_start) + select psa.id, _plan_id, psa.name, psa.source_scheduling_goal_id, psa.source_scheduling_goal_invocation_id, + psa.created_at, psa.created_by, psa.last_modified_at, psa.last_modified_by, + psa.start_offset, psa.type, psa.arguments, psa.last_modified_arguments_at, psa.metadata, + psa.anchor_id, psa.anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = _snapshot_id + on conflict (id, plan_id) do update + -- 'last_modified_at' and 'last_modified_arguments_at' are skipped during update, as triggers will overwrite them to now() + set name = excluded.name, + source_scheduling_goal_id = excluded.source_scheduling_goal_id, + source_scheduling_goal_invocation_id = excluded.source_scheduling_goal_invocation_id, + created_at = excluded.created_at, + created_by = excluded.created_by, + last_modified_by = excluded.last_modified_by, + start_offset = excluded.start_offset, + type = excluded.type, + arguments = excluded.arguments, + metadata = excluded.metadata, + anchor_id = excluded.anchor_id, + anchored_to_start = excluded.anchored_to_start; + + -- Tags + delete from tags.activity_directive_tags adt + using diff d + where (adt.directive_id, adt.plan_id) = (d.activity_id, _plan_id); + + insert into tags.activity_directive_tags(directive_id, plan_id, tag_id) + select sat.directive_id, _plan_id, sat.tag_id + from tags.snapshot_activity_tags sat + where sat.snapshot_id = _snapshot_id + on conflict (directive_id, plan_id, tag_id) do nothing; + + -- Presets + delete from merlin.preset_to_directive + where plan_id = _plan_id; + insert into merlin.preset_to_directive(preset_id, activity_id, plan_id) + select pts.preset_id, pts.activity_id, _plan_id + from merlin.preset_to_snapshot_directive pts + where pts.snapshot_id = _snapshot_id + on conflict (activity_id, plan_id) + do update set preset_id = excluded.preset_id; + + -- Clean up + drop table diff; +end +$$; + +create or replace function merlin.create_snapshot(_plan_id integer, _snapshot_name text, _description text, _user text) + returns integer -- snapshot id inserted into the table + language plpgsql as $$ +declare + validate_plan_id integer; + inserted_snapshot_id integer; +begin + select id from merlin.plan where plan.id = _plan_id into validate_plan_id; + if validate_plan_id is null then + raise exception 'Plan % does not exist.', _plan_id; + end if; + + insert into merlin.plan_snapshot(plan_id, model_id, revision, snapshot_name, description, taken_by) + select id, model_id, revision, _snapshot_name, _description, _user + from merlin.plan where id = _plan_id + returning snapshot_id into inserted_snapshot_id; + insert into merlin.plan_snapshot_activities( + snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start) + select + inserted_snapshot_id, -- this is the snapshot id + id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, -- these are the rest of the data for an activity row + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start + from merlin.activity_directive where activity_directive.plan_id = _plan_id; + insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) + select ptd.preset_id, ptd.activity_id, inserted_snapshot_id + from merlin.preset_to_directive ptd + where ptd.plan_id = _plan_id; + insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) + select inserted_snapshot_id, directive_id, tag_id + from tags.activity_directive_tags adt + where adt.plan_id = _plan_id; + + --all snapshots in plan_latest_snapshot for plan plan_id become the parent of the current snapshot + insert into merlin.plan_snapshot_parent(snapshot_id, parent_snapshot_id) + select inserted_snapshot_id, snapshot_id + from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + + --remove all of those entries from plan_latest_snapshot and add this new snapshot. + delete from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + insert into merlin.plan_latest_snapshot(plan_id, snapshot_id) values (_plan_id, inserted_snapshot_id); + + return inserted_snapshot_id; +end; +$$; + +-- Drop new columns +alter table merlin.plan_snapshot + drop column plan_start_time, + drop column plan_duration; + +call migrations.mark_migration_rolled_back(35); diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql new file mode 100644 index 0000000000..95d78b8e71 --- /dev/null +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql @@ -0,0 +1,650 @@ +-- Update Plan Snapshot to include plan bounds +alter table merlin.plan_snapshot +add column plan_start_time timestamptz, +add column plan_duration interval; + +comment on column merlin.plan_snapshot.plan_start_time is e'' + 'The start time of the plan at the time the snapshot was taken.'; +comment on column merlin.plan_snapshot.plan_duration is e'' + 'The duration of the plan at the time the snapshot was taken.'; + +-- Data Migration: fill in default info for these columns +update merlin.plan_snapshot +set plan_start_time = p.start_time, + plan_duration = p.duration +from merlin.plan p +where p.id = plan_id; + +-- Add not null argument to new columns +alter table merlin.plan_snapshot +alter column plan_start_time set not null, +alter column plan_duration set not null; + +-- Update Create and Restore snapshot functions +create or replace function merlin.create_snapshot(_plan_id integer, _snapshot_name text, _description text, _user text) + returns integer -- snapshot id inserted into the table + language plpgsql as $$ +declare + validate_plan_id integer; + inserted_snapshot_id integer; +begin + select id from merlin.plan where plan.id = _plan_id into validate_plan_id; + if validate_plan_id is null then + raise exception 'Plan % does not exist.', _plan_id; + end if; + + insert into merlin.plan_snapshot(plan_id, model_id, revision, plan_start_time, plan_duration, + snapshot_name, description, taken_by) + select id, model_id, revision, start_time, duration, + _snapshot_name, _description, _user + from merlin.plan where id = _plan_id + returning snapshot_id into inserted_snapshot_id; + + insert into merlin.plan_snapshot_activities( + snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start) + select + inserted_snapshot_id, -- this is the snapshot id + id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, -- these are the rest of the data for an activity row + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start + from merlin.activity_directive where activity_directive.plan_id = _plan_id; + + insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) + select ptd.preset_id, ptd.activity_id, inserted_snapshot_id + from merlin.preset_to_directive ptd + where ptd.plan_id = _plan_id; + + insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) + select inserted_snapshot_id, directive_id, tag_id + from tags.activity_directive_tags adt + where adt.plan_id = _plan_id; + + --all snapshots in plan_latest_snapshot for plan plan_id become the parent of the current snapshot + insert into merlin.plan_snapshot_parent(snapshot_id, parent_snapshot_id) + select inserted_snapshot_id, snapshot_id + from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + + --remove all of those entries from plan_latest_snapshot and add this new snapshot. + delete from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + insert into merlin.plan_latest_snapshot(plan_id, snapshot_id) values (_plan_id, inserted_snapshot_id); + + return inserted_snapshot_id; +end; +$$; + +create or replace procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id integer) + language plpgsql as $$ +declare + _snapshot_name text; + _plan_name text; + _model_id integer; + _plan_start_time timestamptz; + _plan_duration interval; +begin + -- Input Validation + select name from merlin.plan where id = _plan_id into _plan_name; + if _plan_name is null then + raise exception 'Cannot Restore: Plan with ID % does not exist.', _plan_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where snapshot_id = _snapshot_id) then + raise exception 'Cannot Restore: Snapshot with ID % does not exist.', _snapshot_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where _snapshot_id = snapshot_id and _plan_id = plan_id ) then + select snapshot_name from merlin.plan_snapshot where snapshot_id = _snapshot_id into _snapshot_name; + if _snapshot_name is not null then + raise exception 'Cannot Restore: Snapshot ''%'' (ID %) is not a snapshot of Plan ''%'' (ID %)', + _snapshot_name, _snapshot_id, _plan_name, _plan_id; + else + raise exception 'Cannot Restore: Snapshot % is not a snapshot of Plan ''%'' (ID %)', + _snapshot_id, _plan_name, _plan_id; + end if; + end if; + + select model_id, plan_start_time, plan_duration + from merlin.plan_snapshot + where snapshot_id = _snapshot_id + into _model_id, _plan_start_time, _plan_duration; + if not exists(select from merlin.mission_model m where m.id = _model_id) then + raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; + end if; + + -- Catch Plan_Locked + call merlin.plan_locked_exception(_plan_id); + + -- Update model_id and bounds of the plan + update merlin.plan + set model_id = _model_id, + start_time = _plan_start_time, + duration = _plan_duration + where id = _plan_id; + + -- Record the Union of Activities in Plan and Snapshot + -- and note which ones have been added since the Snapshot was taken (in_snapshot = false) + create temp table diff( + activity_id integer, + in_snapshot boolean not null + ); + insert into diff(activity_id, in_snapshot) + select id as activity_id, true + from merlin.plan_snapshot_activities where snapshot_id = _snapshot_id; + + insert into diff (activity_id, in_snapshot) + select activity_id, false + from( + select id as activity_id + from merlin.activity_directive + where plan_id = _plan_id + except + select activity_id + from diff) a; + + -- Remove any added activities + delete from merlin.activity_directive ad + using diff d + where (ad.id, ad.plan_id) = (d.activity_id, _plan_id) + and d.in_snapshot is false; + + -- Upsert the rest + insert into merlin.activity_directive ( + id, plan_id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_at, last_modified_by, + start_offset, type, arguments, last_modified_arguments_at, metadata, + anchor_id, anchored_to_start) + select psa.id, _plan_id, psa.name, psa.source_scheduling_goal_id, psa.source_scheduling_goal_invocation_id, + psa.created_at, psa.created_by, psa.last_modified_at, psa.last_modified_by, + psa.start_offset, psa.type, psa.arguments, psa.last_modified_arguments_at, psa.metadata, + psa.anchor_id, psa.anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = _snapshot_id + on conflict (id, plan_id) do update + -- 'last_modified_at' and 'last_modified_arguments_at' are skipped during update, as triggers will overwrite them to now() + set name = excluded.name, + source_scheduling_goal_id = excluded.source_scheduling_goal_id, + source_scheduling_goal_invocation_id = excluded.source_scheduling_goal_invocation_id, + created_at = excluded.created_at, + created_by = excluded.created_by, + last_modified_by = excluded.last_modified_by, + start_offset = excluded.start_offset, + type = excluded.type, + arguments = excluded.arguments, + metadata = excluded.metadata, + anchor_id = excluded.anchor_id, + anchored_to_start = excluded.anchored_to_start; + + -- Tags + delete from tags.activity_directive_tags adt + using diff d + where (adt.directive_id, adt.plan_id) = (d.activity_id, _plan_id); + + insert into tags.activity_directive_tags(directive_id, plan_id, tag_id) + select sat.directive_id, _plan_id, sat.tag_id + from tags.snapshot_activity_tags sat + where sat.snapshot_id = _snapshot_id + on conflict (directive_id, plan_id, tag_id) do nothing; + + -- Presets + delete from merlin.preset_to_directive + where plan_id = _plan_id; + insert into merlin.preset_to_directive(preset_id, activity_id, plan_id) + select pts.preset_id, pts.activity_id, _plan_id + from merlin.preset_to_snapshot_directive pts + where pts.snapshot_id = _snapshot_id + on conflict (activity_id, plan_id) + do update set preset_id = excluded.preset_id; + + -- Clean up + drop table diff; +end +$$; + +-- Create trigger to create snapshot/cascade plan bounds changes +create function merlin.cascade_plan_bounds_update() + returns trigger + language plpgsql as $$ +begin + -- prevent adjustment if the plan is locked + if old.is_locked then + raise exception 'Cannot adjust bounds of locked plan.'; + end if; + + -- Take a backup snapshot + perform merlin.create_snapshot( + old.id, + 'Plan Bound Adjustment', + 'Automatic snapshot made before adjusting plan bounds from ' || + '['|| old.start_time ||' - '|| old.start_time + old.duration || '] to ' || + '[' || new.start_time || ' - ' || new.start_time + new.duration || ']', + null); + + -- Update activities that are anchored to the plan bounds + update merlin.activity_directive + set start_offset = start_offset + (new.start_time - old.start_time) + where anchor_id is null and anchored_to_start; -- anchored to plan start + + update merlin.activity_directive + set start_offset = start_offset + (new.duration - old.duration) + where anchor_id is null and not anchored_to_start; -- anchored to plan end + + -- Update associated dataset offsets (simulation and plan) + update merlin.simulation_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_offset - old.start_offset) + from merlin.simulation sim_spec + where simulation_id = sim_spec.id + and sim_spec.plan_id = old.id; + + update merlin.plan_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_offset - old.start_offset) + where plan_id = old.id; +end; +$$; + +create trigger cascade_plan_bounds_on_update + before update on merlin.plan + for each row + when (old.start_time is distinct from new.start_time or old.duration is distinct from new.duration) +execute function merlin.cascade_plan_bounds_update(); + +-- Prevent "Update Plan Revision on Directive Change" from firing during other triggers +create or replace trigger increment_plan_revision_on_directive_update_trigger + after update on merlin.activity_directive + for each row + when (pg_trigger_depth() < 1) +execute function merlin.increment_plan_revision_on_directive_update(); + +-- Update Plan Merge Functions to block merging plans with different bounds +create or replace function merlin.create_merge_request(plan_id_supplying integer, plan_id_receiving integer, request_username text) + returns integer + language plpgsql as $$ +declare + merge_base_snapshot_id integer; + validate_planIds integer; + supplying_snapshot_id integer; + merge_request_id integer; + model_id_receiving integer; + model_id_supplying integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; +begin + if plan_id_receiving = plan_id_supplying then + raise exception 'Cannot create a merge request between a plan and itself.'; + end if; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into validate_planIds, model_id_receiving, start_time_receiving, duration_receiving; + if validate_planIds is null then + raise exception 'Plan receiving changes (Plan %) does not exist.', plan_id_receiving; + end if; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_supplying + into validate_planIds, model_id_supplying, start_time_supplying, duration_supplying; + if validate_planIds is null then + raise exception 'Plan supplying changes (Plan %) does not exist.', plan_id_supplying; + end if; + + select merlin.create_snapshot(plan_id_supplying) into supplying_snapshot_id; + + select merlin.get_merge_base(plan_id_receiving, supplying_snapshot_id) into merge_base_snapshot_id; + if merge_base_snapshot_id is null then + raise exception 'Cannot create merge request between unrelated plans.'; + end if; + + if model_id_receiving is distinct from model_id_supplying then + raise exception 'Cannot create merge request: plan supplying changes is using a different model (%) than the receiving plan (%)', model_id_supplying, model_id_receiving; + end if; + + if (start_time_receiving is distinct from start_time_supplying) or + (duration_receiving is distinct from duration_supplying) then + raise exception 'Cannot create merge request between plans with different bounds'; + end if; + + insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) + values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) + returning id into merge_request_id; + return merge_request_id; +end +$$; + +create or replace procedure merlin.begin_merge(_merge_request_id integer, review_username text) + language plpgsql as $$ +declare + validate_id integer; + validate_status merlin.merge_request_status; + validate_non_no_op_status merlin.activity_change_type; + snapshot_id_supplying integer; + plan_id_receiving integer; + merge_base_id integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; +begin + -- validate id and status + select id, status + from merlin.merge_request + where _merge_request_id = id + into validate_id, validate_status; + + if validate_id is null then + raise exception 'Request ID % is not present in merge_request table.', _merge_request_id; + end if; + + if validate_status != 'pending' then + raise exception 'Cannot begin request. Merge request % is not in pending state.', _merge_request_id; + end if; + + -- select from merge-request the snapshot_sc (s_sc) and plan_rc (p_rc) ids + select plan_id_receiving_changes, snapshot_id_supplying_changes + from merlin.merge_request + where id = _merge_request_id + into plan_id_receiving, snapshot_id_supplying; + + -- ensure that the plans cover the same boundaries + select start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into start_time_receiving, duration_receiving; + + select plan_start_time, plan_duration + from merlin.plan_snapshot ps + where ps.snapshot_id = snapshot_id_supplying + into start_time_supplying, duration_supplying; + + if start_time_receiving is distinct from start_time_supplying or + duration_receiving is distinct from duration_supplying then + raise exception 'Cannot begin merge request between plans with different bounds'; + end if; + + -- ensure the plan receiving changes isn't locked + if (select is_locked from merlin.plan where plan.id=plan_id_receiving) then + raise exception 'Cannot begin merge request. Plan to receive changes is locked.'; + end if; + + -- lock plan_rc + update merlin.plan + set is_locked = true + where plan.id = plan_id_receiving; + + -- get merge base (mb) + select merlin.get_merge_base(plan_id_receiving, snapshot_id_supplying) + into merge_base_id; + + -- update the status to "in progress" + update merlin.merge_request + set status = 'in-progress', + merge_base_snapshot_id = merge_base_id, + reviewer_username = review_username + where id = _merge_request_id; + + -- perform diff between mb and s_sc (s_diff) + -- delete is B minus A on key + -- add is A minus B on key + -- A intersect B is no op + -- A minus B on everything except everything currently in the table is modify + create temp table supplying_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'none' + from( + select psa.id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, snapshot_id_supplying), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'modify' + from( + select id as activity_id from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id or snapshot_id = snapshot_id_supplying + except + select activity_id from supplying_diff) a; + + -- perform diff between mb and p_rc (r_diff) + create temp table receiving_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'none' + from( + select id as activity_id, name, tags.tag_ids_activity_snapshot(id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_directive(id, plan_id_receiving), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.activity_directive ad + where ad.plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'modify' + from ( + (select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + union + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) + except + select activity_id + from receiving_diff) a; + + + -- perform diff between s_diff and r_diff + -- upload the non-conflicts into merge_staging_area + -- upload conflict into conflicting_activities + create temp table diff_diff( + activity_id integer, + change_type_supplying merlin.activity_change_type not null, + change_type_receiving merlin.activity_change_type not null + ); + + -- this is going to require us to do the "none" operation again on the remaining modifies + -- but otherwise we can just dump the 'adds' and 'none' into the merge staging area table + + -- 'delete' against a 'delete' does not enter the merge staging area table + -- receiving 'delete' against supplying 'none' does not enter the merge staging area table + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- 'adds' can go directly into the merge staging area table + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(s_diff.activity_id, psa.snapshot_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + from supplying_diff as s_diff + join merlin.plan_snapshot_activities psa + on s_diff.activity_id = psa.id + where snapshot_id = snapshot_id_supplying and change_type = 'add' + union + -- an 'add' between the receiving plan and merge base is actually a 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(r_diff.activity_id, ad.plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none'::merlin.activity_change_type + from receiving_diff as r_diff + join merlin.activity_directive ad + on r_diff.activity_id = ad.id + where plan_id = plan_id_receiving and change_type = 'add'; + + -- put the rest in diff_diff + insert into diff_diff (activity_id, change_type_supplying, change_type_receiving) + select activity_id, supplying_diff.change_type as change_type_supplying, receiving_diff.change_type as change_type_receiving + from receiving_diff + join supplying_diff using (activity_id) + where receiving_diff.change_type != 'add' or supplying_diff.change_type != 'add'; + + -- ...except for that which is not recorded + delete from diff_diff + where (change_type_receiving = 'delete' and change_type_supplying = 'delete') + or (change_type_receiving = 'delete' and change_type_supplying = 'none'); + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- receiving 'none' and 'modify' against 'none' in the supplying side go into the merge staging area as 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none' + from diff_diff + join merlin.activity_directive + on activity_id=id + where plan_id = plan_id_receiving + and change_type_supplying = 'none' + and (change_type_receiving = 'modify' or change_type_receiving = 'none') + union + -- supplying 'modify' against receiving 'none' go into the merge staging area as 'modify' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(diff_diff.activity_id, snapshot_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.plan_snapshot_activities p + on diff_diff.activity_id = p.id + where snapshot_id = snapshot_id_supplying + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'modify') + union + -- supplying 'delete' against receiving 'none' go into the merge staging area as 'delete' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.activity_directive p + on diff_diff.activity_id = p.id + where plan_id = plan_id_receiving + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'delete'); + + -- 'modify' against a 'modify' must be checked for equality first. + with false_modify as ( + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, psa.snapshot_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + join diff_diff dd + on dd.activity_id = psa.id + where psa.snapshot_id = snapshot_id_supplying + and (dd.change_type_receiving = 'modify' and dd.change_type_supplying = 'modify') + intersect + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, ad.plan_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from diff_diff dd + join merlin.activity_directive ad + on dd.activity_id = ad.id + where ad.plan_id = plan_id_receiving + and (dd.change_type_supplying = 'modify' and dd.change_type_receiving = 'modify')) + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type) + select _merge_request_id, ad.id, ad.name, tags, ad.source_scheduling_goal_id, ad.source_scheduling_goal_invocation_id, + ad.created_at, ad.created_by, ad.last_modified_by, ad.start_offset, ad.type, ad.arguments, ad.metadata, + ad.anchor_id, ad.anchored_to_start, 'none' + from false_modify fm + left join merlin.activity_directive ad + on (ad.plan_id, ad.id) = (plan_id_receiving, fm.activity_id); + + -- 'modify' against 'delete' and inequal 'modify' against 'modify' goes into conflict table (aka everything left in diff_diff) + insert into merlin.conflicting_activities (merge_request_id, activity_id, change_type_supplying, change_type_receiving) + select begin_merge._merge_request_id, activity_id, change_type_supplying, change_type_receiving + from (select begin_merge._merge_request_id, activity_id + from diff_diff + except + select msa.merge_request_id, activity_id + from merlin.merge_staging_area msa) a + join diff_diff using (activity_id); + + -- Fail if there are no differences between the snapshot and the plan getting merged + validate_non_no_op_status := null; + select change_type_receiving + from merlin.conflicting_activities + where merge_request_id = _merge_request_id + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + select change_type + from merlin.merge_staging_area msa + where merge_request_id = _merge_request_id + and msa.change_type != 'none' + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + raise exception 'Cannot begin merge. The contents of the two plans are identical.'; + end if; + end if; + + + -- clean up + drop table supplying_diff; + drop table receiving_diff; + drop table diff_diff; +end +$$; + +call migrations.mark_migration_applied(35); diff --git a/deployment/postgres-init-db/sql/applied_migrations.sql b/deployment/postgres-init-db/sql/applied_migrations.sql index e5c6fba97f..1045aa9e00 100644 --- a/deployment/postgres-init-db/sql/applied_migrations.sql +++ b/deployment/postgres-init-db/sql/applied_migrations.sql @@ -36,3 +36,4 @@ call migrations.mark_migration_applied(31); call migrations.mark_migration_applied(32); call migrations.mark_migration_applied(33); call migrations.mark_migration_applied(34); +call migrations.mark_migration_applied(35); From 08fd41e28768ea3fdb386bfd8ec2d59bced4026c Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 29 May 2026 10:22:57 -0400 Subject: [PATCH 03/11] Remove snapshot name uniqueness constraint - This constraint is no longer necessary now that we have snapshot description to disambiguate identically-named snapshots --- .../sql/tables/merlin/snapshot/plan_snapshot.sql | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql b/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql index 41ed03418e..75cb237dfd 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql @@ -18,9 +18,7 @@ create table merlin.plan_snapshot( snapshot_name text, description text, taken_by text, - taken_at timestamptz not null default now(), - constraint snapshot_name_unique_per_plan - unique (plan_id, snapshot_name) + taken_at timestamptz not null default now() ); comment on table merlin.plan_snapshot is e'' From 27c2ef2f39af71589b7023aedf0f8195e9d37f49 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 29 May 2026 10:41:29 -0400 Subject: [PATCH 04/11] DB Migration for snapshot name uniqueness drop --- .../Aerie/35_change_plan_bounds/down.sql | 41 +++++++++++++++++++ .../Aerie/35_change_plan_bounds/up.sql | 4 ++ 2 files changed, 45 insertions(+) diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql index 2fb7d257fc..708723413c 100644 --- a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql @@ -539,4 +539,45 @@ alter table merlin.plan_snapshot drop column plan_start_time, drop column plan_duration; +-- Data Migration: Prepare to restore snapshot name uniqueness constraint +-- First, add 'at TIMESTAMP' to any duplicate names +update merlin.plan_snapshot ps +set snapshot_name = snapshot_name || ' at ' || taken_at +from ( + select + snapshot_id, + row_number() over (partition by (snapshot_name, plan_id)) - 1 as row + from merlin.plan_snapshot +) as ir +where ps.snapshot_id = ir.snapshot_id +and ir.row > 0; + +-- Then, deduplicate any duplicate names +do $$ + begin + -- While there are duplicate names in the snapshots table... + while exists( + select from merlin.plan_snapshot + group by snapshot_name, plan_id + having count(snapshot_name) > 1 + ) loop + -- ...deduplicate them + update merlin.plan_snapshot ps + set snapshot_name = snapshot_name || '(' || ir.row || ')' + from ( + select snapshot_id, + row_number() over (partition by snapshot_name, plan_id) - 1 as row + from merlin.plan_snapshot + ) as ir + where ps.snapshot_id = ir.snapshot_id + and ir.row > 0; + end loop; + end +$$; + +-- Restore uniqueness constraint +alter table merlin.plan_snapshot +add constraint snapshot_name_unique_per_plan + unique (plan_id, snapshot_name); + call migrations.mark_migration_rolled_back(35); diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql index 95d78b8e71..0957aa5f15 100644 --- a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql @@ -1,3 +1,7 @@ +-- Drop snapshot name uniqueness constraint +alter table merlin.plan_snapshot + drop constraint snapshot_name_unique_per_plan; + -- Update Plan Snapshot to include plan bounds alter table merlin.plan_snapshot add column plan_start_time timestamptz, From 8cbe008367ee15fff81968aedef61638d268beab Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 22 May 2026 09:16:32 -0400 Subject: [PATCH 05/11] Update Hasura Metadata to permit Plan Bounds modification --- deployment/hasura/metadata/databases/tables/merlin/plan.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/hasura/metadata/databases/tables/merlin/plan.yaml b/deployment/hasura/metadata/databases/tables/merlin/plan.yaml index a29f047350..8b78d91c46 100644 --- a/deployment/hasura/metadata/databases/tables/merlin/plan.yaml +++ b/deployment/hasura/metadata/databases/tables/merlin/plan.yaml @@ -107,7 +107,7 @@ update_permissions: updated_by: "x-hasura-user-id" - role: user permission: - columns: [name, owner, description] + columns: [name, owner, duration, description, start_time] filter: {"owner":{"_eq":"X-Hasura-User-Id"}} set: updated_by: "x-hasura-user-id" From cb151bced65436d4b80b18ff3328d932af8f5c3e Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Mon, 1 Jun 2026 13:14:21 -0400 Subject: [PATCH 06/11] Fix whitespace in migration --- .../Aerie/35_change_plan_bounds/up.sql | 536 +++++++++--------- 1 file changed, 271 insertions(+), 265 deletions(-) diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql index 0957aa5f15..65ea1e2de9 100644 --- a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql @@ -28,9 +28,9 @@ alter column plan_duration set not null; create or replace function merlin.create_snapshot(_plan_id integer, _snapshot_name text, _description text, _user text) returns integer -- snapshot id inserted into the table language plpgsql as $$ -declare - validate_plan_id integer; - inserted_snapshot_id integer; + declare + validate_plan_id integer; + inserted_snapshot_id integer; begin select id from merlin.plan where plan.id = _plan_id into validate_plan_id; if validate_plan_id is null then @@ -39,168 +39,168 @@ begin insert into merlin.plan_snapshot(plan_id, model_id, revision, plan_start_time, plan_duration, snapshot_name, description, taken_by) - select id, model_id, revision, start_time, duration, - _snapshot_name, _description, _user - from merlin.plan where id = _plan_id - returning snapshot_id into inserted_snapshot_id; + select id, model_id, revision, start_time, duration, + _snapshot_name, _description, _user + from merlin.plan where id = _plan_id + returning snapshot_id into inserted_snapshot_id; insert into merlin.plan_snapshot_activities( - snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, - last_modified_at, last_modified_by, start_offset, type, - arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start) - select - inserted_snapshot_id, -- this is the snapshot id - id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, -- these are the rest of the data for an activity row - last_modified_at, last_modified_by, start_offset, type, - arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start - from merlin.activity_directive where activity_directive.plan_id = _plan_id; + snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start) + select + inserted_snapshot_id, -- this is the snapshot id + id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, -- these are the rest of the data for an activity row + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start + from merlin.activity_directive where activity_directive.plan_id = _plan_id; insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) - select ptd.preset_id, ptd.activity_id, inserted_snapshot_id - from merlin.preset_to_directive ptd - where ptd.plan_id = _plan_id; + select ptd.preset_id, ptd.activity_id, inserted_snapshot_id + from merlin.preset_to_directive ptd + where ptd.plan_id = _plan_id; insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) - select inserted_snapshot_id, directive_id, tag_id - from tags.activity_directive_tags adt - where adt.plan_id = _plan_id; + select inserted_snapshot_id, directive_id, tag_id + from tags.activity_directive_tags adt + where adt.plan_id = _plan_id; --all snapshots in plan_latest_snapshot for plan plan_id become the parent of the current snapshot insert into merlin.plan_snapshot_parent(snapshot_id, parent_snapshot_id) - select inserted_snapshot_id, snapshot_id - from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + select inserted_snapshot_id, snapshot_id + from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; --remove all of those entries from plan_latest_snapshot and add this new snapshot. delete from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; insert into merlin.plan_latest_snapshot(plan_id, snapshot_id) values (_plan_id, inserted_snapshot_id); return inserted_snapshot_id; -end; + end; $$; create or replace procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id integer) - language plpgsql as $$ -declare - _snapshot_name text; - _plan_name text; - _model_id integer; - _plan_start_time timestamptz; - _plan_duration interval; -begin - -- Input Validation - select name from merlin.plan where id = _plan_id into _plan_name; - if _plan_name is null then - raise exception 'Cannot Restore: Plan with ID % does not exist.', _plan_id; - end if; - if not exists(select snapshot_id from merlin.plan_snapshot where snapshot_id = _snapshot_id) then - raise exception 'Cannot Restore: Snapshot with ID % does not exist.', _snapshot_id; - end if; - if not exists(select snapshot_id from merlin.plan_snapshot where _snapshot_id = snapshot_id and _plan_id = plan_id ) then - select snapshot_name from merlin.plan_snapshot where snapshot_id = _snapshot_id into _snapshot_name; - if _snapshot_name is not null then - raise exception 'Cannot Restore: Snapshot ''%'' (ID %) is not a snapshot of Plan ''%'' (ID %)', - _snapshot_name, _snapshot_id, _plan_name, _plan_id; - else - raise exception 'Cannot Restore: Snapshot % is not a snapshot of Plan ''%'' (ID %)', - _snapshot_id, _plan_name, _plan_id; + language plpgsql as $$ + declare + _snapshot_name text; + _plan_name text; + _model_id integer; + _plan_start_time timestamptz; + _plan_duration interval; + begin + -- Input Validation + select name from merlin.plan where id = _plan_id into _plan_name; + if _plan_name is null then + raise exception 'Cannot Restore: Plan with ID % does not exist.', _plan_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where snapshot_id = _snapshot_id) then + raise exception 'Cannot Restore: Snapshot with ID % does not exist.', _snapshot_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where _snapshot_id = snapshot_id and _plan_id = plan_id ) then + select snapshot_name from merlin.plan_snapshot where snapshot_id = _snapshot_id into _snapshot_name; + if _snapshot_name is not null then + raise exception 'Cannot Restore: Snapshot ''%'' (ID %) is not a snapshot of Plan ''%'' (ID %)', + _snapshot_name, _snapshot_id, _plan_name, _plan_id; + else + raise exception 'Cannot Restore: Snapshot % is not a snapshot of Plan ''%'' (ID %)', + _snapshot_id, _plan_name, _plan_id; + end if; end if; - end if; - - select model_id, plan_start_time, plan_duration - from merlin.plan_snapshot - where snapshot_id = _snapshot_id - into _model_id, _plan_start_time, _plan_duration; - if not exists(select from merlin.mission_model m where m.id = _model_id) then - raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; - end if; - - -- Catch Plan_Locked - call merlin.plan_locked_exception(_plan_id); - - -- Update model_id and bounds of the plan - update merlin.plan - set model_id = _model_id, - start_time = _plan_start_time, - duration = _plan_duration - where id = _plan_id; - - -- Record the Union of Activities in Plan and Snapshot - -- and note which ones have been added since the Snapshot was taken (in_snapshot = false) - create temp table diff( - activity_id integer, - in_snapshot boolean not null - ); - insert into diff(activity_id, in_snapshot) - select id as activity_id, true - from merlin.plan_snapshot_activities where snapshot_id = _snapshot_id; - insert into diff (activity_id, in_snapshot) - select activity_id, false - from( - select id as activity_id - from merlin.activity_directive - where plan_id = _plan_id - except - select activity_id - from diff) a; + select model_id, plan_start_time, plan_duration + from merlin.plan_snapshot + where snapshot_id = _snapshot_id + into _model_id, _plan_start_time, _plan_duration; + if not exists(select from merlin.mission_model m where m.id = _model_id) then + raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; + end if; - -- Remove any added activities + -- Catch Plan_Locked + call merlin.plan_locked_exception(_plan_id); + + -- Update model_id and bounds of the plan + update merlin.plan + set model_id = _model_id, + start_time = _plan_start_time, + duration = _plan_duration + where id = _plan_id; + + -- Record the Union of Activities in Plan and Snapshot + -- and note which ones have been added since the Snapshot was taken (in_snapshot = false) + create temp table diff( + activity_id integer, + in_snapshot boolean not null + ); + insert into diff(activity_id, in_snapshot) + select id as activity_id, true + from merlin.plan_snapshot_activities where snapshot_id = _snapshot_id; + + insert into diff (activity_id, in_snapshot) + select activity_id, false + from( + select id as activity_id + from merlin.activity_directive + where plan_id = _plan_id + except + select activity_id + from diff) a; + + -- Remove any added activities delete from merlin.activity_directive ad - using diff d - where (ad.id, ad.plan_id) = (d.activity_id, _plan_id) - and d.in_snapshot is false; - - -- Upsert the rest - insert into merlin.activity_directive ( - id, plan_id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, - created_at, created_by, last_modified_at, last_modified_by, - start_offset, type, arguments, last_modified_arguments_at, metadata, - anchor_id, anchored_to_start) - select psa.id, _plan_id, psa.name, psa.source_scheduling_goal_id, psa.source_scheduling_goal_invocation_id, - psa.created_at, psa.created_by, psa.last_modified_at, psa.last_modified_by, - psa.start_offset, psa.type, psa.arguments, psa.last_modified_arguments_at, psa.metadata, - psa.anchor_id, psa.anchored_to_start - from merlin.plan_snapshot_activities psa - where psa.snapshot_id = _snapshot_id - on conflict (id, plan_id) do update - -- 'last_modified_at' and 'last_modified_arguments_at' are skipped during update, as triggers will overwrite them to now() - set name = excluded.name, - source_scheduling_goal_id = excluded.source_scheduling_goal_id, - source_scheduling_goal_invocation_id = excluded.source_scheduling_goal_invocation_id, - created_at = excluded.created_at, - created_by = excluded.created_by, - last_modified_by = excluded.last_modified_by, - start_offset = excluded.start_offset, - type = excluded.type, - arguments = excluded.arguments, - metadata = excluded.metadata, - anchor_id = excluded.anchor_id, - anchored_to_start = excluded.anchored_to_start; - - -- Tags - delete from tags.activity_directive_tags adt - using diff d - where (adt.directive_id, adt.plan_id) = (d.activity_id, _plan_id); - - insert into tags.activity_directive_tags(directive_id, plan_id, tag_id) - select sat.directive_id, _plan_id, sat.tag_id - from tags.snapshot_activity_tags sat - where sat.snapshot_id = _snapshot_id - on conflict (directive_id, plan_id, tag_id) do nothing; - - -- Presets - delete from merlin.preset_to_directive - where plan_id = _plan_id; - insert into merlin.preset_to_directive(preset_id, activity_id, plan_id) - select pts.preset_id, pts.activity_id, _plan_id - from merlin.preset_to_snapshot_directive pts - where pts.snapshot_id = _snapshot_id - on conflict (activity_id, plan_id) - do update set preset_id = excluded.preset_id; - - -- Clean up - drop table diff; -end + using diff d + where (ad.id, ad.plan_id) = (d.activity_id, _plan_id) + and d.in_snapshot is false; + + -- Upsert the rest + insert into merlin.activity_directive ( + id, plan_id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_at, last_modified_by, + start_offset, type, arguments, last_modified_arguments_at, metadata, + anchor_id, anchored_to_start) + select psa.id, _plan_id, psa.name, psa.source_scheduling_goal_id, psa.source_scheduling_goal_invocation_id, + psa.created_at, psa.created_by, psa.last_modified_at, psa.last_modified_by, + psa.start_offset, psa.type, psa.arguments, psa.last_modified_arguments_at, psa.metadata, + psa.anchor_id, psa.anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = _snapshot_id + on conflict (id, plan_id) do update + -- 'last_modified_at' and 'last_modified_arguments_at' are skipped during update, as triggers will overwrite them to now() + set name = excluded.name, + source_scheduling_goal_id = excluded.source_scheduling_goal_id, + source_scheduling_goal_invocation_id = excluded.source_scheduling_goal_invocation_id, + created_at = excluded.created_at, + created_by = excluded.created_by, + last_modified_by = excluded.last_modified_by, + start_offset = excluded.start_offset, + type = excluded.type, + arguments = excluded.arguments, + metadata = excluded.metadata, + anchor_id = excluded.anchor_id, + anchored_to_start = excluded.anchored_to_start; + + -- Tags + delete from tags.activity_directive_tags adt + using diff d + where (adt.directive_id, adt.plan_id) = (d.activity_id, _plan_id); + + insert into tags.activity_directive_tags(directive_id, plan_id, tag_id) + select sat.directive_id, _plan_id, sat.tag_id + from tags.snapshot_activity_tags sat + where sat.snapshot_id = _snapshot_id + on conflict (directive_id, plan_id, tag_id) do nothing; + + -- Presets + delete from merlin.preset_to_directive + where plan_id = _plan_id; + insert into merlin.preset_to_directive(preset_id, activity_id, plan_id) + select pts.preset_id, pts.activity_id, _plan_id + from merlin.preset_to_snapshot_directive pts + where pts.snapshot_id = _snapshot_id + on conflict (activity_id, plan_id) + do update set preset_id = excluded.preset_id; + + -- Clean up + drop table diff; + end $$; -- Create trigger to create snapshot/cascade plan bounds changes @@ -223,24 +223,30 @@ begin null); -- Update activities that are anchored to the plan bounds - update merlin.activity_directive + update merlin.activity_directive ad set start_offset = start_offset + (new.start_time - old.start_time) - where anchor_id is null and anchored_to_start; -- anchored to plan start + where anchor_id is null + and anchored_to_start -- anchored to plan start + and ad.plan_id = old.id; - update merlin.activity_directive + update merlin.activity_directive ad set start_offset = start_offset + (new.duration - old.duration) - where anchor_id is null and not anchored_to_start; -- anchored to plan end + where anchor_id is null + and not anchored_to_start -- anchored to plan end + and ad.plan_id = old.id; -- Update associated dataset offsets (simulation and plan) update merlin.simulation_dataset - set offset_from_plan_start = offset_from_plan_start + (new.start_offset - old.start_offset) + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) from merlin.simulation sim_spec where simulation_id = sim_spec.id and sim_spec.plan_id = old.id; update merlin.plan_dataset - set offset_from_plan_start = offset_from_plan_start + (new.start_offset - old.start_offset) + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) where plan_id = old.id; + + return new; end; $$; @@ -310,31 +316,31 @@ begin end if; insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) - values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) - returning id into merge_request_id; + values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) + returning id into merge_request_id; return merge_request_id; end $$; create or replace procedure merlin.begin_merge(_merge_request_id integer, review_username text) language plpgsql as $$ -declare - validate_id integer; - validate_status merlin.merge_request_status; - validate_non_no_op_status merlin.activity_change_type; - snapshot_id_supplying integer; - plan_id_receiving integer; - merge_base_id integer; - start_time_receiving timestamptz; - duration_receiving interval; - start_time_supplying timestamptz; - duration_supplying interval; + declare + validate_id integer; + validate_status merlin.merge_request_status; + validate_non_no_op_status merlin.activity_change_type; + snapshot_id_supplying integer; + plan_id_receiving integer; + merge_base_id integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; begin -- validate id and status select id, status - from merlin.merge_request - where _merge_request_id = id - into validate_id, validate_status; + from merlin.merge_request + where _merge_request_id = id + into validate_id, validate_status; if validate_id is null then raise exception 'Request ID % is not present in merge_request table.', _merge_request_id; @@ -346,9 +352,9 @@ begin -- select from merge-request the snapshot_sc (s_sc) and plan_rc (p_rc) ids select plan_id_receiving_changes, snapshot_id_supplying_changes - from merlin.merge_request - where id = _merge_request_id - into plan_id_receiving, snapshot_id_supplying; + from merlin.merge_request + where id = _merge_request_id + into plan_id_receiving, snapshot_id_supplying; -- ensure that the plans cover the same boundaries select start_time, duration @@ -373,79 +379,79 @@ begin -- lock plan_rc update merlin.plan - set is_locked = true - where plan.id = plan_id_receiving; + set is_locked = true + where plan.id = plan_id_receiving; -- get merge base (mb) select merlin.get_merge_base(plan_id_receiving, snapshot_id_supplying) - into merge_base_id; + into merge_base_id; -- update the status to "in progress" update merlin.merge_request - set status = 'in-progress', - merge_base_snapshot_id = merge_base_id, - reviewer_username = review_username - where id = _merge_request_id; + set status = 'in-progress', + merge_base_snapshot_id = merge_base_id, + reviewer_username = review_username + where id = _merge_request_id; -- perform diff between mb and s_sc (s_diff) - -- delete is B minus A on key - -- add is A minus B on key - -- A intersect B is no op - -- A minus B on everything except everything currently in the table is modify + -- delete is B minus A on key + -- add is A minus B on key + -- A intersect B is no op + -- A minus B on everything except everything currently in the table is modify create temp table supplying_diff( - activity_id integer, - change_type merlin.activity_change_type not null + activity_id integer, + change_type merlin.activity_change_type not null ); insert into supplying_diff (activity_id, change_type) select activity_id, 'delete' from( - select id as activity_id - from merlin.plan_snapshot_activities - where snapshot_id = merge_base_id - except - select id as activity_id - from merlin.plan_snapshot_activities - where snapshot_id = snapshot_id_supplying) a; + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying) a; insert into supplying_diff (activity_id, change_type) select activity_id, 'add' from( - select id as activity_id - from merlin.plan_snapshot_activities - where snapshot_id = snapshot_id_supplying - except - select id as activity_id - from merlin.plan_snapshot_activities - where snapshot_id = merge_base_id) a; + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; insert into supplying_diff (activity_id, change_type) - select activity_id, 'none' - from( + select activity_id, 'none' + from( select psa.id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, merge_base_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start from merlin.plan_snapshot_activities psa where psa.snapshot_id = merge_base_id - intersect - select id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, snapshot_id_supplying), - source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, - metadata, anchor_id, anchored_to_start + intersect + select id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, snapshot_id_supplying), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start from merlin.plan_snapshot_activities psa where psa.snapshot_id = snapshot_id_supplying) a; insert into supplying_diff (activity_id, change_type) - select activity_id, 'modify' - from( - select id as activity_id from merlin.plan_snapshot_activities + select activity_id, 'modify' + from( + select id as activity_id from merlin.plan_snapshot_activities where snapshot_id = merge_base_id or snapshot_id = snapshot_id_supplying - except - select activity_id from supplying_diff) a; + except + select activity_id from supplying_diff) a; -- perform diff between mb and p_rc (r_diff) create temp table receiving_diff( - activity_id integer, - change_type merlin.activity_change_type not null + activity_id integer, + change_type merlin.activity_change_type not null ); insert into receiving_diff (activity_id, change_type) @@ -488,25 +494,25 @@ begin insert into receiving_diff (activity_id, change_type) select activity_id, 'modify' from ( - (select id as activity_id - from merlin.plan_snapshot_activities - where snapshot_id = merge_base_id - union - select id as activity_id - from merlin.activity_directive - where plan_id = plan_id_receiving) - except - select activity_id - from receiving_diff) a; + (select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + union + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) + except + select activity_id + from receiving_diff) a; -- perform diff between s_diff and r_diff - -- upload the non-conflicts into merge_staging_area - -- upload conflict into conflicting_activities + -- upload the non-conflicts into merge_staging_area + -- upload conflict into conflicting_activities create temp table diff_diff( - activity_id integer, - change_type_supplying merlin.activity_change_type not null, - change_type_receiving merlin.activity_change_type not null + activity_id integer, + change_type_supplying merlin.activity_change_type not null, + change_type_receiving merlin.activity_change_type not null ); -- this is going to require us to do the "none" operation again on the remaining modifies @@ -519,36 +525,36 @@ begin merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type - ) + ) -- 'adds' can go directly into the merge staging area table select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(s_diff.activity_id, psa.snapshot_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type - from supplying_diff as s_diff - join merlin.plan_snapshot_activities psa - on s_diff.activity_id = psa.id - where snapshot_id = snapshot_id_supplying and change_type = 'add' + from supplying_diff as s_diff + join merlin.plan_snapshot_activities psa + on s_diff.activity_id = psa.id + where snapshot_id = snapshot_id_supplying and change_type = 'add' union -- an 'add' between the receiving plan and merge base is actually a 'none' select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(r_diff.activity_id, ad.plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none'::merlin.activity_change_type - from receiving_diff as r_diff - join merlin.activity_directive ad - on r_diff.activity_id = ad.id - where plan_id = plan_id_receiving and change_type = 'add'; + from receiving_diff as r_diff + join merlin.activity_directive ad + on r_diff.activity_id = ad.id + where plan_id = plan_id_receiving and change_type = 'add'; -- put the rest in diff_diff insert into diff_diff (activity_id, change_type_supplying, change_type_receiving) select activity_id, supplying_diff.change_type as change_type_supplying, receiving_diff.change_type as change_type_receiving - from receiving_diff - join supplying_diff using (activity_id) + from receiving_diff + join supplying_diff using (activity_id) where receiving_diff.change_type != 'add' or supplying_diff.change_type != 'add'; -- ...except for that which is not recorded delete from diff_diff - where (change_type_receiving = 'delete' and change_type_supplying = 'delete') - or (change_type_receiving = 'delete' and change_type_supplying = 'none'); + where (change_type_receiving = 'delete' and change_type_supplying = 'delete') + or (change_type_receiving = 'delete' and change_type_supplying = 'none'); insert into merlin.merge_staging_area ( merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, @@ -559,58 +565,58 @@ begin select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none' - from diff_diff - join merlin.activity_directive - on activity_id=id - where plan_id = plan_id_receiving - and change_type_supplying = 'none' - and (change_type_receiving = 'modify' or change_type_receiving = 'none') + from diff_diff + join merlin.activity_directive + on activity_id=id + where plan_id = plan_id_receiving + and change_type_supplying = 'none' + and (change_type_receiving = 'modify' or change_type_receiving = 'none') union -- supplying 'modify' against receiving 'none' go into the merge staging area as 'modify' select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(diff_diff.activity_id, snapshot_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying - from diff_diff - join merlin.plan_snapshot_activities p - on diff_diff.activity_id = p.id - where snapshot_id = snapshot_id_supplying - and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'modify') + from diff_diff + join merlin.plan_snapshot_activities p + on diff_diff.activity_id = p.id + where snapshot_id = snapshot_id_supplying + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'modify') union -- supplying 'delete' against receiving 'none' go into the merge staging area as 'delete' - select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying - from diff_diff - join merlin.activity_directive p - on diff_diff.activity_id = p.id - where plan_id = plan_id_receiving - and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'delete'); + from diff_diff + join merlin.activity_directive p + on diff_diff.activity_id = p.id + where plan_id = plan_id_receiving + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'delete'); -- 'modify' against a 'modify' must be checked for equality first. with false_modify as ( select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, psa.snapshot_id) as tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start from merlin.plan_snapshot_activities psa - join diff_diff dd - on dd.activity_id = psa.id + join diff_diff dd + on dd.activity_id = psa.id where psa.snapshot_id = snapshot_id_supplying and (dd.change_type_receiving = 'modify' and dd.change_type_supplying = 'modify') intersect select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, ad.plan_id) as tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start from diff_diff dd - join merlin.activity_directive ad - on dd.activity_id = ad.id + join merlin.activity_directive ad + on dd.activity_id = ad.id where ad.plan_id = plan_id_receiving and (dd.change_type_supplying = 'modify' and dd.change_type_receiving = 'modify')) insert into merlin.merge_staging_area ( merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type) - select _merge_request_id, ad.id, ad.name, tags, ad.source_scheduling_goal_id, ad.source_scheduling_goal_invocation_id, - ad.created_at, ad.created_by, ad.last_modified_by, ad.start_offset, ad.type, ad.arguments, ad.metadata, - ad.anchor_id, ad.anchored_to_start, 'none' - from false_modify fm - left join merlin.activity_directive ad - on (ad.plan_id, ad.id) = (plan_id_receiving, fm.activity_id); + select _merge_request_id, ad.id, ad.name, tags, ad.source_scheduling_goal_id, ad.source_scheduling_goal_invocation_id, + ad.created_at, ad.created_by, ad.last_modified_by, ad.start_offset, ad.type, ad.arguments, ad.metadata, + ad.anchor_id, ad.anchored_to_start, 'none' + from false_modify fm + left join merlin.activity_directive ad + on (ad.plan_id, ad.id) = (plan_id_receiving, fm.activity_id); -- 'modify' against 'delete' and inequal 'modify' against 'modify' goes into conflict table (aka everything left in diff_diff) insert into merlin.conflicting_activities (merge_request_id, activity_id, change_type_supplying, change_type_receiving) @@ -620,7 +626,7 @@ begin except select msa.merge_request_id, activity_id from merlin.merge_staging_area msa) a - join diff_diff using (activity_id); + join diff_diff using (activity_id); -- Fail if there are no differences between the snapshot and the plan getting merged validate_non_no_op_status := null; @@ -634,7 +640,7 @@ begin select change_type from merlin.merge_staging_area msa where merge_request_id = _merge_request_id - and msa.change_type != 'none' + and msa.change_type != 'none' limit 1 into validate_non_no_op_status; From 6f1d031c98ad590459ab57f4a9cfa799248d5aa8 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Tue, 2 Jun 2026 13:56:37 -0400 Subject: [PATCH 07/11] Add missing punctuation in exception message --- .../hasura/migrations/Aerie/35_change_plan_bounds/up.sql | 4 ++-- .../sql/functions/merlin/merging/begin_merge.sql | 2 +- .../merlin/merging/merge_request_state_functions.sql | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql index 65ea1e2de9..d0faaf69d1 100644 --- a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql @@ -312,7 +312,7 @@ begin if (start_time_receiving is distinct from start_time_supplying) or (duration_receiving is distinct from duration_supplying) then - raise exception 'Cannot create merge request between plans with different bounds'; + raise exception 'Cannot create merge request between plans with different bounds.'; end if; insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) @@ -369,7 +369,7 @@ begin if start_time_receiving is distinct from start_time_supplying or duration_receiving is distinct from duration_supplying then - raise exception 'Cannot begin merge request between plans with different bounds'; + raise exception 'Cannot begin merge request between plans with different bounds.'; end if; -- ensure the plan receiving changes isn't locked diff --git a/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql b/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql index 580e0ebb09..21da6ed3ae 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql @@ -67,7 +67,7 @@ begin if start_time_receiving is distinct from start_time_supplying or duration_receiving is distinct from duration_supplying then - raise exception 'Cannot begin merge request between plans with different bounds'; + raise exception 'Cannot begin merge request between plans with different bounds.'; end if; -- ensure the plan receiving changes isn't locked diff --git a/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql b/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql index 63fc7e969e..dd99943cf5 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql @@ -46,7 +46,7 @@ begin if (start_time_receiving is distinct from start_time_supplying) or (duration_receiving is distinct from duration_supplying) then - raise exception 'Cannot create merge request between plans with different bounds'; + raise exception 'Cannot create merge request between plans with different bounds.'; end if; insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) From 20fd228bc72275332e4aff09ea544e48a033d268 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 5 Jun 2026 12:13:08 -0400 Subject: [PATCH 08/11] Replace "try-catch" pattern with "assertThrows" pattern --- .../database/PlanCollaborationTests.java | 578 ++++++------------ 1 file changed, 191 insertions(+), 387 deletions(-) diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java index c0d4df8da4..102581651d 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java @@ -21,10 +21,13 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import gov.nasa.jpl.aerie.database.TagsTests.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; @SuppressWarnings("SqlSourceToSinkFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -736,16 +739,9 @@ void snapshotInheritsAllLatestAsParents() throws SQLException{ } @Test - void snapshotFailsForNonexistentPlanId() throws SQLException{ - try { - createSnapshot(1000); - fail(); - } - catch(SQLException sqlEx) - { - if(!sqlEx.getMessage().contains("Plan 1000 does not exist.")) - throw sqlEx; - } + void snapshotFailsForNonexistentPlanId() { + final var ex = assertThrows(SQLException.class, () -> createSnapshot(1000)); + assertEquals("Plan 1000 does not exist.", ex.getMessage()); } @Test @@ -796,25 +792,15 @@ class RestorePlanSnapshotTests{ @Test void restoreFailsForNonexistentPlan() throws SQLException { final int snapshotId = createSnapshot(merlinHelper.insertPlan(missionModelId)); - try { - restoreFromSnapshot(-1, snapshotId); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Plan with ID -1 does not exist.")) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(-1, snapshotId)); + assertEquals("Cannot Restore: Plan with ID -1 does not exist.", ex.getMessage()); } @Test void restoreFailsForNonexistentSnapshot() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId); - try { - restoreFromSnapshot(planId, -1); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Snapshot with ID -1 does not exist.")) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(planId, -1)); + assertEquals("Cannot Restore: Snapshot with ID -1 does not exist.", ex.getMessage()); } @Test @@ -823,14 +809,9 @@ void cannotRestoreSnapshotOfDifferentPlan() throws SQLException { final int snapshotId = createSnapshot(wrongPlan); final int planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name(), "Other Plan"); - try { - restoreFromSnapshot(planId, snapshotId); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Other Plan' (ID %d)" - .formatted(snapshotId, planId))) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(planId, snapshotId)); + assertEquals("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Other Plan' (ID %d)" + .formatted(snapshotId, planId), ex.getMessage()); } @Test @@ -839,14 +820,9 @@ void cannotRestoreBranchToParentSnapshot() throws SQLException { final int snapshotId = createSnapshot(wrongPlan); final int branchId = duplicatePlan(wrongPlan, "Different Plan"); - try{ - restoreFromSnapshot(branchId, snapshotId); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Different Plan' (ID %d)" - .formatted(snapshotId, branchId))) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(branchId, snapshotId)); + assertEquals("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Different Plan' (ID %d)" + .formatted(snapshotId, branchId), ex.getMessage()); } @Test @@ -1006,16 +982,9 @@ void duplicateAttachesParentHistoryToChild() throws SQLException{ } @Test - void duplicateNonexistentPlanFails() throws SQLException { - try { - duplicatePlan(1000, "Nonexistent Parent Duplicate"); - fail(); - } - catch(SQLException sqlEx) - { - if(!sqlEx.getMessage().contains("Plan 1000 does not exist.")) - throw sqlEx; - } + void duplicateNonexistentPlanFails() { + final var ex = assertThrows(SQLException.class, () -> duplicatePlan(1000, "Nonexistent Parent Duplicate")); + assertEquals("Plan 1000 does not exist.", ex.getMessage()); } } @@ -1046,15 +1015,9 @@ void getPlanHistoryNoAncestors() throws SQLException { } @Test - void getPlanHistoryInvalidId() throws SQLException { - try { - getPlanHistory(-1); - fail(); - } - catch (SQLException sqlException) { - if (!sqlException.getMessage().contains("Plan ID -1 is not present in plan table.")) - throw sqlException; - } + void getPlanHistoryInvalidId() { + final var ex = assertThrows(SQLException.class, () -> getPlanHistory(-1)); + assertEquals("Plan ID -1 is not present in plan table.", ex.getMessage()); } @Test @@ -1102,10 +1065,8 @@ void updateActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); - merlinHelper.updateActivityName(newName, activityId, planId); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Plan " + planId + " is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.updateActivityName(newName, activityId, planId)); + assertEquals("Plan " + planId + " is locked.", ex.getMessage()); } finally { unlockPlan(planId); } @@ -1130,10 +1091,8 @@ void deleteActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); - merlinHelper.deleteActivityDirective(planId, activityId); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Plan " + planId + " is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.deleteActivityDirective(planId, activityId)); + assertEquals("Plan " + planId + " is locked.", ex.getMessage()); } finally { unlockPlan(planId); } @@ -1154,10 +1113,8 @@ void insertActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); - merlinHelper.insertActivity(planId); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Plan " + planId + " is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.insertActivity(planId)); + assertEquals("Plan " + planId + " is locked.", ex.getMessage()); } finally { unlockPlan(planId); } @@ -1183,10 +1140,8 @@ void beginReviewFailsOnLockedPlan() throws SQLException { try { lockPlan(planId); - beginMerge(mergeRequest); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin merge request. Plan to receive changes is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRequest)); + assertEquals("Cannot begin merge request. Plan to receive changes is locked.", ex.getMessage()); } finally { unlockPlan(planId); } @@ -1198,11 +1153,8 @@ void deletePlanFailsWhileLocked() throws SQLException { try { lockPlan(planId); - merlinHelper.deletePlan(planId); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot delete locked plan.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.deletePlan(planId)); + assertEquals("Cannot delete locked plan.", ex.getMessage()); } finally { unlockPlan(planId); } @@ -1423,27 +1375,21 @@ void mergeBaseFailsForInvalidPlanIds() throws SQLException { final int snapshotId = createSnapshot(planId); try(final var statement = connection.createStatement()) { - statement.execute( + final var ex = assertThrows(SQLException.class, () -> statement.execute( //language=sql """ select merlin.get_merge_base(%d, -1); - """.formatted(planId)); - } - catch (SQLException sqlEx){ - if(!sqlEx.getMessage().contains("Snapshot ID "+-1 +" is not present in plan_snapshot table.")) - throw sqlEx; + """.formatted(planId))); + assertEquals("Snapshot ID "+ -1 +" is not present in plan_snapshot table.", ex.getMessage()); } try(final var statement = connection.createStatement()) { - statement.execute( + final var ex = assertThrows(SQLException.class, () -> statement.execute( //language=sql """ select merlin.get_merge_base(-2, %d); - """.formatted(snapshotId)); - } - catch (SQLException sqlEx){ - if(!sqlEx.getMessage().contains("Snapshot ID "+-2 +" is not present in plan_snapshot table.")) - throw sqlEx; + """.formatted(snapshotId))); + assertEquals("Plan ID "+ -2 +" is not present in plan_snapshot table.", ex.getMessage()); } } @@ -1511,23 +1457,11 @@ class MergeRequestTests{ void createRequestFailsForNonexistentPlans() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId); - try{ - createMergeRequest(planId, -1); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Plan supplying changes (Plan -1) does not exist.")) - throw sqEx; - } + final var exInvalidSupplying = assertThrows(SQLException.class, () -> createMergeRequest(planId, -1)); + assertEquals("Plan supplying changes (Plan -1) does not exist.", exInvalidSupplying.getMessage()); - try{ - createMergeRequest(-1, planId); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Plan receiving changes (Plan -1) does not exist.")) - throw sqEx; - } + final var exInvalidReceiving = assertThrows(SQLException.class, () -> createMergeRequest(-1, planId)); + assertEquals("Plan receiving changes (Plan -1) does not exist.", exInvalidReceiving.getMessage()); } @Test @@ -1538,39 +1472,53 @@ void createRequestFailsForUnrelatedPlans() throws SQLException { //Creating a snapshot so that the error comes from create_merge_request, not get_merge_base createSnapshot(plan1); - try{ - createMergeRequest(plan1, plan2); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Cannot create merge request between unrelated plans.")) - throw sqEx; - } + final var ex = assertThrows(SQLException.class, () -> createMergeRequest(plan1, plan2)); + assertEquals("Cannot create merge request between unrelated plans.", ex.getMessage()); } @Test void createRequestFailsBetweenPlanAndSelf() throws SQLException { final int plan = merlinHelper.insertPlan(missionModelId); - try{ - createMergeRequest(plan, plan); - fail(); - } catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Cannot create a merge request between a plan and itself.")) - throw sqEx; - } + final var ex = assertThrows(SQLException.class, () -> createMergeRequest(plan, plan)); + assertEquals("Cannot create a merge request between a plan and itself.", ex.getMessage()); } @Test - void withdrawFailsForNonexistentRequest() throws SQLException { - try{ - withdrawMergeRequest(-1); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")) - throw sqEx; - } + void withdrawFailsForNonexistentRequest() { + final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(-1)); + assertEquals("Merge request -1 does not exist. Cannot withdraw request.", ex.getMessage()); + } + + /** + * If two plans have different bounds, a merge request cannot be created between them. + */ + @Test + void createRequestFailsBoundsDiffer() throws SQLException { + final int planId = merlinHelper.insertPlan(missionModelId); + final int childId = duplicatePlan(planId, "Child Plan"); + + merlinHelper.insertActivity(childId); + + // Update the bounds of the parent plan + merlinHelper.updatePlanDuration(planId, "24:00:00"); + + // Merge request creation should fail + final var ex1 = assertThrows(SQLException.class, () -> createMergeRequest(planId, childId)); + assertEquals("Cannot create merge request between plans with different bounds.", ex1.getMessage()); + + // Update the child so they have the same bounds + merlinHelper.updatePlanDuration(childId, "24:00:00"); + + // Merge request creation should succeed + assertDoesNotThrow(() -> createMergeRequest(planId, childId)); + + // Update the child so it has different bounds + merlinHelper.updatePlanDuration(childId, "48:00:00"); + + // Merge request creation should fail + final var ex2 = assertThrows(SQLException.class, () -> createMergeRequest(planId, childId)); + assertEquals("Cannot create merge request between plans with different bounds.", ex2.getMessage()); } } @@ -1581,14 +1529,10 @@ void withdrawFailsForNonexistentRequest() throws SQLException { @Nested class BeginMergeTests { @Test - void beginMergeFailsOnInvalidRequestId() throws SQLException { - try{ - beginMerge(-1); - fail(); - }catch (SQLException sqlEx){ - if(!sqlEx.getMessage().contains("Request ID -1 is not present in merge_request table.")) - throw sqlEx; - } + void beginMergeFailsOnInvalidRequestId() { + final var ex = assertThrows(SQLException.class, () -> beginMerge(-1)); + assertEquals("Request ID -1 is not present in merge_request table.", ex.getMessage()); + } } @Test @@ -1625,14 +1569,9 @@ void beginMergeNoChangesThrowsError() throws SQLException { merlinHelper.insertActivity(planId); final int childPlan = duplicatePlan(planId, "Child"); - try { - beginMerge(createMergeRequest(planId,childPlan)); - fail(); - } catch (SQLException sqlex) { - if(!sqlex.getMessage().contains("Cannot begin merge. The contents of the two plans are identical.")){ - throw sqlex; - } - } + final var ex = assertThrows(SQLException.class, () -> beginMerge(createMergeRequest(planId,childPlan))); + assertEquals("Cannot begin merge. The contents of the two plans are identical.", ex.getMessage()); + // Assert that the plan was not locked assertFalse(isPlanLocked(planId)); } @@ -2013,14 +1952,9 @@ void deleteDeleteIsExcludedFromStageAndConflict() throws SQLException { @Nested class CommitMergeTests{ @Test - void commitMergeFailsForNonexistentId() throws SQLException { - try { - commitMerge(-1); - fail(); - } catch (SQLException sqlex){ - if(!sqlex.getMessage().contains("Invalid merge request id -1.")) - throw sqlex; - } + void commitMergeFailsForNonexistentId() { + final var ex = assertThrows(SQLException.class, () -> commitMerge(-1)); + assertEquals("Invalid merge request id -1.", ex.getMessage()); } @Test @@ -2034,13 +1968,9 @@ void commitMergeFailsIfConflictsExist() throws SQLException { final int mergeRQ = createMergeRequest(basePlan, childPlan); beginMerge(mergeRQ); - try{ - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlex){ - if(!sqlex.getMessage().contains("There are unresolved conflicts in merge request "+mergeRQ+". Cannot commit merge.")) - throw sqlex; - } + + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertEquals("There are unresolved conflicts in merge request "+mergeRQ+". Cannot commit merge.", ex.getMessage()); } @Test @@ -2456,36 +2386,21 @@ void ModifyUntouchedPersists() throws SQLException { @Nested class MergeStateMachineTests{ @Test - void cancelFailsForInvalidId() throws SQLException{ - try{ - cancelMerge(-1); - fail(); - } catch (SQLException sqlException) { - if(!sqlException.getMessage().contains("Invalid merge request id -1.")) - throw sqlException; - } + void cancelFailsForInvalidId() { + final var ex = assertThrows(SQLException.class, () -> cancelMerge(-1)); + assertEquals("Invalid merge request id -1.", ex.getMessage()); } @Test - void denyFailsForInvalidId() throws SQLException { - try{ - denyMerge(-1); - fail(); - } catch (SQLException sqlException) { - if(!sqlException.getMessage().contains("Invalid merge request id -1.")) - throw sqlException; - } + void denyFailsForInvalidId() { + final var ex = assertThrows(SQLException.class, () -> denyMerge(-1)); + assertEquals("Invalid merge request id -1.", ex.getMessage()); } @Test - void withdrawFailsForInvalidId() throws SQLException { - try{ - withdrawMergeRequest(-1); - fail(); - } catch (SQLException sqlException){ - if(!sqlException.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")) - throw sqlException; - } + void withdrawFailsForInvalidId() { + final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(-1)); + assertEquals("Merge request -1 does not exist. Cannot withdraw request.", ex.getMessage()); } @Test @@ -2496,229 +2411,137 @@ void defaultStateOfMergeRequestIsPendingStatus() throws SQLException { assertEquals("pending", mergeRequest.status); } - @Test - void beginMergeOnlySucceedsOnPendingStatus() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "accepted", "rejected", "in-progress"}) + void beginMergeFailsOnNonPendingStatus(String status) throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - beginMerge(mergeRQ); - } catch (SQLException sqlEx){ - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "accepted"); - try { - beginMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "rejected"); - try { - beginMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "in-progress"); - try { - beginMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "pending"); - beginMerge(mergeRQ); + final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRQ)); + assertEquals("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.", ex.getMessage()); } @Test - void withdrawOnlySucceedsOnPendingOrWithdrawnStatus() throws SQLException { + void beginMergeSucceedsOnPendingStatus() throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); setMergeRequestStatus(mergeRQ, "pending"); - withdrawMergeRequest(mergeRQ); - - setMergeRequestStatus(mergeRQ, "withdrawn"); - withdrawMergeRequest(mergeRQ); + assertDoesNotThrow(() -> beginMerge(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "accepted"); - try { - withdrawMergeRequest(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot withdraw request.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"accepted", "rejected", "in-progress"}) + void withdrawFailsAcceptedRejectedInProgress(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); - setMergeRequestStatus(mergeRQ, "rejected"); - try { - withdrawMergeRequest(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot withdraw request.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - try { - withdrawMergeRequest(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot withdraw request.")) - throw sqlEx; - } + final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(mergeRQ)); + assertEquals("Cannot withdraw request.", ex.getMessage()); } - @Test - void cancelOnlySucceedsOnInProgressOrPendingStatus() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"accepted", "rejected", "in-progress"}) + void withdrawSucceedsPendingOrWithdrawnStatus(String status) throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); - beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "pending"); - cancelMerge(mergeRQ); + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - cancelMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot cancel merge.")) - throw sqlEx; - } + assertDoesNotThrow(() -> withdrawMergeRequest(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "accepted"); - try { - cancelMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot cancel merge.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "accepted", "rejected"}) + void cancelFailsWithdrawnAcceptedRejected(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "rejected"); - try { - cancelMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot cancel merge.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - cancelMerge(mergeRQ); + final var ex = assertThrows(SQLException.class, () -> cancelMerge(mergeRQ)); + assertEquals("Cannot cancel merge.", ex.getMessage()); } - @Test - void denyOnlySucceedsOnInProgressStatus() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"pending", "in-progress"}) + void cancelSucceedsInProgressOrPendingStatus(String status) throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "pending"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); + assertDoesNotThrow(() -> cancelMerge(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "accepted"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "pending", "accepted", "rejected"}) + void denyFailsNonInProgressStatus(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "rejected"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - denyMerge(mergeRQ); + final var ex = assertThrows(SQLException.class, () -> denyMerge(mergeRQ)); + assertEquals("Cannot reject merge not in progress.", ex.getMessage()); } @Test - void commitOnlySucceedsOnInProgressStatus() throws SQLException { + void denySucceedsInProgressStatus() throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); beginMerge(mergeRQ); + setMergeRequestStatus(mergeRQ, "in-progress"); + assertDoesNotThrow(() -> denyMerge(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "pending"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "pending", "accepted", "rejected"}) + void commitFailsNonInProgressStatus(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "accepted"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertEquals("Cannot reject merge not in progress.", ex.getMessage()); + } - setMergeRequestStatus(mergeRQ, "rejected"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + @Test + void commitOnlySucceedsOnInProgressStatus() throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); setMergeRequestStatus(mergeRQ, "in-progress"); - commitMerge(mergeRQ); + assertDoesNotThrow(() -> commitMerge(mergeRQ)); } /** @@ -2901,14 +2724,9 @@ void cantMergeCycle() throws SQLException{ // Merge fails as it would establish B -> A -> B cycle final int mergeRQ = createMergeRequest(planId, childPlan); beginMerge(mergeRQ); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException ex) { - if(!ex.getMessage().contains("Cycle detected. Cannot apply changes.")){ - throw ex; - } - } + + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertEquals("Cycle detected. Cannot apply changes.", ex.getMessage()); } @Test @@ -2925,15 +2743,9 @@ void anchorMustBeInTargetPlanAtEndOfMerge() throws SQLException{ final int mergeRQ = createMergeRequest(planId, childPlan); beginMerge(mergeRQ); - try{ - commitMerge(mergeRQ); - fail(); - } catch (SQLException ex){ - if(!ex.getMessage().contains( - "insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"")){ - throw ex; - } - } + + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertEquals("insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"", ex.getMessage()); } @Test @@ -3426,20 +3238,12 @@ void tagsCannotBeDeletedMidMerge() throws SQLException { final int mergeRQ = createMergeRequest(planId, branchId); beginMerge(mergeRQ); - try { - tagsHelper.deleteTag(activityTagId); - } catch (SQLException ex) { - if(!ex.getMessage().contains("Plan "+planId +" is locked.")){ - throw ex; - } - } - try { - tagsHelper.deleteTag(snapshotTagId); - } catch (SQLException ex) { - if(!ex.getMessage().contains("Cannot delete. Snapshot is in use in an active merge review.")){ - throw ex; - } - } + final var exActivityTagDelete = assertThrows(SQLException.class, () -> tagsHelper.deleteTag(activityTagId)); + assertEquals("Plan "+planId +" is locked.", exActivityTagDelete.getMessage()); + + final var exSnapshotTagDelete = assertThrows(SQLException.class, () -> tagsHelper.deleteTag(snapshotTagId)); + assertEquals("Cannot delete. Snapshot is in use in an active merge review.", exSnapshotTagDelete.getMessage()); + assertDoesNotThrow(()->tagsHelper.deleteTag(unrelatedTagId)); unlockPlan(planId); From c83f9af25e245738c1b781a94f69a6e81516747c Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 5 Jun 2026 12:41:54 -0400 Subject: [PATCH 09/11] Add new DB Tests --- .../database/MerlinDatabaseTestHelper.java | 62 +++++++- .../database/PlanCollaborationTests.java | 143 +++++++++++++++++- 2 files changed, 194 insertions(+), 11 deletions(-) diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java index 19b693bd3c..3942dcf9dd 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java @@ -148,7 +148,6 @@ int insertActivity(final int planId, final String startOffset, final String type return insertActivity(planId, startOffset, type, arguments, admin); } - int insertActivity(final int planId, final String startOffset, final String arguments, User user) throws SQLException { try (final var statement = connection.createStatement()) { final var res = statement @@ -294,7 +293,7 @@ void assignPreset(int presetId, int activityId, int planId, String userSession) } } -void unassignPreset(int presetId, int activityId, int planId) throws SQLException { + void unassignPreset(int presetId, int activityId, int planId) throws SQLException { try(final var statement = connection.createStatement()){ statement.execute( //language=sql @@ -305,7 +304,6 @@ void unassignPreset(int presetId, int activityId, int planId) throws SQLExceptio } } - int insertConstraint(String name, String definition, User user) throws SQLException { try(final var statement = connection.createStatement()) { final var res = statement.executeQuery( @@ -325,4 +323,62 @@ WITH metadata(id, owner) AS ( return res.getInt("constraint_id"); } } + + void updatePlanDuration(int planId, String newDuration) throws SQLException { + try(final var statement = connection.createStatement()) { + statement.executeUpdate( + //language=sql + """ + update merlin.plan + set duration = '%s' + where id = %d + """.formatted(newDuration, planId) + ); + } + } + + int getPlanRevision(int planId) throws SQLException { + try(final var statement = connection.createStatement()) { + final var res = statement.executeQuery( + //language=sql + """ + select revision + from merlin.plan + where id = %d + """.formatted(planId) + ); + res.next(); + return res.getInt("revision"); + } + } + + String getPlanStartTime(int planId) throws SQLException { + try(final var statement = connection.createStatement()) { + final var res = statement.executeQuery( + //language=sql + """ + select start_time + from merlin.plan + where id = %d + """.formatted(planId) + ); + res.next(); + return res.getString("start_time"); + } + } + + String getPlanDuration(int planId) throws SQLException { + try(final var statement = connection.createStatement()) { + final var res = statement.executeQuery( + //language=sql + """ + select duration + from merlin.plan + where id = %d + """.formatted(planId) + ); + res.next(); + return res.getString("duration"); + } + } } diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java index 102581651d..04d9ba59fb 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java @@ -217,8 +217,12 @@ private SnapshotMetadata getSnapshotMetadata(final int snapshotId) throws SQLExc return new SnapshotMetadata( res.getInt("snapshot_id"), res.getInt("plan_id"), + res.getInt("model_id"), res.getInt("revision"), + res.getString("plan_start_time"), + res.getString("plan_duration"), res.getString("snapshot_name"), + res.getString("description"), res.getString("taken_by"), res.getString("taken_at") ); @@ -618,8 +622,12 @@ public record Activity( private record SnapshotMetadata( int snapshot_id, int plan_id, + int model_id, int revision, + String planStartTime, + String planDuration, String snapshot_name, + String description, String taken_by, String taken_at) {} private record SnapshotActivity( @@ -754,17 +762,15 @@ void createNamedSnapshot() throws SQLException { assertEquals(0, snapshot.revision); } + /** + * Snapshots can have duplicate names, as they have a description field to help + * disambiguate them + */ @Test - void namedSnapshotsMustBeUnique() throws SQLException{ + void namedSnapshotsMayHaveDuplicateNames() throws SQLException{ final var planId = merlinHelper.insertPlan(missionModelId); createSnapshot(planId, "Snapshot", merlinHelper.admin); - try { - createSnapshot(planId, "Snapshot", merlinHelper.admin); - } catch (SQLException ex) { - if (!ex.getMessage().contains("duplicate key value violates unique constraint \"snapshot_name_unique_per_plan\"")) { - throw ex; - } - } + assertDoesNotThrow(() -> createSnapshot(planId, "Snapshot", merlinHelper.admin)); } @Test @@ -785,6 +791,36 @@ void canCreateMultipleNamedSnapshots() throws SQLException{ assertEquals(merlinHelper.admin.name(), firstSnapshot.taken_by); assertEquals(merlinHelper.user.name(), secondSnapshot.taken_by); } + + /** + * A snapshot is automatically taken when the plan bounds are updated. + */ + @Test + void snapshotTakenOnPlanBoundsUpdate() throws SQLException { + final var planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name()); + assertTrue(getLatestSnapshots(planId).isEmpty()); + + // Update plan bounds + merlinHelper.updatePlanDuration(planId, "28:00:00"); + + final var latestSnapshots = getLatestSnapshots(planId); + assertEquals(1, latestSnapshots.size()); + + // Check the snapshot's contents + final var snapshot = getSnapshotMetadata(latestSnapshots.getFirst()); + assertEquals("Plan Bounds Adjustment", snapshot.snapshot_name); + assertEquals("Automatic snapshot made before adjusting plan bounds from " + + "[2020-1-1 00:00:00+00 - 2020-1-1 00:00:00+00] to " + + "[2020-1-1 00:00:00+00 - 2020-1-2 04:00:00+00]", snapshot.description); + assertEquals(planId, snapshot.plan_id); + assertEquals(missionModelId, snapshot.model_id); + assertEquals("2020-1-1 00:00:00+00", snapshot.planStartTime); + assertEquals("0", snapshot.planDuration); + + // Assert that the snapshot was taken BEFORE the plan's revision was updated + assertEquals(0, snapshot.revision); + assertTrue(merlinHelper.getPlanRevision(planId) > 0); + } } @Nested @@ -885,6 +921,48 @@ void restoresChangedActivities() throws SQLException { final Activity restoredDirective = planActivities.get(0); assertActivityEquals(oldDirective, restoredDirective); } + + /** + * If plan bounds are updated after a snapshot is taken, then restoring the snapshot + * restores the boundaries to those at the time of the snapshot. + */ + @Test + void restoresPlanBounds() throws SQLException { + final var planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name()); + + // Update plan bounds + merlinHelper.updatePlanDuration(planId, "28:00:00"); + + // Get a handle on the revision + final var oldRevision = merlinHelper.getPlanRevision(planId); + + // Restore the automatically created snapshot + final var oldSnapshotId = getLatestSnapshot(planId); + restoreFromSnapshot(planId, oldSnapshotId); + + // The plan bounds should be restored + assertEquals("2020-1-1 00:00:00+00", merlinHelper.getPlanStartTime(planId)); + assertEquals("0", merlinHelper.getPlanDuration(planId)); + + // The plan's revision should have been updated + assertTrue(merlinHelper.getPlanRevision(planId) > oldRevision); + + // A new snapshot should have been created + final var newSnapshotId = getLatestSnapshot(planId); + assertNotEquals(oldSnapshotId, newSnapshotId); + + // Check the new snapshot's contents + final var snapshot = getSnapshotMetadata(newSnapshotId); + assertEquals("Plan Bounds Adjustment", snapshot.snapshot_name); + assertEquals("Automatic snapshot made before adjusting plan bounds from " + + "[2020-1-1 00:00:00+00 - 2020-1-2 04:00:00+00] to " + + "[2020-1-1 00:00:00+00 - 2020-1-1 00:00:00+00]", snapshot.description); + assertEquals(planId, snapshot.plan_id); + assertEquals(missionModelId, snapshot.model_id); + assertEquals("2020-1-1 00:00:00+00", snapshot.planStartTime); + assertEquals("28:00:00", snapshot.planDuration); + assertEquals(oldRevision, snapshot.revision); + } } @Nested @@ -1533,6 +1611,55 @@ void beginMergeFailsOnInvalidRequestId() { final var ex = assertThrows(SQLException.class, () -> beginMerge(-1)); assertEquals("Request ID -1 is not present in merge_request table.", ex.getMessage()); } + + /** + * If the plan receiving changes has its bounds changed between a merge request being made + * and the merge beginning, then the "begin_merge" method fails. + */ + @Test + void beginMergeReceivingBoundsChange() throws SQLException { + final int planId = merlinHelper.insertPlan(missionModelId); + final int childId = duplicatePlan(planId, "Child Plan"); + + merlinHelper.insertActivity(childId); + + // Create a merge request + final var mergeRQId = createMergeRequest(planId, childId); + + // Update the plan bounds + merlinHelper.updatePlanDuration(planId, "24:00:00"); + + // Attempt to begin a merge request + final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRQId)); + + assertEquals("Cannot begin merge request between plans with different bounds.", ex.getMessage()); + + unlockPlan(planId); + } + + /** + * If the plan supplying changes has its bounds changed between a merge request being made + * and the merge beginning, then the merge succeeds as normal. + * This is because the snapshot used in the merge request is unaffected by changes to the plan, + * including bounds changes. + */ + @Test + void beginMergeSupplyingBoundsChange() throws SQLException { + final int planId = merlinHelper.insertPlan(missionModelId); + final int childId = duplicatePlan(planId, "Child Plan"); + + merlinHelper.insertActivity(childId); + + // Create a merge request + final var mergeRQId = createMergeRequest(planId, childId); + + // Update the child plan's bounds + merlinHelper.updatePlanDuration(childId, "24:00:00"); + + // Attempt to begin a merge request. This should succeed + assertDoesNotThrow(() -> beginMerge(mergeRQId)); + + unlockPlan(planId); } @Test From 42902dcb5a766ac9f1662ef0e0aac2d60dd018d4 Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 5 Jun 2026 12:43:05 -0400 Subject: [PATCH 10/11] Fix flipped assertEquals --- .../gov/nasa/jpl/aerie/database/PlanCollaborationTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java index 04d9ba59fb..3d7aa6ee30 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java @@ -736,7 +736,7 @@ void snapshotInheritsAllLatestAsParents() throws SQLException{ } //assert that the snapshot history is n+1 long - assertEquals(snapshotHistory.size(), numberOfSnapshots + 1); + assertEquals(numberOfSnapshots + 1, snapshotHistory.size()); //assert that res contains, in order: finalSnapshotId, snapshotId[0,1,...,n] assertEquals(finalSnapshotId, snapshotHistory.get(0)); @@ -1048,7 +1048,7 @@ void duplicateAttachesParentHistoryToChild() throws SQLException{ parentHistory.add(parentRes.getInt(1)); } - assertEquals(parentHistory.size(), numberOfSnapshots + 1); + assertEquals(numberOfSnapshots + 1, parentHistory.size()); final var childHistory = new ArrayList(); while (childRes.next()) { From f4c256b7d443b8f40ea11d70c3ccee95b9e4d68a Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Fri, 5 Jun 2026 13:04:34 -0400 Subject: [PATCH 11/11] Fix tests --- .../database/PlanCollaborationTests.java | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java index 3d7aa6ee30..8a825d3bcf 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java @@ -749,7 +749,7 @@ void snapshotInheritsAllLatestAsParents() throws SQLException{ @Test void snapshotFailsForNonexistentPlanId() { final var ex = assertThrows(SQLException.class, () -> createSnapshot(1000)); - assertEquals("Plan 1000 does not exist.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan 1000 does not exist.")); } @Test @@ -808,14 +808,14 @@ void snapshotTakenOnPlanBoundsUpdate() throws SQLException { // Check the snapshot's contents final var snapshot = getSnapshotMetadata(latestSnapshots.getFirst()); - assertEquals("Plan Bounds Adjustment", snapshot.snapshot_name); + assertEquals("Plan Bound Adjustment", snapshot.snapshot_name); assertEquals("Automatic snapshot made before adjusting plan bounds from " - + "[2020-1-1 00:00:00+00 - 2020-1-1 00:00:00+00] to " - + "[2020-1-1 00:00:00+00 - 2020-1-2 04:00:00+00]", snapshot.description); + + "[2020-01-01 00:00:00+00 - 2020-01-01 00:00:00+00] to " + + "[2020-01-01 00:00:00+00 - 2020-01-02 04:00:00+00]", snapshot.description); assertEquals(planId, snapshot.plan_id); assertEquals(missionModelId, snapshot.model_id); - assertEquals("2020-1-1 00:00:00+00", snapshot.planStartTime); - assertEquals("0", snapshot.planDuration); + assertEquals("2020-01-01 00:00:00+00", snapshot.planStartTime); + assertEquals("00:00:00", snapshot.planDuration); // Assert that the snapshot was taken BEFORE the plan's revision was updated assertEquals(0, snapshot.revision); @@ -829,14 +829,14 @@ class RestorePlanSnapshotTests{ void restoreFailsForNonexistentPlan() throws SQLException { final int snapshotId = createSnapshot(merlinHelper.insertPlan(missionModelId)); final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(-1, snapshotId)); - assertEquals("Cannot Restore: Plan with ID -1 does not exist.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot Restore: Plan with ID -1 does not exist.")); } @Test void restoreFailsForNonexistentSnapshot() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId); final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(planId, -1)); - assertEquals("Cannot Restore: Snapshot with ID -1 does not exist.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot Restore: Snapshot with ID -1 does not exist.")); } @Test @@ -846,8 +846,8 @@ void cannotRestoreSnapshotOfDifferentPlan() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name(), "Other Plan"); final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(planId, snapshotId)); - assertEquals("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Other Plan' (ID %d)" - .formatted(snapshotId, planId), ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Other Plan' (ID %d)" + .formatted(snapshotId, planId))); } @Test @@ -857,8 +857,8 @@ void cannotRestoreBranchToParentSnapshot() throws SQLException { final int branchId = duplicatePlan(wrongPlan, "Different Plan"); final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(branchId, snapshotId)); - assertEquals("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Different Plan' (ID %d)" - .formatted(snapshotId, branchId), ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Different Plan' (ID %d)" + .formatted(snapshotId, branchId))); } @Test @@ -941,8 +941,8 @@ void restoresPlanBounds() throws SQLException { restoreFromSnapshot(planId, oldSnapshotId); // The plan bounds should be restored - assertEquals("2020-1-1 00:00:00+00", merlinHelper.getPlanStartTime(planId)); - assertEquals("0", merlinHelper.getPlanDuration(planId)); + assertEquals("2020-01-01 00:00:00+00", merlinHelper.getPlanStartTime(planId)); + assertEquals("00:00:00", merlinHelper.getPlanDuration(planId)); // The plan's revision should have been updated assertTrue(merlinHelper.getPlanRevision(planId) > oldRevision); @@ -953,13 +953,13 @@ void restoresPlanBounds() throws SQLException { // Check the new snapshot's contents final var snapshot = getSnapshotMetadata(newSnapshotId); - assertEquals("Plan Bounds Adjustment", snapshot.snapshot_name); + assertEquals("Plan Bound Adjustment", snapshot.snapshot_name); assertEquals("Automatic snapshot made before adjusting plan bounds from " - + "[2020-1-1 00:00:00+00 - 2020-1-2 04:00:00+00] to " - + "[2020-1-1 00:00:00+00 - 2020-1-1 00:00:00+00]", snapshot.description); + + "[2020-01-01 00:00:00+00 - 2020-01-02 04:00:00+00] to " + + "[2020-01-01 00:00:00+00 - 2020-01-01 00:00:00+00]", snapshot.description); assertEquals(planId, snapshot.plan_id); assertEquals(missionModelId, snapshot.model_id); - assertEquals("2020-1-1 00:00:00+00", snapshot.planStartTime); + assertEquals("2020-01-01 00:00:00+00", snapshot.planStartTime); assertEquals("28:00:00", snapshot.planDuration); assertEquals(oldRevision, snapshot.revision); } @@ -1062,7 +1062,7 @@ void duplicateAttachesParentHistoryToChild() throws SQLException{ @Test void duplicateNonexistentPlanFails() { final var ex = assertThrows(SQLException.class, () -> duplicatePlan(1000, "Nonexistent Parent Duplicate")); - assertEquals("Plan 1000 does not exist.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan 1000 does not exist.")); } } @@ -1095,7 +1095,7 @@ void getPlanHistoryNoAncestors() throws SQLException { @Test void getPlanHistoryInvalidId() { final var ex = assertThrows(SQLException.class, () -> getPlanHistory(-1)); - assertEquals("Plan ID -1 is not present in plan table.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan ID -1 is not present in plan table.")); } @Test @@ -1144,7 +1144,7 @@ void updateActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); final var ex = assertThrows(SQLException.class, () -> merlinHelper.updateActivityName(newName, activityId, planId)); - assertEquals("Plan " + planId + " is locked.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan " + planId + " is locked.")); } finally { unlockPlan(planId); } @@ -1170,7 +1170,7 @@ void deleteActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); final var ex = assertThrows(SQLException.class, () -> merlinHelper.deleteActivityDirective(planId, activityId)); - assertEquals("Plan " + planId + " is locked.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan " + planId + " is locked.")); } finally { unlockPlan(planId); } @@ -1192,7 +1192,7 @@ void insertActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); final var ex = assertThrows(SQLException.class, () -> merlinHelper.insertActivity(planId)); - assertEquals("Plan " + planId + " is locked.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan " + planId + " is locked.")); } finally { unlockPlan(planId); } @@ -1219,7 +1219,7 @@ void beginReviewFailsOnLockedPlan() throws SQLException { try { lockPlan(planId); final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRequest)); - assertEquals("Cannot begin merge request. Plan to receive changes is locked.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot begin merge request. Plan to receive changes is locked."));; } finally { unlockPlan(planId); } @@ -1232,7 +1232,7 @@ void deletePlanFailsWhileLocked() throws SQLException { try { lockPlan(planId); final var ex = assertThrows(SQLException.class, () -> merlinHelper.deletePlan(planId)); - assertEquals("Cannot delete locked plan.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot delete locked plan.")); } finally { unlockPlan(planId); } @@ -1458,7 +1458,7 @@ void mergeBaseFailsForInvalidPlanIds() throws SQLException { """ select merlin.get_merge_base(%d, -1); """.formatted(planId))); - assertEquals("Snapshot ID "+ -1 +" is not present in plan_snapshot table.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Snapshot ID "+ -1 +" is not present in plan_snapshot table.")); } try(final var statement = connection.createStatement()) { @@ -1467,7 +1467,7 @@ void mergeBaseFailsForInvalidPlanIds() throws SQLException { """ select merlin.get_merge_base(-2, %d); """.formatted(snapshotId))); - assertEquals("Plan ID "+ -2 +" is not present in plan_snapshot table.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Plan ID "+ -2 +" is not present in plan_snapshot table.")); } } @@ -1536,10 +1536,10 @@ void createRequestFailsForNonexistentPlans() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId); final var exInvalidSupplying = assertThrows(SQLException.class, () -> createMergeRequest(planId, -1)); - assertEquals("Plan supplying changes (Plan -1) does not exist.", exInvalidSupplying.getMessage()); + assertTrue(exInvalidSupplying.getMessage().contains("Plan supplying changes (Plan -1) does not exist.")); final var exInvalidReceiving = assertThrows(SQLException.class, () -> createMergeRequest(-1, planId)); - assertEquals("Plan receiving changes (Plan -1) does not exist.", exInvalidReceiving.getMessage()); + assertTrue(exInvalidReceiving.getMessage().contains("Plan receiving changes (Plan -1) does not exist.")); } @Test @@ -1551,7 +1551,7 @@ void createRequestFailsForUnrelatedPlans() throws SQLException { createSnapshot(plan1); final var ex = assertThrows(SQLException.class, () -> createMergeRequest(plan1, plan2)); - assertEquals("Cannot create merge request between unrelated plans.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot create merge request between unrelated plans.")); } @Test @@ -1559,13 +1559,13 @@ void createRequestFailsBetweenPlanAndSelf() throws SQLException { final int plan = merlinHelper.insertPlan(missionModelId); final var ex = assertThrows(SQLException.class, () -> createMergeRequest(plan, plan)); - assertEquals("Cannot create a merge request between a plan and itself.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot create a merge request between a plan and itself.")); } @Test void withdrawFailsForNonexistentRequest() { final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(-1)); - assertEquals("Merge request -1 does not exist. Cannot withdraw request.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")); } /** @@ -1583,7 +1583,7 @@ void createRequestFailsBoundsDiffer() throws SQLException { // Merge request creation should fail final var ex1 = assertThrows(SQLException.class, () -> createMergeRequest(planId, childId)); - assertEquals("Cannot create merge request between plans with different bounds.", ex1.getMessage()); + assertTrue(ex1.getMessage().contains("Cannot create merge request between plans with different bounds.")); // Update the child so they have the same bounds merlinHelper.updatePlanDuration(childId, "24:00:00"); @@ -1596,7 +1596,7 @@ void createRequestFailsBoundsDiffer() throws SQLException { // Merge request creation should fail final var ex2 = assertThrows(SQLException.class, () -> createMergeRequest(planId, childId)); - assertEquals("Cannot create merge request between plans with different bounds.", ex2.getMessage()); + assertTrue(ex2.getMessage().contains("Cannot create merge request between plans with different bounds.")); } } @@ -1609,7 +1609,7 @@ class BeginMergeTests { @Test void beginMergeFailsOnInvalidRequestId() { final var ex = assertThrows(SQLException.class, () -> beginMerge(-1)); - assertEquals("Request ID -1 is not present in merge_request table.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Request ID -1 is not present in merge_request table.")); } /** @@ -1632,7 +1632,7 @@ void beginMergeReceivingBoundsChange() throws SQLException { // Attempt to begin a merge request final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRQId)); - assertEquals("Cannot begin merge request between plans with different bounds.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot begin merge request between plans with different bounds.")); unlockPlan(planId); } @@ -1697,7 +1697,7 @@ void beginMergeNoChangesThrowsError() throws SQLException { final int childPlan = duplicatePlan(planId, "Child"); final var ex = assertThrows(SQLException.class, () -> beginMerge(createMergeRequest(planId,childPlan))); - assertEquals("Cannot begin merge. The contents of the two plans are identical.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot begin merge. The contents of the two plans are identical.")); // Assert that the plan was not locked assertFalse(isPlanLocked(planId)); @@ -2081,7 +2081,7 @@ class CommitMergeTests{ @Test void commitMergeFailsForNonexistentId() { final var ex = assertThrows(SQLException.class, () -> commitMerge(-1)); - assertEquals("Invalid merge request id -1.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Invalid merge request id -1.")); } @Test @@ -2097,7 +2097,7 @@ void commitMergeFailsIfConflictsExist() throws SQLException { beginMerge(mergeRQ); final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); - assertEquals("There are unresolved conflicts in merge request "+mergeRQ+". Cannot commit merge.", ex.getMessage()); + assertTrue(ex.getMessage().contains("There are unresolved conflicts in merge request "+mergeRQ+". Cannot commit merge.")); } @Test @@ -2515,19 +2515,19 @@ class MergeStateMachineTests{ @Test void cancelFailsForInvalidId() { final var ex = assertThrows(SQLException.class, () -> cancelMerge(-1)); - assertEquals("Invalid merge request id -1.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Invalid merge request id -1.")); } @Test void denyFailsForInvalidId() { final var ex = assertThrows(SQLException.class, () -> denyMerge(-1)); - assertEquals("Invalid merge request id -1.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Invalid merge request id -1.")); } @Test void withdrawFailsForInvalidId() { final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(-1)); - assertEquals("Merge request -1 does not exist. Cannot withdraw request.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")); } @Test @@ -2549,7 +2549,7 @@ void beginMergeFailsOnNonPendingStatus(String status) throws SQLException { setMergeRequestStatus(mergeRQ, status); final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRQ)); - assertEquals("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")); } @Test @@ -2574,7 +2574,7 @@ void withdrawFailsAcceptedRejectedInProgress(String status) throws SQLException setMergeRequestStatus(mergeRQ, status); final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(mergeRQ)); - assertEquals("Cannot withdraw request.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot withdraw request.")); } @ParameterizedTest @@ -2602,7 +2602,7 @@ void cancelFailsWithdrawnAcceptedRejected(String status) throws SQLException { setMergeRequestStatus(mergeRQ, status); final var ex = assertThrows(SQLException.class, () -> cancelMerge(mergeRQ)); - assertEquals("Cannot cancel merge.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot cancel merge.")); } @ParameterizedTest @@ -2630,7 +2630,7 @@ void denyFailsNonInProgressStatus(String status) throws SQLException { setMergeRequestStatus(mergeRQ, status); final var ex = assertThrows(SQLException.class, () -> denyMerge(mergeRQ)); - assertEquals("Cannot reject merge not in progress.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot reject merge not in progress.")); } @Test @@ -2656,7 +2656,7 @@ void commitFailsNonInProgressStatus(String status) throws SQLException { setMergeRequestStatus(mergeRQ, status); final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); - assertEquals("Cannot reject merge not in progress.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cannot reject merge not in progress.")); } @Test @@ -2853,7 +2853,7 @@ void cantMergeCycle() throws SQLException{ beginMerge(mergeRQ); final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); - assertEquals("Cycle detected. Cannot apply changes.", ex.getMessage()); + assertTrue(ex.getMessage().contains("Cycle detected. Cannot apply changes.")); } @Test @@ -2872,7 +2872,7 @@ void anchorMustBeInTargetPlanAtEndOfMerge() throws SQLException{ beginMerge(mergeRQ); final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); - assertEquals("insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"", ex.getMessage()); + assertTrue(ex.getMessage().contains("insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"")); } @Test @@ -3366,10 +3366,10 @@ void tagsCannotBeDeletedMidMerge() throws SQLException { beginMerge(mergeRQ); final var exActivityTagDelete = assertThrows(SQLException.class, () -> tagsHelper.deleteTag(activityTagId)); - assertEquals("Plan "+planId +" is locked.", exActivityTagDelete.getMessage()); + assertTrue(exActivityTagDelete.getMessage().contains("Plan "+planId +" is locked.")); final var exSnapshotTagDelete = assertThrows(SQLException.class, () -> tagsHelper.deleteTag(snapshotTagId)); - assertEquals("Cannot delete. Snapshot is in use in an active merge review.", exSnapshotTagDelete.getMessage()); + assertTrue(exSnapshotTagDelete.getMessage().contains("Cannot delete. Snapshot is in use in an active merge review.")); assertDoesNotThrow(()->tagsHelper.deleteTag(unrelatedTagId));