From 57db03f4eb0b10c8d14712a0c172b851f9e0a43e Mon Sep 17 00:00:00 2001 From: Tom Brooks Date: Wed, 11 Dec 2024 14:41:21 -0500 Subject: [PATCH] feat: updated category delete trigger to recurse --- .../V193__Add_MTO_Category_Delete_Trigger.sql | 102 +++++------------- pkg/graph/resolvers/mto_category_test.go | 90 ++++++++++++++-- 2 files changed, 112 insertions(+), 80 deletions(-) diff --git a/migrations/V193__Add_MTO_Category_Delete_Trigger.sql b/migrations/V193__Add_MTO_Category_Delete_Trigger.sql index e105a0f7d8..4b2e78cb03 100644 --- a/migrations/V193__Add_MTO_Category_Delete_Trigger.sql +++ b/migrations/V193__Add_MTO_Category_Delete_Trigger.sql @@ -1,89 +1,43 @@ -/* - This SQL script sets up a trigger and a supporting function to handle custom cascading - deletion behavior for categories in the `mto_category` table, per the application requirements. - - Updated Assumption (per user's note): - ------------------------------------- - Instead of assigning milestones to a designated "Uncategorized" category with a known UUID, - we will set their `mto_category_id` to NULL (a "nil" UUID), indicating they are now uncategorized. - - Requirements: - - Deleting a subcategory: - * All milestones referencing that subcategory should be reassigned to its parent category. - - Deleting a top-level category: - * All milestones referencing the deleted top-level category or its direct subcategories - should have `mto_category_id` set to NULL, indicating they are now uncategorized. - * All direct subcategories of the deleted category should be deleted. - - Why the "CASCADE" Keyword Isn't Used: - ------------------------------------- - The standard ON DELETE CASCADE simply removes dependent rows. Here, we need to reassign - references rather than just delete them. Since this behavior is more complex, we implement - custom logic in a trigger and a PL/pgSQL function rather than relying on cascade deletes. - - Usage: - - Once this script is applied, a `DELETE FROM mto_category WHERE id = 'some-category-uuid';` - will trigger the logic to reassign milestones and remove subcategories as defined. - - Assumptions: - - When deleting a top-level category, milestones are "uncategorized" by setting their - `mto_category_id` to NULL. - - For subcategories, milestones are moved up to the parent category. - - This handles one level of subcategories. If a deeper hierarchy is required, - the logic should be extended to recursively handle all descendants. -*/ - --- Drop existing trigger and function to allow clean re-creation DROP TRIGGER IF EXISTS mto_category_before_delete ON mto_category; DROP FUNCTION IF EXISTS rebalance_milestones_before_category_delete(); --- Create the trigger function that implements the custom cascading logic CREATE OR REPLACE FUNCTION rebalance_milestones_before_category_delete() RETURNS TRIGGER AS $$ DECLARE - cat_model_plan_id UUID; - is_top_level BOOLEAN; + cat_ids UUID[]; BEGIN - -- Determine if the category is top-level or a subcategory - SELECT model_plan_id, (parent_id IS NULL) INTO cat_model_plan_id, is_top_level - FROM mto_category - WHERE id = OLD.id; - - IF is_top_level THEN - -- TOP-LEVEL CATEGORY DELETION LOGIC: - -- Reassign all milestones referencing this category or its direct subcategories - -- to NULL, marking them as uncategorized. - - UPDATE mto_milestone - SET mto_category_id = NULL - WHERE mto_category_id IN ( - SELECT id FROM mto_category - WHERE parent_id = OLD.id - UNION ALL - SELECT OLD.id - ); - - -- Delete all direct subcategories of this category - DELETE FROM mto_category - WHERE parent_id = OLD.id; - - -- The category itself (OLD.id) will be deleted by the original DELETE statement - ELSE - -- SUBCATEGORY DELETION LOGIC: - -- Move all milestones from this subcategory to its parent category - UPDATE mto_milestone - SET mto_category_id = OLD.parent_id - WHERE mto_category_id = OLD.id; - - -- The subcategory (OLD.id) will be deleted by the original DELETE statement + IF pg_trigger_depth() > 1 THEN + RETURN OLD; END IF; - RETURN OLD; -- Allow the DELETE to proceed after adjustments + -- Recursively gather all descendant categories, including OLD.id + WITH RECURSIVE cat_tree AS ( + SELECT id, parent_id + FROM mto_category + WHERE id = OLD.id + UNION ALL + SELECT c.id, c.parent_id + FROM mto_category c + JOIN cat_tree t ON t.id = c.parent_id + ) + SELECT array_agg(id) INTO cat_ids FROM cat_tree; + + -- Always assign milestones to OLD.parent_id + -- If OLD.parent_id is NULL, they become uncategorized. + UPDATE mto_milestone + SET mto_category_id = OLD.parent_id + WHERE mto_category_id = ANY(cat_ids); + + -- Delete all descendant categories except the current one + DELETE FROM mto_category + WHERE id != OLD.id + AND id = ANY(cat_ids); + + RETURN OLD; END; $$ LANGUAGE plpgsql; --- Create the trigger to invoke the above function before any category deletion CREATE TRIGGER mto_category_before_delete BEFORE DELETE ON mto_category FOR EACH ROW -EXECUTE FUNCTION rebalance_milestones_before_category_delete(); +EXECUTE FUNCTION rebalance_milestones_before_category_delete(); diff --git a/pkg/graph/resolvers/mto_category_test.go b/pkg/graph/resolvers/mto_category_test.go index df6f6eca5c..4f895b7a2e 100644 --- a/pkg/graph/resolvers/mto_category_test.go +++ b/pkg/graph/resolvers/mto_category_test.go @@ -757,21 +757,35 @@ func (suite *ResolverSuite) TestMTOCategoryDelete_SubCategory() { // Create a top-level category and a single subcategory with milestones. // On deleting the subcategory: // - All milestones referencing that subcategory should be reassigned to the parent category. - // Expectation: + // Expectations: // - No error on deletion. // - Subcategory is deleted. - // - Milestones previously in the subcategory now reference the parent category. + // - Milestones previously in the subcategory now reference the parent category (not nil). plan := suite.createModelPlan("Test Subcategory Deletion") parentCategory := suite.createMTOCategory("Parent Category", plan.ID, nil) subCategory := suite.createMTOCategory("SubCategory To Delete", plan.ID, &parentCategory.ID) // Create a milestone in the subcategory - milestoneSub, err := MTOMilestoneCreateCustom(suite.testConfigs.Context, suite.testConfigs.Logger, suite.testConfigs.Principal, suite.testConfigs.Store, "SubCategory Milestone", plan.ID, &subCategory.ID) - suite.NoError(err) + milestoneSub, err := MTOMilestoneCreateCustom( + suite.testConfigs.Context, + suite.testConfigs.Logger, + suite.testConfigs.Principal, + suite.testConfigs.Store, + "SubCategory Milestone", + plan.ID, + &subCategory.ID, + ) + suite.NoError(err, "Creating a milestone in the subcategory should succeed") // Delete the subcategory - err = MTOCategoryDelete(suite.testConfigs.Context, suite.testConfigs.Logger, suite.testConfigs.Principal, suite.testConfigs.Store, subCategory.ID) + err = MTOCategoryDelete( + suite.testConfigs.Context, + suite.testConfigs.Logger, + suite.testConfigs.Principal, + suite.testConfigs.Store, + subCategory.ID, + ) suite.NoError(err, "Deleting a subcategory should succeed") // Verify subcategory no longer exists @@ -780,6 +794,70 @@ func (suite *ResolverSuite) TestMTOCategoryDelete_SubCategory() { // Verify milestone has been reassigned to parent category milestoneSubAfter, err := MTOMilestoneGetByIDLOADER(suite.testConfigs.Context, milestoneSub.ID) + suite.NoError(err, "Retrieving milestone after reassignment should not error") + suite.NotNil(milestoneSubAfter, "Retrieved milestone should not be nil after reassignment") + suite.NotNil(milestoneSubAfter.MTOCategoryID, "Milestone's MTOCategoryID should not be nil after reassignment") + + suite.Equal(parentCategory.ID, *milestoneSubAfter.MTOCategoryID, + "Milestone should now reference the parent category after subcategory deletion") +} + +func (suite *ResolverSuite) TestMTOCategoryDelete_RecursiveSubCategory() { + // This test validates that when deleting a top-level category with multiple levels of subcategories, + // all descendants are deleted and all milestones in the subtree are reassigned or uncategorized as expected. + + plan := suite.createModelPlan("Test Recursive Subcategory Deletion") + + // Create a top-level category + topCategory := suite.createMTOCategory("Top Category", plan.ID, nil) + + // Create a first-level subcategory under the top category + subCategory1 := suite.createMTOCategory("SubCategory Level 1", plan.ID, &topCategory.ID) + + // Create a second-level subcategory under subCategory1 + subCategory2 := suite.createMTOCategory("SubCategory Level 2", plan.ID, &subCategory1.ID) + + // Create milestones at various levels + milestoneTop, err := MTOMilestoneCreateCustom(suite.testConfigs.Context, suite.testConfigs.Logger, suite.testConfigs.Principal, suite.testConfigs.Store, "Milestone in Top", plan.ID, &topCategory.ID) + suite.NoError(err) + + milestoneSub1, err := MTOMilestoneCreateCustom(suite.testConfigs.Context, suite.testConfigs.Logger, suite.testConfigs.Principal, suite.testConfigs.Store, "Milestone in SubCategory 1", plan.ID, &subCategory1.ID) + suite.NoError(err) + + milestoneSub2, err := MTOMilestoneCreateCustom(suite.testConfigs.Context, suite.testConfigs.Logger, suite.testConfigs.Principal, suite.testConfigs.Store, "Milestone in SubCategory 2", plan.ID, &subCategory2.ID) + suite.NoError(err) + + // Delete the top-level category, which should recursively delete subcategories + // and reassign or uncategorize all milestones. + err = MTOCategoryDelete(suite.testConfigs.Context, suite.testConfigs.Logger, suite.testConfigs.Principal, suite.testConfigs.Store, topCategory.ID) + suite.NoError(err, "Deleting a top-level category with nested subcategories should succeed") + + // Verify top-level category no longer exists + _, err = MTOCategoryGetByID(suite.testConfigs.Context, topCategory.ID) + suite.Error(err, "Top-level category should not exist after deletion") + + // Verify first-level subcategory no longer exists + _, err = MTOCategoryGetByID(suite.testConfigs.Context, subCategory1.ID) + suite.Error(err, "First-level subcategory should not exist after recursive deletion") + + // Verify second-level subcategory no longer exists + _, err = MTOCategoryGetByID(suite.testConfigs.Context, subCategory2.ID) + suite.Error(err, "Second-level subcategory should not exist after recursive deletion") + + // Since the top-level category had a nil parent_id, all milestones should now be uncategorized (mto_category_id = NULL). + + // Check milestone that was in the top-level category + milestoneTopAfter, err := MTOMilestoneGetByIDLOADER(suite.testConfigs.Context, milestoneTop.ID) + suite.NoError(err) + suite.Nil(milestoneTopAfter.MTOCategoryID, "Milestone originally in top-level category should now be uncategorized") + + // Check milestone that was in the first-level subcategory + milestoneSub1After, err := MTOMilestoneGetByIDLOADER(suite.testConfigs.Context, milestoneSub1.ID) + suite.NoError(err) + suite.Nil(milestoneSub1After.MTOCategoryID, "Milestone originally in first-level subcategory should now be uncategorized") + + // Check milestone that was in the second-level subcategory + milestoneSub2After, err := MTOMilestoneGetByIDLOADER(suite.testConfigs.Context, milestoneSub2.ID) suite.NoError(err) - suite.Equal(parentCategory.ID, *milestoneSubAfter.MTOCategoryID, "Milestone should now reference the parent category after subcategory deletion") + suite.Nil(milestoneSub2After.MTOCategoryID, "Milestone originally in second-level subcategory should now be uncategorized") }