A question I had when first learning contexts and Ecto was how to expand the default context API to support more flexible queries. Usually your auto-generated context modules look like:
defmodule App.Accounts do
def list_users()
def get_user(id)
def get_user!(id)
...
end
Which soon becomes a little restrictive when you need to query your database with different filters. How do I find a user by email? How do I list only admin users? So one would create helper functions such as:
defmodule App.Accounts do
def list_users()
def list_admin_users()
def get_user(id)
def get_user!(id)
def get_user_by_email(email)
def get_user_by_email!(email)
...
end
As you can imagine, creating a function for each possible query type doesnât scale very well, and if you introduce GraphQL to your application, where queries tend to be both user-generated and composable (find a user by email which is an admin), this way of organising your context API gets problematic.
Thankfully, the dataloader library, which is often used in conjuction with (Absinthe) GraphQL, shows an interesting pattern that can be used in all cases, not only in the GraphQL context.
dataloader mandates the creation of a query/2
function, that takes a queryable (i.e. an Ecto schema or a query) and a list of filters we want to apply to that queryable. Basically we want to take a list of filters, and build a query out of it.
def query(User, opts \\ [])
# apply filters listed in opts and return an Ecto query
end
We can use this function to make all our context functions accept a list of filters than can be composed together:
defmodule App.Accounts do
def query(User = queryable, opts \\ []) do
Enum.reduce(queryable, opts, fn
{:email, email}, query ->
where(query, [u], u.email == ^email)
{:admin?, admin?}, query ->
where(query, [u], u.admin? == ^admin?)
{:in_group, group_name}, query ->
query
|> join(:inner, [u], g in assoc(u, :group))
|> where(g.name == ^group_name)
end)
end)
def list_users(opts \\ []) do
User
|> query(opts)
|> Repo.all()
end
def get_user(opts \\ []) do
User
|> query(opts)
|> Repo.one()
...
end
All the possible filters are now encapsulated in this query
function, theyâre composable because Ecto is awesome, and our context API remains slim and neat. Want to find an admin user by email? Accounts.get_user(email: "foo@example.com", admin?: true)
. And bonus point, your context module is ready to be integrated with your GraphQL server.
An astute reader might notice that some of the filters tend to be very similar: we often want to filter a field by value (i.e. field == something), we often want to give our context API a way of ordering the results or limiting the number of rows returned. It would be nice to generalise some of these filters in a shared module. What I tend to do in my code is change the query
function like this:
def query(User = queryable, opts \\ []) do
Enum.reduce(queryable, opts, fn
{:in_group, group_name}, query ->
query
|> join(:inner, [u], g in assoc(u, :group))
|> where(g.name == ^group_name)
filter, query -> Helpers.default_query_filters(filter, query)
end)
end)
# in some other helper module...
def default_query_filters({:preload, preloads}, query) do
preload(query, ^preloads)
end
def default_query_filters({:limit, n}, query) do
limit(query, [q], ^n)
end
def default_query_filters({field, value}, query) when is_atom(field) do
where(query, [q], field(q, ^field) == ^value)
end
The query
function in the context now only contains filters and queries specific to the module, whereas the common functionality such as preloading or filtering a field by value are held by a helper module, since the logic is always the same no matter the queryable. And all this work lets us express complex queries very simply: Accounts.list_users(in_group: "Friends", admin?: true, preload: :group)
Iâve never seen this pattern in the wild, and Iâve been using it for a year at work with great success. I hope this helps somebody, and if someone has more free time than me, I imagine the default_query_filters
function could be implemented in a library available for everybody to use.