Idiomatic way to access association field

So I have this Type, which has a belong_to association with this OtherType, which can be nil.

I want to write get %OtherType{}.name from Type and it becomes much more complicated than I’d expect.

Here’s my take:

  def other_type_name(type) do
    type
    |> Repo.preload(:other_type)
    |> get_in([Access.key(:other_type)])
    |> case do
      nil -> nil
      %OtherType{name: name} -> name
    end
  end

This is aweful.

I wanted to write: type |> preload |> get_in(:other_type, :name) but it fails if association other_type is not present. Not get_in([Access.key(:other_type), Access.key(:name)]).

I miss so much Ruby’s try method: Type.try(:other_type).try(:name), which forwards the nil value as expected… I still disagree with this comment as the nil value is completely expected.

Thanks

I found this way which I find pretty satisfying, though I’d still be curious to discuss the whole point here.

  def other_type_name(%Type{} = type) do
    typer
    |> Repo.preload(:other_type)
    |> case do
      %Type{other_type: %OtherType{name: name}} -> name
      _ -> nil
    end
  end

Cool thing here is that keys are static and not dynamic, so this is optimized for compilation.

1 Like

Have you tried going an extra level deep with get_in. Something like:

def other_type_name(type) do
    type
    |> Repo.preload(:other_type)
    |> get_in([Access.key(:other_type), Access.key(:name)])
  end

I know that works to navigate nil with regular map keys, though I haven’t used Access.key much

edit to add
Well, the docs suggest that won’t work. However, with a default value in the first Access.key call it could, but it loses some of the elegance that way.

    |> get_in([Access.key(:other_type, %{}), Access.key(:name)])
1 Like

What about:

  def other_type_name(%Type{} = type) do
    type
    |> Repo.preload(:other_type)
    |> case do
      %{other_type: nil} -> nil
      type -> type.other_type.name
    end
  end

Summary of the changes:

  • skips matching on __struct__ again in the case, since it’s already happened in the function head
  • makes the nil case explicit

Also consider making the Repo.preload a responsibility of the caller; doing preloads in helper functions like this can lead to poor performance at scale when other_type_name gets called while looping over a a list of Types.

BUT

Note that unloaded associations need careful thought in that situation; for instance, your original code will silently return nil if the association isn’t loaded. Explicitly matching on other_type: nil means that unloaded associations cause a KeyError at type.other_type.name.

2 Likes

I have a Maybe module which I use for cases like this

defmodule Maybe do
  def map(nil, _cb), do: nil
  def map(value, cb), do: cb.(value)

  ...
end

so that you can do

def other_type_name(type) do
  type
  |> Ecto.assoc(:other_type)
  |> Repo.one()
  |> Maybe.map(& &1.name)
end

If you want to go a step further you can also add

defmodule MyApp.Repo do
  def get_has_one(record, assoc) do
    Repo.one(Ecto.assoc(record, assoc))
  end
end

so that you can do

def other_type_name(type) do
  type
  |> Repo.get_has_one(:other_type)
  |> Maybe.map(& &1.name)
end
2 Likes

I tend to use pattern matching liberally in cases like this. You can pattern match on unloaded associations and then use recursion to produce the correct result:

def other_type_name(%{other_type: %Ecto.Association.NotLoaded{}} = type), do: type |> Repo.preload(:other_type) |> other_type_name

def other_type_name(%{other_type: nil}), do: nil

def other_type_name(%{other_type: %OtherType{name: name}}), do: name

I should mention that there is a danger of missing n+1 problems when “automatically” performing preloads like this because if you loop through a collection and call this method on each one you won’t get an error that essentially is telling you to add the preload to the collection query. Note this problem affects your original implementation as well.

Personally I prefer to use specific tools to id/monitor performance issues, not app logic. Just make sure your query generation code has all the includes it needs.

Thanks all, I like your solutions too, though it seems like there isn’t an idiomatic way to do that, and it’s a matter of preferences.

I understand the N+1 query issue, but it won’t be an issue in my case: for me if there is a query that loads multiple instances, it must include the preload of all required associations, and if it is missing it is the caller responsibility, not the callee that was too permissive.

Regarding the double type matching, I thought this would not impact performances, but if it does, then I would remove it too.