Created
February 3, 2024 22:41
-
-
Save sbutterfield/6e7e9aa9b55ca73d79c5abe9cc41393c to your computer and use it in GitHub Desktop.
Comprehensive trigger handler
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
public without sharing virtual class TriggerHandler { | |
/** VARIABLES **/ | |
// static map of handlername, times run() was invoked | |
private static Map<String, LoopCount> loopCountMap; //Map of handlername to the number of times run() was invoked | |
private static Set<String> bypassedHandlers; //Set of handlers who will be bypassed | |
private List<Business_Unit_Data__mdt> businessUnitDataList; //List of the business unit metadata records for an object | |
private String objectName; //Api name of the object on which triggger is running | |
private Map<String,Map<Id,sObject>> oldBusinessRecordMap; //Map of Business Unit name to the corresponding trigger.oldMap | |
private Map<String,Map<Id,sObject>> newBusinessRecordMap; //Map of Business Unit name to the corresponding trigger.newMap | |
private Map<String,List<sObject>> businessMap; //Map of Business Unit name to the corresponding trigger.new | |
private Map<String,String> serviceClassMap; //Map of Business Unit name to the corresponding service Apex class name | |
private static Map<String,Schema.SObjectField> objectFieldMap; //Map of fields of the current object | |
private static Boolean usePlatformCache=false; | |
@TestVisible | |
private Boolean isTriggerExecuting; //Context variable for trigger execution check | |
@TestVisible | |
private TriggerContext context; //Current context of the trigger being run | |
//Enumeration of all possible trigger contexts | |
private enum TriggerContext { | |
BEFORE_INSERT, BEFORE_UPDATE, BEFORE_DELETE, | |
AFTER_INSERT, AFTER_UPDATE, AFTER_DELETE, | |
AFTER_UNDELETE | |
} | |
/** STATIC BLOCKS & CONSTRUCTORS **/ | |
static { | |
loopCountMap = new Map<String, LoopCount>(); | |
bypassedHandlers = new Set<String>(); | |
} | |
public TriggerHandler() { | |
this.setTriggerContext(); | |
} | |
/** CONTEXT METHODS **/ | |
@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(){} | |
/** PUBLIC INSTANCE METHODS **/ | |
/** | |
* Main method to set the BU collections and call context methods | |
* | |
* @param objName the api name of the object in context | |
* @return none | |
*/ | |
public void run(String objName) { | |
if(!validateRun()) { | |
return; | |
} | |
this.objectName=objName; | |
fetchBusinessUnitData(); | |
populateSchemaFields(); | |
if(!this.businessUnitDataList.isEmpty()){ | |
populateBusinessUnitMaps(); | |
} | |
if(addToLoopCount()){ | |
return; | |
} | |
// dispatch to the correct handler method | |
switch on this.context { | |
when BEFORE_INSERT { | |
this.beforeInsert(); | |
} | |
when BEFORE_UPDATE { | |
this.beforeUpdate(); | |
} | |
when BEFORE_DELETE { | |
this.beforeDelete(); | |
} | |
when AFTER_INSERT { | |
CustomSharingHandler.initiateSharing(Trigger.new, null); | |
this.afterInsert(); | |
} | |
when AFTER_UPDATE { | |
CustomSharingHandler.initiateSharing(Trigger.new, Trigger.OldMap); | |
this.afterUpdate(); | |
} | |
when AFTER_DELETE { | |
this.afterDelete(); | |
} | |
when AFTER_UNDELETE { | |
this.afterUndelete(); | |
} | |
} | |
} | |
/** | |
* To set the max run count for the current handler | |
* | |
* @param max maximum count for consecutive trigger runs | |
* @return none | |
*/ | |
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); | |
} | |
} | |
/** | |
* To clear the max run count for the current handler | |
* | |
* @param none | |
* @return none | |
*/ | |
public void clearMaxLoopCount() { | |
this.setMaxLoopCount(-1); | |
} | |
/** | |
* To check if a field has changed or not for the passed in record | |
* | |
* @param fieldName api name of the field to check | |
* @param recordId Id of the record to check on | |
* @return True, if changed, otherwise False | |
*/ | |
public Boolean isFieldChanged(String fieldName,Id recordId){ | |
if(this.context!=TriggerContext.BEFORE_UPDATE && this.context!=TriggerContext.AFTER_UPDATE){ | |
throw new TriggerHandlerException('Method only available for update events.'); | |
} | |
return (Trigger.newMap.get(recordId).get(fieldName) != Trigger.oldMap.get(recordId).get(fieldName)); | |
} | |
/** | |
* To get all the updated fields for the passed in record | |
* | |
* @param recordId Id of the record to check on | |
* @return Set of field api names which have changed | |
*/ | |
public List<String> getUpdatedFields(Id recordId){ | |
if(this.context!=TriggerContext.BEFORE_UPDATE && this.context!=TriggerContext.AFTER_UPDATE){ | |
throw new TriggerHandlerException('Method only available for update events.'); | |
} | |
List<String> fieldsToReturn=new List<String>(); | |
for(String fieldName:objectFieldMap.keySet()){ | |
if(Trigger.newMap.get(recordId).get(fieldName) != Trigger.oldMap.get(recordId).get(fieldName)){ | |
fieldsToReturn.add(fieldName); | |
} | |
} | |
return fieldsToReturn; | |
} | |
/** PUBLIC STATIC METHODS **/ | |
/** | |
* To bypass a specific trigger handler class | |
* | |
* @param handlerName name of the trigger handler apex class to bypass | |
* @return none | |
*/ | |
public static void bypass(String handlerName) { | |
TriggerHandler.bypassedHandlers.add(handlerName); | |
} | |
/** | |
* To remove a bypass of a specific trigger handler class | |
* | |
* @param handlerName name of the trigger handler apex class to bypass | |
* @return none | |
*/ | |
public static void clearBypass(String handlerName) { | |
TriggerHandler.bypassedHandlers.remove(handlerName); | |
} | |
/** | |
* To check if a specific trigger handler class is getting bypassed in current context | |
* | |
* @param handlerName name of the trigger handler apex class to bypass | |
* @return True, if bypassed, otherwise False | |
*/ | |
public static Boolean isBypassed(String handlerName) { | |
return TriggerHandler.bypassedHandlers.contains(handlerName); | |
} | |
/** | |
* To clear all handler bypasses | |
* | |
* @param none | |
* @return none | |
*/ | |
public static void clearAllBypasses() { | |
TriggerHandler.bypassedHandlers.clear(); | |
} | |
/** PRIVATE INSTANCE METHODS **/ | |
/** | |
* To set the trigger context | |
* | |
* @param none | |
* @return none | |
*/ | |
@TestVisible | |
private void setTriggerContext() { | |
this.setTriggerContext(null, false); | |
} | |
/** | |
* To clear all handler bypasses | |
* | |
* @param ctx context of the trigger | |
* @param testMode whether the current context is of test class | |
* @return none | |
*/ | |
@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; | |
} | |
} | |
/** | |
* To increment the loop count | |
* | |
* @param none | |
* @return True, if max loop count exceeded, otherwise false | |
*/ | |
@TestVisible | |
private Boolean addToLoopCount() { | |
String handlerName = getHandlerName(); | |
if(TriggerHandler.loopCountMap.containsKey(handlerName)) { | |
return TriggerHandler.loopCountMap.get(handlerName).increment(); | |
} | |
return false; | |
} | |
/** | |
* To validate the trigger context | |
* | |
* @param none | |
* @return True, if validated, otherwise False | |
*/ | |
@TestVisible | |
private Boolean validateRun() { | |
if(!this.isTriggerExecuting || this.context == null) { | |
throw new TriggerHandlerException('Trigger handler called outside of Trigger execution'); | |
} | |
return !TriggerHandler.bypassedHandlers.contains(getHandlerName()); | |
} | |
/** | |
* To clear all handler bypasses | |
* | |
* @param none | |
* @return Name of the current trigger handler apex class | |
*/ | |
@TestVisible | |
private String getHandlerName() { | |
return String.valueOf(this).substring(0,String.valueOf(this).indexOf(':')); | |
} | |
/** | |
* To fetch the custom metdata records for business units | |
* | |
* @param none | |
* @return none | |
*/ | |
private void fetchBusinessUnitData(){ | |
this.businessUnitDataList=[SELECT Id, Apex_Class__c, BU_Logic_Fields__c, BU_Logic_Values__c, Schema_Storage_Type__c, Business_Unit_Name__c,Object__c | |
FROM Business_Unit_Data__mdt | |
WHERE Object__c=:this.objectName]; | |
this.serviceClassMap=new Map<String,String>(); | |
for(Business_Unit_Data__mdt objBU:this.businessUnitDataList){ | |
if(objBU.Business_Unit_Name__c=='Default'){ | |
usePlatformCache=objBU.Schema_Storage_Type__c=='Platform Cache'; | |
continue; | |
} | |
this.serviceClassMap.put(objBU.Business_Unit_Name__c,objBU.Apex_Class__c); | |
} | |
} | |
/** | |
* To populate the business unit record maps based on trigger context | |
* | |
* @param none | |
* @return none | |
*/ | |
private void populateBusinessUnitMaps(){ | |
try{ | |
this.oldBusinessRecordMap=new Map<String,Map<Id,sObject>>(); | |
this.newBusinessRecordMap=new Map<String,Map<Id,sObject>>(); | |
this.businessMap=new Map<String,List<sObject>>(); | |
if(this.businessUnitDataList.isEmpty()){ | |
throw new TriggerHandlerException('No business units present.'); | |
} | |
if(this.businessUnitDataList.size()==1){ | |
handleDefaultType(); | |
return; | |
} | |
List<String> buLogicFields= this.businessUnitDataList[0].BU_Logic_Fields__c.split(','); | |
Map<String,List<sObject>> businessUnitMap=new Map<String,List<sObject>>(); | |
Map<String,String> buValueToBuNameMap=new Map<String,String>(); | |
for(Business_Unit_Data__mdt objBU:this.businessUnitDataList){ | |
if(objBU.Business_Unit_Name__c=='Default'){ | |
continue; | |
} | |
this.businessMap.put(objBU.Business_Unit_Name__c,new List<sObject>()); | |
this.oldBusinessRecordMap.put(objBU.Business_Unit_Name__c,new Map<Id,sObject>()); | |
this.newBusinessRecordMap.put(objBU.Business_Unit_Name__c,new Map<Id,sObject>()); | |
buValueToBuNameMap.put(objBU.BU_Logic_Values__c,objBU.Business_Unit_Name__c); | |
} | |
List<sObject> newTriggerRecordList=Trigger.new != null?Trigger.new:new List<sObject>(); | |
List<sObject> oldTriggerRecordList=Trigger.old != null?Trigger.old:new List<sObject>(); | |
for(sObject sObj:newTriggerRecordList){ | |
String recordValueString=''; | |
for(String objFld:buLogicFields){ | |
String fieldVal=''; | |
if(objFld.contains('RecordType.')){ | |
fieldVal=getRecordTypeName(objFld,(Id)sObj.get('RecordTypeId')); | |
}else{ | |
fieldVal=String.valueOf(sObj.get(objFld)); | |
} | |
recordValueString+=(recordValueString=='')?fieldVal:(','+fieldVal); | |
} | |
String buName=buValueToBuNameMap.get(recordValueString); | |
this.businessMap.get(buName).add(sObj); | |
this.newBusinessRecordMap.get(buName).put((Id)sObj.get('Id'),sObj); | |
} | |
for(sObject sObj:oldTriggerRecordList){ | |
String recordValueString=''; | |
for(String objFld:buLogicFields){ | |
String fieldVal=''; | |
if(objFld.contains('RecordType.')){ | |
fieldVal=getRecordTypeName(objFld,(Id)sObj.get('RecordTypeId')); | |
}else{ | |
fieldVal=String.valueOf(sObj.get(objFld)); | |
} | |
recordValueString+=(recordValueString=='')?fieldVal:(','+fieldVal); | |
} | |
String buName=buValueToBuNameMap.get(recordValueString); | |
this.oldBusinessRecordMap.get(buName).put((Id)sObj.get('Id'),sObj); | |
} | |
}catch(Exception e){ | |
System.debug('populateBusinessUnitMaps Exception-> '+e.getLineNumber()); | |
} | |
} | |
/** | |
* To handle the case of object with single type of records | |
* | |
* @param none | |
* @return none | |
*/ | |
@TestVisible | |
private void handleDefaultType(){ | |
Business_Unit_Data__mdt defaultObj=this.businessUnitDataList[0]; | |
this.businessMap.put(defaultObj.Business_Unit_Name__c,new List<sObject>()); | |
this.oldBusinessRecordMap.put(defaultObj.Business_Unit_Name__c,new Map<Id,sObject>()); | |
this.newBusinessRecordMap.put(defaultObj.Business_Unit_Name__c,new Map<Id,sObject>()); | |
this.serviceClassMap.put(defaultObj.Business_Unit_Name__c,defaultObj.Apex_Class__c); | |
List<sObject> newTriggerRecordList=Trigger.new != null?Trigger.new:new List<sObject>(); | |
List<sObject> oldTriggerRecordList=Trigger.old != null?Trigger.old:new List<sObject>(); | |
for(sObject sObj:newTriggerRecordList){ | |
this.businessMap.get(defaultObj.Business_Unit_Name__c).add(sObj); | |
this.newBusinessRecordMap.get(defaultObj.Business_Unit_Name__c).put((Id)sObj.get('Id'),sObj); | |
} | |
for(sObject sObj:oldTriggerRecordList){ | |
this.oldBusinessRecordMap.get(defaultObj.Business_Unit_Name__c).put((Id)sObj.get('Id'),sObj); | |
} | |
} | |
/** | |
* To populate the schema map | |
* | |
* @param none | |
* @return none | |
*/ | |
private void populateSchemaFields(){ | |
objectFieldMap=getObjectSchemaMap(); | |
} | |
/** GETTER METHODS **/ | |
/** | |
* To get the name of the record type | |
* | |
* @param fieldName | |
* @param recordTypeId | |
* @return name of the recordtype | |
*/ | |
private String getRecordTypeName(String fieldName, Id recordTypeId){ | |
if(fieldName=='RecordType.DeveloperName'){ | |
return Schema.getGlobalDescribe().get(objectName).getDescribe().getRecordTypeInfosById().get(recordTypeId).getDeveloperName(); | |
}else if(fieldName=='RecordType.Name'){ | |
return Schema.getGlobalDescribe().get(objectName).getDescribe().getRecordTypeInfosById().get(recordTypeId).getName(); | |
} | |
return null; | |
} | |
/** | |
* To get the custom metadata records for business units | |
* | |
* @param none | |
* @return list of all the custom metadata type records for the current object | |
*/ | |
public List<Business_Unit_Data__mdt> getBusinessUnitData(){ | |
return this.businessUnitDataList; | |
} | |
/** | |
* To get the trigger.oldMap for passed in business unit | |
* | |
* @param businessUnitName name of the business unit to fetch the records for | |
* @return trigger.oldMap for the passed in business unit | |
*/ | |
public Map<Id,sObject> getBusinessUnitOldMap(String businessUnitName){ | |
if(!this.oldBusinessRecordMap.containsKey(businessUnitName)){ | |
throw new TriggerHandlerException('Business Unit not found.'); | |
} | |
return this.oldBusinessRecordMap.get(businessUnitName); | |
} | |
/** | |
* To get the trigger.newMap for passed in business unit | |
* | |
* @param businessUnitName name of the business unit to fetch the records for | |
* @return trigger.newMap for the passed in business unit | |
*/ | |
public Map<Id,sObject> getBusinessUnitNewMap(String businessUnitName){ | |
if(!this.newBusinessRecordMap.containsKey(businessUnitName)){ | |
throw new TriggerHandlerException('Business Unit not found.'); | |
} | |
return this.newBusinessRecordMap.get(businessUnitName); | |
} | |
/** | |
* To get the trigger.new for passed in business unit | |
* | |
* @param businessUnitName name of the business unit to fetch the records for | |
* @return trigger.new for the passed in business unit | |
*/ | |
public List<sObject> getBusinessUnitNewList(String businessUnitName){ | |
if(!this.businessMap.containsKey(businessUnitName)){ | |
throw new TriggerHandlerException('Business Unit not found.'); | |
} | |
return this.businessMap.get(businessUnitName); | |
} | |
/** | |
* To get all the business unit names | |
* | |
* @param none | |
* @return set of all business unit names present in custom metadata type for the current object | |
*/ | |
public Set<String> getBusinessUnitNames(){ | |
return this.newBusinessRecordMap.keySet(); | |
} | |
/** | |
* To get the apex service class for passed in business unit | |
* | |
* @param businessUnitName name of the business unit to fetch the records for | |
* @return name of the apex service class for the passed in business unit | |
*/ | |
public String getServiceClassName(String businessUnitName){ | |
if(!this.serviceClassMap.containsKey(businessUnitName)){ | |
throw new TriggerHandlerException('Business Unit not found.'); | |
} | |
return this.serviceClassMap.get(businessUnitName); | |
} | |
/** | |
* To get the field map for the current object | |
* | |
* @param none | |
* @return field map for the current object api name | |
*/ | |
public Map<String,Schema.sObjectField> getObjectSchemaMap(){ | |
if(usePlatformCache){ | |
Cache.OrgPartition orgPart = Cache.Org.getPartition('local.TriggerSchema'); | |
if(orgPart.get('ObjectDescribe')!=null){ | |
return ((Map<String, Map<String,Schema.SObjectField>>)orgPart.get('ObjectDescribe')).get(this.objectName); | |
} | |
} | |
return Schema.getGlobalDescribe().get(this.objectName).getDescribe().fields.getMap(); | |
} | |
/** 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() { | |
return this.max >= 0 && this.count > this.max; | |
} | |
public Integer getMax() { | |
return this.max; | |
} | |
public Integer getCount() { | |
return this.count; | |
} | |
public void setMax(Integer max) { | |
this.max = max; | |
} | |
} | |
/** | |
* Custom Exception class | |
*/ | |
public class TriggerHandlerException extends Exception {} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment