I’m running into an issue saving data for a schema that has associations nested two-levels deep:
Product → ProductOption → ProductOptionValue
Here are the schemas to reproduce the issue:
defmodule AppWeb.Products.Product do
use Ecto.Schema
alias AppWeb.Products.{ProductOption, ProductVariant}
schema "products" do
field(:name, :string)
field(:vendor_id, :string)
has_many(:options, ProductOption, defaults: :has_many_options_defaults)
timestamps()
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :vendor_id])
|> cast_assoc(:options, with: &ProductOption.product_assoc_changeset/2)
|> validate_required([:name, :vendor_id])
end
@doc """
Set default values for product option
"""
def has_many_options_defaults(%ProductOption{} = option, %__MODULE__{} = product) do
%{option | product_id: product.id, vendor_id: product.vendor_id}
end
end
defmodule AppWeb.Products.ProductOption do
use Ecto.Schema
alias AppWeb.Products.{Product, ProductOptionValue}
schema "product_options" do
field(:name, :string)
field(:vendor_id, :string)
field(:delete, :boolean, virtual: true)
belongs_to(:product, Product)
has_many(:values, ProductOptionValue, defaults: :has_many_values_defaults)
timestamps()
end
@doc false
def changeset(product_option, attrs) do
product_option
|> cast(attrs, [:name, :delete, :product_id, :vendor_id])
|> cast_assoc(:values, with: &ProductOptionValue.changeset/2)
|> validate_required([:name, :product_id, :vendor_id])
|> unique_constraint([:name, :product_id, :vendor_id])
end
@doc """
Changeset for creating a new product option when creating or updating a product
"""
def product_assoc_changeset(product_option, attrs) do
changeset = changeset(product_option, attrs)
if get_change(changeset, :delete) do
%{changeset | action: :delete}
else
changeset
end
end
@doc """
Set default values for product option values
"""
def has_many_values_defaults(%ProductOptionValue{} = value, %__MODULE__{} = option) do
%{value | product_option_id: option.id, vendor_id: option.vendor_id}
end
end
defmodule AppWeb.Products.ProductOptionValue do
use Ecto.Schema
alias AppWeb.Products.{ProductOption, ProductVariant}
schema "product_option_values" do
field(:value, :string)
field(:vendor_id, :string)
belongs_to(:product_option, ProductOption, prefix: "popt")
timestamps()
end
@doc false
def changeset(product_option_value, attrs) do
product_option_value
|> cast(attrs, [:value, :product_option_id, :vendor_id])
|> validate_required([:value, :product_option_id, :vendor_id])
|> unique_constraint([:value, :product_option_id, :vendor_id])
end
end
The issue is when adding a new ProductOption
which has new ProductOptionValue
s associated with it, the changeset validation complains because the new values to be added are missing the product_option_id
(rightfully so, because it hasn’t been saved yet). Is it possible to defer setting the product_option_id
on the ProductOptionValue
until after the parent ProductOption
has been saved? I haven’t seen anything in the docs that allows me to hook into the assoc relationship at the right time to add this attribute manually. It also seems like on_replace: :update
is the ideal solution but it doesn’t work with has_many
associations.
Here’s an example of the incoming data from the liveview form:
%{
"options" => %{
"0" => %{
"delete" => "false",
"id" => "popt_01GZ44GDBJW6Z4YKB2BP3FHQVB",
"name" => "Certification",
"values" => %{
"0" => %{"id" => "pval_01GZ44GDBQ975H7PCVV0FG9VG6", "value" => "ASC"},
"1" => %{"id" => "pval_01GZ44GDBQRQ9HF8W89BGP1M0N", "value" => "MSC"},
"2" => %{"id" => "pval_01GZ44GDBQZ4GP8HYBMZCBC0EM", "value" => "None"}
}
},
"1" => %{
"delete" => "false",
"name" => "New Option",
"values" => %{"0" => %{"value" => "New Value"}}
}
},
}
And here’s the error from the changeset:
#Ecto.Changeset<
action: :update,
changes: %{
options: [
#Ecto.Changeset<action: :update, changes: %{}, errors: [],
data: #Wharf.Products.ProductOption<>, valid?: true>,
#Ecto.Changeset<
action: :insert,
changes: %{
name: "New Option",
values: [
#Ecto.Changeset<
action: :insert,
changes: %{value: "New Value"},
errors: [
product_option_id: {"can't be blank", [validation: :required]}
],
data: #Wharf.Products.ProductOptionValue<>,
valid?: false
>
]
},
errors: [],
data: #Wharf.Products.ProductOption<>,
valid?: false
>
]
},
errors: [],
data: #Wharf.Products.Product<>,
valid?: false
>