Ecto `change/2` preloading associations?

I’m in the process of updating a project from Elixir 1.13 to 1.16.

On the main branch all tests are passing, however on the 1.16 branch I’m getting a failed test:

     ** (RuntimeError) attempting to cast or change association `cart_items` from `MyApp.Carts.Cart` that was not loaded. Please preload your associations before manipulating them through changesets
     code: assert should_add_shipping?(changeset)
       (ecto 3.10.3) lib/ecto/changeset/relation.ex:81: Ecto.Changeset.Relation.load!/2
       (packsize_data 0.0.1) lib/myapp/carts/shipping.ex:112:

This is the test:

 test "includes shipping", ctxt do
      %{customer: customer, cart: cart} = ctxt
      cart = %{cart | customer: customer}
      changeset = change(cart, %{})

      assert should_add_shipping?(changeset)

I get the error, it’s pretty straightforward, cart_items is an association that’s not preloaded. The weird thing is that on the main branch calling changeset = change(cart, %{}) in the test returns a %Changeset{} where cart_items is already preloaded, however in my updated branch it is not. This happens in the test before any application code is called. Why would that be? I’m actually more confused that this was working before, I see why the test is failing now but how did it ever work‽

I realize without showing the entire codebase it may be difficult to diagnose this, there are very few changes other than updates to Elixir and some dependencies (see below) so I’m curious if something changed in a minor patch of Ecto or there is something obvious that change/2 preloads I’m missing.

Other relevant changes include:
Plug from 1.10.4 to 1.14.0
Plug Cowboy from 2.5.2 to 2.6
Ecto 3.10.2 to Ecto 3.10.3
Ecto SQL 3.10.1 to 3.10.2

Updates to Plug were specified in mix.exs but Ecto was updated by running mix deps.update --all.

First thing I would try is only upgrade Elixir and not the app’s dependencies. Does the test pass then?

Are the items “preloaded” with an empty list, or are there actual items? If it’s empty I suspect there’s a difference in the test ctxt’s cart struct metadata states.

change/2 added support for assocs and embeds in v2.2.0-rc.0 about 7 years ago and has called Ecto.Changeset.Relation.load!/2 since then.

  @doc """
  Loads the relation with the given struct.

  Loading will fail if the association is not loaded but the struct is.
  def load!(%{__meta__: %{state: :built}}, %NotLoaded{__cardinality__: cardinality}) do

  def load!(struct, %NotLoaded{__field__: field}) do
    raise "attempting to cast or change association `#{field}` " <>
          "from `#{inspect struct.__struct__}` that was not loaded. Please preload your " <>
          "associations before manipulating them through changesets"

  def load!(_struct, loaded), do: loaded

  defp cardinality_to_empty(:one), do: nil
  defp cardinality_to_empty(:many), do: []

This function raises if the association isn’t loaded on the parent schema when the parent’s metadata state isn’t :built, otherwise it returns a default empty value depending on the relation’s cardinality.

built_cart = %MyApp.Carts.Cart{}
assoc_items = built_cart.cart_items

assert Ecto.get_meta(built_cart, :state) == :built
assert is_struct(assoc_items, Ecto.Association.NotLoaded)
assert Ecto.Changeset.Relation.load!(built_cart, assoc_items) == []

%{cart: ctxt_cart} = ctxt
assoc_items = ctxt_cart.cart_items

assert Ecto.get_meta(ctxt_cart, :state) == :loaded
assert is_struct(assoc_items, Ecto.Association.NotLoaded)
assert_raise RuntimeError, fn ->
  Ecto.Changeset.Relation.load!(ctxt_cart, assoc_items) == []

1 Like