Best practice to implement dynamic filtering for a read action?

Eduardo B. Alexandre
2023-02-05

Eduardo B. Alexandre:

Let’s say I have a page that shows a bunch of products for sale.

Now I want to start giving the user options for him to filter some of the products, let’s say by department, by mix and/or max price, date, etc.

Not only that but I also want to being able to sort by different fields (ex: price, date).

I was wondering what is the best approach to accomplish that using Ash.

I think I could easily do something like run for_read and then do a bunch of Ash.Query.filter or Ash.Query.sort depending on what inputs the user sent.

But for me this doesn’t seem correct, not only I would be moving business logic to my live module (I’m using LiveView, not GraphQL or JsonAPI in this case) instead of having it all being handled directly in my resources, but I would also be “creating” SQL queries outside my resource, which I don’t like because I prefer to limit what the actions other domains can do in my database.

What would you suggest in this case? Is it possible to write an action that would allow me to compose the query depending on its inputs?

ZachDaniel:

<a:wavey:989613883197095946> there are a few ways you can approach this

ZachDaniel:

  1. add arguments to your action, and switch on them in a preparation
    read :front_page do
      argument :name, :string
    
      prepare fn query, _ -> 
        case Ash.Changeset.fetch_argument(query, :name) do
          {:ok, name} -> Ash.Query.filter(query, name == name)
          _ -> query
        end
      end
    end

ZachDaniel:

You can add as many arguments and as many prepare statements as you want

Eduardo B. Alexandre:

Amazing!!

ZachDaniel:

  1. filters support passing in simple maps/keyword lists of data. You could build one up based on inputs, and pass it in. You can use Ash.Query.do_filter/2 which is not a macro in this case (but using Ash.Query.filter/2 is also fine).
    filter = %{
      name: "fred"
      # or
      name: [eq: "fred"]
      # or (this has a bug that I'm pushing a fix to main for)
      contains: {Ash.Filter.TemplateHelpers.ref(:name), "fred"}
    }
    
    Ash.Query.filter(resource, ^filter)
    # or
    Ash.Query.do_filter(resource, filter)

ZachDaniel:

  1. There is a tool for this in AshPhoenix called AshPhoenix.FilterForm

ZachDaniel:

It hasn’t been documented very well, but it can be used to build filters and then instead of “submitting” the form, you say AshPhoenix.FilterForm.filter(query, form) )

ZachDaniel:

And if you want it to filter on type you could say something like:

def handle_event("filter_change", %{"filter" => params}, socket) do
  filter_form = AshPhoenix.FilterForm.validate(socket.assigns.filter_form, params)
  if filter_form.valid? do
    new_data = 
      socket.assigns.query
      |> AshPhoenix.FilterForm.query(params)
      |> MyApi.read!()
    
    {:noreply, assign(socket, filter_form: filter_form, data: new_data}
  else
   {:noreply, assign(socket, filter_form: filter_form)}
  end
end

arosenb2:

Can validate also be invoked inside this? I was looking at validating a field with a regex, but only if the field is present (i.e. not a required field)

ZachDaniel:

validations are not supported in read actions currently, but we could add something similar at some point.

ZachDaniel:

But you can do that validation in a custom preparation