0

I have a complex multi nested array of hashes like below:

{
  "Food":[
    {
      "id": "01",
      "name":"ABC",
      "branch":"London",
      "platter_cost":"£40.00",
      "Kebab":[
        {
          "name":"chicken",
          "value":"£8.12"
        },
        {
          "name":"lamb",
          "value":"£9.67"
        }
      ],
      "sides":[
        {
          "type":"drinks",
          "options":[
            {
              "id":1,
              "name":"Coke",
              "price":"£4.70"
            },
            {
              "id":2,
              "name":"Pepsi",
              "price":"£2.90"
            },
            {
              "id":3,
              "name":"Tango",
              "price":"£4.00"
            }
          ]
        },
        {
          "type":"chips",
          "options":[
            {
              "id":4,
              "name":"Peri-Peri",
              "price":"£4.00"
            }
          ]
        }
      ]
    },
    {
      "id": "02",
      "name":"XYZ",
      "branch":"Manchester",
      "platter_cost":"£30.00",
      "Kebab":[
        {
          "name":"chicken",
          "value":"£5.22"
        },
        {
          "name":"lamb",
          "value":"£6.35"
        }
      ],
      "sides":[
        {
          "type":"drinks",
          "options":[
            {
              "id":77,
              "name":"coke",
              "price":"£3.70"
            },
            {
              "id":51,
              "name":"Orange",
              "price":"£4.00"
            },
            {
              "id":33,
              "name":"Apple",
              "price":"£2.00"
            }
          ]
        },
        {
          "type":"chips",
          "options":[
            {
              "id":20,
              "name":"peri-peri",
              "price":"£4.00"
            },
            {
              "id":18,
              "name":"cheesy",
              "price":"£3.50"
            }
          ]
        }
      ]
    }
  ]
}

I have a method to return a cost value based on the arguments. Example:

def total_cost(id: "01", options: [1, 4], kebab: 'chicken')
  platter_cost + (sum of)options + cost of chicken kebab
end

Arguments explanation: First argument: id is the main id(company_id), Second argument: options: [1, 4]. 1 and 4 are the id's inside the Side options, The ids are unique so it doesn't matter the options are chips or drinks. Third argument: is the cost of the chicken kebab.

So the output for the id: "01" is £16.82. coke_cost + tango_cost + chicken_kebab_cost

what is the clean and efficient way to get the results?

So far I tried the below but am a bit lost on which way to choose. Thanks in advance.

def dishes
  file = File.read('food.json')
  obj = JSON.parse(file)
  obj['food']
end

def self_hash # Trying to create a single hash like an active record object
  h = {}
  dishes.each do |dish|
    h["id"] = dish["id"]
    h["platter_cost"] = dish["platter_cost"]
    h["kebab"] = dish["kebab"].each{ |k| {"chicken: #{k["chicken"]}", "lamb: #{k["lamb"]}"} }  # Not working
  end
end
3
  • Thanks, @CarySwoveland I have updated my question with the explanation of the arguments and example output. Commented Sep 5, 2019 at 20:59
  • When you say you want to create a "single hash like an active record object" do you want to just extract each dish has its own Hash? And can dishes be altered? This would be an easier problem if dishes were structured differently. Commented Sep 5, 2019 at 21:07
  • Thanks, @Schwern Sorry I thought of creating an active record object would be the best solution and then confused myself. The JSON object is what I have posted and that can't be changed but the dishes method is what I have created and that can be changed. Commented Sep 5, 2019 at 21:12

2 Answers 2

2

This is an awkward data structure to work with. It's unfortunate it can't be changed, but we can do things to make it easier to work with.

First, turn it into a class so we have something to hang behavior off of.

class Dishes
  attr_reader :dishes
  def initialize(dishes)
    @dishes = dishes
  end

Now we need to get the right pieces of dishes. Unfortunately dishes is poorly designed. We can't just do dishes[id] we need to search through Arrays for matches. With a class we can write methods to abstract away working with this awkward data structure.

Let's abstract away having to dig into the Food key every time.

  def menus
    @dishes.fetch(:Food)
  end

Note that it's the Symbol :Food, not the string "Food". "Food":[...] produces a Symbol.

Note that I'm using fetch because unlike [] it will throw a KeyError if Food is not found. This makes error handling much easier. I'll be using fetch consistently through the code.

Also note that the method is called menus because this appears to be a better description of what dishes["Food"] is: a list of menus for various locations.

