Equivalent of `has_one through` in Ash

In my application, I have three resources: Device, SystemModelPartNumber and SystemModel. Each Device belongs to a SystemModelPartNumber via its part_number column. A SystemModelPartNumber belongs to a SystemModel via system_model_id. A SystemModel has many SystemModelPartNumbers.

This are the relevant parts of the Ash resources

defmodule Device do
  # ...
  relationships do
    belongs_to :system_model_part_number, SystemModelPartNumber do
      attribute_type :string
      source_attribute :part_number
      destination_attribute :part_number
    end
  end
  # ...
end

defmodule SystemModelPartNumber do
  attributes do
    attribute :part_number, :string, allow_nil?: false
  end

  relationships do
    belongs_to :system_model, SystemModel
  end

  identities do
    identity :part_number, [:part_number]
  end
end

# def SystemModel ...

When I was using Ecto, I was exposing SystemModel using has_one through: [:system_model_part_number, :system_model].

How can achieve a similar result using Ash?

For now I tried:

  • Using a calculation
  calculations do
    calculate :system_model, :struct, expr(system_model_part_number.system_model) do
      constraints instance_of: SystemModel
    end
  end

but the DB query fails with:

* ** (Postgrex.Error) ERROR 42846 (cannot_coerce) cannot cast type bigint to jsonb

    query: SELECT d0."id", d0."device_id", d0."name", d0."online", s1."system_model_id"::bigint::jsonb::jsonb FROM "devices" AS d0 LEFT OUTER JOIN "public"."system_model_part_numbers" AS s1 ON d0."part_number" = s1."part_number" WHERE (d0."id"::bigint = $1::bigint) AND (d0."tenant_id"::bigint = $2::bigint)
  • Using an aggregate (first): doesn’t work for belongs_to

The next option I’ve wanted to try was a manual has_one, but I want to check if I’m overcomplicating things before.

Right now, a manual has_one is the way. Really just need to add a through option to has_many and has_one. It wouldn’t even be that difficult at this point TBH. The only thing is that it wouldn’t be compatible with manage_relationship, but I think that is to be expected.

1 Like

I’ve tried to do it like this:

defmodule MyApp.Devices.Device.ManualRelationships.SystemModel do
  use Ash.Resource.ManualRelationship
  require Ash.Query

  alias MyApp.Devices

  @impl true
  def load(devices, _opts, %{query: query} = context) do
    device_ids = Enum.map(devices, & &1.id)

    related_system_models =
      query
      |> Ash.Query.filter(part_numbers.devices.id in ^device_ids)
      |> Ash.Query.load(part_numbers: [:devices])
      |> Devices.read!()

    device_id_to_system_model =
      related_system_models
      |> Enum.flat_map(fn system_model ->
        system_model.part_numbers
        |> Enum.flat_map(fn part_number ->
          Enum.map(part_number.devices, &{&1.id, system_model})
        end)
      end)

    {:ok, device_id_to_system_model}
  end
end

and it works when there’s a single SystemModel, but it fails if there’s more than one. It seems that Ash.Query.filter(SystemModel, part_numbers.devices.id in ^device_ids) only picks up the first SystemModelPartNumber in part_numbers.

Am I missing some pieces of the expression syntax?

Solved it, I needed to reset the limit because the query included limit: 1 by default (that’s actually mentioned in the docs)

# ...
    related_system_models =
      query
      |> Ash.Query.unset(:limit)
      |> Ash.Query.filter(part_numbers.devices.id in ^device_ids)
      |> Ash.Query.load(part_numbers: [:devices])
      |> Devices.read!()
# ...

Doing this, it works!

1 Like

Something you can also do is generalize that code, if you have another relationship that needs something similar. You can pass options to the manual relationship, like manual {HasOneThrough, through: [:foo, :bar]}