Ecto.Repo.Preload and Dialyzer

Hey all — I’m running into an interesting issue with dialyzer. Below is a trimmed down/generalized example of what I’m running into.
The problem arises as I’m composing some Ecto.Querys.
base_query makes a call to preload([_, u], upload: u) and dialyzer seems to ignore that fact.

defmodule SomeItemContext do 
  import Ecto.Query

  @spec list_item_data(String.t(), pos_integer, non_neg_integer | nil) :: [MyFirstStruct.t()]
  def list_item_data(item_id, limit, offset) do
    do_list_item_data(item_id, limit, offset, MyFirstStruct)
  end
    
  @typep struct_modules_with_upload_assoc :: MySecondStruct | MyFirstStruct
  @spec do_list_item_data(
          String.t(),
          pos_integer(),
          non_neg_integer() | nil,
          struct_modules_with_upload_assoc()
        ) :: [MySecondStruct.t()] | [MyFirstStruct.t()]
  defp do_list_item_data(item_id, limit, offset, item_type) do
    item_id
    |> base_query(item_type)
    |> limit(^limit)
    |> offset(^offset)
    |> order_by([datum, u], u.updated_at)
    |> Repo.all()
  end
  
  @spec base_query(String.t(), struct_modules_with_upload_assoc()) :: Ecto.Query.t()
  defp base_query(item_id, item_type) do
    item_type
    |> join(:inner, [item], u in Upload, on: item.upload_id == u.id)
    |> where([item, _], item.item_id == ^item_id)
    |> where([_, u], u.status == ^Upload.status_upload_complete())
    |> preload([_, u], upload: u) # !!! Note the call to preload here !!!
  end
end

Edit:

@spec download_attachments([%{upload: Upload.t()}]) :: {:ok, [String.t(), String.t()]}
def download_attachements(items) do
 # irrelevant
end

In another module I make a the below call.
Dialyzer warns on: call The function call download_attachments will not succeed.

items = Uploads.list_item_data(item.id, 10, 0)
attachments = download_attachments(items)

However, if I preload the :upload assoc in the calling module, the dialyzer warning is suppressed.

items = 
  item.id 
  |> Uploads.list_item_data(10, 0)
  |> Repo.preload(:upload)

attachments = download_attachments(items)

Repo.preload has a much more general type than the one on list_item_data, so something about the specific types is telling Dialyzer download_attachments won’t succeed. Try moving the preload inside list_item_data and the error should reappear.

Hard to say more without digging into download_attachments.

1 Like

Good point! I edited the original post to contain the definition/spec of download_attachments.
I figured the issue out as I was typing this response. Thank you for making me investigate more closely!

spec download_attachments([%{required(:upload) => Upload.t(), optional(atom()) => any()}])
1 Like

I think that %{a: some_type()} translates to %{required(:a) => some_type()} which is a “closed” map type, meaning it expects only this field. Here’s a simpler example of this warning:

  def hello do
    get_hello(%{a: :b, hello: :world})
  end

  @spec get_hello(%{hello: :world}) :: :world
  def get_hello(map) do
    Map.get(map, :hello)
  end

gives a similar Dialyzer warning.

To have an “open” map type that also captures maps with any other fields, you could add , optional(any()) => any() to the map type, something like this:

  def hello do
    get_hello(%{a: :b, hello: :world})
  end

  @spec get_hello(%{required(:hello) => :world, optional(any()) => any()}) :: :world
  def get_hello(map) do
    Map.get(map, :hello)
  end

edit: There is a note about this in Elixir documentation here

1 Like

Thanks for the link to the docs! I realized the same thing and marked your answer as the solution :smiley:

1 Like