Skip to content

Instantly share code, notes, and snippets.

@paustint
Last active February 12, 2025 13:01
Show Gist options
  • Save paustint/bd18bd281134a180e014829b49ed043a to your computer and use it in GitHub Desktop.
Save paustint/bd18bd281134a180e014829b49ed043a to your computer and use it in GitHub Desktop.
Apex Utility Classes / CPQ Quote Calculator Plugin Example
/**
* Utility class for common operations
*
* Any classes that use Schema.SObjectField, this property is object by calling "Schema.Account.Description"
* This allows type safety to ensure that code will not break if fields are changed
* this will not work with person accounts
*
* (c) Advanced Technology Group, 2019
* This code may be used and modified freely as long as this copyright attribution
* is included with the code.
*
*/
public class ApexUtils {
public class NoParentRecordIncludedException extends Exception {}
private static Map<String, RecordType> recordTypeMap = new Map<String, RecordType>();
@TestVisible
private static String subDomainWithProtocol = (System.URL.getSalesforceBaseURL().toExternalForm()).split('\\.')[0];
public static RecordType getRecordTypeByDeveloperName(String developerName) {
if(recordTypeMap.isEmpty()) {
List<RecordType> recordTypes = [SELECT Id , DeveloperName FROM RecordType];
for(RecordType recordType : recordTypes) {
recordTypeMap.put(recordType.DeveloperName, recordType);
}
}
return recordTypeMap.get(developerName);
}
/**
* Given a list and a string property (of an ID field), return a set of the extracted ids
* (Example: Given a list of contacts where prop='AccountId', return a set of the account Id's)
* @param items [description]
* @param prop [description]
* @return [description]
*/
public static Set<Id> pluckIdSet(List<SObject> items, Schema.SObjectField prop) {
Set<Id> ids = new Set<Id>();
for(SObject obj : items) {
try {
if(obj.get(prop) != null) {
ids.add((Id)obj.get(prop));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return ids;
}
/**
* Given a list and a string property (of an ID field), return a set of the extracted ids
* (Example: Given a list of contacts where prop='AccountId', return a set of the account Id's)
* This method allows relationships - Ex. 'Account.CreatedBy.Id'
* @param items [description]
* @param prop [description]
* @return [description]
*/
public static Set<Id> pluckIdSet(List<SObject> items, String prop) {
Set<Id> ids = new Set<Id>();
for(SObject obj : items) {
try {
if(prop.contains('.')) {
SObject currObj = obj;
List<String> fields = prop.split('\\.');
for(String field : fields) {
try {
currObj = (SObject)currObj.getSobject(field);
} catch (Exception ex) {
ids.add((Id)currObj.get(field));
}
}
} else if (obj.get(prop) != null) {
ids.add((Id)obj.get(prop));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return ids;
}
/**
* Given a list and a string property (of a String field), return a set of the extracted values
* (Example: Given a list of contacts where prop='FirstName', return a set of the contacts first name)
* @param items [description]
* @param prop [description]
* @return [description]
*/
public static Set<String> pluckStringSet(List<SObject> items, Schema.SObjectField prop) {
Set<String> strings = new Set<String>();
for(SObject obj : items) {
try {
if(obj.get(prop) != null) {
strings.add((String)obj.get(prop));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return strings;
}
/**
* Given a list and a string property (of a String field), return a set of the extracted values
* The string version provides the ability to get relationship fields - e.x. 'Account.CreatedBy.Name'
* @param items [description]
* @param prop [description]
* @return [description]
*/
public static Set<String> pluckStringSet(List<SObject> items, String prop) {
Set<String> strings = new Set<String>();
for(SObject obj : items) {
try {
if(prop.contains('.')) {
SObject currObj = obj;
List<String> fields = prop.split('\\.');
for(String field : fields) {
try {
currObj = (SObject)currObj.getSobject(field);
} catch (Exception ex) {
strings.add((String)currObj.get(field));
}
}
} else if (obj.get(prop) != null) {
strings.add((String)obj.get(prop));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return strings;
}
/**
* Build a map from two proprties on a list of objects
* Example: Given a list of Opportunites, passing in prop1='Id', prop2='AccountId', get a map back with the opp id to the account id
* If two records have the same value for key, then record later in the list will overwrite prior value
* @param items list of SObject
* @param key Property to get a map by
* @param value [description]
* @return [description]
*/
public static Map<String, String> pluckMap(List<SObject> items, Schema.SObjectField key, Schema.SObjectField value) {
Map<String, String> outputMap = new Map<String, String>();
for(SObject obj : items) {
try {
if(obj.get(key) != null && obj.get(value) != null) {
outputMap.put((String)obj.get(key), (String)obj.get(value));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return outputMap;
}
/**
* Same logic as pluckMap, but value is of type Object and will need to be casted to proper type
* @param items list of SObject
* @param key Property to get a map by
* @param value [description]
* @return [description]
*/
public static Map<String, Object> pluckMapAny(List<SObject> items, Schema.SObjectField key, Schema.SObjectField value) {
Map<String, Object> propToPropMap = new Map<String, Object>();
for(SObject obj : items) {
try {
if(obj.get(key) != null && obj.get(value) != null) {
propToPropMap.put((String)obj.get(key), obj.get(value));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getMessage() + ex.getStackTraceString());
}
}
return propToPropMap;
}
/**
* Group a list of SObjects by any field on the SObject.
* @param items list of SObject
* @param field Property to get a map by
* @return [description]
*/
public static Map<String, List<SObject>> groupBy(List<SObject> items, Schema.SObjectField field) {
Map<String, List<SObject>> propToPropMap = new Map<String, List<SObject>>();
for(SObject obj : items) {
try {
if(obj.get(field) != null) {
if(!propToPropMap.containsKey((String)obj.get(field))) {
propToPropMap.put((String)obj.get(field), new List<SObject>());
}
propToPropMap.get((String)obj.get(field)).add(obj);
} else {
System.debug(field + ' is null, ignoring record: ' + obj);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getMessage() + ex.getStackTraceString());
}
}
return propToPropMap;
}
/**
* Group a list of SObjects by any field on the SObject.
* This version of the method supports passing in a string key with relationship fields
* For example, if you want to group Contacts by Contact.Account.Name
* @param items list of SObject
* @param field Property to get a map by
* @return [description]
*/
public static Map<String, List<SObject>> groupBy(List<SObject> items, String field) {
Map<String, List<SObject>> propToPropMap = new Map<String, List<SObject>>();
for(SObject obj : items) {
try {
SObject baseObj = obj;
SObject tempObj = obj; // if field has a "." this holds the nested objects until fields is reached
String currField = field;
// If provided field is using dot notation, get nested object and field
if(field.contains('.')) {
List<String> fields = field.split('\\.');
for(String splitField : fields) {
try {
tempObj = (SObject)tempObj.getSobject(splitField);
} catch (Exception ex) {
currField = splitField;
}
}
}
if(tempObj.get(currField) != null) {
if(!propToPropMap.containsKey((String)tempObj.get(currField))) {
propToPropMap.put((String)tempObj.get(currField), new List<SObject>());
}
propToPropMap.get((String)tempObj.get(currField)).add(baseObj);
} else {
System.debug(currField + ' is null, ignoring record: ' + baseObj);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getMessage() + ex.getStackTraceString());
}
}
return propToPropMap;
}
/**
* Sames as groupBy, but only returns one record per key
* if two records have the same key, the record later in the list will overwrite the previous record
* @param items list of records
* @param key field key
* @return [description]
*/
public static Map<String, SObject> groupByFlat(List<SObject> items, Schema.SObjectField key) {
Map<String, SObject> propToSObjMap = new Map<String, SObject>();
for(SObject obj : items) {
try {
if(obj.get(key) != null) {
propToSObjMap.put((String)obj.get(key), obj);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getMessage() + ex.getStackTraceString());
}
}
return propToSObjMap;
}
/**
* Get a list of records where the specified value changed
* @param items trigger.new
* @param oldItemsMap trigger.oldMap
* @param fields string | string[], name(s) of property to check
* @return
*/
public static List<SObject> findChangedRecs(List<SObject> items, Map<Id, SObject> oldItemsMap, Schema.SObjectField field) {
return findChangedRecs(items, oldItemsMap, new List<Schema.SObjectField>{field});
}
public static List<SObject> findChangedRecs(List<SObject> items, Map<Id, SObject> oldItemsMap, List<Schema.SObjectField> fields) {
List<SObject> changedObjects = new List<SObject>();
for(SObject obj : items) {
for(Schema.SObjectField field : fields) {
try {
Object newObjValue = obj.get(field);
Object oldObjValue = oldItemsMap.get((Id)obj.get('Id')).get(field);
if(newObjValue != oldObjValue) {
changedObjects.add(obj);
break; // do not need to continue checking for this record
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
}
return changedObjects;
}
/**
* Same as findChangedRecs, but check if changed values meet a specific new value
* @param items List of new records
* @param oldItemsMap Map of old records
* @param field The field on the SObject to check
* @param expectedNewValue The value that is desired on the new records. IF the value on the new record equals this value, then the old record is checked
* to see if the value is set differently
* @return List of eligible objects, or an empy list
*/
public static List<SObject> findChangedRecsWithMatchingVal(List<SObject> items, Map<Id, SObject> oldItemsMap, Schema.SObjectField field, Object expectedNewValue) {
List<SObject> changedObjects = new List<SObject>();
for(SObject obj : items) {
try {
Object newObjValue = obj.get(field);
if(newObjValue == expectedNewValue) {
Object oldObjValue = oldItemsMap.get((Id)obj.get('Id')).get(field);
if(newObjValue != oldObjValue) {
changedObjects.add(obj);
}
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return changedObjects;
}
/**
* Given a newList and oldMap, return the records that have a matching old value and new value.
* This use-case is used when we only want to match records that had a specific value to begin with where the field was changed to another specified value
* Example: Status changed from "Draft" to "Cancelled"
* @param items List of new records
* @param oldItemsMap Map of old records
* @param field The field on the SObject to check
* @param oldValue Old value of the record that the value should had to be considered to be returned
* @param expectedNewValue Value that the record should have to be returned
* @return List of eligible objects, or an empy list
*/
public static List<SObject> findChangedRecsWithMatchingVal(List<SObject> items, Map<Id, SObject> oldItemsMap, Schema.SObjectField field, Object oldValue, Object expectedNewValue) {
List<SObject> changedObjects = new List<SObject>();
for(SObject obj : items) {
try {
Object newObjValue = obj.get(field);
Object oldObjValue = oldItemsMap.get((Id)obj.get('Id')).get(field);
if(oldObjValue == oldValue && newObjValue == expectedNewValue) {
changedObjects.add(obj);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return changedObjects;
}
/**
* Same as findChangedRecs, but check if changed values meet a specific new value
* @param items List of new records
* @param field The field to check
* @param expectedValue Return the record if the field's value equals this value
* @return List of records where the field == expectedValue
*/
public static List<SObject> findRecsWithMatchingValue(List<SObject> items, Schema.SObjectField field, Object expectedValue) {
List<SObject> matchedObjects = new List<SObject>();
for(SObject obj : items) {
try {
if(obj.get(field) == expectedValue) {
matchedObjects.add(obj);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return matchedObjects;
}
/**
* Same as findChangedRecs, but check if changed values meet a specific new value
* @param items List of new records
* @param field The field to check
* @param expectedValue Return the record if the field's value does not equals this value
* @return List of records where the field != expectedValue
*/
public static List<SObject> findRecsWithNonMatchingValue(List<SObject> items, Schema.SObjectField field, Object expectedValue) {
List<SObject> matchedObjects = new List<SObject>();
for(SObject obj : items) {
try {
if(obj.get(field) != expectedValue) {
matchedObjects.add(obj);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return matchedObjects;
}
public static Date getEarliestDate(List<SObject> items, Schema.SObjectField field) {
return getEarliestDate(items, field, Date.today());
}
public static Date getEarliestDate(List<SObject> items, Schema.SObjectField field, Date defaultIfNull) {
Date outputDate;
for(SObject obj : items) {
try {
if(outputDate == null || outputDate > (Date) obj.get(field)) {
outputDate = (Date)obj.get(field);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
outputDate = outputDate != null ? outputDate : defaultIfNull;
return outputDate;
}
public static Date getLatestDate(List<SObject> items, Schema.SObjectField field) {
return getLatestDate(items, field, Date.today());
}
public static Date getLatestDate(List<SObject> items, Schema.SObjectField field, Date defaultIfNull) {
Date outputDate;
for(SObject obj : items) {
try {
if(outputDate == null || outputDate < (Date) obj.get(field)) {
outputDate = (Date)obj.get(field);
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
outputDate = outputDate != null ? outputDate : defaultIfNull;
return outputDate;
}
/**
* Find a record where a date is between a start/end date on a given record
* This method is useful to find a record that exists within some defined range of another set of records
*
* @param items
* @param valueToCompare Date to compare against, usually obtained from a record
* @param startDateField Field containing a start date
* @param endDateField field containing an end date
* @param isInclusive [*optional] Defaults = true. IF true, this uses <= and >= instead of < and >
*
* @return
*/
public static SObject findRecWithDateBetween(List<SObject> items, Date valueToCompare, Schema.SObjectField startDateField, Schema.SObjectField endDateField) {
return findRecWithDateBetween(items, valueToCompare, startDateField, endDateField, true);
}
public static SObject findRecWithDateBetween(List<SObject> items, Date valueToCompare, Schema.SObjectField startDateField, Schema.SObjectField endDateField, Boolean isInclusive) {
for(SObject obj : items) {
try {
if(isInclusive) {
if(valueToCompare >= (Date) obj.get(startDateField) && valueToCompare <= (Date) obj.get(endDateField)) {
return obj;
}
} else {
if(valueToCompare > (Date) obj.get(startDateField) && valueToCompare < (Date) obj.get(endDateField)) {
return obj;
}
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return null;
}
/**
* For a given record, compare a decimal field to see if record is between a range of two fields
* Example: If you have an object with "Start" and "End" (as decimals), then you can pass in a number and find the record that matches that range
* This is useful when you are working with many records, so you need to perform multiple matches, thus cannot use SOQL to target just one record
*
* This method returns the first match, and not multiple matches. Ensure that your orders are sorted appropriately.
*
* @param items
* @param valueToCompare Date to compare against, usually obtained from a record
* @param startField Field containing a start decimal
* @param endField Field containing an end decimal
* @param startIsInclusive [*optional] Defaults = true. If true, this uses <= and >= instead of < and >
* @param endIsInclusive [*optional] Defaults = true. If true, this uses <= and >= instead of < and >
* @param allowNullEnd [*optional] Defaults = false. If true, this allows end field to be null
*
* @return
*/
public static SObject findRecWithDecimalBetween(List<SObject> items, Decimal valueToCompare, Schema.SObjectField startField, Schema.SObjectField endField) {
return findRecWithDecimalBetween(items, valueToCompare, startField, endField, true, true);
}
public static SObject findRecWithDecimalBetween(List<SObject> items, Decimal valueToCompare, Schema.SObjectField startField, Schema.SObjectField endField, Boolean startIsInclusive, Boolean endIsInclusive) {
return findRecWithDecimalBetween(items, valueToCompare, startField, endField, startIsInclusive, endIsInclusive, false);
}
public static SObject findRecWithDecimalBetween(List<SObject> items, Decimal valueToCompare, Schema.SObjectField startField, Schema.SObjectField endField, Boolean startIsInclusive, Boolean endIsInclusive, Boolean allowNullEnd) {
for(SObject obj : items) {
try {
if(startIsInclusive) {
if(endIsInclusive) {
if (allowNullEnd) {
if(valueToCompare >= (Decimal) obj.get(startField) && (obj.get(endField) == null || valueToCompare <= (Decimal) obj.get(endField))) {
return obj;
}
} else {
if(valueToCompare >= (Decimal) obj.get(startField) && (obj.get(endField) != null && valueToCompare <= (Decimal) obj.get(endField))) {
return obj;
}
}
} else {
if (allowNullEnd) {
if(valueToCompare >= (Decimal) obj.get(startField) && (obj.get(endField) == null || valueToCompare < (Decimal) obj.get(endField))) {
return obj;
}
} else {
if(valueToCompare >= (Decimal) obj.get(startField) && (obj.get(endField) != null && valueToCompare < (Decimal) obj.get(endField))) {
return obj;
}
}
}
} else {
if(endIsInclusive) {
if (allowNullEnd) {
if(valueToCompare > (Decimal) obj.get(startField) && (obj.get(endField) == null || valueToCompare <= (Decimal) obj.get(endField))) {
return obj;
}
} else {
if(valueToCompare > (Decimal) obj.get(startField) && (obj.get(endField) != null && valueToCompare <= (Decimal) obj.get(endField))) {
return obj;
}
}
} else {
if (allowNullEnd) {
if(valueToCompare > (Decimal) obj.get(startField) && (obj.get(endField) == null || valueToCompare < (Decimal) obj.get(endField))) {
return obj;
}
} else {
if(valueToCompare > (Decimal) obj.get(startField) && (obj.get(endField) != null && valueToCompare < (Decimal) obj.get(endField))) {
return obj;
}
}
}
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
return null;
}
/**
* For items that are "bundled" by a self-lookup (e.x. quote lines or order product lines), this method groups
* the quote lines by the parent most record id. Items will appear in the list in the order they are provided
* @param items List of records
* @param bundleField Field that has the self-lookup
* @return List of eligible objects, or an empy list
*/
public static Map<Id, List<SObject>> groupByTopLevelBundle(List<SObject> items, Schema.SObjectField bundleField) {
Map<Id, List<SObject>> output = new Map<Id, List<SObject>>();
Map<Id, Id> temp = new Map<Id, Id>();
// Get top level bundle and create map of records and their parents
for(SObject obj : items) {
try {
if(obj.get(bundleField) == null) {
output.put((Id) obj.get('Id'), new List<SObject>{obj});
} else {
temp.put((Id) obj.get('Id'), (Id) obj.get(bundleField));
}
} catch (Exception ex) {
System.debug('Error processing record, ignoring ' + ex.getStackTraceString());
}
}
for(SObject obj : items) {
// skip parent items
if(output.containsKey((Id) obj.get('Id'))) {
continue;
}
Boolean foundTopMostParent = false;
Id currParent = (Id) obj.get(bundleField);
// keep looking up through parent/grandparents until we find the topMost grandparent
while(!foundTopMostParent) {
if(output.containsKey(currParent)) {
output.get(currParent).add(obj);
foundTopMostParent = true;
} else {
currParent = temp.get((Id) obj.get(bundleField));
}
if(currParent == null) {
throw new NoParentRecordIncludedException('Parent record not included in dataset');
}
}
}
return output;
}
/**
* ********************** Query Helpers ********************
*/
/**
* Query records with a string SObject name
* @params objectName
* @params whereClause [*optional] Where clause (this must begin with "WHERE", e.x. "WHERE Type = \'Foo\'")
* This can be overloaded with any final part of the query (e.x. LIMIT, ORDER BY) that can be included with or without "WHERE"
*
*/
public static List<sObject> dynamicQuery(String objectName) {
return dynamicQuery(objectName, '');
}
public static List<sObject> dynamicQuery(String objectName, String whereClause) {
String soql = 'SELECT ' + getAllFieldsForSObjAsStr(objectName) + ' FROM ' + objectName + ' ' + whereClause;
return Database.query(soql);
}
/**
* Query records with a string SObject name
* @params parentObjName
* @params childObjName
* @params childObjRelationshipName
* @params whereClause [*optional] Where clause (this must begin with "WHERE", e.x. "WHERE Type = \'Foo\'")
* This can be overloaded with any final part of the query (e.x. LIMIT, ORDER BY) that can be included with or without "WHERE"
*
*/
public static List<sObject> dynamicQueryWithSubquery(String parentObjName, String childObjName, String childObjRelationshipName) {
return dynamicQueryWithSubquery(parentObjName, childObjName, childObjRelationshipName, '', '');
}
public static List<sObject> dynamicQueryWithSubquery(String parentObjName, String childObjName, String childObjRelationshipName, String parentWhereClause, String childWhereClause) {
String soql = 'SELECT ' + getAllFieldsForSObjAsStr(parentObjName) + ', (SELECT ' + getAllFieldsForSObjAsStr(childObjName) + ' FROM ' + childObjRelationshipName + ' ' + childWhereClause + ') FROM ' + parentObjName + ' ' + parentWhereClause;
return Database.query(soql);
}
/** Get all fields for an sobject as a list - helpful for dynamic SOQL */
public static List<String> getAllFieldsForSobj(String sobjectName) {
List<String> allFields = new List<String>(Schema.getGlobalDescribe().get(sobjectName).getDescribe().fields.getMap().keySet());
return allFields;
}
/**
* Method to return list of creatable fields for a given object.
* @param String objectName
* @return List of creatable fields for a given sObject.
*/
public static List<String> getCreatableFields(String sObjectName) { // Get a map of field name and field token
Map<String, Schema.SObjectField> fMap = Schema.getGlobalDescribe().get(sObjectName).getDescribe().Fields.getMap();
List<String> creatableFields = new List<String>();
if (fMap != null){
for (Schema.SObjectField ft : fMap.values()){ // loop through all field tokens (ft)
Schema.DescribeFieldResult fd = ft.getDescribe(); // describe each field (fd)
if (fd.isCreateable() && !(fd.isExternalId() && fd.isAutoNumber())){ // field is creatable
creatableFields.add(fd.getName());
}
}
}
return creatableFields;
}
/** Get all fields for an sobject as a list, except those in the blacklist */
public static List<String> getAllFieldsExceptBlacklist(String sobjectName, List<String> blackList) {
Set<string> fields = new Set<String>(getAllFieldsForSobj(sobjectName));
for(String blackListedField : blackList) {
if(fields.contains(blackListedField)) {
fields.remove(blackListedField);
} else if(fields.contains(blackListedField.toLowerCase())) {
fields.remove(blackListedField.toLowerCase());
}
}
return new List<String>(fields);
}
/** Get comma delimited string list of all sobject fields */
public static String getAllFieldsForSObjAsStr(String sobjectName) {
return String.join(getAllFieldsForSobj(sobjectName), ', ');
}
/** Get comma delimited string list of sobject fields, except those in the blacklist */
public static String getAllFieldsExceptBlacklistAsStr(String sobjectName, List<String> blackList) {
return String.join(getAllFieldsExceptBlacklist(sobjectName, blackList), ', ');
}
/** Get comma delimited string list of creatable sobject fields */
public static String getCreatableFieldsAsStr(String sobjectName) {
List<String> creatableFields = getCreatableFields(sobjectName);
return String.join(creatableFields, ', ');
}
/*
* randomizers
*/
public static String randomString(Integer length){
String key = EncodingUtil.base64encode(crypto.generateAesKey(192));
return key.substring(0,length);
}
/*
* Id validation
*/
static public String validateId(String Idparam) {
String id = String.escapeSingleQuotes(Idparam);
if((Idparam InstanceOf ID) && (id.length() == 15 || id.length() == 18) && Pattern.matches('^[a-zA-Z0-9]*$', id)) {
return id;
}
return null;
}
/**
* Get environment short name from url.
* @param none
* @return String of the environment name
* Example:
* URL structure: https://xyzcompany.my.salesforce.com
* This method will return 'xyzcompany'
*/
public static String getEnvironmentName() {
return getEnvironmentName(null);
}
/* Optionally pass in a string to detect in the subDomainWithProtocol name and truncate after that.
* Example:
* URL structure: https://xyzcompany--dev.cs77.my.salesforce.com
* Passing in '--' as the parameter will cause the method to return 'dev'
*/
public static String getEnvironmentName(String urlPortionToSnipAfter) {
// subDomainWithProtocol is set at the class level to allow mocking in unit test by setting specific URL
if(urlPortionToSnipAfter == null || !subDomainWithProtocol.contains(urlPortionToSnipAfter)) {
urlPortionToSnipAfter = 'https://';
}
Integer intIndex = subDomainWithProtocol.indexOf(urlPortionToSnipAfter) + urlPortionToSnipAfter.length();
String envName = subDomainWithProtocol.substring(intIndex, subDomainWithProtocol.length());
return envName;
}
/**
* Sort a string to list map by the number of items in each list ascending.
* Example param: We have a quote id to quote lines map, where each quote id is mapped to associated list of quote lines.
* quote id A (first quote id in the map) has 5 quote lines
* quote id B (second quote in id the map) has 3 quote lines
* quote id C (third quote id in the map) has 11 quote lines
*
* After sorting:
* quote id B (first quote id in the map, 3 quote lines)
* quote id A (second quote id in the map, 5 quote lines)
* quote id C (third quote id in the map, 11 quote lines)
*
* @param mapToSort -> map of: String to list of sobjects to sort.
* @return sortedMap -> a sorted map by the number of items in each of the key's lists ascending
*/
public static Map<String, List<sObject>> mapSorter(Map<String, List<sObject>> mapToSort) {
Map<String, List<sObject>> prepSortedMap = new Map<String, List<sObject>>();
for(String key : mapToSort.keySet()) {
List<sObject> childObjList = mapToSort.get(key) == null ? new List<sObject>() : mapToSort.get(key);
String numOfChildren = String.valueOf(childObjList.size());
String placeholderDigits = '00000';
String keyPrefix = placeholderDigits.substring(0, placeholderDigits.length() - numOfChildren.length()) + numOfChildren;
prepSortedMap.put(keyPrefix + '*' + key, childObjList);
}
List<String> sortedList = new List<String>(prepSortedMap.keyset());
sortedList.sort();
Map<String, List<sObject>> sortedMap = new Map<String, List<sObject>>();
for(String idKey : sortedList) {
Id idFromIdKey = Id.valueOf(idKey.substring(idKey.indexOf('*') + 1, idKey.length()));
sortedMap.put(idFromIdKey, prepSortedMap.get(idKey));
}
return sortedMap;
}
}
/**
*
* (c) Advanced Technology Group, 2019
* This code may be used and modified freely as long as this copyright attribution
* is included with the code.
*
*/
@isTest
private class ApexUtilsTest {
private static Integer sObjectIdCounter = 1;
public static String getFakeId(Schema.SobjectType sot) {
String result = String.valueOf(sObjectIdCounter++);
return sot.getDescribe().getKeyPrefix() + '0'.repeat(12 - result.length()) + result;
}
private static List<Account> createTestAccounts() {
List<Account> accounts = new List<Account>();
accounts.add(new Account(Name = '1'));
accounts.add(new Account(Name = '2'));
accounts.add(new Account(Name = '3'));
insert accounts;
return accounts;
}
private static Contact createTestContact(Account account) {
return new Contact(FirstName = account.Name, LastName = account.name, AccountId = account.Id);
}
private static Contact createTestContact(Account account, Boolean doInsert) {
Contact c = createTestContact(account);
if(doInsert) {
insert c;
}
return c;
}
private static List<Contact> createTestContacts(List<Account> accounts) {
List<Contact> contacts = new List<Contact>();
for(Account account : accounts) {
contacts.add(createTestContact(account));
}
insert contacts;
return contacts;
}
// NOTE: this requires some record types to exist in org, can adjust unit test in client org
@isTest static void getRecordTypeByDeveloperName() {
RecordType recordType = ApexUtils.getRecordTypeByDeveloperName('SomeRecTypeDevName');
System.assertEquals(null, recordType);
// System.assertNotEquals(null, recordType);
}
@isTest static void pluckIdSet() {
List<Account> accounts = createTestAccounts();
Set<Id> accountIds = ApexUtils.pluckIdSet(accounts, Schema.Account.Id);
System.assertEquals(3, accountIds.size());
}
@isTest static void pluckIdSetNonMatching() {
List<Account> accounts = createTestAccounts();
Set<Id> accountIds = ApexUtils.pluckIdSet(accounts, Schema.Contact.Name);
System.assertEquals(0, accountIds.size());
}
@isTest static void pluckIdSetRelationship() {
List<Contact> contacts = new List<Contact>{
new Contact(LastName = '1', Account = new Account(Name = 'Account 1', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-1'))),
new Contact(LastName = '2', Account = new Account(Name = 'Account 2', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-2'))),
new Contact(LastName = '3', Account = new Account(Name = 'Account 3', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-3'))),
new Contact(LastName = '4', Account = new Account(Name = 'Account 4', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-4'))),
new Contact(LastName = '5', Account = new Account(Name = 'Account 4')),
new Contact(LastName = '5')
};
Set<Id> userIds = ApexUtils.pluckIdSet(contacts, 'Account.Parent.Id');
System.assertEquals(4, userIds.size());
// System.assertEquals(contacts[0].Account.Parent.Id, userIds[0]);
// System.assertEquals(contacts[1].Account.Parent.Id, userIds[1]);
// System.assertEquals(contacts[2].Account.Parent.Id, userIds[2]);
// System.assertEquals(contacts[3].Account.Parent.Id, userIds[3]);
}
@isTest static void pluckStringSet() {
List<Account> accounts = createTestAccounts();
Set<String> accountIds = ApexUtils.pluckStringSet(accounts, Schema.Account.Name);
System.assertEquals(3, accountIds.size());
System.assert(accountIds.contains('1'));
System.assert(accountIds.contains('2'));
System.assert(accountIds.contains('3'));
}
@isTest static void pluckStringSetRelationship() {
List<Contact> contacts = new List<Contact>{
new Contact(LastName = '1', Account = new Account(Name = 'Account 1', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-1'))),
new Contact(LastName = '2', Account = new Account(Name = 'Account 2', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-2'))),
new Contact(LastName = '3', Account = new Account(Name = 'Account 3', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-3'))),
new Contact(LastName = '4', Account = new Account(Name = 'Account 4', Parent = new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), Name = 'parent-account-4'))),
new Contact(LastName = '5', Account = new Account(Name = 'Account 4')),
new Contact(LastName = '5')
};
Set<String> userNames = ApexUtils.pluckStringSet(contacts, 'Account.Parent.Name');
System.assertEquals(4, userNames.size());
// System.assertEquals(userNames[0].Account.Parent.Name, userNames[0]);
// System.assertEquals(userNames[1].Account.Parent.Name, userNames[1]);
// System.assertEquals(userNames[2].Account.Parent.Name, userNames[2]);
// System.assertEquals(userNames[3].Account.Parent.Name, userNames[3]);
}
@isTest static void pluckMap() {
List<Account> accounts = createTestAccounts();
Map<String, String> accountMap = ApexUtils.pluckMap(accounts, Schema.Account.Id, Schema.Account.Name);
System.assertEquals(accounts[0].Name, accountMap.get(accounts[0].Id));
System.assertEquals(accounts[1].Name, accountMap.get(accounts[1].Id));
System.assertEquals(accounts[2].Name, accountMap.get(accounts[2].Id));
}
@isTest static void groupBy() {
List<Account> accounts = createTestAccounts();
accounts[0].Type = 'Franchise';
accounts[1].Type = 'Franchise';
accounts[2].Type = 'Client';
Map<String, List<SObject>> accountMap = ApexUtils.groupBy(accounts, Schema.Account.Type);
System.assertEquals(2, accountMap.get('Franchise').size());
System.assertEquals(1, accountMap.get('Client').size());
}
@isTest static void groupByString() {
List<Account> accounts = createTestAccounts();
accounts[0].Parent = new Account();
accounts[0].Parent.Owner = new User(FirstName = 'Bob');
accounts[1].Parent = new Account();
accounts[1].Parent.Owner = new User(FirstName = 'Bob');
accounts[2].Parent = new Account();
accounts[2].Parent.Owner = new User(FirstName = 'Sally');
Map<String, List<SObject>> accountMap = ApexUtils.groupBy(accounts, 'Parent.Owner.FirstName');
System.assertEquals(2, accountMap.get('Bob').size());
System.assertEquals(1, accountMap.get('Sally').size());
}
@isTest static void groupByStringMultiLevel() {
List<Account> accounts = createTestAccounts();
accounts[0].Parent = new Account();
accounts[0].Parent.Owner = new User(FirstName = 'Bob');
accounts[1].Parent = new Account();
accounts[1].Parent.Owner = new User(FirstName = 'Bob');
accounts[2].Parent = new Account();
accounts[2].Parent.Owner = new User(FirstName = 'Sally');
Map<String, List<SObject>> accountMap = ApexUtils.groupBy(accounts, 'Parent.Owner.FirstName');
System.assertEquals(2, accountMap.get('Bob').size());
System.assertEquals(1, accountMap.get('Sally').size());
}
@isTest static void pluckMapAny() {
List<Account> accounts = createTestAccounts();
Map<String, Object> accountMap = ApexUtils.pluckMapAny(accounts, Schema.Account.Id, Schema.Account.Name);
System.assertEquals(accounts[0].Name, accountMap.get(accounts[0].Id));
System.assertEquals(accounts[1].Name, accountMap.get(accounts[1].Id));
System.assertEquals(accounts[2].Name, accountMap.get(accounts[2].Id));
}
@isTest static void pluckMapAny2() {
List<Account> accounts = createTestAccounts();
accounts[0].Type = 'Client';
accounts[1].Type = 'Partner';
accounts[2].Type = 'Franchise';
Map<String, SObject> accountMap = ApexUtils.groupByFlat(accounts, Schema.Account.Type);
System.assertEquals(accounts[0], accountMap.get('Client'));
System.assertEquals(accounts[1], accountMap.get('Partner'));
System.assertEquals(accounts[2], accountMap.get('Franchise'));
}
@isTest static void findChangedRecs() {
List<Account> accounts = createTestAccounts();
Map<Id, Account> accountMap = new Map<Id, Account>([SELECT Id, Name FROM Account]);
accounts[0].Name = 'CHANGED1';
accounts[2].Name = 'CHANGED3';
List<SObject> changedAccounts = ApexUtils.findChangedRecs(accounts, accountMap, Schema.Account.Name);
System.assertEquals(2, changedAccounts.size());
System.assertEquals(accounts[0], changedAccounts[0]);
System.assertEquals(accounts[2], changedAccounts[1]);
}
@isTest static void findChangedRecsWithMatchingVal() {
List<Account> accounts = createTestAccounts();
Map<Id, Account> accountMap = new Map<Id, Account>([SELECT Id, Name FROM Account]);
accounts[0].Name = 'CHANGED1';
accounts[2].Name = 'CHANGED3';
List<SObject> changedAccounts = ApexUtils.findChangedRecsWithMatchingVal(accounts, accountMap, Schema.Account.Name, 'CHANGED1');
System.assertEquals(1, changedAccounts.size());
System.assertEquals(accounts[0], changedAccounts[0]);
}
@isTest static void findChangedRecsWithMatchingVal2() {
List<Account> accounts = createTestAccounts();
Map<Id, Account> accountMap = new Map<Id, Account>([SELECT Id, Name FROM Account]);
accounts[0].Name = 'CHANGED1';
accounts[2].Name = 'CHANGED1';
List<SObject> changedAccounts = ApexUtils.findChangedRecsWithMatchingVal(accounts, accountMap, Schema.Account.Name, '1', 'CHANGED1');
System.assertEquals(1, changedAccounts.size());
System.assertEquals(accounts[0], changedAccounts[0]);
}
@isTest static void findRecsWithMatchingValue() {
List<Account> accounts = createTestAccounts();
accounts[0].Type = 'Franchise';
accounts[1].Type = 'Franchise';
accounts[2].Type = 'Client';
List<SObject> foundObjects1 = ApexUtils.findRecsWithMatchingValue(accounts, Schema.Account.Type, 'Franchise');
List<SObject> foundObjects2 = ApexUtils.findRecsWithMatchingValue(accounts, Schema.Account.Type, 'Client');
List<SObject> foundObjects3 = ApexUtils.findRecsWithMatchingValue(accounts, Schema.Account.Type, 'foo-bar');
System.assertEquals(accounts[0], foundObjects1[0]);
System.assertEquals(accounts[1], foundObjects1[1]);
System.assertEquals(2, foundObjects1.size());
System.assertEquals(1, foundObjects2.size());
System.assertEquals(0, foundObjects3.size());
}
@isTest static void findRecsWithNonMatchingValue() {
List<Account> accounts = createTestAccounts();
accounts[0].Type = 'Franchise';
accounts[1].Type = 'Franchise';
accounts[2].Type = 'Client';
List<SObject> foundObjects1 = ApexUtils.findRecsWithNonMatchingValue(accounts, Schema.Account.Type, 'Franchise');
List<SObject> foundObjects2 = ApexUtils.findRecsWithNonMatchingValue(accounts, Schema.Account.Type, 'Client');
List<SObject> foundObjects3 = ApexUtils.findRecsWithNonMatchingValue(accounts, Schema.Account.Type, 'foo-bar');
System.assertEquals(accounts[2], foundObjects1[0]);
System.assertEquals(accounts[0], foundObjects2[0]);
System.assertEquals(accounts[1], foundObjects2[1]);
System.assertEquals(1, foundObjects1.size());
System.assertEquals(2, foundObjects2.size());
System.assertEquals(3, foundObjects3.size());
}
@isTest static void getEarliestAndLatestDate() {
List<Contact> contacts = new List<Contact>{
new Contact(Birthdate = Date.today().addDays(10)),
new Contact(Birthdate = Date.today().addDays(-10)),
new Contact(Birthdate = Date.today().addDays(20))
};
Date earliestDate = ApexUtils.getEarliestDate(contacts, Schema.Contact.Birthdate);
Date latestDate = ApexUtils.getLatestDate(contacts, Schema.Contact.Birthdate);
System.assertEquals(contacts[1].Birthdate, earliestDate);
System.assertEquals(contacts[2].Birthdate, latestDate);
}
@isTest static void findRecWithDateBetween() {
List<Asset> assets = new List<Asset>{
new Asset(Id = ApexUtilsTest.getFakeId(Asset.getSObjectType()), InstallDate = Date.today().addDays(0), UsageEndDate = Date.Today().addDays(29)), // 0
new Asset(Id = ApexUtilsTest.getFakeId(Asset.getSObjectType()), InstallDate = Date.today().addDays(30), UsageEndDate = Date.Today().addDays(39)), // 1
new Asset(Id = ApexUtilsTest.getFakeId(Asset.getSObjectType()), InstallDate = Date.today().addDays(40), UsageEndDate = Date.Today().addDays(49)), // 2
new Asset(Id = ApexUtilsTest.getFakeId(Asset.getSObjectType()), InstallDate = Date.today().addDays(50), UsageEndDate = Date.Today().addDays(59)), // 3
new Asset(Id = ApexUtilsTest.getFakeId(Asset.getSObjectType()), InstallDate = Date.today().addDays(60), UsageEndDate = Date.Today().addDays(69)), // 4
new Asset(Id = ApexUtilsTest.getFakeId(Asset.getSObjectType()), InstallDate = Date.today().addDays(70), UsageEndDate = Date.Today().addDays(79)) // 5
};
System.assertEquals(assets[0], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(0), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[0], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(29), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[1], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(30), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[1], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(35), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[2], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(45), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[3], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(55), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[4], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(60), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[4], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(69), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(assets[5], ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(75), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(null, ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(-1), Asset.InstallDate, Asset.UsageEndDate));
System.assertEquals(null, ApexUtils.findRecWithDateBetween(assets, Date.today(), Asset.InstallDate, Asset.UsageEndDate, false));
System.assertEquals(null, ApexUtils.findRecWithDateBetween(assets, Date.today().addDays(29), Asset.InstallDate, Asset.UsageEndDate, false));
}
@isTest static void findRecWithDecimalBetween() {
List<Account> accounts = new List<Account>{
new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), BillingLatitude = 1, BillingLongitude = 15),
new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), BillingLatitude = 16, BillingLongitude = 30),
new Account(Id = ApexUtilsTest.getFakeId(Account.getSObjectType()), BillingLatitude = 31, BillingLongitude = null)
};
// startIsInclusive = true
// endIsInclusive = true
System.assertEquals(accounts[0], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 1, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude));
System.assertEquals(accounts[0], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 1, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true));
System.assertEquals(accounts[0], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 15, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true));
System.assertEquals(accounts[1], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 16, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true));
System.assertEquals(accounts[1], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 30, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 99, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true));
// startIsInclusive = true
// endIsInclusive = false
System.assertEquals(accounts[0], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 1, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, false));
System.assertEquals(accounts[1], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 16, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, false));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 15, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, false));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 30, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, false));
// startIsInclusive = false
// endIsInclusive = true
System.assertEquals(accounts[0], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 2, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, true));
System.assertEquals(accounts[1], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 17, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, true));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 1, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, true));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 16, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, true));
// startIsInclusive = false
// endIsInclusive = false
System.assertEquals(accounts[0], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 10, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false));
System.assertEquals(accounts[1], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 20, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 1, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 15, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 16, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false));
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 30, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false));
// includeEndIsNullRule = true
// ( startIsInclusive and endIsInclusive defaults to true )
System.assertEquals(accounts[2], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 45, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, false, true));
System.assertEquals(accounts[2], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 45, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, false, true, true));
System.assertEquals(accounts[2], (Account) ApexUtils.findRecWithDecimalBetween(accounts, 45, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true, true));
// includeEndIsNullRule = false
// ( startIsInclusive and endIsInclusive defaults to true )
System.assertEquals(null, (Account) ApexUtils.findRecWithDecimalBetween(accounts, 45, Schema.Account.BillingLatitude, Schema.Account.BillingLongitude, true, true, false));
}
@isTest static void groupByTopLevelBundle() {
List<Account> accounts = new List<Account>{
new Account(Id = getFakeId(Account.getSObjectType())), // 0
new Account(Id = getFakeId(Account.getSObjectType())), // 1
new Account(Id = getFakeId(Account.getSObjectType())), // 2
new Account(Id = getFakeId(Account.getSObjectType())), // 3
new Account(Id = getFakeId(Account.getSObjectType())), // 4
new Account(Id = getFakeId(Account.getSObjectType())) // 5
};
// line 1 is a child of line 0
accounts[1].ParentId = accounts[0].Id;
// line 2 is a child of line 0
accounts[2].ParentId = accounts[0].Id;
// line 3 is a child of line 2
accounts[3].ParentId = accounts[2].Id;
// line 4 is a child of line 5
accounts[4].ParentId = accounts[5].Id;
Map<Id, List<Account>> bundleByTopLevelRecord = (Map<Id, List<Account>>) ApexUtils.groupByTopLevelBundle(accounts, Schema.Account.ParentId);
System.debug('bundleByTopLevelRecord: ' + bundleByTopLevelRecord);
// Example log output
// bundleByTopLevelRecord: {
// 802000000000001AAA=(
// Account:{Id=802000000000001AAA},
// Account:{Id=802000000000002AAA, ParentId=802000000000001AAA},
// Account:{Id=802000000000003AAA, ParentId=802000000000001AAA},
// Account:{Id=802000000000004AAA, ParentId=802000000000003AAA}
// ),
// 802000000000006AAA=(
// ParentId:{Id=802000000000006AAA},
// ParentId:{Id=802000000000005AAA, ParentId=802000000000006AAA}
// )
// }
System.assert(bundleByTopLevelRecord.containsKey(accounts[0].Id));
System.assertEquals(accounts[0], bundleByTopLevelRecord.get(accounts[0].Id)[0]);
System.assertEquals(accounts[1], bundleByTopLevelRecord.get(accounts[0].Id)[1]);
System.assertEquals(accounts[2], bundleByTopLevelRecord.get(accounts[0].Id)[2]);
System.assertEquals(accounts[3], bundleByTopLevelRecord.get(accounts[0].Id)[3]);
System.assert(bundleByTopLevelRecord.containsKey(accounts[5].Id));
System.assertEquals(accounts[5], bundleByTopLevelRecord.get(accounts[5].Id)[0]);
System.assertEquals(accounts[4], bundleByTopLevelRecord.get(accounts[5].Id)[1]);
}
@isTest static void groupByTopLevelBundleWhenNoParent() {
List<Account> accounts = new List<Account>{
new Account(Id = getFakeId(Account.getSObjectType())), // 0
new Account(Id = getFakeId(Account.getSObjectType())), // 1
new Account(Id = getFakeId(Account.getSObjectType())), // 2
new Account(Id = getFakeId(Account.getSObjectType())), // 3
new Account(Id = getFakeId(Account.getSObjectType())), // 4
new Account(Id = getFakeId(Account.getSObjectType())) // 5
};
// line 1 has a parent not included in the dataset - this makes sure that there is not an infinite loop
accounts[1].ParentId = ApexUtilsTest.getFakeId(Account.getSObjectType());
// line 2 is a child of line 0
accounts[2].ParentId = accounts[0].Id;
// line 3 is a child of line 2
accounts[3].ParentId = accounts[2].Id;
// line 4 is a child of line 5
accounts[4].ParentId = accounts[5].Id;
try {
Map<Id, List<Account>> bundleByTopLevelRecord = (Map<Id, List<Account>>) ApexUtils.groupByTopLevelBundle(accounts, Schema.Account.ParentId);
} catch(ApexUtils.NoParentRecordIncludedException ex) {
System.assert(true);
} catch(Exception ex) {
System.assert(false, 'Exception ApexUtils.NoParentRecordIncludedException was expected, received ' + ex);
}
}
@isTest static void testDynamicQueryMethods() {
List<Account> existingAccounts = createTestAccounts();
List<Contact> existingContacts = createTestContacts(existingAccounts);
System.assertEquals(existingAccounts.size(), ApexUtils.dynamicQuery('Account').size());
System.assertEquals(existingContacts.size(), ApexUtils.dynamicQuery('Contact').size());
List<Account> accounts = (List<Account>) ApexUtils.dynamicQueryWithSubquery('Account', 'Contact', 'Contacts');
System.assertEquals(existingAccounts.size(), accounts.size());
System.assertEquals(1, accounts[0].Contacts.size());
}
@isTest static void testGetFieldsMethods() {
System.assert(ApexUtils.getAllFieldsForSobj('Account').size() > 0);
System.assert(ApexUtils.getCreatableFields('Account').size() > 0);
System.assertEquals(false, ApexUtils.getCreatableFields('Account').contains('CreatedById'));
System.assertEquals(true, ApexUtils.getCreatableFields('Account').contains('Name'));
System.assertEquals(false, ApexUtils.getAllFieldsExceptBlacklist('Account', new List<String>{'Name'}).contains('name'));
System.assertEquals(true, ApexUtils.getAllFieldsExceptBlacklist('Account', new List<String>{'Name'}).contains('type'));
System.assert(ApexUtils.getAllFieldsForSObjAsStr('Account').length() > 0);
System.assertEquals(false, ApexUtils.getAllFieldsExceptBlacklistAsStr('Account', new List<String>{'Name'}).contains('name,'));
System.assertEquals(true, ApexUtils.getAllFieldsExceptBlacklistAsStr('Account', new List<String>{'Name'}).contains('type,'));
System.assert(ApexUtils.getCreatableFieldsAsStr('Account').length() > 0);
System.assertEquals(true, ApexUtils.getCreatableFieldsAsStr('Account').contains('Name'));
System.assertEquals(false, ApexUtils.getCreatableFieldsAsStr('Account').contains('CreatedById'));
}
@isTest static void randomString() {
System.assertNotEquals(null, ApexUtils.randomString(15));
System.assertEquals(15, ApexUtils.randomString(15).length());
}
@isTest static void validateId() {
System.assertEquals('0011800000dSq2KAAS', ApexUtils.validateId('0011800000dSq2KAAS'));
System.assertEquals('0011800000dSq2K', ApexUtils.validateId('0011800000dSq2K'));
System.assertEquals(null, ApexUtils.validateId('0011800000dSq2KAA#'));
System.assertEquals(null, ApexUtils.validateId('0011800000dSq2#'));
System.assertEquals(null, ApexUtils.validateId('!!!FOO!!!'));
}
@isTest static void getEnvironmentName() {
String subDomainWithProtocol1 = 'https://foo--sfbill';
String subDomainWithProtocol2 = 'https://foo-steelbrick-dev-ed';
ApexUtils.subDomainWithProtocol = subDomainWithProtocol1;
System.assertEquals('foo--sfbill', ApexUtils.getEnvironmentName());
System.assertEquals('sfbill', ApexUtils.getEnvironmentName('--'));
ApexUtils.subDomainWithProtocol = subDomainWithProtocol2;
System.assertEquals('foo-steelbrick-dev-ed', ApexUtils.getEnvironmentName());
}
@isTest static void mapSorter() {
List<Account> acctList = createTestAccounts();
List<Contact> contactList = new List<Contact>();
contactList.add(createTestContact(acctList[0]));
contactList.add(createTestContact(acctList[0]));
contactList.add(createTestContact(acctList[0]));
contactList.add(createTestContact(acctList[2]));
contactList.add(createTestContact(acctList[2]));
contactList.add(createTestContact(acctList[1]));
insert contactList;
Schema.sObjectField contactField = Contact.AccountId.getDescribe().getSObjectField();
Map<String, List<sObject>> accountIdToContactsMap = ApexUtils.groupBy(contactList, contactField);
accountIdToContactsMap = ApexUtils.mapSorter(accountIdToContactsMap); // sort accounts by number of contacts ascending
List<Integer> numOfContactsList = new List<Integer>();
for(String accountId : accountIdToContactsMap.keySet()) {
Integer numOfContacts = accountIdToContactsMap.get(accountId).size();
numOfContactsList.add(numOfContacts);
}
// Verify the map got sorted by number of contacts ascending:
System.assertEquals(numOfContactsList[0], 1);
System.assertEquals(numOfContactsList[1], 2);
System.assertEquals(numOfContactsList[2], 3);
}
}
/**
*
* Data models for interacting with Salesforce CPQ API
* https://developer.salesforce.com/docs/atlas.en-us.cpq_dev_api.meta/cpq_dev_api/cpq_api_get_started.htm
*
*/
public without sharing class CPQ_ApiDataModels {
/** INPUT PAYLOADS */
public class RenewalContext {
public Id masterContractId;
public Contract[] renewedContracts;
}
public class ProductLoadContext {
public Id pricebookId;
public String currencyCode;
public ProductLoadContext(){}
public ProductLoadContext(Id pricebookId, String currencyCode) {
this.pricebookId = pricebookId;
this.currencyCode = currencyCode;
}
}
public class SearchContext {
public String format;
public QuoteModel quote;
public SBQQ__SearchFilter__c[] filters;
}
public class SuggestContext {
public String format;
public QuoteModel quote;
public SBQQ__QuoteProcess__c process;
}
public class ProductAddContext {
public Boolean ignoreCalculate;
public QuoteModel quote;
public ProductModel[] products;
public Integer groupKey;
public ProductAddContext(){
products = new List<ProductModel>();
}
public ProductAddContext(QuoteModel quote, ProductModel[] products){
this(false, quote, products, null);
}
public ProductAddContext(Boolean ignoreCalculate, QuoteModel quote, ProductModel[] products) {
this(ignoreCalculate, quote, products, null);
}
public ProductAddContext(Boolean ignoreCalculate, QuoteModel quote, ProductModel[] products, Integer groupKey){
this.ignoreCalculate = ignoreCalculate;
this.quote = quote;
this.products = products;
this.groupKey = groupKey;
}
}
public class CalculatorContext {
public QuoteModel quote;
public CalculatorContext(){}
public CalculatorContext(QuoteModel quote) {
this.quote = quote;
}
}
public class ConfigLoadContext {
public TinyQuoteModel quote;
public TinyProductModel parentProduct; // Only required if the configuration must inherit Configuration Attribute values from its parent.
}
public class LoadRuleRunnerContext {
public TinyQuoteModel quote;
public String[] dynamicOptionSkus;
public TinyConfigurationModel configuration;
public TinyProductModel parentProduct; // Only required if the configuration must inherit Configuration Attributes from the parent.
}
public class ValidationContext {
public TinyQuoteModel quote;
public TinyConfigurationModel configuration;
public Id upgradedAssetId;
public String event;
}
/** DATA MODELS */
public without sharing class ProductModel {
/**
* The record that this product model represents.
*/
public Product2 record {get; private set;}
/**
* Provides a source for SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c
*/
public Id upgradedAssetId {get; set;}
/**
* The symbol for the currency in use
*/
public String currencySymbol {get; private set;}
/**
* The ISO code for the currency in use
*/
public String currencyCode {get; private set;}
/**
* Allows for Product Features to be sorted by category
*/
public String[] featureCategories {get; private set;}
/**
* A list of all available options on this product
*/
public OptionModel[] options {get; private set;}
/**
* All features present on this product
*/
public FeatureModel[] features {get; private set;}
/**
* An object representing this product's current configuration
*/
public ConfigurationModel configuration {get; private set;}
/**
* A list of all configuration attributes available on this product
*/
public ConfigAttributeModel[] configurationAttributes {get; private set;}
/**
* A list of all configuration attributes this product inherits from ancestor products
*/
public ConfigAttributeModel[] inheritedConfigurationAttributes {get; private set;}
/**
* Constraints on this product
*/
public ConstraintModel[] constraints;
}
public class ConstraintModel {
public SBQQ__OptionConstraint__c record;
public Boolean priorOptionExists;
}
public class OptionModel {
public SBQQ__ProductOption__c record;
public Map<String,String> externalConfigurationData;
public Boolean configurable;
public Boolean configurationRequired;
public Boolean quantityEditable;
public Boolean priceEditable;
public Decimal productQuantityScale;
public Boolean priorOptionExists;
public Set<Id> dependentIds;
public Map<String,Set<Id>> controllingGroups;
public Map<String,Set<Id>> exclusionGroups;
public String reconfigureDimensionWarning;
public Boolean hasDimension;
public Boolean isUpgrade;
public String dynamicOptionKey;
}
public class ConfigAttributeModel {
public String name;
public String targetFieldName; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__TargetField__c
public Decimal displayOrder; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__DisplayOrder__c
public String columnOrder; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ColumnOrder__c
public Boolean required; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Required__c
public Id featureId; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Feature__c
public String position; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Position__c
public Boolean appliedImmediately; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AppliedImmediately__c
public Boolean applyToProductOptions; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ApplyToProductOptions__c
public Boolean autoSelect; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AutoSelect__c
public String[] shownValues; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ShownValues__c
public String[] hiddenValues; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__HiddenValues__c
public Boolean hidden; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Hidden__c
public String noSuchFieldName; // If no field with the target name exists, the target name is stored here.
public Id myId; // Corresponds directly to SBQQ__ConfigurationAttribute__c.Id
}
public class FeatureModel {
public SBQQ__ProductFeature__c record;
public String instructionsText;
public Boolean containsUpgrades;
}
public class ConfigurationModel {
public Id configuredProductId;
public Id optionId;
public SBQQ__ProductOption__c optionData; // Editable data about the option in question, such as quantity or discount
public SBQQ__ProductOption__c configurationData;
public SBQQ__ProductOption__c inheritedConfigurationData;
public ConfigurationModel[] optionConfigurations;
public Boolean configured;
public Boolean configurationEntered;
public Boolean changedByProductActions;
public Boolean isDynamicOption;
public Boolean isUpgrade;
public Set<Id> disabledOptionIds;
public Set<Id> hiddenOptionIds;
public Decimal listPrice;
public Boolean priceEditable;
public String[] validationMessages;
public String dynamicOptionKey;
}
public without sharing class QuoteModel {
/**
* The record represented by this model
*/
public SBQQ__Quote__c record;
/**
* The lines contained in this quote
*/
public QuoteLineModel[] lineItems;
/**
* The groups contained in this quote
*/
public QuoteLineGroupModel[] lineItemGroups;
/**
* The next key that will be used for new groups or lines.
* To ensure uniqueness of keys, this value should never be changed to a lower value.
*/
public Integer nextKey;
/**
* Corresponds to the 'magic field', SBQQ__Quote__c.ApplyAdditionalDiscountLast__c
*/
public Boolean applyAdditionalDiscountLast;
/**
* Corresponds to the 'magic field', SBQQ__Quote__c.ApplyPartnerDiscountFirst__c
*/
public Boolean applyPartnerDiscountFirst;
/**
* Corresponds to the 'magic field', SBQQ__Quote__c.ChannelDiscountsOffList__c
*/
public Boolean channelDiscountsOffList;
/**
* SBQQ__Quote__c.SBQQ__CustomerAmount__c is a Roll-up Summary Field, so its accuracy can only be guaranteed
* after a quote is persisted. As such, its current value is stored here until then.
*/
public Decimal customerTotal;
/**
* SBQQ__Quote__c.SBQQ__NetAmount__c is a Roll-up Summary Field, so its accuracy can only be guaranteed
* after a quote is persisted. As such, its current value is stored here until then.
*/
public Decimal netTotal;
/**
* The Net Total for all non-multidimensional quote lines.
*/
public Decimal netNonSegmentTotal;
public Boolean calculationRequired;
}
public without sharing class QuoteLineModel {
/**
* The record represented by this model.
*/
public SBQQ__QuoteLine__c record;
/**
* Corresponds to the 'magic field', SBQQ__QuoteLine__c.ProrateAmountDiscount__c.
*/
public Boolean amountDiscountProrated;
/**
* The unique key of this line's group, if this line is part of a grouped quote.
*/
public Integer parentGroupKey;
/**
* The unique key of this line's parent, if this line is part of a bundle.
*/
public Integer parentItemKey;
/**
* Each quote line and group has a key that is unique against all other keys on the same quote.
*/
public Integer key;
/**
* True if this line is an MDQ segment that can be uplifted from a previous segment.
*/
public Boolean upliftable;
/**
* Indicates the configuration type of the product this line represents.
*/
public String configurationType;
/**
* Indicates the configuration event of the product this line represents.
*/
public String configurationEvent;
/**
* If true, this line cannot be reconfigured.
*/
public Boolean reconfigurationDisabled;
/**
* If true, this line's description cannot be changed.
*/
public Boolean descriptionLocked;
/**
* If true, this line's quantity cannot be changed.
*/
public Boolean productQuantityEditable;
/**
* The number of decimal places to which this line's quantity shall be rounded.
*/
public Decimal productQuantityScale;
/**
* The type of MDQ dimension this line represents.
*/
public String dimensionType;
/**
* If true, the underlying product can be represented as a Multi-dimensional line.
*/
public Boolean productHasDimensions;
/**
* The unit price towards which this quote line will be discounted.
*/
public Decimal targetCustomerAmount;
/**
* The customer amount towards which this quote line will be discounted.
*/
public Decimal targetCustomerTotal;
/**
* The net total towards which this quote line will be discounted.
*/
}
public without sharing class QuoteLineGroupModel {
/**
* The record represented by this model.
*/
public SBQQ__QuoteLineGroup__c record;
/**
* The Net Total for all non-multidimensional quote lines.
*/
public Decimal netNonSegmentTotal;
/**
* Each quote line and group has a key that is unique against all other keys on the same quote.
*/
public Integer key;
}
// ============ TINY MODEL CLASSES =========
// Use these with config API's
// These are referenced in the docs here: https://community.steelbrick.com/t5/Developer-Guidebook/Public-API-Technical-Documentation-amp-Code-2/ta-p/5691
// They should probably be refactored to use full models (even if some values are null) instead of keeping multiple versions
public class TinyProductModel {
public Product2 record;
public String currencyCode;
public TinyOptionModel[] options;
public TinyFeatureModel[] features;
public TinyConfigurationModel configuration;
public TinyConfigAttributeModel[] configurationAttributes;
public TinyConfigAttributeModel[] inheritedConfigurationAttributes;
public TinyConstraintModel[] constraints;
}
public class TinyConstraintModel {
public SBQQ__OptionConstraint__c record;
public Boolean priorOptionExists;
}
public class TinyOptionModel {
public SBQQ__ProductOption__c record;
public Map<String,String> externalConfigurationData;
public Boolean configurable;
public Boolean configurationRequired;
public Boolean quantityEditable;
public Boolean priceEditable;
public Decimal productQuantityScale;
public Boolean priorOptionExists;
public Set<Id> dependentIds;
public Map<String,Set<Id>> controllingGroups;
public Map<String,Set<Id>> exclusionGroups;
public String reconfigureDimensionWarning;
public Boolean hasDimension;
public Boolean isUpgrade;
public String dynamicOptionKey;
}
public class TinyConfigAttributeModel {
public String name;
public String targetFieldName; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__TargetField__c
public Decimal displayOrder; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__DisplayOrder__c
public String columnOrder; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ColumnOrder__c
public Boolean required; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Required__c
public Id featureId; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Feature__c
public String position; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Position__c
public Boolean appliedImmediately; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AppliedImmediately__c
public Boolean applyToProductOptions; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ApplyToProductOptions__c
public Boolean autoSelect; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AutoSelect__c
public String[] shownValues; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ShownValues__c
public String[] hiddenValues; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__HiddenValues__c
public Boolean hidden; // Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Hidden__c
public String noSuchFieldName; // If no field with the target name exists, the target name is stored here.
public Id myId; // Corresponds directly to SBQQ__ConfigurationAttribute__c.Id
}
public class TinyFeatureModel {
public SBQQ__ProductFeature__c record;
public String instructionsText;
public Boolean containsUpgrades;
}
public class TinyConfigurationModel {
public Id configuredProductId;
public Id optionId;
public SBQQ__ProductOption__c optionData; // Editable data about the option in question, such as quantity or discount
public SBQQ__ProductOption__c configurationData;
public SBQQ__ProductOption__c inheritedConfigurationData;
public TinyConfigurationModel[] optionConfigurations;
public Boolean configured;
public Boolean changedByProductActions;
public Boolean isDynamicOption;
public Boolean isUpgrade;
public Set<Id> disabledOptionIds;
public Set<Id> hiddenOptionIds;
public Decimal listPrice;
public Boolean priceEditable;
public String[] validationMessages;
public String dynamicOptionKey;
}
public class TinyQuoteModel {
public SBQQ__Quote__c record;
public TinyQuoteLineModel[] lineItems;
public TinyQuoteLineGroupModel[] lineItemGroups;
public Integer nextKey;
public Boolean applyAdditionalDiscountLast;
public Boolean applyPartnerDiscountFirst;
public Boolean channelDiscountsOffList;
public Decimal customerTotal;
public Decimal netTotal;
public Decimal netNonSegmentTotal;
}
public class TinyQuoteLineModel {
public SBQQ__QuoteLine__c record;
public Decimal renewalPrice;
public Boolean amountDiscountProrated;
public Integer parentGroupKey;
public Integer parentItemKey;
public Integer key;
public Boolean upliftable;
public String configurationType;
public String configurationEvent;
public Boolean reconfigurationDisabled;
public Boolean descriptionLocked;
public Boolean productQuantityEditable;
public Decimal productQuantityScale;
public String dimensionType;
public Boolean productHasDimensions;
public Decimal targetCustomerAmount;
public Decimal targetCustomerTotal;
}
public class TinyQuoteLineGroupModel {
public SBQQ__QuoteLineGroup__c record;
public Decimal netNonSegmentTotal;
public Integer key;
}
}
/**
* This test class is only testing for code coverage. Functionality does not require testing for this wrapper class.
*
*/
@isTest
private class CPQ_ApiDataModelsTest {
@isTest static void testProductLoadContext() {
CPQ_ApiDataModels.ProductLoadContext loadContext = new CPQ_ApiDataModels.ProductLoadContext();
System.assertEquals(loadContext.pricebookId, null);
System.assertEquals(loadContext.currencyCode, null);
Id pricebookId = Test.getStandardPricebookId();
String currencyCode = 'USD';
CPQ_ApiDataModels.ProductLoadContext loadContextWithPricebookAndCurrency = new CPQ_ApiDataModels.ProductLoadContext(pricebookId, currencyCode);
System.assertEquals(loadContextWithPricebookAndCurrency.pricebookId, pricebookId);
System.assertEquals(loadContextWithPricebookAndCurrency.currencyCode, currencyCode);
}
@isTest static void testProductAddContext() {
CPQ_ApiDataModels.ProductAddContext addContextDefault = new CPQ_ApiDataModels.ProductAddContext();
System.assertEquals(addContextDefault.quote, null);
System.assertEquals(addContextDefault.products, new List<CPQ_ApiDataModels.ProductModel>());
System.assertEquals(addContextDefault.groupKey, null);
CPQ_ApiDataModels.QuoteModel quote = new CPQ_ApiDataModels.QuoteModel();
List<CPQ_ApiDataModels.ProductModel> products = new List<CPQ_ApiDataModels.ProductModel>();
CPQ_ApiDataModels.ProductAddContext addContextquoteProducts = new CPQ_ApiDataModels.ProductAddContext(quote, products);
System.assertEquals(addContextquoteProducts.quote, quote);
System.assertEquals(addContextquoteProducts.products, products);
System.assertEquals(addContextquoteProducts.groupKey, null);
CPQ_ApiDataModels.ProductAddContext addContextquoteProductsIgnoreCalculate = new CPQ_ApiDataModels.ProductAddContext(true, quote, products);
System.assertEquals(addContextquoteProductsIgnoreCalculate.quote, quote);
System.assertEquals(addContextquoteProductsIgnoreCalculate.products, products);
System.assertEquals(addContextquoteProductsIgnoreCalculate.groupKey, null);
System.assertEquals(addContextquoteProductsIgnoreCalculate.ignoreCalculate, true);
CPQ_ApiDataModels.ProductAddContext addContextquoteProductsIgnoreCalculateWithGroup =
new CPQ_ApiDataModels.ProductAddContext(true, quote, products, 1);
System.assertEquals(addContextquoteProductsIgnoreCalculateWithGroup.quote, quote);
System.assertEquals(addContextquoteProductsIgnoreCalculateWithGroup.products, products);
System.assertEquals(addContextquoteProductsIgnoreCalculateWithGroup.groupKey, 1);
System.assertEquals(addContextquoteProductsIgnoreCalculateWithGroup.ignoreCalculate, true);
}
@isTest static void testCalculatorContext() {
CPQ_ApiDataModels.CalculatorContext calcContext = new CPQ_ApiDataModels.CalculatorContext();
System.assertEquals(calcContext.quote, null);
CPQ_ApiDataModels.QuoteModel quote = new CPQ_ApiDataModels.QuoteModel();
CPQ_ApiDataModels.CalculatorContext calcContextWithQuote = new CPQ_ApiDataModels.CalculatorContext(quote);
System.assertEquals(calcContextWithQuote.quote, quote);
}
@isTest static void testProductModel() {
String productModelJson = '{' +
'"record": {' +
'"attributes": {' +
'"type": "Product2",' +
'"url": "/services/data/v42.0/sobjects/Product2/01t0q000000gaO9AAI"' +
'},' +
'"Id": "01t0q000000gaO9AAI",' +
'"CurrencyIsoCode": "USD",' +
'"Name": "API - Overage",' +
'"ProductCode": "API - Overage",' +
'"Description": "atg",' +
'"SBQQ__SubscriptionPricing__c": "Fixed Price",' +
'"SBQQ__PriceEditable__c": false,' +
'"SBQQ__DefaultQuantity__c": 1.00000,' +
'"SBQQ__QuantityEditable__c": true,' +
'"SBQQ__CostEditable__c": false,' +
'"SBQQ__NonDiscountable__c": false,' +
'"SBQQ__NonPartnerDiscountable__c": false,' +
'"SBQQ__SubscriptionTerm__c": 1,' +
'"SBQQ__PricingMethod__c": "List",' +
'"SBQQ__PricingMethodEditable__c": true,' +
'"SBQQ__OptionSelectionMethod__c": "Click",' +
'"SBQQ__Optional__c": false,' +
'"SBQQ__Taxable__c": false,' +
'"SBQQ__CustomConfigurationRequired__c": false,' +
'"SBQQ__Hidden__c": false,' +
'"SBQQ__ReconfigurationDisabled__c": false,' +
'"SBQQ__ExcludeFromOpportunity__c": true,' +
'"SBQQ__DescriptionLocked__c": false,' +
'"SBQQ__ExcludeFromMaintenance__c": false,' +
'"SBQQ__IncludeInMaintenance__c": false,' +
'"SBQQ__AllocatePotOnOrders__c": false,' +
'"SBQQ__NewQuoteGroup__c": false,' +
'"SBQQ__SubscriptionType__c": "Renewable",' +
'"SBQQ__HasConfigurationAttributes__c": false,' +
'"SBQQ__ExternallyConfigurable__c": false,' +
'"SBQQ__BillingFrequency__c": "Monthly",' +
'"SBQQ__ChargeType__c": "Usage",' +
'"PricebookEntries": {' +
'"totalSize": 1,' +
'"done": true,' +
'"records": [' +
'{' +
'"attributes": {' +
'"type": "PricebookEntry",' +
'"url": "/services/data/v42.0/sobjects/PricebookEntry/01u0q000001jwBjAAI"' +
'},' +
'"Product2Id": "01t0q000000gaO9AAI",' +
'"Id": "01u0q000001jwBjAAI",' +
'"Pricebook2Id": "01s0q000000CbjqAAC",' +
'"UnitPrice": 0.08,' +
'"IsActive": true,' +
'"CurrencyIsoCode": "USD"' +
'}' +
']' +
'}' +
'},' +
'"options": [],' +
'"features": [],' +
'"featureCategoryLabels": {' +
'"Reporting": "Reporting",' +
'"Implementation": "Implementation",' +
'"Software": "Software",' +
'"Hardware": "Hardware"' +
'},' +
'"featureCategories": [],' +
'"currencySymbol": "USD",' +
'"currencyCode": "USD",' +
'"constraints": [],' +
'"configurationAttributes": []' +
'}';
CPQ_ApiDataModels.ProductModel productModel = (CPQ_ApiDataModels.ProductModel) JSON.deserialize(productModelJson, CPQ_ApiDataModels.ProductModel.class);
System.assertEquals(productModel.record.Name, 'API - Overage');
System.assertEquals(productModel.upgradedAssetId, null);
System.assertEquals(productModel.currencySymbol, 'USD');
System.assertEquals(productModel.currencyCode, 'USD');
System.assertEquals(productModel.featureCategories, new String[]{});
System.assertEquals(productModel.options, new CPQ_ApiDataModels.OptionModel[]{});
System.assertEquals(productModel.features, new CPQ_ApiDataModels.FeatureModel[]{});
System.assertEquals(productModel.configuration, null);
System.assertEquals(productModel.configurationAttributes, new CPQ_ApiDataModels.ConfigAttributeModel[]{});
System.assertEquals(productModel.inheritedConfigurationAttributes, null);
}
}
/**
* NOTE: This requires some cleanup - but is a good starting point
*
* This class wraps the Salesforce CPQ API to allow
* easier interaction and to demonstrate how to call various methods
* EXAMPLE INVOKING:
* Contract contract = [SELECT Id FROM Contract WHERE Id = '800f4000000DL11' LIMIT 1];
* CPQ_ApiWrapper.renewContract(contract);
* To Manually call the CPQ API via REST, the convention is as follows:
* GET /services/apexrest/SBQQ/ServiceRouter/read?reader=SBQQ.QuoteAPI.QuoteReader&uid=a0nf4000000W4vs
*
*/
public without sharing class CPQ_ApiWrapper {
public static Boolean debug = true;
/** CPQ API METHODS */
public static final String CONTRACT_RENEWER = 'SBQQ.ContractManipulationAPI.ContractRenewer';
public static final String CONTRACT_AMENDER = 'SBQQ.ContractManipulationAPI.ContractAmender';
public static final String CONFIG_LOADER = 'SBQQ.ConfigAPI.ConfigLoader';
public static final String LOAD_RULE_EXECUTOR = 'SBQQ.ConfigAPI.LoadRuleExecutor';
public static final String CONFIGURATION_VALIDATOR = 'SBQQ.ConfigAPI.ConfigurationValidator';
public static final String PRODUCT_LOADER = 'SBQQ.ProductAPI.ProductLoader';
public static final String PRODUCT_SUGGESTER = 'SBQQ.ProductAPI.ProductSuggester';
public static final String PRODUCT_SEARCHER = 'SBQQ.ProductAPI.ProductSearcher';
public static final String QUOTE_READER = 'SBQQ.QuoteAPI.QuoteReader';
public static final String QUOTE_PRODUCT_ADDER = 'SBQQ.QuoteAPI.QuoteProductAdder';
public static final String QUOTE_CALCULATOR = 'SBQQ.QuoteAPI.QuoteCalculator';
public static final String QUOTE_SAVER = 'SBQQ.QuoteAPI.QuoteSaver';
/** Mini Wrapper around SBQQ API METHODS */
private static String read(String name, String uid) {
return SBQQ.ServiceRouter.read(name, uid);
}
private static String load(String name, String uid, Object payload) {
return loadStr(name, uid, JSON.serialize(payload));
}
private static String loadStr(String name, String uid, String payloadJson) {
return SBQQ.ServiceRouter.load(name, uid, payloadJson);
}
private static String save(String name, Object model) {
return saveStr(name, JSON.serialize(model));
}
private static String saveStr(String name, String modelJson) {
return SBQQ.ServiceRouter.save(name, modelJson);
}
// Will need to add unit tests for these if uncommented
//public static List<CPQ_ApiDataModels.QuoteModel> renewContract(Contract contract) {
// return renewContract(contract.Id, new List<Contract>{contract});
//}
//public static List<CPQ_ApiDataModels.QuoteModel> renewContract(Id contractId, List<Contract> contracts) {
// CPQ_ApiDataModels.RenewalContext payload = new CPQ_ApiDataModels.RenewalContext();
// payload.renewedContracts = contracts;
// String jsonResult = load(CONTRACT_RENEWER, (String) contractId, payload);
// if(debug) {
// System.debug(LoggingLevel.WARN, 'jsonResult: ' + jsonResult);
// }
// List<CPQ_ApiDataModels.QuoteModel> quoteModel = (List<CPQ_ApiDataModels.QuoteModel>) JSON.deserialize(jsonResult, LIST<CPQ_ApiDataModels.QuoteModel>.class);
// if(debug) {
// System.debug(LoggingLevel.WARN, 'jsonResult: ' + jsonResult);
// System.debug(LoggingLevel.WARN, 'quoteModel: ' + quoteModel);
// }
// return quoteModel;
//}
//public static CPQ_ApiDataModels.QuoteModel amendContract(Id contractId) {
// System.debug(LoggingLevel.WARN, 'amending');
// String jsonResult = load(CONTRACT_AMENDER, (String) contractId, null);
// System.debug(LoggingLevel.WARN, 'amended ' + jsonResult);
// CPQ_ApiDataModels.QuoteModel quoteModel = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(jsonResult, CPQ_ApiDataModels.QuoteModel.class);
// System.debug(LoggingLevel.WARN, 'quoteModel >>> ' + quoteModel);
//if(debug) {
// System.debug(LoggingLevel.WARN, 'jsonResult: ' + jsonResult);
// System.debug(LoggingLevel.WARN, 'quoteModel: ' + quoteModel);
//}
//return quoteModel;
//}
/**
* ******* QUOTE API EXAMPLES ********
*/
public static CPQ_ApiDataModels.QuoteModel getQuoteModel(Id quoteId) {
String jsonResult = read(QUOTE_READER, (String) quoteId);
CPQ_ApiDataModels.QuoteModel quoteModel = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(jsonResult, CPQ_ApiDataModels.QuoteModel.class);
if(debug) {
System.debug(LoggingLevel.WARN, 'jsonResult: ' + jsonResult);
System.debug(LoggingLevel.WARN, 'quoteModel: ' + quoteModel);
}
return quoteModel;
}
public static CPQ_ApiDataModels.ProductModel loadProduct(Id productId, Id pricebookId, String currencyCode) {
CPQ_ApiDataModels.ProductLoadContext productLoadPayload = new CPQ_ApiDataModels.ProductLoadContext(pricebookId, currencyCode);
String jsonResultProduct = load(PRODUCT_LOADER, (String) productId, productLoadPayload);
CPQ_ApiDataModels.ProductModel productModel = (CPQ_ApiDataModels.ProductModel) JSON.deserialize(jsonResultProduct, CPQ_ApiDataModels.ProductModel.class);
if(debug) {
System.debug(LoggingLevel.WARN, 'jsonResultProduct: ' + jsonResultProduct);
System.debug(LoggingLevel.WARN, 'productModel: ' + productModel);
}
return productModel;
}
public static CPQ_ApiDataModels.ProductModel setOptionsConfigured(CPQ_ApiDataModels.ProductModel productModel) {
if(productModel.configuration != null){
productModel.configuration.configured = true;
productModel.configuration.configurationEntered = true;
for(CPQ_ApiDataModels.ConfigurationModel configModel : productModel.configuration.optionConfigurations) {
configModel.configured = true;
configModel.configurationEntered = true;
}
return productModel;
}else{return productModel;}
}
public static CPQ_ApiDataModels.QuoteModel addProductsToQuote(Id quoteId, Id productId, Id pricebookId, String currencyCode) {
return addProductsToQuote(quoteId, pricebookId, productId, currencyCode, false);
}
public static CPQ_ApiDataModels.QuoteModel addProductsToQuote(Id quoteId, Id productId, Id pricebookId, String currencyCode, Boolean skipCalculate) {
CPQ_ApiDataModels.ProductModel productModel = loadProduct(productId, pricebookId, currencyCode);
// Set product model as configured and configurationEntered
productModel = setOptionsConfigured(productModel);
String jsonResultQuote = read(QUOTE_READER, (String) quoteId);
CPQ_ApiDataModels.QuoteModel initialQuoteModel = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(jsonResultQuote, CPQ_ApiDataModels.QuoteModel.class);
if(debug) {
System.debug(LoggingLevel.WARN, 'jsonResultQuote: ' + jsonResultQuote);
System.debug(LoggingLevel.WARN, 'initialQuoteModel: ' + initialQuoteModel);
}
CPQ_ApiDataModels.ProductAddContext productAddPayload = new CPQ_ApiDataModels.ProductAddContext(skipCalculate, initialQuoteModel, new List<CPQ_ApiDataModels.ProductModel>{productModel});
return addProductsToQuote(productAddPayload);
}
public static CPQ_ApiDataModels.QuoteModel addProductsToQuote(CPQ_ApiDataModels.ProductAddContext productAddPayload) {
if(debug) {
System.debug(LoggingLevel.WARN, 'productAddPayloadJSON: ' + JSON.serialize(productAddPayload));
}
String updatedQuoteJSON = load(QUOTE_PRODUCT_ADDER, null, productAddPayload);
CPQ_ApiDataModels.QuoteModel updatedQuoteModel = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(updatedQuoteJSON, CPQ_ApiDataModels.QuoteModel.class);
if(debug) {
System.debug(LoggingLevel.WARN, 'updatedQuoteJSON: ' + updatedQuoteJSON);
System.debug(LoggingLevel.WARN, 'updatedQuoteModel: ' + updatedQuoteModel);
}
return updatedQuoteModel;
}
public static CPQ_ApiDataModels.QuoteModel calculateQuote(CPQ_ApiDataModels.QuoteModel quoteModel) {
CPQ_ApiDataModels.CalculatorContext calculatorPayload = new CPQ_ApiDataModels.CalculatorContext(quoteModel);
String updatedQuoteJSON = load(QUOTE_CALCULATOR, null, calculatorPayload);
CPQ_ApiDataModels.QuoteModel updatedQuoteModel = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(updatedQuoteJSON, CPQ_ApiDataModels.QuoteModel.class);
if(debug) {
System.debug(LoggingLevel.WARN, 'updatedQuoteJSON: ' + updatedQuoteJSON);
System.debug(LoggingLevel.WARN, 'updatedQuoteModel: ' + updatedQuoteModel);
}
return updatedQuoteModel;
}
public static CPQ_ApiDataModels.QuoteModel saveQuote(CPQ_ApiDataModels.QuoteModel quoteModel) {
String savedQuoteJSON = save(QUOTE_SAVER, quoteModel);
CPQ_ApiDataModels.QuoteModel updatedQuoteModel = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(savedQuoteJSON, CPQ_ApiDataModels.QuoteModel.class);
if(debug) {
System.debug(LoggingLevel.WARN, 'updatedQuoteModel: ' + updatedQuoteModel);
}
return updatedQuoteModel;
}
public static CPQ_ApiDataModels.QuoteModel calculateAndSaveQuote(CPQ_ApiDataModels.QuoteModel quoteModel) {
//String calculatedQuoteJSON = SBQQ.QuoteLineEditorController.calculateQuote2(quoteModel.record.Id, JSON.serialize(quoteModel));
// Attempt to get around uncomitted changes by saving first
String savedQuoteJSON = saveStr(QUOTE_SAVER, JSON.serialize(quoteModel));
//String calculatedQuoteJSON = SBQQ.QuoteLineEditorController.calculateQuote2(quoteModel.record.Id, JSON.serialize(savedQuoteJSON));
//savedQuoteJSON = saveStr(QUOTE_SAVER, calculatedQuoteJSON);
CPQ_ApiDataModels.QuoteModel savedQuote = (CPQ_ApiDataModels.QuoteModel) JSON.deserialize(savedQuoteJSON, CPQ_ApiDataModels.QuoteModel.class);
//String calculatedQuoteJSON = SBQQ.QuoteLineEditorController.calculateQuote2(quoteModel.record.Id, JSON.serialize(savedQuoteJSON));
if(debug) {
//System.debug(LoggingLevel.WARN, 'calculatedQuoteJSON: ' + calculatedQuoteJSON);
System.debug(LoggingLevel.WARN, 'savedQuoteJSON: ' + savedQuoteJSON);
//System.debug(LoggingLevel.WARN, 'savedQuote: ' + savedQuote);
}
return savedQuote;
}
//public static void configureBundle(CPQ_ApiDataModels.QuoteModel quoteModel, Id productId) {
// //CPQ_ApiDataModels.ConfigLoadContext context = new CPQ_ApiDataModels.ConfigLoadContext();
// // Using alt payload to avoid requiring tiny quoteModel since it is basically the same - could modify contrstructor to convert
// CPQ_ApiDataModels.TinyQuoteModel tinyQuoteModel = new CPQ_ApiDataModels.TinyQuoteModel();
// tinyQuoteModel.record = quoteModel.record;
// CPQ_ApiDataModels.ConfigLoadContext context = new CPQ_ApiDataModels.ConfigLoadContext();
// context.quote = tinyQuoteModel;
// System.debug(LoggingLevel.WARN, JSON.serialize(context));
// String configLoaderJSON = load(CONFIG_LOADER, (String) productId, JSON.serialize(context));
// if(debug) {
// System.debug(LoggingLevel.WARN, 'configLoaderJSON: ' + configLoaderJSON);
// }
//}
/**
* Force a re-calculation of provided quote
* @param quoteId [description]
* @return [description]
*/
public static CPQ_ApiDataModels.QuoteModel calculateQuote(String quoteId) {
CPQ_ApiDataModels.QuoteModel initialQuoteModel = getQuoteModel(quoteId);
if(debug) {
System.debug(LoggingLevel.WARN, 'initialQuoteModel: ' + initialQuoteModel);
}
CPQ_ApiDataModels.QuoteModel calculatedQuoteModel = calculateQuote(initialQuoteModel);
if(debug) {
System.debug(LoggingLevel.WARN, 'calculatedQuoteModel: ' + calculatedQuoteModel);
}
CPQ_ApiDataModels.QuoteModel savedQuoteModel = saveQuote(calculatedQuoteModel);
if(debug) {
System.debug(LoggingLevel.WARN, 'savedQuoteModel: ' + savedQuoteModel);
}
return savedQuoteModel;
}
}
/**
* SFDC QCP Plugin docs: https://resources.docs.salesforce.com/214/latest/en-us/sfdc/pdf/cpq_plugins.pdf
*
* It is recommended Use the JavaScript way of iterating look (e.x. rarely use for(i=0;i++;i<foo) and instead use array iteraction methods and chain them together)
* Overview: https://gist.github.com/ljharb/58faf1cfcb4e6808f74aae4ef7944cff
*
* The conn property is a customized JSForce connection object that can be used to perform DML or call Apex methods
*
* FROM DOCS:
* JSForce is a third-party library that provides a unified way to perform queries, execute Apex REST calls, use theMetadata API, or make
* HTTP requests remotely. Methods access jsforce through the optional parameter conn.
*
* This code must be able to run in the browser and on node, do not use any browser specific (e.x. DOM) related functions
*
* TIP: if you are using VSCode, rename the file with a .ts file extension to get better IDE debug information.
*
* For information on when each Life Cycle Hook runs in the Calculation Sequence, refer to this Salesforce Documentation
* https://help.salesforce.com/articleView?id=cpq_quote_calc_process.htm&type=5
*/
const DEBUG = true;
const MIN_SUB_TERM_PRODUCT_CODE = 'GC-01';
/**
* Log - takes any number of parameters and will log them to the console if DEBUG = true
*/
function log(...params) {
if (DEBUG) {
console.log(...params);
}
}
/**
* Page Security Plugin
* Allows hiding or locking down fields based on conditions
*/
export function isFieldEditable(fieldName, quoteLine) {
if (fieldName === 'SBQQ__SubscriptionTerm__c') {
return quoteLine.SBQQ__ProductCode__c === MIN_SUB_TERM_PRODUCT_CODE;
}
return true;
}
/**
* QCP PLUGIN LIFE-CYCLE HOOKS
*/
export function onInit(quoteLineModels, conn) {
return new Promise((resolve, reject) => {
log('onInit()', quoteLineModels);
resolve();
});
}
export function onBeforeCalculate(quoteModel, quoteLineModels) {
return new Promise((resolve, reject) => {
log('onBeforeCalculate()', quoteModel, quoteLineModels);
// Set the max subscription term on whichever quote line if the parent to the product code provided
const minimumTerm = typeof quoteModel.record.Min_Subscription_Term__c === 'number' ? quoteModel.record.Min_Subscription_Term__c : 12;
getMaxSubTermForProductsByBundle(quoteLineModels, MIN_SUB_TERM_PRODUCT_CODE, minimumTerm);
resolve();
});
}
export function onBeforePriceRules(quoteModel, quoteLineModels) {
return new Promise((resolve, reject) => {
log('onBeforePriceRules()', quoteModel, quoteLineModels);
resolve();
});
}
export function onAfterPriceRules(quoteModel, quoteLineModels, conn) {
return new Promise((resolve, reject) => {
log('onAfterPriceRules()', quoteModel, quoteLineModels);
resolve();
});
}
export function onAfterCalculate(quoteModel, quoteLineModels, conn) {
return new Promise((resolve, reject) => {
log('onAfterCalculate()', quoteModel, quoteLineModels);
resolve();
});
}
function getMaxSubTermForProductsByBundle(quoteLineModels, productCode, minimumTerm) {
const quoteLinesModelsByParentKey = quoteLineModels
.filter(qlModel => qlModel.parentItemKey !== null) // filter out lines with no parent
.filter(qlModel => qlModel.record.SBQQ__ProductCode__c === productCode) // filter out lines that do not match provided productCode
.reduce((qlByBundleId, qlModel) => {
// combine all objects into an map where the key is the parent quote line id
// If current required by is not set, initialize to empty array
qlByBundleId[qlModel.parentItemKey] = qlByBundleId[qlModel.parentItemKey] || [];
qlByBundleId[qlModel.parentItemKey].push(qlModel);
return qlByBundleId;
}, {});
log('quoteLinesModelsByParentKey', quoteLinesModelsByParentKey);
// For each bundle, set the maximum subscription term on the parent quote line
Object.keys(quoteLinesModelsByParentKey).forEach(key => {
log('Working on bundle:', key);
log('Child Lines:', quoteLinesModelsByParentKey[key]);
// find parent quote line based on key (== because a number key got turned into a string when placed in a map)
const parentQuoteLine = quoteLineModels.find(qlModel => qlModel.key == key);
log('parentQuoteLine:', parentQuoteLine);
// set to default minimum term in case no lines have a SBQQ__SubscriptionTerm__c set
parentQuoteLine.record.SBQQ__SubscriptionTerm__c = minimumTerm;
const maxSubTermFromChildLines = quoteLinesModelsByParentKey[key]
.filter(ql => !!ql.record.SBQQ__SubscriptionTerm__c) // filter out products that do not have a subscription term set
.reduce((maxSubTerm, ql) => {
return Math.max(maxSubTerm, ql.record.SBQQ__SubscriptionTerm__c);
}, 0);
log('maxSubTermFromChildLines:', maxSubTermFromChildLines);
// Set the subscription term on the parent quote line or the default, whichever is higher
parentQuoteLine.record.SBQQ__SubscriptionTerm__c = Math.max(parentQuoteLine.record.SBQQ__SubscriptionTerm__c, maxSubTermFromChildLines);
log('Max subscription term for bundle:', parentQuoteLine.record.SBQQ__SubscriptionTerm__c);
});
}
// https://github.com/kevinohara80/sfdc-trigger-framework/blob/master/src/classes/TriggerHandler.cls
public virtual class TriggerHandler {
// static map of handlername, times run() was invoked
private static Map<String, LoopCount> loopCountMap;
private static Set<String> bypassedHandlers;
// the current context of the trigger, overridable in tests
@TestVisible
private TriggerContext context;
// the current context of the trigger, overridable in tests
@TestVisible
private Boolean isTriggerExecuting;
// static initialization
static {
loopCountMap = new Map<String, LoopCount>();
bypassedHandlers = new Set<String>();
}
// constructor
public TriggerHandler() {
this.setTriggerContext();
}
/***************************************
* public instance methods
***************************************/
// main method that will be called during execution
public void run() {
if(!validateRun()) return;
addToLoopCount();
// dispatch to the correct handler method
if(this.context == TriggerContext.BEFORE_INSERT) {
this.beforeInsert();
} else if(this.context == TriggerContext.BEFORE_UPDATE) {
this.beforeUpdate();
} else if(this.context == TriggerContext.BEFORE_DELETE) {
this.beforeDelete();
} else if(this.context == TriggerContext.AFTER_INSERT) {
this.afterInsert();
} else if(this.context == TriggerContext.AFTER_UPDATE) {
this.afterUpdate();
} else if(this.context == TriggerContext.AFTER_DELETE) {
this.afterDelete();
} else if(this.context == TriggerContext.AFTER_UNDELETE) {
this.afterUndelete();
}
}
public void setMaxLoopCount(Integer max) {
String handlerName = getHandlerName();
if(!TriggerHandler.loopCountMap.containsKey(handlerName)) {
TriggerHandler.loopCountMap.put(handlerName, new LoopCount(max));
} else {
TriggerHandler.loopCountMap.get(handlerName).setMax(max);
}
}
public void clearMaxLoopCount() {
this.setMaxLoopCount(-1);
}
/***************************************
* public static methods
***************************************/
public static void bypass(String handlerName) {
TriggerHandler.bypassedHandlers.add(handlerName);
}
public static void clearBypass(String handlerName) {
TriggerHandler.bypassedHandlers.remove(handlerName);
}
public static Boolean isBypassed(String handlerName) {
return TriggerHandler.bypassedHandlers.contains(handlerName);
}
public static void clearAllBypasses() {
TriggerHandler.bypassedHandlers.clear();
}
/***************************************
* private instancemethods
***************************************/
@TestVisible
private void setTriggerContext() {
this.setTriggerContext(null, false);
}
@TestVisible
private void setTriggerContext(String ctx, Boolean testMode) {
if(!Trigger.isExecuting && !testMode) {
this.isTriggerExecuting = false;
return;
} else {
this.isTriggerExecuting = true;
}
if((Trigger.isExecuting && Trigger.isBefore && Trigger.isInsert) ||
(ctx != null && ctx == 'before insert')) {
this.context = TriggerContext.BEFORE_INSERT;
} else if((Trigger.isExecuting && Trigger.isBefore && Trigger.isUpdate) ||
(ctx != null && ctx == 'before update')){
this.context = TriggerContext.BEFORE_UPDATE;
} else if((Trigger.isExecuting && Trigger.isBefore && Trigger.isDelete) ||
(ctx != null && ctx == 'before delete')) {
this.context = TriggerContext.BEFORE_DELETE;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isInsert) ||
(ctx != null && ctx == 'after insert')) {
this.context = TriggerContext.AFTER_INSERT;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isUpdate) ||
(ctx != null && ctx == 'after update')) {
this.context = TriggerContext.AFTER_UPDATE;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isDelete) ||
(ctx != null && ctx == 'after delete')) {
this.context = TriggerContext.AFTER_DELETE;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isUndelete) ||
(ctx != null && ctx == 'after undelete')) {
this.context = TriggerContext.AFTER_UNDELETE;
}
}
// increment the loop count
@TestVisible
private void addToLoopCount() {
String handlerName = getHandlerName();
if(TriggerHandler.loopCountMap.containsKey(handlerName)) {
Boolean exceeded = TriggerHandler.loopCountMap.get(handlerName).increment();
if(exceeded) {
Integer max = TriggerHandler.loopCountMap.get(handlerName).max;
throw new TriggerHandlerException('Maximum loop count of ' + String.valueOf(max) + ' reached in ' + handlerName);
}
}
}
// make sure this trigger should continue to run
@TestVisible
private Boolean validateRun() {
if(!this.isTriggerExecuting || this.context == null) {
throw new TriggerHandlerException('Trigger handler called outside of Trigger execution');
}
if(TriggerHandler.bypassedHandlers.contains(getHandlerName())) {
return false;
}
return true;
}
@TestVisible
private String getHandlerName() {
return String.valueOf(this).substring(0,String.valueOf(this).indexOf(':'));
}
/***************************************
* context methods
***************************************/
// context-specific methods for override
@TestVisible
protected virtual void beforeInsert(){}
@TestVisible
protected virtual void beforeUpdate(){}
@TestVisible
protected virtual void beforeDelete(){}
@TestVisible
protected virtual void afterInsert(){}
@TestVisible
protected virtual void afterUpdate(){}
@TestVisible
protected virtual void afterDelete(){}
@TestVisible
protected virtual void afterUndelete(){}
/***************************************
* inner classes
***************************************/
// inner class for managing the loop count per handler
@TestVisible
private class LoopCount {
private Integer max;
private Integer count;
public LoopCount() {
this.max = 5;
this.count = 0;
}
public LoopCount(Integer max) {
this.max = max;
this.count = 0;
}
public Boolean increment() {
this.count++;
return this.exceeded();
}
public Boolean exceeded() {
if(this.max < 0) return false;
if(this.count > this.max) {
return true;
}
return false;
}
public Integer getMax() {
return this.max;
}
public Integer getCount() {
return this.count;
}
public void setMax(Integer max) {
this.max = max;
}
}
// possible trigger contexts
@TestVisible
private enum TriggerContext {
BEFORE_INSERT, BEFORE_UPDATE, BEFORE_DELETE,
AFTER_INSERT, AFTER_UPDATE, AFTER_DELETE,
AFTER_UNDELETE
}
// exception class
public class TriggerHandlerException extends Exception {}
}
@isTest
private class TriggerHandlerTest {
private static final String TRIGGER_CONTEXT_ERROR = 'Trigger handler called outside of Trigger execution';
private static String lastMethodCalled;
private static TriggerHandlerTest.TestHandler handler;
static {
handler = new TriggerHandlerTest.TestHandler();
// override its internal trigger detection
handler.isTriggerExecuting = true;
}
/***************************************
* unit tests
***************************************/
// contexts tests
@isTest
static void testBeforeInsert() {
beforeInsertMode();
handler.run();
System.assertEquals('beforeInsert', lastMethodCalled, 'last method should be beforeInsert');
}
@isTest
static void testBeforeUpdate() {
beforeUpdateMode();
handler.run();
System.assertEquals('beforeUpdate', lastMethodCalled, 'last method should be beforeUpdate');
}
@isTest
static void testBeforeDelete() {
beforeDeleteMode();
handler.run();
System.assertEquals('beforeDelete', lastMethodCalled, 'last method should be beforeDelete');
}
@isTest
static void testAfterInsert() {
afterInsertMode();
handler.run();
System.assertEquals('afterInsert', lastMethodCalled, 'last method should be afterInsert');
}
@isTest
static void testAfterUpdate() {
afterUpdateMode();
handler.run();
System.assertEquals('afterUpdate', lastMethodCalled, 'last method should be afterUpdate');
}
@isTest
static void testAfterDelete() {
afterDeleteMode();
handler.run();
System.assertEquals('afterDelete', lastMethodCalled, 'last method should be afterDelete');
}
@isTest
static void testAfterUndelete() {
afterUndeleteMode();
handler.run();
System.assertEquals('afterUndelete', lastMethodCalled, 'last method should be afterUndelete');
}
@isTest
static void testNonTriggerContext() {
try{
handler.run();
System.assert(false, 'the handler ran but should have thrown');
} catch(TriggerHandler.TriggerHandlerException te) {
System.assertEquals(TRIGGER_CONTEXT_ERROR, te.getMessage(), 'the exception message should match');
} catch(Exception e) {
System.assert(false, 'the exception thrown was not expected: ' + e.getTypeName() + ': ' + e.getMessage());
}
}
// test bypass api
@isTest
static void testBypassAPI() {
afterUpdateMode();
// test a bypass and run handler
TriggerHandler.bypass('TestHandler');
handler.run();
System.assertEquals(null, lastMethodCalled, 'last method should be null when bypassed');
System.assertEquals(true, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
// clear that bypass and run handler
TriggerHandler.clearBypass('TestHandler');
handler.run();
System.assertEquals('afterUpdate', lastMethodCalled, 'last method called should be afterUpdate');
System.assertEquals(false, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
// test a re-bypass and run handler
TriggerHandler.bypass('TestHandler');
handler.run();
System.assertEquals(null, lastMethodCalled, 'last method should be null when bypassed');
System.assertEquals(true, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
// clear all bypasses and run handler
TriggerHandler.clearAllBypasses();
handler.run();
System.assertEquals('afterUpdate', lastMethodCalled, 'last method called should be afterUpdate');
System.assertEquals(false, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
}
// instance method tests
@isTest
static void testLoopCount() {
beforeInsertMode();
// set the max loops to 2
handler.setMaxLoopCount(2);
// run the handler twice
handler.run();
handler.run();
// clear the tests
resetTest();
try {
// try running it. This should exceed the limit.
handler.run();
System.assert(false, 'the handler should throw on the 3rd run when maxloopcount is 3');
} catch(TriggerHandler.TriggerHandlerException te) {
// we're expecting to get here
System.assertEquals(null, lastMethodCalled, 'last method should be null');
} catch(Exception e) {
System.assert(false, 'the exception thrown was not expected: ' + e.getTypeName() + ': ' + e.getMessage());
}
// clear the tests
resetTest();
// now clear the loop count
handler.clearMaxLoopCount();
try {
// re-run the handler. We shouldn't throw now.
handler.run();
System.assertEquals('beforeInsert', lastMethodCalled, 'last method should be beforeInsert');
} catch(TriggerHandler.TriggerHandlerException te) {
System.assert(false, 'running the handler after clearing the loop count should not throw');
} catch(Exception e) {
System.assert(false, 'the exception thrown was not expected: ' + e.getTypeName() + ': ' + e.getMessage());
}
}
@isTest
static void testLoopCountClass() {
TriggerHandler.LoopCount lc = new TriggerHandler.LoopCount();
System.assertEquals(5, lc.getMax(), 'max should be five on init');
System.assertEquals(0, lc.getCount(), 'count should be zero on init');
lc.increment();
System.assertEquals(1, lc.getCount(), 'count should be 1');
System.assertEquals(false, lc.exceeded(), 'should not be exceeded with count of 1');
lc.increment();
lc.increment();
lc.increment();
lc.increment();
System.assertEquals(5, lc.getCount(), 'count should be 5');
System.assertEquals(false, lc.exceeded(), 'should not be exceeded with count of 5');
lc.increment();
System.assertEquals(6, lc.getCount(), 'count should be 6');
System.assertEquals(true, lc.exceeded(), 'should not be exceeded with count of 6');
}
// private method tests
@isTest
static void testGetHandlerName() {
System.assertEquals('TestHandler', handler.getHandlerName(), 'handler name should match class name');
}
// test virtual methods
@isTest
static void testVirtualMethods() {
TriggerHandler h = new TriggerHandler();
h.beforeInsert();
h.beforeUpdate();
h.beforeDelete();
h.afterInsert();
h.afterUpdate();
h.afterDelete();
h.afterUndelete();
}
/***************************************
* testing utilities
***************************************/
private static void resetTest() {
lastMethodCalled = null;
}
// modes for testing
private static void beforeInsertMode() {
handler.setTriggerContext('before insert', true);
}
private static void beforeUpdateMode() {
handler.setTriggerContext('before update', true);
}
private static void beforeDeleteMode() {
handler.setTriggerContext('before delete', true);
}
private static void afterInsertMode() {
handler.setTriggerContext('after insert', true);
}
private static void afterUpdateMode() {
handler.setTriggerContext('after update', true);
}
private static void afterDeleteMode() {
handler.setTriggerContext('after delete', true);
}
private static void afterUndeleteMode() {
handler.setTriggerContext('after undelete', true);
}
// test implementation of the TriggerHandler
private class TestHandler extends TriggerHandler {
public override void beforeInsert() {
TriggerHandlerTest.lastMethodCalled = 'beforeInsert';
}
public override void beforeUpdate() {
TriggerHandlerTest.lastMethodCalled = 'beforeUpdate';
}
public override void beforeDelete() {
TriggerHandlerTest.lastMethodCalled = 'beforeDelete';
}
public override void afterInsert() {
TriggerHandlerTest.lastMethodCalled = 'afterInsert';
}
public override void afterUpdate() {
TriggerHandlerTest.lastMethodCalled = 'afterUpdate';
}
public override void afterDelete() {
TriggerHandlerTest.lastMethodCalled = 'afterDelete';
}
public override void afterUndelete() {
TriggerHandlerTest.lastMethodCalled = 'afterUndelete';
}
}
}
@kevina-code
Copy link

kevina-code commented May 31, 2024

@paustint
Nice callout of SObjectDescribeOptions.DEFERRED. That seems like an appropriate usage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment