💧 Elixir: Trabalhando com Nested Associations
Dev Maiqui 🇧🇷
Posted on March 27, 2022
Esse post é uma continuação da série sobre associações com Ecto no Elixir. Para acompanhar este post seria bacana você ver os anteriores antes desse, assim o seu entendimento será melhor.
Nesse post vamos construir um projeto bem simples apenas pra entender sobre associações aninhadas, sobre as funções cast_assoc
, put_assoc
, preload
...
Nested Associations
Da mesma forma que usamos os changesets
para manipular os embeds
, também podemos usá-los para mudar as associações dos filhos, em simultâneo, em que estamos manipulando os pais.
Um dos benefícios deste recurso é que podemos usá-los para construir formas aninhadas em uma aplicação Phoenix. Enquanto os formulários aninhados em outras línguas e estruturas podem ser confusos e complexos, o Ecto usa changesets
e validações explícitas para fornecer uma maneira simples e direta de manipular múltiplas estruturas de uma só vez.
Criando o projeto
Vamos ver um exemplo de como usar o que vimos nos posts anteriores para trabalhar com associações aninhadas em Phoenix.
Nota: você precisará do Phoenix_ecto 3.0
para poder seguir este exemplo.
Primeiro, vamos criar uma aplicação Phoenix:
$ mix phx.new my_todo_list && cd my_todo_list
Não se esqueça de criar o banco de dados:
$ mix ecto.create
O exemplo que construiremos é um clássico para fazer lista, onde uma lista tem muitos itens. Vamos gerar o recurso TodoList
:
$ mix phx.gen.html TodoLists TodoList todo_lists title
Você pode ver mais sobre o comando acima na documentação: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Html.html.
Vamos gerar um modelo TodoItem
:
mix phx.gen.schema TodoItem todo_items body:text todo_list_id:references:todo_lists
Você pode ver mais sobre o comando acima na documentação:
https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Schema.html
Adicionando as rotas
Em "lib/my_todo_list_web/router.ex":
...
pipeline :api do
plug :accepts, ["json"]
end
scope "/", MyTodoListWeb do
pipe_through :browser
get "/", PageController, :index
# adicione essa linha
resources "/todolists", TodoListController
end
...
Testando as rotas
Suba o servidor:
$ mix phx.server
Observe que faltam os todo_items
, então vamos fazer isso agora.
Adicionando a associação dos itens
Vamos agora associar os todo_items
a um todo_list
.
Abra o módulo MyTodoList.TodoLists.TodoList
em "lib/my_todo_list/todo_lists/todo_list.ex" e adicione a definição has_many
no bloco do schema
:
defmodule MyTodoList.TodoLists.TodoList do
use Ecto.Schema
import Ecto.Changeset
schema "todo_lists" do
field :title, :string
# adicione essa linha
has_many :todo_items, MyTodoList.TodoItem
timestamps()
end
@doc false
def changeset(todo_list, attrs) do
todo_list
|> cast(attrs, [:title])
|> validate_required([:title])
end
end
Em seguida, vamos também incluir/fundir (cast) "todo_items" na função changeset do TodoList
:
def changeset(todo_list, params \\ %{}) do
todo_list
|> cast(params, [:body])
|> cast_assoc(:todo_items, required: true)
end
Ou seja, uma todo_list
tem muitos todo_items
.
cast_assoc e cast_embed X put_assoc e put_embed
Note que estamos usando cast_assoc
em vez de put_assoc
neste exemplo. Ambas as funções são definidas no Ecto.Changeset. cast_assoc (ou cast_embed)
é usado quando se deseja gerenciar associações ou incorporações (embeds) com base em parâmetros externos, tais como os dados recebidos através dos formulários Phoenix. Nesses casos, o Ecto irá comparar os dados existentes na estrutura com os dados enviados através do formulário e gerar as operações adequadas. Por outro lado, usamos put_assoc (ou put_embed)
quando já temos as associações (ou embeds) como structs e changesets, e queremos simplesmente dizer ao Ecto para pegar essas entradas como estão.
Cadastrando os todo_items
Como adicionamos todo_items
como um campo obrigatório, estamos prontos para submetê-los através do formulário. Portanto, vamos mudar nosso modelo para enviar todos os itens também. Abra "lib/my_todo_list_web/templates/todo_list/form.html.heex" e adicione o seguinte entre o input do título e o botão submit:
<%= inputs_for f, :todo_items, fn i -> %>
<div class="form-group">
<%= label i, :body, "Task ##{i.index + 1}", class: "control-label" %>
<%= text_input i, :body, class: "form-control" %>
<%= if message = i.errors[:body] do %>
<span class="help-block"><%= message %></span>
<% end %>
</div>
<% end %>
A função inputs_for/4
vem do Phoenix.HTML.Form e nos permite gerar campos para uma associação ou um embed, emitindo uma nova estrutura de formulário (representada pela variável i no exemplo acima) para podermos trabalhar com ela. Na função inputs_for/4
, nós geramos uma entrada de texto para cada ‘item’.
Agora que mudamos o template (modelo), o passo final é mudar a nova ação no controlador para incluir dois itens todos vazios por padrão na lista de todos:
Em "lib/my_todo_list_web/controllers/todo_list_controller.ex":
...
def new(conn, _params) do
# remova essa linha
# changeset = TodoLists.change_todo_list(%TodoList{})
# adicione essa linha
changeset = TodoList.changeset(%TodoList{todo_items: [%MyTodoList.TodoItem{}, %MyTodoList.TodoItem{}]})
render(conn, "new.html", changeset: changeset)
end
...
Agora é possível cadastrar os itens:
Entendendo mais o cast_assoc
Vamos colocar um IO.inspect
:
Antes de salvar, clicando no New Todo list
:
Padrão do cast_assoc
E enquanto o padrão é chamar MyTodoList.TodoItem.changeset/2
, é possível personalizar a função a ser invocada que vai colocar todo_items
em todo_list
através da opção :with
:
|> cast_assoc(:todo_items, required: true, with: &custom_changeset/2)
Portanto, se uma associação tem regras de validação diferentes, dependendo se ela é enviada como parte de uma associação aninhada ou quando administrada diretamente, podemos facilmente manter essas regras comerciais separadas, fornecendo duas funções diferentes de conjunto de mudanças. E como usamos apenas funções, até o fim, elas são fáceis de compor e testar.
Editando
Entretanto, se você tentar editar a lista de todos recém-criada, você deverá receber um erro:
using inputs_for for association `todo_items` from `MyTodoList.TodoLists.TodoList` but it was not loaded. Please preload your associations before using them in inputs_for
Como a mensagem de erro diz que precisamos pré-carregar todos os itens
para ações de edição e atualização em MyApp.TodoListController
.
Abra seu controlador e mude a seguinte linha em ambas as ações:
Em "lib/my_todo_list_web/controllers/todo_list_controller.ex", podemos perceber que o edit
e o update
usam a função TodoLists.get_todo_list!(id)
e ela que precisamos adicionar o preload
.
Em "lib/my_todo_list/todo_lists.ex" adicione o preload
:
def get_todo_list!(id), do: Repo.get!(TodoList, id) |> Repo.preload(:todo_items)
Agora também deve ser possível atualizar todos os itens com a todo_list:
Tanto as operações de inserção como as de atualização são alimentadas por changesets
, como podemos ver em nossas ações de controle:
changeset = TodoList.changeset(todo_list, todo_list_params)
Todos os benefícios que discutimos a respeito de changesets
ainda se aplicam aqui. Ao inspecionar o changeset
antes de chamar o Repo.insert
ou Repo.update
, é possível ver um retrato de todas as mudanças que vão acontecer no banco de dados.
Não apenas isso, o processo de validação por trás dos changesets
é explícito. Como adicionamos todo_items
como um campo obrigatório no schema da todo_list
, toda vez que chamarmos MyTodoList.TodoLists.TodoList.changeset/2
, MyTodoList.TodoItem.changeset/2
será chamado para cada item enviado através do formulário. Os changesets
devolvidos para cada todo_item
são então armazenados no changeset principal do todo_list
(isso é efetivamente uma árvore de alterações).
Para nos ajudar a construir nossa intuição em relação às mudanças, vamos acrescentar algumas validações a todos os itens e também permitir que eles sejam apagados.
Deletando todo_items
Ao tentar deletar uma lista, recebemos o seguinte erro de constraint:
Vamos lembrar que no primeiro post da série, falamos sobre isso. Podemos colocar no schema on_delete: :delete_all
ou podemos colocar na migration. Lembrando que o mais indicado seria colocar na migration. Apenas para um teste rápido vamos colocar no schema.
defmodule MyTodoList.TodoLists.TodoList do
use Ecto.Schema
import Ecto.Changeset
schema "todo_lists" do
field :title, :string
# adicione aqui o on_delete
has_many :todo_items, MyTodoList.TodoItem, on_delete: :delete_all
timestamps()
end
@doc false
def changeset(todo_list, params \\ %{}) do
todo_list
|> cast(params, [:title])
|> validate_required([:title])
|> cast_assoc(:todo_items, required: true)
end
end
Agora sim conseguimos deletar, onde ao deletar uma todo_list
serão removidos também todos os todo_items
:
Lembrando que o projeto final você pode encontrar aqui: https://github.com/maiquitome/my_todo_list
Posted on March 27, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.