Say I have the following models
import Ecto.Changeset
import Ecto.Schema
defmodule Parent do
schema "parents" do
has_one :child, Child
end
def changeset(%Parent{} = ch, attrs) do
ch
|> cast(attrs, [])
end
end
defmodule Child do
schema "childs" do
belongs_to :parent, Parent
end
def changeset(%Child{} = ch, attrs) do
ch
|> cast(attrs, [:parent_id])
|> foreign_key_constraint(:parent_id)
end
end
Whenever I create a Parent
, I want a Child
to be created and associated with the Parent
automatically. Whenever I update Parent
, I only want the Child
to be updated if it is a part of the attrs or changeset given to Parent.changeset/2
. Otherwise, the changeset should silently work.
Basically, Child
should only be part of the changeset if it is being updated or if Parent
is being created.
I’ve tried playing around with cast_assoc
and put_assoc
but neither seems to work the way I want it to.
Could you clarify this: what does it mean that cast_assoc
does not do what you want it to do?
AFAIR cast_assoc
will silently ignore missing child unless you explicitly require a child when creating a parent.
If I do:
defmodule Parent do
# ...
def changeset(%Parent{} = ch, attrs) do
ch
|> cast(attrs, [])
|> cast_assoc(:child, required: true)
end
and I provide no child, the changeset fails with {"child":["can't be blank"]}
. I want the child to be created automatically if it’s not already part of the changeset.
I tried adding put_assoc/3
:
defmodule Parent do
# ...
def changeset(%Parent{} = ch, attrs) do
ch
|> cast(attrs, [])
|> put_assoc(:child, %Child{})
|> cast_assoc(:child, required: true)
end
but that gives ** (RuntimeError) attempting to cast or change association `child` from `Parent` that was not loaded. Please preload your associations before manipulating them through changesets
I could maybe do Repo.preload(parent, :child)
before every operation, but that looks like duplication which I’d like to avoid if possible.
Provided you have this parent changeset function:
defmodule Parent do
# ...
def changeset(%Parent{} = ch, attrs) do
ch
|> cast(attrs, [])
|> cast_assoc(:child, required: true)
end
…then something like should be possible:
%Parent{child: %Child{children_fields: ...}, other_parent_fields: ...}}
|> Parent.changeset(your_params_coming_from_the_outside)
|> Repo.insert()
Problem is, I want that behaviour, but implicitly. If the Parent
does not have a child, the child should be created automatically per Child
's default fields. Then, the Parent
changeset should merge the changes on the child into the new or existing Child
. I want to avoid specifying %Parent{child: ...}
before every change, if possible.
Would it be possible to somehow perform a put_assoc
only when the child does not already exist? Then I could do has_one :child, Child, on_replace: :update
.
Yes it is:
defmodule Parent do
# ...
def changeset(%Parent{} = ch, attrs) do
cs =
ch
|> cast(attrs, [])
|> cast_assoc(:child, required: true)
# Conditionally set `child` unless it's already set in the parameters.
case Ecto.Changeset.get_change(cs, :child) do
nil ->
Ecto.Changeset.put_assoc(cs, :child, %Child{"your_desired_default_child_fields_go_here"}
_ ->
cs
end
end
In short, no magic. You can make it implicit by encoding that behaviour in the default changeset
function.
1 Like
Thanks, based off your suggestion I’ve written the following:
defmodule Parent do
schema "parents" do
has_one :child, Child, on_replace: :update
end
def changeset(%Parent{} = ch, attrs) do
ch
|> cast(attrs, [])
|> cast_assoc(:child)
|> changeset_preload(:child)
|> put_assoc_nochange(:child, %{})
end
def changeset_preload(ch, field),
do: update_in(ch.data, &Repo.preload(&1, field))
def put_assoc_nochange(ch, field, new_change) do
case get_field(ch, field) do
nil -> put_assoc(ch, field, new_change)
_ -> ch
end
end
end
changeset_preload/2
is based off this post.
Though I’m not sure if this is the best way to solve this problem, I’m pretty ok with the solution. Unfortunately, I have to put a preload for when put_assoc
is called.
I suggest you use get_change
. If you use get_field
then you will not detect if a child has been supplied in the attrs
variable.
2 Likes
Thanks for your help @dimitarvp. Final solution:
defmodule Parent do
schema "parents" do
has_one :child, Child, on_replace: :update
end
def changeset(%Parent{} = ch, attrs) do
ch
|> cast(attrs, [])
|> cast_assoc(:child)
|> changeset_preload(:child)
|> put_assoc_nochange(:child, %{})
end
def changeset_preload(ch, field),
do: update_in(ch.data, &Repo.preload(&1, field))
def put_assoc_nochange(ch, field, new_change) do
case get_change(ch, field) do
nil -> put_assoc(ch, field, new_change)
_ -> ch
end
end
end
1 Like