diff --git a/extra-tests/classes/RollupIntegrationTests.cls b/extra-tests/classes/RollupIntegrationTests.cls index 618246c9..6183684c 100644 --- a/extra-tests/classes/RollupIntegrationTests.cls +++ b/extra-tests/classes/RollupIntegrationTests.cls @@ -1,7 +1,6 @@ @isTest private class RollupIntegrationTests { - // "Integration," in the sense that these include custom fields that shouldn't be installed - // we still don't need to actually update the records to prove the point + // "Integration," in the sense that these include custom fields / objects that shouldn't be installed @TestSetup static void setup() { Rollup.defaultControl = new RollupControl__mdt(ShouldAbortRun__c = true); diff --git a/rollup/main/default/classes/RollupRelationshipFieldFinder.cls b/rollup/main/default/classes/RollupRelationshipFieldFinder.cls index 7cde94a6..0a9865af 100644 --- a/rollup/main/default/classes/RollupRelationshipFieldFinder.cls +++ b/rollup/main/default/classes/RollupRelationshipFieldFinder.cls @@ -1,6 +1,12 @@ +/** + * Big caveat here - this class can only be used with lookup relationships. The larger Rollup framework + * accepts and even welcomes text-based keys being used, but here we accept that text-based keys + * are likely niche, anyway, and that people looking to make use of a grandchild -> grandparent (or greater!) + * rollup are likely operating using lookups anyway + */ public without sharing class RollupRelationshipFieldFinder { private final List relationshipParts; - private final Traversal traverseObject; + private final Traversal traversal; private final SObjectType ultimateParent; private final String optionalWhereClause; private final RollupControl__mdt rollupControl; @@ -9,11 +15,17 @@ public without sharing class RollupRelationshipFieldFinder { private Boolean isFirstRun = true; public RollupRelationshipFieldFinder(RollupControl__mdt rollupControl, String relationshipPathName, SObjectType ultimateParent, Map oldRecords) { - this.traverseObject = new Traversal(this); + this.traversal = new Traversal(this); this.relationshipParts = relationshipPathName.split('\\.'); this.rollupControl = rollupControl; this.ultimateParent = ultimateParent; this.oldRecords = oldRecords; + + if(this.relationshipParts.size() == 1) { + String fieldName = this.relationshipParts[0]; + this.relationshipParts.set(0, ultimateParent.getDescribe().getName()); + this.relationshipParts.add(fieldName); + } } public RollupRelationshipFieldFinder( @@ -28,7 +40,7 @@ public without sharing class RollupRelationshipFieldFinder { } public class Traversal { - public Boolean isFinished = true; + public Boolean isFinished = false; private final Map lookupIdToFinalRecords = new Map(); private Map lookupIdMap = new Map(); private final Map> hierarchy = new Map>(); @@ -41,6 +53,7 @@ public without sharing class RollupRelationshipFieldFinder { public SObject retrieveParent(Id descendantId) { return lookupIdToFinalRecords.get(descendantId); } + public Boolean isUltimatelyReparented(SObject record, String relationshipFieldName) { Id currentLookupId = (Id) record.get(relationshipFieldName); Id oldLookupId = (Id) (this.finder.oldRecords.containsKey(record.Id) @@ -63,28 +76,29 @@ public without sharing class RollupRelationshipFieldFinder { } } - // TODO handle heap size public Traversal getParents(List records) { if (records.isEmpty() || this.relationshipParts.isEmpty()) { - this.traverseObject.isFinished = true; - return this.traverseObject; - } else if (this.rollupControl.MaxQueryRows__c < Limits.getQueries() || Limits.getLimitQueryRows() / 4 < Limits.getQueryRows()) { - return this.traverseObject; + this.traversal.isFinished = true; + return this.traversal; + } else if ( + this.rollupControl.MaxQueryRows__c < Limits.getQueries() || + Limits.getLimitQueryRows() / 4 < Limits.getQueryRows() || + Limits.getLimitHeapSize() / 2 < Limits.getHeapSize() + ) { + return this.traversal; } - this.traverseObject.isFinished = false; // even before the recursion begins, the List won't be strongly typed SObjectType baseSObjectType = records[0].getSObjectType(); if (baseSObjectType == this.ultimateParent) { this.prepFinishedObject(records); - return this.traverseObject; + return this.traversal; } else { return this.recurseThroughObjectChain(records, baseSObjectType); } } private SObjectField getField(Map fieldMap, String relationshipPart) { - // this sucks, but we need to find the field with the matching relationship name for (String key : fieldMap.keySet()) { SObjectField field = fieldMap.get(key); if (field.getDescribe().getRelationshipName() == relationshipPart) { @@ -98,22 +112,23 @@ public without sharing class RollupRelationshipFieldFinder { } private Id getDescendantId(Id lookupId) { - Boolean hasMatch = this.traverseObject.lookupIdMap.containsKey(lookupId); + Boolean hasMatch = this.traversal.lookupIdMap.containsKey(lookupId); if (hasMatch) { - return this.getDescendantId(this.traverseObject.lookupIdMap.get(lookupId)); + return this.getDescendantId(this.traversal.lookupIdMap.get(lookupId)); } return lookupId; } private void prepFinishedObject(List records) { - this.traverseObject.isFinished = true; + this.traversal.isFinished = true; for (SObject record : records) { Id descendantId = this.getDescendantId(record.Id); if (descendantId != record.Id) { - this.traverseObject.lookupIdToFinalRecords.put(descendantId, record); + this.traversal.lookupIdToFinalRecords.put(descendantId, record); } } - this.traverseObject.lookupIdMap = null; // try to spare the heap + this.traversal.isFinished = true; + this.traversal.lookupIdMap = null; // try to spare the heap } private Traversal recurseThroughObjectChain(List records, SObjectType baseSObjectType) { @@ -131,18 +146,18 @@ public without sharing class RollupRelationshipFieldFinder { if (String.isNotBlank(lookupId)) { lookupIds.add(lookupId); - this.traverseObject.lookupIdMap.put(lookupId, record.Id); + this.traversal.lookupIdMap.put(lookupId, record.Id); if (this.isFirstRun) { // we need to keep track of potentially reparented lookups to aid with the note below if (this.oldRecords.containsKey(record.Id)) { Id oldLookupId = (Id) this.oldRecords.get(record.Id).get(field); if (String.isNotBlank(oldLookupId) && oldLookupId != lookupId) { lookupIds.add(oldLookupId); - this.traverseObject.hierarchy.put(oldLookupId, new List{ oldLookupId }); + this.traversal.hierarchy.put(oldLookupId, new List{ oldLookupId }); } } - this.traverseObject.hierarchy.put(lookupId, new List{ lookupId }); - } else if (this.traverseObject.hierarchy.containsKey(record.Id)) { + this.traversal.hierarchy.put(lookupId, new List{ lookupId }); + } else if (this.traversal.hierarchy.containsKey(record.Id)) { // track the hierarchy of objects to help in determining whether or not something // has ultimately been reparented // for example: @@ -151,7 +166,7 @@ public without sharing class RollupRelationshipFieldFinder { // this would "traditionally" be a reparenting situation, but if we are skipping // the intermediate objects for a rollup and the end result is the same, we need // to avoid reporting false positives like this one - this.traverseObject.hierarchy.get(record.Id).add(lookupId); + this.traversal.hierarchy.get(record.Id).add(lookupId); } } } @@ -160,6 +175,7 @@ public without sharing class RollupRelationshipFieldFinder { Schema.DescribeSObjectResult nextSObjectDescribe = firstId.getSObjectType().getDescribe(); SObjectField nextFieldToken = this.getField(nextSObjectDescribe.fields.getMap(), nextFieldToLookup); // NB - we only support one route through polymorphic fields such as Task.WhoId and Task.WhatId for this sort of thing + // TODO need to check to see if we need to add in additional fields in the SELECT statement String query = 'SELECT Id, ' + nextFieldToken.getDescribe().getName() + ' FROM ' + nextSObjectDescribe.getName() + ' WHERE Id = :lookupIds'; if (this.isFirstRun && String.isNotBlank(this.optionalWhereClause)) { query += '\nAND ' + this.optionalWhereClause; diff --git a/rollup/main/default/classes/RollupRelationshipFieldFinderTests.cls b/rollup/main/default/classes/RollupRelationshipFieldFinderTests.cls index 92a924c1..7104c711 100644 --- a/rollup/main/default/classes/RollupRelationshipFieldFinderTests.cls +++ b/rollup/main/default/classes/RollupRelationshipFieldFinderTests.cls @@ -52,6 +52,11 @@ private class RollupRelationshipFieldFinderTests { RollupRelationshipFieldFinder.Traversal traversal = finder.getParents(new List{ opp }); System.assertEquals(parent, traversal.retrieveParent(opp.Id)); + + finder = new RollupRelationshipFieldFinder(control, 'Name', Account.SObjectType, new Map()); + traversal = finder.getParents(new List{ opp }); + + System.assertEquals(parent, traversal.retrieveParent(opp.Id)); } @isTest @@ -89,6 +94,17 @@ private class RollupRelationshipFieldFinderTests { Account intermediateTwo = new Account(Name = 'Intermediate 2'); insert new List{ intermediateOne, intermediateTwo }; + List updatedAccounts = [SELECT Id, OwnerId FROM Account]; + if(updatedAccounts.size() == 2) { + // don't run the rest of the test if the org has some kind of ownership assignment going on that would invalidate + // the results + Account one = updatedAccounts[0]; + Account two = updatedAccounts[1]; + if(one.OwnerId != two.OwnerId) { + return; + } + } + Opportunity opp = new Opportunity(AccountId = intermediateTwo.Id, Name = 'Child reparented', StageName = 'Prospecting', CloseDate = System.today()); List opps = new List{ opp }; insert opps; @@ -129,4 +145,23 @@ private class RollupRelationshipFieldFinderTests { finder = new RollupRelationshipFieldFinder(control, 'Account.Owner.Name', User.SObjectType, oldOpps); System.assertEquals(true, traversal.isUltimatelyReparented(opp, 'AccountId'), 'Should correctly report reparenting if new lookup is null'); } + + @isTest + static void shouldReportReparentingCorrectlyForImmediateParent() { + Account parentOne = new Account(Name = 'Parent1'); + Account parentTwo = new Account(Name = 'Parent2'); + insert new List{ parentOne, parentTwo }; + + Opportunity oppOne = new Opportunity(AccountId = parentOne.Id, Name = 'Child1', StageName = 'Prospecting', CloseDate = System.today()); + Opportunity oppTwo = new Opportunity(AccountId = parentOne.Id, Name = 'Child1', StageName = 'Prospecting', CloseDate = System.today()); + List opps = new List{ oppOne, oppTwo }; + insert opps; + + Map oldOpps = new Map{ oppOne.Id => oppOne, oppTwo.Id => new Opportunity(AccountId = parentTwo.Id) }; + RollupRelationshipFieldFinder finder = new RollupRelationshipFieldFinder(control, 'Name', Account.SObjectType, oldOpps); + RollupRelationshipFieldFinder.Traversal traversal = finder.getParents(opps); + + System.assertEquals(true, traversal.isUltimatelyReparented(oppTwo, 'AccountId')); + System.assertEquals(false, traversal.isUltimatelyReparented(oppOne, 'AccountId')); + } } diff --git a/rollup/main/default/classes/RollupTests.cls b/rollup/main/default/classes/RollupTests.cls index 39055b07..17f65ac2 100644 --- a/rollup/main/default/classes/RollupTests.cls +++ b/rollup/main/default/classes/RollupTests.cls @@ -2908,6 +2908,39 @@ private class RollupTests { System.assertEquals('Completed', [SELECT Status FROM AsyncApexJob WHERE JobType = 'Queueable' LIMIT 1]?.Status); } + /** Grandparent rollups */ + @isTest + static void shouldAllowGrandparentRollups() { + Account acc = [SELECT Id FROM Account]; + List opps = new List { + new Opportunity(AccountId = acc.Id, StageName = 'Prospecting', Name = 'One', CloseDate = System.today()), + new Opportunity(AccountId = acc.Id, StageName = 'Prospecting', Name = 'Two', CloseDate = System.today()) + }; + insert opps; + + DMLMock mock = new DMLMock(); + Rollup.DML = mock; + Rollup.shouldRun = true; + Rollup.records = opps; + Rollup.rollupMetadata = new List{ + new Rollup__mdt( + CalcItem__c = 'Opportunity', + RollupFieldOnCalcItem__c = 'Name', + LookupFieldOnCalcItem__c = 'AccountId', + LookupObject__c = 'User', + LookupFieldOnLookupObject__c = 'Id', + RollupFieldOnLookupObject__c = 'AboutMe', + RollupOperation__c = 'CONCAT' + // TODO - time to add in new field for relationship name path + ) + }; + Rollup.apexContext = TriggerOperation.AFTER_INSERT; + + System.assertEquals(1, mock.Records.size(), 'Grandparent record should have been found!'); + User updatedUser = (User) mock.Records[0]; + System.assertEquals(opps[0].Name + ', ' + opps[1].Name, updatedUser.AboutMe, 'Grandparent rollup should have worked!'); + } + //** Helpers */ private static DMLMock loadAccountIdMock(List records) {