@freemework/sql.misc.migration
Version:
Hosting library of the Freemework Project.
351 lines (308 loc) • 12.6 kB
text/typescript
import * as fs from "fs";
import * as path from "path";
import { promisify } from "util";
import { fileURLToPath } from "url";
import {
FCancellationToken,
FExecutionContext,
FCancellationExecutionContext,
FExceptionArgument,
FExceptionInvalidOperation
} from "@freemework/common";
const existsAsync = promisify(fs.exists);
const mkdirAsync = promisify(fs.mkdir);
const readdirAsync = promisify(fs.readdir);
const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
export class FSqlMigrationSources {
public readonly versionNames: ReadonlyArray<string>;
private readonly _versions: Map<string, FSqlMigrationSources.VersionBundle>;
/**
* Load data into memory and represent it as FMigrationSources
* @param executionContext
* @param sourceUri Sources url. Support schemas `file:`, `http+tar+gz:` and `https+tar+gz:`
*/
public static load(executionContext: FExecutionContext, sourceUri: URL, opts?: {
readonly versionFrom?: string;
readonly versionTo?: string;
}): Promise<FSqlMigrationSources> {
switch (sourceUri.protocol as UrlSchemas) {
case UrlSchemas.FILE: {
const sourceDirectory: string = fileURLToPath(sourceUri);
return FSqlMigrationSources.loadFromFilesystem(executionContext, sourceDirectory, opts);
}
case UrlSchemas.HTTP_TAR_GZ:
throw new FExceptionInvalidOperation("Not implemented yet");
case UrlSchemas.HTTPS_TAR_GZ:
throw new FExceptionInvalidOperation("Not implemented yet");
default:
throw new NotSupportedUrlSchemaException(sourceUri);
}
}
public static async loadFromFilesystem(
executionContext: FExecutionContext, sourceDirectory: string, opts?: {
readonly versionFrom?: string;
readonly versionTo?: string;
}
): Promise<FSqlMigrationSources> {
if (!await existsAsync(sourceDirectory)) {
throw new FSqlMigrationSources.WrongMigrationDataException(`Migration directory '${sourceDirectory}' is not exist`);
}
const migrationBundles: Array<FSqlMigrationSources.VersionBundle> = [];
const listVersions: Array<string> = (await readdirAsync(sourceDirectory, { withFileTypes: true }))
.filter(w => w.isDirectory())
.map(directory => directory.name);
const cancellationToken: FCancellationToken = FCancellationExecutionContext.of(executionContext).cancellationToken;
if (listVersions.length > 0) {
for (const version of listVersions) {
if (opts !== undefined) {
// Control version load range, skip out range versions
if (opts.versionFrom !== undefined) {
if (version < opts.versionFrom) {
continue;
}
}
if (opts.versionTo !== undefined) {
if (version > opts.versionTo) {
continue;
}
}
}
cancellationToken.throwIfCancellationRequested();
const versionDirectory = path.join(sourceDirectory, version);
const installDirectory = path.join(versionDirectory, FSqlMigrationSources.Direction.INSTALL);
const rollbackDirectory = path.join(versionDirectory, FSqlMigrationSources.Direction.ROLLBACK);
const installBundleItems: Array<FSqlMigrationSources.Script> = [];
const rollbackBundleItems: Array<FSqlMigrationSources.Script> = [];
if (await existsAsync(installDirectory)) {
const migrationFiles = await readdirAsync(installDirectory);
if (migrationFiles.length > 0) {
for (const migrationFile of migrationFiles) {
cancellationToken.throwIfCancellationRequested();
const scriptFile = path.join(installDirectory, migrationFile);
const scriptContent: string = await readFileAsync(scriptFile, "utf-8");
const scriptKind: FSqlMigrationSources.Script.Kind = resolveScriptKindByExtension(scriptFile);
installBundleItems.push(
new FSqlMigrationSources.Script(migrationFile, scriptKind, scriptFile, scriptContent)
);
}
}
}
if (await existsAsync(rollbackDirectory)) {
const migrationFiles = await readdirAsync(rollbackDirectory);
if (migrationFiles.length > 0) {
for (const migrationFile of migrationFiles) {
cancellationToken.throwIfCancellationRequested();
const scriptFile = path.join(rollbackDirectory, migrationFile);
const scriptContent: string = await readFileAsync(scriptFile, "utf-8");
const scriptKind: FSqlMigrationSources.Script.Kind = resolveScriptKindByExtension(scriptFile);
rollbackBundleItems.push(
new FSqlMigrationSources.Script(migrationFile, scriptKind, scriptFile, scriptContent)
);
}
}
}
migrationBundles.push(
new FSqlMigrationSources.VersionBundle(version, installBundleItems, rollbackBundleItems)
);
}
}
return new FSqlMigrationSources(migrationBundles);
}
public getVersionBundle(versionName: string): FSqlMigrationSources.VersionBundle {
const item = this._versions.get(versionName);
if (item === undefined) {
throw new FExceptionArgument(`No version bundle with name: ${versionName}`, "versionName");
}
return item;
}
public map(
callbackfn: (content: FSqlMigrationSources.Script["content"], info: {
readonly versionName: string;
readonly direction: FSqlMigrationSources.Direction;
readonly itemName: string;
}) => FSqlMigrationSources.Script["content"]
): FSqlMigrationSources {
const mappedBundles: Array<FSqlMigrationSources.VersionBundle> = [];
for (const versionName of this.versionNames) {
const bundle: FSqlMigrationSources.VersionBundle = this.getVersionBundle(versionName);
const newBundle = bundle.map((item, opts) => {
return callbackfn(item.content, Object.freeze({
itemName: item.name,
direction: opts.direction,
versionName
}));
});
mappedBundles.push(newBundle);
}
return new FSqlMigrationSources(mappedBundles);
}
public async saveToFilesystem(
executionContext: FExecutionContext, destinationDirectory: string
): Promise<void> {
if (!(await existsAsync(destinationDirectory))) {
throw new FExceptionArgument(
`Target directory '${destinationDirectory}' not exist. You must provide empty directory.`,
"destinationDirectory"
);
}
const cancellationToken: FCancellationToken = FCancellationExecutionContext.of(executionContext).cancellationToken;
for (const versionName of this.versionNames) {
cancellationToken.throwIfCancellationRequested();
const versionDirectory: string = path.join(destinationDirectory, versionName);
const installDirectory: string = path.join(versionDirectory, FSqlMigrationSources.Direction.INSTALL);
const rollbackDirectory: string = path.join(versionDirectory, FSqlMigrationSources.Direction.ROLLBACK);
await mkdirAsync(versionDirectory);
cancellationToken.throwIfCancellationRequested();
await mkdirAsync(installDirectory);
cancellationToken.throwIfCancellationRequested();
await mkdirAsync(rollbackDirectory);
const versionBundle: FSqlMigrationSources.VersionBundle = this.getVersionBundle(versionName);
for (const installItemName of versionBundle.installScriptNames) {
cancellationToken.throwIfCancellationRequested();
const bundleFile = path.join(installDirectory, installItemName);
const bundleItem: FSqlMigrationSources.Script = versionBundle.getInstallScript(installItemName);
await writeFileAsync(bundleFile, bundleItem.content, "utf-8");
}
for (const rollbackItemName of versionBundle.rollbackScriptNames) {
cancellationToken.throwIfCancellationRequested();
const bundleFile: string = path.join(rollbackDirectory, rollbackItemName);
const bundleItem: FSqlMigrationSources.Script = versionBundle.getRollbackScript(rollbackItemName);
await writeFileAsync(bundleFile, bundleItem.content, "utf-8");
}
}
}
private constructor(bundles: Array<FSqlMigrationSources.VersionBundle>) {
this._versions = new Map(bundles.map(bundle => ([bundle.versionName, bundle])));
this.versionNames = Object.freeze([...this._versions.keys()].sort());
}
}
export namespace FSqlMigrationSources {
export class WrongMigrationDataException extends FExceptionInvalidOperation { }
export class VersionBundle {
public readonly versionName: string;
public readonly installScriptNames: ReadonlyArray<string>;
public readonly rollbackScriptNames: ReadonlyArray<string>;
private readonly _installScripts: Map<string, Script>;
private readonly _rollbackScripts: Map<string, Script>;
public constructor(
versionName: string,
installItems: ReadonlyArray<Script>,
rollbackItems: ReadonlyArray<Script>
) {
this.versionName = versionName;
this._installScripts = new Map(installItems.map(installItem => ([installItem.name, installItem])));
this._rollbackScripts = new Map(rollbackItems.map(rollbackItem => ([rollbackItem.name, rollbackItem])));
this.installScriptNames = Object.freeze([...this._installScripts.keys()].sort());
this.rollbackScriptNames = Object.freeze([...this._rollbackScripts.keys()].sort());
}
public getInstallScript(itemName: string): Script {
const item = this._installScripts.get(itemName);
if (item === undefined) {
throw new FExceptionArgument(`No bundle item with name: ${itemName}`, "itemName");
}
return item;
}
public getRollbackScript(itemName: string): Script {
const script = this._rollbackScripts.get(itemName);
if (script === undefined) {
throw new FExceptionArgument(`No bundle item with name: ${itemName}`, "itemName");
}
return script;
}
public map(
callbackFn: (item: Script, opts: {
readonly direction: FSqlMigrationSources.Direction;
}) => Script["content"]
): VersionBundle {
const installScripts: ReadonlyArray<Script> =
VersionBundle._map(this.installScriptNames, this._installScripts, (item) => {
return callbackFn(item, { direction: FSqlMigrationSources.Direction.INSTALL });
});
const rollbackScripts: ReadonlyArray<Script> =
VersionBundle._map(this.rollbackScriptNames, this._rollbackScripts, (item) => {
return callbackFn(item, { direction: FSqlMigrationSources.Direction.ROLLBACK });
});
return new VersionBundle(this.versionName, installScripts, rollbackScripts);
}
private static _map(
itemNames: ReadonlyArray<string>,
itemsMap: ReadonlyMap<string, Script>,
callbackfn: (item: Script) => Script["content"]
): ReadonlyArray<Script> {
const mappedItems: Array<Script> = [];
for (const itemName of itemNames) {
const item: Script = itemsMap.get(itemName)!;
const newContent: Script["content"] = callbackfn(item);
mappedItems.push(new Script(item.name, item.kind, item.file, newContent));
}
return mappedItems;
}
}
export class Script {
public constructor(
readonly name: string,
readonly kind: Script.Kind,
readonly file: string,
readonly content: string
) { }
}
export namespace Script {
export enum Kind {
SQL = "SQL",
JAVASCRIPT = "JAVASCRIPT",
UNKNOWN = "UNKNOWN"
}
export namespace Kind {
export function guard(kind: string): kind is Kind {
const friendlyValue: Kind = kind as Kind;
switch (friendlyValue) {
case Kind.SQL:
case Kind.JAVASCRIPT:
case Kind.UNKNOWN:
return true;
default:
return guardFalse(friendlyValue);
}
}
// tslint:disable-next-line: no-shadowed-variable
export function parse(kind: string): Kind {
const friendlyValue: Kind = kind as Kind;
if (guard(friendlyValue)) { return friendlyValue; }
throw new UnreachableNotSupportedScriptKindException(friendlyValue);
}
export class UnreachableNotSupportedScriptKindException extends FExceptionArgument {
public constructor(kind: never) {
super(`Not supported script kind '${JSON.stringify(kind)}'`, "fiatCurrency");
}
}
function guardFalse(_never: never): false { return false; }
}
}
export enum Direction {
INSTALL = "install",
ROLLBACK = "rollback"
}
}
const enum UrlSchemas {
FILE = "file:",
HTTP_TAR_GZ = "http+tar+gz:",
HTTPS_TAR_GZ = "https+tar+gz:"
}
class NotSupportedUrlSchemaException extends FExceptionInvalidOperation {
public constructor(uri: URL) {
super(`Not supported schema: ${uri}`);
}
}
const sqlFilesExtensions = Object.freeze([".sql"]);
const jsFilesExtensions = Object.freeze([".js", ".javascript"]);
function resolveScriptKindByExtension(fileName: string): FSqlMigrationSources.Script.Kind {
const ext = path.extname(fileName);
if (sqlFilesExtensions.includes(ext)) {
return FSqlMigrationSources.Script.Kind.SQL;
}
if (jsFilesExtensions.includes(ext)) {
return FSqlMigrationSources.Script.Kind.JAVASCRIPT;
}
return FSqlMigrationSources.Script.Kind.UNKNOWN;
}