Wednesday, January 28, 2009

Sorting any List<sObject>.

I recently developed an utility class that sorts any List retrieved using SOQL. This blog explains how you can use the class and how I developed it (maybe you can grab some ideas for similar classes you are building).

Using this class is quite simple, and because I have written unit tests that validates 100% of the code you can easily use it in production sytems.

This class can can sort any List on any field. It can sort the list either in ascending or descending order. The class does not care what type of sObjects are in the list, so you could use it for custom and/or standard sObjects.

Performance

  • The class performs quite well because the sorting is done in memory (using Maps, Sets and Lists).
  • The class also caches previous sorts, so if it detects that you are re-sorting on a previously sorted field, it uses the information from the cache and does not re-sort.
  • If you sort in ascending order and then request a sort in descending order for the same field, the class uses the data in the cache.
How to use the class?

Before going into details of the Appex class, let me show you how the class can be used. For this example, I'm going to build a VisualForce page that shows a datatable with information from the Contact sObject. The table has three columns with three columns (Contact name, Contact phone, Account related to this contact).

The VisualForce page:

<apex:page controller="SorterContact">
    <apex:form >
        <apex:pageBlock >
            <apex:pageBlockSection columns="1" ID="AjaxTable">
                <apex:datatable value="{!List}" var="acc" Border="1" cellspacing="1" cellpadding="5">
                    <apex:column >
                        <apex:facet name="header">
                            <apex:commandButton action="{!SortByName}" value="Sort By Name" rerender="AjaxTable" />
                        </apex:facet>
                        <apex:outputText value="{!acc.Name}"></apex:outputText>
                    </apex:column>
                    <apex:column >
                        <apex:facet name="header">
                            <apex:commandButton action="{!SortByPhone}" value="Sort By Phone" rerender="AjaxTable" />
                        </apex:facet>
                        <apex:outputText value="{!acc.Phone}"></apex:outputText>
                    </apex:column>
                    <apex:column >
                        <apex:facet name="header">
                            <apex:commandButton action="{!SortByAccount}" value="Sort By Account" rerender="AjaxTable" />
                        </apex:facet>
                        <apex:outputText value="{!acc.Account.Name}"></apex:outputText>
                    </apex:column>
                </apex:datatable>
            </apex:pageBlockSection>
        </apex:pageBlock>
    </apex:form>
</apex:page>
Nothing fancy here... Just building a page like this:

When the buttons on the header are clicked, the table is sorted either ascending or descending. If the user selects a different column from the previous one, then the table gest sorted ascending by that column. But when the user clicks on the button for the same column, then the sort order gets reversed from ascending to descending and viceversa.

The controller:

Couple things going in here, but that is just to make the page look nice... Nothing really to do with the sorting.

public class SorterContact {
    private String sortedBy = null;
    private Boolean sortAscending = null;
    private AP_SortHelper sorter = new AP_SortHelper();
    private List<Contact> sortedList = null;

    public SorterContact() {
        sorter.originalList = [SELECT Name, Phone, Account.Name FROM Contact];
    }
    public PageReference SortByName() {
        setSortedBy('NAME');
        sortedList = (List<Contact>) sorter.getSortedList('Name', sortAscending);
        return null;
    }
    public PageReference SortByAccount() {
        setSortedBy('ACCOUNT');
        sortedList = (List<Contact>) sorter.getSortedList('Account.Name', sortAscending);
        return null;
    }
    public PageReference SortByPhone() {
        setSortedBy('PHONE');
        sortedList = (List<Contact>) sorter.getSortedList('Phone', sortAscending);
        return null;
    }
    public List<Contact> getList() {
        if (sortedList == null) {
            SortByName();
        }
        return sortedList;
    }
    private void setSortedBy(String value) {
        if (sortedBy == value) {
             sortAscending = !sortAscending;
        } else {
            sortAscending = true;
        }
        sortedBy = value;
    }
}
Let me talk about the easy part first...

There are methods that answer the calls from the commandbuttons on the page:

  • SortByName()
  • SortByAccount()
  • SortByPhone()
These methods follow the same structure:

public PageReference SortByName() {
   setSortedBy('NAME');
   sortedList = (List<contact>) sorter.getSortedList('Name', sortAscending);
   return null;
}
First, it calls a method setSortedBy() to find out the ascending or descending order. If the user clicks on a different button, the table is sorted ascending by that column, ortherwise the order is inverted from Ascending to descending and viceversa.

Second, it calls the method in the Appex class that does the sorting. (I will explain on detail how to use the Appex class, keep reading)

Finally, the controller's method returns a null value to the page.

The controller's constructor gets the list from the database.

public SorterContact() {
   sorter.originalList = [SELECT Name, Phone, Account.Name FROM Contact];
}
Since the buttons use the rerendered propery, and therefore a partial page refresh is performed using AJAX, the class constructor is only called at the initial page load rather than every time the buttons are clicked. This means the SOQL statement gets called only once regardless of how many times the data table gets sorted.

