From ab04fad8bfe3f659c06ceaf45e18289f8fbc0a96 Mon Sep 17 00:00:00 2001 From: Robert Baillie Date: Fri, 22 Apr 2022 09:26:46 +0100 Subject: [PATCH 1/4] Started the sketch of the dynamic SObject UOW --- .../ortoo_DynamicSobjectUnitOfWork.cls | 381 ++++++++++++++++++ ...rtoo_DynamicSobjectUnitOfWork.cls-meta.xml | 5 + .../common/fflib_SObjectUnitOfWork.cls | 52 +-- .../ortoo_SobjectUnitOfWork.cls | 2 +- .../default/classes/utils/DirectedGraph.cls | 4 +- .../default/classes/utils/SobjectUtils.cls | 14 + 6 files changed, 429 insertions(+), 29 deletions(-) create mode 100644 framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls create mode 100644 framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml diff --git a/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls b/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls new file mode 100644 index 00000000000..20684222f4d --- /dev/null +++ b/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls @@ -0,0 +1,381 @@ +// TODO: lots of null checks + +public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_SobjectUnitOfWork +{ + DirectedGraph graph = new DirectedGraph(); + Set registeredTypes = new Set(); + + /** + * Default parameterless constructor + * + */ + public ortoo_DynamicSobjectUnitOfWork() + { + super( new List() ); + } + + /** + * Constructor, allowing a custom DML interface to be defined + * + * @param IDML The custom DML instance + */ + public ortoo_DynamicSobjectUnitOfWork( IDML dml ) + { + super( new List(), dml ); + } + + /** + * Register an deleted record to be removed from the recycle bin during the commitWork method + * + * @param record An deleted record + **/ + public override void registerEmptyRecycleBin( SObject record ) + { + registerType( record ); + super.registerEmptyRecycleBin( record ); + } + + /** + * Register deleted records to be removed from the recycle bin during the commitWork method + * + * @param records Deleted records + **/ + public override void registerEmptyRecycleBin( List records ) + { + registerTypes( records ); + super.registerEmptyRecycleBin( records ); + } + + /** + * 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 override void registerNew( SObject record ) + { + registerType( record ); + super.registerNew( record ); + } + + /** + * 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 override void registerNew( List records ) + { + registerTypes( records ); + super.registerNew( records ); + } + + /** + * 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 override void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) + { + registerType( record ); + if ( relatedToParentRecord != null ) + { + registerTypeRelationship( record, relatedToParentRecord ); + } + super.registerNew( 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 override void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) + { + registerTypeRelationship( record, relatedTo ); + super.registerRelationship( record, relatedToField, relatedTo ); + } + + /** + * Registers a relationship between a record and a lookup value using an external ID field and a provided value. This + * information will be used during the commitWork phase to make the lookup reference requested when 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 externalIdField A SObjectField reference to a field on the target SObject that is marked as isExternalId + * @param externalId A Object representing the targeted value of the externalIdField in said lookup + * + * Usage Example: uow.registerRelationship(recordSObject, record_sobject__c.relationship_field__c, lookup_sobject__c.external_id__c, 'abc123'); + * + * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. + */ + public override void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) + { + List relatedObjects = relatedToField.getDescribe().getReferenceTo(); + Schema.SObjectType relatedObject = relatedObjects[0]; + + registerTypeRelationship( SObjectUtils.getSobjectType( record ), relatedObject ); + + super.registerRelationship( record, relatedToField, externalIdField, externalId ); + } + + /** + * Register an existing record to be updated during the commitWork method + * + * @param record An existing record + **/ + public override void registerDirty(SObject record) + { + registerType( record ); + super.registerDirty( record ); + } + + /** + * Registers the entire records as dirty or just only the dirty fields if the record was already registered + * + * @param records SObjects to register as dirty + * @param dirtyFields A list of modified fields + */ + public override void registerDirty(List records, List dirtyFields) + { + registerTypes( records ); + super.registerDirty( records, dirtyFields ); + } + + /** + * Registers the entire record as dirty or just only the dirty fields if the record was already registered + * + * @param record SObject to register as dirty + * @param dirtyFields A list of modified fields + */ + public override void registerDirty(SObject record, List dirtyFields) + { + registerType( record ); + super.registerDirty( record, dirtyFields ); + } + + /** + * 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 override void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) + { + registerType( record ); + + if ( relatedToParentRecord != null ) + { + registerTypeRelationship( record, relatedToParentRecord ); + } + + super.registerDirty( record, relatedToParentField, relatedToParentRecord ); + } + + /** + * Register a list of existing records to be updated during the commitWork method + * + * @param records A list of existing records + **/ + public override void registerDirty(List records) + { + registerTypes( records ); + super.registerDirty( records ); + } + + /** + * Register a new or existing record to be inserted/updated during the commitWork method + * + * @param record A new or existing record + **/ + public override void registerUpsert(SObject record) + { + registerType( record ); + super.registerUpsert( record ); + } + + /** + * 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 override void registerUpsert(List records) + { + registerTypes( records ); + super.registerUpsert( records ); + } + + /** + * Register an existing record to be deleted during the commitWork method + * + * @param record An existing record + **/ + public override void registerDeleted(SObject record) + { + registerType( record ); + super.registerDeleted( record ); + } + + /** + * Register a list of existing records to be deleted during the commitWork method + * + * @param records A list of existing records + **/ + public override void registerDeleted(List records) + { + registerTypes( records ); + super.registerDeleted( records ); + } + + /** + * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method + * + * @param records A list of existing records + **/ + public override void registerPermanentlyDeleted(List records) + { + registerTypes( records ); + super.registerPermanentlyDeleted( records ); + } + + /** + * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method + * + * @param record A list of existing records + **/ + public override void registerPermanentlyDeleted(SObject record) + { + registerType( record ); + super.registerPermanentlyDeleted( 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 override void registerPublishBeforeTransaction(SObject record) + { + registerType( record ); + super.registerPublishBeforeTransaction( 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 override void registerPublishBeforeTransaction(List records) + { + registerTypes( records ); + super.registerPublishBeforeTransaction( records ); + } + + /** + * 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 override void registerPublishAfterSuccessTransaction(SObject record) + { + registerType( record ); + super.registerPublishAfterSuccessTransaction( 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 override void registerPublishAfterSuccessTransaction(List records) + { + registerTypes( records ); + super.registerPublishAfterSuccessTransaction( records ); + } + /** + * 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 override void registerPublishAfterFailureTransaction(SObject record) + { + registerType( record ); + super.registerPublishAfterFailureTransaction( 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 override void registerPublishAfterFailureTransaction(List records) + { + registerTypes( records ); + super.registerPublishAfterFailureTransaction( records ); + } + + public override void onCommitWorkStarting() + { + setOrderOfOperations( generateOrderOfOperations() ); + } + + private void setOrderOfOperations( List orderOfOperations ) + { + system.debug( orderOfOperations ); + m_sObjectTypes = orderOfOperations.clone(); + } + + private void registerType( Sobject record ) + { + registerType( SobjectUtils.getSobjectType( record ) ); + } + + private void registerTypes( List records ) + { + Set types = SobjectUtils.getSobjectTypes(records); + for ( Schema.SObjectType thisType : types ) + { + registerType( thisType ); + } + } + + private void registerType( Schema.SObjectType typeToRegister ) + { + if ( registeredTypes.contains( typeToRegister ) ) + { + return; + } + + registeredTypes.add( typeToRegister ); + graph.addNode( typeToRegister ); + handleRegisterType( typeToRegister ); + } + + private void registerTypeRelationship( Sobject child, Sobject parent ) + { + registerTypeRelationship( SobjectUtils.getSobjectType( child ), SobjectUtils.getSobjectType( parent ) ); + } + + private void registerTypeRelationship( Schema.SObjectType child, Schema.SObjectType parent ) + { + graph.addRelationship( child, parent ); + } + + private List generateOrderOfOperations() + { + List childToParentTypes = graph.generateSorted(); + + List parentToChildTypes = new List(); + for( Integer i = childToParentTypes.size() - 1; i >= 0; i-- ) + { + parentToChildTypes.add( (SobjectType)childToParentTypes[i] ); + } + return parentToChildTypes; + } +} diff --git a/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml b/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls b/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls index 24c8385b501..c135eda1893 100644 --- a/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls +++ b/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls @@ -174,7 +174,7 @@ public virtual class fflib_SObjectUnitOfWork * @param sObjectType - The type to register * */ - private void handleRegisterType(Schema.SObjectType sObjectType) + protected void handleRegisterType(Schema.SObjectType sObjectType) { String sObjectName = sObjectType.getDescribe().getName(); @@ -214,7 +214,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record An deleted record **/ - public void registerEmptyRecycleBin(SObject record) + public virtual void registerEmptyRecycleBin(SObject record) { String sObjectType = record.getSObjectType().getDescribe().getName(); assertForSupportedSObjectType(m_emptyRecycleBinMapByType, sObjectType); @@ -227,7 +227,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records Deleted records **/ - public void registerEmptyRecycleBin(List records) + public virtual void registerEmptyRecycleBin(List records) { for (SObject record : records) { @@ -240,7 +240,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record A newly created SObject instance to be inserted during commitWork **/ - public void registerNew(SObject record) + public virtual void registerNew(SObject record) { registerNew(record, null, null); } @@ -250,7 +250,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of newly created SObject instances to be inserted during commitWork **/ - public void registerNew(List records) + public virtual void registerNew(List records) { for (SObject record : records) { @@ -266,7 +266,7 @@ public virtual class fflib_SObjectUnitOfWork * @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) + public virtual void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) { if (record.Id != null) throw new UnitOfWorkException('Only new records can be registered as new'); @@ -288,7 +288,7 @@ public virtual class fflib_SObjectUnitOfWork * @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) + public virtual void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) { String sObjectType = record.getSObjectType().getDescribe().getName(); @@ -306,7 +306,7 @@ public virtual class fflib_SObjectUnitOfWork * @param email 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 ) + public virtual void registerRelationship( Messaging.SingleEmailMessage email, SObject relatedTo ) { m_relationships.get( Messaging.SingleEmailMessage.class.getName() ).add(email, relatedTo); } @@ -324,7 +324,7 @@ public virtual class fflib_SObjectUnitOfWork * * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. */ - public void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) + public virtual void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) { // NOTE: Due to the lack of ExternalID references on Standard Objects, this method can not be provided a standardized Unit Test. - Rick Parker String sObjectType = record.getSObjectType().getDescribe().getName(); @@ -338,7 +338,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record An existing record **/ - public void registerDirty(SObject record) + public virtual void registerDirty(SObject record) { registerDirty(record, new List()); } @@ -349,7 +349,7 @@ public virtual class fflib_SObjectUnitOfWork * @param records SObjects to register as dirty * @param dirtyFields A list of modified fields */ - public void registerDirty(List records, List dirtyFields) + public virtual void registerDirty(List records, List dirtyFields) { for (SObject record : records) { @@ -363,7 +363,7 @@ public virtual class fflib_SObjectUnitOfWork * @param record SObject to register as dirty * @param dirtyFields A list of modified fields */ - public void registerDirty(SObject record, List dirtyFields) + public virtual void registerDirty(SObject record, List dirtyFields) { if (record.Id == null) throw new UnitOfWorkException('New records cannot be registered as dirty'); @@ -406,7 +406,7 @@ public virtual class fflib_SObjectUnitOfWork * @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) + public virtual void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) { registerDirty(record); if (relatedToParentRecord!=null && relatedToParentField!=null) @@ -418,7 +418,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of existing records **/ - public void registerDirty(List records) + public virtual void registerDirty(List records) { for (SObject record : records) { @@ -431,7 +431,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record A new or existing record **/ - public void registerUpsert(SObject record) + public virtual void registerUpsert(SObject record) { if (record.Id == null) { @@ -448,7 +448,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of mix of new and existing records **/ - public void registerUpsert(List records) + public virtual void registerUpsert(List records) { for (SObject record : records) { @@ -461,7 +461,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record An existing record **/ - public void registerDeleted(SObject record) + public virtual void registerDeleted(SObject record) { if (record.Id == null) throw new UnitOfWorkException('New records cannot be registered for deletion'); @@ -478,7 +478,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of existing records **/ - public void registerDeleted(List records) + public virtual void registerDeleted(List records) { for (SObject record : records) { @@ -491,7 +491,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of existing records **/ - public void registerPermanentlyDeleted(List records) + public virtual void registerPermanentlyDeleted(List records) { this.registerEmptyRecycleBin(records); this.registerDeleted(records); @@ -502,7 +502,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record A list of existing records **/ - public void registerPermanentlyDeleted(SObject record) + public virtual void registerPermanentlyDeleted(SObject record) { this.registerEmptyRecycleBin(record); this.registerDeleted(record); @@ -513,7 +513,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork **/ - public void registerPublishBeforeTransaction(SObject record) + public virtual void registerPublishBeforeTransaction(SObject record) { String sObjectType = record.getSObjectType().getDescribe().getName(); @@ -528,7 +528,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of existing records **/ - public void registerPublishBeforeTransaction(List records) + public virtual void registerPublishBeforeTransaction(List records) { for (SObject record : records) { @@ -541,7 +541,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork **/ - public void registerPublishAfterSuccessTransaction(SObject record) + public virtual void registerPublishAfterSuccessTransaction(SObject record) { String sObjectType = record.getSObjectType().getDescribe().getName(); @@ -556,7 +556,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of existing records **/ - public void registerPublishAfterSuccessTransaction(List records) + public virtual void registerPublishAfterSuccessTransaction(List records) { for (SObject record : records) { @@ -568,7 +568,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork **/ - public void registerPublishAfterFailureTransaction(SObject record) + public virtual void registerPublishAfterFailureTransaction(SObject record) { String sObjectType = record.getSObjectType().getDescribe().getName(); @@ -583,7 +583,7 @@ public virtual class fflib_SObjectUnitOfWork * * @param records A list of existing records **/ - public void registerPublishAfterFailureTransaction(List records) + public virtual void registerPublishAfterFailureTransaction(List records) { for (SObject record : records) { diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls index 02f3fd76d93..746e3b553b4 100644 --- a/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls @@ -4,7 +4,7 @@ * * @group fflib Extension */ -public inherited sharing class ortoo_SobjectUnitOfWork extends fflib_SObjectUnitOfWork // NOPMD: specified a mini-namespace to differentiate from fflib versions +public virtual inherited sharing class ortoo_SobjectUnitOfWork extends fflib_SObjectUnitOfWork // NOPMD: specified a mini-namespace to differentiate from fflib versions { private static final Integer ROWS_OFFSET_FOR_LIBRARY = 1; // exists because a call to commitWork will issue a savepoint, and this counds as a DML row private static final Integer STATEMENT_OFFSET_FOR_LIBRARY = 1; // and a DML statement diff --git a/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls b/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls index 4c8f8736d0a..7fa8ace1c42 100644 --- a/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls +++ b/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls @@ -1,4 +1,4 @@ -// Directed Graph algorithm ased on the DirectedGraph implemented by Robert Sösemann: https://github.com/rsoesemann/apex-domainbuilder +// Directed Graph algorithm based on the DirectedGraph implemented by Robert Sösemann: https://github.com/rsoesemann/apex-domainbuilder public inherited sharing class DirectedGraph { public inherited sharing class GraphContainsCircularReferenceException extends ortoo_Exception {} @@ -38,7 +38,7 @@ public inherited sharing class DirectedGraph } /** - * Generates a list of nodes, sorted by their depdencies. + * Generates a list of nodes, sorted by their dependencies. * * That is, the children first, resolving upwards to the parents. * No parent appears in the list prior to any of their children. diff --git a/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls b/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls index 4915b9cb430..b09eb9b4a49 100644 --- a/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls +++ b/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls @@ -62,6 +62,20 @@ public inherited sharing class SobjectUtils return record.getSObjectType(); } + // TODO: document + // TODO: test + public static Set getSobjectTypes( List records ) + { + Contract.requires( records != null, 'getSobjectType called with a null records' ); + + Set types = new Set(); + for ( Sobject thisRecord : records ) + { + types.add( getSobjectType( thisRecord ) ); + } + return types; + } + // TODO: use the describe on the object? /** * Given an instance of an SObject, will return the developer / API name of the SObject (e.g. Contact) From 0bf55d4fc61b8512f5395e6477ba2f67ef36081f Mon Sep 17 00:00:00 2001 From: Robert Baillie Date: Fri, 22 Apr 2022 15:48:29 +0100 Subject: [PATCH 2/4] Added tests for the dynamic unit of work --- .../common/fflib_SObjectUnitOfWork.cls | 5 + .../ortoo_DynamicSobjectUnitOfWork.cls | 87 ++- ...rtoo_DynamicSobjectUnitOfWork.cls-meta.xml | 0 .../ortoo_SobjectUnitOfWork.cls | 4 + .../ortoo_DynamicSobjectUnitOfWorkTest.cls | 563 ++++++++++++++++++ ..._DynamicSobjectUnitOfWorkTest.cls-meta.xml | 5 + .../default/classes/utils/SobjectUtils.cls | 14 + .../classes/utils/tests/SobjectUtilsTest.cls | 66 ++ 8 files changed, 697 insertions(+), 47 deletions(-) rename framework/default/{fflib-apex-extensions/default/classes => ortoo-core/default/classes/fflib-extension}/ortoo_DynamicSobjectUnitOfWork.cls (77%) rename framework/default/{fflib-apex-extensions/default/classes => ortoo-core/default/classes/fflib-extension}/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml (100%) create mode 100644 framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls create mode 100644 framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls-meta.xml diff --git a/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls b/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls index c135eda1893..a1f13f1221c 100644 --- a/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls +++ b/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls @@ -54,6 +54,11 @@ public virtual class fflib_SObjectUnitOfWork implements fflib_ISObjectUnitOfWork { + // NOTE: + // If registration methods are added to this class, then they should probably also be added + // to ortoo_DynamicSobjectUnitOfWork + // + @testVisible protected List m_sObjectTypes = new List(); protected Map> m_newListByType = new Map>(); diff --git a/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls similarity index 77% rename from framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls rename to framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls index 20684222f4d..eea1e50162e 100644 --- a/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls @@ -1,5 +1,18 @@ -// TODO: lots of null checks - +/** + * An extension of ortoo fflib SobjectUnitOfWork, adding the ability to dynamically work out the + * order in which the operations should take place. + * + * I.E. the construction does not require the specification of the supported SObjects and the order in + * which they are sent to the database. As the work is queued, the instance will register the types + * and then calculate the order of operations based on a directed graph. + * + * Does not support circular references and will throw an exception if one is detected. + * + * Note: this will not perform as well as a pre-configured ortoo_SobjectUnitOfWork and so should only + * be used when the order of operations is not known at compile time. + * + * @group fflib Extension + */ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_SobjectUnitOfWork { DirectedGraph graph = new DirectedGraph(); @@ -76,7 +89,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * @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 override void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) + public override void registerNew( SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord ) { registerType( record ); if ( relatedToParentRecord != null ) @@ -94,33 +107,14 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * @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 override void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) - { - registerTypeRelationship( record, relatedTo ); - super.registerRelationship( record, relatedToField, relatedTo ); - } - - /** - * Registers a relationship between a record and a lookup value using an external ID field and a provided value. This - * information will be used during the commitWork phase to make the lookup reference requested when 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 externalIdField A SObjectField reference to a field on the target SObject that is marked as isExternalId - * @param externalId A Object representing the targeted value of the externalIdField in said lookup - * - * Usage Example: uow.registerRelationship(recordSObject, record_sobject__c.relationship_field__c, lookup_sobject__c.external_id__c, 'abc123'); - * - * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. - */ - public override void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) + public override void registerRelationship( SObject record, Schema.SObjectField relatedToField, SObject relatedTo ) { - List relatedObjects = relatedToField.getDescribe().getReferenceTo(); - Schema.SObjectType relatedObject = relatedObjects[0]; - - registerTypeRelationship( SObjectUtils.getSobjectType( record ), relatedObject ); + if ( relatedTo != null ) + { + registerTypeRelationship( record, relatedTo ); + } - super.registerRelationship( record, relatedToField, externalIdField, externalId ); + super.registerRelationship( record, relatedToField, relatedTo ); } /** @@ -128,7 +122,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record An existing record **/ - public override void registerDirty(SObject record) + public override void registerDirty( SObject record ) { registerType( record ); super.registerDirty( record ); @@ -140,7 +134,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * @param records SObjects to register as dirty * @param dirtyFields A list of modified fields */ - public override void registerDirty(List records, List dirtyFields) + public override void registerDirty( List records, List dirtyFields ) { registerTypes( records ); super.registerDirty( records, dirtyFields ); @@ -152,7 +146,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * @param record SObject to register as dirty * @param dirtyFields A list of modified fields */ - public override void registerDirty(SObject record, List dirtyFields) + public override void registerDirty( SObject record, List dirtyFields ) { registerType( record ); super.registerDirty( record, dirtyFields ); @@ -166,7 +160,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * @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 override void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) + public override void registerDirty( SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord ) { registerType( record ); @@ -183,7 +177,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of existing records **/ - public override void registerDirty(List records) + public override void registerDirty( List records ) { registerTypes( records ); super.registerDirty( records ); @@ -194,7 +188,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record A new or existing record **/ - public override void registerUpsert(SObject record) + public override void registerUpsert( SObject record ) { registerType( record ); super.registerUpsert( record ); @@ -205,7 +199,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of mix of new and existing records **/ - public override void registerUpsert(List records) + public override void registerUpsert( List records ) { registerTypes( records ); super.registerUpsert( records ); @@ -216,7 +210,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record An existing record **/ - public override void registerDeleted(SObject record) + public override void registerDeleted( SObject record ) { registerType( record ); super.registerDeleted( record ); @@ -227,7 +221,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of existing records **/ - public override void registerDeleted(List records) + public override void registerDeleted( List records ) { registerTypes( records ); super.registerDeleted( records ); @@ -238,7 +232,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of existing records **/ - public override void registerPermanentlyDeleted(List records) + public override void registerPermanentlyDeleted( List records ) { registerTypes( records ); super.registerPermanentlyDeleted( records ); @@ -249,7 +243,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record A list of existing records **/ - public override void registerPermanentlyDeleted(SObject record) + public override void registerPermanentlyDeleted( SObject record ) { registerType( record ); super.registerPermanentlyDeleted( record ); @@ -260,7 +254,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork **/ - public override void registerPublishBeforeTransaction(SObject record) + public override void registerPublishBeforeTransaction( SObject record ) { registerType( record ); super.registerPublishBeforeTransaction( record ); @@ -271,7 +265,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of existing records **/ - public override void registerPublishBeforeTransaction(List records) + public override void registerPublishBeforeTransaction( List records ) { registerTypes( records ); super.registerPublishBeforeTransaction( records ); @@ -282,7 +276,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork **/ - public override void registerPublishAfterSuccessTransaction(SObject record) + public override void registerPublishAfterSuccessTransaction( SObject record ) { registerType( record ); super.registerPublishAfterSuccessTransaction( record ); @@ -293,7 +287,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of existing records **/ - public override void registerPublishAfterSuccessTransaction(List records) + public override void registerPublishAfterSuccessTransaction( List records ) { registerTypes( records ); super.registerPublishAfterSuccessTransaction( records ); @@ -303,7 +297,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork **/ - public override void registerPublishAfterFailureTransaction(SObject record) + public override void registerPublishAfterFailureTransaction( SObject record ) { registerType( record ); super.registerPublishAfterFailureTransaction( record ); @@ -314,7 +308,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj * * @param records A list of existing records **/ - public override void registerPublishAfterFailureTransaction(List records) + public override void registerPublishAfterFailureTransaction( List records ) { registerTypes( records ); super.registerPublishAfterFailureTransaction( records ); @@ -327,7 +321,6 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj private void setOrderOfOperations( List orderOfOperations ) { - system.debug( orderOfOperations ); m_sObjectTypes = orderOfOperations.clone(); } @@ -338,7 +331,7 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj private void registerTypes( List records ) { - Set types = SobjectUtils.getSobjectTypes(records); + Set types = SobjectUtils.getSobjectTypes( records ); for ( Schema.SObjectType thisType : types ) { registerType( thisType ); diff --git a/framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml similarity index 100% rename from framework/default/fflib-apex-extensions/default/classes/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml rename to framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls-meta.xml diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls index 746e3b553b4..5f7f9a3118a 100644 --- a/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_SobjectUnitOfWork.cls @@ -6,6 +6,10 @@ */ public virtual inherited sharing class ortoo_SobjectUnitOfWork extends fflib_SObjectUnitOfWork // NOPMD: specified a mini-namespace to differentiate from fflib versions { + // NOTE: + // If registration methods are added to this class, then they should probably also be added + // to ortoo_DynamicSobjectUnitOfWork + // private static final Integer ROWS_OFFSET_FOR_LIBRARY = 1; // exists because a call to commitWork will issue a savepoint, and this counds as a DML row private static final Integer STATEMENT_OFFSET_FOR_LIBRARY = 1; // and a DML statement diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls new file mode 100644 index 00000000000..6d598ee0baf --- /dev/null +++ b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls @@ -0,0 +1,563 @@ +@isTest +private without sharing class ortoo_DynamicSobjectUnitOfWorkTest +{ + @isTest + private static void constructor_whenCalledWithNoParameters_setsTheSobjectTypesToEmpty() // NOPMD: Test method name format + { + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork(); + Test.stopTest(); + + System.assertEquals( 0, uow.m_sObjectTypes.size(), 'constructor, when called with no parameters, will set the registered SobjectTypes to empty' ); + } + + @isTest + private static void constructor_whenCalledWithAnIDml_setsTheSobjectTypesToEmpty() // NOPMD: Test method name format + { + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( new MockDml() ); + Test.stopTest(); + + System.assertEquals( 0, uow.m_sObjectTypes.size(), 'constructor, when called with an IDml, will set the registered SobjectTypes to empty' ); + } + + @isTest + private static void registerEmptyRecycleBin_whenGivenAnSobject_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( LastName = 'test' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerEmptyRecycleBin( expected ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForRecycleBin.size(), 'registerEmptyRecycleBin, when given an SObject, will queue the record for empty' ); + System.assertEquals( expected, dml.recordsForRecycleBin[0], 'registerEmptyRecycleBin, when given an SObject, will queue the record for empty' ); + } + + @isTest + private static void registerEmptyRecycleBin_whenGivenMultipleSobjects_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( LastName = 'test' ), + new Account( Name = 'test' ), + new Opportunity( Name = 'test' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerEmptyRecycleBin( records ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForRecycleBin.size(), 'registerEmptyRecycleBin, when given a list of SObjects, will register the types and the DML' ); + System.assertEquals( records[0], dml.recordsForRecycleBin[0], 'registerEmptyRecycleBin, when given a list of SObjects, will register the types and the DML - checking record 0' ); + System.assertEquals( records[1], dml.recordsForRecycleBin[1], 'registerEmptyRecycleBin, when given a list of SObjects, will register the types and the DML - checking record 1' ); + System.assertEquals( records[2], dml.recordsForRecycleBin[2], 'registerEmptyRecycleBin, when given a list of SObjects, will register the types and the DML - checking record 2' ); + } + + @isTest + private static void registerNew_whenGivenAnSobject_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( LastName = 'test' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerNew( expected ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForInsert.size(), 'registerNew, when given an SObject, will queue the record' ); + System.assertEquals( expected, dml.recordsForInsert[0], 'registerNew, when given an SObject, will queue the record' ); + } + + @isTest + private static void registerNew_whenGivenMultipleSobjects_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( LastName = 'test' ), + new Account( Name = 'test' ), + new Opportunity( Name = 'test' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerNew( records ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForInsert.size(), 'registerNew, when given a list of SObjects, will register the types and the DML' ); + // Don't really know why it reverses it on inserts, but not on empty recycle bin - but it does! + System.assertEquals( records[2], dml.recordsForInsert[0], 'registerNew, when given a list of SObjects, will register the types and the DML - checking record 0' ); + System.assertEquals( records[1], dml.recordsForInsert[1], 'registerNew, when given a list of SObjects, will register the types and the DML - checking record 1' ); + System.assertEquals( records[0], dml.recordsForInsert[2], 'registerNew, when given a list of SObjects, will register the types and the DML - checking record 2' ); + } + + @isTest + private static void registerNew_whenGivenMultipleSobjectsAndRelationships_registersTheTypesAndDmlInOrder() // NOPMD: Test method name format + { + Opportunity opportunityRecord = new Opportunity( Name = 'test' ); + Contact contactRecord = new Contact( LastName = 'test' ); + Account accountRecord = new Account( Name = 'test' ); + + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerNew( accountRecord ); + uow.registerNew( opportunityRecord, Opportunity.AccountId, accountRecord ); + uow.registerNew( contactRecord, Contact.AccountId, accountRecord ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForInsert.size(), 'registerNew, when given sobjects with relationships, will register the types and the DML' ); + + System.assertEquals( accountRecord, dml.recordsForInsert[0], 'registerNew, when given sobjects with relationships, will register the types and the DML in the right order - checking record 0' ); + System.assertEquals( contactRecord, dml.recordsForInsert[1], 'registerNew, when given sobjects with relationships, will register the types and the DML in the right order - checking record 1' ); + System.assertEquals( opportunityRecord, dml.recordsForInsert[2], 'registerNew, when given sobjects with relationships, will register the types and the DML in the right order - checking record 2' ); + } + + @isTest + private static void registerRelationship_whenGivenMultipleSobjectsAndRelationships_registersTheTypesAndDmlInOrder() // NOPMD: Test method name format + { + Opportunity opportunityRecord = new Opportunity( Name = 'test' ); + Contact contactRecord = new Contact( LastName = 'test' ); + Account accountRecord = new Account( Name = 'test' ); + + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerNew( opportunityRecord ); + uow.registerNew( contactRecord ); + uow.registerNew( accountRecord ); + + uow.registerRelationship( opportunityRecord, Opportunity.AccountId, accountRecord ); + uow.registerRelationship( contactRecord, Contact.AccountId, accountRecord ); + + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForInsert.size(), 'registerRelationship, when given sobjects with relationships, will register the types and the DML' ); + + System.assertEquals( accountRecord, dml.recordsForInsert[0], 'registerRelationship, when given sobjects with relationships, will register the types and the DML in the right order - checking record 0' ); + System.assertEquals( contactRecord, dml.recordsForInsert[1], 'registerRelationship, when given sobjects with relationships, will register the types and the DML in the right order - checking record 1' ); + System.assertEquals( opportunityRecord, dml.recordsForInsert[2], 'registerRelationship, when given sobjects with relationships, will register the types and the DML in the right order - checking record 2' ); + } + + @isTest + private static void registerDirty_whenGivenAnSobject_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( Id = TestIdUtils.generateId( Contact.SobjectType ), LastName = 'test' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDirty( expected ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForUpdate.size(), 'registerDirty, when given an SObject, will queue the record' ); + System.assertEquals( expected, dml.recordsForUpdate[0], 'registerDirty, when given an SObject, will queue the record' ); + } + + @isTest + private static void registerDirty_whenGivenAnSobjectAndDirtyFields_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( Id = TestIdUtils.generateId( Contact.SobjectType ), LastName = 'test', FirstName = 'ignored' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDirty( expected, new List{ Contact.LastName } ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForUpdate.size(), 'registerDirty, when given an SObject and dirty fields, will queue the record' ); + System.assertEquals( expected.Id, dml.recordsForUpdate[0].Id, 'registerDirty, when given an SObject and dirty fields, will queue the record' ); + System.assertEquals( expected.LastName, ((Contact)dml.recordsForUpdate[0]).LastName, 'registerDirty, when given an SObject and dirty fields, will queue the record' ); + System.assertEquals( false, SobjectUtils.hasFieldPopulated( dml.recordsForUpdate[0], Contact.FirstName ), 'registerDirty, when given an SObject and dirty fields, will queue the record, only with the dirty fields included' ); + } + + @isTest + private static void registerDirty_whenGivenMultipleSobjects_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( Id = TestIdUtils.generateId( Contact.SobjectType ), LastName = 'test' ), + new Account( Id = TestIdUtils.generateId( Account.SobjectType ), Name = 'test' ), + new Opportunity( Id = TestIdUtils.generateId( Opportunity.SobjectType ), Name = 'test' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDirty( records ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForUpdate.size(), 'registerDirty, when given a list of SObjects, will register the types and the DML' ); + System.assertEquals( records[2], dml.recordsForUpdate[0], 'registerDirty, when given a list of SObjects, will register the types and the DML - checking record 0' ); + System.assertEquals( records[1], dml.recordsForUpdate[1], 'registerDirty, when given a list of SObjects, will register the types and the DML - checking record 1' ); + System.assertEquals( records[0], dml.recordsForUpdate[2], 'registerDirty, when given a list of SObjects, will register the types and the DML - checking record 2' ); + } + + @isTest + private static void registerDirty_whenGivenMultipleSobjectsAndDirtyFields_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( Id = TestIdUtils.generateId( Contact.SobjectType ), FirstName = 'ignored', LastName = 'test 1' ), + new Contact( Id = TestIdUtils.generateId( Contact.SobjectType ), FirstName = 'ignored', LastName = 'test 2' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDirty( records, new List{ Contact.LastName } ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 2, dml.recordsForUpdate.size(), 'registerDirty, when given a list of SObject and dirty fields, will queue the records' ); + + System.assertEquals( records[0].Id, dml.recordsForUpdate[0].Id, 'registerDirty, when given a list of SObject and dirty fields, will queue the records - 0' ); + System.assertEquals( records[0].LastName, ((Contact)dml.recordsForUpdate[0]).LastName, 'registerDirty, when given a list of SObject and dirty fields, will queue the records - 0' ); + System.assertEquals( false, SobjectUtils.hasFieldPopulated( dml.recordsForUpdate[0], Contact.FirstName ), 'registerDirty, when given an SObject and dirty fields, will queue the records, only with the dirty fields included - 0' ); + + System.assertEquals( records[1].Id, dml.recordsForUpdate[1].Id, 'registerDirty, when given a list of SObject and dirty fields, will queue the records - 1' ); + System.assertEquals( records[1].LastName, ((Contact)dml.recordsForUpdate[1]).LastName, 'registerDirty, when given a list of SObject and dirty fields, will queue the records - 1' ); + System.assertEquals( false, SobjectUtils.hasFieldPopulated( dml.recordsForUpdate[1], Contact.FirstName ), 'registerDirty, when given an SObject and dirty fields, will queue the records, only with the dirty fields included - 1' ); + } + + + @isTest + private static void registerDirty_whenGivenMultipleSobjectsAndRelationships_registersTheTypesAndDmlInOrder() // NOPMD: Test method name format + { + Opportunity opportunityRecord = new Opportunity( Id = TestIdUtils.generateId( Opportunity.SobjectType ), Name = 'test' ); + Contact contactRecord = new Contact( Id = TestIdUtils.generateId( Contact.SobjectType ), LastName = 'test' ); + Account accountRecord = new Account( Id = TestIdUtils.generateId( Account.SobjectType ), Name = 'test' ); + + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDirty( accountRecord ); + uow.registerDirty( opportunityRecord, Opportunity.AccountId, accountRecord ); + uow.registerDirty( contactRecord, Contact.AccountId, accountRecord ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForUpdate.size(), 'registerDirty, when given sobjects with relationships, will register the types and the DML' ); + + System.assertEquals( accountRecord, dml.recordsForUpdate[0], 'registerDirty, when given sobjects with relationships, will register the types and the DML in the right order - checking record 0' ); + System.assertEquals( contactRecord, dml.recordsForUpdate[1], 'registerDirty, when given sobjects with relationships, will register the types and the DML in the right order - checking record 1' ); + System.assertEquals( opportunityRecord, dml.recordsForUpdate[2], 'registerDirty, when given sobjects with relationships, will register the types and the DML in the right order - checking record 2' ); + } + + @isTest + private static void registerUpsert_whenGivenAnSobject_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( LastName = 'test' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerUpsert( expected ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForInsert.size(), 'registerUpsert, when given an SObject, will queue the record' ); + System.assertEquals( expected, dml.recordsForInsert[0], 'registerUpsert, when given an SObject, will queue the record' ); + } + + @isTest + private static void registerUpsert_whenGivenMultipleSobjects_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( LastName = 'test' ), + new Account( Id = TestIdUtils.generateId( Account.sobjectType ), Name = 'test' ), + new Opportunity( Name = 'test' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerUpsert( records ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 2, dml.recordsForInsert.size(), 'registerUpsert, when given a list of SObjects, will register the types and the DML - insert' ); + System.assertEquals( records[2], dml.recordsForInsert[0], 'registerUpsert, when given a list of SObjects, will register the types and the DML - checking record 0 - insert' ); + System.assertEquals( records[0], dml.recordsForInsert[1], 'registerUpsert, when given a list of SObjects, will register the types and the DML - checking record 2 - insert' ); + + System.assertEquals( 1, dml.recordsForUpdate.size(), 'registerUpsert, when given a list of SObjects, will register the types and the DML - update' ); + System.assertEquals( records[1], dml.recordsForUpdate[0], 'registerUpsert, when given a list of SObjects, will register the types and the DML - checking record 1 - update' ); + } + + @isTest + private static void registerDeleted_whenGivenAnSobject_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( Id = TestIdUtils.generateId( Contact.sobjectType ), LastName = 'test' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDeleted( expected ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForDelete.size(), 'registerDeleted, when given an SObject, will queue the record' ); + System.assertEquals( expected, dml.recordsForDelete[0], 'registerDeleted, when given an SObject, will queue the record' ); + } + + @isTest + private static void registerDeleted_whenGivenMultipleSobjects_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( Id = TestIdUtils.generateId( Contact.sobjectType ), LastName = 'test' ), + new Account( Id = TestIdUtils.generateId( Account.sobjectType ), Name = 'test' ), + new Opportunity( Id = TestIdUtils.generateId( Opportunity.sobjectType ), Name = 'test' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerDeleted( records ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForDelete.size(), 'registerDeleted, when given a list of SObjects, will register the types and the DML' ); + System.assertEquals( records[0], dml.recordsForDelete[0], 'registerDeleted, when given a list of SObjects, will register the types and the DML - checking record 0' ); + System.assertEquals( records[1], dml.recordsForDelete[1], 'registerDeleted, when given a list of SObjects, will register the types and the DML - checking record 1' ); + System.assertEquals( records[2], dml.recordsForDelete[2], 'registerDeleted, when given a list of SObjects, will register the types and the DML - checking record 2' ); + } + + @isTest + private static void registerPermanentlyDeleted_whenGivenAnSobject_registersTheTypeForDml() // NOPMD: Test method name format + { + Contact expected = new Contact( Id = TestIdUtils.generateId( Contact.sobjectType ), LastName = 'test' ); + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPermanentlyDeleted( expected ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 1, dml.recordsForDelete.size(), 'registerPermanentlyDeleted, when given an SObject, will queue the record' ); + System.assertEquals( expected, dml.recordsForDelete[0], 'registerPermanentlyDeleted, when given an SObject, will queue the record' ); + } + + @isTest + private static void registerPermanentlyDeleted_whenGivenMultipleSobjects_registersTheTypesAndDml() // NOPMD: Test method name format + { + List records = new List + { + new Contact( Id = TestIdUtils.generateId( Contact.sobjectType ), LastName = 'test' ), + new Account( Id = TestIdUtils.generateId( Account.sobjectType ), Name = 'test' ), + new Opportunity( Id = TestIdUtils.generateId( Opportunity.sobjectType ), Name = 'test' ) + }; + MockDml dml = new MockDml(); + + Test.startTest(); + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPermanentlyDeleted( records ); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals( 3, dml.recordsForDelete.size(), 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML' ); + System.assertEquals( records[0], dml.recordsForDelete[0], 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - checking record 0' ); + System.assertEquals( records[1], dml.recordsForDelete[1], 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - checking record 1' ); + System.assertEquals( records[2], dml.recordsForDelete[2], 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - checking record 2' ); + + System.assertEquals( 3, dml.recordsForRecycleBin.size(), 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - recordsForRecycleBin' ); + System.assertEquals( records[0], dml.recordsForRecycleBin[0], 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - checking record 0 - recordsForRecycleBin' ); + System.assertEquals( records[1], dml.recordsForRecycleBin[1], 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - checking record 1 - recordsForRecycleBin' ); + System.assertEquals( records[2], dml.recordsForRecycleBin[2], 'registerPermanentlyDeleted, when given a list of SObjects, will register the types and the DML - checking record 2 - recordsForRecycleBin' ); + } + + @isTest + private static void registerPublishBeforeTransaction_whenCalledWithANonEvent_throwsAnException() // NOPMD: Test method name format + { + Contact notAnEvent = new Contact(); + MockDml dml = new MockDml(); + + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPublishBeforeTransaction( notAnEvent ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishBeforeTransaction, when called with a non event, will throw an exception' ); + } + + @isTest + private static void registerPublishBeforeTransaction_whenCalledWithNonEvents_throwsAnException() // NOPMD: Test method name format + { + List notEvents = new List{ new Contact() }; + MockDml dml = new MockDml(); + + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPublishBeforeTransaction( notEvents ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishBeforeTransaction, when called with a non event, will throw an exception' ); + } + + @isTest + private static void registerPublishAfterSuccessTransaction_whenCalledWithANonEvent_throwsAnException() // NOPMD: Test method name format + { + Contact notAnEvent = new Contact(); + MockDml dml = new MockDml(); + + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPublishAfterSuccessTransaction( notAnEvent ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishAfterSuccessTransaction, when called with a non event, will throw an exception' ); + } + + @isTest + private static void registerPublishAfterSuccessTransaction_whenCalledWithNonEvents_throwsAnException() // NOPMD: Test method name format + { + List notEvents = new List{ new Contact() }; + MockDml dml = new MockDml(); + + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPublishAfterSuccessTransaction( notEvents ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishAfterSuccessTransaction, when called with a non event, will throw an exception' ); + } + + @isTest + private static void registerPublishAfterFailureTransaction_whenCalledWithANonEvent_throwsAnException() // NOPMD: Test method name format + { + Contact notAnEvent = new Contact(); + MockDml dml = new MockDml(); + + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPublishAfterFailureTransaction( notAnEvent ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishAfterFailureTransaction, when called with a non event, will throw an exception' ); + } + + @isTest + private static void registerPublishAfterFailureTransaction_whenCalledWithNonEvents_throwsAnException() // NOPMD: Test method name format + { + List notEvents = new List{ new Contact() }; + MockDml dml = new MockDml(); + + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerPublishAfterFailureTransaction( notEvents ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishAfterFailureTransaction, when called with a non event, will throw an exception' ); + } + + + // TODO: when given a circular reference + + // IDml is defined as an inner class, and so cannot be mocked using Amoss at time of writing + private class MockDml implements fflib_SObjectUnitOfWork.IDml + { + public List recordsForInsert = new List(); + public List recordsForUpdate = new List(); + public List recordsForDelete = new List(); + public List recordsForRecycleBin = new List(); + public List recordsForEventPublish = new List(); + + public void dmlInsert(List objList) + { + this.recordsForInsert.addAll(objList); + } + + public void dmlUpdate(List objList) + { + this.recordsForUpdate.addAll(objList); + } + + public void dmlDelete(List objList) + { + this.recordsForDelete.addAll(objList); + } + + public void eventPublish(List objList) + { + this.recordsForEventPublish.addAll(objList); + } + + public void emptyRecycleBin(List objList) + { + this.recordsForRecycleBin.addAll(objList); + } + } +} \ No newline at end of file diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls-meta.xml b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls-meta.xml new file mode 100644 index 00000000000..dd61d1f917e --- /dev/null +++ b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 52.0 + Active + diff --git a/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls b/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls index b09eb9b4a49..fe532737bf6 100644 --- a/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls +++ b/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls @@ -125,6 +125,20 @@ public inherited sharing class SobjectUtils return sobjectName; } + /** + * States if the given record has the specified field 'populated' as defined by the behaviour + * of 'getPopulatedFieldsAsMap', being either: + * Retrieved in a SOQL statement, or + * Explicitly set in Apex + * + * @param Sobject The record to check + * @return SobjectField The field to look for + */ + public static Boolean hasFieldPopulated( Sobject record, SobjectField field ) + { + return record.getPopulatedFieldsAsMap().keySet().contains( field.getDescribe().getName() ); + } + /** * States if the given SObject is of a type that the current user is allowed to insert * diff --git a/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls b/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls index 337d95b004e..6be510c3fbc 100644 --- a/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls +++ b/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls @@ -170,6 +170,72 @@ private without sharing class SobjectUtilsTest ortoo_Asserts.assertContains( 'Attempted to get the local name of a null SObjectType', exceptionMessage, 'getSobjectLocalName, when given a null SobjectType, will throw an exception' ); } + @isTest + private static void hasFieldPopulated_whenTheFieldIsPopulatedByApex_returnsTrue() // NOPMD: Test method name format + { + Contact con = new Contact( LastName = 'lastname' ); + Test.startTest(); + Boolean got = SObjectUtils.hasFieldPopulated( con, Contact.LastName ); + Test.stopTest(); + + System.assertEquals( true, got, 'hasFieldPopulated, when given an sobject object with the specified field populated by Apex, will return true' ); + } + + @isTest + private static void hasFieldPopulated_whenTheFieldIsPopulatedByApexToNull_returnsTrue() // NOPMD: Test method name format + { + Contact con = new Contact( LastName = null ); + Test.startTest(); + Boolean got = SObjectUtils.hasFieldPopulated( con, Contact.LastName ); + Test.stopTest(); + + System.assertEquals( true, got, 'hasFieldPopulated, when given an sobject object with the specified field populated by Apex, will return true' ); + } + + @isTest + private static void hasFieldPopulated_whenTheFieldIsNotPopulatedByApex_returnsFalse() // NOPMD: Test method name format + { + Contact con = new Contact(); + Test.startTest(); + Boolean got = SObjectUtils.hasFieldPopulated( con, Contact.LastName ); + Test.stopTest(); + + System.assertEquals( false, got, 'hasFieldPopulated, when given an sobject object with the specified field not populated, will return false' ); + } + + @isTest + private static void hasFieldPopulated_whenNamespacedFieldIsPopulatedByApex_returnsTrue() // NOPMD: Test method name format + { + Application_Configuration__mdt con = new Application_Configuration__mdt( Type__c = 'Service' ); + Test.startTest(); + Boolean got = SObjectUtils.hasFieldPopulated( con, Application_Configuration__mdt.Type__c ); + Test.stopTest(); + + System.assertEquals( true, got, 'hasFieldPopulated, when given a namespaced sobject object with the specified field populated by Apex, will return true' ); + } + + @isTest + private static void hasFieldPopulated_whenNamespacedFieldIsPopulatedByApexToNull_returnsTrue() // NOPMD: Test method name format + { + Application_Configuration__mdt con = new Application_Configuration__mdt( Type__c = null ); + Test.startTest(); + Boolean got = SObjectUtils.hasFieldPopulated( con, Application_Configuration__mdt.Type__c ); + Test.stopTest(); + + System.assertEquals( true, got, 'hasFieldPopulated, when given a namespaced sobject object with the specified field populated by Apex, will return true' ); + } + + @isTest + private static void hasFieldPopulated_whenNamespacedFieldIsNotPopulatedByApex_returnsFalse() // NOPMD: Test method name format + { + Application_Configuration__mdt con = new Application_Configuration__mdt(); + Test.startTest(); + Boolean got = SObjectUtils.hasFieldPopulated( con, Application_Configuration__mdt.Type__c ); + Test.stopTest(); + + System.assertEquals( false, got, 'hasFieldPopulated, when given a namespaced sobject object with the specified field not populated, will return false' ); + } + @isTest private static void isCreateable_whenCalled_willReturnIsCreatableOfThatSobject() // NOPMD: Test method name format { From be4e4c412b9aff66dc9ce3ede5bbaceae1a39b3f Mon Sep 17 00:00:00 2001 From: Robert Baillie Date: Fri, 22 Apr 2022 16:37:57 +0100 Subject: [PATCH 3/4] Added output of better exception when a circular reference is found in the Dynamic UOW Improved string output of directed graph --- .../ortoo_DynamicSobjectUnitOfWork.cls | 11 +++- .../ortoo_DynamicSobjectUnitOfWorkTest.cls | 26 +++++++++- .../default/classes/utils/DirectedGraph.cls | 32 ++++++++++++ .../default/classes/utils/ListUtils.cls | 24 +++++++++ .../default/classes/utils/SobjectUtils.cls | 10 ++-- .../classes/utils/tests/DirectedGraphTest.cls | 43 ++++++++++++++++ .../classes/utils/tests/ListUtilsTest.cls | 38 ++++++++++++++ .../classes/utils/tests/SobjectUtilsTest.cls | 50 +++++++++++++++++++ 8 files changed, 229 insertions(+), 5 deletions(-) diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls index eea1e50162e..9121909f283 100644 --- a/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/ortoo_DynamicSobjectUnitOfWork.cls @@ -362,7 +362,16 @@ public inherited sharing class ortoo_DynamicSobjectUnitOfWork extends ortoo_Sobj private List generateOrderOfOperations() { - List childToParentTypes = graph.generateSorted(); + List childToParentTypes; + try + { + childToParentTypes = graph.generateSorted(); + } + catch ( DirectedGraph.GraphContainsCircularReferenceException e ) + { + + throw new Exceptions.ConfigurationException( 'Cannot resolve the order of work to be done for the commit, there is a circular reference in the data\n' + graph.toString(), e ); + } List parentToChildTypes = new List(); for( Integer i = childToParentTypes.size() - 1; i >= 0; i-- ) diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls index 6d598ee0baf..62cd7071c54 100644 --- a/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls @@ -523,8 +523,32 @@ private without sharing class ortoo_DynamicSobjectUnitOfWorkTest ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishAfterFailureTransaction, when called with a non event, will throw an exception' ); } + @isTest + private static void commitWork_whenGivenCircularReferences_throwsAnException() // NOPMD: Test method name format + { + Contact childContact = new Contact( LastName = 'Child' ); + Contact parentContact = new Contact( LastName = 'Parent' ); + MockDml dml = new MockDml(); - // TODO: when given a circular reference + Test.startTest(); + String exceptionMessage; + try + { + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork( dml ); + uow.registerNew( childContact ); + uow.registerNew( parentContact ); + uow.registerRelationship( childContact, Contact.ReportsToId, parentContact ); + uow.commitWork(); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'Cannot resolve the order of work to be done for the commit, there is a circular reference in the data', exceptionMessage, 'commitWork, when given circular references, will throw an exception' ); + ortoo_Asserts.assertContains( 'Contact is a child of Contact', exceptionMessage, 'commitWork, when given circular references, will throw an exception detailing the relationships' ); + } // IDml is defined as an inner class, and so cannot be mocked using Amoss at time of writing private class MockDml implements fflib_SObjectUnitOfWork.IDml diff --git a/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls b/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls index 7fa8ace1c42..a190b1119d9 100644 --- a/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls +++ b/framework/default/ortoo-core/default/classes/utils/DirectedGraph.cls @@ -92,6 +92,38 @@ public inherited sharing class DirectedGraph return sortedObjects; } + /** + * Produces a String representation of the graph + * + * @return String The graph + */ + public override String toString() + { + if ( parentsByChildren.isEmpty() ) + { + return 'Empty Graph'; + } + + List relationships = new List(); + + for ( Object thisChild : parentsByChildren.keySet() ) + { + List parents = ListUtils.convertToListOfStrings( parentsByChildren.get( thisChild ) ); + + String description; + if ( parents.size() == 0 ) + { + description = String.valueOf( thisChild ) + ' is not a child'; + } + else + { + description = String.valueOf( thisChild ) + ' is a child of ' + String.join( parents, ', ' ); + } + relationships.add( description ); + } + return String.join( relationships, '\n' ); + } + /** * A reference to the full list of nodes registered on this graph. */ diff --git a/framework/default/ortoo-core/default/classes/utils/ListUtils.cls b/framework/default/ortoo-core/default/classes/utils/ListUtils.cls index 58b1ad9ea0f..4215a8535ac 100644 --- a/framework/default/ortoo-core/default/classes/utils/ListUtils.cls +++ b/framework/default/ortoo-core/default/classes/utils/ListUtils.cls @@ -138,6 +138,30 @@ public inherited sharing class ListUtils return returnList; } + /** + * Given a Set of Objects, will return a List of those Strings + * + * Note this will only work for sets defined as Set + * + * @param Set The set of Obejcts to convert + * @return List The converted list + */ + public static List convertToListOfStrings( Set setOfObjects ) + { + if ( setOfObjects == null ) + { + return new List(); + } + + List returnList = new List(); + for ( Object thisObject : setOfObjects ) + { + returnList.add( thisObject.toString() ); + } + + return returnList; + } + /** * Given a Set of any Strings, will return a List of those Strings * diff --git a/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls b/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls index fe532737bf6..4e18d6065d4 100644 --- a/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls +++ b/framework/default/ortoo-core/default/classes/utils/SobjectUtils.cls @@ -62,11 +62,15 @@ public inherited sharing class SobjectUtils return record.getSObjectType(); } - // TODO: document - // TODO: test + /** + * Given a list of instances of SObjects, will return their SobjectTypes + * + * @param List The SObjects for which to return the SobjectTypes + * @return Set The SobjectTypes of the SObjects + */ public static Set getSobjectTypes( List records ) { - Contract.requires( records != null, 'getSobjectType called with a null records' ); + Contract.requires( records != null, 'getSobjectTypes called with a null records' ); Set types = new Set(); for ( Sobject thisRecord : records ) diff --git a/framework/default/ortoo-core/default/classes/utils/tests/DirectedGraphTest.cls b/framework/default/ortoo-core/default/classes/utils/tests/DirectedGraphTest.cls index 74ce60b9086..597e150df6b 100644 --- a/framework/default/ortoo-core/default/classes/utils/tests/DirectedGraphTest.cls +++ b/framework/default/ortoo-core/default/classes/utils/tests/DirectedGraphTest.cls @@ -181,4 +181,47 @@ private without sharing class DirectedGraphTest ortoo_Asserts.assertContains( 'addRelationship called with a parent that has not been added as a node (UnregisteredParent)', exceptionMessage, 'addRelationship, when given a parent that has not previously been added, will throw an exception' ); } + + @isTest + private static void toString_whenCalledAgainstAnGraph_returnsADescriptionOfTheGraph() // NOPMD: Test method name format + { + DirectedGraph graph = new DirectedGraph() + .addNode( 'Grandparent' ) + .addNode( 'Parent1' ) + .addNode( 'Parent2' ) + .addNode( 'Child1' ) + .addNode( 'Child2' ) + .addNode( 'Child3' ) + .addRelationship( 'Child1', 'Parent1' ) + .addRelationship( 'Child1', 'Parent2' ) + .addRelationship( 'Child2', 'Parent1' ) + .addRelationship( 'Child3', 'Parent2' ) + .addRelationship( 'Parent1', 'Grandparent' ); + + String expected = '' + + 'Grandparent is not a child\n' + + 'Parent1 is a child of Grandparent\n' + + 'Parent2 is not a child\n' + + 'Child1 is a child of Parent1, Parent2\n' + + 'Child2 is a child of Parent1\n' + + 'Child3 is a child of Parent2'; + + Test.startTest(); + String got = graph.toString(); + Test.stopTest(); + + System.assertEquals( expected, got, 'toString, when called, will return a description of the graph' ); + } + + @isTest + private static void toString_whenCalledAgainstAnEmptyGraph_returnsEmpty() // NOPMD: Test method name format + { + DirectedGraph graph = new DirectedGraph(); + + Test.startTest(); + String got = graph.toString(); + Test.stopTest(); + + System.assertEquals( 'Empty Graph', got, 'toString, when called against an empty graph, will return empty' ); + } } \ No newline at end of file diff --git a/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls b/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls index 28f3f423552..bf73f5038ea 100644 --- a/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls +++ b/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls @@ -498,6 +498,44 @@ private without sharing class ListUtilsTest System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given null set of strings, will return an empty list' ); } + @isTest + private static void convertToListOfStrings_setObject_whenGivenASetOfStrings_returnsAListOfString() // NOPMD: Test method name format + { + Set setOfStrings = new Set{ 'one', 'two', 'three' }; + + Test.startTest(); + List returnedList = ListUtils.convertToListOfStrings( setOfStrings ); + Test.stopTest(); + + List expectedList = new List{ 'one', 'two', 'three' }; + + System.assertEquals( expectedList, returnedList, 'convertToListOfStrings, when given a set of Object, will return a list of the strings' ); + } + + @isTest + private static void convertToListOfStrings_setObject_whenGivenAnEmptySet_returnsAnEmptySet() // NOPMD: Test method name format + { + Set emptySetOfStrings = new Set(); + + Test.startTest(); + List returnedList = ListUtils.convertToListOfStrings( emptySetOfStrings ); + Test.stopTest(); + + System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given an empty set of Object, will return an empty list' ); + } + + @isTest + private static void convertToListOfStrings_setObject_whenGivenNull_returnsAnEmptyList() // NOPMD: Test method name format + { + Set nullSetOfObjects = null; + + Test.startTest(); + List returnedList = ListUtils.convertToListOfStrings( nullSetOfObjects ); + Test.stopTest(); + + System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given null set of Object, will return an empty list' ); + } + private inherited sharing class StringableThing { String name; public StringableThing( String name ) diff --git a/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls b/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls index 6be510c3fbc..6b95d646241 100644 --- a/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls +++ b/framework/default/ortoo-core/default/classes/utils/tests/SobjectUtilsTest.cls @@ -82,6 +82,56 @@ private without sharing class SobjectUtilsTest ortoo_Asserts.assertContains( 'getSobjectType called with a null record', exceptionMessage, 'getSobjectType, when passed a null record, will throw an exception' ); } + @isTest + private static void getSobjectTypes_whenGivenRecords_willReturnTheirTypes() // NOPMD: Test method name format + { + Set actualTypes = SobjectUtils.getSobjectTypes( new List{ new Contact(), new Account() } ); + + System.assertEquals( 2, actualTypes.size(), 'getSobjectType, when given SObject records, will return their types' ); + System.assert( actualTypes.contains( Contact.sobjectType ), 'getSobjectType, when given SObject records, will return their types - 1' ); + System.assert( actualTypes.contains( Account.sobjectType ), 'getSobjectType, when given SObject records, will return their types - 2' ); + } + + @isTest + private static void getSobjectTypes_whenPassedANullRecord_willThrowAnException() // NOPMD: Test method name format + { + Sobject nullRecord = null; + + Test.startTest(); + String exceptionMessage; + try + { + SobjectUtils.getSobjectTypes( new List{ new Contact(), new Account(), null } ); + } + catch ( Contract.RequiresException e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'getSobjectType called with a null record', exceptionMessage, 'getSobjectType, when passed a null record, will throw an exception' ); + } + + @isTest + private static void getSobjectTypes_whenPassedANullList_willThrowAnException() // NOPMD: Test method name format + { + List nullList = null; + + Test.startTest(); + String exceptionMessage; + try + { + SobjectUtils.getSobjectTypes( nullList ); + } + catch ( Contract.RequiresException e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + ortoo_Asserts.assertContains( 'getSobjectTypes called with a null records', exceptionMessage, 'getSobjectTypes, when passed a null list, will throw an exception' ); + } + @isTest private static void getSobjectName_whenGivenAnSobject_willReturnTheApiNameOfIt() // NOPMD: Test method name format { From fdc89e859bd1ef549833f92cbea57aec3277eb4d Mon Sep 17 00:00:00 2001 From: Robert Baillie Date: Fri, 22 Apr 2022 16:44:28 +0100 Subject: [PATCH 4/4] Added direct test for the dynamic unit of work ordering resolution --- .../ortoo_DynamicSobjectUnitOfWorkTest.cls | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls index 62cd7071c54..61cba347ee3 100644 --- a/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/tests/ortoo_DynamicSobjectUnitOfWorkTest.cls @@ -523,6 +523,35 @@ private without sharing class ortoo_DynamicSobjectUnitOfWorkTest ortoo_Asserts.assertContains( 'SObject type Contact is invalid for publishing within this unit of work', exceptionMessage, 'registerPublishAfterFailureTransaction, when called with a non event, will throw an exception' ); } + @isTest + private static void onCommitWorkStarting_whenCalled_setsTheOrderOfTheOperations() // NOPMD: Test method name format + { + Opportunity opportunityRecord = new Opportunity( Name = 'test' ); + Contact contactRecord = new Contact( LastName = 'test' ); + Account accountRecord = new Account( Name = 'test' ); + + Test.startTest(); + + ortoo_DynamicSobjectUnitOfWork uow = new ortoo_DynamicSobjectUnitOfWork(); + + uow.registerNew( opportunityRecord ); + uow.registerNew( contactRecord ); + uow.registerNew( accountRecord ); + + uow.registerRelationship( contactRecord, Contact.AccountId, accountRecord ); + uow.registerRelationship( opportunityRecord, Opportunity.AccountId, accountRecord ); + + uow.onCommitWorkStarting(); + + Test.stopTest(); + + List expected = new List{ + Account.sobjectType, Contact.sobjectType, Opportunity.sobjectType + }; + + System.assertEquals( expected, uow.m_sObjectTypes, 'onCommitWorkStarting, when called, will set the order of operations' ); + } + @isTest private static void commitWork_whenGivenCircularReferences_throwsAnException() // NOPMD: Test method name format {