Ash query composition appreciation post

  1. First, you don’t get Ash.
  2. Then you hate it’s verbose low-level API
  3. Then you atempt to write everyting declaratively in resource actions
  4. Then you discover
action :name, :return_type do
   run fn input, context ->
       .... whatever you want, including direct Ecto queries etc. ....
       {:ok, result}
   end
end
  1. And then you start noticing things in documentation that tell you: you can use Ash.Query here

I’m building a blog engine (how original). One of the main things that a blog has is: pages and tags. So, my question was: given a tag, load all of its pages, given a specific offset and a limit.

For example you have many pages tagged with ash, and a user requests page 2 from those pages. And their pages can additionally have their own tags, and calculations, and aggregations, and things that you may want to load. And you want a total count of those pages etc.

My first attempt

My first attempt was: why not use Ecto?

action :get_pages_for_tag, :map do
   argument :tag_id, :uuid
   argument :limit, :integer
   argument :offset, :integer

   run fn input, _context ->
     query = from tag in Tags,
        where: tag.id = ^input.arguments.tag_id,
        join: page_tags in PageTags,
        on: page_tags.tag_id == tag.id,
        join: page in Pages,
        on: page.id,
        select: page
    ....
  end
end

This poses a few issues: it’s a bit tedious to write out, you still have to add code to load related data (like al the related tags for the loaded pages) etc. There were some issues with haveing macros inside macros inside macros which would confuse the compiler (I think).

Second attempt and epiphany

I decided to play with Ash.Query in the console while skimming through docs. And these things simultaneously struck me:

  • Ash.Query is a slightly higher level of abstraction than pure Ecto
  • You can load related data by simply asking Ash to load it
  • Documentation for Ash.Query.load is this:
  load(query, options)
  @spec load(
      t() | Ash.Resource.t(),
      atom
      | Ash.Query.Calculation.t()
      | list(atom | Ash.Query.Calculation.t())
      | list({atom | Ash.Query.Calculation.t(), term})
  ) :: t()

Yes. It accepts a resource or a query as the first parameter!

So, without getting into the details of the actual resources involved, I give you this beauty:

query =
  MyApp.Blog.Tag
  # we don't need other fields, just id
  |> Ash.Query.select([:id])
  # load a calculaiton that counts no of pages in this tag
  |> Ash.Query.load(:no_of_pages)
  # load pages related to this tag, with limit and offset
  |> Ash.Query.load(
    pages: 
      MyApp.Blog.Page
      |> Ash.Query.load([:tags, :external_scripts])
      |> Ash.Query.limit(paging[:limit])
      |> Ash.Query.offset(paging[:offset])
  )
  # regular filters on the :pages relationship. 
  # these could be added directly in the `load(pages: ...)` query
  |> Ash.Query.filter(
    Ash.Query.expr(
      pages.is_published == true and pages.is_tag == false and
        pages.published_at <= ^NaiveDateTime.utc_now()
    )
  )
  |> Ash.Query.filter(Ash.Query.expr(id == ^input.arguments[:tag_id]))

{:ok, result} = query |> MyApp.Blog.read_one()

Boom. One srather straightforward piece of code to do what I need.

Yes, those can be used in declarative actions, too

  read :pages_for_tag do
     load(pages: 
       MyApp.Blog.Page 
       # only select specific fields
       |> Ash.Query.Select([:id, :title])
       # load relations for pages
       |> Ash.Query.load([:tags, :external_scripts]) ....)

     filter(... your filters ...)
  end

Caveats

Don’t blindly trust code. The query above generates three separate queries that are fetched from the database. This is totally fine in my case, but might not be fine for you.

6 Likes

Great stuff! A couple tips for you:

  1. if you want the tags and then for that given tag the pages that match that criteria, you’d want to do the filtering in the load query. The query you have is more like “tags where they have a published page that matches these criteria”, as opposed to “the tag, loading the pages that match these criteria”

  2. You don’t need to use Ash.Query.expr with Ash.Query.filter. It already wraps it for you.

pages_query =
  MyApp.Blog.Page
  |> Ash.Query.load([:tags, :external_scripts])
  |> Ash.Query.limit(paging[:limit])
  |> Ash.Query.offset(paging[:offset])
  |> Ash.Query.filter(
      pages.is_published == true and pages.is_tag == false and
        pages.published_at <= ^NaiveDateTime.utc_now()
  )

query =
  MyApp.Blog.Tag
  |> Ash.Query.select([:id])
  |> Ash.Query.load(:no_of_pages)
  |> Ash.Query.load(pages: pages_query)
  |> Ash.Query.filter(id == ^input.arguments[:tag_id])
2 Likes

There’s also the Ash Realworld git that has some good example code for posts and tags.

3 Likes

you’d want to do the filtering in the load query.

That’s what I did in the code right after I posted this :slight_smile: Unfortunately, can’t update the post anymore…

You don’t need to use Ash.Query.expr with Ash.Query.filter. It already wraps it for you.

Ah. I would sometimes run into function x is undefined when running without the expr, but can’t remember when. So now I tend to just stick expr everywhere :slight_smile:

Thank you! I took a quick look and… so that’s how you use prepare! :slight_smile:

This happens when you’ve not done require Ash.Query typically.

1 Like

Somehow I’ve had issues even after require’ing Ash.Query :thinking:

:thinking: would need to see some specific example, but typically that is what causes that issue.

1 Like

I’m in step no. 3, the realworld git is definitely a big help

Amazing stuff.
Im in step 3 as well, literally did a lot generic actions with run() and now fighting understanding the Resource Actions in detail (hoping to definitely contribute to DX docs once I feel like im not just blindly making things work)

Maybe we should have a thread of all the docs pain points somewhere so we can gather them and be able clean them up.

Last night I had to dig through discord to find out how to actually use Ash.PubSub but it is made using pubsubs easy-peasy. Took a solid minute there to get the instructions.

Edit: I kind of made a github discussion thread Documentation Pain Points Thread · ash-project/ash · Discussion #776 · GitHub to help out

1 Like

@deviprsd

Great idea! I definitely need to add my thoughts there as well, if I manage to arrange them into a coherent form :slight_smile:

1 Like