AP_SortHelper:

Finally, the more interesting part... This is the Appex class that does the sorting. Please note that you do not need to understand how this class is built in order to be able to use it (just grab the source code below and paste it into salesforce.com).

public class AP_SortHelper {
    private Map<String, Integer> listPosition = null; // <ID, Position>
    private Map<String, List<String>> sortedFieldValuesPerFieldName = null; // <FieldName, <FieldValues>>
    private Map<String, Map<String, List<String>>> sObjectIDsPerFieldNames = null; // <FieldName, <FieldValue, <IDs>>>

// Properties
    public List<sObject> originalList {get; set;}

// Constructor
    public AP_SortHelper() {
        originalList = null;
    }

// Public Method
    public List<sObject> getSortedList(String fieldName, Boolean ascending) {
        if (originalList == null) {
            // Assume that originalList has a not NULL value.
            // If the class who uses this method has not assigned a value it will get an Exception which
            //    needs to be handled by the calling class.

            // Force the exception...
            originalList.clear();
        }

        // Make field name uppercase
        fieldName = fieldName.toUpperCase();

        // Get sorted list
        return makeSortedList(fieldName, ascending);
    }
    public List<sObject> getSortedList(List<sObject> originalList, String fieldName, Boolean ascending) {
        this.originalList = originalList;
        sortedFieldValuesPerFieldName = null;
        return getSortedList(fieldName, ascending);
    }
    public static String getValue(sObject sObj, String fieldName) {
        // This returns the sObject desired in case the fieldName refers to a linked object.
        Integer pieceCount;
        String[] fieldNamePieces;
   
        fieldNamePieces = fieldName.split('\\.');
        pieceCount = fieldNamePieces.size();
        for (Integer i = 0; i < (pieceCount-1); i++) {
            sObj = sObj.getSObject(fieldNamePieces[i]);
        }
        return String.valueOf(sObj.get(fieldNamePieces[pieceCount-1]));
    }


// Private Methods
    private void InitializeFieldName(String fieldName) {
        String sObjectID;
        Integer position;
        String fieldValue;
        List<String> sObjectIDs = null;
        Set<String> valuesForFieldSet = null;    // Sets automatically omit duplicate values
        List<String> valuesForFieldList = null;
        Map<String, List<String>> sObjectIDsPerFieldValues = null;
   
        // Make sortedFieldValuesPerFieldName
        if (sortedFieldValuesPerFieldName == null) {
            listPosition = new Map<String, Integer>();
            sortedFieldValuesPerFieldName = new Map<String, List<String>>();
            sObjectIDsPerFieldNames = new Map<String, Map<String, List<String>>>();
        }
   
        // Get (or create) map of sObjectIDsPerFieldValues
        sObjectIDsPerFieldValues = sObjectIDsPerFieldNames.get(fieldName);
        if (sObjectIDsPerFieldValues == null) {
            sObjectIDsPerFieldValues = new Map<String, List<String>>();
            sObjectIDsPerFieldNames.put(fieldName, sObjectIDsPerFieldValues);
        }
        if (!sortedFieldValuesPerFieldName.keySet().contains(fieldName)) {
            // Objects need to be initialized
            position = 0;
            valuesForFieldSet = new Set<String>();
            listPosition = new Map<String, Integer>();
       
            for (sObject sObj : originalList) {
                sObjectID = sObj.ID;
                fieldValue = getValue(sObj, fieldName);
           
                // Add position to list
                listPosition.put(sObjectID, position++);
           
                // Add the value to the set (sets rather than lists to prevent duplicates)
                valuesForFieldSet.add(fieldValue);
           
                // Get (or create) map of sObjectIDs
                sObjectIDs = sObjectIDsPerFieldValues.get(fieldValue);
                if (sObjectIDs == null) {
                    sObjectIDs = new List<String>();
                    sObjectIDsPerFieldValues.put(fieldValue, sObjectIDs);
                }
           
                // Add ID to sObjectIDs
                sObjectIDs.add(sObjectID);
            }
       
            // Sort set items (Need to convert to list)
            valuesForFieldList = new List<String>();
            valuesForFieldList.addAll(valuesForFieldSet);
            valuesForFieldList.sort();
       
            // Now add it to the map.
            sortedFieldValuesPerFieldName.put(fieldName, valuesForFieldList);
        }
    }
    private List<sObject> makeSortedList(String fieldName, Boolean ascending) {
        Integer position;
        List<String> sObjectIDs = null;
        List<String> valuesForFieldList = null;

        // Initialize objects
        InitializeFieldName(fieldName);

        // Get a list of the same type as the "originalList"
        List<sObject> outputList = originalList.clone();
        outputList.clear();

        // Get a list of sorted values
        valuesForFieldList = sortedFieldValuesPerFieldName.get(fieldName);
   
        // for each sorted value
        for (String fieldValue : valuesForFieldList) {
            // Get lisft of IDs
            sObjectIDs = sObjectIDsPerFieldNames.get(fieldName).get(fieldValue);
       
            // for each ID
            for (String ID : sObjectIDs) {
                // Get position in originalList
                position = listPosition.get(ID);

                // Add each sObject to the list.
                if ((ascending)  (outputList.size()==0)) {
                    outputList.add(originalList[position]);
                } else {
                    outputList.add(0, originalList[position]);
                }
            }
        }
        return outputList;
    }

// Unit testing
/*
    static testMethod void testSortCustomObject() {
        List<TPValue__c> TPValues;
        AP_SortHelper sorter = new AP_SortHelper();
        String fieldName;
   
        TPValues = [SELECT TPName__r.TPName__c, Value__c FROM TPValue__c LIMIT 50];
        fieldName = 'Value__c';
        testOrderedList(sorter.getSortedList(TPValues, fieldName, true), fieldName, true);
   
        fieldName = 'TPName__r.TPName__c';
        testOrderedList(sorter.getSortedList(TPValues, fieldName, true), fieldName, true);
    }
*/
    static testMethod void testSimpleField_Ascending() {
        testSortingContacts('Name', true);
    }
    static testMethod void testSimpleField_Descending() {
        testSortingContacts('Name', False);
    }
    static testMethod void testLookupField_Ascending() {
        testSortingContacts('Account.Name', True);
    }
    static testMethod void testLookupField_Decending() {
        testSortingContacts('Account.Name', False);
    }
    static testMethod void testMultipleCalls() {
        AP_SortHelper sorter;
        sorter = testSortingContacts(null, 'Name', true);
        testSortingContacts(sorter, 'Name', False);
        testSortingContacts(sorter, 'Account.Name', True);
        testSortingContacts(sorter, 'Account.Name', False);
    }
    static testMethod void testForgotOriginalList() {
        Boolean exceptionDetected = false;
        AP_SortHelper sorter = new AP_SortHelper();
        try {
            sorter.getSortedList('Name', true);
        } catch (NullPointerException e) {
            exceptionDetected = true;
        }
        System.assert(exceptionDetected);
    }
    static testMethod void testPassingList() {
        AP_SortHelper sorter = new AP_SortHelper();
        List<Contact> contacts = [SELECT Name, Phone, Account.Name FROM Contact LIMIT 50];
        List<Contact> sortedList = (List<Contact>) sorter.getSortedList(contacts, 'Name', true);
        testOrderedList(sortedList, 'Name', true);
    }
    private static void testSortingContacts(string fieldName, Boolean isAscending) {
        testSortingContacts(null, fieldName, isAscending);
    }
    private static AP_SortHelper testSortingContacts(AP_SortHelper sorter, string fieldName, Boolean isAscending) {
        // If sorted is null,create it.   
        if (sorter == null) {
            sorter = new AP_SortHelper();
            sorter.originalList = [SELECT Name, Phone, Account.Name FROM Contact LIMIT 50];
        }
   
        // Sort list
        List<Contact> sortedList = (List<Contact>) sorter.getSortedList(fieldName, isAscending);

        // Test sort order
        testOrderedList(sortedList, fieldName, isAscending);
   
        return sorter;   
    }
    private static void testOrderedList(List<sObject> sortedList, string fieldName, Boolean isAscending) {
        String lastValue = null;
        String currentValue = null;

        for (sObject sObj : sortedList) {
            currentValue = getValue(sObj, fieldName);
            if ((lastValue != null) && (currentValue != null)) {

                String strDebug = '';
                strDebug += '\n--------------------------------------------------------------';
                strDebug += '\nSTART';
                strDebug += '\n--------------------------------------------------------------';
                strDebug += '\n[Ascending:'+isAscending+']';
                strDebug += '\n[Previous:'+lastValue+'] [IsNull():'+(lastValue==null)+']';
                strDebug += '\n[Current:'+currentValue+'] [IsNull():'+(currentValue==null)+']';
                strDebug += '\n[CompareTo:'+(currentValue.compareTo(lastValue))+']';
                strDebug += '\n--------------------------------------------------------------';
                strDebug += '\nEND';
                strDebug += '\n--------------------------------------------------------------';
                System.debug(strDebug);

                if (isAscending) {
                    System.assertEquals(currentValue.compareTo(lastValue)>=0, true);
                } else {
                    System.assertEquals(currentValue.compareTo(lastValue)<=0, true);
                }
            }
            lastValue = currentValue;
        }
    }
}
Summary: How to use this class?

  1. Copy the source code above into Salesforce.com
  2. Create an instance of this class AP_SortHelper()
  3. Assign the list to sort (this list is created when you execute a valid SOQL statement)
  4. Call the getSortedList() method which takes two parameters:
    1. The name of the field as it was used in the SOQL
    2. The order (true for ascending, false for descending)
Please let me know if there are people interested in the explanation of how the sorter class works. If there are enough people interested, I'll revisit this post and explain how I did it.

No comments:

Post a Comment