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