├── scripts
├── config.sh
└── createScratchOrg.sh
├── config
└── project-scratch-def.json
├── sfdx-source
├── apex-domainbuilder-sample
│ └── classes
│ │ ├── Random.cls-meta.xml
│ │ ├── domains
│ │ ├── User_t.cls-meta.xml
│ │ ├── Account_t.cls-meta.xml
│ │ ├── Contact_t.cls-meta.xml
│ │ ├── Opportunity_t.cls-meta.xml
│ │ ├── OpportunityContactRole_t.cls-meta.xml
│ │ ├── OpportunityContactRole_t.cls
│ │ ├── Account_t.cls
│ │ ├── Contact_t.cls
│ │ ├── User_t.cls
│ │ └── Opportunity_t.cls
│ │ ├── test
│ │ ├── DomainBuilder_Test.cls-meta.xml
│ │ └── DomainBuilder_Test.cls
│ │ └── Random.cls
├── apex-domainbuilder
│ └── main
│ │ └── classes
│ │ ├── DomainBuilder.cls-meta.xml
│ │ └── DomainBuilder.cls
└── fflib-apex-common-subset
│ ├── main
│ └── classes
│ │ ├── fflib_IDGenerator.cls-meta.xml
│ │ ├── fflib_ISObjectUnitOfWork.cls-meta.xml
│ │ ├── fflib_SObjectUnitOfWork.cls-meta.xml
│ │ ├── fflib_IDGenerator.cls
│ │ ├── fflib_ISObjectUnitOfWork.cls
│ │ └── fflib_SObjectUnitOfWork.cls
│ └── test
│ └── classes
│ ├── fflib_SObjectUnitOfWorkTest.cls-meta.xml
│ └── fflib_SObjectUnitOfWorkTest.cls
├── bin
└── resetScratchOrg.sh
├── .forceignore
├── sfdx-project.json
├── .gitignore
├── LICENSE
├── README.md
└── ruleset.xml
/scripts/config.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | DEV_HUB_ALIAS="MyDevHub"
3 | PACKAGENAME="apex-domainbuilder"
4 | SCRATCH_ORG_ALIAS="apex-domainbuilder_DEV"
--------------------------------------------------------------------------------
/config/project-scratch-def.json:
--------------------------------------------------------------------------------
1 | {
2 | "orgName": "apex-domainbuilder_DEV",
3 | "country": "US",
4 | "edition": "Developer",
5 | "language": "en_US"
6 | }
--------------------------------------------------------------------------------
/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/User_t.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/sfdx-source/apex-domainbuilder/main/classes/DomainBuilder.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/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-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/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/test/DomainBuilder_Test.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/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-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/sfdx-source/fflib-apex-common-subset/main/classes/fflib_SObjectUnitOfWork.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/sfdx-source/apex-domainbuilder-sample/classes/domains/OpportunityContactRole_t.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/sfdx-source/fflib-apex-common-subset/test/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 60.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/.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__/**
--------------------------------------------------------------------------------
/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-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-sample",
13 | "default": false
14 | }
15 | ],
16 | "namespace": "",
17 | "sfdcLoginUrl": "https://login.salesforce.com",
18 | "sourceApiVersion": "60.0"
19 | }
20 |
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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-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 | }
--------------------------------------------------------------------------------
/.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/
--------------------------------------------------------------------------------
/sfdx-source/apex-domainbuilder-sample/classes/Random.cls:
--------------------------------------------------------------------------------
1 | @IsTest
2 | public class Random {
3 |
4 | public String string() {
5 | return string(8);
6 | }
7 |
8 |
9 | public String string(Integer length) {
10 | String result = '';
11 |
12 | for(Integer i=0; i values) {
47 | return values.get(integer(values.size()-1));
48 | }
49 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/Kc0q8pfD3Ic?t=279 "")
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 |
--------------------------------------------------------------------------------
/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 Map parentByRelationship = new Map();
7 | public SObject record;
8 | public SObjectType type;
9 | public Id id { private set; get {return record.Id;} }
10 |
11 |
12 | // CONSTRUCTORS
13 |
14 | public DomainBuilder(SObjectType type) {
15 | this.type = type;
16 | this.record = type.newSObject(null, true);
17 |
18 | graph.node(type);
19 | objects.add(this);
20 | }
21 |
22 |
23 | // PUBLIC
24 |
25 | public static void save() {
26 | save(new fflib_SObjectUnitOfWork.SimpleDML());
27 | }
28 |
29 | public static void save(fflib_SObjectUnitOfWork.IDML dml) {
30 | fflib_SObjectUnitOfWork uow = unitOfWork(dml);
31 |
32 | for(DomainBuilder obj: objects) {
33 | if(obj.record.Id == null) {
34 | uow.registerNew(obj.record);
35 | }
36 |
37 | for(SObjectField rel: obj.parentByRelationship.keySet()) {
38 | DomainBuilder parent = obj.parentByRelationship.get(rel);
39 | uow.registerRelationship(obj.record, rel, parent.record);
40 | }
41 | }
42 |
43 | uow.commitWork();
44 |
45 | objects.clear();
46 | }
47 |
48 | public SObject persist() {
49 | return persist(new fflib_SObjectUnitOfWork.SimpleDML());
50 | }
51 |
52 | public SObject persist(fflib_SObjectUnitOfWork.IDML dml) {
53 | save(dml);
54 |
55 | return record;
56 | }
57 |
58 | // Note: virtual so it can be extended by implementations in order to cast the return type to its specifyc type
59 | public virtual DomainBuilder recordType(String developerName) {
60 | Id rtId = type.getDescribe().getRecordTypeInfosByDeveloperName().get(developerName).getRecordTypeId();
61 | return set('RecordTypeId', rtId);
62 | }
63 |
64 |
65 | // PROTECTED
66 |
67 | protected DomainBuilder setParent(SObjectField relationship, DomainBuilder parent) {
68 | // Note: The parent registered last always wins!
69 | DomainBuilder oldParent = parentByRelationship.get(relationship);
70 |
71 | // Note: Sometime we manually unregister parent that are set by default constructor
72 | if(parent != null) {
73 | parentByRelationship.put(relationship, parent);
74 | }
75 |
76 | if(oldParent != null && oldParent != parent) {
77 | oldParent.unregisterIncludingParents();
78 | }
79 |
80 | if(parent != null && !objects.contains(parent)) {
81 | parent.registerIncludingParents();
82 | }
83 |
84 | graph.edge(this.type, parent.type);
85 |
86 | // Note: Return parent instead of this as we call this always from the parent
87 | return parent;
88 | }
89 |
90 |
91 | protected DomainBuilder set(String fieldName, Object value) {
92 | record.put(fieldName, value);
93 | return this;
94 | }
95 |
96 |
97 | protected DomainBuilder set(SObjectField field, Object value) {
98 | record.put(field, value);
99 | return this;
100 | }
101 |
102 |
103 | protected void unregisterIncludingParents() {
104 | objects.remove(this);
105 |
106 | for(DomainBuilder parent : parentByRelationship.values()) {
107 | parent.unregisterIncludingParents();
108 | }
109 | }
110 |
111 |
112 | // PRIVATE
113 |
114 | private void registerIncludingParents() {
115 | if(record.Id == null) {
116 | objects.add(this);
117 |
118 | for(DomainBuilder parent: parentByRelationship.values()) {
119 | parent.registerIncludingParents();
120 | }
121 | }
122 | }
123 |
124 |
125 | private static fflib_SObjectUnitOfWork unitOfWork(fflib_SObjectUnitOfWork.IDML dml) {
126 | List insertOrder = new List();
127 | List sorted = graph.sortTopologically();
128 |
129 | for(Integer i = sorted.size() - 1; i >= 0; i--){
130 | insertOrder.add(sorted[i]);
131 | }
132 | return new fflib_SObjectUnitOfWork(insertOrder, dml);
133 | }
134 |
135 |
136 | // INNER
137 |
138 | // Note: Code adapted from https://codereview.stackexchange.com/questions/177442
139 |
140 | @TestVisible
141 | class DirectedGraph {
142 |
143 | Map childCount = new Map();
144 | Set pureChilds = new Set();
145 | Map> parents = new Map>();
146 |
147 |
148 | @TestVisible
149 | DirectedGraph node(SObjectType type) {
150 | if(!parents.containsKey(type)) {
151 | parents.put(type, new Set());
152 | }
153 |
154 | return this;
155 | }
156 |
157 | @TestVisible
158 | DirectedGraph edge(SObjectType child, SObjectType parent) {
159 | parents.get(child).add(parent);
160 | return this;
161 | }
162 |
163 |
164 | @TestVisible
165 | List sortTopologically() {
166 | List result = new List();
167 |
168 | countDependencies();
169 |
170 | while(!pureChilds.isEmpty()) {
171 | SObjectType cur = (SObjectType) pureChilds.iterator().next();
172 | pureChilds.remove(cur);
173 |
174 | result.add(cur);
175 |
176 | for(SObjectType type : parents.get(cur)) {
177 | if(childCount.containsKey(type)) {
178 | Integer newCnt = childCount.get(type) - 1;
179 | childCount.put(type, newCnt);
180 |
181 | if(newCnt == 0) {
182 | pureChilds.add(type);
183 | }
184 | }
185 | }
186 | }
187 |
188 | // Note: Handle cycles
189 | if(result.size() < parents.size()) {
190 | Set missing = parents.keySet();
191 | missing.removeAll( new Set(result) );
192 | result.addAll(missing);
193 | }
194 |
195 | return result;
196 | }
197 |
198 |
199 | void countDependencies() {
200 | for(SObjectType type : parents.keySet()) {
201 | if(!childCount.containsKey(type)) {
202 | pureChilds.add(type);
203 | }
204 |
205 | for(SObjectType parent : parents.get(type)) {
206 | pureChilds.remove(parent);
207 |
208 | // Note: Ignore cycles
209 | if(childCount.containsKey(type)) {
210 | childCount.remove(parent);
211 | pureChilds.remove(type);
212 | }
213 | else if(!childCount.containsKey(parent)) {
214 | childCount.put(parent, 1);
215 | }
216 | else {
217 | childCount.put(parent, childCount.get(parent) + 1);
218 | }
219 | }
220 | }
221 | }
222 | }
223 | }
--------------------------------------------------------------------------------
/sfdx-source/apex-domainbuilder-sample/classes/test/DomainBuilder_Test.cls:
--------------------------------------------------------------------------------
1 | @IsTest
2 | public class DomainBuilder_Test {
3 | @IsTest
4 | private static void staticHappyPath() {
5 |
6 | Contact_t joe = new Contact_t().first('Joe').last('Harris');
7 |
8 | new Account_t()
9 | .name('Acme Corp')
10 | .add( new Contact_t() )
11 | .add( new Opportunity_t()
12 | .amount(1000)
13 | .closes(2019, 12)
14 | .contact(joe));
15 |
16 | DomainBuilder.save();
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 | @IsTest
25 | private static void happyPath() {
26 |
27 | Contact_t joe = new Contact_t().first('Joe').last('Harris');
28 |
29 | new Account_t()
30 | .name('Acme Corp')
31 | .add( new Contact_t() )
32 | .add( new Opportunity_t()
33 | .amount(1000)
34 | .closes(2019, 12)
35 | .contact(joe))
36 | .persist();
37 |
38 | System.assertEquals(2, [SELECT Count() FROM Account]);
39 | System.assertEquals(1, [SELECT Count() FROM Opportunity]);
40 | System.assertEquals(2, [SELECT Count() FROM Contact]);
41 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole]);
42 | }
43 |
44 |
45 | @IsTest
46 | private static void autoPopulatesRequiredButIrrelevantFields() {
47 |
48 | new Account_t()
49 | .add(new Contact_t())
50 | .add(new Opportunity_t())
51 |
52 | .persist();
53 |
54 | System.assertNotEquals(null, [SELECT Name FROM Account]);
55 | System.assertNotEquals(null, [SELECT LastName FROM Contact]);
56 | System.assertNotEquals(null, [SELECT StageName FROM Opportunity]);
57 | }
58 |
59 |
60 | @IsTest
61 | private static void autoGeneratesRequiredButIrrelevantRelations() {
62 |
63 | new Opportunity_t()
64 | .amount(1000)
65 |
66 | .persist();
67 |
68 | System.assertEquals(1, [SELECT Count() FROM Account]);
69 | System.assertEquals(1, [SELECT Count() FROM Opportunity]);
70 | System.assertNotEquals(null, [SELECT AccountId FROM Opportunity]);
71 | }
72 |
73 |
74 | @IsTest
75 | private static void allowNicerFieldSetters() {
76 |
77 | new Opportunity_t()
78 | .closes(2019, 7)
79 | .persist();
80 |
81 | System.assertEquals(Date.newInstance(2019, 7, 1), [SELECT CloseDate FROM Opportunity].CloseDate);
82 | }
83 |
84 |
85 | @IsTest
86 | private static void addChildrenOfArbitraryDepth() {
87 |
88 | new Account_t()
89 | .add(new Contact_t())
90 | .add(new Opportunity_t()
91 | .add(new Contact_t()))
92 |
93 | .persist();
94 |
95 | System.assertEquals(2, [SELECT Count() FROM Account]);
96 | System.assertEquals(1, [SELECT Count() FROM Opportunity]);
97 | System.assertEquals(2, [SELECT Count() FROM Contact]);
98 | }
99 |
100 |
101 | @IsTest
102 | private static void allowsSetupObjects() {
103 |
104 | try {
105 | System.runAs(User_t.standard()) {
106 | new Account_t().persist();
107 | }
108 | }
109 | catch(Exception ex) {
110 | System.assert(false);
111 | }
112 |
113 | System.assertEquals(1, [SELECT Count() FROM Account]);
114 | }
115 |
116 |
117 | @IsTest
118 | private static void allowsSelfReferences() {
119 |
120 | // Setup & Exercise
121 | Contact_t boss = new Contact_t();
122 | new Contact_t()
123 | .reports(boss)
124 | .persist();
125 |
126 | // Verify
127 | System.assertEquals(2, [SELECT Count() FROM Contact]);
128 | System.assertEquals(1, [SELECT Count() FROM Contact WHERE ReportsToId != NULL]);
129 | }
130 |
131 |
132 | @IsTest
133 | private static void allowsJunctionObjects() {
134 |
135 | Opportunity_t o = new Opportunity_t();
136 | Contact_t c = new Contact_t();
137 |
138 | new OpportunityContactRole_t(o, c).persist();
139 |
140 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole
141 | WHERE ContactId = :c.Id AND OpportunityId = :o.Id]);
142 | }
143 |
144 |
145 | @IsTest
146 | private static void hideJunctionComplexity() {
147 |
148 | new Opportunity_t()
149 | .contact(new Contact_t())
150 | .persist();
151 |
152 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole
153 | WHERE ContactId != null AND OpportunityId != null]);
154 | }
155 |
156 |
157 | @IsTest
158 | private static void accessRecordFromBuilder() {
159 |
160 | Account_t a = new Account_t().name('Salesforce.com');
161 | a.persist();
162 |
163 | System.assertEquals(Account.SObjectType, a.record.getSObjectType());
164 | }
165 |
166 |
167 | @IsTest
168 | private static void accessIdFromBuilder() {
169 | Account_t a = new Account_t();
170 | a.persist();
171 |
172 | System.assertEquals(a.Id, [SELECT Id FROM Account].Id);
173 | }
174 |
175 |
176 | @IsTest
177 | private static void persistReturnsSObject() {
178 | new Account_t().persist();
179 |
180 | System.assertEquals(1, [SELECT Count() FROM Account]);
181 | }
182 |
183 |
184 | @IsTest
185 | private static void insertOrder() {
186 |
187 | // Setup
188 | DomainBuilder.DirectedGraph graph = new DomainBuilder.DirectedGraph()
189 | .node(Account.SObjectType)
190 | .node(Contact.SObjectType)
191 | .node(Opportunity.SObjectType)
192 | .node(OpportunityContactRole.SObjectType)
193 |
194 | .edge(Contact.SObjectType, Account.SObjectType)
195 | .edge(Contact.SObjectType, Opportunity.SObjectType)
196 | .edge(Opportunity.SObjectType, Account.SObjectType)
197 | .edge(OpportunityContactRole.SObjectType, Contact.SObjectType)
198 | .edge(OpportunityContactRole.SObjectType, Opportunity.SObjectType);
199 |
200 | // Verify
201 | List expectedOrder = new List{
202 | OpportunityContactRole.SObjectType, Contact.SObjectType, Opportunity.SObjectType, Account.SObjectType };
203 | System.assertEquals(expectedOrder, graph.sortTopologically());
204 | }
205 |
206 |
207 | @IsTest
208 | private static void noUnneededRecords() {
209 |
210 | // Setup & Exercise
211 | Contact_t con = new Contact_t(); // 1x insert statement, 1x row
212 | Opportunity_t opp = new Opportunity_t(); // 1x insert statement, 1x row
213 |
214 | new Account_t() // 1x insert statement, 1x row
215 | .add(new Contact_t()) // 0x insert statement (both contacts are inserted together at the same time in the same DML), 1x row
216 | .add(con) // 0x insert statement, 0x row
217 | .add(opp // 0x insert statement, 0x row
218 | .add(con)) // 1x insert OpportunityContactRole, 1x row
219 | .persist(); // 1x setSavepoint statement, 0x row
220 |
221 | // Verify
222 | System.assertEquals(5, Limits.getDmlStatements());
223 | System.assertEquals(5, Limits.getDmlRows());
224 | System.assertEquals(1, [SELECT Count() FROM Account]);
225 | System.assertEquals(2, [SELECT Count() FROM Contact]);
226 | System.assertEquals(1, [SELECT Count() FROM Opportunity]);
227 | System.assertEquals(1, [SELECT Count() FROM OpportunityContactRole]);
228 | }
229 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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