"Se um trabalhador quiser fazer bem o seu trabalho, ele deve primeiro afiar suas ferramentas." - Confúcio, "Os Analectos de Confúcio. Lu Linggong"
Primeira página > Programação > Exercício de codificação: ferramenta de migração de banco de dados em nodejs

Exercício de codificação: ferramenta de migração de banco de dados em nodejs

Publicado em 2024-11-04
Navegar:867

Coding exercise: database migration tool in nodejs

Requisitos

Quero ter uma ferramenta de migração de banco de dados, que tenha as seguintes propriedades:

  1. Cada migração é escrita em um único arquivo SQL, o que significa partes "acima" e "abaixo". Isso permitirá que o Copilot preencha a migração de reversão. E o fato de ser um SQL simples também o torna a solução mais flexível e com suporte.
  2. A versão atualmente aplicada deve ser gerenciada pela ferramenta. Quero que a ferramenta seja autossuficiente.
  3. Quero que a ferramenta suporte diferentes bancos de dados, como Postgres, MySQL, SQL Server, etc., portanto, deve ser extensível nesse sentido.
  4. Não quero que ele seja superdimensionado, portanto, apenas drivers para o banco de dados necessário devem ser instalados, de preferência sob demanda.
  5. Quero que faça parte do ecossistema javascript, já que a maioria dos projetos em que trabalho fazem parte dele.
  6. Toda migração deve ser realizada dentro de uma transação.

Introdução

Muitos desses pontos nasceram da minha experiência com essa ferramenta incrível chamada tern. Fiquei triste porque o javascript não tem o mesmo! (Ou talvez eu seja péssimo pesquisando no Google...). Então decidi que este poderia ser um bom exercício de codificação para mim e uma história que poderia ser interessante para outra pessoa :)

Desenvolvimento

Parte 1. Projetando a ferramenta

Vamos roubar projetar a ferramenta CLI!

  1. Todas as migrações teriam o seguinte esquema de nomenclatura: _.sql, onde o número representaria o número da versão da migração, por exemplo, 001_initial_setup.sql.
  2. Todas as migrações residiriam em um único diretório.
  3. O driver do banco de dados seria baixado sob demanda, seja algum pacote pré-empacotado ou apenas emitindo algum tipo de npm install .

Portanto, a sintaxe da ferramenta seria a seguinte: martlet up --database-url --driver --dir

ou martlet down .

Onde "up" deve ser aplicado a todas as migrações que ainda não foram aplicadas e down deve reverter para a versão especificada.
As opções têm o seguinte significado e padrões:

  • database-url - string de conexão para o banco de dados, o padrão seria procurar a variável env DATABASE_URL
  • driver - driver de banco de dados a ser usado. Para a primeira versão, darei suporte apenas ao Postgres com uma opção chamada "pg".
  • dir - diretório onde residem as migrações, o padrão é migrações

Como você pode ver, comecei descobrindo como invocaria a ferramenta antes de escrever qualquer código real. Esta é uma boa prática, pois ajuda a concretizar os requisitos e reduzir os ciclos de desenvolvimento.

Parte 2. Implementação

2.1 Opções de análise

Ok, comecemos pelo princípio! Vamos criar um arquivo index.js e gerar a mensagem de ajuda. Seria algo assim:

function printHelp() {
  console.log(
    "Usage: martlet up --driver  --dir  --database-url ",
  );
  console.log(
    "       martlet down  --driver  --dir  --database-url ",
  );
  console.log(
    "        is a number that specifies the version to migrate down to",
  );
  console.log("Options:");
  console.log('  --driver   Driver to use, default is "pg"');
  console.log('  --dir         Directory to use, default is "migrations"');
  console.log(
    "  --database-url  Database URL to use, default is DATABASE_URL environment variable",
  );
}

printHelp();

Agora analisaremos as opções:

