Automatically creating has_one child on parent creation

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.

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

Glad you made it work! :slightly_smiling_face: