„Wenn ein Arbeiter seine Arbeit gut machen will, muss er zuerst seine Werkzeuge schärfen.“ – Konfuzius, „Die Gespräche des Konfuzius. Lu Linggong“
Titelseite > Programmierung > Codierungsübung: Datenbankmigrationstool in NodeJS

Codierungsübung: Datenbankmigrationstool in NodeJS

Veröffentlicht am 04.11.2024
Durchsuche:377

Coding exercise: database migration tool in nodejs

Anforderungen

Ich möchte ein Datenbankmigrationstool haben, das die folgenden Eigenschaften hat:

  1. Jede Migration wird in einer einzigen SQL-Datei geschrieben, d. h. sowohl „nach oben“ als auch „nach unten“-Teile. Dadurch kann Copilot die Rollback-Migration durchführen. Und die Tatsache, dass es sich um reines SQL handelt, macht es auch zur flexibelsten und unterstütztesten Lösung.
  2. Die aktuell angewendete Version sollte vom Tool verwaltet werden. Ich möchte, dass das Tool autark ist.
  3. Ich möchte, dass das Tool verschiedene Datenbanken wie Postgres, MySQL, SQL Server usw. unterstützt, daher sollte es in diesem Sinne erweiterbar sein.
  4. Ich möchte nicht, dass es überdimensioniert wird, daher sollten nur Treiber für die erforderliche Datenbank installiert werden, idealerweise bei Bedarf.
  5. Ich möchte, dass es Teil des Javascript-Ökosystems ist, da die meisten Projekte, an denen ich arbeite, ein Teil davon sind.
  6. Jede Migration sollte innerhalb einer Transaktion durchgeführt werden.

Einführung

Viele dieser Punkte sind aus meiner Erfahrung mit diesem tollen Tool namens Tern entstanden. Ich war traurig, dass Javascript nicht das Gleiche hat! (Oder vielleicht ist mir das Googeln scheiße...). Also habe ich beschlossen, dass dies eine schöne Programmierübung für mich selbst und eine Geschichte sein könnte, die für jemand anderen interessant sein könnte :)

Entwicklung

Teil 1. Entwerfen des Werkzeugs

Lassen Sie uns das CLI-Tool stehlen!

  1. Alle Migrationen hätten das folgende Benennungsschema: _.sql, wobei die Nummer die Versionsnummer der Migration darstellen würde, zum Beispiel 001_initial_setup.sql.
  2. Alle Migrationen würden sich in einem einzigen Verzeichnis befinden.
  3. Der Datenbanktreiber wird bei Bedarf heruntergeladen, entweder als vorgefertigtes Paket oder einfach durch Ausgabe einer Art npm install .

Die Syntax für das Tool wäre also die folgende: martlet up --database-url --driver --dir

oder martlet down .

Wobei „up“ alle Migrationen anwenden sollte, die noch nicht angewendet wurden, und „down“ auf die angegebene Version zurücksetzen sollte.
Optionen haben die folgende Bedeutung und Standardeinstellungen:

  • Datenbank-URL – Verbindungszeichenfolge für die Datenbank. Standardmäßig wird nach der Umgebungsvariablen DATABASE_URL gesucht
  • driver – zu verwendender Datenbanktreiber. Für die erste Version werde ich Postgres nur mit einer Option namens „pg“ unterstützen.
  • dir – Verzeichnis, in dem sich Migrationen befinden, Standard ist Migrationen

Wie Sie sehen, habe ich zunächst herausgefunden, wie ich das Tool aufrufen würde, bevor ich tatsächlichen Code schreibe. Dies ist eine gute Vorgehensweise, sie hilft, Anforderungen zu realisieren und Entwicklungszyklen zu verkürzen.

Teil 2. Implementierung

2.1 Parsing-Optionen

Ok, das Wichtigste zuerst! Lassen Sie uns eine index.js-Datei erstellen und die Hilfemeldung ausgeben. Es würde ungefähr so ​​aussehen:

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

Jetzt analysieren wir die Optionen:

