Last active
February 12, 2025 13:01
-
-
Save paustint/bd18bd281134a180e014829b49ed043a to your computer and use it in GitHub Desktop.
Apex Utility Classes / CPQ Quote Calculator Plugin Example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* | |
* (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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* | |
* 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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 {} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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'; | |
} | |
} | |
} |
Hey @kevina-code!
Yeah, caching the results will definitely improve performance if calling the same describe call multiple times within a transaction.
Also, Salesforce does appear to perform some caching behind the scenes as well since subsequent calls to the same describe are faster after the first time.
One other optimization is that all calls should be using the SObjectDescribeOptions.DEFERRED
option so that child relationship describes don't get loaded until they are explicitly accessed with improve performance - Salesforce determined this was the most expensive part of describe calls and added this option.
@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
@paustint , long time no see. Hope all is well. Might I recommend ditching the global describe calls and going with something like this: