DEV Community

José Camelo de Freitas for Trybe

Posted on • Edited on

Data migrations para Phoenix

O que são?

Migrations estão geralmente relacionadas a alterações na estrutura do banco de dados, mas é recorrente em ambientes de produção termos também a necessidade de efetuar operações em nossos dados. Chamaremos isso de data migrations. Nessas situações, é comum fazer essas data migrations através do mecanismo das migrations normais, as estruturais, ou até mesmo se conectar diretamente ao banco de dados e executar um SQL com as operações desejadas.

Mas…

Executar a SQL direto no banco não é a melhor das ideias, pois incentiva a falta de code review nessas operações, e é bastante propenso a erro humano, como esquecer uma transação aberta ou fazer um update sem where. Já fazer essas migrações junto das tradicionais migrações de estrutura, é um code smell conhecido.

A solução

Uma solução recomendada é criar Mix Scripts para essas operações em dados, mas com Mix Releases não temos o Mix disponível, pois o Mix é uma ferramenta de desenvolvimento e build, e no binário gerado de uma release o objetivo é não incluir nada que não seja estritamente necessário para que o projeto seja executado. Aqui na Trybe, em vários serviços não temos nem Elixir nem Erlang instalados dentro dos containers — tudo é executado a partir do binário gerado pela release.

Para contornar isso, dentro do nosso serviço responsável por projetos, adotamos uma solução levemente customizada para nossas data migrations.

Nosso serviço de projetos é uma aplicação Phoenix, e nela já tínhamos um módulo chamado Release, responsável por executar as migrações tradicionais, e como a própria documentação diz, esse é o local perfeito para adicionar qualquer tipo de comando customizado que venha precisar ser executado em produção!

No módulo Release, e seguindo o esqueleto da função migrate/0 que já temos por padrão, podemos ter uma função que ficará responsável por executar data migrations no nosso serviço.

Primeiro, vamos definir como serão nossas data migrations!

  • Elas devem estar em um diretório separado. Podemos colocar essas data migrations em um diretório chamado data_migrations, ao lado do nosso diretório migrations tradicional.
  • Elas vão receber um Repo para fazer suas operações no banco de dados. Significa que não precisaremos acessar diretamente o módulo Repo da nossa aplicação, receberemos ele como parâmetro de uma função. Já podemos definir que essa função deverá ser chamada run/1.
  • Elas podem ser executadas “n” vezes, individualmente, e não possuem uma ordem específica.

Com essas premissas já podemos começar nossa implementação:

Essa é a função migrate/0 do nosso módulo Release:

def migrate do
  load_app()

  for repo <- repos() do
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
  end
end
Enter fullscreen mode Exit fullscreen mode

A função migrate/0 é responsável por:

  1. Garantir o carregamento da aplicação;
  2. Pegar uma lista de todos os repos disponível através da repos/0;
  3. Pedir para que o ecto execute as migrações pendentes para cada repo disponível;

Bem simples, nossa função customizada para migrações de dados reaproveitará os passos 1 e 2, só se diferenciando no 3, que irá executar uma data migration a partir de seu nome de arquivo.

Nossa implementação fica assim:

  def migrate_data(file_name) do
    load_app()

    for repo <- repos() do
      with {:ok, migration} <- eval_data_migration(repo, file_name),
           {:ok, _, _} <- Ecto.Migrator.with_repo(repo, &migration.run(&1)) do
        Logger.info("A migração de dados foi executada.")
      else
        {:error, message} -> Logger.error(inspect(message))
      end
    end
  end

  defp eval_data_migration(repo, file_name) do
    with file_path <- get_data_migration_path(repo, file_name),
         true <- File.regular?(file_path),
         {{:module, module, _, _}, _} <- Code.eval_file(file_path) do
      {:ok, module}
    else
      false -> {:error, "Não foi possível encontrar a migração de dados."}
      _ -> {:error, "A migração de dados aparenta ser inválida."}
    end
  end

  defp get_data_migration_path(repo, file_name) do
    repo
    |> Ecto.Migrator.migrations_path("data_migrations")
    |> Path.join(file_name)
  end
Enter fullscreen mode Exit fullscreen mode

Temos 2 funções auxiliares aqui:

  • get_data_migrations_path/2: será responsável retornar o path do arquivo de migração que será executado.
  • eval_data_migration/2: irá fazer um eval da migração, retornando uma tupla de sucesso/erro, e o módulo da migração em caso de sucesso

A nossa função migrate_data/1 irá:

  1. Garantir o carregamento da aplicação;
  2. Iterar numa lista de repos;
  3. Para cada repo pegar o path completo do arquivo de data migration;
  4. Fazer o eval da data_migration;
  5. Passar a função run/1 da data migration para o Ecto.Migrator, que irá executar a migração;

Criando uma data migration

Para criar uma data migration basta criar um arquivo no diretório /priv/repo/data_migrations/. Devemos dar um nome descritivo para o arquivo, como fix_trybetunes_module.exs.

Nossa migração só irá precisar ser um módulo simples com nossa função run/1:

defmodule MyProject.Repo.DataMigrations.FixTrybetunesModule do
  def run(repo) do
    repo.update_all(
      from(p in "projects",
        where: p.template == "trybetunes",
        update: [set: [module: "frontend"]] 
      ),
      []
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Usando as data migrations

Com a nossa nova função Release.migrate_data/1, executar nossas data migrations é tão simples quanto… chamar uma função 😀

Utilizando o IEx:

  • iex -S mix em ambiente de desenvolvimento;
  • ./release_bin remote em ambiente de produção ou staging, onde um deploy já foi feito, a aplicação já está sendo executada, e só temos o binário dela disponível;
iex(1)> Release.migrate_data("fix_trybetunes_module.exs")
[info] A migração de dados foi executada.
Enter fullscreen mode Exit fullscreen mode

Chamando a função diretamente através do binário de release:

$ ./release_bin eval "Release.migrate_data('fix_trybetunes_module.exs')"
[info] A migração de dados foi executada.
Enter fullscreen mode Exit fullscreen mode

Vantagens

  • Separação das responsabilidades;
  • Versionamento e revisão de operações que em outro momento seriam feitas diretamente no banco de dados;
  • Controle de quando será executado, e a possibilidade de executar a mesma operação quantas vezes se fizer necessário;

E é isso, implementadas as migrações de dados!

Top comments (0)