diff --git a/fflib/src/classes/fflib_SObjectUnitOfWork.cls b/fflib/src/classes/fflib_SObjectUnitOfWork.cls index 058bef9a338..2126316697a 100644 --- a/fflib/src/classes/fflib_SObjectUnitOfWork.cls +++ b/fflib/src/classes/fflib_SObjectUnitOfWork.cls @@ -2,22 +2,22 @@ * Copyright (c), FinancialForce.com, inc * All rights reserved. * - * Redistribution and use in source and binary forms, with or without modification, + * Redistribution and use in source and binary forms, with or without modification, * are permitted provided that the following conditions are met: * - * - Redistributions of source code must retain the above copyright notice, + * - Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. - * - Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * - Neither the name of the FinancialForce.com, inc nor the names of its contributors - * may be used to endorse or promote products derived from this software without + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without * specific prior written permission. * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES - * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL - * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) @@ -28,24 +28,24 @@ * Provides an implementation of the Enterprise Application Architecture Unit Of Work, as defined by Martin Fowler * http://martinfowler.com/eaaCatalog/unitOfWork.html * - * "When you're pulling data in and out of a database, it's important to keep track of what you've changed; otherwise, - * that data won't be written back into the database. Similarly you have to insert new objects you create and + * "When you're pulling data in and out of a database, it's important to keep track of what you've changed; otherwise, + * that data won't be written back into the database. Similarly you have to insert new objects you create and * remove any objects you delete." * - * "You can change the database with each change to your object model, but this can lead to lots of very small database calls, - * which ends up being very slow. Furthermore it requires you to have a transaction open for the whole interaction, which is + * "You can change the database with each change to your object model, but this can lead to lots of very small database calls, + * which ends up being very slow. Furthermore it requires you to have a transaction open for the whole interaction, which is * impractical if you have a business transaction that spans multiple requests. The situation is even worse if you need to * keep track of the objects you've read so you can avoid inconsistent reads." * - * "A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done, + * "A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done, * it figures out everything that needs to be done to alter the database as a result of your work." * * In an Apex context this pattern provides the following specific benifits * - Applies bulkfication to DML operations, insert, update and delete * - Manages a business transaction around the work and ensures a rollback occurs (even when exceptions are later handled by the caller) - * - Honours dependency rules between records and updates dependent relationships automatically during the commit + * - Honours dependency rules between records and updates dependent relationships automatically during the commit * - * Please refer to the testMethod's in this class for example usage + * Please refer to the testMethod's in this class for example usage * * TODO: Need to complete the 100% coverage by covering parameter exceptions in tests * TODO: Need to add some more test methods for more complex use cases and some unexpected (e.g. registerDirty and then registerDeleted) @@ -55,25 +55,25 @@ public virtual class fflib_SObjectUnitOfWork implements fflib_ISObjectUnitOfWork { private List m_sObjectTypes = new List(); - + private Map> m_newListByType = new Map>(); - + private Map> m_dirtyMapByType = new Map>(); - + private Map> m_deletedMapByType = new Map>(); - + private Map m_relationships = new Map(); private List m_workList = new List(); private SendEmailWork m_emailWork = new SendEmailWork(); - + private IDML m_dml; - + /** * Interface describes work to be performed during the commitWork method **/ - public interface IDoWork + public interface IDoWork { void doWork(); } @@ -84,7 +84,7 @@ public virtual class fflib_SObjectUnitOfWork void dmlUpdate(List objList); void dmlDelete(List objList); } - + public class SimpleDML implements IDML { public void dmlInsert(List objList){ @@ -111,17 +111,17 @@ public virtual class fflib_SObjectUnitOfWork public fflib_SObjectUnitOfWork(List sObjectTypes, IDML dml) { m_sObjectTypes = sObjectTypes.clone(); - + for(Schema.SObjectType sObjectType : m_sObjectTypes) { m_newListByType.put(sObjectType.getDescribe().getName(), new List()); m_dirtyMapByType.put(sObjectType.getDescribe().getName(), new Map()); m_deletedMapByType.put(sObjectType.getDescribe().getName(), new Map()); - m_relationships.put(sObjectType.getDescribe().getName(), new Relationships()); + m_relationships.put(sObjectType.getDescribe().getName(), new Relationships()); } m_workList.add(m_emailWork); - + m_dml = dml; } @@ -140,7 +140,7 @@ public virtual class fflib_SObjectUnitOfWork { m_emailWork.registerEmail(email); } - + /** * Register a newly created SObject instance to be inserted when commitWork is called * @@ -165,7 +165,7 @@ public virtual class fflib_SObjectUnitOfWork } /** - * Register a newly created SObject instance to be inserted when commitWork is called, + * 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 separatly) * * @param record A newly created SObject instance to be inserted during commitWork @@ -176,16 +176,16 @@ public virtual class fflib_SObjectUnitOfWork { if(record.Id != null) throw new UnitOfWorkException('Only new records can be registered as new'); - String sObjectType = record.getSObjectType().getDescribe().getName(); + String sObjectType = record.getSObjectType().getDescribe().getName(); if(!m_newListByType.containsKey(sObjectType)) throw new UnitOfWorkException(String.format('SObject type {0} is not supported by this unit of work', new String[] { sObjectType })); - m_newListByType.get(sObjectType).add(record); + m_newListByType.get(sObjectType).add(record); if(relatedToParentRecord!=null && relatedToParentField!=null) registerRelationship(record, relatedToParentField, relatedToParentRecord); } - + /** - * Register a relationship between two records that have yet to be inserted to the database. This information will be + * 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 @@ -194,12 +194,12 @@ public virtual class fflib_SObjectUnitOfWork */ public void registerRelationship(SObject record, Schema.sObjectField relatedToField, SObject relatedTo) { - String sObjectType = record.getSObjectType().getDescribe().getName(); + String sObjectType = record.getSObjectType().getDescribe().getName(); if(!m_newListByType.containsKey(sObjectType)) throw new UnitOfWorkException(String.format('SObject type {0} is not supported by this unit of work', new String[] { sObjectType })); m_relationships.get(sObjectType).add(record, relatedToField, relatedTo); } - + /** * Register an existing record to be updated during the commitWork method * @@ -209,10 +209,10 @@ public virtual class fflib_SObjectUnitOfWork { if(record.Id == null) throw new UnitOfWorkException('New records cannot be registered as dirty'); - String sObjectType = record.getSObjectType().getDescribe().getName(); + String sObjectType = record.getSObjectType().getDescribe().getName(); if(!m_dirtyMapByType.containsKey(sObjectType)) throw new UnitOfWorkException(String.format('SObject type {0} is not supported by this unit of work', new String[] { sObjectType })); - m_dirtyMapByType.get(sObjectType).put(record.Id, record); + m_dirtyMapByType.get(sObjectType).put(record.Id, record); } /** @@ -237,12 +237,12 @@ public virtual class fflib_SObjectUnitOfWork { if(record.Id == null) throw new UnitOfWorkException('New records cannot be registered for deletion'); - String sObjectType = record.getSObjectType().getDescribe().getName(); + String sObjectType = record.getSObjectType().getDescribe().getName(); if(!m_deletedMapByType.containsKey(sObjectType)) throw new UnitOfWorkException(String.format('SObject type {0} is not supported by this unit of work', new String[] { sObjectType })); - m_deletedMapByType.get(sObjectType).put(record.Id, record); + m_deletedMapByType.get(sObjectType).put(record.Id, record); } - + /** * Register a list of existing records to be deleted during the commitWork method * @@ -255,25 +255,29 @@ public virtual class fflib_SObjectUnitOfWork this.registerDeleted(record); } } - + /** * Takes all the work that has been registered with the UnitOfWork and commits it to the database **/ public void commitWork() { - // Wrap the work in its own transaction - Savepoint sp = Database.setSavePoint(); + // Wrap the work in its own transaction + Savepoint sp = Database.setSavePoint(); try - { + { // Insert by type for(Schema.SObjectType sObjectType : m_sObjectTypes) { - m_relationships.get(sObjectType.getDescribe().getName()).resolve(); + Relationships relationships = m_relationships.get(sObjectType.getDescribe().getName()); + relationships.resolve(); m_dml.dmlInsert(m_newListByType.get(sObjectType.getDescribe().getName())); - } + if (relationships.hasReflective()) { + m_dml.dmlUpdate(relationships.getResolvedReflectiveRecords()); + } + } // Update by type for(Schema.SObjectType sObjectType : m_sObjectTypes) - m_dml.dmlUpdate(m_dirtyMapByType.get(sObjectType.getDescribe().getName()).values()); + m_dml.dmlUpdate(m_dirtyMapByType.get(sObjectType.getDescribe().getName()).values()); // Delete by type (in reverse dependency order) Integer objectIdx = m_sObjectTypes.size() - 1; while(objectIdx>=0) @@ -290,10 +294,11 @@ public virtual class fflib_SObjectUnitOfWork throw e; } } - + private class Relationships { private List m_relationships = new List(); + private List m_reflectiveRelationships = new List(); public void resolve() { @@ -301,7 +306,28 @@ public virtual class fflib_SObjectUnitOfWork for(Relationship relationship : m_relationships) relationship.Record.put(relationship.RelatedToField, relationship.RelatedTo.Id); } - + + public void resolveReflective() + { + // Resolve reflective relationships + for(Relationship relationship : m_reflectiveRelationships) + relationship.Record.put(relationship.RelatedToField, relationship.RelatedTo.Id); + } + + public Boolean hasReflective() + { + return m_reflectiveRelationships.size() > 0; + } + + public List getResolvedReflectiveRecords() + { + resolveReflective(); + List records = new List(); + for(Relationship relationship : m_reflectiveRelationships) + records.add(relationship.Record); + return records; + } + public void add(SObject record, Schema.sObjectField relatedToField, SObject relatedTo) { // Relationship to resolve @@ -309,23 +335,26 @@ public virtual class fflib_SObjectUnitOfWork relationship.Record = record; relationship.RelatedToField = relatedToField; relationship.RelatedTo = relatedTo; - m_relationships.add(relationship); + if (record.getSObjectType() == relatedTo.getSObjectType() && relatedTo.Id == null) + m_reflectiveRelationships.add(relationship); + else + m_relationships.add(relationship); } } - + private class Relationship { public SObject Record; public Schema.sObjectField RelatedToField; public SObject RelatedTo; } - + /** * UnitOfWork Exception **/ public class UnitOfWorkException extends Exception {} - /** + /** * Internal implementation of Messaging.sendEmail, see outer class registerEmail method **/ private class SendEmailWork implements IDoWork @@ -346,5 +375,5 @@ public virtual class fflib_SObjectUnitOfWork { if(emails.size() > 0) Messaging.sendEmail(emails); } - } -} \ No newline at end of file + } +} diff --git a/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls b/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls index 1f569797df9..17050a51991 100644 --- a/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls +++ b/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls @@ -2,22 +2,22 @@ * Copyright (c), FinancialForce.com, inc * All rights reserved. * - * Redistribution and use in source and binary forms, with or without modification, + * Redistribution and use in source and binary forms, with or without modification, * are permitted provided that the following conditions are met: * - * - Redistributions of source code must retain the above copyright notice, + * - Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. - * - Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * - Neither the name of the FinancialForce.com, inc nor the names of its contributors - * may be used to endorse or promote products derived from this software without + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without * specific prior written permission. * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES - * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL - * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) @@ -25,14 +25,14 @@ **/ @IsTest -private with sharing class fflib_SObjectUnitOfWorkTest +private with sharing class fflib_SObjectUnitOfWorkTest { - // SObjects (in order of dependency) used by UnitOfWork in tests bellow - private static List MY_SOBJECTS = - new Schema.SObjectType[] { - Product2.SObjectType, - PricebookEntry.SObjectType, - Opportunity.SObjectType, + // SObjects (in order of dependency) used by UnitOfWork in tests bellow + private static List MY_SOBJECTS = + new Schema.SObjectType[] { + Product2.SObjectType, + PricebookEntry.SObjectType, + Opportunity.SObjectType, OpportunityLineItem.SObjectType }; @isTest @@ -68,19 +68,19 @@ private with sharing class fflib_SObjectUnitOfWorkTest } uow.commitWork(); } - - // Assert Results + + // Assert Results assertResults('UoW'); - // TODO: Need to re-instate this check with a better approach, as it is not possible when + // TODO: Need to re-instate this check with a better approach, as it is not possible when // product triggers contribute to DML (e.g. in sample app Opportunity trigger) // System.assertEquals(5 /* Oddly a setSavePoint consumes a DML */, Limits.getDmlStatements()); // Records to update List opps = [select Id, Name, (Select Id from OpportunityLineItems) from Opportunity where Name like 'UoW Test Name %' order by Name]; - + // Update some records with UnitOfWork { - fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS); Opportunity opp = opps[0]; opp.Name = opp.Name + ' Changed'; uow.registerDirty(new List{opp}); @@ -107,9 +107,9 @@ private with sharing class fflib_SObjectUnitOfWorkTest uow.registerDirty(new List{existingOppLine}); uow.commitWork(); } - + // Assert Results - // TODO: Need to re-instate this check with a better approach, as it is not possible when + // TODO: Need to re-instate this check with a better approach, as it is not possible when // product triggers contribute to DML (e.g. in sample app Opportunity trigger) // System.assertEquals(11, Limits.getDmlStatements()); opps = [select Id, Name, (Select Id, PricebookEntry.Product2.Name, Quantity, TotalPrice from OpportunityLineItems Order By PricebookEntry.Product2.Name) from Opportunity where Name like 'UoW Test Name %' order by Name]; @@ -120,23 +120,23 @@ private with sharing class fflib_SObjectUnitOfWorkTest System.assertEquals(2, opps[0].OpportunityLineItems[0].Quantity); System.assertEquals(20, opps[0].OpportunityLineItems[0].TotalPrice); System.assertEquals('UoW Test Name 0 Changed : New Product', opps[0].OpportunityLineItems[1].PricebookEntry.Product2.Name); - + // Delete some records with the UnitOfWork { - fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS); - uow.registerDeleted(new List{opps[0].OpportunityLineItems[1].PricebookEntry.Product2}); // Delete PricebookEntry Product + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + uow.registerDeleted(new List{opps[0].OpportunityLineItems[1].PricebookEntry.Product2}); // Delete PricebookEntry Product uow.registerDeleted(new List{opps[0].OpportunityLineItems[1].PricebookEntry}); // Delete PricebookEntry uow.registerDeleted(new List{opps[0].OpportunityLineItems[1]}); // Delete OpportunityLine Item // Register the same deletions more than once. // This verifies that using a Map to back the deleted records collection prevents duplicate registration. - uow.registerDeleted(new List{opps[0].OpportunityLineItems[1].PricebookEntry.Product2}); // Delete PricebookEntry Product + uow.registerDeleted(new List{opps[0].OpportunityLineItems[1].PricebookEntry.Product2}); // Delete PricebookEntry Product uow.registerDeleted(new List{opps[0].OpportunityLineItems[1].PricebookEntry}); // Delete PricebookEntry uow.registerDeleted(new List{opps[0].OpportunityLineItems[1]}); // Delete OpportunityLine Item uow.commitWork(); } - + // Assert Results - // TODO: Need to re-instate this check with a better approach, as it is not possible when + // TODO: Need to re-instate this check with a better approach, as it is not possible when // product triggers contribute to DML (e.g. in sample app Opportunity trigger) // System.assertEquals(15, Limits.getDmlStatements()); opps = [select Id, Name, (Select Id, PricebookEntry.Product2.Name, Quantity from OpportunityLineItems Order By PricebookEntry.Product2.Name) from Opportunity where Name like 'UoW Test Name %' order by Name]; @@ -146,7 +146,28 @@ private with sharing class fflib_SObjectUnitOfWorkTest System.assertEquals(1, opps[0].OpportunityLineItems.size()); // Should have deleted OpportunityLineItem added above System.assertEquals(0, prods.size()); // Should have deleted Product added above } - + + @isTest + private static void testUnitOfWorkNewWithReflectiveLookups() + { + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork( new List { Account.SObjectType } ); + Account accountTop = new Account( Name = 'Top Account' ); + Account accountMiddle = new Account( Name = 'Middle Account' ); + Account accountBottom = new Account( Name = 'Bottom Account' ); + uow.registerNew(accountTop); + uow.registerNew(accountMiddle, Account.ParentId, accountTop); + uow.registerNew(accountBottom, Account.ParentId, accountMiddle); + Test.startTest(); + uow.commitWork(); + Test.stopTest(); + accountTop = [SELECT Id, ParentId FROM Account WHERE Id = :accountTop.Id]; + accountMiddle = [SELECT Id, ParentId FROM Account WHERE Id = :accountMiddle.Id]; + accountBottom = [SELECT Id, ParentId FROM Account WHERE Id = :accountBottom.Id]; + System.assertEquals(null, accountTop.ParentId, 'The top account should not be related to a parent account'); + System.assertEquals(accountTop.Id, accountMiddle.ParentId, 'The middle account should be related to the top account'); + System.assertEquals(accountMiddle.Id, accountBottom.ParentId, 'The bottom account should be related to the middle account'); + } + private static void assertResults(String prefix) { // Standard Assertions on tests data inserted by tests @@ -164,4 +185,4 @@ private with sharing class fflib_SObjectUnitOfWorkTest System.assertEquals(9, opps[8].OpportunityLineItems.size()); System.assertEquals(10, opps[9].OpportunityLineItems.size()); } -} \ No newline at end of file +}