"Si un trabajador quiere hacer bien su trabajo, primero debe afilar sus herramientas." - Confucio, "Las Analectas de Confucio. Lu Linggong"
Página delantera > Programación > Ejercicio de codificación: herramienta de migración de bases de datos en nodejs

Ejercicio de codificación: herramienta de migración de bases de datos en nodejs

Publicado el 2024-11-04
Navegar:766

Coding exercise: database migration tool in nodejs

Requisitos

Quiero tener una herramienta de migración de bases de datos, que tenga las siguientes propiedades:

  1. Cada migración se escribe en un único archivo SQL, es decir, partes "arriba" y "abajo". Esto permitirá a Copilot completar la migración de reversión. Y el hecho de que sea un SQL básico también la convierte en la solución más flexible y compatible.
  2. La versión aplicada actualmente debe ser administrada por la herramienta. Quiero que la herramienta sea autosuficiente.
  3. Quiero que la herramienta admita diferentes bases de datos, como Postgres, MySQL, SQL Server, etc., por lo que debería ser extensible en ese sentido.
  4. No quiero que sea demasiado grande, por lo que solo se deben instalar los controladores para la base de datos necesaria, idealmente según demanda.
  5. Quiero que sea parte del ecosistema javascript ya que la mayoría de los proyectos en los que trabajo son parte de él.
  6. Cada migración debe realizarse dentro de una transacción.

Introducción

Muchos de estos puntos surgieron de mi experiencia con esta increíble herramienta llamada tern. ¡Me entristeció que JavaScript no tuviera lo mismo! (O tal vez soy un desastre buscando en Google...). Así que decidí que este podría ser un buen ejercicio de codificación para mí y una historia que podría resultar interesante para otra persona :)

Desarrollo

Parte 1. Diseño de la herramienta

¡Vamos a robar diseñar la herramienta CLI!

  1. Todas las migraciones tendrían el siguiente esquema de nombres: _.sql, donde el número representaría el número de versión de la migración, por ejemplo, 001_initial_setup.sql.
  2. Todas las migraciones residirían en un solo directorio.
  3. El controlador de la base de datos se descargará a pedido, ya sea mediante algún paquete preinstalado o simplemente emitiendo algún tipo de de instalación de npm.

Entonces la sintaxis de la herramienta sería la siguiente: martlet up --database-url --driver --dir

o martlet down .

Donde "arriba" debería aplicar todas las migraciones que aún no se han aplicado y abajo debería revertirse a la versión especificada.
Las opciones tienen el siguiente significado y valores predeterminados:

  • database-url - cadena de conexión para la base de datos, el valor predeterminado sería buscar la variable env DATABASE_URL
  • controlador - controlador de base de datos a utilizar. Para la primera versión, solo admitiré Postgres con una opción llamada "pg".
  • dir - directorio donde residen las migraciones, el valor predeterminado es migraciones

Como puede ver, comencé a descubrir cómo invocaría la herramienta antes de escribir cualquier código real. Esta es una buena práctica, ayuda a cumplir los requisitos y reducir los ciclos de desarrollo.

Parte 2. Implementación

2.1 Opciones de análisis

Ok, ¡lo primero es lo primero! Creemos un archivo index.js y enviemos el mensaje de ayuda. Se vería así:

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();

Ahora analizaremos las opciones:

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



Como puedes ver, no uso ninguna biblioteca para analizar; Simplemente repito la lista de argumentos y proceso cada opción. Entonces, si tengo una opción booleana, cambiaría el índice de iteración en 1, y si tengo una opción con un valor, lo cambiaría en 2.

2.2 Implementación del adaptador de controlador

Para admitir múltiples controladores, necesitamos tener alguna interfaz universal para acceder a una base de datos; así es como puede verse:

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

Creo que conectar y cerrar son funciones bastante obvias, déjame explicarte el método de transacción. Debería aceptar una función que sería llamada con una función que acepta un texto de consulta y devuelve una promesa con un resultado intermedio. Esta complejidad es necesaria para tener una interfaz general que brinde la capacidad de ejecutar múltiples consultas dentro de una transacción. Es más fácil de entender mirando el ejemplo de uso.

Así es como el adaptador busca el controlador 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();
  }
}

Y el ejemplo de uso podría 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 Instalación del controlador bajo 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",
  });
};

Al principio intentamos instalar el controlador con Yarn, pero no queremos generar ninguna diferencia en el directorio, por lo que conservamos los archivos Yarn.lock y Package.json. Si el hilo no está disponible, recurriremos a npm.

Cuando nos aseguramos de que el controlador esté instalado, podemos crear un adaptador y usarlo:

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

2.4 Implementación de la lógica de migración

Comenzamos conectándonos a la base de datos y obteniendo la versión actual:

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}`);

Luego, leemos el directorio de migraciones y las ordenamos por versión. Después de eso, aplicamos cada migración que tenga una versión mayor a la actual. Simplemente presentaré la migración real en el siguiente fragmento:

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}`);
});

La migración de reversión es similar, pero clasificamos las migraciones en orden inverso y las aplicamos hasta llegar a la versión deseada.

3. Pruebas

Decidí no utilizar ningún marco de prueba específico, sino utilizar las capacidades de prueba integradas de Nodejs. Incluyen el ejecutor de pruebas y el paquete de aserción.

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

Y para ejecutar pruebas ejecutaría node --test --test-concurrency=1.

En realidad, estaba escribiendo el código en una especie de forma TDD. No validé que mi código de migraciones funcionara a mano, pero lo fui escribiendo junto con las pruebas. Por eso decidí que las pruebas de un extremo a otro serían la mejor opción para esta herramienta.
Para tal enfoque, las pruebas necesitarían iniciar una base de datos vacía, aplicar algunas migraciones, verificar que el contenido de la base de datos sea correcto y luego volver al estado inicial y validar que la base de datos esté vacía.
Para ejecutar una base de datos, utilicé la biblioteca "testcontainers", que proporciona un buen complemento para la ventana acoplable.

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();
});

Escribí algunas migraciones simples y probé que funcionaban como se esperaba. A continuación se muestra un ejemplo de validación del estado de una base de datos:

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. Conclusión

Este fue un ejemplo de cómo abordaría el desarrollo de una herramienta CLI simple en el ecosistema de JavaScript. Quiero señalar que el ecosistema moderno de JavaScript es bastante potente y potente, y logré implementar la herramienta con un mínimo de dependencias externas. Utilicé un controlador Postgres que se descargaría a pedido y contenedores de prueba para las pruebas. Creo que ese enfoque ofrece a los desarrolladores la mayor flexibilidad y control sobre la aplicación.

5. Referencias

  • repositorio de mercado
  • tern
  • controlador postgres
Declaración de liberación Este artículo se reproduce en: https://dev.to/duskpoet/coding-exercise-database-migration-tool-in-nodejs-30pg?1 Si hay alguna infracción, comuníquese con [email protected] para eliminarla.
Último tutorial Más>

Descargo de responsabilidad: Todos los recursos proporcionados provienen en parte de Internet. Si existe alguna infracción de sus derechos de autor u otros derechos e intereses, explique los motivos detallados y proporcione pruebas de los derechos de autor o derechos e intereses y luego envíelos al correo electrónico: [email protected]. Lo manejaremos por usted lo antes posible.

Copyright© 2022 湘ICP备2022001581号-3