AsyncPipe; table schemata; migrations; File logging
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
e86cf420df
commit
fcce28081b
@ -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')
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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}`)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
48
src/orm/directive/CreateMigrationDirective.ts
Normal file
48
src/orm/directive/CreateMigrationDirective.ts
Normal 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}`)
|
||||
}
|
||||
}
|
117
src/orm/directive/MigrateDirective.ts
Normal file
117
src/orm/directive/MigrateDirective.ts
Normal 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 = []
|
||||
}
|
||||
}
|
102
src/orm/directive/RollbackDirective.ts
Normal file
102
src/orm/directive/RollbackDirective.ts
Normal 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 = []
|
||||
}
|
||||
}
|
@ -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'
|
||||
|
179
src/orm/migrations/DatabaseMigrator.ts
Normal file
179
src/orm/migrations/DatabaseMigrator.ts
Normal 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)
|
||||
}
|
||||
}
|
39
src/orm/migrations/Migration.ts
Normal file
39
src/orm/migrations/Migration.ts
Normal 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>
|
||||
}
|
295
src/orm/migrations/Migrator.ts
Normal file
295
src/orm/migrations/Migrator.ts
Normal 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[]>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
81
src/orm/migrations/MigratorFactory.ts
Normal file
81
src/orm/migrations/MigratorFactory.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
14
src/orm/migrations/NothingToMigrateError.ts
Normal file
14
src/orm/migrations/NothingToMigrateError.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
8
src/orm/migrations/events/AppliedMigrationEvent.ts
Normal file
8
src/orm/migrations/events/AppliedMigrationEvent.ts
Normal file
@ -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 {}
|
8
src/orm/migrations/events/ApplyingMigrationEvent.ts
Normal file
8
src/orm/migrations/events/ApplyingMigrationEvent.ts
Normal file
@ -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 {}
|
49
src/orm/migrations/events/MigrationEvent.ts
Normal file
49
src/orm/migrations/events/MigrationEvent.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
8
src/orm/migrations/events/RolledBackMigrationEvent.ts
Normal file
8
src/orm/migrations/events/RolledBackMigrationEvent.ts
Normal file
@ -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 {}
|
8
src/orm/migrations/events/RollingBackMigrationEvent.ts
Normal file
8
src/orm/migrations/events/RollingBackMigrationEvent.ts
Normal file
@ -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 {}
|
345
src/orm/schema/PostgresSchema.ts
Normal file
345
src/orm/schema/PostgresSchema.ts
Normal file
@ -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 {Awaitable} from '../../util'
|
||||
import {TableBuilder} from './TableBuilder'
|
||||
|
||||
/**
|
||||
* Represents a SQL-schema implementation.
|
||||
*/
|
||||
export abstract class Schema {
|
||||
constructor(
|
||||
/** The SQL connection to execute against. */
|
||||
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>
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* Whether or not the schema item should be dropped.
|
||||
* - `exists` - drop if exists
|
||||
* @protected
|
||||
*/
|
||||
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
|
||||
|
||||
/**
|
||||
* The name the schema item should have if renaming.
|
||||
* @protected
|
||||
*/
|
||||
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(
|
||||
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 {
|
||||
this.dirty = true
|
||||
this.shouldDrop = 'yes'
|
||||
return this
|
||||
}
|
||||
|
||||
/** Mark the resource to be removed, if it exists. */
|
||||
public dropIfExists(): this {
|
||||
this.dirty = true
|
||||
this.shouldDrop = 'exists'
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the resource to a different name.
|
||||
* @param to
|
||||
*/
|
||||
public rename(to: string): this {
|
||||
this.dirty = true
|
||||
this.shouldRenameTo = to
|
||||
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> {
|
||||
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 {
|
||||
/** The data type of the column. */
|
||||
protected targetType?: FieldType
|
||||
|
||||
}
|
||||
/** True if the column should allow NULL values. */
|
||||
protected shouldBeNullable = false
|
||||
|
||||
export class IndexBuilder extends SchemaBuilderBase {
|
||||
/** The default value of the column, if one should exist. */
|
||||
protected defaultValue?: EscapeValue
|
||||
|
||||
protected fields: Set<string> = new Set<string>()
|
||||
|
||||
protected removedFields: Set<string> = new Set<string>()
|
||||
|
||||
protected shouldBeUnique = false
|
||||
/** The data length of this column, if set */
|
||||
protected targetLength?: number
|
||||
|
||||
/** True if this is a primary key constraint. */
|
||||
protected shouldBePrimary = false
|
||||
|
||||
protected field(name: string): this {
|
||||
/** 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',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>()
|
||||
|
||||
/** Fields to remove from the index. */
|
||||
protected removedFields: Set<string> = new Set<string>()
|
||||
|
||||
/** True if this is a unique index. */
|
||||
protected shouldBeUnique = false
|
||||
|
||||
/** True if this is a primary key index. */
|
||||
protected shouldBePrimary = false
|
||||
|
||||
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)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given field from this index.
|
||||
* @param name
|
||||
* @protected
|
||||
*/
|
||||
protected removeField(name: string): this {
|
||||
if ( !this.fields.has(name) ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.removedFields.add(name)
|
||||
this.fields.delete(name)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this a primary-key index.
|
||||
*/
|
||||
primary(): this {
|
||||
if ( this.shouldBePrimary ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBePrimary = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this a unique index.
|
||||
*/
|
||||
unique(): this {
|
||||
if ( this.shouldBeUnique ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBeUnique = true
|
||||
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 {
|
||||
|
||||
/**
|
||||
* Mapping of column name to column schemata.
|
||||
* @protected
|
||||
*/
|
||||
protected columns: {[key: string]: ColumnBuilder} = {}
|
||||
|
||||
/**
|
||||
* Mapping of index name to index schemata.
|
||||
* @protected
|
||||
*/
|
||||
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 {
|
||||
this.dirty = true
|
||||
this.column(name).drop()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a column to be renamed.
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
public renameColumn(from: string, to: string): this {
|
||||
this.column(from).rename(to)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an index to be dropped.
|
||||
* @param name
|
||||
*/
|
||||
public dropIndex(name: string): this {
|
||||
this.dirty = true
|
||||
this.index(name).drop()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an index to be renamed.
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
public renameIndex(from: string, to: string): this {
|
||||
this.index(from).rename(to)
|
||||
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] ) {
|
||||
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]
|
||||
}
|
||||
|
||||
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] ) {
|
||||
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]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import {Inject, Singleton} from '../../di'
|
||||
import {Container, Inject, Singleton} from '../../di'
|
||||
import {DatabaseService} from '../DatabaseService'
|
||||
import {PostgresConnection} from '../connection/PostgresConnection'
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {Unit} from '../../lifecycle/Unit'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {MigratorFactory} from '../migrations/MigratorFactory'
|
||||
|
||||
/**
|
||||
* Application unit responsible for loading and creating database connections from config.
|
||||
@ -12,13 +13,16 @@ import {Logging} from '../../service/Logging'
|
||||
@Singleton()
|
||||
export class Database extends Unit {
|
||||
@Inject()
|
||||
protected readonly config!: Config;
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly dbService!: DatabaseService;
|
||||
protected readonly dbService!: DatabaseService
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging;
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/**
|
||||
* Load the `database.connections` config and register Connection instances for each config.
|
||||
@ -28,6 +32,9 @@ export class Database extends Unit {
|
||||
const connections = this.config.get('database.connections')
|
||||
const promises = []
|
||||
|
||||
// Register the migrator factory
|
||||
this.injector.registerFactory(this.injector.make(MigratorFactory))
|
||||
|
||||
for ( const key in connections ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(connections, key) ) {
|
||||
continue
|
||||
|
98
src/orm/services/Migrations.ts
Normal file
98
src/orm/services/Migrations.ts
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
40
src/orm/template/migration.ts
Normal file
40
src/orm/template/migration.ts
Normal file
@ -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 }
|
@ -152,3 +152,56 @@ export enum FieldType {
|
||||
other = 'other',
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a FieldType, get the inverse (that is, the code-form name).
|
||||
* @example
|
||||
* ```ts
|
||||
* console.log(FieldType.varchar) // => "character varying"
|
||||
* console.log(inverseFieldType(FieldType.varchar)) // => "varchar"
|
||||
* console.log(inverseFieldType('character varying')) // => "varchar"
|
||||
* ```
|
||||
* @param type
|
||||
*/
|
||||
export function inverseFieldType(type: FieldType): string {
|
||||
return ({
|
||||
bigint: 'bigint',
|
||||
bigserial: 'bigserial',
|
||||
bit: 'bit',
|
||||
'bit varying': 'varbit',
|
||||
boolean: 'boolean',
|
||||
box: 'box',
|
||||
bytea: 'bytea',
|
||||
character: 'character',
|
||||
char: 'character',
|
||||
'character varying': 'varchar',
|
||||
cidr: 'cidr',
|
||||
circle: 'circle',
|
||||
date: 'date',
|
||||
'double precision': 'float8',
|
||||
inet: 'inet',
|
||||
integer: 'integer',
|
||||
interval: 'interval',
|
||||
json: 'json',
|
||||
line: 'line',
|
||||
lseg: 'lseg',
|
||||
macaddr: 'macaddr',
|
||||
money: 'money',
|
||||
numeric: 'numeric',
|
||||
path: 'path',
|
||||
point: 'point',
|
||||
polygon: 'polygon',
|
||||
real: 'real',
|
||||
smallint: 'smallint',
|
||||
smallserial: 'smallserial',
|
||||
serial: 'serial',
|
||||
text: 'text',
|
||||
time: 'time',
|
||||
timestamp: 'timestamp',
|
||||
tsquery: 'tsquery',
|
||||
tsvector: 'tsvector',
|
||||
txidSnapshot: 'txidSnapshot',
|
||||
uuid: 'uuid',
|
||||
xml: 'xml',
|
||||
other: 'other',
|
||||
})[type]
|
||||
}
|
||||
|
@ -20,7 +20,17 @@ export interface CanonicalDefinition {
|
||||
/**
|
||||
* Type alias for a function that resolves a canonical name to a canonical item, if one exists.
|
||||
*/
|
||||
export type CanonicalResolver<T> = (key: string) => T | undefined
|
||||
export type CanonicalResolverFunction<T> = (key: string) => T | undefined
|
||||
|
||||
/**
|
||||
* Interface for a canonical resolver that provides additional information.
|
||||
*/
|
||||
export interface ComplexCanonicalResolver<T> {
|
||||
get: CanonicalResolverFunction<T>,
|
||||
all: () => string[],
|
||||
}
|
||||
|
||||
export type CanonicalResolver<T> = CanonicalResolverFunction<T> | ComplexCanonicalResolver<T>
|
||||
|
||||
/**
|
||||
* Base type for a canonical name reference.
|
||||
@ -105,10 +115,33 @@ export abstract class Canonical<T> extends Unit {
|
||||
/**
|
||||
* Return an array of all loaded canonical names.
|
||||
*/
|
||||
public all(): string[] {
|
||||
public all(namespace?: string): string[] {
|
||||
if ( namespace ) {
|
||||
const resolver = this.loadedNamespaces[namespace]
|
||||
if ( !resolver ) {
|
||||
throw new ErrorWithContext(`Unable to find namespace for ${this.canonicalItem}: ${namespace}`, {
|
||||
canonicalItem: this.canonicalItem,
|
||||
namespace,
|
||||
})
|
||||
}
|
||||
|
||||
if ( typeof resolver === 'function' ) {
|
||||
return []
|
||||
} else {
|
||||
return resolver.all()
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(this.loadedItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of all loaded canonical namespaces.
|
||||
*/
|
||||
public namespaces(): string[] {
|
||||
return Object.keys(this.loadedNamespaces)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Universal path to the base directory where this unit loads its canonical files from.
|
||||
*/
|
||||
@ -127,7 +160,8 @@ export abstract class Canonical<T> extends Unit {
|
||||
const [namespace, ...rest] = key.split(':')
|
||||
key = rest.join(':')
|
||||
|
||||
if ( !this.loadedNamespaces[namespace] ) {
|
||||
const resolver = this.loadedNamespaces[namespace]
|
||||
if ( !resolver ) {
|
||||
throw new ErrorWithContext(`Unable to find namespace for ${this.canonicalItem}: ${namespace}`, {
|
||||
canonicalItem: this.canonicalItem,
|
||||
namespace,
|
||||
@ -135,7 +169,11 @@ export abstract class Canonical<T> extends Unit {
|
||||
})
|
||||
}
|
||||
|
||||
return this.loadedNamespaces[namespace](key)
|
||||
if ( typeof resolver === 'function' ) {
|
||||
return resolver(key)
|
||||
} else {
|
||||
return resolver.get(key)
|
||||
}
|
||||
}
|
||||
|
||||
return this.loadedItems[key]
|
||||
@ -208,10 +246,11 @@ export abstract class Canonical<T> extends Unit {
|
||||
/**
|
||||
* Given the path to a file in the canonical items directory, create a CanonicalDefinition record from that file.
|
||||
* @param filePath
|
||||
* @param basePath
|
||||
* @protected
|
||||
*/
|
||||
protected async buildCanonicalDefinition(filePath: string): Promise<CanonicalDefinition> {
|
||||
const originalName = filePath.replace(this.path.toLocal, '').substr(1)
|
||||
protected async buildCanonicalDefinition(filePath: string, basePath?: UniversalPath): Promise<CanonicalDefinition> {
|
||||
const originalName = filePath.replace((basePath || this.path).toLocal, '').substr(1)
|
||||
const pathRegex = new RegExp(nodePath.sep, 'g')
|
||||
const canonicalName = originalName.replace(pathRegex, ':')
|
||||
.split('')
|
||||
|
@ -3,7 +3,7 @@
|
||||
* @extends Error
|
||||
*/
|
||||
import {Canonical, CanonicalDefinition} from './Canonical'
|
||||
import {Instantiable, isInstantiable} from '../di'
|
||||
import {isInstantiable} from '../di'
|
||||
|
||||
/**
|
||||
* Error thrown when the export of a canonical file is determined to be invalid.
|
||||
@ -17,8 +17,8 @@ export class InvalidCanonicalExportError extends Error {
|
||||
/**
|
||||
* Variant of the Canonical unit whose files export classes which are instantiated using the global container.
|
||||
*/
|
||||
export class CanonicalInstantiable<T> extends Canonical<Instantiable<T>> {
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<Instantiable<T>> {
|
||||
export class CanonicalInstantiable<T> extends Canonical<T> {
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<T> {
|
||||
if ( isInstantiable(definition.imported.default) ) {
|
||||
return this.app().make(definition.imported.default)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
} from './Collection'
|
||||
import {Iterable, StopIteration} from './Iterable'
|
||||
import {applyWhere, WhereOperator} from './where'
|
||||
import {AsyncPipe, Pipe} from '../support/Pipe'
|
||||
type AsyncCollectionComparable<T> = CollectionItem<T>[] | Collection<T> | AsyncCollection<T>
|
||||
type AsyncKeyFunction<T, T2> = (item: CollectionItem<T>, index: number) => CollectionItem<T2> | Promise<CollectionItem<T2>>
|
||||
type AsyncCollectionFunction<T, T2> = (items: AsyncCollection<T>) => T2
|
||||
@ -318,6 +319,24 @@ export class AsyncCollection<T> {
|
||||
return new Collection<T2>(newItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new collection by mapping the items in this collection using the given function,
|
||||
* excluding any for which the function resolves undefined.
|
||||
* @param func
|
||||
*/
|
||||
async partialMap<T2>(func: AsyncKeyFunction<T, T2 | undefined>): Promise<Collection<NonNullable<T2>>> {
|
||||
const newItems: CollectionItem<NonNullable<T2>>[] = []
|
||||
|
||||
await this.each(async (item, index) => {
|
||||
const result = await func(item, index)
|
||||
if ( typeof result !== 'undefined' ) {
|
||||
newItems.push(result as NonNullable<T2>)
|
||||
}
|
||||
})
|
||||
|
||||
return new Collection<NonNullable<T2>>(newItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given operator returns true for every item in the collection.
|
||||
* @param {AsyncKeyFunction} func
|
||||
@ -783,10 +802,24 @@ export class AsyncCollection<T> {
|
||||
* Return the value of the function, passing this collection to it.
|
||||
* @param {AsyncCollectionFunction} func
|
||||
*/
|
||||
pipe<T2>(func: AsyncCollectionFunction<T, T2>): any {
|
||||
pipeTo<T2>(func: AsyncCollectionFunction<T, T2>): any {
|
||||
return func(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new Pipe of this collection.
|
||||
*/
|
||||
pipe(): Pipe<AsyncCollection<T>> {
|
||||
return Pipe.wrap(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new AsyncPipe of this collection.
|
||||
*/
|
||||
asyncPipe(): AsyncPipe<AsyncCollection<T>> {
|
||||
return AsyncPipe.wrap(this)
|
||||
}
|
||||
|
||||
/* async pop(): Promise<MaybeCollectionItem<T>> {
|
||||
const nextItem = await this.storedItems.next()
|
||||
if ( !nextItem.done ) {
|
||||
|
@ -1,11 +1,11 @@
|
||||
import {Pipe} from '../support/Pipe'
|
||||
import {AsyncPipe, Pipe} from '../support/Pipe'
|
||||
|
||||
type CollectionItem<T> = T
|
||||
type MaybeCollectionItem<T> = CollectionItem<T> | undefined
|
||||
type KeyFunction<T, T2> = (item: CollectionItem<T>, index: number) => CollectionItem<T2>
|
||||
type KeyReducerFunction<T, T2> = (current: any, item: CollectionItem<T>, index: number) => T2
|
||||
type CollectionFunction<T, T2> = (items: Collection<T>) => T2
|
||||
type KeyOperator<T, T2> = string | KeyFunction<T, T2>
|
||||
type KeyOperator<T, T2> = keyof T | KeyFunction<T, T2>
|
||||
type AssociatedCollectionItem<T2, T> = { key: T2, item: CollectionItem<T> }
|
||||
type CollectionComparable<T> = CollectionItem<T>[] | Collection<T>
|
||||
type DeterminesEquality<T> = (item: CollectionItem<T>, other: any) => boolean
|
||||
@ -313,6 +313,24 @@ class Collection<T> {
|
||||
return new Collection<T2>(newItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new collection by mapping the items in this collection using the given function,
|
||||
* excluding any for which the function returns undefined.
|
||||
* @param func
|
||||
*/
|
||||
partialMap<T2>(func: KeyFunction<T, T2 | undefined>): Collection<NonNullable<T2>> {
|
||||
const newItems: CollectionItem<NonNullable<T2>>[] = []
|
||||
|
||||
this.each(((item, index) => {
|
||||
const result = func(item, index)
|
||||
if ( typeof result !== 'undefined' ) {
|
||||
newItems.push(result as NonNullable<T2>)
|
||||
}
|
||||
}))
|
||||
|
||||
return new Collection<NonNullable<T2>>(newItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this collection to an object keyed by the given field.
|
||||
*
|
||||
@ -354,10 +372,10 @@ class Collection<T> {
|
||||
this.allAssociated(key).forEach(assoc => {
|
||||
i += 1
|
||||
|
||||
if ( typeof value === 'string' ) {
|
||||
obj[assoc.key] = (assoc.item as any)[value]
|
||||
} else {
|
||||
if ( typeof value === 'function' ) {
|
||||
obj[assoc.key] = value(assoc.item, i)
|
||||
} else {
|
||||
obj[assoc.key] = (assoc.item[value] as any) as T2
|
||||
}
|
||||
})
|
||||
|
||||
@ -805,6 +823,13 @@ class Collection<T> {
|
||||
return Pipe.wrap(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new AsyncPipe of this collection.
|
||||
*/
|
||||
asyncPipe(): AsyncPipe<Collection<T>> {
|
||||
return AsyncPipe.wrap(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last item from this collection.
|
||||
*/
|
||||
|
@ -13,6 +13,7 @@ export * from './error/ErrorWithContext'
|
||||
|
||||
export * from './logging/Logger'
|
||||
export * from './logging/StandardLogger'
|
||||
export * from './logging/FileLogger'
|
||||
export * from './logging/types'
|
||||
|
||||
export * from './support/BehaviorSubject'
|
||||
|
44
src/util/logging/FileLogger.ts
Normal file
44
src/util/logging/FileLogger.ts
Normal file
@ -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')
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import {LoggingLevel, LogMessage} from './types'
|
||||
import * as color from 'colors/safe'
|
||||
import {Awaitable} from '../support/types'
|
||||
|
||||
/**
|
||||
* Base class for an application logger.
|
||||
@ -10,7 +11,7 @@ export abstract class Logger {
|
||||
* @param {LogMessage} message
|
||||
* @return Promise<void>
|
||||
*/
|
||||
public abstract write(message: LogMessage): Promise<void> | void;
|
||||
public abstract write(message: LogMessage): Awaitable<void>;
|
||||
|
||||
/**
|
||||
* Format the date object to the string output format.
|
||||
|
@ -1,12 +1,14 @@
|
||||
/**
|
||||
* A closure that maps a given pipe item to a different type.
|
||||
*/
|
||||
import {Awaitable} from './types'
|
||||
|
||||
export type PipeOperator<T, T2> = (subject: T) => T2
|
||||
|
||||
/**
|
||||
* A closure that maps a given pipe item to an item of the same type.
|
||||
*/
|
||||
export type ReflexivePipeOperator<T> = (subject: T) => T
|
||||
export type ReflexivePipeOperator<T> = (subject: T) => T|void
|
||||
|
||||
/**
|
||||
* A condition or condition-resolving function for pipe methods.
|
||||
@ -77,6 +79,15 @@ export class Pipe<T> {
|
||||
return new Pipe(op(this.subject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Like tap, but always returns the original pipe.
|
||||
* @param op
|
||||
*/
|
||||
peek<T2>(op: PipeOperator<T, T2>): this {
|
||||
op(this.subject)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* If `check` is truthy, apply the given operator to the item in the pipe and return the result.
|
||||
* Otherwise, just return the current pipe unchanged.
|
||||
@ -86,7 +97,7 @@ export class Pipe<T> {
|
||||
*/
|
||||
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
||||
return Pipe.wrap(op(this.subject))
|
||||
Pipe.wrap(op(this.subject))
|
||||
}
|
||||
|
||||
return this
|
||||
@ -104,7 +115,8 @@ export class Pipe<T> {
|
||||
return this
|
||||
}
|
||||
|
||||
return Pipe.wrap(op(this.subject))
|
||||
Pipe.wrap(op(this.subject))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,4 +139,134 @@ export class Pipe<T> {
|
||||
get(): T {
|
||||
return this.subject
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an AsyncPipe with the current item in the pipe.
|
||||
*/
|
||||
async(): AsyncPipe<T> {
|
||||
return AsyncPipe.wrap<T>(this.subject)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subject function that yields the value in the AsyncPipe.
|
||||
*/
|
||||
export type AsyncPipeResolver<T> = () => Awaitable<T>
|
||||
|
||||
/**
|
||||
* A closure that maps a given pipe item to a different type.
|
||||
*/
|
||||
export type AsyncPipeOperator<T, T2> = (subject: T) => Awaitable<T2>
|
||||
|
||||
/**
|
||||
* A closure that maps a given pipe item to an item of the same type.
|
||||
*/
|
||||
export type ReflexiveAsyncPipeOperator<T> = (subject: T) => Awaitable<T|void>
|
||||
|
||||
/**
|
||||
* A condition or condition-resolving function for pipe methods.
|
||||
*/
|
||||
export type AsyncPipeCondition<T> = boolean | ((subject: T) => Awaitable<boolean>)
|
||||
|
||||
/**
|
||||
* An asynchronous version of the Pipe helper.
|
||||
*/
|
||||
export class AsyncPipe<T> {
|
||||
/**
|
||||
* Get an AsyncPipe with the given value in it.
|
||||
* @param subject
|
||||
*/
|
||||
static wrap<subjectType>(subject: subjectType): AsyncPipe<subjectType> {
|
||||
return new AsyncPipe<subjectType>(() => subject)
|
||||
}
|
||||
|
||||
constructor(
|
||||
/** The current value resolver of the pipe. */
|
||||
private subject: AsyncPipeResolver<T>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Apply a transformative operator to the pipe.
|
||||
* @param op
|
||||
*/
|
||||
tap<T2>(op: AsyncPipeOperator<T, T2>): AsyncPipe<T2> {
|
||||
return new AsyncPipe<T2>(async () => op(await this.subject()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operator to the pipe, but return the reference
|
||||
* to the current pipe. The operator is resolved when the
|
||||
* overall pipe is resolved.
|
||||
* @param op
|
||||
*/
|
||||
peek<T2>(op: AsyncPipeOperator<T, T2>): AsyncPipe<T> {
|
||||
return new AsyncPipe<T>(async () => {
|
||||
const subject = await this.subject()
|
||||
await op(subject)
|
||||
return subject
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operator to the pipe, if the check condition passes.
|
||||
* @param check
|
||||
* @param op
|
||||
*/
|
||||
when(check: AsyncPipeCondition<T>, op: ReflexiveAsyncPipeOperator<T>): AsyncPipe<T> {
|
||||
return new AsyncPipe<T>(async () => {
|
||||
let subject
|
||||
|
||||
if ( typeof check === 'function' ) {
|
||||
check = await check(subject = await this.subject())
|
||||
}
|
||||
|
||||
subject = subject ?? await this.subject()
|
||||
if ( check ) {
|
||||
return ((await op(subject)) ?? subject) as T
|
||||
}
|
||||
|
||||
return subject as T
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operator to the pipe, if the check condition fails.
|
||||
* @param check
|
||||
* @param op
|
||||
*/
|
||||
unless(check: AsyncPipeCondition<T>, op: ReflexiveAsyncPipeOperator<T>): AsyncPipe<T> {
|
||||
if ( typeof check === 'function' ) {
|
||||
return this.when(async (subject: T) => !(await check(subject)), op)
|
||||
}
|
||||
|
||||
return this.when(!check, op)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `unless()`.
|
||||
* @param check
|
||||
* @param op
|
||||
*/
|
||||
whenNot(check: AsyncPipeCondition<T>, op: ReflexiveAsyncPipeOperator<T>): AsyncPipe<T> {
|
||||
return this.unless(check, op)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transformed value from the pipe.
|
||||
*/
|
||||
async resolve(): Promise<T> {
|
||||
return this.subject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the value and return it in a sync `Pipe` instance.
|
||||
*/
|
||||
async sync(): Promise<Pipe<T>> {
|
||||
return Pipe.wrap<T>(await this.subject())
|
||||
}
|
||||
|
||||
/** Get the transformed value from the pipe. Allows awaiting the pipe directly. */
|
||||
then(): Promise<T> {
|
||||
return this.resolve()
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,28 @@
|
||||
import * as nodeUUID from 'uuid'
|
||||
import {ErrorWithContext} from '../error/ErrorWithContext'
|
||||
import {JSONState} from './Rehydratable'
|
||||
import {KeyValue} from './types'
|
||||
|
||||
/**
|
||||
* Create an array of key-value pairs for the keys in a uniform object.
|
||||
* @param obj
|
||||
*/
|
||||
export function objectToKeyValue<T>(obj: {[key: string]: T}): KeyValue<T>[] {
|
||||
const values: KeyValue<T>[] = []
|
||||
|
||||
for ( const key in obj ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(obj, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
values.push({
|
||||
key,
|
||||
value: obj[key],
|
||||
})
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a deep copy of an object.
|
||||
|
@ -47,3 +47,17 @@ export function padCenter(string: string, length: number, padWith = ' '): string
|
||||
|
||||
return string
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to PascalCase.
|
||||
* @param input
|
||||
*/
|
||||
export function stringToPascal(input: string): string {
|
||||
return input.split(/[\s_]+/i)
|
||||
.map(part => {
|
||||
return part[0].toUpperCase() + part.substr(1)
|
||||
})
|
||||
.join('')
|
||||
.split(/\W+/i)
|
||||
.join('')
|
||||
}
|
||||
|
@ -3,3 +3,9 @@ export type Awaitable<T> = T | Promise<T>
|
||||
|
||||
/** Type alias for something that may be undefined. */
|
||||
export type Maybe<T> = T | undefined
|
||||
|
||||
/** Type alias for a callback that accepts a typed argument. */
|
||||
export type ParameterizedCallback<T> = ((arg: T) => any)
|
||||
|
||||
/** A key-value form of a given type. */
|
||||
export type KeyValue<T> = {key: string, value: T}
|
||||
|
Loading…
Reference in New Issue
Block a user