Skip to content

Commit

Permalink
feat: updated category delete trigger to recurse
Browse files Browse the repository at this point in the history
  • Loading branch information
OddTomBrooks committed Dec 11, 2024
1 parent 3c2991e commit 57db03f
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 80 deletions.
102 changes: 28 additions & 74 deletions migrations/V193__Add_MTO_Category_Delete_Trigger.sql
Original file line number Diff line number Diff line change
@@ -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();
90 changes: 84 additions & 6 deletions pkg/graph/resolvers/mto_category_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}

0 comments on commit 57db03f

Please sign in to comment.