Background
There are some basic things we all want in our web applications: authentication, proper navigation routing (with active tab highlighting), and the ability to filter large tables of information, to name a few.
While building any of these items from scratch could be an interesting thought experiment, when you are building many Phoenix apps in a row, having a base model to start from can make things a lot easier. You would have a tried and tested pattern that you can rely on, and it can cut development time in half.
At SmartLogic, we built an example Phoenix application called SteinExample. It serves as a template for all of our greenfield projects so that we do not have to re-invent the wheel every time. We have established how we prefer to handle authentication, created a styling system that incorporates Tailwind, designed pagination and filtering, and much more. Today, we're focusing on how we implemented filtering, and going through why we like this solution.
SteinExample
Stein's filtering module only has about six lines of active code. We established a filtering function that takes an Ecto Query, a map of parameters by which you wish to filter, and the module name to which we are trying to apply filters. This gives us a reusable system that can apply to various places in the application if we need to apply filters to various data sets.
defmodule Stein.Filter do
@moduledoc """
Filter an `Ecto.Query` by a map of parameters.
### Example Module
defmodule MyApp.Orders do
@behaviour Stein.Filter
def filter_on_attribute({"name", value}, query) do
value = "%" <> value <> "%"
where(query, [o], like(o.name, ^value))
end
def filter_on_attribute(_, query), do: query
end
"""
@type attribute() :: String.t()
@type callback_module() :: atom()
@type pair() :: {attribute(), value()}
@type params() :: %{attribute() => value()}
@type query() :: Ecto.Query.t()
@type value() :: String.t()
@doc """
This will be reduced from the query params that are passed into `filter/3`
"""
@callback filter_on_attribute(pair(), query()) :: query()
@doc """
Filter a query based on a set of params
This will reduce the query over the filter parameters.
"""
@spec filter(query(), params(), callback_module()) :: query()
def filter(query, nil, _), do: query
def filter(query, filter, module) do
filter
|> Enum.reject(&(elem(&1, 1) == ""))
|> Enum.reduce(query, &module.filter_on_attribute/2)
end
end
It is simple enough to add this module to your Phoenix application wherever you want this, just take note of what you named it so you can call upon it later.
The View
Your filters will essentially act as a form on whichever view page you decide you need filters on. It could be an index.html.eex
file, a show.html.eex
file, or the like.
Your form setup should look something like this:
<%= form_for(@conn, Routes.your_path(@conn, :index), [class: "your css classes", method: :get, as: :filter], fn f -> %>
# filtering here
<% end) %>
Your first parameter will be the @conn
, followed by the Route to your module, followed by any add-ons you might need like CSS classes. Then the important step: you will need to indicate the method type with as: :filter
. You can learn more about why this syntax works by looking into the forms with limited data section of the Phoenix.HTML.Form hex docs.
When setting up the items you want to filter by, you can use a multitude of form inputs to create your filters. For example, a multi-select may look something like this:
<div class="input-group">
<label class="label font-bold">Author</label>
<%= multiple_select(f, :name, @author_names) %>
</div>
The f
is the function variable we assigned while setting up the form, the :name
is the name of our filter (hold on to this, we'll need to match on it later) and the @author_names
is a list of options to select from. That attribute needs to be passed to the view from the controller somehow; so let's talk about what the controller looks like.
The Controller
The module you are filtering by has a corresponding controller where you have established expected behaviors for the index, create, new, etc. Let's take a look at what needs to be in your controller to make sure a filter acts as it should.
First, you want to make sure you are not ignoring parameters being passed into your function head. If we're looking at an index function, let's say this is the function head:
def index(conn, params) do
# controller logic
end
The filter you sent to the controller via the view comes through the params, so you'll want to get the filter out of the parameters.
You will also want to apply your filter to whatever GET call you're using to capture your main dataset. Let's say you're trying to get a dataset of favorite books for a particular user. That might look something like:
def index(conn, params) do
filter = Map.get(params, "filter", %{})
%{user: user} = conn.assigns
%{books: books} = Books.get_for_user(user, filter: filter)
end
And finally, you'll want to make sure you assign your filters to your connection.
def index(conn, params) do
filter = Map.get(params, "filter", %{})
%{user: user} = conn.assigns
%{books: books} = Books.get_for_user(user, filter: filter)
conn
|> assign(:filter, filter)
|> assign(:books, books)
|> assign(:path, Routes.your_path(conn, :index, filter: filter))
|> render("index.html")
end
You might also notice that there is an assignment for a :path
; we are able to use a very similar method for pagination as we do for filtering, and this line allows us to applies filters to our paginated pages. If you're not paginating, nothing to see here!
Now, we used a few things there that don't make sense out of context. So let's give it that! Context time.
The Context
Let's take a look at the query we used to get our favorite books for a user. That function without filtering might look something like this:
defmodule Books do
def get_for_user(user) do
Book
|> where([b], b.user_id == ^user.id)
end
end
This is of course assuming some things; we're assuming that the Book model has been aliased per your project naming conventions, and that a Book has a user_id
field on it. Let us just assume these things are true, and note that we are returning a query here without a transaction.
Now, to fix this up to accommodate for filtering:
defmodule Books do
def get_for_user(user, opts \\ [])
def get_for_user(user, opts) do
opts = Enum.into(opts, %{})
Book
|> where([b], b.user_id == ^user.id)
|> Stein.Filter.filter(opts[:filter], __MODULE__)
end
end
We've added an options field to account for our optional filters, and made a default for get_for_user
so that we can account for when there are no filters at all. In our pipeline, we are calling to our filter function from our Filter module, which if you recall, takes a query, filter parameters, and the module in question. In this case, we are in the Books module, so the third parameter is Books
. The first parameter is being piped to the filter function, and we're getting the list of filters off of opts
for the second parameter.
You will also recall that in our view, we labeled our filter as :name
and need to write a query specifically to filter by names. You can do this wherever I suppose, but I like doing this in the context, underneath my get_for_user
function.
def filter_on_attribute({"name", author_names}, query) do
where(query, [b], b.author_name in ^author_names)
end
The name of our function here was defined in our Stein Filter module as the function to use specifically for filtering. We labeled our filter :name
in the view, so we're passing that name into the first parameter here along with the list of names to match to.
Conclusion
The beauty of this solution is that because of pattern matching, you can stack as many filters as you need on top of each other and they will all work nicely together. Just keep adding filter_on_attribute
functions and you can have what you need to your heart's content.
Remember, this setup is per module, so in our example if you had a separate page apart from Books, say, Authors, you'd need to set up the Authors controller's index function, and the Author's context with the filter functions, etc.
Keep calm and filter on!
Photo by Andrew Neel