Salesforce Roll-Up Summary Trigger Revised

Salesforce Roll-Up Summary Trigger

Salesforce Roll-Up Summary Trigger

Why on earth would anybody want a Salesforce trigger to perform a roll-up summary type function when Salesforce already provides you with Roll-Up Summary fields?

The answer may not be apparent until you begin to be inhibited by the limitations of Roll-Up Summary fields. So what are these limitations?

  • Objects have a limit of 10 maximum roll-up summary fields
  • Objects on the master side of a master-detail relationship can have roll-up summary fields
  • Lookup relationships do not support roll-up summary fields
  • Long text area, multi-select picklist, and system fields are not supported as filters or bases for roll-up summary fields
  • Campaign roll-up summary fields do not recalculate values when Lead or Contacts are deleted

Colin Loretz and Jeff Douglas have both developed very similar solutions for combating these challenges through the use of Salesforce apex triggers:

However, both approaches still face some challenges:

  • Governor Limits – If their list<> contains more than the allowed amount (currently 10,000) then an exception will be thrown
  • Invalid Query Locator – Colin’s solutions relies on sub-query (related record) calls, which tend to break when processing thousands of records.

To address these challenges I put together an apex trigger that reduces the size of my list by taking advantage of sets, the SOQL SUM and Group By functions and the AggregateResult sObject.  I also used maps to retrieve my sum totals which eliminates the invalid query locator errors.

The following trigger sums a number field on a custom object and then updates an Opportunity number field with the total. The structure is as follows:

  • Opportunity
    • Payments (Lookup Relationship on Opportunity__c)

Salesforce Roll-Up Summary Trigger

trigger OpportunityRollUpPayments on Payment__c (after delete, after insert, after update) {

  //Limit the size of list by using Sets which do not contain duplicate elements
  set<Id> OpportunityIds = new set<Id>();

  //When adding new payments or updating existing payments
  if(trigger.isInsert || trigger.isUpdate){
    for(Payment__c p : trigger.new){
      OpportunityIds.add(p.Opportunity__c);
    }
  }

  //When deleting payments
  if(trigger.isDelete){
    for(Payment__c p : trigger.old){
      OpportunityIds.add(p.Opportunity__c);
    }
  }

  //Map will contain one Opportunity Id to one sum value
  map<Id,Double> OpportunityMap = new map <Id,Double>();

  //Produce a sum of Payments__c and add them to the map
  //use group by to have a single Opportunity Id with a single sum value
  for(AggregateResult q : [select Opportunity__c,sum(Amount__c)
    from Payment__c where Opportunity__c IN :OpportunityIds group by Opportunity__c]){
      OpportunityMap.put((Id)q.get('Opportunity__c'),(Double)q.get('expr0'));
  }

  List<Opportunity> OpportunitiesToUpdate = new List<Opportunity>();

  //Run the for loop on Opportunity using the non-duplicate set of Opportunities Ids
  //Get the sum value from the map and create a list of Opportunities to update
  for(Opportunity o : [Select Id, Total_Payments__c from Opportunity where Id IN :OpportunityIds]){
    Double PaymentSum = OpportunityMap.get(o.Id);
    o.Total_Payments__c = PaymentSum;
    OpportunitiesToUpdate.add(o);
  }

  update OpportunitiesToUpdate;
}

I have tested this method and have successfully updated over 10,000 Opportunity records with over 35,000 payment records through the bulk-api, so I feel good about it. When I get around to it I’ll write up the test class and publish it here.

Update: Here’s the test class.

@isTest
private class TestOpportunityRollUpPayments {

