我正在尝试在 Ecto 内的 Rails 中复制我习惯的行为。在 Rails 中,如果我有
Parent
和 Child
模型,并且 Child
属于 Parent
,我可以这样做:Child.create(parent: parent)
。这会将 parent_id
的 Child
属性分配给 parent
的 ID。
这是我的最小 Ecto 示例:
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
这是我想要的行为的示例:
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
我已经尝试了几种不同的方法来让它在 Ecto 中工作,但我总是表现不佳。
child
|> cast(attributes, [:parent_id])
|> put_assoc(:parent, attributes.parent)
这似乎没有分配关联。
我尝试直接铸造协会:
child
|> cast(attributes, [:parent_id, :parent])
但这会产生一个
RuntimeError
告诉我使用 cast_assoc/3
。这似乎不是我真正想要的,但我还是尝试了。
child
|> cast(attributes, [:parent_id])
|> cast_assoc(:parent, with: &Example.Parent.changeset/2)
这会产生
Ecto.CastError
。
最后,我尝试从
:with
中删除 cast_assoc/3
选项。
child
|> cast(attributes, [:parent_id])
|> cast_assoc(:parent)
但是我遇到了同样的错误。
这对于内置的 Ecto 功能似乎是不可能的。为了启用它,我自己编写了:
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
这两个函数使得将
belongs_to
关联添加到变更集变得非常容易。
def changeset(child, attributes) do
child
|> cast(attributes, [])
|> assign_assoc(attributes, :parent)
|> validate_assoc_required(:parent)
end
这种方法可以让您根据需要分配关联。两种形式均适用于
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})
@LandonSchropp 的解决方案的一个小问题是表字段的名称是硬编码的,如果架构具有自定义字段名称,它将无法工作。这是更正它的版本:
@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