Quero ter uma ferramenta de migração de banco de dados, que tenha as seguintes propriedades:
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 :)
Vamos roubar projetar a ferramenta CLI!
Portanto, a sintaxe da ferramenta seria a seguinte: martlet up --database-url
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:
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.
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; idxComo 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
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