|
| 1 | +public with sharing class fflib_SObjectHierarchy { |
| 2 | + |
| 3 | + private final SObjectType sobjType; |
| 4 | + private final SObjectField lookupField; |
| 5 | + private final Set<SObjectField> queryFields; |
| 6 | + |
| 7 | + /** |
| 8 | + * This constructor takes in all the necessary information to create a hierarchy. The following components are needed. |
| 9 | + * First, the type of record that comprises the hierarchy must be given. A hierarchy may only |
| 10 | + * contain one type of object within it. So for example, a hierarchy cannot contain Accounts and Cases, |
| 11 | + * only one or the other. |
| 12 | + * Second, the lookup field that connects the records must be given. |
| 13 | + * For example, in order to construct an Account hierarchy, one would give the Account.ParentId lookup field. |
| 14 | + * Third, but optional, any additional fields that you want to query during the construction of the hierarchy must be given. |
| 15 | + * |
| 16 | + * @param sobjType SObjectType The object type within the hierarchy |
| 17 | + * @param lookupField SObjectField The lookup field used to build the hierarchy. This field must be a lookup FROM the |
| 18 | + * given SObjectType TO the given SObjectType. |
| 19 | + * @param queryFields Set<SObjectField> The list of fields that will be added to the records when the hierarchy is traversed |
| 20 | + * @exception IllegalArgumentException will be thrown if any of the arguments are null |
| 21 | + * @exception IllegalArgumentException will be thrown if the lookup field is not a lookup |
| 22 | + * @exception IllegalArgumentException will be thrown if the lookup does not point FROM sobjType TO the sobjType |
| 23 | + **/ |
| 24 | + public fflib_SObjectHierarchy(SObjectType sobjType, SObjectField lookupField, Set<SObjectField> queryFields) { |
| 25 | + |
| 26 | + this.assertValidConstructorParams(sobjType, lookupField, queryFields); |
| 27 | + |
| 28 | + this.sobjType = sobjType; |
| 29 | + this.lookupField = lookupField; |
| 30 | + this.queryFields = queryFields; |
| 31 | + |
| 32 | + if (this.queryFields.contains(this.lookupField)) { |
| 33 | + this.queryFields.remove(this.lookupField); |
| 34 | + } |
| 35 | + } |
| 36 | + |
| 37 | + public fflib_SObjectHierarchy(SObjectType sobjType, SObjectField lookupField) { |
| 38 | + this(sobjType, lookupField, new Set<SObjectField>()); |
| 39 | + } |
| 40 | + |
| 41 | + private void assertValidConstructorParams(SObjectType sobjType, SObjectField lookupField, Set<SObjectField> queryFields) { |
| 42 | + if (sobjType == null) { |
| 43 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 44 | + iae.setMessage('sobjType cannot be null.'); |
| 45 | + throw iae; |
| 46 | + } |
| 47 | + if (lookupField == null) { |
| 48 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 49 | + iae.setMessage('lookupField cannot be null.'); |
| 50 | + throw iae; |
| 51 | + } |
| 52 | + if (queryFields == null) { |
| 53 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 54 | + iae.setMessage('queryFields cannot be null'); |
| 55 | + throw iae; |
| 56 | + } |
| 57 | + if (lookupField.getDescribe().getType() != DisplayType.REFERENCE) { |
| 58 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 59 | + iae.setMessage('Cannot construct a hierarchy with a non-lookup field [' + |
| 60 | + lookupField.getDescribe().getName() + ']'); |
| 61 | + throw iae; |
| 62 | + } |
| 63 | + if (!lookupField.getDescribe().getReferenceTo().contains(sobjType)) { |
| 64 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 65 | + iae.setMessage('Lookup field must point to same type as SObject type [was ' + |
| 66 | + lookupField.getDescribe().getReferenceTo() + ', must be ' + sobjType + ']'); |
| 67 | + throw iae; |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + /** |
| 72 | + * This function will start from each given Id, and make a list of all SObjects |
| 73 | + * until the top of the hierarchy is found. |
| 74 | + * The hierarchy is traversed by using the lookup field given in the constructor. |
| 75 | + * For each record, the set of query fields will be queried while going up the hierarchy. |
| 76 | + * Thus, every record in the hierarchy will have all fields queried that were given in queryFields. |
| 77 | + * |
| 78 | + * For example, say that you have an account, A. A has a parent, B. B has a parent, C. |
| 79 | + * This function will return the following if the Id for account A is given in the set of ids. |
| 80 | + * |
| 81 | + * A Map of Id to SObjects, where |
| 82 | + * A -> [A, B, C] |
| 83 | + * |
| 84 | + * Each record (A, B, and C) will have all query fields queried. If the set of query fields is empty, |
| 85 | + * it will only have the lookup field queried. |
| 86 | + * @param ids Set<Id> a non-null set of ids. These ids will each represent a key in the map, whose value is the |
| 87 | + * list of records until the top of the hierarchy |
| 88 | + * @exception IllegalArgumentException if the given set of ids is null |
| 89 | + * @exception IllegalArgumentException if the given set of ids contains a cycle somewhere in its hierarchy. |
| 90 | + * @return Map<Id, List<SObject>> a map of the given id to a list of SObjects representing its hierarchy |
| 91 | + */ |
| 92 | + public Map<Id, List<SObject>> getUntilTopOfHierarchy(Set<Id> ids) { |
| 93 | + /** |
| 94 | + * IMPLEMENTATION: |
| 95 | + * |
| 96 | + * The following implementation uses recursion to build a n-level deep hierarchy. |
| 97 | + * |
| 98 | + * For example, say you have a hierarchy of the form: |
| 99 | + * A -> B -> C -> D -> E -> F -> G -> H -> I |
| 100 | + * where each element is a record and they are connected by a lookup field. |
| 101 | + * |
| 102 | + * The reason that there must be a recursive call is for the following reason: |
| 103 | + * There is chance that you will not be able to parse all records in the hierarchy in one SQOL query |
| 104 | + * (since it maxes at six levels in one call). |
| 105 | + * |
| 106 | + * For this, the following recursive algorithm is employed. This algorithm does technically use SOQL in a for loop, |
| 107 | + * but it is minimal as possible. |
| 108 | + * |
| 109 | + * For every six levels in the hierarchy, a SOQL is done. So in the previous example, |
| 110 | + * A -> B -> C -> D -> E -> F -> G -> H -> I |
| 111 | + * there will be two SOQLS done. One to go from A -> F, and then one to go from G -> I. |
| 112 | + * |
| 113 | + * The recursion works in the following manner: |
| 114 | + * First, a multi-level SOQL is created. |
| 115 | + * Second, for each record in given in the set of ids, navigate up the hierarchy as far as possible until a null parent is found, |
| 116 | + * or there no parent but a parent id. |
| 117 | + * If it is the former, you have reached the top of the hierarchy, and the recursion will end. (The base case) |
| 118 | + * |
| 119 | + * If it is the latter, the parent id will be used in a recursive call to get the hierarchy from the parent id to its own top. |
| 120 | + * |
| 121 | + * So before the recursive call, you will have the following: |
| 122 | + * A -> [A, B, C, D, E, F] |
| 123 | + * |
| 124 | + * But F.ParentId still has a value, thus, you have not reached the top of the hierarchy. |
| 125 | + * |
| 126 | + * Do a recursive call with F.ParentId. You will then have the following: |
| 127 | + * G -> [G, H, I], where F.ParentId == G.Id |
| 128 | + * |
| 129 | + * The lookup field on F, is used to locate G, and then the lists are concatenated. Thus, the final output will be: |
| 130 | + * A -> [A, B, C, D, E, F, G, H, I] |
| 131 | + * |
| 132 | + * and the algorithm terminates once there are no more hierarchies to view. |
| 133 | + * This algorithm has no limit on how deep the hierarchy can go. The only limits is that of salesforce and the number of SOQLs to be performed. |
| 134 | + **/ |
| 135 | + |
| 136 | + if (ids == null) { |
| 137 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 138 | + iae.setMessage('Cannot parse hierarchy with a null set of ids'); |
| 139 | + throw iae; |
| 140 | + } |
| 141 | + |
| 142 | + String dynamicQuery = createDynamicMultiLevelSoql(5); |
| 143 | + List<SObject> records = Database.query(dynamicQuery); |
| 144 | + |
| 145 | + Map<Id, List<SObject>> hierarchies = new Map<Id, List<SObject>>(); |
| 146 | + |
| 147 | + Set<Id> nextStartOfHierarchies = new Set<Id>(); |
| 148 | + for (SObject record : records) { |
| 149 | + if (!hierarchies.containsKey(record.Id)) { |
| 150 | + hierarchies.put(record.Id, new List<SObject>()); |
| 151 | + } |
| 152 | + |
| 153 | + List<SObject> hierarchy = hierarchies.get(record.Id); |
| 154 | + hierarchy.addAll(buildQueriedHierarchy(record)); |
| 155 | + |
| 156 | + assertNoCycle(hierarchy); |
| 157 | + |
| 158 | + SObject lastElementInHierarchy = hierarchy.get(hierarchy.size() - 1); |
| 159 | + if (lastElementInHierarchy.get(this.lookupField) != null) { |
| 160 | + nextStartOfHierarchies.add((Id)lastElementInHierarchy.get(this.lookupField)); |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + if (!nextStartOfHierarchies.isEmpty()) { |
| 165 | + this.attachRecursiveHierarchies(hierarchies, nextStartOfHierarchies); |
| 166 | + } |
| 167 | + |
| 168 | + return hierarchies; |
| 169 | + } |
| 170 | + |
| 171 | + /** |
| 172 | + * Starting from a record, iterate as high as possible into the hierarchy. |
| 173 | + * The record in question was started from a multi-level query of the following form: |
| 174 | + * Parent.Parent.Parent.Parent.Parent.* |
| 175 | + * |
| 176 | + * Thus, the iteration will go parent by parent, until parent is null. |
| 177 | + * If parent ends up being null, but there is still a parent-id on the last record, |
| 178 | + * the algorithm will start recursively from the parent id. |
| 179 | + */ |
| 180 | + private List<SObject> buildQueriedHierarchy(SObject record) { |
| 181 | + List<SObject> hierarchy = new List<SObject>(); |
| 182 | + SObject iterator = record; |
| 183 | + while (true) { |
| 184 | + hierarchy.add(iterator); |
| 185 | + if (!doesParentExist(iterator)) { |
| 186 | + break; |
| 187 | + } |
| 188 | + iterator = iterator.getSObject(this.lookupField); |
| 189 | + } |
| 190 | + |
| 191 | + return hierarchy; |
| 192 | + } |
| 193 | + |
| 194 | + /** |
| 195 | + * Checks if the given hierarchy has a cycle within it. |
| 196 | + * It does this in the following manner. |
| 197 | + * |
| 198 | + * If there are any ids that are duplicates within the list of SObjects, |
| 199 | + * there must be one SObjects that has already been traversed. |
| 200 | + * |
| 201 | + * If this is the case, then there is a cycle. |
| 202 | + * |
| 203 | + * For example, if you have a list A -> B -> C -> D -> E -> A |
| 204 | + * A appearing twice implies a cycle, since continuing the hierarchy would result in |
| 205 | + * A -> B -> C -> D -> E -> A -> B -> C -> D -> E -> A. |
| 206 | + */ |
| 207 | + private void assertNoCycle(List<SObject> sobjects) { |
| 208 | + |
| 209 | + Set<Id> ids = new Set<Id>(); |
| 210 | + for (SObject sObj : sobjects) { |
| 211 | + ids.add(sObj.Id); |
| 212 | + } |
| 213 | + |
| 214 | + if (ids.size() != sobjects.size()) { |
| 215 | + IllegalArgumentException iae = new IllegalArgumentException(); |
| 216 | + iae.setMessage('The hierarchy contained a cycle ' + sobjects + '.'); |
| 217 | + throw iae; |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + /** |
| 222 | + * If the record is able to find a parent sobject attached to it, this function will return true. |
| 223 | + * If there is NO parent, but a LookupFieldId does exist (such as ParentId) this function will return false. |
| 224 | + * If there is NO parent and no LookupFieldId, this function will return false. |
| 225 | + */ |
| 226 | + private Boolean doesParentExist(SObject record) { |
| 227 | + try { |
| 228 | + return record.getSObject(this.lookupField) != null; |
| 229 | + } catch (System.SObjectException e) { |
| 230 | + return false; |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + /** |
| 235 | + * Whenever a query is done, the longest SOQL lookup field chain can be something of the following form: |
| 236 | + * Parent.Parent.Parent.Parent.Parent.ParentId |
| 237 | + * |
| 238 | + * If there are five parents, and still a ParentId on the last parent, that id |
| 239 | + * will be contained within nextStartOfHierarchies. |
| 240 | + * |
| 241 | + * The algorithm will be ran again, and the last record of the current hierarchy (found in hierarchies) |
| 242 | + * will link its ParentId to the the newly parsed hierarchy. |
| 243 | + * |
| 244 | + * For example, if hierarchies contained A -> [A, B, C, D, E, F], and then a new hierarchy was created |
| 245 | + * (starting from F.ParentId) was G -> [G, H, I, J, K], then G's Id == F.ParentId. |
| 246 | + * |
| 247 | + * Thus, attach the hierarchy of G to the end of A's hierarchy, giving you, |
| 248 | + * A -> [A, B, C, D, E, F, G, H, I, J, K]. |
| 249 | + */ |
| 250 | + private void attachRecursiveHierarchies(Map<Id, List<SObject>> hierarchies, Set<Id> nextStartOfHierarchies) { |
| 251 | + Map<Id, List<SObject>> recursion = this.getUntilTopOfHierarchy(nextStartOfHierarchies); |
| 252 | + |
| 253 | + for (Id identifier : hierarchies.keySet()) { |
| 254 | + List<SObject> hierarchy = hierarchies.get(identifier); |
| 255 | + SObject topOfHierarchySObject = hierarchy.get(hierarchy.size() - 1); |
| 256 | + |
| 257 | + Id parentIdLookup = (Id)topOfHierarchySObject.get(this.lookupField); |
| 258 | + if (recursion.containsKey(parentIdLookup)) { |
| 259 | + hierarchy.addAll(recursion.get(parentIdLookup)); |
| 260 | + } |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + /** |
| 265 | + * This function will query as many lookups (along with its queryFields) as it possibly can in one SOQL statement. |
| 266 | + * For example, if you were using the Account.ParentId field, while also supplying queryFields with Account.Name, the following SOQL |
| 267 | + * will be created. |
| 268 | + * |
| 269 | + * Name |
| 270 | + * Parent.ParentId |
| 271 | + * Parent.Name |
| 272 | + * Parent.Parent.ParentId |
| 273 | + * Parent.Parent.Name |
| 274 | + * Parent.Parent.Parent.ParentId |
| 275 | + * Parent.Parent.Parent.Name |
| 276 | + * Parent.Parent.Parent.Parent.ParentId |
| 277 | + * Parent.Parent.Parent.Parent.Name |
| 278 | + * Parent.Parent.Parent.Parent.Parent.ParentId |
| 279 | + * Parent.Parent.Parent.Parent.Parent.Name |
| 280 | + * |
| 281 | + * A SOQL string with field: |
| 282 | + * Parent.Parent.Parent.Parent.Parent.Parent.ParentId |
| 283 | + * |
| 284 | + * is considered an invalid SOQL statement. |
| 285 | + * Thus, five levels of parents is the deepest this function can go. |
| 286 | + */ |
| 287 | + private String createDynamicMultiLevelSoql(Integer numLevels) { |
| 288 | + String dynamicSoql = 'SELECT {0} FROM ' + this.sobjType.getDescribe().getName() + ' WHERE Id IN :ids'; |
| 289 | + |
| 290 | + List<String> fields = new List<String>(); |
| 291 | + |
| 292 | + for (SObjectField field : this.queryFields) { |
| 293 | + fields.add(field.getDescribe().getName()); |
| 294 | + } |
| 295 | + |
| 296 | + for (Integer i = 0; i < numLevels; i++) { |
| 297 | + String lookupFieldName = this.lookupField.getDescribe().getName(); |
| 298 | + String prefix = getMultiLookupPrefix(lookupFieldName, i + 1); |
| 299 | + fields.add(prefix + lookupFieldName); |
| 300 | + |
| 301 | + for (SObjectField field : this.queryFields) { |
| 302 | + fields.add(prefix + field.getDescribe().getName()); |
| 303 | + } |
| 304 | + } |
| 305 | + |
| 306 | + String completeFields = String.join(fields, ','); |
| 307 | + return String.format(dynamicSoql, new List<Object> {completeFields}); |
| 308 | + } |
| 309 | + |
| 310 | + /** |
| 311 | + * Given a field name, repeats the field name a given amount of times in SOQL format. |
| 312 | + * For example, given the field Account.ParentId (which fieldName would be ParentId), and |
| 313 | + * numRepeats is 4, the return of this function would be: Parent.Parent.Parent.Parent. |
| 314 | + */ |
| 315 | + private String getMultiLookupPrefix(String fieldName, Integer numRepeats) { |
| 316 | + String prefix = ''; |
| 317 | + if (fieldName.endsWith('Id')) { |
| 318 | + prefix = repeatString(fieldName.removeEnd('Id') + '.', numRepeats); |
| 319 | + } else { |
| 320 | + prefix = repeatString(fieldName.replace('__c', '__r') + '.', numRepeats); |
| 321 | + } |
| 322 | + return prefix; |
| 323 | + } |
| 324 | + |
| 325 | + private String repeatString(String toRepeat, Integer numTimes) { |
| 326 | + |
| 327 | + String repeatingString = ''; |
| 328 | + for (Integer i = 0; i < numTimes; i++) { |
| 329 | + repeatingString = repeatingString + toRepeat; |
| 330 | + } |
| 331 | + |
| 332 | + return repeatingString; |
| 333 | + } |
| 334 | +} |
0 commit comments