DEV Community

Maiqui Tomé 🇧🇷
Maiqui Tomé 🇧🇷

Posted on • Updated on

💧Elixir: Trabalhando com Associações no Ecto

Primeiramente, este artigo foi feito tendo como base o post em inglês do José Valim, criador da linguagem Elixir.

Resolvi fazer esse post, para traduzir o post citado acima para o português e adicionar mais detalhes, fazendo um projeto do zero, podendo assim, ajudar programadores iniciantes da linguagem.

O código do projeto você pode encontrar aqui: https://github.com/maiquitome/blog

Neste post, vamos aprender a trabalhar com associações no Ecto, como ler, inserir, atualizar e excluir associações e incorporações (embeds).

Setup do Projeto

Criando o projeto e entrando na pasta do projeto:

$ mix phx.new blog --binary-id && cd blog
Enter fullscreen mode Exit fullscreen mode

Criando o banco de dados:

$ mix ecto.create
Enter fullscreen mode Exit fullscreen mode

Criando os schemas e migrations:

$ mix phx.gen.schema Post posts title body:text 
Enter fullscreen mode Exit fullscreen mode
$ mix phx.gen.schema Comment comments post_id:references:posts body:text 
Enter fullscreen mode Exit fullscreen mode

Associações

As associações no Ecto são usadas quando duas fontes diferentes (tabelas) são ligadas através de chaves estrangeiras (foreign keys).

Image description

Um exemplo clássico desta configuração é um post que tem muitos comentários:

defmodule Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "posts" do
    field :body, :string
    field :title, :string

    # adicione essa linha
    has_many :comments, Blog.Comment

    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
  end
end
Enter fullscreen mode Exit fullscreen mode

E um comentário pertence a um post:

defmodule Blog.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "comments" do
    field :body, :string

    # remova essa linha
    # field :post_id, :binary_id

    # adicione essa linha
    belongs_to :post, Blog.Post

    timestamps()
  end

  @doc false
  def changeset(comment, attrs) do
    comment
    # adicione o campo :post_id
    |> cast(attrs, [:body, :post_id])
    |> validate_required([:body])
  end
end
Enter fullscreen mode Exit fullscreen mode

Inserindo registros

Para gente testar as consultas a seguir, vamos inserir alguns registros.

Criando as tabelas no banco (rodando as migrations):

$ mix ecto.migrate 
Enter fullscreen mode Exit fullscreen mode

Vamos criar alguns posts e comentários. Adicione o conteúdo abaixo ao arquivo priv/repo/seeds.ex:

# Primeiro Post
{:ok, post1} = %Blog.Post{}
|> Blog.Post.changeset(%{title: "Primeiro Post", body: "Conteúdo do primeiro post"})
|> Blog.Repo.insert

%Blog.Comment{}
|> Blog.Comment.changeset(%{body: "comentário do Primeiro Post", post_id: post1.id})
|> Blog.Repo.insert

# Segundo Post
{:ok, post2} = %Blog.Post{}
|> Blog.Post.changeset(%{title: "Segundo Post", body: "Conteúdo do segundo post"})
|> Blog.Repo.insert

%Blog.Comment{}
|> Blog.Comment.changeset(%{body: "comentário do Segundo Post", post_id: post2.id})
|> Blog.Repo.insert

# Terceiro Post
{:ok, post3} = %Blog.Post{}
|> Blog.Post.changeset(%{title: "Terceiro Post", body: "Conteúdo do terceiro post"})
|> Blog.Repo.insert

%Blog.Comment{}
|> Blog.Comment.changeset(%{body: "comentário do Terceiro Post", post_id: post3.id})
|> Blog.Repo.insert
Enter fullscreen mode Exit fullscreen mode
$ mix run priv/repo/seeds.exs
Enter fullscreen mode Exit fullscreen mode

Associações de consulta (Querying associations)

Uma das vantagens de definir associações é que elas podem ser usadas em consultas. Por exemplo:

iex> import Ecto.Query

iex> Blog.Repo.all(from p in Blog.Post, preload: [:comments])
Enter fullscreen mode Exit fullscreen mode

Resultado:
Image description

Agora todos os posts serão buscados no banco de dados com seus comentários associados. O exemplo acima realizará duas consultas: uma para carregar todos os posts e outra para carregar todos os comentários. Esta é frequentemente a forma eficiente de carregar associações do banco de dados (mesmo que duas consultas sejam realizadas), pois precisamos receber e analisar apenas os resultados dos POSTS + COMENTÁRIOS.

Também é possível pré-carregar (preload) as associações usando as uniões enquanto se realizam consultas mais complexas. Por exemplo, imagine que tanto os posts como os comentários têm votos e você quer apenas comentários com mais votos do que o próprio post:

Blog.Repo.all from p in Blog.Post,
            join: c in assoc(p, :comments),
            where: c.votes > p.votes,
            preload: [comments: c]
Enter fullscreen mode Exit fullscreen mode

