├── .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 | } --------------------------------------------------------------------------------