121

I am trying to sort an array with objects based on multiple attributes. I.e if the first attribute is the same between two objects a second attribute should be used to comapare the two objects. For example, consider the following array:

var patients = [
             [{name: 'John', roomNumber: 1, bedNumber: 1}],
             [{name: 'Lisa', roomNumber: 1, bedNumber: 2}],
             [{name: 'Chris', roomNumber: 2, bedNumber: 1}],
             [{name: 'Omar', roomNumber: 3, bedNumber: 1}]
               ];

Sorting these by the roomNumber attribute i would use the following code:

var sortedArray = _.sortBy(patients, function(patient) {
    return patient[0].roomNumber;
});

This works fine, but how do i proceed so that 'John' and 'Lisa' will be sorted properly?

Andrew
  • 12,991
  • 15
  • 55
  • 85
Christian R
  • 1,545
  • 3
  • 13
  • 17

12 Answers12

261

sortBy says that it is a stable sort algorithm so you should be able to sort by your second property first, then sort again by your first property, like this:

var sortedArray = _(patients).chain().sortBy(function(patient) {
    return patient[0].name;
}).sortBy(function(patient) {
    return patient[0].roomNumber;
}).value();

When the second sortBy finds that John and Lisa have the same room number it will keep them in the order it found them, which the first sortBy set to "Lisa, John".

