DEV Community

José Camelo de Freitas for Trybe

Posted on • Edited on

5 2

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!

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read more →

Top comments (0)

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More