Sunday, December 29, 2013

Multi-Column Sort in Backbone Collections

Amongst the features that I wish Backbone implemented a little more fully is collection sorting. Its great that it can accommodate several approached (like the attribute name or a function) so you can implement more complex sorting schemes. However, sorting a set of data is probably fairly well defined and, in most cases, will be a combination of one or more columns in either ascending or descending order. Generally, one column is enough so the built-in string variation is sufficient and easy to use. However, that will only give you ascending order. If you want descending order, you'll need a sorting function to reverse the comparison. A generalized version of that function might look like this:


   comparator: function(a, b) {

      var a = a.get( this.sortColumn ),
          b = b.get( this.sortColumn );

      if ( a == b ) return 0;

      if ( this.sortDirection == 'asc' ) {
         return a > b;
      } else {
         return a < b;
      }
   }



That's great for one column, but what if you want several columns to be considered in the sorting? Before writing anything, consider what is happening with multiple columns. The leading column is sorted first, then the second, and so on. If the leading column is unique across all the rows, the additional columns add no value to the sort. The only time the additional columns are important is if there are non-unique values in the leading column. In that situation, the next column is used to break the tie and define the ordering. Given that logic, the above implementation can be extended to detect whether the equality case exists and use the first non-equal column in the sort comparison:


_.extend( Backbone.Collection.prototype, {
   
   // Comma separated list of attributes
   sortColumn: null, 
 
   // Comma separated list corresponding to column list
   sortDirection: 'asc', // - [ 'asc'|'desc' ]

   comparator: function( a, b ) {

      if ( !this.sortColumn ) return 0;

      var cols = this.sortColumn.split( ',' ),
          dirs = this.sortDirection.split( ',' ),
          cmp;

      // First column that does not have equal values
      cmp = _.find( cols, function( c ) { return a.attributes[c] != b.attributes[c]; });

      // undefined means they're all equal, so we're done.
      if ( !cmp ) return 0;

      // Otherwise, use that column to determine the order
      // match the column sequence to the methods for ascending/descending
      // default to ascending when not defined.
      if ( ( dirs[_.indexOf( cols, cmp )] || 'asc' ).toLowerCase() == 'asc' ) {
         return a.attributes[cmp] > b.attributes[cmp] ? 1 : -1;
      } else {
         return a.attributes[cmp] < b.attributes[cmp] ? 1 : -1;
      }

   },

});



The result of that code can be seen in the following example. Use the buttons to toggle the sort. Each button changes the sortColumn and sortDirection values on the collection and then calls the sort() method:


   movieList.sortColumn = 'year,studio';
   movieList.sortDirection = 'desc,asc';

   movieList.sort();


Sort:


There are a few minor improvements that can be made to this code. The comparator function does get called a lot so optimizing what it does will be beneficial for larger data sets. The default Backbone.Collection sort method could be overridden to split the strings for sortColumn and sortDirection prior to calling the comparator to avoid that operation each time. Also, when there is only one column being sorted, the equality detection could be skipped and replaced with the simpler variation presented in the first example. However, for smaller data sets, the version shown here will work fine.