diff --git a/.gitignore b/.gitignore index 48167be4..b5064618 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dmc_config.json yarn.lock .sfdx/ .vscode/ -.localdevserver/ \ No newline at end of file +.localdevserver/ +debug.log \ No newline at end of file diff --git a/README.md b/README.md index 83830307..43004b77 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,15 @@ trigger ExampleTrigger on Opportunity(after insert, after update, before delete) That's it! Now you're ready to configure your rollups using Custom Metadata. `Rollup` makes heavy use of Entity Definition & Field Definition metadata fields, which allows you to simply select your options from within picklists, or dropdowns. This is great for giving you quick feedback on which objects/fields are available without requiring you to know the API name for every SObject and their corresponding field names. +#### Special Considerations For Use Of Custom Fields As Rollup/Lookup Fields + +One **special** thing to note on the subject of Field Definitions — custom fields referenced in CMDT Field Definition fields are stored in an atypical way, and require the usage of additional SOQL queries as part of `Rollup`'s upfront cost. A typical `Rollup` operation will use `2` SOQL queries per rollup — the query that determines whether or not a job should be queued or batched, and a query for the specific Rollup Limit metadata (a dynamic query, which unfortunately means that it counts against the SOQL limits) — prior to going into the async context (where all limits are renewed) plus `1` SOQL qery (also dynamic, which is why it contributes even though it's querying CMDT). However, usage of custom fields as any of the four fields referenced in the `Rollup__mdt` custom metadata (details below) adds an additional SOQL query. If the SOQL queries used by `Rollup` becomes cause for concern, please submit an issue and we can work to address it! + +#### Rollup Custom Metadata Field Breakdown + Within the `Rollup__mdt` custom metadata type, add a new record with fields: -- `Calc Item` - the SObject the calculation is derived from -- in this case, Oppportunity +- `Calc Item` - the SObject the calculation is derived from — in this case, Oppportunity - `Lookup Object` - the SObject you’d like to roll the values up to (in this case, Account) - `Rollup Field On Calc Item` - the field you’d like to aggregate (let's say Amount) - `Lookup Field On Calc Item`- the field storing the Id or String referencing a unique value on another object (In the example, Id) @@ -49,7 +55,7 @@ Within the `Rollup__mdt` custom metadata type, add a new record with fields: - `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). -You can perform have as many rollups as you'd like per object/trigger -- all operations are boxcarred together for optimal efficiency. +You can perform have as many rollups as you'd like per object/trigger — all operations are boxcarred together for optimal efficiency. #### Establishing Org Limits For Rollup Operations @@ -77,7 +83,7 @@ Invoking the `Rollup` process from a Flow, in particular, is a joy; with a Recor ![Example flow](./media/joys-of-apex-rollup-flow.png "Fun and easy rollups from Flows") -This is also the preferred method for scheduling; while I do expose the option to schedule a rollup from Apex, I find the ease of use in creating Scheduled Flows in conjunction with the deep power of properly configured Invocables to be much more scalable than the "Scheduled Jobs" of old. This also gives you the chance to do some truly crazy rollups -- be it from a Scheduled Flow, an Autolaunched Flow, or a Platform Event-Triggered Flow. As long as you can manipulate data to correspond to the shape of an existing SObject's fields, they don't even have to exist; you could have an Autolaunched flow rolling up records when invoked from a REST API so long as the data you're consuming contains a String/Id matching something on the "parent" rollup object. +This is also the preferred method for scheduling; while I do expose the option to schedule a rollup from Apex, I find the ease of use in creating Scheduled Flows in conjunction with the deep power of properly configured Invocables to be much more scalable than the "Scheduled Jobs" of old. This also gives you the chance to do some truly crazy rollups — be it from a Scheduled Flow, an Autolaunched Flow, or a Platform Event-Triggered Flow. As long as you can manipulate data to correspond to the shape of an existing SObject's fields, they don't even have to exist; you could have an Autolaunched flow rolling up records when invoked from a REST API so long as the data you're consuming contains a String/Id matching something on the "parent" rollup object. Here are the arguments necessary to invoke `Rollup` from a Flow / Process Builder: @@ -205,6 +211,10 @@ public static Rollup sumFromTrigger( // for using as the "one line of code" and CMDT-driven rollups public static void runFromTrigger() + +// the alternative one-liner for CDC triggers +// more on that in the CDC section of "Special Considerations", below +public static void runFromCDCTrigger() ``` All of the "...fromTrigger" methods shown above can also be invoked using a final argument, the `Evaluator`: @@ -245,7 +255,7 @@ Rollup.sumFromTrigger( It's that simple. Note that in order for custom Apex solutions that don't use the `batch` static method on `Rollup` to properly start, the `runCalc()` method must also be called. That is, if you only have one rollup operation per object, you'll _always_ need to call `runCalc()` when invoking `Rollup` from a trigger. -Another note for when the use of an `Evaluator` class might be necessary -- let's say that you have some slight lookup skew caused by a fallback object in a lookup relationship. This fallback object has thousands of objects tied to it, and updates to it are frequently painful / slow. If you didn't need the rollup for the fallback, you could implement an `Evaluator` to exclude it from being processed: +Another note for when the use of an `Evaluator` class might be necessary — let's say that you have some slight lookup skew caused by a fallback object in a lookup relationship. This fallback object has thousands of objects tied to it, and updates to it are frequently painful / slow. If you didn't need the rollup for the fallback, you could implement an `Evaluator` to exclude it from being processed: ```java // again using the example of Opportunities @@ -277,7 +287,7 @@ trigger OpportunityTrigger on Opportunity(before update, after update, before in ## Special Considerations -While pains have been taken to create a solution that's truly one-sized-fits-all, any professional working in the Salesforce ecosystem knows that it's difficult to make that the case for any product or service - even something open-source and forever-free, like `Rollup`. All of that is to say that while I have tested the hell out of `Rollup` and have used it already in production, your mileage may vary depending on what you're trying to do. Some operations that are explicitly not supported within the SOQL aggregate functions (like `SELECT MIN(ActivityDate) FROM Task`) are possible when using `Rollup`. Another example would be `MAX` or `MIN` operations on multi-select picklists. I don't know _why_ you would want to do that ... but you can! +While pains have been taken to create a solution that's truly one-sized-fits-all, any professional working in the Salesforce ecosystem knows that it's difficult to make that the case for any product or service — even something open-source and forever-free, like `Rollup`. All of that is to say that while I have tested the hell out of `Rollup` and have used it already in production, your mileage may vary depending on what you're trying to do. Some operations that are explicitly not supported within the SOQL aggregate functions (like `SELECT MIN(ActivityDate) FROM Task`) are possible when using `Rollup`. Another example would be `MAX` or `MIN` operations on multi-select picklists. I don't know _why_ you would want to do that ... but you can! ### Picklists @@ -291,11 +301,11 @@ One of the reasons that `Rollup` can boast of superior performance is that, for - a MAX operation might find that one of the calculation items supplied to it previously _was_ the _maxmimum_ value, but is no longer the max on an update - ... pretty much any operation involving AVERAGE -In these instances, `Rollup` _does_ requery the calculation object; it also does another loop through the calculation items supplied to it in search of _all_ the values necessary to find the true rollup value. This provides context, more than anything -- the rollup operation should still be lightning fast. +In these instances, `Rollup` _does_ requery the calculation object; it also does another loop through the calculation items supplied to it in search of _all_ the values necessary to find the true rollup value. This provides context, more than anything — the rollup operation should still be lightning fast. ### Custom Apex -If you are implementing `Rollup` through the use of the static Apex methods instead of CMDT, one thing to be aware of -- if you need to perform 6+ rollup operations _and_ you are rolling up to more than one target object, you should absolutely keep your rollups ordered by the target object when invoking the `batch` method: +If you are implementing `Rollup` through the use of the static Apex methods instead of CMDT, one thing to be aware of — if you need to perform 6+ rollup operations _and_ you are rolling up to more than one target object, you should absolutely keep your rollups ordered by the target object when invoking the `batch` method: ```java // this is perfectly valid @@ -321,6 +331,23 @@ Rollup.batch( ); ``` +### Change Data Capture (CDC) + +As of [v1.0.4](https://github.com/jamessimone/apex-rollup/tree/v1.0.4), CDC _is_ supported. However, at the moment Change Data Capture can be used strictly through CMDT, and requires a different one-liner for installation into your CDC object Trigger: + +```java +// within your CDC trigger, using Opportunity as an example: +trigger OpportunityChangeEventTrigger on OpportunityChangeEvent (after insert) { + Rollup.runFromCDCTrigger(); +} +``` + +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. + +### 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! + ## Commit History & Contributions This repository comes after the result of [dozens of commits](https://github.com/jamessimone/apex-mocks-stress-test/commits/rollup) on my working repository. You can view the full history of the evolution of `Rollup` there. diff --git a/package.json b/package.json index 9f285210..258d42d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apex-rollup", - "version": "1.0.3", + "version": "1.0.4", "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", diff --git a/rollup/main/default/classes/Rollup.cls b/rollup/main/default/classes/Rollup.cls index 7535a7ff..6556e6fa 100644 --- a/rollup/main/default/classes/Rollup.cls +++ b/rollup/main/default/classes/Rollup.cls @@ -8,7 +8,7 @@ global without sharing virtual class Rollup implements Database.Batchable records; @testVisible @@ -20,6 +20,8 @@ global without sharing virtual class Rollup implements Database.Batchable> lookupObjectToUniqueFieldNames; private List lookupItems; @@ -229,7 +232,10 @@ global without sharing virtual class Rollup implements Database.Batchable uniqueLookupFields = new Set(); for (SObject calcItem : rollup.calcItems) { - uniqueLookupFields.add((String) calcItem.get(rollup.lookupFieldOnCalcItem)); + String lookupKey = (String) calcItem.get(rollup.lookupFieldOnCalcItem); + if (String.isNotBlank(lookupKey)) { + uniqueLookupFields.add(lookupKey); + } } String countQuery = getCountQueryString(rollup.lookupObj, '='); @@ -249,7 +255,9 @@ global without sharing virtual class Rollup implements Database.Batchable= orgDefaults.MaxLookupRowsBeforeBatching__c); if (shouldRunAsBatch && hasMoreThanOneTarget == false) { // safe to batch because the QueryLocator will only return one type of SObject - Database.executeBatch(new Rollup(this)); + Rollup rollupToRun = new Rollup(this); + rollupToRun.isFullRecalc = this.isFullRecalc; + Database.executeBatch(rollupToRun); } else if ( shouldRunAsBatch == false && (orgDefaults.MaxLookupRowsForQueueable__c == null || totalCountOfRecords <= orgDefaults.MaxLookupRowsForQueueable__c) ) { @@ -298,8 +306,7 @@ global without sharing virtual class Rollup implements Database.Batchable{ rollupMetadata }, eval); + return runFromTrigger(new List{ rollupMetadata }, eval, getTriggerRecords(), getOldTriggerRecordsMap()); + } + + global static void runFromCDCTrigger() { + isCDC = true; + // CDC always uses Trigger.new + List cdcRecords = records != null ? records : Trigger.new; + if(cdcRecords.isEmpty()) { + return; + } + SObject firstRecord = cdcRecords[0]; + EventBus.ChangeEventHeader header = (EventBus.ChangeEventHeader) firstRecord.get('ChangeEventHeader'); + SObjectType sObjectType = getSObjectTypeFromName(header.getEntityName()); + + List rollupMetadata = getTriggerRollupMetadata(sObjectType); + if(rollupMetadata.isEmpty()) { + return; + } + + DescribeSObjectResult objectDescribe = sObjectType.getDescribe(); + Set uniqueFieldNames = new Set(); + for(Rollup__mdt rollupInfo : rollupMetadata) { + uniqueFieldNames.add(getParedFieldName(rollupInfo.LookupFieldOnCalcItem__c, objectDescribe)); + uniqueFieldNames.add(getParedFieldName(rollupInfo.ROllupFieldOnCalcItem__c, objectDescribe)); + } + + switch on header.changeType { + WHEN 'CREATE' { + triggerContext = TriggerOperation.AFTER_INSERT; + } + when 'UPDATE' { + triggerContext = TriggerOperation.AFTER_UPDATE; + } + when 'DELETE' { + triggerContext = TriggerOperation.BEFORE_DELETE; + } + } + + List recordIds = new List(); + for (SObject cdcRecord : cdcRecords) { + uniqueFieldNames.addAll(header.changedfields); + recordIds.add(header.getRecordIds()[0]); + } + + String startQuery = uniqueFieldNames.contains('Id') ? 'SELECT ' : 'SELECT Id,'; + String fullQuery = startQuery + String.join(new List(uniqueFieldNames), ',') + ' FROM ' + sObjectType + ' WHERE Id = :recordIds'; + Map cdcRecordsMap = new Map(Database.query(fullQuery)); + + Rollup rollupToReturn = runFromTrigger(rollupMetadata, null, cdcRecordsMap.values(), cdcRecordsMap); + // because CDC is async, the DB will always be updated by the time we get there + // for update, that means we always have to trigger a full recalc + // the performance downsides should be negligible, given that we're already within an async context + rollupToReturn.isFullRecalc = triggerContext == TriggerOperation.AFTER_UPDATE; + rollupToReturn.runCalc(); } global static void runFromTrigger() { SObjectType sObjectType = getTriggerRecords().getSObjectType(); List rollupMetadata = getTriggerRollupMetadata(sObjectType); - runFromTrigger(rollupMetadata, null).runCalc(); + runFromTrigger(rollupMetadata, null, getTriggerRecords(), getOldTriggerRecordsMap()).runCalc(); } - private static Rollup runFromTrigger(List rollupMetadata, Evaluator eval) { + private static Rollup runFromTrigger(List rollupMetadata, Evaluator eval, List calcItems, Map oldCalcItems) { if (shouldRunFromTrigger() == false) { return new RollupAsyncProcessor(RollupInvocationPoint.FROM_TRIGGER); } String rollupContext; Boolean shouldReturn = false; - Map oldCalcItems; switch on triggerContext { when AFTER_UPDATE { rollupContext = 'UPDATE_'; - oldCalcItems = getOldTriggerRecordsMap(); } when BEFORE_DELETE { rollupContext = 'DELETE_'; - oldCalcItems = getOldTriggerRecordsMap(); } when AFTER_INSERT { /** for AFTER_INSERT, the base operation name will always be used */ @@ -788,7 +845,6 @@ global without sharing virtual class Rollup implements Database.Batchable calcItems = getTriggerRecords(); return shouldReturn ? new RollupAsyncProcessor(RollupInvocationPoint.FROM_TRIGGER) : getRollup(rollupMetadata, calcItems.getSObjectType(), calcItems, oldCalcItems, eval, RollupInvocationPoint.FROM_TRIGGER); @@ -802,8 +858,8 @@ global without sharing virtual class Rollup implements Database.Batchable rollups) { for (Rollup rollup : rollups) { - if (rollup.isBatched || rollup.rollups.isEmpty() == false) { - // recurse through lists until there aren't any more nested batch types + if (rollup.rollups.isEmpty() == false) { + // recurse through lists until there aren't any more nested rollups flattenBatches(outerRollup, rollup.rollups); } else { loadRollups(rollup, outerRollup); @@ -870,6 +926,7 @@ global without sharing virtual class Rollup implements Database.Batchable fieldNameToField = describeForSObject.fields.getMap(); for (Rollup__mdt rollupMetadata : rollupOperations) { Op rollupOp = opNameToOp.get(rollupMetadata.RollupType__c); - SObjectField rollupFieldOnCalcItem = fieldNameToField.get(getParedFieldName(rollupMetadata.RollupFieldOnCalcItem__c, describeForSObject)); - SObjectField lookupFieldOnCalcItem = fieldNameToField.get(getParedFieldName(rollupMetadata.LookupFieldOnCalcItem__c, describeForSObject)); + SObjectField rollupFieldOnCalcItem = getSObjectFieldByName(describeForSObject, rollupMetadata.RollupFieldOnCalcItem__c); + SObjectField lookupFieldOnCalcItem = getSObjectFieldByName(describeForSObject, rollupMetadata.LookupFieldOnCalcItem__c); // NB - this SHOULD work even for SObjects part of managed packages SObjectType lookupSObjectType = getSObjectTypeFromName(rollupMetadata.LookupObject__c); DescribeSObjectResult lookupObjectDescribe = lookupSObjectType.getDescribe(); Map lookupFieldNameToLookupFields = lookupObjectDescribe.fields.getMap(); - SObjectField lookupFieldOnOpObject = lookupFieldNameToLookupFields.get( - getParedFieldName(rollupMetadata.LookupFieldOnLookupObject__c, lookupObjectDescribe) + SObjectField lookupFieldOnOpObject = getSObjectFieldByName( + lookupObjectDescribe, rollupMetadata.LookupFieldOnLookupObject__c ); - SObjectField rollupFieldOnOpObject = lookupFieldNameToLookupFields.get( - getParedFieldName(rollupMetadata.RollupFieldOnLookupObject__c, lookupObjectDescribe) + SObjectField rollupFieldOnOpObject = getSObjectFieldByName( + lookupObjectDescribe, rollupMetadata.RollupFieldOnLookupObject__c ); if (eval == null && String.isNotBlank(rollupMetadata.ChangedFieldsOnCalcItem__c)) { @@ -951,6 +1010,36 @@ global without sharing virtual class Rollup implements Database.Batchable durableIdToFieldName; + private static SObjectField getSObjectFieldByName(DescribeSObjectResult objectDescribe, String desiredField) { + Map fieldNameToField = objectDescribe.fields.getMap(); + String paredFieldName = getParedFieldName(desiredField, objectDescribe); + if(fieldNameToField.containsKey(paredFieldName)) { + return fieldNameToField.get(paredFieldName); + } + // for lookup fields, CMDT field-level definition fields store the field name, which is outrageous + else if(fieldNameToField.containsKey(paredFieldName + 'Id')) { + return fieldNameToField.get(paredFieldName + 'Id'); + } + + try { + Id testCustomField = Id.valueOf(paredFieldName); + if(durableIdToFieldName == null) { + durableIdToFieldName = new Map(); + List fieldDefinitions = [SELECT QualifiedApiName, DurableId FROM FieldDefinition WHERE EntityDefinitionId = : objectDescribe.getName()]; + for(FieldDefinition fieldDef : fieldDefinitions) { + durableIdToFieldName.put(fieldDef.DurableId, fieldDef.QualifiedApiName); + } + } + String actualFieldName = durableIdToFieldName.get(desiredField); + return fieldNameToField.get(actualFieldName); + } catch(Exception ex) { + // do nothing, it didn't work + } + + return null; + } + private static String getRollupLimitMetadataKey( RollupInvocationPoint invokePoint, SObjectField rollupFieldOnCalcItem, @@ -969,8 +1058,11 @@ global without sharing virtual class Rollup implements Database.Batchable getOldTriggerRecordsMap() { - return oldRecordsMap != null ? oldRecordsMap : Trigger.oldMap; + if (oldRecordsMap != null) { + return oldRecordsMap; + } else if (Trigger.oldMap != null) { + return Trigger.oldMap; + } + + return new Map(); } private static SObjectType getSObjectTypeFromName(String sobjectName) { @@ -1214,6 +1313,7 @@ global without sharing virtual class Rollup implements Database.Batchable calcItems, Object priorVal, String lookupRecordKey, SObjectField lookupKeyField) { RollupCalculator rollupCalc = this.getRollupType(priorVal, rollup, lookupRecordKey, lookupKeyField); + rollupCalc.setIsFullRecalc(this.isFullRecalc); rollupCalc.performRollup(rollup.op, calcItems, rollup.oldCalcItems, rollup.opFieldOnCalcItem); return rollupCalc.getReturnValue(); } @@ -1381,6 +1481,7 @@ global without sharing virtual class Rollup implements Database.Batchable calcItems, Map oldCalcItems, SObjectField operationField) { for (Integer index = 0; index < calcItems.size(); index++) { SObject calcItem = calcItems[index]; if (this.shouldShortCircuit) { this.handleShortCircuit(op, calcItem, operationField); continue; + } else if(this.isFullRecalc) { + // here we don't exclude items because the calc items have already been updated + this.returnVal = this.calculateNewAggregateValue(new Set(), op, operationField, calcItem.getSObjectType()); + // not just a break, a return. We don't want to pass go - we don't want to call "setReturnValue" below + return; } else { switch on op { when COUNT_DISTINCT, DELETE_COUNT_DISTINCT { @@ -1978,6 +2088,7 @@ global without sharing virtual class Rollup implements Database.Batchable{ new Opportunity(Name = 'Test Count Distinct Insert Two') }); Rollup.triggerContext = TriggerOperation.AFTER_INSERT; @@ -98,8 +104,10 @@ private class RollupTests { @isTest static void shouldCountDistinctFromTriggerOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; insert new Opportunity(Name = 'Test Count Distinct Insert', StageName = 'something', CloseDate = System.today(), AccountId = acc.Id); + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(Name = 'Test Count Distinct Insert', Id = generateId(Opportunity.SObjectType)); DMLMock mock = getMock(new List{ opp }); @@ -118,9 +126,11 @@ private class RollupTests { @isTest static void shouldCountDistinctFromTriggerOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AnnualRevenue FROM Account]; acc.AnnualRevenue = 1; update acc; // we want to ensure that the AnnualRevenue is reset because there are no other matching values + Rollup.defaultRollupLimit = null; DMLMock mock = getMock(new List{ new Opportunity() }); Rollup.triggerContext = TriggerOperation.BEFORE_DELETE; @@ -150,9 +160,11 @@ private class RollupTests { @isTest static void shouldDecrementCountFromTriggerAfterUpdateIfValueIsRemoved() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AnnualRevenue FROM Account]; acc.AnnualRevenue = 1; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(Id = generateId(Opportunity.SObjectType)); DMLMock mock = getMock(new List{ opp }); @@ -170,9 +182,11 @@ private class RollupTests { @isTest static void shouldKeepCountUnchangedFromTriggerAfterUpdateEvenIfValueChanges() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AnnualRevenue FROM Account]; acc.AnnualRevenue = 1; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(Id = generateId(Opportunity.SObjectType), Amount = 50); DMLMock mock = getMock(new List{ opp }); @@ -409,6 +423,7 @@ private class RollupTests { @isTest static void shouldMaxNumbersSuccessfullyAfterUpdateWhenUpdatedItemIsNoLongerMax() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 250; update acc; @@ -417,6 +432,7 @@ private class RollupTests { Opportunity secondOpp = new Opportunity(Amount = 175, AccountId = acc.Id, Name = 'testOppTwo', StageName = 'something', CloseDate = System.today()); List originalOpps = new List{ opp, secondOpp }; insert originalOpps; + Rollup.defaultRollupLimit = null; Opportunity updatedOpp = opp.clone(true, true); updatedOpp.Amount = 150; @@ -439,6 +455,7 @@ private class RollupTests { @isTest static void shouldTakeIntoAccountInMemorySObjectsWhenUpdatedItemIsNoLongerMax() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 250; update acc; @@ -447,6 +464,7 @@ private class RollupTests { Opportunity secondOpp = new Opportunity(Amount = 175, AccountId = acc.Id, Name = 'testOppTwo', StageName = 'something', CloseDate = System.today()); List originalOpps = new List{ opp, secondOpp }; insert originalOpps; + Rollup.defaultRollupLimit = null; Opportunity updatedOpp = opp.clone(true, true); updatedOpp.Amount = 150; @@ -471,9 +489,11 @@ private class RollupTests { @isTest static void shouldTakeIntoAccountInMemorySObjectsWhenUpdatedItemIsNoLongerMaxAndNoOtherSObjectsExist() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 250; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity( Amount = acc.AnnualRevenue, @@ -516,6 +536,7 @@ private class RollupTests { @isTest static void shouldMaxNumbersSuccessfullyOnDeleteWhenDeletedItemIsNoLongerMax() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 250; update acc; @@ -524,6 +545,7 @@ private class RollupTests { Opportunity secondOpp = new Opportunity(Amount = 175, AccountId = acc.Id, Name = 'testOppTwo', StageName = 'something', CloseDate = System.today()); List originalOpps = new List{ opp, secondOpp }; insert originalOpps; + Rollup.defaultRollupLimit = null; Rollup.oldRecordsMap = new Map(originalOpps); DMLMock mock = getMock(originalOpps); @@ -577,6 +599,7 @@ private class RollupTests { @isTest static void shouldMinNumbersSuccessfullyAfterUpdateWhenUpdatedItemIsNoLongerMin() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 150; update acc; @@ -585,6 +608,7 @@ private class RollupTests { Opportunity secondOpp = new Opportunity(Amount = 175, AccountId = acc.Id, Name = 'testOppTwo', StageName = 'something', CloseDate = System.today()); List originalOpps = new List{ opp, secondOpp }; insert originalOpps; + Rollup.defaultRollupLimit = null; Opportunity updatedOpp = opp.clone(true, true); updatedOpp.Amount = 200; @@ -607,6 +631,7 @@ private class RollupTests { @isTest static void shouldTakeIntoAccountInMemorySObjectsWhenUpdatedItemIsNoLongerMin() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 150; update acc; @@ -615,6 +640,7 @@ private class RollupTests { Opportunity secondOpp = new Opportunity(Amount = 175, AccountId = acc.Id, Name = 'testOppTwo', StageName = 'something', CloseDate = System.today()); List originalOpps = new List{ opp, secondOpp }; insert originalOpps; + Rollup.defaultRollupLimit = null; Opportunity updatedOpp = opp.clone(true, true); updatedOpp.Amount = secondOpp.Amount + 50; // the amount isn't important - that it's now more than the second Opp amount is @@ -639,9 +665,11 @@ private class RollupTests { @isTest static void shouldTakeIntoAccountInMemorySObjectsWhenUpdatedItemIsNoLongerMinAndNoOtherSObjectsExist() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 150; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity( Amount = acc.AnnualRevenue, @@ -684,6 +712,7 @@ private class RollupTests { @isTest static void shouldMinNumbersSuccessfullyOnDeleteWhenDeletedItemIsNoLongerMax() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 150; update acc; @@ -692,6 +721,7 @@ private class RollupTests { Opportunity secondOpp = new Opportunity(Amount = 175, AccountId = acc.Id, Name = 'testOppTwo', StageName = 'something', CloseDate = System.today()); List originalOpps = new List{ opp, secondOpp }; insert originalOpps; + Rollup.defaultRollupLimit = null; Rollup.records = new List{ opp }; Rollup.oldRecordsMap = new Map(Rollup.records); @@ -712,9 +742,11 @@ private class RollupTests { @isTest static void shouldConcatOnUpdate() { // AFTER_INSERT test is handled in the "shouldBatchThreeOperations" method + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AccountNumber FROM Account]; acc.AccountNumber = 'first test string'; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(AccountId = acc.Id, Name = 'second test string', Id = generateId(Opportunity.SObjectType)); Opportunity oldOpp = opp.clone(true, true); @@ -735,9 +767,11 @@ private class RollupTests { @isTest static void shouldConcatOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AccountNumber FROM Account]; acc.AccountNumber = 'beginning test string something'; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(AccountId = acc.Id, Name = 'test string'); DMLMock mock = getMock(new List{ opp }); @@ -754,9 +788,11 @@ private class RollupTests { @isTest static void shouldConcatDistinctOnInsert() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AccountNumber FROM Account]; acc.AccountNumber = 'beginning test string something'; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(AccountId = acc.Id, Name = 'test string'); Opportunity secondOpp = new Opportunity(AccountId = acc.Id, Name = 'hello another string'); @@ -779,9 +815,11 @@ private class RollupTests { @isTest static void shouldConcatDistinctOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AccountNumber FROM Account]; acc.AccountNumber = 'first test string'; update acc; + Rollup.defaultRollupLimit = null; Opportunity opp = new Opportunity(AccountId = acc.Id, Name = 'second test string', Id = generateId(Opportunity.SObjectType)); Opportunity secondOpp = new Opportunity(AccountId = acc.Id, Name = 'third test string', Id = generateId(Opportunity.SObjectType)); @@ -884,9 +922,11 @@ private class RollupTests { @isTest static void shouldMinOnStringsBeforeDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AccountNumber FROM Account]; acc.AccountNumber = 'A'; update acc; + Rollup.defaultRollupLimit = null; List testOpps = new List{ new Opportunity(Name = 'A', Id = generateId(Opportunity.SObjectType)) }; @@ -905,12 +945,14 @@ private class RollupTests { @isTest static void shouldMaxOnStringsBeforeDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AccountNumber FROM Account]; acc.AccountNumber = 'A'; update acc; Opportunity opp = new Opportunity(StageName = 'test max on delete', CloseDate = System.today(), Name = 'Z', AccountId = acc.Id); insert opp; + Rollup.defaultRollupLimit = null; List testOpps = new List{ new Opportunity(Name = 'A', Id = generateId(Opportunity.SObjectType)) }; @@ -937,11 +979,13 @@ private class RollupTests { Account acc = [SELECT Id FROM Account]; + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); List leads = new List{ new Lead(Company = acc.Id, LeadSource = picklistVals[0].getValue(), LastName = 'Max Picklist on insert one'), new Lead(Company = acc.Id, LeadSource = picklistVals[1].getValue(), LastName = 'Max Picklist on insert two') }; insert leads; // not the best, either, but we need to be able to use SOQL below + Rollup.defaultRollupLimit = null; DMLMock mock = loadMock(leads); Rollup.TriggerContext = TriggerOperation.AFTER_INSERT; @@ -968,11 +1012,13 @@ private class RollupTests { Account acc = [SELECT Id FROM Account]; + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); List leads = new List{ new Lead(Company = acc.Id, LeadSource = picklistVals[0].getValue(), LastName = 'Min Picklist on insert one'), new Lead(Company = acc.Id, LeadSource = picklistVals[1].getValue(), LastName = 'Min Picklist on insert two') }; insert leads; + Rollup.defaultRollupLimit = null; DMLMock mock = loadMock(leads); Rollup.TriggerContext = TriggerOperation.AFTER_INSERT; @@ -992,6 +1038,7 @@ private class RollupTests { @isTest static void shouldAverageOnInsert() { // average is a special case; even on insert, we have to also check for pre-existing records existing + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id, AnnualRevenue FROM Account]; acc.AnnualRevenue = 100; update acc; @@ -1004,6 +1051,7 @@ private class RollupTests { AccountId = acc.Id ); insert testOpp; + Rollup.defaultRollupLimit = null; List opps = new List{ new Opportunity(Amount = 200000, Id = generateId(Opportunity.SObjectType)), @@ -1027,6 +1075,7 @@ private class RollupTests { @isTest static void shouldAverageOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 100; update acc; @@ -1039,6 +1088,7 @@ private class RollupTests { AccountId = acc.Id ); insert testOpp; + Rollup.defaultRollupLimit = null; List opps = new List{ new Opportunity(Amount = 200000, Id = generateId(Opportunity.SObjectType)), @@ -1066,6 +1116,7 @@ private class RollupTests { @isTest static void shouldAverageOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Account acc = [SELECT Id FROM Account]; acc.AnnualRevenue = 200; update acc; @@ -1073,6 +1124,7 @@ private class RollupTests { Opportunity testOppOne = new Opportunity(Amount = 100, Name = 'Pre-existing one', StageName = 'random', CloseDate = System.today(), AccountId = acc.Id); Opportunity testOppTwo = new Opportunity(Amount = 300, Name = 'Pre-existing one', StageName = 'random', CloseDate = System.today(), AccountId = acc.Id); insert new List{ testOppOne, testOppTwo }; + Rollup.defaultRollupLimit = null; List opps = new List{ testOppTwo }; @@ -1092,8 +1144,10 @@ private class RollupTests { // Now we test different field types for success: time, datetime, date. Here be dragons. @isTest static void shouldMaxDateOnInsert() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Opportunity opp = new Opportunity(StageName = 'something', CloseDate = System.today(), Name = 'Max date on insert test'); insert opp; + Rollup.defaultRollupLimit = null; Task taskOne = new Task(Subject = 'Test One', ActivityDate = System.today().addDays(-50), WhatId = opp.Id); Task taskTwo = new Task(Subject = 'Test Two', ActivityDate = System.today().addDays(50), WhatId = opp.Id); @@ -1112,8 +1166,10 @@ private class RollupTests { @isTest static void shouldMinDateOnInsert() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Opportunity opp = new Opportunity(StageName = 'something', CloseDate = System.today(), Name = 'Min date on insert test'); insert opp; + Rollup.defaultRollupLimit = null; Task taskOne = new Task(Subject = 'Test One', ActivityDate = System.today().addDays(-50)); Task taskTwo = new Task(Subject = 'Test Two', ActivityDate = System.today().addDays(50)); @@ -1132,9 +1188,11 @@ private class RollupTests { @isTest static void shouldMinDateOnInsertWhereParentDateIsNull() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); //using campaign for this test because EndDate is not required when inserting a Campaign Campaign camp = new Campaign(Name = 'Test Date with no initialized value'); insert camp; + Rollup.defaultRollupLimit = null; Task taskOne = new Task(Subject = 'Test One', ActivityDate = System.today().addDays(-50), WhatId = camp.Id); Task taskTwo = new Task(Subject = 'Test Two', ActivityDate = System.today().addDays(50), WhatId = camp.Id); @@ -1153,11 +1211,13 @@ private class RollupTests { @isTest static void shouldMinDateOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Opportunity opp = new Opportunity(StageName = 'something', CloseDate = System.today(), Name = 'Min date on insert test'); insert opp; Task taskOne = new Task(Subject = 'Test One', ActivityDate = System.today().addDays(-50), WhatId = opp.Id); insert taskOne; + Rollup.defaultRollupLimit = null; Task taskTwo = new Task(Subject = 'Test Two', ActivityDate = opp.CloseDate.addDays(5), Id = generateId(Task.SObjectType), WhatId = opp.Id); @@ -1214,9 +1274,11 @@ private class RollupTests { @isTest static void shouldMinDatetimeOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Contract con = [SELECT Id FROM Contract]; con.ActivatedDate = System.now(); update con; + Rollup.defaultRollupLimit = null; // now the "new" version of eventOne is no longer the min Event eventOne = new Event(ActivityDateTime = con.ActivatedDate.addDays(50), WhatId = con.Id, Id = generateId(Event.SObjectType)); @@ -1238,9 +1300,11 @@ private class RollupTests { @isTest static void shouldMaxDatetimeOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Contract con = [SELECT Id FROM Contract]; con.ActivatedDate = System.now(); update con; + Rollup.defaultRollupLimit = null; // now the "new" version of eventOne is no longer the max Event eventOne = new Event(ActivityDateTime = con.ActivatedDate.addDays(-25), WhatId = con.Id, Id = generateId(Event.SObjectType)); @@ -1262,6 +1326,7 @@ private class RollupTests { @isTest static void shouldMaxDatetimeOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Contract con = [SELECT Id FROM Contract]; con.ActivatedDate = System.now(); update con; @@ -1269,6 +1334,7 @@ private class RollupTests { Event eventOne = new Event(ActivityDateTime = con.ActivatedDate, WhatId = con.Id, Id = generateId(Event.SObjectType)); Event eventTwo = new Event(ActivityDateTime = con.ActivatedDate.addDays(50), WhatId = con.Id, DurationInMinutes = 50); insert eventTwo; + Rollup.defaultRollupLimit = null; DMLMock mock = loadMock(new List{ eventOne }); Rollup.triggerContext = TriggerOperation.BEFORE_DELETE; @@ -1285,6 +1351,7 @@ private class RollupTests { @isTest static void shouldMinDatetimeOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); Contract con = [SELECT Id FROM Contract]; con.ActivatedDate = System.now(); update con; @@ -1292,6 +1359,7 @@ private class RollupTests { Event eventOne = new Event(ActivityDateTime = con.ActivatedDate, WhatId = con.Id, Id = generateId(Event.SObjectType)); Event eventTwo = new Event(ActivityDateTime = con.ActivatedDate.addDays(-50), WhatId = con.Id, DurationInMinutes = 50); insert eventTwo; + Rollup.defaultRollupLimit = null; DMLMock mock = loadMock(new List{ eventOne }); Rollup.triggerContext = TriggerOperation.BEFORE_DELETE; @@ -1314,8 +1382,10 @@ private class RollupTests { @isTest static void shouldMinTimeOnInsert() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); ContactPointEmail cpe = new ContactPointEmail(EmailDomain = 'Lookupfield', EmailAddress = 'testrollup' + System.now().getTime() + '@email.com'); insert cpe; + Rollup.defaultRollupLimit = null; ContactPointAddress cp1 = new ContactPointAddress(BestTimeToContactEndTime = Time.newInstance(11, 11, 11, 11), Name = cpe.EmailDomain); ContactPointAddress cp2 = new ContactPointAddress(BestTimeToContactEndTime = Time.newInstance(12, 12, 12, 12), Name = cpe.EmailDomain); @@ -1341,8 +1411,10 @@ private class RollupTests { @isTest static void shouldMaxTimeOnInsert() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); ContactPointEmail cpe = new ContactPointEmail(EmailDomain = 'Lookupfield', EmailAddress = 'testrollup' + System.now().getTime() + '@email.com'); insert cpe; + Rollup.defaultRollupLimit = null; ContactPointAddress cp1 = new ContactPointAddress(BestTimeToContactEndTime = Time.newInstance(11, 11, 11, 11), Name = cpe.EmailDomain); ContactPointAddress cp2 = new ContactPointAddress(BestTimeToContactEndTime = Time.newInstance(12, 12, 12, 12), Name = cpe.EmailDomain); @@ -1368,12 +1440,14 @@ private class RollupTests { @isTest static void shouldMaxTimeOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); ContactPointEmail cpe = new ContactPointEmail( BestTimeToContactEndTime = Time.newInstance(0, 0, 0, 0), EmailDomain = 'Lookupfield', EmailAddress = 'testrollup' + System.now().getTime() + '@email.com' ); insert cpe; + Rollup.defaultRollupLimit = null; ContactPointAddress cp1 = new ContactPointAddress( BestTimeToContactEndTime = Time.newInstance(5, 5, 5, 5), @@ -1409,12 +1483,14 @@ private class RollupTests { @isTest static void shouldMinTimeOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); ContactPointEmail cpe = new ContactPointEmail( BestTimeToContactEndTime = Time.newInstance(0, 0, 0, 0), EmailDomain = 'Lookupfield', EmailAddress = 'testrollup' + System.now().getTime() + '@email.com' ); insert cpe; + Rollup.defaultRollupLimit = null; ContactPointAddress cp1 = new ContactPointAddress( BestTimeToContactEndTime = Time.newInstance(5, 5, 5, 5), @@ -1450,6 +1526,7 @@ private class RollupTests { @isTest static void shouldMinTimeOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); ContactPointEmail cpe = new ContactPointEmail( BestTimeToContactEndTime = Time.newInstance(5, 5, 5, 5), EmailDomain = 'Lookupfield', @@ -1464,6 +1541,7 @@ private class RollupTests { ); ContactPointAddress cp2 = new ContactPointAddress(BestTimeToContactEndTime = Time.newInstance(6, 6, 6, 6), Name = cpe.EmailDomain); insert cp2; + Rollup.defaultRollupLimit = null; List addresses = new List{ cp1 }; DMLMock mock = loadMock(new List{ cp1 }); @@ -1489,6 +1567,7 @@ private class RollupTests { @isTest static void shouldMaxTimeOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); ContactPointEmail cpe = new ContactPointEmail( BestTimeToContactEndTime = Time.newInstance(5, 5, 5, 5), EmailDomain = 'Lookupfield', @@ -1503,6 +1582,7 @@ private class RollupTests { ); ContactPointAddress cp2 = new ContactPointAddress(BestTimeToContactEndTime = Time.newInstance(4, 4, 4, 4), Name = cpe.EmailDomain); insert cp2; + Rollup.defaultRollupLimit = null; List addresses = new List{ cp1 }; DMLMock mock = loadMock(new List{ cp1 }); @@ -1545,6 +1625,135 @@ private class RollupTests { System.assertEquals('Field: BillingAddress of type: ADDRESS specified invalid for rollup operation', ex.getMessage()); } + /** CDC trigger tests */ + + @isTest + static void shouldWorkForChangeDataEventCaptureTriggersOnCreate() { + // CDC functions ** nearly ** the same as regular triggers, yet the SObjects supplied to ChangeEventTriggers + // differ in two subtle ways: not all the fields are populated (just the changed ones), and the reference info + // is supplied on a separate object, the ChangeEventHeader + // Unfortunately, these two "tiny" differences means a lot of other code needs to be tested + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); + Account acc = [SELECT Id FROM Account]; + Opportunity opp = new Opportunity(CloseDate = System.today(), StageName = 'test cdc', Amount = 500, Name = 'test cdc', AccountId = acc.Id); + insert opp; + Rollup.defaultRollupLimit = null; + + EventBus.ChangeEventHeader header = new EventBus.ChangeEventHeader(); + header.changeType = 'CREATE'; + header.changedFields = new List{ 'Amount', 'LastModifiedDate' }; + header.recordIds = new List{ opp.Id }; + header.entityName = 'Opportunity'; + + OpportunityChangeEvent ev = new OpportunityChangeEvent(); + ev.ChangeEventHeader = header; + ev.Amount = 500; + + DMLMock mock = loadMock(new List{ ev }); + + Rollup.rollupMetadata = new List{ + new Rollup__mdt( + RollupFieldOnCalcItem__c = 'Amount', + LookupObject__c = 'Account', + LookupFieldOnCalcItem__c = 'AccountId', + LookupFieldOnLookupObject__c = 'Id', + RollupFieldOnLookupObject__c = 'AnnualRevenue', + RollupType__c = 'SUM' + ) + }; + + Test.startTest(); + Rollup.runFromCDCTrigger(); + Test.stopTest(); + + System.assertEquals(1, mock.Records.size(), 'Records should have been populated CDC AFTER_INSERT'); + acc = (Account) mock.Records[0]; + System.assertEquals(500, acc.AnnualRevenue); + } + + @isTest + static void shouldWorkForChangeDataEventCaptureTriggersOnUpdate() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); + Account acc = [SELECT Id FROM Account]; + Opportunity opp = new Opportunity(CloseDate = System.today(), StageName = 'test cdc update', Amount = 500, Name = 'test cdc update', AccountId = acc.Id); + insert opp; + Rollup.defaultRollupLimit = null; + + EventBus.ChangeEventHeader header = new EventBus.ChangeEventHeader(); + header.changeType = 'UPDATE'; + header.changedFields = new List{ 'Amount', 'LastModifiedDate' }; + header.recordIds = new List{ opp.Id }; + header.entityName = 'Opportunity'; + + OpportunityChangeEvent ev = new OpportunityChangeEvent(); + ev.ChangeEventHeader = header; + ev.Amount = 500; + + DMLMock mock = loadMock(new List{ ev }); + + Rollup.rollupMetadata = new List{ + new Rollup__mdt( + RollupFieldOnCalcItem__c = 'Amount', + LookupObject__c = 'Account', + LookupFieldOnCalcItem__c = 'AccountId', + LookupFieldOnLookupObject__c = 'Id', + RollupFieldOnLookupObject__c = 'AnnualRevenue', + RollupType__c = 'SUM' + ) + }; + + Test.startTest(); + Rollup.runFromCDCTrigger(); + Test.stopTest(); + + System.assertEquals(1, mock.Records.size(), 'Records should have been populated CDC AFTER_UPDATE'); + acc = (Account) mock.Records[0]; + System.assertEquals(500, acc.AnnualRevenue); + } + + @isTest + static void shouldWorkForChangeDataCaptureTriggersOnDelete() { + Rollup.defaultRollupLimit = new RollupLimit__mdt(ShouldAbortRun__c = true); + Account acc = [SELECT Id, AnnualRevenue FROM Account]; + acc.AnnualRevenue = 10000; + update acc; + + Opportunity opp = new Opportunity(CloseDate = System.today(), StageName = 'test cdc update', Amount = 500, Name = 'test cdc update', AccountId = acc.Id); + insert opp; + Rollup.defaultRollupLimit = null; + + EventBus.ChangeEventHeader header = new EventBus.ChangeEventHeader(); + header.changeType = 'DELETE'; + header.changedFields = new List{ 'Amount', 'LastModifiedDate' }; + header.recordIds = new List{ opp.Id }; + header.entityName = 'Opportunity'; + + OpportunityChangeEvent ev = new OpportunityChangeEvent(); + ev.ChangeEventHeader = header; + ev.Amount = 500; + + DMLMock mock = loadMock(new List{ ev }); + + Rollup.rollupMetadata = new List{ + new Rollup__mdt( + RollupFieldOnCalcItem__c = 'Amount', + LookupObject__c = 'Account', + LookupFieldOnCalcItem__c = 'AccountId', + LookupFieldOnLookupObject__c = 'Id', + RollupFieldOnLookupObject__c = 'AnnualRevenue', + RollupType__c = 'SUM' + ) + }; + + Test.startTest(); + Rollup.runFromCDCTrigger(); + Test.stopTest(); + + System.assertEquals(1, mock.Records.size(), 'Records should have been populated CDC BEFORE_DELETE'); + Account updatedAcc = (Account) mock.Records[0]; + System.assertEquals(acc.AnnualRevenue - ev.Amount, updatedAcc.AnnualRevenue); + } + /** Invocable tests */ @isTest @@ -1883,7 +2092,7 @@ private class RollupTests { Test.stopTest(); System.assertEquals(1, mock.Records.size(), 'Rollup run should have run'); - System.assertEquals('Completed', [SELECT Status FROM AsyncApexJob WHERE JobType = 'Queueable']?.Status); + System.assertEquals('Completed', [SELECT Status FROM AsyncApexJob WHERE JobType = 'Queueable' LIMIT 1]?.Status); } @isTest @@ -1897,7 +2106,7 @@ private class RollupTests { Test.stopTest(); System.assertEquals(1, mock.Records.size(), 'Rollup run should have run'); - System.assertEquals('Completed', [SELECT Status FROM AsyncApexJob WHERE JobType = 'Queueable']?.Status); + System.assertEquals('Completed', [SELECT Status FROM AsyncApexJob WHERE JobType = 'Queueable' LIMIT 1]?.Status); } /** Schedulable tests */ @@ -1968,6 +2177,7 @@ private class RollupTests { return new List{ flowInput }; } + // from https://salesforce.stackexchange.com/questions/21137/creating-unit-tests-without-interacting-with-the-database-creating-fake-ids private static Integer startingNumber = 1; private static String generateId(Schema.SObjectType sObjectType) { String result = String.valueOf(startingNumber++);