├── .forceignore ├── .gitignore ├── LICENSE ├── README.md ├── bin └── resetScratchOrg.sh ├── config └── project-scratch-def.json ├── ruleset.xml ├── scripts ├── config.sh └── createScratchOrg.sh ├── sfdx-project.json └── sfdx-source ├── apex-domainbuilder-sample └── classes │ ├── Random.cls │ ├── Random.cls-meta.xml │ ├── domains │ ├── Account_t.cls │ ├── Account_t.cls-meta.xml │ ├── Contact_t.cls │ ├── Contact_t.cls-meta.xml │ ├── OpportunityContactRole_t.cls │ ├── OpportunityContactRole_t.cls-meta.xml │ ├── Opportunity_t.cls │ ├── Opportunity_t.cls-meta.xml │ ├── User_t.cls │ └── User_t.cls-meta.xml │ └── test │ ├── DomainBuilder_Test.cls │ └── DomainBuilder_Test.cls-meta.xml ├── apex-domainbuilder └── main │ └── classes │ ├── DomainBuilder.cls │ └── DomainBuilder.cls-meta.xml └── fflib-apex-common-subset ├── main └── classes │ ├── fflib_IDGenerator.cls │ ├── fflib_IDGenerator.cls-meta.xml │ ├── fflib_ISObjectUnitOfWork.cls │ ├── fflib_ISObjectUnitOfWork.cls-meta.xml │ ├── fflib_SObjectUnitOfWork.cls │ └── fflib_SObjectUnitOfWork.cls-meta.xml └── test └── classes ├── fflib_SObjectUnitOfWorkTest.cls └── fflib_SObjectUnitOfWorkTest.cls-meta.xml /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | 4 | package.xml 5 | **appMenu 6 | **appSwitcher 7 | **setting 8 | **/profiles/* 9 | **/emailservices/* 10 | 11 | # LWC configuration files 12 | **/jsconfig.json 13 | **/.eslintrc.json 14 | 15 | # LWC Jest 16 | **/__tests__/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sfdx/ 7 | .sf/ 8 | .localdevserver/ 9 | 10 | #Snyk cache 11 | .dccache 12 | 13 | # IDEs 14 | .idea/ 15 | .vscode/ 16 | .vim-force.com/ 17 | IlluminatedCloud/ 18 | projectFilesBackup/ 19 | # LWC VSCode autocomplete 20 | **/lwc/jsconfig.json 21 | 22 | # Logs 23 | logs 24 | *.log 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # Dependency directories 30 | node_modules/ 31 | 32 | # Eslint cache 33 | .eslintcache 34 | 35 | # MacOS system files 36 | .DS_Store 37 | 38 | # Windows system files 39 | Thumbs.db 40 | ehthumbs.db 41 | [Dd]esktop.ini 42 | $RECYCLE.BIN/ 43 | .prettierignore 44 | .prettierrc 45 | package-lock.json 46 | *.iml 47 | 48 | mdapi/ 49 | /.idea/ 50 | .pmdCache 51 | temp/ 52 | 53 | # VS Code Extensions Related files 54 | .history/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Robert Sösemann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Domain Builder [![Codacy Badge](https://api.codacy.com/project/badge/Grade/3814b20244d14e3d846ff05dfd3c2e2a)](https://www.codacy.com/app/rsoesemann/apex-domainbuilder?utm_source=github.com&utm_medium=referral&utm_content=rsoesemann/apex-unified-logging&utm_campaign=Badge_Grade) 2 | 3 | Test Data Builder framework to setup test data for complex Apex integration tests in a concise, readable and flexible way. 4 | 5 | **TL;DR; Watch my a Youtube Code Live with Salesforce about the apex-domainbuilder** 6 | 7 | [![](http://img.youtube.com/vi/e03lvRfOHNs/hqdefault.jpg)](https://youtu.be/e03lvRfOHNs "") 8 | 9 | 10 | Deploy to Salesforce 12 | 13 | 14 | Setting up test data for complex Apex integration tests is not easy, because you need to..: 15 | 16 | - set required fields even if irrelevant for the test 17 | - insert the objects in the right order 18 | - create relationships by setting Lookup fields 19 | - put ugly `__c` all over the place 20 | - clutter your code with `Map` to keep track of related records 21 | - reduce the DML statements to not hit Governor Limits 22 | 23 | TestFactories as used by many developers and recommended by Salesforce.com can help to minimize ugly setup code by moving it to seperate classes. But over time those classes tend to accumulate complexity and redundant spaghetti code. 24 | 25 | In the world of Enterprise software outside of Salesforce.com there are experts that have created patterns for flexible and readable (fluent, concise) test data generation. Among them, the most notable is Nat Pryce who wrote a great book about testing and somewhat invented the [Test Data Builder](http://www.natpryce.com/articles/000714.html) pattern. It's based on the more general [Builder Pattern](https://refactoring.guru/design-patterns/builder) which simplifies the construction of objects by moving variations from complex constructors to separate objects that only construct parts of the whole. 26 | 27 | **apex-domainbuilder** brings those ideas to Apex testing: 28 | 1. By incorporating a simple small Builder class for each test-relevant Domain SObject we centralize all the creation knowledge and eliminate redundancy. 29 | 30 | ```java 31 | @IsTest 32 | public class Account_t extends DomainBuilder { 33 | 34 | // Construct the object 35 | 36 | public Account_t() { 37 | super(Account.SObjectType); 38 | 39 | name('Acme Corp'); 40 | } 41 | 42 | // Set Properties 43 | 44 | public Account_t name(String value) { 45 | return (Account_t) set(Account.Name, value); 46 | } 47 | 48 | // Relate it to other Domain Objects 49 | 50 | public Account_t add(Opportunity_t opp) { 51 | return (Account_t) opp.setParent(Opportunity.AccountId, this); 52 | } 53 | 54 | public Account_t add(Contact_t con) { 55 | return (Account_t) con.setParent(Contact.AccountId, this); 56 | } 57 | } 58 | ``` 59 | 60 | 2. By internally leveraging the [`fflib_SObjectUnitOfWork`](https://github.com/financialforcedev/fflib-apex-common/blob/master/fflib/src/classes/fflib_SObjectUnitOfWork.cls) for the DML all tests run dramatically faster. 61 | 62 | 3. The **[Fluent Interface](https://martinfowler.com/bliki/FluentInterface.html)** style of the Builder pattern combined with having all the database wiring encapsulated in the Unit of work made each test much more understandable. 63 | 64 | ```java 65 | @IsTest 66 | private static void easyTestDataCreation() { 67 | 68 | // Setup 69 | Contact_t jack = new Contact_t().first('Jack').last('Harris');h 70 | 71 | new Account_t() 72 | .name('Acme Corp') 73 | .add( new Opportunity_t() 74 | .amount(1000) 75 | .closes(2019, 12) 76 | .contact(jack)) 77 | .persist(); 78 | 79 | // Exercise 80 | ... 81 | 82 | 83 | // Verify 84 | ... 85 | } 86 | ``` 87 | 88 | 4. Using **Graph algorithms** to autodetect the correct insert order in the Unit Of Work. 89 | 5. Is able to **handle self-reference fields** (e.g. Manager Contact Lookup on Contact) by using a patched **fflib Unit of Work**. 90 | -------------------------------------------------------------------------------- /bin/resetScratchOrg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sf org delete scratch --no-prompt --target-org apexdomainbuilder 3 | sf org create scratch --alias apexdomainbuilder --set-default --duration-days 1 --definition-file config/project-scratch-def.json 4 | sf project deploy start --ignore-conflicts -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "apex-domainbuilder_DEV", 3 | "country": "US", 4 | "edition": "Developer", 5 | "language": "en_US" 6 | } -------------------------------------------------------------------------------- /ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Default ruleset for PMD 5 | 6 | 7 | 8 | 1 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 3 27 | 28 | 29 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 75 | 76 | 77 | 78 | 79 | 80 | 3 81 | 82 | 83 | 86 | 87 | 88 | 89 | 90 | 91 | 3 92 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | 102 | 3 103 | 104 | 105 | 108 | 109 | 110 | 111 | 112 | 113 | 3 114 | 115 | 116 | 119 | 120 | 121 | 122 | 123 | 124 | 3 125 | 126 | 127 | 130 | 131 | 132 | 133 | 134 | 135 | 3 136 | 137 | 138 | 141 | 142 | 143 | 144 | 145 | 146 | 3 147 | 148 | 149 | 152 | 153 | 154 | 155 | 156 | 157 | 3 158 | 159 | 160 | 163 | 164 | 165 | 166 | 167 | 168 | 3 169 | 170 | 171 | 174 | 175 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /scripts/config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DEV_HUB_ALIAS="MyDevHub" 3 | PACKAGENAME="apex-domainbuilder" 4 | SCRATCH_ORG_ALIAS="apex-domainbuilder_DEV" -------------------------------------------------------------------------------- /scripts/createScratchOrg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source `dirname $0`/config.sh 3 | 4 | execute() { 5 | $@ || exit 6 | } 7 | 8 | 9 | echo "set default devhub user" 10 | execute sf config set defaultdevhubusername=$DEV_HUB_ALIAS 11 | 12 | echo "Deleting old scratch org" 13 | sf org delete scratch --no-prompt --target-org $SCRATCH_ORG_ALIAS 14 | 15 | echo "Creating scratch org" 16 | execute sf org create scratch --alias $SCRATCH_ORG_ALIAS --set-default --definition-file ./config/scratch-org-def.json --duration-days 30 17 | 18 | echo "Pushing changes to scratch org" 19 | execute sf project deploy start 20 | 21 | echo "Make sure Org user is english" 22 | sf data update record --sobject User --where "Name='User User'" --values "Languagelocalekey=en_US" 23 | 24 | echo "Running Apex Tests" 25 | execute sf apex run test --test-level RunLocalTests --wait 30 --code-coverage --result-format human -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "sfdx-source/fflib-apex-common-subset", 5 | "default": false 6 | }, 7 | { 8 | "path": "sfdx-source/apex-domainbuilder", 9 | "default": true 10 | }, 11 | { 12 | "path": "sfdx-source/apex-domainbuilder-samplecode", 13 | "default": false 14 | } 15 | ], 16 | "namespace": "", 17 | "sfdcLoginUrl": "https://login.salesforce.com", 18 | "sourceApiVersion": "60.0" 19 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/Random.cls: -------------------------------------------------------------------------------- 1 | public class Random { 2 | 3 | public String string() { 4 | return string(8); 5 | } 6 | 7 | 8 | public String string(Integer length) { 9 | String result = ''; 10 | 11 | for(Integer i=0; i values) { 46 | return values.get(integer(values.size()-1)); 47 | } 48 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/Random.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/Account_t.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | public class Account_t extends DomainBuilder { 3 | 4 | public Account_t() { 5 | super(Account.SObjectType); 6 | 7 | name('Acme Corp'); 8 | } 9 | 10 | public Account_t name(String value) { 11 | return (Account_t) set(Account.Name, value); 12 | } 13 | 14 | 15 | public Account_t add(Opportunity_t o) { 16 | return (Account_t) o.setParent(Opportunity.AccountId, this); 17 | } 18 | 19 | public Account_t add(Contact_t c) { 20 | return (Account_t) c.setParent(Contact.AccountId, this); 21 | } 22 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/Account_t.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/Contact_t.cls: -------------------------------------------------------------------------------- 1 | public class Contact_t extends DomainBuilder { 2 | 3 | public Contact_t(Account_t a) { 4 | super(Contact.SObjectType); 5 | setParent(Contact.AccountId, a); 6 | 7 | last(new Random().string()); 8 | } 9 | 10 | public Contact_t() { 11 | this(new Account_t()); 12 | } 13 | 14 | public Contact_t first(String value) { 15 | return (Contact_t) set(Contact.FirstName, value); 16 | } 17 | 18 | public Contact_t last(String value) { 19 | return (Contact_t) set(Contact.LastName, value); 20 | } 21 | 22 | public Contact_t email(String value) { 23 | return (Contact_t) set(Contact.Email, value); 24 | } 25 | 26 | public Contact_t reports(Contact_t c) { 27 | return (Contact_t) setParent(Contact.ReportsToId, c); 28 | } 29 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/Contact_t.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/OpportunityContactRole_t.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public class OpportunityContactRole_t extends DomainBuilder { 3 | 4 | public OpportunityContactRole_t(Opportunity_t o, Contact_t c) { 5 | super(OpportunityContactRole.SObjectType); 6 | setParent(OpportunityContactRole.OpportunityId, o); 7 | setParent(OpportunityContactRole.ContactId, c); 8 | } 9 | 10 | public OpportunityContactRole_t(Contact_t c) { 11 | this(new Opportunity_t(), c); 12 | } 13 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/OpportunityContactRole_t.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/Opportunity_t.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public class Opportunity_t extends DomainBuilder { 3 | 4 | public Opportunity_t(Account_t a) { 5 | super(Opportunity.SObjectType); 6 | setParent(Opportunity.AccountId, a); 7 | 8 | name('Opp1'); 9 | stage('Open'); 10 | closes(System.today().year()+1, 1); 11 | } 12 | 13 | public Opportunity_t() { 14 | this(new Account_t()); 15 | } 16 | 17 | public Opportunity_t name(String value) { 18 | return (Opportunity_t) set(Opportunity.Name, value); 19 | } 20 | 21 | public Opportunity_t amount(Decimal value) { 22 | return (Opportunity_t) set(Opportunity.Amount, value); 23 | } 24 | 25 | public Opportunity_t stage(String value) { 26 | return (Opportunity_t) set(Opportunity.StageName, value); 27 | } 28 | 29 | public Opportunity_t contact(Contact_t c) { 30 | new OpportunityContactRole_t(this, c); 31 | return this; 32 | } 33 | 34 | public Opportunity_t closes(Integer y, Integer m) { 35 | return (Opportunity_t) set(Opportunity.CloseDate, Date.newInstance(y, m, 1)); 36 | } 37 | 38 | public Opportunity_t add(Contact_t c) { 39 | new OpportunityContactRole_t(this, c); 40 | return this; 41 | } 42 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/Opportunity_t.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0s 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/User_t.cls: -------------------------------------------------------------------------------- 1 | public with sharing class User_t extends DomainBuilder { 2 | 3 | private static final Id STANDARD_USER = [SELECT Id FROM Profile WHERE Name='Standard User'].Id; 4 | 5 | public User_t() { 6 | super(User.SObjectType); 7 | 8 | String name = new Random().string(); 9 | set(User.Alias, 'alias'); 10 | set(User.Email, name + '@scott.com'); 11 | set(User.EmailEncodingKey, 'UTF-8'); 12 | set(User.FirstName, 'Jill'); 13 | set(User.Lastname, 'Scott'); 14 | set(User.languagelocalekey, 'en_US'); 15 | set(User.localesidkey, 'en_US'); 16 | set(User.timezonesidkey, 'America/Los_Angeles'); 17 | set(User.isActive, true); 18 | set(User.username, name + '@scott.com'); 19 | set(User.profileId, STANDARD_USER); 20 | set(User.UserPermissionsSFContentUser, false); 21 | } 22 | 23 | 24 | public static User standard() { 25 | return (User) new User_t() 26 | .set(User.profileId, STANDARD_USER) 27 | .persist(); 28 | } 29 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/domains/User_t.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/test/DomainBuilder_Test.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | public class DomainBuilder_Test { 3 | 4 | @IsTest 5 | private static void happyPath() { 6 | 7 | Contact_t joe = new Contact_t().first('Ron').last('Harris'); 8 | 9 | new Account_t() 10 | .name('Acme Corp') 11 | .add( new Contact_t() ) 12 | .add( new Opportunity_t() 13 | .amount(1000) 14 | .closes(2019, 12) 15 | .contact(joe)) 16 | .persist(); 17 | 18 | System.assertEquals(2, [SELECT Count() FROM Account]); 19 | System.assertEquals(1, [SELECT Count() FROM Opportunity]); 20 | System.assertEquals(2, [SELECT Count() FROM Contact]); 21 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole]); 22 | } 23 | 24 | 25 | @IsTest 26 | private static void autoPopulatesRequiredButIrrelevantFields() { 27 | 28 | new Account_t() 29 | .add(new Contact_t()) 30 | .add(new Opportunity_t()) 31 | 32 | .persist(); 33 | 34 | System.assertNotEquals(null, [SELECT Name FROM Account]); 35 | System.assertNotEquals(null, [SELECT LastName FROM Contact]); 36 | System.assertNotEquals(null, [SELECT StageName FROM Opportunity]); 37 | } 38 | 39 | 40 | @IsTest 41 | private static void autoGeneratesRequiredButIrrelevantRelations() { 42 | 43 | new Opportunity_t() 44 | .amount(1000) 45 | 46 | .persist(); 47 | 48 | System.assertEquals(1, [SELECT Count() FROM Account]); 49 | System.assertEquals(1, [SELECT Count() FROM Opportunity]); 50 | System.assertNotEquals(null, [SELECT AccountId FROM Opportunity]); 51 | } 52 | 53 | 54 | @IsTest 55 | private static void allowNicerFieldSetters() { 56 | 57 | new Opportunity_t() 58 | .closes(2019, 7) 59 | .persist(); 60 | 61 | System.assertEquals(Date.newInstance(2019, 7, 1), [SELECT CloseDate FROM Opportunity].CloseDate); 62 | } 63 | 64 | 65 | @IsTest 66 | private static void addChildrenOfArbitraryDepth() { 67 | 68 | new Account_t() 69 | .add(new Contact_t()) 70 | .add(new Opportunity_t() 71 | .add(new Contact_t())) 72 | 73 | .persist(); 74 | 75 | System.assertEquals(1, [SELECT Count() FROM Account]); 76 | System.assertEquals(1, [SELECT Count() FROM Opportunity]); 77 | System.assertEquals(2, [SELECT Count() FROM Contact]); 78 | } 79 | 80 | 81 | @IsTest 82 | private static void allowsSetupObjects() { 83 | 84 | try { 85 | System.runAs(User_t.standard()) { 86 | new Account_t().persist(); 87 | } 88 | } 89 | catch(Exception ex) { 90 | System.assert(false); 91 | } 92 | 93 | System.assertEquals(1, [SELECT Count() FROM Account]); 94 | } 95 | 96 | 97 | @IsTest 98 | private static void allowsSelfReferences() { 99 | 100 | // Setup & Exercise 101 | Contact_t boss = new Contact_t(); 102 | new Contact_t() 103 | .reports(boss) 104 | .persist(); 105 | 106 | // Verify 107 | System.assertEquals(2, [SELECT Count() FROM Contact]); 108 | System.assertEquals(1, [SELECT Count() FROM Contact WHERE ReportsToId != NULL]); 109 | } 110 | 111 | 112 | @IsTest 113 | private static void allowsJunctionObjects() { 114 | 115 | Opportunity_t o = new Opportunity_t(); 116 | Contact_t c = new Contact_t(); 117 | 118 | new OpportunityContactRole_t(o, c).persist(); 119 | 120 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole 121 | WHERE ContactId = :c.Id AND OpportunityId = :o.Id]); 122 | } 123 | 124 | 125 | @IsTest 126 | private static void hideJunctionComplexity() { 127 | 128 | new Opportunity_t() 129 | .contact(new Contact_t()) 130 | .persist(); 131 | 132 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole 133 | WHERE ContactId != null AND OpportunityId != null]); 134 | } 135 | 136 | 137 | @IsTest 138 | private static void accessRecordFromBuilder() { 139 | 140 | Account_t a = new Account_t().name('Salesforce.com'); 141 | a.persist(); 142 | 143 | System.assertEquals(Account.SObjectType, a.record.getSObjectType()); 144 | } 145 | 146 | 147 | @IsTest 148 | private static void accessIdFromBuilder() { 149 | Account_t a = new Account_t(); 150 | a.persist(); 151 | 152 | System.assertEquals(a.Id, [SELECT Id FROM Account].Id); 153 | } 154 | 155 | 156 | @IsTest 157 | private static void persistReturnsSObject() { 158 | new Account_t().persist(); 159 | 160 | System.assertEquals(1, [SELECT Count() FROM Account]); 161 | } 162 | 163 | 164 | @IsTest 165 | private static void insertOrder() { 166 | 167 | // Setup 168 | DomainBuilder.DirectedGraph graph = new DomainBuilder.DirectedGraph() 169 | .node(Account.SObjectType) 170 | .node(Contact.SObjectType) 171 | .node(Opportunity.SObjectType) 172 | .node(OpportunityContactRole.SObjectType) 173 | 174 | .edge(Contact.SObjectType, Account.SObjectType) 175 | .edge(Contact.SObjectType, Opportunity.SObjectType) 176 | .edge(Opportunity.SObjectType, Account.SObjectType) 177 | .edge(OpportunityContactRole.SObjectType, Contact.SObjectType) 178 | .edge(OpportunityContactRole.SObjectType, Opportunity.SObjectType); 179 | 180 | // Verify 181 | List expectedOrder = new List{ 182 | OpportunityContactRole.SObjectType, Contact.SObjectType, Opportunity.SObjectType, Account.SObjectType }; 183 | System.assertEquals(expectedOrder, graph.sortTopologically()); 184 | } 185 | 186 | 187 | @IsTest 188 | private static void noUnneededRecords() { 189 | 190 | // Setup & Exercise 191 | Contact_t con = new Contact_t(); // 1x insert 192 | Opportunity_t opp = new Opportunity_t(); // 1x insert 193 | 194 | new Account_t() // 1x insert 195 | .add(new Contact_t()) // 1x insert 196 | .add(con) // 0x insert 197 | .add(opp // 0x insert 198 | .add(con)) // 1x insert OpportunityContactRole 199 | .persist(); // 1x setSavepoint 200 | 201 | // Verify 202 | System.assertEquals(6, Limits.getDmlRows()); 203 | System.assertEquals(1, [SELECT Count() FROM Account]); 204 | System.assertEquals(2, [SELECT Count() FROM Contact]); 205 | System.assertEquals(1, [SELECT Count() FROM Opportunity]); 206 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole]); 207 | } 208 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder-sample/classes/test/DomainBuilder_Test.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder/main/classes/DomainBuilder.cls: -------------------------------------------------------------------------------- 1 | public abstract class DomainBuilder { 2 | 3 | private static DirectedGraph graph = new DirectedGraph(); 4 | private static Set objects = new Set(); 5 | 6 | private Boolean isSetupObject; 7 | private Map parentByRelationship = new Map(); 8 | private Map>> relationshipsToSync 9 | = new Map>>(); 10 | public SObject record; 11 | public SObjectType type; 12 | public Id id { private set; get {return record.Id;} } 13 | 14 | 15 | // CONSTRUCTORS 16 | 17 | public DomainBuilder(SObjectType type, Boolean isSetupObject) { 18 | this.type = type; 19 | this.record = type.newSObject(null, true); 20 | this.isSetupObject = isSetupObject; 21 | 22 | graph.node(type); 23 | objects.add(this); 24 | } 25 | 26 | 27 | public DomainBuilder(SObjectType type) { 28 | this(type, false); 29 | } 30 | 31 | 32 | // PUBLIC 33 | 34 | public SObject persist() { 35 | fflib_SObjectUnitOfWork uow = unitOfWork(); 36 | 37 | for(DomainBuilder obj: objects) { 38 | if(obj.record.Id == null) { 39 | uow.registerNew(obj.record); 40 | } 41 | 42 | for(SObjectField rel: obj.parentByRelationship.keySet()) { 43 | DomainBuilder parent = obj.parentByRelationship.get(rel); 44 | uow.registerRelationship(obj.record, rel, parent.record); 45 | } 46 | } 47 | 48 | uow.commitWork(); 49 | 50 | objects.clear(); 51 | 52 | return record; 53 | } 54 | 55 | 56 | public DomainBuilder recordType(String developerName) { 57 | Id rtId = type.getDescribe().getRecordTypeInfosByDeveloperName().get(developerName).getRecordTypeId(); 58 | return set('RecordTypeId', rtId); 59 | } 60 | 61 | 62 | // PROTECTED 63 | 64 | protected DomainBuilder setParent(SObjectField relationship, DomainBuilder parent) { 65 | // Note: The parent registered last always wins! 66 | DomainBuilder oldParent = parentByRelationship.get(relationship); 67 | 68 | // Note: Sometime we manually unregister parent that are set by default constructor 69 | if(parent != null) { 70 | parentByRelationship.put(relationship, parent); 71 | } 72 | 73 | if(oldParent != null && oldParent != parent) { 74 | oldParent.unregisterIncludingParents(); 75 | } 76 | 77 | if(parent != null && !objects.contains(parent)) { 78 | parent.registerIncludingParents(); 79 | } 80 | 81 | if(relationshipsToSync.containsKey(relationship)) { 82 | synchronize(relationship); 83 | } 84 | 85 | graph.edge(this.type, parent.type); 86 | 87 | // Note: Return parent instead of this as we call this always from the parent 88 | return parent; 89 | } 90 | 91 | 92 | protected void syncOnChange(SObjectField sourceField, DomainBuilder targetObject, SObjectField targetField) { 93 | if( !relationshipsToSync.containsKey(sourceField)) { 94 | relationshipsToSync.put(sourceField, new Map>()); 95 | } 96 | if( !relationshipsToSync.get(sourceField).containsKey(targetField)) { 97 | relationshipsToSync.get(sourceField).put(targetField, new List()); 98 | } 99 | 100 | relationshipsToSync.get(sourceField).get(targetField).add(targetObject); 101 | 102 | synchronize(sourceField); 103 | } 104 | 105 | 106 | protected DomainBuilder set(String fieldName, Object value) { 107 | record.put(fieldName, value); 108 | return this; 109 | } 110 | 111 | 112 | protected DomainBuilder set(SObjectField field, Object value) { 113 | record.put(field, value); 114 | return this; 115 | } 116 | 117 | 118 | protected void unregisterIncludingParents() { 119 | objects.remove(this); 120 | 121 | for(DomainBuilder parent : parentByRelationship.values()) { 122 | parent.unregisterIncludingParents(); 123 | } 124 | } 125 | 126 | 127 | // PRIVATE 128 | 129 | private void registerIncludingParents() { 130 | if(record.Id == null) { 131 | objects.add(this); 132 | 133 | for(DomainBuilder parent: parentByRelationship.values()) { 134 | parent.registerIncludingParents(); 135 | } 136 | } 137 | } 138 | 139 | 140 | private void synchronize(SObjectField sourceField) { 141 | for(SObjectField targetField: relationshipsToSync.get(sourceField).keySet()) { 142 | for(DomainBuilder obj : relationshipsToSync.get(sourceField).get(targetField)) { 143 | 144 | DomainBuilder parent = parentByRelationship.get(sourceField); 145 | obj.setParent(targetField, parent); 146 | } 147 | } 148 | } 149 | 150 | 151 | private static fflib_SObjectUnitOfWork unitOfWork() { 152 | List insertOrder = new List(); 153 | List sorted = graph.sortTopologically(); 154 | 155 | for(Integer i = sorted.size() - 1; i >= 0; i--){ 156 | insertOrder.add(sorted[i]); 157 | } 158 | return new fflib_SObjectUnitOfWork(insertOrder); 159 | } 160 | 161 | 162 | // INNER 163 | 164 | // Note: Code adapted from https://codereview.stackexchange.com/questions/177442 165 | 166 | @TestVisible 167 | class DirectedGraph { 168 | 169 | Map childCount = new Map(); 170 | Set pureChilds = new Set(); 171 | Map> parents = new Map>(); 172 | 173 | 174 | @TestVisible 175 | DirectedGraph node(SObjectType type) { 176 | if(!parents.containsKey(type)) { 177 | parents.put(type, new Set()); 178 | } 179 | 180 | return this; 181 | } 182 | 183 | @TestVisible 184 | DirectedGraph edge(SObjectType child, SObjectType parent) { 185 | parents.get(child).add(parent); 186 | return this; 187 | } 188 | 189 | 190 | @TestVisible 191 | List sortTopologically() { 192 | List result = new List(); 193 | 194 | countDependencies(); 195 | 196 | while(!pureChilds.isEmpty()) { 197 | SObjectType cur = (SObjectType) pureChilds.iterator().next(); 198 | pureChilds.remove(cur); 199 | 200 | result.add(cur); 201 | 202 | for(SObjectType type : parents.get(cur)) { 203 | if(childCount.containsKey(type)) { 204 | Integer newCnt = childCount.get(type) - 1; 205 | childCount.put(type, newCnt); 206 | 207 | if(newCnt == 0) { 208 | pureChilds.add(type); 209 | } 210 | } 211 | } 212 | } 213 | 214 | // Note: Handle cycles 215 | if(result.size() < parents.size()) { 216 | Set missing = parents.keySet(); 217 | missing.removeAll( new Set(result) ); 218 | result.addAll(missing); 219 | } 220 | 221 | return result; 222 | } 223 | 224 | 225 | void countDependencies() { 226 | for(SObjectType type : parents.keySet()) { 227 | if(!childCount.containsKey(type)) { 228 | pureChilds.add(type); 229 | } 230 | 231 | for(SObjectType parent : parents.get(type)) { 232 | pureChilds.remove(parent); 233 | 234 | // Note: Ignore cycles 235 | if(childCount.containsKey(type)) { 236 | childCount.remove(parent); 237 | pureChilds.remove(type); 238 | } 239 | else if(!childCount.containsKey(parent)) { 240 | childCount.put(parent, 1); 241 | } 242 | else { 243 | childCount.put(parent, childCount.get(parent) + 1); 244 | } 245 | } 246 | } 247 | } 248 | } 249 | } -------------------------------------------------------------------------------- /sfdx-source/apex-domainbuilder/main/classes/DomainBuilder.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/main/classes/fflib_IDGenerator.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2014, FinancialForce.com, inc 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without modification, 6 | * are permitted provided that the following conditions are met: 7 | * 8 | * - Redistributions of source code must retain the above copyright notice, 9 | * this list of conditions and the following disclaimer. 10 | * - Redistributions in binary form must reproduce the above copyright notice, 11 | * this list of conditions and the following disclaimer in the documentation 12 | * and/or other materials provided with the distribution. 13 | * - Neither the name of the FinancialForce.com, inc nor the names of its contributors 14 | * may be used to endorse or promote products derived from this software without 15 | * specific prior written permission. 16 | * 17 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 20 | * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 22 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 23 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | */ 26 | public with sharing class fflib_IDGenerator { 27 | private static Integer fakeIdCount = 0; 28 | private static final String ID_PATTERN = '000000000000'; 29 | 30 | /** 31 | * Generate a fake Salesforce Id for the given SObjectType 32 | */ 33 | public static Id generate(Schema.SObjectType sobjectType) { 34 | String keyPrefix = sobjectType.getDescribe().getKeyPrefix(); 35 | fakeIdCount++; 36 | 37 | String fakeIdPrefix = ID_PATTERN.substring(0, ID_PATTERN.length() - String.valueOf(fakeIdCount).length()); 38 | 39 | return Id.valueOf(keyPrefix + fakeIdPrefix + fakeIdCount); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/main/classes/fflib_IDGenerator.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/main/classes/fflib_ISObjectUnitOfWork.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c), FinancialForce.com, inc 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without modification, 6 | * are permitted provided that the following conditions are met: 7 | * 8 | * - Redistributions of source code must retain the above copyright notice, 9 | * this list of conditions and the following disclaimer. 10 | * - Redistributions in binary form must reproduce the above copyright notice, 11 | * this list of conditions and the following disclaimer in the documentation 12 | * and/or other materials provided with the distribution. 13 | * - Neither the name of the FinancialForce.com, inc nor the names of its contributors 14 | * may be used to endorse or promote products derived from this software without 15 | * specific prior written permission. 16 | * 17 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 20 | * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 22 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 23 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | **/ 26 | 27 | /** 28 | * @see fflib_SObjectUnitOfWork 29 | **/ 30 | public interface fflib_ISObjectUnitOfWork 31 | { 32 | /** 33 | * Register a newly created SObject instance to be inserted when commitWork is called 34 | * 35 | * @param record A newly created SObject instance to be inserted during commitWork 36 | **/ 37 | void registerNew(SObject record); 38 | /** 39 | * Register a list of newly created SObject instances to be inserted when commitWork is called 40 | * 41 | * @param records A list of newly created SObject instances to be inserted during commitWork 42 | **/ 43 | void registerNew(List records); 44 | /** 45 | * Register a newly created SObject instance to be inserted when commitWork is called, 46 | * you may also provide a reference to the parent record instance (should also be registered as new separately) 47 | * 48 | * @param record A newly created SObject instance to be inserted during commitWork 49 | * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent 50 | * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) 51 | **/ 52 | void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord); 53 | /** 54 | * Register a relationship between two records that have yet to be inserted to the database. This information will be 55 | * used during the commitWork phase to make the references only when related records have been inserted to the database. 56 | * 57 | * @param record An existing or newly created record 58 | * @param relatedToField A SObjectField reference to the lookup field that relates the two records together 59 | * @param relatedTo A SObject instance (yet to be committed to the database) 60 | */ 61 | void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo); 62 | /** 63 | * Registers a relationship between a record and a Messaging.Email where the record has yet to be inserted 64 | * to the database. This information will be 65 | * used during the commitWork phase to make the references only when related records have been inserted to the database. 66 | * 67 | * @param email a single email message instance 68 | * @param relatedTo A SObject instance (yet to be committed to the database) 69 | */ 70 | void registerRelationship(Messaging.SingleEmailMessage email, SObject relatedTo); 71 | /** 72 | * Registers a relationship between a record and a lookup value using an external ID field and a provided value. This 73 | * information will be used during the commitWork phase to make the lookup reference requested when inserted to the database. 74 | * 75 | * @param record An existing or newly created record 76 | * @param relatedToField A SObjectField reference to the lookup field that relates the two records together 77 | * @param externalIdField A SObjectField reference to a field on the target SObject that is marked as isExternalId 78 | * @param externalId A Object representing the targeted value of the externalIdField in said lookup 79 | * 80 | * Usage Example: uow.registerRelationship(recordSObject, record_sobject__c.relationship_field__c, lookup_sobject__c.external_id__c, 'abc123'); 81 | * 82 | * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. 83 | */ 84 | void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId); 85 | /** 86 | * Register an existing record to be updated during the commitWork method 87 | * 88 | * @param record An existing record 89 | **/ 90 | void registerDirty(SObject record); 91 | /** 92 | * Register specific fields on records to be updated when work is committed 93 | * 94 | * If the records are previously registered as dirty, the dirty fields on the records in this call will overwrite 95 | * the values of the previously registered dirty records 96 | * 97 | * @param records A list of existing records 98 | * @param dirtyFields The fields to update if record is already registered 99 | **/ 100 | void registerDirty(List records, List dirtyFields); 101 | /** 102 | * Register specific fields on record to be updated when work is committed 103 | * 104 | * If the record has previously been registered as dirty, the dirty fields on the record in this call will overwrite 105 | * the values of the previously registered dirty record 106 | * 107 | * @param record An existing record 108 | * @param dirtyFields The fields to update if record is already registered 109 | **/ 110 | void registerDirty(SObject record, List dirtyFields); 111 | /** 112 | * Register an existing record to be updated when commitWork is called, 113 | * you may also provide a reference to the parent record instance (should also be registered as new separately) 114 | * 115 | * @param record A newly created SObject instance to be inserted during commitWork 116 | * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent 117 | * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) 118 | **/ 119 | void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord); 120 | /** 121 | * Register a list of existing records to be updated during the commitWork method 122 | * 123 | * @param records A list of existing records 124 | **/ 125 | void registerDirty(List records); 126 | /** 127 | * Register an deleted record to be removed from the recycle bin during the commitWork method 128 | * 129 | * @param record An deleted record 130 | **/ 131 | void registerEmptyRecycleBin(SObject record); 132 | /** 133 | * Register deleted records to be removed from the recycle bin during the commitWork method 134 | * 135 | * @param records Deleted records 136 | **/ 137 | void registerEmptyRecycleBin(List records); 138 | /** 139 | * Register a new or existing record to be inserted or updated during the commitWork method 140 | * 141 | * @param record An new or existing record 142 | **/ 143 | void registerUpsert(SObject record); 144 | /** 145 | * Register a list of mix of new and existing records to be upserted during the commitWork method 146 | * 147 | * @param records A list of mix of existing and new records 148 | **/ 149 | void registerUpsert(List records); 150 | /** 151 | * Register an existing record to be deleted during the commitWork method 152 | * 153 | * @param record An existing record 154 | **/ 155 | void registerDeleted(SObject record); 156 | /** 157 | * Register a list of existing records to be deleted during the commitWork method 158 | * 159 | * @param records A list of existing records 160 | **/ 161 | void registerDeleted(List records); 162 | /** 163 | * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method 164 | * 165 | * @param records A list of existing records 166 | **/ 167 | void registerPermanentlyDeleted(List records); 168 | /** 169 | * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method 170 | * 171 | * @param record A list of existing records 172 | **/ 173 | void registerPermanentlyDeleted(SObject record); 174 | /** 175 | * Register a newly created SObject (Platform Event) instance to be published when commitWork is called 176 | * 177 | * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork 178 | **/ 179 | void registerPublishBeforeTransaction(SObject record); 180 | /** 181 | * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called 182 | * 183 | * @param records A list of existing records 184 | **/ 185 | void registerPublishBeforeTransaction(List records); 186 | /** 187 | * Register a newly created SObject (Platform Event) instance to be published when commitWork has successfully 188 | * completed 189 | * 190 | * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork 191 | **/ 192 | void registerPublishAfterSuccessTransaction(SObject record); 193 | /** 194 | * Register a list of newly created SObject (Platform Event) instance to be published when commitWork has successfully 195 | * completed 196 | * 197 | * @param records A list of existing records 198 | **/ 199 | void registerPublishAfterSuccessTransaction(List records); 200 | /** 201 | * Register a newly created SObject (Platform Event) instance to be published when commitWork has caused an error 202 | * 203 | * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork 204 | **/ 205 | void registerPublishAfterFailureTransaction(SObject record); 206 | /** 207 | * Register a list of newly created SObject (Platform Event) instance to be published when commitWork has caused an 208 | * error 209 | * 210 | * @param records A list of existing records 211 | **/ 212 | void registerPublishAfterFailureTransaction(List records); 213 | /** 214 | * Takes all the work that has been registered with the UnitOfWork and commits it to the database 215 | **/ 216 | void commitWork(); 217 | /** 218 | * Register a generic peace of work to be invoked during the commitWork phase 219 | * 220 | * @param work Work to be registered 221 | **/ 222 | void registerWork(fflib_SObjectUnitOfWork.IDoWork work); 223 | /** 224 | * Registers the given email to be sent during the commitWork 225 | * 226 | * @param email Email to be sent 227 | **/ 228 | void registerEmail(Messaging.Email email); 229 | } -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/main/classes/fflib_ISObjectUnitOfWork.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/main/classes/fflib_SObjectUnitOfWork.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c), FinancialForce.com, inc 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without modification, 6 | * are permitted provided that the following conditions are met: 7 | * 8 | * - Redistributions of source code must retain the above copyright notice, 9 | * this list of conditions and the following disclaimer. 10 | * - Redistributions in binary form must reproduce the above copyright notice, 11 | * this list of conditions and the following disclaimer in the documentation 12 | * and/or other materials provided with the distribution. 13 | * - Neither the name of the FinancialForce.com, inc nor the names of its contributors 14 | * may be used to endorse or promote products derived from this software without 15 | * specific prior written permission. 16 | * 17 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 20 | * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 22 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 23 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | **/ 26 | 27 | /** 28 | * Provides an implementation of the Enterprise Application Architecture Unit Of Work, as defined by Martin Fowler 29 | * http://martinfowler.com/eaaCatalog/unitOfWork.html 30 | * 31 | * "When you're pulling data in and out of a database, it's important to keep track of what you've changed; otherwise, 32 | * that data won't be written back into the database. Similarly you have to insert new objects you create and 33 | * remove any objects you delete." 34 | * 35 | * "You can change the database with each change to your object model, but this can lead to lots of very small database calls, 36 | * which ends up being very slow. Furthermore it requires you to have a transaction open for the whole interaction, which is 37 | * impractical if you have a business transaction that spans multiple requests. The situation is even worse if you need to 38 | * keep track of the objects you've read so you can avoid inconsistent reads." 39 | * 40 | * "A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done, 41 | * it figures out everything that needs to be done to alter the database as a result of your work." 42 | * 43 | * In an Apex context this pattern provides the following specific benefits 44 | * - Applies bulkfication to DML operations, insert, update and delete 45 | * - Manages a business transaction around the work and ensures a rollback occurs (even when exceptions are later handled by the caller) 46 | * - Honours dependency rules between records and updates dependent relationships automatically during the commit 47 | * 48 | * Please refer to the testMethod's in this class for example usage 49 | * 50 | * TODO: Need to complete the 100% coverage by covering parameter exceptions in tests 51 | * TODO: Need to add some more test methods for more complex use cases and some unexpected (e.g. registerDirty and then registerDeleted) 52 | * 53 | **/ 54 | public virtual class fflib_SObjectUnitOfWork 55 | implements fflib_ISObjectUnitOfWork 56 | { 57 | protected List m_sObjectTypes = new List(); 58 | 59 | protected Map> m_newListByType = new Map>(); 60 | 61 | protected Map> m_dirtyMapByType = new Map>(); 62 | 63 | protected Map> m_deletedMapByType = new Map>(); 64 | protected Map> m_emptyRecycleBinMapByType = new Map>(); 65 | 66 | protected Map m_relationships = new Map(); 67 | 68 | protected Map> m_publishBeforeListByType = new Map>(); 69 | protected Map> m_publishAfterSuccessListByType = new Map>(); 70 | protected Map> m_publishAfterFailureListByType = new Map>(); 71 | 72 | protected List m_workList = new List(); 73 | 74 | @TestVisible 75 | protected IEmailWork m_emailWork = new SendEmailWork(); 76 | 77 | protected IDML m_dml; 78 | 79 | /** 80 | * Interface describes work to be performed during the commitWork method 81 | **/ 82 | public interface IDoWork 83 | { 84 | void doWork(); 85 | } 86 | 87 | public interface IDML 88 | { 89 | void dmlInsert(List objList); 90 | void dmlUpdate(List objList); 91 | void dmlDelete(List objList); 92 | void eventPublish(List objList); 93 | void emptyRecycleBin(List objList); 94 | } 95 | 96 | public virtual class SimpleDML implements IDML 97 | { 98 | public virtual void dmlInsert(List objList) 99 | { 100 | insert objList; 101 | } 102 | public virtual void dmlUpdate(List objList) 103 | { 104 | update objList; 105 | } 106 | public virtual void dmlDelete(List objList) 107 | { 108 | delete objList; 109 | } 110 | public virtual void eventPublish(List objList) 111 | { 112 | EventBus.publish(objList); 113 | } 114 | public virtual void emptyRecycleBin(List objList) 115 | { 116 | if (objList.isEmpty()) 117 | { 118 | return; 119 | } 120 | 121 | Database.emptyRecycleBin(objList); 122 | } 123 | } 124 | /** 125 | * Constructs a new UnitOfWork to support work against the given object list 126 | * 127 | * @param sObjectTypes A list of objects given in dependency order (least dependent first) 128 | */ 129 | public fflib_SObjectUnitOfWork(List sObjectTypes) 130 | { 131 | this(sObjectTypes,new SimpleDML()); 132 | } 133 | 134 | public fflib_SObjectUnitOfWork(List sObjectTypes, IDML dml) 135 | { 136 | m_sObjectTypes = sObjectTypes.clone(); 137 | 138 | for (Schema.SObjectType sObjectType : m_sObjectTypes) 139 | { 140 | // register the type 141 | handleRegisterType(sObjectType); 142 | } 143 | 144 | m_relationships.put(Messaging.SingleEmailMessage.class.getName(), new Relationships()); 145 | 146 | m_dml = dml; 147 | } 148 | 149 | // default implementations for commitWork events 150 | public virtual void onRegisterType(Schema.SObjectType sObjectType) {} 151 | public virtual void onCommitWorkStarting() {} 152 | 153 | public virtual void onPublishBeforeEventsStarting() {} 154 | public virtual void onPublishBeforeEventsFinished() {} 155 | 156 | public virtual void onDMLStarting() {} 157 | public virtual void onDMLFinished() {} 158 | 159 | public virtual void onDoWorkStarting() {} 160 | public virtual void onDoWorkFinished() {} 161 | 162 | public virtual void onPublishAfterSuccessEventsStarting() {} 163 | public virtual void onPublishAfterSuccessEventsFinished() {} 164 | 165 | public virtual void onPublishAfterFailureEventsStarting() {} 166 | public virtual void onPublishAfterFailureEventsFinished() {} 167 | 168 | public virtual void onCommitWorkFinishing() {} 169 | public virtual void onCommitWorkFinished(Boolean wasSuccessful) {} 170 | 171 | /** 172 | * Registers the type to be used for DML operations 173 | * 174 | * @param sObjectType - The type to register 175 | * 176 | */ 177 | private void handleRegisterType(Schema.SObjectType sObjectType) 178 | { 179 | String sObjectName = sObjectType.getDescribe().getName(); 180 | 181 | // add type to dml operation tracking 182 | m_newListByType.put(sObjectName, new List()); 183 | m_dirtyMapByType.put(sObjectName, new Map()); 184 | m_deletedMapByType.put(sObjectName, new Map()); 185 | m_emptyRecycleBinMapByType.put(sObjectName, new Map()); 186 | m_relationships.put(sObjectName, new Relationships()); 187 | 188 | m_publishBeforeListByType.put(sObjectName, new List()); 189 | m_publishAfterSuccessListByType.put(sObjectName, new List()); 190 | m_publishAfterFailureListByType.put(sObjectName, new List()); 191 | 192 | // give derived class opportunity to register the type 193 | onRegisterType(sObjectType); 194 | } 195 | 196 | /** 197 | * Register a generic piece of work to be invoked during the commitWork phase 198 | **/ 199 | public void registerWork(IDoWork work) 200 | { 201 | m_workList.add(work); 202 | } 203 | 204 | /** 205 | * Registers the given email to be sent during the commitWork 206 | **/ 207 | public void registerEmail(Messaging.Email email) 208 | { 209 | m_emailWork.registerEmail(email); 210 | } 211 | 212 | /** 213 | * Register an deleted record to be removed from the recycle bin during the commitWork method 214 | * 215 | * @param record An deleted record 216 | **/ 217 | public void registerEmptyRecycleBin(SObject record) 218 | { 219 | String sObjectType = record.getSObjectType().getDescribe().getName(); 220 | assertForSupportedSObjectType(m_emptyRecycleBinMapByType, sObjectType); 221 | 222 | m_emptyRecycleBinMapByType.get(sObjectType).put(record.Id, record); 223 | } 224 | 225 | /** 226 | * Register deleted records to be removed from the recycle bin during the commitWork method 227 | * 228 | * @param records Deleted records 229 | **/ 230 | public void registerEmptyRecycleBin(List records) 231 | { 232 | for (SObject record : records) 233 | { 234 | registerEmptyRecycleBin(record); 235 | } 236 | } 237 | 238 | /** 239 | * Register a newly created SObject instance to be inserted when commitWork is called 240 | * 241 | * @param record A newly created SObject instance to be inserted during commitWork 242 | **/ 243 | public void registerNew(SObject record) 244 | { 245 | registerNew(record, null, null); 246 | } 247 | 248 | /** 249 | * Register a list of newly created SObject instances to be inserted when commitWork is called 250 | * 251 | * @param records A list of newly created SObject instances to be inserted during commitWork 252 | **/ 253 | public void registerNew(List records) 254 | { 255 | for (SObject record : records) 256 | { 257 | registerNew(record, null, null); 258 | } 259 | } 260 | 261 | /** 262 | * Register a newly created SObject instance to be inserted when commitWork is called, 263 | * you may also provide a reference to the parent record instance (should also be registered as new separately) 264 | * 265 | * @param record A newly created SObject instance to be inserted during commitWork 266 | * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent 267 | * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) 268 | **/ 269 | public void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) 270 | { 271 | if (record.Id != null) 272 | throw new UnitOfWorkException('Only new records can be registered as new'); 273 | String sObjectType = record.getSObjectType().getDescribe().getName(); 274 | 275 | assertForNonEventSObjectType(sObjectType); 276 | assertForSupportedSObjectType(m_newListByType, sObjectType); 277 | 278 | m_newListByType.get(sObjectType).add(record); 279 | if (relatedToParentRecord!=null && relatedToParentField!=null) 280 | registerRelationship(record, relatedToParentField, relatedToParentRecord); 281 | } 282 | 283 | /** 284 | * Register a relationship between two records that have yet to be inserted to the database. This information will be 285 | * used during the commitWork phase to make the references only when related records have been inserted to the database. 286 | * 287 | * @param record An existing or newly created record 288 | * @param relatedToField A SObjectField reference to the lookup field that relates the two records together 289 | * @param relatedTo A SObject instance (yet to be committed to the database) 290 | */ 291 | public void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) 292 | { 293 | String sObjectType = record.getSObjectType().getDescribe().getName(); 294 | 295 | assertForNonEventSObjectType(sObjectType); 296 | assertForSupportedSObjectType(m_newListByType, sObjectType); 297 | 298 | m_relationships.get(sObjectType).add(record, relatedToField, relatedTo); 299 | } 300 | 301 | /** 302 | * Registers a relationship between a record and a Messaging.Email where the record has yet to be inserted 303 | * to the database. This information will be 304 | * used during the commitWork phase to make the references only when related records have been inserted to the database. 305 | * 306 | * @param email a single email message instance 307 | * @param relatedTo A SObject instance (yet to be committed to the database) 308 | */ 309 | public void registerRelationship( Messaging.SingleEmailMessage email, SObject relatedTo ) 310 | { 311 | m_relationships.get( Messaging.SingleEmailMessage.class.getName() ).add(email, relatedTo); 312 | } 313 | 314 | /** 315 | * Registers a relationship between a record and a lookup value using an external ID field and a provided value. This 316 | * information will be used during the commitWork phase to make the lookup reference requested when inserted to the database. 317 | * 318 | * @param record An existing or newly created record 319 | * @param relatedToField A SObjectField reference to the lookup field that relates the two records together 320 | * @param externalIdField A SObjectField reference to a field on the target SObject that is marked as isExternalId 321 | * @param externalId A Object representing the targeted value of the externalIdField in said lookup 322 | * 323 | * Usage Example: uow.registerRelationship(recordSObject, record_sobject__c.relationship_field__c, lookup_sobject__c.external_id__c, 'abc123'); 324 | * 325 | * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. 326 | */ 327 | public void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) 328 | { 329 | // NOTE: Due to the lack of ExternalID references on Standard Objects, this method can not be provided a standardized Unit Test. - Rick Parker 330 | String sObjectType = record.getSObjectType().getDescribe().getName(); 331 | if(!m_newListByType.containsKey(sObjectType)) 332 | throw new UnitOfWorkException(String.format('SObject type {0} is not supported by this unit of work', new String[] { sObjectType })); 333 | m_relationships.get(sObjectType).add(record, relatedToField, externalIdField, externalId); 334 | } 335 | 336 | /** 337 | * Register an existing record to be updated during the commitWork method 338 | * 339 | * @param record An existing record 340 | **/ 341 | public void registerDirty(SObject record) 342 | { 343 | registerDirty(record, new List()); 344 | } 345 | 346 | /** 347 | * Registers the entire records as dirty or just only the dirty fields if the record was already registered 348 | * 349 | * @param records SObjects to register as dirty 350 | * @param dirtyFields A list of modified fields 351 | */ 352 | public void registerDirty(List records, List dirtyFields) 353 | { 354 | for (SObject record : records) 355 | { 356 | registerDirty(record, dirtyFields); 357 | } 358 | } 359 | 360 | /** 361 | * Registers the entire record as dirty or just only the dirty fields if the record was already registered 362 | * 363 | * @param record SObject to register as dirty 364 | * @param dirtyFields A list of modified fields 365 | */ 366 | public void registerDirty(SObject record, List dirtyFields) 367 | { 368 | if (record.Id == null) 369 | throw new UnitOfWorkException('New records cannot be registered as dirty'); 370 | String sObjectType = record.getSObjectType().getDescribe().getName(); 371 | 372 | assertForNonEventSObjectType(sObjectType); 373 | assertForSupportedSObjectType(m_dirtyMapByType, sObjectType); 374 | 375 | // If record isn't registered as dirty, or no dirty fields to drive a merge 376 | if (!m_dirtyMapByType.get(sObjectType).containsKey(record.Id) || dirtyFields.isEmpty()) 377 | { 378 | // Register the record as dirty 379 | m_dirtyMapByType.get(sObjectType).put(record.Id, record); 380 | } 381 | else 382 | { 383 | // Update the registered record's fields 384 | SObject registeredRecord = m_dirtyMapByType.get(sObjectType).get(record.Id); 385 | 386 | for (SObjectField dirtyField : dirtyFields) { 387 | registeredRecord.put(dirtyField, record.get(dirtyField)); 388 | } 389 | 390 | m_dirtyMapByType.get(sObjectType).put(record.Id, registeredRecord); 391 | } 392 | } 393 | 394 | /** 395 | * Register an existing record to be updated when commitWork is called, 396 | * you may also provide a reference to the parent record instance (should also be registered as new separately) 397 | * 398 | * @param record A newly created SObject instance to be inserted during commitWork 399 | * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent 400 | * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) 401 | **/ 402 | public void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) 403 | { 404 | registerDirty(record); 405 | if (relatedToParentRecord!=null && relatedToParentField!=null) 406 | registerRelationship(record, relatedToParentField, relatedToParentRecord); 407 | } 408 | 409 | /** 410 | * Register a list of existing records to be updated during the commitWork method 411 | * 412 | * @param records A list of existing records 413 | **/ 414 | public void registerDirty(List records) 415 | { 416 | for (SObject record : records) 417 | { 418 | this.registerDirty(record); 419 | } 420 | } 421 | 422 | /** 423 | * Register a new or existing record to be inserted/updated during the commitWork method 424 | * 425 | * @param record A new or existing record 426 | **/ 427 | public void registerUpsert(SObject record) 428 | { 429 | if (record.Id == null) 430 | { 431 | registerNew(record, null, null); 432 | } 433 | else 434 | { 435 | registerDirty(record, new List()); 436 | } 437 | } 438 | 439 | /** 440 | * Register a list of mix of new and existing records to be inserted updated during the commitWork method 441 | * 442 | * @param records A list of mix of new and existing records 443 | **/ 444 | public void registerUpsert(List records) 445 | { 446 | for (SObject record : records) 447 | { 448 | this.registerUpsert(record); 449 | } 450 | } 451 | 452 | /** 453 | * Register an existing record to be deleted during the commitWork method 454 | * 455 | * @param record An existing record 456 | **/ 457 | public void registerDeleted(SObject record) 458 | { 459 | if (record.Id == null) 460 | throw new UnitOfWorkException('New records cannot be registered for deletion'); 461 | String sObjectType = record.getSObjectType().getDescribe().getName(); 462 | 463 | assertForNonEventSObjectType(sObjectType); 464 | assertForSupportedSObjectType(m_deletedMapByType, sObjectType); 465 | 466 | m_deletedMapByType.get(sObjectType).put(record.Id, record); 467 | } 468 | 469 | /** 470 | * Register a list of existing records to be deleted during the commitWork method 471 | * 472 | * @param records A list of existing records 473 | **/ 474 | public void registerDeleted(List records) 475 | { 476 | for (SObject record : records) 477 | { 478 | this.registerDeleted(record); 479 | } 480 | } 481 | 482 | /** 483 | * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method 484 | * 485 | * @param records A list of existing records 486 | **/ 487 | public void registerPermanentlyDeleted(List records) 488 | { 489 | this.registerEmptyRecycleBin(records); 490 | this.registerDeleted(records); 491 | } 492 | 493 | /** 494 | * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method 495 | * 496 | * @param record A list of existing records 497 | **/ 498 | public void registerPermanentlyDeleted(SObject record) 499 | { 500 | this.registerEmptyRecycleBin(record); 501 | this.registerDeleted(record); 502 | } 503 | 504 | /** 505 | * Register a newly created SObject (Platform Event) instance to be published when commitWork is called 506 | * 507 | * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork 508 | **/ 509 | public void registerPublishBeforeTransaction(SObject record) 510 | { 511 | String sObjectType = record.getSObjectType().getDescribe().getName(); 512 | 513 | assertForEventSObjectType(sObjectType); 514 | assertForSupportedSObjectType(m_publishBeforeListByType, sObjectType); 515 | 516 | m_publishBeforeListByType.get(sObjectType).add(record); 517 | } 518 | 519 | /** 520 | * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called 521 | * 522 | * @param records A list of existing records 523 | **/ 524 | public void registerPublishBeforeTransaction(List records) 525 | { 526 | for (SObject record : records) 527 | { 528 | this.registerPublishBeforeTransaction(record); 529 | } 530 | } 531 | 532 | /** 533 | * Register a newly created SObject (Platform Event) instance to be published when commitWork is called 534 | * 535 | * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork 536 | **/ 537 | public void registerPublishAfterSuccessTransaction(SObject record) 538 | { 539 | String sObjectType = record.getSObjectType().getDescribe().getName(); 540 | 541 | assertForEventSObjectType(sObjectType); 542 | assertForSupportedSObjectType(m_publishAfterSuccessListByType, sObjectType); 543 | 544 | m_publishAfterSuccessListByType.get(sObjectType).add(record); 545 | } 546 | 547 | /** 548 | * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called 549 | * 550 | * @param records A list of existing records 551 | **/ 552 | public void registerPublishAfterSuccessTransaction(List records) 553 | { 554 | for (SObject record : records) 555 | { 556 | this.registerPublishAfterSuccessTransaction(record); 557 | } 558 | } 559 | /** 560 | * Register a newly created SObject (Platform Event) instance to be published when commitWork is called 561 | * 562 | * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork 563 | **/ 564 | public void registerPublishAfterFailureTransaction(SObject record) 565 | { 566 | String sObjectType = record.getSObjectType().getDescribe().getName(); 567 | 568 | assertForEventSObjectType(sObjectType); 569 | assertForSupportedSObjectType(m_publishAfterFailureListByType, sObjectType); 570 | 571 | m_publishAfterFailureListByType.get(sObjectType).add(record); 572 | } 573 | 574 | /** 575 | * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called 576 | * 577 | * @param records A list of existing records 578 | **/ 579 | public void registerPublishAfterFailureTransaction(List records) 580 | { 581 | for (SObject record : records) 582 | { 583 | this.registerPublishAfterFailureTransaction(record); 584 | } 585 | } 586 | 587 | /** 588 | * Takes all the work that has been registered with the UnitOfWork and commits it to the database 589 | **/ 590 | public void commitWork() 591 | { 592 | Savepoint sp = Database.setSavepoint(); 593 | Boolean wasSuccessful = false; 594 | try 595 | { 596 | doCommitWork(); 597 | wasSuccessful = true; 598 | } 599 | catch (Exception e) 600 | { 601 | Database.rollback(sp); 602 | throw e; 603 | } 604 | finally 605 | { 606 | doAfterCommitWorkSteps(wasSuccessful); 607 | } 608 | } 609 | 610 | private void doCommitWork() 611 | { 612 | onCommitWorkStarting(); 613 | onPublishBeforeEventsStarting(); 614 | publishBeforeEventsStarting(); 615 | onPublishBeforeEventsFinished(); 616 | 617 | onDMLStarting(); 618 | insertDmlByType(); 619 | updateDmlByType(); 620 | deleteDmlByType(); 621 | emptyRecycleBinByType(); 622 | resolveEmailRelationships(); 623 | onDMLFinished(); 624 | 625 | onDoWorkStarting(); 626 | doWork(); 627 | onDoWorkFinished(); 628 | onCommitWorkFinishing(); 629 | } 630 | 631 | private void doAfterCommitWorkSteps(Boolean wasSuccessful) 632 | { 633 | if (wasSuccessful) 634 | { 635 | doAfterCommitWorkSuccessSteps(); 636 | } 637 | else 638 | { 639 | doAfterCommitWorkFailureSteps(); 640 | } 641 | onCommitWorkFinished(wasSuccessful); 642 | } 643 | 644 | private void doAfterCommitWorkSuccessSteps() 645 | { 646 | onPublishAfterSuccessEventsStarting(); 647 | publishAfterSuccessEvents(); 648 | onPublishAfterSuccessEventsFinished(); 649 | } 650 | 651 | private void doAfterCommitWorkFailureSteps() 652 | { 653 | onPublishAfterFailureEventsStarting(); 654 | publishAfterFailureEvents(); 655 | onPublishAfterFailureEventsFinished(); 656 | } 657 | 658 | private void publishBeforeEventsStarting() 659 | { 660 | for (Schema.SObjectType sObjectType : m_sObjectTypes) 661 | { 662 | m_dml.eventPublish(m_publishBeforeListByType.get(sObjectType.getDescribe().getName())); 663 | } 664 | } 665 | 666 | private void insertDmlByType() 667 | { 668 | for (Schema.SObjectType sObjectType : m_sObjectTypes) 669 | { 670 | Boolean hasSelfLookup = m_relationships.get(sObjectType.getDescribe().getName()).resolve(); 671 | m_dml.dmlInsert(m_newListByType.get(sObjectType.getDescribe().getName())); 672 | 673 | if (hasSelfLookup) 674 | { 675 | m_relationships.get(sObjectType.getDescribe().getName()).resolve(); 676 | m_dml.dmlUpdate(m_newListByType.get(sObjectType.getDescribe().getName())); 677 | } 678 | } 679 | } 680 | 681 | private void updateDmlByType() 682 | { 683 | for (Schema.SObjectType sObjectType : m_sObjectTypes) 684 | { 685 | m_dml.dmlUpdate(m_dirtyMapByType.get(sObjectType.getDescribe().getName()).values()); 686 | } 687 | } 688 | 689 | private void deleteDmlByType() 690 | { 691 | Integer objectIdx = m_sObjectTypes.size() - 1; 692 | while (objectIdx >= 0) 693 | { 694 | m_dml.dmlDelete(m_deletedMapByType.get(m_sObjectTypes[objectIdx--].getDescribe().getName()).values()); 695 | } 696 | } 697 | 698 | private void emptyRecycleBinByType() 699 | { 700 | Integer objectIdx = m_sObjectTypes.size() - 1; 701 | while (objectIdx >= 0) 702 | { 703 | m_dml.emptyRecycleBin(m_emptyRecycleBinMapByType.get(m_sObjectTypes[objectIdx--].getDescribe().getName()).values()); 704 | } 705 | } 706 | 707 | private void resolveEmailRelationships() 708 | { 709 | m_relationships.get(Messaging.SingleEmailMessage.class.getName()).resolve(); 710 | } 711 | 712 | private void doWork() 713 | { 714 | m_workList.add(m_emailWork); 715 | for (IDoWork work : m_workList) 716 | { 717 | work.doWork(); 718 | } 719 | } 720 | 721 | private void publishAfterSuccessEvents() 722 | { 723 | for (Schema.SObjectType sObjectType : m_sObjectTypes) 724 | { 725 | m_dml.eventPublish(m_publishAfterSuccessListByType.get(sObjectType.getDescribe().getName())); 726 | } 727 | } 728 | 729 | private void publishAfterFailureEvents() 730 | { 731 | for (Schema.SObjectType sObjectType : m_sObjectTypes) 732 | { 733 | m_dml.eventPublish(m_publishAfterFailureListByType.get(sObjectType.getDescribe().getName())); 734 | } 735 | } 736 | 737 | @TestVisible 738 | private void assertForNonEventSObjectType(String sObjectType) 739 | { 740 | if (sObjectType.length() > 3 && sObjectType.right(3) == '__e') 741 | { 742 | throw new UnitOfWorkException( 743 | String.format( 744 | 'SObject type {0} must use registerPublishBeforeTransaction or ' + 745 | 'registerPublishAfterTransaction methods to be used within this unit of work', 746 | new List { sObjectType } 747 | ) 748 | ); 749 | } 750 | } 751 | 752 | @TestVisible 753 | private void assertForEventSObjectType(String sObjectType) 754 | { 755 | if (sObjectType.length() > 3 && sObjectType.right(3) != '__e') 756 | { 757 | throw new UnitOfWorkException( 758 | String.format( 759 | 'SObject type {0} is invalid for publishing within this unit of work', 760 | new List {sObjectType} 761 | ) 762 | ); 763 | } 764 | } 765 | 766 | @TestVisible 767 | private void assertForSupportedSObjectType(Map theMap, String sObjectType) 768 | { 769 | if (!theMap.containsKey(sObjectType)) 770 | { 771 | throw new UnitOfWorkException( 772 | String.format( 773 | 'SObject type {0} is not supported by this unit of work', 774 | new List { sObjectType } 775 | ) 776 | ); 777 | } 778 | } 779 | 780 | private class Relationships 781 | { 782 | private List m_relationships = new List(); 783 | 784 | public Boolean resolve() 785 | { 786 | Boolean result = false; 787 | 788 | // Resolve relationships 789 | for (IRelationship relationship : m_relationships) 790 | { 791 | //relationship.Record.put(relationship.RelatedToField, relationship.RelatedTo.Id); 792 | relationship.resolve(); 793 | result = result || relationship.isSelfLookup(); 794 | } 795 | 796 | return result; 797 | } 798 | 799 | public void add(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) 800 | { 801 | if (relatedToField == null) { 802 | throw new UnitOfWorkException('Invalid argument: relatedToField.'); 803 | } 804 | 805 | String relationshipName = relatedToField.getDescribe().getRelationshipName(); 806 | if (String.isBlank(relationshipName)) { 807 | throw new UnitOfWorkException('Invalid argument: relatedToField. Field supplied is not a relationship field.'); 808 | } 809 | 810 | List relatedObjects = relatedToField.getDescribe().getReferenceTo(); 811 | Schema.SObjectType relatedObject = relatedObjects[0]; 812 | 813 | String externalIdFieldName = externalIdField.getDescribe().getName(); 814 | Boolean relatedHasExternalIdField = relatedObject.getDescribe().fields.getMap().keySet().contains(externalIdFieldName.toLowerCase()); 815 | Boolean externalIdFieldIsValid = externalIdField.getDescribe().isExternalId(); 816 | 817 | if (!relatedHasExternalIdField) { 818 | throw new UnitOfWorkException('Invalid argument: externalIdField. Field supplied is not a known field on the target sObject.'); 819 | } 820 | 821 | if (!externalIdFieldIsValid) { 822 | throw new UnitOfWorkException('Invalid argument: externalIdField. Field supplied is not a marked as an External Identifier.'); 823 | } 824 | 825 | RelationshipByExternalId relationship = new RelationshipByExternalId(); 826 | relationship.Record = record; 827 | relationship.RelatedToField = relatedToField; 828 | relationship.RelatedTo = relatedObject; 829 | relationship.RelationshipName = relationshipName; 830 | relationship.ExternalIdField = externalIdField; 831 | relationship.ExternalId = externalId; 832 | m_relationships.add(relationship); 833 | } 834 | 835 | public void add(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) 836 | { 837 | // Relationship to resolve 838 | Relationship relationship = new Relationship(); 839 | relationship.Record = record; 840 | relationship.RelatedToField = relatedToField; 841 | relationship.RelatedTo = relatedTo; 842 | m_relationships.add(relationship); 843 | } 844 | 845 | public void add(Messaging.SingleEmailMessage email, SObject relatedTo) 846 | { 847 | EmailRelationship emailRelationship = new EmailRelationship(); 848 | emailRelationship.email = email; 849 | emailRelationship.relatedTo = relatedTo; 850 | m_relationships.add(emailRelationship); 851 | } 852 | } 853 | 854 | private interface IRelationship 855 | { 856 | void resolve(); 857 | Boolean isSelfLookup(); 858 | } 859 | 860 | private class RelationshipByExternalId implements IRelationship 861 | { 862 | public SObject Record; 863 | public Schema.SObjectField RelatedToField; 864 | public Schema.SObjectType RelatedTo; 865 | public String RelationshipName; 866 | public Schema.SObjectField ExternalIdField; 867 | public Object ExternalId; 868 | 869 | public void resolve() 870 | { 871 | SObject relationshipObject = this.RelatedTo.newSObject(); 872 | relationshipObject.put( ExternalIdField.getDescribe().getName(), this.ExternalId ); 873 | this.Record.putSObject( this.RelationshipName, relationshipObject ); 874 | } 875 | 876 | public Boolean isSelfLookup() 877 | { 878 | return Record.getSObjectType() == RelatedTo; 879 | } 880 | } 881 | 882 | private class Relationship implements IRelationship 883 | { 884 | public SObject Record; 885 | public Schema.SObjectField RelatedToField; 886 | public SObject RelatedTo; 887 | 888 | public void resolve() 889 | { 890 | this.Record.put( this.RelatedToField, this.RelatedTo.Id); 891 | } 892 | 893 | public Boolean isSelfLookup() 894 | { 895 | return Record.getSObjectType() == RelatedTo.getSObjectType(); 896 | } 897 | } 898 | 899 | private class EmailRelationship implements IRelationship 900 | { 901 | public Messaging.SingleEmailMessage email; 902 | public SObject relatedTo; 903 | 904 | public void resolve() 905 | { 906 | this.email.setWhatId( this.relatedTo.Id ); 907 | } 908 | 909 | public Boolean isSelfLookup() 910 | { 911 | return false; 912 | } 913 | } 914 | 915 | /** 916 | * UnitOfWork Exception 917 | **/ 918 | public class UnitOfWorkException extends Exception {} 919 | 920 | /** 921 | * Internal implementation of Messaging.sendEmail, see outer class registerEmail method 922 | **/ 923 | public interface IEmailWork extends IDoWork 924 | { 925 | void registerEmail(Messaging.Email email); 926 | } 927 | 928 | private class SendEmailWork implements IEmailWork 929 | { 930 | private List emails; 931 | 932 | public SendEmailWork() 933 | { 934 | this.emails = new List(); 935 | } 936 | 937 | public void registerEmail(Messaging.Email email) 938 | { 939 | this.emails.add(email); 940 | } 941 | 942 | public void doWork() 943 | { 944 | if (emails.size() > 0) Messaging.sendEmail(emails); 945 | } 946 | } 947 | } 948 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/main/classes/fflib_SObjectUnitOfWork.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/test/classes/fflib_SObjectUnitOfWorkTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c), FinancialForce.com, inc 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without modification, 6 | * are permitted provided that the following conditions are met: 7 | * 8 | * - Redistributions of source code must retain the above copyright notice, 9 | * this list of conditions and the following disclaimer. 10 | * - Redistributions in binary form must reproduce the above copyright notice, 11 | * this list of conditions and the following disclaimer in the documentation 12 | * and/or other materials provided with the distribution. 13 | * - Neither the name of the FinancialForce.com, inc nor the names of its contributors 14 | * may be used to endorse or promote products derived from this software without 15 | * specific prior written permission. 16 | * 17 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 20 | * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 22 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 23 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | **/ 26 | 27 | @IsTest(IsParallel=true) 28 | private with sharing class fflib_SObjectUnitOfWorkTest 29 | { 30 | // SObjects (in order of dependency) used by UnitOfWork in tests bellow 31 | private static List MY_SOBJECTS = 32 | new Schema.SObjectType[] { 33 | Product2.SObjectType, 34 | PricebookEntry.SObjectType, 35 | Opportunity.SObjectType, 36 | OpportunityLineItem.SObjectType }; 37 | 38 | @IsTest 39 | private static void testUnitOfWorkEmail() 40 | { 41 | String testRecordName = 'UoW Test Name 1'; 42 | 43 | Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage(); 44 | email.setToAddresses(new List{ 'foobar@test.com' }); 45 | email.setPlainTextBody('See Spot run.'); 46 | 47 | MockDML mockDML = new MockDML(); 48 | fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 49 | 50 | uow.m_emailWork = new Mock_SendEmailWork(); 51 | 52 | Opportunity opp = new Opportunity(); 53 | opp.Name = testRecordName; 54 | opp.StageName = 'Open'; 55 | opp.CloseDate = System.today(); 56 | uow.registerNew( opp ); 57 | 58 | uow.registerEmail( email ); 59 | 60 | uow.registerRelationship( email, opp ); 61 | 62 | uow.commitWork(); 63 | 64 | // assert that mock email functionality was called 65 | System.assert(((Mock_SendEmailWork) uow.m_emailWork).doWorkWasCalled); 66 | 67 | System.assertEquals(1, mockDML.recordsForInsert.size()); 68 | } 69 | 70 | @IsTest 71 | private static void testRegisterNew_ThrowExceptionOnDirtyRecord() 72 | { 73 | // GIVEN an existing record 74 | Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); 75 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 76 | 77 | // WHEN we register the existing record as new 78 | Boolean exceptionThrown = false; 79 | try 80 | { 81 | unitOfWork.registerNew(opportunity); 82 | } 83 | catch (Exception e) 84 | { 85 | exceptionThrown = true; 86 | System.assertEquals( 87 | 'Only new records can be registered as new', 88 | e.getMessage(), 89 | 'Incorrect exception message thrown' 90 | ); 91 | } 92 | 93 | // THEN it should have thrown an exception 94 | System.assert(exceptionThrown); 95 | } 96 | 97 | @IsTest 98 | private static void testRegisterDirty_ThrowExceptionOnNewRecord() 99 | { 100 | // GIVEN an new record 101 | Opportunity opportunity = new Opportunity(); 102 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 103 | 104 | // WHEN we register the existing record as new 105 | Boolean exceptionThrown = false; 106 | try 107 | { 108 | unitOfWork.registerDirty(opportunity); 109 | } 110 | catch (Exception e) 111 | { 112 | exceptionThrown = true; 113 | System.assertEquals( 114 | 'New records cannot be registered as dirty', 115 | e.getMessage(), 116 | 'Incorrect exception message thrown' 117 | ); 118 | } 119 | 120 | // THEN it should have thrown an exception 121 | System.assert(exceptionThrown); 122 | } 123 | 124 | @IsTest 125 | private static void testRegisterDeleted() 126 | { 127 | // GIVEN - two existing records 128 | Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); 129 | Product2 product = new Product2(Id = fflib_IDGenerator.generate(Schema.Product2.SObjectType)); 130 | MockDML mockDML = new MockDML(); 131 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 132 | 133 | // WHEN - we mark the records as deleted 134 | unitOfWork.registerDeleted(new List { opportunity, product }); 135 | unitOfWork.commitWork(); 136 | 137 | // THEN - the dmlDelete action should be invoked 138 | System.assertEquals(new List { opportunity, product }, mockDML.recordsForDelete); 139 | } 140 | 141 | @IsTest 142 | private static void testRegisterPermanentlyDeleted() 143 | { 144 | // GIVEN - two existing records 145 | Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); 146 | Product2 product = new Product2(Id = fflib_IDGenerator.generate(Schema.Product2.SObjectType)); 147 | MockDML mockDML = new MockDML(); 148 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 149 | 150 | // WHEN - we mark the records as deleted 151 | unitOfWork.registerPermanentlyDeleted(new List { opportunity, product }); 152 | unitOfWork.commitWork(); 153 | 154 | // THEN - the dmlDelete and emptyRecycleBin actions should be invoked 155 | System.assertEquals(new List { opportunity, product }, mockDML.recordsForDelete); 156 | System.assertEquals(new List { opportunity, product }, mockDML.recordsForRecycleBin); 157 | } 158 | 159 | @IsTest 160 | private static void testRegisterEmptyRecycleBin() 161 | { 162 | // GIVEN - an existing record of the recycle bin 163 | Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); 164 | MockDML mockDML = new MockDML(); 165 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 166 | 167 | // WHEN - we empty the record from the recycle bin 168 | unitOfWork.registerEmptyRecycleBin(new List{ opportunity }); 169 | unitOfWork.commitWork(); 170 | 171 | // THEN - the emptyRecycleBin action should be invoked 172 | System.assertEquals(1, mockDML.recordsForRecycleBin.size()); 173 | } 174 | 175 | @IsTest 176 | private static void testAssertForNonEventSObjectType() 177 | { 178 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 179 | unitOfWork.assertForNonEventSObjectType('CustomObject__c'); 180 | } 181 | 182 | @IsTest 183 | private static void testAssertForNonEventSObjectType_ThrowExceptionOnEventObject() 184 | { 185 | Boolean exceptionThrown = false; 186 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 187 | try 188 | { 189 | unitOfWork.assertForNonEventSObjectType('PlatformEventObject__e'); 190 | } 191 | catch (Exception e) 192 | { 193 | exceptionThrown = true; 194 | System.assert( 195 | e.getMessage().contains('registerPublishBeforeTransaction'), 196 | 'Incorrect exception message thrown' 197 | ); 198 | } 199 | 200 | System.assert(exceptionThrown); 201 | } 202 | 203 | @IsTest 204 | private static void testAssertForEventSObjectType() 205 | { 206 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 207 | unitOfWork.assertForEventSObjectType('PlatformEventObject__e'); 208 | } 209 | 210 | @IsTest 211 | private static void testAssertForEventSObjectType_ThrowExceptionOnNonEventObject() 212 | { 213 | Boolean exceptionThrown = false; 214 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 215 | try 216 | { 217 | unitOfWork.assertForEventSObjectType('CustomObject__c'); 218 | } 219 | catch (Exception e) 220 | { 221 | exceptionThrown = true; 222 | System.assert( 223 | e.getMessage().contains('invalid for publishing'), 224 | 'Incorrect exception message thrown' 225 | ); 226 | } 227 | 228 | System.assert(exceptionThrown); 229 | } 230 | 231 | @IsTest 232 | private static void testAssertForSupportedSObjectType_throwExceptionOnUnsupportedType() 233 | { 234 | Boolean exceptionThrown = false; 235 | fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); 236 | try 237 | { 238 | unitOfWork.registerNew(new Account()); 239 | } 240 | catch (Exception e) 241 | { 242 | exceptionThrown = true; 243 | System.assert( 244 | e.getMessage().contains('not supported by this unit of work'), 245 | 'Incorrect exception message thrown' 246 | ); 247 | } 248 | 249 | System.assert(exceptionThrown); 250 | } 251 | 252 | /** 253 | * Create uow with new records and commit 254 | * 255 | * Testing: 256 | * 257 | * - Correct events are fired when commitWork completes successfully 258 | * 259 | */ 260 | @IsTest 261 | private static void testDerivedUnitOfWork_CommitSuccess() 262 | { 263 | // Insert Opportunities with UnitOfWork 264 | MockDML mockDML = new MockDML(); 265 | DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS, mockDML); 266 | for(Integer o=0; o<10; o++) 267 | { 268 | Opportunity opp = new Opportunity(); 269 | opp.Name = 'UoW Test Name ' + o; 270 | opp.StageName = 'Open'; 271 | opp.CloseDate = System.today(); 272 | uow.registerNew(new List{opp}); 273 | for(Integer i=0; i{product}); 278 | PricebookEntry pbe = new PricebookEntry(); 279 | pbe.UnitPrice = 10; 280 | pbe.IsActive = true; 281 | pbe.UseStandardPrice = false; 282 | uow.registerNew(pbe, PricebookEntry.Product2Id, product); 283 | OpportunityLineItem oppLineItem = new OpportunityLineItem(); 284 | oppLineItem.Quantity = 1; 285 | oppLineItem.TotalPrice = 10; 286 | uow.registerRelationship(oppLineItem, OpportunityLineItem.PricebookEntryId, pbe); 287 | uow.registerNew(oppLineItem, OpportunityLineItem.OpportunityId, opp); 288 | } 289 | } 290 | uow.commitWork(); 291 | 292 | // Assert Results 293 | System.assertEquals(175, mockDML.recordsForInsert.size(), 'Incorrect of new records'); 294 | 295 | assertEvents(new List { 296 | 'onCommitWorkStarting' 297 | , 'onPublishBeforeEventsStarting' 298 | , 'onPublishBeforeEventsFinished' 299 | , 'onDMLStarting' 300 | , 'onDMLFinished' 301 | , 'onDoWorkStarting' 302 | , 'onDoWorkFinished' 303 | , 'onCommitWorkFinishing' 304 | , 'onPublishAfterSuccessEventsStarting' 305 | , 'onPublishAfterSuccessEventsFinished' 306 | , 'onCommitWorkFinished - true' 307 | } 308 | , uow.getCommitWorkEventsFired(), new Set(MY_SOBJECTS), uow.getRegisteredTypes()); 309 | } 310 | 311 | /** 312 | * Create uow with data that results in DML Exception 313 | * 314 | * Testing: 315 | * 316 | * - Correct events are fired when commitWork fails during DML processing 317 | * 318 | */ 319 | @IsTest 320 | private static void testDerivedUnitOfWork_CommitDMLFail() 321 | { 322 | // Insert Opportunities with UnitOfWork forcing a failure on DML by not setting 'Name' field 323 | DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS); 324 | Opportunity opp = new Opportunity(); 325 | uow.registerNew(new List{opp}); 326 | Boolean didFail = false; 327 | System.DmlException caughtEx = null; 328 | 329 | try { 330 | uow.commitWork(); 331 | } 332 | catch (System.DmlException dmlex) { 333 | didFail = true; 334 | caughtEx = dmlex; 335 | } 336 | 337 | // Assert Results 338 | System.assertEquals(didFail, true, 'didFail'); 339 | System.assert(caughtEx.getMessage().contains('REQUIRED_FIELD_MISSING'), String.format('Exception message was ', new List { caughtEx.getMessage() })); 340 | 341 | assertEvents(new List { 342 | 'onCommitWorkStarting' 343 | , 'onPublishBeforeEventsStarting' 344 | , 'onPublishBeforeEventsFinished' 345 | , 'onDMLStarting' 346 | , 'onPublishAfterFailureEventsStarting' 347 | , 'onPublishAfterFailureEventsFinished' 348 | , 'onCommitWorkFinished - false' 349 | } 350 | , uow.getCommitWorkEventsFired(), new Set(MY_SOBJECTS), uow.getRegisteredTypes()); 351 | } 352 | 353 | /** 354 | * Create uow with work that fails 355 | * 356 | * Testing: 357 | * 358 | * - Correct events are fired when commitWork fails during DoWork processing 359 | * 360 | */ 361 | @isTest 362 | private static void testDerivedUnitOfWork_CommitDoWorkFail() 363 | { 364 | // Insert Opportunities with UnitOfWork 365 | MockDML mockDML = new MockDML(); 366 | DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS, mockDML); 367 | Opportunity opp = new Opportunity(); 368 | opp.Name = 'UoW Test Name 1'; 369 | opp.StageName = 'Open'; 370 | opp.CloseDate = System.today(); 371 | uow.registerNew(new List{opp}); 372 | 373 | // register work that will fail during processing 374 | FailDoingWork fdw = new FailDoingWork(); 375 | uow.registerWork(fdw); 376 | 377 | Boolean didFail = false; 378 | FailDoingWorkException caughtEx = null; 379 | 380 | try { 381 | uow.commitWork(); 382 | } 383 | catch (FailDoingWorkException fdwe) { 384 | didFail = true; 385 | caughtEx = fdwe; 386 | } 387 | 388 | // Assert Results 389 | System.assertEquals(didFail, true, 'didFail'); 390 | System.assert(caughtEx.getMessage().contains('Work failed.'), String.format('Exception message was ', new List { caughtEx.getMessage() })); 391 | 392 | assertEvents(new List { 393 | 'onCommitWorkStarting' 394 | , 'onPublishBeforeEventsStarting' 395 | , 'onPublishBeforeEventsFinished' 396 | , 'onDMLStarting' 397 | , 'onDMLFinished' 398 | , 'onDoWorkStarting' 399 | , 'onPublishAfterFailureEventsStarting' 400 | , 'onPublishAfterFailureEventsFinished' 401 | , 'onCommitWorkFinished - false' 402 | } 403 | , uow.getCommitWorkEventsFired(), new Set(MY_SOBJECTS), uow.getRegisteredTypes()); 404 | } 405 | 406 | /** 407 | * Try registering two instances of the same record as dirty. Second register should overwrite first. 408 | * 409 | * Testing: 410 | * 411 | * - Exception is thrown stopping second registration 412 | */ 413 | @IsTest 414 | private static void testRegisterDirty_ExpectReplacement() 415 | { 416 | final Opportunity insertedOpp = new Opportunity( 417 | Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), 418 | Name = 'Original', 419 | StageName = 'Open', 420 | CloseDate = System.today()); 421 | 422 | Opportunity opp = new Opportunity(Id = insertedOpp.Id, Name = 'Never'); 423 | Opportunity opp2 = new Opportunity(Id = insertedOpp.Id, Name = 'Expected'); 424 | 425 | MockDML mockDML = new MockDML(); 426 | fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 427 | uow.registerDirty(opp); 428 | uow.registerDirty(opp2); 429 | uow.commitWork(); 430 | 431 | System.assertEquals(1, mockDML.recordsForUpdate.size()); 432 | System.assertEquals('Expected', mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Name)); 433 | } 434 | 435 | /** 436 | * Try registering a single field as dirty. 437 | * 438 | * Testing: 439 | * 440 | * - field is updated 441 | */ 442 | @IsTest 443 | private static void testRegisterDirty_field() { 444 | Opportunity opp = new Opportunity( 445 | Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), 446 | Name = 'test name', 447 | StageName = 'Open', 448 | CloseDate = System.today()); 449 | 450 | Opportunity nameUpdate = new Opportunity(Id = opp.Id, Name = 'UpdateName'); 451 | Opportunity amountUpdate = new Opportunity(Id = opp.Id, Amount = 250); 452 | MockDML mockDML = new MockDML(); 453 | fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 454 | uow.registerDirty(nameUpdate); 455 | uow.registerDirty(amountUpdate, new List { Opportunity.Amount } ); 456 | uow.commitWork(); 457 | 458 | System.assertEquals(1, mockDML.recordsForUpdate.size()); 459 | System.assertEquals(nameUpdate.Name, mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Name)); 460 | System.assertEquals(amountUpdate.Amount, mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Amount)); 461 | } 462 | 463 | /** 464 | * Try registering a single field as dirty on multiple records. 465 | * 466 | */ 467 | @IsTest 468 | private static void testRegisterDirtyRecordsWithDirtyFields() 469 | { 470 | // GIVEN a list of existing records 471 | Opportunity opportunityA = new Opportunity( 472 | Id = fflib_IDGenerator.generate(Opportunity.SObjectType), 473 | Name = 'test name A', 474 | StageName = 'Open', 475 | CloseDate = System.today()); 476 | Opportunity opportunityB = new Opportunity( 477 | Id = fflib_IDGenerator.generate(Opportunity.SObjectType), 478 | Name = 'test name B', 479 | StageName = 'Open', 480 | CloseDate = System.today()); 481 | 482 | MockDML mockDML = new MockDML(); 483 | fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 484 | uow.registerDirty(new List{ opportunityA, opportunityB }); 485 | 486 | // WHEN we register the records again with different fields updated 487 | List recordsWithStageUpdate = new List 488 | { 489 | new Opportunity(Id = opportunityA.Id, StageName = 'Closed'), 490 | new Opportunity(Id = opportunityB.Id, StageName = 'Closed') 491 | }; 492 | List recordsWithAmountUpdate = new List 493 | { 494 | new Opportunity(Id = opportunityA.Id, Amount = 250), 495 | new Opportunity(Id = opportunityB.Id, Amount = 250) 496 | }; 497 | uow.registerDirty(recordsWithStageUpdate, new List { Opportunity.StageName }); 498 | uow.registerDirty(recordsWithAmountUpdate, new List { Opportunity.Amount }); 499 | uow.commitWork(); 500 | 501 | // THEN the records should be registered with both changed values for Amount and StageName 502 | 503 | // Commented out to remove dependancy on fflib_MatcherDefinitions 504 | // System.assert( 505 | // new fflib_MatcherDefinitions.SObjectsWith( 506 | // new List>{ 507 | // new Map 508 | // { 509 | // Opportunity.Id => opportunityA.Id, 510 | // Opportunity.Amount => 250, 511 | // Opportunity.StageName => 'Closed' 512 | // }, 513 | // new Map 514 | // { 515 | // Opportunity.Id => opportunityB.Id, 516 | // Opportunity.Amount => 250, 517 | // Opportunity.StageName => 'Closed' 518 | // } 519 | // } 520 | // ) 521 | // .matches(mockDML.recordsForUpdate), 522 | // 'Records not registered with the correct values' 523 | // ); 524 | } 525 | 526 | /** 527 | * Try registering a single field as dirty on multiple records. 528 | * 529 | */ 530 | @IsTest 531 | private static void testRegisterDirtyRecordsWithDirtyFields_failing() 532 | { 533 | // GIVEN a list of existing records 534 | Opportunity opportunityA = new Opportunity( 535 | Id = fflib_IDGenerator.generate(Opportunity.SObjectType), 536 | Name = 'test name A', 537 | StageName = 'Open', 538 | CloseDate = System.today()); 539 | Opportunity opportunityB = new Opportunity( 540 | Id = fflib_IDGenerator.generate(Opportunity.SObjectType), 541 | Name = 'test name B', 542 | StageName = 'Open', 543 | CloseDate = System.today()); 544 | 545 | MockDML mockDML = new MockDML(); 546 | fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 547 | uow.registerDirty(new List{ opportunityA, opportunityB }); 548 | 549 | // WHEN we register the records again with different fields updated 550 | List recordsWithStageUpdate = new List 551 | { 552 | new Opportunity(Id = opportunityA.Id, StageName = 'Closed'), 553 | new Opportunity(Id = opportunityB.Id, StageName = 'Closed') 554 | }; 555 | List recordsWithAmountUpdate = new List 556 | { 557 | new Opportunity(Id = opportunityA.Id, Amount = 250), 558 | new Opportunity(Id = opportunityB.Id, Amount = 250) 559 | }; 560 | uow.registerDirty(recordsWithStageUpdate, new List { Opportunity.StageName }); 561 | uow.registerDirty(recordsWithAmountUpdate, new List { Opportunity.Amount }); 562 | uow.registerDirty( // Register again the original record, should overwrite the one with the dirty fields 563 | new Opportunity( 564 | Id = opportunityB.Id, 565 | Name = 'test name B', 566 | StageName = 'Open', 567 | CloseDate = System.today()) 568 | ); 569 | uow.commitWork(); 570 | 571 | // THEN only the first record should be registered with both changed values for Amount and StageName and the second should be the original 572 | 573 | // Commented out to remove dependancy on fflib_MatcherDefinitions 574 | // System.assert( 575 | // !new fflib_MatcherDefinitions.SObjectsWith( 576 | // new List>{ 577 | // new Map 578 | // { 579 | // Opportunity.Id => opportunityA.Id, 580 | // Opportunity.Amount => 250, 581 | // Opportunity.StageName => 'Closed' 582 | // }, 583 | // new Map 584 | // { 585 | // Opportunity.Id => opportunityB.Id, 586 | // Opportunity.Amount => 250, 587 | // Opportunity.StageName => 'Closed' 588 | // } 589 | // } 590 | // ) 591 | // .matches(mockDML.recordsForUpdate), 592 | // 'Not all records should not be registered with the dirty values' 593 | // ); 594 | // System.assert( 595 | // new fflib_MatcherDefinitions.SObjectsWith( 596 | // new List>{ 597 | // new Map 598 | // { 599 | // Opportunity.Id => opportunityA.Id, 600 | // Opportunity.Amount => 250, 601 | // Opportunity.StageName => 'Closed' 602 | // }, 603 | // new Map 604 | // { 605 | // Opportunity.Id => opportunityB.Id, 606 | // Opportunity.StageName => 'Open' 607 | // } 608 | // } 609 | // ) 610 | // .matches(mockDML.recordsForUpdate), 611 | // 'The second record should be registered with the original values' 612 | // ); 613 | } 614 | 615 | @IsTest 616 | private static void testRegisterUpsert() { 617 | Opportunity existingOpp = new Opportunity( 618 | Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), 619 | Name = 'Existing Opportunity', 620 | StageName = 'Closed', 621 | CloseDate = System.today()); 622 | 623 | Opportunity newOpportunity = new Opportunity(Name = 'New Opportunity', StageName = 'Closed', CloseDate = System.today()); 624 | 625 | Test.startTest(); 626 | MockDML mockDML = new MockDML(); 627 | fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); 628 | uow.registerUpsert(new List{existingOpp, newOpportunity}); 629 | uow.commitWork(); 630 | Test.stopTest(); 631 | 632 | System.assertEquals(1, mockDML.recordsForUpdate.size()); 633 | System.assertEquals(1, mockDML.recordsForInsert.size()); 634 | } 635 | 636 | /** 637 | * Assert that actual events exactly match expected events (size, order and name) 638 | * and types match expected types 639 | */ 640 | private static void assertEvents(List expectedEvents, List actualEvents, Set expectedTypes, Set actualTypes) 641 | { 642 | // assert that events match 643 | System.assertEquals(expectedEvents.size(), actualEvents.size(), 'events size'); 644 | for (Integer i = 0; i < expectedEvents.size(); i++) 645 | { 646 | System.assertEquals(expectedEvents[i], actualEvents[i], String.format('Event {0} was not fired in order expected.', new List { expectedEvents[i] })); 647 | } 648 | 649 | // assert that types match 650 | System.assertEquals(expectedTypes.size(), actualTypes.size(), 'types size'); 651 | for (Schema.SObjectType sObjectType :expectedTypes) 652 | { 653 | System.assertEquals(true, actualTypes.contains(sObjectType), String.format('Type {0} was not registered.', new List { sObjectType.getDescribe().getName() })); 654 | } 655 | } 656 | 657 | /** 658 | * DoWork implementation that throws exception during processing 659 | */ 660 | private class FailDoingWork implements fflib_SObjectUnitOfWork.IDoWork 661 | { 662 | public void doWork() 663 | { 664 | throw new FailDoingWorkException('Work failed.'); 665 | } 666 | } 667 | 668 | /** 669 | * Derived unit of work that tracks event notifications and handle registration of type 670 | */ 671 | private class DerivedUnitOfWork extends fflib_SObjectUnitOfWork 672 | { 673 | private List m_commitWorkEventsFired = new List(); 674 | private Set m_registeredTypes = new Set(); 675 | 676 | public List getCommitWorkEventsFired() 677 | { 678 | return m_commitWorkEventsFired.clone(); 679 | } 680 | 681 | public Set getRegisteredTypes() 682 | { 683 | return m_registeredTypes.clone(); 684 | } 685 | 686 | public DerivedUnitOfWork(List sObjectTypes) 687 | { 688 | super(sObjectTypes); 689 | } 690 | 691 | public DerivedUnitOfWork(List sObjectTypes, IDML dml) 692 | { 693 | super(sObjectTypes, dml); 694 | } 695 | 696 | private void addEvent(String event) 697 | { 698 | // events should only be fired one time 699 | // ensure that this event has not been fired already 700 | for (String eventName :m_commitWorkEventsFired) 701 | { 702 | if (event == eventName) 703 | { 704 | throw new DerivedUnitOfWorkException(String.format('Event {0} has already been fired.', new List { event })); 705 | } 706 | } 707 | m_commitWorkEventsFired.add(event); 708 | } 709 | 710 | public override void onRegisterType(Schema.SObjectType sObjectType) 711 | { 712 | if (m_registeredTypes.contains(sObjectType)) 713 | { 714 | throw new DerivedUnitOfWorkException(String.format('Type {0} has already been registered.', new List { sObjectType.getDescribe().getName() })); 715 | } 716 | m_registeredTypes.add(sObjectType); 717 | } 718 | 719 | public override void onCommitWorkStarting() 720 | { 721 | addEvent('onCommitWorkStarting'); 722 | } 723 | 724 | public override void onPublishBeforeEventsStarting() 725 | { 726 | addEvent('onPublishBeforeEventsStarting'); 727 | } 728 | 729 | public override void onPublishBeforeEventsFinished() 730 | { 731 | addEvent('onPublishBeforeEventsFinished'); 732 | } 733 | 734 | public override void onDMLStarting() 735 | { 736 | addEvent('onDMLStarting'); 737 | } 738 | 739 | public override void onDMLFinished() 740 | { 741 | addEvent('onDMLFinished'); 742 | } 743 | 744 | public override void onDoWorkStarting() 745 | { 746 | addEvent('onDoWorkStarting'); 747 | } 748 | 749 | public override void onDoWorkFinished() 750 | { 751 | addEvent('onDoWorkFinished'); 752 | } 753 | 754 | public override void onCommitWorkFinishing() 755 | { 756 | addEvent('onCommitWorkFinishing'); 757 | } 758 | 759 | public override void onPublishAfterSuccessEventsStarting() 760 | { 761 | addEvent('onPublishAfterSuccessEventsStarting'); 762 | } 763 | 764 | public override void onPublishAfterSuccessEventsFinished() 765 | { 766 | addEvent('onPublishAfterSuccessEventsFinished'); 767 | } 768 | 769 | public override void onPublishAfterFailureEventsStarting() 770 | { 771 | addEvent('onPublishAfterFailureEventsStarting'); 772 | } 773 | 774 | public override void onPublishAfterFailureEventsFinished() 775 | { 776 | addEvent('onPublishAfterFailureEventsFinished'); 777 | } 778 | 779 | public override void onCommitWorkFinished(Boolean wasSuccessful) 780 | { 781 | addEvent('onCommitWorkFinished - ' + wasSuccessful); 782 | } 783 | } 784 | 785 | /** 786 | * Mock implementation of fflib_SObjectUnitOfWork.SendEmailWork 787 | **/ 788 | private class Mock_SendEmailWork implements fflib_SObjectUnitOfWork.IEmailWork 789 | { 790 | public Mock_SendEmailWork() 791 | { 792 | } 793 | 794 | public void registerEmail(Messaging.Email email) 795 | { 796 | } 797 | 798 | public void doWork() 799 | { 800 | doWorkWasCalled = true; 801 | // The code in the fflib_SObjectUnitOfWork class 802 | // causes unit test failures in Orgs that do not 803 | // have email enabled. 804 | } 805 | 806 | private Boolean doWorkWasCalled = false; 807 | } 808 | 809 | private class MockDML implements fflib_SObjectUnitOfWork.IDML 810 | { 811 | public List recordsForInsert = new List(); 812 | public List recordsForUpdate = new List(); 813 | public List recordsForDelete = new List(); 814 | public List recordsForRecycleBin = new List(); 815 | public List recordsForEventPublish = new List(); 816 | 817 | public void dmlInsert(List objList) 818 | { 819 | this.recordsForInsert.addAll(objList); 820 | } 821 | 822 | public void dmlUpdate(List objList) 823 | { 824 | this.recordsForUpdate.addAll(objList); 825 | } 826 | 827 | public void dmlDelete(List objList) 828 | { 829 | this.recordsForDelete.addAll(objList); 830 | } 831 | 832 | public void eventPublish(List objList) 833 | { 834 | this.recordsForEventPublish.addAll(objList); 835 | } 836 | 837 | public void emptyRecycleBin(List objList) 838 | { 839 | this.recordsForRecycleBin.addAll(objList); 840 | } 841 | } 842 | 843 | public class DerivedUnitOfWorkException extends Exception {} 844 | public class FailDoingWorkException extends Exception {} 845 | } 846 | -------------------------------------------------------------------------------- /sfdx-source/fflib-apex-common-subset/test/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60.0 4 | Active 5 | 6 | --------------------------------------------------------------------------------