Skip to content

Instantly share code, notes, and snippets.

@cnaccio
Last active April 18, 2021 18:33
Show Gist options
  • Save cnaccio/4b1b44f49aa8cf4333a65aa9b3c32f23 to your computer and use it in GitHub Desktop.
Save cnaccio/4b1b44f49aa8cf4333a65aa9b3c32f23 to your computer and use it in GitHub Desktop.
Salesforce Apex CRUD/Field Level Security Implementation for DML Operations (needed to pass security review) [Note that this code leverages the fflib commons library and concepts from the DMLManager library]
/**
* @author Charles Naccio
* @date 5/31/2019
*
* @group Security
* @group-content ../../ApexDocContent/Security.htm
*
* @description A unit of work DML interface for executing secure database write operations
* based on user level permissions.
*/
public inherited sharing class pattern_SecureDML implements pattern_SObjectUnitOfWork.IDML {
/*******************************************************************************************************
* Class Properties
*/
/**
* @description Sobject types that crud/fls security has been disabled for.
*/
protected Set<SObjectType> disabledSecurity {get; private set;}
/*******************************************************************************************************
* Class Interface Methods
*/
/**
* @description Default Constructor.
*/
public pattern_SecureDML() {
// Security is not disabled for any sobject types by default
disabledSecurity = new Set<SObjectType>();
}
/**
* @description Construct with sobject types we want to disable security for
*/
public pattern_SecureDML(Set<SObjectType> disabledSecurity) {
this.disabledSecurity = disabledSecurity;
}
/**
* @description Insert given sobject records into database if user has appropriate permissions
* @param List of sobject records
* @example
* pattern_SecureDML.dmlInsert(new List<SObject>{*});
*/
public void dmlInsert(List<SObject> records) {
if (records == null || records.size() == 0) return; // avoid wasting any cycles
// Check CRUD and FLS before inserting records
if (enforcingSecurity(getSobjectType(records)))
checkInsertSecurity(records);
// Insert records if above security check passed
insert records;
}
/**
* @description Update given sobject records in database if user has appropriate permissions
* @param List of sobject records
* @example
* pattern_SecureDML.dmlUpdate(new List<SObject>{*});
*/
public void dmlUpdate(List<SObject> records) {
if (records == null || records.size() == 0) return; // avoid wasting any cycles
// Check CRUD and FLS before updating records, if not handled elsewhere
if (!pattern_SObjectUnitOfWork.isCommitting) // a commit indicates security is being handled elsewhere
if (enforcingSecurity(getSobjectType(records)))
checkUpdateSecurity(records);
// Update records if above security check passed
update records;
}
/**
* @description Delete given sobject records from database if user has appropriate permissions
* @param List of sobject records
* @example
* pattern_SecureDML.dmlDelete(new List<SObject>{*});
*/
public void dmlDelete(List<SObject> records) {
if (records == null || records.size() == 0) return; // avoid wasting any cycles
// Check CRUD security before deleting records
if (enforcingSecurity(getSobjectType(records)))
checkDeleteSecurity(records);
// Delete records if above security check passed
delete records;
}
/**
* @description Publish platform events for given records
* @param List of sobject records
* @example
* pattern_SecureDML.eventPublish(new List<SObject>{*});
*/
public void eventPublish(List<SObject> records) {
if (records == null || records.size() == 0) return; // avoid wasting any cycles
EventBus.publish(records);
}
/**
* @description Enable or disable CRUD/Field Level Security enforcement. Use the disable method sparingly
* and only when you believe the DML being executed is purely system level and not user level
* @param SObject type you want to enable/disable security enforcement for
* @param Whether or not you want to enable or disable security
*/
public void enforceSecurity(SObjectType sObjectType, Boolean enable) {
if(enable)
disabledSecurity.remove(sObjectType);
else
disabledSecurity.add(sObjectType);
}
/**
* @description Confirm if CRUD/Field level security is being enforced for given SObject type.
* @param SObject type you want to check security enforcement for
*/
public boolean enforcingSecurity(SObjectType sObjectType) {
return disabledSecurity.contains(sObjectType) ? false : true;
}
/*******************************************************************************************************
* Check Security Methods
*/
/**
* @description Scans given records and populated fields and ensures user has permission to create
* those records and set those fields. Throws SecurityException (crud or fls) if user does not have
* appropriate permissions.
* @param List of sobject records
* @example
* pattern_SecureDML.checkInsertSecurity(new List<Project__c>{*});
*/
private static void checkInsertSecurity(List<SObject> records) {
// Scan records for populated fields, for each sobject type
Map<SObjectType, List<String>> populatedFields = resolvePopulatedFields(records);
// Check field level security for all populated fields within each given sobject type
for (SObjectType sObjectType : populatedFields.keySet())
// Check populated fields against user's permissions and throw an exception for any failures
pattern_SecurityUtils.checkInsert(sObjectType, populatedFields.get(sObjectType));
}
/**
* @description Scans given records and changed fields and ensures user has permission to update
* those records and/or fields. Throws SecurityException (crud or fls) if user does not have
* appropriate permissions.
* @param List of sobject records
* @example
* pattern_SecureDML.checkUpdateSecurity(new List<Project__c>{*});
*/
private static void checkUpdateSecurity(List<SObject> records) {
// Scan records for updated fields, for each sobject type
Map<SObjectType, List<String>> updatedFields = resolveUpdatedFields(records);
// Check field level security for all updated fields within each given sobject type
for (SObjectType sObjectType : updatedFields.keySet())
// Check updated fields against user's permissions and throw an exception for any failures
pattern_SecurityUtils.checkUpdate(sObjectType, updatedFields.get(sObjectType));
}
/**
* @description Checks that user has permission to delete given records. Throws SecurityException (crud) if
* user does not have appropriate permissions.
* @param List of sobject records
* @example
* pattern_SecureDML.checkDeleteSecurity(new List<Project__c>{*});
*/
private static void checkDeleteSecurity(List<SObject> records) {
// Check each sobject type for given records
for (SObjectType sObjectType : groupBySObjectType(records).keySet())
// Check user's permission to delete records, and throw exception for any failures
pattern_SecurityUtils.checkObjectIsDeletable(sObjectType);
}
/*******************************************************************************************************
* Class General Methods
*/
/**
* @description Resolve populated fields for/from given record
* @param Sobject record
* @return Populated fields
* @example
* Set<String> populatedFields = pattern_SecureDML.resolvePopulatedFields(new SObject(*));
*/
private static Set<String> resolvePopulatedFields(SObject record) {
// Grab populated fields
List<String> populatedFields = new List<String>(record.getPopulatedFieldsAsMap().keySet());
// Remove relationship fields as they're not needed for dml security validation
if (populatedFields != null)
for (Integer i = populatedFields.size() - 1; i >= 0 ; i--) {
if (populatedFields.get(i).endsWithIgnoreCase('__r'))
populatedFields.remove(i);
}
// Return populated fields to caller
return new Set<String>(populatedFields);
/**
* NOTE: Please upvote https://success.salesforce.com/ideaView?id=08730000000l5vbAAA)
*/
}
/**
* @description Resolve populated fields for/from given records
* @param List of sobject records
* @return Map of populated fields by sobject type
* @example
* Map<SObjectType, List<String>> populatedFields =
* pattern_SecureDML.resolvePopulatedFields(new List<SObject>(*));
*/
private static Map<SObjectType, List<String>> resolvePopulatedFields(List<SObject> records) {
Map<SObjectType, List<String>> populatedFieldsByType = new Map<SObjectType, List<String>>();
Set<String> populatedFields = new Set<String>();
SObjectType sObjectType; // there's always only one sobject type
for(SObject record : records) {
if (sObjectType == null)
sObjectType = record.getSObjectType();
populatedFields.addAll(resolvePopulatedFields(record));
}
if (populatedFields.size() > 0)
populatedFieldsByType.put(sObjectType, new List<String>(populatedFields));
return populatedFieldsByType;
}
/**
* @description Resolve updated fields for/from given record, and previous record
* @param Sobject record
* @param Previous version of sobject record; prior to updates.
* @return Updated fields
* @example
* Set<String> updatedFields = pattern_SecureDML.resolveUpdatedFields(new SObject(*), new SObject(*));
*/
public static Set<String> resolveUpdatedFields(SObject record, SObject previousRecord) {
Set<String> updatedFields = new Set<String>();
// Scan through each populated field for given record
for(String populatedField : resolvePopulatedFields(record)) {
// Populated field is considered updated if it's value is different from the previous record
if (record.get(populatedField) != previousRecord.get(populatedField)) {
updatedFields.add(populatedField);
}
}
// Return updated fields to caller
return updatedFields;
}
/**
* @description Resolve updated fields for/from given records
* @param List of sobject records
* @return Map of updated fields by sobject type
* @example
* Map<SObjectType, List<String>> updatedFields =
* pattern_SecureDML.resolveUpdatedFields(new List<SObject>(*));
*/
private static Map<SObjectType, List<String>> resolveUpdatedFields(List<SObject> records) {
Map<SObjectType, List<String>> updatedFieldsByType = new Map<SObjectType, List<String>>();
// Get populated fields and sobject type for given records
Map<SObjectType, List<String>> populatedFields = resolvePopulatedFields(records);
for (SObjectType sObjectType : populatedFields.keySet()) {
// Grab previous/existing records
Map<Id, SObject> previousRecords = getPreviousRecords(
records,
sObjectType,
populatedFields.get(sObjectType)
);
// Figure out which fields have been updated
Set<String> updatedFields = new Set<String>();
for(SObject record : records)
updatedFields.addAll(resolveUpdatedFields(record, previousRecords.get(record.Id)));
// Group updated fields by sobject type
if (updatedFields.size() > 0)
updatedFieldsByType.put(sObjectType, new List<String>(updatedFields));
// Break loop, as we'll always have only a single sobject type and thus one iteration
break;
}
// Return updated fields by sobject type to caller
return updatedFieldsByType;
}
/**
* @description Get current version of given records as they current are in the database
* @param List of sobject records
* @param Sobject type of given records
* @param Fields to query from previous records
* @return Map of current/previous version of given records, grouped by record id.
* @example
* Map<Id, SObject> previousRecords = pattern_SecureDML.getPreviousRecords(new List<SObject>{*});
*/
private static Map<Id, SObject> getPreviousRecords(
List<SObject> records,
SObjectType sObjectType,
List<String> fields
) {
// Setup sobject query
pattern_QueryFactory query = Application.Selector.newInstance(sObjectType)
.newQueryFactory(false)
.selectFields(fields)
.setCondition('Id IN :records')
.clearOrdering();
// Execute query and return previous records to caller
return new Map<Id, SObject>(Database.query(query.toSOQL()));
}
/**
* @description Return given records grouped by sobject type
* @param List of sobject records
* @return Map of records by sobject type
* @example
* Map<SObjectType, List<SObject>> recordsBySObjectType = pattern_SecureDML.groupBySObjectType(new List<SObject>(*));
*/
private static Map<SObjectType, List<SObject>> groupBySObjectType(List<SObject> records) {
Map<SObjectType, List<SObject>> recordsBySObjectType = new Map<SObjectType, List<SObject>>();
for (SObject record : records) {
SObjectType sObjectType = record.getSObjectType();
if (!recordsBySObjectType.containsKey(sObjectType))
recordsBySObjectType.put(sObjectType, records);
break; // records will always of a single sobject type
}
return recordsBySObjectType;
}
/**
* @description Grab sobject type for given records (it'll always be a single type)
* @param List of sobject records
* @return Sobject type of given records
* @example
* SObjectType sObjectType = pattern_SecureDML.getSobjectType(new List<SObject>{*});
*/
private static SObjectType getSobjectType(List<SObject> records) {
for (SObject record : records) {
return record.getSObjectType();
}
return null;
}
}
/**
* Copyright (c), FinancialForce.com, inc
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of the FinancialForce.com, inc nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
* OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**/
/**
* Provides an implementation of the Enterprise Application Architecture Unit Of Work, as defined by Martin Fowler
* http://martinfowler.com/eaaCatalog/unitOfWork.html
*
* "When you're pulling data in and out of a database, it's important to keep track of what you've changed; otherwise,
* that data won't be written back into the database. Similarly you have to insert new objects you create and
* remove any objects you delete."
*
* "You can change the database with each change to your object model, but this can lead to lots of very small database calls,
* which ends up being very slow. Furthermore it requires you to have a transaction open for the whole interaction, which is
* impractical if you have a business transaction that spans multiple requests. The situation is even worse if you need to
* keep track of the objects you've read so you can avoid inconsistent reads."
*
* "A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done,
* it figures out everything that needs to be done to alter the database as a result of your work."
*
* In an Apex context this pattern provides the following specific benefits
* - Applies bulkfication to DML operations, insert, update and delete
* - Manages a business transaction around the work and ensures a rollback occurs (even when exceptions are later handled by the caller)
* - Honours dependency rules between records and updates dependent relationships automatically during the commit
*
* Please refer to the testMethod's in this class for example usage
*
* TODO: Need to complete the 100% coverage by covering parameter exceptions in tests
* TODO: Need to add some more test methods for more complex use cases and some unexpected (e.g. registerDirty and then registerDeleted)
*
**/
public virtual class pattern_SObjectUnitOfWork
implements pattern_ISObjectUnitOfWork
{
protected List<Schema.SObjectType> m_sObjectTypes = new List<Schema.SObjectType>();
protected Map<String, List<SObject>> m_newListByType = new Map<String, List<SObject>>();
protected Map<String, Map<Id, SObject>> m_dirtyMapByType = new Map<String, Map<Id, SObject>>();
protected Map<String, Map<Id, SObject>> m_deletedMapByType = new Map<String, Map<Id, SObject>>();
protected Map<String, Relationships> m_relationships = new Map<String, Relationships>();
protected Map<String, List<SObject>> m_publishBeforeListByType = new Map<String, List<SObject>>();
protected Map<String, List<SObject>> m_publishAfterSuccessListByType = new Map<String, List<SObject>>();
protected Map<String, List<SObject>> m_publishAfterFailureListByType = new Map<String, List<SObject>>();
protected List<IDoWork> m_workList = new List<IDoWork>();
@TestVisible
protected IEmailWork m_emailWork = new SendEmailWork();
protected IDML m_dml;
/**
* True if the unit of work is executing the commitWork method
**/
public static Boolean isCommitting {
get {
return CurrentCommits != null && CurrentCommits.size() > 0;
}
private set;
}
/**
* If a unit of work is executing the commitWork method represents information about that unit of work
**/
public static List<CommitScope> CurrentCommits {
get {
if (CurrentCommits == null)
CurrentCommits = new List<CommitScope>();
return CurrentCommits;
}
private set;
}
/**
* Latest commit added to commit scope/stack.
**/
public static CommitScope LastCommit {
get {
if (isCommitting)
return CurrentCommits.get(CurrentCommits.size() - 1);
return null;
}
private set;
}
/**
* Interface describes work to be performed during the commitWork method
**/
public interface IDoWork
{
void doWork();
}
public interface IDML
{
void dmlInsert(List<SObject> objList);
void dmlUpdate(List<SObject> objList);
void dmlDelete(List<SObject> objList);
void eventPublish(List<SObject> objList);
void enforceSecurity(SObjectType sObjectType, Boolean enable);
boolean enforcingSecurity(SObjectType sObjectType);
}
/**
* Constructs a new UnitOfWork to support work against the given object list
*
* @param sObjectList A list of objects given in dependency order (least dependent first)
*/
public pattern_SObjectUnitOfWork(List<Schema.SObjectType> sObjectTypes)
{
this(sObjectTypes,new SystemDML());
}
public pattern_SObjectUnitOfWork(List<Schema.SObjectType> sObjectTypes, IDML dml)
{
m_sObjectTypes = sObjectTypes.clone();
for (Schema.SObjectType sObjectType : m_sObjectTypes)
{
// register the type
handleRegisterType(sObjectType);
}
m_relationships.put(Messaging.SingleEmailMessage.class.getName(), new Relationships());
m_dml = dml;
}
// default implementations for commitWork events
public virtual void onRegisterType(Schema.SObjectType sObjectType) {}
public virtual void onCommitWorkStarting() {}
public virtual void onPublishBeforeEventsStarting() {}
public virtual void onPublishBeforeEventsFinished() {}
public virtual void onDMLStarting() {}
public virtual void onDMLFinished() {}
public virtual void onDoWorkStarting() {}
public virtual void onDoWorkFinished() {}
public virtual void onPublishAfterSuccessEventsStarting() {}
public virtual void onPublishAfterSuccessEventsFinished() {}
public virtual void onPublishAfterFailureEventsStarting() {}
public virtual void onPublishAfterFailureEventsFinished() {}
public virtual void onCommitWorkFinishing() {}
public virtual void onCommitWorkFinished(Boolean wasSuccessful) {}
/**
* Registers the type to be used for DML operations
*
* @param sObjectType - The type to register
*
*/
private void handleRegisterType(Schema.SObjectType sObjectType)
{
String sObjectName = sObjectType.getDescribe().getName();
// add type to dml operation tracking
m_newListByType.put(sObjectName, new List<SObject>());
m_dirtyMapByType.put(sObjectName, new Map<Id, SObject>());
m_deletedMapByType.put(sObjectName, new Map<Id, SObject>());
m_relationships.put(sObjectName, new Relationships());
m_publishBeforeListByType.put(sObjectName, new List<SObject>());
m_publishAfterSuccessListByType.put(sObjectName, new List<SObject>());
m_publishAfterFailureListByType.put(sObjectName, new List<SObject>());
// give derived class chance to register the type
onRegisterType(sObjectType);
}
/**
* Register a generic peace of work to be invoked during the commitWork phase
**/
public void registerWork(IDoWork work)
{
m_workList.add(work);
}
/**
* Registers the given email to be sent during the commitWork
**/
public void registerEmail(Messaging.Email email)
{
m_emailWork.registerEmail(email);
}
/**
* Register a newly created SObject instance to be inserted when commitWork is called
*
* @param record A newly created SObject instance to be inserted during commitWork
**/
public void registerNew(SObject record)
{
registerNew(record, null, null);
}
/**
* Register a list of newly created SObject instances to be inserted when commitWork is called
*
* @param records A list of newly created SObject instances to be inserted during commitWork
**/
public void registerNew(List<SObject> records)
{
for (SObject record : records)
{
registerNew(record, null, null);
}
}
/**
* Register a newly created SObject instance to be inserted when commitWork is called,
* you may also provide a reference to the parent record instance (should also be registered as new separately)
*
* @param record A newly created SObject instance to be inserted during commitWork
* @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent
* @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately)
**/
public void registerNew(SObject record, Schema.sObjectField relatedToParentField, SObject relatedToParentRecord)
{
if (record.Id != null)
throw new UnitOfWorkException('Only new records can be registered as new');
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForNonEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_newListByType, sObjectType);
m_newListByType.get(sObjectType).add(record);
if (relatedToParentRecord!=null && relatedToParentField!=null)
registerRelationship(record, relatedToParentField, relatedToParentRecord);
}
/**
* Register a relationship between two records that have yet to be inserted to the database. This information will be
* used during the commitWork phase to make the references only when related records have been inserted to the database.
*
* @param record An existing or newly created record
* @param relatedToField A SObjectField reference to the lookup field that relates the two records together
* @param relatedTo A SObject instance (yet to be committed to the database)
*/
public void registerRelationship(SObject record, Schema.sObjectField relatedToField, SObject relatedTo)
{
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForNonEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_newListByType, sObjectType);
m_relationships.get(sObjectType).add(record, relatedToField, relatedTo);
}
/**
* Registers a relationship between a record and a Messaging.Email where the record has yet to be inserted
* to the database. This information will be
* used during the commitWork phase to make the references only when related records have been inserted to the database.
*
* @param a single email message instance
* @param relatedTo A SObject instance (yet to be committed to the database)
*/
public void registerRelationship( Messaging.SingleEmailMessage email, SObject relatedTo )
{
m_relationships.get( Messaging.SingleEmailMessage.class.getName() ).add(email, relatedTo);
}
/**
* Register an existing record to be updated during the commitWork method
*
* @param record An existing record
**/
public void registerDirty(SObject record)
{
registerDirty(record, new List<SObjectField>());
}
public void registerDirty(SObject record, List<SObjectField> dirtyFields)
{
if (record.Id == null)
throw new UnitOfWorkException('New records cannot be registered as dirty');
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForNonEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_dirtyMapByType, sObjectType);
// If record isn't registered as dirty, or no dirty fields to drive a merge
if (!m_dirtyMapByType.get(sObjectType).containsKey(record.Id) || dirtyFields.isEmpty())
{
// Register the record as dirty
m_dirtyMapByType.get(sObjectType).put(record.Id, record);
}
else
{
// Update the registered record's fields
SObject registeredRecord = m_dirtyMapByType.get(sObjectType).get(record.Id);
for (SObjectField dirtyField : dirtyFields) {
registeredRecord.put(dirtyField, record.get(dirtyField));
}
m_dirtyMapByType.get(sObjectType).put(record.Id, registeredRecord);
}
}
/**
* Register an existing record to be updated when commitWork is called,
* you may also provide a reference to the parent record instance (should also be registered as new separately)
*
* @param record A newly created SObject instance to be inserted during commitWork
* @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent
* @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately)
**/
public void registerDirty(SObject record, Schema.sObjectField relatedToParentField, SObject relatedToParentRecord)
{
if (record.Id == null)
throw new UnitOfWorkException('New records cannot be registered as dirty');
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForNonEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_dirtyMapByType, sObjectType);
m_dirtyMapByType.get(sObjectType).put(record.Id, record);
if (relatedToParentRecord!=null && relatedToParentField!=null)
registerRelationship(record, relatedToParentField, relatedToParentRecord);
}
/**
* Register a list of existing records to be updated during the commitWork method
*
* @param records A list of existing records
**/
public void registerDirty(List<SObject> records)
{
for (SObject record : records)
{
this.registerDirty(record);
}
}
/**
* Register a new or existing record to be inserted/updated during the commitWork method
*
* @param record A new or existing record
**/
public void registerUpsert(SObject record)
{
if (record.Id == null)
{
registerNew(record, null, null);
}
else
{
registerDirty(record, new List<SObjectField>());
}
}
/**
* Register a list of mix of new and existing records to be inserted updated during the commitWork method
*
* @param records A list of mix of new and existing records
**/
public void registerUpsert(List<SObject> records)
{
for (SObject record : records)
{
this.registerUpsert(record);
}
}
/**
* Register an existing record to be deleted during the commitWork method
*
* @param record An existing record
**/
public void registerDeleted(SObject record)
{
if (record.Id == null)
throw new UnitOfWorkException('New records cannot be registered for deletion');
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForNonEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_deletedMapByType, sObjectType);
m_deletedMapByType.get(sObjectType).put(record.Id, record);
}
/**
* Register a list of existing records to be deleted during the commitWork method
*
* @param records A list of existing records
**/
public void registerDeleted(List<SObject> records)
{
for (SObject record : records)
{
this.registerDeleted(record);
}
}
/**
* Register a newly created SObject (Platform Event) instance to be published when commitWork is called
*
* @param record A newly created SObject (Platform Event) instance to be inserted during commitWork
**/
public void registerPublishBeforeTransaction(SObject record)
{
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_publishBeforeListByType, sObjectType);
m_publishBeforeListByType.get(sObjectType).add(record);
}
/**
* Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called
*
* @param records A list of existing records
**/
public void registerPublishBeforeTransaction(List<SObject> records)
{
for (SObject record : records)
{
this.registerPublishBeforeTransaction(record);
}
}
/**
* Register a newly created SObject (Platform Event) instance to be published when commitWork is called
*
* @param record A newly created SObject (Platform Event) instance to be inserted during commitWork
**/
public void registerPublishAfterSuccessTransaction(SObject record)
{
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_publishAfterSuccessListByType, sObjectType);
m_publishAfterSuccessListByType.get(sObjectType).add(record);
}
/**
* Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called
*
* @param records A list of existing records
**/
public void registerPublishAfterSuccessTransaction(List<SObject> records)
{
for (SObject record : records)
{
this.registerPublishAfterSuccessTransaction(record);
}
}
/**
* Register a newly created SObject (Platform Event) instance to be published when commitWork is called
*
* @param record A newly created SObject (Platform Event) instance to be inserted during commitWork
**/
public void registerPublishAfterFailureTransaction(SObject record)
{
String sObjectType = record.getSObjectType().getDescribe().getName();
assertForEventSObjectType(sObjectType);
assertForSupportedSObjectType(m_publishAfterFailureListByType, sObjectType);
m_publishAfterFailureListByType.get(sObjectType).add(record);
}
/**
* Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called
*
* @param records A list of existing records
**/
public void registerPublishAfterFailureTransaction(List<SObject> records)
{
for (SObject record : records)
{
this.registerPublishAfterFailureTransaction(record);
}
}
/**
* Disable CRUD and FLS security for current unit of work. This method should be used sparingly,
* and only when you're sure you want to execute with system level access.
**/
public void disableSecurity(SObjectType sObjectType) {
this.m_dml.enforceSecurity(sObjectType, false);
}
/**
* Enable CRUD and FLS security for current selector/query.
**/
public void enableSecurity(SObjectType sObjectType) {
this.m_dml.enforceSecurity(sObjectType, true);
}
/**
* Takes all the work that has been registered with the UnitOfWork and commits it to the database
**/
public void commitWork()
{
Savepoint sp = Database.setSavePoint();
Boolean wasSuccessful = false;
try
{
doCommitWork();
wasSuccessful = true;
}
catch (Exception e)
{
Database.rollback(sp);
throw e;
}
finally
{
doAfterCommitWorkSteps(wasSuccessful);
}
}
private void doCommitWork()
{
onCommitWorkStarting();
onPublishBeforeEventsStarting();
publishBeforeEventsStarting();
onPublishBeforeEventsFinished();
onDMLStarting();
insertDmlByType();
updateDmlByType();
deleteDmlByType();
resolveEmailRelationships();
onDMLFinished();
onDoWorkStarting();
doWork();
onDoWorkFinished();
onCommitWorkFinishing();
}
private void doAfterCommitWorkSteps(Boolean wasSuccessful)
{
if (wasSuccessful)
{
doAfterCommitWorkSuccessSteps();
}
else
{
doAfterCommitWorkFailureSteps();
}
onCommitWorkFinished(wasSuccessful);
}
private void doAfterCommitWorkSuccessSteps()
{
onPublishAfterSuccessEventsStarting();
publishAfterSuccessEvents();
onPublishAfterSuccessEventsFinished();
}
private void doAfterCommitWorkFailureSteps()
{
onPublishAfterFailureEventsStarting();
publishAfterFailureEvents();
onPublishAfterFailureEventsFinished();
}
private void publishBeforeEventsStarting()
{
for (Schema.SObjectType sObjectType : m_sObjectTypes)
{
m_dml.eventPublish(m_publishBeforeListByType.get(sObjectType.getDescribe().getName()));
}
}
private void insertDmlByType()
{
for (Schema.SObjectType sObjectType : m_sObjectTypes)
{
m_relationships.get(sObjectType.getDescribe().getName()).resolve();
m_dml.dmlInsert(m_newListByType.get(sObjectType.getDescribe().getName()));
}
}
private void updateDmlByType()
{
for (Schema.SObjectType sObjectType : m_sObjectTypes)
{
try {
// Register commit so that our downstream pattern_SObjectDomain can leverage for CRUD/FLS enforcement
List<SObject> recordsToUpdate = m_dirtyMapByType.get(sObjectType.getDescribe().getName()).values();
if (recordsToUpdate == null || recordsToUpdate.size() == 0) continue; // avoid wasting cycles below
CommitScope newCommit = new CommitScope(this);
newCommit.Records = new Map<Id, SObject>(recordsToUpdate);
CurrentCommits.add(newCommit); // push in case trigger also utilizes the unit of work
// Perform DML to update records
m_relationships.get(sObjectType.getDescribe().getName()).resolve();
m_dml.dmlUpdate(recordsToUpdate);
}
finally {
// Indicate to others the unit of work is no longer committing
if (isCommitting)
CurrentCommits.remove(CurrentCommits.size() - 1); // Pop last commit from scope/stack
}
}
}
private void deleteDmlByType()
{
Integer objectIdx = m_sObjectTypes.size() - 1;
while (objectIdx >= 0)
{
m_dml.dmlDelete(m_deletedMapByType.get(m_sObjectTypes[objectIdx--].getDescribe().getName()).values());
}
}
private void resolveEmailRelationships()
{
m_relationships.get(Messaging.SingleEmailMessage.class.getName()).resolve();
}
private void doWork()
{
m_workList.add(m_emailWork);
for (IDoWork work : m_workList)
{
work.doWork();
}
}
private void publishAfterSuccessEvents()
{
for (Schema.SObjectType sObjectType : m_sObjectTypes)
{
m_dml.eventPublish(m_publishAfterSuccessListByType.get(sObjectType.getDescribe().getName()));
}
}
private void publishAfterFailureEvents()
{
for (Schema.SObjectType sObjectType : m_sObjectTypes)
{
m_dml.eventPublish(m_publishAfterFailureListByType.get(sObjectType.getDescribe().getName()));
}
}
private void assertForNonEventSObjectType(String sObjectType)
{
if (sObjectType.length() > 3 && sObjectType.right(3) == '__e')
{
throw new UnitOfWorkException(
String.format(
'SObject type {0} must use registerPublishBeforeTransaction or ' +
'registerPublishAfterTransaction methods to be used within this unit of work',
new List<String> { sObjectType }
)
);
}
}
private void assertForEventSObjectType(String sObjectType)
{
if (sObjectType.length() > 3 && sObjectType.right(3) != '__e')
{
throw new UnitOfWorkException(
String.format(
'SObject type {0} is invalid for publishing within this unit of work',
new List<String> {sObjectType}
)
);
}
}
private void assertForSupportedSObjectType(Map<String, Object> theMap, String sObjectType)
{
if (!theMap.containsKey(sObjectType))
{
throw new UnitOfWorkException(
String.format(
'SObject type {0} is not supported by this unit of work',
new List<String> { sObjectType }
)
);
}
}
private class Relationships
{
private List<IRelationship> m_relationships = new List<IRelationship>();
public void resolve()
{
// Resolve relationships
for (IRelationship relationship : m_relationships)
{
//relationship.Record.put(relationship.RelatedToField, relationship.RelatedTo.Id);
relationship.resolve();
}
}
public void add(SObject record, Schema.sObjectField relatedToField, SObject relatedTo)
{
// Relationship to resolve
Relationship relationship = new Relationship();
relationship.Record = record;
relationship.RelatedToField = relatedToField;
relationship.RelatedTo = relatedTo;
m_relationships.add(relationship);
}
public void add(Messaging.SingleEmailMessage email, SObject relatedTo)
{
EmailRelationship emailRelationship = new EmailRelationship();
emailRelationship.email = email;
emailRelationship.relatedTo = relatedTo;
m_relationships.add(emailRelationship);
}
}
private interface IRelationship
{
void resolve();
}
private class Relationship implements IRelationship
{
public SObject Record;
public Schema.sObjectField RelatedToField;
public SObject RelatedTo;
public void resolve()
{
// check relationship Id due to update use.
if (this.Record.get(this.RelatedToField) == null) {
this.Record.put( this.RelatedToField, this.RelatedTo.Id);
}
}
}
private class EmailRelationship implements IRelationship
{
public Messaging.SingleEmailMessage email;
public SObject relatedTo;
public void resolve()
{
this.email.setWhatId( this.RelatedTo.Id );
}
}
/**
* UnitOfWork Exception
**/
public class UnitOfWorkException extends Exception {}
/**
* Internal implementation of Messaging.sendEmail, see outer class registerEmail method
**/
public interface IEmailWork extends IDoWork
{
void registerEmail(Messaging.Email email);
}
private class SendEmailWork implements IEmailWork
{
private List<Messaging.Email> emails;
public SendEmailWork()
{
this.emails = new List<Messaging.Email>();
}
public void registerEmail(Messaging.Email email)
{
this.emails.add(email);
}
public void doWork()
{
if (emails.size() > 0) Messaging.sendEmail(emails);
}
}
/**
* @description Simple dml implementation which runs system level access
*/
public class SystemDML implements IDML
{
public void dmlInsert(List<SObject> objList)
{
insert objList;
}
public void dmlUpdate(List<SObject> objList)
{
update objList;
}
public void dmlDelete(List<SObject> objList)
{
delete objList;
}
public void eventPublish(List<SObject> objList)
{
EventBus.publish(objList);
}
public void enforceSecurity(SObjectType sObjectType, Boolean enable) {}
public boolean enforcingSecurity(SObjectType sObjectType) { return false; }
}
/**
* Class used to express information about a commit in progress (used by pattern_SObjectDomain to determine if
* security enforcement should be applied)
**/
public class CommitScope
{
private pattern_SObjectUnitOfWork m_uow;
/**
* Exposes the records being updated as part of the commit scope
**/
public Map<Id, SObject> Records {get; private set;}
private CommitScope(pattern_SObjectUnitOfWork uow)
{
m_uow = uow;
}
/**
* Confirm if the given SObject type requires security enforcement (via IDML implementation)
* within this current commit scope
**/
public boolean enforcingSecurity(SObjectType sObjectType)
{
return m_uow.m_dml.enforcingSecurity(sObjectType);
}
}
}
/**
* Copyright (c) 2012, FinancialForce.com, inc
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of the FinancialForce.com, inc nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
* OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**/
/**
* Base class aiding in the implementation of a Domain Model around SObject collections
*
* Domain (software engineering). “a set of common requirements, terminology, and functionality
* for any software program constructed to solve a problem in that field”,
* http://en.wikipedia.org/wiki/Domain_(software_engineering)
*
* Domain Model, “An object model of the domain that incorporates both behavior and data.”,
* “At its worst business logic can be very complex. Rules and logic describe many different "
* "cases and slants of behavior, and it's this complexity that objects were designed to work with...”
* Martin Fowler, EAA Patterns
* http://martinfowler.com/eaaCatalog/domainModel.html
*
**/
public virtual with sharing class pattern_SObjectDomain extends Application
implements pattern_ISObjectDomain
{
/**
* Provides access to the data represented by this domain class
**/
public List<SObject> Records { get; private set;}
/**
* Derived from the records provided during construction, provides the native describe for the standard or custom object
**/
public Schema.DescribeSObjectResult SObjectDescribe {get; private set;}
/**
* Exposes the configuration for this domain class instance
**/
public Configuration Configuration {get; private set;}
/**
* Useful during unit testign to assert at a more granular and robust level for errors raised during the various trigger events
**/
public static ErrorFactory Errors {get; private set;}
/**
* Useful during unit testing to access mock support for database inserts and updates (testing without DML)
**/
public static TestFactory Test {get; private set;}
/**
* Retains instances of domain classes implementing trigger stateful
**/
private static Map<Type, List<pattern_SObjectDomain>> TriggerStateByClass;
/**
* Retains the trigger tracking configuration used for each domain
**/
private static Map<Type, TriggerEvent> TriggerEventByClass;
static
{
Errors = new ErrorFactory();
Test = new TestFactory();
TriggerStateByClass = new Map<Type, List<pattern_SObjectDomain>>();
TriggerEventByClass = new Map<Type, TriggerEvent>();
}
/**
* Constructor required for Test.createStub
**/
public pattern_SObjectDomain() {
super();
System.assert(System.Test.isRunningTest());
}
/**
* Constructs the domain class with the data on which to apply the behaviour implemented within
*
* @param sObjectList A concrete list (e.g. List<Account> vs List<SObject>) of records
**/
public pattern_SObjectDomain(List<SObject> sObjectList)
{
this(sObjectList, sObjectList.getSObjectType());
}
/**
* Constructs the domain class with the data and type on which to apply the behaviour implemented within
*
* @param sObjectList A list (e.g. List<Contact>, List<Account>, etc.) of records
* @param sObjectType The Schema.SObjectType of the records contained in the list
*
* @remark Will support List<SObject> but all records in the list will be assumed to be of
* the type specified in sObjectType
**/
public pattern_SObjectDomain(List<SObject> sObjectList, SObjectType sObjectType)
{
// Ensure the domain class has its own copy of the data
Records = sObjectList.clone();
// Capture SObjectType describe for this domain class
SObjectDescribe = sObjectType.getDescribe();
// Configure the Domain object instance
Configuration = new Configuration();
}
/**
* Override this to apply defaults to the records, this is called by the handleBeforeInsert method
**/
public virtual void onApplyDefaults() { }
/**
* Override this to apply general validation to be performed during insert or update, called by the handleAfterInsert and handleAfterUpdate methods
**/
public virtual void onValidate() { }
/**
* Override this to apply validation to be performed during insert, called by the handleAfterUpdate method
**/
public virtual void onValidate(Map<Id,SObject> existingRecords) { }
/**
* Override this to perform processing during the before insert phase, this is called by the handleBeforeInsert method
**/
public virtual void onBeforeInsert() { }
/**
* Override this to perform processing during the before update phase, this is called by the handleBeforeUpdate method
**/
public virtual void onBeforeUpdate(Map<Id,SObject> existingRecords) { }
/**
* Override this to perform processing during the before delete phase, this is called by the handleBeforeDelete method
**/
public virtual void onBeforeDelete() { }
/**
* Override this to perform processing during the after insert phase, this is called by the handleAfterInsert method
**/
public virtual void onAfterInsert() { }
/**
* Override this to perform processing during the after update phase, this is called by the handleAfterUpdate method
**/
public virtual void onAfterUpdate(Map<Id,SObject> existingRecords) { }
/**
* Override this to perform processing during the after delete phase, this is called by the handleAfterDelete method
**/
public virtual void onAfterDelete() { }
/**
* Override this to perform processing during the after undelete phase, this is called by the handleAfterDelete method
**/
public virtual void onAfterUndelete() { }
/**
* Base handler for the Apex Trigger event Before Insert, calls the onApplyDefaults method, followed by onBeforeInsert
**/
public virtual void handleBeforeInsert()
{
onApplyDefaults();
onBeforeInsert();
}
/**
* Base handler for the Apex Trigger event Before Update, calls the onBeforeUpdate method
**/
public virtual void handleBeforeUpdate(Map<Id,SObject> existingRecords)
{
onBeforeUpdate(existingRecords);
}
/**
* Base handler for the Apex Trigger event Before Delete, calls the onBeforeDelete method
**/
public virtual void handleBeforeDelete()
{
onBeforeDelete();
}
/**
* Base handler for the Apex Trigger event After Insert, checks object security and calls the onValidate and onAfterInsert methods
*
* @throws DomainException if the current user context is not able to create records
**/
public virtual void handleAfterInsert()
{
if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isCreateable())
throw new DomainException('Permission to create an ' + SObjectDescribe.getName() + ' denied.');
onValidate();
onAfterInsert();
}
/**
* Base handler for the Apex Trigger event After Update, checks object security and calls the onValidate, onValidate(Map<Id,SObject>) and onAfterUpdate methods
*
* @throws DomainException if the current user context is not able to update records
**/
public virtual void handleAfterUpdate(Map<Id,SObject> existingRecords)
{
if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isUpdateable())
throw new DomainException('Permission to update an ' + SObjectDescribe.getName() + ' denied.');
// Is this update a result of a Unit Of Work commit? (aka part of service called by a controller, batch or other Apex context)
if(pattern_SObjectUnitOfWork.isCommitting && pattern_SObjectUnitOfWork.LastCommit.enforcingSecurity(SObjectDescribe.getSObjectType()))
if (!checkUpdateSecurity(pattern_SObjectUnitOfWork.LastCommit.Records, existingRecords))
return; // update is not allowed
if(Configuration.OldOnUpdateValidateBehaviour)
onValidate();
onValidate(existingRecords);
onAfterUpdate(existingRecords);
}
/**
* Base handler for the Apex Trigger event After Delete, checks object security and calls the onAfterDelete method
*
* @throws DomainException if the current user context is not able to delete records
**/
public virtual void handleAfterDelete()
{
if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isDeletable())
throw new DomainException('Permission to delete an ' + SObjectDescribe.getName() + ' denied.');
onAfterDelete();
}
/**
* Base handler for the Apex Trigger event After Undelete, checks object security and calls the onAfterUndelete method
*
* @throws DomainException if the current user context is not able to delete records
**/
public virtual void handleAfterUndelete()
{
if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isCreateable())
throw new DomainException('Permission to create an ' + SObjectDescribe.getName() + ' denied.');
onAfterUndelete();
}
/**
* Returns the SObjectType this Domain class represents
**/
public SObjectType getSObjectType()
{
return SObjectDescribe.getSObjectType();
}
/**
* Returns the SObjectType this Domain class represents
**/
public SObjectType sObjectType()
{
return getSObjectType();
}
/**
* Alternative to the Records property, provided to support mocking of Domain classes
**/
public List<SObject> getRecords()
{
return Records;
}
/**
* Detects whether any values in context records have changed for given fields as strings
* Returns list of SObject records that have changes in the specified fields
**/
public List<SObject> getChangedRecords(Set<String> fieldNames)
{
List<SObject> changedRecords = new List<SObject>();
for(SObject newRecord : Records)
{
Id recordId = (Id)newRecord.get('Id');
if(Trigger.oldMap == null || !Trigger.oldMap.containsKey(recordId)) continue;
SObject oldRecord = Trigger.oldMap.get(recordId);
for(String fieldName : fieldNames)
{
if(oldRecord.get(fieldName) != newRecord.get(fieldName)) changedRecords.add(newRecord);
}
}
return changedRecords;
}
/**
* Detects whether any values in context records have changed for given fields as tokens
* Returns list of SObject records that have changes in the specified fields
**/
public List<SObject> getChangedRecords(Set<Schema.SObjectField> fieldTokens)
{
List<SObject> changedRecords = new List<SObject>();
for(SObject newRecord : Records)
{
Id recordId = (Id)newRecord.get('Id');
if(Trigger.oldMap == null || !Trigger.oldMap.containsKey(recordId)) continue;
SObject oldRecord = Trigger.oldMap.get(recordId);
for(Schema.SObjectField fieldToken : fieldTokens)
{
if(oldRecord.get(fieldToken) != newRecord.get(fieldToken)) changedRecords.add(newRecord);
}
}
return changedRecords;
}
/**
* Scans given records and changed fields and ensures user has permission to update
* those records and/or fields. Throws SecurityException (crud or fls) if user does not have
* appropriate permissions.
**/
private Boolean checkUpdateSecurity(Map<Id, SObject> committedRecords, Map<Id, SObject> existingRecords) {
Boolean updateAllowed = true;
// Scan committed records for fields that have changed compared to existing records in the database
List<String> updatedFields = resolveUpdatedFields(committedRecords, existingRecords);
// Check updated fields against user's permissions and throw an exception for any failures
try {
pattern_SecurityUtils.checkUpdate(SObjectDescribe.getSObjectType(), updatedFields);
} catch (pattern_SecurityUtils.SecurityException e) {
for (SObject record : this.Records)
record.addError(e.getMessage());
updateAllowed = false;
}
return updateAllowed;
}
/**
* Resolve updated fields based on given committed records (via unit of work), and the previous version of
* those records, as they are currently in the database.
**/
private List<String> resolveUpdatedFields(Map<Id, SObject> committedRecords, Map<Id, SObject> existingRecords) {
Set<String> updatedFields = new Set<String>();
for (SObject record : this.Records) {
// Compare against the record as it was provided to the uow; prior to any
// platform defaults or calculated fields that may cause false positives.
SObject committedRecord = committedRecords.get(record.Id);
if (committedRecord == null)
continue; // shouldn't ever happen, but just to be safe
// Compare those fields set with those in the existing record, if they
// have changed add to the list of fields for security enforcement
updatedFields.addAll(
pattern_SecureDML.resolveUpdatedFields( // reuse secure dml code despite the slight coupling
committedRecord,
existingRecords.get(record.Id)
)
);
}
return new List<String>(updatedFields);
}
/**
* Interface used to aid the triggerHandler in constructing instances of Domain classes
**/
public interface IConstructable
{
pattern_SObjectDomain construct(List<SObject> sObjectList);
}
/**
* Interface used to aid the triggerHandler in constructing instances of Domain classes
**/
public interface IConstructable2 extends IConstructable
{
pattern_SObjectDomain construct(List<SObject> sObjectList, SObjectType sObjectType);
}
/**
* For Domain classes implementing the ITriggerStateful interface returns the instance
* of the domain class being shared between trigger invocations, returns null if
* the Domain class trigger has not yet fired or the given domain class does not implement
* the ITriggerStateful interface. Note this method is sensitive to recursion, meaning
* it will return the applicable domain instance for the level of recursion
**/
public static pattern_SObjectDomain getTriggerInstance(Type domainClass)
{
List<pattern_SObjectDomain> domains = TriggerStateByClass.get(domainClass);
if(domains==null || domains.size()==0)
return null;
return domains[domains.size()-1];
}
/**
* Method constructs the given Domain class with the current Trigger context
* before calling the applicable override methods such as beforeInsert, beforeUpdate etc.
**/
public static void triggerHandler(Type domainClass)
{
// Process the trigger context
if(System.Test.isRunningTest() & Test.Database.hasRecords())
{
// If in test context and records in the mock database delegate initially to the mock database trigger handler
Test.Database.testTriggerHandler(domainClass);
}
else
{
// Process the runtime Apex Trigger context
triggerHandler(domainClass,
Trigger.isBefore,
Trigger.isAfter,
Trigger.isInsert,
Trigger.isUpdate,
Trigger.isDelete,
Trigger.isUnDelete,
Trigger.new,
Trigger.oldMap);
}
}
/**
* Calls the applicable override methods such as beforeInsert, beforeUpdate etc. based on a Trigger context
**/
private static void triggerHandler(Type domainClass, Boolean isBefore, Boolean isAfter, Boolean isInsert, Boolean isUpdate, Boolean isDelete, Boolean isUndelete, List<SObject> newRecords, Map<Id, SObject> oldRecordsMap)
{
// After phase of trigger will reuse prior instance of domain class if ITriggerStateful implemented
pattern_SObjectDomain domainObject = isBefore ? null : popTriggerInstance(domainClass, isDelete ? oldRecordsMap.values() : newRecords);
if(domainObject==null)
{
// Construct the domain class constructor class
String domainClassName = domainClass.getName();
Type constructableClass = domainClassName.endsWith('Constructor') ? Type.forName(domainClassName) : Type.forName(domainClassName+'.Constructor');
IConstructable domainConstructor = (IConstructable) constructableClass.newInstance();
// Construct the domain class with the approprite record set
if(isInsert) domainObject = domainConstructor.construct(newRecords);
else if(isUpdate) domainObject = domainConstructor.construct(newRecords);
else if(isDelete) domainObject = domainConstructor.construct(oldRecordsMap.values());
else if(isUndelete) domainObject = domainConstructor.construct(newRecords);
// Should this instance be reused on the next trigger invocation?
if(domainObject.Configuration.TriggerStateEnabled)
// Push this instance onto the stack to be popped during the after phase
pushTriggerInstance(domainClass, domainObject);
}
// has this event been disabled?
if(!getTriggerEvent(domainClass).isEnabled(isBefore, isAfter, isInsert, isUpdate, isDelete, isUndelete))
{
return;
}
// Invoke the applicable handler
if(isBefore)
{
if(isInsert) domainObject.handleBeforeInsert();
else if(isUpdate) domainObject.handleBeforeUpdate(oldRecordsMap);
else if(isDelete) domainObject.handleBeforeDelete();
}
else
{
if(isInsert) domainObject.handleAfterInsert();
else if(isUpdate) domainObject.handleAfterUpdate(oldRecordsMap);
else if(isDelete) domainObject.handleAfterDelete();
else if(isUndelete) domainObject.handleAfterUndelete();
}
}
/**
* Pushes to the stack of domain classes per type a domain object instance
**/
private static void pushTriggerInstance(Type domainClass, pattern_SObjectDomain domain)
{
List<pattern_SObjectDomain> domains = TriggerStateByClass.get(domainClass);
if(domains==null)
TriggerStateByClass.put(domainClass, domains = new List<pattern_SObjectDomain>());
domains.add(domain);
}
/**
* Pops from the stack of domain classes per type a domain object instance and updates the record set
**/
private static pattern_SObjectDomain popTriggerInstance(Type domainClass, List<SObject> records)
{
List<pattern_SObjectDomain> domains = TriggerStateByClass.get(domainClass);
if(domains==null || domains.size()==0)
return null;
pattern_SObjectDomain domain = domains.remove(domains.size()-1);
domain.Records = records;
return domain;
}
public static TriggerEvent getTriggerEvent(Type domainClass)
{
if(!TriggerEventByClass.containsKey(domainClass))
{
TriggerEventByClass.put(domainClass, new TriggerEvent());
}
return TriggerEventByClass.get(domainClass);
}
public class TriggerEvent
{
public boolean BeforeInsertEnabled {get; private set;}
public boolean BeforeUpdateEnabled {get; private set;}
public boolean BeforeDeleteEnabled {get; private set;}
public boolean AfterInsertEnabled {get; private set;}
public boolean AfterUpdateEnabled {get; private set;}
public boolean AfterDeleteEnabled {get; private set;}
public boolean AfterUndeleteEnabled {get; private set;}
public TriggerEvent()
{
this.enableAll();
}
// befores
public TriggerEvent enableBeforeInsert() {BeforeInsertEnabled = true; return this;}
public TriggerEvent enableBeforeUpdate() {BeforeUpdateEnabled = true; return this;}
public TriggerEvent enableBeforeDelete() {BeforeDeleteEnabled = true; return this;}
public TriggerEvent disableBeforeInsert() {BeforeInsertEnabled = false; return this;}
public TriggerEvent disableBeforeUpdate() {BeforeUpdateEnabled = false; return this;}
public TriggerEvent disableBeforeDelete() {BeforeDeleteEnabled = false; return this;}
// afters
public TriggerEvent enableAfterInsert() {AfterInsertEnabled = true; return this;}
public TriggerEvent enableAfterUpdate() {AfterUpdateEnabled = true; return this;}
public TriggerEvent enableAfterDelete() {AfterDeleteEnabled = true; return this;}
public TriggerEvent enableAfterUndelete() {AfterUndeleteEnabled = true; return this;}
public TriggerEvent disableAfterInsert() {AfterInsertEnabled = false; return this;}
public TriggerEvent disableAfterUpdate() {AfterUpdateEnabled = false; return this;}
public TriggerEvent disableAfterDelete() {AfterDeleteEnabled = false; return this;}
public TriggerEvent disableAfterUndelete(){AfterUndeleteEnabled = false; return this;}
public TriggerEvent enableAll()
{
return this.enableAllBefore().enableAllAfter();
}
public TriggerEvent disableAll()
{
return this.disableAllBefore().disableAllAfter();
}
public TriggerEvent enableAllBefore()
{
return this.enableBeforeInsert().enableBeforeUpdate().enableBeforeDelete();
}
public TriggerEvent disableAllBefore()
{
return this.disableBeforeInsert().disableBeforeUpdate().disableBeforeDelete();
}
public TriggerEvent enableAllAfter()
{
return this.enableAfterInsert().enableAfterUpdate().enableAfterDelete().enableAfterUndelete();
}
public TriggerEvent disableAllAfter()
{
return this.disableAfterInsert().disableAfterUpdate().disableAfterDelete().disableAfterUndelete();
}
public boolean isEnabled(Boolean isBefore, Boolean isAfter, Boolean isInsert, Boolean isUpdate, Boolean isDelete, Boolean isUndelete)
{
if(isBefore)
{
if(isInsert) return BeforeInsertEnabled;
else if(isUpdate) return BeforeUpdateEnabled;
else if(isDelete) return BeforeDeleteEnabled;
}
else if(isAfter)
{
if(isInsert) return AfterInsertEnabled;
else if(isUpdate) return AfterUpdateEnabled;
else if(isDelete) return AfterDeleteEnabled;
else if(isUndelete) return AfterUndeleteEnabled;
}
return true; // shouldnt ever get here!
}
}
/**
* Fluent style Configuration system for Domain class creation
**/
public class Configuration
{
/**
* Backwards compatibility mode for handleAfterUpdate routing to onValidate()
**/
public Boolean OldOnUpdateValidateBehaviour {get; private set;}
/**
* True if the base class is checking the users CRUD requirements before invoking trigger methods
**/
public Boolean EnforcingTriggerCRUDSecurity {get; private set;}
/**
* Enables reuse of the same Domain instance between before and after trigger phases (subject to recursive scenarios)
**/
public Boolean TriggerStateEnabled {get; private set;}
/**
* Default configuration
**/
public Configuration()
{
EnforcingTriggerCRUDSecurity = true; // Default is true for backwards compatability
TriggerStateEnabled = false;
OldOnUpdateValidateBehaviour = false; // Breaking change, but felt to better practice
}
/**
* See associated property
**/
public Configuration enableTriggerState()
{
TriggerStateEnabled = true;
return this;
}
/**
* See associated property
**/
public Configuration disableTriggerState()
{
TriggerStateEnabled = false;
return this;
}
/**
* See associated property
**/
public Configuration enforceTriggerCRUDSecurity()
{
EnforcingTriggerCRUDSecurity = true;
return this;
}
/**
* See associated property
**/
public Configuration disableTriggerCRUDSecurity()
{
EnforcingTriggerCRUDSecurity = false;
return this;
}
/**
* See associated property
**/
public Configuration enableOldOnUpdateValidateBehaviour()
{
OldOnUpdateValidateBehaviour = true;
return this;
}
/**
* See associated property
**/
public Configuration disableOldOnUpdateValidateBehaviour()
{
OldOnUpdateValidateBehaviour = false;
return this;
}
}
/**
* General exception class for the domain layer
**/
public class DomainException extends Exception
{
}
/**
* Ensures logging of errors in the Domain context for later assertions in tests
**/
public String error(String message, SObject record)
{
return Errors.error(this, message, record);
}
/**
* Ensures logging of errors in the Domain context for later assertions in tests
**/
public String error(String message, SObject record, SObjectField field)
{
return Errors.error(this, message, record, field);
}
/**
* Ensures logging of errors in the Domain context for later assertions in tests
**/
public class ErrorFactory
{
private List<Error> errorList = new List<Error>();
private ErrorFactory()
{
}
public String error(String message, SObject record)
{
return error(null, message, record);
}
private String error(pattern_SObjectDomain domain, String message, SObject record)
{
ObjectError objectError = new ObjectError();
objectError.domain = domain;
objectError.message = message;
objectError.record = record;
errorList.add(objectError);
return message;
}
public String error(String message, SObject record, SObjectField field)
{
return error(null, message, record, field);
}
private String error(pattern_SObjectDomain domain, String message, SObject record, SObjectField field)
{
FieldError fieldError = new FieldError();
fieldError.domain = domain;
fieldError.message = message;
fieldError.record = record;
fieldError.field = field;
errorList.add(fieldError);
return message;
}
public List<Error> getAll()
{
return errorList.clone();
}
public void clearAll()
{
errorList.clear();
}
}
/**
* Ensures logging of errors in the Domain context for later assertions in tests
**/
public virtual class FieldError extends ObjectError
{
public SObjectField field;
public FieldError()
{
}
}
/**
* Ensures logging of errors in the Domain context for later assertions in tests
**/
public virtual class ObjectError extends Error
{
public SObject record;
public ObjectError()
{
}
}
/**
* Ensures logging of errors in the Domain context for later assertions in tests
**/
public abstract class Error
{
public String message;
public pattern_SObjectDomain domain;
}
/**
* Provides test context mocking facilities to unit tests testing domain classes
**/
public class TestFactory
{
public MockDatabase Database = new MockDatabase();
private TestFactory()
{
}
}
/**
* Class used during Unit testing of Domain classes, can be used (not exclusively) to speed up test execution and focus testing
**/
public class MockDatabase
{
private Boolean isInsert = false;
private Boolean isUpdate = false;
private Boolean isDelete = false;
private Boolean isUndelete = false;
private List<SObject> records = new List<SObject>();
private Map<Id, SObject> oldRecords = new Map<Id, SObject>();
private MockDatabase()
{
}
private void testTriggerHandler(Type domainClass)
{
// Mock Before
triggerHandler(domainClass, true, false, isInsert, isUpdate, isDelete, isUndelete, records, oldRecords);
// Mock After
triggerHandler(domainClass, false, true, isInsert, isUpdate, isDelete, isUndelete, records, oldRecords);
}
public void onInsert(List<SObject> records)
{
this.isInsert = true;
this.isUpdate = false;
this.isDelete = false;
this.isUndelete = false;
this.records = records;
}
public void onUpdate(List<SObject> records, Map<Id, SObject> oldRecords)
{
this.isInsert = false;
this.isUpdate = true;
this.isDelete = false;
this.records = records;
this.isUndelete = false;
this.oldRecords = oldRecords;
}
public void onDelete(Map<Id, SObject> records)
{
this.isInsert = false;
this.isUpdate = false;
this.isDelete = true;
this.isUndelete = false;
this.oldRecords = records;
}
public void onUndelete(List<SObject> records)
{
this.isInsert = false;
this.isUpdate = false;
this.isDelete = false;
this.isUndelete = true;
this.records = records;
}
public Boolean hasRecords()
{
return records!=null && records.size()>0 || oldRecords!=null && oldRecords.size()>0;
}
}
/**
* NOTE: Removed sobject domain test code below to avoid hard dependency on certain sales cloud objects
*/
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment