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.