├── .gitignore
├── src
├── classes
│ ├── SObjectDeepClone.cls-meta.xml
│ ├── SObjectDeepCloneTests.cls-meta.xml
│ ├── SObjectDeepCloneTests.cls
│ └── SObjectDeepClone.cls
└── package.xml
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .vim-force.com
--------------------------------------------------------------------------------
/src/classes/SObjectDeepClone.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 42.0
4 | Active
5 |
--------------------------------------------------------------------------------
/src/classes/SObjectDeepCloneTests.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 42.0
4 | Active
5 |
--------------------------------------------------------------------------------
/src/package.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | *
5 | ApexClass
6 |
7 | 42.0
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Charlie Jonas
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 | # SObjectDeepClone
2 |
3 | A Apex Utility class to Clone a Salesforce SObject & and it's children.
4 |
5 | ## Install
6 |
7 | 1. `git clone`
8 | 2. `cd` into folder
9 | 3. `sfdx force:mdapi:deploy -d ./src -w 10000 -u [username]`
10 |
11 | ## Usage
12 |
13 | 1: Initialize `SObjectDeepClone` with:
14 |
15 | - `Id` of the SObject you want to clone. For more control you can pass the SObject itself
16 |
17 | - `Set` of any child relationships you want to clone
18 |
19 | 2. (Optional) make modifications to `.clone`
20 |
21 | 3. Call `save()`. Returns Id of cloned record
22 |
23 | **Example:**
24 |
25 | ```java
26 | Id leadIdToClone = '00Q3A00001Q0wu7';
27 | SObjectDeepClone cloner = new SObjectDeepClone(
28 | leadIdToClone,
29 | new Set{
30 | 'Tasks',
31 | 'Events'
32 | }
33 | );
34 | Lead beforeClone = (Lead) cloner.clone;
35 | beforeClone.LastName = beforeClone.LastName + ' Copy';
36 | Id clonedLeadId = cloner.save();
37 |
38 | System.debug(clonedLeadId);
39 | ```
40 |
41 | By default, all `createable` fields on the parent and target child relationships are cloned. If you need more control over what is cloned, you can instead pass in the actual `SObject` instance to clone (you're responsible for ensuring all data is present).
42 |
43 | ## Considerations
44 |
45 | - This utility is [not currently optimized for cloning multiple objects](https://github.com/ChuckJonas/SObjectDeepClone/issues/1) (My use-case was to replace the Standard Layout `Clone` button)
46 | - [Currently limited to 5 relationships](https://github.com/ChuckJonas/SObjectDeepClone/issues/2) (due to SOQL query limit)
47 | - Because we must run DML to properly test, you might need update [`SObjectDeepCloneTest`](https://github.com/ChuckJonas/SObjectDeepClone/blob/master/src/classes/SObjectDeepCloneTests.cls#L45) with your own custom object generators to get tests to pass.
48 |
--------------------------------------------------------------------------------
/src/classes/SObjectDeepCloneTests.cls:
--------------------------------------------------------------------------------
1 | @isTest
2 | private class SObjectDeepCloneTests {
3 |
4 | @isTest static void accountWithContacts() {
5 | Account acc = createAccount();
6 | insert acc;
7 |
8 | Map contactsByName = new Map();
9 | for(Integer i = 0; i < 5; i++){
10 | Contact c = createContact(acc.Id, 'Contact', String.valueOf(i));
11 | contactsByName.put(c.FirstName + c.LastName, c);
12 | }
13 |
14 | insert contactsByName.values();
15 |
16 | //pass opportunities just to test query gen on multiple children
17 | SObjectDeepClone cloner = new SObjectDeepClone(acc.Id, new Set{'contacts', 'opportunities'});
18 | String cloneName = acc.name + ' Copy';
19 | Account toBeCloned = (Account) cloner.clone;
20 | toBeCloned.Name = cloneName; //change name
21 | Id clonedId = cloner.save();
22 | System.assertNotEquals(null, clonedId);
23 | System.assertNotEquals(acc.Id, clonedId);
24 |
25 | Account queriedClonedAccount = [SELECT Name, (SELECT FirstName, LastName FROM Contacts) FROM Account WHERE Id = :clonedId];
26 |
27 | System.assertEquals(cloneName, queriedClonedAccount.Name);
28 | System.assertEquals(contactsByName.size(), queriedClonedAccount.Contacts.size());
29 | for(contact c : queriedClonedAccount.Contacts){
30 | Contact orgContact = contactsByName.get(c.FirstName + c.LastName);
31 | System.assertNotEquals(null, orgContact);
32 | }
33 | }
34 |
35 | //== OBJECT CREATORS... Might need to adjust to pass ORG validations
36 | private static Account createAccount(){
37 | return new Account(
38 | name = 'abc'
39 | );
40 | }
41 |
42 | private static Contact createContact(Id accId, String firstName, String lastName){
43 | return new Contact(
44 | AccountId = accId,
45 | FirstName = firstName,
46 | LastName = lastName
47 | );
48 | }
49 |
50 | }
--------------------------------------------------------------------------------
/src/classes/SObjectDeepClone.cls:
--------------------------------------------------------------------------------
1 | // Author Charlie Jonas (charlie@callaway.cloud)
2 | // Class to clone an SObject and it's children.
3 | // - Not currently optimized for bulk use!
4 | // See https://github.com/ChuckJonas/SObjectDeepClone for readme
5 | public with sharing class SObjectDeepClone {
6 | private Map relationshipMap;
7 | private SObjectType type;
8 |
9 | //The SObject that will be cloned. Accessible so modications can be made prior to saving
10 | public SObject clone;
11 |
12 | /**
13 | * @description Constructor to query object
14 | * @param toCloneId: Id to clone. All creatable fields will be pulled
15 | * @param relationshipsToClone: Child Relationship names
16 | */
17 | public SObjectDeepClone(Id toCloneId, Set relationshipsToClone) {
18 | type = toCloneId.getSObjectType();
19 | mapStringsToRelations(relationshipsToClone);
20 | retrieveSObject(toCloneId);
21 | }
22 |
23 | /**
24 | * @description Constructor without object query. Allows more control of cloning
25 | * @param toClone: SObject to clone. Must include all relevant information
26 | * @param relationshipsToClone: Child Relationship names
27 | */
28 | public SObjectDeepClone(SObject toClone, Set relationshipsToClone) {
29 | type = toClone.getSObjectType();
30 | mapStringsToRelations(relationshipsToClone);
31 | this.clone = toClone;
32 | }
33 |
34 | /**
35 | * @description Saves the Cloned SObject
36 | * @return The Id of the cloned SObject
37 | */
38 | public Id save(){
39 | // setup the save point for rollback
40 | Savepoint sp = Database.setSavepoint();
41 |
42 | try {
43 | insert clone;
44 |
45 | for(String relationshipName : this.relationshipMap.keySet()){
46 | SObject[] clonedChildren = new SObject[]{};
47 | ChildRelationshipProps rel = this.relationshipMap.get(relationshipName);
48 | for(Sobject child : clone.getSObjects(relationshipName)){
49 | SObject childClone = child.clone(false);
50 | childClone.put(rel.field, clone.Id);
51 | clonedChildren.add(childClone);
52 | }
53 | insert clonedChildren;
54 | }
55 |
56 | }catch(Exception e){
57 | Database.rollback(sp);
58 | throw e;
59 | }
60 |
61 | return clone.Id;
62 | }
63 |
64 |
65 | private void mapStringsToRelations(Set relationshipStrings){
66 | this.relationshipMap = new Map();
67 | Map childRelationMap = new Map();
68 | for(ChildRelationship rel : type.getDescribe().getChildRelationships()){
69 | String relName = rel.getRelationshipName();
70 | if(relName != null){ //not sure why this would happen but it does
71 | childRelationMap.put(rel.getRelationshipName().toUpperCase(), rel);
72 | }
73 | }
74 |
75 | for(String relStr : relationshipStrings){
76 | relStr = relStr.toUpperCase();
77 | if(childRelationMap.containsKey(relStr)){
78 | ChildRelationship rel = childRelationMap.get(relStr);
79 | relationshipMap.put(rel.getRelationshipName().toUpperCase(), new ChildRelationshipProps(rel));
80 | }else{
81 | throw new DeepCloneException(
82 | 'Child Relationship \'' + relStr + '\' does not exsist on ' + type.getDescribe().getName()
83 | );
84 | }
85 | }
86 | }
87 |
88 | private void retrieveSObject(Id toCloneId){
89 | // Get a map of field name and field token
90 | String[] selectFields = getCreatableFields(type);
91 |
92 | //subqueries
93 | for(String relationName : this.relationshipMap.keySet()){
94 | ChildRelationshipProps rel = this.relationshipMap.get(relationName);
95 | String[] relationFields = getCreatableFields(rel.sObjectType);
96 | if(relationFields.size() > 0){
97 | selectFields.add('(' + buildQuery(relationFields, relationName, null) + ')');
98 | }
99 | }
100 |
101 | String qry = buildQuery(
102 | selectFields,
103 | type.getDescribe().getName(),
104 | 'ID = \''+String.escapeSingleQuotes(toCloneId)+'\''
105 | );
106 |
107 | this.clone = ((SObject) Database.query(qry)).clone(false);
108 | }
109 |
110 | private string buildQuery(String[] fields, String fromObj, string whereClause){
111 | String qry = 'SELECT ' + String.join(fields, ',') + ' FROM ' + fromObj;
112 | if(!String.isEmpty(whereClause)){
113 | qry += ' WHERE ' + whereClause;
114 | }
115 | return qry;
116 | }
117 |
118 | private String[] getCreatableFields(SObjectType objType){
119 | DescribeSObjectResult describe = objType.getDescribe();
120 |
121 | // Get a map of field name and field token
122 | Map fMap = describe.Fields.getMap();
123 | String[] selectFields = new String[]{};
124 |
125 | if (fMap != null){
126 | for (Schema.SObjectField ft : fMap.values()){ // loop through all field tokens (ft)
127 | Schema.DescribeFieldResult fd = ft.getDescribe(); // describe each field (fd)
128 | if (fd.isCreateable()){ // field is creatable
129 | selectFields.add(fd.getName());
130 | }
131 | }
132 | }
133 | return selectFields;
134 | }
135 |
136 | //seralizable subset of ChildRelationship properties that we need
137 | private class ChildRelationshipProps{
138 | public SObjectType sObjectType;
139 | public SObjectField field;
140 | public ChildRelationshipProps(ChildRelationship rel){
141 | this.sObjectType = rel.getChildSObject();
142 | this.field = rel.getField();
143 | }
144 | }
145 |
146 | public class DeepCloneException extends Exception {}
147 | }
--------------------------------------------------------------------------------