Project 4: Clone full Campaign records and hierarchies using recursion and cloning in Apex with the click of a button

Posted by

5 min read ~ Hello readers! I came across an interesting problem that required some cool patterns and intentional thought about the architecture and wanted to share in case anyone else found themselves needing to solve something similar. Per usual, I like to start with a made up business case that gives us insight into the value it will provide.

Jill uses Salesforce Campaigns to track annual marketing initiatives such as marketing events like demos and conferences. Typically, because of the reoccurring nature of the events every year, these events end up getting manually duplicated which is very time consuming and costly to crucial resources with other responsibilities. The goal is to generate a shell (clone) of the campaigns and then eventually allocate associated contacts down the road as they get added based on the new attendees for the event.

Technical topics that are covered in this blog post:

Where do we start? Well, we know that all of the Campaign records are either a parent or a child of a parent. My first thought was: “Okay great. What is a useful programmatic pattern that gives us the ability to iterate through a tree of elements with no known stopping point”. Remember, the limit is 5 levels deep, however it could only be 2 levels down on some or 4 levels down on others. We won’t really ever know and our application should scale to this expectation. So back to our question: How do we iterate through a branch of records with no knowledge of how far the depth goes? The answer is Recursion. Consider this algorithm:

const getSum = (n) => {
  if (n === 0) {
    return 0;
  } else {
    return getSum(n - 1) + n;
  }
}

console.log(getSum(5)) // Output is 15

If we break this algorithm down and examine the order of execution. Lets look at this very artistic drawing that I made to illustrate it:

The first and most important part to writing a recursive algorithm is establishing a base case. The whole point is to take a large problem and break it up into smaller chunks until you reach a point thats no longer applicable to the algorithm. As you can see from the drawing above, when we move down the tree, we stop at the base case and then start returning back out out of it. In highlighted line 5, before anything else happens, we have to compute getSum(n -1) before we start returning evaluating the second part adding the value of n. So let’s walk through the tree. We hit the base case and return 0 so we move onto evaluating 1.

  • Base case of 0 will return 0
  • 0 + 1 evaluates to 1
  • 1 + 2 evaluates to 3
  • 3 + 3 evaluates to 6
  • 6 + 4 evaluates to 10
  • 10 + 5 evaluates to 15

Just so you know, there is a way to solve this iteratively. This might give you some added perspective on what the recursive function is actually doing above. Below we are using a while loop to increment our counter and manage the base case of evaluating all numbers that are not 0:

let num = 5;
let count = 0;
while (num > 0) {
    count += num;
    num -= 1;
}

console.log(count); // Output is 15

Now that we’ve taken a look at a simple example, lets look at the Apex code:

public class generateClonedCampaignHierarchy {

    @AuraEnabled
    public static void generateCampaignHierarchy(Id recordId) {
        Set<Id> campaignIds = new Set<Id>{recordId};
        // Use recursion to get the full tree of Ids of Campaigns
        campaignIds.addAll(getCampaignHierarchy(campaignIds));

        // Generate a list of the original campaigns we want to clone
        List<Campaign> origCampaigns = [SELECT Id, Name, ParentId FROM Campaign WHERE Id IN :campaignIds];

        Map<Id, Campaign> oldCampaignToNewCampaignMap = new Map<Id, Campaign>();
        for (Campaign camp : origCampaigns) {
            // Second paramter of clone to true to generate a copy of the record but not the Id
            Campaign newCamp = camp.clone(false, true, false, false);
            oldCampaignToNewCampaignMap.put(camp.Id, newCamp);
        }

        try {
            insert oldCampaignToNewCampaignMap.values();
        } catch (exception ex) {
            System.debug('The insert of the records has failed! Please see message for more info: '  + ex.getMessage());
        }
        
        // From the map values, loop over them and check to see if the parent Id contains the old Id stored in the map. If so, reassign the recoords parent id to its new record valuee
        List<Campaign> campaignsToUpdate = new List<Campaign>();
        for (Campaign c : oldCampaignToNewCampaignMap.values()) {
            if (oldCampaignToNewCampaignMap.get(c.ParentId) != null) {
                // Reparent the new records using the map previously created
                c.ParentId = oldCampaignToNewCampaignMap.get(c.ParentId).Id;
            }
            // Add any other changes you want to the new campaign records. Ex:
            if (c.Name.contains('Parent')) {
                c.Name = 'Other Parent';
            }
            campaignsToUpdate.add(c);
        }

        try {
            update campaignsToUpdate;
        } catch (exception ex) {
            System.debug('The update of the records has failed! Please see message for more info: '  + ex.getMessage());
        }
    }

