Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions lib/ecto/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 70 additions & 2 deletions lib/ecto/embedded.ex
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 20 additions & 18 deletions lib/ecto/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions test/ecto/changeset/embedded_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading