/**
* @author Justus van den Berg (jfwberg@gmail.com)
* @date August 2024
* @copyright (c) 2024 Justus van den Berg
* @license MIT (See LICENSE file in the project root)
* @description Class that converts JSON with nested objects to flat values and splits out
* each child list to it's own individual list using the path as map key
* The parent map key is called "parent", child map keys are based on the
* path like: "Opportunities" or Opportunities.OpportunityContactRoles
* @use case The main use case is to transform SOQL query results that can be used
* for exporting CSV or XLSX data that require individual tables for child lists.
* A secondary use is to store related data for archiving purposes where a
* potential reverse loading of related data might be required.
* - https://medium.com/@justusvandenberg/dynamically-handle-salesforce-soql-subquery-response-data-using-apex-8130bd0622aa
*/
public with sharing class SoqlMultiTableParser {
/** **************************************************************************************************** **
** PRIVATE CONSTANTS **
** **************************************************************************************************** **/
// Parameter names to be ignored when creating the data table
private final static Set<String> ATTRIBUTE_FILTER = new Set<String>{'attributes','done','totalSize','nextRecordsUrl'};
// List parameter names of this type will not be appended, this allows for a more flat table
private final static Set<String> LIST_NAME_FILTER = new Set<String>{'records'};
/** **************************************************************************************************** **
** PUBLIC SUPPORT METHODS **
** **************************************************************************************************** **/
/**
* @description Create table from an untyped Object list
* @param input A list of Objects to traverse
* @param output The final output map
* @return A list of flattened JSON records
*/
public static Map<String,List<Map<String,Object>>> create(Object[] input){
// Create a variable for the data output
Map<String,List<Map<String,Object>>> output = new Map<String,List<Map<String,Object>>>();
// Input validation, if the input is null, return an empty list
if(input == null){return output;}
// Populate the data by traversing the input and add the data to the output
// Set the path to an empty string, as it is the top level we begin with
traverseList(input, output, 'parent');
// Return the flat table
return output;
}
/** **************************************************************************************************** **
** PRIVATE TRAVERSE METHODS **
** **************************************************************************************************** **/
/**
* @description Traverse the parent list
* @param input The input to traverse
* @param output The final output map
* @param path The location in the traverse path
*/
private static void traverseList(Object[] input, Map<String,List<Map<String,Object>>> output, String path){
// Each list goes into it's own flat table, so create the empty set, map and
// populate the lists paths
if(!output.containsKey(path)){
output.put(path, new List<Map<String,Object>>());
}
// Having this type casting again seems redundant, but doing the check in here
// saves having to do it twice, what on 10k+ statements gives a small performance improvement
for(Integer i=0, max=input.size();i<max;i++){
// Create a new row to combine the values in the list
Map<String,Object> row = new Map<String,Object>();
// Handle each child object according to it's type
if(input[i] instanceof Map<String,Object>){
traverseMap((Map<String,Object>) input[i], output, row, path, path);
// If the child is a list, traverse the child list
}else if(input[i] instanceof Object[]){
traverseList(
(Object[]) input[i],
output,
(LIST_NAME_FILTER.contains(path?.substringAfterLast('.'))) ?
path?.substringBeforeLast('.') :
path
);
// Anything other than objects and maps, primitive data types are not supported
// i.e. String Lists ['a','b','c'] or [1,2,3,] etc. So simply ignore these values
}else{
continue;
}
// After the traversal is complete, Add the full row to the table
if(!row.isEmpty()){output.get(path).add(row);}
}
}
/**
* @description Method to traverse a map
* @param input The input to traverse
* @param output The final output map
* @param row The current row in the traverse path
* @param path The location in the traverse path
* @param listPath The current list path (map key) in the traverse path
*/
private static void traverseMap(Map<String,Object> input, Map<String,List<Map<String,Object>>> output, Map<String,Object> row, String path, String listPath){
// Iterate all the values in the input
for(String key : input.keySet() ){
// Continue if an attribute needs to be ignored
if(ATTRIBUTE_FILTER.contains(key)){continue;}
// Create the path for the specfic child node
String childPath = ((String.isNotBlank(path) && path != 'parent') ? path + '.' + key : key);
// If the child node is an object list, traverse as list
if(input.get(key) instanceof Object[]){
traverseList(
(Object[]) input.get(key),
output,
(LIST_NAME_FILTER.contains(childPath?.substringAfterLast('.'))) ?
childPath?.substringBeforeLast('.') :
childPath
);
// If the child node is an object, (i.e. owner.name), traverse as map
}else if(input.get(key) instanceof Map<String,Object>){
traverseMap(
(Map<String,Object>) input.get(key),
output,
row,
childPath,
listPath
);
// If it's not a map or a list, it must a value, so add the value to row
}else{
populateRow(
input.get(key),
childPath,
row,
listPath
);
}
}
}
/**
* @description Method to add the value to a row at the end of a traversel path
* @param input The input to traverse
* @param path The location in the traverse path
* @param row The current row in the traverse path
* @param listPath The current list path (map key) in the traverse path
*/
private static void populateRow(Object input, String path, Map<String,Object> row, String listPath){
// Add the value to the row
row.put(path.removeStart(listPath).removeStart('.'),input);
}
}