Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 248b24e612 | |||
| b4a9057e2b | |||
| c078d695a8 | |||
| 55ffadc742 | |||
| 56574d43ce | |||
| e16f02ce12 | |||
| c34fad3502 | |||
| 156006053b | |||
| 22cf6aa953 | |||
| b35eb8d6a1 | |||
| 9ee4c42e43 | |||
| 8d1dcc87fb | |||
| 3efbfecf9d | |||
| a1d04d652e | |||
| 5940b6e2b3 | |||
|
074a3187eb
|
|||
|
26e0444e40
|
|||
|
fcce28081b
|
|||
|
e86cf420df
|
|||
|
e33d8dee8f
|
|||
|
39d97d6e14
|
|||
|
f496046461
|
|||
|
b3b5b169e8
|
|||
|
5d960e6186
|
|||
|
cf6d14abca
|
|||
|
faa8a31102
|
|||
|
7506d6567d
|
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>
|
||||
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.5",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@@ -8,12 +8,15 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atao60/fse-cli": "^0.1.6",
|
||||
"@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",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/node": "^14.17.4",
|
||||
"@types/pg": "^8.6.0",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/pug": "^2.0.4",
|
||||
@@ -25,8 +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",
|
||||
@@ -42,8 +48,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"prebuild": "pnpm run lint",
|
||||
"build": "tsc",
|
||||
"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",
|
||||
@@ -65,5 +70,11 @@
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
||||
"@typescript-eslint/parser": "^4.26.0",
|
||||
"eslint": "^7.27.0"
|
||||
},
|
||||
"extollo": {
|
||||
"discover": true,
|
||||
"units": {
|
||||
"discover": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2453
pnpm-lock.yaml
generated
2453
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() ) {
|
||||
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() ) {
|
||||
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
|
||||
} else {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ORMUserRepository} from '../orm/ORMUserRepository'
|
||||
import {AuthConfig, AuthenticatableRepositories} from '../config'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
/**
|
||||
* Injects a SessionSecurityContext into the request and attempts to
|
||||
@@ -17,7 +18,11 @@ export class SessionAuthMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
this.logging.debug('Applying session auth middleware...')
|
||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||
this.request.registerSingletonInstance(SecurityContext, context)
|
||||
await context.resume()
|
||||
|
||||
@@ -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) {
|
||||
} 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,8 +1,19 @@
|
||||
import {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
|
||||
|
||||
@@ -16,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.
|
||||
@@ -36,7 +60,48 @@ export class ContainerBlueprint {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a producer function as a ClosureFactory with this container.
|
||||
* @param key
|
||||
* @param producer
|
||||
*/
|
||||
registerProducer(key: DependencyKey, producer: () => any): this {
|
||||
this.factories.push(() => new ClosureFactory(key, producer))
|
||||
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,11 +1,13 @@
|
||||
import {Dispatchable} from './types'
|
||||
import {JSONState} from '../util'
|
||||
import {Awaitable, JSONState} from '../util'
|
||||
|
||||
/**
|
||||
* Abstract class representing an event that may be fired.
|
||||
*/
|
||||
export abstract class Event implements Dispatchable {
|
||||
abstract dehydrate(): Promise<JSONState>
|
||||
|
||||
abstract rehydrate(state: JSONState): void | Promise<void>
|
||||
|
||||
abstract dehydrate(): Awaitable<JSONState>
|
||||
|
||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
|
||||
constructor(
|
||||
public readonly status: HTTPStatus = 500,
|
||||
public readonly message: string = '',
|
||||
context?: {[key: string]: any},
|
||||
) {
|
||||
super('HTTP ERROR')
|
||||
super('HTTP ERROR', context)
|
||||
this.message = message || HTTPMessage[status]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
|
||||
import {plaintext} from '../../response/StringResponseFactory'
|
||||
import {ResponseFactory} from '../../response/ResponseFactory'
|
||||
import {json} from '../../response/JSONResponseFactory'
|
||||
import {UniversalPath} from '../../../util'
|
||||
import {file} from '../../response/FileResponseFactory'
|
||||
|
||||
/**
|
||||
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
|
||||
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
|
||||
|
||||
if ( object instanceof ResponseFactory ) {
|
||||
await object.write(request)
|
||||
} else if ( object instanceof UniversalPath ) {
|
||||
await file(object).write(request)
|
||||
} else if ( typeof object !== 'undefined' ) {
|
||||
await json(object).write(request)
|
||||
} else {
|
||||
|
||||
@@ -28,7 +28,7 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule {
|
||||
const route = this.routing.match(request.method, request.path)
|
||||
if ( route ) {
|
||||
this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`)
|
||||
const activated = new ActivatedRoute(route, request.path)
|
||||
const activated = <ActivatedRoute> request.make(ActivatedRoute, route, request.path)
|
||||
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
|
||||
} else {
|
||||
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
|
||||
|
||||
@@ -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 ?? '/'
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Request} from './Request'
|
||||
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
|
||||
import {ServerResponse} from 'http'
|
||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||
import {Readable} from 'stream'
|
||||
|
||||
/**
|
||||
* Error thrown when the server tries to re-send headers after they have been sent once.
|
||||
@@ -47,7 +48,7 @@ export class Response {
|
||||
private isBlockingWriteback = false
|
||||
|
||||
/** The body contents that should be written to the response. */
|
||||
public body = ''
|
||||
public body: string | Buffer | Uint8Array | Readable = ''
|
||||
|
||||
/**
|
||||
* Behavior subject fired right before the response content is written.
|
||||
@@ -192,11 +193,21 @@ export class Response {
|
||||
* Write the headers and specified data to the client.
|
||||
* @param data
|
||||
*/
|
||||
public async write(data: unknown): Promise<void> {
|
||||
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||
return new Promise<void>((res, rej) => {
|
||||
if ( !this.sentHeaders ) {
|
||||
this.sendHeaders()
|
||||
}
|
||||
|
||||
if ( data instanceof Readable ) {
|
||||
data.pipe(this.serverResponse)
|
||||
.on('finish', () => {
|
||||
res()
|
||||
})
|
||||
.on('error', error => {
|
||||
rej(error)
|
||||
})
|
||||
} else {
|
||||
this.serverResponse.write(data, error => {
|
||||
if ( error ) {
|
||||
rej(error)
|
||||
@@ -204,6 +215,7 @@ export class Response {
|
||||
res()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,9 +224,17 @@ export class Response {
|
||||
*/
|
||||
public async send(): Promise<void> {
|
||||
await this.sending$.next(this)
|
||||
|
||||
if ( !(this.body instanceof Readable) ) {
|
||||
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()
|
||||
|
||||
await this.sent$.next(this)
|
||||
}
|
||||
|
||||
|
||||
38
src/http/response/FileResponseFactory.ts
Normal file
38
src/http/response/FileResponseFactory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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.
|
||||
* @param path
|
||||
*/
|
||||
export function file(path: UniversalPath): FileResponseFactory {
|
||||
return new FileResponseFactory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response factory that sends a file referenced by a given UniversalPath.
|
||||
*/
|
||||
export class FileResponseFactory extends ResponseFactory {
|
||||
constructor(
|
||||
/** The file to be sent. */
|
||||
public readonly path: UniversalPath,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public async write(request: Request): Promise<Request> {
|
||||
if ( !(await this.path.isFile()) ) {
|
||||
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
|
||||
path: this.path,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
return request
|
||||
}
|
||||
}
|
||||
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,9 +1,11 @@
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {ResolvedRouteHandler, Route} from './Route'
|
||||
import {Injectable} from '../../di'
|
||||
|
||||
/**
|
||||
* Class representing a resolved route that a request is mounted to.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ActivatedRoute {
|
||||
/**
|
||||
* The parsed params from the route definition.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,11 +87,15 @@ export class Route extends AppClass {
|
||||
for ( const group of stack ) {
|
||||
route.prepend(group.prefix)
|
||||
group.getGroupMiddlewareDefinitions()
|
||||
.each(def => route.prependMiddleware(def))
|
||||
.where('stage', '=', 'pre')
|
||||
.each(def => {
|
||||
route.prependMiddleware(def)
|
||||
})
|
||||
}
|
||||
|
||||
for ( const group of this.compiledGroupStack ) {
|
||||
group.getGroupMiddlewareDefinitions()
|
||||
.where('stage', '=', 'post')
|
||||
.each(def => route.appendMiddleware(def))
|
||||
}
|
||||
|
||||
@@ -211,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[],
|
||||
@@ -224,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.
|
||||
*/
|
||||
|
||||
188
src/http/servers/static.ts
Normal file
188
src/http/servers/static.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ActivatedRoute} from '../routing/ActivatedRoute'
|
||||
import {Config} from '../../service/Config'
|
||||
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/RedirectResponseFactory'
|
||||
import {file} from '../response/FileResponseFactory'
|
||||
import {RouteHandler} from '../routing/Route'
|
||||
|
||||
/**
|
||||
* Defines the behavior of the static server.
|
||||
*/
|
||||
export interface StaticServerOptions {
|
||||
/** If true, browsing to a directory route will show the directory listing page. */
|
||||
directoryListing?: boolean
|
||||
|
||||
/** The path to the directory whose files should be served. */
|
||||
basePath?: string | string[] | UniversalPath
|
||||
|
||||
/** If specified, only files with these extensions will be served. */
|
||||
allowedExtensions?: string[]
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPError class thrown by the static server.
|
||||
*/
|
||||
export class StaticServerHTTPError extends HTTPError {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response factory that shows the directory listing.
|
||||
* @param dirname
|
||||
* @param dirPath
|
||||
*/
|
||||
async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise<ViewResponseFactory> {
|
||||
return view('@extollo:static:dirlist', {
|
||||
dirname,
|
||||
contents: (await (await dirPath.list())
|
||||
.promiseMap(async path => {
|
||||
const isDirectory = await path.isDirectory()
|
||||
return {
|
||||
isDirectory,
|
||||
name: path.toBase,
|
||||
size: isDirectory ? '-' : await path.sizeForHumans(),
|
||||
}
|
||||
}))
|
||||
.sortBy(row => {
|
||||
return `${row.isDirectory ? 0 : 1}${row.name}`
|
||||
})
|
||||
.all(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given file path has an extension that is allowed by
|
||||
* the static server options.
|
||||
* @param filePath
|
||||
* @param options
|
||||
*/
|
||||
function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean {
|
||||
return (
|
||||
(
|
||||
!options.allowedExtensions
|
||||
|| options.allowedExtensions.includes(filePath.ext)
|
||||
)
|
||||
&& (
|
||||
!options.excludedExtensions
|
||||
|| !options.excludedExtensions.includes(filePath.ext)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the configured base path into a universal path.
|
||||
* Defaults to `{app path}/resources/static` if none provided.
|
||||
* @param appPath
|
||||
* @param basePath
|
||||
*/
|
||||
function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath {
|
||||
if ( basePath instanceof UniversalPath ) {
|
||||
return basePath
|
||||
}
|
||||
|
||||
if ( !basePath ) {
|
||||
return appPath.concat('resources', 'static')
|
||||
}
|
||||
|
||||
if ( Array.isArray(basePath) ) {
|
||||
return appPath.concat(...basePath)
|
||||
}
|
||||
|
||||
if ( basePath.startsWith('/') ) {
|
||||
return universalPath(basePath)
|
||||
}
|
||||
|
||||
return appPath.concat(basePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a route handler that serves a directory as static files.
|
||||
* @param options
|
||||
*/
|
||||
export function staticServer(options: StaticServerOptions = {}): RouteHandler {
|
||||
return async (request: Request) => {
|
||||
const config = <Config> request.make(Config)
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const app = <Application> request.make(Application)
|
||||
|
||||
const staticConfig = config.get('server.builtIns.static', {})
|
||||
const mergedOptions = {
|
||||
...staticConfig,
|
||||
...options,
|
||||
}
|
||||
|
||||
// Resolve the path to the resource on the filesystem
|
||||
const basePath = getBasePath(app.appPath(), mergedOptions.basePath)
|
||||
const filePath = basePath.concat(...Collection.normalize<string>(route.params[0]))
|
||||
|
||||
// If the resolved path is outside of the base path, fail out
|
||||
if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
reason: 'Resolved file is not a child of the base path.',
|
||||
})
|
||||
}
|
||||
|
||||
// If the resolved file is an invalid file extension, fail out
|
||||
if ( !isValidFileExtension(filePath, mergedOptions) ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
allowedExtensions: mergedOptions.allowedExtensions,
|
||||
excludedExtensions: mergedOptions.excludedExtensions,
|
||||
reason: 'Resolved file is not an allowed extension type',
|
||||
})
|
||||
}
|
||||
|
||||
// If the resolved file does not exist on the filesystem, fail out
|
||||
if ( !(await filePath.exists()) ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
reason: 'Resolved file does not exist on the filesystem',
|
||||
})
|
||||
}
|
||||
|
||||
// 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}/`)
|
||||
}
|
||||
|
||||
return getDirectoryListingResponse(route.path, filePath)
|
||||
}
|
||||
|
||||
// Otherwise, just send the file as the response body
|
||||
return file(filePath)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
15
src/index.ts
15
src/index.ts
@@ -1,4 +1,5 @@
|
||||
export * from './util'
|
||||
export * from './lib'
|
||||
export * from './di'
|
||||
|
||||
export * from './event/types'
|
||||
@@ -41,7 +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'
|
||||
@@ -56,6 +60,11 @@ export * from './http/session/MemorySession'
|
||||
|
||||
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'
|
||||
@@ -68,8 +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'
|
||||
|
||||
8
src/lib.ts
Normal file
8
src/lib.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {UniversalPath} from './util'
|
||||
|
||||
/**
|
||||
* Get the path to the root of the @extollo/lib package.
|
||||
*/
|
||||
export function lib(): UniversalPath {
|
||||
return new UniversalPath(__dirname)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -48,15 +48,16 @@ export function appPath(...parts: PathLike[]): UniversalPath {
|
||||
* The main application container.
|
||||
*/
|
||||
export class Application extends Container {
|
||||
public static readonly NODE_MODULES_INJECTION = 'extollo/npm'
|
||||
|
||||
public static get NODE_MODULES_PROVIDER(): string {
|
||||
return process.env.EXTOLLO_NPM || 'pnpm'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -72,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
|
||||
}
|
||||
@@ -205,6 +200,7 @@ export class Application extends Container {
|
||||
this.setupLogging()
|
||||
|
||||
this.registerFactory(new CacheFactory()) // FIXME move this somewhere else?
|
||||
this.registerSingleton(Application.NODE_MODULES_INJECTION, Application.NODE_MODULES_PROVIDER)
|
||||
|
||||
this.make<Logging>(Logging).debug(`Application root: ${this.baseDir}`)
|
||||
}
|
||||
@@ -218,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')
|
||||
@@ -257,9 +259,13 @@ export class Application extends Container {
|
||||
try {
|
||||
await this.up()
|
||||
await this.down()
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
this.errorHandler(e)
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,10 +310,15 @@ 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
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,7 +336,12 @@ export class Application extends Container {
|
||||
logging.info(`Stopped ${unit.constructor.name}.`)
|
||||
} catch (e) {
|
||||
unit.status = UnitStatus.Error
|
||||
|
||||
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) {
|
||||
} 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) {
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user