💧 Elixir: Trabalhando com Nested Associations

maiquitome

Dev Maiqui 🇧🇷

Posted on March 27, 2022

💧 Elixir: Trabalhando com Nested Associations

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

A Jornada do Autodidata em Inglês

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
Enter fullscreen mode Exit fullscreen mode

Não se esqueça de criar o banco de dados:

$ mix ecto.create
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

Testando as rotas

Suba o servidor:

$ mix phx.server
Enter fullscreen mode Exit fullscreen mode

Image description

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Image description

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 %>
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

Agora é possível cadastrar os itens:

Image description

Entendendo mais o cast_assoc

Vamos colocar um IO.inspect:

Image description

Antes de salvar, clicando no New Todo list:

Image description

Após salvar:
Image description

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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)
Enter fullscreen mode Exit fullscreen mode

Agora também deve ser possível atualizar todos os itens com a todo_list:

Image description

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)
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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
Enter fullscreen mode Exit fullscreen mode

Agora sim conseguimos deletar, onde ao deletar uma todo_list serão removidos também todos os todo_items:
Image description

Lembrando que o projeto final você pode encontrar aqui: https://github.com/maiquitome/my_todo_list

💖 💪 🙅 🚩
maiquitome
Dev Maiqui 🇧🇷

Posted on March 27, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related