Ruby |> Elixir |> Elixir

In this post we'll refactor a bit of Ruby code into Elixir, and then again into even less Elixir to see the benefits we gain from savvy pattern matching. Let's start with the problem.

Imagine you have a database with two tables. A Game table stores information about a video game, such as name, description, and developer_id. And a Developer table stores attributes about the developer of a game. At Versus Systems, if we need to lookup a game developer's name, we might write something like this:

game.developer.name
=> "704Games"

Any seasoned Ruby dev knows the pain of calling name on an attribute that may be missing. If a game record has no developer_id saved, calling name will throw the infamous undefined method error.

 game.developer.name
 NoMethodError: undefined method `name' for nil:NilClass

In Elixir, we see a similar pain point when using Ecto. If an association like developer is not preloaded, calling name will also raise an error.

 game.developer.name
 (KeyError) :name not found in: #Ecto.Association.NotLoaded

Of course, in a well tested application with either Ruby or Elixir, we need not worry about calling chained associations and their attributes. In fact, we generally want to error if the value we're trying to get is mistakenly missing. But not always.

When integrating third-party systems that aren't crucial to the core application, it's helpful to keep both systems as operational as possible. An analytics integration, for example, should always send data but also never crash the application. If we have a game record and failed to preload the associated developer, we shouldn't crash our app or stop sending analytics to the marketing team. But enough business, let's look at some code!

Ruby

class Unpack
  # not my best Ruby

  def self.get(obj, keys)
    keys.reduce(obj) do |val, key|
      extract_value(val, key)
    end
  end

  def self.extract_value(val, key)
    if val.respond_to? key
      val.send(key)
    elsif val.is_a? Hash
      val.fetch(key) { nil }
    else
      val unless key
    end
  end
end

Here's an example of how we might implement a method that can retrieve our desired value in Ruby. From our previous example, we could get a game developer's name by calling our new method.

Unpack.get(game, [:developer, :name])
=> "704Games"

And if game is missing the developer association, we simply get nil.

Unpack.get(game, [:developer, :name])
=> nil

Neat! Let's unpack what's going on in this ruby class. (see what I did there?)

def self.get(obj, keys)
  keys.reduce(obj) do |val, key|
    extract_value(val, key)
  end
end

With our get method we take our keys and iterate over each key with reduce, extracting a value from our object if a value can be found from the given key.

def self.extract_value(val, key)
  if val.respond_to? key
    val.send(key)
  elsif val.is_a? Hash
    val.fetch(key) { nil }
  else
    return val unless key
  end
end

When extracting a value we can first see if it responds to the given key. We do this to avoid undefined method errors for any missing ActiveRecord associations. Our second conditional checks if our latest value is a Hash, and if so we can simply use fetch to grab the value from the key, returning nil if fetch can't find anything. Our third catch is less intuitive. Essentially if we passed both conditionals and we have no more keys, we have our value. But if we still have keys, then we shouldn't try calling them on our value (since it's not an AR object or a Hash), so we return nil.

Unpack.get(game, [:developer, :name, :some_bad_key])
=> nil

So this code does the job. We might be able to clean it up with the #dig method, but it doesn't handle our edge cases. It's somewhat readable and fairly small, however it's doing a lot of work. In addition to line count, we have a lot of conditionals and at least seven method calls!

 Unpack.get
 reduce
 Unpack.extract_value
 respond_to?
 send
 is_a?
 fetch

Let's see how this functionality fares in Elixir.

Elixir

defmodule Unpack do
  # not my best Elixir

  def get(obj, keys) do
    Enum.reduce(keys, obj, fn(key, val) ->
      extract_value(val, key)
    end)
  end

  def extract_value(val, key) when is_map(val) do
    case Map.get(val, key) do
      %Ecto.Association.NotLoaded{} -> nil
      result -> result
    end
  end

  def extract_value(_val, _key), do: nil
end

Following very much the same pattern from our Ruby Unpack class, let's break this down. We have our get/2 function that uses Enum.reduce/3 to iterate over our keys and extract a value if found. If you're new to Elixir, you may be wondering why we have two extract_value/2 functions.

The first function uses the guard clause is_map() to only fire if val is a map or a struct (similar to a hash in Ruby). It will then use a case statement with Elixir's Map.get/2 function, returning nil for any missing Ecto associations, otherwise just the result. If the first function's parameters or guard clause are not matched, the second extract_value/2 function runs to ensure that nil is returned if we failed to match our key to an object.

We now have the Unpack module written in Elixir. It's using a few Elixir tricks, perhaps a little bit easier to read, and we use fewer function calls.

Unpack.get/2
Enum.reduce/3
Unpack.get_value/2
Map.get/2

Of course we're close to the previous seven calls made in Ruby if you count the guard clauses. And it's still somewhat complex with two Unpack functions and a lot of conditional flow control. More importantly, it's not really maximizing the benefits of pattern matching that we see in Elixir. We can do better.

defmodule Unpack.V2 do
  def get(%Ecto.Association.NotLoaded{}, _keys), do: nil
  def get(obj, [key | rest]) when is_map(obj) do
    obj
    |> Map.get(key)
    |> get(rest)
  end

  def get(data, []), do: data
  def get(_data, _keys), do: nil
end

Wow! Only three function calls, even with the guard clause.

Unpack.V2.get/2
Map.get/2
Kernel.is_map/1

Herein lies the real power of Elixir. Simple, short and clear functions that can work together to deliver a punch. Let's step through line by line.

def get(%Ecto.Association.NotLoaded{}, _keys), do: nil 

Here we simply catch any Ecto warnings and return nil. We don't care about the keys, so we prefix this variable with an underscore. It's important we define this function first, since this is an Ecto struct which will pass our is_map guard clause (and we don't want that). For the second function, we'll reformat the styling to help explain.

def get(obj, [key | rest]) when is_map(obj) do
  obj
  |> Map.get(key)
  |> get(rest)
end

We pass object again as our first argument and a list of keys as the second. However, now we match on the list by grabbing the head of the list (key) and the rest of the list (rest). Of course, we won't call this function if we don't ensure we have a map using our guard clause. Very important to avoid this work if obj is not a map!

Inside the function we pass our object to Elixir's Map.get/2 (like Ruby fetch) looking for the key pulled from the head of the list. We pass that result recursively to our function again (Unpack.V2.get/2) along with the rest of our list (minus the head). If our most recent fetched value is not an Ecto struct or a map, we move on to the next function. Ordering matters in Elixir!

 def get(data, []), do: data 

A great simple function. Take data in if we have an empty list and return the data. Wipe hands, we're done. But we need a catch-all.

 def get(_data, _keys), do: nil 

If we don't have an empty list of keys by now, return nil. Remember, by this point our obj or _data is not an Ecto struct, it's not a map, and so it must be a value. But if we got this far, then we didn't match on an empty list of keys above, which means we're still looking for data that we shouldn't be, so we return nil.

Unpack.V2.get(game, [:developer, :name])
=> "704Games"

I hope you've enjoyed this post and are enjoying Elixir! If you're curious about this code or want to see how it works, check out the tiny open source Elixir drop we made on Hex: Unpack. And if you like games and Elixir (or many other languages) check out our job page. Bye for now.