EctoNestedChangeset (Ecto Nested Changeset v1.1.0)

Copy Markdown View Source

This module defines functions for manipulating nested changesets.

All functions take a path as the second argument. The path is a list of atoms (for field names) and non-negative integers (for indexes in lists). A bare atom is accepted as a shorthand for a single-segment path. Every function validates the path and raises an ArgumentError naming the offending segment and its position if it is empty or holds anything else.

append_at/3, prepend_at/3, insert_at/3 and update_at/3 raise EctoNestedChangeset.NotLoadedError if the path passes through a relation field that is not loaded on a struct that is already persisted, at any depth. If the struct was never persisted, the relation reads as an empty list instead, since there is nothing to preload yet. get_at/2 does not raise: it returns nil for a relation that is not loaded.

Schemas used in the examples

The examples in this module operate on these schemas.

defmodule Category do
  use Ecto.Schema

  schema "categories" do
    has_many :posts, Post, on_replace: :delete
  end
end

defmodule Post do
  use Ecto.Schema

  schema "posts" do
    field :delete, :boolean, virtual: true, default: false
    field :title, :string
    field :tags, {:array, :string}, default: []
    belongs_to :category, Category
    has_many :comments, Comment
  end
end

defmodule Comment do
  use Ecto.Schema

  schema "comments" do
    field :body, :string
    belongs_to :post, Post
  end
end

Summary

Types

Points at a field or a list item within a changeset.

Functions

Appends a value to the field referenced by the path.

Deletes the item at the given path.

Returns a value from a changeset referenced by the path.

Inserts a value into a field at the given position.

Prepends a value to the field referenced by the path.

Updates the value in the changeset at the given position with the given update function.

Types

path()

(since 1.1.0)
@type path() :: [atom() | non_neg_integer()] | atom()

Points at a field or a list item within a changeset.

A path is a list of atoms for field names and non-negative integers for indexes in lists. A bare atom is a shorthand for a single-segment path.

[:posts, 0, :comments]
:posts

Functions

append_at(changeset, path, value)

(since 0.1.0)
@spec append_at(Ecto.Changeset.t(), path(), any()) :: Ecto.Changeset.t()

Appends a value to the field referenced by the path.

The last path segment must be an atom referencing either a to-many relation field or an array field.

Example

iex> %Category{
...>   posts: [
...>     %Post{id: 1, title: "first", comments: []},
...>     %Post{id: 2, title: "second", comments: [%Comment{body: "one"}]}
...>   ]
...> }
...> |> Ecto.Changeset.change()
...> |> append_at([:posts, 1, :comments], %Comment{body: "two"})
...> |> Ecto.Changeset.apply_changes()
...> |> Map.fetch!(:posts)
...> |> Enum.map(fn post -> Enum.map(post.comments, & &1.body) end)
[[], ["one", "two"]]

delete_at(changeset, path, opts \\ [])

(since 0.1.0)
@spec delete_at(Ecto.Changeset.t(), path(), keyword()) :: Ecto.Changeset.t()

Deletes the item at the given path.

The last path segment is expected to be an integer index.

Items added with append_at/3, prepend_at/3 or insert_at/3 and not persisted in the database yet will always be removed from the list, whatever the mode. For structs that are already persisted in the database, there are three different modes.

  • [mode: {:action, :replace}] (default) - The item will be wrapped in a changeset with the :replace action. This only works if an appropriate :on_replace option is set for the relation in the schema.
  • [mode: {:action, :delete}] - The item will be wrapped in a changeset with the action set to :delete.
  • [mode: {:flag, field}] - Puts true as a change for the given field.

An unpersisted struct that the caller put into the parent's :data rather than its :changes is neither of those cases. It is treated like a persisted struct, and Ecto.Repo.update/2 then raises Ecto.NoPrimaryKeyValueError. Removing it from the list instead would not help, since Ecto reconciles the relation against :data and tries to delete it there. Add unpersisted items with append_at/3, prepend_at/3 or insert_at/3, so that they end up in the changes.

The flag option is useful for explicitly marking items for deletion in form parameters. In this case, you would configure a virtual field on the schema and set the changeset action to :delete in the changeset function in case the value is set to true.

schema "pets" do
  field :name, :string
  field :delete, :boolean, virtual: true, default: false
end

