Banco de Dados

23 jan, 2017

Many to many e upserts

Publicidade

Este artigo é uma amostra de um capítulo do ebook gratuito da Plataformatec, “What’s new in Ecto 2.0”. Baixe o ebook completo.

No capítulo anterior, aprendemos sobre associações many_to_many e como mapear dados externos para entradas associadas com a ajuda do Ecto.Changeset.cast_assoc/3. Enquanto no capítulo anterior conseguimos seguir as regras impostas por cast_assoc/3, isso nem sempre é possível nem desejado.

Neste capítulo, vamos dar uma olhada no Ecto.Changeset.put_assoc/4 em contraste com cast_assoc/3 e explorar alguns exemplos. Vamos também espiar os recursos upsert que chegam com o Ecto 2.1.

put_assoc contra cast_assoc

Imagine que estamos construindo uma aplicação que publica em um blog e essas publicações podem ter muitas tags. Não só isso, uma determinada tag também pode pertencer a muitas publicações. Esse é um cenário clássico onde usamos associações many_to_many. Nossas migrações se pareceriam com:

create table(:posts) do
  add :title
  add :body
  timestamps()
end

create table(:tags) do
  add :name
  timestamps()
end

create unique_index(:tags, [:name])

create table(:posts_tags, primary_key: false) do
  add :post_id, references(:posts)
  add :tag_id, references(:tags)
end

Observe que adicionamos um unique index ao nome da tag porque não queremos ter tags duplicadas em nosso banco de dados. É importante adicionar um índice no nível do banco de dados em vez de usar uma validação, pois há sempre uma chance de que duas tags com o mesmo nome sejam validadas e inseridas simultaneamente, passando a validação e levando a entradas duplicadas.

Agora vamos também imaginar que queremos que o usuário digite essas tags como uma lista de palavras divididas por vírgula, como: “elixir, erlang, ecto”. Uma vez que esses dados são recebidos no servidor, vamos dividi-los em várias tags e associá-los ao artigo, criando qualquer tag que ainda não exista no banco de dados.

Enquanto as restrições acima soam razoáveis, são exatamente elas que nos colocam em uma enrascada com cast_assoc/3. Lembre-se de que a função changeset cast_assoc/3 foi projetada para receber parâmetros externos e compará-los com os dados associados em nossas estruturas. Para fazer isso corretamente, o Ecto requer que as tags sejam enviadas como uma lista de mapas. No entanto, aqui esperamos que as tags sejam enviadas em uma string separada por vírgula.

Além disso, cast_assoc/3 depende do campo da chave primária em cada tag enviada para decidir se deve ser inserido, atualizado ou excluído. Mais uma vez, porque o usuário está simplesmente passando uma string, nós não temos a informação do ID em mãos.

Quando não podemos lidar com cast_assoc/3, é hora de usar put_assoc/4. Em put_assoc/4, nós damos Ecto structs ou changesets em vez de parâmetros, dando-nos a capacidade de manipular os dados como queremos. Vamos definir o esquema e a função changeset para uma publicação que pode receber tags como uma string:

defmodule MyApp.Post do
  use Ecto.Schema

  schema "posts" do
    field :title
    field :body
    many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> Ecto.Changeset.cast(params, [:title, :body])
    |> Ecto.Changeset.put_assoc(:tags, parse_tags(params))
  end

  defp parse_tags(params)  do
    (params["tags"] || "")
    |> String.split(",")
    |> Enum.map(&String.trim/1)
    |> Enum.reject(& &1 == "")
    |> Enum.map(&get_or_insert_tag/1)
  end

  defp get_or_insert_tag(name) do
    Repo.get_by(MyApp.Tag, name: name) ||
      Repo.insert!(MyApp.Tag, %Tag{name: name})
  end
end

Na função changeset acima, movemos todo o manuseio de tags para uma função separada, chamada parse_tags/1, que verifica o parâmetro, quebra suas entradas separadamente através de String.split/2 e, então, remove qualquer espaço em branco com String.trim/1, rejeita qualquer string vazia e finalmente verifica se a tag existe no banco de dados ou não, criando uma no caso de não existir .

A função parse_tags/1 vai retornar uma lista de estruturas MyApp.Tag que são, então, passadas para put_assoc/3. Ao chamar put_assoc/3, estamos dizendo ao Ecto que essas devem ser as tags associadas à publicação de agora em diante. No caso de uma tag anterior ter sido associada ao artigo e não dada em put_assoc/3, Ecto também cuidará de remover a associação entre o artigo e a tag removida do banco de dados.

E isso é tudo que precisamos para usar associações many_to_many com put_assoc/3. put_assoc/3 trabalha com has_many, belongs_to e todos os outros tipos de associação. No entanto, nosso código ainda não está pronto para produção. Vamos ver por quê.

Restrições e condições de corrida

