Composable Queries with Ecto Part 2
In a prior article, we demonstrated how to write composable queries leveraging Ecto. Today, we’re going to refactor our code to make things even cleaner to make for a more maintainable codebase.
Refactor
In our prior example, we made use of pattern matching to filter the visibility of users based on the role of the user making a request. Combined with filters based on user-supplied criteria, we can narrow our result set.
A manager of one company shouldn’t see the users who belong to another company after all — regardless of who/what they ask for.
As users make these API requests, we made calls into our top-level context module Account
including not only the params
but also the current_user
as determined by Guardian.
With the user in-hand, we can call into the User
module with the params
, the current_user
, and that user’s role
. This gives us everything we need to filter their view of the universe and get the desired results.
Think of this process as a Venn diagram exercise. Each filter is a new set may reduce our matches.
So how can we improve on this design?
You’ll notice in our previous iteration every filter required two versions of the function. One that successfully pattern matches on the params for the given filter. The other doesn’t and simply returns our query
.
This is a good opportunity to refactor.
Background
Remember that parameters coming in from an API call will come in as a map. Let’s leverage some of the power that Map
gives us with the function to_list
.
iex(1)> %{"foo" => "Hello", "baz" => "world"}
|> Map.to_list()
[{"baz", "world"}, {"foo", "Hello"}]
By having a list in-hand, we can make use of reduce/3 to iterate and pattern match on our params. Let’s look at how this plays out in a more realistic example.
# retrieve active users who report to manager with ID of 1
# params = %{"manager_id" => 1, "status" => "active"} defmodule Account.User do def list_users(_user, "admin", params) do
User
|> filter_by_params(params |> Enum.to_list())
end def filter_by_params(query, params) do
Enum.reduce(params, query, fn
{"company_id", company_id}, query ->
filter_by_company(query, company_id)
{"manager_id", manager_id}, query ->
filter_by_manager(query, manager_id)
{"by_gender", gender}, query ->
filter_by_gender(query, gender)
{"by_state", state}, query ->
filter_by_state(query, state)
{"by_title", title}, query ->
filter_by_title(query, title)
{"with_appointment", appointment_type}, query ->
filter_by_appointment_type(query, appointment_type)
# any value that doesn't match
_, query ->
query
end)
end def filter_by_company(query, company_id) do
query
|> join(:inner, [u], c in assoc(u, :company))
|> where([_u, ..., company], company.id == ^company_id)
end
end
In this example, we take our list of two-element tuples and reduce over it. As we iterate, we pattern match on the first element to determine which filter to apply. Any filter that doesn’t match simply returns query.
In doing so we can reduce (pun intended) the number of functions required by half.
Another approach is to simply call a filter_dataset
function that accepts the two element tuple and pattern matches there. This would mean having a number of filters with the same name, but different implementation based on the pattern. Either way, we come out ahead — all thanks to the power of pattern matching and Map.to_list
. Our filter_by_params
shrinks as well.
Here’s how that solution would look.
defmodule Account.User do
def list_users(_user, "admin", params) do
User
|> filter_by_params(params |> Map.to_list())
end def filter_by_params(query, params) do
Enum.reduce(params, query, fn
tuple, query ->
filter_dataset(query, tuple)
end)
end def filter_dataset(query, {"company_id", company_id}) do
query
|> join(:inner, [u], c in assoc(u, :company))
|> where([_u, ..., company], company.id == ^company_id)
end # catch all
def filter_dataset(query, _no_match_tuple), do: queryend
Win-win.
Want to see how this works in our demo application? Take a spin with the branch 30-refactor-2.