import { create } from "@stdjs/database" import * as fs from "../helpers/fs" import { create as createHistory } from "../history/create" import { MigratorConfig } from "../interfaces/config" import { History } from "../interfaces/history" import { ConnectionMap, Migration, MigrationExecutor } from "../interfaces/interfaces" import { parse } from "../migration/sql-parser" async function executeSqlfile(connections: ConnectionMap, filename: string): Promise { const resp = parse((await fs.readFile(filename)).toString()) const connection = connections[resp.meta.connection || "default"] if (!connection) { throw new Error(`not defined connection named "${resp.meta.connection}"!`) } for (const sql of resp.body.split(";")) { const trimSql = sql.trim() if (trimSql) { await connection.query(trimSql) } } } export class Migrator { protected connections: ConnectionMap protected history: History protected migrations: string constructor(config: MigratorConfig) { const connections: ConnectionMap = {} for (const key of Object.keys(config.connections)) { connections[key] = create(config.connections[key]) } this.connections = connections this.history = createHistory(config.history, connections) this.migrations = config.migrations } public async close(): Promise { for (const key of Object.keys(this.connections)) { await this.connections[key].close() } } public async up(id: string, forced = false): Promise { const migration = await this.getMigrationExecutor(id) const hasId = await this.history.has(id) if (forced || !hasId) { if (migration.up) { const result = migration.up(this.connections) if (result instanceof Promise) { await result } } await this.history.apply(id) } } public async down(id: string, forced = false): Promise { const migration = await this.getMigrationExecutor(id) const hasId = await this.history.has(id) if (forced || hasId) { if (migration.down) { const result = migration.down(this.connections) if (result instanceof Promise) { await result } } await this.history.cancel(id) } } public async migrate( onUp?: (migration: Migration) => void, onComplete?: (migration: Migration) => void, onError?: (error: any, migration: Migration) => void): Promise { const migrations = (await this.status()).filter(migration => !migration.applied) for (const migration of migrations) { if (onUp) { onUp(migration) } try { await this.up(migration.id) if (onComplete) { onComplete(migration) } } catch (e) { if (onError) { onError(e, migration) } throw e } } } public async rollback( onDown?: (migration: Migration) => void, onComplete?: (migration: Migration) => void, onError?: (error: any, migration: Migration) => void): Promise { const migrations = (await this.status()).filter(migration => migration.applied) const last = migrations.pop() if (last) { if (onDown) { onDown(last) } try { await this.down(last.id) if (onComplete) { onComplete(last) } } catch (e) { if (onError) { onError(e, last) } throw e } } } public async status(): Promise { const migrations: {[id: string]: Migration} = {} for (const file of await this.getMigrationFiles()) { const matches = file.match(/^([0-9]{6}_[0-9]{6})_(.+)$/) if (!matches) { continue } const id = matches[1] if (!migrations[id]) { migrations[id] = { id, file: {}, applied: await this.history.has(id), } } const filename = matches[0] if (filename.indexOf(".up.") > -1) { migrations[id].file.up = filename } else if (filename.indexOf(".down.") > -1) { migrations[id].file.down = filename } } return Object.values(migrations).sort((a, b) => { if (a.id === b.id) { return 0 } return a.id > b.id ? 1 : -1 }) } protected async getMigrationExecutor(id: string): Promise { if (!/^\d{6}_\d{6}$/.test(id)) { throw new Error("invalid migration id. it must be like 000000_000000.") } const migration = (await this.status()).find(migrate => migrate.id === id) if (!migration) { throw new Error(`not exists migration(id=${id})`) } return { up: async (connections) => { if (!migration.file.up) { return } await executeSqlfile(connections, `${this.migrations}/${migration.file.up}`) }, down: async (connections) => { if (!migration.file.down) { return } await executeSqlfile(connections, `${this.migrations}/${migration.file.down}`) }, } } protected async getMigrationFiles(): Promise { const RE_SQL_EXT = /\.(up|down)\.sql$/ try { return (await fs.readdir(this.migrations)) .filter(file => RE_SQL_EXT.test(file)) .sort((a, b) => { if (a === b) { return 0 } return a > b ? 1 : -1 }) } catch (e) { if (e.code === "ENOENT") { return [] } throw e } } }