    static testMethod void myUnitTest() {

        Profile pf = [Select Id from Profile where Name = 'System Administrator'];

        User u = new User();
        u.FirstName = 'Test';
        u.LastName = 'User';
        u.Email = 'testuser@test123456789.com';
        u.CompanyName = 'test.com';
        u.Title = 'Test User';
        u.Username = 'testuser@test123456789.com';
        u.Alias = 'testuser';
        u.CommunityNickname = 'Test User';
        u.TimeZoneSidKey = 'America/Mexico_City';
        u.LocaleSidKey = 'en_US';
        u.EmailEncodingKey = 'ISO-8859-1';
        u.ProfileId = pf.Id;
        u.LanguageLocaleKey = 'en_US';
        insert u;

        system.runAs(u){

            Opportunity o = new Opportunity();
            o.Name = 'Test Opportunity';
            o.StageName = 'Closed Won';
            o.CloseDate = system.today();
            insert o;

            system.assertEquals(o.Total_Payments__c, null);

            //Test payments on insert
            Payment__c p1 = new Payment__c();
            p1.Opportunity__c = o.Id;
            p1.Amount__c = 100;
            insert p1;

            Opportunity ou1 = [select Total_Payments__c from Opportunity where Id = :o.Id];
            system.assertEquals(ou1.Total_Payments__c,p1.Amount__c);

            //Test payments on update
            Payment__c p1u = [select Amount__c from Payment__c where Id = :p1.Id];
            p1u.Amount__c = 200;
            update p1u;

            Opportunity ou2 = [select Total_Payments__c from Opportunity where Id = :o.Id];
            system.assertEquals(ou2.Total_Payments__c,p1u.Amount__c);

            //Test payments on second insert
            Payment__c p2 = new Payment__c();
            p2.Opportunity__c = o.Id;
            p2.Amount__c = 800;
            insert p2;

            AggregateResult ag1 = [select sum(Amount__c) from Payment__c where Opportunity__c = :o.Id];

            Opportunity ou3 = [select Total_Payments__c from Opportunity where Id = :o.Id];
            system.assertEquals(ou3.Total_Payments__c,ag1.get('expr0'));

            //Test payment on delete
            delete p2;

            AggregateResult ag2 = [select sum(Amount__c) from Payment__c where Opportunity__c = :o.Id];

            Opportunity ou4 = [select Total_Payments__c from Opportunity where Id = :o.Id];
            system.assertEquals(ou4.Total_Payments__c,ag2.get('expr0'));

        }

    }

}

To see this trigger in action jump over to the roll-up summary trigger demo.

