Put_embed invalidates changeset for fields not being updated

Hi everyone,

I’ve slowly worked through the many issues I’ve encountered with embedded schemas and I’m finally building some familiarity and comfort with it.

However, I’ve encountered an issue that I’m stumped on, and would love your help if you have the time to take a look.

I have an embedded schema, let’s call it GameState, whose fields I’m partially updating. I’m specifically updating one of the existing Players in the :players field and adding a new Attack to the :attacks field. However, certain fields :defends, :deck, and :discard that I’m not attempting to change or update are producing errors and invalidating my Changeset.

Why is this happening?

The parameters passed to the offending call to changeset are below:

attrs
%{
  players: [
    #Ecto.Changeset<
      action: nil,
      changes: %{
        hand: [
          #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>
        ]
      },
      errors: [],
      data: #MyGame.Game.Player<>,
      valid?: true
    >,
    %MyGame.Game.Player{
      id: "1c75c56f-cce7-4213-a33e-bb80ba40da76",
      name: "otherplayer",
      hand: [
        %MyGame.Game.Card{
          id: "040e6f06-b79e-4787-af97-e3b90019b8fd",
          suit: :club,
          rank: :jack
        },
        %MyGame.Game.Card{
          id: "12815acf-63e2-4037-851a-80fc742a8934",
          suit: :spade,
          rank: :queen
        },
        %MyGame.Game.Card{
          id: "1ec13e46-3556-495e-a8d7-6e62fa85e207",
          suit: :spade,
          rank: :ace
        },
        %MyGame.Game.Card{
          id: "4e0a9126-1658-48d2-a022-f79ad5743cf9",
          suit: :club,
          rank: :three
        },
        %MyGame.Game.Card{
          id: "9e6393ee-cca7-49d7-94ba-ba6992a6bc23",
          suit: :heart,
          rank: :six
        },
        %MyGame.Game.Card{
          id: "21c29731-3bb1-46d8-a2da-9b4ece6ad4d2",
          suit: :heart,
          rank: :two
        },
        %MyGame.Game.Card{
          id: "326268a1-910a-4de1-bc72-0f5c4a4d3a81",
          suit: :club,
          rank: :king
        }
      ]
    }
  ],
  attacks: [
    #Ecto.Changeset<
      action: nil,
      changes: %{
        id: "56167751-6b44-426e-b4a4-c5c5b59568e9",
        card: #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
         data: #MyGame.Game.Card<>, valid?: true>,
        attacker_index: 0,
        defender_index: 1
      },
      errors: [],
      data: #MyGame.Game.Turn.Attack<>,
      valid?: true
    >
  ]
}

However, changeset produces the following invalid Changeset:

Invalid Changeset Result
#Ecto.Changeset<
action: nil,
changes: %{
  trump_card: nil,
  players: [
    #Ecto.Changeset<
      action: :update,
      changes: %{
        hand: [
          #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>,
          #Ecto.Changeset<action: :update, changes: %{}, errors: [],
           data: #MyGame.Game.Card<>, valid?: true>
        ]
      },
      errors: [],
      data: #MyGame.Game.Player<>,
      valid?: true
    >,
    #Ecto.Changeset<action: :update, changes: %{}, errors: [],
     data: #MyGame.Game.Player<>, valid?: true>
  ],
  attacks: [
    #Ecto.Changeset<
      action: :insert,
      changes: %{
        id: "d484401e-7a95-45db-b1b6-ec9682d74d4c",
        card: #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
         data: #MyGame.Game.Card<>, valid?: true>,
        attacker_index: 0,
        defender_index: 1
      },
      errors: [],
      data: #MyGame.Game.Turn.Attack<>,
      valid?: true
    >
  ]
},
errors: [
  defends: {"is invalid", [type: {:array, :map}]},
  discard: {"is invalid", [type: {:array, :map}]},
  deck: {"is invalid", [type: {:array, :map}]}
],
data: #MyGame.Game.GameState<>,
valid?: false
>

The following is the code for the embedded schema:

Embedded Schema
  @primary_key {:id, :binary_id, autogenerate: false}
  embedded_schema do
    embeds_many :deck, Card, on_replace: :delete
    embeds_many :discard, Card, on_replace: :delete

    embeds_one :trump_card, Card, on_replace: :delete

    embeds_many :players, Player, on_replace: :delete
    field :current_player_index, :integer, default: 0

    field :current_turn, Ecto.Enum,
      values: [:init_attack, :init_defend, :open_season],
      default: :init_attack

    embeds_many :attacks, Attack, on_replace: :delete
    embeds_many :defends, Defend, on_replace: :delete
  end

  @doc false
  def changeset(%__MODULE__{} = game_state, attrs) do
      game_state
      |> cast(attrs, [:id, :current_player_index, :current_turn])
      |> put_embed(:players, attrs[:players])
      |> put_embed(:deck, attrs[:deck])
      |> put_embed(:discard, attrs[:discard])
      |> put_embed(:trump_card, attrs[:trump_card])
      |> put_embed(:attacks, attrs[:attacks])
      |> put_embed(:defends, attrs[:defends])
      |> validate_required([:id, :current_player_index, :current_turn])
      |> validate_players
  end

You’re setting embeds_many to nil when they need to be set to an empty list if you don’t want them to have any value. When accessing maps like so some_map[:key], if :key doesn’t exist the value is nil.

See the map docs for access patterns

See the put_embed docs for how to set empty values for many type associations

2 Likes

I don’t think put_embed is the right function for what you want.

Passing nil doesn’t work for embeds_many as you’ve already encountered.

Passing [] would mean “remove the value in this association”, which doesn’t meet your requirement for a partial update.

Only calling put_embed for keys that are present in attrs seems like the best way to use that particular function. Perhaps a helper like:

defp maybe_put_embed(changeset, key, attrs) do
  case attrs[key] do
    nil -> changeset
    value -> put_embed(changeset, key, value)
  end
end

Alternatively, your input is already a map of Changesets - where are those coming from? Usually when I find myself writing code to produce structures like that, I’ve missed a chance to use cast_* functions instead.

I ended up going with a solution like what you suggested, writing a function very similar to your maybe_put_embed.

I’ve missed a chance to use cast_* functions instead.

You’re probably right. I’ll have to take a closer look and see if I can benefit from those functions instead.

Thank you both for your help! I appreciate it very much.