LandonSchropp
LandonSchropp

Reputation: 10234

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.

Upvotes: 1

Views: 1283

Answers (2)

Serge Aleynikov
Serge Aleynikov

Reputation: 11

A little issue with @LandonSchropp's solution is that the the name of the table field is hardcoded, and it won't work if the schema has a custom field name. Here's the version that corrects it:

      @spec assign_assoc(Changeset.t, atom() | String.t, map(), list()) :: Changeset.t
      def assign_assoc(changeset, name, attrs \\ %{}, opts \\ []) when is_map(attrs) and is_list(opts) do
        {:assoc, %{owner_key: id_field, field: name_field}} =
          Ecto.Changeset.assoc_constraint(changeset, name).types[name]
        id_string = Atom.to_string(id_field)
        name_str  = Atom.to_string(name_field)
        validate? = Keyword.get(opts, :required, false)

        cond do
          Map.has_key?(attrs, name_str) ->
            put_assoc(changeset, name_field, attrs[name_str])
            |> maybe_validate(validate?, id_field)

          Map.has_key?(attrs, name_field) ->
            put_assoc(changeset, name_field, attrs[name_field])
            |> maybe_validate(validate?, id_field)

          Map.has_key?(attrs, id_string) ->
            put_change(changeset, id_field, attrs[id_string])

          Map.has_key?(attrs, id_field) ->
            put_change(changeset, id_field, attrs[id_field])

          true ->
            changeset
        end
      end

      @doc """
      Validates that the given association is present either in the changeset's
      changes or its data.
      """
      def validate_required_assoc(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)

        (has_id? or has_value?) && changeset || add_error(changeset, name, "is required")
      end

      defp maybe_validate(changeset, true, name), do: validate_required_assoc(changeset, name)
      defp maybe_validate(changeset,    _,    _), do: changeset

Upvotes: 0

LandonSchropp
LandonSchropp

Reputation: 10234

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})

Upvotes: 1

Related Questions