diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 0937c4f12d..24c7ca0894 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -289,26 +289,29 @@ defmodule Ecto.Changeset do |> Ecto.Changeset.validate_length(...) Besides the basic types which are mentioned above, such as `:boolean` and `:string`, - parameterized types can also be used in schemaless changesets. They implement - the `Ecto.ParameterizedType` behaviour and we can create the necessary type info by - calling the `init/2` function. - - For example, to use `Ecto.Enum` in a schemaless changeset: + embeds and parameterized types can also be used in schemaless changesets. + For parameterized types, you can call `Ecto.ParameterizedType.init/2`. For embeds, + call `Ecto.Embedded.one/2` or `Ecto.Embedded.many/2` accordingly. Here is an example: types = %{ name: :string, - role: Ecto.ParameterizedType.init(Ecto.Enum, values: [:reader, :editor, :admin]) + role: Ecto.ParameterizedType.init(Ecto.Enum, values: [:reader, :editor, :admin]), + profile: Ecto.Embedded.one(Profile) } - data = %{} - params = %{name: "Callum", role: "reader"} + data = %{profile: nil} + params = %{name: "Callum", role: "reader", profile: %{bio: "Hello!"}} changeset = {data, types} - |> Ecto.Changeset.cast(params, Map.keys(types)) + |> Ecto.Changeset.cast(params, [:name, :role]) + |> Ecto.Changeset.cast_embed(:profile) |> Ecto.Changeset.validate_required(...) |> Ecto.Changeset.validate_length(...) + In the example above, `Profile` is an schema that defines a `changeset/2` function. + You may instead pass a custom function to the `:with` option of `cast_embed/3`. + Schemaless changesets make Ecto extremely useful to cast, validate and prune data even if it is not meant to be persisted to the database. @@ -1387,6 +1390,7 @@ defmodule Ecto.Changeset do end relation = relation!(:cast, type, key, Map.get(types, key)) + relation = put_relation_context(relation, data, key) on_cast = Keyword.get_lazy(opts, :with, fn -> Relation.on_cast_default(relation) end) sort = opts_key_from_params(:sort_param, opts, params) drop = opts_key_from_params(:drop_param, opts, params) @@ -1418,9 +1422,7 @@ defmodule Ecto.Changeset do missing_relation(changeset, key, required?, relation, opts) end - update_in(changeset.types[key], fn {type, relation} -> - {type, %{relation | on_cast: on_cast}} - end) + put_in(changeset.types[key], {type, %{relation | on_cast: on_cast}}) end defp cast_params(:many, nil, sort, drop) when is_list(sort) or is_list(drop) do @@ -1517,6 +1519,13 @@ defmodule Ecto.Changeset do "expected `#{name}` to be an #{type} in `#{op}_#{type}`, got: `#{inspect(schema_type)}`" end + defp put_relation_context(%{field: nil, owner: nil} = relation, data, key) do + owner = if is_struct(data), do: data.__struct__, else: nil + %{relation | field: key, owner: owner} + end + + defp put_relation_context(relation, _data, _key), do: relation + defp force_update(changeset, opts) do if Keyword.get(opts, :force_update_on_change, true) do put_in(changeset.repo_opts[:force], true) diff --git a/lib/ecto/embedded.ex b/lib/ecto/embedded.ex index e8c5fb28fb..cefb204a0b 100644 --- a/lib/ecto/embedded.ex +++ b/lib/ecto/embedded.ex @@ -1,6 +1,15 @@ defmodule Ecto.Embedded do @moduledoc """ - The embedding struct for `embeds_one` and `embeds_many`. + Provide embedded functionality to Ecto. + + It provides `one/2` and `many/2` for defining embeds in schemaless changesets. + + ## Struct + + This The embedding struct for `embeds_one` and `embeds_many`. + + This module also provides `one/2` and `many/2` for defining embeds in + schemaless changesets. Its fields are: @@ -33,6 +42,63 @@ defmodule Ecto.Embedded do ordered: true ] + @doc """ + Defines an embeds-one type for a schemaless changeset. + + The field value may be `nil` when there is no existing embed or a struct of + the related schema when there is one. Schemaless changesets do not initialize + default values, so the data should explicitly contain `nil` for an empty + embed. The returned type can be passed in the types map accepted by + `Ecto.Changeset.cast/4` and then cast with `Ecto.Changeset.cast_embed/3`: + + types = %{profile: Ecto.Embedded.one(Profile)} + + {%{profile: nil}, types} + |> Ecto.Changeset.cast(params, []) + |> Ecto.Changeset.cast_embed(:profile) + + The supported option is `:on_replace`, with the same values supported by + `Ecto.Schema.embeds_one/3`. + """ + @spec one(module(), keyword()) :: {:embed, %Embedded{}} + def one(related, opts \\ []) when is_atom(related) do + init_schemaless(:one, related, opts) + end + + @doc """ + Defines an embeds-many type for a schemaless changeset. + + The field value may be an empty list or a list of structs of the related + schema. Schemaless changesets do not initialize default values, so the data + should explicitly contain an empty list when there are no existing embeds. + The returned type can be passed in the types map accepted by + `Ecto.Changeset.cast/4` and then cast with `Ecto.Changeset.cast_embed/3`: + + types = %{posts: Ecto.Embedded.many(Post)} + + {%{posts: []}, types} + |> Ecto.Changeset.cast(params, []) + |> Ecto.Changeset.cast_embed(:posts) + + The supported option is `:on_replace`, with the same values supported by + `Ecto.Schema.embeds_many/3`. + """ + @spec many(module(), keyword()) :: {:embed, %Embedded{}} + def many(related, opts \\ []) when is_atom(related) do + init_schemaless(:many, related, opts) + end + + defp init_schemaless(cardinality, related, opts) do + case Keyword.keys(opts) -- [:on_replace] do + [] -> + opts = [cardinality: cardinality, related: related] ++ opts + {:embed, init(opts)} + + [option | _] -> + raise ArgumentError, "invalid option #{inspect(option)} for #{cardinality}/2" + end + end + ## Parameterized API # We treat even embed_many as maps, as that's often the @@ -49,8 +115,10 @@ defmodule Ecto.Embedded do if cardinality == :one, do: @embeds_one_on_replace_opts, else: @on_replace_opts unless opts[:on_replace] in on_replace_opts do + field = if field = opts[:field], do: " for #{inspect(field)}", else: "" + raise ArgumentError, - "invalid `:on_replace` option for #{inspect(Keyword.fetch!(opts, :field))}. " <> + "invalid `:on_replace` option#{field}. " <> "The only valid options are: " <> Enum.map_join(on_replace_opts, ", ", &"`#{inspect(&1)}`") end diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index b293e11e5b..6e9e3ce983 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -1600,9 +1600,8 @@ defmodule Ecto.Schema do @doc ~S""" Indicates an embedding of a schema. - The current schema has zero or one records of the other schema embedded - inside of it. It uses a field similar to the `:map` type for storage, - but allows embeds to have all the things regular schema can. + The current schema keeps zero or a single record of the embedded schema + directly inside of it. You must declare your `embeds_one/3` field with type `:map` at the database level. @@ -1782,8 +1781,8 @@ defmodule Ecto.Schema do @doc ~S""" Indicates an embedding of many schemas. - The current schema has zero or more records of the other schema embedded - inside of it. Embeds have all the things regular schemas have. + The current schema keeps zero or more records of the embedded schema + directly inside of it. It is recommended to declare your `embeds_many/3` field with type `:map` in your migrations, instead of using `{:array, :map}`. Ecto can work with @@ -2117,21 +2116,24 @@ defmodule Ecto.Schema do if inserted_at do opts = if source = timestamps[:inserted_at_source], do: [source: source], else: [] - opts = if writable = timestamps[:inserted_at_writable] do - if writable == :never do - raise ArgumentError, ":inserted_at_writable option cannot be set to :never as `inserted_at` will never be populated" - end + opts = + if writable = timestamps[:inserted_at_writable] do + if writable == :never do + raise ArgumentError, + ":inserted_at_writable option cannot be set to :never as `inserted_at` will never be populated" + end - Keyword.put(opts, :writable, writable) - else - opts - end + Keyword.put(opts, :writable, writable) + else + opts + end - opts = if on_writable_violation = timestamps[:inserted_at_on_writable_violation] do - Keyword.put(opts, :on_writable_violation, on_writable_violation) - else - opts - end + opts = + if on_writable_violation = timestamps[:inserted_at_on_writable_violation] do + Keyword.put(opts, :on_writable_violation, on_writable_violation) + else + opts + end Ecto.Schema.__field__(mod, inserted_at, type, opts) end diff --git a/test/ecto/changeset/embedded_test.exs b/test/ecto/changeset/embedded_test.exs index fe467aaf1e..ec5e7f5bf3 100644 --- a/test/ecto/changeset/embedded_test.exs +++ b/test/ecto/changeset/embedded_test.exs @@ -98,6 +98,10 @@ defmodule Ecto.Changeset.EmbeddedTest do end end + defmodule SchemalessAuthor do + defstruct profile: nil, posts: [] + end + defmodule Nested do use Ecto.Schema import Ecto.Changeset @@ -179,6 +183,70 @@ defmodule Ecto.Changeset.EmbeddedTest do |> Changeset.cast_embed(embed, opts) end + describe "schemaless embeds" do + test "casts embeds_one" do + types = %{profile: Embedded.one(Profile)} + + changeset = + {%SchemalessAuthor{}, types} + |> Changeset.cast(%{"profile" => %{"name" => "michal"}}, []) + |> Changeset.cast_embed(:profile) + + assert %{profile: profile} = changeset.changes + assert profile.data == %Profile{} + assert profile.changes == %{name: "michal"} + assert profile.action == :insert + assert changeset.valid? + + assert {:ok, %SchemalessAuthor{profile: %Profile{name: "michal"}}} = + Changeset.apply_action(changeset, :insert) + + assert {:embed, %Embedded{cardinality: :one, field: :profile, owner: SchemalessAuthor}} = + changeset.types.profile + end + + test "casts embeds_many and preserves nested errors" do + types = %{posts: Embedded.many(Post, on_replace: :delete)} + + changeset = + {%{posts: []}, types} + |> Changeset.cast( + %{"posts" => [%{"title" => "valid"}, %{"title" => "no"}]}, + [] + ) + |> Changeset.cast_embed(:posts) + + assert [valid, invalid] = changeset.changes.posts + assert valid.changes == %{title: "valid"} + + assert invalid.errors == [ + title: + {"should be at least %{count} character(s)", + [count: 3, validation: :length, kind: :min, type: :string]} + ] + + refute changeset.valid? + + assert {:embed, + %Embedded{ + cardinality: :many, + field: :posts, + owner: nil, + on_replace: :delete + }} = changeset.types.posts + + changeset = + {%SchemalessAuthor{posts: [%Post{title: "before"}]}, types} + |> Changeset.cast(%{"posts" => [%{"title" => "after"}]}, []) + |> Changeset.cast_embed(:posts) + + assert [%Changeset{action: :update}] = changeset.changes.posts + + assert {:ok, %SchemalessAuthor{posts: [%Post{title: "after"}]}} = + Changeset.apply_action(changeset, :update) + end + end + ## Cast embeds one test "cast embeds_one with valid params" do