AsyncPipe; table schemata; migrations; File logging
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2021-07-25 09:15:01 -05:00
parent e86cf420df
commit fcce28081b
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
42 changed files with 3139 additions and 56 deletions

View File

@ -15,6 +15,7 @@ import {RunLevelErrorHandler} from './RunLevelErrorHandler'
import {Unit, UnitStatus} from './Unit'
import * as dotenv from 'dotenv'
import {CacheFactory} from '../support/cache/CacheFactory'
import {FileLogger} from '../util/logging/FileLogger'
/**
* Helper function that resolves and infers environment variable values.
@ -225,6 +226,12 @@ export class Application extends Container {
const logging: Logging = this.make<Logging>(Logging)
logging.registerLogger(standard)
if ( this.env('EXTOLLO_LOGGING_ENABLE_FILE') ) {
const file: FileLogger = this.make<FileLogger>(FileLogger)
logging.registerLogger(file)
}
logging.verbose('Attempting to load logging level from the environment...')
const envLevel = this.env('EXTOLLO_LOGGING_LEVEL')

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -41,7 +41,7 @@ export class DatabaseService extends AppClass {
* Get a connection instance by its name. Throws if none exists.
* @param name
*/
get(name: string): Connection {
get(name = 'default'): Connection {
if ( !this.has(name) ) {
throw new ErrorWithContext(`No such connection is registered: ${name}`)
}

View File

@ -1,4 +1,4 @@
import {Inject} from '../../di'
import {Inject, Injectable} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {
Constraint, ConstraintConnectionOperator,
@ -9,7 +9,7 @@ import {
SpecifiedField,
} from '../types'
import {Connection} from '../connection/Connection'
import {deepCopy, ErrorWithContext} from '../../util'
import {deepCopy, ErrorWithContext, Maybe} from '../../util'
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
import {ResultCollection} from './result/ResultCollection'
import {AbstractResultIterable} from './result/AbstractResultIterable'
@ -24,6 +24,7 @@ export type ConstraintGroupClosure<T> = (group: AbstractBuilder<T>) => any
* A base class that facilitates building database queries using a fluent interface.
* This can be specialized by child-classes to yield query results of the given type `T`.
*/
@Injectable()
export abstract class AbstractBuilder<T> extends AppClass {
@Inject()
protected readonly databaseService!: DatabaseService
@ -55,6 +56,9 @@ export abstract class AbstractBuilder<T> extends AppClass {
/** The connection on which the query should be executed. */
protected registeredConnection?: Connection
/** Raw SQL to use instead. Overrides builder methods. */
protected rawSql?: string
/**
* Create a new, empty, instance of the current builder.
*/
@ -80,6 +84,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
bldr.registeredGroupings = deepCopy(this.registeredGroupings)
bldr.registeredOrders = deepCopy(this.registeredOrders)
bldr.registeredConnection = this.registeredConnection
bldr.rawSql = this.rawSql
return bldr
}
@ -115,6 +120,11 @@ export abstract class AbstractBuilder<T> extends AppClass {
return deepCopy(this.registeredOrders)
}
/** Get the raw SQL overriding the builder methods, if it exists. */
public get appliedRawSql(): Maybe<string> {
return this.rawSql
}
/** Get the source table for this query. */
public get querySource(): QuerySource | undefined {
if ( this.source ) {
@ -555,6 +565,21 @@ export abstract class AbstractBuilder<T> extends AppClass {
return Boolean(result.rows.first())
}
/**
* Set the query manually. Overrides any builder methods.
* @example
* ```ts
* (new Builder())
* .raw('SELECT NOW() AS example_column')
* .get()
* ```
* @param sql
*/
raw(sql: string): this {
this.rawSql = sql
return this
}
/**
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
* @param preop

View File

@ -1,5 +1,5 @@
import {ErrorWithContext} from '../../util'
import {Container} from '../../di'
import {Container, Injectable} from '../../di'
import {ResultIterable} from './result/ResultIterable'
import {QueryRow} from '../types'
import {AbstractBuilder} from './AbstractBuilder'
@ -8,6 +8,7 @@ import {AbstractResultIterable} from './result/AbstractResultIterable'
/**
* Implementation of the abstract builder class that returns simple QueryRow objects.
*/
@Injectable()
export class Builder extends AbstractBuilder<QueryRow> {
public getNewInstance(): AbstractBuilder<QueryRow> {
return Container.getContainer().make<Builder>(Builder)

View File

@ -5,6 +5,7 @@ import {AppClass} from '../../lifecycle/AppClass'
import {Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
import {Schema} from '../schema/Schema'
/**
* Error thrown when a connection is used before it is ready.
@ -61,15 +62,11 @@ export abstract class Connection extends AppClass {
*/
public abstract close(): Promise<void>
// public abstract databases(): Promise<Collection<Database>>
// public abstract database(name: string): Promise<Database | undefined>
// public abstract database_as_schema(name: string): Promise<Database>
// public abstract tables(database_name: string): Promise<Collection<Table>>
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
/**
* Get a Schema on this connection.
* @param name
*/
public abstract schema(name?: string): Schema
/**
* Fire a QueryExecutedEvent for the given query string.

View File

@ -6,6 +6,8 @@ import {collect} from '../../util'
import {SQLDialect} from '../dialect/SQLDialect'
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
import {Logging} from '../../service/Logging'
import {Schema} from '../schema/Schema'
import {PostgresSchema} from '../schema/PostgresSchema'
/**
* Type interface representing the config for a PostgreSQL connection.
@ -67,4 +69,8 @@ export class PostgresConnection extends Connection {
})
}
}
public schema(name?: string): Schema {
return new PostgresSchema(this, name)
}
}

View File

@ -1,9 +1,13 @@
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
import {Constraint, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
import {ErrorWithContext, Maybe} from '../../util'
/**
* An implementation of the SQLDialect specific to PostgreSQL.
* @todo joins
* @todo sub-selects
*/
export class PostgreSQLDialect extends SQLDialect {
@ -29,7 +33,7 @@ export class PostgreSQLDialect extends SQLDialect {
`${pad(value.getSeconds())}`,
]
return new QuerySafeValue(value, `${y}-${m}-${d} ${h}:${i}:${s}`)
return new QuerySafeValue(value, `'${y}-${m}-${d} ${h}:${i}:${s}'`)
} else if ( !isNaN(Number(value)) ) {
return new QuerySafeValue(value, String(Number(value)))
} else if ( value === null || typeof value === 'undefined' ) {
@ -55,7 +59,7 @@ export class PostgreSQLDialect extends SQLDialect {
'FROM (',
...query.split('\n').map(x => ` ${x}`),
') AS extollo_target_query',
`OFFSET ${start} LIMIT ${(end - start) + 1}`,
`OFFSET ${start} LIMIT ${(end - start) + 1}`, // FIXME - the +1 is only needed when start === end
].join('\n')
}
@ -85,6 +89,11 @@ export class PostgreSQLDialect extends SQLDialect {
}
public renderSelect(builder: AbstractBuilder<any>): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const indent = (item: string, level = 1) => Array(level + 1).fill('')
.join(' ') + item
const queryLines = [
@ -147,6 +156,11 @@ export class PostgreSQLDialect extends SQLDialect {
// TODO support FROM, RETURNING
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const queryLines: string[] = []
// Add table source
@ -171,6 +185,15 @@ export class PostgreSQLDialect extends SQLDialect {
}
public renderExistential(builder: AbstractBuilder<any>): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return `
SELECT EXISTS(
${rawSql}
)
`
}
const query = builder.clone()
.clearFields()
.field(raw('TRUE'))
@ -181,6 +204,11 @@ export class PostgreSQLDialect extends SQLDialect {
// FIXME: subquery support here and with select
public renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[] = []): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const indent = (item: string, level = 1) => Array(level + 1).fill('')
.join(' ') + item
const queryLines: string[] = []
@ -188,6 +216,11 @@ export class PostgreSQLDialect extends SQLDialect {
if ( !Array.isArray(data) ) {
data = [data]
}
if ( data.length < 1 ) {
return ''
}
const columns = Object.keys(data[0])
// Add table source
@ -227,6 +260,11 @@ export class PostgreSQLDialect extends SQLDialect {
}
public renderDelete(builder: AbstractBuilder<any>): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const indent = (item: string, level = 1) => Array(level + 1).fill('')
.join(' ') + item
const queryLines: string[] = []
@ -270,6 +308,11 @@ export class PostgreSQLDialect extends SQLDialect {
if ( isConstraintGroup(constraint) ) {
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
} else if ( isConstraintItem(constraint) ) {
if ( Array.isArray(constraint.operand) && !constraint.operand.length ) {
statements.push(`${indent}1 = 0 -- ${constraint.field} ${constraint.operator} empty set`)
continue
}
const field: string = constraint.field.split('.').map(x => `"${x}"`)
.join('.')
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
@ -294,4 +337,247 @@ export class PostgreSQLDialect extends SQLDialect {
return ['SET', ...sets].join('\n')
}
public renderCreateTable(builder: TableBuilder): string {
const cols = this.renderTableColumns(builder).map(x => ` ${x}`)
const builderConstraints = builder.getConstraints()
const constraints: string[] = []
for ( const constraintName in builderConstraints ) {
if ( !Object.prototype.hasOwnProperty.call(builderConstraints, constraintName) ) {
continue
}
const constraintBuilder = builderConstraints[constraintName]
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
if ( constraintDefinition ) {
constraints.push(` CONSTRAINT ${constraintDefinition}`)
}
}
const parts = [
`CREATE TABLE ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name} (`,
[
...cols,
...constraints,
].join(',\n'),
`)`,
]
return parts.join('\n')
}
public renderTableColumns(builder: TableBuilder): string[] {
const defined = builder.getColumns()
const rendered: string[] = []
for ( const columnName in defined ) {
if ( !Object.prototype.hasOwnProperty.call(defined, columnName) ) {
continue
}
const columnBuilder = defined[columnName]
rendered.push(this.renderColumnDefinition(columnBuilder))
}
return rendered
}
/**
* Given a constraint schema-builder, render the constraint definition.
* @param builder
* @protected
*/
protected renderConstraintDefinition(builder: ConstraintBuilder): Maybe<string> {
const constraintType = builder.getType()
if ( constraintType === ConstraintType.Unique ) {
const fields = builder.getFields()
.map(x => `"${x}"`)
.join(',')
return `${builder.name} UNIQUE(${fields})`
} else if ( constraintType === ConstraintType.Check ) {
const expression = builder.getExpression()
if ( !expression ) {
throw new ErrorWithContext('Cannot create check constraint without expression.', {
constraintName: builder.name,
tableName: builder.parent.name,
})
}
return `${builder.name} CHECK(${expression})`
}
}
/**
* Given a column-builder, render the SQL-definition as used in
* CREATE TABLE and ALTER TABLE statements.
* @fixme Type `serial` only exists on CREATE TABLE... queries
* @param builder
* @protected
*/
protected renderColumnDefinition(builder: ColumnBuilder): string {
const type = builder.getType()
if ( !type ) {
throw new ErrorWithContext(`Missing field type for column: ${builder.name}`, {
columnName: builder.name,
columnType: type,
})
}
let render = `"${builder.name}" ${inverseFieldType(type)}`
if ( builder.getLength() ) {
render += `(${builder.getLength()})`
}
const defaultValue = builder.getDefaultValue()
if ( typeof defaultValue !== 'undefined' ) {
render += ` DEFAULT ${this.escape(defaultValue)}`
}
if ( builder.isPrimary() ) {
render += ` CONSTRAINT ${builder.name}_pk PRIMARY KEY`
}
if ( builder.isUnique() ) {
render += ` UNIQUE`
}
render += ` ${builder.isNullable() ? 'NULL' : 'NOT NULL'}`
return render
}
public renderDropTable(builder: TableBuilder): string {
return `DROP TABLE ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`
}
public renderCreateIndex(builder: IndexBuilder): string {
const cols = builder.getFields().map(x => `"${x}"`)
const parts = [
`CREATE ${builder.isUnique() ? 'UNIQUE ' : ''}INDEX ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name}`,
` ON ${builder.parent.name}`,
` (${cols.join(',')})`,
]
return parts.join('\n')
}
public renderAlterTable(builder: TableBuilder): string {
const alters: string[] = []
const columns = builder.getColumns()
for ( const columnName in columns ) {
if ( !Object.prototype.hasOwnProperty.call(columns, columnName) ) {
continue
}
const columnBuilder = columns[columnName]
if ( !columnBuilder.isExisting() ) {
// The column doesn't exist on the table, but was added to the schema
alters.push(` ADD COLUMN ${this.renderColumnDefinition(columnBuilder)}`)
} else if ( columnBuilder.isDirty() && columnBuilder.originalFromSchema ) {
// The column exists in the table, but was modified in the schema
if ( columnBuilder.isDropping() || columnBuilder.isDroppingIfExists() ) {
alters.push(` DROP COLUMN "${columnBuilder.name}"`)
continue
}
// Change the data type of the column
if ( columnBuilder.getType() !== columnBuilder.originalFromSchema.getType() ) {
const renderedType = `${columnBuilder.getType()}${columnBuilder.getLength() ? `(${columnBuilder.getLength()})` : ''}`
alters.push(` ALTER COLUMN "${columnBuilder.name}" TYPE ${renderedType}`)
}
// Change the default value of the column
if ( columnBuilder.getDefaultValue() !== columnBuilder.originalFromSchema.getDefaultValue() ) {
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET default ${this.escape(columnBuilder.getDefaultValue())}`)
}
// Change the nullable-status of the column
if ( columnBuilder.isNullable() !== columnBuilder.originalFromSchema.isNullable() ) {
if ( columnBuilder.isNullable() ) {
alters.push(` ALTER COLUMN "${columnBuilder.name}" DROP NOT NULL`)
} else {
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET NOT NULL`)
}
}
// Change the name of the column
if ( columnBuilder.getRename() ) {
alters.push(` RENAME COLUMN "${columnBuilder.name}" TO "${columnBuilder.getRename()}"`)
}
}
}
const constraints = builder.getConstraints()
for ( const constraintName in constraints ) {
if ( !Object.prototype.hasOwnProperty.call(constraints, constraintName) ) {
continue
}
const constraintBuilder = constraints[constraintName]
// Drop the constraint if specified
if ( constraintBuilder.isDropping() ) {
alters.push(` DROP CONSTRAINT ${constraintBuilder.name}`)
continue
}
// Drop the constraint with IF EXISTS if specified
if ( constraintBuilder.isDroppingIfExists() ) {
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
continue
}
// Otherwise, drop and recreate the constraint if it was modified
if ( constraintBuilder.isDirty() ) {
if ( constraintBuilder.isExisting() ) {
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
}
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
if ( constraintDefinition ) {
alters.push(` ADD CONSTRAINT ${constraintDefinition}`)
}
}
}
if ( builder.getRename() ) {
alters.push(` RENAME TO "${builder.getRename()}"`)
}
return 'ALTER TABLE ' + builder.name + '\n' + alters.join(',\n')
}
public renderDropIndex(builder: IndexBuilder): string {
return `DROP INDEX ${builder.isDroppingIfExists() ? 'IF EXISTS ' : ''}${builder.name}`
}
public renderTransaction(queries: string[]): string {
const parts = [
'BEGIN',
...queries,
'COMMIT',
]
return parts.join(';\n\n')
}
public renderRenameIndex(builder: IndexBuilder): string {
return `ALTER INDEX ${builder.name} RENAME TO ${builder.getRename()}`
}
public renderRecreateIndex(builder: IndexBuilder): string {
return `${this.renderDropIndex(builder)};\n\n${this.renderCreateIndex(builder)}`
}
public renderDropColumn(builder: ColumnBuilder): string {
const parts = [
`ALTER TABLE ${builder.parent.name} ${builder.parent.isSkippedIfExisting() ? 'IF EXISTS ' : ''}`,
` DROP COLUMN ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`,
]
return parts.join('\n')
}
}

View File

@ -1,6 +1,7 @@
import {Constraint} from '../types'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {AppClass} from '../../lifecycle/AppClass'
import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
/**
* A value which can be escaped to be interpolated into an SQL query.
@ -160,10 +161,141 @@ export abstract class SQLDialect extends AppClass {
* This function should escape the values before they are included in the query string.
*
* @example
* ```ts
* dialect.renderUpdateSet({field1: 'value', field2: 45})
* // => "SET field1 = 'value', field2 = 45"
* ```
*
* @param data
*/
public abstract renderUpdateSet(data: {[key: string]: EscapeValue}): string;
/**
* Given a table schema-builder, render a `CREATE TABLE...` query.
* @param builder
*/
public abstract renderCreateTable(builder: TableBuilder): string;
/**
* Given a table schema-builder, render an `ALTER TABLE...` query.
* @param builder
*/
public abstract renderAlterTable(builder: TableBuilder): string;
/**
* Given a table schema-builder, render a `DROP TABLE...` query.
* @param builder
*/
public abstract renderDropTable(builder: TableBuilder): string;
/**
* Render the table-column definitions for the table defined by
* the given schema-builder.
*
* @example
* ```ts
* dialect.renderTableColumns(builder)
* // => ['col1 varchar(100) NULL', 'col2 serial NOT NULL']
* ```
*
* @param builder
*/
public abstract renderTableColumns(builder: TableBuilder): string[];
/**
* Given an index schema-builder, render a `CREATE INDEX...` query.
* @param builder
*/
public abstract renderCreateIndex(builder: IndexBuilder): string;
/**
* Given a column schema-builder, render an `ALTER TABLE... DROP COLUMN...` query.
* @param builder
*/
public abstract renderDropColumn(builder: ColumnBuilder): string;
/**
* Given an index schema-builder, render a `DROP INDEX...` query.
* @param builder
*/
public abstract renderDropIndex(builder: IndexBuilder): string;
/**
* Given an index schema-builder, render an `ALTER INDEX... RENAME...` query.
* @param builder
*/
public abstract renderRenameIndex(builder: IndexBuilder): string;
/**
* Given an index schema-builder, render either an `ALTER INDEX...` query,
* or a `DROP INDEX...; CREATE INDEX...` query.
* @param builder
*/
public abstract renderRecreateIndex(builder: IndexBuilder): string;
/**
* Given a series of fully-formed queries, render them as a single transaction.
* @example
* ```ts
* const queries = [
* 'SELECT * FROM a',
* 'UPDATE b SET col = 123',
* ]
*
* dialect.renderTransaction(queries)
* // => 'BEGIN; SELECT * FROM a; UPDATE b SET col = 123; COMMIT;'
* ```
* @param queries
*/
public abstract renderTransaction(queries: string[]): string;
/**
* Given a table schema-builder, render a series of queries as a transaction
* that apply the given schema to database.
* @todo handle constraints better - ConstraintBuilder
* @param builder
*/
public renderCommitSchemaTransaction(builder: TableBuilder): string {
if ( builder.isDropping() || builder.isDroppingIfExists() ) {
// If we're dropping the table, just return the DROP TABLE query
return this.renderTransaction([
this.renderDropTable(builder),
])
}
// Render the queries to create/update/drop indexes
const indexes = Object.values(builder.getIndexes())
.filter(index => !index.isExisting() || index.isDirty())
.map(index => {
if ( index.isDropping() || index.isDroppingIfExists() ) {
return this.renderDropIndex(index)
}
if ( index.isExisting() ) {
// The index was changed in the schema, but exists in the DB
return this.renderRecreateIndex(index)
}
return this.renderCreateIndex(index)
})
// Render the queries to rename indexes AFTER the above operations
const renamedIndexes = Object.values(builder.getIndexes())
.filter(idx => idx.getRename())
.map(x => this.renderRenameIndex(x))
let parts: string[] = []
// Render the CREATE/ALTER TABLE query
if ( !builder.isExisting() && builder.isDirty() ) {
parts.push(this.renderCreateTable(builder))
} else if ( builder.isExisting() && builder.isDirty() ) {
parts.push(this.renderAlterTable(builder))
}
// Render the various schema queries as a single transaction
parts = parts.concat(...indexes)
parts = parts.concat(...renamedIndexes)
return this.renderTransaction(parts)
}
}

View File

@ -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}`)
}
}

View File

@ -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 = []
}
}

View File

@ -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 = []
}
}

View File

@ -18,9 +18,6 @@ export * from './model/ModelResultIterable'
export * from './model/events'
export * from './model/Model'
export * from './services/Database'
export * from './services/Models'
export * from './support/SessionModel'
export * from './support/ORMSession'
export * from './support/CacheModel'
@ -28,3 +25,25 @@ export * from './support/ORMCache'
export * from './DatabaseService'
export * from './types'
export * from './schema/TableBuilder'
export * from './schema/Schema'
export * from './schema/PostgresSchema'
export * from './migrations/NothingToMigrateError'
export * from './migrations/events/ApplyingMigrationEvent'
export * from './migrations/events/AppliedMigrationEvent'
export * from './migrations/events/RollingBackMigrationEvent'
export * from './migrations/events/RolledBackMigrationEvent'
export * from './migrations/Migration'
export * from './migrations/Migrator'
export * from './migrations/MigratorFactory'
export * from './migrations/DatabaseMigrator'
export * from './services/Database'
export * from './services/Models'
export * from './services/Migrations'
export * from './directive/CreateMigrationDirective'
export * from './directive/MigrateDirective'
export * from './directive/RollbackDirective'

View File

@ -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)
}
}

View File

@ -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>
}

View File

@ -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[]>