├── .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 [](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 | [](https://youtu.be/e03lvRfOHNs "")
8 |
9 |
10 |
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