34 Commits

Author SHA1 Message Date
d245d15ad6 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 16:52:35 -06:00
265837b5cd Fix stupid typescript error...
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 16:50:01 -06:00
fe0b4d6d8f bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-11-25 16:39:53 -06:00
ce1d22ff44 Make the linter happy
Some checks failed
continuous-integration/drone/push Build is failing
2021-11-25 16:39:25 -06:00
b7bfb3e153 Model: fix eager-loaded relation loading from static query 2021-11-25 16:39:17 -06:00
e57819d318 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-15 14:00:29 -06:00
0a9dd30909 Implement scopes on models and support interacting with them via ModelBuilder
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-11 16:42:37 -06:00
d92c8b5409 Start implementation of model relations
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-10 21:30:59 -06:00
589cb7d579 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 22:29:34 -05:00
3680ad1914 Fix back-fill on insert for Model.save 2021-10-19 22:29:15 -05:00
96e13d85fc Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:59:14 -05:00
5a9283ad85 orm(PostgreSQLDialect): double-quote column names in INSERT field lists 2021-10-19 13:59:00 -05:00
b1ea489ccb Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:00:27 -05:00
c3f2779650 Add generic to APIResponse 2021-10-19 13:00:14 -05:00
248b24e612 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 10:26:47 -05:00
b4a9057e2b CLI invocation output better debugging infor 2021-10-19 10:26:32 -05:00
c078d695a8 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-18 17:23:30 -05:00
55ffadc742 Export CLI decorators
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 17:23:16 -05:00
56574d43ce Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-10-18 14:57:44 -05:00
e16f02ce12 Readd migrations
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 14:49:19 -05:00
c34fad3502 Fix path in drone docs deploy
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-10-18 14:02:00 -05:00
156006053b Fix path in drone static deploy
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
2021-10-18 13:53:37 -05:00
22cf6aa953 bump version
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is failing
2021-10-18 13:41:22 -05:00
b35eb8d6a1 Fix error throw
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 13:36:59 -05:00
9ee4c42e43 Error type fixes
Some checks failed
continuous-integration/drone/push Build is failing
2021-10-18 13:03:28 -05:00
8d1dcc87fb Bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-10-18 12:49:56 -05:00
3efbfecf9d OAuth2 stuff
Some checks failed
continuous-integration/drone/push Build is failing
2021-10-18 12:48:16 -05:00
a1d04d652e Implement basic login & registration forms
Some checks failed
continuous-integration/drone/push Build is failing
2021-09-21 22:25:51 -05:00
5940b6e2b3 Fix circular dependencies in migrator
Some checks failed
continuous-integration/drone/push Build is failing
2021-09-21 13:42:06 -05:00
074a3187eb Add support for jobs & queueables, migrations
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
- Create migration directives & migrators
- Modify Cache classes to support array manipulation
- Create Redis unit and RedisCache implementation
- Create Queueable base class and Queue class that uses Cache backend
2021-08-23 23:51:53 -05:00
26e0444e40 version 0.5.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-07-25 09:15:41 -05:00
fcce28081b AsyncPipe; table schemata; migrations; File logging
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-25 09:15:01 -05:00
e86cf420df Named routes & basic login framework 2021-07-17 12:49:07 -05:00
e33d8dee8f Add support for registering vendor asset routes
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-07 22:50:48 -05:00
144 changed files with 7421 additions and 1447 deletions

View File

@@ -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
View 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
View 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
View 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
View File

@@ -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
View File

@@ -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
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.4.0",
"version": "0.5.11",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -12,6 +12,7 @@
"@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3",
"@types/cli-table": "^0.3.0",
"@types/ioredis": "^4.26.6",
"@types/mime-types": "^2.1.0",
"@types/mkdirp": "^1.0.1",
"@types/negotiator": "^0.6.1",
@@ -27,9 +28,11 @@
"cli-table": "^0.3.6",
"colors": "^1.4.0",
"dotenv": "^8.2.0",
"ioredis": "^4.27.6",
"mime-types": "^2.1.31",
"mkdirp": "^1.0.4",
"negotiator": "^0.6.2",
"node-fetch": "^3",
"pg": "^8.6.0",
"pluralize": "^8.0.0",
"pug": "^3.0.2",
@@ -45,9 +48,7 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "pnpm run lint && rimraf lib",
"build": "tsc",
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js",
"prepare": "pnpm run build",
"docs:build": "typedoc --options typedoc.json",

2387
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import {ErrorWithContext} from '../util'
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
}

View File

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

View 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,
})
}
}

View 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),
],
}
}
}

View 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,
],
}
}
}

View File

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

View File

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

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

View File

@@ -0,0 +1,155 @@
import {
Authenticatable,
AuthenticatableCredentials,
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(): 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
View 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
}
}

View File

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

View File