    public static Set<Id> getCampaignHierarchy(Set<Id> campaignIds) {
        
        Set<ID> campIds = new Set<ID>();
        // Query for all of the campaigns that contain the parent ids passed in from the argument
        for (Campaign camp :[SELECT Id FROM Campaign where ParentId IN :campaignIds AND ParentId != null]) {
            campIds.add(camp.Id);
        }

        // if the set isnt empty after we query, re-enter the methood invocation and recursively call it until its empty
        if(!campIds.isEmpty()) {
            campIds.addAll(getCampaignHierarchy(campIds));
        }
        return campIds;
    }
}

Using the same concepts as above, we want to recursively iterate through a tree of campaigns. In the method getCampaignHierarchy(campaignIds), we initially pass in the parent Id and use it to go find all child Campaign Ids that are under the parent. When we find them, they get added to the set of Ids and serve as the new parent ids that are used to go find the the next set . of children down the tree. Here is a visual of the tree:

Representation of the tree we are recursively iterating through

On the highlighted line above, if we find other IDs being retrieved from the query, the method invokes itself again and goes deeper in the tree until it can no longer find any retrieved records making the base case an empty set of campaign Ids. Pretty cool, right??

After we have a full set of all Ids that we care about wanting to clone, we now perform a query to grab all of the original Campaigns. This is where things get interesting again. Not only will I have to duplicate (clone) the records, but I will also need to maintain the structure of the parent to child relationships and re-parent the records to their respective parent record. But how?….

Well, let’s look at what we have. Apex gives us the ability to clone records and maintain the state of the record (fields and all) and ditch the original Id assignment. So if we iterate over the original records, we can generate a map of old campaign id to new cloned campaign shell. This old Id will serve a very important purpose.

Now we insert our new Campaign records. So what do we have? We have a list of original records and we have a map of the old Id and its associated new Campaign record. Now all we need to do is loop over the map values and check to see if if the parent Id of the new record (still holding the old value) is equal to any of the values in the keyset of the map. If it is, then reparent the new record with the mapped value. This allows us to maintain the hierarchy of information and print the tree accordingly. Here is a test class and a TestFactory that I generated for the class above.

@isTest
public class generateClonedCampaignHierarchyTest {

    @TestSetup
    static void makeData(){
        List<Campaign> createParent = TestFactory.createCampaigns(1, 'Parent');
        createParent[0].ParentId = null;
        insert createParent;

        List<Campaign> children = TestFactory.createCampaigns(3, 'Child');
        for (Campaign child : children) {
            child.ParentId = createParent[0].Id;
        }
        insert children;

        List<Campaign> grandChildren = TestFactory.createCampaigns(6, 'GrandChild');
        for (Integer i = 0; i < grandChildren.size(); i++) {
            if (i <= 2)  {
                grandChildren[i].ParentId = children[0].Id;
            } else if (i <= 4) {
                grandChildren[i].ParentId = children[1].Id;
            } else {
                grandChildren[i].ParentId = children[2].Id;
            }
        }
        insert grandChildren;
    }

    @isTest
    static void test_generateCampaignHierarchy() {
        Id parentId;
        List<Campaign> campaigns = [SELECT Id, ParentId, Name FROM Campaign];
        for  (Campaign camp : campaigns) {
            if (camp.ParentId == null)  {
                parentId = camp.Id;
                break;
            }
        }

        Test.startTest();
        generateClonedCampaignHierarchy.generateCampaignHierarchy(parentId);
        Test.stopTest();

        Campaign newParent = [SELECT Id, ParentId, Name FROM Campaign WHERE Name = 'Other Parent'];
        System.assert(newParent != null, 'The new parent value should have been generated');
        
        Set<Id> campaignIds = new Set<Id>{newParent.Id};
        campaignIds.addAll(generateClonedCampaignHierarchy.getCampaignHierarchy(campaignIds));
        System.assertEquals(10, campaignIds.size(), 'The remainder of the hierarchy thats generated should be 10: 1 parent, 3 children and 6 grandchildren');
    }
}
public class TestFactory {
    
    public static List<Campaign> createCampaigns(Integer numbOfRecords, String hierarchy) {
        List<Campaign> campaignsToReturn = new List<Campaign>();
        for (Integer i = 0; i <  numbOfRecords; i++) {
            Campaign camp = new Campaign();
            camp.Name = hierarchy + ' ' + String.valueOf(i);
            camp.isActive = true;
            campaignsToReturn.add(camp);
        }
        return campaignsToReturn;
    }
}

If you did want to utilize a Visualforce Page or a Lightning Quick Action thats invoked from the record and hierarchy you are wanting to clone, simply pass in the record Id into the generateCampaignHierarchy() method and let the class handle the rest!

Please reach out if you have any questions or if you have more efficient way to solve this! I would love to learn from you. Happy Coding!

One comment

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s