Lembre-se de que adicionamos um unique index à tag coluna :name ao criar a tabela de tags. Fizemos isso para nos proteger de ter tags duplicadas no banco de dados.

Adicionando o unique index e, em seguida, usando get_by com uma insert! para obter ou inserir uma tag, introduzimos um erro potencial em nossa aplicação. Se duas postagens forem enviadas ao mesmo tempo com uma tag semelhante, há uma chance de verificar se a tag existe ao mesmo tempo, levando ambas as submissões a acreditarem que não há essa tag no banco de dados. Quando isso acontece, apenas uma das submissões terá êxito enquanto a outra falhará. Essa é uma condição de corrida: seu código irá falhar de vez em quando, somente quando certas condições forem atendidas. E essas condições são sensíveis ao tempo.

Muitos desenvolvedores têm uma tendência a pensar que tais erros não acontecerão na prática ou, se acontecerem, seriam irrelevantes. Mas, na prática, muitas vezes eles levam a experiências de usuário muito frustrantes. Ouvi um exemplo em primeira mão vindo de uma empresa de jogos de celular. No game, um jogador é capaz de jogar quests e em cada quest você tem que escolher um personagem convidado de outro jogador fora de uma lista curta para ir na quest com você. No final da quest, você tem a opção de adicionar o personagem convidado como amigo.

Originalmente, toda a lista de convidados era aleatória mas, com o passar do tempo, os jogadores começaram, às vezes, a reclamar que contas antigas, muitas vezes inativas, estavam sendo exibidas na lista de opções dos convidados. Para melhorar a situação, os desenvolvedores do jogo começaram a classificar a lista de convidados pela mais recentemente ativa. Isso significa que, se você jogou recentemente, há uma maior chance de você estar em listas de convidados de alguém. No entanto, quando eles fizeram essa mudança, muitos erros começaram a aparecer e os usuários ficaram repentinamente furiosos no fórum do jogo. Isso porque quando eles classificaram os jogadores por atividade, logo que dois jogadores logassem, seus personagens provavelmente apareceriam na lista de convidados de cada um. Se esses jogadores escolhessem cada um dos personagens, o primeiro a adicionar o outro como amigo no final de uma quest seria capaz de ter sucesso, mas um erro apareceria quando o segundo jogador tentasse adicionar esse personagem como amigo, uma vez que a relação já existia no banco de dados! E não só isso, todo o progresso feito na quest seria perdido, porque o servidor seria incapaz de persistir corretamente os resultados ao banco de dados. Compreensivelmente, os jogadores começaram a apresentar queixas.

Resumindo: devemos abordar a condição de corrida.

Felizmente, Ecto nos dá um mecanismo para lidar com erros de restrição do banco de dados.

Verificando erros de restrição

Como a função get_or_insert_tag (name) falha quando uma tag já existe no banco de dados, precisamos lidar com esses cenários adequadamente. Vamos reescrevê-la tendo as condições de corrida em mente:

defp get_or_insert_tag(name) do
  %Tag{}
  |> Ecto.Changeset.change(name: name)
  |> Ecto.Changeset.unique_constraint(:name)
  |> Repo.insert
  |> case do
    {:ok, tag} -> tag
    {:error, _} -> Repo.get_by!(MyApp.Tag, name: name)
  end
end

Em vez de inserir a tag diretamente, sabemos construir um changeset, o que nos permite usar a annotation unique_constraint. Agora, se a operação Repo.insert falhar porque o unique index para :name é violado, Ecto não aumentará, mas retornará uma tupla {:error, changeset}. Portanto, se o Repo.insert for bem-sucedido, é porque a tag foi salva, ou a tag já existe, a qual, em seguida, buscamos com Repo.get_by!.

Enquanto o mecanismo acima corrige a condição de corrida, é coisa bastante cara: precisamos realizar duas consultas para cada tag que já exista no banco de dados: a inserção (falha) e, em seguida, a pesquisa do repositório. Dado que esse é o cenário mais comum, podemos querer reescrevê-lo para o seguinte:

defp get_or_insert_tag(name) do
  Repo.get_by(MyApp.Tag, name: name) || maybe_insert_tag(name)
end

defp maybe_insert_tag(name) do
  %Tag{}
  |> Ecto.Changeset.change(name: name)
  |> Ecto.Changeset.unique_constraint(:name)
  |> Repo.insert
  |> case do
    {:ok, tag} -> tag
    {:error, _} -> Repo.get_by!(MyApp.Tag, name: name)
  end
end

O demonstrado acima executa 1 consulta para cada tag que já exista, 2 consultas para cada nova tag e possivelmente 3 consultas no caso de condições de corrida. Enquanto o acima seria um pouco melhor em média, Ecto 2.1 tem uma opção melhor.

Upserts

