- First, you don’t get Ash.
- Then you hate it’s verbose low-level API
- Then you atempt to write everyting declaratively in resource actions
- Then you discover
action :name, :return_type do
run fn input, context ->
.... whatever you want, including direct Ecto queries etc. ....
{:ok, result}
end
end
- 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.