Мне нужен инструмент миграции базы данных со следующими свойствами:
Многие из этих замечаний возникли из моего опыта работы с этим замечательным инструментом под названием tern. Мне было грустно, что в Javascript нет того же самого! (Или, может быть, я плохо гуглю...). Поэтому я решил, что это может быть хорошим упражнением по программированию для меня и историей, которая может быть интересна кому-то еще :)
Давайте украдем разработку инструмента CLI!
Таким образом, синтаксис инструмента будет следующим: martlet up --database-url
Где «up» должны применить все миграции, которые еще не применены, а «down» должен выполнить откат к указанной версии.
Опции имеют следующее значение и значения по умолчанию:
Как видите, я начал с выяснения того, как я буду вызывать этот инструмент, прежде чем писать какой-либо реальный код. Это хорошая практика, она помогает реализовать требования и сократить циклы разработки.
Хорошо, обо всём по порядку! Давайте создадим файл 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. Ссылки
Отказ от ответственности: Все предоставленные ресурсы частично взяты из Интернета. В случае нарушения ваших авторских прав или других прав и интересов, пожалуйста, объясните подробные причины и предоставьте доказательства авторских прав или прав и интересов, а затем отправьте их по электронной почте: [email protected]. Мы сделаем это за вас как можно скорее.
Copyright© 2022 湘ICP备2022001581号-3