Search Filters with Ecto Queries
We have also written an accompanying Livebook for this article that includes the final code for you to play around with.
Use case: articles and authors
Let's say you're building an admin interface for blog articles, and you want to offer a function to filter the articles based on multiple fields like title, summary, or status as well as fields of the associated author. The web form for the filters might look something like this:
If a user fills out one filter form field, only the matching records should be returned. If a form field is left blank, it should be skipped when building the query. So leaving all fields empty should lead to a response that includes all records.
The corresponding Ecto schemas for article and author are defined in the usual way:
In this blog article, we focus on the code to build the correct Ecto Query based on the filter values and omit the code for transforming the raw form data into a struct. If you want to see the complete picture, the accompanying Livebook includes a way to use an embedded_schema for handling the input values. For now, let's assume the filter values are represented by this map:
Why don't we just build a normal query?
The default way of applying this filter to an Ecto.Query would look like this:
While this approach is easily readable, it does not meet the requirement that empty form values should be skipped when building the query. Additionally, if neither <code>author_first_name_eq</code> nor <code>author_last_name_eq</code> is filled, then the <code>join</code> should be skipped as well to make the query cheaper.
Basic filters
Let's start with building a filter for one field. We build one implementation that simply returns the query untouched if the form field was submitted empty, and a second implementation that adds the <code>where</code> condition.
However, if we want to use this approach for multiple fields, it could become cumbersome to repeat the approach multiple times. Luckily, Ecto provides field/2 macro that enables us to build a function that takes the column name as an argument, as shown for an <code>eq</code> and an <code>in</code> filter:
Following this approach, now we can chain the filters:
Filters on associated records
In order to extend the approach to fields on associated records, let's start again with a less dynamic version to show what we want to achieve:
This approach already addresses the issue that the author association should only be joined when necessary, and also prevents joining the association twice by checking if the association has already been made via has_named_binding?/2. In order to make this more dynamic and reusable, we have to turn the <code>:author</code> atom into a parameter:
We can now combine all filter values:
Adding custom filters
While we can support plenty of filters in a dynamic way as described above, there will always be some custom cases that require custom search conditions. Let's say you want to add a "Most Relevant" checkbox to your search form that limits the results to articles that have been published and are newer than one month. In order to support this, we add a <code>most_relevant</code> key to the filter struct. You can now write a function like this (note that you still have to care about omitting the condition if the box is not checked):
Conclusion
We have shown how to transform the user input of a search form into the corresponding Ecto Query by using some lesser known Ecto features like <code>field/2</code> and <code>has_named_binding?/2</code>. We can build helper functions that allow building most filter queries in a nicely readable and chainable way, while still allowing for extending the queries with custom functions.