Data migrations para Phoenix

29/08/2022 tipselixirphoenix

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!

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

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

Temos 2 funções auxiliares aqui:

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

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(1)> Release.migrate_data("fix_trybetunes_module.exs")
[info] A migração de dados foi executada.

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.

Vantagens

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