O Ecto 2.1 suporta o chamado comando “upsert”, que é uma abreviação para “update or insert”. A ideia é que tentemos inserir um registro e, no caso de ele entrar em conflito com uma entrada já existente, por exemplo, devido a um índice exclusivo, possamos escolher como queremos que o banco de dados atue levantando um erro (o comportamento padrão), ignorando a inserção (sem erro) ou atualizando as entradas conflitantes do banco de dados.

“Upsert” no Ecto 2.1 é feito com a opção :on_conflict. Vamos reescrever get_or_insert_tag (name) mais uma vez, mas desta vez usando a opção :on_conflict. Lembre-se de que “upsert” é um novo recurso no PostgreSQL 9.5, portanto, certifique se você está atualizado.

Sua primeira tentativa em usar :on_conflict pode ser configurando-o para :nothing, como abaixo:

defp get_or_insert_tag(name) do
  Repo.insert!(%MyApp.Tag{name: name}, on_conflict: :nothing)
end

Enquanto o demonstrado acima não levantará um erro em caso de conflitos, ele também não irá atualizar a estrutura dada, então ele irá retornar uma tag sem ID. Uma solução é forçar uma atualização para acontecer em caso de conflitos, mesmo se a atualização é sobre como definir o nome da tag para seu nome atual. Em tais casos, o PostgreSQL também requer a opção :conflict_target para ser dada, que é a coluna (ou uma lista de colunas) em que esperamos que o conflito aconteça:

defp get_or_insert_tag(name) do
  Repo.insert!(%MyApp.Tag{name: name},
               on_conflict: [set: [name: name]], conflict_target: :name)
end

E é isso! Nós tentamos inserir uma tag com o nome dado e se tal tag já existir, nós dizemos a Ecto para atualizar seu nome para o valor atual, atualizando a tag e buscando seu id. Embora o acima seja certamente um passo acima de todas as soluções até o momento, ele ainda realiza uma consulta por tag. Se 10 tags forem enviadas, nós executaremos 10 consultas. Podemos melhorar isso mais pra frente?

Upserts e insert_all

Ecto 2.1 não só adicionou a opção :on_conflict ao Repo.insert/2, mas também à função Repo.insert_all/3 introduzida no Ecto 2.0. Isso significa que podemos construir uma consulta que tenta inserir todas as tags ausentes e, em seguida, outra consulta que busca todos elas de uma vez. Vamos ver como nosso esquema Post irá parecer após essas mudanças:

defmodule MyApp.Post do
  use Ecto.Schema

  # Schema is the same
  schema "posts" do
    field :title
    field :body
    many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
    timestamps()
  end

  # Changeset is the same
  def changeset(struct, params \\ %{}) do
    struct
    |> Ecto.Changeset.cast(params, [:title, :body])
    |> Ecto.Changeset.put_assoc(:tags, parse_tags(params))
  end

  # Parse tags has slightly changed
  defp parse_tags(params)  do
    (params["tags"] || "")
    |> String.split(",")
    |> Enum.map(&String.trim/1)
    |> Enum.reject(& &1 == "")
    |> insert_and_get_all()
  end

  defp insert_and_get_all([]) do
    []
  end
  defp insert_and_get_all(names) do
    maps = Enum.map(names, &%{name: &1})
    Repo.insert_all MyApp.Tag, maps, on_conflict: :nothing
    Repo.all(from t in MyApp.Tag, where: t.name in ^names)
  end
end

Em vez de tentar obter e inserir cada tag individualmente, o código acima funciona em todas as tags de uma só vez, primeiro construindo uma lista de mapas que é dada por insert_all e, em seguida, procurando todas as tags com os nomes existentes. Portanto, independentemente de quantas tags são enviadas, vamos executar apenas 2 consultas (a menos que nenhuma tag seja enviada, e aí devolvemos uma lista vazia de volta prontamente). Essa solução só é possível no Ecto 2.1 graças à opção :on_conflict, que garante que insert_all não falhará caso um determinado nome de tag já exista.

Finalmente, lembre-se de que não utilizamos transações em nenhum dos exemplos até agora. Tal decisão foi deliberada. Como obter ou inserir tags é uma operação idempotente, podemos repeti-la muitas vezes e ela sempre nos dará o mesmo resultado de volta. Portanto, mesmo se falharmos em introduzir o artigo no banco de dados devido a um erro de validação, o usuário será livre para reenviar o formulário e apenas tentaremos obter ou inserir as mesmas tags novamente. A desvantagem dessa abordagem é que as tags serão criadas mesmo que a criação da postagem falhe, o que significa que algumas tags podem não ter artigos associadas a elas. Caso isso não seja desejado, toda a operação pode ser envolvida em uma transação ou modelada com a abstração Ecto.Multi que aprenderemos em capítulos futuros.

***

José Valim faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: http://blog.plataformatec.com.br/2016/12/many-to-many-and-upserts/.