Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d245d15ad6 | |||
| 265837b5cd | |||
| fe0b4d6d8f | |||
| ce1d22ff44 | |||
| b7bfb3e153 | |||
| e57819d318 | |||
| 0a9dd30909 | |||
| d92c8b5409 | |||
| 589cb7d579 | |||
| 3680ad1914 | |||
| 96e13d85fc | |||
| 5a9283ad85 | |||
| b1ea489ccb | |||
| c3f2779650 | |||
| 248b24e612 | |||
| b4a9057e2b | |||
| c078d695a8 | |||
| 55ffadc742 | |||
| 56574d43ce | |||
| e16f02ce12 | |||
| c34fad3502 | |||
| 156006053b | |||
| 22cf6aa953 | |||
| b35eb8d6a1 | |||
| 9ee4c42e43 | |||
| 8d1dcc87fb | |||
| 3efbfecf9d | |||
| a1d04d652e | |||
| 5940b6e2b3 | |||
|
074a3187eb
|
15
.drone.yml
15
.drone.yml
@@ -22,7 +22,7 @@ steps:
|
|||||||
from_secret: docs_deploy_key
|
from_secret: docs_deploy_key
|
||||||
port: 22
|
port: 22
|
||||||
source: extollo_api_documentation.tar.gz
|
source: extollo_api_documentation.tar.gz
|
||||||
target: /var/nfs/general/static/sites/extollo
|
target: /var/nfs/storage/static/sites/extollo
|
||||||
when:
|
when:
|
||||||
event: promote
|
event: promote
|
||||||
target: docs
|
target: docs
|
||||||
@@ -38,7 +38,7 @@ steps:
|
|||||||
from_secret: docs_deploy_key
|
from_secret: docs_deploy_key
|
||||||
port: 22
|
port: 22
|
||||||
script:
|
script:
|
||||||
- cd /var/nfs/general/static/sites/extollo
|
- cd /var/nfs/storage/static/sites/extollo
|
||||||
- rm -rf docs
|
- rm -rf docs
|
||||||
- tar xzf extollo_api_documentation.tar.gz
|
- tar xzf extollo_api_documentation.tar.gz
|
||||||
- rm -rf extollo_api_documentation.tar.gz
|
- rm -rf extollo_api_documentation.tar.gz
|
||||||
@@ -103,10 +103,19 @@ steps:
|
|||||||
event:
|
event:
|
||||||
exclude: tag
|
exclude: tag
|
||||||
|
|
||||||
- name: build module
|
- name: Install dependencies
|
||||||
image: glmdev/node-pnpm:latest
|
image: glmdev/node-pnpm:latest
|
||||||
commands:
|
commands:
|
||||||
- pnpm i
|
- pnpm i
|
||||||
|
|
||||||
|
- name: Lint code
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
|
commands:
|
||||||
|
- pnpm lint
|
||||||
|
|
||||||
|
- name: build module
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
|
commands:
|
||||||
- pnpm build
|
- pnpm build
|
||||||
- mkdir artifacts
|
- mkdir artifacts
|
||||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
- tar czf artifacts/extollo-lib.tar.gz lib
|
||||||
|
|||||||
55
.idea/codeStyles/Project.xml
generated
Normal file
55
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<editorconfig>
|
||||||
|
<option name="ENABLED" value="false" />
|
||||||
|
</editorconfig>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
|
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||||
|
<option name="IF_BRACE_FORCE" value="3" />
|
||||||
|
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="FOR_BRACE_FORCE" value="3" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="PHP">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
<option name="SMART_TABS" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Shell Script">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="4" />
|
||||||
|
<option name="TAB_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
|
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||||
|
<option name="IF_BRACE_FORCE" value="3" />
|
||||||
|
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="FOR_BRACE_FORCE" value="3" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
19
.idea/dataSources.xml
generated
Normal file
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
|
||||||
|
<driver-ref>mongo</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||||
|
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
.idea/lib.iml
generated
1
.idea/lib.iml
generated
@@ -4,5 +4,6 @@
|
|||||||
<content url="file://$MODULE_DIR$" />
|
<content url="file://$MODULE_DIR$" />
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="module" module-name="extollo" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
1
.idea/modules.xml
generated
1
.idea/modules.xml
generated
@@ -2,6 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<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" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@extollo/lib",
|
"name": "@extollo/lib",
|
||||||
"version": "0.5.0",
|
"version": "0.5.11",
|
||||||
"description": "The framework library that lifts up your code.",
|
"description": "The framework library that lifts up your code.",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/busboy": "^0.2.3",
|
"@types/busboy": "^0.2.3",
|
||||||
"@types/cli-table": "^0.3.0",
|
"@types/cli-table": "^0.3.0",
|
||||||
|
"@types/ioredis": "^4.26.6",
|
||||||
"@types/mime-types": "^2.1.0",
|
"@types/mime-types": "^2.1.0",
|
||||||
"@types/mkdirp": "^1.0.1",
|
"@types/mkdirp": "^1.0.1",
|
||||||
"@types/negotiator": "^0.6.1",
|
"@types/negotiator": "^0.6.1",
|
||||||
@@ -27,9 +28,11 @@
|
|||||||
"cli-table": "^0.3.6",
|
"cli-table": "^0.3.6",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
|
"ioredis": "^4.27.6",
|
||||||
"mime-types": "^2.1.31",
|
"mime-types": "^2.1.31",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"negotiator": "^0.6.2",
|
"negotiator": "^0.6.2",
|
||||||
|
"node-fetch": "^3",
|
||||||
"pg": "^8.6.0",
|
"pg": "^8.6.0",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
@@ -45,9 +48,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"prebuild": "pnpm run lint && rimraf lib",
|
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||||
"build": "tsc",
|
|
||||||
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
|
||||||
"app": "tsc && node lib/index.js",
|
"app": "tsc && node lib/index.js",
|
||||||
"prepare": "pnpm run build",
|
"prepare": "pnpm run build",
|
||||||
"docs:build": "typedoc --options typedoc.json",
|
"docs:build": "typedoc --options typedoc.json",
|
||||||
|
|||||||
2387
pnpm-lock.yaml
generated
2387
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import {ErrorWithContext} from '../util'
|
||||||
|
|
||||||
|
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import {Inject, Injectable} from '../di'
|
import {Inject, Injectable} from '../di'
|
||||||
import {EventBus} from '../event/EventBus'
|
import {EventBus} from '../event/EventBus'
|
||||||
import {Awaitable, Maybe} from '../util'
|
import {Awaitable, Maybe} from '../util'
|
||||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base-class for a context that authenticates users and manages security.
|
* Base-class for a context that authenticates users and manages security.
|
||||||
@@ -14,6 +15,9 @@ export abstract class SecurityContext {
|
|||||||
@Inject()
|
@Inject()
|
||||||
protected readonly bus!: EventBus
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
/** The currently authenticated user, if one exists. */
|
/** The currently authenticated user, if one exists. */
|
||||||
private authenticatedUser?: Authenticatable
|
private authenticatedUser?: Authenticatable
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ export abstract class SecurityContext {
|
|||||||
* unauthenticated implicitly.
|
* unauthenticated implicitly.
|
||||||
* @param credentials
|
* @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)
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
if ( user ) {
|
if ( user ) {
|
||||||
await this.authenticateOnce(user)
|
await this.authenticateOnce(user)
|
||||||
@@ -71,7 +75,7 @@ export abstract class SecurityContext {
|
|||||||
* authentication will be persisted.
|
* authentication will be persisted.
|
||||||
* @param credentials
|
* @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)
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
if ( user ) {
|
if ( user ) {
|
||||||
await this.authenticate(user)
|
await this.authenticate(user)
|
||||||
@@ -108,6 +112,8 @@ export abstract class SecurityContext {
|
|||||||
*/
|
*/
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
const credentials = await this.getCredentials()
|
const credentials = await this.getCredentials()
|
||||||
|
this.logging.debug('resume:')
|
||||||
|
this.logging.debug(credentials)
|
||||||
const user = await this.repository.getByCredentials(credentials)
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
if ( user ) {
|
if ( user ) {
|
||||||
this.authenticatedUser = user
|
this.authenticatedUser = user
|
||||||
@@ -125,7 +131,7 @@ export abstract class SecurityContext {
|
|||||||
* Get the credentials for the current user from whatever storage medium
|
* Get the credentials for the current user from whatever storage medium
|
||||||
* the context's host provides.
|
* the context's host provides.
|
||||||
*/
|
*/
|
||||||
abstract getCredentials(): Awaitable<Record<string, string>>
|
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently authenticated user, if one exists.
|
* 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.
|
* Returns true if there is a currently authenticated user.
|
||||||
*/
|
*/
|
||||||
hasUser(): boolean {
|
hasUser(): boolean {
|
||||||
|
this.logging.debug('hasUser?')
|
||||||
|
this.logging.debug(this.authenticatedUser)
|
||||||
return Boolean(this.authenticatedUser)
|
return Boolean(this.authenticatedUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {Controller} from '../../http/Controller'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||||
|
import {Request} from '../../http/lifecycle/Request'
|
||||||
|
import {view} from '../../http/response/ViewResponseFactory'
|
||||||
|
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import {Valid, ValidationError} from '../../forms'
|
||||||
|
import {AuthenticatableCredentials} from '../types'
|
||||||
|
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||||
|
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||||
|
import {Session} from '../../http/session/Session'
|
||||||
|
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BasicLoginController extends Controller {
|
||||||
|
public static routes({ enableRegistration = true } = {}): void {
|
||||||
|
Route.group('auth', () => {
|
||||||
|
Route.get('login', (request: Request) => {
|
||||||
|
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||||
|
return controller.getLogin()
|
||||||
|
})
|
||||||
|
.pre('@auth:guest')
|
||||||
|
.alias('@auth.login')
|
||||||
|
|
||||||
|
Route.post('login', (request: Request) => {
|
||||||
|
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||||
|
return controller.attemptLogin()
|
||||||
|
})
|
||||||
|
.pre('@auth:guest')
|
||||||
|
.alias('@auth.login.attempt')
|
||||||
|
|
||||||
|
Route.any('logout', (request: Request) => {
|
||||||
|
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||||
|
return controller.attemptLogout()
|
||||||
|
})
|
||||||
|
.pre('@auth:required')
|
||||||
|
.alias('@auth.logout')
|
||||||
|
|
||||||
|
if ( enableRegistration ) {
|
||||||
|
Route.get('register', (request: Request) => {
|
||||||
|
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||||
|
return controller.getRegistration()
|
||||||
|
})
|
||||||
|
.pre('@auth:guest')
|
||||||
|
.alias('@auth.register')
|
||||||
|
|
||||||
|
Route.post('register', (request: Request) => {
|
||||||
|
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||||
|
return controller.attemptRegister()
|
||||||
|
})
|
||||||
|
.pre('@auth:guest')
|
||||||
|
.alias('@auth.register.attempt')
|
||||||
|
}
|
||||||
|
}).pre('@auth:web')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly session!: Session
|
||||||
|
|
||||||
|
public getLogin(): ResponseFactory {
|
||||||
|
return this.getLoginView()
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRegistration(): ResponseFactory {
|
||||||
|
return this.getRegistrationView()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptLogin(): Promise<ResponseObject> {
|
||||||
|
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||||
|
const user = await this.security.attempt(data)
|
||||||
|
if ( user ) {
|
||||||
|
const intention = this.session.get('auth.intention', '/')
|
||||||
|
this.session.forget('auth.intention')
|
||||||
|
return temporary(intention)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getLoginView(['Invalid username/password.'])
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof ValidationError ) {
|
||||||
|
return this.getLoginView(e.errors.all())
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptLogout(): Promise<ResponseObject> {
|
||||||
|
await this.security.flush()
|
||||||
|
return this.getMessageView('You have been logged out.')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptRegister(): Promise<ResponseObject> {
|
||||||
|
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||||
|
const user = await this.security.repository.createByCredentials(data)
|
||||||
|
await this.security.authenticate(user)
|
||||||
|
|
||||||
|
const intention = this.session.get('auth.intention', '/')
|
||||||
|
this.session.forget('auth.intention')
|
||||||
|
return temporary(intention)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof ValidationError ) {
|
||||||
|
return this.getRegistrationView(e.errors.all())
|
||||||
|
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
|
||||||
|
return this.getRegistrationView(['A user with that username already exists.'])
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getLoginView(errors?: string[]): ResponseFactory {
|
||||||
|
return view('@extollo:auth:login', {
|
||||||
|
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
||||||
|
return view('@extollo:auth:register', {
|
||||||
|
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getMessageView(message: string): ResponseFactory {
|
||||||
|
return view('@extollo:auth:message', {
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import {FormRequest, ValidationRules} from '../../forms'
|
import {FormRequest, ValidationRules} from '../../forms'
|
||||||
import {Is, Str} from '../../forms/rules/rules'
|
import {Is, Str} from '../../forms/rules/rules'
|
||||||
import {Singleton} from '../../di'
|
import {Singleton} from '../../di'
|
||||||
|
import {AuthenticatableCredentials} from '../types'
|
||||||
export interface BasicLoginCredentials {
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton()
|
@Singleton()
|
||||||
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
|
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||||
protected getRules(): ValidationRules {
|
protected getRules(): ValidationRules {
|
||||||
return {
|
return {
|
||||||
username: [
|
identifier: [
|
||||||
Is.required,
|
Is.required,
|
||||||
Str.lengthMin(1),
|
Str.lengthMin(1),
|
||||||
],
|
],
|
||||||
password: [
|
credential: [
|
||||||
Is.required,
|
Is.required,
|
||||||
Str.lengthMin(1),
|
Str.lengthMin(1),
|
||||||
],
|
],
|
||||||
|
|||||||
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {FormRequest, ValidationRules} from '../../forms'
|
||||||
|
import {Is, Str} from '../../forms/rules/rules'
|
||||||
|
import {Singleton} from '../../di'
|
||||||
|
import {AuthenticatableCredentials} from '../types'
|
||||||
|
|
||||||
|
@Singleton()
|
||||||
|
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||||
|
protected getRules(): ValidationRules {
|
||||||
|
return {
|
||||||
|
identifier: [
|
||||||
|
Is.required,
|
||||||
|
Str.lengthMin(1),
|
||||||
|
Str.alphaNum,
|
||||||
|
],
|
||||||
|
credential: [
|
||||||
|
Is.required,
|
||||||
|
Str.lengthMin(8),
|
||||||
|
Str.confirmed,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Instantiable} from '../di'
|
import {Instantiable} from '../di'
|
||||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||||
|
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||||
@@ -21,5 +22,8 @@ export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
|||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
repositories: {
|
repositories: {
|
||||||
session: keyof AuthenticatableRepositoryMapping,
|
session: keyof AuthenticatableRepositoryMapping,
|
||||||
}
|
},
|
||||||
|
sources?: {
|
||||||
|
[key: string]: OAuth2LoginConfig,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {SecurityContext} from '../SecurityContext'
|
|||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {Session} from '../../http/session/Session'
|
import {Session} from '../../http/session/Session'
|
||||||
import {Awaitable} from '../../util'
|
import {Awaitable} from '../../util'
|
||||||
import {AuthenticatableRepository} from '../types'
|
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security context implementation that uses the session as storage.
|
* Security context implementation that uses the session as storage.
|
||||||
@@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
|
|||||||
super(repository, 'session')
|
super(repository, 'session')
|
||||||
}
|
}
|
||||||
|
|
||||||
getCredentials(): Awaitable<Record<string, string>> {
|
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||||
return {
|
return {
|
||||||
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
|
identifier: '',
|
||||||
|
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import {Controller} from '../../../http/Controller'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
|
import {Request} from '../../../http/lifecycle/Request'
|
||||||
|
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||||
|
import {ErrorWithContext} from '../../../util'
|
||||||
|
import {OAuth2Repository} from './OAuth2Repository'
|
||||||
|
import {json} from '../../../http/response/JSONResponseFactory'
|
||||||
|
|
||||||
|
export interface OAuth2LoginConfig {
|
||||||
|
name: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
redirectUrl: string,
|
||||||
|
authorizationCodeField: string,
|
||||||
|
tokenEndpoint: string,
|
||||||
|
tokenEndpointMapping?: {
|
||||||
|
clientId?: string,
|
||||||
|
clientSecret?: string,
|
||||||
|
grantType?: string,
|
||||||
|
codeKey?: string,
|
||||||
|
},
|
||||||
|
tokenEndpointResponseMapping?: {
|
||||||
|
token?: string,
|
||||||
|
expiresIn?: string,
|
||||||
|
expiresAt?: string,
|
||||||
|
},
|
||||||
|
userEndpoint: string,
|
||||||
|
userEndpointResponseMapping?: {
|
||||||
|
identifier?: string,
|
||||||
|
display?: string,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
|
||||||
|
return (
|
||||||
|
Boolean(what)
|
||||||
|
&& typeof (what as any).name === 'string'
|
||||||
|
&& typeof (what as any).clientId === 'string'
|
||||||
|
&& typeof (what as any).clientSecret === 'string'
|
||||||
|
&& typeof (what as any).redirectUrl === 'string'
|
||||||
|
&& typeof (what as any).authorizationCodeField === 'string'
|
||||||
|
&& typeof (what as any).tokenEndpoint === 'string'
|
||||||
|
&& typeof (what as any).userEndpoint === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OAuth2LoginController extends Controller {
|
||||||
|
public static routes(configName: string): void {
|
||||||
|
Route.group(`/auth/${configName}`, () => {
|
||||||
|
Route.get('login', (request: Request) => {
|
||||||
|
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
|
||||||
|
return controller.getLogin()
|
||||||
|
}).pre('@auth:guest')
|
||||||
|
}).pre('@auth:web')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected readonly request: Request,
|
||||||
|
protected readonly configName: string,
|
||||||
|
) {
|
||||||
|
super(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogin(): Promise<ResponseObject> {
|
||||||
|
const repo = this.getRepository()
|
||||||
|
if ( repo.shouldRedirect() ) {
|
||||||
|
return repo.redirect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// We were redirected from the auth source
|
||||||
|
const user = await repo.redeem()
|
||||||
|
return json(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getRepository(): OAuth2Repository {
|
||||||
|
return this.request.make(OAuth2Repository, this.getConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getConfig(): OAuth2LoginConfig {
|
||||||
|
const config = this.config.get(`auth.sources.${this.configName}`)
|
||||||
|
if ( !isOAuth2LoginConfig(config) ) {
|
||||||
|
throw new ErrorWithContext('Invalid OAuth2 source config.', {
|
||||||
|
configName: this.configName,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal file
155
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal 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
50
src/auth/external/oauth2/OAuth2User.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
|
||||||
|
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||||
|
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
|
||||||
|
|
||||||
|
export class OAuth2User implements Authenticatable {
|
||||||
|
protected displayField: string
|
||||||
|
|
||||||
|
protected identifierField: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected data: {[key: string]: any},
|
||||||
|
config: OAuth2LoginConfig,
|
||||||
|
) {
|
||||||
|
this.displayField = config.userEndpointResponseMapping?.display || 'name'
|
||||||
|
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayIdentifier(): string {
|
||||||
|
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdentifier(): AuthenticatableIdentifier {
|
||||||
|
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return {
|
||||||
|
isOAuth2User: true,
|
||||||
|
data: this.data,
|
||||||
|
displayField: this.displayField,
|
||||||
|
identifierField: this.identifierField,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: JSONState): Awaitable<void> {
|
||||||
|
if (
|
||||||
|
!state.isOAuth2User
|
||||||
|
|| typeof state.data !== 'object'
|
||||||
|
|| state.data === null
|
||||||
|
|| typeof state.displayField !== 'string'
|
||||||
|
|| typeof state.identifierField !== 'string'
|
||||||
|
) {
|
||||||
|
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = state.data
|
||||||
|
this.identifierField = state.identifierField
|
||||||
|
this.displayField = state.identifierField
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,3 +21,6 @@ export * from './Authentication'
|
|||||||
export * from './config'
|
export * from './config'
|
||||||
|
|
||||||
export * from './basic-ui/BasicLoginFormRequest'
|
export * from './basic-ui/BasicLoginFormRequest'
|
||||||
|
export * from './basic-ui/BasicLoginController'
|
||||||
|
|
||||||
|
export * from './external/oauth2/OAuth2LoginController'
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
|||||||
|
|
||||||
/** The user's first name. */
|
/** The user's first name. */
|
||||||
@Field(FieldType.varchar, 'first_name')
|
@Field(FieldType.varchar, 'first_name')
|
||||||
public firstName!: string
|
public firstName?: string
|
||||||
|
|
||||||
/** The user's last name. */
|
/** The user's last name. */
|
||||||
@Field(FieldType.varchar, 'last_name')
|
@Field(FieldType.varchar, 'last_name')
|
||||||
public lastName!: string
|
public lastName?: string
|
||||||
|
|
||||||
/** The hashed and salted password of the user. */
|
/** The hashed and salted password of the user. */
|
||||||
@Field(FieldType.varchar, 'password_hash')
|
@Field(FieldType.varchar, 'password_hash')
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
|
import {
|
||||||
|
Authenticatable,
|
||||||
|
AuthenticatableCredentials,
|
||||||
|
AuthenticatableIdentifier,
|
||||||
|
AuthenticatableRepository,
|
||||||
|
} from '../types'
|
||||||
import {Awaitable, Maybe} from '../../util'
|
import {Awaitable, Maybe} from '../../util'
|
||||||
import {ORMUser} from './ORMUser'
|
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.
|
* A user repository implementation that looks up users stored in the database.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ORMUserRepository extends AuthenticatableRepository {
|
export class ORMUserRepository extends AuthenticatableRepository {
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
/** Look up the user by their username. */
|
/** Look up the user by their username. */
|
||||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||||
return ORMUser.query<ORMUser>()
|
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.
|
* If username/password are specified, look up the user and verify the password.
|
||||||
* @param credentials
|
* @param credentials
|
||||||
*/
|
*/
|
||||||
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||||
if ( credentials.securityIdentifier ) {
|
if ( !credentials.identifier && credentials.credential ) {
|
||||||
return ORMUser.query<ORMUser>()
|
return ORMUser.query<ORMUser>()
|
||||||
.where('username', '=', credentials.securityIdentifier)
|
.where('username', '=', credentials.credential)
|
||||||
.first()
|
.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( credentials.username && credentials.password ) {
|
if ( credentials.identifier && credentials.credential ) {
|
||||||
const user = await ORMUser.query<ORMUser>()
|
const user = await ORMUser.query<ORMUser>()
|
||||||
.where('username', '=', credentials.username)
|
.where('username', '=', credentials.identifier)
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||||
|
if ( await this.getByCredentials(credentials) ) {
|
||||||
|
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||||
|
identifier: credentials.identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = <ORMUser> this.injector.make(ORMUser)
|
||||||
|
user.username = credentials.identifier
|
||||||
|
await user.setPassword(credentials.credential)
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
|||||||
/** Value that can be used to uniquely identify a user. */
|
/** Value that can be used to uniquely identify a user. */
|
||||||
export type AuthenticatableIdentifier = string | number
|
export type AuthenticatableIdentifier = string | number
|
||||||
|
|
||||||
|
export interface AuthenticatableCredentials {
|
||||||
|
identifier: string,
|
||||||
|
credential: string,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for entities that can be authenticated.
|
* Base class for entities that can be authenticated.
|
||||||
*/
|
*/
|
||||||
@@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
|
|||||||
* Returns the user if the credentials are valid.
|
* Returns the user if the credentials are valid.
|
||||||
* @param credentials
|
* @param credentials
|
||||||
*/
|
*/
|
||||||
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
|
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||||
|
|
||||||
|
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,8 +169,12 @@ export abstract class Directive extends AppClass {
|
|||||||
const optionValues = this.parseOptions(options, argv)
|
const optionValues = this.parseOptions(options, argv)
|
||||||
this.setOptionValues(optionValues)
|
this.setOptionValues(optionValues)
|
||||||
await this.handle(argv)
|
await this.handle(argv)
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof Error ) {
|
||||||
this.nativeOutput(e.message)
|
this.nativeOutput(e.message)
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
if ( e instanceof OptionValidationError ) {
|
if ( e instanceof OptionValidationError ) {
|
||||||
// expecting, value, requirements
|
// expecting, value, requirements
|
||||||
if ( e.context.expecting ) {
|
if ( e.context.expecting ) {
|
||||||
@@ -187,6 +191,7 @@ export abstract class Directive extends AppClass {
|
|||||||
this.nativeOutput(` - ${e.context.value}`)
|
this.nativeOutput(` - ${e.context.value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nativeOutput('\nUse --help for more info.')
|
this.nativeOutput('\nUse --help for more info.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/cli/decorators.ts
Normal file
23
src/cli/decorators.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
|
||||||
|
import {CommandLine} from './service'
|
||||||
|
import {Directive} from './Directive'
|
||||||
|
import {logIfDebugging} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a class as a command-line Directive.
|
||||||
|
* The class must extend Directive.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const CLIDirective = (): ClassDecorator => {
|
||||||
|
return (target) => {
|
||||||
|
if ( isInstantiableOf(target, Directive) ) {
|
||||||
|
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||||
|
ContainerBlueprint.getContainerBlueprint()
|
||||||
|
.onResolve<CommandLine>(CommandLine, cli => {
|
||||||
|
cli.registerDirective(target as Instantiable<Directive>)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,3 +11,5 @@ export * from './directive/options/PositionalOption'
|
|||||||
export * from './directive/ShellDirective'
|
export * from './directive/ShellDirective'
|
||||||
export * from './directive/TemplateDirective'
|
export * from './directive/TemplateDirective'
|
||||||
export * from './directive/UsageDirective'
|
export * from './directive/UsageDirective'
|
||||||
|
|
||||||
|
export * from './decorators'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
|
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types'
|
||||||
import {AbstractFactory} from './factory/AbstractFactory'
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||||
import {Factory} from './factory/Factory'
|
import {Factory} from './factory/Factory'
|
||||||
@@ -7,7 +7,7 @@ import {ClosureFactory} from './factory/ClosureFactory'
|
|||||||
import NamedFactory from './factory/NamedFactory'
|
import NamedFactory from './factory/NamedFactory'
|
||||||
import SingletonFactory from './factory/SingletonFactory'
|
import SingletonFactory from './factory/SingletonFactory'
|
||||||
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
||||||
import {ContainerBlueprint} from './ContainerBlueprint'
|
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
|
||||||
|
|
||||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||||
export type MaybeDependency = any | 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.
|
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||||
*/
|
*/
|
||||||
export class Container {
|
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.
|
* Get the global instance of this container.
|
||||||
*/
|
*/
|
||||||
public static getContainer(): Container {
|
public static getContainer(): Container {
|
||||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||||
if ( !existing ) {
|
if ( !existing ) {
|
||||||
const container = new Container()
|
const container = Container.realizeContainer(new Container())
|
||||||
|
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
|
||||||
.resolve()
|
|
||||||
.map(factory => container.registerFactory(factory))
|
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
@@ -48,6 +66,12 @@ export class Container {
|
|||||||
*/
|
*/
|
||||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
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() {
|
constructor() {
|
||||||
this.registerSingletonInstance<Container>(Container, this)
|
this.registerSingletonInstance<Container>(Container, this)
|
||||||
this.registerSingleton('injector', this)
|
this.registerSingleton('injector', this)
|
||||||
@@ -172,6 +196,26 @@ export class Container {
|
|||||||
return this.instances.where('key', '=', key).isNotEmpty()
|
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.
|
* Returns true if the container has a factory for the given key.
|
||||||
* @param {DependencyKey} key
|
* @param {DependencyKey} key
|
||||||
@@ -234,6 +278,15 @@ export class Container {
|
|||||||
value: newInstance,
|
value: newInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
|
||||||
|
if ( waiter.key === key ) {
|
||||||
|
waiter.callback(newInstance)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return newInstance
|
return newInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import {DependencyKey, Instantiable} from './types'
|
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||||
import NamedFactory from './factory/NamedFactory'
|
import NamedFactory from './factory/NamedFactory'
|
||||||
import {AbstractFactory} from './factory/AbstractFactory'
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
import {Factory} from './factory/Factory'
|
import {Factory} from './factory/Factory'
|
||||||
import {ClosureFactory} from './factory/ClosureFactory'
|
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 {
|
export class ContainerBlueprint {
|
||||||
private static instance?: ContainerBlueprint
|
private static instance?: ContainerBlueprint
|
||||||
|
|
||||||
@@ -17,6 +27,19 @@ export class ContainerBlueprint {
|
|||||||
|
|
||||||
protected factories: (() => AbstractFactory<any>)[] = []
|
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,
|
* Register a basic instantiable class as a standard Factory with this container,
|
||||||
* identified by a string name rather than static class.
|
* identified by a string name rather than static class.
|
||||||
@@ -47,7 +70,38 @@ export class ContainerBlueprint {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of factory instances in the blueprint.
|
||||||
|
*/
|
||||||
resolve(): AbstractFactory<any>[] {
|
resolve(): AbstractFactory<any>[] {
|
||||||
return this.factories.map(x => x())
|
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
37
src/di/InjectionAware.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'reflect-metadata'
|
import 'reflect-metadata'
|
||||||
import {collect, Collection} from '../../util'
|
import {collect, Collection, logIfDebugging} from '../../util'
|
||||||
import {
|
import {
|
||||||
DependencyKey,
|
DependencyKey,
|
||||||
DependencyRequirement,
|
DependencyRequirement,
|
||||||
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
|
|||||||
* If a `key` is specified, that DependencyKey will be injected.
|
* If a `key` is specified, that DependencyKey will be injected.
|
||||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||||
* @param key
|
* @param key
|
||||||
|
* @param debug
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||||
return (target, property) => {
|
return (target, property) => {
|
||||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||||
if ( !propertyMetadata ) {
|
if ( !propertyMetadata ) {
|
||||||
@@ -91,11 +92,18 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
|||||||
if ( existing ) {
|
if ( existing ) {
|
||||||
existing.key = key
|
existing.key = key
|
||||||
} else {
|
} else {
|
||||||
propertyMetadata.push({ property,
|
propertyMetadata.push({
|
||||||
key })
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export * from './ScopedContainer'
|
|||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
export * from './decorator/injection'
|
export * from './decorator/injection'
|
||||||
|
export * from './InjectionAware'
|
||||||
|
|||||||
@@ -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.
|
* 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
|
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
|
* Interface used to store dependency requirements by their place in the injectable
|
||||||
* target's parameters.
|
* target's parameters.
|
||||||
@@ -58,6 +73,7 @@ export interface DependencyRequirement {
|
|||||||
export interface PropertyDependency {
|
export interface PropertyDependency {
|
||||||
key: DependencyKey,
|
key: DependencyKey,
|
||||||
property: string | symbol,
|
property: string | symbol,
|
||||||
|
debug?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ValidationResult, ValidatorFunction} from './types'
|
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
|
||||||
import {isJSON} from '../../util'
|
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 = {
|
export const Str = {
|
||||||
alpha,
|
alpha,
|
||||||
alphaNum,
|
alphaNum,
|
||||||
@@ -242,4 +260,5 @@ export const Str = {
|
|||||||
length,
|
length,
|
||||||
lengthMin,
|
lengthMin,
|
||||||
lengthMax,
|
lengthMax,
|
||||||
|
confirmed,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {AppClass} from '../lifecycle/AppClass'
|
|
||||||
import {Request} from './lifecycle/Request'
|
import {Request} from './lifecycle/Request'
|
||||||
import {Container} from '../di'
|
import {Container} from '../di'
|
||||||
|
import {CanonicalItemClass} from '../support/CanonicalReceiver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for controllers that define methods that
|
* Base class for controllers that define methods that
|
||||||
* handle HTTP requests.
|
* handle HTTP requests.
|
||||||
*/
|
*/
|
||||||
export class Controller extends AppClass {
|
export class Controller extends CanonicalItemClass {
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly request: Request,
|
protected readonly request: Request,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export class Request extends ScopedContainer implements DataContainer {
|
|||||||
protected serverResponse: ServerResponse,
|
protected serverResponse: ServerResponse,
|
||||||
) {
|
) {
|
||||||
super(Container.getContainer())
|
super(Container.getContainer())
|
||||||
|
this.registerSingletonInstance(Request, this)
|
||||||
|
|
||||||
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
||||||
|
|
||||||
@@ -124,12 +125,6 @@ export class Request extends ScopedContainer implements DataContainer {
|
|||||||
minor: clientRequest.httpVersionMinor,
|
minor: clientRequest.httpVersionMinor,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.register(Request)
|
|
||||||
this.instances.push({
|
|
||||||
key: Request,
|
|
||||||
value: this,
|
|
||||||
})
|
|
||||||
|
|
||||||
const parts = url.parse(this.url, true)
|
const parts = url.parse(this.url, true)
|
||||||
|
|
||||||
this.path = parts.pathname ?? '/'
|
this.path = parts.pathname ?? '/'
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Base type for an API response format.
|
* Base type for an API response format.
|
||||||
*/
|
*/
|
||||||
export interface APIResponse {
|
export interface APIResponse<T> {
|
||||||
success: boolean,
|
success: boolean,
|
||||||
message?: string,
|
message?: string,
|
||||||
data?: any,
|
data?: T,
|
||||||
error?: {
|
error?: {
|
||||||
name: string,
|
name: string,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -17,7 +17,7 @@ export interface APIResponse {
|
|||||||
* @param {string} displayMessage
|
* @param {string} displayMessage
|
||||||
* @return APIResponse
|
* @return APIResponse
|
||||||
*/
|
*/
|
||||||
export function message(displayMessage: string): APIResponse {
|
export function message(displayMessage: string): APIResponse<void> {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: displayMessage,
|
message: displayMessage,
|
||||||
@@ -29,7 +29,7 @@ export function message(displayMessage: string): APIResponse {
|
|||||||
* @param record
|
* @param record
|
||||||
* @return APIResponse
|
* @return APIResponse
|
||||||
*/
|
*/
|
||||||
export function one(record: unknown): APIResponse {
|
export function one<T>(record: T): APIResponse<T> {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: record,
|
data: record,
|
||||||
@@ -41,7 +41,7 @@ export function one(record: unknown): APIResponse {
|
|||||||
* @param {array} records
|
* @param {array} records
|
||||||
* @return APIResponse
|
* @return APIResponse
|
||||||
*/
|
*/
|
||||||
export function many(records: any[]): APIResponse {
|
export function many<T>(records: T[]): APIResponse<{records: T[], total: number}> {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -56,7 +56,7 @@ export function many(records: any[]): APIResponse {
|
|||||||
* @return APIResponse
|
* @return APIResponse
|
||||||
* @param thrownError
|
* @param thrownError
|
||||||
*/
|
*/
|
||||||
export function error(thrownError: string | Error): APIResponse {
|
export function error(thrownError: string | Error): APIResponse<void> {
|
||||||
if ( typeof thrownError === 'string' ) {
|
if ( typeof thrownError === 'string' ) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {AppClass} from '../../lifecycle/AppClass'
|
|
||||||
import {Request} from '../lifecycle/Request'
|
import {Request} from '../lifecycle/Request'
|
||||||
import {ResponseObject} from './Route'
|
import {ResponseObject} from './Route'
|
||||||
import {Container} from '../../di'
|
import {Container} from '../../di'
|
||||||
|
import {CanonicalItemClass} from '../../support/CanonicalReceiver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class representing a middleware handler that can be applied to routes.
|
* 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(
|
constructor(
|
||||||
/** The request that will be handled by this middleware. */
|
/** The request that will be handled by this middleware. */
|
||||||
protected readonly request: Request,
|
protected readonly request: Request,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export * from './http/Controller'
|
|||||||
|
|
||||||
export * from './http/servers/static'
|
export * from './http/servers/static'
|
||||||
|
|
||||||
|
export * from './support/CanonicalReceiver'
|
||||||
|
|
||||||
|
export * from './service/Canon'
|
||||||
export * from './service/Canonical'
|
export * from './service/Canonical'
|
||||||
export * from './service/CanonicalInstantiable'
|
export * from './service/CanonicalInstantiable'
|
||||||
export * from './service/CanonicalRecursive'
|
export * from './service/CanonicalRecursive'
|
||||||
@@ -74,9 +77,14 @@ export * from './service/HTTPServer'
|
|||||||
export * from './service/Routing'
|
export * from './service/Routing'
|
||||||
export * from './service/Middlewares'
|
export * from './service/Middlewares'
|
||||||
|
|
||||||
|
export * from './support/redis/Redis'
|
||||||
export * from './support/cache/MemoryCache'
|
export * from './support/cache/MemoryCache'
|
||||||
|
export * from './support/cache/RedisCache'
|
||||||
export * from './support/cache/CacheFactory'
|
export * from './support/cache/CacheFactory'
|
||||||
export * from './support/NodeModules'
|
export * from './support/NodeModules'
|
||||||
|
export * from './support/queue/Queue'
|
||||||
|
|
||||||
|
export * from './service/Queueables'
|
||||||
|
|
||||||
export * from './views/ViewEngine'
|
export * from './views/ViewEngine'
|
||||||
export * from './views/ViewEngineFactory'
|
export * from './views/ViewEngineFactory'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Application} from './Application'
|
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.
|
* 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. */
|
/** 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)
|
return this.container().make<T>(target, ...parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Container, ContainerBlueprint} from '../di'
|
import {Container} from '../di'
|
||||||
import {
|
import {
|
||||||
ErrorWithContext,
|
ErrorWithContext,
|
||||||
globalRegistry,
|
globalRegistry,
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
universalPath,
|
universalPath,
|
||||||
UniversalPath,
|
UniversalPath,
|
||||||
} from '../util'
|
} from '../util'
|
||||||
|
|
||||||
import {Logging} from '../service/Logging'
|
import {Logging} from '../service/Logging'
|
||||||
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
|
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
|
||||||
import {Unit, UnitStatus} from './Unit'
|
import {Unit, UnitStatus} from './Unit'
|
||||||
@@ -58,12 +57,7 @@ export class Application extends Container {
|
|||||||
public static getContainer(): Container {
|
public static getContainer(): Container {
|
||||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||||
if ( !existing ) {
|
if ( !existing ) {
|
||||||
const container = new Application()
|
const container = Application.realizeContainer(new Application())
|
||||||
|
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
|
||||||
.resolve()
|
|
||||||
.map(factory => container.registerFactory(factory))
|
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
@@ -79,18 +73,12 @@ export class Application extends Container {
|
|||||||
if ( existing instanceof Application ) {
|
if ( existing instanceof Application ) {
|
||||||
return existing
|
return existing
|
||||||
} else if ( existing ) {
|
} else if ( existing ) {
|
||||||
const app = new Application()
|
const app = Application.realizeContainer(new Application())
|
||||||
existing.cloneTo(app)
|
existing.cloneTo(app)
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', app)
|
globalRegistry.setGlobal('extollo/injector', app)
|
||||||
return app
|
return app
|
||||||
} else {
|
} else {
|
||||||
const app = new Application()
|
const app = Application.realizeContainer(new Application())
|
||||||
|
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
|
||||||
.resolve()
|
|
||||||
.map(factory => app.registerFactory(factory))
|
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', app)
|
globalRegistry.setGlobal('extollo/injector', app)
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
@@ -271,9 +259,13 @@ export class Application extends Container {
|
|||||||
try {
|
try {
|
||||||
await this.up()
|
await this.up()
|
||||||
await this.down()
|
await this.down()
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof Error ) {
|
||||||
this.errorHandler(e)
|
this.errorHandler(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,10 +310,15 @@ export class Application extends Container {
|
|||||||
await unit.up()
|
await unit.up()
|
||||||
unit.status = UnitStatus.Started
|
unit.status = UnitStatus.Started
|
||||||
logging.info(`Started ${unit.constructor.name}.`)
|
logging.info(`Started ${unit.constructor.name}.`)
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
unit.status = UnitStatus.Error
|
unit.status = UnitStatus.Error
|
||||||
|
|
||||||
|
if ( e instanceof Error ) {
|
||||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,7 +336,12 @@ export class Application extends Container {
|
|||||||
logging.info(`Stopped ${unit.constructor.name}.`)
|
logging.info(`Stopped ${unit.constructor.name}.`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
unit.status = UnitStatus.Error
|
unit.status = UnitStatus.Error
|
||||||
|
|
||||||
|
if ( e instanceof Error ) {
|
||||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,13 @@ ${contextDisplay}
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logging.error(errorString, true)
|
this.logging.error(errorString, true)
|
||||||
} catch (displayError) {
|
} catch (displayError: unknown) {
|
||||||
|
if ( displayError instanceof Error ) {
|
||||||
// The error display encountered an error...
|
// The error display encountered an error...
|
||||||
// just throw the original so it makes it out
|
// just throw the original so it makes it out
|
||||||
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
|
||||||
throw operativeError
|
throw operativeError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export default class CreateUsersTableMigration extends Migration {
|
|||||||
|
|
||||||
table.column('first_name')
|
table.column('first_name')
|
||||||
.type(FieldType.varchar)
|
.type(FieldType.varchar)
|
||||||
.required()
|
.nullable()
|
||||||
|
|
||||||
table.column('last_name')
|
table.column('last_name')
|
||||||
.type(FieldType.varchar)
|
.type(FieldType.varchar)
|
||||||
.required()
|
.nullable()
|
||||||
|
|
||||||
table.column('password_hash')
|
table.column('password_hash')
|
||||||
.type(FieldType.text)
|
.type(FieldType.text)
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
*/
|
*/
|
||||||
public abstract getResultIterable(): AbstractResultIterable<T>
|
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.
|
* Clone the current query to a new AbstractBuilder instance with the same properties.
|
||||||
*/
|
*/
|
||||||
@@ -489,7 +496,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
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)
|
return this.registeredConnection.query(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +522,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
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)
|
return this.registeredConnection.query(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +555,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
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)
|
return this.registeredConnection.query(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +567,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
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)
|
const result = await this.registeredConnection.query(query)
|
||||||
return Boolean(result.rows.first())
|
return Boolean(result.rows.first())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ export class Builder extends AbstractBuilder<QueryRow> {
|
|||||||
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ErrorWithContext} from '../../util'
|
import {Awaitable, ErrorWithContext} from '../../util'
|
||||||
import {QueryResult} from '../types'
|
import {QueryResult} from '../types'
|
||||||
import {SQLDialect} from '../dialect/SQLDialect'
|
import {SQLDialect} from '../dialect/SQLDialect'
|
||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
@@ -68,6 +68,13 @@ export abstract class Connection extends AppClass {
|
|||||||
*/
|
*/
|
||||||
public abstract schema(name?: string): Schema
|
public abstract schema(name?: string): Schema
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Fire a QueryExecutedEvent for the given query string.
|
||||||
* @param query
|
* @param query
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Connection, ConnectionNotReadyError} from './Connection'
|
|||||||
import {Client} from 'pg'
|
import {Client} from 'pg'
|
||||||
import {Inject} from '../../di'
|
import {Inject} from '../../di'
|
||||||
import {QueryResult} from '../types'
|
import {QueryResult} from '../types'
|
||||||
import {collect} from '../../util'
|
import {Awaitable, collect} from '../../util'
|
||||||
import {SQLDialect} from '../dialect/SQLDialect'
|
import {SQLDialect} from '../dialect/SQLDialect'
|
||||||
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
|
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
@@ -63,11 +63,26 @@ export class PostgresConnection extends Connection {
|
|||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ( e instanceof Error ) {
|
||||||
throw this.app().errorWrapContext(e, {
|
throw this.app().errorWrapContext(e, {
|
||||||
query,
|
query,
|
||||||
connection: this.name,
|
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 {
|
public schema(name?: string): Schema {
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
const table: string = tableString.split('.').map(x => `"${x}"`)
|
const table: string = tableString.split('.').map(x => `"${x}"`)
|
||||||
.join('.')
|
.join('.')
|
||||||
queryLines.push('INSERT INTO ' + (typeof source === 'string' ? table : `${table} AS "${source.alias}"`)
|
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 ) {
|
if ( Array.isArray(data) && !data.length ) {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import {Directive, OptionDefinition} from '../../cli'
|
|||||||
import {Injectable} from '../../di'
|
import {Injectable} from '../../di'
|
||||||
import {stringToPascal} from '../../util'
|
import {stringToPascal} from '../../util'
|
||||||
import {templateMigration} from '../template/migration'
|
import {templateMigration} from '../template/migration'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI directive that creates migration classes from template.
|
* CLI directive that creates migration classes from template.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
export class CreateMigrationDirective extends Directive {
|
export class CreateMigrationDirective extends Directive {
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
return 'create a new migration'
|
return 'create a new migration'
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import {Directive, OptionDefinition} from '../../cli'
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
import {Container, Inject, Injectable} from '../../di'
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
import {EventBus} from '../../event/EventBus'
|
import {EventBus} from '../../event/EventBus'
|
||||||
import {Migrator} from '../migrations/Migrator'
|
|
||||||
import {Migrations} from '../services/Migrations'
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
||||||
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
||||||
import {EventSubscription} from '../../event/types'
|
import {EventSubscription} from '../../event/types'
|
||||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI directive that applies migrations using the default Migrator.
|
* CLI directive that applies migrations using the default Migrator.
|
||||||
* @fixme Support dry run mode
|
* @fixme Support dry run mode
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
export class MigrateDirective extends Directive {
|
export class MigrateDirective extends Directive {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly bus!: EventBus
|
protected readonly bus!: EventBus
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrati
|
|||||||
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
||||||
import {EventSubscription} from '../../event/types'
|
import {EventSubscription} from '../../event/types'
|
||||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI directive that undoes applied migrations using the default Migrator.
|
* CLI directive that undoes applied migrations using the default Migrator.
|
||||||
* @fixme Support dry run mode
|
* @fixme Support dry run mode
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
export class RollbackDirective extends Directive {
|
export class RollbackDirective extends Directive {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly bus!: EventBus
|
protected readonly bus!: EventBus
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ export * from './model/ModelResultIterable'
|
|||||||
export * from './model/events'
|
export * from './model/events'
|
||||||
export * from './model/Model'
|
export * from './model/Model'
|
||||||
|
|
||||||
|
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/SessionModel'
|
||||||
export * from './support/ORMSession'
|
export * from './support/ORMSession'
|
||||||
export * from './support/CacheModel'
|
export * from './support/CacheModel'
|
||||||
@@ -30,19 +40,20 @@ export * from './schema/TableBuilder'
|
|||||||
export * from './schema/Schema'
|
export * from './schema/Schema'
|
||||||
export * from './schema/PostgresSchema'
|
export * from './schema/PostgresSchema'
|
||||||
|
|
||||||
|
export * from './services/Migrations'
|
||||||
|
export * from './migrations/Migrator'
|
||||||
export * from './migrations/NothingToMigrateError'
|
export * from './migrations/NothingToMigrateError'
|
||||||
|
export * from './migrations/events/MigrationEvent'
|
||||||
export * from './migrations/events/ApplyingMigrationEvent'
|
export * from './migrations/events/ApplyingMigrationEvent'
|
||||||
export * from './migrations/events/AppliedMigrationEvent'
|
export * from './migrations/events/AppliedMigrationEvent'
|
||||||
export * from './migrations/events/RollingBackMigrationEvent'
|
export * from './migrations/events/RollingBackMigrationEvent'
|
||||||
export * from './migrations/events/RolledBackMigrationEvent'
|
export * from './migrations/events/RolledBackMigrationEvent'
|
||||||
export * from './migrations/Migration'
|
export * from './migrations/Migration'
|
||||||
export * from './migrations/Migrator'
|
|
||||||
export * from './migrations/MigratorFactory'
|
export * from './migrations/MigratorFactory'
|
||||||
export * from './migrations/DatabaseMigrator'
|
export * from './migrations/DatabaseMigrator'
|
||||||
|
|
||||||
export * from './services/Database'
|
export * from './services/Database'
|
||||||
export * from './services/Models'
|
export * from './services/Models'
|
||||||
export * from './services/Migrations'
|
|
||||||
|
|
||||||
export * from './directive/CreateMigrationDirective'
|
export * from './directive/CreateMigrationDirective'
|
||||||
export * from './directive/MigrateDirective'
|
export * from './directive/MigrateDirective'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {NothingToMigrateError} from './NothingToMigrateError'
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export abstract class Migrator {
|
export abstract class Migrator {
|
||||||
@Inject()
|
@Inject(Migrations, { debug: true })
|
||||||
protected readonly migrations!: Migrations
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
PropertyDependency,
|
PropertyDependency,
|
||||||
isInstantiable,
|
isInstantiable,
|
||||||
DEPENDENCY_KEYS_METADATA_KEY,
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject,
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
|
||||||
} from '../../di'
|
} from '../../di'
|
||||||
import {Collection, ErrorWithContext} from '../../util'
|
import {Collection, ErrorWithContext} from '../../util'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
@@ -17,6 +17,7 @@ import {DatabaseMigrator} from './DatabaseMigrator'
|
|||||||
* and produces an instance of the configured session driver implementation.
|
* and produces an instance of the configured session driver implementation.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@FactoryProducer()
|
||||||
export class MigratorFactory extends AbstractFactory<Migrator> {
|
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly logging!: Logging
|
protected readonly logging!: Logging
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {ModelKey, QueryRow, QuerySource} from '../types'
|
import {ModelKey, QueryRow, QuerySource} from '../types'
|
||||||
import {Container, Inject, Instantiable, StaticClass} from '../../di'
|
import {Container, Inject, Instantiable, isInstantiable, StaticClass} from '../../di'
|
||||||
import {DatabaseService} from '../DatabaseService'
|
import {DatabaseService} from '../DatabaseService'
|
||||||
import {ModelBuilder} from './ModelBuilder'
|
import {ModelBuilder} from './ModelBuilder'
|
||||||
import {getFieldsMeta, ModelField} from './Field'
|
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 {EscapeValueObject} from '../dialect/SQLDialect'
|
||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
@@ -17,6 +17,11 @@ import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
|
|||||||
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
|
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
|
||||||
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
|
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
|
||||||
import {EventBus} from '../../event/EventBus'
|
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.
|
* 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[] = []
|
protected static masks: string[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relations that should be eager-loaded by default.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected with: (keyof T)[] = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The original row fetched from the database.
|
* The original row fetched from the database.
|
||||||
* @protected
|
* @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>>()
|
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.
|
* 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)
|
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
|
return builder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +348,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
|||||||
builder.field(field.databaseKey)
|
builder.field(field.databaseKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for ( const relation of this.with ) {
|
||||||
|
builder.with(relation)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.withScopes(this.scopes)
|
||||||
|
|
||||||
return builder
|
return builder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +659,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = this.buildInsertFieldObject()
|
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 returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
|
||||||
|
|
||||||
const result = await this.query()
|
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()
|
const data = result.rows.first()
|
||||||
if ( data ) {
|
if ( data ) {
|
||||||
await this.assumeFromSource(result)
|
await this.assumeFromSource(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dispatch(new ModelCreatedEvent<T>(this as any))
|
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
|
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.
|
* 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 {
|
private buildInsertFieldObject(): EscapeValueObject {
|
||||||
const ctor = this.constructor as typeof Model
|
const ctor = this.constructor as typeof Model
|
||||||
|
|
||||||
|
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
|
||||||
|
|
||||||
return getFieldsMeta(this)
|
return getFieldsMeta(this)
|
||||||
.pipe()
|
.pipe()
|
||||||
.unless(ctor.populateKeyOnInsert, fields => {
|
.unless(ctor.populateKeyOnInsert, fields => {
|
||||||
return fields.where('modelKey', '!=', this.keyName())
|
return fields.where('databaseKey', '!=', this.keyName())
|
||||||
})
|
})
|
||||||
.get()
|
.get()
|
||||||
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,136 @@
|
|||||||
import {Model} from './Model'
|
import {Model} from './Model'
|
||||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||||
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
||||||
import {Instantiable} from '../../di'
|
import {Instantiable, StaticClass} from '../../di'
|
||||||
import {ModelResultIterable} from './ModelResultIterable'
|
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`.
|
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||||
*/
|
*/
|
||||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<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(
|
constructor(
|
||||||
/** The model class that is created for results of this query. */
|
/** 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()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withScopes(scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }>): this {
|
||||||
|
this.appliedScopes = scopes.clone()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
public getNewInstance(): AbstractBuilder<T> {
|
public getNewInstance(): AbstractBuilder<T> {
|
||||||
return this.app().make<ModelBuilder<T>>(ModelBuilder)
|
return this.app().make<ModelBuilder<T>>(ModelBuilder, this.ModelClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
public getResultIterable(): AbstractResultIterable<T> {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Connection} from '../connection/Connection'
|
|||||||
import {ModelBuilder} from './ModelBuilder'
|
import {ModelBuilder} from './ModelBuilder'
|
||||||
import {Container, Instantiable} from '../../di'
|
import {Container, Instantiable} from '../../di'
|
||||||
import {QueryRow} from '../types'
|
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.
|
* 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()
|
const row = (await this.connection.query(query)).rows.first()
|
||||||
|
|
||||||
if ( row ) {
|
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>> {
|
async range(start: number, end: number): Promise<Collection<T>> {
|
||||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
|
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> {
|
async count(): Promise<number> {
|
||||||
@@ -45,7 +49,9 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
|
|||||||
|
|
||||||
async all(): Promise<Collection<T>> {
|
async all(): Promise<Collection<T>> {
|
||||||
const result = await this.connection.query(this.selectSQL)
|
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)
|
.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> {
|
clone(): ModelResultIterable<T> {
|
||||||
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
|
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/orm/model/relation/HasMany.ts
Normal file
47
src/orm/model/relation/HasMany.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/orm/model/relation/HasOne.ts
Normal file
47
src/orm/model/relation/HasOne.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/orm/model/relation/HasOneOrMany.ts
Normal file
80
src/orm/model/relation/HasOneOrMany.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/orm/model/relation/Relation.ts
Normal file
111
src/orm/model/relation/Relation.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/orm/model/relation/RelationBuilder.ts
Normal file
14
src/orm/model/relation/RelationBuilder.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/orm/model/relation/decorators.ts
Normal file
37
src/orm/model/relation/decorators.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/orm/model/scope/ActiveScope.ts
Normal file
11
src/orm/model/scope/ActiveScope.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/orm/model/scope/Scope.ts
Normal file
18
src/orm/model/scope/Scope.ts
Normal 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>
|
||||||
|
|
||||||
|
}
|
||||||
@@ -164,12 +164,8 @@ export class PostgresSchema extends Schema {
|
|||||||
.pluck<string>('column_name')
|
.pluck<string>('column_name')
|
||||||
.each(col => idx.field(col))
|
.each(col => idx.field(col))
|
||||||
})
|
})
|
||||||
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
|
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary())
|
||||||
idx.primary()
|
.when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique())
|
||||||
})
|
|
||||||
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
|
|
||||||
idx.unique()
|
|
||||||
})
|
|
||||||
.get()
|
.get()
|
||||||
.flagAsExistingInSchema()
|
.flagAsExistingInSchema()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,15 @@ import {Inject, Singleton} from '../../di'
|
|||||||
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
||||||
import {Migration} from '../migrations/Migration'
|
import {Migration} from '../migrations/Migration'
|
||||||
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
||||||
import {Migrator} from '../migrations/Migrator'
|
|
||||||
import {UniversalPath} from '../../util'
|
import {UniversalPath} from '../../util'
|
||||||
import {lib} from '../../lib'
|
import {lib} from '../../lib'
|
||||||
import {CommandLine} from '../../cli'
|
import {CommandLine} from '../../cli'
|
||||||
import {MigrateDirective} from '../directive/MigrateDirective'
|
|
||||||
import {RollbackDirective} from '../directive/RollbackDirective'
|
|
||||||
import {CreateMigrationDirective} from '../directive/CreateMigrationDirective'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service unit that loads and instantiates migration classes.
|
* Service unit that loads and instantiates migration classes.
|
||||||
*/
|
*/
|
||||||
@Singleton()
|
@Singleton()
|
||||||
export class Migrations extends CanonicalInstantiable<Migration> {
|
export class Migrations extends CanonicalInstantiable<Migration> {
|
||||||
@Inject()
|
|
||||||
protected readonly migrator!: Migrator
|
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly cli!: CommandLine
|
protected readonly cli!: CommandLine
|
||||||
|
|
||||||
@@ -38,11 +31,6 @@ export class Migrations extends CanonicalInstantiable<Migration> {
|
|||||||
const basePath = lib().concat('migrations')
|
const basePath = lib().concat('migrations')
|
||||||
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
|
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
|
||||||
this.registerNamespace('@extollo', resolver)
|
this.registerNamespace('@extollo', resolver)
|
||||||
|
|
||||||
// Register the migrate CLI directives
|
|
||||||
this.cli.registerDirective(MigrateDirective)
|
|
||||||
this.cli.registerDirective(RollbackDirective)
|
|
||||||
this.cli.registerDirective(CreateMigrationDirective)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initCanonicalItem(definition: CanonicalDefinition): Promise<Migration> {
|
async initCanonicalItem(definition: CanonicalDefinition): Promise<Migration> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {Model} from '../model/Model'
|
import {Model} from '../model/Model'
|
||||||
import {Field} from '../model/Field'
|
import {Field} from '../model/Field'
|
||||||
import {FieldType} from '../types'
|
import {FieldType} from '../types'
|
||||||
|
import {Maybe} from '../../util'
|
||||||
|
import {ModelBuilder} from '../model/ModelBuilder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A model instance which stores records from the ORMCache driver.
|
* 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')
|
@Field(FieldType.timestamp, 'cache_expires')
|
||||||
public cacheExpires?: Date;
|
public cacheExpires?: Date;
|
||||||
|
|
||||||
|
public static withCacheKey(key: string): ModelBuilder<CacheModel> {
|
||||||
|
return this.query<CacheModel>()
|
||||||
|
.whereKey(key)
|
||||||
|
.whereProperty('cacheExpires', '>', new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getCacheKey(key: string): Promise<Maybe<CacheModel>> {
|
||||||
|
return this.withCacheKey(key)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Container} from '../../di'
|
import {Container} from '../../di'
|
||||||
import {Cache} from '../../util'
|
import {Awaitable, Cache, ErrorWithContext, Maybe} from '../../util'
|
||||||
import {CacheModel} from './CacheModel'
|
import {CacheModel} from './CacheModel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7,14 +7,7 @@ import {CacheModel} from './CacheModel'
|
|||||||
*/
|
*/
|
||||||
export class ORMCache extends Cache {
|
export class ORMCache extends Cache {
|
||||||
public async fetch(key: string): Promise<string | undefined> {
|
public async fetch(key: string): Promise<string | undefined> {
|
||||||
const model = await CacheModel.query<CacheModel>()
|
return (await CacheModel.getCacheKey(key))?.cacheValue
|
||||||
.where(CacheModel.qualifyKey(), '=', key)
|
|
||||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
|
||||||
.first()
|
|
||||||
|
|
||||||
if ( model ) {
|
|
||||||
return model.cacheValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async put(key: string, value: string, expires?: Date): Promise<void> {
|
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> {
|
public async has(key: string): Promise<boolean> {
|
||||||
return CacheModel.query()
|
return CacheModel.withCacheKey(key)
|
||||||
.where(CacheModel.qualifyKey(), '=', key)
|
|
||||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
|
||||||
.exists()
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
public async drop(key: string): Promise<void> {
|
public async drop(key: string): Promise<void> {
|
||||||
await CacheModel.query()
|
await CacheModel.query()
|
||||||
.where(CacheModel.qualifyKey(), '=', key)
|
.whereKey(key)
|
||||||
.delete()
|
.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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {FieldType} from '../types'
|
|||||||
* Model used to fetch & store sessions from the ORMSession driver.
|
* Model used to fetch & store sessions from the ORMSession driver.
|
||||||
*/
|
*/
|
||||||
export class SessionModel extends Model<SessionModel> {
|
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')
|
@Field(FieldType.varchar, 'session_uuid')
|
||||||
public uuid!: string;
|
public uuid!: string;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export type QueryRow = { [key: string]: any }
|
|||||||
*/
|
*/
|
||||||
export type ModelKey = string | number
|
export type ModelKey = string | number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of keys of a set of models.
|
||||||
|
*/
|
||||||
|
export type ModelKeys = ModelKey | ModelKey[] | Collection<ModelKey>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for the result of a query execution.
|
* Interface for the result of a query execution.
|
||||||
*/
|
*/
|
||||||
@@ -126,6 +131,7 @@ export enum FieldType {
|
|||||||
json = 'json',
|
json = 'json',
|
||||||
line = 'line',
|
line = 'line',
|
||||||
lseg = 'lseg',
|
lseg = 'lseg',
|
||||||
|
ltree = 'ltree',
|
||||||
macaddr = 'macaddr',
|
macaddr = 'macaddr',
|
||||||
money = 'money',
|
money = 'money',
|
||||||
numeric = 'numeric',
|
numeric = 'numeric',
|
||||||
@@ -184,6 +190,7 @@ export function inverseFieldType(type: FieldType): string {
|
|||||||
json: 'json',
|
json: 'json',
|
||||||
line: 'line',
|
line: 'line',
|
||||||
lseg: 'lseg',
|
lseg: 'lseg',
|
||||||
|
ltree: 'ltree',
|
||||||
macaddr: 'macaddr',
|
macaddr: 'macaddr',
|
||||||
money: 'money',
|
money: 'money',
|
||||||
numeric: 'numeric',
|
numeric: 'numeric',
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ block content
|
|||||||
each error in errors
|
each error in errors
|
||||||
p.form-error-message #{error}
|
p.form-error-message #{error}
|
||||||
|
|
||||||
|
if formAction
|
||||||
|
form(method='post' enctype='multipart/form-data' action=formAction)
|
||||||
|
block form
|
||||||
|
else
|
||||||
form(method='post' enctype='multipart/form-data')
|
form(method='post' enctype='multipart/form-data')
|
||||||
block form
|
block form
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ block heading
|
|||||||
|
|
||||||
block form
|
block form
|
||||||
.form-label-group
|
.form-label-group
|
||||||
input#inputUsername.form-control(type='text' name='username' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||||
label(for='inputUsername') Username
|
label(for='inputUsername') Username
|
||||||
|
|
||||||
.form-label-group
|
.form-label-group
|
||||||
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
|
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
|
||||||
label(for='inputPassword') Password
|
label(for='inputPassword') Password
|
||||||
|
|
||||||
|
|
||||||
@@ -21,4 +21,4 @@ block form
|
|||||||
|
|
||||||
.text-center
|
.text-center
|
||||||
span.small Need an account?
|
span.small Need an account?
|
||||||
a(href='./register') Register here.
|
a(href=named('@auth.register')) Register here.
|
||||||
|
|||||||
26
src/resources/views/auth/register.pug
Normal file
26
src/resources/views/auth/register.pug
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
extends ./form
|
||||||
|
|
||||||
|
block head
|
||||||
|
title Register | #{config('app.name', 'Extollo')}
|
||||||
|
|
||||||
|
block heading
|
||||||
|
| Register to continue
|
||||||
|
|
||||||
|
block form
|
||||||
|
.form-label-group
|
||||||
|
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||||
|
label(for='inputUsername') Username
|
||||||
|
|
||||||
|
.form-label-group
|
||||||
|
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
|
||||||
|
label(for='inputPassword') Password
|
||||||
|
|
||||||
|
.form-label-group
|
||||||
|
input#inputPasswordConfirm.form-control(type='password' name='credentialConfirm' required placeholder='Confirm Password')
|
||||||
|
label(for='inputPassword') Confirm Password
|
||||||
|
|
||||||
|
button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login
|
||||||
|
|
||||||
|
.text-center
|
||||||
|
span.small Have an account?
|
||||||
|
a(href=named('@auth.login')) Login here.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Canonical} from './Canonical'
|
import {Canonical} from './Canonical'
|
||||||
import {Singleton} from '../di'
|
import {Singleton} from '../di'
|
||||||
|
import {Maybe} from '../util'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error throw when a duplicate canonical key is registered.
|
* Error throw when a duplicate canonical key is registered.
|
||||||
@@ -46,6 +47,17 @@ export class Canon {
|
|||||||
return this.resources[key] as Canonical<T>
|
return this.resources[key] as Canonical<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a canonical item from a fully-qualified canonical name.
|
||||||
|
* This is just a quality-of-life wrapper around `this.resource(...).get(...)`.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
getFromFullyQualified(key: string): Maybe<any> {
|
||||||
|
const [namespace, ...parts] = key.split('::')
|
||||||
|
const unqualified = parts.join('::')
|
||||||
|
return this.resource(namespace).get(unqualified)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a canonical resource.
|
* Register a canonical resource.
|
||||||
* @param {Canonical} unit
|
* @param {Canonical} unit
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Logging} from './Logging'
|
|||||||
import {Inject} from '../di'
|
import {Inject} from '../di'
|
||||||
import * as nodePath from 'path'
|
import * as nodePath from 'path'
|
||||||
import {Unit} from '../lifecycle/Unit'
|
import {Unit} from '../lifecycle/Unit'
|
||||||
|
import {isCanonicalReceiver} from '../support/CanonicalReceiver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface describing a definition of a single canonical item loaded from the app.
|
* Interface describing a definition of a single canonical item loaded from the app.
|
||||||
@@ -228,7 +229,16 @@ export abstract class Canonical<T> extends Unit {
|
|||||||
|
|
||||||
const definition = await this.buildCanonicalDefinition(entry)
|
const definition = await this.buildCanonicalDefinition(entry)
|
||||||
this.logging.verbose(`Registering canonical ${this.canonicalItem} "${definition.canonicalName}" from ${entry}`)
|
this.logging.verbose(`Registering canonical ${this.canonicalItem} "${definition.canonicalName}" from ${entry}`)
|
||||||
this.loadedItems[definition.canonicalName] = await this.initCanonicalItem(definition)
|
const resolvedItem = await this.initCanonicalItem(definition)
|
||||||
|
|
||||||
|
if ( isCanonicalReceiver(resolvedItem) ) {
|
||||||
|
resolvedItem.setCanonicalResolver(
|
||||||
|
`${this.canonicalItems}::${definition.canonicalName}`,
|
||||||
|
definition.canonicalName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadedItems[definition.canonicalName] = resolvedItem
|
||||||
}
|
}
|
||||||
|
|
||||||
this.canon.registerCanonical(this)
|
this.canon.registerCanonical(this)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Inject, Singleton} from '../di'
|
import {Inject, Singleton} from '../di'
|
||||||
import {HTTPStatus, withTimeout} from '../util'
|
import {ErrorWithContext, HTTPStatus, withTimeout} from '../util'
|
||||||
import {Unit} from '../lifecycle/Unit'
|
import {Unit} from '../lifecycle/Unit'
|
||||||
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
|
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
|
||||||
import {Logging} from './Logging'
|
import {Logging} from './Logging'
|
||||||
@@ -82,7 +82,8 @@ export class HTTPServer extends Unit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public get handler(): RequestListener {
|
public get handler(): RequestListener {
|
||||||
const timeout = this.config.get('server.timeout', 10000)
|
// const timeout = this.config.get('server.timeout', 10000)
|
||||||
|
const timeout = 0 // temporarily disable this because it is causing problems
|
||||||
|
|
||||||
return async (request: IncomingMessage, response: ServerResponse) => {
|
return async (request: IncomingMessage, response: ServerResponse) => {
|
||||||
const extolloReq = new Request(request, response)
|
const extolloReq = new Request(request, response)
|
||||||
@@ -113,9 +114,13 @@ export class HTTPServer extends Unit {
|
|||||||
try {
|
try {
|
||||||
await this.kernel.handle(extolloReq)
|
await this.kernel.handle(extolloReq)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ( e instanceof Error ) {
|
||||||
await error(e).write(extolloReq)
|
await error(e).write(extolloReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await error(new ErrorWithContext('Unknown error occurred.', { e }))
|
||||||
|
}
|
||||||
|
|
||||||
await extolloReq.response.send()
|
await extolloReq.response.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/service/Queueables.ts
Normal file
25
src/service/Queueables.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {CanonicalStatic} from './CanonicalStatic'
|
||||||
|
import {Singleton, Instantiable, StaticClass} from '../di'
|
||||||
|
import {CanonicalDefinition} from './Canonical'
|
||||||
|
import {Queueable} from '../support/queue/Queue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A canonical unit that resolves Queueable classes from `app/queueables`.
|
||||||
|
*/
|
||||||
|
@Singleton()
|
||||||
|
export class Queueables extends CanonicalStatic<Queueable, Instantiable<Queueable>> {
|
||||||
|
protected appPath = ['queueables']
|
||||||
|
|
||||||
|
protected canonicalItem = 'job'
|
||||||
|
|
||||||
|
protected suffix = '.job.js'
|
||||||
|
|
||||||
|
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Queueable, Instantiable<Queueable>>> {
|
||||||
|
const item = await super.initCanonicalItem(definition)
|
||||||
|
if ( !(item.prototype instanceof Queueable) ) {
|
||||||
|
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.Queueable.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/support/CanonicalReceiver.ts
Normal file
61
src/support/CanonicalReceiver.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {AppClass} from '../lifecycle/AppClass'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for a class that receives its canonical resolver names upon load.
|
||||||
|
*/
|
||||||
|
export interface CanonicalReceiver {
|
||||||
|
setCanonicalResolver(fullyQualifiedResolver: string, unqualifiedResolver: string): void
|
||||||
|
getCanonicalResolver(): string | undefined
|
||||||
|
getFullyQualifiedCanonicalResolver(): string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function that checks whether a given value satisfies the CanonicalReceiver interface.
|
||||||
|
* @param something
|
||||||
|
*/
|
||||||
|
export function isCanonicalReceiver(something: unknown): something is CanonicalReceiver {
|
||||||
|
return (
|
||||||
|
typeof something === 'function'
|
||||||
|
&& typeof (something as any).setCanonicalResolver === 'function'
|
||||||
|
&& (something as any).setCanonicalResolver.length >= 1
|
||||||
|
&& typeof (something as any).getCanonicalResolver === 'function'
|
||||||
|
&& (something as any).getCanonicalResolver.length === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for canonical items that implements the CanonicalReceiver interface.
|
||||||
|
* That is, `isCanonicalReceiver(CanonicalItemClass) === true`.
|
||||||
|
*/
|
||||||
|
export class CanonicalItemClass extends AppClass {
|
||||||
|
/** The type-prefixed canonical resolver of this class, set by the startup unit. */
|
||||||
|
private static canonFullyQualifiedResolver?: string
|
||||||
|
|
||||||
|
/** The unqualified canonical resolver of this class, set by the startup unit. */
|
||||||
|
private static canonUnqualifiedResolver?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the fully- and un-qualified canonical resolver strings. Intended for use
|
||||||
|
* by the Canonical unit.
|
||||||
|
* @param fullyQualifiedResolver
|
||||||
|
* @param unqualifiedResolver
|
||||||
|
*/
|
||||||
|
public static setCanonicalResolver(fullyQualifiedResolver: string, unqualifiedResolver: string): void {
|
||||||
|
this.canonFullyQualifiedResolver = fullyQualifiedResolver
|
||||||
|
this.canonUnqualifiedResolver = unqualifiedResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the fully-qualified canonical resolver of this class, if one has been set.
|
||||||
|
*/
|
||||||
|
public static getFullyQualifiedCanonicalResolver(): string | undefined {
|
||||||
|
return this.canonFullyQualifiedResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unqualified canonical resolver of this class, if one has been set.
|
||||||
|
*/
|
||||||
|
public static getCanonicalResolver(): string | undefined {
|
||||||
|
return this.canonUnqualifiedResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/support/cache/MemoryCache.ts
vendored
42
src/support/cache/MemoryCache.ts
vendored
@@ -8,7 +8,10 @@ export class MemoryCache extends Cache {
|
|||||||
/** Static collection of in-memory cache items. */
|
/** Static collection of in-memory cache items. */
|
||||||
private static cacheItems: Collection<{key: string, value: string, expires?: Date}> = new Collection<{key: string; value: string, expires?: Date}>()
|
private static cacheItems: Collection<{key: string, value: string, expires?: Date}> = new Collection<{key: string; value: string, expires?: Date}>()
|
||||||
|
|
||||||
public fetch(key: string): Awaitable<string|undefined> {
|
/** Static collection of in-memory arrays. */
|
||||||
|
private static cacheArrays: Collection<{key: string, values: string[]}> = new Collection<{key: string; values: string[]}>()
|
||||||
|
|
||||||
|
public fetch(key: string): string|undefined {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return MemoryCache.cacheItems
|
return MemoryCache.cacheItems
|
||||||
.where('key', '=', key)
|
.where('key', '=', key)
|
||||||
@@ -41,4 +44,41 @@ export class MemoryCache extends Cache {
|
|||||||
public drop(key: string): Awaitable<void> {
|
public drop(key: string): Awaitable<void> {
|
||||||
MemoryCache.cacheItems = MemoryCache.cacheItems.where('key', '!=', key)
|
MemoryCache.cacheItems = MemoryCache.cacheItems.where('key', '!=', key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public decrement(key: string, amount = 1): Awaitable<number | undefined> {
|
||||||
|
const nextValue = (parseInt(this.fetch(key) ?? '0', 10) ?? 0) - amount
|
||||||
|
this.put(key, String(nextValue))
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public increment(key: string, amount = 1): Awaitable<number | undefined> {
|
||||||
|
const nextValue = (parseInt(this.fetch(key) ?? '0', 10) ?? 0) + amount
|
||||||
|
this.put(key, String(nextValue))
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public pop(key: string): Awaitable<string | undefined> {
|
||||||
|
const value = this.fetch(key)
|
||||||
|
this.drop(key)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
public arrayPop(key: string): Awaitable<string | undefined> {
|
||||||
|
const arr = MemoryCache.cacheArrays.firstWhere('key', '=', key)
|
||||||
|
if ( arr ) {
|
||||||
|
return arr.values.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public arrayPush(key: string, value: string): Awaitable<void> {
|
||||||
|
const arr = MemoryCache.cacheArrays.firstWhere('key', '=', key)
|
||||||
|
if ( arr ) {
|
||||||
|
arr.values.push(value)
|
||||||
|
} else {
|
||||||
|
MemoryCache.cacheArrays.push({
|
||||||
|
key,
|
||||||
|
values: [value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/support/cache/RedisCache.ts
vendored
Normal file
85
src/support/cache/RedisCache.ts
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import {Cache, Maybe} from '../../util'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Redis} from '../redis/Redis'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis-driven Cache implementation.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RedisCache extends Cache {
|
||||||
|
/** The Redis service. */
|
||||||
|
@Inject()
|
||||||
|
protected readonly redis!: Redis
|
||||||
|
|
||||||
|
async arrayPop(key: string): Promise<string | undefined> {
|
||||||
|
return this.redis.pipe()
|
||||||
|
.tap(redis => redis.lpop(key))
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async arrayPush(key: string, value: string): Promise<void> {
|
||||||
|
await this.redis.pipe()
|
||||||
|
.tap(redis => redis.rpush(key, value))
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrement(key: string, amount?: number): Promise<number | undefined> {
|
||||||
|
return this.redis.pipe()
|
||||||
|
.tap(redis => redis.decrby(key, amount ?? 1))
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async increment(key: string, amount?: number): Promise<number | undefined> {
|
||||||
|
return this.redis.pipe()
|
||||||
|
.tap(redis => redis.incrby(key, amount ?? 1))
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async drop(key: string): Promise<void> {
|
||||||
|
await this.redis.pipe()
|
||||||
|
.tap(redis => redis.del(key))
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(key: string): Promise<string | undefined> {
|
||||||
|
return this.redis.pipe()
|
||||||
|
.tap(redis => redis.get(key))
|
||||||
|
.tap(value => value ?? undefined)
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
return this.redis.pipe()
|
||||||
|
.tap(redis => redis.exists(key))
|
||||||
|
.tap(numExisting => numExisting > 0)
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
pop(key: string): Promise<Maybe<string>> {
|
||||||
|
return new Promise<Maybe<string>>((res, rej) => {
|
||||||
|
this.redis.pipe()
|
||||||
|
.tap(redis => {
|
||||||
|
redis.multi()
|
||||||
|
.get(key, (err, value) => {
|
||||||
|
if ( err ) {
|
||||||
|
rej(err)
|
||||||
|
} else {
|
||||||
|
res(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.del(key)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(key: string, value: string, expires?: Date): Promise<void> {
|
||||||
|
await this.redis.multi()
|
||||||
|
.tap(redis => redis.set(key, value))
|
||||||
|
.when(Boolean(expires), redis => {
|
||||||
|
const seconds = Math.round(((new Date()).getTime() - expires!.getTime()) / 1000) // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return redis.expire(key, seconds)
|
||||||
|
})
|
||||||
|
.tap(pipeline => pipeline.exec())
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/support/queue/Queue.ts
Normal file
190
src/support/queue/Queue.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import {Awaitable, ErrorWithContext, JSONState, Maybe, Rehydratable, Cache} from '../../util'
|
||||||
|
import {CanonicalItemClass} from '../CanonicalReceiver'
|
||||||
|
import {Container, Inject, Injectable, isInstantiable} from '../../di'
|
||||||
|
import {Canon} from '../../service/Canon'
|
||||||
|
|
||||||
|
/** Type annotation for a Queueable that should be pushed onto a queue. */
|
||||||
|
export type ShouldQueue<T> = T & Queueable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for an object that can be pushed to/popped from a queue.
|
||||||
|
*/
|
||||||
|
export abstract class Queueable extends CanonicalItemClass implements Rehydratable {
|
||||||
|
abstract dehydrate(): Awaitable<JSONState>
|
||||||
|
|
||||||
|
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the item is popped from the queue, this method is called.
|
||||||
|
*/
|
||||||
|
public abstract execute(): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the object should be pushed to the queue or not.
|
||||||
|
*/
|
||||||
|
public shouldQueue(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the queue where this object should be pushed by default.
|
||||||
|
*/
|
||||||
|
public defaultQueue(): string {
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the canonical resolver so we can re-instantiate this class from the queue.
|
||||||
|
* Throw an error if it could not be determined.
|
||||||
|
*/
|
||||||
|
public getFullyQualifiedCanonicalResolver(): string {
|
||||||
|
const resolver = (this.constructor as typeof Queueable).getFullyQualifiedCanonicalResolver()
|
||||||
|
if ( !resolver ) {
|
||||||
|
throw new ErrorWithContext('Cannot push Queueable onto queue: missing canonical resolver.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truth function that returns true if an object implements the same interface as Queueable.
|
||||||
|
* This is done in case some external library needs to be incorporated as the base class for
|
||||||
|
* a Queueable, and cannot be made to extend Queueable.
|
||||||
|
* @param something
|
||||||
|
*/
|
||||||
|
export function isQueueable(something: unknown): something is Queueable {
|
||||||
|
if ( something instanceof Queueable ) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
typeof something === 'function'
|
||||||
|
&& typeof (something as any).dehydrate === 'function'
|
||||||
|
&& typeof (something as any).rehydrate === 'function'
|
||||||
|
&& typeof (something as any).shouldQueue === 'function'
|
||||||
|
&& typeof (something as any).defaultQueue === 'function'
|
||||||
|
&& typeof (something as any).getFullyQualifiedCanonicalResolver === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truth function that returns true if the given object is Queueable and wants to be
|
||||||
|
* pushed onto the queue.
|
||||||
|
* @param something
|
||||||
|
*/
|
||||||
|
export function shouldQueue<T>(something: T): something is ShouldQueue<T> {
|
||||||
|
return isQueueable(something) && something.shouldQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A multi-node queue that accepts & reinstantiates Queueables.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* There are several queue backends your application may use. These are
|
||||||
|
* configured via the `queue` config. To get the default queue, however,
|
||||||
|
* use this class as a DI token:
|
||||||
|
* ```ts
|
||||||
|
* this.container().make<Queue>(Queue)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This will resolve the concrete implementation configured by your app.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class Queue {
|
||||||
|
@Inject()
|
||||||
|
protected readonly cache!: Cache
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly canon!: Canon
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public get queueIdentifier(): string {
|
||||||
|
return `extollo__queue__${this.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the number of items waiting in the queue. */
|
||||||
|
// public abstract length(): Awaitable<number>
|
||||||
|
|
||||||
|
/** Push a new queueable onto the queue. */
|
||||||
|
public async push(item: ShouldQueue<Queueable>): Promise<void> {
|
||||||
|
const data = {
|
||||||
|
q: true,
|
||||||
|
r: item.getFullyQualifiedCanonicalResolver(),
|
||||||
|
d: await item.dehydrate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove and return a queueable from the queue. */
|
||||||
|
public async pop(): Promise<Maybe<Queueable>> {
|
||||||
|
const item = await this.cache.arrayPop(this.queueIdentifier)
|
||||||
|
if ( !item ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(item)
|
||||||
|
if ( !data.q || !data.r ) {
|
||||||
|
throw new ErrorWithContext('Cannot pop Queueable: payload is invalid.', {
|
||||||
|
data,
|
||||||
|
queueName: this.name,
|
||||||
|
queueIdentifier: this.queueIdentifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalItem = this.canon.getFromFullyQualified(data.r)
|
||||||
|
if ( !canonicalItem ) {
|
||||||
|
throw new ErrorWithContext('Cannot pop Queueable: canonical name is not resolvable', {
|
||||||
|
data,
|
||||||
|
queueName: this.name,
|
||||||
|
queueIdentifier: this.queueIdentifier,
|
||||||
|
canonicalName: data.r,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !isInstantiable(canonicalItem) ) {
|
||||||
|
throw new ErrorWithContext('Cannot pop Queueable: canonical item is not instantiable', {
|
||||||
|
data,
|
||||||
|
canonicalItem,
|
||||||
|
queueName: this.name,
|
||||||
|
queueIdentifier: this.queueIdentifier,
|
||||||
|
canonicalName: data.r,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = this.injector.make(canonicalItem)
|
||||||
|
if ( !isQueueable(instance) ) {
|
||||||
|
throw new ErrorWithContext('Cannot pop Queueable: canonical item instance is not Queueable', {
|
||||||
|
data,
|
||||||
|
canonicalItem,
|
||||||
|
instance,
|
||||||
|
queueName: this.name,
|
||||||
|
queueIdentifier: this.queueIdentifier,
|
||||||
|
canonicalName: data.r,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await instance.rehydrate(data.d)
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a raw payload onto the queue. */
|
||||||
|
public async pushRaw(item: JSONState): Promise<void> {
|
||||||
|
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove and return a raw payload from the queue. */
|
||||||
|
public async popRaw(): Promise<Maybe<JSONState>> {
|
||||||
|
const item = await this.cache.arrayPop(this.queueIdentifier)
|
||||||
|
if ( item ) {
|
||||||
|
return JSON.parse(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/support/redis/Redis.ts
Normal file
75
src/support/redis/Redis.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {Inject, Singleton} from '../../di'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import * as IORedis from 'ioredis'
|
||||||
|
import {RedisOptions} from 'ioredis'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Unit} from '../../lifecycle/Unit'
|
||||||
|
import {AsyncPipe} from '../../util'
|
||||||
|
|
||||||
|
export {RedisOptions} from 'ioredis'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit that loads configuration for and manages instantiation
|
||||||
|
* of an IORedis connection.
|
||||||
|
*/
|
||||||
|
@Singleton()
|
||||||
|
export class Redis extends Unit {
|
||||||
|
/** The config service. */
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
/** The loggers. */
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instantiated connection, if one exists.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private connection?: IORedis.Redis
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.logging.info('Attempting initial connection to Redis...')
|
||||||
|
this.logging.debug('Config:')
|
||||||
|
this.logging.debug(Config)
|
||||||
|
this.logging.debug(this.config)
|
||||||
|
await this.getConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.logging.info('Disconnecting Redis...')
|
||||||
|
if ( this.connection?.status === 'ready' ) {
|
||||||
|
await this.connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the IORedis connection instance.
|
||||||
|
*/
|
||||||
|
public async getConnection(): Promise<IORedis.Redis> {
|
||||||
|
if ( !this.connection ) {
|
||||||
|
const options = this.config.get('redis.connection') as RedisOptions
|
||||||
|
this.logging.verbose(options)
|
||||||
|
this.connection = new IORedis(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the IORedis connection in an AsyncPipe.
|
||||||
|
*/
|
||||||
|
public pipe(): AsyncPipe<IORedis.Redis> {
|
||||||
|
return new AsyncPipe<IORedis.Redis>(() => this.getConnection())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an IORedis.Pipeline instance in an AsyncPipe.
|
||||||
|
*/
|
||||||
|
public multi(): AsyncPipe<IORedis.Pipeline> {
|
||||||
|
return this.pipe()
|
||||||
|
.tap(redis => {
|
||||||
|
return redis.multi()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/util/cache/Cache.ts
vendored
37
src/util/cache/Cache.ts
vendored
@@ -15,8 +15,9 @@ export abstract class Cache {
|
|||||||
* Store the given value in the cache by key.
|
* Store the given value in the cache by key.
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
* @param {string} value
|
* @param {string} value
|
||||||
|
* @param expires
|
||||||
*/
|
*/
|
||||||
public abstract put(key: string, value: string): Awaitable<void>;
|
public abstract put(key: string, value: string, expires?: Date): Awaitable<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the cache has the given key.
|
* Check if the cache has the given key.
|
||||||
@@ -30,4 +31,38 @@ export abstract class Cache {
|
|||||||
* @param {string} key
|
* @param {string} key
|
||||||
*/
|
*/
|
||||||
public abstract drop(key: string): Awaitable<void>;
|
public abstract drop(key: string): Awaitable<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an item from the cache by key, and then remove it.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
public abstract pop(key: string): Awaitable<string|undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment a key in the cache by a given amount.
|
||||||
|
* @param key
|
||||||
|
* @param amount
|
||||||
|
*/
|
||||||
|
public abstract increment(key: string, amount?: number): Awaitable<number|undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement a key in the cache by a given amount.
|
||||||
|
* @param key
|
||||||
|
* @param amount
|
||||||
|
*/
|
||||||
|
public abstract decrement(key: string, amount?: number): Awaitable<number|undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push an item onto the end an array-like key.
|
||||||
|
* @param key
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
public abstract arrayPush(key: string, value: string): Awaitable<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return an item from the beginning of an array-like key.
|
||||||
|
* @param key
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
public abstract arrayPop(key: string): Awaitable<string|undefined>;
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/util/cache/InMemCache.ts
vendored
59
src/util/cache/InMemCache.ts
vendored
@@ -1,5 +1,7 @@
|
|||||||
import { Cache } from './Cache'
|
import { Cache } from './Cache'
|
||||||
import { Collection } from '../collection/Collection'
|
import { Collection } from '../collection/Collection'
|
||||||
|
import {Awaitable, Maybe} from '../support/types'
|
||||||
|
import {ErrorWithContext} from '../error/ErrorWithContext'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base interface for an item stored in a memory cache.
|
* Base interface for an item stored in a memory cache.
|
||||||
@@ -44,4 +46,61 @@ export class InMemCache extends Cache {
|
|||||||
public async drop(key: string): Promise<void> {
|
public async drop(key: string): Promise<void> {
|
||||||
this.items = this.items.whereNot('key', '=', key)
|
this.items = this.items.whereNot('key', '=', key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public pop(key: string): Awaitable<Maybe<string>> {
|
||||||
|
const existing = this.items.firstWhere('key', '=', key)
|
||||||
|
this.items = this.items.where('key', '!=', key)
|
||||||
|
return existing?.item
|
||||||
|
}
|
||||||
|
|
||||||
|
public async increment(key: string, amount?: number): Promise<number> {
|
||||||
|
const next = parseInt((await this.fetch(key)) ?? '0', 10) + (amount ?? 1)
|
||||||
|
await this.put(key, String(next))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
public async decrement(key: string, amount?: number): Promise<number> {
|
||||||
|
const next = parseInt((await this.fetch(key)) ?? '0', 10) - (amount ?? 1)
|
||||||
|
await this.put(key, String(next))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
public arrayPush(key: string, value: string): Awaitable<void> {
|
||||||
|
const existing = this.items.where('key', '=', key).first()
|
||||||
|
const arr = JSON.parse(existing?.item ?? '[]')
|
||||||
|
|
||||||
|
if ( !Array.isArray(arr) ) {
|
||||||
|
throw new ErrorWithContext('Unable to arrayPush: key is not an array', {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
arr.push(value)
|
||||||
|
if ( existing ) {
|
||||||
|
existing.item = JSON.stringify(arr)
|
||||||
|
} else {
|
||||||
|
this.items.push({
|
||||||
|
key,
|
||||||
|
item: JSON.stringify(arr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public arrayPop(key: string): Awaitable<Maybe<string>> {
|
||||||
|
const existing = this.items.where('key', '=', key).first()
|
||||||
|
const arr = JSON.parse(existing?.item ?? '[]')
|
||||||
|
|
||||||
|
const value = arr.pop()
|
||||||
|
if ( existing ) {
|
||||||
|
existing.item = JSON.stringify(arr)
|
||||||
|
} else {
|
||||||
|
this.items.push({
|
||||||
|
key,
|
||||||
|
item: JSON.stringify(arr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export class AsyncCollection<T> {
|
|||||||
await this.each(async (item, index) => {
|
await this.each(async (item, index) => {
|
||||||
const result = await func(item, index)
|
const result = await func(item, index)
|
||||||
if ( typeof result !== 'undefined' ) {
|
if ( typeof result !== 'undefined' ) {
|
||||||
newItems.push(result as NonNullable<T2>)
|
newItems.push(result as unknown as NonNullable<T2>)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem
|
|||||||
import { WhereOperator, applyWhere, whereMatch } from './where'
|
import { WhereOperator, applyWhere, whereMatch } from './where'
|
||||||
|
|
||||||
const collect = <T>(items: CollectionItem<T>[]): Collection<T> => Collection.collect(items)
|
const collect = <T>(items: CollectionItem<T>[]): Collection<T> => Collection.collect(items)
|
||||||
|
const toString = (item: unknown): string => String(item)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
collect,
|
collect,
|
||||||
|
toString,
|
||||||
Collection,
|
Collection,
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {Collection} from './Collection'
|
import {Collection} from './Collection'
|
||||||
|
import {InjectionAware} from '../../di'
|
||||||
|
|
||||||
export type MaybeIterationItem<T> = { done: boolean, value?: T }
|
export type MaybeIterationItem<T> = { done: boolean, value?: T }
|
||||||
export type ChunkCallback<T> = (items: Collection<T>) => any
|
export type ChunkCallback<T> = (items: Collection<T>) => any
|
||||||
@@ -9,7 +10,7 @@ export class StopIteration extends Error {}
|
|||||||
* Abstract class representing an iterable, lazy-loaded dataset.
|
* Abstract class representing an iterable, lazy-loaded dataset.
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export abstract class Iterable<T> {
|
export abstract class Iterable<T> extends InjectionAware {
|
||||||
/**
|
/**
|
||||||
* The current index of the iterable.
|
* The current index of the iterable.
|
||||||
* @type number
|
* @type number
|
||||||
|
|||||||
10
src/util/error/MethodNotSupportedError.ts
Normal file
10
src/util/error/MethodNotSupportedError.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {ErrorWithContext} from './ErrorWithContext'
|
||||||
|
|
||||||
|
export class MethodNotSupportedError extends ErrorWithContext {
|
||||||
|
constructor(
|
||||||
|
message = 'Method not supported',
|
||||||
|
context: {[key: string]: any} = {},
|
||||||
|
) {
|
||||||
|
super(message, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {RequestInfo, RequestInit, Response} from 'node-fetch'
|
||||||
|
import {unsafeESMImport} from './unsafe'
|
||||||
|
export const fetch = (url: RequestInfo, init?: RequestInit): Promise<Response> => unsafeESMImport('node-fetch').then(({default: nodeFetch}) => nodeFetch(url, init))
|
||||||
|
|
||||||
export * from './cache/Cache'
|
export * from './cache/Cache'
|
||||||
export * from './cache/InMemCache'
|
export * from './cache/InMemCache'
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ export * from './collection/where'
|
|||||||
export * from './const/http'
|
export * from './const/http'
|
||||||
|
|
||||||
export * from './error/ErrorWithContext'
|
export * from './error/ErrorWithContext'
|
||||||
|
export * from './error/MethodNotSupportedError'
|
||||||
|
|
||||||
export * from './logging/Logger'
|
export * from './logging/Logger'
|
||||||
export * from './logging/StandardLogger'
|
export * from './logging/StandardLogger'
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class BehaviorSubject<T> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ( e instanceof UnsubscribeError ) {
|
if ( e instanceof UnsubscribeError ) {
|
||||||
this.subscribers = this.subscribers.filter(x => x !== subscriber)
|
this.subscribers = this.subscribers.filter(x => x !== subscriber)
|
||||||
} else if (subscriber.error) {
|
} else if (subscriber.error && e instanceof Error) {
|
||||||
await subscriber.error(e)
|
await subscriber.error(e)
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
@@ -181,7 +181,7 @@ export class BehaviorSubject<T> {
|
|||||||
try {
|
try {
|
||||||
await subscriber.complete(finalValue)
|
await subscriber.complete(finalValue)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ( subscriber.error ) {
|
if ( subscriber.error && e instanceof Error ) {
|
||||||
await subscriber.error(e)
|
await subscriber.error(e)
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type PipeOperator<T, T2> = (subject: T) => T2
|
|||||||
/**
|
/**
|
||||||
* A closure that maps a given pipe item to an item of the same type.
|
* A closure that maps a given pipe item to an item of the same type.
|
||||||
*/
|
*/
|
||||||
export type ReflexivePipeOperator<T> = (subject: T) => T|void
|
export type ReflexivePipeOperator<T> = (subject: T) => T
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A condition or condition-resolving function for pipe methods.
|
* A condition or condition-resolving function for pipe methods.
|
||||||
@@ -97,7 +97,7 @@ export class Pipe<T> {
|
|||||||
*/
|
*/
|
||||||
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||||
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
||||||
Pipe.wrap(op(this.subject))
|
return Pipe.wrap(op(this.subject))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@@ -115,8 +115,7 @@ export class Pipe<T> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
Pipe.wrap(op(this.subject))
|
return Pipe.wrap(op(this.subject))
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +157,8 @@ export type AsyncPipeResolver<T> = () => Awaitable<T>
|
|||||||
*/
|
*/
|
||||||
export type AsyncPipeOperator<T, T2> = (subject: T) => Awaitable<T2>
|
export type AsyncPipeOperator<T, T2> = (subject: T) => Awaitable<T2>
|
||||||
|
|
||||||
|
export type PromisePipeOperator<T, T2> = (subject: T, resolve: (val: T2) => unknown, reject: (err: Error) => unknown) => Awaitable<unknown>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A closure that maps a given pipe item to an item of the same type.
|
* A closure that maps a given pipe item to an item of the same type.
|
||||||
*/
|
*/
|
||||||
@@ -193,6 +194,23 @@ export class AsyncPipe<T> {
|
|||||||
return new AsyncPipe<T2>(async () => op(await this.subject()))
|
return new AsyncPipe<T2>(async () => op(await this.subject()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a transformative operator to the pipe, wrapping it
|
||||||
|
* in a Promise and passing the resolve/reject callbacks to the
|
||||||
|
* closure.
|
||||||
|
* @param op
|
||||||
|
*/
|
||||||
|
promise<T2>(op: PromisePipeOperator<T, T2>): AsyncPipe<T2> {
|
||||||
|
return new AsyncPipe<T2>(() => {
|
||||||
|
return new Promise<T2>((res, rej) => {
|
||||||
|
(async () => this.subject())()
|
||||||
|
.then(subject => {
|
||||||
|
op(subject, res, rej)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply an operator to the pipe, but return the reference
|
* Apply an operator to the pipe, but return the reference
|
||||||
* to the current pipe. The operator is resolved when the
|
* to the current pipe. The operator is resolved when the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export function isDebugging(key: string): boolean {
|
export function isDebugging(key: string): boolean {
|
||||||
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
|
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
|
|
||||||
return process.env[env] === 'yes'
|
return process.env[env] === 'yes'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as mime from 'mime-types'
|
|||||||
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
||||||
import {Collection} from '../collection/Collection'
|
import {Collection} from '../collection/Collection'
|
||||||
import {Readable, Writable} from 'stream'
|
import {Readable, Writable} from 'stream'
|
||||||
|
import {Pipe} from './Pipe'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item that could represent a path.
|
* An item that could represent a path.
|
||||||
@@ -82,6 +83,8 @@ export class UniversalPath {
|
|||||||
|
|
||||||
protected resourceLocalPath!: string
|
protected resourceLocalPath!: string
|
||||||
|
|
||||||
|
protected resourceQuery: URLSearchParams = new URLSearchParams()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
/**
|
/**
|
||||||
* The path string this path refers to.
|
* The path string this path refers to.
|
||||||
@@ -94,6 +97,10 @@ export class UniversalPath {
|
|||||||
) {
|
) {
|
||||||
this.setPrefix()
|
this.setPrefix()
|
||||||
this.setLocal()
|
this.setLocal()
|
||||||
|
|
||||||
|
if ( this.isRemote ) {
|
||||||
|
this.resourceQuery = (new URL(this.toRemote)).searchParams
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,6 +147,13 @@ export class UniversalPath {
|
|||||||
return new UniversalPath(this.initial)
|
return new UniversalPath(this.initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URLSearchParams for this resource.
|
||||||
|
*/
|
||||||
|
get query(): URLSearchParams {
|
||||||
|
return this.resourceQuery
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the string of this resource.
|
* Get the string of this resource.
|
||||||
*/
|
*/
|
||||||
@@ -183,7 +197,8 @@ export class UniversalPath {
|
|||||||
* Get the fully-prefixed path to this resource.
|
* Get the fully-prefixed path to this resource.
|
||||||
*/
|
*/
|
||||||
get toRemote(): string {
|
get toRemote(): string {
|
||||||
return `${this.prefix}${this.resourceLocalPath}`
|
const query = this.query.toString()
|
||||||
|
return `${this.prefix}${this.resourceLocalPath}${query ? '?' + query : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -517,4 +532,9 @@ export class UniversalPath {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get a new Pipe instance wrapping this. */
|
||||||
|
toPipe(): Pipe<UniversalPath> {
|
||||||
|
return Pipe.wrap(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export class LocalFilesystem extends Filesystem {
|
|||||||
isFile: stat.isFile(),
|
isFile: stat.isFile(),
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ( e?.code === 'ENOENT' ) {
|
if ( (e as any)?.code === 'ENOENT' ) {
|
||||||
return {
|
return {
|
||||||
path: new UniversalPath(args.storePath, this),
|
path: new UniversalPath(args.storePath, this),
|
||||||
exists: false,
|
exists: false,
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ export function withTimeout<T>(timeout: number, promise: Promise<T>): TimeoutSub
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
let expired = false
|
let expired = false
|
||||||
let resolved = false
|
let resolved = false
|
||||||
|
if ( timeout ) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expired = true
|
expired = true
|
||||||
if ( !resolved ) {
|
if ( !resolved ) {
|
||||||
timeoutHandler()
|
timeoutHandler()
|
||||||
}
|
}
|
||||||
}, timeout)
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
const result: T = await promise
|
const result: T = await promise
|
||||||
resolved = true
|
resolved = true
|
||||||
|
|||||||
@@ -9,3 +9,12 @@ export type ParameterizedCallback<T> = ((arg: T) => any)
|
|||||||
|
|
||||||
/** A key-value form of a given type. */
|
/** A key-value form of a given type. */
|
||||||
export type KeyValue<T> = {key: string, value: T}
|
export type KeyValue<T> = {key: string, value: T}
|
||||||
|
|
||||||
|
/** Simple helper method to verify that a key is a keyof some object. */
|
||||||
|
export function isKeyof<T>(key: unknown, obj: T): key is keyof T {
|
||||||
|
if ( typeof key !== 'string' && typeof key !== 'symbol' ) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return key in obj
|
||||||
|
}
|
||||||
|
|||||||
24
src/util/unsafe.ts
Normal file
24
src/util/unsafe.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* UNSAFE
|
||||||
|
*
|
||||||
|
* Sometimes, we need to make a literal `import()` call from within commonJS
|
||||||
|
* modules in order to pull in ES modules from commonJS.
|
||||||
|
*
|
||||||
|
* However, when tsc renders the modules to commonJS, it rewrites _all_ calls
|
||||||
|
* to `import` as calls to `require`, which means we cannot actually use ES
|
||||||
|
* modules from commonJS-transpiled TypeScript.
|
||||||
|
*
|
||||||
|
* To bypass this, we can eval the literal string. This is a stupid hack and
|
||||||
|
* I hate it so much, but unfortunately it works.
|
||||||
|
*
|
||||||
|
* So, this is a wrapper function that results in a call to the literal
|
||||||
|
* `import(...)` function in the transpiled code. It should be used VERY
|
||||||
|
* sparingly.
|
||||||
|
*
|
||||||
|
* @see https://github.com/microsoft/TypeScript/issues/43329
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
export function unsafeESMImport(path: string): Promise<any> {
|
||||||
|
((p: string) => p)(path)
|
||||||
|
return eval('import(path)') // eslint-disable-line no-eval
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export class PugViewEngine extends ViewEngine {
|
|||||||
return {
|
return {
|
||||||
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
|
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
|
||||||
debug: this.debug,
|
debug: this.debug,
|
||||||
compileDebug: this.debug,
|
// compileDebug: this.debug,
|
||||||
globals: [],
|
globals: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user