@@ -5,15 +5,30 @@ import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {Routing} from '../../service/Routing'
import {Session} from '../../http/session/Session'
@Injectable()
export class AuthRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly session!: Session
async apply(): Promise<ResponseObject> {
if ( !this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
this.session.set('auth.intention', this.request.url)
if ( this.routing.hasNamedRoute('@auth.login') ) {
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

View File

@@ -5,15 +5,24 @@ import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {Routing} from '../../service/Routing'
import {redirect} from '../../http/response/RedirectResponseFactory'
@Injectable()
export class GuestRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
async apply(): Promise<ResponseObject> {
if ( this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -169,8 +169,12 @@ export abstract class Directive extends AppClass {
const optionValues = this.parseOptions(options, argv)
this.setOptionValues(optionValues)
await this.handle(argv)
} catch (e) {
this.nativeOutput(e.message)
} catch (e: unknown) {
if ( e instanceof Error ) {
this.nativeOutput(e.message)
this.error(e)
}
if ( e instanceof OptionValidationError ) {
// expecting, value, requirements
if ( e.context.expecting ) {
@@ -187,6 +191,7 @@ export abstract class Directive extends AppClass {
this.nativeOutput(` - ${e.context.value}`)
}
}
this.nativeOutput('\nUse --help for more info.')
}
}

23
src/cli/decorators.ts Normal file
View 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)
}
}
}

View File

@@ -11,3 +11,5 @@ export * from './directive/options/PositionalOption'
export * from './directive/ShellDirective'
export * from './directive/TemplateDirective'
export * from './directive/UsageDirective'
export * from './decorators'

View File

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

View File

@@ -1,9 +1,19 @@
import {DependencyKey, Instantiable} from './types'
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
import NamedFactory from './factory/NamedFactory'
import {AbstractFactory} from './factory/AbstractFactory'
import {Factory} from './factory/Factory'
import {ClosureFactory} from './factory/ClosureFactory'
/** Simple type alias for a callback to a container's onResolve method. */
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
/**
* Blueprint for newly-created containers.
*
* This is used to allow global helpers like `@Singleton()`
* or `@CLIDirective()` while still supporting multiple
* global Container instances at once.
*/
export class ContainerBlueprint {
private static instance?: ContainerBlueprint
@@ -17,6 +27,19 @@ export class ContainerBlueprint {
protected factories: (() => AbstractFactory<any>)[] = []
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
/**
* Register some factory class with the container. Should take no construction params.
* @param factory
*/
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
this.constructableFactories.push(factory)
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
@@ -47,7 +70,38 @@ export class ContainerBlueprint {
return this
}
/**
* Get an array of factory instances in the blueprint.
*/
resolve(): AbstractFactory<any>[] {
return this.factories.map(x => x())
}
/**
* Register an onResolve callback to be added to all newly-created containers.
* @param key
* @param callback
*/
onResolve<T>(key: TypedDependencyKey<T>, callback: ContainerResolutionCallback<T>): this {
this.resolutionCallbacks.push({
key,
callback,
})
return this
}
/**
* Get an array of static Factory classes that need to be instantiated by
* the container itself.
*/
resolveConstructable(): StaticClass<AbstractFactory<any>, any> {
return [...this.constructableFactories]
}
/**
* Get an array of DependencyKey-callback pairs to register with new containers.
*/
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
return [...this.resolutionCallbacks]
}
}

37
src/di/InjectionAware.ts Normal file
View File

@@ -0,0 +1,37 @@
import {Container} from './Container'
import {TypedDependencyKey} from './types'
/**
* Base class for Injection-aware classes that automatically
* pass along their configured container to instances created
* via their `make` method.
*/
export class InjectionAware {
private ci: Container
constructor() {
this.ci = Container.getContainer()
}
/** Set the container for this instance. */
public setContainer(ci: Container): this {
this.ci = ci
return this
}
/** Get the container for this instance. */
public getContainer(): Container {
return this.ci
}
/** Instantiate a new injectable using the container. */
public make<T>(target: TypedDependencyKey<T>, ...parameters: any[]): T {
const inst = this.ci.make<T>(target, ...parameters)
if ( inst instanceof InjectionAware ) {
inst.setContainer(this.ci)
}
return inst
}
}

View File

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

View File

@@ -13,3 +13,4 @@ export * from './ScopedContainer'
export * from './types'
export * from './decorator/injection'
export * from './InjectionAware'

View File

@@ -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,
}
/**

View File

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

View File

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

View File

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

View File

@@ -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,
) {

View File

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

View File

@@ -138,7 +138,6 @@ export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
})
busboy.on('finish', () => {
this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`)
res()
})

View File

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

View File

@@ -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 ?? '/'

View File

@@ -229,6 +229,9 @@ export class Response {
this.setHeader('Content-Length', String(this.body?.length ?? 0))
}
this.setHeader('Date', (new Date()).toUTCString())
this.setHeader('Permissions-Policy', 'interest-cohort=()')
await this.write(this.body ?? '')
this.end()

View File

@@ -1,6 +1,7 @@
import {ResponseFactory} from './ResponseFactory'
import {Request} from '../lifecycle/Request'
import {ErrorWithContext, UniversalPath} from '../../util'
import {Logging} from '../../service/Logging'
/**
* Helper function that creates a FileResponseFactory for the given path.
@@ -28,6 +29,7 @@ export class FileResponseFactory extends ResponseFactory {
})
}
request.make<Logging>(Logging).debug(`Setting Content-Type of ${this.path} to ${this.path.contentType}...`)
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
request.response.body = await this.path.readStream()

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

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

View File

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

View File

@@ -1,10 +1,10 @@
/**
* Base type for an API response format.
*/
export interface APIResponse {
export interface APIResponse<T> {
success: boolean,
message?: string,
data?: any,
data?: T,
error?: {
name: string,
message: string,
@@ -17,7 +17,7 @@ export interface APIResponse {
* @param {string} displayMessage
* @return APIResponse
*/
export function message(displayMessage: string): APIResponse {
export function message(displayMessage: string): APIResponse<void> {
return {
success: true,
message: displayMessage,
@@ -29,7 +29,7 @@ export function message(displayMessage: string): APIResponse {
* @param record
* @return APIResponse
*/
export function one(record: unknown): APIResponse {
export function one<T>(record: T): APIResponse<T> {
return {
success: true,
data: record,
@@ -41,7 +41,7 @@ export function one(record: unknown): APIResponse {
* @param {array} records
* @return APIResponse
*/
export function many(records: any[]): APIResponse {
export function many<T>(records: T[]): APIResponse<{records: T[], total: number}> {
return {
success: true,
data: {
@@ -56,7 +56,7 @@ export function many(records: any[]): APIResponse {
* @return APIResponse
* @param thrownError
*/
export function error(thrownError: string | Error): APIResponse {
export function error(thrownError: string | Error): APIResponse<void> {
if ( typeof thrownError === 'string' ) {
return {
success: false,

View File

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

View File

@@ -215,6 +215,9 @@ export class Route extends AppClass {
/** Pre-compiled route handler for the main route handler for this route. */
protected compiledPostflight?: ResolvedRouteHandler[]
/** Programmatic aliases of this route. */
public aliases: string[] = []
constructor(
/** The HTTP method(s) that this route listens on. */
protected method: HTTPMethod | HTTPMethod[],
@@ -228,6 +231,15 @@ export class Route extends AppClass {
super()
}
/**
* Set a programmatic name for this route.
* @param name
*/
public alias(name: string): this {
this.aliases.push(name)
return this
}
/**
* Get the string-form of the route.
*/

View File

@@ -5,7 +5,7 @@ import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util'
import {Application} from '../../lifecycle/Application'
import {HTTPError} from '../HTTPError'
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
import {redirect} from '../response/TemporaryRedirectResponseFactory'
import {redirect} from '../response/RedirectResponseFactory'
import {file} from '../response/FileResponseFactory'
import {RouteHandler} from '../routing/Route'
@@ -24,6 +24,9 @@ export interface StaticServerOptions {
/** If specified, files with these extensions will not be served. */
excludedExtensions?: string[]
/** If a file with this name exists in a directory, it will be served. */
indexFile?: string
}
/**
@@ -156,6 +159,22 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler {
// If the resolved path is a directory, send the directory listing response
if ( await filePath.isDirectory() ) {
if ( options.indexFile ) {
const indexFile = filePath.concat(options.indexFile)
if ( await indexFile.exists() ) {
return file(indexFile)
}
}
if ( !options.directoryListing ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
reason: 'Path is a directory, and directory listing is disabled',
})
}
if ( !route.path.endsWith('/') ) {
return redirect(`${route.path}/`)
}

View File

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

View File

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

View File

@@ -42,8 +42,10 @@ export * from './http/response/JSONResponseFactory'
export * from './http/response/ResponseFactory'
export * from './http/response/StringResponseFactory'
export * from './http/response/TemporaryRedirectResponseFactory'
export * from './http/response/RedirectResponseFactory'
export * from './http/response/ViewResponseFactory'
export * from './http/response/FileResponseFactory'
export * from './http/response/RouteResponseFactory'
export * from './http/routing/ActivatedRoute'
export * from './http/routing/Route'
@@ -60,6 +62,9 @@ export * from './http/Controller'
export * from './http/servers/static'
export * from './support/CanonicalReceiver'
export * from './service/Canon'
export * from './service/Canonical'
export * from './service/CanonicalInstantiable'
export * from './service/CanonicalRecursive'
@@ -72,9 +77,14 @@ export * from './service/HTTPServer'
export * from './service/Routing'
export * from './service/Middlewares'
export * from './support/redis/Redis'
export * from './support/cache/MemoryCache'
export * from './support/cache/RedisCache'
export * from './support/cache/CacheFactory'
export * from './support/NodeModules'
export * from './support/queue/Queue'
export * from './service/Queueables'
export * from './views/ViewEngine'
export * from './views/ViewEngineFactory'

View File

@@ -1,5 +1,5 @@
import {Application} from './Application'
import {Container, DependencyKey, Injectable} from '../di'
import {Container, Injectable, TypedDependencyKey} from '../di'
/**
* Base type for a class that supports binding methods by string.
@@ -43,7 +43,7 @@ export class AppClass {
}
/** Call the `make()` method on the global container. */
protected make<T>(target: DependencyKey, ...parameters: any[]): T {
protected make<T>(target: TypedDependencyKey<T>, ...parameters: any[]): T {
return this.container().make<T>(target, ...parameters)
}

View File

@@ -1,4 +1,4 @@
import {Container, ContainerBlueprint} from '../di'
import {Container} from '../di'
import {
ErrorWithContext,
globalRegistry,
@@ -9,12 +9,12 @@ import {
universalPath,
UniversalPath,
} from '../util'
import {Logging} from '../service/Logging'
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
import {Unit, UnitStatus} from './Unit'
import * as dotenv from 'dotenv'
import {CacheFactory} from '../support/cache/CacheFactory'
import {FileLogger} from '../util/logging/FileLogger'
/**
* Helper function that resolves and infers environment variable values.
@@ -57,12 +57,7 @@ export class Application extends Container {
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
const container = Application.realizeContainer(new Application())
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -78,18 +73,12 @@ export class Application extends Container {
if ( existing instanceof Application ) {
return existing
} else if ( existing ) {
const app = new Application()
const app = Application.realizeContainer(new Application())
existing.cloneTo(app)
globalRegistry.setGlobal('extollo/injector', app)
return app
} else {
const app = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => app.registerFactory(factory))
const app = Application.realizeContainer(new Application())
globalRegistry.setGlobal('extollo/injector', app)
return app
}
@@ -225,6 +214,12 @@ export class Application extends Container {
const logging: Logging = this.make<Logging>(Logging)
logging.registerLogger(standard)
if ( this.env('EXTOLLO_LOGGING_ENABLE_FILE') ) {
const file: FileLogger = this.make<FileLogger>(FileLogger)
logging.registerLogger(file)
}
logging.verbose('Attempting to load logging level from the environment...')
const envLevel = this.env('EXTOLLO_LOGGING_LEVEL')
@@ -264,8 +259,12 @@ export class Application extends Container {
try {
await this.up()
await this.down()
} catch (e) {
this.errorHandler(e)
} catch (e: unknown) {
if ( e instanceof Error ) {
this.errorHandler(e)
}
throw e
}
}
@@ -311,9 +310,14 @@ export class Application extends Container {
await unit.up()
unit.status = UnitStatus.Started
logging.info(`Started ${unit.constructor.name}.`)
} catch (e) {
} catch (e: unknown) {
unit.status = UnitStatus.Error
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
if ( e instanceof Error ) {
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
}
throw e
}
}
@@ -332,7 +336,12 @@ export class Application extends Container {
logging.info(`Stopped ${unit.constructor.name}.`)
} catch (e) {
unit.status = UnitStatus.Error
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
if ( e instanceof Error ) {
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
}
throw e
}
}
}

View File

@@ -79,10 +79,13 @@ ${contextDisplay}
}
this.logging.error(errorString, true)
} catch (displayError) {
// The error display encountered an error...
// just throw the original so it makes it out
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
} catch (displayError: unknown) {
if ( displayError instanceof Error ) {
// The error display encountered an error...
// just throw the original so it makes it out
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
}
throw operativeError
}
}

View File

@@ -0,0 +1,39 @@
import {Inject, Injectable} from '../di'
import {ConstraintType, DatabaseService, FieldType, Migration, Schema} from '../orm'
/**
* Migration that creates the sessions table used by the ORMSession backend.
*/
@Injectable()
export default class CreateSessionsTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
async up(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('sessions')
table.primaryKey('session_uuid', FieldType.varchar)
.required()
table.column('session_data')
.type(FieldType.json)
.required()
.default('{}')
table.constraint('session_uuid_ck')
.type(ConstraintType.Check)
.expression('LENGTH(session_uuid) > 0')
await schema.commit(table)
}
async down(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('sessions')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@@ -0,0 +1,47 @@
import {Inject, Injectable} from '../di'
import {DatabaseService, FieldType, Migration, Schema} from '../orm'
/**
* Migration that creates the users table used by @extollo/lib.auth.
*/
@Injectable()
export default class CreateUsersTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
async up(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('users')
table.primaryKey('user_id')
.required()
table.column('first_name')
.type(FieldType.varchar)
.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)
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import {Inject} from '../../di'
import {Inject, Injectable} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {
Constraint, ConstraintConnectionOperator,
@@ -9,7 +9,7 @@ import {
SpecifiedField,
} from '../types'
import {Connection} from '../connection/Connection'
import {deepCopy, ErrorWithContext} from '../../util'
import {deepCopy, ErrorWithContext, Maybe} from '../../util'
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
import {ResultCollection} from './result/ResultCollection'
import {AbstractResultIterable} from './result/AbstractResultIterable'
@@ -24,6 +24,7 @@ export type ConstraintGroupClosure<T> = (group: AbstractBuilder<T>) => any
* A base class that facilitates building database queries using a fluent interface.
* This can be specialized by child-classes to yield query results of the given type `T`.
*/
@Injectable()
export abstract class AbstractBuilder<T> extends AppClass {
@Inject()
protected readonly databaseService!: DatabaseService
@@ -55,6 +56,9 @@ export abstract class AbstractBuilder<T> extends AppClass {
/** The connection on which the query should be executed. */
protected registeredConnection?: Connection
/** Raw SQL to use instead. Overrides builder methods. */
protected rawSql?: string
/**
* Create a new, empty, instance of the current builder.
*/
@@ -65,6 +69,13 @@ export abstract class AbstractBuilder<T> extends AppClass {
*/
public abstract getResultIterable(): AbstractResultIterable<T>
/**
* Get a copy of this builder with its values finalized.
*/
public finalize(): AbstractBuilder<T> {
return this.clone()
}
/**
* Clone the current query to a new AbstractBuilder instance with the same properties.
*/
@@ -80,6 +91,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 +127,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 ) {
@@ -479,7 +496,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`)
}
const query = this.registeredConnection.dialect().renderUpdate(this, data)
const query = this.registeredConnection.dialect().renderUpdate(this.finalize(), data)
return this.registeredConnection.query(query)
}
@@ -505,7 +522,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`)
}
const query = this.registeredConnection.dialect().renderDelete(this)
const query = this.registeredConnection.dialect().renderDelete(this.finalize())
return this.registeredConnection.query(query)
}
@@ -538,7 +555,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`)
}
const query = this.registeredConnection.dialect().renderInsert(this, rowOrRows)
const query = this.registeredConnection.dialect().renderInsert(this.finalize(), rowOrRows)
return this.registeredConnection.query(query)
}
@@ -550,11 +567,26 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`)
}
const query = this.registeredConnection.dialect().renderExistential(this)
const query = this.registeredConnection.dialect().renderExistential(this.finalize())
const result = await this.registeredConnection.query(query)
return Boolean(result.rows.first())
}
/**
* Set the query manually. Overrides any builder methods.
* @example
* ```ts
* (new Builder())
* .raw('SELECT NOW() AS example_column')
* .get()
* ```
* @param sql
*/
raw(sql: string): this {
this.rawSql = sql
return this
}
/**
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
* @param preop

View File

@@ -1,5 +1,5 @@
import {ErrorWithContext} from '../../util'
import {Container} from '../../di'
import {Container, Injectable} from '../../di'
import {ResultIterable} from './result/ResultIterable'
import {QueryRow} from '../types'
import {AbstractBuilder} from './AbstractBuilder'
@@ -8,6 +8,7 @@ import {AbstractResultIterable} from './result/AbstractResultIterable'
/**
* Implementation of the abstract builder class that returns simple QueryRow objects.
*/
@Injectable()
export class Builder extends AbstractBuilder<QueryRow> {
public getNewInstance(): AbstractBuilder<QueryRow> {
return Container.getContainer().make<Builder>(Builder)
@@ -18,6 +19,6 @@ export class Builder extends AbstractBuilder<QueryRow> {
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
}
return Container.getContainer().make<ResultIterable>(ResultIterable, this, this.registeredConnection)
return Container.getContainer().make<ResultIterable>(ResultIterable, this.finalize(), this.registeredConnection)
}
}

View File

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

View File

@@ -2,10 +2,12 @@ import {Connection, ConnectionNotReadyError} from './Connection'
import {Client} from 'pg'
import {Inject} from '../../di'
import {QueryResult} from '../types'
import {collect} from '../../util'
import {Awaitable, collect} from '../../util'
import {SQLDialect} from '../dialect/SQLDialect'
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
import {Logging} from '../../service/Logging'
import {Schema} from '../schema/Schema'
import {PostgresSchema} from '../schema/PostgresSchema'
/**
* Type interface representing the config for a PostgreSQL connection.
@@ -61,10 +63,29 @@ export class PostgresConnection extends Connection {
rowCount: result.rowCount,
}
} catch (e) {
throw this.app().errorWrapContext(e, {
query,
connection: this.name,
})
if ( e instanceof Error ) {
throw this.app().errorWrapContext(e, {
query,
connection: this.name,
})
}
throw e
}
}
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
if ( !this.client ) {
throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(this.config) })
}
await this.client.query('BEGIN')
const result = await closure()
await this.client.query('COMMIT')
return result
}
public schema(name?: string): Schema {
return new PostgresSchema(this, name)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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}`)
}
}

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

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

View File

@@ -18,8 +18,15 @@ export * from './model/ModelResultIterable'
export * from './model/events'
export * from './model/Model'
export * from './services/Database'
export * from './services/Models'
export * from './model/relation/RelationBuilder'
export * from './model/relation/Relation'
export * from './model/relation/HasOneOrMany'
export * from './model/relation/HasOne'
export * from './model/relation/HasMany'
export * from './model/relation/decorators'
export * from './model/scope/Scope'
export * from './model/scope/ActiveScope'
export * from './support/SessionModel'
export * from './support/ORMSession'
@@ -28,3 +35,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'

View File

@@ -0,0 +1,179 @@
import {Container, Inject, Injectable} from '../../di'
import {Migrator} from './Migrator'
import {DatabaseService} from '../DatabaseService'
import {FieldType} from '../types'
import {Migration} from './Migration'
import {Builder} from '../builder/Builder'
/**
* Migrator implementation that tracks applied migrations in a database table.
* @todo allow configuring more of this
*/
@Injectable()
export class DatabaseMigrator extends Migrator {
@Inject()
protected readonly db!: DatabaseService
@Inject('injector')
protected readonly injector!: Container
/** True if we've initialized the migrator. */
protected initialized = false
public async initialize(): Promise<void> {
await super.initialize()
if ( this.initialized ) {
return
}
const schema = this.db.get().schema()
if ( !(await schema.hasTable('migrations')) ) {
const table = await schema.table('migrations')
table.primaryKey('id', FieldType.serial).required()
table.column('identifier')
.type(FieldType.varchar)
.required()
table.column('applygroup')
.type(FieldType.integer)
.required()
table.column('applydate')
.type(FieldType.timestamp)
.required()
await schema.commit(table)
}
this.initialized = true
}
async has(migration: Migration): Promise<boolean> {
return this.builder()
.connection('default')
.select('id')
.from('migrations')
.where('identifier', '=', migration.identifier)
.exists()
}
async markApplied(migrations: Migration | Migration[], applyDate: Date = new Date()): Promise<void> {
if ( !Array.isArray(migrations) ) {
migrations = [migrations]
}
const applyGroup = await this.getNextGroupIdentifier()
const rows = migrations.map(migration => {
return {
applygroup: applyGroup,
applydate: applyDate,
identifier: migration.identifier,
}
})
await this.builder()
.connection('default')
.table('migrations')
.insert(rows)
}
async unmarkApplied(migrations: Migration | Migration[]): Promise<void> {
if ( !Array.isArray(migrations) ) {
migrations = [migrations]
}
const identifiers = migrations.map(migration => migration.identifier)
await this.builder()
.connection('default')
.table('migrations')
.whereIn('identifier', identifiers)
.delete()
}
async getLastApplyGroup(): Promise<string[]> {
const applyGroup = await this.builder()
.connection('default')
.select('applygroup')
.from('migrations')
.get()
.max<number>('applygroup')
return this.builder()
.connection('default')
.select('identifier')
.from('migrations')
.where('applygroup', '=', applyGroup)
.get()
.asyncPipe()
.tap(coll => {
return coll.pluck<string>('identifier')
})
.tap(coll => {
return coll.all()
})
.resolve()
}
/**
* Helper method to look up the next `applygroup` that should be used.
* @protected
*/
protected async getNextGroupIdentifier(): Promise<number> {
const current = await this.builder()
.connection('default')
.select('applygroup')
.from('migrations')
.get()
.max<number>('applygroup')
return (current ?? 0) + 1
}
/**
* Given a list of migration identifiers, filter out those that have been applied.
* @override to make this more efficient
* @param identifiers
* @protected
*/
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
const existing = await this.builder()
.connection('default')
.select('identifier')
.from('migrations')
.whereIn('identifier', identifiers)
.get()
.pluck<string>('identifier')
return identifiers.filter(id => !existing.includes(id))
}
/**
* Given a list of migration identifiers, filter out those that have not been applied.
* @override to make this more efficient
* @param identifiers
* @protected
*/
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> {
const existing = await this.builder()
.connection('default')
.select('identifier')
.from('migrations')
.whereIn('identifier', identifiers)
.get()
.pluck<string>('identifier')
return existing.all()
}
/**
* Get a query builder instance.
* @protected
*/
protected builder(): Builder {
return this.injector.make<Builder>(Builder)
}
}

View File

@@ -0,0 +1,39 @@
import {Injectable} from '../../di'
import {Awaitable} from '../../util'
/**
* Abstract base-class for one-time migrations.
*/
@Injectable()
export abstract class Migration {
/** Set by the Migrations unit on load. */
protected migrationIdentifier!: string
/**
* Sets the migration identifier.
* This is used internally when the Migrations service loads
* the migration files to determine the ID from the file-name.
* It shouldn't be used externally.
* @param name
*/
public setMigrationIdentifier(name: string): void {
this.migrationIdentifier = name
}
/**
* Get the unique identifier of this migration.
*/
public get identifier(): string {
return this.migrationIdentifier
}
/**
* Apply the migration.
*/
abstract up(): Awaitable<void>
/**
* Undo the migration.
*/
abstract down(): Awaitable<void>
}

View File

@@ -0,0 +1,295 @@
import {Container, Inject, Injectable} from '../../di'
import {Awaitable, collect, ErrorWithContext} from '../../util'
import {Migration} from './Migration'
import {Migrations} from '../services/Migrations'
import {EventBus} from '../../event/EventBus'
import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent'
import {AppliedMigrationEvent} from './events/AppliedMigrationEvent'
import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent'
import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent'
import {NothingToMigrateError} from './NothingToMigrateError'
/**
* Manages single-run patches/migrations.
*/
@Injectable()
export abstract class Migrator {
@Inject(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)
}
}

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

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

View 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 {}

View 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 {}

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

View 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 {}

View 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 {}

View File

@@ -1,9 +1,9 @@
import {ModelKey, QueryRow, QuerySource} from '../types'
import {Container, Inject, StaticClass} from '../../di'
import {Container, Inject, Instantiable, isInstantiable, StaticClass} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util'
import {deepCopy, Pipe, Collection, Awaitable, uuid4, isKeyof} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Logging} from '../../service/Logging'
@@ -17,6 +17,11 @@ import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
import {EventBus} from '../../event/EventBus'
import {Relation, RelationValue} from './relation/Relation'
import {HasOne} from './relation/HasOne'
import {HasMany} from './relation/HasMany'
import {HasOneOrMany} from './relation/HasOneOrMany'
import {Scope, ScopeClosure} from './scope/Scope'
/**
* Base for classes that are mapped to tables in a database.
@@ -83,6 +88,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
*/
protected static masks: string[] = []
/**
* Relations that should be eager-loaded by default.
* @protected
*/
protected with: (keyof T)[] = []
/**
* The original row fetched from the database.
* @protected
@@ -95,6 +106,14 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
*/
protected modelEventBusSubscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
/**
* Cache of relation instances by property accessor.
* This is used by the `@Relation()` decorator to cache Relation instances.
*/
public relationCache: Collection<{ accessor: string | symbol, relation: Relation<T, any, any> }> = new Collection<{accessor: string | symbol; relation: Relation<T, any, any>}>()
protected scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
/**
* Get the table name for this model.
*/
@@ -155,6 +174,28 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
builder.field(field.databaseKey)
})
if ( Array.isArray(this.prototype.with) ) {
// Try to get the eager-loaded relations statically, if possible
for (const relation of this.prototype.with) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
builder.with(relation)
}
} else if ( this.constructor.length < 1 ) {
// Otherwise, if we can instantiate the model without any arguments,
// do that and get the eager-loaded relations directly.
const inst = Container.getContainer().make<Model<any>>(this)
if ( Array.isArray(inst.with) ) {
for (const relation of inst.with) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
builder.with(relation)
}
}
}
builder.withScopes(this.prototype.scopes)
return builder
}
@@ -307,6 +348,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
builder.field(field.databaseKey)
})
for ( const relation of this.with ) {
builder.with(relation)
}
builder.withScopes(this.scopes)
return builder
}
@@ -612,6 +659,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()
@@ -625,7 +674,7 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
const data = result.rows.first()
if ( data ) {
await this.assumeFromSource(result)
await this.assumeFromSource(data)
}
await this.dispatch(new ModelCreatedEvent<T>(this as any))
@@ -635,6 +684,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 +857,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 +879,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,
@@ -843,4 +918,208 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
},
}
}
/**
* Create a new one-to-one relation instance. Should be called from a method on the model:
*
* @example
* ```ts
* class MyModel extends Model<MyModel> {
* @Related()
* public otherModel() {
* return this.hasOne(MyOtherModel)
* }
* }
* ```
*
* @param related
* @param foreignKeyOverride
* @param localKeyOverride
*/
public hasOne<T2 extends Model<T2>>(related: Instantiable<T2>, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasOne<T, T2> {
return new HasOne<T, T2>(this as unknown as T, this.make(related), foreignKeyOverride, localKeyOverride)
}
/**
* Create a new one-to-one relation instance. Should be called from a method on the model:
*
* @example
* ```ts
* class MyModel extends Model<MyModel> {
* @Related()
* public otherModels() {
* return this.hasMany(MyOtherModel)
* }
* }
* ```
*
* @param related
* @param foreignKeyOverride
* @param localKeyOverride
*/
public hasMany<T2 extends Model<T2>>(related: Instantiable<T2>, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasMany<T, T2> {
return new HasMany<T, T2>(this as unknown as T, this.make(related), foreignKeyOverride, localKeyOverride)
}
/**
* Create the inverse of a one-to-one relation. Should be called from a method on the model:
*
* @example
* ```ts
* class MyModel extends Model<MyModel> {
* @Related()
* public otherModel() {
* return this.hasOne(MyOtherModel)
* }
* }
*
* class MyOtherModel extends Model<MyOtherModel> {
* @Related()
* public myModel() {
* return this.belongsToOne(MyModel, 'otherModel')
* }
* }
* ```
*
* @param related
* @param relationName
*/
public belongsToOne<T2 extends Model<T2>>(related: Instantiable<T>, relationName: keyof T2): HasOne<T, T2> {
const relatedInst = this.make(related) as T2
const relation = relatedInst.getRelation(relationName)
if ( !(relation instanceof HasOneOrMany) ) {
throw new TypeError(`Cannot create belongs to one relation. Inverse relation must be HasOneOrMany.`)
}
const localKey = relation.localKey
const foreignKey = relation.foreignKey
if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) {
throw new TypeError('Local or foreign keys do not exist on the base model.')
}
return new HasOne<T, T2>(this as unknown as T, relatedInst, localKey, foreignKey)
}
/**
* Create the inverse of a one-to-many relation. Should be called from a method on the model:
*
* @example
* ```ts
* class MyModel extends Model<MyModel> {
* @Related()
* public otherModels() {
* return this.hasMany(MyOtherModel)
* }
* }
*
* class MyOtherModel extends Model<MyOtherModel> {
* @Related()
* public myModels() {
* return this.belongsToMany(MyModel, 'otherModels')
* }
* }
* ```
*
* @param related
* @param relationName
*/
public belongsToMany<T2 extends Model<T2>>(related: Instantiable<T>, relationName: keyof T2): HasMany<T, T2> {
const relatedInst = this.make(related) as T2
const relation = relatedInst.getRelation(relationName)
if ( !(relation instanceof HasOneOrMany) ) {
throw new TypeError(`Cannot create belongs to one relation. Inverse relation must be HasOneOrMany.`)
}
const localKey = relation.localKey
const foreignKey = relation.foreignKey
if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) {
throw new TypeError('Local or foreign keys do not exist on the base model.')
}
return new HasMany<T, T2>(this as unknown as T, relatedInst, localKey, foreignKey)
}
/**
* Get the relation instance returned by a method on this model.
* @param name
* @protected
*/
public getRelation<T2 extends Model<T2>>(name: keyof this): Relation<T, T2, RelationValue<T2>> {
const relFn = this[name]
if ( relFn instanceof Relation ) {
return relFn
}
if ( typeof relFn === 'function' ) {
const rel = relFn.apply(relFn, this)
if ( rel instanceof Relation ) {
return rel
}
}
throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`)
}
/**
* Register a scope on the model.
* @param scope
* @protected
*/
protected scope(scope: Instantiable<Scope> | ScopeClosure): this {
if ( isInstantiable(scope) ) {
if ( !this.hasScope(scope) ) {
this.scopes.push({
accessor: scope,
scope: builder => (this.make<Scope>(scope)).apply(builder),
})
}
} else {
this.scopes.push({
accessor: uuid4(),
scope,
})
}
return this
}
/**
* Register a scope on the model with a specific name.
* @param name
* @param scope
* @protected
*/
protected namedScope(name: string, scope: Instantiable<Scope> | ScopeClosure): this {
if ( isInstantiable(scope) ) {
if ( !this.hasScope(scope) ) {
this.scopes.push({
accessor: name,
scope: builder => (this.make<Scope>(scope)).apply(builder),
})
}
} else {
this.scopes.push({
accessor: name,
scope,
})
}
return this
}
/**
* Returns true if the current model has a scope with the given identifier.
* @param name
* @protected
*/
protected hasScope(name: string | Instantiable<Scope>): boolean {
return Boolean(this.scopes.firstWhere('accessor', '=', name))
}
}

View File

@@ -1,25 +1,136 @@
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'
import {Scope, ScopeClosure} from './scope/Scope'
/**
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
*/
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
protected eagerLoadRelations: (keyof T)[] = []
protected appliedScopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
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()
}
public withScopes(scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }>): this {
this.appliedScopes = scopes.clone()
return this
}
public getNewInstance(): AbstractBuilder<T> {
return this.app().make<ModelBuilder<T>>(ModelBuilder)
return this.app().make<ModelBuilder<T>>(ModelBuilder, this.ModelClass)
}
public getResultIterable(): AbstractResultIterable<T> {
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass)
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this.finalize(), this.registeredConnection, this.ModelClass)
}
/**
* Get a copy of this builder with all of its values finalized.
* @override to apply scopes
*/
public finalize(): AbstractBuilder<T> {
const inst = super.finalize()
this.appliedScopes.each(rec => rec.scope(inst))
return inst
}
/**
* 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,
)
}
/**
* Mark a relation to be eager-loaded.
* @param relationName
*/
public with(relationName: keyof T): this {
if ( !this.eagerLoadRelations.includes(relationName) ) {
// Try to load the Relation so we fail if the name is invalid
this.make<T>(this.ModelClass).getRelation(relationName)
this.eagerLoadRelations.push(relationName)
}
return this
}
/**
* Remove all global scopes from this query.
*/
public withoutGlobalScopes(): this {
this.appliedScopes = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
return this
}
/**
* Remove a specific scope from this query by its identifier.
* @param name
*/
public withoutGlobalScope(name: string | Instantiable<Scope>): this {
this.appliedScopes = this.appliedScopes.where('accessor', '=', name)
return this
}
/** Get the list of relations to eager-load. */
public getEagerLoadedRelations(): (keyof T)[] {
return [...this.eagerLoadRelations]
}
/**
* 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]
}
/**
* Create a copy of this builder.
* @override to add implementation-specific pass-alongs.
*/
public clone(): ModelBuilder<T> {
const inst = super.clone() as ModelBuilder<T>
inst.eagerLoadRelations = [...this.eagerLoadRelations]
inst.appliedScopes = this.appliedScopes.clone()
return inst
}
}

View File

@@ -4,7 +4,7 @@ import {Connection} from '../connection/Connection'
import {ModelBuilder} from './ModelBuilder'
import {Container, Instantiable} from '../../di'
import {QueryRow} from '../types'
import {Collection} from '../../util'
import {collect, Collection} from '../../util'
/**
* Implementation of the result iterable that returns query results as instances of the defined model.
@@ -28,13 +28,17 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
const row = (await this.connection.query(query)).rows.first()
if ( row ) {
return this.inflateRow(row)
const inflated = await this.inflateRow(row)
await this.processEagerLoads(collect([inflated]))
return inflated
}
}
async range(start: number, end: number): Promise<Collection<T>> {
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
return (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row))
const inflated = await (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row))
await this.processEagerLoads(inflated)
return inflated
}
async count(): Promise<number> {
@@ -45,7 +49,9 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
async all(): Promise<Collection<T>> {
const result = await this.connection.query(this.selectSQL)
return result.rows.promiseMap<T>(row => this.inflateRow(row))
const inflated = await result.rows.promiseMap<T>(row => this.inflateRow(row))
await this.processEagerLoads(inflated)
return inflated
}
/**
@@ -58,6 +64,30 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
.assumeFromSource(row)
}
/**
* Eager-load eager-loaded relations for the models in the query result.
* @param results
* @protected
*/
protected async processEagerLoads(results: Collection<T>): Promise<void> {
const eagers = this.builder.getEagerLoadedRelations()
const model = this.make<T>(this.ModelClass)
for ( const name of eagers ) {
// TODO support nested eager loads?
const relation = model.getRelation(name)
const select = relation.buildEagerQuery(this.builder, results)
const allRelated = await select.get().collect()
allRelated.each(result => {
const resultRelation = result.getRelation(name as any)
const resultRelated = resultRelation.matchResults(allRelated as any)
resultRelation.setValue(resultRelated as any)
})
}
}
clone(): ModelResultIterable<T> {
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
}

View File

@@ -0,0 +1,47 @@
import {Model} from '../Model'
import {HasOneOrMany} from './HasOneOrMany'
import {Collection} from '../../../util'
import {RelationNotLoadedError} from './Relation'
/**
* One-to-many relation implementation.
*/
export class HasMany<T extends Model<T>, T2 extends Model<T2>> extends HasOneOrMany<T, T2, Collection<T2>> {
protected cachedValue?: Collection<T2>
protected cachedLoaded = false
constructor(
parent: T,
related: T2,
foreignKeyOverride?: keyof T & string,
localKeyOverride?: keyof T2 & string,
) {
super(parent, related, foreignKeyOverride, localKeyOverride)
}
/** Resolve the result of this relation. */
public get(): Promise<Collection<T2>> {
return this.fetch().collect()
}
/** Set the value of this relation. */
public setValue(related: Collection<T2>): void {
this.cachedValue = related.clone()
this.cachedLoaded = true
}
/** Get the value of this relation. */
public getValue(): Collection<T2> {
if ( !this.cachedValue ) {
throw new RelationNotLoadedError()
}
return this.cachedValue
}
/** Returns true if the relation has been loaded. */
public isLoaded(): boolean {
return this.cachedLoaded
}
}

View File

@@ -0,0 +1,47 @@
import {Model} from '../Model'
import {HasOneOrMany} from './HasOneOrMany'
import {RelationNotLoadedError} from './Relation'
import {Maybe} from '../../../util'
/**
* One-to-one relation implementation.
*/
export class HasOne<T extends Model<T>, T2 extends Model<T2>> extends HasOneOrMany<T, T2, Maybe<T2>> {
protected cachedValue?: T2
protected cachedLoaded = false
constructor(
parent: T,
related: T2,
foreignKeyOverride?: keyof T & string,
localKeyOverride?: keyof T2 & string,
) {
super(parent, related, foreignKeyOverride, localKeyOverride)
}
/** Resolve the result of this relation. */
async get(): Promise<Maybe<T2>> {
return this.fetch().first()
}
/** Set the value of this relation. */
public setValue(related: T2): void {
this.cachedValue = related
this.cachedLoaded = true
}
/** Get the value of this relation. */
public getValue(): Maybe<T2> {
if ( !this.cachedLoaded ) {
throw new RelationNotLoadedError()
}
return this.cachedValue
}
/** Returns true if the relation has been loaded. */
public isLoaded(): boolean {
return this.cachedLoaded
}
}

View File

@@ -0,0 +1,80 @@
import {Model} from '../Model'
import {Relation, RelationValue} from './Relation'
import {RelationBuilder} from './RelationBuilder'
import {raw} from '../../dialect/SQLDialect'
import {AbstractBuilder} from '../../builder/AbstractBuilder'
import {ModelBuilder} from '../ModelBuilder'
import {Collection, toString} from '../../../util'
/**
* Base class for 1:1 and 1:M relations.
*/
export abstract class HasOneOrMany<T extends Model<T>, T2 extends Model<T2>, V extends RelationValue<T2>> extends Relation<T, T2, V> {
protected constructor(
parent: T,
related: T2,
/** Override the foreign key property. */
protected foreignKeyOverride?: keyof T & string,
/** Override the local key property. */
protected localKeyOverride?: keyof T2 & string,
) {
super(parent, related)
}
/** Get the name of the foreign key for this relation. */
public get foreignKey(): string {
return this.foreignKeyOverride || this.parent.keyName()
}
/** Get the name of the local key for this relation. */
public get localKey(): string {
return this.localKeyOverride || this.foreignKey
}
/** Get the fully-qualified name of the foreign key. */
public get qualifiedForeignKey(): string {
return this.related.qualify(this.foreignKey)
}
/** Get the fully-qualified name of the local key. */
public get qualifiedLocalKey(): string {
return this.related.qualify(this.localKey)
}
/** Get the value of the pivot for this relation from the parent model. */
public get parentValue(): any {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return this.parent[this.localKey]
}
/** Create a new query for this relation. */
public query(): RelationBuilder<T2> {
return this.builder().select(raw('*'))
}
/** Apply the relation's constraints on a model query. */
public applyScope(where: AbstractBuilder<T2>): void {
where.where(subq => {
subq.where(this.qualifiedForeignKey, '=', this.parentValue)
.whereRaw(this.qualifiedForeignKey, 'IS NOT', 'NULL')
})
}
/** Create an eager-load query matching this relation's models. */
public buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T2> {
const keys = result.pluck(this.localKey as keyof T)
.map(toString)
.all()
return this.related.query()
.whereIn(this.foreignKey, keys)
}
/** Given a collection of results, filter out those that are relevant to this relation. */
public matchResults(possiblyRelated: Collection<T>): Collection<T> {
return possiblyRelated.where(this.foreignKey as keyof T, '=', this.parentValue)
}
}

View File

@@ -0,0 +1,111 @@
import {Model} from '../Model'
import {ModelBuilder} from '../ModelBuilder'
import {AbstractBuilder} from '../../builder/AbstractBuilder'
import {ResultCollection} from '../../builder/result/ResultCollection'
import {Collection, ErrorWithContext, Maybe} from '../../../util'
import {QuerySource} from '../../types'
import {RelationBuilder} from './RelationBuilder'
import {InjectionAware} from '../../../di'
/** Type alias for possible values of a relation. */
export type RelationValue<T2> = Maybe<Collection<T2> | T2>
/** Error thrown when a relation result is accessed synchronously before it is loaded. */
export class RelationNotLoadedError extends ErrorWithContext {
constructor(
context: {[key: string]: any} = {},
) {
super('Attempted to get value of relation that has not yet been loaded.', context)
}
}
/**
* Base class for inter-model relation implementations.
*/
export abstract class Relation<T extends Model<T>, T2 extends Model<T2>, V extends RelationValue<T2>> extends InjectionAware {
protected constructor(
/** The model related from. */
protected parent: T,
/** The model related to. */
public readonly related: T2,
) {
super()
}
/** Get the value of the key field from the parent model. */
protected abstract get parentValue(): any
/** Create a new relation builder query for this relation instance. */
public abstract query(): RelationBuilder<T2>
/** Limit the results of the builder to only this relation's rows. */
public abstract applyScope(where: AbstractBuilder<T2>): void
/** Create a relation query that will eager-load the result of this relation for a set of models. */
public abstract buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T2>
/** Given a set of possibly-related instances, filter out the ones that are relevant to the parent. */
public abstract matchResults(possiblyRelated: Collection<T>): Collection<T>
/** Set the value of the relation. */
public abstract setValue(related: V): void
/** Get the value of the relation. */
public abstract getValue(): V
/** Returns true if the relation has been loaded. */
public abstract isLoaded(): boolean
/** Get a collection of the results of this relation. */
public fetch(): ResultCollection<T2> {
return this.query().get()
}
/** Resolve the result of this relation. */
public abstract get(): Promise<V>
/**
* Makes the relation "thenable" so relation methods on models can be awaited
* to yield the result of the relation.
*
* @example
* ```ts
* const rows = await myModelInstance.myHasManyRelation() -- rows is a Collection
* ```
*
* @param resolve
* @param reject
*/
public then(resolve: (result: V) => unknown, reject: (e: Error) => unknown): void {
if ( this.isLoaded() ) {
resolve(this.getValue())
} else {
this.get()
.then(result => {
if ( result instanceof Collection ) {
this.setValue(result)
}
resolve(result)
})
.catch(reject)
}
}
/** Get the value of this relation. */
public get value(): V {
return this.getValue()
}
/** Get the query source for the related model in this relation. */
public get relatedQuerySource(): QuerySource {
const related = this.related.constructor as typeof Model
return related.querySource()
}
/** Get a new builder instance for this relation. */
public builder(): RelationBuilder<T2> {
return this.make(RelationBuilder, this)
}
}

View File

@@ -0,0 +1,14 @@
import {Model} from '../Model'
import {ModelBuilder} from '../ModelBuilder'
import {Relation} from './Relation'
/**
* ModelBuilder instance that queries the related model in a relation.
*/
export class RelationBuilder<T extends Model<T>> extends ModelBuilder<T> {
constructor(
protected relation: Relation<any, T, any>,
) {
super(relation.related.constructor as any)
}
}

View File

@@ -0,0 +1,37 @@
import {Model} from '../Model'
/**
* Decorator for relation methods on a Model implementation.
* Caches the relation instances between uses for the life of the model.
* @constructor
*/
export function Related(): MethodDecorator {
return (target, propertyKey, descriptor) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const original = descriptor.value
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
descriptor.value = function(...args) {
const model = this as Model<any>
const cache = model.relationCache
const existing = cache.firstWhere('accessor', '=', propertyKey)
if ( existing ) {
return existing.relation
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const value = original.apply(this, args)
cache.push({
accessor: propertyKey,
relation: value,
})
return value
}
}
}

View File

@@ -0,0 +1,11 @@
import {Scope} from './Scope'
import {AbstractBuilder} from '../../builder/AbstractBuilder'
/**
* A basic scope to limit results where `active` = true.
*/
export class ActiveScope extends Scope {
apply(query: AbstractBuilder<any>): void {
query.where('active', '=', true)
}
}

View File

@@ -0,0 +1,18 @@
import {Injectable, InjectionAware} from '../../../di'
import {Awaitable} from '../../../util'
import {AbstractBuilder} from '../../builder/AbstractBuilder'
/**
* A closure that takes a query and applies some scope to it.
*/
export type ScopeClosure = (query: AbstractBuilder<any>) => Awaitable<void>
/**
* Base class for scopes that can be applied to queries.
*/
@Injectable()
export abstract class Scope extends InjectionAware {
abstract apply(query: AbstractBuilder<any>): Awaitable<void>
}

View 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()
}
}

View File

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

View File

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

View File

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

View 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),
}
}
}

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More