export function parseOptions(args) {
  const options = {
    dir: "migrations",
    driver: "pg",
    databaseUrl: process.env.DATABASE_URL,
  };
  for (let idx = 0; idx 



Wie Sie sehen, verwende ich keine Bibliothek zum Parsen; Ich gehe einfach die Argumentliste durch und verarbeite jede Option. Wenn ich also eine boolesche Option habe, würde ich den Iterationsindex um 1 verschieben, und wenn ich eine Option mit einem Wert habe, würde ich ihn um 2 verschieben.

2.2 Implementierung des Treiberadapters

Um mehrere Treiber zu unterstützen, benötigen wir eine universelle Schnittstelle für den Zugriff auf eine Datenbank. So könnte es aussehen:

interface Adapter {
    connect(url: string): Promise;
    transact(query: (fn: (text) => Promise)): Promise;
    close(): Promise;
}

Ich denke, Verbinden und Schließen sind ziemlich offensichtliche Funktionen. Lassen Sie mich die Transaktionsmethode erklären. Es sollte eine Funktion akzeptieren, die mit einer Funktion aufgerufen würde, die einen Abfragetext akzeptiert und ein Versprechen mit einem Zwischenergebnis zurückgibt. Diese Komplexität ist erforderlich, um über eine allgemeine Schnittstelle zu verfügen, die die Möglichkeit bietet, mehrere Abfragen innerhalb einer Transaktion auszuführen. Es ist leichter zu verstehen, wenn man sich das Anwendungsbeispiel ansieht.

So sucht der Adapter also nach dem Postgres-Treiber:

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

Und das Anwendungsbeispiel könnte sein:

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 On-Demand-Treiberinstallation

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

Wir versuchen zunächst, den Treiber mit Garn zu installieren, möchten aber keine Diffs im Verzeichnis generieren, daher behalten wir die Dateien Yarn.lock und Package.json bei. Sollte Garn nicht verfügbar sein, greifen wir auf npm zurück.

Wenn wir sichergestellt haben, dass der Treiber installiert ist, können wir einen Adapter erstellen und ihn verwenden:

export async function loadAdapter(driver) {
  await downloadDriver(driver);
  return import(PACKAGES[driver].split("@")[0]).then(
    (m) => new PGAdapter(m.default),
  );

2.4 Implementierung der Migrationslogik

Wir beginnen damit, eine Verbindung zur Datenbank herzustellen und die aktuelle Version abzurufen:

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

Dann lesen wir das Migrationsverzeichnis und sortieren sie nach Version. Danach wenden wir jede Migration an, deren Version größer als die aktuelle ist. Ich werde die tatsächliche Migration nur im folgenden Snippet vorstellen:

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

Die Rollback-Migration ist ähnlich, aber wir sortieren die Migrationen in umgekehrter Reihenfolge und wenden sie an, bis wir die gewünschte Version erreichen.

3. Testen

Ich habe mich entschieden, kein bestimmtes Test-Framework zu verwenden, sondern die integrierten NodeJS-Testfunktionen zu verwenden. Dazu gehören der Testläufer und das Assertion-Paket.

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

Und um Tests auszuführen, würde ich node --test --test-concurrency=1 ausführen.

Eigentlich habe ich den Code auf eine Art TDD-Art geschrieben. Ich habe nicht manuell überprüft, ob mein Migrationscode funktioniert, aber ich habe ihn zusammen mit Tests geschrieben. Aus diesem Grund habe ich entschieden, dass End-to-End-Tests am besten zu diesem Tool passen würden.
Für einen solchen Ansatz müssten Tests eine leere Datenbank booten, einige Migrationen durchführen, prüfen, ob die Datenbankinhalte korrekt sind, und dann zum Ausgangszustand zurückkehren und bestätigen, dass die Datenbank leer ist.
Um eine Datenbank auszuführen, habe ich die Bibliothek „testcontainers“ verwendet, die einen schönen Wrapper für Docker bietet.

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

Ich habe einige einfache Migrationen geschrieben und getestet, ob sie wie erwartet funktionieren. Hier ist ein Beispiel für eine Datenbankstatusvalidierung:

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. Fazit

Dies war ein Beispiel dafür, wie ich die Entwicklung eines einfachen CLI-Tools im Javascript-Ökosystem angehen würde. Ich möchte anmerken, dass das moderne Javascript-Ökosystem ziemlich umfangreich und leistungsstark ist und ich es geschafft habe, das Tool mit einem Minimum an externen Abhängigkeiten zu implementieren. Ich habe einen Postgres-Treiber verwendet, der bei Bedarf heruntergeladen wurde, und Testcontainer für Tests. Ich denke, dass dieser Ansatz Entwicklern die größte Flexibilität und Kontrolle über die Anwendung bietet.

5. Referenzen

  • Martlet-Repo
  • tern
  • Postgres-Treiber
Freigabeerklärung Dieser Artikel ist abgedruckt unter: https://dev.to/duskpoet/coding-exercise-database-migration-tool-in-nodejs-30pg?1 Bei Verstößen wenden Sie sich bitte an [email protected], um ihn zu löschen
Neuestes Tutorial Mehr>

Haftungsausschluss: Alle bereitgestellten Ressourcen stammen teilweise aus dem Internet. Wenn eine Verletzung Ihres Urheberrechts oder anderer Rechte und Interessen vorliegt, erläutern Sie bitte die detaillierten Gründe und legen Sie einen Nachweis des Urheberrechts oder Ihrer Rechte und Interessen vor und senden Sie ihn dann an die E-Mail-Adresse: [email protected] Wir werden die Angelegenheit so schnell wie möglich für Sie erledigen.

Copyright© 2022 湘ICP备2022001581号-3