Now we can search menus for a matching id using Enumerable#find. Again, we abstract this away in a method.

  def menu(id)
    menu = menus.find { |m| m.fetch(:id) == id }
    raise "Can't find menu id #{id}" if !menu
    return Menu.new(menu)
  end

Not only is finding a menu abstracted away, but we also have proper error handling if we can't find it.


Now that we've found the menu we want, we can ignore the rest of the data structure. We have a Menu class just for working with the menu.

class Menu
  attr_reader :menu
  def initialize(menu)
    @menu = menu
  end

We can now fetch the kebabs. Searching an Array is awkward. Let's turn it into a more useful Hash keyed on the name of the kebab.

  # Turn the list of kebabs into a hash keyed on
  # the name. Cache the result.
  def kebabs
    @kebabs ||= menu.fetch(:Kebab).each_with_object({}) { |k,h|
      h[ k[:name] ] = k
    }
  end

Now we can search the Hash of kebabs for matching names using Hash#fetch_values. Note it's names because someone might want to order more than one delicious kebab.

  def find_kebabs(names = [])
    kebabs.fetch_values(*names)
  end

An advantage of this approach is we'll get a KeyError if a kebab does not exist.

Like with the kebabs, we want to turn all the sides into one hash keyed on the ID. Getting all the sides is a bit tricky. They're broken up into several different Arrays. We can use flat_map to flatten the sides into one Array.

  def sides
    # Flatten out the list of sides into one Array.
    # Then turn it into a Hash keyed on the ID
    @sides ||= menu.fetch(:sides).flat_map { |types|
      types.fetch(:options)
    }.each_with_object({}) { |s,h|
      h[ s[:id] ] = s
    }
  end

Now that it's flattened we can search the Hash just like we did with kebabs.

  def find_sides(ids = [])
    sides.fetch_values(*ids)
  end

Now that we have these methods we can find the sides and kebabs. Again, the data structure is working against us. The price is in a string with a £. If we want to total up the prices we need to turn "£4.00" into 4.00

  def price_to_f(price)
    price.gsub(/^\D*/, '').to_f
  end

And where the price is stored is inconsistent. For kebabs it's value and for sides its price. More methods to smooth this over.

  def side_price(side)
    price_to_f(side.fetch(:price))
  end

  def kebab_price(kebab)
    price_to_f(kebab.fetch(:value))
  end

(Note: Kebab and Side could be their own classes with their own price methods)

Finally we can put it all together. Find the items and sum their prices.

  def price(kebabs:[], sides:[])    
    price  = find_kebabs(kebabs).sum { |k| kebab_price(k) }
    price += find_sides(sides).sum { |s| side_price(s) }

    return price
  end

It would look like so.

dishes = Dishes.new(data)
menu = dishes.menu("01")
p menu.price(kebabs: ["chicken"], sides: [1,3])

If any kebabs or sides are not found you get a KeyError.

