Maison > Article > interface Web > Exercice de codage : outil de migration de base de données dans nodejs
Je souhaite disposer d'un outil de migration de base de données, qui possède les propriétés suivantes :
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 :)
Volons concevons l'outil CLI !
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 :
Partie 2. Mise en œuvre
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
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
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.
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" }, ]);
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.
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!