Elixir

Filters In Phoenix

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

Author image

About Sundi Myint

Sundi was an Engineering Manager at SmartLogic working on projects in Elixir & Flutter, as well as a Co-Host on SmartLogic's podcast ✨Elixir Wizards✨.
  • Baltimore, MD
You've successfully subscribed to SmartLogic Blog
Great! Next, complete checkout for full access to SmartLogic Blog
Welcome back! You've successfully signed in.
Unable to sign you in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.