Rumah  >  Artikel  >  hujung hadapan web  >  Latihan pengekodan: alat pemindahan pangkalan data dalam nodejs

Latihan pengekodan: alat pemindahan pangkalan data dalam nodejs

DDD
DDDasal
2024-09-25 20:18:22521semak imbas

Coding exercise: database migration tool in nodejs

Keperluan

Saya mahu mempunyai alat migrasi pangkalan data, yang mempunyai sifat berikut:

  1. Setiap migrasi ditulis dalam satu fail SQL, bermakna kedua-dua bahagian "atas" dan "bawah". Ini akan membolehkan Copilot mengisi migrasi balik. Dan hakikat bahawa ia adalah SQL kosong juga menjadikannya penyelesaian yang paling fleksibel dan disokong.
  2. Versi yang digunakan pada masa ini harus diuruskan oleh alat. Saya mahu alat itu berdikari.
  3. Saya mahu alat itu menyokong pangkalan data yang berbeza, seperti Postgres, MySQL, SQL Server, dsb., jadi alat itu harus boleh dilanjutkan dalam erti kata itu.
  4. Saya tidak mahu ia terlalu besar, jadi hanya pemacu untuk pangkalan data yang diperlukan perlu dipasang, sebaik-baiknya atas permintaan.
  5. Saya mahu ia menjadi sebahagian daripada ekosistem javascript kerana kebanyakan projek yang saya kerjakan adalah sebahagian daripadanya.
  6. Setiap penghijrahan harus dilakukan di dalam urus niaga.

pengenalan

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 :)

Pembangunan

Bahagian 1. Mereka bentuk alat

Jom mencuri mereka bentuk alat CLI!

  1. Semua migrasi akan mempunyai skema penamaan berikut: _.sql, di mana nombor itu akan mewakili nombor versi migrasi, contohnya, 001_initial_setup.sql.
  2. Semua penghijrahan akan berada dalam satu dir.
  3. Pemandu pangkalan data akan dimuat turun atas permintaan, sama ada beberapa pakej yang diprabundel atau hanya mengeluarkan semacam npm install .

Jadi sintaks untuk alat itu ialah seperti berikut: martlet up --database-url --pemandu --dir

atau martlet down .

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:

  • url pangkalan data - rentetan sambungan untuk pangkalan data, lalainya adalah untuk mencari pembolehubah env DATABASE_URL
  • pemandu - pemacu pangkalan data untuk digunakan. Untuk versi pertama, saya hanya akan menyokong Postgres dengan pilihan bernama "pg".
  • dir - direktori tempat migrasi berada, lalai ialah migrasi

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.

Bahagian 2. Pelaksanaan

2.1 Pilihan penghuraian

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.

2.2 Melaksanakan penyesuai pemacu

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

2.3 Pemasangan pemandu atas permintaan

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),
  );

2.4 Melaksanakan logik migrasi

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.

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

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!

Kenyataan:
Kandungan artikel ini disumbangkan secara sukarela oleh netizen, dan hak cipta adalah milik pengarang asal. Laman web ini tidak memikul tanggungjawab undang-undang yang sepadan. Jika anda menemui sebarang kandungan yang disyaki plagiarisme atau pelanggaran, sila hubungi admin@php.cn