Última atualização:
Um monte de ferramentas: martelos, chaves...
Um monte de ferramentas: martelos, chaves...

Como migrar um campo de banco de dados em 9 passos

Nawarian
Nawarian php

O problema

Vamos cobrir um caso muito específico e chato de lidar: migração de chave primária.

Temos uma entidade User, identificada por User::$id que é assim:

final class User
{
public function __construct(
public int $id,
) {}
}

E para acessar seus dados a gente usa uma interface chamada UserRepository. Eu vou deixar aqui uma implementação simples deste repository com Sqlite:

interface UserRepository
{
/** @throws UserNotFoundException */
public function findById(int $id): User;
}

final class SqliteUserRepository implements UserRepository
{
public function findById(int $id): User
{
$sql = "...";
$stmt = $this->pdo->prepare($sql);

$stmt->execute([
'id' => $id,
]);

// ...
}
}

Digamos que o seu time decidiu que a chave primária da classe User, por motivos de segurança, não deveriam ser um integer mas um UUID. E, é claro, não é possível desativar a aplicação durante a migração.

A solução

Eu implementaria esta mudança em três fases:

  1. Deixar os dois IDs coexistirem
  2. Migrar a aplicação para a implementação com UUID
  3. Remover a implementação com ID inteiro

O maior motivo de eu adotar estas três fases é que os testes não são suficientes para garantir que a aplicação vai funcionar como esperado. Este campo pode ser utilizado por outros componentes via API or alguma outra coisa.

Então para evitar maiores desastres, eu gostaria de alterar a implementação de forma que eu possa voltar à implementação anterior imediatamente se necessário.

Eu estou assumindo que cada passo descrito aqui está coberto com testes, antes mesmo de refatorar o código.

Passo 1: desacoplar dos tipos primitivos

Não importa o que a gente resolva implementar, vai ser difícil sem desacoplar primitivos. Aquele tipo int an classe User é um catalisador de desgraças.

Uma forma simples de fazer isso é encapsular primitivos. Eu vou criar uma classe chamada UserId e fazer com que User dependa dela em vez de int:

final class UserId
{
public function __construct(public int $id) {}

public function getId(): int
{
return $this->id;
}
}

final class User
{
public function __construct(public UserId $id) {}
}

interface UserRepository
{
/** @throws UserNotFoundException */
public function findById(UserId $id): User;
}

Esta mudança vai tornar a nossa refatoração bem mais fácil. UserId ainda retorna int quando UserId::getId() é chamado, mas isto não é problema! O que importa é que User agora depende do tipo UserId - um tipo que nós controlamos - em vez do primitivo integer - o qual não temos controle algum.

Agora é só atualizar o código existente para utilizar UserId:

final class SqliteUserRepository implements UserRepository
{
public function findById(UserId $id): User
{
$sql = "...";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
'id' => $id->getId(),
]);

// ...
}
}

Até aqui nada mudou, só preparamos o ambiente. Eu não veria muitos problemas em subir este código para produção, especialmente se estiver coberto com testes.

Passo 2: Deixar as duas chaves coexistirem

Agora vamos adicionar o novo campo uuid à nossa tabela do banco de dados:

sqlite> ALTER TABLE `users` ADD `uuid` VARCHAR;

Este campo ainda não pode ser NOT NULL e nem UNIQUE por enquanto, porque todos registros que já existem vão considerar uuid como NULL.

Agora vamos modificar a nossa classe UserId:

final class UserId
{
public function __construct(
public int $id,
public ?UuidInterface $uuid,
) {}

public function getId(): int
{
return $this->id;
}

public function getUuid(): ?UuidInterface
{
return $this->uuid;
}
}

Note que o campo uuid é nullable, porque nenhum registro possui UUID ainda.

Agora a precisa garantir duas coisas:

  1. Todo registro da tabela "users" possui um UUID que não é nulo; e
  2. Todo novo registro da tabela "users" possuirá um UUID

É possível considerar que os dois ids estão coexistindo sem problemas somente quando o campo users.uuid nunca é NULL no banco de dados.

Passo 3: garantir que todo novo registro possui um UUID

Em algum lugar do sistema, alguma coisa grava usuários na tabela Users. Precisamos ter certeza de que em todo lugar onde isto acontece, o campo UUID será preenchido.

Então o método abaixo:

...

public function insert(User $user): void
{
// insert into...
}
...

Precisaria ser modificado com algo assim:

...

public function insert(User $user): void
{
$id = $user->id;

if ($id->uuid === null) {
$id->uuid - Uuid::uuid4();
}

// insert into...
}
...

Eu recomendo fortemente cobrir este IF com testes, para garantir que você não importou nada errado ou coisa do gênero. Mas fora isso, eu não esperaria ver nenhuma outra regressão.

Todo novo registro na tabela users a partir de agora deverá ter o campo uuid com um valor não nulo.

Passo 4: preencher UUID para registros já existentes

Isto pode ser feito com um script. Se você utiliza algum tipo de framework de migrations, provavelmente será mais fácil ainda.

A gente só precisa buscar todos os User com uuid nulo e preenchê-los. Algo assim já deveria dar conta do recado:

