Idiomatic way to duplicate an Ecto.Changeset

In my CRUD app I would like to add a duplicate feature, in which an existing object’s data is used to prepopulate the form. The naive way I started with was:

def duplicate(conn, %{"id" => item_id}) do
  item = Repo.get!(Item, item_id)
  changeset = Ecto.Changeset.change(item)

  render(conn, "new.html", changeset: changeset)
end

where new.html renders a form with form_for pointing to the POST method.

BUT this does not work, as phoenix_ecto overrides the POST method in the form by setting the hidden _method field to put, because it’s noticed that the changeset refers to an existing item in the database, as shown here: https://github.com/phoenixframework/phoenix_ecto/blob/master/lib/phoenix_ecto/html.ex#L315

What is the idiomatic way of duplicating a Changeset that refers to an actual record, so that a new entry is created, without having to manually remove the __meta__ field from the changeset?

1 Like

This version seems to work:

def duplicate(conn, %{"id" => item_id}) do
  item = Repo.get!(Item, item_id)
  item_data = Map.take(item, Item.__schema__(:fields))
  changeset = Ecto.Changeset.change(%Item{}, item_data)

  render(conn, "new.html", changeset: changeset)
end

But it feels to me there’s a more idiomatic way to do this.

I am also curious of the most idiomatic way to do this. In my case, I used Map.from_struct/1 to get the item_data.

Unset the id (or any other primary key) for the item and it’s essentially a unsaved copy.

3 Likes

I suspect any function on Ecto.Changeset is going to wind up doing things you don’t want - in this case, I’d recommend taking the simplest possible route and writing a function in Item that takes an Item and returns an unsaved Ecto.Changeset:

def duplicate(item) do
  changeset(%Item{}, %{title: item.title})
end

Reasoning:

  • you likely don’t want to copy some fields (ID, naturally; also probably inserted_at etc)

  • you might want to modify specific data - for instance, adding " (copy)" or similar to a title

  • you may need to do additional work to copy some fields (has_many associations, for instance)

3 Likes
item = Repo.get!(Item, item_id)
  item_data = Map.take(item, Item.__schema__(:fields)) |> Map.delete(:id) # <=== Add this
  changeset = Ecto.Changeset.change(%Item{}, item_data)

I’ve written a library that provides sugar for this, but I do this:

def duplicate(conn, %{"id" => item_id}) do
  item = Repo.get!(Item, item_id)
  changeset = EctoMorph.generate_changeset(item, Item)

  render(conn, "new.html", changeset: changeset)
end

Essentially the generate_changeset function allows you to pass in a struct as the data that you want to cast. That means I can use the same function everywhere. And if you pass in the struct that you want to duplicate as the data to be casted you get duplicate for free.