Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1ea489ccb | |||
| c3f2779650 | |||
| 248b24e612 | |||
| b4a9057e2b | |||
| c078d695a8 | |||
| 55ffadc742 | |||
| 56574d43ce | |||
| e16f02ce12 | |||
| c34fad3502 | |||
| 156006053b | |||
| 22cf6aa953 | |||
| b35eb8d6a1 | |||
| 9ee4c42e43 | |||
| 8d1dcc87fb | |||
| 3efbfecf9d | |||
| a1d04d652e | |||
| 5940b6e2b3 | |||
|
074a3187eb
|
|||
|
26e0444e40
|
|||
|
fcce28081b
|
|||
|
e86cf420df
|
|||
|
e33d8dee8f
|
15
.drone.yml
15
.drone.yml
@@ -22,7 +22,7 @@ steps:
|
||||
from_secret: docs_deploy_key
|
||||
port: 22
|
||||
source: extollo_api_documentation.tar.gz
|
||||
target: /var/nfs/general/static/sites/extollo
|
||||
target: /var/nfs/storage/static/sites/extollo
|
||||
when:
|
||||
event: promote
|
||||
target: docs
|
||||
@@ -38,7 +38,7 @@ steps:
|
||||
from_secret: docs_deploy_key
|
||||
port: 22
|
||||
script:
|
||||
- cd /var/nfs/general/static/sites/extollo
|
||||
- cd /var/nfs/storage/static/sites/extollo
|
||||
- rm -rf docs
|
||||
- tar xzf extollo_api_documentation.tar.gz
|
||||
- rm -rf extollo_api_documentation.tar.gz
|
||||
@@ -103,10 +103,19 @@ steps:
|
||||
event:
|
||||
exclude: tag
|
||||
|
||||
- name: build module
|
||||
- name: Install dependencies
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm i
|
||||
|
||||
- name: Lint code
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm lint
|
||||
|
||||
- name: build module
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm build
|
||||
- mkdir artifacts
|
||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
||||
|
||||
55
.idea/codeStyles/Project.xml
generated
Normal file
55
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,55 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<editorconfig>
|
||||
<option name="ENABLED" value="false" />
|
||||
</editorconfig>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||
<option name="IF_BRACE_FORCE" value="3" />
|
||||
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||
<option name="FOR_BRACE_FORCE" value="3" />
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="PHP">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
<option name="SMART_TABS" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||
<option name="IF_BRACE_FORCE" value="3" />
|
||||
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||
<option name="FOR_BRACE_FORCE" value="3" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
19
.idea/dataSources.xml
generated
Normal file
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
|
||||
<driver-ref>mongo</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/lib.iml
generated
1
.idea/lib.iml
generated
@@ -4,5 +4,6 @@
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="module" module-name="extollo" />
|
||||
</component>
|
||||
</module>
|
||||
1
.idea/modules.xml
generated
1
.idea/modules.xml
generated
@@ -2,6 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/../app/.idea/extollo.iml" filepath="$PROJECT_DIR$/../app/.idea/extollo.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.6",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@@ -12,6 +12,7 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cli-table": "^0.3.0",
|
||||
"@types/ioredis": "^4.26.6",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
@@ -27,9 +28,11 @@
|
||||
"cli-table": "^0.3.6",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"ioredis": "^4.27.6",
|
||||
"mime-types": "^2.1.31",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.2",
|
||||
"node-fetch": "^3",
|
||||
"pg": "^8.6.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"pug": "^3.0.2",
|
||||
@@ -45,9 +48,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"prebuild": "pnpm run lint && rimraf lib",
|
||||
"build": "tsc",
|
||||
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"app": "tsc && node lib/index.js",
|
||||
"prepare": "pnpm run build",
|
||||
"docs:build": "typedoc --options typedoc.json",
|
||||
|
||||
2387
pnpm-lock.yaml
generated
2387
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {ErrorWithContext} from '../util'
|
||||
|
||||
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Inject, Injectable} from '../di'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {Awaitable, Maybe} from '../util'
|
||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
||||
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||
import {Logging} from '../service/Logging'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
@@ -14,6 +15,9 @@ export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The currently authenticated user, if one exists. */
|
||||
private authenticatedUser?: Authenticatable
|
||||
|
||||
@@ -57,7 +61,7 @@ export abstract class SecurityContext {
|
||||
* unauthenticated implicitly.
|
||||
* @param credentials
|
||||
*/
|
||||
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticateOnce(user)
|
||||
@@ -71,7 +75,7 @@ export abstract class SecurityContext {
|
||||
* authentication will be persisted.
|
||||
* @param credentials
|
||||
*/
|
||||
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticate(user)
|
||||
@@ -108,6 +112,8 @@ export abstract class SecurityContext {
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
const credentials = await this.getCredentials()
|
||||
this.logging.debug('resume:')
|
||||
this.logging.debug(credentials)
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
@@ -125,7 +131,7 @@ export abstract class SecurityContext {
|
||||
* Get the credentials for the current user from whatever storage medium
|
||||
* the context's host provides.
|
||||
*/
|
||||
abstract getCredentials(): Awaitable<Record<string, string>>
|
||||
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user, if one exists.
|
||||
@@ -138,6 +144,8 @@ export abstract class SecurityContext {
|
||||
* Returns true if there is a currently authenticated user.
|
||||
*/
|
||||
hasUser(): boolean {
|
||||
this.logging.debug('hasUser?')
|
||||
this.logging.debug(this.authenticatedUser)
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
|
||||
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Valid, ValidationError} from '../../forms'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class BasicLoginController extends Controller {
|
||||
public static routes({ enableRegistration = true } = {}): void {
|
||||
Route.group('auth', () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login')
|
||||
|
||||
Route.post('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login.attempt')
|
||||
|
||||
Route.any('logout', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogout()
|
||||
})
|
||||
.pre('@auth:required')
|
||||
.alias('@auth.logout')
|
||||
|
||||
if ( enableRegistration ) {
|
||||
Route.get('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getRegistration()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register')
|
||||
|
||||
Route.post('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptRegister()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register.attempt')
|
||||
}
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
public getLogin(): ResponseFactory {
|
||||
return this.getLoginView()
|
||||
}
|
||||
|
||||
public getRegistration(): ResponseFactory {
|
||||
return this.getRegistrationView()
|
||||
}
|
||||
|
||||
public async attemptLogin(): Promise<ResponseObject> {
|
||||
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.attempt(data)
|
||||
if ( user ) {
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
}
|
||||
|
||||
return this.getLoginView(['Invalid username/password.'])
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getLoginView(e.errors.all())
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async attemptLogout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
return this.getMessageView('You have been logged out.')
|
||||
}
|
||||
|
||||
public async attemptRegister(): Promise<ResponseObject> {
|
||||
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.repository.createByCredentials(data)
|
||||
await this.security.authenticate(user)
|
||||
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getRegistrationView(e.errors.all())
|
||||
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
|
||||
return this.getRegistrationView(['A user with that username already exists.'])
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
protected getLoginView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:login', {
|
||||
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:register', {
|
||||
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getMessageView(message: string): ResponseFactory {
|
||||
return view('@extollo:auth:message', {
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
20
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
20
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
Str.alphaNum,
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(8),
|
||||
Str.confirmed,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Instantiable} from '../di'
|
||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||
|
||||
/**
|
||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||
@@ -21,5 +22,8 @@ export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
||||
export interface AuthConfig {
|
||||
repositories: {
|
||||
session: keyof AuthenticatableRepositoryMapping,
|
||||
}
|
||||
},
|
||||
sources?: {
|
||||
[key: string]: OAuth2LoginConfig,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {SecurityContext} from '../SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||
|
||||
/**
|
||||
* Security context implementation that uses the session as storage.
|
||||
@@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
|
||||
super(repository, 'session')
|
||||
}
|
||||
|
||||
getCredentials(): Awaitable<Record<string, string>> {
|
||||
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||
return {
|
||||
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
|
||||
identifier: '',
|
||||
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import {Controller} from '../../../http/Controller'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||
import {ErrorWithContext} from '../../../util'
|
||||
import {OAuth2Repository} from './OAuth2Repository'
|
||||
import {json} from '../../../http/response/JSONResponseFactory'
|
||||
|
||||
export interface OAuth2LoginConfig {
|
||||
name: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUrl: string,
|
||||
authorizationCodeField: string,
|
||||
tokenEndpoint: string,
|
||||
tokenEndpointMapping?: {
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
grantType?: string,
|
||||
codeKey?: string,
|
||||
},
|
||||
tokenEndpointResponseMapping?: {
|
||||
token?: string,
|
||||
expiresIn?: string,
|
||||
expiresAt?: string,
|
||||
},
|
||||
userEndpoint: string,
|
||||
userEndpointResponseMapping?: {
|
||||
identifier?: string,
|
||||
display?: string,
|
||||
},
|
||||
}
|
||||
|
||||
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
|
||||
return (
|
||||
Boolean(what)
|
||||
&& typeof (what as any).name === 'string'
|
||||
&& typeof (what as any).clientId === 'string'
|
||||
&& typeof (what as any).clientSecret === 'string'
|
||||
&& typeof (what as any).redirectUrl === 'string'
|
||||
&& typeof (what as any).authorizationCodeField === 'string'
|
||||
&& typeof (what as any).tokenEndpoint === 'string'
|
||||
&& typeof (what as any).userEndpoint === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2LoginController extends Controller {
|
||||
public static routes(configName: string): void {
|
||||
Route.group(`/auth/${configName}`, () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
|
||||
return controller.getLogin()
|
||||
}).pre('@auth:guest')
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
protected readonly configName: string,
|
||||
) {
|
||||
super(request)
|
||||
}
|
||||
|
||||
public async getLogin(): Promise<ResponseObject> {
|
||||
const repo = this.getRepository()
|
||||
if ( repo.shouldRedirect() ) {
|
||||
return repo.redirect()
|
||||
}
|
||||
|
||||
// We were redirected from the auth source
|
||||
const user = await repo.redeem()
|
||||
return json(user)
|
||||
}
|
||||
|
||||
protected getRepository(): OAuth2Repository {
|
||||
return this.request.make(OAuth2Repository, this.getConfig())
|
||||
}
|
||||
|
||||
protected getConfig(): OAuth2LoginConfig {
|
||||
const config = this.config.get(`auth.sources.${this.configName}`)
|
||||
if ( !isOAuth2LoginConfig(config) ) {
|
||||
throw new ErrorWithContext('Invalid OAuth2 source config.', {
|
||||
configName: this.configName,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
156
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal file
156
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {
|
||||
Awaitable,
|
||||
dataGetUnsafe,
|
||||
fetch,
|
||||
Maybe,
|
||||
MethodNotSupportedError,
|
||||
UniversalPath,
|
||||
universalPath,
|
||||
uuid4,
|
||||
} from '../../../util'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Session} from '../../../http/session/Session'
|
||||
import {ResponseObject} from '../../../http/routing/Route'
|
||||
import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
import {OAuth2User} from './OAuth2User'
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Repository implements AuthenticatableRepository {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
@Inject()
|
||||
protected readonly request!: Request
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
constructor(
|
||||
protected readonly config: OAuth2LoginConfig,
|
||||
) { }
|
||||
|
||||
public createByCredentials(): Awaitable<Authenticatable> {
|
||||
throw new MethodNotSupportedError()
|
||||
}
|
||||
|
||||
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
|
||||
return this.getAuthenticatableFromBearer(credentials.credential)
|
||||
}
|
||||
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
public getRedirectUrl(state?: string): UniversalPath {
|
||||
const url = universalPath(this.config.redirectUrl)
|
||||
if ( state ) {
|
||||
url.query.append('state', state)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
public getTokenEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.tokenEndpoint)
|
||||
}
|
||||
|
||||
public getUserEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.userEndpoint)
|
||||
}
|
||||
|
||||
public async redeem(): Promise<Maybe<OAuth2User>> {
|
||||
if ( !this.stateIsValid() ) {
|
||||
return // FIXME throw
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
|
||||
if ( this.config.tokenEndpointMapping ) {
|
||||
if ( this.config.tokenEndpointMapping.clientId ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.clientSecret ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.codeKey ) {
|
||||
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.grantType ) {
|
||||
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
|
||||
}
|
||||
}
|
||||
|
||||
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
|
||||
|
||||
const response = await fetch(this.getTokenEndpoint().toRemote, {
|
||||
method: 'post',
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
this.logging.debug(data)
|
||||
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
|
||||
|
||||
this.logging.debug(bearer)
|
||||
if ( !bearer || typeof bearer !== 'string' ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return this.getAuthenticatableFromBearer(bearer)
|
||||
}
|
||||
|
||||
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
|
||||
const response = await fetch(this.getUserEndpoint().toRemote, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${bearer}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return new OAuth2User(data, this.config)
|
||||
}
|
||||
|
||||
public stateIsValid(): boolean {
|
||||
const correctState = this.session.get('extollo.auth.oauth2.state', '')
|
||||
const inputState = this.request.input('state') || ''
|
||||
return correctState === inputState
|
||||
}
|
||||
|
||||
public shouldRedirect(): boolean {
|
||||
const codeField = this.config.authorizationCodeField
|
||||
const code = this.request.input(codeField)
|
||||
return !code
|
||||
}
|
||||
|
||||
public async redirect(): Promise<ResponseObject> {
|
||||
const state = uuid4()
|
||||
await this.session.set('extollo.auth.oauth2.state', state)
|
||||
return temporary(this.getRedirectUrl(state).toRemote)
|
||||
}
|
||||
}
|
||||
50
src/auth/external/oauth2/OAuth2User.ts
vendored
Normal file
50
src/auth/external/oauth2/OAuth2User.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
|
||||
|
||||
export class OAuth2User implements Authenticatable {
|
||||
protected displayField: string
|
||||
|
||||
protected identifierField: string
|
||||
|
||||
constructor(
|
||||
protected data: {[key: string]: any},
|
||||
config: OAuth2LoginConfig,
|
||||
) {
|
||||
this.displayField = config.userEndpointResponseMapping?.display || 'name'
|
||||
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
|
||||
}
|
||||
|
||||
getDisplayIdentifier(): string {
|
||||
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
|
||||
}
|
||||
|
||||
getIdentifier(): AuthenticatableIdentifier {
|
||||
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
isOAuth2User: true,
|
||||
data: this.data,
|
||||
displayField: this.displayField,
|
||||
identifierField: this.identifierField,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> {
|
||||
if (
|
||||
!state.isOAuth2User
|
||||
|| typeof state.data !== 'object'
|
||||
|| state.data === null
|
||||
|| typeof state.displayField !== 'string'
|
||||
|| typeof state.identifierField !== 'string'
|
||||
) {
|
||||
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
|
||||
}
|
||||
|
||||
this.data = state.data
|
||||
this.identifierField = state.identifierField
|
||||
this.displayField = state.identifierField
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,8 @@ export * from './middleware/SessionAuthMiddleware'
|
||||
export * from './Authentication'
|
||||
|
||||
export * from './config'
|
||||
|
||||
export * from './basic-ui/BasicLoginFormRequest'
|
||||
export * from './basic-ui/BasicLoginController'
|
||||
|
||||
export * from './external/oauth2/OAuth2LoginController'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
|
||||
/** The user's first name. */
|
||||
@Field(FieldType.varchar, 'first_name')
|
||||
public firstName!: string
|
||||
public firstName?: string
|
||||
|
||||
/** The user's last name. */
|
||||
@Field(FieldType.varchar, 'last_name')
|
||||
public lastName!: string
|
||||
public lastName?: string
|
||||
|
||||
/** The hashed and salted password of the user. */
|
||||
@Field(FieldType.varchar, 'password_hash')
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../types'
|
||||
import {Awaitable, Maybe} from '../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Injectable} from '../../di'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return ORMUser.query<ORMUser>()
|
||||
@@ -21,21 +30,36 @@ export class ORMUserRepository extends AuthenticatableRepository {
|
||||
* If username/password are specified, look up the user and verify the password.
|
||||
* @param credentials
|
||||
*/
|
||||
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
if ( credentials.securityIdentifier ) {
|
||||
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
if ( !credentials.identifier && credentials.credential ) {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.securityIdentifier)
|
||||
.where('username', '=', credentials.credential)
|
||||
.first()
|
||||
}
|
||||
|
||||
if ( credentials.username && credentials.password ) {
|
||||
if ( credentials.identifier && credentials.credential ) {
|
||||
const user = await ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.username)
|
||||
.where('username', '=', credentials.identifier)
|
||||
.first()
|
||||
|
||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
||||
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||
if ( await this.getByCredentials(credentials) ) {
|
||||
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||
identifier: credentials.identifier,
|
||||
})
|
||||
}
|
||||
|
||||
const user = <ORMUser> this.injector.make(ORMUser)
|
||||
user.username = credentials.identifier
|
||||
await user.setPassword(credentials.credential)
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||
/** Value that can be used to uniquely identify a user. */
|
||||
export type AuthenticatableIdentifier = string | number
|
||||
|
||||
export interface AuthenticatableCredentials {
|
||||
identifier: string,
|
||||
credential: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for entities that can be authenticated.
|
||||
*/
|
||||
@@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
|
||||
* Returns the user if the credentials are valid.
|
||||
* @param credentials
|
||||
*/
|
||||
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
|
||||
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||
|
||||
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||
}
|
||||
|
||||
@@ -169,8 +169,12 @@ export abstract class Directive extends AppClass {
|
||||
const optionValues = this.parseOptions(options, argv)
|
||||
this.setOptionValues(optionValues)
|
||||
await this.handle(argv)
|
||||
} catch (e) {
|
||||
this.nativeOutput(e.message)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
this.nativeOutput(e.message)
|
||||
this.error(e)
|
||||
}
|
||||
|
||||
if ( e instanceof OptionValidationError ) {
|
||||
// expecting, value, requirements
|
||||
if ( e.context.expecting ) {
|
||||
@@ -187,6 +191,7 @@ export abstract class Directive extends AppClass {
|
||||
this.nativeOutput(` - ${e.context.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.nativeOutput('\nUse --help for more info.')
|
||||
}
|
||||
}
|
||||
|
||||
23
src/cli/decorators.ts
Normal file
23
src/cli/decorators.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
|
||||
import {CommandLine} from './service'
|
||||
import {Directive} from './Directive'
|
||||
import {logIfDebugging} from '../util'
|
||||
|
||||
/**
|
||||
* Register a class as a command-line Directive.
|
||||
* The class must extend Directive.
|
||||
* @constructor
|
||||
*/
|
||||
export const CLIDirective = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiableOf(target, Directive) ) {
|
||||
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.onResolve<CommandLine>(CommandLine, cli => {
|
||||
cli.registerDirective(target as Instantiable<Directive>)
|
||||
})
|
||||
} else {
|
||||
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,5 @@ export * from './directive/options/PositionalOption'
|
||||
export * from './directive/ShellDirective'
|
||||
export * from './directive/TemplateDirective'
|
||||
export * from './directive/UsageDirective'
|
||||
|
||||
export * from './decorators'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||
import {Factory} from './factory/Factory'
|
||||
@@ -7,7 +7,7 @@ import {ClosureFactory} from './factory/ClosureFactory'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import SingletonFactory from './factory/SingletonFactory'
|
||||
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
||||
import {ContainerBlueprint} from './ContainerBlueprint'
|
||||
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
|
||||
|
||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||
export type MaybeDependency = any | undefined
|
||||
@@ -17,18 +17,36 @@ export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resol
|
||||
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||
*/
|
||||
export class Container {
|
||||
/**
|
||||
* Given a Container instance, apply the ContainerBlueprint to it.
|
||||
* @param container
|
||||
*/
|
||||
public static realizeContainer<T extends Container>(container: T): T {
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolveConstructable()
|
||||
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolveResolutionCallbacks()
|
||||
.map((listener: {key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}) => {
|
||||
container.onResolve(listener.key)
|
||||
.then(value => listener.callback(value))
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global instance of this container.
|
||||
*/
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = new Container()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
const container = Container.realizeContainer(new Container())
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
@@ -48,6 +66,12 @@ export class Container {
|
||||
*/
|
||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
||||
|
||||
/**
|
||||
* Collection of callbacks waiting for a dependency key to be resolved.
|
||||
* @protected
|
||||
*/
|
||||
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
|
||||
|
||||
constructor() {
|
||||
this.registerSingletonInstance<Container>(Container, this)
|
||||
this.registerSingleton('injector', this)
|
||||
@@ -172,6 +196,26 @@ export class Container {
|
||||
return this.instances.where('key', '=', key).isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Promise that resolves the first time the given dependency key is resolved
|
||||
* by the application. If it has already been resolved, the Promise will resolve immediately.
|
||||
* @param key
|
||||
*/
|
||||
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
|
||||
if ( this.hasInstance(key) ) {
|
||||
return new Promise<T>(res => res(this.make<T>(key)))
|
||||
}
|
||||
|
||||
// Otherwise, we haven't instantiated an instance with this key yet,
|
||||
// so put it onto the waitlist.
|
||||
return new Promise<T>(res => {
|
||||
this.waitingResolveCallbacks.push({
|
||||
key,
|
||||
callback: (res as (t: unknown) => unknown),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the container has a factory for the given key.
|
||||
* @param {DependencyKey} key
|
||||
@@ -234,6 +278,15 @@ export class Container {
|
||||
value: newInstance,
|
||||
})
|
||||
|
||||
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
|
||||
if ( waiter.key === key ) {
|
||||
waiter.callback(newInstance)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return newInstance
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import {DependencyKey, Instantiable} from './types'
|
||||
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
|
||||
/** Simple type alias for a callback to a container's onResolve method. */
|
||||
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
|
||||
|
||||
/**
|
||||
* Blueprint for newly-created containers.
|
||||
*
|
||||
* This is used to allow global helpers like `@Singleton()`
|
||||
* or `@CLIDirective()` while still supporting multiple
|
||||
* global Container instances at once.
|
||||
*/
|
||||
export class ContainerBlueprint {
|
||||
private static instance?: ContainerBlueprint
|
||||
|
||||
@@ -17,6 +27,19 @@ export class ContainerBlueprint {
|
||||
|
||||
protected factories: (() => AbstractFactory<any>)[] = []
|
||||
|
||||
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
|
||||
|
||||
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
|
||||
|
||||
/**
|
||||
* Register some factory class with the container. Should take no construction params.
|
||||
* @param factory
|
||||
*/
|
||||
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
|
||||
this.constructableFactories.push(factory)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
@@ -47,7 +70,38 @@ export class ContainerBlueprint {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of factory instances in the blueprint.
|
||||
*/
|
||||
resolve(): AbstractFactory<any>[] {
|
||||
return this.factories.map(x => x())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an onResolve callback to be added to all newly-created containers.
|
||||
* @param key
|
||||
* @param callback
|
||||
*/
|
||||
onResolve<T>(key: TypedDependencyKey<T>, callback: ContainerResolutionCallback<T>): this {
|
||||
this.resolutionCallbacks.push({
|
||||
key,
|
||||
callback,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of static Factory classes that need to be instantiated by
|
||||
* the container itself.
|
||||
*/
|
||||
resolveConstructable(): StaticClass<AbstractFactory<any>, any> {
|
||||
return [...this.constructableFactories]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of DependencyKey-callback pairs to register with new containers.
|
||||
*/
|
||||
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
|
||||
return [...this.resolutionCallbacks]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'reflect-metadata'
|
||||
import {collect, Collection} from '../../util'
|
||||
import {collect, Collection, logIfDebugging} from '../../util'
|
||||
import {
|
||||
DependencyKey,
|
||||
DependencyRequirement,
|
||||
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
|
||||
* If a `key` is specified, that DependencyKey will be injected.
|
||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||
* @param key
|
||||
* @param debug
|
||||
* @constructor
|
||||
*/
|
||||
export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
||||
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||
return (target, property) => {
|
||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||
if ( !propertyMetadata ) {
|
||||
@@ -91,11 +92,18 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
||||
if ( existing ) {
|
||||
existing.key = key
|
||||
} else {
|
||||
propertyMetadata.push({ property,
|
||||
key })
|
||||
propertyMetadata.push({
|
||||
property,
|
||||
key,
|
||||
debug,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ( debug ) {
|
||||
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
}
|
||||
@@ -152,3 +160,15 @@ export const Singleton = (name?: string): ClassDecorator => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a factory class directly with any created containers.
|
||||
* @constructor
|
||||
*/
|
||||
export const FactoryProducer = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiable(target) ) {
|
||||
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ export function isInstantiable<T>(what: unknown): what is Instantiable<T> {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given value is instantiable and, once instantiated,
|
||||
* will create an instance of the given static class.
|
||||
* @param what
|
||||
* @param type
|
||||
*/
|
||||
export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): what is Instantiable<T> {
|
||||
return isInstantiable(what) && what.prototype instanceof type
|
||||
}
|
||||
|
||||
/**
|
||||
* Type that identifies a value as a static class, even if it is not instantiable.
|
||||
*/
|
||||
@@ -41,6 +51,11 @@ export function isStaticClass<T, T2>(something: unknown): something is StaticCla
|
||||
*/
|
||||
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
|
||||
|
||||
/**
|
||||
* A DependencyKey, but typed
|
||||
*/
|
||||
export type TypedDependencyKey<T> = Instantiable<T> | StaticClass<T, any> | string
|
||||
|
||||
/**
|
||||
* Interface used to store dependency requirements by their place in the injectable
|
||||
* target's parameters.
|
||||
@@ -58,6 +73,7 @@ export interface DependencyRequirement {
|
||||
export interface PropertyDependency {
|
||||
key: DependencyKey,
|
||||
property: string | symbol,
|
||||
debug?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ValidationResult, ValidatorFunction} from './types'
|
||||
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
|
||||
import {isJSON} from '../../util'
|
||||
|
||||
/**
|
||||
@@ -221,6 +221,24 @@ function lengthMax(len: number): ValidatorFunction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator function that requires the input value to match a `${field}Confirm` field's value.
|
||||
* @param fieldName
|
||||
* @param inputValue
|
||||
* @param params
|
||||
*/
|
||||
function confirmed(fieldName: string, inputValue: unknown, params: ValidatorFunctionParams): ValidationResult {
|
||||
const confirmedFieldName = `${fieldName}Confirm`
|
||||
if ( inputValue === params.data[confirmedFieldName] ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `confirmation does not match`,
|
||||
}
|
||||
}
|
||||
|
||||
export const Str = {
|
||||
alpha,
|
||||
alphaNum,
|
||||
@@ -242,4 +260,5 @@ export const Str = {
|
||||
length,
|
||||
lengthMin,
|
||||
lengthMax,
|
||||
confirmed,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {AppClass} from '../lifecycle/AppClass'
|
||||
import {Request} from './lifecycle/Request'
|
||||
import {Container} from '../di'
|
||||
import {CanonicalItemClass} from '../support/CanonicalReceiver'
|
||||
|
||||
/**
|
||||
* Base class for controllers that define methods that
|
||||
* handle HTTP requests.
|
||||
*/
|
||||
export class Controller extends AppClass {
|
||||
export class Controller extends CanonicalItemClass {
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -108,6 +108,7 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
protected serverResponse: ServerResponse,
|
||||
) {
|
||||
super(Container.getContainer())
|
||||
this.registerSingletonInstance(Request, this)
|
||||
|
||||
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
||||
|
||||
@@ -124,12 +125,6 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
minor: clientRequest.httpVersionMinor,
|
||||
}
|
||||
|
||||
this.register(Request)
|
||||
this.instances.push({
|
||||
key: Request,
|
||||
value: this,
|
||||
})
|
||||
|
||||
const parts = url.parse(this.url, true)
|
||||
|
||||
this.path = parts.pathname ?? '/'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Base type for an API response format.
|
||||
*/
|
||||
export interface APIResponse {
|
||||
export interface APIResponse<T> {
|
||||
success: boolean,
|
||||
message?: string,
|
||||
data?: any,
|
||||
data?: T,
|
||||
error?: {
|
||||
name: string,
|
||||
message: string,
|
||||
@@ -17,7 +17,7 @@ export interface APIResponse {
|
||||
* @param {string} displayMessage
|
||||
* @return APIResponse
|
||||
*/
|
||||
export function message(displayMessage: string): APIResponse {
|
||||
export function message(displayMessage: string): APIResponse<void> {
|
||||
return {
|
||||
success: true,
|
||||
message: displayMessage,
|
||||
@@ -29,7 +29,7 @@ export function message(displayMessage: string): APIResponse {
|
||||
* @param record
|
||||
* @return APIResponse
|
||||
*/
|
||||
export function one(record: unknown): APIResponse {
|
||||
export function one<T>(record: T): APIResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data: record,
|
||||
@@ -41,7 +41,7 @@ export function one(record: unknown): APIResponse {
|
||||
* @param {array} records
|
||||
* @return APIResponse
|
||||
*/
|
||||
export function many(records: any[]): APIResponse {
|
||||
export function many<T>(records: T[]): APIResponse<{records: T[], total: number}> {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -56,7 +56,7 @@ export function many(records: any[]): APIResponse {
|
||||
* @return APIResponse
|
||||
* @param thrownError
|
||||
*/
|
||||
export function error(thrownError: string | Error): APIResponse {
|
||||
export function error(thrownError: string | Error): APIResponse<void> {
|
||||
if ( typeof thrownError === 'string' ) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ResponseObject} from './Route'
|
||||
import {Container} from '../../di'
|
||||
import {CanonicalItemClass} from '../../support/CanonicalReceiver'
|
||||
|
||||
/**
|
||||
* Base class representing a middleware handler that can be applied to routes.
|
||||
*/
|
||||
export abstract class Middleware extends AppClass {
|
||||
export abstract class Middleware extends CanonicalItemClass {
|
||||
constructor(
|
||||
/** The request that will be handled by this middleware. */
|
||||
protected readonly request: Request,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -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'
|
||||
@@ -60,6 +62,9 @@ export * from './http/Controller'
|
||||
|
||||
export * from './http/servers/static'
|
||||
|
||||
export * from './support/CanonicalReceiver'
|
||||
|
||||
export * from './service/Canon'
|
||||
export * from './service/Canonical'
|
||||
export * from './service/CanonicalInstantiable'
|
||||
export * from './service/CanonicalRecursive'
|
||||
@@ -72,9 +77,14 @@ export * from './service/HTTPServer'
|
||||
export * from './service/Routing'
|
||||
export * from './service/Middlewares'
|
||||
|
||||
export * from './support/redis/Redis'
|
||||
export * from './support/cache/MemoryCache'
|
||||
export * from './support/cache/RedisCache'
|
||||
export * from './support/cache/CacheFactory'
|
||||
export * from './support/NodeModules'
|
||||
export * from './support/queue/Queue'
|
||||
|
||||
export * from './service/Queueables'
|
||||
|
||||
export * from './views/ViewEngine'
|
||||
export * from './views/ViewEngineFactory'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Container, ContainerBlueprint} from '../di'
|
||||
import {Container} from '../di'
|
||||
import {
|
||||
ErrorWithContext,
|
||||
globalRegistry,
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
universalPath,
|
||||
UniversalPath,
|
||||
} from '../util'
|
||||
|
||||
import {Logging} from '../service/Logging'
|
||||
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.
|
||||
@@ -57,12 +57,7 @@ export class Application extends Container {
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = new Application()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
const container = Application.realizeContainer(new Application())
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
@@ -78,18 +73,12 @@ export class Application extends Container {
|
||||
if ( existing instanceof Application ) {
|
||||
return existing
|
||||
} else if ( existing ) {
|
||||
const app = new Application()
|
||||
const app = Application.realizeContainer(new Application())
|
||||
existing.cloneTo(app)
|
||||
|
||||
globalRegistry.setGlobal('extollo/injector', app)
|
||||
return app
|
||||
} else {
|
||||
const app = new Application()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => app.registerFactory(factory))
|
||||
|
||||
const app = Application.realizeContainer(new Application())
|
||||
globalRegistry.setGlobal('extollo/injector', app)
|
||||
return app
|
||||
}
|
||||
@@ -225,6 +214,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')
|
||||
@@ -264,8 +259,12 @@ export class Application extends Container {
|
||||
try {
|
||||
await this.up()
|
||||
await this.down()
|
||||
} catch (e) {
|
||||
this.errorHandler(e)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
this.errorHandler(e)
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,9 +310,14 @@ export class Application extends Container {
|
||||
await unit.up()
|
||||
unit.status = UnitStatus.Started
|
||||
logging.info(`Started ${unit.constructor.name}.`)
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
unit.status = UnitStatus.Error
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +336,12 @@ export class Application extends Container {
|
||||
logging.info(`Stopped ${unit.constructor.name}.`)
|
||||
} catch (e) {
|
||||
unit.status = UnitStatus.Error
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,13 @@ ${contextDisplay}
|
||||
}
|
||||
|
||||
this.logging.error(errorString, true)
|
||||
} catch (displayError) {
|
||||
// The error display encountered an error...
|
||||
// just throw the original so it makes it out
|
||||
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
||||
} catch (displayError: unknown) {
|
||||
if ( displayError instanceof Error ) {
|
||||
// The error display encountered an error...
|
||||
// just throw the original so it makes it out
|
||||
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
throw operativeError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
.nullable()
|
||||
|
||||
table.column('last_name')
|
||||
.type(FieldType.varchar)
|
||||
.nullable()
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {Awaitable, ErrorWithContext} from '../../util'
|
||||
import {QueryResult} from '../types'
|
||||
import {SQLDialect} from '../dialect/SQLDialect'
|
||||
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,18 @@ export abstract class Connection extends AppClass {
|
||||
*/
|
||||
public abstract close(): Promise<void>
|
||||
|
||||
// public abstract databases(): Promise<Collection<Database>>
|
||||
/**
|
||||
* Get a Schema on this connection.
|
||||
* @param name
|
||||
*/
|
||||
public abstract schema(name?: string): Schema
|
||||
|
||||
// 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>
|
||||
/**
|
||||
* Execute all queries logged to this connection during the closure
|
||||
* as a transaction in the database.
|
||||
* @param closure
|
||||
*/
|
||||
public abstract asTransaction<T>(closure: () => Awaitable<T>): Awaitable<T>
|
||||
|
||||
/**
|
||||
* Fire a QueryExecutedEvent for the given query string.
|
||||
|
||||
@@ -2,10 +2,12 @@ import {Connection, ConnectionNotReadyError} from './Connection'
|
||||
import {Client} from 'pg'
|
||||
import {Inject} from '../../di'
|
||||
import {QueryResult} from '../types'
|
||||
import {collect} from '../../util'
|
||||
import {Awaitable, 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.
|
||||
@@ -61,10 +63,29 @@ export class PostgresConnection extends Connection {
|
||||
rowCount: result.rowCount,
|
||||
}
|
||||
} catch (e) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
query,
|
||||
connection: this.name,
|
||||
})
|
||||
if ( e instanceof Error ) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
query,
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
|
||||
if ( !this.client ) {
|
||||
throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(this.config) })
|
||||
}
|
||||
|
||||
await this.client.query('BEGIN')
|
||||
const result = await closure()
|
||||
await this.client.query('COMMIT')
|
||||
return result
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
50
src/orm/directive/CreateMigrationDirective.ts
Normal file
50
src/orm/directive/CreateMigrationDirective.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {Directive, OptionDefinition} from '../../cli'
|
||||
import {Injectable} from '../../di'
|
||||
import {stringToPascal} from '../../util'
|
||||
import {templateMigration} from '../template/migration'
|
||||
import {CLIDirective} from '../../cli/decorators'
|
||||
|
||||
/**
|
||||
* CLI directive that creates migration classes from template.
|
||||
*/
|
||||
@Injectable()
|
||||
@CLIDirective()
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
119
src/orm/directive/MigrateDirective.ts
Normal file
119
src/orm/directive/MigrateDirective.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {Directive, OptionDefinition} from '../../cli'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {EventBus} from '../../event/EventBus'
|
||||
import {Migrations} from '../services/Migrations'
|
||||
import {Migrator} from '../migrations/Migrator'
|
||||
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
||||
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
||||
import {EventSubscription} from '../../event/types'
|
||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||
import {CLIDirective} from '../../cli/decorators'
|
||||
|
||||
/**
|
||||
* CLI directive that applies migrations using the default Migrator.
|
||||
* @fixme Support dry run mode
|
||||
*/
|
||||
@Injectable()
|
||||
@CLIDirective()
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
104
src/orm/directive/RollbackDirective.ts
Normal file
104
src/orm/directive/RollbackDirective.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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'
|
||||
import {CLIDirective} from '../../cli/decorators'
|
||||
|
||||
/**
|
||||
* CLI directive that undoes applied migrations using the default Migrator.
|
||||
* @fixme Support dry run mode
|
||||
*/
|
||||
@Injectable()
|
||||
@CLIDirective()
|
||||
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,26 @@ export * from './support/ORMCache'
|
||||
|
||||
export * from './DatabaseService'
|
||||
export * from './types'
|
||||
|
||||
export * from './schema/TableBuilder'
|
||||
export * from './schema/Schema'
|
||||
export * from './schema/PostgresSchema'
|
||||
|
||||
export * from './services/Migrations'
|
||||
export * from './migrations/Migrator'
|
||||
export * from './migrations/NothingToMigrateError'
|
||||
export * from './migrations/events/MigrationEvent'
|
||||
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/MigratorFactory'
|
||||
export * from './migrations/DatabaseMigrator'
|
||||
|
||||
export * from './services/Database'
|
||||
export * from './services/Models'
|
||||
|
||||
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(Migrations, { debug: true })
|
||||
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)
|
||||
}
|
||||
}
|
||||
82
src/orm/migrations/MigratorFactory.ts
Normal file
82
src/orm/migrations/MigratorFactory.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
AbstractFactory,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
|
||||
} 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()
|
||||
@FactoryProducer()
|
||||
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'
|
||||
@@ -612,6 +612,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
}
|
||||
|
||||
const row = this.buildInsertFieldObject()
|
||||
this.logging.debug('Insert field object:')
|
||||
this.logging.debug(row)
|
||||
const returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
|
||||
|
||||
const result = await this.query()
|
||||
@@ -635,6 +637,30 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current model from the database, if it exists.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
if ( !this.exists() ) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.delete()
|
||||
|
||||
const ctor = this.constructor as typeof Model
|
||||
const field = getFieldsMeta(this)
|
||||
.firstWhere('databaseKey', '=', ctor.key)
|
||||
|
||||
if ( field ) {
|
||||
delete (this as any)[field.modelKey]
|
||||
return
|
||||
}
|
||||
|
||||
delete (this as any)[ctor.key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast this model to a simple object mapping model fields to their values.
|
||||
*
|
||||
@@ -784,10 +810,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
private buildInsertFieldObject(): EscapeValueObject {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
|
||||
|
||||
return getFieldsMeta(this)
|
||||
.pipe()
|
||||
.unless(ctor.populateKeyOnInsert, fields => {
|
||||
return fields.where('modelKey', '!=', this.keyName())
|
||||
return fields.where('databaseKey', '!=', this.keyName())
|
||||
})
|
||||
.get()
|
||||
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
||||
@@ -804,7 +832,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,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {Model} from './Model'
|
||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
||||
import {Instantiable} from '../../di'
|
||||
import {Instantiable, StaticClass} from '../../di'
|
||||
import {ModelResultIterable} from './ModelResultIterable'
|
||||
import {Collection} from '../../util'
|
||||
import {ConstraintOperator, ModelKey, ModelKeys} from '../types'
|
||||
import {EscapeValue} from '../dialect/SQLDialect'
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||
@@ -10,7 +13,7 @@ import {ModelResultIterable} from './ModelResultIterable'
|
||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
constructor(
|
||||
/** The model class that is created for results of this query. */
|
||||
protected readonly ModelClass: Instantiable<T>,
|
||||
protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -22,4 +25,45 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
public getResultIterable(): AbstractResultIterable<T> {
|
||||
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a WHERE...IN... constraint on the primary key of the model.
|
||||
* @param keys
|
||||
*/
|
||||
public whereKey(keys: ModelKeys): this {
|
||||
return this.whereIn(
|
||||
this.ModelClass.qualifyKey(),
|
||||
this.normalizeModelKeys(keys),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a where constraint on the column corresponding the the specified
|
||||
* property on the model.
|
||||
* @param propertyName
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
public whereProperty(propertyName: string, operator: ConstraintOperator, operand?: EscapeValue): this {
|
||||
return this.where(
|
||||
this.ModelClass.propertyToColumn(propertyName),
|
||||
operator,
|
||||
operand,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given some format of keys of the model, try to normalize them to a flat array.
|
||||
* @param keys
|
||||
* @protected
|
||||
*/
|
||||
protected normalizeModelKeys(keys: ModelKeys): ModelKey[] {
|
||||
if ( Array.isArray(keys) ) {
|
||||
return keys
|
||||
} else if ( keys instanceof Collection ) {
|
||||
return keys.all()
|
||||
}
|
||||
|
||||
return [keys]
|
||||
}
|
||||
}
|
||||
|
||||
341
src/orm/schema/PostgresSchema.ts
Normal file
341
src/orm/schema/PostgresSchema.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
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
|
||||
|
||||
86
src/orm/services/Migrations.ts
Normal file
86
src/orm/services/Migrations.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {Inject, Singleton} from '../../di'
|
||||
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
||||
import {Migration} from '../migrations/Migration'
|
||||
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
||||
import {UniversalPath} from '../../util'
|
||||
import {lib} from '../../lib'
|
||||
import {CommandLine} from '../../cli'
|
||||
|
||||
/**
|
||||
* Service unit that loads and instantiates migration classes.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Migrations extends CanonicalInstantiable<Migration> {
|
||||
@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)
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {Model} from '../model/Model'
|
||||
import {Field} from '../model/Field'
|
||||
import {FieldType} from '../types'
|
||||
import {Maybe} from '../../util'
|
||||
import {ModelBuilder} from '../model/ModelBuilder'
|
||||
|
||||
/**
|
||||
* A model instance which stores records from the ORMCache driver.
|
||||
@@ -18,4 +20,15 @@ export class CacheModel extends Model<CacheModel> {
|
||||
|
||||
@Field(FieldType.timestamp, 'cache_expires')
|
||||
public cacheExpires?: Date;
|
||||
|
||||
public static withCacheKey(key: string): ModelBuilder<CacheModel> {
|
||||
return this.query<CacheModel>()
|
||||
.whereKey(key)
|
||||
.whereProperty('cacheExpires', '>', new Date())
|
||||
}
|
||||
|
||||
public static getCacheKey(key: string): Promise<Maybe<CacheModel>> {
|
||||
return this.withCacheKey(key)
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Container} from '../../di'
|
||||
import {Cache} from '../../util'
|
||||
import {Awaitable, Cache, ErrorWithContext, Maybe} from '../../util'
|
||||
import {CacheModel} from './CacheModel'
|
||||
|
||||
/**
|
||||
@@ -7,14 +7,7 @@ import {CacheModel} from './CacheModel'
|
||||
*/
|
||||
export class ORMCache extends Cache {
|
||||
public async fetch(key: string): Promise<string | undefined> {
|
||||
const model = await CacheModel.query<CacheModel>()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
.first()
|
||||
|
||||
if ( model ) {
|
||||
return model.cacheValue
|
||||
}
|
||||
return (await CacheModel.getCacheKey(key))?.cacheValue
|
||||
}
|
||||
|
||||
public async put(key: string, value: string, expires?: Date): Promise<void> {
|
||||
@@ -31,15 +24,103 @@ export class ORMCache extends Cache {
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
return CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
return CacheModel.withCacheKey(key)
|
||||
.exists()
|
||||
}
|
||||
|
||||
public async drop(key: string): Promise<void> {
|
||||
await CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.whereKey(key)
|
||||
.delete()
|
||||
}
|
||||
|
||||
public async pop(key: string): Promise<string> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<string>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
throw new ErrorWithContext('Cannot pop cache value: key does not exist.', {
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
await model.delete()
|
||||
return model.cacheValue
|
||||
})
|
||||
}
|
||||
|
||||
public increment(key: string, amount = 1): Awaitable<number> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<number>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
await this.put(key, String(amount))
|
||||
return amount
|
||||
}
|
||||
|
||||
model.cacheValue = String(parseInt(model.cacheValue, 10) + amount)
|
||||
await model.save()
|
||||
return parseInt(model.cacheValue, 10)
|
||||
})
|
||||
}
|
||||
|
||||
public decrement(key: string, amount = 1): Awaitable<number> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<number>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
await this.put(key, String(-amount))
|
||||
return amount
|
||||
}
|
||||
|
||||
model.cacheValue = String(parseInt(model.cacheValue, 10) - amount)
|
||||
await model.save()
|
||||
return parseInt(model.cacheValue, 10)
|
||||
})
|
||||
}
|
||||
|
||||
public async arrayPush(key: string, value: string): Promise<void> {
|
||||
await CacheModel.getConnection()
|
||||
.asTransaction<void>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
await this.put(key, JSON.stringify([value]))
|
||||
return
|
||||
}
|
||||
|
||||
const cacheValue = JSON.parse(model.cacheValue)
|
||||
if ( !Array.isArray(cacheValue) ) {
|
||||
throw new ErrorWithContext('Cannot push value to non-array.', {
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
cacheValue.push(value)
|
||||
model.cacheValue = JSON.stringify(cacheValue)
|
||||
})
|
||||
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
public async arrayPop(key: string): Promise<Maybe<string>> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<Maybe<string>>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
return
|
||||
}
|
||||
|
||||
const cacheValue = JSON.parse(model.cacheValue)
|
||||
if ( !Array.isArray(cacheValue) ) {
|
||||
throw new ErrorWithContext('Cannot pop value from non-array.', {
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
const value = cacheValue.pop()
|
||||
model.cacheValue = JSON.stringify(cacheValue)
|
||||
await model.save()
|
||||
return value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {FieldType} from '../types'
|
||||
* Model used to fetch & store sessions from the ORMSession driver.
|
||||
*/
|
||||
export class SessionModel extends Model<SessionModel> {
|
||||
protected static table = 'sessions'; // FIXME allow configuring
|
||||
protected static table = 'sessions' // FIXME allow configuring
|
||||
|
||||
protected static key = 'session_uuid';
|
||||
protected static key = 'session_uuid'
|
||||
|
||||
protected static populateKeyOnInsert = true
|
||||
|
||||
@Field(FieldType.varchar, 'session_uuid')
|
||||
public uuid!: string;
|
||||
|
||||
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 }
|
||||
@@ -11,6 +11,11 @@ export type QueryRow = { [key: string]: any }
|
||||
*/
|
||||
export type ModelKey = string | number
|
||||
|
||||
/**
|
||||
* Collection of keys of a set of models.
|
||||
*/
|
||||
export type ModelKeys = ModelKey | ModelKey[] | Collection<ModelKey>
|
||||
|
||||
/**
|
||||
* Interface for the result of a query execution.
|
||||
*/
|
||||
@@ -152,3 +157,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
@@ -8,5 +8,9 @@ block content
|
||||
each error in errors
|
||||
p.form-error-message #{error}
|
||||
|
||||
form(method='post' enctype='multipart/form-data')
|
||||
block form
|
||||
if formAction
|
||||
form(method='post' enctype='multipart/form-data' action=formAction)
|
||||
block form
|
||||
else
|
||||
form(method='post' enctype='multipart/form-data')
|
||||
block form
|
||||
|
||||
@@ -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='identifier' 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')
|
||||
input#inputPassword.form-control(type='password' name='credential' 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}
|
||||
a(href=named('@auth.register')) Register here.
|
||||
|
||||
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}
|
||||
26
src/resources/views/auth/register.pug
Normal file
26
src/resources/views/auth/register.pug
Normal file
@@ -0,0 +1,26 @@
|
||||
extends ./form
|
||||
|
||||
block head
|
||||
title Register | #{config('app.name', 'Extollo')}
|
||||
|
||||
block heading
|
||||
| Register to continue
|
||||
|
||||
block form
|
||||
.form-label-group
|
||||
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||
label(for='inputUsername') Username
|
||||
|
||||
.form-label-group
|
||||
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
|
||||
label(for='inputPassword') Password
|
||||
|
||||
.form-label-group
|
||||
input#inputPasswordConfirm.form-control(type='password' name='credentialConfirm' required placeholder='Confirm Password')
|
||||
label(for='inputPassword') Confirm 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 Have an account?
|
||||
a(href=named('@auth.login')) Login here.
|
||||
@@ -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'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Canonical} from './Canonical'
|
||||
import {Singleton} from '../di'
|
||||
import {Maybe} from '../util'
|
||||
|
||||
/**
|
||||
* Error throw when a duplicate canonical key is registered.
|
||||
@@ -46,6 +47,17 @@ export class Canon {
|
||||
return this.resources[key] as Canonical<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a canonical item from a fully-qualified canonical name.
|
||||
* This is just a quality-of-life wrapper around `this.resource(...).get(...)`.
|
||||
* @param key
|
||||
*/
|
||||
getFromFullyQualified(key: string): Maybe<any> {
|
||||
const [namespace, ...parts] = key.split('::')
|
||||
const unqualified = parts.join('::')
|
||||
return this.resource(namespace).get(unqualified)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a canonical resource.
|
||||
* @param {Canonical} unit
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user