Run an Ecto Query in an Ash Manual Action


Not getting the data you want from your Ash action? Try a manual action, in Ecto, hombre!

After Many Attempts

I had tried many things. Ash makes things so nice and tidy with its cool, terse macros for everything. But filter(expr()) was failing me. Manual calculations were working, but then I couldn't filter on them in actions. Then I read that raw SQL queries in Ash were possible, along with the admonition to use Ecto to build off the already-started query on the resource. So, I tried Ecto directly in Ash... and it worked!

Manual Ash Actions

To implement a manual action, use the manual macro:

defmodule MyResource do
  # ...
  actions
    read :special_thing do
      manual fn ash_query, ecto_query, context ->
        IO.inspect(ecto_query, label: "ecto_query")
      end
    end
  end
end

If you recompile and call this:

MyResource.special_thing()

You'll see that there's an Ecto.Query that's already been started and passed into this callback.

ecto_query: #Ecto.Query<from p0 in MyResource, as: 0,
 select: struct(m0, [:id, :name])>

Ecto Queries in Ash

Now that you have ecto_query, you can start writing Ecto-style queries, not using Ash.Query or Ash expr.

This is possible because, as the docs for AshPostgres.Repo say:

This repo is a thin wrapper around an Ecto.Repo.

So let's write some Ecto queries:

# ...
read :special_thing do
  manual fn _ash_query, ecto_query, _context ->
    # This query is doable in an expr() and doesn't require a manual action.
    # My actual query is distractingly-big for this example.
    updated_query = 
      from mr in ecto_query,
        join: f in assoc(mr, :floop),
        join: ft in assoc(mr, :floop_thing),
        where: ft.wow_factor == "mega"
    # ...
  end
end

Now how to execute the query? Well, AshPostgres.Repo is a wrapper around Ecto.Repo, right? Somewhere, you should have a repo.ex for Ash that looks in part like:

defmodule MyRepo do
  use AshPostgres.Repo, otp_app: :my_app
  # ...
end

So let's use that repo to execute our query:

MyRepo.all(updated_query)

But we need to return {:ok, [list, of, results]} from this manual action callback:

{:ok, MyRepo.all(updated_query)}

But what about the potenial error case? Ash.Query.read will return {:ok, results} or {:error, reason}. But Ecto.Repo.all throws an exception in the error case, so we need a try/rescue block:

try do
  {:ok, MyRepo.all(updated_query)}
rescue
  e ->
    {:error, e}
end

The Entire Solution

At this point, we should be golden. We can define our action in a manual callback, we can define our data set with a flexible Ecto query, and we can return that for the action.

Here's all the relevant code together:

defmodule MyResource do
  # ...
  import Ecto.Query

  actions
    read :special_thing do
      manual fn ash_query, ecto_query, context ->
        updated_query = 
          from mr in ecto_query,
            join: f in assoc(mr, :floop),
            join: ft in assoc(mr, :floop_thing),
            where: ft.wow_factor == "mega"
        try do
          {:ok, MyRepo.all(updated_query)}
        rescue
          e ->
            {:error, e}
        end
      end
    end
  end
end

Carry on, and act manually!

Optional: Only One Select Error

When we join to the ecto_query query, we don't have to select again. Our resource is already selected. If we select again, Ecto growls at us:

** (Ecto.Query.CompileError) only one select expression is allowed in query
    (my_app 0.1.0) lib/my_app/my_domain/resources/my_resource.ex:166: MyResource.manual_0_generated_05E2D9A32863AE2D58B94772EFC75701/3
    (ash 3.3.3) lib/ash/actions/read/read.ex:2440: Ash.Actions.Read.run_query/4
    ...

Change a query that might look like this:

updated_query = 
  from mr in ecto_query,
    # joins and wheres...
    select: mr

To this:

updated_query = 
  from mr in ecto_query,
    # joins and wheres...