export function parseOptions(args) {
  const options = {
    dir: "migrations",
    driver: "pg",
    databaseUrl: process.env.DATABASE_URL,
  };
  for (let idx = 0; idx 



Como você pode ver, não uso nenhuma biblioteca para análise; Eu simplesmente itero a lista de argumentos e processo cada opção. Então, se eu tivesse uma opção booleana, eu mudaria o índice de iteração em 1, e se eu tivesse uma opção com um valor, eu mudaria em 2.

2.2 Implementando o adaptador de driver

Para suportar vários drivers, precisamos ter alguma interface universal para acessar um banco de dados; aqui está como pode parecer:

interface Adapter {
    connect(url: string): Promise;
    transact(query: (fn: (text) => Promise)): Promise;
    close(): Promise;
}

Acho que conectar e fechar são funções bastante óbvias, deixe-me explicar o método de transação. Deveria aceitar uma função que seria chamada com uma função que aceita um texto de consulta e retorna uma promessa com um resultado intermediário. Essa complexidade é necessária para ter uma interface geral que forneça a capacidade de executar múltiplas consultas dentro de uma transação. É mais fácil entender olhando o exemplo de uso.

Então é assim que o adaptador procura o driver postgres:

class PGAdapter {
  constructor(driver) {
    this.driver = driver;
  }

  async connect(url) {
    this.sql = this.driver(url);
  }

  async transact(query) {
    return this.sql.begin((sql) => (
      query((text) => sql.unsafe(text))
    ));
  }

  async close() {
    await this.sql.end();
  }
}

E o exemplo de uso poderia ser:

import postgres from "postgres";

const adapter = new PGAdapter(postgres);
await adapter.connect(url);
await adapter.transact(async (sql) => {
    const rows = await sql("SELECT * FROM table1");
    await sql(`INSERT INTO table2 (id) VALUES (${rows[0].id})`);
});

2.3 Instalação de driver sob demanda

const PACKAGES = {
  pg: "[email protected]",
};

const downloadDriver = async (driver) => {
  const pkg = PACKAGES[driver];
  if (!pkg) {
    throw new Error(`Unknown driver: ${driver}`);
  }
  try {
    await stat(join(process.cwd(), "yarn.lock"));
    const lockfile = await readFile(join(process.cwd(), "yarn.lock"));
    const packagejson = await readFile(join(process.cwd(), "package.json"));
    spawnSync("yarn", ["add", pkg], {
      stdio: "inherit",
    });
    await writeFile(join(process.cwd(), "yarn.lock"), lockfile);
    await writeFile(join(process.cwd(), "package.json"), packagejson);
    return;
  } catch {}
  spawnSync("npm", ["install", "--no-save", "--legacy-peer-deps", pkg], {
    stdio: "inherit",
  });
};

Tentamos instalar o driver com o yarn primeiro, mas não queremos gerar nenhuma diferença no diretório, então preservamos os arquivos yarn.lock e package.json. Se o fio não estiver disponível, voltaremos ao npm.

Quando tivermos certeza de que o driver está instalado, podemos criar um adaptador e usá-lo:

export async function loadAdapter(driver) {
  await downloadDriver(driver);
  return import(PACKAGES[driver].split("@")[0]).then(
    (m) => new PGAdapter(m.default),
  );

2.4 Implementando a lógica de migração

Começamos conectando-nos ao banco de dados e obtendo a versão atual:

await adapter.connect(options.databaseUrl);
console.log("Connected to database");

const currentVersion = await adapter.transact(async (sql) => {
    await sql(`create table if not exists schema_migrations (
      version integer primary key
    )`);
    const result = await sql(`select version from schema_migrations limit 1`);
    return result[0]?.version || 0;
});

console.log(`Current version: ${currentVersion}`);

Em seguida, lemos o diretório de migrações e os classificamos por versão. Depois disso, aplicamos toda migração que possui uma versão superior à atual. Apresentarei apenas a migração real no seguinte trecho:

await adapter.transact(async (sql) => {
    await sql(upMigration);
    await sql(
      `insert into schema_migrations (version) values (${version})`
    );
    await sql(`delete from schema_migrations where version != ${version}`);
});

A migração de reversão é semelhante, mas classificamos as migrações na ordem inversa e as aplicamos até chegarmos à versão desejada.

3. Teste

Decidi não usar nenhuma estrutura de teste específica, mas usar os recursos integrados de teste do nodejs. Eles incluem o executor de testes e o pacote de asserções.

import { it, before, after, describe } from "node:test";
import assert from "node:assert";

E para executar testes eu executaria node --test --test-concurrency=1.

Na verdade, eu estava escrevendo o código de uma maneira meio TDD. Não validei se meu código de migração funcionava manualmente, mas o estava escrevendo junto com os testes. É por isso que decidi que testes ponta a ponta seriam os mais adequados para esta ferramenta.
Para tal abordagem, os testes precisariam inicializar um banco de dados vazio, aplicar algumas migrações, verificar se o conteúdo do banco de dados está correto e, em seguida, reverter para o estado inicial e validar se o banco de dados está vazio.
Para executar um banco de dados, usei a biblioteca "testcontainers", que fornece um ótimo wrapper para o docker.

before(async () => {
    console.log("Starting container");
    container = await new GenericContainer("postgres:16-alpine")
    .withExposedPorts(5432)
    .withEnvironment({ POSTGRES_PASSWORD: "password" })
    .start();
});

after(async () => {
    await container.stop();
});

Escrevi algumas migrações simples e testei se funcionavam conforme o esperado. Aqui está um exemplo de validação de estado de banco de dados:

const sql = pg(`postgres://postgres:password@localhost:${port}/postgres`);
const result = await sql`select * from schema_migrations`;
assert.deepEqual(result, [{ version: 2 }]);
const tables =
    await sql`select table_name from information_schema.tables where table_schema = 'public'`;
assert.deepEqual(tables, [
    { table_name: "schema_migrations" },
    { table_name: "test" },
]);

4. Conclusão

Este foi um exemplo de como eu abordaria o desenvolvimento de uma ferramenta CLI simples no ecossistema javascript. Quero observar que o ecossistema javascript moderno é bastante carregado e poderoso, e consegui implementar a ferramenta com um mínimo de dependências externas. Usei um driver postgres que seria baixado sob demanda e testcontainers para testes. Acho que essa abordagem dá aos desenvolvedores mais flexibilidade e controle sobre o aplicativo.

5. Referências

  • repositório de martlet
  • tern
  • driver postgres
Declaração de lançamento Este artigo foi reproduzido em: https://dev.to/duskpoet/coding-exercise-database-migration-tool-in-nodejs-30pg?1 Se houver alguma violação, entre em contato com [email protected] para excluí-la
Tutorial mais recente Mais>

Isenção de responsabilidade: Todos os recursos fornecidos são parcialmente provenientes da Internet. Se houver qualquer violação de seus direitos autorais ou outros direitos e interesses, explique os motivos detalhados e forneça prova de direitos autorais ou direitos e interesses e envie-a para o e-mail: [email protected]. Nós cuidaremos disso para você o mais rápido possível.

Copyright© 2022 湘ICP备2022001581号-3