diff --git a/libs/fedaco/src/migrations/database-migration-repository.ts b/libs/fedaco/src/migrations/database-migration-repository.ts new file mode 100644 index 0000000..0c0b398 --- /dev/null +++ b/libs/fedaco/src/migrations/database-migration-repository.ts @@ -0,0 +1,116 @@ +import type { ConnectionResolverInterface } from '../interface/connection-resolver-interface'; +import type { MigrationRepositoryInterface } from './migration-repository-interface'; + +export class DatabaseMigrationRepository implements MigrationRepositoryInterface { + /*The database connection resolver instance.*/ + _resolver: ConnectionResolverInterface; + /*The name of the migration table.*/ + _table: string; + /*The name of the database connection to use.*/ + _connection: string; + + /*Create a new database migration repository instance.*/ + public constructor(resolver: ConnectionResolverInterface, table: string) { + this._table = table; + this._resolver = resolver; + } + + /*Get the completed migrations.*/ + public async getRan() { + return await this.table() + .orderBy('batch', 'asc') + .orderBy('migration', 'asc') + .pluck('migration') as Promise; + } + + /*Get the list of migrations.*/ + public async getMigrations(steps: number) { + const query = this.table().where('batch', '>=', '1'); + return query.orderBy('batch', 'desc').orderBy('migration', 'desc').take(steps).get(); + } + + /*Get the list of the migrations by batch number.*/ + public async getMigrationsByBatch(batch: number) { + return this.table().where('batch', batch).orderBy('migration', 'desc').get(); + } + + /*Get the last migration batch.*/ + public async getLast() { + const query = this.table().where('batch', await this.getLastBatchNumber()); + return query.orderBy('migration', 'desc').get(); + } + + /*Get the completed migrations with their batch numbers.*/ + public async getMigrationBatches() { + return this.table() + .orderBy('batch', 'asc') + .orderBy('migration', 'asc') + .pluck('batch', 'migration'); + } + + /*Log that a migration was run.*/ + public async log(file: string, batch: number) { + const record = { + 'migration': file, + 'batch' : batch + }; + await this.table().insert(record); + } + + /*Remove a migration from the log.*/ + public async delete(migration: any) { + this.table().where('migration', migration.migration).delete(); + } + + /*Get the next migration batch number.*/ + public async getNextBatchNumber() { + return (await this.getLastBatchNumber()) + 1; + } + + /*Get the last migration batch number.*/ + public async getLastBatchNumber() { + return this.table().max('batch'); + } + + /*Create the migration repository data store.*/ + public createRepository() { + const schema = this.getConnection().getSchemaBuilder(); + schema.create(this._table, table => { + table.increments('id'); + table.string('migration'); + table.integer('batch'); + }); + } + + /*Determine if the migration repository exists.*/ + public repositoryExists() { + const schema = this.getConnection().getSchemaBuilder(); + return schema.hasTable(this._table); + } + + /*Delete the migration repository data store.*/ + public async deleteRepository() { + const schema = this.getConnection().getSchemaBuilder(); + await schema.drop(this._table); + } + + /*Get a query builder for the migration table.*/ + protected table() { + return this.getConnection().table(this.table).useWriteConnection(); + } + + /*Get the connection resolver instance.*/ + public getConnectionResolver() { + return this._resolver; + } + + /*Resolve the database connection instance.*/ + public getConnection() { + return this._resolver.connection(this._connection); + } + + /*Set the information source to gather data.*/ + public setSource(name: string) { + this._connection = name; + } +} diff --git a/libs/fedaco/src/migrations/migration-creator.ts b/libs/fedaco/src/migrations/migration-creator.ts new file mode 100644 index 0000000..856cebc --- /dev/null +++ b/libs/fedaco/src/migrations/migration-creator.ts @@ -0,0 +1,102 @@ +import { isBlank } from '@gradii/nanofn'; +import { format } from 'date-fns'; + +export class MigrationCreator { + /*The filesystem instance.*/ + files: Filesystem; + /*The custom app stubs directory.*/ + customStubPath: string; + /*The registered post create hooks.*/ + postCreate: any[] = []; + + /*Create a new migration creator instance.*/ + public constructor(files: Filesystem, customStubPath: string) { + this.files = files; + this.customStubPath = customStubPath; + } + + /*Create a new migration at the given path.*/ + public create(name: string, path: string, table: string | null = null, create = false) { + this.ensureMigrationDoesntAlreadyExist(name, path); + const stub = this.getStub(table, create); + path = this.getPath(name, path); + this.files.ensureDirectoryExists(dirname(path)); + this.files.put(path, this.populateStub(stub, table)); + this.firePostCreateHooks(table, path); + return path; + } + + /*Ensure that a migration with the given name doesn't already exist.*/ + protected ensureMigrationDoesntAlreadyExist(name: string, migrationPath: string = null) { + if (!empty(migrationPath)) { + const migrationFiles = this.files.glob(migrationPath + '/*.php'); + for (const migrationFile of migrationFiles) { + this.files.requireOnce(migrationFile); + } + } + if (class_exists(className = this.getClassName(name))) { + throw new InvalidArgumentException('"A {$className} class already exists."'); + } + } + + /*Get the migration stub file.*/ + protected getStub(table: string | null, create: boolean) { + let stub; + if (isBlank(table)) { + stub = this.files.exists( + customPath = this.customStubPath + '/migration.stub') ? customPath : this.stubPath() + '/migration.stub'; + } else if (create) { + stub = this.files.exists( + customPath = this.customStubPath + '/migration.create.stub') ? customPath : this.stubPath() + '/migration.create.stub'; + } else { + stub = this.files.exists( + customPath = this.customStubPath + '/migration.update.stub') ? customPath : this.stubPath() + '/migration.update.stub'; + } + return this.files.get(stub); + } + + /*Populate the place-holders in the migration stub.*/ + protected populateStub(stub: string, table: string | null) { + if (!isBlank(table)) { + var stub = str_replace(['DummyTable', '{{ table }}', '{{table}}'], table, stub); + } + return stub; + } + + /*Get the class name of a migration name.*/ + protected getClassName(name: string) { + return Str.studly(name); + } + + /*Get the full path to the migration.*/ + protected getPath(name: string, path: string) { + return path + '/' + this.getDatePrefix() + '_' + name + '.php'; + } + + /*Fire the registered post create hooks.*/ + protected firePostCreateHooks(table: string | null, path: string) { + for (const callback of this.postCreate) { + callback(table, path); + } + } + + /*Register a post migration create hook.*/ + public afterCreate(callback: Function) { + this.postCreate.push(callback); + } + + /*Get the date prefix for the migration.*/ + protected getDatePrefix() { + return format(new Date(), 'yyyy_MM_dd_HHmmss'); + } + + /*Get the path to the stubs.*/ + public stubPath() { + return __DIR__ + '/stubs'; + } + + /*Get the filesystem instance.*/ + public getFilesystem() { + return this.files; + } +} diff --git a/libs/fedaco/src/migrations/migration-repository-interface.ts b/libs/fedaco/src/migrations/migration-repository-interface.ts new file mode 100644 index 0000000..27a5805 --- /dev/null +++ b/libs/fedaco/src/migrations/migration-repository-interface.ts @@ -0,0 +1,37 @@ +export interface MigrationRepositoryInterface { + /*Get the completed migrations.*/ + getRan(): Promise; + + /*Get the list of migrations.*/ + getMigrations(steps: number): Promise; + + /*Get the list of the migrations by batch.*/ + getMigrationsByBatch(batch: number); + + /*Get the last migration batch.*/ + getLast(); + + /*Get the completed migrations with their batch numbers.*/ + getMigrationBatches(); + + /*Log that a migration was run.*/ + log(file: string, batch: number); + + /*Remove a migration from the log.*/ + delete(migration: object); + + /*Get the next migration batch number.*/ + getNextBatchNumber(); + + /*Create the migration repository data store.*/ + createRepository(); + + /*Determine if the migration repository exists.*/ + repositoryExists(); + + /*Delete the migration repository data store.*/ + deleteRepository(); + + /*Set the information source to gather data.*/ + setSource(name: string); +} diff --git a/libs/fedaco/src/migrations/migration.ts b/libs/fedaco/src/migrations/migration.ts new file mode 100644 index 0000000..9eb9eee --- /dev/null +++ b/libs/fedaco/src/migrations/migration.ts @@ -0,0 +1,11 @@ +export class Migration { + /*The name of the database connection to use.*/ + _connection: string | null; + /*Enables, if supported, wrapping the migration within a transaction.*/ + _withinTransaction = true; + + /*Get the migration connection name.*/ + public getConnection() { + return this._connection; + } +} diff --git a/libs/fedaco/src/migrations/migrator.ts b/libs/fedaco/src/migrations/migrator.ts new file mode 100644 index 0000000..8a86042 --- /dev/null +++ b/libs/fedaco/src/migrations/migrator.ts @@ -0,0 +1,367 @@ +import type { Dispatcher } from 'Illuminate/Contracts/Events/Dispatcher'; +import type { MigrationRepositoryInterface } from 'Illuminate/Database/Migrations/MigrationRepositoryInterface'; +import type { Filesystem } from 'Illuminate/Filesystem/Filesystem'; +import type { ConnectionResolverInterface } from 'Illuminate/Database/ConnectionResolverInterface'; +import type { OutputInterface } from 'Symfony/Component/Console/Output/OutputInterface'; +import type { Connection } from 'Illuminate/Database/Connection'; +import type { MigrationEvent } from 'Illuminate/Contracts/Database/Events/MigrationEvent'; +import { BulletList } from 'Illuminate/Console/View/Components/BulletList'; +import { Info } from 'Illuminate/Console/View/Components/Info'; +import { Task } from 'Illuminate/Console/View/Components/Task'; +import { TwoColumnDetail } from 'Illuminate/Console/View/Components/TwoColumnDetail'; +import type { Dispatcher } from 'Illuminate/Contracts/Events/Dispatcher'; +import { ConnectionResolverInterface as Resolver } from 'Illuminate/Database/ConnectionResolverInterface'; +import { MigrationEnded } from 'Illuminate/Database/Events/MigrationEnded'; +import { MigrationsEnded } from 'Illuminate/Database/Events/MigrationsEnded'; +import { MigrationsStarted } from 'Illuminate/Database/Events/MigrationsStarted'; +import { MigrationStarted } from 'Illuminate/Database/Events/MigrationStarted'; +import { NoPendingMigrations } from 'Illuminate/Database/Events/NoPendingMigrations'; +import type { Filesystem } from 'Illuminate/Filesystem/Filesystem'; +import { Arr } from 'Illuminate/Support/Arr'; +import { Collection } from 'Illuminate/Support/Collection'; +import { Str } from 'Illuminate/Support/Str'; +import { ReflectionClass } from 'ReflectionClass'; +import type { OutputInterface } from 'Symfony/Component/Console/Output/OutputInterface'; + +export class Migrator { + /*The event dispatcher instance.*/ + events: Dispatcher; + /*The migration repository implementation.*/ + repository: MigrationRepositoryInterface; + /*The filesystem instance.*/ + files: Filesystem; + /*The connection resolver instance.*/ + resolver: ConnectionResolverInterface; + /*The name of the default connection.*/ + connection: string; + /*The paths to all of the migration files.*/ + paths: any[] = []; + /*The paths that have already been required.*/ + requiredPathCache: array = []; + /*The output interface implementation.*/ + output: OutputInterface; + + /*Create a new migrator instance.*/ + public constructor(repository: MigrationRepositoryInterface, resolver: ConnectionResolverInterface, files: Filesystem, + dispatcher: Dispatcher | null = null) { + this.files = files; + this.events = dispatcher; + this.resolver = resolver; + this.repository = repository; + } + + /*Run the pending migrations at a given path.*/ + public run(paths: any[] | string = [], options: any[] = []) { + const files = this.getMigrationFiles(paths); + this.requireFiles(migrations = this.pendingMigrations(files, this.repository.getRan())); + this.runPending(migrations, options); + return migrations; + } + + /*Get the migration files that have not yet run.*/ + protected pendingMigrations(files: any[], ran: any[]) { + return Collection.make(files).reject(file => { + return in_array(this.getMigrationName(file), ran); + }).values().all(); + } + + /*Run an array of migrations.*/ + public runPending(migrations: any[], options: any = {}) { + if (migrations.length === 0) { + this.fireMigrationEvent(new NoPendingMigrations('up')); + this.write(Info, 'Nothing to migrate'); + return; + } + let batch = this.repository.getNextBatchNumber(); + const pretend = options['pretend'] ?? false; + const step = options['step'] ?? false; + this.fireMigrationEvent(new MigrationsStarted('up')); + this.write(Info, 'Running migrations.'); + for (const file of migrations) { + this.runUp(file, batch, pretend); + if (step) { + batch++; + } + } + this.fireMigrationEvent(new MigrationsEnded('up'))(this.output?.writeln)(''); + } + + /*Run "up" a migration instance.*/ + protected runUp(file: string, batch: number, pretend: boolean) { + const migration = this.resolvePath(file); + const name = this.getMigrationName(file); + if (pretend) { + return this.pretendToRun(migration, 'up'); + } + this.write(Task, name, () => this.runMigration(migration, 'up')); + this.repository.log(name, batch); + } + + /*Rollback the last migration operation.*/ + public rollback(paths: any[] | string = [], options: any[] = []) { + const migrations = this.getMigrationsForRollback(options); + if (migrations.length === 0) { + this.fireMigrationEvent(new NoPendingMigrations('down')); + this.write(Info, 'Nothing to rollback.'); + return []; + } + return tap(this.rollbackMigrations(migrations, paths, options), () => { + (this.output?.writeln)(''); + }); + } + + /*Get the migrations for a rollback operation.*/ + protected getMigrationsForRollback(options: any[]) { + if ((steps = options['step'] ?? 0) > 0) { + return this.repository.getMigrations(steps); + } + if ((batch = options['batch'] ?? 0) > 0) { + return this.repository.getMigrationsByBatch(batch); + } + return this.repository.getLast(); + } + + /*Rollback the given migrations.*/ + protected rollbackMigrations(migrations: any[], paths: any[] | string, options: any[]) { + const rolledBack = []; + this.requireFiles(files = this.getMigrationFiles(paths)); + this.fireMigrationEvent(new MigrationsStarted('down')); + this.write(Info, 'Rolling back migrations.'); + for (const migration of migrations) { + const migration = /*cast type object*/ migration; + if (!(file = Arr.get(files, migration.migration))) { + this.write(TwoColumnDetail, migration.migration, 'Migration not found'); + continue; + } + rolledBack.push(file); + this.runDown(file, migration, options['pretend'] ?? false); + } + this.fireMigrationEvent(new MigrationsEnded('down')); + return rolledBack; + } + + /*Rolls all of the currently applied migrations back.*/ + public reset(paths: any[] | string = [], pretend = false) { + const migrations = array_reverse(this.repository.getRan()); + if (count(migrations) === 0) { + this.write(Info, 'Nothing to rollback.'); + return []; + } + return tap(this.resetMigrations(migrations, Arr.wrap(paths), pretend), () => { + (this.output?.writeln)(''); + }); + } + + /*Reset the given migrations.*/ + protected resetMigrations(migrations: any[], paths: any[], pretend = false) { + var migrations = collect(migrations).map(m => { + return /*cast type object*/ { + 'migration': m + }; + }).all(); + return this.rollbackMigrations(migrations, paths, compact('pretend')); + } + + /*Run "down" a migration instance.*/ + protected runDown(file: string, migration: object, pretend: boolean) { + const instance = this.resolvePath(file); + const name = this.getMigrationName(file); + if (pretend) { + return this.pretendToRun(instance, 'down'); + } + this.write(Task, name, () => this.runMigration(instance, 'down')); + this.repository.delete(migration); + } + + /*Run a migration inside a transaction if the database supports it.*/ + protected runMigration(migration: object, method: string) { + const connection = this.resolveConnection(migration.getConnection()); + const callback = () => { + if (method_exists(migration, method)) { + this.fireMigrationEvent(new MigrationStarted(migration, method)); + this.runMethod(connection, migration, method); + this.fireMigrationEvent(new MigrationEnded(migration, method)); + } + }; + this.getSchemaGrammar( + connection).supportsSchemaTransactions() && migration.withinTransaction ? connection.transaction( + callback) : callback(); + } + + /*Pretend to run the migrations.*/ + protected pretendToRun(migration: object, method: string) { + var name = get_class(migration); + const reflectionClass = new ReflectionClass(migration); + if (reflectionClass.isAnonymous()) { + var name = this.getMigrationName(reflectionClass.getFileName()); + } + this.write(TwoColumnDetail, name); + this.write(BulletList, collect(this.getQueries(migration, method)).map(query => { + return query['query']; + })); + } + + /*Get all of the queries that would be run for a migration.*/ + protected getQueries(migration: object, method: string) { + const db = this.resolveConnection(migration.getConnection()); + return db.pretend(() => { + if (method_exists(migration, method)) { + this.runMethod(db, migration, method); + } + }); + } + + /*Run a migration method on the given connection.*/ + protected runMethod(connection: Connection, migration: object, method: string) { + const previousConnection = this.resolver.getDefaultConnection(); + try { + this.resolver.setDefaultConnection(connection.getName()); + migration[method](); + } finally { + this.resolver.setDefaultConnection(previousConnection); + } + } + + /*Resolve a migration instance from a file.*/ + public resolve(file: string) { + const clazz = this.getMigrationClass(file); + return new clazz(); + } + + /*Resolve a migration instance from a migration path.*/ + protected resolvePath(path: string) { + const clazz = this.getMigrationClass(this.getMigrationName(path)); + if (class_exists(clazz) && realpath(path) == new ReflectionClass(clazz).getFileName()) { + return new clazz(); + } + const migration = Migrator.requiredPathCache[path] ??= this.files.getRequire(path); + if (is_object(migration)) { + return method_exists(migration, '__construct') ? this.files.getRequire(path) : migration.clone(); + } + return new clazz(); + } + + /*Generate a migration class name based on the migration file name.*/ + protected getMigrationClass(migrationName: string) { + return Str.studly(array_slice(explode('_', migrationName), 4).join('_')); + } + + /*Get all of the migration files in a given path.*/ + public getMigrationFiles(paths: string | any[]) { + return Collection.make(paths).flatMap(path => { + return str_ends_with(path, '.php') ? [path] : this.files.glob(path + '/*_*.php'); + }).filter().values().keyBy(file => { + return this.getMigrationName(file); + }).sortBy((file, key) => { + return key; + }).all(); + } + + /*Require in all the migration files in a given path.*/ + public requireFiles(files: any[]) { + for (const file of files) { + this.files.requireOnce(file); + } + } + + /*Get the name of the migration.*/ + public getMigrationName(path: string) { + return str_replace('.php', '', basename(path)); + } + + /*Register a custom migration path.*/ + public path(path: string) { + this.paths = array_unique([...this.paths, ...[path]]); + } + + /*Get all of the custom migration paths.*/ + public paths() { + return this.paths; + } + + /*Get the default connection name.*/ + public getConnection() { + return this.connection; + } + + /*Execute the given callback using the given connection as the default connection.*/ + public usingConnection(name: string, callback: callable) { + const previousConnection = this.resolver.getDefaultConnection(); + this.setConnection(name); + return tap(callback(), () => { + this.setConnection(previousConnection); + }); + } + + /*Set the default connection name.*/ + public setConnection(name: string) { + if (!isBlank(name)) { + this.resolver.setDefaultConnection(name); + } + this.repository.setSource(name); + this.connection = name; + } + + /*Resolve the database connection instance.*/ + public resolveConnection(connection: string) { + return this.resolver.connection(connection || this.connection); + } + + /*Get the schema grammar out of a migration connection.*/ + protected getSchemaGrammar(connection: Connection) { + if (isBlank(grammar = connection.getSchemaGrammar())) { + connection.useDefaultSchemaGrammar(); + var grammar = connection.getSchemaGrammar(); + } + return grammar; + } + + /*Get the migration repository instance.*/ + public getRepository() { + return this.repository; + } + + /*Determine if the migration repository exists.*/ + public repositoryExists() { + return this.repository.repositoryExists(); + } + + /*Determine if any migrations have been run.*/ + public hasRunAnyMigrations() { + return this.repositoryExists() && count(this.repository.getRan()) > 0; + } + + /*Delete the migration repository data store.*/ + public deleteRepository() { + this.repository.deleteRepository(); + } + + /*Get the file system instance.*/ + public getFilesystem() { + return this.files; + } + + /*Set the output implementation that should be used by the console.*/ + public setOutput(output: OutputInterface) { + this.output = output; + return this; + } + + /*Write to the console's output.*/ + protected write(component: string, arguments) { + if (this.output && class_exists(component)) { + new component(this.output).render(...arguments); + } else { + for (const argument of arguments) { + if (is_callable(argument)) { + argument(); + } + } + } + } + + /*Fire the given event for the migration.*/ + public fireMigrationEvent(event: MigrationEvent) { + (this.events?.dispatch)(event); + } +}