Creating new deeply nested associations

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 ProductOptionValues 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
>

Can you share your changesets?

I’ve never actually used the :defaults option on associations before. It’s probably fine for vendor but otherwise you should ditch the product_id default and cast_assoc in your changesets.

Product changeset:

def changeset(product, attrs) do
  product
  |> cast(attrs, @fields)
  |> cast_assoc(:options, with: &ProductOption.changeset/2)
end

ProductOption changeset:

def changeset(option, attrs) do
  option
  |> cast(attrs, @fields)
  |> cast_assoc(:values, with: &ProductOptionValue.changeset/2)
end

That should work.

I may be misunderstanding you needs here, though.

The :withs aren’t strictly necessary if you have those generic changeset/2 functions but @dimitarvp will yell at me if I don’t put them :upside_down_face: :stuck_out_tongue_winking_eye:

1 Like

Apologies, I should have included the changeset functions as well. I have updated the original code sample to include the changesets.

As for the :defaults option, you are correct, I am using it to pass the vendor_id around. I added product_id and product_option_id when trying to solve this issue—but it seems I can remove them.

I thought it should work just using cast_assoc as well, but it doesn’t seem to know how to save the child association (product options) before saving the grandchild association (product option values) so that it can populate the child id on the grandchildren.

Taking another look at your error, you’re getting a validation error that product_option_id can’t be blank. You shouldn’t validate_required association ids nor should you cast them, just use cast_assoc like this:

...
product_option
|> cast(attrs, [:name, :delete])
|> cast_assoc(:values, with: &ProductOptionValue.changeset/2, required: true)

You may still need the vendor_id in there depending on how you’re doing it but for a many style association, leave ids out of cast and validates.

EDIT: Personally I would explicitly add the vendor depending where these operations are exposed. casting an association id is pretty unsafe for something like vendor as it is untrusted data and people could pass whatever id they wanted. Personally, I would make a dedicated create_changeset and pass a loaded and validated vendor explicitly to the changeset:

def create_changeset(vendor, attrs) do
  %Product{}
  |> cast(attrs, [:name])
  |> put_assoc(:vendor, vendor)
  |> cast_assoc(:options, required: true)
  |> ...
end
2 Likes

Wow that was totally it—thank you for your help, Andrew!

1 Like

No prob! And welcome to the forum!