[Elixir Newbie] Mysterious reappearance of attributes in Ecto.Changeset.cast_assoc

Hi all,

newbie speaking, so I’m probably having some fundamental misunderstanding here…

I’m looking at the Phoenix tutorial under
https://hexdocs.pm/phoenix/contexts.html#cross-context-dependencies
more specifically, the following code snippet:

 def update_cart(%Cart{} = cart, attrs) do
    changeset =
      cart
      |> Cart.changeset(attrs)
      |> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)

and am baffled at where Ecto.Changeset.cast_assoc is getting the attribute values for items from.

When I decorate the code like this

  def update_cart(%Cart{} = cart, attrs) do
    IO.inspect(attrs, label: "Attrs")
    changeset =
      cart
      |> Cart.changeset(attrs)
      |> IO.inspect(label: "Cart Changeset")
      |> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)
      |> IO.inspect(label: "Multi Changeset")

then I get the followng output:

Attrs: %{
  "items" => %{
    "0" => %{"_persistent_id" => "0", "id" => "1", "quantity" => "11"}
  }
}
Cart Changeset: #Ecto.Changeset<action: nil, changes: %{}, errors: [],
 data: #Demo.ShoppingCart.Cart<>, valid?: true>
Multi Changeset: #Ecto.Changeset<
  action: nil,
  changes: %{
    items: [
      #Ecto.Changeset<
        action: :update,
        changes: %{quantity: 11},
        errors: [],
        data: #Demo.ShoppingCart.CartItem<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: #Demo.ShoppingCart.Cart<>,
  valid?: true
>

To me, the sequence looks like this:

  1. Items are in the attrs parameter, as expected.
  2. Again, as expected, they are dropped when constructing the changeset for Cart.
  3. Then, they magically reappear in the next step of the pipe, in the “multi changeset”.

Where were they hidden in the meantime?

Is there any hidden payload in Ecto.Changeset that stores the original attributes?

I was also confused by this back in the day.

The short answer to your question is that there is a params field that is not shown by inspect. The full struct is documented here.

Inspect doesn’t always give you the full picture as some types implement the inspect protocol differently. The inspect protocol implementation for Ecto.Changeset can be found here.

EDIT: Had to fix the first link in case you already clicked it!

4 Likes

Thanks a lot for the helpful answer.

I had looked at the struct documentation. As the params field was labeled as “public” but didn’t show in the inspect, I didn’t suspect it to be the culprit.

That it would be explicitly suppressed in the implementation of the inspect protocol was the missing link.

One has to wonder about the wisdom of such a definition though :wink:

1 Like

It’s a bit confusing at first, yes, and may tie into this thread a bit. I believe the protocol implementation is optimized for debugging over learning. Inspecting changesets is a very common debugging occurrence and having the params always present would be incredibly noisy. When I want to inspect the params, I’m always doing so in the controller/liveview. So in that regard it’s a very sound decision!

If you want to override inspect protocol you can use: IO.inspect(some_struct, structs: false) which will print their underlying raw map with all keys.

2 Likes

Yes, on further contemplation the same occurred to me too.
So, I’ll strive to get out of the newbie phase as swiftly as possible :wink:

Hey, take your time! :smiley: Though judging by your experience in your profile, it’ll probably be pretty quick :slight_smile:

1 Like

Looking a bit further, this behaviour, as well as the access via changeset.params, is actually well documented in
https://hexdocs.pm/ecto/3.10.1/Ecto.Changeset.html#cast_assoc/3
So, it’s probably really a matter of getting a bit more secure with the whole toolset.

1 Like

For what it’s worth, the notation here is intended to tell the reader “this is not the whole data structure”. From the docs for Kernel.inspect:

Note that the Inspect protocol does not necessarily return a valid representation of an Elixir term. In such cases, the inspected result must start with # .

2 Likes