Search code examples
elixirphoenix-frameworkecto

Ecto changeset for belongs_to association


I'm trying to replicate behavior I'm used to in Rails inside of Ecto. In Rails, if I had Parent and Child models, and Child belonged to Parent, I could do this: Child.create(parent: parent). This would assign the parent_id attribute of Child to the parent's ID.

Here's my minimal Ecto example:

defmodule Example.Parent do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "parent" do
    has_many :children, Example.Child
  end

  def changeset(parent, attributes) do
    parent |> cast(attributes, [])
  end
end
defmodule Example.Child do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "child" do
    belongs_to :parent, Example.Parent
  end

  def changeset(child, attributes) do
    child
    |> cast(attributes, [:parent_id])
  end
end

And here's an example of the behavior I want:

parent = %Example.Parent{id: Ecto.UUID.generate()}
changeset = Example.Child.changeset(%Example.Child{}, %{parent: parent})

# This should be the parent's ID!
changeset.changes.parent_id 

What I've Tried

I've tried several different approaches to get this to work in Ecto, and I keep coming up short.

child
|> cast(attributes, [:parent_id])
|> put_assoc(:parent, attributes.parent)

This doesn't seem to assign the association.

I tried casting the association directly:

child
|> cast(attributes, [:parent_id, :parent])

But this produces a RuntimeError telling me to use cast_assoc/3. This doesn't really seem to be what I want, but I tried it anyway.

child
|> cast(attributes, [:parent_id])
|> cast_assoc(:parent, with: &Example.Parent.changeset/2)

This produces an Ecto.CastError.

Finally, I tried remove the :with option from cast_assoc/3.

child
|> cast(attributes, [:parent_id])
|> cast_assoc(:parent)

But I got the same error.


Solution

  • This doesn't seem to be possible with the built-in Ecto functions. In order to enable it, I wrote my own:

    defmodule Example.Schema do
      @moduledoc """
      This module contains schema helpers which can be mixed into any schema. In addition, it also
      automatically sets the ID type to a UUID, and uses and imports the standard Ecto modules.
      """
    
      defmacro __using__(_options) do
        quote do
    
          use Ecto.Schema
          import Ecto.Changeset
    
          @doc """
          Allows an association to be assigned to a changeset from the changeset's data with minimal fuss.
          You can either assign the association's ID attribute, or assign the association struct directly.
          """
          def assign_assoc(changeset, attributes = %{}, name) do
            name_string = to_string(name)
            name_atom = String.to_existing_atom(name_string)
            id_string = "#{name_string}_id"
            id_atom = String.to_existing_atom(id_string)
    
            cond do
              Map.has_key?(attributes, name_string) ->
                put_assoc(changeset, name_atom, attributes[name_string])
    
              Map.has_key?(attributes, name_atom) ->
                put_assoc(changeset, name_atom, attributes[name_atom])
    
              Map.has_key?(attributes, id_string) ->
                put_change(changeset, id_atom, attributes[id_string])
    
              Map.has_key?(attributes, id_atom) ->
                put_change(changeset, id_atom, attributes[id_atom])
    
              true ->
                changeset
            end
          end
    
          @doc """
          Validates that the given association is present either in the changeset's changes or its data.
          """
          def validate_assoc_required(changeset, name) do
            # NOTE: The name value doesn't use `get_field` because that produces an error when the
            # association isn't loaded.
            id_value = get_field(changeset, :"#{name}_id")
            name_value = get_change(changeset, name) || Map.get(changeset.data, name)
    
            has_id? = id_value != nil
            has_value? = name_value != nil && Ecto.assoc_loaded?(name_value)
    
            unless has_id? || has_value? do
              add_error(changeset, name, "is required")
            else
              changeset
            end
          end
        end
      end
    end
    

    These two functions make it really easy to add belongs_to associations to a changeset.

    def changeset(child, attributes) do
      child
      |> cast(attributes, [])
      |> assign_assoc(attributes, :parent)
      |> validate_assoc_required(:parent)
    end
    

    This approach lets you assign associations however you’d like. Both forms work with Repo.insert.

    Example.Child.changeset(%Example.Child{}, %{parent: parent})
    Example.Child.changeset(%Example.Child{}, %{parent_id: parent.id})
    Example.Child.changeset(%Example.Child{parent: parent}, %{parent: nil})
    Example.Child.changeset(%Example.Child{parent_id: parent_id}, %{parent_id: nil})