Development
Creating Reusable Ecto Code in Elixir
Mika Kalathil
December 21st 2021
Creating Reusable Ecto Code
Creating a highly reusable Ecto API is one of the ways we can create long-term sustainable code for ourselves, while growing it with our application to allow for infinite combination possibilites and high code reusability. If we write our Ecto code correctly, we can not only have a very well defined split between query definition and combination/execution using our context but also have the ability to re-use the queries we design individually, together with others to create larger complex queries.
Splitting our definitions from glue
Our first step is to split out our Ecto query definitions from the schema, we can do this by defining all our queries inside schema files and banning usage of the Ecto.Query
and Ecto.Query.API
functions from within our contexts.
For example, imagine we had an accounts context with a user schema, we’d like to have an api for querying for the user by ID, first name and last name.
To set this up, we can first define our queries in our User
schema:
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Query
schema "users" do
field :first_name, :string
field :last_name, :string
end
def by_id(id), do: where(User, id: ^id)
def by_first_name(first_name), do: where(User, first_name: ^first_name)
def by_last_name(last_name), do: where(User, last_name: ^last_name)
end
Then we can define some interfacing functions in our Accounts
context:
defmodule MyApp.Accounts do
alias MyApp.{Repo, Accounts.User}
def find_user_by_id(id) do
id |> User.by_id |> Repo.all
end
def find_user_by_first_name(name) do
name |> User.by_first_name |> Repo.all
end
def find_user_by_last_name(name) do
name |> User.by_last_name |> Repo.all
end
end
This creates a clear split in responsibilites, the Schemas define the queries, the Contexts glue the queries together and call Repo
. Sticking to this pattern allows us to scale our code and keep it simple in a highly complex system.
Reusing Queries
To enable us to re-use queries, we need to be able to chain them together. We can do this by passing in the current query as the first parameter, and then adding onto it. As an extra step we’ll set a default so they can be used either as the initial part of a query, by itself, or as part of a bigger query.
In our User
schema module, we can change our functions like so:
defmodule MyApp.Accounts.User do
alias MyApp.Accounts.User
...
def by_id(query \\ User, id) do
where(query, id: ^id)
end
def by_first_name(query \\ User, first_name) do
where(query, first_name: ^first_name)
end
def by_last_name(query \\ User, last_name) do
where(query, last_name: ^last_name)
end
end
In turn we can utilize pipes to now filter for first names and last names:
defmodule MyApp.Accounts do
...
def find_by_first_and_last_name(first_name, last_name) do
first_name |> User.by_first_name |> User.by_last_name(last_name) |> Repo.all
end
end
Reusability Over Joins Using as
Now that we’ve made queries more re-usable what about when we need to do a join. For example what if we had a Role
schema like so:
defmodule MyApp.Accounts.Role do
use Ecto.Schema
import Ecto.Query
schema "roles" do
field :code, :string
end
end
And our user could have roles like so:
defmodule MyApp.Accounts.User do
schema "users" do
many_to_many :roles, MyApp.Accounts.Role, join_through: "user_roles"
end
end
Now, say we wanted to query a user by a specific role, we could define a join and then filter by the role.code
:
defmodule MyApp.Accounts.User do
...
def join_roles(query \\ User) do
join(query, :inner, [u], r in assoc(u, :roles))
end
end
This is where it get’s a bit weird, we would need to define a specific function for the query with joined roles since the binding position would be in the second position and would go like this:
defmodule MyApp.Accounts.User do
def by_roles(query \\ join_roles(), role_codes) do
where(query, [user, role], role in ^role_codes)
end
end
While this is great for this situation, it’s not very reusable, instead we can use the as
keyword to create a reusable function for roles like so:
defmodule MyApp.Accounts.User do
...
def join_roles(query \\ User) do
join(query, :inner, [u], r in assoc(u, :roles), as: :role)
end
end
In our Role
schema we could then define a by_code
function that looks for a role
binding:
defmodule MyApp.Accounts.Role do
...
def by_code(query \\ Role, code) do
where(query, [role: r], r.code == ^code)
end
end
But wait there’s an error?!
Yes, we have an error, if we use this as the first function in our query chain, we’re going to pass in Role
which will not have a binding for [role: r]
since we didin’t define an as
keyword. To get around this we should strive to define all our default query
parameters like so:
defmodule MyApp.Accounts.Role do
...
def from(query \\ Role), do: from(query, as: :role)
# This allows us to do the following
def by_code(query \\ from(), code) do
where(query, [role: r], r.code == ^code)
end
end
By doing this we can now use Role.by_code()
as the first function in a query chain and call this all together like so:
def find_user_by_code(code) do
User.join_roles() |> Role.by_code(code) |> Repo.all
end
# We can also use it to filter on the Role table itself
def find_role_by_code(code) do
code |> Role.by_code |> Repo.one
end
By using the as
keyword in combination with a default query \\ from()
parameter, we can define all our filtering functions in our original schema module, and re-use the functions across any joins we may have in the future. This allows us to re-use all our query definitions in different combinations without creating many variants.
Reusable Contexts
Our final goal, is to be able to re-use the functions from within our MyApp.Accounts
context. After all our work our current context function currently looks like the following:
defmodule MyApp.Accounts do
alias MyApp.{Repo, Accounts.User}
def find_user_by_code(code) do
User.join_roles() |> Role.by_code(code) |> Repo.all
end
def find_role_by_code(code) do
code |> Role.by_code |> Repo.one
end
def find_user_by_id(id) do
id |> User.by_id |> Repo.all
end
def find_user_by_first_name(name) do
name |> User.by_first_name |> Repo.all
end
def find_user_by_last_name(name) do
name |> User.by_last_name |> Repo.all
end
def find_by_first_and_last_name(first_name, last_name) do
first_name |> User.by_first_name |> User.by_last_name(last_name) |> Repo.find
end
end
We can start refactoring this by building ourselves a more abstract api to use, and making it so each parameter is combine into a query part, then we can combine them in any way we want, without defining a new function like how we had to for find_by_first_and_last_name
.
To do this we can combine the parameters and add to our query like so:
def find_user(query \\ User.from(), params)
def find_user(query, %{first_name: first_name} = params) do
query
|> User.by_first_name(first_name)
|> find_user(Map.delete(params, :first_name))
end
def find_user(query, %{last_name: last_name} = params) do
query
|> User.by_last_name(last_name)
|> find_user(Map.delete(params, :last_name))
end
def find_user(query, %{id: id} = params) do
query
|> User.by_id(id)
|> find_user(Map.delete(params, :id))
end
def find_user(query, %{role_code: role_code} = params) do
query
|> User.join_roles
|> Role.by_code(role_code)
|> find_user(Map.delete(params, :role_code))
end
def find_user(query, _params) do
Repo.one(query)
end
Doing this we can now call our find_user
function in a few ways:
Accounts.find_user(%{id: 1})
Accounts.find_user(%{first_name: "Bilbo", last_name: "Baggins"})
Accounts.find_user(%{first_name: "Bilbo", role_code: "ADMIN"})
As we can see above, because of the fact we’re adding onto each query based off the available parameters, we’re able to construct a query unique to our parameter set, and build a simple API for ourselves via our Context.
Review
We’ve come a long way from our first naive implementation of schemas and context modules, to sum it up there are a few things we should do for re-usable Ecto code:
-
Keep schemas for query definitions and contexts for query glue and
Repo
execution -
Always default query functions to
from(MySchema, as: :my_schema)
so that we can re-use our functions across joins -
Use
as
bindings from joins and inside query functions such aswhere
so they can be reused across different joins -
Use a default
query
in our context functions so we can build ouselves an abstracted api and have parameter combinations to create queries - This doesn’t cover every case, sometimes we still need a very specific query and we need a seperate function, this is totally fine to do!
Extra Bonus
To encapsulate the majority of this reusability, we can utilize a little library I created named EctoShorts. After installing it we can redefine our schema module like so:
defmodule MyApp.Accounts do
def find_user(params), do: EctoShorts.Actions.find(User, params)
end
Now we can do all the same queries but without all the manual query definitions
Accounts.find_user(%{id: 1})
Accounts.find_user(%{first_name: "Bilbo", last_name: "Baggins"})
# Our role code is slightly different in syntax
Accounts.find_user(%{first_name: "Bilbo", role: %{code: "ADMIN"}})
We can also define an all_users
function like so:
defmodule MyApp.Accounts do
alias EctoShorts.Actions
def find_user(params), do: Actions.find(User, params)
def all_users(params), do: Actions.all(User, params)
end
Ontop of simplifying our code, we gain access to filtering on all fields that are present on the schema, as well as all relations ontop of the schema. EctoShorts also contains a few extras that allow us to filter using limit
, offset
as well as before
or after
a specific time. We can also pass in preloads. To put this all together could look like:
MyApp.Accounts.all_users(%{
first_name: ["Frodo", "Bilbo", "Merry", "Pippin"], # Find any of these
last_name: %{!=: "Baggins"} # Last name not baggins,
role: %{code: "ADMIN"},
last: 5, # Latest 5 items
end_date: NaiveDateTime.utc_now() # Created before today,
preload: [:roles]
})
As a bonus for any filters that don’t fit within the confines of EctoShorts, we can also pass in custom queries as our first paramater:
def find_user(%{top_performer: true} = params) do
Actions.all(User.by_top_performing_user(), Map.delete(params, :top_performer))
end
While we could manually define all these queries and create an api to pull it all together, it’s much easier to have it done for us dynamically, which is where EctoShorts shines! Using this library allows us to create basic CRUD actions with easy and easily utilize the code in the rest of our application in a reusable fashion! One of the uses I personally find the best, is to utilize this MyApp.Accounts.find_user
from within an absinthe resolver, and just pass the params
through directly, this allows us to shortcut the majority of creating basic CRUD interfaces via GraphQL!