Rory MacLeod
  • 11,012
  • 7
  • 41
  • 43
  • 12
    There is a [blog post](http://blog.falafel.com/nifty-underscore-tricks-sorting-by-multiple-properties-with-underscore/) that expands on this and includes good information on sorting ascending and descending properties. – Alex C Oct 07 '14 at 15:13
  • 5
    A simpler solution to the chained sort can be found [here](http://janetriley.net/2014/12/sort-on-multiple-keys-with-underscores-sortby.html). To be fair, it looks like the blog post was written after these answers were given, but it helped me figure this out after trying to use the code in the answer above and failing. – Mike Devenney Feb 24 '16 at 20:15
  • 1
    You sure the patient[0].name and patient[1].roomNumber should have the index there? patient is not an array... – StinkyCat Jul 03 '18 at 12:33
  • The `[0]` indexer is required because in the original example, `patients` is an array of arrays. This is also why the "simpler solution" in the blog post mentioned in another comment won't work here. – Rory MacLeod Sep 15 '18 at 20:43
  • 1
    @ac_fire Here is an archive of that now dead link: http://archive.is/tiatQ – technoplato Jan 10 '19 at 19:07
  • Not sure why, but the result of this doesn't seem correct to me on Chromium: `_.chain([{a:1, b:1}, {a:1, b:0}, {a:2, b:2}, {a:1, b:3}]).sortBy(function(i){ return i.a;}).sortBy(function(i){ return i.b;}).value()`. The results are not sorted based on `a` at all. Library version "1.8.3". Also, I don't see how stable sort can help with this. The 2nd sort does eliminate the results of the first. It only leaves some rare changes made by it. – user2173353 Feb 16 '21 at 18:11
  • This answer is not correct the second sort will change order of all elements, instead use this approach `_.sortBy(patients, (patient) => [patient.name,patient.roomNumber]);` – Alexandr Sargsyan Aug 30 '21 at 10:44
53

Here's a hacky trick I sometimes use in these cases: combine the properties in such a way that the result will be sortable:

var sortedArray = _.sortBy(patients, function(patient) {
  return [patient[0].roomNumber, patient[0].name].join("_");
});

However, as I said, that's pretty hacky. To do this properly you'd probably want to actually use the core JavaScript sort method:

patients.sort(function(x, y) {
  var roomX = x[0].roomNumber;
  var roomY = y[0].roomNumber;
  if (roomX !== roomY) {
    return compare(roomX, roomY);
  }
  return compare(x[0].name, y[0].name);
});

// General comparison function for convenience
function compare(x, y) {
  if (x === y) {
    return 0;
  }
  return x > y ? 1 : -1;
}

Of course, this will sort your array in place. If you want a sorted copy (like _.sortBy would give you), clone the array first:

function sortOutOfPlace(sequence, sorter) {
  var copy = _.clone(sequence);
  copy.sort(sorter);
  return copy;
}

Out of boredom, I just wrote a general solution (to sort by any arbitrary number of keys) for this as well: have a look.

Dan Tao
  • 125,917
  • 54
  • 300
  • 447
  • Thanks a lot for this solution ended up using the second one since my attributes could be both strings and numbers. So there doesn't seem to be a simple native way to sort arrays? – Christian R May 11 '13 at 11:00
  • 3
    Why isn't just `return [patient[0].roomNumber, patient[0].name];` enough without the `join`? – Csaba Toth Jun 25 '14 at 20:53
  • 1
    The link to your general solution appears to be broken (or perhaps I am unable to access it through our proxy server). Could you please post it here? – Zev Spitz Apr 13 '16 at 09:53
  • Also, how does `compare` handle values which are not primitive values -- `undefined`,`null` or plain objects? – Zev Spitz Apr 13 '16 at 10:10
  • FYI this hack only works if you ensure that the str length of each value is the same for all items in the array. – miex Mar 03 '18 at 01:49
37

I know I'm late to the party, but I wanted to add this for those in need of a clean-er and quick-er solution that those already suggested. You can chain sortBy calls in order of least important property to most important property. In the code below I create a new array of patients sorted by Name within RoomNumber from the original array called patients.

var sortedPatients = _.chain(patients)
  .sortBy('Name')
  .sortBy('RoomNumber')
  .value();
Mike Devenney
  • 1,758
  • 1
  • 23
  • 42
12

btw your initializer for patients is a bit weird, isn't it? why don't you initialize this variable as this -as a true array of objects-you can do it using _.flatten() and not as an array of arrays of single object, maybe it's typo issue):

var patients = [
        {name: 'Omar', roomNumber: 3, bedNumber: 1},
        {name: 'John', roomNumber: 1, bedNumber: 1},
        {name: 'Chris', roomNumber: 2, bedNumber: 1},
        {name: 'Lisa', roomNumber: 1, bedNumber: 2},
        {name: 'Kiko', roomNumber: 1, bedNumber: 2}
        ];

I sorted the list differently and add Kiko into Lisa's bed; just for fun and see what changes would be done...

var sorted = _(patients).sortBy( 
                    function(patient){
                       return [patient.roomNumber, patient.bedNumber, patient.name];
                    });

inspect sorted and you'll see this

[
{bedNumber: 1, name: "John", roomNumber: 1}, 
{bedNumber: 2, name: "Kiko", roomNumber: 1}, 
{bedNumber: 2, name: "Lisa", roomNumber: 1}, 
{bedNumber: 1, name: "Chris", roomNumber: 2}, 
{bedNumber: 1, name: "Omar", roomNumber: 3}
]

so my answer is : use an array in your callback function this is quite similar to Dan Tao's answer, I just forget the join (maybe because I removed the array of arrays of unique item :))
Using your data structure, then it would be :

var sorted = _(patients).chain()
                        .flatten()
                        .sortBy( function(patient){
                              return [patient.roomNumber, 
                                     patient.bedNumber, 
                                     patient.name];
                        })
                        .value();

and a testload would be interesting...

zobidafly
  • 275
  • 2
  • 9
8

None of these answers are ideal as a general purpose method for using multiple fields in a sort. All of the approaches above are inefficient as they either require sorting the array multiple times (which, on a large enough list could slow things down a lot) or they generate huge amounts of garbage objects that the VM will need to cleanup (and ultimately slowing the program down).

Here's a solution that is fast, efficient, easily allows reverse sorting, and can be used with underscore or lodash, or directly with Array.sort

The most important part is the compositeComparator method, which takes an array of comparator functions and returns a new composite comparator function.

/**
 * Chains a comparator function to another comparator
 * and returns the result of the first comparator, unless
 * the first comparator returns 0, in which case the
 * result of the second comparator is used.
 */
function makeChainedComparator(first, next) {
  return function(a, b) {
    var result = first(a, b);
    if (result !== 0) return result;
    return next(a, b);
  }
}

/**
 * Given an array of comparators, returns a new comparator with
 * descending priority such that
 * the next comparator will only be used if the precending on returned
 * 0 (ie, found the two objects to be equal)
 *
 * Allows multiple sorts to be used simply. For example,
 * sort by column a, then sort by column b, then sort by column c
 */
function compositeComparator(comparators) {
  return comparators.reduceRight(function(memo, comparator) {
    return makeChainedComparator(comparator, memo);
  });
}

You'll also need a comparator function for comparing the fields you wish to sort on. The naturalSort function will create a comparator given a particular field. Writing a comparator for reverse sorting is trivial too.

function naturalSort(field) {
  return function(a, b) {
    var c1 = a[field];
    var c2 = b[field];
    if (c1 > c2) return 1;
    if (c1 < c2) return -1;
    return 0;
  }
}

(All the code so far is reusable and could be kept in utility module, for example)

Next, you need to create the composite comparator. For our example, it would look like this:

var cmp = compositeComparator([naturalSort('roomNumber'), naturalSort('name')]);

This will sort by room number, followed by name. Adding additional sort criteria is trivial and does not affect the performance of the sort.

var patients = [
 {name: 'John', roomNumber: 3, bedNumber: 1},
 {name: 'Omar', roomNumber: 2, bedNumber: 1},
 {name: 'Lisa', roomNumber: 2, bedNumber: 2},
 {name: 'Chris', roomNumber: 1, bedNumber: 1},
];

// Sort using the composite
patients.sort(cmp);

console.log(patients);

Returns the following

[ { name: 'Chris', roomNumber: 1, bedNumber: 1 },
  { name: 'Lisa', roomNumber: 2, bedNumber: 2 },
  { name: 'Omar', roomNumber: 2, bedNumber: 1 },
  { name: 'John', roomNumber: 3, bedNumber: 1 } ]

The reason I prefer this method is that it allows fast sorting on an arbitrary number of fields, does not generate a lot of garbage or perform string concatenation inside the sort and can easily be used so that some columns are reverse sorted while order columns use natural sort.

Andrew Newdigate
  • 6,005
  • 3
  • 37
  • 31
5

Simple Example from http://janetriley.net/2014/12/sort-on-multiple-keys-with-underscores-sortby.html (courtesy of @MikeDevenney)

Code

var FullySortedArray = _.sortBy(( _.sortBy(array, 'second')), 'first');

With Your Data

var FullySortedArray = _.sortBy(( _.sortBy(patients, 'roomNumber')), 'name');
Kevin Danikowski
  • 4,620
  • 6
  • 41
  • 75
2

Perhaps underscore.js or just Javascript engines are different now than when these answers were written, but I was able to solve this by just returning an array of the sort keys.

var input = [];

for (var i = 0; i < 20; ++i) {
  input.push({
    a: Math.round(100 * Math.random()),
    b: Math.round(3 * Math.random())
  })
}

var output = _.sortBy(input, function(o) {
  return [o.b, o.a];
});

// output is now sorted by b ascending, a ascending

In action, please see this fiddle: https://jsfiddle.net/mikeular/xenu3u91/

Mike K
  • 601
  • 5
  • 8
2

Just return an array of properties you want to sort with:

ES6 Syntax

var sortedArray = _.sortBy(patients, patient => [patient[0].name, patient[1].roomNumber])

ES5 Syntax

var sortedArray = _.sortBy(patients, function(patient) { 
    return [patient[0].name, patient[1].roomNumber]
})

This does not have any side effects of converting a number to a string.

Lucky Soni
  • 6,811
  • 3
  • 38
  • 57
1

You could concatenate the properties you want to sort by in the iterator:

return [patient[0].roomNumber,patient[0].name].join('|');

or something equivalent.

NOTE: Since you are converting the numeric attribute roomNumber to a string, you would have to do something if you had room numbers > 10. Otherwise 11 will come before 2. You can pad with leading zeroes to solve the problem, i.e. 01 instead of 1.

Mark Sherretta
  • 10,160
  • 4
  • 37
  • 42
1

I think you'd better use _.orderBy instead of sortBy:

_.orderBy(patients, ['name', 'roomNumber'], ['asc', 'desc'])
Buddy
  • 10,874
  • 5
  • 41
  • 58
ZhangYi
  • 65
  • 1
  • 4
    Are you sure orderBy is in underscore? I can't see it in the docs or my .d.ts file. – Zachary Dow Oct 27 '16 at 23:16
  • 1
    There is no orderBy in underscore. – AfroMogli Mar 21 '17 at 14:14
  • 1
    `_.orderBy` works, but it is a method of the lodash library, not underscore: https://lodash.com/docs/4.17.4#orderBy lodash is mostly a drop-in replacement for underscore, so it might be appropriate for the OP. – Mike K Aug 24 '17 at 04:38
1

I don't think most of the answers really work, and certainly there is none that works and uses purely underscore at the same time.

This answer provides sorting for multiple columns, with the ability to reverse the sort order for some of them, all in one function.

It also builds on the final code step by step, so you may want to take the last code snippet:


I have used this for two columns only (first sort by a, then by b):

var array = [{a:1, b:1}, {a:1, b:0}, {a:2, b:2}, {a:1, b:3}];
_.chain(array)
 .groupBy(function(i){ return i.a;})
 .map(function(g){ return _.chain(g).sortBy(function(i){ return i.b;}).value(); })
 .sortBy(function(i){ return i[0].a;})
 .flatten()
 .value();

Here is the result:

0: {a: 1, b: 0}
1: {a: 1, b: 1}
2: {a: 1, b: 3}
3: {a: 2, b: 2}

I am sure this can be generalized for more than two...


Another version that might be faster:

var array = [{a:1, b:1}, {a:1, b:0}, {a:2, b:2}, {a:1, b:3}];
_.chain(array)
    .sortBy(function(i){ return i.a;})
    .reduce(function(prev, i){
        var ix = prev.length - 1;
        if(!prev[ix] || prev[ix][0].a !== i.a) {
         prev.push([]); ix++;
        }
        prev[ix].push(i);
        return prev;
    }, [])
    .map(function(i){ return _.chain(i).sortBy(function(j){ return j.b; }).value();})
    .flatten()
    .value();

And a parametrized version of it:

var array = [{a:1, b:1}, {a:1, b:0}, {a:2, b:2}, {a:1, b:3}];
function multiColumnSort(array, columnNames) {
    var col0 = columnNames[0],
        col1 = columnNames[1];
    return _.chain(array)
        .sortBy(function(i){ return i[col0];})
        .reduce(function(prev, i){
            var ix = prev.length - 1;
            if(!prev[ix] || prev[ix][0][col0] !== i[col0]) {
             prev.push([]); ix++;
            }
            prev[ix].push(i);
            return prev;
        }, [])
        .map(function(i){ return _.chain(i).sortBy(function(j){ return j[col1]; }).value();})
        .flatten()
        .value();
}
multiColumnSort(array, ['a', 'b']);

And a parametrized version for any number of columns (seems to work from a first test):

var array = [{a:1, b:1, c:9}, {a:1, b:1, c:3}, {a:2, b:2, c:10}, {a:1, b:3, c:0}];
function multiColumnSort(array, columnNames) {
    if(!columnNames || !columnNames.length || array.length === 1) return array;
    var col0 = columnNames[0];
    if(columnNames.length == 1) return _.chain(array).sortBy(function(i){ return i[col0]; }).value();
    
    return _.chain(array)
        .sortBy(function(i){ return i[col0];})
        .reduce(function(prev, i){
            var ix = prev.length - 1;
            if(!prev[ix] || prev[ix][0][col0] !== i[col0]) {
             prev.push([]); ix++;
            }
            prev[ix].push(i);
            return prev;
        }, [])
        .map(function(i){ return multiColumnSort(i, _.rest(columnNames, 1));})
        .flatten()
        .value();
}
multiColumnSort(array, ['a', 'b', 'c']);

If you want to be able to reverse the column sorting too:

var array = [{a:1, b:1, c:9}, {a:1, b:1, c:3}, {a:2, b:2, c:10}, {a:1, b:3, c:0}];
function multiColumnSort(array, columnNames) {
    if(!columnNames || !columnNames.length || array.length === 1) return array;
    var col = columnNames[0],
        isString = !!col.toLocaleLowerCase,
        colName = isString ? col : col.name,
        reverse = isString ? false : col.reverse,
        multiplyWith = reverse ? -1 : +1;
    if(columnNames.length == 1) return _.chain(array).sortBy(function(i){ return multiplyWith * i[colName]; }).value();
    
    return _.chain(array)
        .sortBy(function(i){ return multiplyWith * i[colName];})
        .reduce(function(prev, i){
            var ix = prev.length - 1;
            if(!prev[ix] || prev[ix][0][colName] !== i[colName]) {
             prev.push([]); ix++;
            }
            prev[ix].push(i);
            return prev;
        }, [])
        .map(function(i){ return multiColumnSort(i, _.rest(columnNames, 1));})
        .flatten()
        .value();
}
multiColumnSort(array, ['a', {name:'b', reverse:true}, 'c']);

To also support functions:

var array = [{a:1, b:1, c:9}, {a:1, b:1, c:3}, {a:2, b:2, c:10}, {a:1, b:3, c:0}];
function multiColumnSort(array, columnNames) {
    if (!columnNames || !columnNames.length || array.length === 1) return array;
    var col = columnNames[0],
        isString = !!col.toLocaleLowerCase,
        isFun = typeof (col) === 'function',
        colName = isString ? col : col.name,
        reverse = isString || isFun ? false : col.reverse,
        multiplyWith = reverse ? -1 : +1,
        sortFunc = isFun ? col : function (i) { return multiplyWith * i[colName]; };

    if (columnNames.length == 1) return _.chain(array).sortBy(sortFunc).value();

    return _.chain(array)
        .sortBy(sortFunc)
        .reduce(function (prev, i) {
            var ix = prev.length - 1;
            if (!prev[ix] || (isFun ? sortFunc(prev[ix][0]) !== sortFunc(i) : prev[ix][0][colName] !== i[colName])) {
                prev.push([]); ix++;
            }
            prev[ix].push(i);
            return prev;
        }, [])
        .map(function (i) { return multiColumnSort(i, _.rest(columnNames, 1)); })
        .flatten()
        .value();
}
multiColumnSort(array, ['a', {name:'b', reverse:true}, function(i){ return -i.c; }]);
user2173353
  • 4,316
  • 4
  • 47
  • 79
0

If you happen to be using Angular, you can use its number filter in the html file rather than adding any JS or CSS handlers. For example:

  No fractions: <span>{{val | number:0}}</span><br>

In that example, if val = 1234567, it will be displayed as

  No fractions: 1,234,567

Example and further guidance at: https://docs.angularjs.org/api/ng/filter/number

junktrunk
  • 71
  • 7