$users = getUsersWithEmptyUuid();
foreach ($users as $user) {
$user->id->uuid = Uuid::uuid4();
updateUser($user);
}

Este snippet de código não representa a maioria dos codebases por aí, mas acho que dá pra ter uma noção de o que eu quis dizer.

É importante também revisar possíveis chaves estrangeiras que referenciem user.id e migrá-las se você achar que faz sentido.

Passo 5: deixar a aplicação rodar por um tempo

Não há motivos pra mudar tudo de uma vez. Deixa o sistema rodar com o código novo por um tempo para podermos ter certeza de que os dados estão consistentes: users.uuid não é nulo em lugar algum, as chaves estrangeiras estão se comportando como deveriam e por aí vai.

Avance para o passo 6 somente quando tiver segurança de que users.uuid nunca será nulo.

Passo 6: Atualizar UserRepository para utilizar UUID

Apesar de já estarmos numa posição confortável para migrar para a nova implementação que utiliza UUID, eu não recomendo mudar totalmente ainda.

Vamos primeiro proteger o nosso código com uma feature flag. Atualize a classe SqliteUserRepository com o seguinte:

final class SqliteUserRepository implements UserRepository
{
public function findnById(UserId $id): User
{
if (isFeatureFlagActive('enableNewUsersUuidImplementation')) {
// Nova implementação, utiliza UUID
$sql = "...";
$stmt = $this->pdo->prepare($sql);

$stmt->execute(['uuid' => (string) $id->getUuid()]);
// ...
} else {
// Implementação antiga, utiliza $id inteiro
$sql = "...";
$stmt = $this->pdo->prepare($sql);

$stmt->execute(['id' => $id->getId]);
// ...
}
}
}

Resumidamente: isFeatureFlagActive() retorna TRUE se a funcionalidade estiver habilitada e FALSE caso não esteja. Isto pode ser manipulado com configurações, registros num banco de dados ou mesmo variáveis de ambiente.

O que é importante é que você pode mudar o retorno TRUE/FALSE desta função sem precisar fazer outro deploy. Desta forma você consegue facilmente voltar à implementação anterior caso encontre algum problema.

Passo 7: Lançar, habilitar e monitorar

Vamos primeiro fazer o deploy do software e tomar certeza de que isFeatureFlagActive() sempre vai retornar FALSE para que a implementação original seja utilizada.

Depois, vamos fazer com que isFeatureFlagActive() retorne TRUE para que a nova implementação seja utilizada.

Ai não! Alguma coisa tá errada! O site ficou mega lento de repente!

Desligue a feature flag, para que isFeatureFlagActive() retorne FALSE novamente.

...

As coisas parecem ter voltado ao normal. Vamos voltar à aplicação em máquina local e tentar entender o que aconteceu.

Você vai perceber que esquecemos de adicionar um índice para o campo users.uuid, então toda query para este campo ficou mega lenta. Hora de corrigir!

Passo 8: Tornar UUID unique e adicionar um índice nele

Como eu estou implementando com SQLITE, aqui vai o snippet que resolveria a situação:

sqlite> CREATE UNIQUE INDEX `users_uuid_uq` ON `users`(`uuid`);

Seria ideal mudar o campo para que fique NOT NULL também, mas eu vou pular esta etapa aqui porque é mais complicado no SQLITE e isto não é relevante para o nosso problema em questão.

Bom, tudo parece estar correto agora. Vamos lançar para produção, habilitar a feature flag e ver como o sistema se comporta.

Tudo certo? Agora é hora de limpar a casa.

Passo 9: limpar o campo id da tabela users

Agora que o novo código está funcionando como deveria, é hora de limpar o campo id que existia antes.

Você pode decidir se quer limpar apenas do código ou também do banco de dados, é uma questão de situação.

Ao fim das contas, SqliteUserRepository deverá ficar assim:

final class SqliteUserRepository implements UserRepository
{
public function findById(UserId $id): User
{
$sql = "...";
$stmt = $this->pdo->prepare($sql);

$stmt->execute(['uuid' => (string) $id->getUuid()]);
// ...
}
}

E a função que adiciona novos usuários também precisa de algumas mudanças:

...
public function insert(User $user): void
{
$user->id->uuid = Uuid::uuid4();

// insert logic
}
...

Vamos também remover o campo $id numérico da classe UserId e tornar UUID não nulo:

final class UserId
{
public function __construct(public UuidInterface $uuid) {}

public function getUuid(): UuidInterface
{
return $this->uuid;
}
}

Seu banco de dados foi migrado com segurança

É claro que cada projeto tem sua especificidade, mas no fim das contas, você vai precisar executar alguma variação da técnica descrita aqui.

Isto se aplica a qualquer migração relativa a repositórios de dados. Basta lembrar-se das três fases:

  1. Deixar os dois IDs coexistirem
  2. Migrar a aplicação para a implementação com UUID
  3. Remover a implementação com ID inteiro

Não precisa sentir vergonha ou preguiça de rodar vários passos de uma vez, mesmo que vá apagar depois! Fazer um rollback ou migrar um banco de dados em produção para corrigir problemas é muito mais doloroso do que os passos descritos aqui.

Abraço!

Comentários