Filtering empty embeds_many nested forms

I’m having a few problems with Ecto’s embeds_many feature:

  1. scrub_params nils out empty forms but doesn’t remove them…
  2. What is the idiomatic way of removing empty nested forms?

Conn Params params["inventories"]:

%{"payment_invoice_type" => "mail",
  "shipping_methods" =>
  %{"0" => %{"cost" => "10.00", "description" => "Del Option 1"},
    "1" => %{"cost" => "20.00", "description" => "Del Option 2"},
    "2" => %{"cost" => "30.00", "description" => "Del Option 3"},
    "3" => %{"cost" => nil, "description" => nil}, # should be discarded
    "4" => %{"cost" => nil, "description" => nil}}, # should be discarded
  "payment_method" => "cash",
}

Changeset results:

#Ecto.Changeset<action: nil,
 changes: %{payment_invoice_type: "mail,
   delivery_methods: [#Ecto.Changeset<action: :insert,
     changes: %{cost: #Decimal<10.00>, description: "Del Option 1"}, errors: [],
     data: #Book.Store.ShippingMethod<>, valid?: true>,
    #Ecto.Changeset<action: :insert,
     changes: %{cost: #Decimal<20.00>, description: "Del Option 2"}, errors: [],
     data: #Book.Store.ShippingMethod<>, valid?: true>,
    #Ecto.Changeset<action: :insert,
     changes: %{cost: #Decimal<30.00>, description: "Del Option 3"}, errors: [],
     data: #Book.Store.ShippingMethod<>, valid?: true>,
    #Ecto.Changeset<action: :insert, changes: %{},
     errors: [description: {"can't be blank", [validation: :required]},
      cost: {"can't be blank", [validation: :required]}],
     data: #Book.Store.ShippingMethod<>, valid?: false>,
    #Ecto.Changeset<action: :insert, changes: %{},
     errors: [description: {"can't be blank", [validation: :required]},
      cost: {"can't be blank", [validation: :required]}],
     data: #Book.Store.ShippingMethod<>, valid?: false>],
   payment_method: "cash"},
 errors: [],
 data: #Book.Store.Inventory<>, valid?: false>

Parent Schema:

  schema "inventories" do
    field :payment_invoice_type, :string
    field :payment_method, :string
    embeds_many :shipping_methods, Book.Store.ShippingMethod
    timestamps()
  end

Parent Changeset:

def changeset(%Inventory{} = inventory, attrs) do
  inventory
  |> cast(attrs, [:payment_invoice_type, :payment_method])
  |> validate_required([:payment_invoice_type, :payment_method])
  |> cast_embed(:shipping_methods, required: true)
end

Embedded Schema:

  @primary_key false
  embedded_schema do
    field :description, :string
    field :cost, :decimal
  end

  def changeset(%DeliveryMethod{} = delivery_method, attrs) do
    delivery_method
    |> cast(attrs, [:description, :cost])
    |> validate_required([:description, :cost])
    |> validate_length(:description, min: 5, max: 75)
  end

Okay, this isn’t really all that idiomatic, but maybe it will give you some ideas:

def filter_inventories(inventories) do
  {shipping_methods, inventories} = Map.pop(inventories, "shipping_methods")

  shipping_methods = 
    shipping_methods
    |> Enum.filter(&filter_shipping_methods/1)
    |> Enum.into(%{})

  Map.put(inventories, "shipping_methods", shipping_methods)
end

def filter_shipping_methods({_, %{"cost" => nil, "description" => nil}}), do: false
def filter_shipping_methods(_), do: true

An unintended effect is that entering either a cost or a description, but not both, will come back as an error in the changeset.

2 Likes

This worked. Thank you for your assistance @ryh