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
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
@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
@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"]]
@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:replaceaction. This only works if an appropriate:on_replaceoption 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}]- Putstrueas 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
endAn 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}}]
@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"
@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"]
@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"]]
@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"]