Strange Error using Ecto Changeset cast asserting that struct isn't a map

I am testing a function that builds and executes changesets. Using more direct methods, this changeset has functioned. In this case, I’m getting a very strange cast error that my map isn’t a map.

The changeset:

def changeset(prompt, attrs) do
    IO.inspect(attrs, label: "Changeset Attrs")

    prompt
    |> IO.inspect(label: "Changeset Init")
    |> cast(attrs, [:id, :short, :text])
    |> validate_required([:id, :short, :text])
  end

The error:

** (Ecto.CastError) expected params to be a :map, got: `%Prompt{__meta__: #Ecto.Schema.Metadata<:built, "prompts">, id: 1, short: "Short_A", text: "Gesture", program_id: nil, program: #Ecto.Association.NotLoaded<association :program is not loaded>, inserted_at: nil, updated_at: nil}`

    (ecto 3.10.1) lib/ecto/changeset.ex:665: Ecto.Changeset.cast/4
    (server 0.1.0) prompt.ex:21: Prompt.changeset/2
    (server 0.1.0) lib/util.ex:11: anonymous fn/5 in Util.changeset_over_list/4
    (elixir 1.14.2) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3

The Inspect calls above reveal:

Changeset Attrs: %Prompt{
  __meta__: #Ecto.Schema.Metadata<:built, "prompts">,
  id: 1,
  short: "Short_A",
  text: "Gesture",
  program_id: nil,
  program: #Ecto.Association.NotLoaded<association :program is not loaded>,
  inserted_at: nil,
  updated_at: nil
}
Changeset Init: %Prompt{
  __meta__: #Ecto.Schema.Metadata<:built, "prompts">,
  id: 1,
  short: nil,
  text: nil,
  program_id: nil,
  program: #Ecto.Association.NotLoaded<association :program is not loaded>,
  inserted_at: nil,
  updated_at: nil
}

I honestly have no idea what’s going on here. I even tried guarding the inputs to ensure that maps are entering the changeset.

I may have hit some cornercase for cast, where the error and the message is mismatched but I’m not solid enough in elixir to detect what I could be doing wrong.

If it helps, here is the context where this is getting called:

def changeset_over_list(init_conds, updates, changeset_func, id_key \\ :id) do
    Enum.reduce(updates, [], fn update, list ->
      x =
        get_item_by_id_key(init_conds, Map.get(update, id_key), id_key)
        # |> IO.inspect(label: "Post Get Item By Key")
        |> changeset_func.(update)

      [x | list]
    end)
  end

The error comes from https://github.com/elixir-ecto/ecto/blob/f3311eeb1d49b7dad129fe5a84d290b196403b76/lib/ecto/changeset.ex#L664-L667. You are passing a struct in attrs and ecto expects a plain map.

What if you did this?

def changeset(prompt, attrs) do
  attrs =
    if is_struct(attrs) do
      Map.from_struct(attrs)
    else
      attrs
    end

  prompt
  |> cast(attrs, [:id, :short, :text])
  |> validate_required([:id, :short, :text])
end

We can create a map out of a struct if we happen to pass in a struct

That’s very doable, sure, but I’d still want my functions to be a bit more assertive.

Right, the alternative is to cast to a map before calling a changeset

Yep, that’s what I would do. And then make it obvious by also adding a @spec.

Where are updates being set up? It seems odd that they’re a list of Prompt structs and not plain maps.

I’m building the inputs by hand in iex to test.

I thought structs were maps with an extra field and could be used wherever maps are used. If this is all just a map vs struct problem, this is an easy fix

Structs are maps with :__struct__ key and the Ecto.Changeset.cast function is guarding against structs, accepting only maps as param argument … so yeah, it’s just a map vs. struct problem :smiley:

Thanks everyone for the help!