A title for your blog

Learning Elixir, Phoenix and Ash Part 2: Relationships

This gets me every time.

Like most languages Elixir has object style notation for accessing fields of a structure, eg.

jane = %User{name: "Jane", age: 21}
%User{age: 21, name: "Jane"}
jane.age
21

This is the same as Ruby

jane = User.new(name: "Jane", age: 21)
=> #<struct User name="Jane", age=21>
jane.age
=> 21

Ash resources (the Ash equivalent of a Rails model) have relationships (like Rails associations)

defmodule MyApp.Accounts.User do
  ...
  relationships do
    belongs_to :factory, MyApp.Factories.Factory
  end

So if I have a logged in User and I need to get their factory the temptation is to do it like I would in Ruby on Rails; user.factory, but this doesn’t return a factory.

iex(2)> user.factory
#Ash.NotLoaded<:relationship, field: :factory>

It returns an Ash.NotLoaded struct. If we look at the user we can see this

#MyApp.Accounts.User<
     factory: #Ash.NotLoaded<:relationship, field: :factory>,
     __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
     confirmed_at: nil,
     id: "b2f3d266-e183-4585-836d-0f487a3298a0",
     email: #Ash.CiString<"jane@example.com">,
     factory_id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
     aggregates: %{},
     calculations: %{},
     ...
   >

The factory field is #Ash.NotLoaded<:relationship, field: :factory>

If you squint this makes sense and Rails does something similar behind the scenes. Don’t load the expensive objects until they are needed. In Rails associations are lazy loaded, ie the database query doesn’t happen until you enumerate the association. This is mostly hidden from you in Rails. In Ash this appears to be the same but you have to load the association explicitly by calling load

iex(3)> Ash.load(user, :factory)
...
{:ok,
 #MyApp.Accounts.User<
   factory: #MyApp.Factories.Factory<
     orders: #Ash.NotLoaded<:relationship, field: :orders>,
     parts: #Ash.NotLoaded<:relationship, field: :parts>,
     customers: #Ash.NotLoaded<:relationship, field: :customers>,
     __meta__: #Ecto.Schema.Metadata<:loaded, "factories">,
     id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
     name: "Test Factory",
     aggregates: %{},
     calculations: %{},
     ...
   >,
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   confirmed_at: nil,
   id: "b2f3d266-e183-4585-836d-0f487a3298a0",
   email: #Ash.CiString<"jane@example.com">,
   factory_id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
   aggregates: %{},
   calculations: %{},
   ...
 >}

Except this doesn’t give you a Factory. It just loads the Factory into the User. This is also a tuple of {:ok, User} so we pattern match to get the user

iex(6)> {:ok, user} = Ash.load(user, :factory)
...
{:ok,
 #MyApp.Accounts.User<
   factory: #MyApp.Factories.Factory<
     orders: #Ash.NotLoaded<:relationship, field: :orders>,
     parts: #Ash.NotLoaded<:relationship, field: :parts>,
     customers: #Ash.NotLoaded<:relationship, field: :customers>,
     __meta__: #Ecto.Schema.Metadata<:loaded, "factories">,
     id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
     name: "Test Factory",
     aggregates: %{},
     calculations: %{},
     ...
   >,
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   confirmed_at: nil,
   id: "b2f3d266-e183-4585-836d-0f487a3298a0",
   email: #Ash.CiString<"jane@example.com">,
   factory_id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
   aggregates: %{},
   calculations: %{},
   ...
 >}
iex(7)> user.factory
#MyApp.Factories.Factory<
  orders: #Ash.NotLoaded<:relationship, field: :orders>,
  parts: #Ash.NotLoaded<:relationship, field: :parts>,
  customers: #Ash.NotLoaded<:relationship, field: :customers>,
  __meta__: #Ecto.Schema.Metadata<:loaded, "factories">,
  id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
  name: "Test Factory",
  aggregates: %{},
  calculations: %{},
  ...
> 

Now we have the factory!

There is a shortcut that lets you skip the pattern matching step, but will crash if it can’t load the relationship

iex(8)> Ash.load!(user, :factory).factory
...
#MyApp.Factories.Factory<
  orders: #Ash.NotLoaded<:relationship, field: :orders>,
  parts: #Ash.NotLoaded<:relationship, field: :parts>,
  customers: #Ash.NotLoaded<:relationship, field: :customers>,
  __meta__: #Ecto.Schema.Metadata<:loaded, "factories">,
  id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
  name: "Test Factory",
  aggregates: %{},
  calculations: %{},
  ...
>

All together...

User
|> Ash.Query.for_read(:get_by_email, %{email: "jane@example.com"})
|> Ash.Query.load(:factory)
|> Ash.read().factory

I get why it’s done this way but it’s not very ergonomic 😢

Update: 17 Feb 2025

My buddy James, who’s on the Ash team told me the above code can be written as…

User
|> Ash.read(%{email: "jane@example.com"}, action: :get_by_email, load: [:factory])

Nice! It’s not quite as succinct as Rails but it’s getting there.


I tried to figure out if this an Ash thing or an Ecto thing. Ecto is the underlying module that does all the database stuff. The factory is still not loaded.

User |> Ecto.Query.first |> Repo.one
...
#MyApp.Accounts.User<
  factory: #Ash.NotLoaded<:relationship, field: :factory>,
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  confirmed_at: nil,
  id: "b2f3d266-e183-4585-836d-0f487a3298a0",
  email: #Ash.CiString<"jane@example.com">,
  factory_id: "9bb073f3-7277-4767-9a3f-0518e3a7a737",
  aggregates: %{},
  calculations: %{},
  ...
>

This test is inconclusive because everything is done in terms of the Factory which is an Ash Resource so there’s probably some casting/translation going on.

#ash #elixir #phoenix