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óriomigrations
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
A função migrate/0
é responsável por:
- Garantir o carregamento da aplicação;
- Pegar uma lista de todos os repos disponível através da
repos/0
; - 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:
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á:
- Garantir o carregamento da aplicação;
- Iterar numa lista de repos;
- Para cada repo pegar o path completo do arquivo de data migration;
- Fazer o eval da
data_migration
; - 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 -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.
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
- 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!