def changeset(pet, attrs) do
  pet
  |> cast(attrs, [:name, :delete])
  |> validate_required([:name])
  |> maybe_mark_for_deletion()
end

def maybe_mark_for_deletion(%Ecto.Changeset{} = changeset) do
  if Ecto.Changeset.get_change(changeset, :delete),
    do: Map.put(changeset, :action, :delete),
    else: changeset
end

An unknown option key or an unknown :mode value raises an ArgumentError.

Examples

iex> changeset =
...>   Ecto.Changeset.change(%Category{
...>     posts: [
...>       %Post{id: 1, title: "first"},
...>       %Post{id: 2, title: "second"}
...>     ]
...>   })
iex> changeset
...> |> delete_at([:posts, 1])
...> |> Map.fetch!(:changes)
...> |> Map.fetch!(:posts)
...> |> Enum.map(&{&1.action, &1.data.title})
[replace: "second", update: "first"]
iex> changeset
...> |> delete_at([:posts, 1], mode: {:action, :delete})
...> |> Map.fetch!(:changes)
...> |> Map.fetch!(:posts)
...> |> Enum.map(&{&1.action, &1.data.title})
[update: "first", delete: "second"]
iex> changeset
...> |> delete_at([:posts, 1], mode: {:flag, :delete})
...> |> Map.fetch!(:changes)
...> |> Map.fetch!(:posts)
...> |> Enum.map(&{&1.data.title, &1.changes})
[{"first", %{}}, {"second", %{delete: true}}]

get_at(changeset, path)

(since 0.2.0)
@spec get_at(Ecto.Changeset.t(), path()) :: any()

Returns a value from a changeset referenced by the path.

Example

iex> %Category{
...>   posts: [%Post{title: "first"}, %Post{title: "second"}]
...> }
...> |> Ecto.Changeset.change()
...> |> get_at([:posts, 1, :title])
"second"

insert_at(changeset, path, value)

(since 0.1.0)
@spec insert_at(Ecto.Changeset.t(), path(), any()) :: Ecto.Changeset.t()

Inserts a value into a field at the given position.

The last path segment must be an integer for the position.

Example

iex> %Category{
...>   posts: [
...>     %Post{id: 1, title: "first"},
...>     %Post{id: 2, title: "third"}
...>   ]
...> }
...> |> Ecto.Changeset.change()
...> |> insert_at([:posts, 1], %Post{title: "second"})
...> |> Ecto.Changeset.apply_changes()
...> |> Map.fetch!(:posts)
...> |> Enum.map(& &1.title)
["first", "second", "third"]

prepend_at(changeset, path, value)

(since 0.1.0)
@spec prepend_at(Ecto.Changeset.t(), path(), any()) :: Ecto.Changeset.t()

Prepends a value to the field referenced by the path.

The last path segment must be an atom referencing either a to-many relation field or an array field.

Example

iex> %Category{
...>   posts: [
...>     %Post{id: 1, title: "first", comments: []},
...>     %Post{id: 2, title: "second", comments: [%Comment{body: "one"}]}
...>   ]
...> }
...> |> Ecto.Changeset.change()
...> |> prepend_at([:posts, 1, :comments], %Comment{body: "two"})
...> |> Ecto.Changeset.apply_changes()
...> |> Map.fetch!(:posts)
...> |> Enum.map(fn post -> Enum.map(post.comments, & &1.body) end)
[[], ["two", "one"]]

update_at(changeset, path, func)

(since 0.1.0)
@spec update_at(Ecto.Changeset.t(), path(), (any() -> any())) :: Ecto.Changeset.t()

Updates the value in the changeset at the given position with the given update function.

The path may lead to any field, including arrays and relation fields. Unlike Ecto.Changeset.update_change/3, the update function is always applied, either to the change or to existing value.

If the path points to a field with a simple type, the update function will receive the raw value of the field. However, if the path points to the field of a *-to-many relation, the list values will not be unwrapped, which means that the update function has to handle a list of changesets.

Example

iex> %Category{
...>   posts: [
...>     %Post{id: 1, title: "first"},
...>     %Post{id: 2, title: "second"}
...>   ]
...> }
...> |> Ecto.Changeset.change()
...> |> update_at([:posts, 1, :title], &String.upcase/1)
...> |> Ecto.Changeset.apply_changes()
...> |> Map.fetch!(:posts)
...> |> Enum.map(& &1.title)
["first", "SECOND"]