«Если рабочий хочет хорошо выполнять свою работу, он должен сначала заточить свои инструменты» — Конфуций, «Аналитики Конфуция. Лу Лингун»
титульная страница > программирование > Упражнение по кодированию: инструмент миграции базы данных в nodejs

Упражнение по кодированию: инструмент миграции базы данных в nodejs

Опубликовано 4 ноября 2024 г.
Просматривать:883

Coding exercise: database migration tool in nodejs

Требования

Мне нужен инструмент миграции базы данных со следующими свойствами:

  1. Каждая миграция записывается в одном файле SQL, то есть как «вверх», так и «вниз». Это позволит Copilot выполнить откатную миграцию. А тот факт, что это простой SQL, также делает его наиболее гибким и поддерживаемым решением.
  2. Текущая примененная версия должна управляться этим инструментом. Я хочу, чтобы инструмент был самодостаточным.
  3. Я хочу, чтобы инструмент поддерживал различные базы данных, такие как Postgres, MySQL, SQL Server и т. д., поэтому его следует расширять в этом смысле.
  4. Я не хочу, чтобы она была слишком большой, поэтому следует устанавливать только драйвера для необходимой базы данных, в идеале по требованию.
  5. Я хочу, чтобы он стал частью экосистемы JavaScript, поскольку большинство проектов, над которыми я работаю, являются ее частью.
  6. Каждая миграция должна выполняться внутри транзакции.

Введение

Многие из этих замечаний возникли из моего опыта работы с этим замечательным инструментом под названием tern. Мне было грустно, что в Javascript нет того же самого! (Или, может быть, я плохо гуглю...). Поэтому я решил, что это может быть хорошим упражнением по программированию для меня и историей, которая может быть интересна кому-то еще :)

Разработка

Часть 1. Проектирование инструмента

Давайте украдем разработку инструмента CLI!

  1. Все миграции будут иметь следующую схему именования: _.sql, где число будет представлять номер версии миграции, например, 001_initial_setup.sql.
  2. Все миграции будут находиться в одном каталоге.
  3. Драйвер базы данных будет загружаться по требованию либо в виде какого-либо заранее установленного пакета, либо просто с помощью какой-либо команды npm install .

Таким образом, синтаксис инструмента будет следующим: martlet up --database-url --driver --dir

или martlet down .

Где «up» должны применить все миграции, которые еще не применены, а «down» должен выполнить откат к указанной версии.
Опции имеют следующее значение и значения по умолчанию:

  • database-url — строка подключения к базе данных, по умолчанию будет искать переменную env DATABASE_URL
  • драйвер — используемый драйвер базы данных. В первой версии я буду поддерживать Postgres только с опцией «pg».
  • dir — каталог, в котором находятся миграции, по умолчанию — миграции.

Как видите, я начал с выяснения того, как я буду вызывать этот инструмент, прежде чем писать какой-либо реальный код. Это хорошая практика, она помогает реализовать требования и сократить циклы разработки.

Часть 2. Реализация

2.1 Варианты парсинга

Хорошо, обо всём по порядку! Давайте создадим файл index.js и выведем справочное сообщение. Это будет выглядеть примерно так:

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

Теперь разберем варианты:

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



Как видите, я не использую никакую библиотеку для синтаксического анализа; Я просто перебираю список аргументов и обрабатываю каждый вариант. Итак, если у меня есть логическая опция, я бы сдвинул индекс итерации на 1, а если у меня есть опция со значением, я бы сдвинул ее на 2.

2.2 Реализация адаптера драйвера

Для поддержки нескольких драйверов нам нужен какой-то универсальный интерфейс для доступа к базе данных; вот как это может выглядеть:

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

Я думаю, что Connect и Close — довольно очевидные функции, позвольте мне объяснить метод транзакции. Он должен принимать функцию, которая будет вызываться с помощью функции, принимающей текст запроса и возвращающей обещание с промежуточным результатом. Эта сложность необходима для наличия общего интерфейса, который бы обеспечивал возможность запуска нескольких запросов внутри транзакции. Это легче понять, посмотрев на пример использования.

Вот как адаптер выглядит для драйвера 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();
  }
}

Пример использования может быть таким:

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 Установка драйвера по требованию

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

Сначала мы пытаемся установить драйвер с помощью Yarn, но мы не хотим создавать какие-либо различия в каталоге, поэтому сохраняем файлы Yarn.lock и package.json. Если пряжа недоступна, мы вернемся к npm.

Когда мы убедились, что драйвер установлен, мы можем создать адаптер и использовать его:

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

2.4 Реализация логики миграции

Начинаем с подключения к базе данных и получения текущей версии:

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

Затем мы читаем каталог миграций и сортируем их по версии. После этого мы применяем каждую миграцию, версия которой выше текущей. Я просто представлю фактическую миграцию в следующем фрагменте:

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

Откат миграции аналогичен, но мы сортируем миграции в обратном порядке и применяем их, пока не достигнем желаемой версии.

3. Тестирование

Я решил не использовать какую-либо конкретную среду тестирования, а использовать встроенные возможности тестирования nodejs. Они включают в себя программу запуска тестов и пакет утверждений.

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

И для выполнения тестов я бы запустил node --test --test-concurrency=1.

На самом деле я писал код в своего рода TDD-способе. Я не проверял вручную, работает ли мой код миграции, но писал его вместе с тестами. Поэтому я решил, что для этого инструмента лучше всего подойдут сквозные тесты.
При таком подходе тесты должны будут загрузить пустую базу данных, применить некоторые миграции, проверить правильность содержимого базы данных, а затем вернуться к исходному состоянию и подтвердить, что база данных пуста.
Для запуска базы данных я использовал библиотеку testcontainers, которая представляет собой красивую оболочку 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();
});

Я написал несколько простых миграций и проверил, что они работают должным образом. Вот пример проверки состояния базы данных:

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. Заключение

Это был пример того, как я бы подошел к разработке простого инструмента CLI в экосистеме JavaScript. Хочу отметить, что современная экосистема javascript довольно насыщенная и мощная, и мне удалось реализовать инструмент с минимумом внешних зависимостей. Я использовал драйвер postgres, который загружался по требованию, и тестовые контейнеры для тестов. Я думаю, что такой подход дает разработчикам максимальную гибкость и контроль над приложением.

5. Ссылки

  • репо Martlet
  • терн
  • драйвер Postgres
Заявление о выпуске Эта статья воспроизведена по адресу: https://dev.to/duskpoet/coding-exercision-database-migration-tool-in-nodejs-30pg?1. Если есть какие-либо нарушения, свяжитесь с [email protected], чтобы удалить их.
Последний учебник Более>

Изучайте китайский

Отказ от ответственности: Все предоставленные ресурсы частично взяты из Интернета. В случае нарушения ваших авторских прав или других прав и интересов, пожалуйста, объясните подробные причины и предоставьте доказательства авторских прав или прав и интересов, а затем отправьте их по электронной почте: [email protected]. Мы сделаем это за вас как можно скорее.

Copyright© 2022 湘ICP备2022001581号-3