Saya mahu mempunyai alat migrasi pangkalan data, yang mempunyai sifat berikut:
Banyak perkara ini lahir daripada pengalaman saya dengan alat hebat yang dipanggil tern ini. Saya sedih kerana javascript tidak mempunyai yang sama! (Atau mungkin saya malas googling...). Jadi saya memutuskan ini boleh menjadi latihan pengekodan yang bagus untuk diri saya sendiri dan cerita yang mungkin menarik kepada orang lain :)
Jom mencuri mereka bentuk alat CLI!
Jadi sintaks untuk alat itu ialah seperti berikut: martlet up --database-url
Di mana "atas" harus menggunakan semua migrasi yang belum digunakan dan ke bawah harus diputar semula ke versi yang ditentukan.
Pilihan mempunyai makna dan lalai berikut:
Seperti yang anda lihat, saya telah mula memikirkan cara saya menggunakan alat tersebut sebelum menulis sebarang kod sebenar. Ini adalah amalan yang baik, ia membantu merealisasikan keperluan dan mengurangkan kitaran pembangunan.
Ok, perkara pertama dahulu! Mari buat fail index.js dan keluarkan mesej bantuan. Ia akan kelihatan seperti ini:
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();
Kini kami akan menghuraikan pilihan:
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; }
Seperti yang anda lihat, saya tidak menggunakan mana-mana perpustakaan untuk menghurai; Saya hanya mengulangi senarai hujah dan memproses setiap pilihan. Jadi, jika saya mempunyai pilihan boolean, saya akan mengalihkan indeks lelaran sebanyak 1 dan jika saya mempunyai pilihan dengan nilai, saya akan mengalihkannya sebanyak 2.
Untuk menyokong berbilang pemacu, kita perlu mempunyai antara muka universal untuk mengakses pangkalan data; inilah rupanya:
interface Adapter { connect(url: string): Promise<void>; transact(query: (fn: (text) => Promise<ResultSet>)): Promise<ResultSet>; close(): Promise<void>; }
Saya rasa sambung dan tutup adalah fungsi yang cukup jelas, izinkan saya menerangkan kaedah transaksi. Ia harus menerima fungsi yang akan dipanggil dengan fungsi yang menerima teks pertanyaan dan mengembalikan janji dengan hasil perantaraan. Kerumitan ini diperlukan untuk mempunyai antara muka umum yang akan menyediakan keupayaan untuk menjalankan berbilang pertanyaan dalam transaksi. Ia lebih mudah untuk difahami dengan melihat contoh penggunaan.
Jadi ini adalah cara penyesuai mencari pemacu 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(); } }
Dan contoh penggunaannya mungkin:
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})`); });
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", }); };
Kami cuba memasang pemacu dengan benang pada mulanya, tetapi kami tidak mahu menjana sebarang perbezaan dalam direktori, jadi kami mengekalkan fail yarn.lock dan package.json. Jika benang tidak tersedia, kami akan kembali kepada npm.
Apabila kami memastikan pemacu dipasang, kami boleh mencipta penyesuai dan menggunakannya:
export async function loadAdapter(driver) { await downloadDriver(driver); return import(PACKAGES[driver].split("@")[0]).then( (m) => new PGAdapter(m.default), );
Kami bermula dengan menyambung ke pangkalan data dan mendapatkan versi semasa:
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.
Atas ialah kandungan terperinci Latihan pengekodan: alat pemindahan pangkalan data dalam nodejs. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!