Do you see any holes in my code? Please point them out in the comment section below :)

  • Benjaminplunkett

    Hi Anthony,

    Thanks for this code, I think it’s just what I’m after. I’m new to apex and code in general so I was wondering if you could assist with an error that I keep getting with it.

    Now the object I am rolling up has a master detail relation ship with the parent (both custom objects). The reason I have not used a normal roll up summary field is that I wish to track changes to the roll up in a chatter feed.

    The error relates to the first image below. the error I get is in the second image. The complete adapted trigger is in the 3rd.

    Any assistance would be awesome

    Thanks

    Ben

  • Ben

    Sorry I forgot to add the full trigger

  • Anonymous

    Benjamin,

    Try using Double instead of Integer. Aggregate sums are returned as Double anyways.

    map ChangeRequestMap = new map ();

    If you’re still experiencing the same problem, feel free to email me the full code to anthony.victorio@gmail.com so I can try it out on my end :)

  • Anonymous

    Benjamin,

    Try using Double instead of Integer. Aggregate sums are returned as Double anyways.

    map ChangeRequestMap = new map ();

    If you’re still experiencing the same problem, feel free to email me the full code to anthony.victorio@gmail.com so I can try it out on my end :)

  • Ben

    Hey, yes I tried that first but got the same error. As it happens I have tried a different approach with has actually worked out better for what I wanted.

    a) a normal roll up for the count
    b) trigger to update a date/time field on the parent each time a new child is added, which can be displayed on the chatter feed

    thanks for your help

  • colemab

    Outstanding post from a functionality standpoint and A++ post from a write-up standpoint. Thank you sir for the excellent lesson.

  • Sonu

    Thanks Anthony,
    This helped me a lot..but i have quick question.. i need to put filter on the Amount__c field. how can i do that?? can you please help me out with this issue..

    thanks,

  • SACHIN AGARWAL

    I think when you create a trigger to maintain value as like roll-up summary field,
    then trigger on “UNDELETE” is also required
    otherwise it update the wrong value when we undelete the child record.

  • http://www.anthonyvictorio.com Anthony Victorio

    See line 25 and 26 where the aggregate query is located. In there you can add a “where” statement for your filter. For example:

    [select Opportunity__c,sum(Amount__c) from Payment__c where Opportunity__c IN :OpportunityIds and Category = 'Filtered Category' group by Opportunity__c]

    In the above example ” Category = ‘Filtered Category’ ” is our filter.

  • http://www.anthonyvictorio.com Anthony Victorio

    Sachin, you’re absolutely correct. I’ll update the trigger and post the revision soon.

  • Sonu

    thanks for helping me out. 
    i was not able to filter it out. i have field called manager__c text field. critiria is manager__c contains “TR” and status is active sum the Amount field and update it into the parent object. can you please help me out how to use the contains data type here..

    thanks, 

  • Sonu

    I have 1 more question. how can we use this code to rollup more than one field??

  • Sidagarwal22

    Hey Anthony
    Nice post, I have a quick question.What if i am using Max aggregate function, and want the Id of the Payment record which contains the Max Amount.Right now the following SOQl returns the Oppurtunity ID with Expr0.I need, Oppurtunity ID, PaymentID with Expr0.Is that possible?

    Thanks
    Sid

  • indy

    trigger AccountRollUpContracts on Contract (after delete, after insert, after update) {

      //Limit the size of list by using Sets which do not contain duplicate elements
      set AccountIds= new set();

      //When adding new payments or updating existing payments
      if(trigger.isInsert || trigger.isUpdate){
        for(Contract p : trigger.new){
          AccountIds.add(p.Account);
        }
      }

      //When deleting payments
      if(trigger.isDelete){
        for(Contract p : trigger.old){
          AccountIds.add(p.Account);
        }
      }

      //Map will contain one Account Id to one sum value
      map AccountMap = new map ();

      //Produce a sum of Contracts and add them to the map
      //use group by to have a single Account Id with a single sum value
      for(AggregateResult q : [select Account,sum(Amount__c)
        from Contract where Account IN :AccountIdsgroup group by Account]){
          AccountMap.put((Id)q.get(‘Account’),(Double)q.get(‘expr0′));
      }

      List AccountToUpdate = new List();

      //Run the for loop on Account using the non-duplicate set of Opportunities Ids
      //Get the sum value from the map and create a list of Opportunities to update
      for(Account o : [Select Id, Contract_Count__c from Account where Id IN :AccountIds]){
        Double PaymentSum = AccountMap.get(o.Id);
        o.Amount__c= PaymentSum;
        AccountToUpdate .add(o);
      }

      update AccountToUpdate;
    }

    I am having and issue in line 25:
    //use group by to have a single Account Id with a single sum value

      for(AggregateResult q : [select Account,sum(Amount__c)

    Amount__c is a Contract custom number field

    Error: Compile Error: Invalid field: ‘Account’ at line 25 column 27
    Thank you!!

  • indy

    trigger AccountRollUpContracts on Contract (after delete, after insert, after update) {

      //Limit the size of list by using Sets which do not contain duplicate elements
      set AccountIds= new set();

      //When adding new payments or updating existing payments
      if(trigger.isInsert || trigger.isUpdate){
        for(Contract p : trigger.new){
          AccountIds.add(p.Account);
        }
      }

      //When deleting payments
      if(trigger.isDelete){
        for(Contract p : trigger.old){
          AccountIds.add(p.Account);
        }
      }

      //Map will contain one Account Id to one sum value
      map AccountMap = new map ();

      //Produce a sum of Contracts and add them to the map
      //use group by to have a single Account Id with a single sum value
      for(AggregateResult q : [select Account,sum(Amount__c)
        from Contract where Account IN :AccountIdsgroup group by Account]){
          AccountMap.put((Id)q.get(‘Account’),(Double)q.get(‘expr0′));
      }

      List AccountToUpdate = new List();

      //Run the for loop on Account using the non-duplicate set of Opportunities Ids
      //Get the sum value from the map and create a list of Opportunities to update
      for(Account o : [Select Id, Contract_Count__c from Account where Id IN :AccountIds]){
        Double PaymentSum = AccountMap.get(o.Id);
        o.Amount__c= PaymentSum;
        AccountToUpdate .add(o);
      }

      update AccountToUpdate;
    }

    ______________________________________________________________________ç

    I am having and issue in line 25:
    //use group by to have a single Account Id with a single sum value

      for(AggregateResult q : [select Account,sum(Amount__c)

    Amount__c is a Contract custom number field

    Error: Compile Error: Invalid field: ‘Account’ at line 25 column 27

    Thank you!!

  • Marco

    When updating child and update of payment (child) is actually changing opportunity (parent) from one opportunity to another, i belive it is necessary also to update also “old” parent. So

    something like this need to be added

      if(trigger.isUpdate){

        for(Payment__c p : trigger.old){

          OpportunityIds.add(p.Opportunity__c);

        }

    after

      if(trigger.isInsert || trigger.isUpdate){    for(Payment__c p : trigger.new){
          OpportunityIds.add(p.Opportunity__c);
    }

  • David Cheng

    Hello Anthony.  This is an excellent post, but there is a hidden bug.  If you delete the last payment record on the opp, the summary does not calculate because the AggregateResult query finds 0 payment records for the opp and thus does not include the opp.

    My approach is: before the aggregateresult query, I create a map of opp records where each opp record contains the opp ID and a nulled-out summary field.  Then I loop through the aggregateresult records and assign the values to the appropriate opps.  Finally I run  update the map.values().  That way, if an opp’s last payment was deleted, the summary is  is correctly zeroed/nulled out.