AsyncPipe; table schemata; migrations; File logging
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
e86cf420df
commit
fcce28081b
@ -0,0 +1,39 @@
|
|||||||
|
import {Inject, Injectable} from '../di'
|
||||||
|
import {ConstraintType, DatabaseService, FieldType, Migration, Schema} from '../orm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration that creates the sessions table used by the ORMSession backend.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export default class CreateSessionsTableMigration extends Migration {
|
||||||
|
@Inject()
|
||||||
|
protected readonly db!: DatabaseService
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
const schema: Schema = this.db.get().schema()
|
||||||
|
const table = await schema.table('sessions')
|
||||||
|
|
||||||
|
table.primaryKey('session_uuid', FieldType.varchar)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('session_data')
|
||||||
|
.type(FieldType.json)
|
||||||
|
.required()
|
||||||
|
.default('{}')
|
||||||
|
|
||||||
|
table.constraint('session_uuid_ck')
|
||||||
|
.type(ConstraintType.Check)
|
||||||
|
.expression('LENGTH(session_uuid) > 0')
|
||||||
|
|
||||||
|
await schema.commit(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
const schema: Schema = this.db.get().schema()
|
||||||
|
const table = await schema.table('sessions')
|
||||||
|
|
||||||
|
table.dropIfExists()
|
||||||
|
|
||||||
|
await schema.commit(table)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import {Inject, Injectable} from '../di'
|
||||||
|
import {DatabaseService, FieldType, Migration, Schema} from '../orm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration that creates the users table used by @extollo/lib.auth.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export default class CreateUsersTableMigration extends Migration {
|
||||||
|
@Inject()
|
||||||
|
protected readonly db!: DatabaseService
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
const schema: Schema = this.db.get().schema()
|
||||||
|
const table = await schema.table('users')
|
||||||
|
|
||||||
|
table.primaryKey('user_id')
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('first_name')
|
||||||
|
.type(FieldType.varchar)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('last_name')
|
||||||
|
.type(FieldType.varchar)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('password_hash')
|
||||||
|
.type(FieldType.text)
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
table.column('username')
|
||||||
|
.type(FieldType.varchar)
|
||||||
|
.required()
|
||||||
|
.unique()
|
||||||
|
|
||||||
|
await schema.commit(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
const schema: Schema = this.db.get().schema()
|
||||||
|
const table = await schema.table('users')
|
||||||
|
|
||||||
|
table.dropIfExists()
|
||||||
|
|
||||||
|
await schema.commit(table)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
|
import {Injectable} from '../../di'
|
||||||
|
import {stringToPascal} from '../../util'
|
||||||
|
import {templateMigration} from '../template/migration'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI directive that creates migration classes from template.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CreateMigrationDirective extends Directive {
|
||||||
|
getDescription(): string {
|
||||||
|
return 'create a new migration'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['create-migration', 'make-migration']
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'{description} | Description of what the migration does',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Creates a new migration file in `src/app/migrations`.',
|
||||||
|
'To use, specify a string describing what the migration does. For example:',
|
||||||
|
'./ex create-migration "Add version column to sessions table"',
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const description = this.option('description')
|
||||||
|
const className = `${stringToPascal(description)}Migration`
|
||||||
|
const fileName = `${(new Date()).toISOString()}_${className}.migration.ts`
|
||||||
|
const path = this.app().path('..', 'src', 'app', 'migrations', fileName)
|
||||||
|
|
||||||
|
// Create the migrations directory, if it doesn't already exist
|
||||||
|
await path.concat('..').mkdir()
|
||||||
|
|
||||||
|
// Render the template
|
||||||
|
const rendered = await templateMigration.render(className, className, path)
|
||||||
|
await path.write(rendered)
|
||||||
|
|
||||||
|
this.success(`Created migration: ${className}`)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
||||||
|
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
||||||
|
import {EventSubscription} from '../../event/types'
|
||||||
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI directive that applies migrations using the default Migrator.
|
||||||
|
* @fixme Support dry run mode
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MigrateDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/** Event bus subscriptions. */
|
||||||
|
protected subscriptions: EventSubscription[] = []
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['migrate']
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'apply pending migrations'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'--package -p {name} | apply migrations for a specific namespace',
|
||||||
|
'--identifier -i {name} | apply a specific migration, by identifier',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Migrations are single-run code patches used to track changes to things like database schemata.',
|
||||||
|
'',
|
||||||
|
'You can create migrations in your app using the ./ex command and they can be applied and rolled-back.',
|
||||||
|
'',
|
||||||
|
'./ex migrate:create "Add version column to sessions table"',
|
||||||
|
'',
|
||||||
|
'Modules and packages can also register their own migrations. These are run by default.',
|
||||||
|
'',
|
||||||
|
'To run the migrations for a specific package, and no others, use the --package option. Example:',
|
||||||
|
'',
|
||||||
|
'./ex migrate --package @extollo',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
await this.registerListeners()
|
||||||
|
|
||||||
|
const namespace = this.option('package')
|
||||||
|
const identifier = this.option('identifier')
|
||||||
|
|
||||||
|
let identifiers
|
||||||
|
if ( namespace ) {
|
||||||
|
identifiers = (this.injector.make<Migrations>(Migrations))
|
||||||
|
.all(namespace)
|
||||||
|
.map(id => `${namespace}:${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( identifier ) {
|
||||||
|
if ( !identifiers ) {
|
||||||
|
identifiers = [identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers = identifiers.filter(x => x === identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await (this.injector.make<Migrator>(Migrator)).migrate(identifiers)
|
||||||
|
} catch (e) {
|
||||||
|
if ( e instanceof NothingToMigrateError ) {
|
||||||
|
this.info(e.message)
|
||||||
|
} else {
|
||||||
|
error = e
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await this.removeListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( error ) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register event bus listeners to print messages for the user.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async registerListeners(): Promise<void> {
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(ApplyingMigrationEvent, event => {
|
||||||
|
this.info(`Applying migration ${event.migration.identifier}...`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(AppliedMigrationEvent, event => {
|
||||||
|
this.success(`Applied migration: ${event.migration.identifier}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove event bus listeners before finish. */
|
||||||
|
protected async removeListeners(): Promise<void> {
|
||||||
|
await Promise.all(this.subscriptions.map(x => x.unsubscribe()))
|
||||||
|
this.subscriptions = []
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,102 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrationEvent'
|
||||||
|
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
||||||
|
import {EventSubscription} from '../../event/types'
|
||||||
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI directive that undoes applied migrations using the default Migrator.
|
||||||
|
* @fixme Support dry run mode
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RollbackDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
|
/** Event bus subscriptions. */
|
||||||
|
protected subscriptions: EventSubscription[] = []
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['rollback']
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'roll-back applied migrations'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'--identifier -i {name} | roll-back a specific migration, by identifier',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Use this command to undo one or more migrations that were applied.',
|
||||||
|
'',
|
||||||
|
'By default, the command will undo all of the migrations applied the last time the migrate command was run.',
|
||||||
|
'',
|
||||||
|
'To undo a specific migration, pass its identifier using the --identifier option.',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
await this.registerListeners()
|
||||||
|
|
||||||
|
const identifier = this.option('identifier')
|
||||||
|
|
||||||
|
let identifiers
|
||||||
|
if ( identifier ) {
|
||||||
|
identifiers = [identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await (this.injector.make<Migrator>(Migrator)).rollback(identifiers)
|
||||||
|
} catch (e) {
|
||||||
|
if ( e instanceof NothingToMigrateError ) {
|
||||||
|
this.info(e.message)
|
||||||
|
} else {
|
||||||
|
error = e
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await this.removeListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( error ) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register event-bus listeners to print messages for the user.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async registerListeners(): Promise<void> {
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(RollingBackMigrationEvent, event => {
|
||||||
|
this.info(`Rolling-back migration ${event.migration.identifier}...`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(RolledBackMigrationEvent, event => {
|
||||||
|
this.success(`Rolled-back migration: ${event.migration.identifier}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove event bus listeners before finish. */
|
||||||
|
protected async removeListeners(): Promise<void> {
|
||||||
|
await Promise.all(this.subscriptions.map(x => x.unsubscribe()))
|
||||||
|
this.subscriptions = []
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,179 @@
|
|||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {Migrator} from './Migrator'
|
||||||
|
import {DatabaseService} from '../DatabaseService'
|
||||||
|
import {FieldType} from '../types'
|
||||||
|
import {Migration} from './Migration'
|
||||||
|
import {Builder} from '../builder/Builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrator implementation that tracks applied migrations in a database table.
|
||||||
|
* @todo allow configuring more of this
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseMigrator extends Migrator {
|
||||||
|
@Inject()
|
||||||
|
protected readonly db!: DatabaseService
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/** True if we've initialized the migrator. */
|
||||||
|
protected initialized = false
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await super.initialize()
|
||||||
|
|
||||||
|
if ( this.initialized ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = this.db.get().schema()
|
||||||
|
if ( !(await schema.hasTable('migrations')) ) {
|
||||||
|
const table = await schema.table('migrations')
|
||||||
|
|
||||||
|
table.primaryKey('id', FieldType.serial).required()
|
||||||
|
|
||||||
|
table.column('identifier')
|
||||||
|
.type(FieldType.varchar)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('applygroup')
|
||||||
|
.type(FieldType.integer)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('applydate')
|
||||||
|
.type(FieldType.timestamp)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
await schema.commit(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(migration: Migration): Promise<boolean> {
|
||||||
|
return this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('id')
|
||||||
|
.from('migrations')
|
||||||
|
.where('identifier', '=', migration.identifier)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
async markApplied(migrations: Migration | Migration[], applyDate: Date = new Date()): Promise<void> {
|
||||||
|
if ( !Array.isArray(migrations) ) {
|
||||||
|
migrations = [migrations]
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyGroup = await this.getNextGroupIdentifier()
|
||||||
|
const rows = migrations.map(migration => {
|
||||||
|
return {
|
||||||
|
applygroup: applyGroup,
|
||||||
|
applydate: applyDate,
|
||||||
|
identifier: migration.identifier,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.table('migrations')
|
||||||
|
.insert(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async unmarkApplied(migrations: Migration | Migration[]): Promise<void> {
|
||||||
|
if ( !Array.isArray(migrations) ) {
|
||||||
|
migrations = [migrations]
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifiers = migrations.map(migration => migration.identifier)
|
||||||
|
|
||||||
|
await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.table('migrations')
|
||||||
|
.whereIn('identifier', identifiers)
|
||||||
|
.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastApplyGroup(): Promise<string[]> {
|
||||||
|
const applyGroup = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('applygroup')
|
||||||
|
.from('migrations')
|
||||||
|
.get()
|
||||||
|
.max<number>('applygroup')
|
||||||
|
|
||||||
|
return this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('identifier')
|
||||||
|
.from('migrations')
|
||||||
|
.where('applygroup', '=', applyGroup)
|
||||||
|
.get()
|
||||||
|
.asyncPipe()
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.pluck<string>('identifier')
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.all()
|
||||||
|
})
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to look up the next `applygroup` that should be used.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getNextGroupIdentifier(): Promise<number> {
|
||||||
|
const current = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('applygroup')
|
||||||
|
.from('migrations')
|
||||||
|
.get()
|
||||||
|
.max<number>('applygroup')
|
||||||
|
|
||||||
|
return (current ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have been applied.
|
||||||
|
* @override to make this more efficient
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
const existing = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('identifier')
|
||||||
|
.from('migrations')
|
||||||
|
.whereIn('identifier', identifiers)
|
||||||
|
.get()
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
|
||||||
|
return identifiers.filter(id => !existing.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have not been applied.
|
||||||
|
* @override to make this more efficient
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
const existing = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('identifier')
|
||||||
|
.from('migrations')
|
||||||
|
.whereIn('identifier', identifiers)
|
||||||
|
.get()
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
|
||||||
|
return existing.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a query builder instance.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected builder(): Builder {
|
||||||
|
return this.injector.make<Builder>(Builder)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import {Injectable} from '../../di'
|
||||||
|
import {Awaitable} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base-class for one-time migrations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Migration {
|
||||||
|
/** Set by the Migrations unit on load. */
|
||||||
|
protected migrationIdentifier!: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the migration identifier.
|
||||||
|
* This is used internally when the Migrations service loads
|
||||||
|
* the migration files to determine the ID from the file-name.
|
||||||
|
* It shouldn't be used externally.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public setMigrationIdentifier(name: string): void {
|
||||||
|
this.migrationIdentifier = name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unique identifier of this migration.
|
||||||
|
*/
|
||||||
|
public get identifier(): string {
|
||||||
|
return this.migrationIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the migration.
|
||||||
|
*/
|
||||||
|
abstract up(): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the migration.
|
||||||
|
*/
|
||||||
|
abstract down(): Awaitable<void>
|
||||||
|
}
|
@ -0,0 +1,295 @@
|
|||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {Awaitable, collect, ErrorWithContext} from '../../util'
|
||||||
|
import {Migration} from './Migration'
|
||||||
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent'
|
||||||
|
import {AppliedMigrationEvent} from './events/AppliedMigrationEvent'
|
||||||
|
import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent'
|
||||||
|
import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent'
|
||||||
|
import {NothingToMigrateError} from './NothingToMigrateError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages single-run patches/migrations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Migrator {
|
||||||
|
@Inject()
|
||||||
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should resolve true if the given migration has already been applied.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public abstract has(migration: Migration): Awaitable<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should mark the given migrations as being applied.
|
||||||
|
*
|
||||||
|
* If a date is specified, then that is the timestamp when the migrations
|
||||||
|
* were applied, otherwise, use `new Date()`.
|
||||||
|
*
|
||||||
|
* @param migrations
|
||||||
|
* @param date
|
||||||
|
*/
|
||||||
|
public abstract markApplied(migrations: Migration | Migration[], date?: Date): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should un-mark the given migrations as being applied.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public abstract unmarkApplied(migration: Migration | Migration[]): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the identifiers of the last group of migrations that were applied.
|
||||||
|
*/
|
||||||
|
public abstract getLastApplyGroup(): Awaitable<string[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do any initial setup required to get the migrator ready.
|
||||||
|
* This can be overridden by implementation classes to do any necessary setup.
|
||||||
|
*/
|
||||||
|
public initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply pending migrations.
|
||||||
|
*
|
||||||
|
* If identifiers are specified, only the pending migrations with those
|
||||||
|
* identifiers are applied. If none are specified, all pending migrations
|
||||||
|
* will be applied.
|
||||||
|
*
|
||||||
|
* @param identifiers
|
||||||
|
*/
|
||||||
|
public async migrate(identifiers?: string[]): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
if ( !identifiers ) {
|
||||||
|
identifiers = this.getAllMigrationIdentifiers()
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers = (await this.filterAppliedMigrations(identifiers)).sort()
|
||||||
|
if ( !identifiers.length ) {
|
||||||
|
throw new NothingToMigrateError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = collect(identifiers)
|
||||||
|
.map(id => {
|
||||||
|
const migration = this.migrations.get(id)
|
||||||
|
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find migration with identifier: ${id}`, {
|
||||||
|
identifier: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return migration
|
||||||
|
})
|
||||||
|
|
||||||
|
await migrations.promiseMap(migration => {
|
||||||
|
return this.apply(migration)
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.markApplied(migrations.all())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback applied migrations.
|
||||||
|
*
|
||||||
|
* If specified, only applied migrations with the given identifiers will
|
||||||
|
* be rolled back. If not specified, then the last "batch" of applied
|
||||||
|
* migrations will be rolled back.
|
||||||
|
*
|
||||||
|
* @param identifiers
|
||||||
|
*/
|
||||||
|
public async rollback(identifiers?: string[]): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
if ( !identifiers ) {
|
||||||
|
identifiers = await this.getLastApplyGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers = (await this.filterPendingMigrations(identifiers)).sort()
|
||||||
|
if ( !identifiers.length ) {
|
||||||
|
throw new NothingToMigrateError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = collect(identifiers)
|
||||||
|
.map(id => {
|
||||||
|
const migration = this.migrations.get(id)
|
||||||
|
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find migration with identifier: ${id}`, {
|
||||||
|
identifier: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return migration
|
||||||
|
})
|
||||||
|
|
||||||
|
await migrations.promiseMap(migration => {
|
||||||
|
return this.undo(migration)
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.unmarkApplied(migrations.all())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a single migration.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public async apply(migration: Migration): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
await this.applying(migration)
|
||||||
|
|
||||||
|
await migration.up()
|
||||||
|
|
||||||
|
await this.applied(migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback a single migration.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public async undo(migration: Migration): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
await this.rollingBack(migration)
|
||||||
|
|
||||||
|
await migration.down()
|
||||||
|
|
||||||
|
await this.rolledBack(migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered migrations, by their string-form identifiers.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getAllMigrationIdentifiers(): string[] {
|
||||||
|
return collect<string>(this.migrations.namespaces())
|
||||||
|
.map(nsp => {
|
||||||
|
return this.migrations.all(nsp)
|
||||||
|
.map(id => `${nsp}:${id}`)
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
// non-namespaced migrations
|
||||||
|
coll.push(this.migrations.all())
|
||||||
|
return coll
|
||||||
|
})
|
||||||
|
.reduce((current, item) => {
|
||||||
|
return current.concat(item)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have been applied.
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
return collect(identifiers)
|
||||||
|
.partialMap(identifier => {
|
||||||
|
const migration = this.migrations.get(identifier)
|
||||||
|
if ( migration ) {
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
migration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.asyncPipe()
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.promiseMap(async group => {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
has: await this.has(group.migration),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.filter(group => !group.has)
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
.all()
|
||||||
|
})
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have not been applied.
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
return collect(identifiers)
|
||||||
|
.partialMap(identifier => {
|
||||||
|
const migration = this.migrations.get(identifier)
|
||||||
|
if ( migration ) {
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
migration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.asyncPipe()
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.promiseMap(async group => {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
has: await this.has(group.migration),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.filter(group => group.has)
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
.all()
|
||||||
|
})
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the ApplyingMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async applying(migration: Migration): Promise<void> {
|
||||||
|
const event = <ApplyingMigrationEvent> this.injector.make(ApplyingMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the AppliedMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async applied(migration: Migration): Promise<void> {
|
||||||
|
const event = <AppliedMigrationEvent> this.injector.make(AppliedMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the RollingBackMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async rollingBack(migration: Migration): Promise<void> {
|
||||||
|
const event = <RollingBackMigrationEvent> this.injector.make(RollingBackMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the RolledBackMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async rolledBack(migration: Migration): Promise<void> {
|
||||||
|
const event = <RolledBackMigrationEvent> this.injector.make(RolledBackMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject,
|
||||||
|
} from '../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../util'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import {Migrator} from './Migrator'
|
||||||
|
import {DatabaseMigrator} from './DatabaseMigrator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dependency injection factory that matches the abstract Migrator class
|
||||||
|
* and produces an instance of the configured session driver implementation.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({})
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(): Migrator {
|
||||||
|
return new (this.getMigratorClass())()
|
||||||
|
}
|
||||||
|
|
||||||
|
match(something: unknown): boolean {
|
||||||
|
return something === Migrator
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getMigratorClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getMigratorClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the instantiable class of the configured migrator backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<Migrator>
|
||||||
|
*/
|
||||||
|
protected getMigratorClass(): Instantiable<Migrator> {
|
||||||
|
const MigratorClass = this.config.get('database.migrations.driver', DatabaseMigrator)
|
||||||
|
|
||||||
|
if ( !isInstantiable(MigratorClass) || !(MigratorClass.prototype instanceof Migrator) ) {
|
||||||
|
const e = new ErrorWithContext('Provided migration driver class does not extend from @extollo/lib.Migrator')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'database.migrations.driver',
|
||||||
|
class: MigratorClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MigratorClass
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import {ErrorWithContext} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when the migrator is run, but no migrations need
|
||||||
|
* to be applied/rolled-back.
|
||||||
|
*/
|
||||||
|
export class NothingToMigrateError extends ErrorWithContext {
|
||||||
|
constructor(
|
||||||
|
message = 'There is nothing to migrate',
|
||||||
|
context?: {[key: string]: any},
|
||||||
|
) {
|
||||||
|
super(message, context)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired after a migration is applied.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AppliedMigrationEvent extends MigrationEvent {}
|
@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired before a migration is applied.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApplyingMigrationEvent extends MigrationEvent {}
|
@ -0,0 +1,49 @@
|
|||||||
|
import {Event} from '../../../event/Event'
|
||||||
|
import {Migration} from '../Migration'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Migrations} from '../../services/Migrations'
|
||||||
|
import {ErrorWithContext} from '../../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic base-class for migration-related events.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class MigrationEvent extends Event {
|
||||||
|
@Inject()
|
||||||
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
|
/** The migration relevant to this event. */
|
||||||
|
private internalMigration: Migration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the relevant migration.
|
||||||
|
*/
|
||||||
|
public get migration(): Migration {
|
||||||
|
return this.internalMigration
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
migration: Migration,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.internalMigration = migration
|
||||||
|
}
|
||||||
|
|
||||||
|
dehydrate(): {identifier: string} {
|
||||||
|
return {
|
||||||
|
identifier: this.migration.identifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: {identifier: string}): void {
|
||||||
|
const migration = this.migrations.get(state.identifier)
|
||||||
|
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find migration with identifier: ${state.identifier}`, {
|
||||||
|
identifier: state.identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.internalMigration = migration
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired after a migration has been rolled-back.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RolledBackMigrationEvent extends MigrationEvent {}
|
@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired before a migration is rolled back.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RollingBackMigrationEvent extends MigrationEvent {}
|
@ -0,0 +1,345 @@
|
|||||||
|
import {Schema} from './Schema'
|
||||||
|
import {Awaitable, collect, Collection} from '../../util'
|
||||||
|
import {ConstraintType, TableBuilder} from './TableBuilder'
|
||||||
|
import {PostgresConnection} from '../connection/PostgresConnection'
|
||||||
|
import {Builder} from '../builder/Builder'
|
||||||
|
import {raw} from '../dialect/SQLDialect'
|
||||||
|
import {QueryRow} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PostgreSQL-compatible schema implementation.
|
||||||
|
*/
|
||||||
|
export class PostgresSchema extends Schema {
|
||||||
|
constructor(
|
||||||
|
connection: PostgresConnection,
|
||||||
|
public readonly schema: string = 'public',
|
||||||
|
) {
|
||||||
|
super(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasColumn(table: string, name: string): Awaitable<boolean> {
|
||||||
|
return (new Builder()).connection(this.connection)
|
||||||
|
.select(raw('*'))
|
||||||
|
.from('information_schema.columns')
|
||||||
|
.where('table_schema', '=', this.schema)
|
||||||
|
.where('table_name', '=', table)
|
||||||
|
.where('column_name', '=', name)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasColumns(table: string, name: string[]): Promise<boolean> {
|
||||||
|
const num = await (new Builder()).connection(this.connection)
|
||||||
|
.select(raw('*'))
|
||||||
|
.from('information_schema.columns')
|
||||||
|
.where('table_schema', '=', this.schema)
|
||||||
|
.where('table_name', '=', table)
|
||||||
|
.whereIn('column_name', name)
|
||||||
|
.get()
|
||||||
|
.count()
|
||||||
|
|
||||||
|
return num === name.length
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTable(name: string): Awaitable<boolean> {
|
||||||
|
return (new Builder()).connection(this.connection)
|
||||||
|
.select(raw('*'))
|
||||||
|
.from('information_schema.tables')
|
||||||
|
.where('table_schema', '=', this.schema)
|
||||||
|
.where('table_name', '=', name)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
async table(table: string): Promise<TableBuilder> {
|
||||||
|
return this.populateTable(new TableBuilder(table))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the table for the given TableBuilder already exists in the
|
||||||
|
* database, fill in the columns, constraints, and indexes.
|
||||||
|
* @param table
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async populateTable(table: TableBuilder): Promise<TableBuilder> {
|
||||||
|
if ( await this.hasTable(table.name) ) {
|
||||||
|
// Load the existing columns
|
||||||
|
const cols = await this.getColumns(table.name)
|
||||||
|
cols.each(col => {
|
||||||
|
table.column(col.column_name)
|
||||||
|
.type(col.data_type)
|
||||||
|
.pipe()
|
||||||
|
.when(col.is_nullable, builder => {
|
||||||
|
builder.isNullable()
|
||||||
|
return builder
|
||||||
|
})
|
||||||
|
.when(col.column_default, builder => {
|
||||||
|
builder.default(raw(col.column_default))
|
||||||
|
return builder
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load the existing constraints
|
||||||
|
const constraints = await this.getConstraints(table.name)
|
||||||
|
|
||||||
|
// Apply the unique constraints
|
||||||
|
const uniques = constraints.where('constraint_type', '=', 'u')
|
||||||
|
.sortBy('constraint_name')
|
||||||
|
.groupBy('constraint_name')
|
||||||
|
|
||||||
|
for ( const key in uniques ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(uniques, key) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
table.constraint(key)
|
||||||
|
.type(ConstraintType.Unique)
|
||||||
|
.pipe()
|
||||||
|
.peek(constraint => {
|
||||||
|
collect<{column_name: string}>(uniques[key]) // eslint-disable-line camelcase
|
||||||
|
.pluck<string>('column_name')
|
||||||
|
.each(column => constraint.field(column))
|
||||||
|
})
|
||||||
|
.get()
|
||||||
|
.flagAsExistingInSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the primary key constraints
|
||||||
|
constraints.where('constraint_type', '=', 'p')
|
||||||
|
.pipe()
|
||||||
|
.when(c => c.count() > 0, pk => {
|
||||||
|
pk.each(constraint => {
|
||||||
|
table.column(constraint.column_name)
|
||||||
|
.primary()
|
||||||
|
})
|
||||||
|
|
||||||
|
return pk
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply the non-null constraints
|
||||||
|
// Builder columns are non-null by default, so mark the others as nullable
|
||||||
|
const nonNullable = constraints.filter(x => !x.constraint_type)
|
||||||
|
.where('is_nullable', '=', 'NO')
|
||||||
|
|
||||||
|
collect<string>(Object.keys(table.getColumns()))
|
||||||
|
.map(column => {
|
||||||
|
return {
|
||||||
|
column,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.whereNotIn('column', nonNullable.pluck('column_name'))
|
||||||
|
.pluck<string>('column')
|
||||||
|
.each(column => {
|
||||||
|
table.column(column)
|
||||||
|
.nullable()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Look up and apply the check constraints
|
||||||
|
const checkConstraints = await this.getCheckConstraints(table.name)
|
||||||
|
|
||||||
|
checkConstraints.each(constraint => {
|
||||||
|
table.constraint(constraint.constraint_name)
|
||||||
|
.type(ConstraintType.Check)
|
||||||
|
.expression(constraint.check_clause)
|
||||||
|
.flagAsExistingInSchema()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark the columns as existing in the database
|
||||||
|
cols.each(col => {
|
||||||
|
table.column(col.column_name)
|
||||||
|
.flagAsExistingInSchema()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Look up table indexes
|
||||||
|
const indexes = await this.getIndexes(table.name)
|
||||||
|
const groupedIndexes = indexes.groupBy('index_name')
|
||||||
|
|
||||||
|
for ( const key in groupedIndexes ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(groupedIndexes, key) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
table.index(key)
|
||||||
|
.pipe()
|
||||||
|
.peek(idx => {
|
||||||
|
collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase
|
||||||
|
.pluck<string>('column_name')
|
||||||
|
.each(col => idx.field(col))
|
||||||
|
})
|
||||||
|
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
|
||||||
|
idx.primary()
|
||||||
|
})
|
||||||
|
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
|
||||||
|
idx.unique()
|
||||||
|
})
|
||||||
|
.get()
|
||||||
|
.flagAsExistingInSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
table.flagAsExistingInSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the database to look up all indexes on a table, by column.
|
||||||
|
* @see https://stackoverflow.com/a/2213199/4971138
|
||||||
|
* @param table
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getIndexes(table: string): Promise<Collection<QueryRow>> {
|
||||||
|
const rawQuery = `
|
||||||
|
select
|
||||||
|
t.relname as table_name,
|
||||||
|
i.relname as index_name,
|
||||||
|
a.attname as column_name,
|
||||||
|
ix.*
|
||||||
|
from pg_class t
|
||||||
|
left join pg_attribute a
|
||||||
|
on a.attrelid = t.oid
|
||||||
|
left join pg_index ix
|
||||||
|
on t.oid = ix.indrelid
|
||||||
|
left join pg_class i
|
||||||
|
on i.oid = ix.indexrelid
|
||||||
|
left join pg_namespace n
|
||||||
|
on n.oid = i.relnamespace
|
||||||
|
where
|
||||||
|
a.attnum = any(ix.indkey)
|
||||||
|
and t.relkind = 'r'
|
||||||
|
and t.relname = '${table}'
|
||||||
|
and n.nspname = '${this.schema}'
|
||||||
|
order by
|
||||||
|
t.relname,
|
||||||
|
i.relname;
|
||||||
|
`
|
||||||
|
|
||||||
|
return (new Builder()).connection(this.connection)
|
||||||
|
.raw(rawQuery)
|
||||||
|
.get()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the database to look up all constraints on a table, by column.
|
||||||
|
* @see https://dba.stackexchange.com/a/290854
|
||||||
|
* @param table
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getConstraints(table: string): Promise<Collection<QueryRow>> {
|
||||||
|
const rawQuery = `
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT
|
||||||
|
pgc.contype AS constraint_type,
|
||||||
|
pgc.conname AS constraint_name,
|
||||||
|
ccu.table_schema AS table_schema,
|
||||||
|
kcu.table_name AS table_name,
|
||||||
|
CASE WHEN (pgc.contype = 'f') THEN kcu.COLUMN_NAME ELSE ccu.COLUMN_NAME END AS column_name,
|
||||||
|
CASE WHEN (pgc.contype = 'f') THEN ccu.TABLE_NAME ELSE (null) END AS reference_table,
|
||||||
|
CASE WHEN (pgc.contype = 'f') THEN ccu.COLUMN_NAME ELSE (null) END AS reference_col,
|
||||||
|
CASE WHEN (pgc.contype = 'p') THEN 'yes' ELSE 'no' END AS auto_inc,
|
||||||
|
CASE WHEN (pgc.contype = 'p') THEN 'NO' ELSE 'YES' END AS is_nullable,
|
||||||
|
'integer' AS data_type,
|
||||||
|
'0' AS numeric_scale,
|
||||||
|
'32' AS numeric_precision
|
||||||
|
FROM
|
||||||
|
pg_constraint AS pgc
|
||||||
|
JOIN pg_namespace nsp
|
||||||
|
ON nsp.oid = pgc.connamespace
|
||||||
|
JOIN pg_class cls
|
||||||
|
ON pgc.conrelid = cls.oid
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON kcu.constraint_name = pgc.conname
|
||||||
|
LEFT JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON pgc.conname = ccu.CONSTRAINT_NAME
|
||||||
|
AND nsp.nspname = ccu.CONSTRAINT_SCHEMA
|
||||||
|
WHERE
|
||||||
|
kcu.table_name = '${table}'
|
||||||
|
UNION
|
||||||
|
SELECT
|
||||||
|
NULL AS constraint_type,
|
||||||
|
NULL AS constraint_name,
|
||||||
|
table_schema,
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
NULL AS refrence_table,
|
||||||
|
NULL AS refrence_col,
|
||||||
|
'no' AS auto_inc,
|
||||||
|
is_nullable,
|
||||||
|
data_type,
|
||||||
|
numeric_scale,
|
||||||
|
numeric_precision
|
||||||
|
FROM information_schema.columns cols
|
||||||
|
WHERE
|
||||||
|
table_schema = '${this.schema}'
|
||||||
|
AND table_name = '${table}'
|
||||||
|
) AS child
|
||||||
|
ORDER BY table_name DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
return (new Builder()).connection(this.connection)
|
||||||
|
.raw(rawQuery)
|
||||||
|
.get()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://dataedo.com/kb/query/postgresql/list-table-check-constraints
|
||||||
|
* @param table
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getCheckConstraints(table: string): Promise<Collection<QueryRow>> {
|
||||||
|
const rawQuery = `
|
||||||
|
SELECT
|
||||||
|
tc.table_schema,
|
||||||
|
tc.table_name,
|
||||||
|
ARRAY_AGG(col.column_name) AS columns,
|
||||||
|
tc.constraint_name,
|
||||||
|
cc.check_clause
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.check_constraints cc
|
||||||
|
ON tc.constraint_schema = cc.constraint_schema
|
||||||
|
AND tc.constraint_name = cc.constraint_name
|
||||||
|
JOIN pg_namespace nsp
|
||||||
|
ON nsp.nspname = cc.constraint_schema
|
||||||
|
JOIN pg_constraint pgc
|
||||||
|
ON pgc.conname = cc.constraint_name
|
||||||
|
AND pgc.connamespace = nsp.oid
|
||||||
|
AND pgc.contype = 'c'
|
||||||
|
JOIN information_schema.columns col
|
||||||
|
ON col.table_schema = tc.table_schema
|
||||||
|
AND col.table_name = tc.table_name
|
||||||
|
AND col.ordinal_position = ANY(pgc.conkey)
|
||||||
|
WHERE
|
||||||
|
tc.constraint_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND tc.table_schema = '${this.schema}'
|
||||||
|
AND tc.table_name = '${table}'
|
||||||
|
GROUP BY
|
||||||
|
tc.table_schema,
|
||||||
|
tc.table_name,
|
||||||
|
tc.constraint_name,
|
||||||
|
cc.check_clause
|
||||||
|
ORDER BY
|
||||||
|
tc.table_schema,
|
||||||
|
tc.table_name
|
||||||
|
`
|
||||||
|
|
||||||
|
return (new Builder()).connection(this.connection)
|
||||||
|
.raw(rawQuery)
|
||||||
|
.get()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the database to look up all columns on a table.
|
||||||
|
* @param table
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getColumns(table: string): Promise<Collection<QueryRow>> {
|
||||||
|
return (new Builder()).connection(this.connection)
|
||||||
|
.select(raw('*'))
|
||||||
|
.from('information_schema.columns')
|
||||||
|
.where('table_schema', '=', this.schema)
|
||||||
|
.where('table_name', '=', table)
|
||||||
|
.get()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +1,51 @@
|
|||||||
import {Connection} from '../connection/Connection'
|
import {Connection} from '../connection/Connection'
|
||||||
import {Awaitable} from '../../util'
|
import {Awaitable} from '../../util'
|
||||||
|
import {TableBuilder} from './TableBuilder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a SQL-schema implementation.
|
||||||
|
*/
|
||||||
export abstract class Schema {
|
export abstract class Schema {
|
||||||
constructor(
|
constructor(
|
||||||
|
/** The SQL connection to execute against. */
|
||||||
protected readonly connection: Connection,
|
protected readonly connection: Connection,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve true if the schema has a table with the given name.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
public abstract hasTable(name: string): Awaitable<boolean>
|
public abstract hasTable(name: string): Awaitable<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve true if the schema table with the given name has a column with the given name.
|
||||||
|
* @param table
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
public abstract hasColumn(table: string, name: string): Awaitable<boolean>
|
public abstract hasColumn(table: string, name: string): Awaitable<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve true if the schema table with the given name has all the specified columns.
|
||||||
|
* @param table
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
public abstract hasColumns(table: string, name: string[]): Awaitable<boolean>
|
public abstract hasColumns(table: string, name: string[]): Awaitable<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a TableBuilder instance for a table on the schema.
|
||||||
|
* @param table
|
||||||
|
*/
|
||||||
|
public abstract table(table: string): Awaitable<TableBuilder>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the table to the schema.
|
||||||
|
* @param schema
|
||||||
|
*/
|
||||||
|
public async commit(schema: TableBuilder): Promise<void> {
|
||||||
|
const query = this.connection
|
||||||
|
.dialect()
|
||||||
|
.renderCommitSchemaTransaction(schema)
|
||||||
|
|
||||||
|
await this.connection.query(query)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,109 +1,770 @@
|
|||||||
import {Pipe} from '../../util'
|
import {collect, Maybe, ParameterizedCallback, Pipe} from '../../util'
|
||||||
|
import {FieldType} from '../types'
|
||||||
|
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class with shared logic for the various schema
|
||||||
|
* builders (table, column, index).
|
||||||
|
*/
|
||||||
export abstract class SchemaBuilderBase {
|
export abstract class SchemaBuilderBase {
|
||||||
|
/**
|
||||||
|
* Whether or not the schema item should be dropped.
|
||||||
|
* - `exists` - drop if exists
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
|
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name the schema item should have if renaming.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
protected shouldRenameTo?: string
|
protected shouldRenameTo?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, apply IF NOT EXISTS syntax.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected shouldSkipIfExists = false
|
||||||
|
|
||||||
|
/** True if the schema has been modified since created/loaded. */
|
||||||
|
protected dirty = false
|
||||||
|
|
||||||
|
/** True if this resource exists, in some form, in the schema. */
|
||||||
|
protected existsInSchema = false
|
||||||
|
|
||||||
|
/** If the resource exists in the schema, the unaltered values it has. */
|
||||||
|
public originalFromSchema?: SchemaBuilderBase
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly name: string,
|
/** The name of the schema item. */
|
||||||
|
public readonly name: string,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone the properties of this resource to a different instance.
|
||||||
|
* @param newBuilder
|
||||||
|
*/
|
||||||
|
public cloneTo(newBuilder: SchemaBuilderBase): SchemaBuilderBase {
|
||||||
|
newBuilder.shouldDrop = this.shouldDrop
|
||||||
|
newBuilder.shouldRenameTo = this.shouldRenameTo
|
||||||
|
newBuilder.shouldSkipIfExists = this.shouldSkipIfExists
|
||||||
|
newBuilder.dirty = this.dirty
|
||||||
|
newBuilder.existsInSchema = this.existsInSchema
|
||||||
|
return newBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this resource should be dropped. */
|
||||||
|
public isDropping(): boolean {
|
||||||
|
return this.shouldDrop === 'yes'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this resource should be dropped with IF EXISTS syntax. */
|
||||||
|
public isDroppingIfExists(): boolean {
|
||||||
|
return this.shouldDrop === 'exists'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this resource should be created with IF NOT EXISTS syntax. */
|
||||||
|
public isSkippedIfExisting(): boolean {
|
||||||
|
return this.shouldSkipIfExists
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the resource already exists in some form in the schema. */
|
||||||
|
public isExisting(): boolean {
|
||||||
|
return this.existsInSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the resource has been modified since created/loaded. */
|
||||||
|
public isDirty(): boolean {
|
||||||
|
return this.dirty
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the name this resource should be renamed to, if it exists.
|
||||||
|
*/
|
||||||
|
public getRename(): Maybe<string> {
|
||||||
|
return this.shouldRenameTo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the resource to be removed. */
|
||||||
public drop(): this {
|
public drop(): this {
|
||||||
|
this.dirty = true
|
||||||
this.shouldDrop = 'yes'
|
this.shouldDrop = 'yes'
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark the resource to be removed, if it exists. */
|
||||||
public dropIfExists(): this {
|
public dropIfExists(): this {
|
||||||
|
this.dirty = true
|
||||||
this.shouldDrop = 'exists'
|
this.shouldDrop = 'exists'
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the resource to a different name.
|
||||||
|
* @param to
|
||||||
|
*/
|
||||||
public rename(to: string): this {
|
public rename(to: string): this {
|
||||||
|
this.dirty = true
|
||||||
this.shouldRenameTo = to
|
this.shouldRenameTo = to
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the resource to use IF NOT EXISTS syntax.
|
||||||
|
*/
|
||||||
|
public ifNotExists(): this {
|
||||||
|
this.shouldSkipIfExists = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used internally.
|
||||||
|
* Mark that the resource exists in the schema in some form,
|
||||||
|
* and reset the `dirty` flag.
|
||||||
|
*/
|
||||||
|
public flagAsExistingInSchema(): this {
|
||||||
|
this.existsInSchema = true
|
||||||
|
this.dirty = false
|
||||||
|
this.originalFromSchema = this.cloneTo(this.cloneInstance())
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a Pipe containing this instance. */
|
||||||
pipe(): Pipe<this> {
|
pipe(): Pipe<this> {
|
||||||
return Pipe.wrap<this>(this)
|
return Pipe.wrap<this>(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a new instance of the concrete implementation of this class.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected abstract cloneInstance(): this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to specify the schema of a table column.
|
||||||
|
*/
|
||||||
export class ColumnBuilder extends SchemaBuilderBase {
|
export class ColumnBuilder extends SchemaBuilderBase {
|
||||||
|
/** The data type of the column. */
|
||||||
|
protected targetType?: FieldType
|
||||||
|
|
||||||
|
/** True if the column should allow NULL values. */
|
||||||
|
protected shouldBeNullable = false
|
||||||
|
|
||||||
|
/** The default value of the column, if one should exist. */
|
||||||
|
protected defaultValue?: EscapeValue
|
||||||
|
|
||||||
|
/** The data length of this column, if set */
|
||||||
|
protected targetLength?: number
|
||||||
|
|
||||||
|
/** True if this is a primary key constraint. */
|
||||||
|
protected shouldBePrimary = false
|
||||||
|
|
||||||
|
/** True if this column should contain distinct values. */
|
||||||
|
protected shouldBeUnique = false
|
||||||
|
|
||||||
|
public originalFromSchema?: ColumnBuilder
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
|
||||||
|
/** The table this column belongs to. */
|
||||||
|
public readonly parent: TableBuilder,
|
||||||
|
) {
|
||||||
|
super(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
public cloneTo(newBuilder: ColumnBuilder): ColumnBuilder {
|
||||||
|
super.cloneTo(newBuilder)
|
||||||
|
newBuilder.targetType = this.targetType
|
||||||
|
newBuilder.shouldBeNullable = this.shouldBeNullable
|
||||||
|
newBuilder.defaultValue = this.defaultValue
|
||||||
|
newBuilder.targetLength = this.targetLength
|
||||||
|
newBuilder.shouldBePrimary = this.shouldBePrimary
|
||||||
|
newBuilder.shouldBeUnique = this.shouldBeUnique
|
||||||
|
return newBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the field type of the column, if it exists. */
|
||||||
|
public getType(): Maybe<FieldType> {
|
||||||
|
return this.targetType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the data-type length of the column, if it exists. */
|
||||||
|
public getLength(): Maybe<number> {
|
||||||
|
return this.targetLength
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the default value of the column, if it exists. */
|
||||||
|
public getDefaultValue(): Maybe<EscapeValue> {
|
||||||
|
return this.defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the column should allow NULL values. */
|
||||||
|
public isNullable(): boolean {
|
||||||
|
return this.shouldBeNullable
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the column is a primary key. */
|
||||||
|
public isPrimary(): boolean {
|
||||||
|
return this.shouldBePrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the column should require unique values. */
|
||||||
|
public isUnique(): boolean {
|
||||||
|
return this.shouldBeUnique
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specify the data type of the column.
|
||||||
|
* @param type
|
||||||
|
*/
|
||||||
|
public type(type: FieldType): this {
|
||||||
|
if ( this.targetType === type ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.targetType = type
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the column nullable.
|
||||||
|
*/
|
||||||
|
public nullable(): this {
|
||||||
|
if ( this.shouldBeNullable ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.shouldBeNullable = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the column non-nullable.
|
||||||
|
*/
|
||||||
|
public required(): this {
|
||||||
|
if ( !this.shouldBeNullable ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.shouldBeNullable = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specify the default value of the column.
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
public default(value: EscapeValue): this {
|
||||||
|
if ( this.defaultValue === value ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.defaultValue = value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the length of this column's data type.
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
public length(value: number): this {
|
||||||
|
if ( this.targetLength === value ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.targetLength = value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make this a primary-key column.
|
||||||
|
*/
|
||||||
|
primary(): this {
|
||||||
|
if ( this.shouldBePrimary ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.shouldBePrimary = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make this column require distinct values.
|
||||||
|
*/
|
||||||
|
unique(): this {
|
||||||
|
if ( this.shouldBeUnique ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.shouldBeUnique = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cloneInstance(): this {
|
||||||
|
return new ColumnBuilder(this.name, this.parent) as this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valid constraint types. */
|
||||||
|
export enum ConstraintType {
|
||||||
|
Unique = 'un',
|
||||||
|
Check = 'ck',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IndexBuilder extends SchemaBuilderBase {
|
/**
|
||||||
|
* Builder to specify the schema of a table constraint.
|
||||||
|
*/
|
||||||
|
export class ConstraintBuilder extends SchemaBuilderBase {
|
||||||
|
public originalFromSchema?: ConstraintBuilder
|
||||||
|
|
||||||
|
/** The fields included in this constraint. */
|
||||||
|
protected fields: Set<string> = new Set<string>()
|
||||||
|
|
||||||
|
/** The type of this constraint. */
|
||||||
|
protected constraintType: ConstraintType = ConstraintType.Unique
|
||||||
|
|
||||||
|
/** The expression defining this constraint, if applicable. */
|
||||||
|
protected constraintExpression?: QuerySafeValue
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
|
||||||
|
/** The table this constraint belongs to. */
|
||||||
|
public readonly parent: TableBuilder,
|
||||||
|
) {
|
||||||
|
super(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the type of this constraint. */
|
||||||
|
public getType(): ConstraintType {
|
||||||
|
return this.constraintType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the fields included in this constraint. */
|
||||||
|
public getFields(): string[] {
|
||||||
|
return [...this.fields]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the expression used to evaluate this constraint, if it exists. */
|
||||||
|
public getExpression(): Maybe<QuerySafeValue> {
|
||||||
|
return this.constraintExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
public cloneTo(newBuilder: ConstraintBuilder): ConstraintBuilder {
|
||||||
|
super.cloneTo(newBuilder)
|
||||||
|
newBuilder.fields = new Set<string>([...this.fields])
|
||||||
|
newBuilder.constraintType = this.constraintType
|
||||||
|
return newBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cloneInstance(): this {
|
||||||
|
return new ConstraintBuilder(this.name, this.parent) as this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a field to this constraint. */
|
||||||
|
public field(name: string): this {
|
||||||
|
if ( this.fields.has(name) ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.fields.add(name)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a field from this constraint. */
|
||||||
|
public removeField(name: string): this {
|
||||||
|
if ( !this.fields.has(name) ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.fields.delete(name)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Specify the type of this constraint. */
|
||||||
|
public type(type: ConstraintType): this {
|
||||||
|
if ( this.constraintType === type ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
this.constraintType = type
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Specify the expression used to evaluate this constraint, if applicable. */
|
||||||
|
public expression(sql: string | QuerySafeValue): this {
|
||||||
|
if ( String(this.constraintExpression) === String(sql) ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
|
||||||
|
if ( sql instanceof QuerySafeValue ) {
|
||||||
|
this.constraintExpression = sql
|
||||||
|
}
|
||||||
|
|
||||||
|
this.constraintExpression = raw(sql)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to specify the schema of a table index.
|
||||||
|
*/
|
||||||
|
export class IndexBuilder extends SchemaBuilderBase {
|
||||||
|
/** The fields included in the index. */
|
||||||
protected fields: Set<string> = new Set<string>()
|
protected fields: Set<string> = new Set<string>()
|
||||||
|
|
||||||
|
/** Fields to remove from the index. */
|
||||||
protected removedFields: Set<string> = new Set<string>()
|
protected removedFields: Set<string> = new Set<string>()
|
||||||
|
|
||||||
|
/** True if this is a unique index. */
|
||||||
protected shouldBeUnique = false
|
protected shouldBeUnique = false
|
||||||
|
|
||||||
|
/** True if this is a primary key index. */
|
||||||
protected shouldBePrimary = false
|
protected shouldBePrimary = false
|
||||||
|
|
||||||
protected field(name: string): this {
|
public originalFromSchema?: IndexBuilder
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
|
||||||
|
/** The table this index belongs to. */
|
||||||
|
public readonly parent: TableBuilder,
|
||||||
|
) {
|
||||||
|
super(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
public cloneTo(newBuilder: IndexBuilder): IndexBuilder {
|
||||||
|
super.cloneTo(newBuilder)
|
||||||
|
newBuilder.fields = new Set<string>([...this.fields])
|
||||||
|
newBuilder.removedFields = new Set<string>([...this.removedFields])
|
||||||
|
newBuilder.shouldBeUnique = this.shouldBeUnique
|
||||||
|
newBuilder.shouldBePrimary = this.shouldBePrimary
|
||||||
|
return newBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the fields in this index. */
|
||||||
|
public getFields(): string[] {
|
||||||
|
return [...this.fields]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this index is a unique index. */
|
||||||
|
public isUnique(): boolean {
|
||||||
|
return this.shouldBeUnique
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this index is the primary key index. */
|
||||||
|
public isPrimary(): boolean {
|
||||||
|
return this.shouldBePrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the given field to this index.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public field(name: string): this {
|
||||||
|
if ( this.fields.has(name) ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
this.fields.add(name)
|
this.fields.add(name)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the given field from this index.
|
||||||
|
* @param name
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
protected removeField(name: string): this {
|
protected removeField(name: string): this {
|
||||||
|
if ( !this.fields.has(name) ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
this.removedFields.add(name)
|
this.removedFields.add(name)
|
||||||
this.fields.delete(name)
|
this.fields.delete(name)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make this a primary-key index.
|
||||||
|
*/
|
||||||
primary(): this {
|
primary(): this {
|
||||||
|
if ( this.shouldBePrimary ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
this.shouldBePrimary = true
|
this.shouldBePrimary = true
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make this a unique index.
|
||||||
|
*/
|
||||||
unique(): this {
|
unique(): this {
|
||||||
|
if ( this.shouldBeUnique ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
this.shouldBeUnique = true
|
this.shouldBeUnique = true
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected cloneInstance(): this {
|
||||||
|
return new IndexBuilder(this.name, this.parent) as this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to specify the schema of a table.
|
||||||
|
*/
|
||||||
export class TableBuilder extends SchemaBuilderBase {
|
export class TableBuilder extends SchemaBuilderBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of column name to column schemata.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
protected columns: {[key: string]: ColumnBuilder} = {}
|
protected columns: {[key: string]: ColumnBuilder} = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of index name to index schemata.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
protected indexes: {[key: string]: IndexBuilder} = {}
|
protected indexes: {[key: string]: IndexBuilder} = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of constraint name to constraint schemata.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected constraints: {[key: string]: ConstraintBuilder} = {}
|
||||||
|
|
||||||
|
public originalFromSchema?: TableBuilder
|
||||||
|
|
||||||
|
public cloneTo(newBuilder: TableBuilder): TableBuilder {
|
||||||
|
super.cloneTo(newBuilder)
|
||||||
|
newBuilder.columns = {...this.columns}
|
||||||
|
newBuilder.indexes = {...this.indexes}
|
||||||
|
newBuilder.constraints = {...this.constraints}
|
||||||
|
return newBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the columns defined on this table.
|
||||||
|
*/
|
||||||
|
public getColumns(): {[key: string]: ColumnBuilder} {
|
||||||
|
return {
|
||||||
|
...this.columns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the indices defined on this table.
|
||||||
|
*/
|
||||||
|
public getIndexes(): {[key: string]: IndexBuilder} {
|
||||||
|
return {
|
||||||
|
...this.indexes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the constraints defined on this table.
|
||||||
|
*/
|
||||||
|
public getConstraints(): {[key: string]: ConstraintBuilder} {
|
||||||
|
return {
|
||||||
|
...this.constraints,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a column to be dropped.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
public dropColumn(name: string): this {
|
public dropColumn(name: string): this {
|
||||||
|
this.dirty = true
|
||||||
this.column(name).drop()
|
this.column(name).drop()
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a column to be renamed.
|
||||||
|
* @param from
|
||||||
|
* @param to
|
||||||
|
*/
|
||||||
public renameColumn(from: string, to: string): this {
|
public renameColumn(from: string, to: string): this {
|
||||||
this.column(from).rename(to)
|
this.column(from).rename(to)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an index to be dropped.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
public dropIndex(name: string): this {
|
public dropIndex(name: string): this {
|
||||||
|
this.dirty = true
|
||||||
this.index(name).drop()
|
this.index(name).drop()
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an index to be renamed.
|
||||||
|
* @param from
|
||||||
|
* @param to
|
||||||
|
*/
|
||||||
public renameIndex(from: string, to: string): this {
|
public renameIndex(from: string, to: string): this {
|
||||||
this.index(from).rename(to)
|
this.index(from).rename(to)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
public column(name: string) {
|
/**
|
||||||
|
* Add a column to this table.
|
||||||
|
* @param name
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
public column(name: string, callback?: ParameterizedCallback<ColumnBuilder>): ColumnBuilder {
|
||||||
if ( !this.columns[name] ) {
|
if ( !this.columns[name] ) {
|
||||||
this.columns[name] = new ColumnBuilder(name)
|
this.dirty = true
|
||||||
|
this.columns[name] = new ColumnBuilder(name, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( callback ) {
|
||||||
|
callback(this.columns[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.columns[name]
|
return this.columns[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
public index(name: string) {
|
/**
|
||||||
|
* Add an index to this table.
|
||||||
|
* @param name
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
public index(name: string, callback?: ParameterizedCallback<IndexBuilder>): IndexBuilder {
|
||||||
if ( !this.indexes[name] ) {
|
if ( !this.indexes[name] ) {
|
||||||
this.indexes[name] = new IndexBuilder(name)
|
this.dirty = true
|
||||||
|
this.indexes[name] = new IndexBuilder(name, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( callback ) {
|
||||||
|
callback(this.indexes[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.indexes[name]
|
return this.indexes[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a constraint to this table.
|
||||||
|
* @param name
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
public constraint(name: string, callback?: ParameterizedCallback<ConstraintBuilder>): ConstraintBuilder {
|
||||||
|
if ( !this.constraints[name] ) {
|
||||||
|
this.dirty = true
|
||||||
|
this.constraints[name] = new ConstraintBuilder(name, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( callback ) {
|
||||||
|
callback(this.constraints[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.constraints[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a programmatically-incrementing constraint name.
|
||||||
|
* @param suffix
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getNextAvailableConstraintName(suffix: ConstraintType): string {
|
||||||
|
let current = 1
|
||||||
|
let name = `${this.name}_${current}_${suffix}`
|
||||||
|
|
||||||
|
while ( this.constraints[name] ) {
|
||||||
|
current += 1
|
||||||
|
name = `${this.name}_${current}_${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new check constraint with the given expression.
|
||||||
|
* @param expression
|
||||||
|
*/
|
||||||
|
public check(expression: string | QuerySafeValue): this {
|
||||||
|
const name = this.getNextAvailableConstraintName(ConstraintType.Check)
|
||||||
|
this.constraint(name)
|
||||||
|
.type(ConstraintType.Check)
|
||||||
|
.expression(expression)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new unique constraint for the given fields.
|
||||||
|
* @param fields
|
||||||
|
*/
|
||||||
|
public unique(...fields: string[]): this {
|
||||||
|
// First, check if an existing constraint exists with these fields
|
||||||
|
for ( const key in this.constraints ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(this.constraints, key) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.constraints[key].getType() !== ConstraintType.Unique ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFields = collect<string>(this.constraints[key].getFields())
|
||||||
|
const intersection = existingFields.intersect(fields)
|
||||||
|
|
||||||
|
if ( existingFields.length === fields.length && intersection.length === fields.length ) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an existing constraint can't satisfy this, create a new one
|
||||||
|
const name = this.getNextAvailableConstraintName(ConstraintType.Unique)
|
||||||
|
this.constraint(name)
|
||||||
|
.type(ConstraintType.Unique)
|
||||||
|
.pipe()
|
||||||
|
.peek(constraint => {
|
||||||
|
fields.forEach(field => constraint.field(field))
|
||||||
|
})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a primary key (column & index) to this table.
|
||||||
|
* @param name
|
||||||
|
* @param type
|
||||||
|
*/
|
||||||
|
public primaryKey(name: string, type: FieldType = FieldType.serial): ColumnBuilder {
|
||||||
|
this.dirty = true
|
||||||
|
|
||||||
|
return this.column(name)
|
||||||
|
.type(type)
|
||||||
|
.primary()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cloneInstance(): this {
|
||||||
|
return new TableBuilder(this.name) as this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
import {Inject, Singleton} from '../../di'
|
||||||
|
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
||||||
|
import {Migration} from '../migrations/Migration'
|
||||||
|
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
|
import {UniversalPath} from '../../util'
|
||||||
|
import {lib} from '../../lib'
|
||||||
|
import {CommandLine} from '../../cli'
|
||||||
|
import {MigrateDirective} from '../directive/MigrateDirective'
|
||||||
|
import {RollbackDirective} from '../directive/RollbackDirective'
|
||||||
|
import {CreateMigrationDirective} from '../directive/CreateMigrationDirective'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service unit that loads and instantiates migration classes.
|
||||||
|
*/
|
||||||
|
@Singleton()
|
||||||
|
export class Migrations extends CanonicalInstantiable<Migration> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly migrator!: Migrator
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly cli!: CommandLine
|
||||||
|
|
||||||
|
protected appPath = ['migrations']
|
||||||
|
|
||||||
|
protected canonicalItem = 'migration'
|
||||||
|
|
||||||
|
protected suffix = '.migration.js'
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
if ( await this.path.exists() ) {
|
||||||
|
await super.up()
|
||||||
|
} else {
|
||||||
|
this.logging.debug(`Base migration path does not exist, or has no files: ${this.path}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the migrations for @extollo/lib
|
||||||
|
const basePath = lib().concat('migrations')
|
||||||
|
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
|
||||||
|
this.registerNamespace('@extollo', resolver)
|
||||||
|
|
||||||
|
// Register the migrate CLI directives
|
||||||
|
this.cli.registerDirective(MigrateDirective)
|
||||||
|
this.cli.registerDirective(RollbackDirective)
|
||||||
|
this.cli.registerDirective(CreateMigrationDirective)
|
||||||
|
}
|
||||||
|
|
||||||
|
async initCanonicalItem(definition: CanonicalDefinition): Promise<Migration> {
|
||||||
|
const instance = await super.initCanonicalItem(definition)
|
||||||
|
|
||||||
|
if ( !(instance instanceof Migration) ) {
|
||||||
|
throw new TypeError(`Invalid migration: ${definition.originalName}. Migrations must extend from @extollo/lib.Migration.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.setMigrationIdentifier(definition.canonicalName)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a CanonicalResolver for a directory that contains migration files.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const path = universalPath('path', 'to', 'migrations', 'folder')
|
||||||
|
* const namespace = '@mypackage'
|
||||||
|
*
|
||||||
|
* const resolver = await migrations.buildMigrationNamespaceResolver(namespace, path)
|
||||||
|
* migrations.registerNamespace(namespace, resolver)
|
||||||
|
* ```
|
||||||
|
* @param name
|
||||||
|
* @param basePath
|
||||||
|
*/
|
||||||
|
public async buildMigrationNamespaceResolver(name: string, basePath: UniversalPath): Promise<CanonicalResolver<Migration>> {
|
||||||
|
if ( !name.startsWith('@') ) {
|
||||||
|
name = `@${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespace: {[key: string]: Migration} = {}
|
||||||
|
|
||||||
|
for await ( const entry of basePath.walk() ) {
|
||||||
|
if ( !entry.endsWith(this.suffix) ) {
|
||||||
|
this.logging.debug(`buildMigrationNamespaceResolver - Skipping file with invalid suffix: ${entry}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = await this.buildCanonicalDefinition(entry, basePath)
|
||||||
|
this.logging.verbose(`buildMigrationNamespaceResolver - Discovered canonical ${this.canonicalItem} "${definition.canonicalName}" from ${entry}`)
|
||||||
|
namespace[definition.canonicalName] = await this.initCanonicalItem(definition)
|
||||||
|
namespace[definition.canonicalName].setMigrationIdentifier(`${name}:${namespace[definition.canonicalName].identifier}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (key: string) => namespace[key],
|
||||||
|
all: () => Object.keys(namespace),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import {Template} from '../../cli'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template for creating new migration classes in app/migrations.
|
||||||
|
*/
|
||||||
|
const templateMigration: Template = {
|
||||||
|
name: 'migration',
|
||||||
|
fileSuffix: '.migration.ts',
|
||||||
|
baseAppPath: ['migrations'],
|
||||||
|
description: 'Create a new class that applies a one-time migration',
|
||||||
|
render: (name: string) => {
|
||||||
|
return `import {Injectable, Migration} from '@extollo/lib'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${name}
|
||||||
|
* ----------------------------------
|
||||||
|
* Put some description here.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export default class ${name} extends Migration {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the migration.
|
||||||
|
*/
|
||||||
|
async up(): Promise<void> {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the migration.
|
||||||
|
*/
|
||||||
|
async down(): Promise<void> {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export { templateMigration }
|
@ -0,0 +1,44 @@
|
|||||||
|
import {Logger} from './Logger'
|
||||||
|
import {LogMessage} from './types'
|
||||||
|
import {Injectable} from '../../di'
|
||||||
|
import {universalPath} from '../support/path'
|
||||||
|
import {appPath, env} from '../../lifecycle/Application'
|
||||||
|
import {Writable} from 'stream'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Logger implementation that writes to a UniversalPath.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FileLogger extends Logger {
|
||||||
|
private resolvedPath?: Writable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the re-usable write stream to the log file.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getWriteStream(): Promise<Writable> {
|
||||||
|
if ( !this.resolvedPath ) {
|
||||||
|
let basePath = env('EXTOLLO_LOGGING_FILE')
|
||||||
|
if ( basePath && !Array.isArray(basePath) ) {
|
||||||
|
basePath = [basePath]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = basePath ? universalPath(...basePath) : appPath('..', '..', 'extollo.log')
|
||||||
|
|
||||||
|
if ( !(await resolvedPath.exists()) ) {
|
||||||
|
await resolvedPath.concat('..').mkdir()
|
||||||
|
await resolvedPath.write('')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolvedPath = await resolvedPath.writeStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
public async write(message: LogMessage): Promise<void> {
|
||||||
|
const text = `${message.level} ${this.formatDate(message.date)} (${message.callerName || 'Unknown'}) ${message.output}`
|
||||||
|
const stream = await this.getWriteStream()
|
||||||
|
stream.write(text + '\n')
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue