-
-
Save attilah/a0ff0aea92cf1491a73b5c54f9e04e07 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]
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
/** | |
* @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; | |
} | |
} |
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
/** | |
* 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); | |
} | |
} | |
} |
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
/** | |
* 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