Hi everyone !
I’m currently trying to build a relation inside a Phoenix app.
Here is a sample app I made, with simpler schema just for you to get the idea.
schema "books" do
field :title, :string
has_many :book_publisher, Entity.BookPublisher, on_replace: :delete
timestamps()
end
schema "publishers" do
field :name, :string
has_many :book_publisher, Entity.BookPublisher, on_replace: :delete
timestamps()
end
schema "books_publishers" do
field :date, :date
belongs_to :book, Entity.Book, foreign_key: :book_id
belongs_to :publisher, Entity.Publisher, foreign_key: :publisher_id
timestamps()
end
There is books, there is publishers, and a book can have multiple editions, identified by a date and a publisher.
When editing a view, I have this template, mounted :
<%= f = form_for @changeset, "#", [phx_change: :validate, phx_submit: :save] %>
<%= if @changeset.action do %>
<%= if !@changeset.valid? do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<% end %>
<%= label f, :title %>
<%= text_input f, :title, phx_debounce: "blur" %>
<%= error_tag f, :title %>
<label>Editions</label>
<div>
<%= link "Add New Edition", to: "#", "phx-click": "add-edition" %>
</div>
<%= for {fb, index} <- Enum.with_index(inputs_for f, :book_publisher) do %>
<div class="editions">
<%= select fb, :publisher_id, Enum.map(@publishers, fn e -> {e.name, e.id} end) %>
<%= date_input fb, :date %>
<%= link "Remove", to: "#", "phx-click": "remove-edition", "phx-value-index": index %>
</div>
<% end %>
<div style="padding-top: 1rem;">
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</form>
Corresponding handlers are :
use LveWeb, :live_view
alias LveWeb.BookView
alias Lve.Entity
alias Lve.Repo
def render(assigns) do
BookView.render("edit.html", assigns)
end
def mount(params, _sesion, socket) do
id = params["id"]
publishers = Entity.list_publishers()
book = Entity.get_book!(id) |> Repo.preload(:book_publisher)
cs =
id
|> Entity.get_full_book!()
|> Entity.change_book()
{:ok, assign(socket, changeset: cs, book: book, publishers: publishers)}
end
def handle_event("validate", %{"book" => params}, socket) do
cs =
%Entity.Book{}
|> Entity.change_book(params)
|> Map.put(:action, :insert)
{:noreply, assign(socket, changeset: cs)}
end
def handle_event("save", %{"book" => params}, socket) do
book = Entity.get_book!(socket.assigns.book.id) |> Repo.preload(:book_publisher)
case Entity.update_book(book, params) do
{:ok, book} ->
{:noreply,
socket
|> put_flash(:info, "Book updated")
|> redirect(to: Routes.live_path(socket, LveWeb.BookLive.Show, book.id))}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply, assign(socket, changeset: cs)}
end
end
def handle_event("add-edition", _params, socket) do
currents =
Map.get(
socket.assigns.changeset.changes,
:book_publisher,
socket.assigns.book.book_publisher
)
[first_publisher | _] = socket.assigns.publishers
next = %Entity.BookPublisher{
publisher_id: first_publisher.id,
book_id: socket.assigns.book.id
}
editions = [next | currents]
cs = socket.assigns.changeset |> Ecto.Changeset.put_assoc(:book_publisher, editions)
{:noreply, assign(socket, changeset: cs)}
end
def handle_event("remove-edition", %{"index" => index}, socket) do
currents =
Map.get(
socket.assigns.changeset.changes,
:book_publisher,
socket.assigns.book.book_publisher
)
{i, _} = Integer.parse(index)
editions = List.delete_at(currents, i)
cs = socket.assigns.changeset |> Ecto.Changeset.put_assoc(:book_publisher, editions)
{:noreply, assign(socket, changeset: cs)}
end
end
When interacting with the DB, I use this changeset :
@doc false
def changeset(book, attrs) do
book
|> Repo.preload(:book_publisher)
|> cast(attrs, [:title])
|> validate_required([:title])
|> unique_constraint(:title)
|> put_assoc(:book_publisher, convert_assoc(attrs["book_publisher"]))
end
def convert_assoc(nil) do
end
def convert_assoc([]) do
end
def convert_assoc(list) do
Enum.map(list, fn {_, %{"date" => _, "publisher_id" => id}} ->
{pid, _} = Integer.parse(id)
%{date: nil, publisher_id: pid}
end)
end
Adding data works great, removing data causes an error :
cannot replace related %Lve.Entity.BookPublisher{[...]} because it already exists and it is not currently associated with the given struct. Ecto forbids casting existing records through the association field for security reasons. Instead, set the foreign key value accordingly
It looks like Ecto is not happy with a state mismatch between data from DB and data from my changeset that contains “data to remove” from the association
After days of searching, I don’t know what to do. Am I doing this right ?
Thanks for your help.
NB : Code sandbox is available here https://github.com/papey/lve/tree/features/dynamic-editions-form
Edit : after some time, i think cast_assoc is the good tool here, working on that.