diff --git a/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls b/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls index 537bf3ed78..d598fbecfc 100644 --- a/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls +++ b/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls @@ -60,6 +60,9 @@ public virtual class fflib_SObjectUnitOfWork protected Map> m_dirtyMapByType = new Map>(); + protected Map> m_upsertRecordsPerType = new Map>(); + protected Map m_externalIdToUpsertPerType = new Map(); + protected Map> m_deletedMapByType = new Map>(); protected Map> m_emptyRecycleBinMapByType = new Map>(); @@ -88,6 +91,7 @@ public virtual class fflib_SObjectUnitOfWork { void dmlInsert(List objList); void dmlUpdate(List objList); + void dmlUpsert(List objList, Schema.SObjectField externalId); void dmlDelete(List objList); void eventPublish(List objList); void emptyRecycleBin(List objList); @@ -103,6 +107,16 @@ public virtual class fflib_SObjectUnitOfWork { update objList; } + public virtual void dmlUpsert(List objList, Schema.SObjectField externalId) { + if (!objList.isEmpty()) { + + Type objListType = Type.ForName('List<' + objList[0].getSObjectType() + '>'); + List typpedList = (List)objListType.newInstance(); + typpedList.addAll(objList); + + Database.upsert(typpedList, externalId); + } + } public virtual void dmlDelete(List objList) { delete objList; @@ -212,6 +226,7 @@ public virtual class fflib_SObjectUnitOfWork // add type to dml operation tracking m_newListByType.put(sObjectName, new List()); m_dirtyMapByType.put(sObjectName, new Map()); + m_upsertRecordsPerType.put(sObjectName, new List()); m_deletedMapByType.put(sObjectName, new Map()); m_emptyRecycleBinMapByType.put(sObjectName, new Map()); m_relationships.put(sObjectName, new Relationships()); @@ -480,6 +495,70 @@ public virtual class fflib_SObjectUnitOfWork } } + /** + * Register a list of mix of new and existing records to be inserted updated during the commitWork method by and external id + * + * @param records A list of mix of new and existing records + * @param externalIdField External id field to use for upserting operation + **/ + public void registerUpsert(List records, Schema.SObjectField externalIdField) + { + for (SObject record : records) + { + this.registerUpsert(record, externalIdField, null, null); + } + } + + + /** + * Register an existing or new record to be upserted when commitWork is called using an external id field, + * 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 registerUpsert(SObject record, Schema.SObjectField externalIdField, Schema.sObjectField relatedToParentField, SObject relatedToParentRecord) + { + + SObjectType sObjectType = record.getSObjectType(); + String sObjName = sObjectType.getDescribe().getName(); + + assertForNonEventSObjectType(sObjName); + assertForSupportedSObjectType(m_upsertRecordsPerType, sObjName); + + if (externalIdField == null) + throw new UnitOfWorkException('Invalid argument: externalIdField. If you want to upsert by id, use the registerUpsert method that has only one argument'); + + String externalIdFieldName = externalIdField.getDescribe().getName(); + Boolean relatedHasExternalIdField = sObjectType.getDescribe().fields.getMap().keySet().contains(externalIdFieldName.toLowerCase()); + Boolean externalIdFieldIsValid = externalIdField.getDescribe().isIdLookup(); + + if (record.Id != null && !externalIdFieldName.equalsIgnoreCase('id')) + throw new UnitOfWorkException('When upserting by external id, the record cannot already have the standard id populated'); + + if (!relatedHasExternalIdField) + throw new UnitOfWorkException('Invalid argument: externalIdField. Field supplied is not a known field on the target sObject.'); + + if (!externalIdFieldIsValid) + throw new UnitOfWorkException('Invalid argument: externalIdField. Field supplied cannot be used with upsert.'); + + Schema.SObjectField registeredExternalId = m_externalIdToUpsertPerType.get(sObjName); + if (registeredExternalId != null && registeredExternalId != externalIdField) + { + throw new UnitOfWorkException(String.format( + 'SObject type {0} has already registered an upsert by external id {1}, you cannot use another is this unit of work.', + new List {sObjName, registeredExternalId.getDescribe().getName()} + )); + } + + m_upsertRecordsPerType.get(sObjName).add(record); + m_externalIdToUpsertPerType.put(sObjName, externalIdField); + + if (relatedToParentRecord!=null && relatedToParentField!=null) + registerRelationship(record, relatedToParentField, relatedToParentRecord); + } + /** * Register an existing record to be deleted during the commitWork method * @@ -646,7 +725,8 @@ public virtual class fflib_SObjectUnitOfWork onPublishBeforeEventsFinished(); onDMLStarting(); - insertDmlByType(); + insertDmlByType(); + upsertDmlByType(); updateDmlByType(); deleteDmlByType(); emptyRecycleBinByType(); @@ -711,6 +791,16 @@ public virtual class fflib_SObjectUnitOfWork } } + private void upsertDmlByType() + { + String sobjName; + for (Schema.SObjectType sObjectType : m_sObjectTypes) { + sobjName = sObjectType.getDescribe().getName(); + m_relationships.get(sobjName).resolve(); + m_dml.dmlUpsert(m_upsertRecordsPerType.get(sobjName), m_externalIdToUpsertPerType.get(sobjName)); + } + } + private void deleteDmlByType() { Integer objectIdx = m_sObjectTypes.size() - 1; @@ -811,8 +901,10 @@ public virtual class fflib_SObjectUnitOfWork // Resolve relationships for (IRelationship relationship : m_relationships) { - //relationship.Record.put(relationship.RelatedToField, relationship.RelatedTo.Id); - relationship.resolve(); + if (!relationship.isResolved()) + { + relationship.resolve(); + } } } @@ -875,6 +967,7 @@ public virtual class fflib_SObjectUnitOfWork private interface IRelationship { void resolve(); + Boolean isResolved(); } private class RelationshipByExternalId implements IRelationship @@ -892,6 +985,12 @@ public virtual class fflib_SObjectUnitOfWork relationshipObject.put( ExternalIdField.getDescribe().getName(), this.ExternalId ); this.Record.putSObject( this.RelationshipName, relationshipObject ); } + + + public Boolean isResolved() + { + return this.Record.getSObject( this.RelationshipName ) != null; + } } private class Relationship implements IRelationship @@ -901,8 +1000,16 @@ public virtual class fflib_SObjectUnitOfWork public SObject RelatedTo; public void resolve() + { + if (String.isNotBlank(this.RelatedTo.Id)) + { + this.Record.put( this.RelatedToField, this.RelatedTo.Id ); + } + } + + public Boolean isResolved() { - this.Record.put( this.RelatedToField, this.RelatedTo.Id); + return String.isNotBlank( this.RelatedTo.Id ) && this.Record.get( this.RelatedToField ) == this.RelatedTo.Id; } } @@ -915,6 +1022,11 @@ public virtual class fflib_SObjectUnitOfWork { this.email.setWhatId( this.relatedTo.Id ); } + + public Boolean isResolved() + { + return String.isNotBlank( this.RelatedTo.Id ) && this.email.getWhatId() == this.RelatedTo.Id; + } } /** diff --git a/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls b/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls index af1dd15274..872a66bf53 100644 --- a/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls +++ b/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls @@ -448,6 +448,7 @@ private class fflib_ApplicationTest { public boolean isInsertCalled = false; public boolean isUpdateCalled = false; + public boolean isUpsertCalled = false; public boolean isDeleteCalled = false; public boolean isPublishCalled = false; public Boolean isEmptyRecycleBinCalled = false; @@ -458,6 +459,9 @@ private class fflib_ApplicationTest public void dmlUpdate(List objList){ this.isUpdateCalled = true; } + public void dmlUpsert(List objList, Schema.SObjectField externalId){ + this.isUpsertCalled = true; + } public void dmlDelete(List objList){ this.isDeleteCalled = true; } diff --git a/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls b/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls index 582e759bc3..6cda1f4e9b 100644 --- a/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls +++ b/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls @@ -629,6 +629,118 @@ private with sharing class fflib_SObjectUnitOfWorkTest System.assertEquals(1, mockDML.recordsForInsert.size()); } + @isTest + private static void testRegisterUpsertByExternalId() { + + Opportunity existingOpp = new Opportunity(Name = 'Existing Opportunity', StageName = 'Open', CloseDate = System.today()); + insert existingOpp; + + existingOpp.StageName = 'Closed'; + + System.assertEquals(1, [SELECT COUNT() FROM Opportunity]); + System.assertEquals(0, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed']); + + Test.startTest(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + uow.registerUpsert(new List{existingOpp}, Opportunity.Id); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals(1, [SELECT COUNT() FROM Opportunity]); + System.assertEquals(1, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed']); + } + + + @isTest + private static void testRegisterUpsertByExternalIdParentWillResolve() { + + Opportunity existingOpp = new Opportunity(Name = 'Existing Opportunity', StageName = 'Open', CloseDate = System.today()); + insert existingOpp; + + existingOpp.StageName = 'Closed'; + + System.assertEquals(1, [SELECT COUNT() FROM Opportunity]); + System.assertEquals(0, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed']); + + List orderWithAccounts = new List(); + orderWithAccounts.add(Account.SObjectType); + orderWithAccounts.addAll(MY_SOBJECTS); + + String accName = 'new account'; + Account newAccount = new Account(Name = accName); + + Test.startTest(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(orderWithAccounts); + uow.registerNew(newAccount); + uow.registerUpsert(existingOpp, Opportunity.Id, Opportunity.AccountId, newAccount); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals(1, [SELECT COUNT() FROM Opportunity]); + + Opportunity updatedOpp = [SELECT StageName, Account.Name FROM Opportunity]; + System.assertEquals('Closed', updatedOpp.StageName); + System.assertEquals(accName, updatedOpp.Account.Name ); + } + + + @isTest + private static void testRegisterUpsertByExternalIdChildWillResolve() { + + Opportunity newOpportunity = new Opportunity(Name = 'Existing Opportunity', StageName = 'Closed', CloseDate = System.today()); + + Account existingAccount = new Account(Name = 'old account name'); + insert existingAccount; + + List orderWithAccounts = new List(); + orderWithAccounts.add(Account.SObjectType); + orderWithAccounts.addAll(MY_SOBJECTS); + + String accName = 'new account'; + + existingAccount.Name = accName; + + Test.startTest(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(orderWithAccounts); + uow.registerDirty(existingAccount); + uow.registerUpsert(newOpportunity, Opportunity.Id, Opportunity.AccountId, existingAccount); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals(1, [SELECT COUNT() FROM Opportunity]); + + Opportunity updatedOpp = [SELECT StageName, Account.Name FROM Opportunity]; + System.assertEquals('Closed', updatedOpp.StageName); + System.assertEquals(accName, updatedOpp.Account.Name ); + } + + + @isTest + private static void testRegisterUpsertByExternalIdBothWillResolve() { + + Opportunity newOpportunity = new Opportunity(Name = 'Existing Opportunity', StageName = 'Closed', CloseDate = System.today()); + + String accName = 'new account'; + Account newAccount = new Account(Name = accName); + + List orderWithAccounts = new List(); + orderWithAccounts.add(Account.SObjectType); + orderWithAccounts.addAll(MY_SOBJECTS); + + Test.startTest(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(orderWithAccounts); + uow.registerNew(newAccount); + uow.registerUpsert(newOpportunity, Opportunity.Id, Opportunity.AccountId, newAccount); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals(1, [SELECT COUNT() FROM Opportunity]); + + Opportunity updatedOpp = [SELECT StageName, Account.Name FROM Opportunity]; + System.assertEquals('Closed', updatedOpp.StageName); + System.assertEquals(accName, updatedOpp.Account.Name ); + } + @IsTest private static void constructUserModeDML_When_DefaultConstructor_Expect_UserMode(){ fflib_SObjectUnitOfWork.UserModeDML dml = new fflib_SObjectUnitOfWork.UserModeDML(); @@ -819,6 +931,7 @@ private with sharing class fflib_SObjectUnitOfWorkTest public List recordsForInsert = new List(); public List recordsForUpdate = new List(); public List recordsForDelete = new List(); + public List recordsForUpsert = new List(); public List recordsForRecycleBin = new List(); public List recordsForEventPublish = new List(); @@ -837,6 +950,11 @@ private with sharing class fflib_SObjectUnitOfWorkTest this.recordsForDelete.addAll(objList); } + public void dmlUpsert(List objList, Schema.SObjectField externalId) + { + this.recordsForUpsert.addAll(objList); + } + public void eventPublish(List objList) { this.recordsForEventPublish.addAll(objList);