Rebecca Le

@sevensea.cat

Modelling `through` relationships with Ash

Nov 8, 2024

One thing I've long thought missing from Ash framework is the ability to create a type of "has many through" relationships. These are slightly different from many-to-many relationships, which do exist in Ash already - these are one way relationships, one -> many -> many. The other day I realized that these types of relationships can still be created, using custom relationships - but it's a little bit obscure.

Note: All of the code in this blog post is in a repo on GitHub: https://github.com/sevenseacat/ash_through_relationship_example. Download a copy of the repo and follow along!

The basic data model - countries, regions, and cities

For this example, I'll use something we're hopefully all familiar with - a basic model of the world. Our app will have a resource for a Country, each Country will have many Regions (these could be states, prefectures, provinces, islands, etc.), and each Region will have many Cities.

In Ash, this could look like this:

defmodule Example.World.Country do
use Ash.Resource, # ...

relationships do
has_many :regions, Example.World.Region
end
end
defmodule Example.World.Region do
use Ash.Resource, # ...

relationships do
belongs_to :country, Example.World.Country
has_many :cities, Example.World.City
end
end
defmodule Example.World.City do
use Ash.Resource, # ...

relationshigs do
belongs_to :region, Example.World.Region
end
end

In the example repo, you can populate some data for these resources by running mix run priv/seeds/repo_part1.exs.

In iex, you can use the names of the created countries, regions and cities to load data, eg.

Ash.get!(Example.World.Country, %{name: "Australia"}, load: [regions: [:cities]])

Creating the new relationships

Adding a "has many through" relationship would create a direct link between countries and cities. When added to the Country resource, we'll be able to fetch and work with a list of all of the cities in the country, without iterating over all of the regions and collecting them all in memory.

We can create a custom relationship to model these links, one that doesn't rely on a foreign key like the City resource having a region_id. To remove that foreign key requirement, we can use the no_attributes? option on the relationship:

defmodule Example.World.Country do
use Ash.Resource, # ...

relationships do
has_many :regions, Example.World.Region

has_many :cities, Example.World.City do
no_attributes? true
end
end
end

This means that loading cities on a Country record, will now load all cities in the database. To fix this, we also need to add a filter to the relationship, that will filter the list of cities to only those that belong to a region in this country. Note that the fields in the filter are from the target resource - City - because that's what the relationship is for.

defmodule Example.World.Country do
use Ash.Resource, # ...

relationships do
has_many :regions, Example.World.Region

has_many :cities, Example.World.City do
no_attributes? true
filter expr(region.country_id == parent(id))
end
end
end

This uses a join expression to filter cities by their related region, and also the parent expression function to be able to use attributes from the source Country resource in the filter. It's kind of an inversion of a traditional has many through, that traces from the target resource back to the parent.

You can test this out in iex, using the example repo:

iex(6)> Ash.get!(Example.World.Country, %{name: "New Zealand"}, load: [:cities])
#Example.World.Country<
  cities: [
    #Example.World.City<name: "Auckland", ...>,
    #Example.World.City<name: "Hibiscus Coast", ...>,
    #Example.World.City<name: "Whangārei", ...>,
    #Example.World.City<name: "Hamilton", ...>,
    ...
  ],
  name: "New Zealand",
  ...
>

The generated query for the cities is efficient - it uses inner joins and filters to load all of the correct cities for the specified country.

SELECT DISTINCT c0."id",
s1."id",
s1."name",
s1."population",
s1."region_id"
FROM "public"."countries" AS c0
INNER JOIN LATERAL
(SELECT sc0."id" AS "id",
sc0."name" AS "name",
sc0."population" AS "population",
sc0."region_id" AS "region_id"
FROM "public"."cities" AS sc0
INNER JOIN "public"."regions" AS sr1 ON sc0."region_id" = sr1."id"
WHERE (sr1."country_id"::UUID = c0."id"::UUID)) AS s1 ON TRUE
WHERE (c0."id" = ANY($1)) [["01930c51-66b5-7b2c-bfd5-0ca0ae624a7d"]]

What else can we do with these relationships?

These relationships can be used just like any other relationships, such as in filters and aggregates.

Want to count the number of cities in a country? Add an aggregate to the Country resource that uses the cities relationship:

defmodule Example.World.Country do
# ...

aggregates do
count :city_count, :cities
end
end
iex(4)> Ash.get!(Example.World.Country, %{name: "Australia"}, load: [:city_count])
#Example.World.Country<city_count: 47, name: "Australia", ...>

Want to find the country with the least number of cities?

iex(7)> Example.World.Country.read!(query: [sort: [city_count: :asc], limit: 1])
[#Example.World.Country<city_count: 23, name: "New Zealand", ...>]

Admittedly this isn't the largest sample size, but I hope the idea comes through that these can be used like any other relationship, to really extract useful information from your data.

We need to go deeper

You can add more layers of relationships to go through, and Ash will know exactly what to do. We can add another to the example repo - each City now has a set of Landmarks, notable locations that tourists might like to visit.

defmodule Example.World.Landmark do
use Ash.Resource, ...

relationships do
belongs_to :city, Example.World.City
end
end

We can fetch all of the landmarks in a country, by adding another custom relationship to the Country resource:

defmodule Example.World.Country do
use Ash.Resource, # ...

relationships do
has_many :regions, Example.World.Region

has_many :cities, Example.World.City do
no_attributes? true
filter expr(region.country_id == parent(id))
end

has_many :landmarks, Example.World.Landmark do
no_attributes? true

# "fetch landmarks that have their city's region's `country_id` field
# matching my `id` field"
filter expr(city.region.country_id == parent(id))

# an alternative implementation:
# "fetch landmarks that have a `city_id` value in the list of my related
# cities' `id` values"
# filter expr(city_id in parent(cities.id))
end
end
end

The first version of the filter uses the same idea we did for relating cities. The base layer of the filter (such as city.region.country_id or city_id above) refers to the resource being filtered - in this case, it's a list of Landmark instances, so we can use attributes and relationships that are defined on the Landmark resource.

And the parent layer, parent(id) or parent(cities.id) above, refers to the resource we're currently in, the Country resource. So we can use attributes like id and relationships like cities, that are defined on the Country resource.

What else can `no_attributes?` be used for?

It can be used for a lot of things! Any kind of relationship you can think of, that uses something other than a single static foreign key to link two resources together. It could be:

And probably a lot more!

Using relationships like this can be a bit tricky to wrap your head around, but once you do, it's a real superpower. Let me know about the cool stuff you've used them for!

← Home

Want to talk tech on Bluesky? You can find me at @sevensea.cat!

Built using 11ty and TailwindCSS.