From 59ecc3d6f5289c9206d36147b629080f12b2788f Mon Sep 17 00:00:00 2001
From: James Simone <16430727+jamessimone@users.noreply.github.com>
Date: Tue, 29 Jun 2021 09:21:24 -0600
Subject: [PATCH] V1.2.31 - Beginnings of Rollup Logger framework, massive
refactor (#129)
* Start of work on #68 - begin to flesh out Rollup Logger plugin, with beginning options for Nebula Logger and simple logging framework
* Fixes #125 by making the date/time/datetime comparisons as safe as possible
* Fix CONCAT issue reported by Katherine West
* Fixing RollupLogger instance lazy loading
* Start of work to reproduce issues with multiple DML statements in the same transaction on objects with deferred Rollup actions called from Flow
* Adding LWC coverage to deployment pipeline
* Aligning flow engine, DLRS migration script, and LWC for Rollup Recalc App to use the same field labels/positions
* Fix two straggler issues from invocable refactor - ensure grouped invocable rollups don't add the same item twice, and ensure count-based rollups properly set the recalculated value prior to returning it
* Fixing issue with map key for CACHED_ROLLUPS + invocables. Bumped beta package version and beta links
* Rollup class minimization (#128)
* break apart mono-Rollup class
* Last (?) of improvements to recursion detection after adding recursive updates to Flow integration test in extra-tests/InvocableDrivenTests.cls. Because of the way that flow alternately boxcars/queues up different updates, it's doubly important here to make sure that the records are passing through in the right order to properly trigger detection on events like reparenting AND prevent recursive updates from running unless there's been a definitive change to the calc item/rollup operation in question
* Properly calculate parent items with more than 2000 child records in RollupFullBatchRecalculator
* Pinned to specific LWC jest version to avoid API version issue with VS Code test runner, added test coverage for LWC and added missing RollupLogger coverage
* Bumping package version from Github Action
---
.github/workflows/deploy.yml | 15 +-
.gitignore | 3 +-
README.md | 54 +-
extra-tests/classes/InvocableDrivenTests.cls | 55 +
.../classes/InvocableDrivenTests.cls-meta.xml | 5 +
.../classes/RollupIntegrationTests.cls | 7 +-
...ltiple_Deferred_Case_Rollups.flow-meta.xml | 496 ++++++++
.../fields/DateField__c.field-meta.xml | 9 +
extra-tests/objects/Case/Case.object-meta.xml | 27 +
.../Case/fields/Amount__c.field-meta.xml | 13 +
.../Case/fields/DateField__c.field-meta.xml | 9 +
extra-tests/profiles/Admin.profile-meta.xml | 15 +
package.json | 6 +-
.../rollupForceRecalculation.test.js | 99 +-
.../rollupForceRecalculation.html | 24 +-
.../rollupForceRecalculation.js | 4 +-
rollup/core/classes/Rollup.cls | 1124 +++--------------
rollup/core/classes/RollupAsyncProcessor.cls | 962 ++++++++++++++
.../classes/RollupAsyncProcessor.cls-meta.xml | 5 +
rollup/core/classes/RollupCalculator.cls | 5 +-
rollup/core/classes/RollupEvaluator.cls | 77 +-
.../core/classes/RollupFieldInitializer.cls | 6 -
.../classes/RollupFullBatchRecalculator.cls | 52 +-
rollup/core/classes/RollupLogger.cls | 54 +-
rollup/core/classes/RollupQueryBuilder.cls | 21 +-
rollup/core/classes/RollupRecursionItem.cls | 5 +-
.../RollupControl.Org_Defaults.md-meta.xml | 4 +
..._mdt-Rollup Control Layout.layout-meta.xml | 8 +-
.../fields/RollupLoggerName__c.field-meta.xml | 13 +
rollup/tests/RollupCalculatorTests.cls | 23 +-
rollup/tests/RollupEvaluatorTests.cls | 144 ++-
rollup/tests/RollupLoggerTests.cls | 64 +
rollup/tests/RollupLoggerTests.cls-meta.xml | 5 +
rollup/tests/RollupTests.cls | 71 +-
scripts/convert-dlrs-rules.apex | 29 +-
sfdx-project.json | 7 +-
36 files changed, 2412 insertions(+), 1108 deletions(-)
create mode 100644 extra-tests/classes/InvocableDrivenTests.cls
create mode 100644 extra-tests/classes/InvocableDrivenTests.cls-meta.xml
create mode 100644 extra-tests/flows/Rollup_Integration_Multiple_Deferred_Case_Rollups.flow-meta.xml
create mode 100644 extra-tests/objects/Account/fields/DateField__c.field-meta.xml
create mode 100644 extra-tests/objects/Case/Case.object-meta.xml
create mode 100644 extra-tests/objects/Case/fields/Amount__c.field-meta.xml
create mode 100644 extra-tests/objects/Case/fields/DateField__c.field-meta.xml
create mode 100644 rollup/core/classes/RollupAsyncProcessor.cls
create mode 100644 rollup/core/classes/RollupAsyncProcessor.cls-meta.xml
create mode 100644 rollup/core/objects/RollupControl__mdt/fields/RollupLoggerName__c.field-meta.xml
create mode 100644 rollup/tests/RollupLoggerTests.cls
create mode 100644 rollup/tests/RollupLoggerTests.cls-meta.xml
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 69c6056b..1f684954 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -52,6 +52,16 @@ jobs:
- name: 'Run LWC Unit Tests'
run: npm run test:lwc
+ - name: 'Upload code coverage for LWC to Codecov.io'
+ uses: codecov/codecov-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ flags: LWC
+
+ - name: 'Delete LWC test files after codecov upload'
+ run: |
+ rm coverage/ -rf
+
# Install Salesforce CLI
- name: Install Salesforce CLI
run: |
@@ -74,11 +84,12 @@ jobs:
shell: pwsh
run: '. ./scripts/test.ps1'
- # Upload code coverage data
- - name: 'Upload code coverage for Apex to Codecov.io'
+ # Upload Apex code coverage data
+ - name: 'Upload Apex code coverage for Apex to Codecov.io'
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
+ flags: Apex
# Only create new package versions if a PR is pointed to main or we are merging to main
- name: 'Package & Promote'
diff --git a/.gitignore b/.gitignore
index 03bc9a8f..cbc2288e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,5 @@ debug.log
DEVHUB_SFDX_URL.txt
PACKAGING_SFDX_URL.txt
tests/apex
-main/default/
\ No newline at end of file
+main/default/
+coverage/
\ No newline at end of file
diff --git a/README.md b/README.md
index a1a5be2e..fd39023a 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,12 @@ Create fast, scalable custom rollups driven by Custom Metadata in your Salesforc
### Package deployment options
-
+
-
+
@@ -167,6 +167,7 @@ These are the fields on the `Rollup Control` custom metadata type:
- `Batch Chunk Size` - (defaults to 2000) - The amount of records passed into each batchable job in the event that Rollup batches. Default is 2000, which is the vanilla Salesforce default for batch jobs.
- `Is Rollup Logging Enabled` - (defaults to false) - Check this box in order to debug your rollups. Debug information is included in a few mission-critical pieces of Rollup to provide you with more information about where exactly an error might be occurring, should you encounter one.
- `Is Merge Reparenting Enabled` - (defaults to true) - By default, if there is an `after delete` trigger context for Account / Case / Contact / Lead where Rollup is being used and one or more of those records is merged, Rollup goes and updates any children records from the old lookup to the new lookup automatically prior to recalculating rollup values. If you have pre-existing merge handling covered in your org by some other means, you should disable this checkbox and ensure that Rollup is only called _after_ your pre-existing merge handling has run.
+- `Rollup Logger Name` - (optional) - By default, if `Is Rollup Logging Enabled` is checked, logs associated with Rollup can be seen by keeping the Salesforce Developer Console open while inserting/updating records, or by starting a Debug Trace for a user. You also have the option of customizing how logs are displayed/stored. For more information, see the [Rollup Logging](#rollup-logging) section.
### Flow / Process Builder Invocable
@@ -188,27 +189,27 @@ Here are the arguments necessary to invoke `Rollup` from a Flow / Process Builde
- `Object for "Records To rollup" (input)` - comes from your calculation items, and their SObject type should be selected accordingly. If you are rolling up from Opportunity to Account, you would select Opportunity as the type
- `Object for "Prior records To rollup" (input)` - should be the same as the above
+- `Calc Item Calc Field` - the API Name of the field you’d like to aggregate (let's say Amount)
+- `Calc Item Lookup Field`- the API Name of the field storing the Id or String referencing a unique value on another object (In the example, Id)
+- `Rollup Object Lookup Field` - the API Name of the field on the lookup object that matches the value stored in `Lookup Field On Calc Item`
+- `Rollup Object Calc Field` - the API Name of the field on the lookup object where the rolled-up values will be stored (I've been using AnnualRevenue on the account as an example)
+- `Rollup Operation` - the operation you're looking to perform. Acceptable values are SUM / MIN / MAX / AVERAGE / COUNT / COUNT_DISTINCT / CONCAT / CONCAT_DISTINCT / FIRST / LAST. Both CONCAT and CONCAT_DISTINCT separate values with commas by default, but you can use `Concat Delimiter` to change that.
+- `Rollup Operation Context` - INSERT / UPDATE / UPSERT / DELETE. **Special note** - unless you are using a Record-Triggered Flow / After Update Process Builder, you almost assuredly want to simply use the INSERT context (see image below). However, you _would_ use something like UPDATE if, after retrieving records using Get Records in an auto-launched flow, you then looped through your collection and modified fields prior to sending them to `Rollup`. You would only ever use UPSERT for a record-triggered flow triggering on `A record is created or updated`
- `Records To Rollup` - a collection of SObjects. These need to be stored in a collection variable. **Note** - while this is an optional property, that is only because of a bug in the Flow engine caused by `Get Records` returning null when you use filter conditions that in turn make it so that no records are returned - which then throws an error when using this action without explicitly checking the collection returned by `Get Records` to see if it is null. Because that's a lot to ask of the Flow user, we instead let the collection be optional and handle the null check in Apex. You should **always** provide a value for this input!
- `Prior records to rollup` - another collection of SObjects. For record-triggered flows set to run when `A record is created or updated`, or `A record is updated`, it's necessary to populate this argument - otherwise, Rollup will helpfully throw an error when you attempt to update records. Add `{!$Record__Prior}` to a collection variable and use that collection to populate this argument
-- `Calc Item Rollup Field` - the API Name of the field you’d like to aggregate (let's say Amount)
-- `Lookup Field On Calc Item`- the API Name of the field storing the Id or String referencing a unique value on another object (In the example, Id)
-- `Lookup Field On Lookup Object` - the API Name of the field on the lookup object that matches the value stored in `Lookup Field On Calc Item`
-- `Rollup Field On Lookup Object` - the API Name of the field on the lookup object where the rolled-up values will be stored (I've been using AnnualRevenue on the account as an example)
-- `Rollup Context` - INSERT / UPDATE / UPSERT / DELETE. **Special note** - unless you are using a Record-Triggered Flow / After Update Process Builder, you almost assuredly want to simply use the INSERT context (see image below). However, you _would_ use something like UPDATE if, after retrieving records using Get Records in an auto-launched flow, you then looped through your collection and modified fields prior to sending them to `Rollup`. You would only ever use UPSERT for a record-triggered flow triggering on `A record is created or updated`
-- `Rollup Operation` - the operation you're looking to perform. Acceptable values are SUM / MIN / MAX / AVERAGE / COUNT / COUNT_DISTINCT / CONCAT / CONCAT_DISTINCT / FIRST / LAST. Both CONCAT and CONCAT_DISTINCT separate values with commas by default, but you can use `Concat Delimiter` to change that.
+- `Calc item Changed fields` (optional) - comma-separated list of field API Names to filter items from being used in the rollup calculations unless all the stipulated fields have changed
+- `Calc Item Type When Rollup Started From Parent` (optional) - only necessary to provide if `Is Rollup Started From Parent` field is enabled and set to `{!$GlobalConstant.True}`. Normally in this invocable, the calc item type is figured out by examining the passed-in collection - but when the collection is the parent records, we need the SObject API name of the calculation items explicitly defined.
- `Concat Delimiter` (optional) - for `CONCAT` and `CONCAT_DISTINCT` operations, the delimiter used between text defaults to a comma (unless you are rolling up to a multi-select picklist, in which case it defaults to a semi-colon), but you can override the default delimiter here. At this time, only single character delimiters are supported - please file [an issue](/issues) if you are looking to use multi-character delimiters!
-- `Calc item changed fields` (optional) - comma-separated list of field API Names to filter items from being used in the rollup calculations unless all the stipulated fields have changed
+- `Defer Processing` (optional, default `false`) - when checked and set to `{!$GlobalConstant.True}`, you have to call the separate invocable method `Process Deferred Rollups` at the end of your flow. Otherwise, each invocable action kicks off a separate queueable/batch job. **Note** - for extremely large flows calling dozens of rollup operations, it behooves the end user / admin to occasionally call the `Process Deferred Rollups` invocable action to separate rollup operations into different jobs. You'll avoid running out of memory by doing so. See the "Process Deferred Rollups" section below for more info.
- `Full Recalculation Default Number Value` (optional) - for some rollup operations (SUM / COUNT-based operations in particular), you may want to start fresh with each batch of calculation items provided. When this value is provided, it is used as the basis for rolling values up to the "parent" record (instead of whatever the pre-existing value for that field on the "parent" is, which is the default behavior). **NB**: it's valid to use this field to override the pre-existing value on the "parent" for number-based fields, _and_ that includes Date / Datetime / Time fields as well. In order to work properly for these three field types, however, the value must be converted into UTC milliseconds. You can do this easily using Anonymous Apex, or a site such as [Current Millis](https://currentmillis.com/).
- `Full Recalculation Default String Value` (optional) - same as `Full Recalculation Default Number Value`, but for String-based fields (including Lookup and Id fields).
-- `SOQL Where Clause To Exclude Calc Items` (optional) - add conditions to filter the calculation items that are used. **Note** - the fields, especially parent-level fields, _must_ be present on the calculation items or the filtering will not work correctly. For currency or number fields with multiple decimals, keep in mind that however the number appears in a SOQL query (ie `4.00`) is the format that you should use when performing filtering; `Amount != 4` will not work if the value is stored as `4.00`. The only exception to this is zero; there, you are allowed to omit the decimal places.
+- `Grandparent Relationship Field Path` (optional) - if [you are rolling up to a grandparent (or greater) parent object](#grandparent-rollups), use this field to establish the full relationship name of the field, eg from Opportunity Line Items directly to an Account's Annual Revenue: `Opportunity.Account.AnnualRevenue` would be used here. The field name should match up with what is being used in `Rollup Field On Lookup Object`. Please see the caveats in the linked section for more information on how to set up your rollups correctly when using this feature.
- `Is Full Record Set` (optional) - by default, if the records you are passing in comprise the full set of child items for a given lookup item but none of them "qualify" to be rolled up (either due to the use of the Calc Item Where Clause, Changed Fields On Calc Item, or a custom Evaluator), Rollup aborts early. If you know you have the exhaustive list of records to be used for a given lookup item **and** you stipulate the Full Recalculation Default Number (or String) Value, you can override the existing rollup item's amount by toggling this field
-- `Order By (First/Last)` (optional) - at present, only valid when FIRST/LAST is used as the Rollup Operation. This is the API name of a text/date/number-based field that you would like to order the calculation items by. Like DLRS, this field is optional on a first/last operation, and if a field is not supplied, the `Rollup field On Calc Item` is used.
-- `Defer Processing` (optional, default `false`) - when checked and set to `{!$GlobalConstant.True}`, you have to call the separate invocable method `Process Deferred Rollups` at the end of your flow. Otherwise, each invocable action kicks off a separate queueable/batch job. **Note** - for extremely large flows calling dozens of rollup operations, it behooves the end user / admin to occasionally call the `Process Deferred Rollups` invocable action to separate rollup operations into different jobs. You'll avoid running out of memory by doing so. See the "Process Deferred Rollups" section below for more info.
- `Is Rollup Started From Parent` (optional, defaults to `{!$GlobalConstant.False}`) - set to `{!$GlobalConstant.True}` if collection being passed in is the parent SObject, and you want to recalculate the defined rollup operation for the passed in parent records. Used in conjunction with `Calc Item Type When Rollup Started From Parent`. If you are using `Is Rollup Started From Parent` and grandparent rollups with Tasks/Events (or anything with a polymorphic relationship field like `Who` or `What` on Task/Event; the `Parent` field on `Contact Point Address` is another example of such a field), you **must** also include a filter for `What.Type` or `Who.Type` in your `Calc Item Where Clause` in order to proceed, e.g. `What.Type = 'Account'`.
-- `Calc Item Type When Rollup Started From Parent` (optional) - only necessary to provide if `Is Rollup Started From Parent` field is enabled and set to `{!$GlobalConstant.True}`. Normally in this invocable, the calc item type is figured out by examining the passed-in collection - but when the collection is the parent records, we need the SObject API name of the calculation items explicitly defined.
-- `Grandparent Relationship Field Path` (optional) - if [you are rolling up to a grandparent (or greater) parent object](#grandparent-rollups), use this field to establish the full relationship name of the field, eg from Opportunity Line Items directly to an Account's Annual Revenue: `Opportunity.Account.AnnualRevenue` would be used here. The field name should match up with what is being used in `Rollup Field On Lookup Object`. Please see the caveats in the linked section for more information on how to set up your rollups correctly when using this feature.
-- `Rollup To Ultimate Parent` (optional) - Check this box if you are rolling up to an Account, for example, and use the `Parent Account ID` field on accounts, _and_ want the rolled up value to only be used on the top-level account. Can be used with any hierarchy lookup or lookup back to the same object. Must be used in conjunction with `Ultimate Parent Lookup` (below), and _can_ be used in conjunction with `Grandparent Relationship Field Path` (if the hierarchical field you are rolling up to is not on the immediate parent object).
-- `Ultimate Parent Lookup` (optional) - specify the API Name of the field on the `Lookup Object` that contains the hierarchy relationship. On Account, for example, this would be `ParentId`. **Must** be filled out if `Rollup To Ultimate Parent` is checked.
+- `Order By (First/Last)` (optional) - at present, only valid when FIRST/LAST is used as the Rollup Operation. This is the API name of a text/date/number-based field that you would like to order the calculation items by. Like DLRS, this field is optional on a first/last operation, and if a field is not supplied, the `Rollup field On Calc Item` is used.
+- `Should rollup To ultimate hierarchy parent` (optional) - Check this box if you are rolling up to an Account, for example, and use the `Parent Account ID` field on accounts, _and_ want the rolled up value to only be used on the top-level account. Can be used with any hierarchy lookup or lookup back to the same object. Must be used in conjunction with `Ultimate Parent Lookup` (below), and _can_ be used in conjunction with `Grandparent Relationship Field Path` (if the hierarchical field you are rolling up to is not on the immediate parent object).
+- `SOQL Where Clause To Exclude Calc Items` (optional) - add conditions to filter the calculation items that are used. **Note** - the fields, especially parent-level fields, _must_ be present on the calculation items or the filtering will not work correctly. For currency or number fields with multiple decimals, keep in mind that however the number appears in a SOQL query (ie `4.00`) is the format that you should use when performing filtering; `Amount != 4` will not work if the value is stored as `4.00`. The only exception to this is zero; there, you are allowed to omit the decimal places.
+- `Ultimate Parent Field` (optional) - specify the API Name of the field on the `Lookup Object` that contains the hierarchy relationship. On Account, for example, this would be `ParentId`. **Must** be filled out if `Rollup To Ultimate Parent` is checked.
Here is an example of the base action filled out (not shown, but also important - the assignment of the collection to the `Records to rollup` variable):
@@ -912,6 +913,27 @@ trigger OpportunityChangeEventTrigger on OpportunityChangeEvent (after insert) {
Note that you're still selecting `Opportunity` as the `Calc Item` within your Rollup metadata record in this example; in fact, you cannot select `OpportunityChangeEvent`, so hopefully that was already clear. This means that people interested in using CDC should view it as an either/or option when compared to invoking `Rollup` from a standard, synchronous trigger. Additionally, that means reparenting that occurs at the calculation item level (the child object in the rollup operation) is not yet a supported feature of `Rollup` for CDC-based rollup actions — because the underlying object has already been updated in the database, and because CDC events only contain the new values for changed fields (instead of the new & old values). It's a TBD-type situation if this will ever be supported.
+### Rollup Logging
+
+
+If logging to the debug logs is enough for your purposes, the default logger need never be changed. However, if you want to customize things further, or log errors to a custom object, you can do so! The included `RollupLogger` class also includes an interface:
+
+```java
+public class RollupLogger {
+
+ public interface ILogger {
+ void log(String logString, LoggingLevel logLevel);
+ void log(String logString, Object logObject, LoggingLevel logLevel);
+ void save();
+ }
+}
+```
+
+You can implement `RollupLogger.ILogger` with your own code if you have a pre-existing logging solution. Otherwise, two options for custom loggers will be included in the next release (`v1.2.32`) as separate unmanaged packages:
+
+1. [Nebula Logger](https://github.com/jongpie/NebulaLogger)
+2. A lightweight custom logger that's also part of this repository; it's just bundled separately
+
### Multi-Currency Orgs
Untested. I would expect that MAX/SUM/MIN/AVERAGE operations would have undefined behavior if mixed currencies are present on the children items. This would be a good first issue for somebody looking to contribute!
diff --git a/extra-tests/classes/InvocableDrivenTests.cls b/extra-tests/classes/InvocableDrivenTests.cls
new file mode 100644
index 00000000..dd0f12d5
--- /dev/null
+++ b/extra-tests/classes/InvocableDrivenTests.cls
@@ -0,0 +1,55 @@
+@isTest
+private class InvocableDrivenTests {
+ // Driven by extra-tests/flows/Rollup_Integration_Multiple_Deferred_Case_Rollups.flow-meta.xml
+
+ @TestSetup
+ static void setup() {
+ upsert new RollupSettings__c(IsEnabled__c = true);
+ Account acc = new Account(Name = 'InvocableDrivenRollupTests');
+ insert acc;
+ }
+
+ @isTest
+ static void shouldRollupMultipleDMLStatementsWithinSingleTransaction() {
+ Account acc = [SELECT Id FROM Account];
+ Account reparentAccount = new Account(Name = 'Reparent');
+ insert reparentAccount;
+
+ Date today = System.today();
+
+ Case one = new Case(Amount__c = 1, AccountId = acc.Id, Description = 'distinct', Subject = 'One', DateField__c = today.addDays(-2));
+ Case two = new Case(Amount__c = 2, AccountId = acc.Id, Description = 'again', Subject = 'Two', DateField__c = today);
+ Case three = new Case(Amount__c = 0, AccountId = reparentAccount.Id, Description = 'something else', Subject = 'Three');
+ Case four = new Case(Amount__c = 0, AccountId = reparentAccount.Id, Description = one.Description, Subject = 'Four');
+
+ Test.startTest();
+ insert new List{ one, two, three, four };
+
+ one.Amount__c = 2;
+ one.AccountId = reparentAccount.Id;
+ update one;
+
+ // Trigger recursive update after reparenting
+ // this is important because it not only validates that the recursion
+ // detection is working properly, but also because it validates that the
+ // recursion detection is necessary to calculate the results properly!
+ one.Subject = 'Z';
+ update one;
+ Test.stopTest();
+
+ acc = [SELECT Id, Description, AnnualRevenue, Name, NumberOfEmployees, DateField__c FROM Account WHERE Id = :acc.Id];
+ reparentAccount = [SELECT Id, Description, AnnualRevenue, Name, NumberOfEmployees, DateField__c FROM Account WHERE Id = :reparentAccount.Id];
+
+ System.assertEquals(today, acc.DateField__c, 'LAST should have been updated to new last');
+ System.assertEquals(2, acc.AnnualRevenue, 'First account sum field should be decremented on reparent');
+ System.assertEquals(two.Description, acc.Description, 'CONCAT_DISTINCT should remove extra text on reparent');
+ System.assertEquals(1, acc.NumberOfEmployees);
+ System.assertEquals(two.Subject, acc.Name);
+
+ System.assertEquals(today.addDays(-2), reparentAccount.DateField__c);
+ System.assertEquals(3, reparentAccount.NumberOfEmployees, 'Second account should properly reflect reparented record for number of employees');
+ System.assertEquals(one.Description + ', ' + three.Description, reparentAccount.Description, 'Second account should have only reparented case description');
+ System.assertEquals(one.Subject, reparentAccount.Name, 'Second account name field should reflect last subject');
+ System.assertEquals(2, reparentAccount.AnnualRevenue, 'Second account sum field should include updated amount');
+ }
+}
diff --git a/extra-tests/classes/InvocableDrivenTests.cls-meta.xml b/extra-tests/classes/InvocableDrivenTests.cls-meta.xml
new file mode 100644
index 00000000..c30c8964
--- /dev/null
+++ b/extra-tests/classes/InvocableDrivenTests.cls-meta.xml
@@ -0,0 +1,5 @@
+
+
+ 52.0
+ Active
+
\ No newline at end of file
diff --git a/extra-tests/classes/RollupIntegrationTests.cls b/extra-tests/classes/RollupIntegrationTests.cls
index aeda8daf..6d9c1ccb 100644
--- a/extra-tests/classes/RollupIntegrationTests.cls
+++ b/extra-tests/classes/RollupIntegrationTests.cls
@@ -632,11 +632,7 @@ private class RollupIntegrationTests {
@isTest
static void shouldNotBlowUpForRecursiveCheckOnFormulaFields() {
String oppId = RollupTestUtils.createId(Opportunity.SObjectType);
- Opportunity opp = new Opportunity(
- Amount = 15,
- AccountId = RollupTestUtils.createId(Account.SObjectType),
- Id = oppId
- );
+ Opportunity opp = new Opportunity(Amount = 15, AccountId = RollupTestUtils.createId(Account.SObjectType), Id = oppId);
List opps = new List{ opp };
Formula.recalculateFormulas(opps); // sets the AmountFormula__c field on the opp
@@ -653,6 +649,7 @@ private class RollupIntegrationTests {
System.assertEquals(true, eval.matches(opp), 'Should match when not recursive');
+ RollupEvaluator.stubRequestId = 'somethingElse';
// re-initialize to trigger recursion detection
eval = RollupEvaluator.getEvaluator(
null,
diff --git a/extra-tests/flows/Rollup_Integration_Multiple_Deferred_Case_Rollups.flow-meta.xml b/extra-tests/flows/Rollup_Integration_Multiple_Deferred_Case_Rollups.flow-meta.xml
new file mode 100644
index 00000000..80f2a97a
--- /dev/null
+++ b/extra-tests/flows/Rollup_Integration_Multiple_Deferred_Case_Rollups.flow-meta.xml
@@ -0,0 +1,496 @@
+
+
+
+ Commit_deferred_rollups
+
+ 176
+ 1031
+ RollupFlowBulkSaver
+ apex
+ CurrentTransaction
+
+
+ Count_Cases
+
+ 176
+ 911
+ Rollup
+ apex
+
+ Commit_deferred_rollups
+
+
+ T__oldRecordsToRollup
+ Case
+
+
+ T__recordsToRollup
+ Case
+
+ CurrentTransaction
+
+ rollupFieldOnCalcItem
+
+ Id
+
+
+
+ lookupFieldOnCalcItem
+
+ AccountId
+
+
+
+ deferProcessing
+
+ true
+
+
+
+ oldRecordsToRollup
+
+ PriorCases
+
+
+
+ recordsToRollup
+
+ Cases
+
+
+
+ rollupSObjectName
+
+ Account
+
+
+
+ rollupFieldOnOpObject
+
+ NumberOfEmployees
+
+
+
+ lookupFieldOnOpObject
+
+ Id
+
+
+
+ rollupOperation
+
+ COUNT
+
+
+
+ rollupContext
+
+ UPSERT
+
+
+ true
+
+
+ Rollup_Case_Description_To_Account_Description
+
+ 176
+ 431
+ Rollup
+ apex
+
+ Rollup_Custom_Amount_to_Annual_Revenue
+
+
+ T__oldRecordsToRollup
+ Case
+
+
+ T__recordsToRollup
+ Case
+
+ CurrentTransaction
+
+ rollupFieldOnCalcItem
+
+ Description
+
+
+
+ lookupFieldOnCalcItem
+
+ AccountId
+
+
+
+ deferProcessing
+
+ true
+
+
+
+ oldRecordsToRollup
+
+ PriorCases
+
+
+
+ recordsToRollup
+
+ Cases
+
+
+
+ rollupSObjectName
+
+ Account
+
+
+
+ rollupFieldOnOpObject
+
+ Description
+
+
+
+ lookupFieldOnOpObject
+
+ Id
+
+
+
+ rollupOperation
+
+ CONCAT_DISTINCT
+
+
+
+ rollupContext
+
+ UPSERT
+
+
+ true
+
+
+ Rollup_Custom_Amount_to_Annual_Revenue
+
+ 176
+ 551
+ Rollup
+ apex
+
+ Rollup_Last_Name
+
+
+ T__oldRecordsToRollup
+ Case
+
+
+ T__recordsToRollup
+ Case
+
+ CurrentTransaction
+
+ rollupFieldOnCalcItem
+
+ Amount__c
+
+
+
+ lookupFieldOnCalcItem
+
+ AccountId
+
+
+
+ deferProcessing
+
+ true
+
+
+
+ oldRecordsToRollup
+
+ PriorCases
+
+
+
+ recordsToRollup
+
+ Cases
+
+
+
+ rollupSObjectName
+
+ Account
+
+
+
+ rollupFieldOnOpObject
+
+ AnnualRevenue
+
+
+
+ lookupFieldOnOpObject
+
+ Id
+
+
+
+ rollupOperation
+
+ SUM
+
+
+
+ rollupContext
+
+ UPSERT
+
+
+ true
+
+
+ Rollup_Last_Date
+
+ 176
+ 791
+ Rollup
+ apex
+
+ Count_Cases
+
+
+ T__oldRecordsToRollup
+ Case
+
+
+ T__recordsToRollup
+ Case
+
+ CurrentTransaction
+
+ rollupFieldOnCalcItem
+
+ DateField__c
+
+
+
+ lookupFieldOnCalcItem
+
+ AccountId
+
+
+
+ deferProcessing
+
+ true
+
+
+
+ oldRecordsToRollup
+
+ PriorCases
+
+
+
+ recordsToRollup
+
+ Cases
+
+
+
+ rollupSObjectName
+
+ Account
+
+
+
+ rollupFieldOnOpObject
+
+ DateField__c
+
+
+
+ lookupFieldOnOpObject
+
+ Id
+
+
+
+ rollupOperation
+
+ LAST
+
+
+
+ rollupContext
+
+ UPSERT
+
+
+
+ calcItemWhereClause
+
+ Amount__c != 0
+
+
+ true
+
+
+ Rollup_Last_Name
+
+ 176
+ 671
+ Rollup
+ apex
+
+ Rollup_Last_Date
+
+
+ T__oldRecordsToRollup
+ Case
+
+
+ T__recordsToRollup
+ Case
+
+ CurrentTransaction
+
+ rollupFieldOnCalcItem
+
+ Subject
+
+
+
+ lookupFieldOnCalcItem
+
+ AccountId
+
+
+
+ deferProcessing
+
+ true
+
+
+
+ oldRecordsToRollup
+
+ PriorCases
+
+
+
+ recordsToRollup
+
+ Cases
+
+
+
+ rollupSObjectName
+
+ Account
+
+
+
+ rollupFieldOnOpObject
+
+ Name
+
+
+
+ lookupFieldOnOpObject
+
+ Id
+
+
+
+ rollupOperation
+
+ LAST
+
+
+
+ rollupContext
+
+ UPSERT
+
+
+ true
+
+ 52.0
+
+ Add_case_and_prior_case_to_collections
+
+ 176
+ 311
+
+ Cases
+ Add
+
+ $Record
+
+
+
+ PriorCases
+ Add
+
+ $Record__Prior
+
+
+
+ Rollup_Case_Description_To_Account_Description
+
+
+ Record Triggered flow for Cases to be included in Rollup extra-tests deploy
+ Rollup Integration - Multiple Deferred Case Rollups {!$Flow.CurrentDateTime}
+
+
+ BuilderType
+
+ LightningFlowBuilder
+
+
+
+ CanvasMode
+
+ AUTO_LAYOUT_CANVAS
+
+
+
+ OriginBuilderType
+
+ LightningFlowBuilder
+
+
+ AutoLaunchedFlow
+
+ 50
+ 0
+
+ Add_case_and_prior_case_to_collections
+
+
+ CreateAndUpdate
+ RecordAfterSave
+
+ Active
+
+ Cases
+ SObject
+ true
+ true
+ false
+ Case
+
+
+ PriorCases
+ SObject
+ true
+ true
+ false
+ Case
+
+
diff --git a/extra-tests/objects/Account/fields/DateField__c.field-meta.xml b/extra-tests/objects/Account/fields/DateField__c.field-meta.xml
new file mode 100644
index 00000000..eceee0c6
--- /dev/null
+++ b/extra-tests/objects/Account/fields/DateField__c.field-meta.xml
@@ -0,0 +1,9 @@
+
+
+ DateField__c
+ false
+
+ false
+ false
+ Date
+
diff --git a/extra-tests/objects/Case/Case.object-meta.xml b/extra-tests/objects/Case/Case.object-meta.xml
new file mode 100644
index 00000000..7e59bc91
--- /dev/null
+++ b/extra-tests/objects/Case/Case.object-meta.xml
@@ -0,0 +1,27 @@
+
+
+ SYSTEM
+ true
+
+ CASES.CASE_NUMBER
+ CASES.SUBJECT
+ CASES.CREATED_DATE
+ CASES.PRIORITY
+ CASES.CASE_NUMBER
+ CASES.SUBJECT
+ NAME
+ ACCOUNT.NAME
+ CASES.STATUS
+ CASES.CASE_NUMBER
+ CASES.SUBJECT
+ NAME
+ ACCOUNT.NAME
+ CASES.STATUS
+ CASES.CASE_NUMBER
+ CASES.SUBJECT
+ CASES.STATUS
+ CASES.CREATED_DATE
+ CORE.USERS.ALIAS
+
+ ReadWriteTransfer
+
diff --git a/extra-tests/objects/Case/fields/Amount__c.field-meta.xml b/extra-tests/objects/Case/fields/Amount__c.field-meta.xml
new file mode 100644
index 00000000..408718ec
--- /dev/null
+++ b/extra-tests/objects/Case/fields/Amount__c.field-meta.xml
@@ -0,0 +1,13 @@
+
+
+ Amount__c
+ false
+
+ 18
+ false
+ 2
+ false
+ false
+ false
+ Currency
+
diff --git a/extra-tests/objects/Case/fields/DateField__c.field-meta.xml b/extra-tests/objects/Case/fields/DateField__c.field-meta.xml
new file mode 100644
index 00000000..eceee0c6
--- /dev/null
+++ b/extra-tests/objects/Case/fields/DateField__c.field-meta.xml
@@ -0,0 +1,9 @@
+
+
+ DateField__c
+ false
+
+ false
+ false
+ Date
+
diff --git a/extra-tests/profiles/Admin.profile-meta.xml b/extra-tests/profiles/Admin.profile-meta.xml
index 41649272..1c46f326 100644
--- a/extra-tests/profiles/Admin.profile-meta.xml
+++ b/extra-tests/profiles/Admin.profile-meta.xml
@@ -9,6 +9,16 @@
RollupIntegrationTeststrue
+
+ true
+ Account.AccountIdText__c
+ true
+
+
+ true
+ Account.DateField__c
+ true
+ trueApplication__c.Engagement_Score__c
@@ -44,6 +54,11 @@
ApplicationLog__c.Object__ctrue
+
+ true
+ Case.DateField__c
+ true
+ trueParentApplication__c.Account__c
diff --git a/package.json b/package.json
index a2c5c01c..93711296 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "apex-rollup",
- "version": "1.2.30.0",
+ "version": "1.2.31.0",
"description": "Fast, configurable, elastically scaling custom rollup solution. Apex Invocable action, one-liner Apex trigger/CMDT-driven logic, and scheduled Apex-ready.",
"repository": {
"type": "git",
@@ -11,12 +11,12 @@
"devDependencies": {
"prettier-plugin-apex": "latest",
"prettier": "latest",
- "@salesforce/sfdx-lwc-jest": "latest"
+ "@salesforce/sfdx-lwc-jest": "0.12.5"
},
"scripts": {
"test": "npm run test:apex && npm run test:lwc",
"test:apex": "sh ./scripts/runLocalTests.sh",
- "test:lwc": "sfdx-lwc-jest",
+ "test:lwc": "sfdx-lwc-jest --coverage --skipApiVersionCheck",
"prettier": "prettier"
}
}
\ No newline at end of file
diff --git a/rollup/app/lwc/rollupForceRecalculation/__tests__/rollupForceRecalculation.test.js b/rollup/app/lwc/rollupForceRecalculation/__tests__/rollupForceRecalculation.test.js
index c8208f68..bc220153 100644
--- a/rollup/app/lwc/rollupForceRecalculation/__tests__/rollupForceRecalculation.test.js
+++ b/rollup/app/lwc/rollupForceRecalculation/__tests__/rollupForceRecalculation.test.js
@@ -2,18 +2,12 @@ import { createElement } from 'lwc';
import { getObjectInfo } from 'lightning/uiObjectInfoApi';
import performFullRecalculation from '@salesforce/apex/Rollup.performFullRecalculation';
import performBulkFullRecalc from '@salesforce/apex/Rollup.performBulkFullRecalc';
-import { registerLdsTestWireAdapter } from '@salesforce/sfdx-lwc-jest';
+import getBatchRollupStatus from '@salesforce/apex/Rollup.getBatchRollupStatus';
import { mockMetadata } from '../../__mockData__';
import RollupForceRecalculation from 'c/rollupForceRecalculation';
const mockGetObjectInfo = require('./data/rollupCMDTWireAdapter.json');
-const getObjectInfoWireAdapter = registerLdsTestWireAdapter(getObjectInfo);
-
-function assertForTestConditions() {
- const resolvedPromise = Promise.resolve();
- return resolvedPromise.then.apply(resolvedPromise, arguments);
-}
function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0));
@@ -49,15 +43,15 @@ jest.mock(
{ virtual: true }
);
-jest.mock(
- '@salesforce/apex/Rollup.getBatchRollupStatus',
- () => {
- return {
- default: () => jest.fn()
- };
- },
- { virtual: true }
-);
+// jest.mock(
+// '@salesforce/apex/Rollup.getBatchRollupStatus',
+// () => {
+// return {
+// default: () => jest.fn()
+// };
+// },
+// { virtual: true }
+// );
function setElementValue(element, value) {
element.value = value;
@@ -78,7 +72,7 @@ describe('Rollup force recalc tests', () => {
});
document.body.appendChild(fullRecalc);
- return assertForTestConditions(() => {
+ return flushPromises().then(() => {
expect(document.title).toEqual('Recalculate Rollup');
});
});
@@ -118,7 +112,7 @@ describe('Rollup force recalc tests', () => {
const submitButton = fullRecalc.shadowRoot.querySelector('lightning-button');
submitButton.click();
- return assertForTestConditions(() => {
+ return flushPromises().then(() => {
expect(performFullRecalculation.mock.calls[0][0]).toEqual({
metadata: {
RollupFieldOnCalcItem__c: 'FirstName',
@@ -189,7 +183,7 @@ describe('Rollup force recalc tests', () => {
expect(fullRecalc.isCMDTRecalc).toBeTruthy();
- getObjectInfoWireAdapter.emit(mockGetObjectInfo);
+ getObjectInfo.emit(mockGetObjectInfo);
// flush to re-render
return flushPromises().then(() => {
@@ -217,8 +211,73 @@ describe('Rollup force recalc tests', () => {
});
});
+ it('sets error when CMDT is not returned', () => {
+ const fullRecalc = createElement('c-rollup-force-recalculation', {
+ is: RollupForceRecalculation
+ });
+ document.body.appendChild(fullRecalc);
+
+ const toggle = fullRecalc.shadowRoot.querySelector('lightning-input[data-id="cmdt-toggle"]');
+ toggle.dispatchEvent(new CustomEvent('change')); // _like_ a click ...
+
+ expect(fullRecalc.isCMDTRecalc).toBeTruthy();
+
+ getObjectInfo.emitError();
+
+ return flushPromises().then(() => {
+
+ const errorDiv = fullRecalc.shadowRoot.querySelector('div[data-id="rollupError"]')
+ expect(errorDiv).toBeTruthy();
+ })
+ })
+
+ it('succeeds even when exception is thrown', () => {
+ performFullRecalculation.mockRejectedValue('error!');
+ const fullRecalc = createElement('c-rollup-force-recalculation', {
+ is: RollupForceRecalculation
+ });
+ document.body.appendChild(fullRecalc);
+
+ const submitButton = fullRecalc.shadowRoot.querySelector('lightning-button');
+ submitButton.click();
+
+ let hasError = false;
+ return flushPromises()
+ .catch(() => {
+ hasError = true;
+ })
+ .finally(() => {
+ expect(hasError).toBeFalsy();
+ });
+ });
+
it('succeeds even when no process id', () => {
performFullRecalculation.mockResolvedValue('No process Id');
+
+ const fullRecalc = createElement('c-rollup-force-recalculation', {
+ is: RollupForceRecalculation
+ });
+ document.body.appendChild(fullRecalc);
+
+ const submitButton = fullRecalc.shadowRoot.querySelector('lightning-button');
+ submitButton.click();
+
+ let hasError = false;
+ return flushPromises()
+ .catch(() => {
+ hasError = true;
+ })
+ .finally(() => {
+ expect(hasError).toBeFalsy();
+ });
+ });
+
+ it('polls when process Id given', () => {
+ // simulate second poll receiving one of the completed values
+ getBatchRollupStatus.mockResolvedValueOnce('test').mockResolvedValueOnce('Completed');
+ performFullRecalculation.mockResolvedValueOnce('someProcessId');
+
+ performBulkFullRecalc.mock
const fullRecalc = createElement('c-rollup-force-recalculation', {
is: RollupForceRecalculation
});
@@ -228,7 +287,7 @@ describe('Rollup force recalc tests', () => {
submitButton.click();
let hasError = false;
- return assertForTestConditions()
+ return flushPromises()
.catch(() => {
hasError = true;
})
diff --git a/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.html b/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.html
index a1bc2972..0bd2ddf4 100644
--- a/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.html
+++ b/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.html
@@ -45,7 +45,7 @@
data-id="RollupFieldOnCalcItem__c"
class="slds-col slds-form-element slds-form-element_horizontal"
type="text"
- label="API Name of the rollup field on calc item"
+ label="Calc Item Calc Field"
name="RollupFieldOnCalcItem__c"
oncommit={handleChange}
required
@@ -55,18 +55,18 @@
data-id="LookupFieldOnCalcItem__c"
class="slds-col slds-form-element slds-form-element_horizontal"
type="text"
- label="API Name of the lookup field on calc item"
+ label="Calc Item Lookup Field"
name="LookupFieldOnCalcItem__c"
oncommit={handleChange}
required
>
@@ -75,18 +75,18 @@
data-id="RollupFieldOnLookupObject__c"
class="slds-col slds-form-element slds-form-element_horizontal"
type="text"
- label="API Name of the rollup field on the lookup object"
+ label="Rollup Object Calc Field"
name="RollupFieldOnLookupObject__c"
oncommit={handleChange}
required
>
@@ -114,7 +114,7 @@
data-id="OrderByFirstLast__c"
class="slds-col slds-form-element slds-form-element_horizontal"
type="text"
- label="Order By Field For First/Last (Optional)"
+ label="Order By (First/Last) (Optional)"
name="OrderByFirstLast__c"
oncommit={handleChange}
>
@@ -122,7 +122,7 @@
@@ -137,7 +137,7 @@
Rollup job status: {rollupStatus}
-
There was an error performing your rollup: {error}
+
There was an error performing your rollup: {error}
diff --git a/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.js b/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.js
index bd3244f6..4c65d52e 100644
--- a/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.js
+++ b/rollup/app/lwc/rollupForceRecalculation/rollupForceRecalculation.js
@@ -133,10 +133,10 @@ export default class RollupForceRecalculation extends LightningElement {
const statusPromise = new Promise(resolve => {
let timeoutId;
if (this._resolvedBatchStatuses.includes(this.rollupStatus) == false) {
- timeoutId = setTimeout(() => this._getBatchJobStatus(jobId), 3000);
+ timeoutId = setTimeout(this._getBatchJobStatus(jobId), 3000);
} else {
this.isRollingUp = false;
- clearInterval(timeoutId);
+ clearTimeout(timeoutId);
resolve();
}
});
diff --git a/rollup/core/classes/Rollup.cls b/rollup/core/classes/Rollup.cls
index 0d5efbc1..2ca92333 100644
--- a/rollup/core/classes/Rollup.cls
+++ b/rollup/core/classes/Rollup.cls
@@ -1,4 +1,4 @@
-global without sharing virtual class Rollup implements Database.Batchable, System.Comparable {
+global without sharing virtual class Rollup {
/**
* Test override / bookkeeping section. Normally I would do this through dependency injection,
* but this keeps things much simpler
@@ -6,8 +6,6 @@ global without sharing virtual class Rollup implements Database.Batchable records;
@@ -21,44 +19,24 @@ global without sharing virtual class Rollup implements Database.Batchable CACHED_ROLLUPS = new List();
- private static Boolean isRunningAsync = false;
- private static Integer SENTINEL_COUNT_VALUE = -1;
- private static Map> CACHED_APEX_OPERATIONS = new Map>();
+ private static final List CACHED_ROLLUPS = new List();
+ private static Map> CACHED_APEX_OPERATIONS = new Map>();
private static Boolean isCDC = false;
private static Boolean isDeferralAllowed = true;
- private static Integer stackDepth = 0;
private static final String CONTROL_ORG_DEFAULTS = 'Org_Defaults';
- private static final RollupSettings__c SETTINGS = RollupSettings__c.getInstance();
private static final Set ALWAYS_FULL_RECALC_OPS = new Set{ Op.FIRST.name(), Op.LAST.name(), Op.AVERAGE.name() };
- private final List calcItems;
- private final Map oldCalcItems;
- private final SObjectField opFieldOnCalcItem;
- private final SObjectField lookupFieldOnCalcItem;
- private final SObjectField lookupFieldOnLookupObject;
- private final SObjectField opFieldOnLookupObject;
- private final SObjectType lookupObj;
- private final Evaluator eval;
- private final Op op;
- private final Rollup__mdt metadata;
-
- protected final Boolean isBatched;
protected final RollupControl__mdt rollupControl;
- protected final SObjectType calcItemType;
protected final InvocationPoint invokePoint;
+ protected final Boolean isBatched;
+ protected final List rollups = new List();
// non-final instance variables
protected Boolean isFullRecalc = false;
protected Boolean isNoOp;
- private Boolean isCDCUpdate = false;
- private Map> lookupObjectToUniqueFieldNames;
- private List lookupItems;
- private RollupRelationshipFieldFinder.Traversal traversal;
+ protected Boolean isCDCUpdate = false;
/**
* receiving an interface/subclass from a property get/set (from the book "The Art Of Unit Testing") is an old technique;
@@ -78,36 +56,6 @@ global without sharing virtual class Rollup implements Database.Batchable syncRollups {
- get {
- if (syncRollups == null) {
- syncRollups = new List();
- }
- return syncRollups;
- }
- set;
- }
-
- protected List rollups {
- get {
- if (rollups == null) {
- rollups = new List();
- }
- return rollups;
- }
- set;
- }
-
- private List deferredRollups {
- get {
- if (deferredRollups == null) {
- deferredRollups = new List();
- }
- return deferredRollups;
- }
- set;
- }
-
public static Map opNameToOp {
get {
if (opNameToOp == null) {
@@ -172,8 +120,7 @@ global without sharing virtual class Rollup implements Database.Batchable recordsToUpdate) {
Database.DMLOptions dmlOptions = new Database.DMLOptions();
dmlOptions.AllowFieldTruncation = true;
@@ -181,15 +128,8 @@ global without sharing virtual class Rollup implements Database.Batchable records;
- public RollupAsyncSaver(List records) {
- this.records = records;
- }
-
- public void execute(QueueableContext context) {
- new DMLHelper().doUpdate(this.records);
- }
+ global interface Evaluator {
+ Boolean matches(Object calcItem);
}
global enum InvocationPoint {
@@ -201,317 +141,53 @@ global without sharing virtual class Rollup implements Database.Batchable calcItems) {
- this(
- calcItems,
- innerRollup.opFieldOnCalcItem,
- innerRollup.lookupFieldOnCalcItem,
- innerRollup.lookupFieldOnLookupObject,
- innerRollup.opFieldOnLookupObject,
- innerRollup.lookupObj,
- innerRollup.calcItemType,
- op,
- innerRollup.oldCalcItems,
- null, // eval gets assigned below
- innerRollup.invokePoint,
- innerRollup.rollupControl,
- innerRollup.metadata
- );
+ protected List getCachedRollups() {
+ return CACHED_ROLLUPS;
+ }
- this.rollups = innerRollup.rollups;
- this.isNoOp = this.rollups.isEmpty() && innerRollup.metadata?.IsFullRecordSet__c == false;
- this.isFullRecalc = innerRollup.isFullRecalc;
- this.isCDCUpdate = innerRollup.isCDCUpdate;
- this.eval = innerRollup.eval;
+ protected Map> getCachedApexOperations() {
+ return CACHED_APEX_OPERATIONS;
}
- private Rollup(Rollup innerRollup) {
- this(innerRollup, innerRollup.op, innerRollup.calcItems);
+ protected Boolean getIsDeferralAllowed() {
+ return isDeferralAllowed;
}
- private Rollup(
+ protected void setIsDeferralAllowed(Boolean value) {
+ isDeferralAllowed = value;
+ }
+
+ protected RollupAsyncProcessor getAsyncRollup(
+ List rollupOperations,
+ SObjectType sObjectType,
List calcItems,
- SObjectField opFieldOnCalcItem,
- SObjectField lookupFieldOnCalcItem,
- SObjectField lookupFieldOnLookupObject,
- SObjectField opFieldOnLookupObject,
- SObjectType lookupObj,
- SObjectType calcItemType,
- Op op,
Map oldCalcItems,
Evaluator eval,
- InvocationPoint invokePoint,
- RollupControl__mdt rollupControl,
- Rollup__mdt rollupMetadata
+ InvocationPoint rollupInvokePoint
) {
- this.calcItems = calcItems;
- this.opFieldOnCalcItem = opFieldOnCalcItem;
- this.lookupFieldOnCalcItem = lookupFieldOnCalcItem;
- this.lookupFieldOnLookupObject = lookupFieldOnLookupObject;
- this.opFieldOnLookupObject = opFieldOnLookupObject;
- this.lookupObj = lookupObj;
- this.calcItemType = calcItemType;
- this.op = op;
- this.oldCalcItems = oldCalcItems;
- this.isBatched = false;
- this.invokePoint = invokePoint;
- this.rollupControl = rollupControl;
- this.metadata = rollupMetadata;
-
- if (eval != null) {
- this.eval = eval;
- }
-
- this.isNoOp = this.calcItems?.isEmpty() == true && this.metadata?.IsFullRecordSet__c == false;
- }
-
- global interface Evaluator {
- Boolean matches(Object calcItem);
- }
-
- public Integer compareTo(Object otherRollup) {
- Integer numberToReturn = 1;
- if (otherRollup instanceof Rollup) {
- Rollup that = (Rollup) otherRollup;
- Boolean thisDelete = this.op.name().contains('DELETE');
- Boolean thatDelete = that.op.name().contains('DELETE');
- Boolean thisUpdate = this.op.name().contains('UPDATE');
- Boolean thatUpdate = that.op.name().contains('UPDATE');
- Boolean thisInsert = thisDelete == false && thisUpdate == false;
- Boolean thatInsert = thatDelete == false && thatUpdate == false;
-
- // INSERT operations always come first, then UPDATEs, then DELETEs (UNDELETEs are transformed to INSERT)
- if (thisInsert && (thatUpdate || thatDelete)) {
- numberToReturn = -1;
- } else if ((thisUpdate || thisDelete) && thatInsert) {
- numberToReturn = 1;
- } else if (thatUpdate && thisDelete) {
- numberToReturn = 1;
- } else if (thisUpdate && thatDelete) {
- numberToReturn = -1;
- }
- }
-
- return numberToReturn;
+ return getRollup(rollupOperations, sObjectType, calcItems, oldCalcItems, eval, rollupInvokePoint);
}
- public override String toString() {
- Map props = new Map{
- 'Invocation Point' => this.invokePoint.name(),
- 'Calc Items' => JSON.serializePretty(this.calcItems),
- 'Old Calc Items' => JSON.serializePretty(this.oldCalcItems),
- 'Rollup Metadata' => JSON.serializePretty(this.metadata),
- 'Rollup Control' => JSON.serializePretty(this.rollupControl),
- 'Is Full Recalc' => String.valueOf(this.isFullRecalc),
- 'Is No Op' => String.valueOf(this.isNoOp)
- };
- String baseString = '';
- for (String key : props.keySet()) {
- baseString += key + ': ' + props.get(key) + '\n';
- }
- return baseString.removeEnd('\n');
+ protected DMLHelper getDML() {
+ return DML;
}
- public String runCalc() {
- // side effect in the below method - rollups can be removed from this.rollups if a control record ShouldAbortRun__c == true
- this.ingestRollupControlData();
-
- if (this.isNoOp) {
- this.isNoOp = this.rollups.isEmpty() && this.syncRollups.isEmpty();
- }
-
- if (this.isNoOp || this.rollupControl.ShouldAbortRun__c || SETTINGS.IsEnabled__c == false) {
- return 'No process Id';
- }
-
- Boolean hasMoreThanOneTarget = false;
- Integer totalCountOfRecords = this.getLookupRecordsCount(hasMoreThanOneTarget);
-
- shouldRunAsBatch =
- shouldRunAsBatch ||
- hasMoreThanOneTarget == false &&
- ((this.rollupControl.ShouldRunAs__c == RollupMetaPicklists.ShouldRunAs.BATCHABLE &&
- totalCountOfRecords >= this.rollupControl.MaxLookupRowsBeforeBatching__c) || totalCountOfRecords == SENTINEL_COUNT_VALUE);
- if (this.syncRollups.isEmpty() == false) {
- RollupLogger.Instance.log('about to process sync rollups');
- this.process(this.syncRollups);
- return 'Running rollups flagged to go synchronously';
- } else {
- return this.getAsyncRollup().beginAsyncRollup();
- }
- }
-
- protected Rollup getAsyncRollup() {
- // swap off on which async process is running to achieve infinite scaling
- isRunningAsync = true;
- Boolean canEnqueue = Limits.getLimitQueueableJobs() > Limits.getQueueableJobs();
- Boolean isAsyncInnerClass = this instanceof RollupAsyncProcessor;
- Rollup roll;
- // deferred rollups delay the full batch recalc
- // till all the others have gone
- if (this.rollups.size() == 1 && this.rollups[0] instanceof RollupFullBatchRecalculator) {
- roll = this.rollups[0];
- } else if (this instanceof RollupFullBatchRecalculator) {
- roll = this;
- } else if (shouldRunAsBatch && System.isBatch() == false) {
- // safe to batch because the QueryLocator will only return one type of SObject
- // we have to re-initialize the rollup because it's the Queueable inner class
- // at this point, and without re-initialization we get "System.UnexpectedException: Error processing messages"
- roll = new Rollup(this);
- } else if (canEnqueue && System.isQueueable() == false && isAsyncInnerClass == false) {
- roll = new RollupAsyncProcessor(this);
- } else if (canEnqueue && isAsyncInnerClass) {
- roll = this;
- } else {
- // the end of the line
- this.throwWithRollupData(this.rollups);
- }
-
- return roll;
- }
-
- protected virtual String beginAsyncRollup() {
- RollupLogger.Instance.log('about to start batch');
- return Database.executeBatch(this, this.rollupControl.BatchChunkSize__c.intValue());
- }
-
- protected virtual List getExistingLookupItems(Set objIds, Rollup rollup, Set uniqueQueryFieldNames) {
- // for Rollups that are Batchable, the lookup items are retrieved en masse in the "start" method and cached in the "execute method"
- return this.lookupItems;
- }
-
- public virtual Database.QueryLocator start(Database.BatchableContext context) {
- /**
- * for batch, we know 100% for sure there's only 1 SObjectType / Set in the map.
- * NB: we have to call "getFieldNamesForRollups" in both the "start" and "execute" methods because
- * trying to use Database.Stateful on the top-level class ** in addition to Batchable ** results in the dreaded:
- * "System.AsyncException: Queueable cannot be implemented with other system interfaces" exception
- */
- this.getFieldNamesForRollups(this.rollups);
- String lookupFieldForLookupObject;
- SObjectType sObjectType;
- Set objIds = new Set();
- for (Rollup rollup : this.rollups) {
- sObjectType = rollup.lookupObj;
- lookupFieldForLookupObject = rollup.lookupFieldOnLookupObject.getDescribe().getName();
- objIds.addAll(this.getCalcItemsByLookupField(rollup, this.lookupObjectToUniqueFieldNames.get(sObjectType)).keySet());
- }
- String query = RollupQueryBuilder.Current.getQuery(
- sObjectType,
- new List(this.lookupObjectToUniqueFieldNames.get(sObjectType)),
- lookupFieldForLookupObject,
- '='
- );
- RollupLogger.Instance.log('starting batch with query: ', query);
- return Database.getQueryLocator(query);
- }
-
- public virtual void execute(Database.BatchableContext context, List lookupItems) {
- for (Rollup rollup : this.rollups) {
- this.initializeRollupFieldDefaults(lookupItems, rollup);
- }
- this.lookupItems = lookupItems;
- this.process(this.rollups);
- }
-
- public virtual void finish(Database.BatchableContext context) {
- RollupLogger.Instance.log('batch finished successfully');
- }
-
- private class RollupAsyncProcessor extends Rollup implements System.Queueable {
- private RollupAsyncProcessor(
- List calcItems,
- SObjectField opFieldOnCalcItem,
- SObjectField lookupFieldOnCalcItem,
- SObjectField lookupFieldOnLookupObject,
- SObjectField opFieldOnLookupObject,
- SObjectType lookupObj,
- SObjectType calcItem,
- Op operation,
- Map oldCalcItems,
- Evaluator eval,
- InvocationPoint rollupInvokePoint,
- RollupControl__mdt rollupControl,
- Rollup__mdt metadata
- ) {
- super(
- calcItems,
- opFieldOnCalcItem,
- lookupFieldOnCalcItem,
- lookupFieldOnLookupObject,
- opFieldOnLookupObject,
- lookupObj,
- calcItem,
- operation,
- oldCalcItems,
- eval,
- rollupInvokePoint,
- rollupControl,
- metadata
- );
- }
-
- private RollupAsyncProcessor(InvocationPoint rollupInvokePoint) {
- super(rollupInvokePoint);
- }
-
- private RollupAsyncProcessor(Rollup roll) {
- super(roll);
- }
-
- protected override String beginAsyncRollup() {
- RollupLogger.Instance.log('about to queue');
- return System.enqueueJob(this);
- }
-
- protected override List getExistingLookupItems(Set objIds, Rollup rollup, Set uniqueQueryFieldNames) {
- if (objIds.isEmpty()) {
- return new List();
- } else {
- List localLookupItems;
- if (String.isNotBlank(rollup.metadata.GrandparentRelationshipFieldPath__c)) {
- localLookupItems = rollup.traversal.getAllParents();
- // winnow the list, which would otherwise occur because of specifically only querying for the objIds passed in
- for (Integer index = localLookupItems.size() - 1; index >= 0; index--) {
- SObject lookupItem = localLookupItems[index];
- String key = (String) lookupItem.get(rollup.lookupFieldOnLookupObject);
- if (objIds.contains(key) == false) {
- localLookupItems.remove(index);
- }
- }
- } else {
- String queryString = RollupQueryBuilder.Current.getQuery(
- rollup.lookupObj,
- new List(uniqueQueryFieldNames),
- String.valueOf(rollup.lookupFieldOnLookupObject),
- '='
- );
- // non-obvious coupling between "objIds" and the computed "queryString", which uses dynamic variable binding
- localLookupItems = Database.query(queryString);
- }
- this.initializeRollupFieldDefaults(localLookupItems, rollup);
- return localLookupItems;
- }
- }
-
- public void execute(System.QueueableContext qc) {
- this.process(this.rollups);
- RollupLogger.Instance.log('queueable finished successfully');
- }
+ public virtual String runCalc() {
+ return 'Not implemented';
}
/**
- * global facing Rollup calculation section
+ * global facing RollupAsyncProcessor calculation section
* - Trigger operations
- * - Batch (multiple Rollup operations chained into one job)
+ * - Batch (multiple RollupAsyncProcessor operations chained into one job)
* - Invocable
* - Schedulable
* - LWC-based full recalculation calls
@@ -563,7 +239,7 @@ global without sharing virtual class Rollup implements Database.Batchable recordsToRollup = new List();
-
- @InvocableVariable(label='Prior records to rollup' description='The old version of the records for update/upsert')
- global List oldRecordsToRollup = new List();
-
- @InvocableVariable(label='Rollup target\'s SObject Name' description='The API Name of the SObject where the rollup value will be stored.' required=true)
- global String rollupSObjectName;
-
- @InvocableVariable(label='Rollup Operation' description='SUM, COUNT, COUNT_DISTINCT, MAX, MIN, AVG, CONCAT, CONCAT_DISTINCT, FIRST, LAST' required=true)
- global String rollupOperation;
-
- @InvocableVariable(label='Rollup Context' description='INSERT, UPDATE, UPSERT, or DELETE' required=true)
- global String rollupContext;
-
- @InvocableVariable(label='Calc Item Rollup Field' description='The API Name of the field on each of the records passed in to consider.' required=true)
+ @InvocableVariable(label='Calc Item Calc Field' description='The API Name of the field on each of the records passed in to rollup.' required=true)
global String rollupFieldOnCalcItem;
-
@InvocableVariable(
- label='Rollup Object Field'
- description='The API Name of the field on the target object where the rollup value will be stored'
+ label='Calc Item Lookup Field'
+ description='The API Name of the field on the record to rollup that matches a field on the object where the rollup will be stored'
required=true
)
- global String rollupFieldOnOpObject;
+ global String lookupFieldOnCalcItem;
+
+ @InvocableVariable(label='Rollup Object API Name' description='The API Name of the SObject where the rollup value will be stored.' required=true)
+ global String rollupSObjectName;
@InvocableVariable(
- label='Lookup Field On Calc Item'
- description='The API Name of the field on the record to rollup that matches a field on the object where the rollup will be stored'
+ label='Rollup Object Calc Field'
+ description='The API Name of the field on the target object where the rollup value will be stored'
required=true
)
- global String lookupFieldOnCalcItem;
-
+ global String rollupFieldOnOpObject;
@InvocableVariable(
- label='Lookup Field On Rollup Object'
+ label='Rollup Object Lookup Field'
description='The API Name of the field on the SObject matching the value found in "lookupFieldOnCalcItem" where the rollup will be stored'
required=true
)
global String lookupFieldOnOpObject;
+ @InvocableVariable(label='Rollup Operation' description='SUM, COUNT, COUNT_DISTINCT, MAX, MIN, AVG, CONCAT, CONCAT_DISTINCT, FIRST, LAST' required=true)
+ global String rollupOperation;
+ @InvocableVariable(label='Rollup Operation Context' description='INSERT, UPDATE, UPSERT, or DELETE' required=true)
+ global String rollupContext;
+
// optional fields
@InvocableVariable(
label='Calc Item Changed Fields'
description='Provide a comma-separated list of field API Names to consider prior to using records in the rollup'
)
global String calcItemChangedFields;
+ @InvocableVariable(label='Calc Item Type When Rollup Started From Parent')
+ global String calcItemTypeWhenRollupStartedFromParent;
+ @InvocableVariable(label='Concat Delimiter' description='Defaults to comma')
+ global String concatDelimiter;
+ @InvocableVariable(label='Defer processing')
+ global Boolean deferProcessing = false;
@InvocableVariable(
label='Full Recalculation Default Number Value'
@@ -652,38 +325,36 @@ global without sharing virtual class Rollup implements Database.Batchable recordsToRollup = new List();
+ @InvocableVariable(label='Prior records to rollup' description='The old version of the records for update/upsert')
+ global List oldRecordsToRollup = new List();
- @InvocableVariable(label='Ultimate Parent Field' description='The lookup field in hierarchy rollups')
- global String ultimateParentLookup;
@InvocableVariable(
label='Should rollup to ultimate hierarchy parent'
description='Used in conjunction with Ultimate Parent Field to drive hierarchical parent rollups'
)
global Boolean rollupToUltimateParent = false;
+ @InvocableVariable(label='SOQL Where Clause To Exclude Calc Items' description='If provided, excludes records based on a valid SOQL where clause')
+ global String calcItemWhereClause;
+ @InvocableVariable(label='Ultimate Parent Field' description='The lookup field in hierarchy rollups')
+ global String ultimateParentLookup;
}
global class FlowOutput {
@@ -705,7 +376,7 @@ global without sharing virtual class Rollup implements Database.Batchable performRollup(List flowInputs) {
List flowOutputs = new List();
- List rollups = new List();
+ List rollups = new List();
InvocationPoint fromInvocable = InvocationPoint.FROM_INVOCABLE;
for (FlowInput flowInput : flowInputs) {
@@ -715,19 +386,19 @@ global without sharing virtual class Rollup implements Database.Batchable oldFlowRecords = getOldFlowRecords(flowInput, sObjectType);
+ String rollupContext = getFlowRollupContext(flowInput, firstRecord, oldFlowRecords);
+
Rollup__mdt rollupMeta = new Rollup__mdt(
RollupFieldOnCalcItem__c = flowInput.rollupFieldOnCalcItem,
LookupObject__c = flowInput.rollupSObjectName,
@@ -752,20 +423,25 @@ global without sharing virtual class Rollup implements Database.Batchable metas = new List{ rollupMeta };
- Map oldFlowRecords = getOldFlowRecords(flowInput, sObjectType);
processCustomMetadata(rollups, metas, flowInput.recordsToRollup, oldFlowRecords, new Set(), rollupContext, fromInvocable);
if (metas.isEmpty() == false) {
- rollups.add(getRollup(new List{ rollupMeta }, sObjectType, flowInput.recordsToRollup, oldFlowRecords, null, fromInvocable));
- }
-
- if (flowInput.deferProcessing == true) {
- RollupLogger.Instance.log('deferring processing for rollup', rollups);
- CACHED_ROLLUPS.addAll(rollups);
- rollups.clear();
- } else {
- RollupLogger.Instance.log('adding invocable rollup to list', rollups);
- rollups.addAll(rollups);
+ RollupAsyncProcessor batchedRollup = getRollup(
+ new List{ rollupMeta },
+ sObjectType,
+ flowInput.recordsToRollup,
+ oldFlowRecords,
+ null,
+ fromInvocable
+ );
+ String logMessage = 'adding invocable rollup to list';
+ if (flowInput.deferProcessing) {
+ logMessage = 'deferring processing for rollup';
+ CACHED_ROLLUPS.addAll(batchedRollup.rollups);
+ } else {
+ rollups.addAll(batchedRollup.rollups);
+ }
+ RollupLogger.Instance.log(logMessage, batchedRollup.rollups, LoggingLevel.DEBUG);
}
}
@@ -774,12 +450,14 @@ global without sharing virtual class Rollup implements Database.Batchable{ apexContext });
- }
+ populateCachedApexOperations(calcItemSObjectType, apexContext);
if (rollupMetadata.isEmpty() == false) {
rollups.addAll(getRollup(rollupMetadata, calcItemSObjectType, calcItems, oldCalcItems, eval, InvocationPoint.FROM_APEX).rollups);
@@ -1554,9 +1228,10 @@ global without sharing virtual class Rollup implements Database.Batchable rollupsToProcess = new List(CACHED_ROLLUPS);
CACHED_ROLLUPS.clear();
+ batch(rollupsToProcess, InvocationPoint.FROM_INVOCABLE);
}
private static List cachedMetadata;
@@ -1634,10 +1309,7 @@ global without sharing virtual class Rollup implements Database.Batchable operationsWithUnderscores = new Set{
- Op.COUNT_DISTINCT.name(),
- Op.CONCAT_DISTINCT.name()
- };
+ Set operationsWithUnderscores = new Set{ Op.COUNT_DISTINCT.name(), Op.CONCAT_DISTINCT.name() };
return operationsWithUnderscores.contains(fullOpName) == false && fullOpName.contains('_') ? fullOpName.substringAfter('_') : fullOpName;
}
@@ -1711,7 +1383,15 @@ global without sharing virtual class Rollup implements Database.Batchable metas) {
+ List orderByFields = new List();
+ for (Rollup__mdt meta : metas) {
+ orderByFields.add(meta.LookupFieldOnCalcItem__c);
+ }
+ return '\nORDER BY ' + String.join(orderByFields, ',');
+ }
+
+ private static RollupAsyncProcessor getFullRecalcRollup(Rollup__mdt meta, QueryWrapper queryWrapper, InvocationPoint invokePoint) {
// just how many items are we talking, here? If it's less than the query limit, we can proceed
// otherwise, kick off a batch to fetch the calc items and then chain into the regular code path
SObjectType childType = getSObjectFromName(meta.CalcItem__c).getSObjectType();
@@ -1734,7 +1414,7 @@ global without sharing virtual class Rollup implements Database.Batchable{ meta }, amountOfCalcItems, queryString, objIds, recordIds, childType, whereEval, invokePoint);
}
- private static Rollup buildFullRecalcRollup(
+ private static RollupAsyncProcessor buildFullRecalcRollup(
List matchingMeta,
Integer amountOfCalcItems,
String queryString,
@@ -1744,13 +1424,17 @@ global without sharing virtual class Rollup implements Database.Batchable calculationItems = Database.query(queryString);
- Rollup thisRollup = getRollup(matchingMeta, calcItemType, calculationItems, new Map(calculationItems), eval, invokePoint);
+ RollupAsyncProcessor thisRollup = getRollup(matchingMeta, calcItemType, calculationItems, new Map(calculationItems), eval, invokePoint);
thisRollup.isFullRecalc = true;
return thisRollup;
} else {
+ String queryWithOrderBy = queryString + getFullRecalcQueryString(matchingMeta);
return new RollupFullBatchRecalculator(queryString, invokePoint, matchingMeta, calcItemType, recordIds);
}
}
@@ -1797,14 +1481,15 @@ global without sharing virtual class Rollup implements Database.Batchable objIds) {
+ public static Integer getCountFromDb(String countQuery, Set objIds) {
return getCountFromDb(countQuery, objIds, null);
}
- private static Integer getCountFromDb(String countQuery, Set objIds, Set recordIds) {
+ public static Integer getCountFromDb(String countQuery, Set objIds, Set recordIds) {
if (countQuery.contains('ALL ROWS')) {
countQuery = countQuery.replace('ALL ROWS', '');
}
@@ -1930,25 +1616,41 @@ global without sharing virtual class Rollup implements Database.Batchable oldFlowRecords) {
String flowContext = firstInput.rollupContext.toUpperCase();
- if (String.isBlank(flowContext)) {
+ if (String.isBlank(flowContext) || flowContext == 'UPSERT' && oldFlowRecords.containsKey(firstRecord.Id) == false) {
flowContext = 'INSERT';
+ } else if (flowContext == 'UPSERT') {
+ flowContext = 'UPDATE';
+ }
+
+ TriggerOperation matchingOperation;
+ switch on flowContext {
+ when 'INSERT' {
+ matchingOperation = TriggerOperation.AFTER_INSERT;
+ }
+ when 'UPDATE' {
+ matchingOperation = TriggerOperation.AFTER_UPDATE;
+ }
+ when 'DELETE' {
+ matchingOperation = TriggerOperation.BEFORE_DELETE;
+ }
}
+ populateCachedApexOperations(firstRecord.getSObjectType(), matchingOperation);
return flowContext == 'INSERT' ? '' : flowContext + '_';
}
private static Map getOldFlowRecords(FlowInput flowInput, SObjectType sObjectType) {
Map oldFlowRecords = new Map();
- if (flowInput.recordsToRollup?.isEmpty() == true || flowInput.rollupContext != 'UPDATE') {
+ if (flowInput.recordsToRollup?.isEmpty() == true || (flowInput.rollupContext != 'UPDATE' && flowInput.rollupContext != 'UPSERT')) {
return oldFlowRecords;
} else if (flowInput.oldRecordsToRollup?.isEmpty() == false) {
// normally, you could use a shortcut to initialize a Set like this
@@ -1956,35 +1658,24 @@ global without sharing virtual class Rollup implements Database.Batchable fieldTokensForObject = sObjectType.getDescribe().fields.getMap();
- for (SObject currentRecord : flowInput.recordsToRollup) {
- Map populatedFields = currentRecord.getPopulatedFieldsAsMap();
- // this is as close as we can get, at present, to detecting upserts. It won't work for records being inserted with the CreatedDate set to a historical value
- if (
- populatedFields.containsKey('CreatedDate') &&
- populatedFields.containsKey('LastModifiedDate') &&
- currentRecord.get('CreatedDate') == currentRecord.get('LastModifiedDate')
- ) {
- SObjectField rollupFieldOnCalcItem = fieldTokensForObject.get(flowInput.rollupFieldOnCalcItem);
- if (rollupFieldOnCalcItem != null && rollupFieldOnCalcItem.getDescribe().isCalculated() == false) {
- SObject clonedRecord = currentRecord.clone(true, true);
- clonedRecord.put(rollupFieldOnCalcItem, RollupFieldInitializer.Current.getDefaultValue(rollupFieldOnCalcItem));
- oldFlowRecords.put(currentRecord.Id, clonedRecord);
- }
- }
- }
}
return oldFlowRecords;
}
+ private static void populateCachedApexOperations(SObjectType calcItemSObjectType, TriggerOperation triggerContext) {
+ if (CACHED_APEX_OPERATIONS.containsKey(calcItemSObjectType)) {
+ CACHED_APEX_OPERATIONS.get(calcItemSObjectType).add(triggerContext);
+ } else {
+ CACHED_APEX_OPERATIONS.put(calcItemSObjectType, new Set{ triggerContext });
+ }
+ }
+
private static List getRollupMetadataBySObject(SObjectType sObjectType) {
String sObjectName = sObjectType.getDescribe().getName();
List rollupMetadatas = getMetadataFromCache(Rollup__mdt.SObjectType);
@@ -2019,7 +1710,7 @@ global without sharing virtual class Rollup implements Database.Batchable rollupOperations,
SObjectType sObjectType,
List calcItems,
@@ -2037,7 +1728,7 @@ global without sharing virtual class Rollup implements Database.Batchable fieldNameToField = describeForSObject.fields.getMap();
@@ -2066,10 +1757,10 @@ global without sharing virtual class Rollup implements Database.Batchable rollupInfo, List calcItems, Map oldCalcItems) {
- isRunningAsync = true; // the first rollup can immediately start rolling up, instead of dispatching to a queueable / another batchable
- Rollup roll = getRollup(rollupInfo, this.calcItemType, calcItems, oldCalcItems, null, this.invokePoint);
- roll.isFullRecalc = true;
- roll.runCalc();
- }
-
- protected void process(List rollups) {
- this.handleMultipleDMLRollupsEnqueuedInTheSameTransaction(rollups);
- this.getFieldNamesForRollups(rollups); // populates this.lookupObjectToUniqueFieldNames
-
- Map updatedLookupRecords = new Map();
- Map grandparentRollups = new Map();
- for (Rollup rollup : rollups) {
- RollupLogger.Instance.log('starting rollup for: ', rollup);
- // for each iteration, ensure we're not operating beyond the bounds of our query limits
- if (hasExceededCurrentRollupLimits(rollup.rollupControl) || rollup instanceof RollupFullBatchRecalculator) {
- this.deferredRollups.add(rollup);
- continue;
- }
-
- if (grandparentRollups.containsKey(rollup.lookupObj) && rollup.traversal == null) {
- rollup.traversal = grandparentRollups.get(rollup.lookupObj);
- }
-
- Map> calcItemsByLookupField = this.getCalcItemsByLookupField(rollup, this.lookupObjectToUniqueFieldNames.get(rollup.lookupObj));
- // some rollups may not finish retrieving all parent rows the first time around - and that's ok! we can keep
- // trying until all necessary records have been retrieved
- if (rollup.traversal?.getIsFinished() == false) {
- this.deferredRollups.add(rollup);
- continue;
- } else if (rollup.traversal != null && grandparentRollups.containsKey(rollup.lookupObj) == false) {
- // cache the traversal for any future callers - because we queried for ALL unique grand(or greater)parent fields
- // we don't need to re-traverse the whole object chain again if there are other grandparent rollups in the list
- grandparentRollups.put(rollup.lookupObj, rollup.traversal);
- }
-
- List localLookupItems = this.getLookupItems(calcItemsByLookupField, updatedLookupRecords, rollup);
- List updatedParentRecords = this.getUpdatedLookupItemsByRollup(rollup, calcItemsByLookupField, localLookupItems);
- for (SObject updatedRecord : updatedParentRecords) {
- updatedLookupRecords.put(updatedRecord.Id, updatedRecord);
- }
- }
-
- this.splitUpdates(updatedLookupRecords);
-
- DML.doUpdate(updatedLookupRecords.values());
-
- this.processDeferredRollups();
- }
-
- private void handleMultipleDMLRollupsEnqueuedInTheSameTransaction(List rolls) {
- // if items are inserted, updated, deleted (etc ...)
- // all in the same transaction, they can be introduced out of order
- // (e.g. the update rollup appears first in the list)
- // this sort restores the rollups to their proper ordering
- if (CACHED_APEX_OPERATIONS.isEmpty() == false && CACHED_ROLLUPS.isEmpty() == false) {
- rolls.addAll(CACHED_ROLLUPS);
- CACHED_ROLLUPS.clear();
- rolls.sort();
- }
- }
-
- private List getLookupItems(Map> calcItemsByLookupField, Map updatedLookupRecords, Rollup roll) {
- List localLookupItems = new List();
- Set lookupItemKeys = new Set(calcItemsByLookupField.keySet());
- for (String lookupId : calcItemsByLookupField.keySet()) {
- if (updatedLookupRecords.containsKey(lookupId)) {
- lookupItemKeys.remove(lookupId);
- // this way, the updated values are persisted for each field, and the default values are initialized
- SObject updatedLookupObject = updatedLookupRecords.get(lookupId);
- this.resetLookupFieldsForNullOrFullRecalcs(updatedLookupObject, roll);
- localLookupItems.add(updatedLookupObject);
- }
- }
- localLookupItems.addAll(this.getExistingLookupItems(lookupItemKeys, roll, this.lookupObjectToUniqueFieldNames.get(roll.lookupObj)));
- return localLookupItems;
- }
-
- private void splitUpdates(Map updatedLookupRecords) {
- if (this.rollupControl.MaxParentRowsUpdatedAtOnce__c < updatedLookupRecords.size()) {
- Integer maxIndexToRemove = updatedLookupRecords.size() / 2;
- Integer removalIndex = 0;
- List asyncUpdateList = new List();
- for (String lookupKey : updatedLookupRecords.keySet()) {
- SObject lookupRecordToUpdate = updatedLookupRecords.get(lookupKey);
- asyncUpdateList.add(lookupRecordToUpdate);
- updatedLookupRecords.remove(lookupKey);
- removalIndex++;
- if (removalIndex >= maxIndexToRemove) {
- break;
- }
- }
- System.enqueueJob(new RollupAsyncSaver(asyncUpdateList));
- }
- }
-
- private void processDeferredRollups() {
- if (this.deferredRollups.isEmpty() == false && isDeferralAllowed && stackDepth < this.rollupControl?.MaxRollupRetries__c) {
- stackDepth++;
- // tragic, but necessary due to limits on requeueing allowed during testing
- isDeferralAllowed = Test.isRunningTest() == false && this.rollupControl.MaxRollupRetries__c > stackDepth;
-
- this.rollups.clear();
- this.rollups.addAll(this.deferredRollups);
- this.deferredRollups.clear();
-
- this.getAsyncRollup().beginAsyncRollup();
- } else if (this.deferredRollups.isEmpty() == false) {
- this.throwWithRollupData(this.deferredRollups);
- }
- }
-
- private void throwWithRollupData(List rolls) {
- List failedRollupInfo = new List();
- for (Rollup roll : rolls) {
- failedRollupInfo.add(roll.metadata);
- }
- String exceptionString = 'rollup failed to re-queue for: ';
- RollupLogger.Instance.log(exceptionString, failedRollupInfo);
- throw new AsyncException(exceptionString + JSON.serialize(failedRollupInfo));
- }
-
- private void getFieldNamesForRollups(List rollups) {
- this.lookupObjectToUniqueFieldNames = new Map>();
- for (Rollup rollup : rollups) {
- String rollupField = rollup.opFieldOnLookupObject.getDescribe().getName();
- String lookupfield = rollup.lookupFieldOnLookupObject.getDescribe().getName();
- if (lookupObjectToUniqueFieldNames.containsKey(rollup.lookupObj)) {
- lookupObjectToUniqueFieldNames.get(rollup.lookupObj).addAll(new List{ rollupField, lookupField });
- } else {
- lookupObjectToUniqueFieldNames.put(rollup.lookupObj, new Set{ rollupField, lookupfield });
- }
- }
- }
-
- private Map> getCalcItemsByLookupField(Rollup rollup, Set uniqueQueryFieldNames) {
- if (String.isNotBlank(rollup.metadata.GrandparentRelationshipFieldPath__c) || rollup.metadata.RollupToUltimateParent__c) {
- if (rollup.traversal == null) {
- rollup.traversal = new RollupRelationshipFieldFinder(
- rollup.rollupControl,
- rollup.metadata,
- uniqueQueryFieldNames,
- rollup.lookupObj,
- rollup.oldCalcItems
- )
- .getParents(rollup.calcItems);
- } else if (rollup.traversal?.getIsFinished() == false) {
- rollup.traversal.recommence();
- }
- return rollup.traversal.getIsFinished() ? rollup.traversal.getParentLookupToRecords() : new Map>();
- }
- Map> lookupFieldToCalcItems = new Map>();
- for (SObject calcItem : rollup.calcItems) {
- String key = (String) calcItem.get(rollup.lookupFieldOnCalcItem);
- if (lookupFieldToCalcItems.containsKey(key) == false) {
- lookupFieldToCalcItems.put(key, new List{ calcItem });
- } else {
- lookupFieldToCalcItems.get(key).add(calcItem);
- }
-
- // if the lookup key differs from what it was on the old calc item,
- // include that value as well so that we can fix reparented records' rollup values
- SObject potentialOldCalcItem = rollup.oldCalcItems?.get(calcItem.Id);
- if (potentialOldCalcItem != null) {
- String oldKey = (String) potentialOldCalcItem.get(rollup.lookupFieldOnCalcItem);
-
- if (key == oldKey) {
- continue;
- }
-
- if (lookupFieldToCalcItems.containsKey(oldKey) == false) {
- lookupFieldToCalcItems.put(oldKey, new List{ potentialOldCalcItem });
- } else {
- lookupFieldToCalcItems.get(oldKey).add(potentialOldCalcItem);
- }
- }
- }
- return lookupFieldToCalcItems;
- }
-
- private void initializeRollupFieldDefaults(List lookupItems, Rollup rollup) {
- // prior to returning, we need to ensure the default value for the rollup field is set
- for (SObject lookupItem : lookupItems) {
- this.resetLookupFieldsForNullOrFullRecalcs(lookupItem, rollup);
- }
- }
-
- private void resetLookupFieldsForNullOrFullRecalcs(SObject lookupItem, Rollup rollup) {
- if (lookupItem.get(rollup.opFieldOnLookupObject) == null || rollup.isFullRecalc) {
- lookupItem.put(rollup.opFieldOnLookupObject, RollupFieldInitializer.Current.getDefaultValue(rollup.opFieldOnLookupObject));
- }
- }
-
- private void ingestRollupControlData() {
- RollupControl__mdt orgDefaults = this.rollupControl;
- for (Integer index = this.rollups.size() - 1; index >= 0; index--) {
- Rollup rollup = this.rollups[index];
- rollup.isFullRecalc = rollup.isFullRecalc || this.isFullRecalc;
-
- Boolean shouldRunSyncDeferred = this.getShouldRunSyncDeferred(rollup);
- Boolean couldRunSync =
- rollup.rollupControl.ShouldRunAs__c == RollupMetaPicklists.ShouldRunAs.SYNCHRONOUS ||
- (hasExceededCurrentRollupLimits(rollup.rollupControl) == false) && isRunningAsync;
-
- if (rollup.rollupControl.ShouldAbortRun__c || orgDefaults.ShouldAbortRun__c) {
- this.rollups.remove(index);
- } else if (couldRunSync && shouldRunSyncDeferred == false) {
- this.rollups.remove(index);
- this.syncRollups.add(rollup);
- } else if (couldRunSync && shouldRunSyncDeferred) {
- this.rollups.remove(index);
- CACHED_ROLLUPS.add(rollup);
- }
-
- // you can increase the default limits, but it would be too messy to try to rank the individual rollup operations in a batched context
- if (rollup.rollupControl.MaxLookupRowsBeforeBatching__c > orgDefaults.MaxLookupRowsBeforeBatching__c) {
- orgDefaults.MaxLookupRowsBeforeBatching__c = rollup.rollupControl.MaxLookupRowsBeforeBatching__c;
- }
- if (rollup.rollupControl.ShouldRunAs__c != orgDefaults.ShouldRunAs__c) {
- orgDefaults.ShouldRunAs__c = rollup.rollupControl.ShouldRunAs__c;
- }
- if (rollup.rollupControl.MaxParentRowsUpdatedAtOnce__c == null) {
- rollup.rollupControl.MaxParentRowsUpdatedAtOnce__c = orgDefaults.MaxParentRowsUpdatedAtOnce__c;
- }
- }
- }
-
- private Boolean getShouldRunSyncDeferred(Rollup roll) {
- if (roll.isNoOp || CACHED_APEX_OPERATIONS.containsKey(roll.calcItemType) == false) {
- return false;
- }
- Set apexOperations = CACHED_APEX_OPERATIONS.get(roll.calcItemType);
- if (apexOperations.contains(TriggerOperation.AFTER_INSERT) && roll.op.name().contains('UPDATE')) {
- return true;
- } else if (
- (apexOperations.contains(TriggerOperation.AFTER_INSERT) || apexOperations.contains(TriggerOperation.AFTER_UPDATE)) && roll.op.name().contains('DELETE')
- ) {
- return true;
- }
-
- return false;
- }
-
- private Integer getLookupRecordsCount(Boolean hasMoreThanOneTarget) {
- // we need to burn a few SOQL calls to consider how many records are going to be queried/updated
- // then, using RollupControl__mdt and/or sensible defaults, we'll decide whether to queue up or batch (or fail - that's always an option)
- // if there's more than one SObjectType involved we bail on retrieving the actual count
- // because you can only return one list of SObjects from a batch job's QueryLocator
- SObjectType targetType;
- Map> queryCountsToLookupIds = new Map>();
-
- for (Rollup roll : this.rollups) {
- if (targetType == null) {
- targetType = roll.lookupObj;
- } else if (roll.lookupObj != targetType) {
- hasMoreThanOneTarget = true;
- }
-
- if (String.isNotBlank(roll.metadata?.GrandparentRelationshipFieldPath__c)) {
- // getting the count for grandparent (or greater) relationships will be handled further
- // downstream; for our purposes, it isn't useful to try to get all of the records while
- // we're still in a sync context
- continue;
- } else if (roll.calcItems?.isEmpty() != false) {
- continue;
- }
-
- if (hasMoreThanOneTarget) {
- break;
- }
-
- Set uniqueIds = new Set();
-
- for (SObject calcItem : roll.calcItems) {
- String lookupKey = (String) calcItem.get(roll.lookupFieldOnCalcItem);
- if (String.isNotBlank(lookupKey)) {
- uniqueIds.add(lookupKey);
- }
- }
-
- String countQuery = RollupQueryBuilder.Current.getQuery(
- roll.lookupObj,
- new List{ 'Count()' },
- String.valueOf(roll.lookupFieldOnLookupObject),
- '='
- );
- if (queryCountsToLookupIds.containsKey(countQuery)) {
- queryCountsToLookupIds.get(countQuery).addAll(uniqueIds);
- } else {
- queryCountsToLookupIds.put(countQuery, uniqueIds);
- }
- }
-
- Integer totalCountOfRecords = 0;
- if (hasMoreThanOneTarget == false) {
- for (String countQuery : queryCountsToLookupIds.keySet()) {
- Set objIds = queryCountsToLookupIds.get(countQuery);
- Integer countForSObject = getCountFromDb(countQuery, objIds);
- if (countForSObject == SENTINEL_COUNT_VALUE) {
- totalCountOfRecords = countForSObject;
- break;
- } else {
- totalCountOfRecords += countForSObject;
- }
- }
- }
- return totalCountOfRecords;
- }
-
- private List getUpdatedLookupItemsByRollup(Rollup rollup, Map> calcItemsByLookupField, List lookupItems) {
- Map recordsToUpdate = new Map();
- Map> oldLookupItems = new Map>();
- Set unprocessedCalcItems = new Set();
- RollupSObjectUpdater updater = new RollupSObjectUpdater(rollup.opFieldOnLookupObject);
-
- for (Integer index = lookupItems.size() - 1; index >= 0; index--) {
- SObject lookupRecord = lookupItems[index];
- String key = (String) lookupRecord.get(rollup.lookupFieldOnLookupObject);
- if (calcItemsByLookupField.containsKey(key)) {
- List localCalcItems = calcItemsByLookupField.get(key);
-
- if (hasExceededCurrentRollupLimits(this.rollupControl)) {
- unprocessedCalcItems.addAll(localCalcItems);
- lookupItems.remove(index);
- continue;
- }
-
- this.winnowCalcItemsAndCheckReparenting(rollup, localCalcItems, oldLookupItems);
-
- // Check for changed values
- RollupLogger.Instance.log('lookup record prior to rolling up: ', lookupRecord);
- Object priorVal = lookupRecord.get(rollup.opFieldOnLookupObject);
- Object newVal = this.getRollupVal(rollup, localCalcItems, priorVal, key, rollup.lookupFieldOnCalcItem);
- if (priorVal != newVal) {
- updater.updateField(lookupRecord, newVal);
- recordsToUpdate.put(key, lookupRecord);
- }
- RollupLogger.Instance.log('lookup record after rolling up: ', lookupRecord);
- }
- }
-
- this.removeRolledUpValuesFromReparentedRecords(lookupItems, oldLookupItems, recordsToUpdate, rollup);
- this.deferCalculationsWhenApproachingLimits(rollup, unprocessedCalcItems);
-
- return recordsToUpdate.values();
- }
-
- private void deferCalculationsWhenApproachingLimits(Rollup roll, Set unprocessedCalcItems) {
- // remove the calc items that were successfully processed -
- // they're the ones that aren't in the unprocessed Set
- for (Integer index = roll.calcItems.size() - 1; index >= 0; index--) {
- SObject calcItem = roll.calcItems[index];
- if (unprocessedCalcItems.contains(calcItem) == false) {
- roll.calcItems.remove(index);
- }
- }
- // if all of the calc items have been processed, we're golden - no need to proceed
- // otherwise, the newly trimmed-down Rollup will get picked up downstream for
- // reprocessing!
- if (roll.calcItems.isEmpty() == false) {
- this.deferredRollups.add(roll);
- }
- }
-
- private void winnowCalcItemsAndCheckReparenting(Rollup roll, List localCalcItems, Map> oldLookupItems) {
- for (Integer index = localCalcItems.size() - 1; index >= 0; index--) {
- SObject calcItem = localCalcItems[index];
- if (roll.metadata?.IsFullRecordSet__c == true && roll.eval.matches(calcItem) == false) {
- // technically it should only be possible for a calc item that doesn't match
- // to still exist if it is a Full Record Set operation; this gives people the chance
- // to reset rollup values if none of the records passed in match the eval criteria
- localCalcItems.remove(index);
- continue;
- }
- // Check for reparented records
- SObject oldCalcItem = roll.oldCalcItems.get(calcItem.Id);
-
- if (oldCalcItem == null) {
- continue;
- }
-
- String priorLookup = (String) oldCalcItem.get(roll.lookupFieldOnCalcItem);
- // if the lookup wasn't previously populated, there's nothing to update
- if (String.isBlank(priorLookup)) {
- continue;
- }
- Object newLookup = calcItem.get(roll.lookupFieldOnCalcItem);
-
- if (newLookup != priorLookup && roll.traversal == null) {
- this.populateOldLookupItems(priorLookup, oldCalcItem, oldLookupItems);
- } else if (roll.traversal?.isUltimatelyReparented(calcItem, roll.lookupFieldOnCalcItem.getDescribe().getName()) == true) {
- // slightly different, but with the same end result
- // note that when the reparented record is not null
- // it should be the same as the current "lookupRecord"
- SObject reparentedRecord = roll.traversal.retrieveParent(oldCalcItem.Id);
- if (reparentedRecord != null) {
- priorLookup = (String) reparentedRecord.get(roll.lookupFieldOnLookupObject);
- if (String.isNotBlank(priorLookup)) {
- Id oldLookupId = roll.traversal.getOldLookupId(calcItem, roll.lookupFieldOnCalcItem.getDescribe().getName());
- oldCalcItem = this.reassignOldCalcItemIfValueChanged(oldLookupId, oldCalcItem, roll);
- this.populateOldLookupItems(priorLookup, oldCalcItem, oldLookupItems);
- }
- }
- }
- }
- }
-
- private void populateOldLookupItems(String priorLookup, SObject oldCalcItem, Map> oldLookupItems) {
- if (oldLookupItems.containsKey(priorLookup) == false) {
- oldLookupItems.put(priorLookup, new List{ oldCalcItem });
- } else {
- oldLookupItems.get(priorLookup).add(oldCalcItem);
- }
- }
-
- private SObject reassignOldCalcItemIfValueChanged(String lookupId, SObject oldCalcItem, Rollup rollup) {
- if (String.isBlank(lookupId)) {
- return oldCalcItem;
- }
- // truly terrible, but before we pass the old item through the reparenting code path, we need to validate that it's only
- // the lookup field that has changed; otherwise, if the opFieldOnCalcItem has changed too, substitute the item whose value
- // previously corresponded to the parent record
- for (SObject otherOldCalcItem : rollup.oldCalcItems.values()) {
- if (otherOldCalcItem.get(rollup.lookupFieldOnCalcItem) == lookupId) {
- if (otherOldCalcItem.get(rollup.opFieldOnCalcItem) != oldCalcItem.get(rollup.opFieldOnCalcItem)) {
- return otherOldCalcItem;
- }
- break; // break on the match, no matter what
- }
- }
- return oldCalcItem;
- }
-
- private Object getRollupVal(Rollup roll, List calcItems, Object priorVal, String lookupRecordKey, SObjectField lookupKeyField) {
- RollupCalculator rollupCalc = RollupCalculator.Factory.getCalculator(
- priorVal,
- roll.op,
- roll.opFieldOnCalcItem,
- roll.opFieldOnLookupObject,
- roll.metadata,
- lookupRecordKey,
- lookupKeyField
- );
- rollupCalc.setEvaluator(roll.eval);
- rollupCalc.setCDCUpdate(this.isCDCUpdate);
- rollupCalc.performRollup(calcItems, roll.oldCalcItems);
- return rollupCalc.getReturnValue();
- }
-
- private void removeRolledUpValuesFromReparentedRecords(
- List lookupItems,
- Map> oldLookupItems,
- Map recordsToUpdate,
- Rollup roll
- ) {
- for (SObject lookupRecord : lookupItems) {
- String key = (String) lookupRecord.get(roll.lookupFieldOnLookupObject);
- if (oldLookupItems.containsKey(key)) {
- // Yes, old parent record has already had a new rollup established in memory
- List reparentedCalcItems = oldLookupItems.get(key);
-
- if (reparentedCalcItems.isEmpty()) {
- continue;
- }
-
- String currentOp = getBaseOperationName(roll.op.name());
- String deleteOpName = 'DELETE_' + currentOp;
- Op deleteOp = opNameToOp.get(deleteOpName);
- Rollup oldLookupsRollup = new Rollup(roll, deleteOp, reparentedCalcItems);
-
- RollupLogger.Instance.log('reparenting operation: ', oldLookupsRollup);
- RollupLogger.Instance.log('Reparented item prior to reparenting rollup: ', lookupRecord);
-
- Object priorVal = lookupRecord.get(roll.opFieldOnLookupObject);
- Object newVal = this.getRollupVal(oldLookupsRollup, reparentedCalcItems, priorVal, key, roll.lookupFieldOnCalcItem);
-
- if (priorVal != newVal) {
- lookupRecord.put(roll.opFieldOnLookupObject, newVal);
- recordsToUpdate.put(key, lookupRecord);
- }
- RollupLogger.Instance.log('Reparented item after reparenting rollup: ', lookupRecord);
- }
- }
- }
-
private class RollupSchedulable implements System.Schedulable {
private final String query;
private final SObjectType rollupObject;
diff --git a/rollup/core/classes/RollupAsyncProcessor.cls b/rollup/core/classes/RollupAsyncProcessor.cls
new file mode 100644
index 00000000..4081e1f3
--- /dev/null
+++ b/rollup/core/classes/RollupAsyncProcessor.cls
@@ -0,0 +1,962 @@
+global virtual without sharing class RollupAsyncProcessor extends Rollup implements Database.Batchable, System.Comparable {
+ private final SObjectField opFieldOnCalcItem;
+ private final SObjectField lookupFieldOnCalcItem;
+ private final SObjectField lookupFieldOnLookupObject;
+ private final SObjectField opFieldOnLookupObject;
+ private final SObjectType lookupObj;
+ private final Evaluator eval;
+ private final Op op;
+
+ private final List deferredRollups = new List();
+ private final List syncRollups = new List();
+
+ protected final List calcItems;
+ protected final Map oldCalcItems;
+ protected final Rollup__mdt metadata;
+ protected final SObjectType calcItemType;
+
+ private RollupRelationshipFieldFinder.Traversal traversal;
+ private Map> lookupObjectToUniqueFieldNames;
+ private List lookupItems;
+ private RollupAsyncProcessor fullRecalcProcessor;
+
+ private static final RollupSettings__c SETTINGS = RollupSettings__c.getInstance();
+ private static Integer stackDepth = 0;
+ private static Boolean isRunningAsync = false;
+ @testVisible
+ private static Boolean shouldRunAsBatch = false;
+
+ private class RollupAsyncSaver implements System.Queueable {
+ private final List records;
+ public RollupAsyncSaver(List records) {
+ this.records = records;
+ }
+
+ public void execute(QueueableContext context) {
+ new DMLHelper().doUpdate(this.records);
+ }
+ }
+
+ public static void flatten(List stackedRollups) {
+ Map rollupOperationToRollup = new Map();
+ Integer counter = 0;
+ Map> operationToProcessedRecords = new Map>();
+ for (RollupAsyncProcessor stackedRollup : stackedRollups) {
+ // If the hashed contents are the same, we can't collapse
+ // the two rollup operations, and instead have to juggle the updated values for the parent in memory
+ String rollupKey = stackedRollup.getHashedContents();
+ Boolean shouldAddSameRollupOperation = false;
+
+ if (rollupOperationToRollup.containsKey(rollupKey)) {
+ RollupAsyncProcessor matchingRollup = rollupOperationToRollup.get(rollupKey);
+ for (Integer index = stackedRollup.calcItems.size() - 1; index >= 0; index--) {
+ SObject calcItem = stackedRollup.calcItems[index];
+ if (matchingRollup.calcItems.contains(calcItem) == false && operationToProcessedRecords.containsKey(rollupKey) == false) {
+ doBookkeepingOnCachedItems(matchingRollup, stackedRollup, operationToProcessedRecords, calcItem, rollupKey, index);
+ } else if (
+ matchingRollup.calcItems.contains(calcItem) == false &&
+ operationToProcessedRecords.containsKey(rollupKey) &&
+ operationToProcessedRecords.get(rollupKey).contains(calcItem.Id) == false
+ ) {
+ doBookkeepingOnCachedItems(matchingRollup, stackedRollup, operationToProcessedRecords, calcItem, rollupKey, index);
+ }
+ }
+
+ if (stackedRollup.calcItems.isEmpty() == false) {
+ shouldAddSameRollupOperation = true;
+ }
+ } else {
+ rollupOperationToRollup.put(rollupKey, stackedRollup);
+ }
+ if (shouldAddSameRollupOperation) {
+ counter++;
+ rollupOperationToRollup.put(rollupKey + counter, stackedRollup);
+ }
+ }
+ stackedRollups.clear();
+ stackedRollups.addAll(rollupOperationToRollup.values());
+ }
+
+ public static RollupAsyncProcessor getProcessor(
+ List calcItems,
+ SObjectField opFieldOnCalcItem,
+ SObjectField lookupFieldOnCalcItem,
+ SObjectField lookupFieldOnLookupObject,
+ SObjectField opFieldOnLookupObject,
+ SObjectType lookupObj,
+ SObjectType calcItem,
+ Op operation,
+ Map oldCalcItems,
+ Evaluator eval,
+ InvocationPoint rollupInvokePoint,
+ RollupControl__mdt rollupControl,
+ Rollup__mdt metadata
+ ) {
+ return new QueueableProcessor(
+ calcItems,
+ opFieldOnCalcItem,
+ lookupFieldOnCalcItem,
+ lookupFieldOnLookupObject,
+ opFieldOnLookupObject,
+ lookupObj,
+ calcItem,
+ operation,
+ oldCalcItems,
+ eval,
+ rollupInvokePoint,
+ rollupControl,
+ metadata
+ );
+ }
+
+ public RollupAsyncProcessor(InvocationPoint invokePoint) {
+ super(invokePoint);
+ this.isBatched = true;
+ // a batch only becomes valid if other Rollups are added to it
+ this.isNoOp = true;
+ }
+
+ public RollupAsyncProcessor(RollupAsyncProcessor innerRollup, Op op, List calcItems) {
+ this(
+ calcItems,
+ innerRollup.opFieldOnCalcItem,
+ innerRollup.lookupFieldOnCalcItem,
+ innerRollup.lookupFieldOnLookupObject,
+ innerRollup.opFieldOnLookupObject,
+ innerRollup.lookupObj,
+ innerRollup.calcItemType,
+ op,
+ innerRollup.oldCalcItems,
+ null, // eval gets assigned below
+ innerRollup.invokePoint,
+ innerRollup.rollupControl,
+ innerRollup.metadata
+ );
+
+ this.rollups.addAll(innerRollup.rollups);
+ this.isNoOp = this.rollups.isEmpty() && innerRollup.metadata?.IsFullRecordSet__c == false;
+ this.isFullRecalc = innerRollup.isFullRecalc;
+ this.isCDCUpdate = innerRollup.isCDCUpdate;
+ this.eval = innerRollup.eval;
+ }
+
+ public RollupAsyncProcessor(RollupAsyncProcessor innerRollup) {
+ this(innerRollup, innerRollup.op, innerRollup.calcItems);
+ }
+
+ public RollupAsyncProcessor(
+ List calcItems,
+ SObjectField opFieldOnCalcItem,
+ SObjectField lookupFieldOnCalcItem,
+ SObjectField lookupFieldOnLookupObject,
+ SObjectField opFieldOnLookupObject,
+ SObjectType lookupObj,
+ SObjectType calcItemType,
+ Op op,
+ Map oldCalcItems,
+ Evaluator eval,
+ InvocationPoint invokePoint,
+ RollupControl__mdt rollupControl,
+ Rollup__mdt rollupMetadata
+ ) {
+ super();
+ this.calcItems = calcItems;
+ this.opFieldOnCalcItem = opFieldOnCalcItem;
+ this.lookupFieldOnCalcItem = lookupFieldOnCalcItem;
+ this.lookupFieldOnLookupObject = lookupFieldOnLookupObject;
+ this.opFieldOnLookupObject = opFieldOnLookupObject;
+ this.lookupObj = lookupObj;
+ this.calcItemType = calcItemType;
+ this.op = op;
+ this.oldCalcItems = oldCalcItems;
+ this.invokePoint = invokePoint;
+ this.rollupControl = rollupControl;
+ this.metadata = rollupMetadata;
+ this.isBatched = false;
+
+ if (eval != null) {
+ this.eval = eval;
+ }
+
+ this.isNoOp = this.calcItems?.isEmpty() == true && this.metadata?.IsFullRecordSet__c == false;
+ }
+
+ public Integer compareTo(Object otherRollup) {
+ Integer numberToReturn = 0;
+ if (otherRollup instanceof Rollup) {
+ RollupAsyncProcessor that = (RollupAsyncProcessor) otherRollup;
+ Boolean thisDelete = this.op.name().contains('DELETE');
+ Boolean thatDelete = that.op.name().contains('DELETE');
+ Boolean thisUpdate = this.op.name().contains('UPDATE');
+ Boolean thatUpdate = that.op.name().contains('UPDATE');
+ Boolean thisInsert = thisDelete == false && thisUpdate == false;
+ Boolean thatInsert = thatDelete == false && thatUpdate == false;
+
+ // INSERT operations always come first, then UPDATEs, then DELETEs (UNDELETEs are transformed to INSERT)
+ if (thisInsert && (thatUpdate || thatDelete)) {
+ numberToReturn = -1;
+ } else if ((thisUpdate || thisDelete) && thatInsert) {
+ numberToReturn = 1;
+ } else if (thatUpdate && thisDelete) {
+ numberToReturn = 1;
+ } else if (thisUpdate && thatDelete) {
+ numberToReturn = -1;
+ }
+ }
+
+ return numberToReturn;
+ }
+
+ public override String toString() {
+ Map props = new Map{
+ 'Invocation Point' => this.invokePoint.name(),
+ 'Calc Items' => JSON.serializePretty(this.calcItems),
+ 'Old Calc Items' => JSON.serializePretty(this.oldCalcItems),
+ 'Rollup Metadata' => JSON.serializePretty(this.metadata),
+ 'Rollup Control' => JSON.serializePretty(this.rollupControl),
+ 'Is Full Recalc' => String.valueOf(this.isFullRecalc),
+ 'Is No Op' => String.valueOf(this.isNoOp)
+ };
+ String baseString = '';
+ for (String key : props.keySet()) {
+ baseString += key + ': ' + props.get(key) + '\n';
+ }
+ return baseString.removeEnd('\n');
+ }
+
+ public virtual Database.QueryLocator start(Database.BatchableContext context) {
+ /**
+ * for batch, we know 100% for sure there's only 1 SObjectType / Set in the map.
+ * NB: we have to call "getFieldNamesForRollups" in both the "start" and "execute" methods because
+ * trying to use Database.Stateful on the top-level class ** in addition to Batchable ** results in the dreaded:
+ * "System.AsyncException: Queueable cannot be implemented with other system interfaces" exception
+ */
+ this.getFieldNamesForRollups(this.rollups);
+ String lookupFieldForLookupObject;
+ SObjectType sObjectType;
+ Set objIds = new Set();
+ for (RollupAsyncProcessor rollup : this.rollups) {
+ sObjectType = rollup.lookupObj;
+ lookupFieldForLookupObject = rollup.lookupFieldOnLookupObject.getDescribe().getName();
+ objIds.addAll(this.getCalcItemsByLookupField(rollup, this.lookupObjectToUniqueFieldNames.get(sObjectType)).keySet());
+ }
+ String query = RollupQueryBuilder.Current.getQuery(
+ sObjectType,
+ new List(this.lookupObjectToUniqueFieldNames.get(sObjectType)),
+ lookupFieldForLookupObject,
+ '='
+ );
+ RollupLogger.Instance.log('starting batch with query: ', query, LoggingLevel.DEBUG);
+ RollupLogger.Instance.save();
+ return Database.getQueryLocator(query);
+ }
+
+ public virtual void execute(Database.BatchableContext context, List lookupItems) {
+ for (RollupAsyncProcessor rollup : this.rollups) {
+ this.initializeRollupFieldDefaults(lookupItems, rollup);
+ }
+ this.lookupItems = lookupItems;
+ this.process(this.rollups);
+ RollupLogger.Instance.save();
+ }
+
+ public virtual void finish(Database.BatchableContext context) {
+ RollupLogger.Instance.log('batch finished successfully', LoggingLevel.DEBUG);
+ RollupLogger.Instance.save();
+ }
+
+ public override String runCalc() {
+ // side effect in the below method - rollups can be removed from this.rollups if a control record ShouldAbortRun__c == true
+ this.ingestRollupControlData();
+
+ if (this.isNoOp) {
+ this.isNoOp = this.rollups.isEmpty() && this.syncRollups.isEmpty();
+ }
+
+ String rollupProcessId = 'No process Id';
+ if (this.isNoOp || this.rollupControl.ShouldAbortRun__c || SETTINGS.IsEnabled__c == false) {
+ RollupLogger.Instance.save();
+ return rollupProcessId;
+ }
+
+ Boolean hasMoreThanOneTarget = false;
+ Integer totalCountOfRecords = this.getLookupRecordsCount(hasMoreThanOneTarget);
+
+ shouldRunAsBatch =
+ shouldRunAsBatch ||
+ hasMoreThanOneTarget == false &&
+ ((this.rollupControl.ShouldRunAs__c == RollupMetaPicklists.ShouldRunAs.BATCHABLE &&
+ totalCountOfRecords >= this.rollupControl.MaxLookupRowsBeforeBatching__c) || totalCountOfRecords == RollupQueryBuilder.SENTINEL_COUNT_VALUE);
+ if (this.syncRollups.isEmpty() == false) {
+ RollupLogger.Instance.log('about to process sync rollups', LoggingLevel.DEBUG);
+ this.process(this.syncRollups);
+ rollupProcessId = 'Running rollups flagged to go synchronously';
+ } else {
+ rollupProcessId = this.getAsyncRollup().beginAsyncRollup();
+ }
+ RollupLogger.Instance.save();
+ return rollupProcessId;
+ }
+
+ protected RollupAsyncProcessor getAsyncRollup() {
+ // swap off on which async process is running to achieve infinite scaling
+ isRunningAsync = true;
+ Boolean canEnqueue = Limits.getLimitQueueableJobs() > Limits.getQueueableJobs();
+ Boolean isAsyncInnerClass = this instanceof QueueableProcessor;
+ RollupAsyncProcessor roll;
+ // deferred rollups delay the full batch recalc
+ // till all the others have gone
+ if (this.rollups.size() == 1 && this.rollups[0] instanceof RollupFullBatchRecalculator) {
+ roll = this.rollups[0];
+ } else if (this instanceof RollupFullBatchRecalculator) {
+ roll = this;
+ } else if (shouldRunAsBatch && System.isBatch() == false) {
+ // safe to batch because the QueryLocator will only return one type of SObject
+ // we have to re-initialize the rollup because it's the Queueable inner class
+ // at this point, and without re-initialization we get "System.UnexpectedException: Error processing messages"
+ roll = new RollupAsyncProcessor(this);
+ } else if (canEnqueue && System.isQueueable() == false && isAsyncInnerClass == false) {
+ roll = new QueueableProcessor(this);
+ } else if (canEnqueue && isAsyncInnerClass) {
+ roll = this;
+ } else {
+ // the end of the line
+ this.throwWithRollupData(this.rollups);
+ }
+
+ return roll;
+ }
+
+ private class QueueableProcessor extends RollupAsyncProcessor implements System.Queueable {
+ private QueueableProcessor(
+ List calcItems,
+ SObjectField opFieldOnCalcItem,
+ SObjectField lookupFieldOnCalcItem,
+ SObjectField lookupFieldOnLookupObject,
+ SObjectField opFieldOnLookupObject,
+ SObjectType lookupObj,
+ SObjectType calcItem,
+ Op operation,
+ Map oldCalcItems,
+ Evaluator eval,
+ InvocationPoint rollupInvokePoint,
+ RollupControl__mdt rollupControl,
+ Rollup__mdt metadata
+ ) {
+ super(
+ calcItems,
+ opFieldOnCalcItem,
+ lookupFieldOnCalcItem,
+ lookupFieldOnLookupObject,
+ opFieldOnLookupObject,
+ lookupObj,
+ calcItem,
+ operation,
+ oldCalcItems,
+ eval,
+ rollupInvokePoint,
+ rollupControl,
+ metadata
+ );
+ }
+
+ private QueueableProcessor(InvocationPoint rollupInvokePoint) {
+ super(rollupInvokePoint);
+ }
+
+ private QueueableProcessor(RollupAsyncProcessor roll) {
+ super(roll);
+ }
+
+ protected override String beginAsyncRollup() {
+ RollupLogger.Instance.log('about to queue', LoggingLevel.DEBUG);
+ RollupLogger.Instance.save();
+ return System.enqueueJob(this);
+ }
+
+ protected override List getExistingLookupItems(Set objIds, RollupAsyncProcessor rollup, Set uniqueQueryFieldNames) {
+ if (objIds.isEmpty()) {
+ return new List();
+ } else {
+ List localLookupItems;
+ if (String.isNotBlank(rollup.metadata.GrandparentRelationshipFieldPath__c)) {
+ localLookupItems = rollup.traversal.getAllParents();
+ // winnow the list, which would otherwise occur because of specifically only querying for the objIds passed in
+ for (Integer index = localLookupItems.size() - 1; index >= 0; index--) {
+ SObject lookupItem = localLookupItems[index];
+ String key = (String) lookupItem.get(rollup.lookupFieldOnLookupObject);
+ if (objIds.contains(key) == false) {
+ localLookupItems.remove(index);
+ }
+ }
+ } else {
+ String queryString = RollupQueryBuilder.Current.getQuery(
+ rollup.lookupObj,
+ new List(uniqueQueryFieldNames),
+ String.valueOf(rollup.lookupFieldOnLookupObject),
+ '='
+ );
+ // non-obvious coupling between "objIds" and the computed "queryString", which uses dynamic variable binding
+ localLookupItems = Database.query(queryString);
+ }
+ this.initializeRollupFieldDefaults(localLookupItems, rollup);
+ return localLookupItems;
+ }
+ }
+
+ public void execute(System.QueueableContext qc) {
+ this.process(this.rollups);
+ RollupLogger.Instance.log('queueable finished successfully', LoggingLevel.DEBUG);
+ RollupLogger.Instance.save();
+ }
+ }
+
+ protected virtual String beginAsyncRollup() {
+ RollupLogger.Instance.log('about to start batch', LoggingLevel.DEBUG);
+ RollupLogger.Instance.save();
+ return Database.executeBatch(this, this.rollupControl.BatchChunkSize__c.intValue());
+ }
+
+ protected virtual List getExistingLookupItems(Set objIds, RollupAsyncProcessor rollup, Set uniqueQueryFieldNames) {
+ // for Rollups that are Batchable, the lookup items are retrieved en masse in the "start" method and cached in the "execute method"
+ return this.lookupItems;
+ }
+
+ protected void processDelegatedFullRecalcRollup(List rollupInfo, List calcItems, Map oldCalcItems) {
+ isRunningAsync = true; // the first rollup can immediately start rolling up, instead of dispatching to a queueable / another batchable
+ RollupAsyncProcessor roll = this.getAsyncRollup(rollupInfo, this.calcItemType, calcItems, oldCalcItems, null, this.invokePoint);
+ roll.isFullRecalc = true;
+ roll.fullRecalcProcessor = this;
+ roll.runCalc();
+ }
+
+ protected virtual void retrieveAdditionalCalcItems(List localCalcItems, String lookupKey, String lookupFieldOnCalcItem, Rollup__mdt meta) {
+ this.fullRecalcProcessor?.retrieveAdditionalCalcItems(localCalcItems, lookupKey, lookupFieldOnCalcItem, meta);
+ }
+
+ protected virtual Boolean shouldBypassUpdatingLookupItems(String lookupKey) {
+ return this.fullRecalcProcessor?.shouldBypassUpdatingLookupItems(lookupKey) == true;
+ }
+
+ protected void process(List rollups) {
+ this.handleMultipleDMLRollupsEnqueuedInTheSameTransaction(rollups);
+ this.getFieldNamesForRollups(rollups); // populates this.lookupObjectToUniqueFieldNames
+
+ Map updatedLookupRecords = new Map();
+ Map grandparentRollups = new Map();
+ for (RollupAsyncProcessor roll : rollups) {
+ RollupLogger.Instance.log('starting rollup for: ', roll, LoggingLevel.DEBUG);
+ // for each iteration, ensure we're not operating beyond the bounds of our query limits
+ if (hasExceededCurrentRollupLimits(roll.rollupControl) || roll instanceof RollupFullBatchRecalculator) {
+ this.deferredRollups.add(roll);
+ continue;
+ }
+
+ if (grandparentRollups.containsKey(roll.lookupObj) && roll.traversal == null) {
+ roll.traversal = grandparentRollups.get(roll.lookupObj);
+ }
+
+ Map> calcItemsByLookupField = this.getCalcItemsByLookupField(roll, this.lookupObjectToUniqueFieldNames.get(roll.lookupObj));
+ // some rollups may not finish retrieving all parent rows the first time around - and that's ok! we can keep
+ // trying until all necessary records have been retrieved
+ if (roll.traversal?.getIsFinished() == false) {
+ this.deferredRollups.add(roll);
+ continue;
+ } else if (roll.traversal != null && grandparentRollups.containsKey(roll.lookupObj) == false) {
+ // cache the traversal for any future callers - because we queried for ALL unique grand(or greater)parent fields
+ // we don't need to re-traverse the whole object chain again if there are other grandparent rollups in the list
+ grandparentRollups.put(roll.lookupObj, roll.traversal);
+ }
+
+ List localLookupItems = this.getLookupItems(calcItemsByLookupField, updatedLookupRecords, roll);
+ List updatedParentRecords = this.getUpdatedLookupItemsByRollup(roll, calcItemsByLookupField, localLookupItems);
+ for (SObject updatedRecord : updatedParentRecords) {
+ updatedLookupRecords.put(updatedRecord.Id, updatedRecord);
+ }
+ }
+
+ this.splitUpdates(updatedLookupRecords);
+
+ this.getDML().doUpdate(updatedLookupRecords.values());
+
+ this.processDeferredRollups();
+ }
+
+ private String getHashedContents() {
+ // the only thing that necessarily makes a rollup unique is the sum total of the metadata behind it
+ // as well as the calc items driving that calculation.
+ // you could have multiple rollups with different calc item where clauses all rolling up to the same field
+ // even worse - in situations where multiple DML operations are enqueued in the same transaction
+ // the same calc items by Id might differ slightly by field value.
+ return String.valueOf(this.metadata);
+ }
+
+ private void handleMultipleDMLRollupsEnqueuedInTheSameTransaction(List rolls) {
+ // if items are inserted, updated, deleted (etc ...)
+ // all in the same transaction, they can be introduced out of order
+ // (e.g. the update rollup appears first in the list)
+ // this sort restores the rollups to their proper ordering
+ for (Rollup cachedRoll : this.getCachedRollups()) {
+ if (cachedRoll.isNoOp == false) {
+ rolls.add(cachedRoll);
+ }
+ }
+ this.getCachedRollups().clear();
+ rolls.sort();
+ }
+
+ private List getLookupItems(
+ Map> calcItemsByLookupField,
+ Map updatedLookupRecords,
+ RollupAsyncProcessor roll
+ ) {
+ List localLookupItems = new List();
+ Set lookupItemKeys = new Set(calcItemsByLookupField.keySet());
+ for (String lookupId : calcItemsByLookupField.keySet()) {
+ if (updatedLookupRecords.containsKey(lookupId)) {
+ lookupItemKeys.remove(lookupId);
+ // this way, the updated values are persisted for each field, and the default values are initialized
+ SObject updatedLookupObject = updatedLookupRecords.get(lookupId);
+ this.resetLookupFieldsForNullOrFullRecalcs(updatedLookupObject, roll);
+ localLookupItems.add(updatedLookupObject);
+ }
+ }
+ localLookupItems.addAll(roll.getExistingLookupItems(lookupItemKeys, roll, this.lookupObjectToUniqueFieldNames.get(roll.lookupObj)));
+ return localLookupItems;
+ }
+
+ private void splitUpdates(Map updatedLookupRecords) {
+ if (this.rollupControl.MaxParentRowsUpdatedAtOnce__c < updatedLookupRecords.size()) {
+ Integer maxIndexToRemove = updatedLookupRecords.size() / 2;
+ Integer removalIndex = 0;
+ List asyncUpdateList = new List();
+ for (String lookupKey : updatedLookupRecords.keySet()) {
+ SObject lookupRecordToUpdate = updatedLookupRecords.get(lookupKey);
+ asyncUpdateList.add(lookupRecordToUpdate);
+ updatedLookupRecords.remove(lookupKey);
+ removalIndex++;
+ if (removalIndex >= maxIndexToRemove) {
+ break;
+ }
+ }
+ System.enqueueJob(new RollupAsyncSaver(asyncUpdateList));
+ }
+ }
+
+ private void processDeferredRollups() {
+ if (this.deferredRollups.isEmpty() == false && this.getIsDeferralAllowed() && stackDepth < this.rollupControl?.MaxRollupRetries__c) {
+ stackDepth++;
+ // tragic, but necessary due to limits on requeueing allowed during testing
+ this.setIsDeferralAllowed(Test.isRunningTest() == false && this.rollupControl.MaxRollupRetries__c > stackDepth);
+
+ this.rollups.clear();
+ this.rollups.addAll(this.deferredRollups);
+ this.deferredRollups.clear();
+
+ this.getAsyncRollup().beginAsyncRollup();
+ } else if (this.deferredRollups.isEmpty() == false) {
+ this.throwWithRollupData(this.deferredRollups);
+ }
+ }
+
+ private void throwWithRollupData(List rolls) {
+ String exceptionString = 'rollup failed to re-queue for: ';
+ RollupLogger.Instance.log(exceptionString, rolls, LoggingLevel.ERROR);
+ throw new AsyncException(exceptionString + rolls);
+ }
+
+ private void getFieldNamesForRollups(List rollups) {
+ this.lookupObjectToUniqueFieldNames = new Map>();
+ for (RollupAsyncProcessor rollup : rollups) {
+ String rollupField = rollup.opFieldOnLookupObject.getDescribe().getName();
+ String lookupfield = rollup.lookupFieldOnLookupObject.getDescribe().getName();
+ if (lookupObjectToUniqueFieldNames.containsKey(rollup.lookupObj)) {
+ lookupObjectToUniqueFieldNames.get(rollup.lookupObj).addAll(new List{ rollupField, lookupField });
+ } else {
+ lookupObjectToUniqueFieldNames.put(rollup.lookupObj, new Set{ rollupField, lookupfield });
+ }
+ }
+ }
+
+ private Map> getCalcItemsByLookupField(RollupAsyncProcessor rollup, Set uniqueQueryFieldNames) {
+ if (String.isNotBlank(rollup.metadata.GrandparentRelationshipFieldPath__c) || rollup.metadata.RollupToUltimateParent__c) {
+ if (rollup.traversal == null) {
+ rollup.traversal = new RollupRelationshipFieldFinder(
+ rollup.rollupControl,
+ rollup.metadata,
+ uniqueQueryFieldNames,
+ rollup.lookupObj,
+ rollup.oldCalcItems
+ )
+ .getParents(rollup.calcItems);
+ } else if (rollup.traversal?.getIsFinished() == false) {
+ rollup.traversal.recommence();
+ }
+ return rollup.traversal.getIsFinished() ? rollup.traversal.getParentLookupToRecords() : new Map>();
+ }
+ Map> lookupFieldToCalcItems = new Map>();
+ for (SObject calcItem : rollup.calcItems) {
+ String key = (String) calcItem.get(rollup.lookupFieldOnCalcItem);
+ if (lookupFieldToCalcItems.containsKey(key) == false) {
+ lookupFieldToCalcItems.put(key, new List{ calcItem });
+ } else {
+ lookupFieldToCalcItems.get(key).add(calcItem);
+ }
+
+ // if the lookup key differs from what it was on the old calc item,
+ // include that value as well so that we can fix reparented records' rollup values
+ SObject potentialOldCalcItem = rollup.oldCalcItems?.get(calcItem.Id);
+ if (potentialOldCalcItem != null) {
+ String oldKey = (String) potentialOldCalcItem.get(rollup.lookupFieldOnCalcItem);
+
+ if (key == oldKey) {
+ continue;
+ }
+
+ if (lookupFieldToCalcItems.containsKey(oldKey) == false) {
+ lookupFieldToCalcItems.put(oldKey, new List{ potentialOldCalcItem });
+ } else {
+ lookupFieldToCalcItems.get(oldKey).add(potentialOldCalcItem);
+ }
+ }
+ }
+ return lookupFieldToCalcItems;
+ }
+
+ private void initializeRollupFieldDefaults(List lookupItems, RollupAsyncProcessor rollup) {
+ // prior to returning, we need to ensure the default value for the rollup field is set
+ for (SObject lookupItem : lookupItems) {
+ this.resetLookupFieldsForNullOrFullRecalcs(lookupItem, rollup);
+ }
+ }
+
+ private void resetLookupFieldsForNullOrFullRecalcs(SObject lookupItem, RollupAsyncProcessor rollup) {
+ if (lookupItem.get(rollup.opFieldOnLookupObject) == null || rollup.isFullRecalc) {
+ lookupItem.put(rollup.opFieldOnLookupObject, RollupFieldInitializer.Current.getDefaultValue(rollup.opFieldOnLookupObject));
+ }
+ }
+
+ private void ingestRollupControlData() {
+ RollupControl__mdt orgDefaults = this.rollupControl;
+ for (Integer index = this.rollups.size() - 1; index >= 0; index--) {
+ RollupAsyncProcessor rollup = this.rollups[index];
+ rollup.isFullRecalc = rollup.isFullRecalc || this.isFullRecalc;
+
+ Boolean shouldRunSyncDeferred = this.getShouldRunSyncDeferred(rollup);
+ Boolean couldRunSync =
+ rollup.rollupControl.ShouldRunAs__c == RollupMetaPicklists.ShouldRunAs.SYNCHRONOUS ||
+ (hasExceededCurrentRollupLimits(rollup.rollupControl) == false) && isRunningAsync;
+
+ if (rollup.rollupControl.ShouldAbortRun__c || orgDefaults.ShouldAbortRun__c) {
+ this.rollups.remove(index);
+ } else if (couldRunSync && shouldRunSyncDeferred == false) {
+ this.rollups.remove(index);
+ this.syncRollups.add(rollup);
+ } else if (couldRunSync && shouldRunSyncDeferred) {
+ this.rollups.remove(index);
+ this.getCachedRollups().add(rollup);
+ }
+
+ // you can increase the default limits, but it would be too messy to try to rank the individual rollup operations in a batched context
+ if (rollup.rollupControl.MaxLookupRowsBeforeBatching__c > orgDefaults.MaxLookupRowsBeforeBatching__c) {
+ orgDefaults.MaxLookupRowsBeforeBatching__c = rollup.rollupControl.MaxLookupRowsBeforeBatching__c;
+ }
+ if (rollup.rollupControl.ShouldRunAs__c != orgDefaults.ShouldRunAs__c) {
+ orgDefaults.ShouldRunAs__c = rollup.rollupControl.ShouldRunAs__c;
+ }
+ if (rollup.rollupControl.MaxParentRowsUpdatedAtOnce__c == null) {
+ rollup.rollupControl.MaxParentRowsUpdatedAtOnce__c = orgDefaults.MaxParentRowsUpdatedAtOnce__c;
+ }
+ }
+ }
+
+ private Boolean getShouldRunSyncDeferred(RollupAsyncProcessor roll) {
+ if (roll.isNoOp || this.getCachedApexOperations().containsKey(roll.calcItemType) == false) {
+ return false;
+ }
+ Set apexOperations = this.getCachedApexOperations().get(roll.calcItemType);
+ if (apexOperations.contains(TriggerOperation.AFTER_INSERT) && roll.op.name().contains('UPDATE')) {
+ return true;
+ } else if (
+ (apexOperations.contains(TriggerOperation.AFTER_INSERT) || apexOperations.contains(TriggerOperation.AFTER_UPDATE)) && roll.op.name().contains('DELETE')
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private Integer getLookupRecordsCount(Boolean hasMoreThanOneTarget) {
+ // we need to burn a few SOQL calls to consider how many records are going to be queried/updated
+ // then, using RollupControl__mdt and/or sensible defaults, we'll decide whether to queue up or batch (or fail - that's always an option)
+ // if there's more than one SObjectType involved we bail on retrieving the actual count
+ // because you can only return one list of SObjects from a batch job's QueryLocator
+ SObjectType targetType;
+ Map