Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Ruby sort by multiple fields and multilple directions for different data types

Tags:

ruby

I need to write a multi_sort method to sort an array of hash which accepts a hash as an argument, eg: { a_sort: 1, display_sort: 1 }. This hash is sort fields and sort direction. (1 means ascending, -1 means descending).

items = [ {name: 'Album 1', a_sort: 5, display_sort: 3}, 
          {name: 'Album 2', a_sort: 1, display_sort: 5}, 
          {name: 'Album 3', a_sort: 3, display_sort: 2},
          {name: 'Album 5', a_sort: 1, display_sort: 8}, 
          {name: 'Album 7', a_sort: 5, display_sort: 1}, 
          {name: 'Album 7', a_sort: 5, display_sort: 6} ]

multi_sort(items, {a_sort: 1, display_sort: 1})

I can't figure it out after 3 hours. The expected output is the array that is sorted correctly.

      [ {name: 'Album 2', a_sort: 1, display_sort: 5},
        {name: 'Album 5', a_sort: 1, display_sort: 8},
        {name: 'Album 3', a_sort: 3, display_sort: 2},
        {name: 'Album 7', a_sort: 5, display_sort: 1}
        {name: 'Album 1', a_sort: 5, display_sort: 3}, 
        {name: 'Album 7', a_sort: 5, display_sort: 6} ]
like image 597
Chamnap Avatar asked Dec 12 '22 01:12

Chamnap


1 Answers

Very interesting problem. I also think the sort_by method would be most helpful. My solution (for numerical values only) works like this:

DIRECTION_MULTIPLIER = { asc: 1, desc: -1 }

def multi_sort(items, order)
  items.sort_by do |item|
    order.collect do |key, direction|
      item[key]*DIRECTION_MULTIPLIER[direction]
    end
  end
end

# ... items ...
multi_sort(items, a_sort: :asc, display_sort: :desc)

The idea is to construct a list for each item passed by sort_by. This list consists out of all values for which a sort order was given. Hence, we use that Ruby knows that [1,2] is smaller than [1,3] but greater than [0,0].

An interesting part to note is that the last parameters for the function will be passed as one Hash and the order of these hash pairs will be maintained. This "ordered" behavior in Hashes is not necessarily true for all languages, but the Ruby documentation states: Hashes enumerate their values in the order that the corresponding keys were inserted.

-- Edit for more generality --

Since, chamnap asked for a more general solution which works with arbitrary data types and nil, here a more comprehensive solution which relies on the <=> operator:

require 'date'
DIRECTION_MULTIPLIER = { asc: 1, desc: -1 }

# Note: nil will be sorted towards the bottom (regardless if :asc or :desc)
def multi_sort(items, order)
  items.sort do |this, that|
    order.reduce(0) do |diff, order|
      next diff if diff != 0 # this and that have differed at an earlier order entry
      key, direction = order
      # deal with nil cases
      next  0 if this[key].nil? && that[key].nil?
      next  1 if this[key].nil?
      next -1 if that[key].nil?
      # do the actual comparison
      comparison = this[key] <=> that[key]
      next comparison * DIRECTION_MULTIPLIER[direction]
    end
  end
end

I am using the sort method. The block gets called each time the sort function needs to compare to elements. The block shall return -1, 0 or 1 (smaller, equal or higher in the order) for the respective pair. Within this sort block I am going through the order hash which contains the key and the direction for a hash value in items. If we have found an earlier difference in order (e.g. the first key was higher) we just return that value. If the past comparisons came up with equal order, we use the <=> operator to compare the two elements passed to the sort block (and multiply the result it with -1 if we want descending order). The only annoying thing is to deal with nil values, which adds the three lines above the actual comparison.

And here my test code:

items = [ {n: 'ABC  ', a:   1, b: Date.today+2},
          {n: 'Huhu ', a: nil, b: Date.today-1},
          {n: 'Man  ', a: nil, b: Date.today},
          {n: 'Woman', a: nil, b: Date.today}, 
          {n: 'DEF  ', a:   7, b: Date.today-1}]
multi_sort(items, b: :asc, a: :desc, n: :asc)

On a more general note: Since the logic for sorting becomes a little more complicated, I would wrap the data in actual objects with attributes. Then you could overwrite the <=> operator as seen here.

like image 119
Motine Avatar answered Apr 30 '23 23:04

Motine