Maison  >  Article  >  interface Web  >  Exercice de codage : outil de migration de base de données dans nodejs

Exercice de codage : outil de migration de base de données dans nodejs

DDD
DDDoriginal
2024-09-25 20:18:22536parcourir

Coding exercise: database migration tool in nodejs

Exigences

Je souhaite disposer d'un outil de migration de base de données, qui possède les propriétés suivantes :

  1. Chaque migration est écrite dans un seul fichier SQL, c'est-à-dire les parties "haut" et "bas". Cela permettra à Copilot de remplir la migration de restauration. Et le fait qu'il s'agisse d'un simple SQL en fait également la solution la plus flexible et la plus prise en charge.
  2. La version actuellement appliquée doit être gérée par l'outil. Je veux que l'outil soit autonome.
  3. Je souhaite que l'outil prenne en charge différentes bases de données, telles que Postgres, MySQL, SQL Server, etc., il devrait donc être extensible dans ce sens.
  4. Je ne veux pas qu'il soit surdimensionné, donc seuls les pilotes de la base de données nécessaire doivent être installés, idéalement à la demande.
  5. Je souhaite qu'il fasse partie de l'écosystème javascript puisque la plupart des projets sur lesquels je travaille en font partie.
  6. Chaque migration doit être effectuée au sein d'une transaction.

Introduction

Beaucoup de ces points sont nés de mon expérience avec cet outil génial appelé tern. J'étais triste que javascript n'ait pas la même chose ! (Ou peut-être que je suis nul en recherche sur Google...). J'ai donc décidé que cela pourrait être un bel exercice de codage pour moi et une histoire qui pourrait intéresser quelqu'un d'autre :)

Développement

Partie 1. Conception de l'outil

Volons concevons l'outil CLI !

    Toutes les migrations auraient le schéma de dénomination suivant : _.sql, où le numéro représenterait le numéro de version de la migration, par exemple, 001_initial_setup.sql.
  1. Toutes les migrations résideraient dans un seul répertoire.
  2. Le pilote de base de données serait téléchargé à la demande, soit dans un package pré-groupé, soit simplement en émettant une sorte d'installation npm .
La syntaxe de l'outil serait donc la suivante : martlet up --database-url --pilote --dir ou martlet vers le bas .

Où "up" devrait appliquer toutes les migrations qui ne sont pas encore appliquées et down devrait revenir à la version spécifiée.

Les options ont la signification et les valeurs par défaut suivantes :

  • database-url - chaîne de connexion pour la base de données, la valeur par défaut serait de rechercher la variable d'environnement DATABASE_URL
  • pilote - pilote de base de données à utiliser. Pour la première version, je ne supporterai Postgres qu'avec une option nommée "pg".
  • dir - répertoire où résident les migrations, la valeur par défaut est migrations
Comme vous pouvez le voir, j'ai commencé par comprendre comment j'invoquerais l'outil avant d'écrire un code réel. C'est une bonne pratique, cela permet de répondre aux exigences et de réduire les cycles de développement.

Partie 2. Mise en œuvre

2.1 Options d'analyse

Ok, commençons par le commencement ! Créons un fichier index.js et affichons le message d'aide. Cela ressemblerait à ceci :


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

printHelp();
Nous allons maintenant analyser les options :


export function parseOptions(args) {
  const options = {
    dir: "migrations",
    driver: "pg",
    databaseUrl: process.env.DATABASE_URL,
  };
  for (let idx = 0; idx < args.length; ) {
    switch (args[idx]) {
      case "--help":
      case "-h": {
        printHelp();
        process.exit(0);
      }
      case "--dir": {
        options.dir = args[idx + 1];
        idx += 2;
        break;
      }
      case "--driver": {
        options.driver = args[idx + 1];
        idx += 2;
        break;
      }
      case "--database-url": {
        options.databaseUrl = args[idx + 1];
        idx += 2;
        break;
      }

      default: {
        console.error(`Unknown option: ${args[idx]}`);
        printHelp();
        process.exit(1);
      }
    }
  }
  return options;
}
Comme vous pouvez le voir, je n'utilise aucune bibliothèque pour l'analyse ; Je parcoure simplement la liste des arguments et traite chaque option. Donc, si j'ai une option booléenne, je décalerais l'index d'itération de 1, et si j'ai une option avec une valeur, je la décalerais de 2.

2.2 Implémentation de l'adaptateur de pilote

Pour prendre en charge plusieurs pilotes, nous devons disposer d'une interface universelle pour accéder à une base de données ; voici à quoi cela peut ressembler :


interface Adapter {
    connect(url: string): Promise<void>;
    transact(query: (fn: (text) => Promise<ResultSet>)): Promise<ResultSet>;
    close(): Promise<void>;
}
Je pense que connecter et fermer sont des fonctions assez évidentes, laissez-moi vous expliquer la méthode de transaction. Il doit accepter une fonction qui serait appelée avec une fonction qui accepte un texte de requête et renvoie une promesse avec un résultat intermédiaire. Cette complexité est nécessaire pour disposer d'une interface générale qui permettrait d'exécuter plusieurs requêtes au sein d'une transaction. C'est plus facile à comprendre en regardant l'exemple d'utilisation.

Voici donc à quoi ressemble l'adaptateur pour le pilote 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();
  }
}
Et l'exemple d'utilisation pourrait être :


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 Installation du pilote à la demande

const PACKAGES = {
  pg: "postgres@3.4.4",
};

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",
  });
};
Nous essayons d'abord d'installer le pilote avec fil, mais nous ne voulons pas générer de différences dans le répertoire, nous préservons donc les fichiers fil.lock et package.json. Si le fil n'est pas disponible, nous recourrons à npm.

Lorsque nous nous sommes assurés que le pilote est installé, nous pouvons créer un adaptateur et l'utiliser :


export async function loadAdapter(driver) {
  await downloadDriver(driver);
  return import(PACKAGES[driver].split("@")[0]).then(
    (m) => new PGAdapter(m.default),
  );
2.4 Mise en œuvre de la logique de migration

On commence par se connecter à la base de données et obtenir la version actuelle :


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

Then, we read the migrations directory and sort them by version. After that, we apply every migration that has a version greater than the current one. I will just present the actual migration in the following snippet:

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

The rollback migration is similar, but we sort the migrations in reverse order and apply them until we reach the desired version.

3. Testing

I decided not to use any specific testing framework but use the built-in nodejs testing capabilities. They include the test runner and the assertion package.

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

And to execute tests I would run node --test --test-concurrency=1.

Actually, I was writing the code in a sort of TDD manner. I didn't validate that my migrations code worked by hand, but I was writing it along with tests. That's why I decided that end-to-end tests would be the best fit for this tool.
For such an approach, tests would need to bootstrap an empty database, apply some migrations, check that database contents are correct, and then roll back to the initial state and validate that the database is empty.
To run a database, I used the "testcontainers" library, which provides a nice wrapper around 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();
});

I wrote some simple migrations and tested that they worked as expected. Here is an example of a database state validation:

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. Conclusion

This was an example of how I would approach the development of a simple CLI tool in the javascript ecosystem. I want to note that the modern javascript ecosystem is pretty charged and powerful, and I managed to implement the tool with a minimum of external dependencies. I used a postgres driver that would be downloaded on demand and testcontainers for tests. I think that approach gives developers the most flexibility and control over the application.

5. References

  • martlet repo
  • tern
  • postgres driver

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn