Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for upsert by external Id #276

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 116 additions & 4 deletions sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public virtual class fflib_SObjectUnitOfWork

protected Map<String, Map<Id, SObject>> m_dirtyMapByType = new Map<String, Map<Id, SObject>>();

protected Map<String, List<SObject>> m_upsertRecordsPerType = new Map<String, List<SObject>>();
protected Map<String, Schema.SObjectField> m_externalIdToUpsertPerType = new Map<String, Schema.SObjectField>();

protected Map<String, Map<Id, SObject>> m_deletedMapByType = new Map<String, Map<Id, SObject>>();
protected Map<String, Map<Id, SObject>> m_emptyRecycleBinMapByType = new Map<String, Map<Id, SObject>>();

Expand Down Expand Up @@ -88,6 +91,7 @@ public virtual class fflib_SObjectUnitOfWork
{
void dmlInsert(List<SObject> objList);
void dmlUpdate(List<SObject> objList);
void dmlUpsert(List<SObject> objList, Schema.SObjectField externalId);
void dmlDelete(List<SObject> objList);
void eventPublish(List<SObject> objList);
void emptyRecycleBin(List<SObject> objList);
Expand All @@ -103,6 +107,16 @@ public virtual class fflib_SObjectUnitOfWork
{
update objList;
}
public virtual void dmlUpsert(List<SObject> objList, Schema.SObjectField externalId) {
if (!objList.isEmpty()) {

Type objListType = Type.ForName('List<' + objList[0].getSObjectType() + '>');
List<SObject> typpedList = (List<SObject>)objListType.newInstance();
typpedList.addAll(objList);

Database.upsert(typpedList, externalId);
}
}
public virtual void dmlDelete(List<SObject> objList)
{
delete objList;
Expand Down Expand Up @@ -181,6 +195,7 @@ public virtual class fflib_SObjectUnitOfWork
// add type to dml operation tracking
m_newListByType.put(sObjectName, new List<SObject>());
m_dirtyMapByType.put(sObjectName, new Map<Id, SObject>());
m_upsertRecordsPerType.put(sObjectName, new List<SObject>());
m_deletedMapByType.put(sObjectName, new Map<Id, SObject>());
m_emptyRecycleBinMapByType.put(sObjectName, new Map<Id, SObject>());
m_relationships.put(sObjectName, new Relationships());
Expand Down Expand Up @@ -456,6 +471,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<SObject> 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 != '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<String> {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
*
Expand Down Expand Up @@ -622,7 +701,8 @@ public virtual class fflib_SObjectUnitOfWork
onPublishBeforeEventsFinished();

onDMLStarting();
insertDmlByType();
insertDmlByType();
upsertDmlByType();
updateDmlByType();
deleteDmlByType();
emptyRecycleBinByType();
Expand Down Expand Up @@ -687,6 +767,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;
Expand Down Expand Up @@ -784,8 +874,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();
}
}

}
Expand Down Expand Up @@ -848,6 +940,7 @@ public virtual class fflib_SObjectUnitOfWork
private interface IRelationship
{
void resolve();
Boolean isResolved();
}

private class RelationshipByExternalId implements IRelationship
Expand All @@ -865,6 +958,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
Expand All @@ -874,8 +973,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;
}
}

Expand All @@ -888,6 +995,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;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,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;
Expand All @@ -452,6 +453,9 @@ private class fflib_ApplicationTest
public void dmlUpdate(List<SObject> objList){
this.isUpdateCalled = true;
}
public void dmlUpsert(List<SObject> objList, Schema.SObjectField externalId){
this.isUpsertCalled = true;
}
public void dmlDelete(List<SObject> objList){
this.isDeleteCalled = true;
}
Expand Down
112 changes: 112 additions & 0 deletions sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,118 @@ private with sharing class fflib_SObjectUnitOfWorkTest
System.assertEquals(2, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed']);
}

@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<Opportunity>{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<Schema.SObjectType> orderWithAccounts = new List<Schema.SObjectType>();
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<Schema.SObjectType> orderWithAccounts = new List<Schema.SObjectType>();
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<Schema.SObjectType> orderWithAccounts = new List<Schema.SObjectType>();
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 );
}

/**
* Assert that actual events exactly match expected events (size, order and name)
* and types match expected types
Expand Down