menu.price(kebabs: ["chicken"], sides: [1,398,3])
test.rb:149:in `fetch_values': key not found: 398 (KeyError)

We can make the error handling a bit more robust by writing up some custom KeyError exceptions.

class Menu
  class SideNotFoundError < KeyError
    def message
      @message ||= "Side not found: #{key}"
    end
  end

  class KebabNotFoundError < KeyError
    def message
      @message ||= "Kebab not found: #{key}"
    end
  end
end

Then we can modify our finder methods to throw these exceptions instead of a generic KeyError.

  def find_sides(ids = [])
    sides.fetch_values(*ids)
  rescue KeyError => e
    raise SideNotFoundError, key: e.key
  end

  def find_kebabs(names = [])
    kebabs.fetch_values(*names)
  rescue KeyError => e
    raise KebabNotFoundError, key: e.key
  end

These more specific errors allow for more robust error handling while maintaining the Menu black box.

begin
  price = menu.price(kebabs: ["chicken"], sides: [1,398,3])

  # more code that depends on having a price
rescue Menu::KebabNotFoundError => e
  # do something when a kabab is not found
rescue Menu::SideNotFoundError => e
  # do something when a side is not found
end

This might seem like overkill, I'm sure someone can come up with some clever compressed code. It's worth it. I work with awkward and inconsistent data structures all the time; a class makes working with them much easier in the long run.

It breaks the problem down into small pieces. These pieces can then be unit tested, documented, given robust error handling, and used to build more functionality.

Here it is all spelled out.

class Dishes
  attr_reader :dishes
  def initialize(dishes)
    @dishes = dishes
  end

  def menus
    dishes.fetch(:Food)
  end

  def menu(id)
    menu = menus.find { |m| m[:id] == id }
    raise "Can't find menu id #{id}" if !menu
    return Menu.new(menu)
  end
end

class Menu
  attr_reader :menu
  def initialize(menu)
    @menu = menu
  end

  def sides
    # Flatten out the list of sides and turn it into
    # a Hash keyed on the ID.
    @sides ||= menu.fetch(:sides).flat_map { |types|
      types.fetch(:options)
    }.each_with_object({}) { |s,h|
      h[ s[:id] ] = s
    }
  end

  # Turn the list of kebabs into a hash keyed on
  # the name.
  def kebabs
    @kebabs ||= menu.fetch(:Kebab).each_with_object({}) { |k,h|
      h[ k[:name] ] = k
    }
  end

  def find_sides(ids = [])
    sides.fetch_values(*ids)
  rescue KeyError => e
    raise SideNotFoundError, key: e.key
  end

  def find_kebabs(names = [])
    kebabs.fetch_values(*names)
  rescue KeyError => e
    raise KebabNotFoundError, key: e.key
  end

  def price_to_f(price)
    price.gsub(/^\D*/, '').to_f
  end

  def side_price(side)
    price_to_f(side.fetch(:price))
  end

  def kebab_price(kebab)
    price_to_f(kebab.fetch(:value))
  end

  def price(kebabs:[], sides:[])    
    price  = find_kebabs(kebabs).sum { |k| kebab_price(k) }
    price += find_sides(sides).sum { |s| side_price(s) }

    return price
  end

  class SideNotFoundError < KeyError
    def message
      @message ||= "Side not found: #{key}"
    end
  end

  class KebabNotFoundError < KeyError
    def message
      @message ||= "Kebab not found: #{key}"
    end
  end
end
Sign up to request clarification or add additional context in comments.

7 Comments

Time on your hands? Interesting and educational solution.
@CarySwoveland I do this sort of thing a lot.
Thank you very much @Schwern really interesting to see and I learned much from your mini-tutorial.
@Schwern One small question really, is that possible to fetch a JSON parsed hash? For example if the above JSON is in a file, the fetch is no more working. Not sure what I am missing.
@Learner Yes. Your example is using Symbols for keys, but when parsed from JSON the keys will be Strings. They are not interchangeable. You can see this if you dump the JSON with p.
|
2
def total_cost(h, id:, options:, kebab:)
  g = h[:Food].find { |g| g[:id] == id }
  g[:Kebab].find { |f| f[:name] == kebab }[:value][1..-1].to_f + 
  g[:sides].sum do |f|
    f[:options].sum { |f| options.include?(f[:id]) ? f[:price][1..-1].to_f : 0 }
  end
end

total_cost(h, id: "01", options: [1, 3], kebab: 'chicken')
  #=> 16.82
total_cost(h, id: "01", options: [1, 3, 4], kebab: 'chicken')
  #=> 20.82

The first step results in

g #=> {:id=>"01", :name=>"ABC", :branch=>"London", :platter_cost=>"£40.00",
  #    :Kebab=>[{:name=>"chicken", :value=>"£8.12"},
  #             {:terms=>"lamb", :value=>"£9.67"}],
  #    :sides=>[{:type=>"drinks",
  #              :options=>[
  #                {:id=>1, :name=>"Coke", :price=>"£4.70"},
  #                {:id=>2, :name=>"Pepsi", :price=>"£2.90"},
  #                {:id=>3, :name=>"Tango", :price=>"£4.00"}
  #              ]
  #             },
  #             {:type=>"chips",
  #              :options=>[
  #                {:id=>4, :name=>"Peri-Peri", :price=>"£4.00"}
  #              ]
  #             }
  #            ]
  #   } 

Note: [].sum #=> 0.

3 Comments

Great. Nicely done. Thank you very much. But one question is why do we need a type argument? The whole sides ids are unique.
How do we know you are only interested in "drinks" and not "chips" or "drinks" and "chips"? "drinks" and "chips" both have the key :type, or are we to assume, as in the example, that the values for :id are different for "chips" and "drinks"?
if we ignore the type, Can't we get the values from their respective id's? The ids are unique. I have updated my question and the example

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.