Apex - SoqlMultiTableParser - SOQL Subqueries to multiple individual tables

PHOTO EMBED

Mon Aug 12 2024 21:41:03 GMT+0000 (Coordinated Universal Time)

Saved by @Justus

/**
 * @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);
    }

}
content_copyCOPY