Je souhaite disposer d'un outil de migration de base de données possédant 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 bon exercice de codage pour moi et une histoire qui pourrait intéresser quelqu'un d'autre :)
Concevons volons l'outil CLI !
La syntaxe de l'outil serait donc la suivante : martlet up --database-url
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 :
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.
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; idxComme 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
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