Ruby %i( ) and always use Hash#fetch

Ruby %i( ) and always use Hash#fetch

I see this in the code of others

fields = [ :last_name, :first_name, :order_number, :total, :order_date, :ship_date ]

Please don’t waste :‘s and commas because as of Ruby 2, you can simply do this:

fields = %i( last_name first_name order_number total order_date ship_date )

Now, let’s talk about Hash#fetch. I advocate always using Hash#fetch to pull values out of a hash as endorsed by Avdi Grim. I actually learned this trick from Dan Kubb and very grateful that he taught me it.

Let me give you an example of where Hash#fetch will fail sooner and give us more information than using standard Hash#[].

In this example, we have a report that takes back results from a query and pushes them into a CSV file for download.

query_result_values = Order.detail_report

CSV.generate do |csv|
  csv << [
    'Last Name',
    'First Name',
    'Order Number',
    'Total',
    'Order Date',
    'Ship Date',
  ]

  query_result_values.each do |row|
        csv << [
          row[:last_name],
          row[:first_name],
          row[:order_number],
          row[:total],
          row[:order_date],
          row[:ship_date]
        ]
  end
end

We quickly see that this can be reduced by mapping from a simple fields array:

fields = %i( last_name first_name order_number total order_date ship_date )
query_result_values = Order.detail_report

CSV.generate do |csv|
  csv << [
    'Last Name',
    'First Name',
    'Order Number',
    'Total',
    'Order Date',
    'Ship Date',
  ]

  query_result_values.each do |row|
    csv << fields.map { |field| row[field] }
  end
end

So what’s the problem here? Looks good right? It’s fine until, Dave, the other programmer on the project, gets a feature request to change the First Name/Last Name into a Full Name field. So Dave updates the query to return full_name instead of first_name and last_name. He updates the html file that displays the report BUT he forgets to update this csv method. The tests run and pass. The feature gets deployed.

The problem is the next person who downloads this CSV file will have blank values in the first two columns because row[:last_name] and row[:first_name] will be nil.

However, if we wrote this using Hash#fetch from the beginning, out test would have caught this problem because row.fetch(:last_name) and row.fetch(:first_name) both would have throw a “IndexError: key not found” error.

Here’s a simple rewrite using fetch:

fields = %i( last_name first_name order_number total order_date ship_date )
query_result_values = Order.detail_report

CSV.generate do |csv|
  csv << [
    'Last Name',
    'First Name',
    'Order Number',
    'Total',
    'Order Date',
    'Ship Date',
  ]

  query_result_values.each do |row|
    csv << fields.map { |field| row.fetch(field) }
  end
end

I am probably repeating much of the wisdom already out there but I feel it is important to emphasize my experience with this.