Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
26e0444e40
|
|||
|
fcce28081b
|
|||
|
e86cf420df
|
|||
|
e33d8dee8f
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
|
||||
24
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
24
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
|
||||
export interface BasicLoginCredentials {
|
||||
username: string,
|
||||
password: string,
|
||||
}
|
||||
|
||||
@Singleton()
|
||||
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
username: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
password: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,5 @@ export * from './middleware/SessionAuthMiddleware'
|
||||
export * from './Authentication'
|
||||
|
||||
export * from './config'
|
||||
|
||||
export * from './basic-ui/BasicLoginFormRequest'
|
||||
|
||||
@@ -5,15 +5,30 @@ import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
import {HTTPStatus} from '../../util'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Session} from '../../http/session/Session'
|
||||
|
||||
@Injectable()
|
||||
export class AuthRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( !this.security.hasUser() ) {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
this.session.set('auth.intention', this.request.url)
|
||||
|
||||
if ( this.routing.hasNamedRoute('@auth.login') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
||||
} else {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,24 @@ import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
import {HTTPStatus} from '../../util'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class GuestRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( this.security.hasUser() ) {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
|
||||
} else {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Singleton, StaticClass} from '../di'
|
||||
import {Instantiable, Singleton, StaticClass} from '../di'
|
||||
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
|
||||
import {Awaitable, Collection, uuid4} from '../util'
|
||||
|
||||
@@ -13,7 +13,7 @@ export class EventBus implements Bus {
|
||||
*/
|
||||
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
||||
|
||||
subscribe<T extends Dispatchable>(event: StaticClass<T, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
|
||||
subscribe<T extends Dispatchable>(event: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
|
||||
const entry: EventSubscriberEntry<T> = {
|
||||
id: uuid4(),
|
||||
event,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Awaitable, Rehydratable} from '../util'
|
||||
import {StaticClass} from '../di'
|
||||
import {Instantiable, StaticClass} from '../di'
|
||||
|
||||
/**
|
||||
* A closure that should be executed with the given event is fired.
|
||||
@@ -14,7 +14,7 @@ export interface EventSubscriberEntry<T extends Dispatchable> {
|
||||
id: string
|
||||
|
||||
/** The event class subscribed to. */
|
||||
event: StaticClass<T, T>
|
||||
event: StaticClass<T, Instantiable<T>>
|
||||
|
||||
/** The closure to execute when the event is fired. */
|
||||
subscriber: EventSubscriber<T>
|
||||
@@ -41,7 +41,7 @@ export interface Dispatchable extends Rehydratable {
|
||||
* An event-driven bus that manages subscribers and dispatched items.
|
||||
*/
|
||||
export interface Bus {
|
||||
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
||||
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
|
||||
dispatch(event: Dispatchable): Awaitable<void>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Logging} from '../../service/Logging'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {error} from '../response/ErrorResponseFactory'
|
||||
import {HTTPError} from '../HTTPError'
|
||||
|
||||
/**
|
||||
* Interface for fluently registering kernel modules into the kernel.
|
||||
@@ -105,7 +106,8 @@ export class HTTPKernel extends AppClass {
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logging.error(e)
|
||||
await error(e).status(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
const status = (e instanceof HTTPError && e.status) ? e.status : HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
await error(e).status(status)
|
||||
.write(request)
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
|
||||
})
|
||||
|
||||
busboy.on('finish', () => {
|
||||
this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`)
|
||||
res()
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule {
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
if ( !this.config.get('server.poweredBy.hide', false) ) {
|
||||
request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo'))
|
||||
request.response.setHeader('Server', this.config.get('server.poweredBy.header', 'Extollo'))
|
||||
}
|
||||
|
||||
return request
|
||||
|
||||
@@ -229,6 +229,9 @@ export class Response {
|
||||
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
||||
}
|
||||
|
||||
this.setHeader('Date', (new Date()).toUTCString())
|
||||
this.setHeader('Permissions-Policy', 'interest-cohort=()')
|
||||
|
||||
await this.write(this.body ?? '')
|
||||
this.end()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ResponseFactory} from './ResponseFactory'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ErrorWithContext, UniversalPath} from '../../util'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
/**
|
||||
* Helper function that creates a FileResponseFactory for the given path.
|
||||
@@ -28,6 +29,7 @@ export class FileResponseFactory extends ResponseFactory {
|
||||
})
|
||||
}
|
||||
|
||||
request.make<Logging>(Logging).debug(`Setting Content-Type of ${this.path} to ${this.path.contentType}...`)
|
||||
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
|
||||
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
|
||||
request.response.body = await this.path.readStream()
|
||||
|
||||
31
src/http/response/RedirectResponseFactory.ts
Normal file
31
src/http/response/RedirectResponseFactory.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {ResponseFactory} from './ResponseFactory'
|
||||
import {HTTPStatus} from '../../util'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
|
||||
/**
|
||||
* Helper function to create a new RedirectResponseFactory to the given destination.
|
||||
* @param destination
|
||||
*/
|
||||
export function redirect(destination: string): RedirectResponseFactory {
|
||||
return new RedirectResponseFactory(destination)
|
||||
}
|
||||
|
||||
/**
|
||||
* Response factory that sends an HTTP redirect to the given destination.
|
||||
*/
|
||||
export class RedirectResponseFactory extends ResponseFactory {
|
||||
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
|
||||
|
||||
constructor(
|
||||
/** THe URL where the client should redirect to. */
|
||||
public readonly destination: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public async write(request: Request): Promise<Request> {
|
||||
request = await super.write(request)
|
||||
request.response.setHeader('Location', this.destination)
|
||||
return request
|
||||
}
|
||||
}
|
||||
40
src/http/response/RouteResponseFactory.ts
Normal file
40
src/http/response/RouteResponseFactory.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {ResponseFactory} from './ResponseFactory'
|
||||
import {HTTPStatus} from '../../util'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {Routing} from '../../service/Routing'
|
||||
|
||||
/**
|
||||
* Helper function to create a new RouteResponseFactory to the given destination.
|
||||
* @param nameOrPath
|
||||
*/
|
||||
export function route(nameOrPath: string): RouteResponseFactory {
|
||||
return new RouteResponseFactory(nameOrPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Response factory that sends an HTTP redirect to the given destination.
|
||||
*/
|
||||
export class RouteResponseFactory extends ResponseFactory {
|
||||
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
|
||||
|
||||
constructor(
|
||||
/** The alias or path of the route to redirect to. */
|
||||
public readonly nameOrPath: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public async write(request: Request): Promise<Request> {
|
||||
const routing = <Routing> request.make(Routing)
|
||||
request = await super.write(request)
|
||||
|
||||
try {
|
||||
const routePath = routing.getNamedPath(this.nameOrPath)
|
||||
request.response.setHeader('Location', routePath.toRemote)
|
||||
} catch (e: unknown) {
|
||||
request.response.setHeader('Location', routing.getAppUrl().concat(this.nameOrPath).toRemote)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {Request} from '../lifecycle/Request'
|
||||
* Helper function to create a new TemporaryRedirectResponseFactory to the given destination.
|
||||
* @param destination
|
||||
*/
|
||||
export function redirect(destination: string): TemporaryRedirectResponseFactory {
|
||||
export function temporary(destination: string): TemporaryRedirectResponseFactory {
|
||||
return new TemporaryRedirectResponseFactory(destination)
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,9 @@ export class Route extends AppClass {
|
||||
/** Pre-compiled route handler for the main route handler for this route. */
|
||||
protected compiledPostflight?: ResolvedRouteHandler[]
|
||||
|
||||
/** Programmatic aliases of this route. */
|
||||
public aliases: string[] = []
|
||||
|
||||
constructor(
|
||||
/** The HTTP method(s) that this route listens on. */
|
||||
protected method: HTTPMethod | HTTPMethod[],
|
||||
@@ -228,6 +231,15 @@ export class Route extends AppClass {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a programmatic name for this route.
|
||||
* @param name
|
||||
*/
|
||||
public alias(name: string): this {
|
||||
this.aliases.push(name)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string-form of the route.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util'
|
||||
import {Application} from '../../lifecycle/Application'
|
||||
import {HTTPError} from '../HTTPError'
|
||||
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
|
||||
import {redirect} from '../response/TemporaryRedirectResponseFactory'
|
||||
import {redirect} from '../response/RedirectResponseFactory'
|
||||
import {file} from '../response/FileResponseFactory'
|
||||
import {RouteHandler} from '../routing/Route'
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface StaticServerOptions {
|
||||
|
||||
/** If specified, files with these extensions will not be served. */
|
||||
excludedExtensions?: string[]
|
||||
|
||||
/** If a file with this name exists in a directory, it will be served. */
|
||||
indexFile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +159,22 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler {
|
||||
|
||||
// If the resolved path is a directory, send the directory listing response
|
||||
if ( await filePath.isDirectory() ) {
|
||||
if ( options.indexFile ) {
|
||||
const indexFile = filePath.concat(options.indexFile)
|
||||
if ( await indexFile.exists() ) {
|
||||
return file(indexFile)
|
||||
}
|
||||
}
|
||||
|
||||
if ( !options.directoryListing ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
reason: 'Path is a directory, and directory listing is disabled',
|
||||
})
|
||||
}
|
||||
|
||||
if ( !route.path.endsWith('/') ) {
|
||||
return redirect(`${route.path}/`)
|
||||
}
|
||||
|
||||
@@ -93,4 +93,12 @@ export class MemorySession extends Session {
|
||||
|
||||
this.data[key] = value
|
||||
}
|
||||
|
||||
public forget(key: string): void {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
delete this.data[key]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +57,7 @@ export abstract class Session {
|
||||
|
||||
/** Set a value in the session by key. */
|
||||
public abstract set(key: string, value: unknown): void
|
||||
|
||||
/** Remove a key from the session data. */
|
||||
public abstract forget(key: string): void
|
||||
}
|
||||
|
||||
@@ -42,8 +42,10 @@ export * from './http/response/JSONResponseFactory'
|
||||
export * from './http/response/ResponseFactory'
|
||||
export * from './http/response/StringResponseFactory'
|
||||
export * from './http/response/TemporaryRedirectResponseFactory'
|
||||
export * from './http/response/RedirectResponseFactory'
|
||||
export * from './http/response/ViewResponseFactory'
|
||||
export * from './http/response/FileResponseFactory'
|
||||
export * from './http/response/RouteResponseFactory'
|
||||
|
||||
export * from './http/routing/ActivatedRoute'
|
||||
export * from './http/routing/Route'
|
||||
|
||||
@@ -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 {}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ModelKey, QueryRow, QuerySource} from '../types'
|
||||
import {Container, Inject, StaticClass} from '../../di'
|
||||
import {Container, Inject, Instantiable, StaticClass} from '../../di'
|
||||
import {DatabaseService} from '../DatabaseService'
|
||||
import {ModelBuilder} from './ModelBuilder'
|
||||
import {getFieldsMeta, ModelField} from './Field'
|
||||
@@ -804,7 +804,7 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
(this as any)[thisFieldName] = object[objectFieldName]
|
||||
}
|
||||
|
||||
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, EventT>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
|
||||
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, Instantiable<EventT>>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
|
||||
const entry: EventSubscriberEntry<EventT> = {
|
||||
id: uuid4(),
|
||||
event,
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export class ORMSession extends Session {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
return this.data[key] ?? fallback
|
||||
}
|
||||
|
||||
@@ -75,6 +76,15 @@ export class ORMSession extends Session {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
this.data[key] = value
|
||||
}
|
||||
|
||||
public forget(key: string): void {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
delete this.data[key]
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
0
src/resources/assets/.gitkeep
Normal file
0
src/resources/assets/.gitkeep
Normal file
116
src/resources/assets/auth/theme.css
Normal file
116
src/resources/assets/auth/theme.css
Normal file
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
--input-padding-x: 1.5rem;
|
||||
--input-padding-y: 0.75rem;
|
||||
}
|
||||
|
||||
.login,
|
||||
.image {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-heading {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.05rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label-group>input,
|
||||
.form-label-group>label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
height: auto;
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group>label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
/* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text;
|
||||
/* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown)~label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-error-message {
|
||||
color: darkred;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-submit-button {
|
||||
margin-top: 50px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group>label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
|
||||
@media all and (-ms-high-contrast: none),
|
||||
(-ms-high-contrast: active) {
|
||||
.form-label-group>label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
7
src/resources/assets/lib/bootstrap.min.css
vendored
Normal file
7
src/resources/assets/lib/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
src/resources/assets/lib/bootstrap.min.js
vendored
Normal file
7
src/resources/assets/lib/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -4,19 +4,21 @@ block head
|
||||
title Login | #{config('app.name', 'Extollo')}
|
||||
|
||||
block heading
|
||||
| Login to Continue
|
||||
| Login to continue
|
||||
|
||||
block form
|
||||
.form-label-group
|
||||
input#inputUsername.form-control(type='text' name='username' value=(form_data ? form_data.username : '') required placeholder='Username' autofocus)
|
||||
input#inputUsername.form-control(type='text' name='username' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||
label(for='inputUsername') Username
|
||||
|
||||
.form-label-group
|
||||
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
|
||||
label(for='inputPassword') Password
|
||||
|
||||
|
||||
|
||||
button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login
|
||||
|
||||
.text-center
|
||||
span.small Need an account?
|
||||
a(href='./register') Register here.
|
||||
// .text-center
|
||||
span.small(style="color: #999999;") Provider: #{provider_name}
|
||||
|
||||
12
src/resources/views/auth/message.pug
Normal file
12
src/resources/views/auth/message.pug
Normal file
@@ -0,0 +1,12 @@
|
||||
extends ./theme
|
||||
|
||||
block content
|
||||
if heading
|
||||
h3.login-heading.mb-4 #{heading}
|
||||
|
||||
if errors
|
||||
each error in errors
|
||||
p.form-error-message #{error}
|
||||
|
||||
if message
|
||||
p #{message}
|
||||
@@ -5,18 +5,17 @@ html
|
||||
block head
|
||||
|
||||
block styles
|
||||
link(rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
|
||||
link(rel='stylesheet' href=vendor('@extollo', 'auth/theme.css'))
|
||||
link(rel='stylesheet' href=vendor('@extollo/lib', 'lib/bootstrap.min.css'))
|
||||
link(rel='stylesheet' href=vendor('@extollo/lib', 'auth/theme.css'))
|
||||
body
|
||||
.container-fluid
|
||||
.row.no-gutter
|
||||
.d-none.d-md-flex.col-md-6.col-lg-8.bg-image
|
||||
.col-md-6.col-lg-4
|
||||
.col-md-12.col-lg-12
|
||||
.login.d-flex.align-items-center.py-5
|
||||
.container
|
||||
.row
|
||||
.col-md-9.col-lg-8.mx-auto
|
||||
.col-md-9.col-lg-6.mx-auto
|
||||
block content
|
||||
|
||||
block scripts
|
||||
script(src='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
|
||||
script(src=vendor('@extollo/lib', 'lib/bootstrap.min.js'))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {ExecuteResolvedRoutePostflightHTTPModule} from '../http/kernel/module/Ex
|
||||
import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBodyHTTPModule'
|
||||
import {Config} from './Config'
|
||||
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
|
||||
import {Routing} from './Routing'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
|
||||
/**
|
||||
* Application unit that starts the HTTP/S server, creates Request and Response objects
|
||||
@@ -33,6 +35,12 @@ export class HTTPServer extends Unit {
|
||||
@Inject()
|
||||
protected readonly kernel!: HTTPKernel
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
/** The underlying native Node.js server. */
|
||||
protected server?: Server
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Singleton, Inject} from '../di'
|
||||
import {UniversalPath, Collection, Pipe, universalPath} from '../util'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {Inject, Singleton} from '../di'
|
||||
import {Awaitable, Collection, ErrorWithContext, Maybe, Pipe, universalPath, UniversalPath} from '../util'
|
||||
import {Unit, UnitStatus} from '../lifecycle/Unit'
|
||||
import {Logging} from './Logging'
|
||||
import {Route} from '../http/routing/Route'
|
||||
import {HTTPMethod} from '../http/lifecycle/Request'
|
||||
@@ -8,6 +8,9 @@ import {ViewEngineFactory} from '../views/ViewEngineFactory'
|
||||
import {ViewEngine} from '../views/ViewEngine'
|
||||
import {lib} from '../lib'
|
||||
import {Config} from './Config'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {PackageDiscovered} from '../support/PackageDiscovered'
|
||||
import {staticServer} from '../http/servers/static'
|
||||
|
||||
/**
|
||||
* Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers.
|
||||
@@ -20,6 +23,9 @@ export class Routing extends Unit {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
protected compiledRoutes: Collection<Route> = new Collection<Route>()
|
||||
|
||||
public async up(): Promise<void> {
|
||||
@@ -38,6 +44,8 @@ export class Routing extends Unit {
|
||||
await import(entry)
|
||||
}
|
||||
|
||||
await this.registerBuiltIns()
|
||||
|
||||
this.logging.info('Compiling routes...')
|
||||
this.compiledRoutes = new Collection<Route>(await Route.compile())
|
||||
|
||||
@@ -45,6 +53,44 @@ export class Routing extends Unit {
|
||||
this.compiledRoutes.each(route => {
|
||||
this.logging.verbose(`${route}`)
|
||||
})
|
||||
|
||||
this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => {
|
||||
const loadFrom = event.packageConfig?.extollo?.routes?.loadFrom
|
||||
if ( Array.isArray(loadFrom) ) {
|
||||
for ( const path of loadFrom ) {
|
||||
const loadFile = event.packageJson.concat('..', path)
|
||||
this.logging.debug(`Loading routes for package ${event.packageConfig.name} from ${loadFile}...`)
|
||||
await import(loadFile.toLocal)
|
||||
}
|
||||
|
||||
await this.recompile()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback that registers routes and recompiles the routing stack.
|
||||
* @param callback
|
||||
*/
|
||||
public async registerRoutes(callback: () => Awaitable<void>): Promise<void> {
|
||||
await callback()
|
||||
|
||||
if ( this.status === UnitStatus.Started ) {
|
||||
await this.recompile()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompile registered routes into resolved handler stacks.
|
||||
*/
|
||||
public async recompile(): Promise<void> {
|
||||
this.logging.debug('Recompiling routes...')
|
||||
this.compiledRoutes = this.compiledRoutes.concat(new Collection<Route>(await Route.compile()))
|
||||
|
||||
this.logging.debug(`Re-compiled ${this.compiledRoutes.length} route(s).`)
|
||||
this.compiledRoutes.each(route => {
|
||||
this.logging.verbose(`${route}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,13 +136,33 @@ export class Routing extends Unit {
|
||||
}
|
||||
|
||||
public getVendorPath(namespace: string, ...parts: string[]): UniversalPath {
|
||||
return this.getVendorBase().concat(encodeURIComponent(namespace), ...parts)
|
||||
return this.getVendorBase().concat(namespace, ...parts)
|
||||
}
|
||||
|
||||
public getVendorBase(): UniversalPath {
|
||||
return this.getAppUrl().concat(this.config.get('server.builtIns.vendor.prefix', '/vendor'))
|
||||
}
|
||||
|
||||
public getNamedPath(name: string): UniversalPath {
|
||||
const route = this.getByName(name)
|
||||
if ( route ) {
|
||||
return this.getAppUrl().concat(route.getRoute())
|
||||
}
|
||||
|
||||
throw new ErrorWithContext(`Route does not exist with name: ${name}`, {
|
||||
routeName: name,
|
||||
})
|
||||
}
|
||||
|
||||
public hasNamedRoute(name: string): boolean {
|
||||
return Boolean(this.getByName(name))
|
||||
}
|
||||
|
||||
public getByName(name: string): Maybe<Route> {
|
||||
return this.compiledRoutes
|
||||
.firstWhere(route => route.aliases.includes(name))
|
||||
}
|
||||
|
||||
public getAppUrl(): UniversalPath {
|
||||
const rawHost = String(this.config.get('server.url', 'http://localhost')).toLowerCase()
|
||||
const isSSL = rawHost.startsWith('https://')
|
||||
@@ -123,4 +189,58 @@ export class Routing extends Unit {
|
||||
.tap<UniversalPath>(host => universalPath(host))
|
||||
.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an asset directory for the given package.
|
||||
* This creates a static server route for the package with the
|
||||
* configured vendor prefix.
|
||||
* @param packageName
|
||||
* @param basePath
|
||||
*/
|
||||
public async registerVendorAssets(packageName: string, basePath: UniversalPath): Promise<void> {
|
||||
if ( this.config.get('server.builtIns.vendor.enabled', true) ) {
|
||||
this.logging.debug(`Registering vendor assets route for package ${packageName} on ${basePath}...`)
|
||||
await this.registerRoutes(() => {
|
||||
const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor')
|
||||
Route.group(prefix, () => {
|
||||
Route.group(packageName, () => {
|
||||
Route.get('/**', staticServer({
|
||||
basePath,
|
||||
directoryListing: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Register built-in servers and routes. */
|
||||
protected async registerBuiltIns(): Promise<void> {
|
||||
const extolloAssets = universalPath(__dirname, '..', 'resources', 'assets')
|
||||
await this.registerVendorAssets('@extollo/lib', extolloAssets)
|
||||
|
||||
this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => {
|
||||
if ( event.packageConfig?.extollo?.assets?.discover && event.packageConfig.name ) {
|
||||
this.logging.debug(`Registering vendor assets for discovered package: ${event.packageConfig.name}`)
|
||||
const basePath = event.packageConfig?.extollo?.assets?.basePath
|
||||
if ( basePath && Array.isArray(basePath) ) {
|
||||
const assetPath = event.packageJson.concat('..', ...basePath)
|
||||
await this.registerVendorAssets(event.packageConfig.name, assetPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if ( this.config.get('server.builtIns.assets.enabled', true) ) {
|
||||
const prefix = this.config.get('server.builtIns.assets.prefix', '/assets')
|
||||
this.logging.debug(`Registering built-in assets server with prefix: ${prefix}`)
|
||||
await this.registerRoutes(() => {
|
||||
Route.group(prefix, () => {
|
||||
Route.get('/**', staticServer({
|
||||
directoryListing: false,
|
||||
basePath: ['resources', 'assets'],
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface ExtolloPackageDiscoveryConfig {
|
||||
recursiveDependencies?: {
|
||||
discover?: boolean,
|
||||
},
|
||||
routes?: {
|
||||
loadFrom?: string[],
|
||||
},
|
||||
assets?: {
|
||||
discover?: boolean,
|
||||
basePath?: string[],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -497,14 +497,14 @@ export class UniversalPath {
|
||||
* Get the mime-type of this resource.
|
||||
*/
|
||||
get mimeType(): string | false {
|
||||
return mime.lookup(this.resourceLocalPath)
|
||||
return mime.lookup(this.toBase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content-type header of this resource.
|
||||
*/
|
||||
get contentType(): string | false {
|
||||
return mime.contentType(this.resourceLocalPath)
|
||||
return mime.contentType(this.toBase)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -61,6 +61,9 @@ export abstract class ViewEngine extends AppClass {
|
||||
config: (key: string, fallback?: any) => this.config.get(key, fallback),
|
||||
asset: (...parts: string[]) => this.routing.getAssetPath(...parts).toRemote,
|
||||
vendor: (namespace: string, ...parts: string[]) => this.routing.getVendorPath(namespace, ...parts).toRemote,
|
||||
named: (name: string) => this.routing.getNamedPath(name).toRemote,
|
||||
route: (...parts: string[]) => this.routing.getAppUrl().concat(...parts).toRemote,
|
||||
hasRoute: (name: string) => this.routing.hasNamedRoute(name),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user