Ecto, iLike you

In Elixir macros are… discouraged, from the docs:

Macros should only be used as a last resort. Remember that explicit is better than implicit. Clear code is better than concise code.

I get it; working in Ruby where every codebase seems to be playing a game of Schrödinger’s Macro it’s refreshing to work in an ecosystem where the code in your editor is what it is. As such I’ve always tried to embrace minimalism in Elixir. Yet Elixir has macros and there are some really good “last resorts” as mentioned above. I’ve encountered one such case a few times when working in Ecto; out of the box Ecto has at least 99% of anything I could ever want in a database wrapper. Over the years there have been an odd handful of cases where I’ve wanted to extend the functionality of Ecto in one way or another. I’m going to provide a few examples of this in action.

Keep in mind both of these examples, out of context may feel a bit contrived and in neither case is the macro reducing the lines of code. However; if placed in the Repo module these macros would make convenient reusable Ecto functions which could be called throughout the codebase.

Combining iLikes

A few years back I was working on some search functionality for a client. Their products were all made to order for specific customers. Allowing customers to search their order history came with several different query options including the name (first, last, email) of the person who placed the order, those same options of the salesperson who placed the order in their behalf or various attributes about the product. This led to a whole chain of joins and ilikes:

   val ->
        query
        |> join(:left, [order: o], u in assoc(o, :user), as: :user)
        |> join(:left, [order: o], s in assoc(o, :salesperson), as: :sp)
        |> join(:left, [user: u], uc in assoc(u, :user_credentials), as: :uc)
        |> join(:left, [salesperson: sp], sc in assoc(sp, :user_credentials), as: :sc)
        |> join(:left, [order: o], oli in assoc(o, :order_line_items), as: :oli)
        |> join(:left, [oli: oli], prod in assoc(oli, :product_item), as: :prod)
        |> join(:left, [prod: prod], config in assoc(pi, :configuration), as: :config)
        |> join(:left, [config: config], pt in assoc(config, :product_type), as: :pt)
        |> search_function(val)
        |> group_by([order: o], o.id)
 	end 
 end 
 
   defp search_function(query, value) do
    str = "%#{value}%"

    query
    |> where([order: o, uc: uc, sc: sc, conf: conf, pt: pt],
    	ilike(o.po_number, ^str) or
        ilike(uc.email, ^str) or
        ilike(uc.firstname, ^str) or
        ilike(uc.lastname, ^str) or
        ilike(sc.email, ^str) or
        ilike(sc.firstname, ^str) or
        ilike(sc.lastname, ^str) or
        ilike(pt.name, ^str) or
        ilike(pt.design_id, ^str)
        )
  end

It’s readable enough, especially the joins; I’d argue that Ecto’s elegant syntax actually makes this slightly more readable than a standard SQL statement but search_function is a bit much; to the point where Credo started lighting up cyclomatic complexity warnings

There was a better way. Maybe not for all cases; frankly if I hadn’t been warned about the complexity I would have called it day here. I thought it would be fun to condense this and pipe all joins into a smaller search_function somehow with fewer ilikes. This is where one can make good use of macros and Ecto:


  defp search_function(query, value) do
    str = "%#{value}%"

    query
    |> where(
      [order: o, uc: uc, ic: ic, bd: bd, obi: obi],
      multiple_ilike([:email, :firstname, :lastname], uc, str) or
        multiple_ilike([:email, :firstname, :lastname], ic, str) or
        multiple_ilike([:name, :design_id], bd, str) or
        ilike(o.po_number, ^str)
    )
  end
  
  
  defmacro multiple_ilike(keys, schema, value) do
    Macro.expand(ilike_irr(keys, schema, value), __CALLER__)
  end

  defp ilike_irr([key | keys], schema, value) do
    quote do
      ilike(field(unquote(schema), unquote(key)), ^unquote(value)) or
        unquote(ilike_irr(keys, schema, value))
    end
  end
  
  defp ilike_irr([key, key2], schema, value) do
    quote do
      ilike(field(unquote(schema), unquote(key)), ^unquote(value)) or
        ilike(field(unquote(schema), unquote(key2)), ^unquote(value))
    end
  end

Working from the top this takes our lines of code from 9 to 4 still making just as many ilike calls. I would have employed multiple_ilike/3 for orders as well if we were searching more than one column.

It’s fairly standard recursion in Elixir, made only a little more frightening with the quoting and unquoting of macro code and passed in runtime values.

To illustrate lets call it: multiple_ilike([:email, :firstname, :lastname], user_credentials, "%trav%") . The recursive call to ilike_irr/3 needs at least two columns (although one could handle a single column for a safer API). Each iteration uses Ecto’s ilike#2 function it takes your list of columns (keys) the table (schema) and the search string. We unquote these values because they are not part of the macro ie we want them to be whatever we are passing in. The first iteration above is going to add to the query: ilike(field(user_credentials, :email), "%trav%")) fairly straightforward (if you aren’t familiar with Ecto, field/2 is a way of dynamically accessing a column which we need because we the Macro won’t know the schema/keys being passed in at compile time). This initial ilike/2 is appended with an or/2 or in regular SQL “or” and the macro is called again. ilike(field(user_credentials, :firstname), "%trav%") which makes up the right hand side of the or we continue in this fashion until there are only 2 keys left at which point we return both ilike queries having a fully formed statement with multiple ilike ... or ilike ... statements chained together.

I love stuff like this; Ecto already feels like magic (not because it’s obfuscating anything just because of how smooth things are) and this lets me add a few ingredients of my own to the potion.