"Si un ouvrier veut bien faire son travail, il doit d'abord affûter ses outils." - Confucius, "Les Entretiens de Confucius. Lu Linggong"
Page de garde > La programmation > 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

Publié le 2024-11-04
Parcourir:960

Coding exercise: database migration tool in nodejs

Exigences

Je souhaite disposer d'un outil de migration de base de données possédant 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 bon exercice de codage pour moi et une histoire qui pourrait intéresser quelqu'un d'autre :)

Développement

Partie 1. Conception de l'outil

Concevons volons l'outil CLI !

  1. 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.
  2. Toutes les migrations résideraient dans un seul répertoire.
  3. 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 de d'installation npm.

La syntaxe de l'outil serait donc la suivante : martlet up --database-url --driver --dir

ou martlet down .

Où "up" doit appliquer toutes les migrations qui ne sont pas encore appliquées et down doit 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
  • driver - 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 les migrations

Comme vous pouvez le voir, j'ai commencé par comprendre comment j'invoquerais l'outil avant d'écrire du code réel. Il s'agit d'une bonne pratique, elle 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  --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();

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 



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;
    transact(query: (fn: (text) => Promise)): Promise;
    close(): Promise;
}

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 comment l'adaptateur recherche 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: "[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",
  });
};

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

Ensuite, nous lisons le répertoire des migrations et les trions par version. Après cela, nous appliquons chaque migration dont la version est supérieure à la version actuelle. Je vais simplement présenter la migration réelle dans l'extrait suivant :

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 migration par restauration est similaire, mais nous trions les migrations dans l'ordre inverse et les appliquons jusqu'à atteindre la version souhaitée.

3. Tests

J'ai décidé de ne pas utiliser de cadre de test spécifique, mais d'utiliser les capacités de test intégrées de Nodejs. Ils incluent le programme d'exécution de tests et le package d'assertions.

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

Et pour exécuter des tests, j'exécuterais node --test --test-concurrency=1.

En fait, j'écrivais le code d'une sorte de manière TDD. Je n'ai pas validé que mon code de migration fonctionnait à la main, mais je l'écrivais avec des tests. C'est pourquoi j'ai décidé que les tests de bout en bout seraient les mieux adaptés à cet outil.
Pour une telle approche, les tests devraient amorcer une base de données vide, appliquer certaines migrations, vérifier que le contenu de la base de données est correct, puis revenir à l'état initial et valider que la base de données est vide.
Pour exécuter une base de données, j'ai utilisé la bibliothèque "testcontainers", qui fournit un joli wrapper autour de 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();
});

J'ai écrit quelques migrations simples et vérifié qu'elles fonctionnaient comme prévu. Voici un exemple de validation de l'état d'une base de données :

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

C'était un exemple de la façon dont j'aborderais le développement d'un outil CLI simple dans l'écosystème javascript. Je tiens à noter que l'écosystème javascript moderne est assez chargé et puissant, et j'ai réussi à implémenter l'outil avec un minimum de dépendances externes. J'ai utilisé un pilote Postgres qui serait téléchargé à la demande et des conteneurs de test pour les tests. Je pense que cette approche donne aux développeurs le plus de flexibilité et de contrôle sur l'application.

5. Références

  • repo de martlets
  • sterne
  • pilote postgres
Déclaration de sortie Cet article est reproduit sur : https://dev.to/duskpoet/coding-exercise-database-migration-tool-in-nodejs-30pg?1 En cas d'infraction, veuillez contacter [email protected] pour le supprimer.
Dernier tutoriel Plus>

Clause de non-responsabilité: Toutes les ressources fournies proviennent en partie d'Internet. En cas de violation de vos droits d'auteur ou d'autres droits et intérêts, veuillez expliquer les raisons détaillées et fournir une preuve du droit d'auteur ou des droits et intérêts, puis l'envoyer à l'adresse e-mail : [email protected]. Nous nous en occuperons pour vous dans les plus brefs délais.

Copyright© 2022 湘ICP备2022001581号-3