O exemplo acima agora realizará uma única consulta, encontrando todos os posts e os respectivos comentários que correspondam aos critérios. Como esta consulta realiza um JOIN, o número de resultados retornados pelo banco de dados é POSTS * COMMENTS, onde o Ecto então processa e associa todos os comentários no post apropriado.

Finalmente, o Ecto também permite que os dados sejam pré-carregados em estruturas (structs) após terem sido carregados através da função Repo.preload/3:

Blog.Repo.preload posts, :comments
Enter fullscreen mode Exit fullscreen mode

Isto é especialmente útil porque o Ecto não suporta carregamento preguiçoso (lazy loading). Se você invocar post.comments e comentários posteriores não tiverem sido pré-carregados, vai retornar Ecto.Association.NotLoaded. O carregamento preguiçoso é frequentemente uma fonte de confusão e problemas de desempenho e o Ecto pressiona os desenvolvedores a fazerem o que é correto. Portanto, o Repo.preload/3 permite que as associações sejam explicitamente carregadas em qualquer lugar, a qualquer momento.

Manipulando Associações

Enquanto o Ecto 2.0 permite inserir um post com múltiplos comentários em uma única operação, por exemplo:

Repo.insert!(%Post{
  title: "Hello",
  body: "world",
  comments: [
    %Comment{body: "Excellent!"}
  ]
})
Enter fullscreen mode Exit fullscreen mode

Muitas vezes você pode querer dividi-lo em etapas diferentes para ter mais flexibilidade no gerenciamento dessas entradas. Por exemplo, você poderia usar conjuntos de mudanças (changesets) para construir seus posts e comentários ao longo do caminho:

Preste atenção no Ecto.Changeset.put_assoc.

post = Ecto.Changeset.change(%Post{}, title: "Hello", body: "world")

comment = Ecto.Changeset.change(%Comment{}, body: "Excellent!")

post_with_comments = Ecto.Changeset.put_assoc(post, :comments, [comment])

Repo.insert!(post_with_comments)
Enter fullscreen mode Exit fullscreen mode

Ou manuseando cada entrada individualmente dentro de uma transação:

Preste atenção no Ecto.build_assoc.

Repo.transaction fn ->
  post = Repo.insert!(%Post{title: "Hello", body: "world"})

  # Build a comment from the post struct
  comment = Ecto.build_assoc(post, :comments, body: "Excellent!")

  Repo.insert!(comment)
end
Enter fullscreen mode Exit fullscreen mode

Ecto.build_assoc/3 constrói o comentário utilizando a identificação atualmente definida na estrutura do post. É equivalente a:

%Comment{post_id: post.id, body: "Excellent!"}
Enter fullscreen mode Exit fullscreen mode

A função Ecto.build_assoc/3 é especialmente útil nos controladores (controllers) do Phoenix. Por exemplo, poderíamos ter uma tabela de usuário...

Image description

e ao criar um post, faríamos:

Ecto.build_assoc(current_user, :post)
Enter fullscreen mode Exit fullscreen mode

Pois, provavelmente queremos associar o post ao usuário atualmente logado na aplicação.

Em outro controlador, poderíamos construir um comentário para um post existente:

Ecto.build_assoc(post, :comments)
Enter fullscreen mode Exit fullscreen mode

O Ecto não fornece funções como post.comments << comment que permite misturar dados persistidos com dados não-persistidos. O único mecanismo para mudar tanto o post como os comentários em simultâneo, é por changesets que iremos explorar quando falarmos sobre incorporações (embeds) e associações aninhadas (nested associations).

Deletando Associações

Quando definimos um has_many/3 ou has_one/3, você também pode passar uma opção :on_delete que especifica qual ação deve ser executada nas associações quando o pai é excluído. Por exemplo, se um post for excluído, então os comentários associados a ele também serão excluídos:

has_many :comments, Blog.Comment, on_delete: :delete_all
Enter fullscreen mode Exit fullscreen mode

Modificando o Schema do post:

defmodule Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "posts" do
    field :body, :string
    field :title, :string

    # modifique aqui adicionando `on_delete: :delete_all`
    has_many :comments, Blog.Comment, on_delete: :delete_all

    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
  end
end
Enter fullscreen mode Exit fullscreen mode

Além disso, :nilify_all também é suportado, sendo que :nothing é o padrão. Verifique has_many/3 na documentação para mais informações.

O uso desta opção é DESENCORAJADA para a maioria dos bancos de dados relacionais. Ao invés disso, em sua migração, defina references(:parent_id, on_delete: :delete_all):

defmodule Blog.Repo.Migrations.CreateComments do
  use Ecto.Migration

  def change do
    create table(:comments, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :body, :text
      # modifique aqui adicionando :delete_all
      add :post_id, references(:posts, on_delete: :delete_all, type: :binary_id)

      timestamps()
    end

    create index(:comments, [:post_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Embeds

Acesse a continuação Elixir: Trabalhando com Ecto Embeds (Campos Json)

Latest comments (0)