diff --git a/rolluptool/src/classes/LREngine.cls b/rolluptool/src/classes/LREngine.cls index 491add50..e55e59bb 100644 --- a/rolluptool/src/classes/LREngine.cls +++ b/rolluptool/src/classes/LREngine.cls @@ -37,7 +37,16 @@ public class LREngine { 2 : Optional WHERE clause filter to add 3 : Group By field name */ - static String SOQL_TEMPLATE = 'SELECT {0} FROM {1} WHERE {3} in :masterIds {2} GROUP BY {3}'; + static String SOQL_AGGREGATE_TEMPLATE = 'SELECT {0} FROM {1} WHERE {3} in :masterIds {2} GROUP BY {3}'; + + /* + Tempalte tokens + 0 : Fields to project + 1 : Object to query + 2 : Optional WHERE clause filter to add + 3 : Order by clause + */ + static String SOQL_QUERY_TEMPLATE = 'SELECT {0} FROM {1} WHERE {2} in :masterIds {3} ORDER BY {4}'; /* Support for multi-currency orgs @@ -118,18 +127,39 @@ public class LREngine { // #0 token : SOQL projection String soqlProjection = ctx.lookupField.getName(); + List orderByFields = new List(); + orderByFields.add(ctx.lookupField.getName()); // ensure details records are ordered by parent record + // k: detail field name, v: master field name Integer exprIdx = 0; Boolean needsCurrency = false; + Boolean builtAggregateQuery = false; Map rsfByAlais = new Map(); for (RollupSummaryField rsf : ctx.fieldsToRoll) { - // create aggreate projection with alias for easy fetching via AggregateResult class - // i.e. SUM(Amount) Amount - String alias = 'lre'+exprIdx++; // Calculate an alias, using field name blew the 25 character limit in some cases - soqlProjection += ', ' + rsf.operation + '(' + rsf.detail.getName() + ') ' + alias; - rsfByAlais.put(alias, rsf); - if(IsMultiCurrencyOrg() == true && needsCurrency == false && rsf.isMasterTypeCurrency){ - needsCurrency = true; + if(rsf.operation == RollupOperation.Sum || + rsf.operation == RollupOperation.Max || + rsf.operation == RollupOperation.Min || + rsf.operation == RollupOperation.Avg || + rsf.operation == RollupOperation.Count || + rsf.operation == RollupOperation.Count_Distinct) { + // create aggreate projection with alias for easy fetching via AggregateResult class + // i.e. SUM(Amount) Amount + builtAggregateQuery = true; + String alias = 'lre'+exprIdx++; // Calculate an alias, using field name blew the 25 character limit in some cases + soqlProjection += ', ' + rsf.operation + '(' + rsf.detail.getName() + ') ' + alias; + rsfByAlais.put(alias, rsf); + if(IsMultiCurrencyOrg() == true && needsCurrency == false && rsf.isMasterTypeCurrency){ + needsCurrency = true; + } + } else { + // create field projection + // i.e. Amount + soqlProjection += ', ' + rsf.detail.getName(); + // create order by projections + // i.e. Amount ASC NULLS FIRST + String orderByField = + rsf.detailOrderBy!=null ? rsf.detailOrderBy.getName() : rsf.detail.getName(); + orderByFields.add(orderByField); } } @@ -151,8 +181,23 @@ public class LREngine { // #3 Group by field String grpByFld = ctx.lookupField.getName(); - - String soql = String.format(SOQL_TEMPLATE, new String[]{soqlProjection, detailTblName, whereClause, grpByFld}); + + // build approprite soql for this rollup context + String soql = + builtAggregateQuery ? + String.format(SOQL_AGGREGATE_TEMPLATE, + new String[]{ + soqlProjection, + detailTblName, + whereClause, + grpByFld}) : + String.format(SOQL_QUERY_TEMPLATE, + new String[]{ + soqlProjection, + detailTblName, + ctx.lookupField.getName(), + whereClause, + String.join(orderByFields, ',')}); System.debug('SOQL is ' + soql); // validate only? @@ -161,30 +206,75 @@ public class LREngine { return null; } - // aggregated results - List results = Database.query(soql); - - for (AggregateResult res : results){ - Id masterRecId = (Id)res.get(grpByFld); - Sobject masterObj = masterRecordsMap.get(masterRecId); - if (masterObj == null) { - System.debug(Logginglevel.WARN, 'No master record found for ID :' + masterRecId); - continue; - } - - for (String alias : rsfByAlais.keySet()) { - RollupSummaryField rsf = rsfByAlais.get(alias); - Object aggregatedDetailVal = res.get(alias); - System.debug(LoggingLevel.INFO, 'New aggregarte value ' + aggregatedDetailVal + ' for master ' + masterRecId); - // Should also test for necessity - if(IsMultiCurrencyOrg() == true && rsf.isMasterTypeCurrency){ - masterObj.put(rsf.master.getName(), convertCurrency((String)res.get(MASTERCURRENCYALIAS),(Decimal)aggregatedDetailVal)); - } else { - masterObj.put(rsf.master.getName(), aggregatedDetailVal); + // query results + Object queryResults = Database.query(soql); + if(queryResults instanceof List) { + + // Process Aggregate query results from RollupOperations related to Aggergate operations + List results = (List) queryResults; + for (AggregateResult res : results){ + Id masterRecId = (Id)res.get(grpByFld); + Sobject masterObj = masterRecordsMap.get(masterRecId); + if (masterObj == null) { + System.debug(Logginglevel.WARN, 'No master record found for ID :' + masterRecId); + continue; } - } - // Remove master Id record as its been processed - masterIds.remove(masterRecId); + + for (String alias : rsfByAlais.keySet()) { + RollupSummaryField rsf = rsfByAlais.get(alias); + Object aggregatedDetailVal = res.get(alias); + System.debug(LoggingLevel.INFO, 'New aggregarte value ' + aggregatedDetailVal + ' for master ' + masterRecId); + // Should also test for necessity + if(IsMultiCurrencyOrg() == true && rsf.isMasterTypeCurrency){ + masterObj.put(rsf.master.getName(), convertCurrency((String)res.get(MASTERCURRENCYALIAS),(Decimal)aggregatedDetailVal)); + } else { + masterObj.put(rsf.master.getName(), aggregatedDetailVal); + } + } + // Remove master Id record as its been processed + masterIds.remove(masterRecId); + } + } else if(queryResults instanceof List) { + + // Group detail records by master Id + List detailRecords = (List) queryResults; + Map> detailRecordsByMasterId = new Map>(); + Id lastMasterId = null; + List currentDetailRecords = null; + for(SObject detailRecord : detailRecords) { + Id masterId = (Id) detailRecord.get(ctx.lookupField.getName()); + if(masterId != lastMasterId) { + currentDetailRecords = new List(); + detailRecordsByMasterId.put(masterId, currentDetailRecords); + } + currentDetailRecords.add(detailRecord); + lastMasterId = masterId; + } + + // Process rollup fields + for(Id masterId : detailRecordsByMasterId.keySet()) { + for (RollupSummaryField rsf : ctx.fieldsToRoll) { + List childDetailRecords = detailRecordsByMasterId.get(masterId); + if(rsf.operation == RollupOperation.Concatenate || + rsf.operation == RollupOperation.Concatenate_Distinct) { + Concatenator concatenator = + new Concatenator(rsf.operation == RollupOperation.Concatenate_Distinct, rsf.concatenateDelimiter); + for(SObject childDetailRecord : childDetailRecords) + concatenator.add(String.valueOf(childDetailRecord.get(rsf.detail.getName()))); + String concatenatedValues = concatenator.toString(); + concatenatedValues = concatenatedValues.abbreviate(rsf.master.getLength()); + masterRecordsMap.get(masterId).put(rsf.master.getName(), concatenatedValues); + } else if(rsf.operation == RollupOperation.First) { + masterRecordsMap.get(masterId).put( + rsf.master.getName(), childDetailRecords[0].get(rsf.detail.getName())); + } else if(rsf.operation == RollupOperation.Last) { + masterRecordsMap.get(masterId).put( + rsf.master.getName(), childDetailRecords[childDetailRecords.size()-1].get(rsf.detail.getName())); + } + // Remove master Id record as its been processed + masterIds.remove(masterId); + } + } } // Zero rollups for unprocessed master records (those with no longer any child relationships) @@ -195,10 +285,38 @@ public class LREngine { return masterRecordsMap.values(); } - - - - + + /** + * Concatenates strings (removes duplicates) + **/ + private class Concatenator + { + private Boolean distinct; + private List listOfString; + private Set setOfStrings; + private String delimiter; + + public Concatenator(Boolean distinct, String delimiter) { + this.distinct = distinct; + if(delimiter!=null) + this.delimiter = delimiter.equals('BR()') ? '\n' : delimiter; + setOfStrings = new Set(); + listOfString = new List(); + } + + public void add(String value) { + Boolean exists = setOfStrings.contains(value); + if(!exists) + setOfStrings.add(value); + if(distinct ? !exists : true) + listOfString.add(value); + } + + public override String toString() { + return String.join(listOfString, delimiter == null ? '' : delimiter); + } + } + /** Exception throwed if Rollup Summary field is in bad state */ @@ -208,7 +326,7 @@ public class LREngine { Which rollup operation you want to perform */ public enum RollupOperation { - Sum, Max, Min, Avg, Count, Count_Distinct + Sum, Max, Min, Avg, Count, Count_Distinct, Concatenate, Concatenate_Distinct, First, Last } /** @@ -221,7 +339,9 @@ public class LREngine { public class RollupSummaryField { public Schema.Describefieldresult master; public Schema.Describefieldresult detail; + public Schema.Describefieldresult detailOrderBy; public RollupOperation operation; + public String concatenateDelimiter; // derived fields, kept like this to save script lines later, by saving the same // computations over and over again @@ -230,12 +350,24 @@ public class LREngine { public boolean isMasterTypeDateOrTime; public boolean isDetailTypeDateOrTime; public boolean isMasterTypeCurrency; - + public boolean isMasterTypeText; + public boolean isDetailTypeText; + public RollupSummaryField(Schema.Describefieldresult m, Schema.Describefieldresult d, RollupOperation op) { + this(m, d, null, op, null); + } + + public RollupSummaryField(Schema.Describefieldresult m, + Schema.Describefieldresult d, + Schema.Describefieldresult detailOrderBy, + RollupOperation op, + String concatenateDelimiter) { this.master = m; this.detail = d; + this.detailOrderBy = detailOrderBy; this.operation = op; + this.concatenateDelimiter = concatenateDelimiter; // caching these derived attrbutes for once // as their is no view state involved here // and this caching will lead to saving in script lines later on @@ -244,6 +376,8 @@ public class LREngine { this.isMasterTypeDateOrTime = isDateOrTime(master.getType()); this.isDetailTypeDateOrTime = isDateOrTime(detail.getType()); this.isMasterTypeCurrency = isCurrency(master.getType()); + this.isMasterTypeText = isText(master.getType()); + this.isDetailTypeText = isText(detail.getType()); // validate if field is good to work on later validate(); } @@ -252,7 +386,25 @@ public class LREngine { if (master == null || detail == null || operation == null) throw new BadRollUpSummaryStateException('All of Master/Detail Describefieldresult and RollupOperation info is mandantory'); - if (operation != RollupOperation.Count) { + if (operation == RollupOperation.Concatenate || + operation == RollupOperation.Concatenate_Distinct) { + if ( !isMasterTypeText ) { + throw new BadRollUpSummaryStateException('Only Text/Text Area fields are allowed for Concatenate and Concatenate Distinct'); + } + } + + if (operation == RollupOperation.First || + operation == RollupOperation.Last) { + if (this.master.getSObjectField() != this.detail.getSObjectField() && + !isDetailTypeText && !isMasterTypeText) { + throw new BadRollUpSummaryStateException('Master and detail fields must be the same field type (or text based) for First or Last operations'); + } + } + + if (operation == RollupOperation.Sum || + operation == RollupOperation.Max || + operation == RollupOperation.Min || + operation == RollupOperation.Avg) { if ( (!isMasterTypeDateOrTime && !isMasterTypeNumber) || (!isDetailTypeDateOrTime && !isDetailTypeNumber)) { throw new BadRollUpSummaryStateException('Only Date/DateTime/Time/Numeric fields are allowed for Sum, Max, Min and Avg'); @@ -263,7 +415,14 @@ public class LREngine { throw new BadRollUpSummaryStateException('Sum/Avg doesnt looks like valid for dates ! Still want, then implement the IRollerCoaster yourself and change this class as required.'); } } - + + boolean isText (Schema.Displaytype dt) { + return dt == Schema.Displaytype.TextArea || + dt == Schema.Displaytype.String || + dt == Schema.Displaytype.Picklist || + dt == Schema.Displaytype.MultiPicklist; + } + boolean isNumber (Schema.Displaytype dt) { return dt == Schema.Displaytype.Currency || dt == Schema.Displaytype.Integer @@ -280,6 +439,22 @@ public class LREngine { boolean isCurrency(Schema.DisplayType dt) { return dt == Schema.Displaytype.Currency; } + + public boolean isAggregateBasedRollup() { + return operation == RollupOperation.Sum || + operation == RollupOperation.Min || + operation == RollupOperation.Max || + operation == RollupOperation.Avg || + operation == RollupOperation.Count || + operation == RollupOperation.Count_Distinct; + } + + public boolean isQueryBasedRollup() { + return operation == RollupOperation.Concatenate || + operation == RollupOperation.Concatenate_Distinct || + operation == RollupOperation.First || + operation == RollupOperation.Last; + } } /** @@ -295,6 +470,9 @@ public class LREngine { public Schema.Describefieldresult lookupField; // various fields to rollup on public List fieldsToRoll; + // what type of rollups does this context contain + private Boolean isAggregateBased = null; + private Boolean isQueryBased = null; // Where clause or filters to apply while aggregating detail records public String detailWhereClause; @@ -317,6 +495,19 @@ public class LREngine { Adds new rollup summary fields to the context */ public void add(RollupSummaryField fld) { + + // The type of query this context is based is driven by the first summary field added + if(isQueryBased == null && isAggregateBased == null) + { + isAggregateBased = fld.isAggregateBasedRollup(); + isQueryBased = fld.isQueryBasedRollup(); + } + + // A context cannot support summary fields with operations that mix the use of underlying query types + if(isAggregateBased && !fld.isAggregateBasedRollup() || + isQueryBased && !fld.isQueryBasedRollup()) + throw new BadRollUpSummaryStateException('Cannot mix Sum, Max, Min, Avg, Count, Count_Distinct operations with Concatenate, Concatenate_Distinct, First, Last operations'); + this.fieldsToRoll.add(fld); } } diff --git a/rolluptool/src/classes/RollupService.cls b/rolluptool/src/classes/RollupService.cls index 8e216c73..82900fe4 100644 --- a/rolluptool/src/classes/RollupService.cls +++ b/rolluptool/src/classes/RollupService.cls @@ -60,7 +60,7 @@ global with sharing class RollupService global static Id runJobToCalculate(Id lookupId) { // Is another calculate job running for this lookup? - List lookups = new RollupSummariesSelector().selectById(new Set { lookupId }); + List lookups = new RollupSummariesSelector().selectById(new Set { lookupId }); if(lookups.size()==0) throw RollupServiceException.rollupNotFound(lookupId); LookupRollupSummary__c lookup = lookups[0]; @@ -559,30 +559,37 @@ global with sharing class RollupService if(childFields==null) gdFields.put(childObjectType, ((childFields = childObjectType.getDescribe().fields.getMap()))); SObjectField fieldToAggregate = childFields.get(lookup.FieldToAggregate__c); + SObjectField fieldToOrderBy = lookup.FieldToOrderBy__c!=null ? childFields.get(lookup.FieldToOrderBy__c) : null; SObjectField relationshipField = childFields.get(lookup.RelationshipField__c); SObjectField aggregateResultField = parentFields.get(lookup.AggregateResultField__c); if(fieldToAggregate==null || relationshipField==null || aggregateResultField==null) throw RollupServiceException.invalidRollup(lookup); - // Determine if an LREngine Context has been created for this parent child relationship and filter combination? - String contextKey = lookup.ParentObject__c + '#' + lookup.RelationshipField__c + '#' + lookup.RelationShipCriteria__c; + // Summary field definition used by LREngine + LREngine.RollupSummaryField rsf = + new LREngine.RollupSummaryField( + aggregateResultField.getDescribe(), + fieldToAggregate.getDescribe(), + fieldToOrderBy !=null ? fieldToOrderBy.getDescribe() : null, // field to order by on child + RollupSummaries.OPERATION_PICKLIST_TO_ENUMS.get(lookup.AggregateOperation__c), + lookup.ConcatenateDelimiter__c); + + // Determine if an LREngine Context has been created for this parent child relationship, filter combination or underlying query type? + String rsfType = rsf.isAggregateBasedRollup() ? 'aggregate' : 'query'; + String contextKey = lookup.ParentObject__c + '#' + lookup.RelationshipField__c + '#' + lookup.RelationShipCriteria__c + '#' + rsfType; LREngine.Context lreContext = engineCtxByParentRelationship.get(contextKey); if(lreContext==null) { // Construct LREngine.Context lreContext = new LREngine.Context( parentObjectType, // parent object - childObjectType, // child object + childObjectType, // child object relationshipField.getDescribe(), // relationship field name lookup.RelationShipCriteria__c); engineCtxByParentRelationship.put(contextKey, lreContext); } // Add the lookup - lreContext.add( - new LREngine.RollupSummaryField( - aggregateResultField.getDescribe(), - fieldToAggregate.getDescribe(), - RollupSummaries.OPERATION_PICKLIST_TO_ENUMS.get(lookup.AggregateOperation__c))); + lreContext.add(rsf); } return engineCtxByParentRelationship; } diff --git a/rolluptool/src/classes/RollupServiceTest.cls b/rolluptool/src/classes/RollupServiceTest.cls index e99e8266..a5445309 100644 --- a/rolluptool/src/classes/RollupServiceTest.cls +++ b/rolluptool/src/classes/RollupServiceTest.cls @@ -954,4 +954,140 @@ private with sharing class RollupServiceTest // Assert rollup System.assertEquals(expectedResult, [select AnnualRevenue from Account where Id = :account.Id].AnnualRevenue); } + + private testmethod static void testMultiRollupOfDifferentTypes() + { + // Test supported? + if(!TestContext.isSupported()) + return; + + // Test data + List rollups = new List { 250, 250, 50, 50 }; + + // Test data for rollup A + Decimal expectedResultA = 600; + RollupSummaries.AggregateOperation operationA = RollupSummaries.AggregateOperation.Sum; + String conditionA = null; + + // Test data for rollup B + String expectedResultB = 'Open,Open,Open,Open'; + RollupSummaries.AggregateOperation operationB = RollupSummaries.AggregateOperation.Concatenate; + String conditionB = null; + + // Configure rollup A + LookupRollupSummary__c rollupSummaryA = new LookupRollupSummary__c(); + rollupSummaryA.Name = 'Total Opportunities into Annual Revenue on Account'; + rollupSummaryA.ParentObject__c = 'Account'; + rollupSummaryA.ChildObject__c = 'Opportunity'; + rollupSummaryA.RelationShipField__c = 'AccountId'; + rollupSummaryA.RelationShipCriteria__c = conditionA; + rollupSummaryA.FieldToAggregate__c = 'Amount'; + rollupSummaryA.AggregateOperation__c = operationA.name(); + rollupSummaryA.AggregateResultField__c = 'AnnualRevenue'; + rollupSummaryA.Active__c = true; + rollupSummaryA.CalculationMode__c = 'Realtime'; + + // Configure rollup B + LookupRollupSummary__c rollupSummaryB = new LookupRollupSummary__c(); + rollupSummaryB.Name = 'Concatenate Opportunities Stage Name into Description on Account'; + rollupSummaryB.ParentObject__c = 'Account'; + rollupSummaryB.ChildObject__c = 'Opportunity'; + rollupSummaryB.RelationShipField__c = 'AccountId'; + rollupSummaryB.RelationShipCriteria__c = conditionB; + rollupSummaryB.FieldToAggregate__c = 'StageName'; + rollupSummaryB.AggregateOperation__c = operationB.name(); + rollupSummaryB.AggregateResultField__c = 'Description'; + rollupSummaryB.ConcatenateDelimiter__c = ','; + rollupSummaryB.Active__c = true; + rollupSummaryB.CalculationMode__c = 'Realtime'; + + // Insert rollup definitions + insert new List { rollupSummaryA, rollupSummaryB }; + + // Test data + Account account = new Account(); + account.Name = 'Test Account'; + account.AnnualRevenue = 0; + insert account; + List opps = new List(); + for(Decimal rollupValue : rollups) + { + Opportunity opp = new Opportunity(); + opp.Name = 'Test Opportunity'; + opp.StageName = 'Open'; + opp.CloseDate = System.today(); + opp.AccountId = account.Id; + opp.Amount = rollupValue; + opps.add(opp); + } + insert opps; + + // Assert rollup + Id accountId = account.Id; + Account accountResult = Database.query('select AnnualRevenue, Description from Account where Id = :accountId'); + System.assertEquals(expectedResultA, accountResult.AnnualRevenue); + System.assertEquals(expectedResultB, accountResult.Description); + } + + private testmethod static void testPicklistRollup() + { + // Test supported? + if(!TestContext.isSupported()) + return; + + // Create a picklist rollup + LookupRollupSummary__c rollupSummary = new LookupRollupSummary__c(); + rollupSummary.Name = 'Test Rollup'; + rollupSummary.ParentObject__c = 'dlrs__LookupParent__c'; + rollupSummary.ChildObject__c = 'dlrs__LookupChild__c'; + rollupSummary.RelationShipField__c = 'dlrs__LookupParent__c'; + rollupSummary.FieldToAggregate__c = 'dlrs__Color__c'; + rollupSummary.AggregateOperation__c = RollupSummaries.AggregateOperation.Concatenate.name(); + rollupSummary.AggregateResultField__c = 'dlrs__Colours__c'; + rollupSummary.ConcatenateDelimiter__c = ';'; + rollupSummary.Active__c = true; + rollupSummary.CalculationMode__c = 'Realtime'; + insert rollupSummary; + + // Insert parents + Schema.SObjectType parentType = Schema.getGlobalDescribe().get('dlrs__LookupParent__c'); + SObject parentA = parentType.newSObject(); + parentA.put('Name', 'ParentA'); + SObject parentB = parentType.newSObject(); + parentB.put('Name', 'ParentB'); + SObject parentC = parentType.newSObject(); + parentC.put('Name', 'ParentC'); + List parents = new List { parentA, parentB, parentC }; + insert parents; + + // Insert children + Schema.SObjectType childType = Schema.getGlobalDescribe().get('dlrs__LookupChild__c'); + List children = new List(); + for(SObject parent : parents) + { + String name = (String) parent.get('Name'); + SObject child1 = childType.newSObject(); + child1.put('dlrs__LookupParent__c', parent.Id); + child1.put('dlrs__Color__c', 'Red'); + children.add(child1); + SObject child2 = childType.newSObject(); + child2.put('dlrs__LookupParent__c', parent.Id); + child2.put('dlrs__Color__c', 'Yellow'); + children.add(child2); + if(name.equals('ParentA') || name.equals('ParentB')) + { + SObject child3 = childType.newSObject(); + child3.put('dlrs__LookupParent__c', parent.Id); + child3.put('dlrs__Color__c', 'Blue'); + children.add(child3); + } + } + insert children; + + // Assert rollups + Map assertParents = new Map(Database.query('select id, dlrs__Colours__c from dlrs__LookupParent__c')); + System.assertEquals('Red;Yellow;Blue', (String) assertParents.get(parentA.id).get('dlrs__Colours__c')); + System.assertEquals('Red;Yellow;Blue', (String) assertParents.get(parentB.id).get('dlrs__Colours__c')); + System.assertEquals('Red;Yellow', (String) assertParents.get(parentC.id).get('dlrs__Colours__c')); + } } diff --git a/rolluptool/src/classes/RollupSummaries.cls b/rolluptool/src/classes/RollupSummaries.cls index 61755a56..cc60284f 100644 --- a/rolluptool/src/classes/RollupSummaries.cls +++ b/rolluptool/src/classes/RollupSummaries.cls @@ -43,7 +43,11 @@ public with sharing class RollupSummaries extends SObjectDomain AggregateOperation.Min.name() => LREngine.RollupOperation.Min, AggregateOperation.Avg.name() => LREngine.RollupOperation.Avg, AggregateOperation.Count.name() => LREngine.RollupOperation.Count, - AggregateOperation.Count_Distinct.name().replace('_', ' ') => LREngine.RollupOperation.Count_Distinct + AggregateOperation.Count_Distinct.name().replace('_', ' ') => LREngine.RollupOperation.Count_Distinct, + AggregateOperation.Concatenate.name() => LREngine.RollupOperation.Concatenate, + AggregateOperation.Concatenate_Distinct.name().replace('_', ' ') => LREngine.RollupOperation.Concatenate_Distinct, + AggregateOperation.First.name() => LREngine.RollupOperation.First, + AggregateOperation.Last.name() => LREngine.RollupOperation.Last }; /** @@ -66,7 +70,11 @@ public with sharing class RollupSummaries extends SObjectDomain Min, Avg, Count, - Count_Distinct + Count_Distinct, + Concatenate, + Concatenate_Distinct, + First, + Last } public RollupSummaries(List records) @@ -112,6 +120,7 @@ public with sharing class RollupSummaries extends SObjectDomain // Child Object fields valid? SObjectField relationshipField = null; SObjectField fieldToAggregate = null; + SObjectField fieldToOrderBy = null; Map childObjectFields = gdFields.get(childObjectType); if(childObjectFields!=null) { @@ -123,6 +132,12 @@ public with sharing class RollupSummaries extends SObjectDomain fieldToAggregate = childObjectFields.get(lookupRollupSummary.FieldToAggregate__c); if(fieldToAggregate==null) lookupRollupSummary.FieldToAggregate__c.addError(error('Field does not exist.', lookupRollupSummary, LookupRollupSummary__c.FieldToAggregate__c)); + // Field to Order By valid? + if(lookupRollupSummary.FieldToOrderBy__c!=null) { + fieldToOrderBy = childObjectFields.get(lookupRollupSummary.FieldToOrderBy__c); + if(fieldToOrderBy==null) + lookupRollupSummary.FieldToOrderBy__c.addError(error('Field does not exist.', lookupRollupSummary, LookupRollupSummary__c.FieldToOrderBy__c)); + } // TODO: Validate relationship field is a lookup to the parent // ... } @@ -178,7 +193,9 @@ public with sharing class RollupSummaries extends SObjectDomain new LREngine.RollupSummaryField( aggregateResultField.getDescribe(), fieldToAggregate.getDescribe(), - OPERATION_PICKLIST_TO_ENUMS.get(lookupRollupSummary.AggregateOperation__c))); + fieldToOrderBy!=null ? fieldToOrderBy.getDescribe() : null, // optional field to order by + OPERATION_PICKLIST_TO_ENUMS.get(lookupRollupSummary.AggregateOperation__c), + lookupRollupSummary.ConcatenateDelimiter__c)); // Validate the SOQL if(lookupRollupSummary.RelationShipCriteria__c!=null && lookupRollupSummary.RelationShipCriteria__c.length()>0) diff --git a/rolluptool/src/classes/RollupSummariesSelector.cls b/rolluptool/src/classes/RollupSummariesSelector.cls index c7844932..4e3d67ae 100644 --- a/rolluptool/src/classes/RollupSummariesSelector.cls +++ b/rolluptool/src/classes/RollupSummariesSelector.cls @@ -38,7 +38,9 @@ public with sharing class RollupSummariesSelector extends SObjectSelector LookupRollupSummary__c.AggregateResultField__c, LookupRollupSummary__c.CalculationMode__c, LookupRollupSummary__c.ChildObject__c, + LookupRollupSummary__c.ConcatenateDelimiter__c, LookupRollupSummary__c.FieldToAggregate__c, + LookupRollupSummary__c.FieldToOrderBy__c, LookupRollupSummary__c.ParentObject__c, LookupRollupSummary__c.RelationshipCriteria__c, LookupRollupSummary__c.RelationshipCriteriaFields__c, diff --git a/rolluptool/src/classes/RollupSummariesTest.cls b/rolluptool/src/classes/RollupSummariesTest.cls index ca178665..11b47b10 100644 --- a/rolluptool/src/classes/RollupSummariesTest.cls +++ b/rolluptool/src/classes/RollupSummariesTest.cls @@ -237,6 +237,31 @@ private with sharing class RollupSummariesTest System.assertEquals('Field does not exist.', SObjectDomain.Errors.getAll()[0].message); System.assertEquals(LookupRollupSummary__c.FieldToAggregate__c, ((SObjectDomain.FieldError)SObjectDomain.Errors.getAll()[0]).field); } + + private testmethod static void testInsertFieldToOrderByValidation() + { + // Test supported? + if(!TestContext.isSupported()) + return; + + LookupRollupSummary__c rollupSummary = new LookupRollupSummary__c(); + rollupSummary.Name = 'Total Opportunities into Annual Revenue on Account'; + rollupSummary.ParentObject__c = 'Account'; + rollupSummary.ChildObject__c = 'Opportunity'; + rollupSummary.RelationShipField__c = 'AccountId'; + rollupSummary.RelationShipCriteria__c = null; + rollupSummary.FieldToAggregate__c = 'Amount'; + rollupSummary.FieldToOrderBy__c = 'AmountX'; + rollupSummary.AggregateOperation__c = 'Sum'; + rollupSummary.AggregateResultField__c = 'AnnualRevenue'; + rollupSummary.Active__c = true; + rollupSummary.CalculationMode__c = 'Realtime'; + SObjectDomain.Test.Database.onInsert(new LookupRollupSummary__c[] { rollupSummary } ); + SObjectDomain.triggerHandler(RollupSummaries.class); + System.assertEquals(1, SObjectDomain.Errors.getAll().size()); + System.assertEquals('Field does not exist.', SObjectDomain.Errors.getAll()[0].message); + System.assertEquals(LookupRollupSummary__c.FieldToOrderBy__c, ((SObjectDomain.FieldError)SObjectDomain.Errors.getAll()[0]).field); + } private testmethod static void testInsertAggregateResultFieldValidation() { diff --git a/rolluptool/src/classes/TestLREngine.cls b/rolluptool/src/classes/TestLREngine.cls index dac0e0d8..b0eb343b 100644 --- a/rolluptool/src/classes/TestLREngine.cls +++ b/rolluptool/src/classes/TestLREngine.cls @@ -107,7 +107,7 @@ private class TestLREngine { AccountId = acc2.Id, Amount = 200.00, CloseDate = System.today().addMonths(2), - StageName = 'test' + StageName = 'Won' ); Opportunity o2Acc2 = new Opportunity( @@ -115,7 +115,7 @@ private class TestLREngine { AccountId = acc2.Id, Amount = 400.00, CloseDate = System.today().addMonths(3), - StageName = 'test' + StageName = 'Lost' ); Opportunity o3Acc2 = new Opportunity( @@ -123,7 +123,7 @@ private class TestLREngine { AccountId = acc2.Id, Amount = 300.00, CloseDate = System.today().addMonths(4), - StageName = 'test' + StageName = 'Won' ); detailRecords = new Opportunity[] {o1Acc1, o2Acc1, o3Acc1, o1Acc2, o2Acc2, o3Acc2}; if(ANNUALIZED_RECCURING_REVENUE!=null) @@ -282,6 +282,42 @@ private class TestLREngine { System.assertEquals(3, reloadedAcc1.get(ACCOUNT_NUMBER_OF_EMPLOYEES)); System.assertEquals(3, reloadedAcc2.get(ACCOUNT_NUMBER_OF_EMPLOYEES)); } + + /* + Tests count distinct + */ + static testMethod void testCountDistinctOperations() { + + // Required custom field/s present? + if(ACCOUNT_NUMBER_OF_EMPLOYEES==null) + return; + + // create seed data + prepareData(); + + LREngine.Context ctx = new LREngine.Context(Account.SobjectType, + Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + ctx.add( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AnnualRevenue, + Schema.SObjectType.Opportunity.fields.StageName, + LREngine.RollupOperation.Count_Distinct + )); + + Sobject[] masters = LREngine.rollUp(ctx, detailRecords); + // 2 masters should be back + System.assertEquals(2, masters.size()); + + Account reloadedAcc1, reloadedAcc2; + for (Sobject so : masters) { + if (so.Id == acc1.id) reloadedAcc1 = (Account)so; + if (so.Id == acc2.id) reloadedAcc2 = (Account)so; + } + + System.assertEquals(1, reloadedAcc1.AnnualRevenue); // Only one set of distinct StageName's on Account 1 + System.assertEquals(2, reloadedAcc2.AnnualRevenue); // Two sets of distinct StageName's on Account 2 + } /* @@ -533,5 +569,207 @@ private class TestLREngine { catch (Exception e) { System.assertEquals('Only Date/DateTime/Time/Numeric fields are allowed for Sum, Max, Min and Avg', e.getMessage()); } + } + + static testMethod void testRollupContextsValidRollupFieldCombosOnly() { + + // Cannot mix (Sum, Max, Min, Avg, Count, Count_Distinct) with (Concatenate, Concatenate_Distinct, First, Last) + try { + LREngine.Context ctx = + new LREngine.Context( + Account.SobjectType, Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + LREngine.RollupOperation.Concatenate)); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AnnualRevenue, + Schema.SObjectType.Opportunity.fields.Amount, + LREngine.RollupOperation.Sum)); + System.assert(false, 'Expecting an exception'); + } catch (Exception e) { + System.assertEquals('Cannot mix Sum, Max, Min, Avg, Count, Count_Distinct operations with Concatenate, Concatenate_Distinct, First, Last operations', e.getMessage()); + } + try { + LREngine.Context ctx = + new LREngine.Context( + Account.SobjectType, Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AnnualRevenue, + Schema.SObjectType.Opportunity.fields.Amount, + LREngine.RollupOperation.Sum)); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + LREngine.RollupOperation.Concatenate)); + System.assert(false, 'Expecting an exception'); + } catch (Exception e) { + System.assertEquals('Cannot mix Sum, Max, Min, Avg, Count, Count_Distinct operations with Concatenate, Concatenate_Distinct, First, Last operations', e.getMessage()); + } + } + + static testMethod void testRollupSummaryFieldValidationConcatenate() { + // Master must be text type + try { + LREngine.Context ctx = + new LREngine.Context( + Account.SobjectType, Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AnnualRevenue, + Schema.SObjectType.Opportunity.fields.Id, + LREngine.RollupOperation.Concatenate)); + System.assert(false, 'Expecting an exception'); + } catch (Exception e) { + System.assertEquals('Only Text/Text Area fields are allowed for Concatenate and Concatenate Distinct', e.getMessage()); + } + } + + static testMethod void testRollupSummaryFieldValidationFirstAndLast() { + // Master and detail field type must match + try { + LREngine.Context ctx = + new LREngine.Context( + Account.SobjectType, Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AnnualRevenue, + Schema.SObjectType.Opportunity.fields.Id, + LREngine.RollupOperation.Last)); + System.assert(false, 'Expecting an exception'); + } catch (Exception e) { + System.assertEquals('Master and detail fields must be the same field type (or text based) for First or Last operations', e.getMessage()); + } + try { + LREngine.Context ctx = + new LREngine.Context( + Account.SobjectType, Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + ctx.add(new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AnnualRevenue, + Schema.SObjectType.Opportunity.fields.Id, + LREngine.RollupOperation.First)); + System.assert(false, 'Expecting an exception'); + } catch (Exception e) { + System.assertEquals('Master and detail fields must be the same field type (or text based) for First or Last operations', e.getMessage()); + } + } + + static testMethod void testRollupConcatenateTruncate() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.AccountNumber, + Schema.SObjectType.Opportunity.fields.StageName, + null, LREngine.RollupOperation.Concatenate, '01234567890123456789,'), + 'test01234567890123456789,test01234567...', + 'Lost01234567890123456789,Won012345678...'); + } + + static testMethod void testRollupConcatenate() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + null, LREngine.RollupOperation.Concatenate, ','), + 'test,test,test', + 'Lost,Won,Won'); + } + + + static testMethod void testRollupConcatenateBR() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + null, LREngine.RollupOperation.Concatenate, 'BR()'), + 'test\ntest\ntest', + 'Lost\nWon\nWon'); + } + + static testMethod void testRollupConcatenateOrderBy() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + Schema.SObjectType.Opportunity.fields.Amount, + LREngine.RollupOperation.Concatenate, ','), + 'test,test,test', + 'Won,Won,Lost'); + } + + static testMethod void testRollupConcatenateNoDelimiter() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + null, LREngine.RollupOperation.Concatenate, null), + 'testtesttest', + 'LostWonWon'); + } + + static testMethod void testRollupConcatenateDistinct() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + null, LREngine.RollupOperation.Concatenate_Distinct, ','), + 'test', + 'Lost,Won'); + } + + static testMethod void testRollupConcatenateDistinctWithOrderBy() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + Schema.SObjectType.Opportunity.fields.Amount, + LREngine.RollupOperation.Concatenate_Distinct, ','), + 'test', + 'Won,Lost'); + } + + static testMethod void testRollupFirst() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + Schema.SObjectType.Opportunity.fields.Amount, + LREngine.RollupOperation.First, null), + 'test', + 'Won'); + } + + static testMethod void testRollupLast() { + testRollup( + new LREngine.RollupSummaryField( + Schema.SObjectType.Account.fields.Description, + Schema.SObjectType.Opportunity.fields.StageName, + Schema.SObjectType.Opportunity.fields.Amount, + LREngine.RollupOperation.Last, null), + 'test', + 'Lost'); + } + + static private void testRollup(LREngine.RollupSummaryField rollupField, String expected1, String expected2) { + + prepareData(); + + LREngine.Context ctx = new LREngine.Context( + Account.SobjectType, + Opportunity.SobjectType, + Schema.SObjectType.Opportunity.fields.AccountId); + + ctx.add(rollupField); + + SObject[] masters = LREngine.rollUp(ctx, detailRecords); + + Map mastersById = new Map(masters); + Account reloadedAcc1 = (Account)mastersById.get(acc1.Id); + Account reloadedAcc2 = (Account)mastersById.get(acc2.Id); + System.assertEquals(2, masters.size()); + System.assertEquals(expected1, reloadedAcc1.get(rollupField.master.getName())); + System.assertEquals(expected2, reloadedAcc2.get(rollupField.master.getName())); } } \ No newline at end of file