Skip to content

Commit 92e1f8b

Browse files
author
Cody T Zeitler
committed
feat: add sobject hierarchy
1 parent 45ab65b commit 92e1f8b

File tree

2 files changed

+876
-0
lines changed

2 files changed

+876
-0
lines changed
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
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

Comments
 (0)