It’s very common when building a Phoenix JSON API to have an endpoint that accepts a number of optional query parameters. In my applications, I’ve fallen into a pattern of handling these query parameters with a combination of recursive function calls and focused pattern matching. Here’s a rundown of my method.

Imagine we’re building a /foos endpoint and a corresponding :index action:


resources "/foos", FooController, only: [:index]

We want our /foos route to optionally accept search, limit, and offset query parameters:

A naive way of handling every possible combination of these three query parameters might involve writing seven function heads to handle every combination of the three:


@query from f in Foo, order_by: f.id

def index(conn, %{"search" => search}) do
  query = from f in @query, where: ilike(f.display_name, ^"%#{search}%")
  render("index.json", foos: Repo.all(query))
end

def index(conn, %{"limit" => limit}) do
  query = from f in @query, limit: ^limit
  render("index.json", foos: Repo.all(query))
end

def index(conn, %{"offset" => offset}) do
  query = from f in @query, offset: ^offset
  render("index.json", foos: Repo.all(query))
end

def index(conn, %{"search" => search, "limit" => limit}) do
  query = from f in @query,
    where: ilike(f.display_name, ^"%#{search}%"),
    limit: ^limit
    
  render("index.json", foos: Repo.all(query))
end

def index(conn, %{"search" => search, "offset" => offset}) do
  query = from f in @query,
    where: ilike(f.display_name, ^"%#{search}%"),
    offset: ^offset
    
  render("index.json", foos: Repo.all(query))
end

def index(conn, %{"limit" => limit, "offset" => offset}) do
  query = from f in @query, limit: ^limit, offset: ^offset
  render("index.json", foos: Repo.all(query))
end

def index(conn, %{"search" => search, "limit" => limit, "offset" => offset}) do
  query = from f in @query,
    where: ilike(f.display_name, ^"%#{search}%"),
    limit: ^limit,
    offset: ^offset
    
  render("index.json", foos: Repo.all(query))
end

This kind of combinatorial explosion in our code can quickly become impossible to manage. Even this small example was almost too much to fully flesh out.

A better way to handle these types of optional query parameter combinations is to write a function with multiple heads, where each head handles a specific query parameter, or a linked set of query parameters, and then recursively calls itself to handle any other parameters.

Let’s see how that looks with our example:


@query from f in Foo, order_by: f.id
         
def index(conn, params) do
  index(conn, params, @query)
end

def index(conn, %{"search" => search} = params, query) do
  query = from f in query, where: ilike(f.display_name, ^"%#{search}%")
  index(conn, Map.delete(params, "search"), query)
end

def index(conn, %{"limit" => limit} = params, query) do
  query = from f in query, limit: ^limit
  index(conn, Map.delete(params, "limit"), query)
end

def index(conn, %{"offset" => offset} = params, query) do
  query = from f in query, offset: ^offset
  index(conn, Map.delete(params, "offset"), query)
end

def index(conn, _params, query) do
  render(conn, "index.json", foos: Repo.all(query))
end

The first index/2 function is called by our router and immediately calls into index/3 with our base @query. The next three index/3 function heads match on a single query parameter and update the provided query accordingly. Finally, the last index/3 function head is invoked when we’ve exhausted the set of params we can handle, so we run our query and render the results.

This setup can handle any combination of search, limit, and offset, and new parameters can easily be added into the mix.

This pattern has been working well for me, but I’m curious, how do you handle query parameters in your Phoenix applications?