3

I would like to sort an array of hashes by several dynamic criteria. Let's say I have this array

persons = [
  {
    id: 1,
    first_name: "Bill",
    last_name: "Zamora",
    age: 37
  },
  {
    id: 2,
    first_name: "Alexia",
    last_name: "Reyes",
    age: 70
  },
  {
    id: 3,
    first_name: "Anthony",
    last_name: "Nelson",
    age: 25
  }
]

I know that you can easily sort an array by multiple criteria with the following code

persons.sort_by!{ |p| [p[:age], p[:first_name]] }

However, in this example the number and the order of the fields by which the array is sorted is hard-coded. In my case, this is determined dynamically at runtime. So I do not know by how many fields the array is to be sorted nor what fields are sorted in which order.

I am looking for an elegant solution to sort my array using a configuration object that I don't know before. Such a configuration could look like this:

sort_settings = [
  {
    field: "first_name",
    order: "asc"
  },
  {
    field: "age",
    order: "desc"
  }
]

I am very grateful for any help on this!

3
  • 1
    The desc part may not be as simple as you think. Just for that functionality, the code could be pretty much complicated. Commented Aug 15, 2018 at 7:49
  • 1
    It is a better idea to give the field values in your sort_settings as symbols rather than strings given that the keys in your original hashes are symbols. Commented Aug 15, 2018 at 7:50
  • For order you could always sort in one direction and then use #reverse to get the opposite. Update: actually that will only help when sorting by multiple fields with different orders. Commented Aug 15, 2018 at 10:01

4 Answers 4

3

It's quite challenging to sort strings in desc order using sort_by, it'd be better to use "lower-level" sort method, which sorts by specified comparator using <=> operator. A quick solution for this looks something like:

persons.sort do |a, b|
  comparator = 0

  sort_settings.each do |s|
    a_field = a[s[:field].to_sym]
    b_field = b[s[:field].to_sym]

    comparator = a_field <=> b_field

    comparator = -comparator if s[:order] == "desc"

    break unless comparator == 0
  end

  comparator
end

The block must implement a comparison between a and b, and return -1, when a follows b, 0 when a and b are equivalent, or +1 if b follows a.

Thus, we iterate over sort_settings and compare specified fields using <=>, which returns 1, 0 or -1. If the specified order is desc, we invert the value. If comparator returns something different to zero, we don't need to continue iterations.

Sign up to request clarification or add additional context in comments.

Comments

2

Ignoring the asc/desc functionality and given that the sort keys are given as symbols and are in this format:

sort_settings = [
  :first_name,
  :age,
]

you simply can do:

persons.sort_by{|p| p.values_at(sort_settings)}

Comments

1

Code

def sort_by_settings(persons, sort_settings)
  sort_mult_by_field = sort_settings.each_with_object({}) do |g,h|
    h[g[:field]] = g[:order] == "asc" ? 1 : -1
  end

  longest_string_by_key = persons.each_with_object(Hash.new(0)) do |g,h|
    g.each { |k,v| h[k] = [h[k], g[k].size].max if sort_mult_by_field.key?(k) &&
      v.is_a?(String) }
  end

  sort_by_arr = persons.each_with_object({}) do |g,h|    
    h[g] = sort_mult_by_field.each_with_object([]) do |(f,m),a|
      gv = g[f]
      a <<
      case gv
      when Integer
        m * gv
      when String
        gv.chars.map { |c| m * c.ord }.concat([m * -256]*(longest_string_by_key[f]-gv.size))
      else # rescue...
      end
    end
  end

  persons.sort_by { |g| sort_by_arr[g] }
end

Examples

persons is as defined in the question.

sort_settings = [{field: :first_name, order: "asc"}, {field: :age, order: "desc"}]

sort_by_settings(persons, sort_settings)
  #=> [{:id=>2, :first_name=>"Alexia",  :last_name=>"Reyes",  :age=>70},
  #    {:id=>3, :first_name=>"Anthony", :last_name=>"Nelson", :age=>25},
  #    {:id=>1, :first_name=>"Bill",    :last_name=>"Zamora", :age=>37}]

persons1 = persons + [{ id: 4, first_name: "Alexia", last_name: "Whoosit", age: 71 }]
sort_by_settings(persons1, sort_settings)
  #=> [{:id=>4, :first_name=>"Alexia",  :last_name=>"Whoosit", :age=>71},
  #    {:id=>2, :first_name=>"Alexia",  :last_name=>"Reyes",   :age=>70},
  #    {:id=>3, :first_name=>"Anthony", :last_name=>"Nelson",  :age=>25},
  #    {:id=>1, :first_name=>"Bill",    :last_name=>"Zamora",  :age=>37}]

sort_settings1 = [{field: :first_name, order: "desc"}, {field: :age, order: "asc"}]
sort_by_settings(persons1, sort_settings1)
  #=> [{:id=>1, :first_name=>"Bill",    :last_name=>"Zamora",  :age=>37},
  #    {:id=>3, :first_name=>"Anthony", :last_name=>"Nelson",  :age=>25},
  #    {:id=>2, :first_name=>"Alexia",  :last_name=>"Reyes",   :age=>70}, 
  #    {:id=>4, :first_name=>"Alexia",  :last_name=>"Whoosit", :age=>71}]

Explanation

In the calculations for the first example, the following intermediate values were computed.

sort_mult_by_field
  #=> {:first_name=>1, :age=>-1}

longest_string_by_key
  #=> {:first_name=>7}

sort_by_arr
  #=> {{:id=>1, :first_name=>"Bill",    :last_name=>"Zamora", :age=>37}=>
  #      [[66, 105, 108, 108, -256, -256, -256], -37],
  #    {:id=>2, :first_name=>"Alexia",  :last_name=>"Reyes",  :age=>70}=>
  #      [[65, 108, 101, 120,  105,   97, -256], -70],
  #    {:id=>3, :first_name=>"Anthony", :last_name=>"Nelson", :age=>25}=>
  #      [[65, 110, 116, 104,  111,  110,  121], -25]}

1 Comment

Great job!! The only working correctly with "asc" and "desc"
0

You're going to need a method that transforms a given item into a sort key, using the configuration as a guide:

def build_sort_key_for(item, configuration)
  configuration.map { |entry|
    value = item[entry[:field].to_sym]
    value = -value if entry[:order] == "desc" # this will only work on numeric values
    value
  }
end

Then you just call that in your sort_by:

persons.sort_by!{ |p| build_sort_key_for(p, configuration) }

Making "desc" work for strings is a challenge unto itself, and so left to the reader (or a separate question).

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.