Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
395e8e4d1c |
253
.drone.yml
253
.drone.yml
@ -1,87 +1,228 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
type: docker
|
||||
name: docs
|
||||
|
||||
metadata:
|
||||
labels:
|
||||
pod-security.kubernetes.io/audit: privileged
|
||||
|
||||
services:
|
||||
- name: docker daemon
|
||||
image: docker:dind
|
||||
privileged: true
|
||||
environment:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
when:
|
||||
event: tag
|
||||
|
||||
steps:
|
||||
- name: typedoc build
|
||||
image: node:18
|
||||
# ============ BUILD STEPS ===============
|
||||
- name: build documentation
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- "node -v"
|
||||
- "npm add --global pnpm"
|
||||
- "pnpm --version"
|
||||
- pnpm i
|
||||
- pnpm run docs:build
|
||||
- pnpm i --silent
|
||||
- pnpm docs:build
|
||||
- cd docs && tar czf ../extollo_api_documentation.tar.gz www
|
||||
|
||||
- name: container build
|
||||
image: docker:latest
|
||||
privileged: true
|
||||
commands:
|
||||
- "while ! docker stats --no-stream; do sleep 1; done"
|
||||
- docker image build docs -t $DOCKER_REGISTRY/extollo/docs:latest
|
||||
- docker push $DOCKER_REGISTRY/extollo/docs:latest
|
||||
environment:
|
||||
DOCKER_HOST: tcp://localhost:2375
|
||||
DOCKER_REGISTRY:
|
||||
from_secret: DOCKER_REGISTRY
|
||||
# =============== DEPLOY STEPS ===============
|
||||
- name: copy artifacts to static host
|
||||
image: appleboy/drone-scp
|
||||
settings:
|
||||
host:
|
||||
from_secret: docs_deploy_host
|
||||
username:
|
||||
from_secret: docs_deploy_user
|
||||
key:
|
||||
from_secret: docs_deploy_key
|
||||
port: 22
|
||||
source: extollo_api_documentation.tar.gz
|
||||
target: /var/nfs/storage/static/sites/extollo
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
event: promote
|
||||
target: docs
|
||||
|
||||
- name: k8s rollout
|
||||
image: bitnami/kubectl
|
||||
commands:
|
||||
- cd docs/deploy && kubectl apply -f .
|
||||
- kubectl rollout restart -n extollo deployment/docs
|
||||
- name: deploy artifacts on static host
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
host:
|
||||
from_secret: docs_deploy_host
|
||||
username:
|
||||
from_secret: docs_deploy_user
|
||||
key:
|
||||
from_secret: docs_deploy_key
|
||||
port: 22
|
||||
script:
|
||||
- cd /var/nfs/storage/static/sites/extollo
|
||||
- rm -rf docs
|
||||
- tar xzf extollo_api_documentation.tar.gz
|
||||
- rm -rf extollo_api_documentation.tar.gz
|
||||
- mv www docs
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
event: promote
|
||||
target: docs
|
||||
|
||||
# =============== BUILD NOTIFICATIONS ===============
|
||||
- name: send build success notifications
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: notify_webhook_url
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
||||
"message": "Build & deploy completed successfully.",
|
||||
"priority": 4
|
||||
}
|
||||
when:
|
||||
status: success
|
||||
event:
|
||||
- promote
|
||||
|
||||
- name: send build error notifications
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: notify_webhook_url
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
||||
"message": "Documentation build failed!",
|
||||
"priority": 6
|
||||
}
|
||||
when:
|
||||
status: failure
|
||||
---
|
||||
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: npm
|
||||
|
||||
name: default
|
||||
type: docker
|
||||
steps:
|
||||
- name: node.js build
|
||||
image: node:18
|
||||
commands:
|
||||
- "npm add --global pnpm"
|
||||
- pnpm i
|
||||
- pnpm build
|
||||
- name: post build in progress comment to PR
|
||||
image: tsakidev/giteacomment:latest
|
||||
settings:
|
||||
gitea_token:
|
||||
from_secret: gitea_token
|
||||
gitea_base_url: https://code.garrettmills.dev
|
||||
comment: "Build ${DRONE_BUILD_NUMBER} started."
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
- name: gitea release
|
||||
- name: remove lockfile
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- rm -rf pnpm-lock.yaml
|
||||
when:
|
||||
event:
|
||||
exclude: tag
|
||||
|
||||
- name: Install dependencies
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm i
|
||||
|
||||
- name: Lint code
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm lint
|
||||
|
||||
- name: build module
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm build
|
||||
- mkdir artifacts
|
||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
||||
|
||||
- name: create Gitea release
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITEA_TOKEN
|
||||
from_secret: gitea_token
|
||||
base_url: https://code.garrettmills.dev
|
||||
checksum: md5
|
||||
title: ${DRONE_TAG}
|
||||
files: "artifacts/*"
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
- name: npm release
|
||||
- name: prepare NPM release
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- rm -rf artifacts
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
- name: create NPM release
|
||||
image: plugins/npm
|
||||
settings:
|
||||
username: extollo_bot
|
||||
password:
|
||||
from_secret: NPM_PASSWORD
|
||||
from_secret: npm_password
|
||||
email: extollo@garrettmills.dev
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
- name: send build success notifications
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: notify_webhook_url
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
||||
"message": "Build completed successfully.",
|
||||
"priority": 4
|
||||
}
|
||||
when:
|
||||
status: success
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
- tag
|
||||
|
||||
- name: send publish success notifications
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: notify_webhook_url
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
||||
"message": "Successfully published tag ${DRONE_TAG}.",
|
||||
"priority": 4
|
||||
}
|
||||
when:
|
||||
status: success
|
||||
event: tag
|
||||
|
||||
- name: post build success comment to PR
|
||||
image: tsakidev/giteacomment:latest
|
||||
settings:
|
||||
gitea_token:
|
||||
from_secret: gitea_token
|
||||
gitea_base_url: https://code.garrettmills.dev
|
||||
comment: "Build ${DRONE_BUILD_NUMBER} completed successfully."
|
||||
when:
|
||||
status: success
|
||||
event: pull_request
|
||||
|
||||
- name: send build error notifications
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: notify_webhook_url
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
||||
"message": "Build failed!",
|
||||
"priority": 6
|
||||
}
|
||||
when:
|
||||
status: failure
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
- name: post build error comment to PR
|
||||
image: tsakidev/giteacomment:latest
|
||||
settings:
|
||||
gitea_token:
|
||||
from_secret: gitea_token
|
||||
gitea_base_url: https://code.garrettmills.dev
|
||||
comment: "Build ${DRONE_BUILD_NUMBER} failed!"
|
||||
when:
|
||||
status: failure
|
||||
event: pull_request
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,3 @@
|
||||
.undodir
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
@ -1,4 +0,0 @@
|
||||
FROM joseluisq/static-web-server:2
|
||||
|
||||
COPY ./www /public
|
||||
|
@ -22,7 +22,7 @@ Node.js provides an excellent platform for quickly getting an application up and
|
||||
## Getting Started
|
||||
Writing an application with Extollo is very straightforward if you are familiar with Node.js/TypeScript, or similar frameworks like Laravel.
|
||||
|
||||
Check out the [Getting Started](pages/Getting-Started.html) page site for more information.
|
||||
Check out the [Getting Started](https://extollo.garrettmills.dev/pages/Documentation/Getting-Started.html) page site for more information.
|
||||
|
||||
## License & Philosophy
|
||||
The Extollo project is, and will always be, free & libre software. The framework itself is open-source available [here](https://code.garrettmills.dev/Extollo), and is licensed under the terms of the MIT license. See the LICENSE file for more information.
|
||||
|
@ -1,5 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: extollo
|
@ -1,23 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: docs
|
||||
namespace: extollo
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: docs
|
||||
template:
|
||||
metadata:
|
||||
name: docs
|
||||
namespace: extollo
|
||||
labels:
|
||||
app: docs
|
||||
spec:
|
||||
containers:
|
||||
- name: docs-www
|
||||
image: registry.millslan.net/extollo/docs
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
@ -1,25 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: docs-service
|
||||
namespace: extollo
|
||||
spec:
|
||||
selector:
|
||||
app: docs
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: docs-service-lb
|
||||
namespace: extollo
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: docs
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
@ -1,13 +0,0 @@
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: docs-tls
|
||||
namespace: extollo
|
||||
spec:
|
||||
secretName: docs-tls-secret
|
||||
dnsNames:
|
||||
- 'extollo.garrettmills.dev'
|
||||
issuerRef:
|
||||
name: letsencrypt-ca
|
||||
kind: ClusterIssuer
|
@ -1,25 +0,0 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: docs-ingress
|
||||
namespace: extollo
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: 'false'
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- extollo.garrettmills.dev
|
||||
secretName: docs-tls-secret
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: extollo.garrettmills.dev
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: '/'
|
||||
backend:
|
||||
service:
|
||||
name: docs-service
|
||||
port:
|
||||
number: 80
|
@ -0,0 +1 @@
|
||||
# About the Extollo Project
|
@ -1,3 +1,4 @@
|
||||
# Getting Started with Extollo
|
||||
|
||||
## Requirements
|
||||
|
||||
|
1608
package-lock.json
generated
Normal file
1608
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.14.14",
|
||||
"version": "0.9.23",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@ -8,48 +8,47 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atao60/fse-cli": "^0.1.7",
|
||||
"@atao60/fse-cli": "^0.1.6",
|
||||
"@extollo/ui": "^0.1.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/busboy": "^0.2.4",
|
||||
"@types/cli-table": "^0.3.1",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/mkdirp": "^1.0.2",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cli-table": "^0.3.0",
|
||||
"@types/ioredis": "^4.26.6",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
"@types/node": "^14.18.51",
|
||||
"@types/pg": "^8.10.2",
|
||||
"@types/node": "^14.17.4",
|
||||
"@types/pg": "^8.6.0",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/pug": "^2.0.6",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/ssh2": "^0.5.52",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/ws": "^8.5.5",
|
||||
"bcrypt": "^5.1.0",
|
||||
"@types/pug": "^2.0.4",
|
||||
"@types/rimraf": "^3.0.0",
|
||||
"@types/ssh2": "^0.5.46",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"busboy": "^0.3.1",
|
||||
"cli-table": "^0.3.11",
|
||||
"cli-table": "^0.3.6",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.6.0",
|
||||
"ioredis": "^4.28.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"ioredis": "^4.27.6",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"mime-types": "^2.1.31",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.3",
|
||||
"node-fetch": "^3.3.1",
|
||||
"pg": "^8.11.0",
|
||||
"negotiator": "^0.6.2",
|
||||
"node-fetch": "^3",
|
||||
"pg": "^8.6.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"pug": "^3.0.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"sqlite": "^4.2.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ssh2": "^1.13.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.5",
|
||||
"ssh2": "^1.1.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typedoc": "^0.20.36",
|
||||
"typedoc-plugin-pages-fork": "^0.0.1",
|
||||
"typedoc-plugin-sourcefile-url": "^1.0.6",
|
||||
"typescript": "^4.2.3",
|
||||
"uuid": "^8.3.2",
|
||||
"ws": "^8.13.0",
|
||||
"zod": "^3.21.4"
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'",
|
||||
@ -57,7 +56,6 @@
|
||||
"app": "tsc && node lib/index.js",
|
||||
"prepare": "pnpm run build",
|
||||
"docs:build": "typedoc --options typedoc.json",
|
||||
"docs:build:docker": "pnpm run docs:build && docker image build docs -t ${DOCKER_REGISTRY}/extollo/docs:latest && docker push ${DOCKER_REGISTRY}/extollo/docs:latest",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint:fix": "eslint --fix . --ext .ts"
|
||||
},
|
||||
@ -73,19 +71,16 @@
|
||||
"author": "garrettmills <shout@garrettmills.dev>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@knodes/typedoc-plugin-pages": "^0.23.4",
|
||||
"@types/chai": "^4.3.5",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/sinon": "^10.0.15",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/sinon": "^10.0.6",
|
||||
"@types/wtfnode": "^0.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.11",
|
||||
"@typescript-eslint/parser": "^5.59.11",
|
||||
"chai": "^4.3.7",
|
||||
"eslint": "^8.42.0",
|
||||
"lunr": "^2.3.9",
|
||||
"mocha": "^9.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
||||
"@typescript-eslint/parser": "^4.26.0",
|
||||
"chai": "^4.3.4",
|
||||
"eslint": "^7.27.0",
|
||||
"mocha": "^9.1.3",
|
||||
"sinon": "^12.0.1",
|
||||
"typedoc": "^0.23.28",
|
||||
"wtfnode": "^0.9.1"
|
||||
},
|
||||
"extollo": {
|
||||
|
3329
pnpm-lock.yaml
3329
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -27,15 +27,6 @@ export class Authentication extends Unit {
|
||||
|
||||
protected providers: {[name: string]: LoginProvider<LoginProviderConfig>} = {}
|
||||
|
||||
getProvider(name: string): LoginProvider<LoginProviderConfig> {
|
||||
const provider = this.providers[name]
|
||||
if ( !provider ) {
|
||||
throw new ErrorWithContext('Invalid auth provider name: ' + name, { name })
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
||||
|
||||
|
@ -80,9 +80,6 @@ export abstract class SecurityContext {
|
||||
/**
|
||||
* Assuming a user is still authenticated in the context,
|
||||
* try to look up and fill in the user.
|
||||
*
|
||||
* If there is NO USER to be resumed, then the method should flush
|
||||
* the user from this context.
|
||||
*/
|
||||
abstract resume(): Awaitable<void>
|
||||
|
||||
|
@ -33,10 +33,7 @@ export class SessionSecurityContext extends SecurityContext {
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.authenticatedUser = undefined
|
||||
}
|
||||
}
|
||||
|
@ -1,44 +0,0 @@
|
||||
import {SecurityContext} from './SecurityContext'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {Awaitable} from '../../util'
|
||||
import {Inject} from '../../di'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {OAuth2Token, TokenRepository} from '../server/types'
|
||||
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
|
||||
|
||||
export class TokenSecurityContext extends SecurityContext {
|
||||
@Inject()
|
||||
protected readonly request!: Request
|
||||
|
||||
@Inject()
|
||||
protected readonly tokens!: TokenRepository
|
||||
|
||||
constructor(
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
) {
|
||||
super(repository, 'token')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
persist(): Awaitable<void> {}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if ( !this.request.hasInstance(OAuth2Token) ) {
|
||||
return
|
||||
}
|
||||
|
||||
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
|
||||
if ( !token.userId ) {
|
||||
return
|
||||
}
|
||||
|
||||
const user = await this.repository.getByIdentifier(token.userId)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
|
||||
return
|
||||
}
|
||||
|
||||
this.authenticatedUser = undefined
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import {BaseEvent, BaseSerializer, ObjectSerializer} from '../../support/bus'
|
||||
import {Awaitable} from '../../util'
|
||||
|
||||
/** An event raised when a required auth check has failed. */
|
||||
export class AuthCheckFailed extends BaseEvent {
|
||||
eventName = '@extollo/lib:AuthCheckFailed'
|
||||
}
|
||||
|
||||
/** Serializes AuthCheckFailed events. */
|
||||
@ObjectSerializer()
|
||||
export class AuthCheckFailedSerializer extends BaseSerializer<AuthCheckFailed, { authCheckFailed: true }> {
|
||||
protected decodeSerial(): Awaitable<AuthCheckFailed> {
|
||||
return new AuthCheckFailed()
|
||||
}
|
||||
|
||||
protected encodeActual(): Awaitable<{ authCheckFailed: true }> {
|
||||
return { authCheckFailed: true }
|
||||
}
|
||||
|
||||
protected getName(): string {
|
||||
return '@extollo/lib:AuthCheckFailedSerializer'
|
||||
}
|
||||
|
||||
matchActual(some: AuthCheckFailed): boolean {
|
||||
return some instanceof AuthCheckFailed
|
||||
}
|
||||
}
|
@ -2,23 +2,18 @@ export * from './types'
|
||||
export * from './AuthenticatableAlreadyExistsError'
|
||||
export * from './NotAuthorizedError'
|
||||
export * from './Authentication'
|
||||
export * from './repository/AuthenticatableRepositoryFactory'
|
||||
|
||||
export * from './context/SecurityContext'
|
||||
export * from './context/SessionSecurityContext'
|
||||
export * from './context/TokenSecurityContext'
|
||||
|
||||
export * from './event/AuthenticationEvent'
|
||||
export * from './event/UserAuthenticatedEvent'
|
||||
export * from './event/UserAuthenticationResumedEvent'
|
||||
export * from './event/UserFlushedEvent'
|
||||
export * from './event/AuthCheckFailed'
|
||||
|
||||
export * from './middleware/AuthRequiredMiddleware'
|
||||
export * from './middleware/GuestRequiredMiddleware'
|
||||
export * from './middleware/SessionAuthMiddleware'
|
||||
export * from './middleware/TokenAuthMiddleware'
|
||||
export * from './middleware/ScopeRequiredMiddleware'
|
||||
|
||||
export * from './provider/basic/BasicLoginAttempt'
|
||||
export * from './provider/basic/BasicLoginProvider'
|
||||
@ -34,8 +29,6 @@ export * from './repository/orm/ORMUserRepository'
|
||||
|
||||
export * from './config'
|
||||
|
||||
export * from './webSocketAuthCheck'
|
||||
|
||||
export * from './server/types'
|
||||
export * from './server/models/OAuth2TokenModel'
|
||||
export * from './server/repositories/ConfigClientRepository'
|
||||
|
@ -1,33 +0,0 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {OAuth2Token} from '../server/types'
|
||||
import {HTTPError} from '../../http/HTTPError'
|
||||
import {HTTPStatus, Pipeline} from '../../util'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {Constructable, Container} from '../../di'
|
||||
|
||||
export class ScopeRequiredMiddleware extends Middleware {
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
protected readonly scope: string,
|
||||
) {
|
||||
super(request)
|
||||
}
|
||||
|
||||
apply(): ResponseObject {
|
||||
if ( !this.request.hasInstance(OAuth2Token) ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Must specify an OAuth2 token.')
|
||||
}
|
||||
|
||||
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
|
||||
if ( typeof token.scope !== 'undefined' && token.scope !== this.scope ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Insufficient token permissions (requires: ' + this.scope + ')')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scope = (name: string): Constructable<ScopeRequiredMiddleware> => {
|
||||
return new Pipeline<Container, ScopeRequiredMiddleware>(
|
||||
container => container.make(ScopeRequiredMiddleware, container, name),
|
||||
)
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Inject, Injectable, Instantiable} from '../../di'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {Maybe} from '../../util'
|
||||
import {AuthenticationConfig, isAuthenticationConfig} from '../config'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {SessionSecurityContext} from '../context/SessionSecurityContext'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
@ -21,9 +23,22 @@ export class SessionAuthMiddleware extends Middleware {
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
this.logging.debug('Applying session auth middleware.')
|
||||
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
|
||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, repo)
|
||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||
this.request.registerSingletonInstance(SecurityContext, context)
|
||||
await context.resume()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the correct AuthenticatableRepository based on the auth config.
|
||||
* @protected
|
||||
*/
|
||||
protected getRepository(): AuthenticatableRepository {
|
||||
const config: Maybe<AuthenticationConfig> = this.config.get('auth')
|
||||
if ( !isAuthenticationConfig(config) ) {
|
||||
throw new TypeError('Invalid authentication config.')
|
||||
}
|
||||
|
||||
const repo: Instantiable<AuthenticatableRepository> = config.storage
|
||||
return this.make(repo)
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +0,0 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {TokenSecurityContext} from '../context/TokenSecurityContext'
|
||||
import {OAuth2Token, oauth2TokenString, TokenRepository} from '../server/types'
|
||||
|
||||
/**
|
||||
* Injects a TokenSecurityContext into the request and attempts to
|
||||
* resume the user's authentication.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TokenAuthMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly tokens!: TokenRepository
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
this.logging.debug('Applying token auth middleware.')
|
||||
let tokenString = this.request.getHeader('Authorization')
|
||||
if ( Array.isArray(tokenString) ) {
|
||||
tokenString = tokenString[0]
|
||||
}
|
||||
|
||||
if ( tokenString ) {
|
||||
const token = await this.tokens.decode(oauth2TokenString(tokenString))
|
||||
if ( token ) {
|
||||
this.request.registerSingletonInstance(OAuth2Token, token)
|
||||
}
|
||||
}
|
||||
|
||||
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
|
||||
const context = <TokenSecurityContext> this.make(TokenSecurityContext, repo)
|
||||
this.request.registerSingletonInstance(SecurityContext, context)
|
||||
await context.resume()
|
||||
}
|
||||
}
|
@ -11,7 +11,12 @@ import {ErrorWithContext, uuid4, fetch} from '../../../util'
|
||||
export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProviderConfig> {
|
||||
protected async callback(request: Request): Promise<Authenticatable> {
|
||||
// Get authentication_code from the request
|
||||
const code = request.safe('code').string()
|
||||
const code = String(request.input('code') || '')
|
||||
if ( !code ) {
|
||||
throw new ErrorWithContext('Unable to authenticate user: missing login code', {
|
||||
input: request.input(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get OAuth2 token from CoreID
|
||||
const token = await this.getToken(code)
|
||||
@ -92,8 +97,5 @@ export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProvider
|
||||
user.email = data.email
|
||||
user.tagline = data.tagline
|
||||
user.photoUrl = data.profile_photo
|
||||
if ( typeof user.save === 'function' ) {
|
||||
user.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
import {Instantiable, FactoryProducer} from '../../di'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ClientRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class AuthenticatableRepositoryFactory extends ConfiguredSingletonFactory<AuthenticatableRepository> {
|
||||
protected getConfigKey(): string {
|
||||
return 'auth.storage'
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<AuthenticatableRepository> {
|
||||
return ORMUserRepository
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return AuthenticatableRepository
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ import {Awaitable, JSONState} from '../../../util'
|
||||
* A basic ORM-driven user class.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
export class ORMUser extends Model implements Authenticatable {
|
||||
|
||||
protected static table = 'users'
|
||||
|
||||
|
@ -18,7 +18,7 @@ export class ORMUserRepository extends AuthenticatableRepository {
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query<ORMUser>()
|
||||
return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query()
|
||||
.where('username', '=', id)
|
||||
.first()
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {Session} from '../../http/session/Session'
|
||||
@ -10,7 +10,6 @@ import {
|
||||
OAuth2Scope,
|
||||
RedemptionCodeRepository,
|
||||
ScopeRepository,
|
||||
TokenRepository,
|
||||
} from './types'
|
||||
import {HTTPError} from '../../http/HTTPError'
|
||||
import {HTTPStatus, Maybe} from '../../util'
|
||||
@ -18,23 +17,9 @@ import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
|
||||
import {one} from '../../http/response/api'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
export enum GrantType {
|
||||
Client = 'client_credentials',
|
||||
Password = 'password',
|
||||
Code = 'authorization_code',
|
||||
}
|
||||
|
||||
export const grantTypes: GrantType[] = [GrantType.Client, GrantType.Code, GrantType.Password]
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Server extends Controller {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public static routes(): void {
|
||||
Route.get('/oauth2/authorize')
|
||||
.alias('@oauth2:authorize')
|
||||
@ -48,52 +33,25 @@ export class OAuth2Server extends Controller {
|
||||
.passingRequest()
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.authorizeAndRedirect)
|
||||
|
||||
Route.post('/oauth2/token')
|
||||
.alias('@oauth2:token')
|
||||
Route.post('/oauth2/redeem')
|
||||
.alias('@oauth2:authorize:redeem')
|
||||
.passingRequest()
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.issue)
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.redeemToken)
|
||||
}
|
||||
|
||||
async issue(request: Request): Promise<ResponseObject> {
|
||||
const grant = request.safe('grant_type').in(grantTypes)
|
||||
const client = await this.getClientFromRequest(request)
|
||||
|
||||
if ( grant === GrantType.Client ) {
|
||||
return this.issueFromClient(request, client)
|
||||
} else if ( grant === GrantType.Code ) {
|
||||
return this.issueFromCode(request, client)
|
||||
} else if ( grant === GrantType.Password ) {
|
||||
return this.issueFromCredential(request, client)
|
||||
}
|
||||
}
|
||||
|
||||
protected async issueFromCredential(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||
const scope = String(this.request.input('scope') ?? '') || undefined
|
||||
const username = this.request.safe('username').string()
|
||||
const password = this.request.safe('password').string()
|
||||
|
||||
this.logging.verbose('Attempting password grant token issue...')
|
||||
this.logging.verbose({
|
||||
scope,
|
||||
username,
|
||||
client,
|
||||
})
|
||||
|
||||
const userRepo = <AuthenticatableRepository> request.make(AuthenticatableRepository)
|
||||
const user = await userRepo.getByIdentifier(username)
|
||||
if ( !user || !(await user.validateCredential(password)) ) {
|
||||
async redeemToken(request: Request): Promise<ResponseObject> {
|
||||
const authParts = String(request.getHeader('Authorization')).split(':')
|
||||
if ( authParts.length !== 2 ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const tokenRepo = <TokenRepository> request.make(TokenRepository)
|
||||
const token = await tokenRepo.issue(user, client, scope)
|
||||
return one({
|
||||
token: await tokenRepo.encode(token),
|
||||
})
|
||||
}
|
||||
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
||||
const [clientId, clientSecret] = authParts
|
||||
const client = await clientRepo.find(clientId)
|
||||
if ( !client || client.secret !== clientSecret ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
protected async issueFromCode(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||
const scope = String(this.request.input('scope') ?? '') || undefined
|
||||
const codeRepo = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
|
||||
const codeString = request.safe('code').string()
|
||||
const code = await codeRepo.find(codeString)
|
||||
@ -101,56 +59,15 @@ export class OAuth2Server extends Controller {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const userRepo = <AuthenticatableRepository> request.make(AuthenticatableRepository)
|
||||
const user = await userRepo.getByIdentifier(code.userId)
|
||||
if ( !user ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const tokenRepo = <TokenRepository> request.make(TokenRepository)
|
||||
const token = await tokenRepo.issue(user, client, scope)
|
||||
return one({
|
||||
token: await tokenRepo.encode(token),
|
||||
})
|
||||
}
|
||||
|
||||
protected async issueFromClient(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||
const scope = String(this.request.input('scope') ?? '') || undefined
|
||||
|
||||
const tokenRepo = <TokenRepository> request.make(TokenRepository)
|
||||
const token = await tokenRepo.issue(undefined, client, scope)
|
||||
return one({
|
||||
token: await tokenRepo.encode(token),
|
||||
})
|
||||
}
|
||||
|
||||
protected async getClientFromRequest(request: Request): Promise<OAuth2Client> {
|
||||
const authParts = String(request.getHeader('Authorization')).split(':')
|
||||
if ( authParts.length !== 2 ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
this.logging.debug('Client auth parts:')
|
||||
this.logging.debug(authParts)
|
||||
|
||||
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
||||
const [clientId, clientSecret] = authParts
|
||||
const client = await clientRepo.find(clientId)
|
||||
|
||||
this.logging.verbose('Client:')
|
||||
this.logging.verbose(client)
|
||||
|
||||
if ( !client || client.secret !== clientSecret ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
async authorizeAndRedirect(request: Request): Promise<ResponseObject> {
|
||||
// Look up the client in the client repo
|
||||
const session = <Session> request.make(Session)
|
||||
const client = await this.getClientFromRequest(request)
|
||||
const clientId = session.safe('oauth2.authorize.clientId').string()
|
||||
const client = await this.getClient(request, clientId)
|
||||
|
||||
const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows)
|
||||
if ( flowType === OAuth2FlowType.code ) {
|
||||
return this.authorizeCodeFlow(request, client)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {Field, FieldType, Model} from '../../../orm'
|
||||
import {OAuth2Token} from '../types'
|
||||
|
||||
export class OAuth2TokenModel extends Model<OAuth2TokenModel> implements OAuth2Token {
|
||||
export class OAuth2TokenModel extends Model implements OAuth2Token {
|
||||
public static table = 'oauth2_tokens'
|
||||
|
||||
public static key = 'oauth2_token_id'
|
||||
@ -14,7 +14,7 @@ export class OAuth2TokenModel extends Model<OAuth2TokenModel> implements OAuth2T
|
||||
}
|
||||
|
||||
@Field(FieldType.varchar, 'user_id')
|
||||
public userId?: string
|
||||
public userId!: string
|
||||
|
||||
@Field(FieldType.varchar, 'client_id')
|
||||
public clientId!: string
|
||||
|
@ -1,23 +1,74 @@
|
||||
import {Instantiable, FactoryProducer} from '../../../di'
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {ClientRepository} from '../types'
|
||||
import {ConfigClientRepository} from './ConfigClientRepository'
|
||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ClientRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class ClientRepositoryFactory extends ConfiguredSingletonFactory<ClientRepository> {
|
||||
protected getConfigKey(): string {
|
||||
return 'oauth2.repository.client'
|
||||
export class ClientRepositoryFactory extends AbstractFactory<ClientRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<ClientRepository> {
|
||||
return ConfigClientRepository
|
||||
produce(): ClientRepository {
|
||||
return new (this.getClientRepositoryClass())()
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return ClientRepository
|
||||
match(something: unknown): boolean {
|
||||
return something === ClientRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getClientRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getClientRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured client repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<ClientRepository>
|
||||
*/
|
||||
protected getClientRepositoryClass(): Instantiable<ClientRepository> {
|
||||
const ClientRepositoryClass = this.config.get('oauth2.repository.client', ConfigClientRepository)
|
||||
|
||||
if ( !isInstantiable(ClientRepositoryClass) || !(ClientRepositoryClass.prototype instanceof ClientRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ClientRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: ClientRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return ClientRepositoryClass
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import {isOAuth2Token, OAuth2Client, OAuth2Token, oauth2TokenString, OAuth2TokenString, TokenRepository} from '../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {ErrorWithContext, Maybe} from '../../../util'
|
||||
import {Maybe} from '../../../util'
|
||||
import {OAuth2TokenModel} from '../models/OAuth2TokenModel'
|
||||
import {Config} from '../../../service/Config'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import {Authenticatable} from '../../types'
|
||||
import {make} from '../../../make'
|
||||
|
||||
@Injectable()
|
||||
export class ORMTokenRepository extends TokenRepository {
|
||||
@ -15,25 +14,23 @@ export class ORMTokenRepository extends TokenRepository {
|
||||
async find(id: string): Promise<Maybe<OAuth2Token>> {
|
||||
const idNum = parseInt(id, 10)
|
||||
if ( !isNaN(idNum) ) {
|
||||
return OAuth2TokenModel.query<OAuth2TokenModel>()
|
||||
return OAuth2TokenModel.query()
|
||||
.whereKey(idNum)
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
async issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
|
||||
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
|
||||
const expiration = this.config.safe('outh2.token.lifetimeSeconds')
|
||||
.or(60 * 60 * 6)
|
||||
.integer() * 1000
|
||||
|
||||
const token = make<OAuth2TokenModel>(OAuth2TokenModel)
|
||||
const token = new OAuth2TokenModel()
|
||||
token.scope = scope
|
||||
token.userId = String(user.getUniqueIdentifier())
|
||||
token.clientId = client.id
|
||||
token.issued = new Date()
|
||||
token.expires = new Date(Math.floor(Date.now() + expiration))
|
||||
if ( user ) {
|
||||
token.userId = String(user.getUniqueIdentifier())
|
||||
}
|
||||
await token.save()
|
||||
|
||||
return token
|
||||
@ -43,20 +40,17 @@ export class ORMTokenRepository extends TokenRepository {
|
||||
const secret = this.config.safe('oauth2.secret').string()
|
||||
const payload = {
|
||||
id: token.id,
|
||||
userId: token.userId,
|
||||
clientId: token.clientId,
|
||||
iat: Math.floor(token.issued.valueOf() / 1000),
|
||||
exp: Math.floor(token.expires.valueOf() / 1000),
|
||||
...(token.userId ? { userId: token.userId } : {}),
|
||||
...(token.scope ? { scope: token.scope } : {}),
|
||||
}
|
||||
|
||||
const generated = await new Promise<string>((res, rej) => {
|
||||
jwt.sign(payload, secret, {}, (err, gen) => {
|
||||
if (err || !gen) {
|
||||
rej(err || new ErrorWithContext('Unable to encode JWT.', {
|
||||
payload,
|
||||
gen,
|
||||
}))
|
||||
if (err || err === null || !gen) {
|
||||
rej(err || new Error('Unable to encode JWT.'))
|
||||
} else {
|
||||
res(gen)
|
||||
}
|
||||
@ -80,10 +74,10 @@ export class ORMTokenRepository extends TokenRepository {
|
||||
|
||||
const value = {
|
||||
id: decoded.id,
|
||||
userId: decoded.userId,
|
||||
clientId: decoded.clientId,
|
||||
issued: new Date(decoded.iat * 1000),
|
||||
expires: new Date(decoded.exp * 1000),
|
||||
...(decoded.userId ? { userId: decoded.userId } : {}),
|
||||
...(decoded.scope ? { scope: decoded.scope } : {}),
|
||||
}
|
||||
|
||||
|
@ -1,23 +1,74 @@
|
||||
import {Instantiable, FactoryProducer} from '../../../di'
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {RedemptionCodeRepository} from '../types'
|
||||
import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
|
||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract RedemptionCodeRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class RedemptionCodeRepositoryFactory extends ConfiguredSingletonFactory<RedemptionCodeRepository> {
|
||||
protected getConfigKey(): string {
|
||||
return 'oauth2.repository.client'
|
||||
export class RedemptionCodeRepositoryFactory extends AbstractFactory<RedemptionCodeRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<RedemptionCodeRepository> {
|
||||
return CacheRedemptionCodeRepository
|
||||
produce(): RedemptionCodeRepository {
|
||||
return new (this.getRedemptionCodeRepositoryClass())()
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return RedemptionCodeRepository
|
||||
match(something: unknown): boolean {
|
||||
return something === RedemptionCodeRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getRedemptionCodeRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getRedemptionCodeRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured client repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<RedemptionCodeRepository>
|
||||
*/
|
||||
protected getRedemptionCodeRepositoryClass(): Instantiable<RedemptionCodeRepository> {
|
||||
const RedemptionCodeRepositoryClass = this.config.get('oauth2.repository.client', CacheRedemptionCodeRepository)
|
||||
|
||||
if ( !isInstantiable(RedemptionCodeRepositoryClass) || !(RedemptionCodeRepositoryClass.prototype instanceof RedemptionCodeRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.RedemptionCodeRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: RedemptionCodeRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return RedemptionCodeRepositoryClass
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,74 @@
|
||||
import {Instantiable, FactoryProducer} from '../../../di'
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {ScopeRepository} from '../types'
|
||||
import {ConfigScopeRepository} from './ConfigScopeRepository'
|
||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ScopeRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class ScopeRepositoryFactory extends ConfiguredSingletonFactory<ScopeRepository> {
|
||||
protected getConfigKey(): string {
|
||||
return 'oauth2.repository.scope'
|
||||
export class ScopeRepositoryFactory extends AbstractFactory<ScopeRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<ScopeRepository> {
|
||||
return ConfigScopeRepository
|
||||
produce(): ScopeRepository {
|
||||
return new (this.getScopeRepositoryClass())()
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return ScopeRepository
|
||||
match(something: unknown): boolean {
|
||||
return something === ScopeRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getScopeRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getScopeRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured scope repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<ScopeRepository>
|
||||
*/
|
||||
protected getScopeRepositoryClass(): Instantiable<ScopeRepository> {
|
||||
const ScopeRepositoryClass = this.config.get('oauth2.repository.scope', ConfigScopeRepository)
|
||||
|
||||
if ( !isInstantiable(ScopeRepositoryClass) || !(ScopeRepositoryClass.prototype instanceof ScopeRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ScopeRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: ScopeRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return ScopeRepositoryClass
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,74 @@
|
||||
import {Instantiable, FactoryProducer} from '../../../di'
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {TokenRepository} from '../types'
|
||||
import {ORMTokenRepository} from './ORMTokenRepository'
|
||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract TokenRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class TokenRepositoryFactory extends ConfiguredSingletonFactory<TokenRepository> {
|
||||
protected getConfigKey(): string {
|
||||
return 'oauth2.repository.token'
|
||||
export class TokenRepositoryFactory extends AbstractFactory<TokenRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<TokenRepository> {
|
||||
return ORMTokenRepository
|
||||
produce(): TokenRepository {
|
||||
return new (this.getTokenRepositoryClass())()
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return TokenRepository
|
||||
match(something: unknown): boolean {
|
||||
return something === TokenRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getTokenRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getTokenRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured token repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<TokenRepository>
|
||||
*/
|
||||
protected getTokenRepositoryClass(): Instantiable<TokenRepository> {
|
||||
const TokenRepositoryClass = this.config.get('oauth2.repository.token', ORMTokenRepository)
|
||||
|
||||
if ( !isInstantiable(TokenRepositoryClass) || !(TokenRepositoryClass.prototype instanceof TokenRepository) ) {
|
||||
const e = new ErrorWithContext('Provided token repository class does not extend from @extollo/lib.TokenRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: TokenRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return TokenRepositoryClass
|
||||
}
|
||||
}
|
||||
|
@ -83,19 +83,13 @@ export abstract class ScopeRepository {
|
||||
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
|
||||
}
|
||||
|
||||
export abstract class OAuth2Token {
|
||||
abstract id: string
|
||||
|
||||
/** When undefined, these are client credentials. */
|
||||
abstract userId?: AuthenticatableIdentifier
|
||||
|
||||
abstract clientId: string
|
||||
|
||||
abstract issued: Date
|
||||
|
||||
abstract expires: Date
|
||||
|
||||
abstract scope?: string
|
||||
export interface OAuth2Token {
|
||||
id: string
|
||||
userId: AuthenticatableIdentifier
|
||||
clientId: string
|
||||
issued: Date
|
||||
expires: Date
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export type OAuth2TokenString = TypeTag<'@extollo/lib.OAuth2TokenString'> & string
|
||||
@ -111,6 +105,7 @@ export function isOAuth2Token(what: unknown): what is OAuth2Token {
|
||||
|
||||
if (
|
||||
!hasOwnProperty(what, 'id')
|
||||
|| !hasOwnProperty(what, 'userId')
|
||||
|| !hasOwnProperty(what, 'clientId')
|
||||
|| !hasOwnProperty(what, 'issued')
|
||||
|| !hasOwnProperty(what, 'expires')
|
||||
@ -120,7 +115,7 @@ export function isOAuth2Token(what: unknown): what is OAuth2Token {
|
||||
|
||||
if (
|
||||
typeof what.id !== 'string'
|
||||
|| (hasOwnProperty(what, 'userId') && !(typeof what.userId === 'string' || typeof what.userId === 'number'))
|
||||
|| !(typeof what.userId === 'string' || typeof what.userId === 'number')
|
||||
|| typeof what.clientId !== 'string'
|
||||
|| !(what.issued instanceof Date)
|
||||
|| !(what.expires instanceof Date)
|
||||
@ -134,7 +129,7 @@ export function isOAuth2Token(what: unknown): what is OAuth2Token {
|
||||
export abstract class TokenRepository {
|
||||
abstract find(id: string): Awaitable<Maybe<OAuth2Token>>
|
||||
|
||||
abstract issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Awaitable<OAuth2Token>
|
||||
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2Token>
|
||||
|
||||
abstract decode(token: OAuth2TokenString): Awaitable<Maybe<OAuth2Token>>
|
||||
|
||||
|
@ -1,36 +0,0 @@
|
||||
import {Container} from '../di'
|
||||
import {RequestLocalStorage} from '../http/RequestLocalStorage'
|
||||
import {Session} from '../http/session/Session'
|
||||
import {Logging} from '../service/Logging'
|
||||
import {SecurityContext} from './context/SecurityContext'
|
||||
import {Bus} from '../support/bus'
|
||||
import {AuthCheckFailed} from './event/AuthCheckFailed'
|
||||
|
||||
/**
|
||||
* Check if the security context for the current request's web socket is still valid.
|
||||
* If not, raise an `AuthCheckFailed` event. This is meant to be used as a subscriber
|
||||
* to `WebSocketHealthCheckEvent` on the request.
|
||||
*
|
||||
* @see AuthCheckFailed
|
||||
*/
|
||||
export async function webSocketAuthCheck(): Promise<void> {
|
||||
const request = Container.getContainer()
|
||||
.make<RequestLocalStorage>(RequestLocalStorage)
|
||||
.get()
|
||||
|
||||
const logging = request.make<Logging>(Logging)
|
||||
|
||||
try {
|
||||
// Try to re-load the session in case we're using the SessionSecurityContext
|
||||
await request.make<Session>(Session).load()
|
||||
} catch (e: unknown) {
|
||||
logging.error(e)
|
||||
}
|
||||
|
||||
const security = request.make<SecurityContext>(SecurityContext)
|
||||
await security.resume()
|
||||
|
||||
if ( !security.hasUser() ) {
|
||||
await request.make<Bus>(Bus).push(new AuthCheckFailed())
|
||||
}
|
||||
}
|
@ -1,26 +1,16 @@
|
||||
import {Directive, OptionDefinition} from '../Directive'
|
||||
import {Directive} from '../Directive'
|
||||
import * as colors from 'colors/safe'
|
||||
import * as repl from 'repl'
|
||||
// import * as tsNode from 'ts-node'
|
||||
import {globalRegistry} from '../../util'
|
||||
import {DependencyKey} from '../../di'
|
||||
|
||||
/**
|
||||
* Launch an interactive REPL shell from within the application.
|
||||
* This is very useful for debugging and testing things during development.
|
||||
*
|
||||
* By default, the shell launches a TypeScript interpreter, but you can use
|
||||
* the `--js` flag to get a JavaScript interpreter.
|
||||
*
|
||||
* @example
|
||||
* ```sh
|
||||
* pnpm cli -- shell
|
||||
* pnpm cli -- shell --js
|
||||
* ```
|
||||
*/
|
||||
export class ShellDirective extends Directive {
|
||||
protected options: any = {
|
||||
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global and @extollo/lib using the "lib" global.`,
|
||||
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
|
||||
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
|
||||
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -41,57 +31,17 @@ export class ShellDirective extends Directive {
|
||||
return ''
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return [
|
||||
'--js | launch in JavaScript mode instead of TypeScript',
|
||||
]
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const state: any = {
|
||||
globalRegistry,
|
||||
app: this.app(),
|
||||
lib: await import('../../index'),
|
||||
exports: {},
|
||||
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
||||
}
|
||||
|
||||
await new Promise<void>(res => {
|
||||
// Currently, there's no way to programmatically access the async context
|
||||
// of the REPL from this directive w/o requiring the user to perform manual
|
||||
// actions. So, instead, override the context on the GlobalRegistry to make
|
||||
// the current one the global default.
|
||||
globalRegistry.forceContextOverride()
|
||||
|
||||
// Create the ts-node compiler service.
|
||||
// const replService = tsNode.createRepl()
|
||||
// const service = tsNode.create({...replService.evalAwarePartialHost})
|
||||
// replService.setService(service)
|
||||
|
||||
// We global these values into the REPL's state directly (using the `state` object
|
||||
// above), but since we're using a separate ts-node interpreter, we need to make it
|
||||
// aware of the globals using declaration syntax.
|
||||
// replService.evalCode(`
|
||||
// declare const lib: typeof import('@extollo/lib');
|
||||
// declare const app: typeof lib['Application'];
|
||||
// declare const globalRegistry: typeof lib['globalRegistry'];
|
||||
// `)
|
||||
|
||||
// Print the welome message and start the interpreter
|
||||
this.nativeOutput(this.options.welcome)
|
||||
this.repl = repl.start({
|
||||
// Causes the REPL to use the ts-node interpreter service:
|
||||
// eval: !this.option('js', false) ? (...args) => replService.nodeEval(...args) : undefined,
|
||||
prompt: this.options.prompt,
|
||||
useGlobal: true,
|
||||
useColors: true,
|
||||
terminal: true,
|
||||
preview: true,
|
||||
})
|
||||
|
||||
// Add our globals into the REPL's context
|
||||
this.repl = repl.start(this.options.prompt)
|
||||
Object.assign(this.repl.context, state)
|
||||
|
||||
// Wait for the REPL to exit
|
||||
this.repl.on('exit', () => res())
|
||||
})
|
||||
}
|
||||
|
@ -8,15 +8,7 @@ import {
|
||||
TypedDependencyKey,
|
||||
} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {
|
||||
Awaitable,
|
||||
collect,
|
||||
Collection,
|
||||
globalRegistry,
|
||||
hasOwnProperty,
|
||||
logIfDebugging, Unsubscribe,
|
||||
} from '../util'
|
||||
import {ErrorWithContext, withErrorContext} from '../util/error/ErrorWithContext'
|
||||
import {Awaitable, collect, Collection, ErrorWithContext, globalRegistry, hasOwnProperty, logIfDebugging} from '../util'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
@ -48,7 +40,7 @@ export interface AwareOfContainerLifecycle {
|
||||
|
||||
export function isAwareOfContainerLifecycle(what: unknown): what is AwareOfContainerLifecycle {
|
||||
return Boolean(
|
||||
(typeof what === 'object' || typeof what === 'function')
|
||||
typeof what === 'object'
|
||||
&& what !== null
|
||||
&& hasOwnProperty(what, 'awareOfContainerLifecycle')
|
||||
&& what.awareOfContainerLifecycle,
|
||||
@ -100,8 +92,6 @@ export class Container {
|
||||
.then(value => listener.callback(value))
|
||||
})
|
||||
|
||||
container.subscribeToBlueprintChanges(ContainerBlueprint.getContainerBlueprint())
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
@ -147,7 +137,7 @@ export class Container {
|
||||
* 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}>()
|
||||
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
|
||||
|
||||
/**
|
||||
* Collection of created objects that should have lifecycle events called on them, if they still exist.
|
||||
@ -155,33 +145,11 @@ export class Container {
|
||||
*/
|
||||
protected waitingLifecycleCallbacks: Collection<WeakRef<AwareOfContainerLifecycle>> = new Collection()
|
||||
|
||||
/**
|
||||
* Collection of subscriptions to ContainerBlueprint events.
|
||||
* We keep this around so we can remove the subscriptions when the container is destroyed.
|
||||
* @protected
|
||||
*/
|
||||
protected blueprintSubscribers: Collection<Unsubscribe> = new Collection()
|
||||
|
||||
constructor() {
|
||||
this.registerSingletonInstance<Container>(Container, this)
|
||||
this.registerSingleton('injector', this)
|
||||
}
|
||||
|
||||
/** Make the container listen to changes in the given blueprint. */
|
||||
private subscribeToBlueprintChanges(blueprint: ContainerBlueprint): void {
|
||||
this.blueprintSubscribers.push(
|
||||
blueprint.resolve$(factory => this.registerFactory(factory())),
|
||||
)
|
||||
|
||||
this.blueprintSubscribers.push(
|
||||
blueprint.resolveConstructable$(factoryClass => this.registerFactory(this.make(factoryClass))),
|
||||
)
|
||||
|
||||
this.blueprintSubscribers.push(
|
||||
blueprint.resolveResolutionCallbacks$(listener => this.onResolve(listener.key).then(value => listener.callback(value))),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all factories and instances of the given key from this container.
|
||||
* @param key
|
||||
@ -408,7 +376,7 @@ export class Container {
|
||||
|
||||
/**
|
||||
* Resolve the dependency key. If a singleton value for that key already exists in this container,
|
||||
* return that value. Otherwise, use the factory and given parameters to produce and return the value.
|
||||
* return that value. Otherwise, use the factory an given parameters to produce and return the value.
|
||||
* @param {DependencyKey} key
|
||||
* @param {...any} parameters
|
||||
*/
|
||||
@ -464,16 +432,11 @@ export class Container {
|
||||
// Create the dependencies for the factory
|
||||
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
|
||||
const dependencies = keys.map<ResolvedDependency>(req => {
|
||||
return withErrorContext(() => {
|
||||
return {
|
||||
paramIndex: req.paramIndex,
|
||||
key: req.key,
|
||||
resolved: this.resolveAndCreate(req.key),
|
||||
}
|
||||
}, {
|
||||
producingToken: factory.getTokenName(),
|
||||
constructorDependency: req,
|
||||
})
|
||||
return {
|
||||
paramIndex: req.paramIndex,
|
||||
key: req.key,
|
||||
resolved: this.resolveAndCreate(req.key),
|
||||
}
|
||||
}).sortBy('paramIndex')
|
||||
|
||||
// Build the arguments for the factory, using dependencies in the
|
||||
@ -497,12 +460,7 @@ export class Container {
|
||||
factory.getInjectedProperties().each(dependency => {
|
||||
logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency)
|
||||
if ( dependency.key && inst ) {
|
||||
withErrorContext(() => {
|
||||
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
|
||||
}, {
|
||||
producingToken: factory.getTokenName(),
|
||||
propertyDependency: dependency,
|
||||
})
|
||||
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
|
||||
}
|
||||
})
|
||||
|
||||
@ -541,22 +499,14 @@ export class Container {
|
||||
this.checkForMakeCycles()
|
||||
|
||||
try {
|
||||
const result = withErrorContext(() => {
|
||||
if (this.hasKey(target)) {
|
||||
const realized = this.resolveAndCreate(target, ...parameters)
|
||||
Container.makeStack?.pop()
|
||||
return realized
|
||||
} else if (typeof target !== 'string' && isInstantiable(target)) {
|
||||
const realized = this.produceFactory(new Factory(target), parameters)
|
||||
Container.makeStack?.pop()
|
||||
return realized
|
||||
}
|
||||
}, {
|
||||
makeStack: Container.makeStack.map(x => typeof x === 'string' ? x : (x?.name || 'unknown')).toArray(),
|
||||
})
|
||||
|
||||
if ( result ) {
|
||||
return result
|
||||
if (this.hasKey(target)) {
|
||||
const realized = this.resolveAndCreate(target, ...parameters)
|
||||
Container.makeStack.pop()
|
||||
return realized
|
||||
} else if (typeof target !== 'string' && isInstantiable(target)) {
|
||||
const realized = this.produceFactory(new Factory(target), parameters)
|
||||
Container.makeStack.pop()
|
||||
return realized
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
Container.makeStack.pop()
|
||||
@ -646,8 +596,6 @@ export class Container {
|
||||
* Perform any cleanup necessary to destroy this container instance.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.blueprintSubscribers.mapCall('unsubscribe')
|
||||
|
||||
this.waitingLifecycleCallbacks
|
||||
.mapCall('deref')
|
||||
.whereDefined()
|
||||
|
@ -3,8 +3,6 @@ import NamedFactory from './factory/NamedFactory'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
import {Collection, collect} from '../util/collection/Collection'
|
||||
import {Subscription, Unsubscribe} from '../util/support/BehaviorSubject'
|
||||
|
||||
/** Simple type alias for a callback to a container's onResolve method. */
|
||||
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
|
||||
@ -27,11 +25,11 @@ export class ContainerBlueprint {
|
||||
return this.instance
|
||||
}
|
||||
|
||||
protected factories: Collection<(() => AbstractFactory<any>)> = collect()
|
||||
protected factories: (() => AbstractFactory<any>)[] = []
|
||||
|
||||
protected constructableFactories: Collection<StaticClass<AbstractFactory<any>, any>> = collect()
|
||||
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
|
||||
|
||||
protected resolutionCallbacks: Collection<{key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}> = collect()
|
||||
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
|
||||
|
||||
/**
|
||||
* Register some factory class with the container. Should take no construction params.
|
||||
@ -76,16 +74,7 @@ export class ContainerBlueprint {
|
||||
* Get an array of factory instances in the blueprint.
|
||||
*/
|
||||
resolve(): AbstractFactory<any>[] {
|
||||
return this.factories.map(x => x()).all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to new factories being registered.
|
||||
* Used by `Container` implementations to listen for factories being registered after the container is realized.
|
||||
* @param sub
|
||||
*/
|
||||
resolve$(sub: Subscription<() => AbstractFactory<any>>): Unsubscribe {
|
||||
return this.factories.push$(sub)
|
||||
return this.factories.map(x => x())
|
||||
}
|
||||
|
||||
/**
|
||||
@ -105,32 +94,14 @@ export class ContainerBlueprint {
|
||||
* Get an array of static Factory classes that need to be instantiated by
|
||||
* the container itself.
|
||||
*/
|
||||
resolveConstructable(): StaticClass<AbstractFactory<any>, any>[] {
|
||||
return this.constructableFactories.all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to new constructable factories being registered.
|
||||
* Used by `Container` implementations to listen for factories registered after the container is realized.
|
||||
* @param sub
|
||||
*/
|
||||
resolveConstructable$(sub: Subscription<StaticClass<AbstractFactory<any>, any>>): Unsubscribe {
|
||||
return this.constructableFactories.push$(sub)
|
||||
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.all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to new resolution callbacks being registered.
|
||||
* Used by `Container` implementations to listen for callbacks registered after the container is realized.
|
||||
* @param sub
|
||||
*/
|
||||
resolveResolutionCallbacks$(sub: Subscription<{key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}>): Unsubscribe {
|
||||
return this.resolutionCallbacks.push$(sub)
|
||||
return [...this.resolutionCallbacks]
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
import {Instantiable, PropertyDependency} from '../types'
|
||||
import {Collection, logIfDebugging} from '../../util'
|
||||
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
|
||||
|
||||
export function getPropertyInjectionMetadata(token: Instantiable<any>): Collection<PropertyDependency> {
|
||||
const loadedMeta = ((token as any)[propertyInjectionMetadata] || new Collection()) as Collection<PropertyDependency>
|
||||
logIfDebugging('extollo.di.injection', 'getPropertyInjectionMetadata() target:', token, 'loaded:', loadedMeta)
|
||||
return loadedMeta
|
||||
}
|
@ -1,17 +1,16 @@
|
||||
import 'reflect-metadata'
|
||||
import {collect, Collection} from '../../util/collection/Collection'
|
||||
import {logIfDebugging} from '../../util/support/debug'
|
||||
import {collect, Collection, logIfDebugging} from '../../util'
|
||||
import {
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||
DependencyKey,
|
||||
DependencyRequirement,
|
||||
InjectionType,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||
isInstantiable,
|
||||
InjectionType,
|
||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||
PropertyDependency,
|
||||
} from '../types'
|
||||
import {ContainerBlueprint} from '../ContainerBlueprint'
|
||||
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
|
||||
|
||||
/**
|
||||
* Get a collection of dependency requirements for the given target object.
|
||||
@ -67,7 +66,6 @@ export const Injectable = (): ClassDecorator => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark the given class property to be injected by the container.
|
||||
* If a `key` is specified, that DependencyKey will be injected.
|
||||
@ -78,27 +76,10 @@ export const Injectable = (): ClassDecorator => {
|
||||
*/
|
||||
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||
return (target, property) => {
|
||||
if ( !target?.constructor ) {
|
||||
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject(): target has no constructor', target)
|
||||
throw new Error('Unable to define property injection: target has no constructor. Enable `extollo.di.decoration` logging to debug')
|
||||
}
|
||||
|
||||
const propertyTarget = target.constructor
|
||||
|
||||
// let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyTarget) as Collection<PropertyDependency>
|
||||
// Okay, this is a little fucky. We can't use Reflect's metadata capabilities because we need to write the metadata to
|
||||
// the constructor, not the `target`. Because Reflect is using the prototype to store data, defining a metadata key on the constructor
|
||||
// will define it for its parent constructors as well.
|
||||
// So, if you have class A, class B extends A, and class C extends A, the properties for B and C will be defined on A, causing
|
||||
// BOTH B and C's properties to be injected on any class extending A.
|
||||
// To get around this, we instead define a custom property on the constructor itself, then use hasOwnProperty to make sure we're not
|
||||
// getting the one for the parent class via the prototype chain.
|
||||
let propertyMetadata = Object.prototype.hasOwnProperty.call(propertyTarget, propertyInjectionMetadata) ?
|
||||
(propertyTarget as any)[propertyInjectionMetadata] as Collection<PropertyDependency> : undefined
|
||||
|
||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||
if ( !propertyMetadata ) {
|
||||
propertyMetadata = new Collection<PropertyDependency>()
|
||||
;(propertyTarget as any)[propertyInjectionMetadata] = propertyMetadata
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
|
||||
const type = Reflect.getMetadata('design:type', target, property)
|
||||
@ -119,9 +100,11 @@ export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDec
|
||||
}
|
||||
}
|
||||
|
||||
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
|
||||
if ( debug ) {
|
||||
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
|
||||
}
|
||||
|
||||
;(propertyTarget as any)[propertyInjectionMetadata] = propertyMetadata
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,7 +149,6 @@ export const Singleton = (name?: string): ClassDecorator => {
|
||||
...(name ? { name } : {}),
|
||||
}
|
||||
|
||||
logIfDebugging('extollo.di.singleton', 'Registering singleton target:', target, 'injectionType:', injectionType)
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
|
||||
Injectable()(target)
|
||||
|
||||
|
@ -1,2 +0,0 @@
|
||||
|
||||
export const propertyInjectionMetadata = Symbol('@extollo/lib:propertyInjectionMetadata')
|
@ -1,12 +1,11 @@
|
||||
import {DependencyKey} from '../types'
|
||||
import {ErrorWithContext} from '../../util/error/ErrorWithContext'
|
||||
|
||||
/**
|
||||
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidDependencyKeyError extends ErrorWithContext {
|
||||
constructor(key: DependencyKey, context: {[key: string]: any} = {}) {
|
||||
super(`No such dependency is registered with this container: ${key}`, context)
|
||||
export class InvalidDependencyKeyError extends Error {
|
||||
constructor(key: DependencyKey) {
|
||||
super(`No such dependency is registered with this container: ${key}`)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import {DependencyKey, DependencyRequirement, Instantiable, PropertyDependency} from '../types'
|
||||
import {Collection, logIfDebugging} from '../../util'
|
||||
import {getPropertyInjectionMetadata} from '../decorator/getPropertyInjectionMetadata'
|
||||
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
|
||||
import { Collection } from '../../util'
|
||||
|
||||
/**
|
||||
* Abstract base class for dependency container factories.
|
||||
@ -42,32 +41,4 @@ export abstract class AbstractFactory<T> {
|
||||
* @return Collection<PropertyDependency>
|
||||
*/
|
||||
abstract getInjectedProperties(): Collection<PropertyDependency>
|
||||
|
||||
/** Helper method that returns all `@Inject()`'ed properties for a token and its prototypical ancestors. */
|
||||
protected getInjectedPropertiesForPrototypeChain(token: Instantiable<any>): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
|
||||
do {
|
||||
const loadedMeta = getPropertyInjectionMetadata(token)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
token = Object.getPrototypeOf(token)
|
||||
logIfDebugging('extollo.di.injection', 'next currentToken:', token)
|
||||
} while (token !== Function.prototype && token !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable name of the token this factory produces.
|
||||
* This is meant for debugging output only.
|
||||
*/
|
||||
public getTokenName(): string {
|
||||
if ( typeof this.token === 'string' ) {
|
||||
return this.token
|
||||
}
|
||||
|
||||
return this.token?.name ?? '(unknown token)'
|
||||
}
|
||||
}
|
||||
|
@ -1,84 +0,0 @@
|
||||
import {AbstractFactory} from './AbstractFactory'
|
||||
import {Inject, Injectable} from '../decorator/injection'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Config} from '../../service/Config'
|
||||
import {
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DependencyRequirement,
|
||||
Instantiable,
|
||||
isInstantiable,
|
||||
PropertyDependency,
|
||||
} from '../types'
|
||||
import {Collection, ErrorWithContext, Maybe} from '../../util'
|
||||
import 'reflect-metadata'
|
||||
|
||||
@Injectable()
|
||||
export abstract class ConfiguredSingletonFactory<T> extends AbstractFactory<T> {
|
||||
protected static loggedDefaultImplementationWarningOnce = false
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
constructor() {
|
||||
super({})
|
||||
}
|
||||
|
||||
protected abstract getConfigKey(): string
|
||||
|
||||
protected abstract getDefaultImplementation(): Instantiable<T>
|
||||
|
||||
protected abstract getAbstractImplementation(): any
|
||||
|
||||
protected getDefaultImplementationWarning(): Maybe<string> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
produce(dependencies: any[], parameters: any[]): T {
|
||||
return new (this.getImplementation())(...dependencies, ...parameters)
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === this.getAbstractImplementation()
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getImplementation())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
return this.getInjectedPropertiesForPrototypeChain(this.getImplementation())
|
||||
}
|
||||
|
||||
protected getImplementation(): Instantiable<T> {
|
||||
const ctor = this.constructor as typeof ConfiguredSingletonFactory
|
||||
const ImplementationClass = this.config.get(this.getConfigKey(), this.getDefaultImplementation())
|
||||
if ( ImplementationClass === this.getDefaultImplementation() ) {
|
||||
const warning = this.getDefaultImplementationWarning()
|
||||
if ( warning && !ctor.loggedDefaultImplementationWarningOnce ) {
|
||||
this.logging.warn(warning)
|
||||
ctor.loggedDefaultImplementationWarningOnce = true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isInstantiable(ImplementationClass)
|
||||
|| !(ImplementationClass.prototype instanceof this.getAbstractImplementation())
|
||||
) {
|
||||
throw new ErrorWithContext('Configured service clas does not properly extend from implementation base class.', {
|
||||
configKey: this.getConfigKey(),
|
||||
class: `${ImplementationClass}`,
|
||||
mustExtendBase: `${this.getAbstractImplementation()}`,
|
||||
})
|
||||
}
|
||||
|
||||
return ImplementationClass as Instantiable<T>
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import {AbstractFactory} from './AbstractFactory'
|
||||
import {
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||
DependencyRequirement,
|
||||
Instantiable,
|
||||
PropertyDependency,
|
||||
@ -52,6 +53,17 @@ export class Factory<T> extends AbstractFactory<T> {
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
return this.getInjectedPropertiesForPrototypeChain(this.token)
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.token
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
}
|
||||
|
@ -7,13 +7,11 @@ export * from './factory/Factory'
|
||||
export * from './factory/NamedFactory'
|
||||
export * from './factory/SingletonFactory'
|
||||
|
||||
export * from './types'
|
||||
export * from './ContainerBlueprint'
|
||||
export * from './decorator/getPropertyInjectionMetadata'
|
||||
export * from './decorator/injection'
|
||||
|
||||
export * from './Container'
|
||||
export * from './ScopedContainer'
|
||||
export * from './types'
|
||||
|
||||
export * from './decorator/injection'
|
||||
export * from './InjectionAware'
|
||||
export * from './constructable'
|
||||
|
@ -36,7 +36,13 @@ export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): w
|
||||
/**
|
||||
* Type that identifies a value as a static class, even if it is not instantiable.
|
||||
*/
|
||||
export type StaticClass<T, T2, TCtorParams extends any[] = any[]> = Function & {prototype: T} & { new (...args: TCtorParams) : T } & T2 // eslint-disable-line @typescript-eslint/ban-types
|
||||
export type StaticClass<T, T2, TCtorParams extends any[] = any[]> = T2 & StaticThis<T, TCtorParams> // eslint-disable-line @typescript-eslint/ban-types
|
||||
|
||||
/**
|
||||
* Quasi-reference to a `this` type w/in a static member.
|
||||
* @see https://github.com/microsoft/TypeScript/issues/5863#issuecomment-302861175
|
||||
*/
|
||||
export type StaticThis<T, TCtorParams extends any[]> = { new (...args: TCtorParams): T }
|
||||
|
||||
/**
|
||||
* Type that identifies a value as a static class that instantiates to itself
|
||||
|
@ -35,13 +35,23 @@ export interface ModuleRegistrationFluency {
|
||||
core: () => HTTPKernel,
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a kernel module is requested that does not exist w/in the kernel.
|
||||
* @extends Error
|
||||
*/
|
||||
export class KernelModuleNotFoundError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`The kernel module ${name} is not registered with the kernel.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A singleton class that handles requests, applying logic in modular layers.
|
||||
*/
|
||||
@Singleton()
|
||||
export class HTTPKernel extends AppClass {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
protected readonly logging!: Logging;
|
||||
|
||||
/**
|
||||
* Collection of preflight modules to apply.
|
||||
@ -130,6 +140,8 @@ export class HTTPKernel extends AppClass {
|
||||
|
||||
if ( typeof foundIdx !== 'undefined' ) {
|
||||
this.postflight = this.postflight.put(foundIdx, this.app().make(module))
|
||||
} else {
|
||||
throw new KernelModuleNotFoundError(other.name)
|
||||
}
|
||||
|
||||
return this
|
||||
@ -150,6 +162,8 @@ export class HTTPKernel extends AppClass {
|
||||
|
||||
if ( typeof foundIdx !== 'undefined' ) {
|
||||
this.postflight = this.postflight.put(foundIdx + 1, this.app().make(module))
|
||||
} else {
|
||||
throw new KernelModuleNotFoundError(other.name)
|
||||
}
|
||||
|
||||
return this
|
||||
|
@ -5,7 +5,6 @@ import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||
import {ResponseObject} from '../../routing/Route'
|
||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
||||
import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
|
||||
import {MountWebSocketRouteHTTPModule} from './MountWebSocketRouteHTTPModule'
|
||||
|
||||
/**
|
||||
* HTTP Kernel module that executes the preflight handlers for the route.
|
||||
@ -14,9 +13,7 @@ import {MountWebSocketRouteHTTPModule} from './MountWebSocketRouteHTTPModule'
|
||||
*/
|
||||
export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
const reg = kernel.register(this)
|
||||
reg.after(MountWebSocketRouteHTTPModule)
|
||||
reg.after(MountActivatedRouteHTTPModule)
|
||||
kernel.register(this).after(MountActivatedRouteHTTPModule)
|
||||
}
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
@ -35,16 +32,14 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou
|
||||
}
|
||||
|
||||
const parameters = route.parameters
|
||||
const resolveResult = (await collect(parameters)
|
||||
.asyncMapRight(handler => handler(request)))
|
||||
const resolveResult = await collect(parameters)
|
||||
.asyncMapRight(handler => handler(request))
|
||||
|
||||
if ( isLeft(resolveResult) ) {
|
||||
await this.applyResponseObject(unleft(resolveResult), request)
|
||||
request.response.blockingWriteback(true)
|
||||
return request
|
||||
return unleft(resolveResult)
|
||||
}
|
||||
|
||||
route.resolvedParameters = unright(resolveResult).toArray(false)
|
||||
route.resolvedParameters = unright(resolveResult).toArray()
|
||||
}
|
||||
|
||||
return request
|
||||
|
@ -1,35 +0,0 @@
|
||||
import {HTTPKernel} from '../HTTPKernel'
|
||||
import {Request} from '../../lifecycle/Request'
|
||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||
import {withErrorContext} from '../../../util'
|
||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
||||
import {ExecuteResolvedRoutePreflightHTTPModule} from './ExecuteResolvedRoutePreflightHTTPModule'
|
||||
import {WebSocketBus} from '../../../support/bus'
|
||||
|
||||
/**
|
||||
* HTTP kernel module that runs the web socket handler for the socket connection's route.
|
||||
*/
|
||||
export class ExecuteResolvedWebSocketHandlerHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
kernel.register(this).after(ExecuteResolvedRoutePreflightHTTPModule)
|
||||
}
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
|
||||
const params = route.resolvedParameters
|
||||
if ( !params ) {
|
||||
throw new Error('Attempted to call route handler without resolved parameters.')
|
||||
}
|
||||
|
||||
await withErrorContext(async () => {
|
||||
const ws = request.make<WebSocketBus>(WebSocketBus)
|
||||
await route.handler
|
||||
.tap(handler => handler(ws, ...params))
|
||||
.apply(request)
|
||||
}, {
|
||||
route,
|
||||
})
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ export class InjectSessionHTTPModule extends HTTPKernelModule {
|
||||
}
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
request.registerFactory(request.make(SessionFactory))
|
||||
request.registerFactory(new SessionFactory())
|
||||
|
||||
const session = <Session> request.make(Session)
|
||||
const id = request.cookies.get('extollo.session')
|
||||
|
@ -1,96 +0,0 @@
|
||||
import {HTTPKernelModule} from '../HTTPKernelModule'
|
||||
import {HTTPKernel} from '../HTTPKernel'
|
||||
import {Request} from '../../lifecycle/Request'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {setInterval} from 'timers'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
import {WebSocketCloseEvent} from '../../lifecycle/WebSocketCloseEvent'
|
||||
import Timeout = NodeJS.Timeout;
|
||||
import {Bus} from '../../../support/bus'
|
||||
import * as WebSockets from 'ws'
|
||||
import {Maybe} from '../../../util'
|
||||
|
||||
@Injectable()
|
||||
export class MonitorWebSocketConnectionHTTPModule extends HTTPKernelModule {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
kernel.register(this).core()
|
||||
}
|
||||
|
||||
async apply(request: Request): Promise<Request> {
|
||||
const ws = request.make<WebSockets.WebSocket>(WebSockets.WebSocket)
|
||||
|
||||
// Time to wait between pings
|
||||
const pollIntervalMs = this.config.safe('server.socket.pollIntervalMs')
|
||||
.or(30000)
|
||||
.integer()
|
||||
|
||||
// Time to wait for a response
|
||||
const pollResponseTimeoutMs = this.config.safe('server.socket.pollResponseTimeoutMs')
|
||||
.or(3000)
|
||||
.integer()
|
||||
|
||||
// Max # of failures before the connection is closed
|
||||
const maxFailedPolls = this.config.safe('server.socket.maxFailedPolls')
|
||||
.or(5)
|
||||
.integer()
|
||||
|
||||
let failedPolls = 0
|
||||
let interval: Maybe<Timeout> = undefined
|
||||
|
||||
await new Promise<void>(res => {
|
||||
let gotResponse = false
|
||||
|
||||
// Listen for pong responses
|
||||
ws.on('pong', () => {
|
||||
this.logging.verbose('Got pong response from socket.')
|
||||
gotResponse = true
|
||||
})
|
||||
|
||||
// Listen for close event
|
||||
ws.on('close', () => {
|
||||
this.logging.debug('Got close event from socket.')
|
||||
res()
|
||||
})
|
||||
|
||||
interval = setInterval(async () => {
|
||||
// Every interval, send a ping request and set a timeout for the response
|
||||
this.logging.verbose('Sending ping request to socket...')
|
||||
gotResponse = false
|
||||
ws.ping()
|
||||
|
||||
await new Promise<void>(res2 => setTimeout(res2, pollResponseTimeoutMs))
|
||||
|
||||
// If no pong response is received before the timeout occurs, tick the # of failed response
|
||||
if ( !gotResponse ) {
|
||||
this.logging.verbose('Socket failed to respond in time.')
|
||||
failedPolls += 1
|
||||
} else {
|
||||
// Otherwise, reset the failure counter
|
||||
failedPolls = 0
|
||||
}
|
||||
|
||||
// Once the failed responses exceeds the threshold, kill the connection
|
||||
if ( failedPolls > maxFailedPolls ) {
|
||||
this.logging.debug('Socket exceeded maximum # of failed pings. Killing.')
|
||||
res()
|
||||
}
|
||||
}, pollIntervalMs)
|
||||
})
|
||||
|
||||
if ( interval ) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
// Tell the server to close the socket connection
|
||||
const bus = request.make<Bus>(Bus)
|
||||
await bus.push(new WebSocketCloseEvent())
|
||||
return request
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import {Injectable, Inject} from '../../../di'
|
||||
import {HTTPKernelModule} from '../HTTPKernelModule'
|
||||
import {HTTPKernel} from '../HTTPKernel'
|
||||
import {Request} from '../../lifecycle/Request'
|
||||
import {Routing} from '../../../service/Routing'
|
||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
import {apiEvent, error} from '../../response/api'
|
||||
import {Bus, WebSocketBus} from '../../../support/bus'
|
||||
import {WebSocketCloseEvent} from '../../lifecycle/WebSocketCloseEvent'
|
||||
|
||||
/**
|
||||
* HTTP kernel middleware that tries to find a registered route matching the request's
|
||||
* path and creates an ActivatedRoute instance from it, limited to websocket handling
|
||||
* routes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MountWebSocketRouteHTTPModule extends HTTPKernelModule {
|
||||
public readonly executeWithBlockingWriteback = true
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
kernel.register(this).before()
|
||||
}
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
const route = this.routing.match('ws', request.path)
|
||||
if ( route ) {
|
||||
this.logging.verbose(`Mounting activated WebSocket route: ${request.path} -> ${route}`)
|
||||
const activated = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute, route, request.path)
|
||||
request.registerSingletonInstance<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute, activated)
|
||||
} else {
|
||||
this.logging.debug(`No matching WebSocket route found for: ${request.method} -> ${request.path}`)
|
||||
|
||||
// Send an error response on the socket to the client
|
||||
const ws = request.make<WebSocketBus>(WebSocketBus)
|
||||
await ws.push(apiEvent(error('Endpoint is not a configured socket listener.')))
|
||||
|
||||
// Then, terminate the request & socket connections
|
||||
await request.make<Bus>(Bus).push(new WebSocketCloseEvent())
|
||||
request.response.blockingWriteback(true)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule {
|
||||
public readonly executeWithBlockingWriteback = true
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
protected readonly config!: Config;
|
||||
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
kernel.register(this).after()
|
||||
|
@ -13,14 +13,14 @@ import {ActivatedRoute} from '../routing/ActivatedRoute'
|
||||
* Enumeration of different HTTP verbs.
|
||||
* @todo add others?
|
||||
*/
|
||||
export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'options' | 'unknown'
|
||||
export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown';
|
||||
|
||||
/**
|
||||
* Returns true if the given item is a valid HTTP verb.
|
||||
* @param what
|
||||
*/
|
||||
export function isHTTPMethod(what: unknown): what is HTTPMethod {
|
||||
return ['post', 'get', 'patch', 'put', 'delete', 'options'].includes(String(what))
|
||||
return ['post', 'get', 'patch', 'put', 'delete'].includes(String(what))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,9 +36,9 @@ export interface HTTPProtocol {
|
||||
* Interface that describes the origin IP address of a request.
|
||||
*/
|
||||
export interface HTTPSourceAddress {
|
||||
address: string
|
||||
family: 'IPv4' | 'IPv6'
|
||||
port: number
|
||||
address: string;
|
||||
family: 'IPv4' | 'IPv6';
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -55,43 +55,43 @@ export interface DataContainer {
|
||||
export class Request extends ScopedContainer implements DataContainer {
|
||||
|
||||
/** The cookie manager for the request. */
|
||||
public readonly cookies: HTTPCookieJar
|
||||
public readonly cookies: HTTPCookieJar;
|
||||
|
||||
/** The URL suffix of the request. */
|
||||
public readonly url: string
|
||||
public readonly url: string;
|
||||
|
||||
/** The fully-qualified URL of the request. */
|
||||
public readonly fullUrl: string
|
||||
public readonly fullUrl: string;
|
||||
|
||||
/** The HTTP verb of the request. */
|
||||
public readonly method: HTTPMethod
|
||||
public readonly method: HTTPMethod;
|
||||
|
||||
/** True if the request was made via TLS. */
|
||||
public readonly secure: boolean
|
||||
public readonly secure: boolean;
|
||||
|
||||
/** The request HTTP protocol version. */
|
||||
public readonly protocol: HTTPProtocol
|
||||
public readonly protocol: HTTPProtocol;
|
||||
|
||||
/** The URL path, stripped of query params. */
|
||||
public readonly path: string
|
||||
public readonly path: string;
|
||||
|
||||
/** The raw parsed query data from the request. */
|
||||
public readonly rawQueryData: {[key: string]: string | string[] | undefined}
|
||||
public readonly rawQueryData: {[key: string]: string | string[] | undefined};
|
||||
|
||||
/** The inferred query data. */
|
||||
public readonly query: {[key: string]: any}
|
||||
public readonly query: {[key: string]: any};
|
||||
|
||||
/** True if the request was made via XMLHttpRequest. */
|
||||
public readonly isXHR: boolean
|
||||
public readonly isXHR: boolean;
|
||||
|
||||
/** The origin IP address of the request. */
|
||||
public readonly address: HTTPSourceAddress
|
||||
public readonly address: HTTPSourceAddress;
|
||||
|
||||
/** The associated response. */
|
||||
public readonly response: Response
|
||||
public readonly response: Response;
|
||||
|
||||
/** The media types accepted by the client. */
|
||||
public readonly mediaTypes: string[]
|
||||
public readonly mediaTypes: string[];
|
||||
|
||||
/** Input parsed from the request */
|
||||
public readonly parsedInput: {[key: string]: any} = {}
|
||||
@ -107,7 +107,7 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
protected clientRequest: IncomingMessage,
|
||||
|
||||
/** The native Node.js response. */
|
||||
protected serverResponse?: ServerResponse,
|
||||
protected serverResponse: ServerResponse,
|
||||
) {
|
||||
super(Container.getContainer())
|
||||
this.registerSingletonInstance(Request, this)
|
||||
|
@ -66,7 +66,7 @@ export class Response {
|
||||
public readonly request: Request,
|
||||
|
||||
/** The native Node.js ServerResponse. */
|
||||
protected readonly serverResponse?: ServerResponse,
|
||||
protected readonly serverResponse: ServerResponse,
|
||||
) { }
|
||||
|
||||
protected get logging(): Logging {
|
||||
@ -109,20 +109,6 @@ export class Response {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a header from the response by name.
|
||||
* @param name
|
||||
*/
|
||||
public unsetHeader(name: string): this {
|
||||
this.logging.verbose(`Will unset header on response: ${name}`)
|
||||
if ( this.sentHeaders ) {
|
||||
throw new HeadersAlreadySentError(this, name)
|
||||
}
|
||||
|
||||
delete this.headers[name]
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk set the specified headers in the response.
|
||||
* @param data
|
||||
@ -173,11 +159,6 @@ export class Response {
|
||||
*/
|
||||
public sendHeaders(): this {
|
||||
this.logging.verbose(`Sending headers...`)
|
||||
if ( !this.serverResponse ) {
|
||||
throw new ErrorWithContext('Unable to send headers: Response has no underlying connection.', {
|
||||
suggestion: 'This usually means the Request was created by an alternative server, like WebsocketServer. You should use that server to handle the request.',
|
||||
})
|
||||
}
|
||||
const headers = {} as any
|
||||
|
||||
const setCookieHeaders = this.cookies.getSetCookieHeaders()
|
||||
@ -225,14 +206,8 @@ export class Response {
|
||||
* @param data
|
||||
*/
|
||||
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||
this.logging.verbose(`Writing headers & data to response... (destroyed? ${!this.serverResponse || this.serverResponse.destroyed})`)
|
||||
this.logging.verbose(`Writing headers & data to response... (destroyed? ${this.serverResponse.destroyed})`)
|
||||
return new Promise<void>((res, rej) => {
|
||||
if ( !this.serverResponse ) {
|
||||
throw new ErrorWithContext('Unable to write response: Response has no underlying connection.', {
|
||||
suggestion: 'This usually means the Request was created by an alternative server, like WebsocketServer. You should use that server to handle the request.',
|
||||
})
|
||||
}
|
||||
|
||||
if ( this.responseEnded || this.serverResponse.destroyed ) {
|
||||
throw new ErrorWithContext('Tried to write to Response after lifecycle ended.')
|
||||
}
|
||||
@ -285,7 +260,7 @@ export class Response {
|
||||
* or the connection has been destroyed.
|
||||
*/
|
||||
public canSend(): boolean {
|
||||
return !(this.responseEnded || !this.serverResponse || this.serverResponse.destroyed)
|
||||
return !(this.responseEnded || this.serverResponse.destroyed)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -297,7 +272,7 @@ export class Response {
|
||||
}
|
||||
|
||||
this.sentHeaders = true
|
||||
this.serverResponse?.end()
|
||||
this.serverResponse.end()
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -1,11 +0,0 @@
|
||||
import {Event} from '../../support/bus'
|
||||
import {uuid4} from '../../util'
|
||||
|
||||
/** Event used to tell the server to close the websocket connection. */
|
||||
export class WebSocketCloseEvent implements Event {
|
||||
eventName = '@extollo/lib:WebSocketCloseEvent'
|
||||
|
||||
eventUuid = uuid4()
|
||||
|
||||
shouldBroadcast = false
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import {Event} from '../../support/bus'
|
||||
import {uuid4} from '../../util'
|
||||
|
||||
/** Event used to tell the server to close the websocket connection. */
|
||||
export class WebSocketHealthCheckEvent implements Event {
|
||||
eventName = '@extollo/lib:WebSocketHealthCheckEvent'
|
||||
|
||||
eventUuid = uuid4()
|
||||
|
||||
shouldBroadcast = false
|
||||
}
|
@ -107,12 +107,6 @@ ${Object.keys(context).map(key => ` - ${key} : ${JSON.stringify(context[key])
|
||||
protected getSuggestion(): string {
|
||||
if ( this.thrownError.message.startsWith('No such dependency is registered with this container: class SecurityContext') ) {
|
||||
return 'It looks like this route relies on the security framework. Is the route you are accessing inside a middleware (e.g. SessionAuthMiddleware)?'
|
||||
} else if ( this.thrownError.message.startsWith('Unable to resolve schema for validator') ) {
|
||||
return 'Make sure the directory in which the interface file is located is listed in extollo.cc.zodify in package.json, and that it ends with the proper .type.ts suffix.'
|
||||
} else if ( this.thrownError instanceof ErrorWithContext ) {
|
||||
if ( typeof this.thrownError.context.suggestion === 'string' ) {
|
||||
return this.thrownError.context.suggestion
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
|
@ -13,8 +13,6 @@ export function plaintext(value: string): StringResponseFactory {
|
||||
* Response factory that renders a given string as the response in plaintext.
|
||||
*/
|
||||
export class StringResponseFactory extends ResponseFactory {
|
||||
protected targetContentType = 'text/plain'
|
||||
|
||||
constructor(
|
||||
/** The string to write as the body. */
|
||||
public readonly value: string,
|
||||
@ -24,14 +22,8 @@ export class StringResponseFactory extends ResponseFactory {
|
||||
|
||||
public async write(request: Request): Promise<Request> {
|
||||
request = await super.write(request)
|
||||
request.response.setHeader('Content-Type', this.targetContentType)
|
||||
request.response.setHeader('Content-Type', 'text/plain')
|
||||
request.response.body = this.value
|
||||
return request
|
||||
}
|
||||
|
||||
/** Override the content type of the string. */
|
||||
public contentType(type: string): this {
|
||||
this.targetContentType = type
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Base type for an API response format.
|
||||
*/
|
||||
import {BaseSerializer, Event, ObjectSerializer} from '../../support/bus'
|
||||
import {Awaitable, ErrorWithContext, hasOwnProperty, JSONState, uuid4} from '../../util'
|
||||
|
||||
export interface APIResponse<T> {
|
||||
eventName?: string,
|
||||
eventUuid?: string,
|
||||
success: boolean,
|
||||
message?: string,
|
||||
data?: T,
|
||||
@ -17,61 +12,6 @@ export interface APIResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export function isAPIResponse(what: unknown): what is APIResponse<unknown> {
|
||||
return typeof what === 'object' && what !== null
|
||||
&& hasOwnProperty(what, 'success')
|
||||
&& typeof what.success === 'boolean'
|
||||
&& (!hasOwnProperty(what, 'message') || typeof what.message === 'string')
|
||||
&& (!hasOwnProperty(what, 'error') || (
|
||||
typeof what.error === 'object' && what.error !== null
|
||||
&& hasOwnProperty(what.error, 'name') && typeof what.error.name === 'string'
|
||||
&& hasOwnProperty(what.error, 'message') && typeof what.error.message === 'string'
|
||||
&& (!hasOwnProperty(what.error, 'stack') || (
|
||||
Array.isArray(what.error.stack) && what.error.stack.every(x => typeof x === 'string')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function apiEvent<T>(response: APIResponse<T>): APIResponse<T> & Event {
|
||||
if ( !response.eventName ) {
|
||||
response.eventName = '@extollo/lib:APIResponse'
|
||||
}
|
||||
|
||||
if ( !response.eventUuid ) {
|
||||
response.eventUuid = uuid4()
|
||||
}
|
||||
|
||||
return response as APIResponse<T> & Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializer implementation that can encode/decode APIResponse objects.
|
||||
*/
|
||||
@ObjectSerializer()
|
||||
export class APIResponseSerializer extends BaseSerializer<APIResponse<unknown>, JSONState> {
|
||||
protected decodeSerial(serial: JSONState): Awaitable<APIResponse<unknown>> {
|
||||
if ( isAPIResponse(serial) ) {
|
||||
return serial
|
||||
}
|
||||
|
||||
throw new ErrorWithContext('Could not decode API response: object is malformed')
|
||||
}
|
||||
|
||||
protected encodeActual(actual: APIResponse<unknown>): Awaitable<JSONState> {
|
||||
return actual as unknown as JSONState
|
||||
}
|
||||
|
||||
protected getName(): string {
|
||||
return '@extollo/lib:APIResponseSerializer'
|
||||
}
|
||||
|
||||
matchActual(some: APIResponse<unknown>): boolean {
|
||||
return isAPIResponse(some)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a mesage as a successful API response.
|
||||
* @param {string} displayMessage
|
||||
@ -116,7 +56,7 @@ export function many<T>(records: T[]): APIResponse<{records: T[], total: number}
|
||||
* @return APIResponse
|
||||
* @param thrownError
|
||||
*/
|
||||
export function error(thrownError: string | Error): APIResponse<undefined> {
|
||||
export function error(thrownError: string | Error): APIResponse<void> {
|
||||
if ( typeof thrownError === 'string' ) {
|
||||
return {
|
||||
success: false,
|
||||
|
@ -2,7 +2,6 @@ import {Request} from '../lifecycle/Request'
|
||||
import {ResponseObject} from './Route'
|
||||
import {Container} from '../../di'
|
||||
import {CanonicalItemClass} from '../../support/CanonicalReceiver'
|
||||
import {Awaitable, Either, ErrorWithContext, Left, left, Right, right} from '../../util'
|
||||
|
||||
/**
|
||||
* Base class representing a middleware handler that can be applied to routes.
|
||||
@ -32,26 +31,3 @@ export abstract class Middleware extends CanonicalItemClass {
|
||||
*/
|
||||
public abstract apply(): ResponseObject
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of Middleware that produces a parameter that is passed to later handlers.
|
||||
* Can be used to do common look-ups, &c before routes.
|
||||
*/
|
||||
export abstract class ParameterMiddleware<T, THandlerArgs extends any[] = []> extends Middleware {
|
||||
/** Look up the value. */
|
||||
public abstract handle(...options: THandlerArgs): Awaitable<Either<ResponseObject, T>>
|
||||
|
||||
apply(): ResponseObject {
|
||||
throw new ErrorWithContext('Attempted to apply parameter-providing middleware directly. Try using `parameterMiddleware()` instead.')
|
||||
}
|
||||
|
||||
/** Alias for an error response return. */
|
||||
left(what: T): Left<T> {
|
||||
return left(what)
|
||||
}
|
||||
|
||||
/** Alias for a good value return. */
|
||||
right(what: T): Right<T> {
|
||||
return right(what)
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,13 @@
|
||||
import {Awaitable, Collection, Either, ErrorWithContext, Maybe, Pipeline, SuffixTypeArray, right} from '../../util'
|
||||
import {Collection, Either, ErrorWithContext, Maybe, Pipeline, PrefixTypeArray, right} from '../../util'
|
||||
import {ResponseFactory} from '../response/ResponseFactory'
|
||||
import {HTTPMethod, Request} from '../lifecycle/Request'
|
||||
import {constructable, Constructable, Container, Instantiable, isInstantiableOf, TypedDependencyKey} from '../../di'
|
||||
import {Middleware, ParameterMiddleware} from './Middleware'
|
||||
import {Middleware} from './Middleware'
|
||||
import {Valid, Validator, ValidatorFactory} from '../../validation/Validator'
|
||||
import {validateMiddleware} from '../../validation/middleware'
|
||||
import {RouteGroup} from './RouteGroup'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Application} from '../../lifecycle/Application'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {WebSocketBus} from '../../support/bus/WebSocketBus'
|
||||
import {SocketRouteBuilder} from './SocketRouteBuilder'
|
||||
|
||||
/**
|
||||
* Type alias for an item that is a valid response object, or lack thereof.
|
||||
@ -23,7 +20,7 @@ export type ResponseObject = ResponseFactory | string | number | void | any | Pr
|
||||
*/
|
||||
export type ResolvedRouteHandler = (request: Request) => ResponseObject
|
||||
|
||||
export type ParameterProvidingMiddleware<T> = (request: Request) => Awaitable<Either<ResponseObject, T>>
|
||||
export type ParameterProvidingMiddleware<T> = (request: Request) => Either<ResponseObject, T>
|
||||
|
||||
export interface HandledRoute<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
|
||||
handler: Constructable<(...x: THandlerParams) => TReturn>
|
||||
@ -138,22 +135,6 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
return new Route(method, endpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new WebSocket route on the given endpoint.
|
||||
* @param endpoint
|
||||
*/
|
||||
public static socket(endpoint: string): SocketRouteBuilder {
|
||||
const builder = SocketRouteBuilder.get()
|
||||
|
||||
;(new Route<Awaitable<void>, [WebSocketBus]>('ws', endpoint))
|
||||
.passingRequest()
|
||||
.handledBy(async (ws: WebSocketBus, request: Request) => {
|
||||
await builder.build(request, ws)
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new GET route on the given endpoint.
|
||||
*/
|
||||
@ -206,14 +187,10 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
protected displays: Collection<{stage: 'pre'|'post'|'handler', display: string}> = new Collection()
|
||||
|
||||
constructor(
|
||||
protected method: 'ws' | HTTPMethod | HTTPMethod[],
|
||||
protected method: HTTPMethod | HTTPMethod[],
|
||||
protected route: string,
|
||||
) {}
|
||||
|
||||
public isForWebSocket(): boolean {
|
||||
return this.method === 'ws'
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a programmatic name for this route.
|
||||
* @param name
|
||||
@ -234,10 +211,6 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
* Get the string-form methods supported by the route.
|
||||
*/
|
||||
public getMethods(): HTTPMethod[] {
|
||||
if ( this.method === 'ws' ) {
|
||||
return []
|
||||
}
|
||||
|
||||
if ( !Array.isArray(this.method) ) {
|
||||
return [this.method]
|
||||
}
|
||||
@ -276,14 +249,10 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
* @param method
|
||||
* @param potential
|
||||
*/
|
||||
public match(method: 'ws' | HTTPMethod, potential: string): boolean {
|
||||
if ( method === 'ws' && !this.isForWebSocket() ) {
|
||||
public match(method: HTTPMethod, potential: string): boolean {
|
||||
if ( Array.isArray(this.method) && !this.method.includes(method) ) {
|
||||
return false
|
||||
} else if ( method !== 'ws' && this.isForWebSocket() ) {
|
||||
return false
|
||||
} else if ( method !== 'ws' && Array.isArray(this.method) && !this.method.includes(method) ) {
|
||||
return false
|
||||
} else if ( method !== 'ws' && !Array.isArray(this.method) && this.method !== method ) {
|
||||
} else if ( !Array.isArray(this.method) && this.method !== method ) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -305,11 +274,8 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
* @param potential
|
||||
*/
|
||||
public extract(potential: string): {[key: string]: string} | undefined {
|
||||
const routeParts = (this.route.startsWith('/') ? this.route.substr(1) : this.route).split('/').filter(Boolean)
|
||||
const potentialParts = (potential.startsWith('/') ? potential.substr(1) : potential).split('/').filter(Boolean)
|
||||
|
||||
Application.getApplication().make<Logging>(Logging)
|
||||
.trace(`Extracting route - (potential: ${potential}, rP: ${JSON.stringify(routeParts)}, pP: ${JSON.stringify(potentialParts)})`)
|
||||
const routeParts = (this.route.startsWith('/') ? this.route.substr(1) : this.route).split('/')
|
||||
const potentialParts = (potential.startsWith('/') ? potential.substr(1) : potential).split('/')
|
||||
|
||||
const params: any = {}
|
||||
let wildcardIdx = 0
|
||||
@ -344,23 +310,16 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
return params
|
||||
}
|
||||
|
||||
public parameterMiddleware<T, THandlerArgs extends any[] = []>(
|
||||
handler: ParameterProvidingMiddleware<T> | Instantiable<ParameterMiddleware<T, THandlerArgs>>,
|
||||
...handlerArgs: THandlerArgs
|
||||
): Route<TReturn, SuffixTypeArray<THandlerParams, T>> {
|
||||
const route = new Route<TReturn, SuffixTypeArray<THandlerParams, T>>(
|
||||
public parameterMiddleware<T>(
|
||||
handler: ParameterProvidingMiddleware<T>,
|
||||
): Route<TReturn, PrefixTypeArray<T, THandlerParams>> {
|
||||
const route = new Route<TReturn, PrefixTypeArray<T, THandlerParams>>(
|
||||
this.method,
|
||||
this.route,
|
||||
)
|
||||
|
||||
route.copyFrom(this)
|
||||
|
||||
if ( handler.prototype instanceof ParameterMiddleware ) {
|
||||
route.parameters.push(req => req.make<ParameterMiddleware<T, THandlerArgs>>(handler, req).handle(...handlerArgs))
|
||||
} else {
|
||||
route.parameters.push(handler as ParameterProvidingMiddleware<T>)
|
||||
}
|
||||
|
||||
route.parameters.push(handler)
|
||||
return route
|
||||
}
|
||||
|
||||
@ -369,7 +328,6 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
this.postflight = other.postflight.clone()
|
||||
this.aliases = other.aliases.clone()
|
||||
this.displays = other.displays.clone()
|
||||
this.parameters = other.parameters.clone()
|
||||
}
|
||||
|
||||
public calls<TKey>(
|
||||
@ -403,43 +361,27 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
return this as HandledRoute<TReturn, THandlerParams>
|
||||
}
|
||||
|
||||
public pre(middleware: Instantiable<Middleware>|Constructable<Middleware>): this {
|
||||
let name: string
|
||||
if ( middleware instanceof Pipeline ) {
|
||||
this.preflight.prepend(request => middleware.apply(request).apply())
|
||||
name = '(unknown pipeline)'
|
||||
} else {
|
||||
this.preflight.prepend(request => request.make<Middleware>(middleware, request).apply())
|
||||
name = middleware.name
|
||||
}
|
||||
|
||||
public pre(middleware: Instantiable<Middleware>): this {
|
||||
this.preflight.prepend(request => request.make<Middleware>(middleware, request).apply())
|
||||
this.displays.push({
|
||||
stage: 'pre',
|
||||
display: name,
|
||||
display: `${middleware.name}`,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public post(middleware: Instantiable<Middleware>|Constructable<Middleware>): this {
|
||||
let name: string
|
||||
if ( middleware instanceof Pipeline ) {
|
||||
this.postflight.push(request => middleware.apply(request).apply())
|
||||
name = '(unknown pipeline)'
|
||||
} else {
|
||||
this.preflight.push(request => request.make<Middleware>(middleware, request).apply())
|
||||
name = middleware.name
|
||||
}
|
||||
|
||||
public post(middleware: Instantiable<Middleware>): this {
|
||||
this.postflight.push(request => request.make<Middleware>(middleware, request).apply())
|
||||
this.displays.push({
|
||||
stage: 'post',
|
||||
display: name,
|
||||
stage: 'pre',
|
||||
display: `${middleware.name}`,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public input<T>(validator: ValidatorFactory<T>): Route<TReturn, SuffixTypeArray<THandlerParams, Valid<T>>> {
|
||||
public input<T>(validator: ValidatorFactory<T>): Route<TReturn, PrefixTypeArray<Valid<T>, THandlerParams>> {
|
||||
if ( !(validator instanceof Validator) ) {
|
||||
validator = validator()
|
||||
}
|
||||
@ -452,7 +394,7 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
return this.parameterMiddleware(validateMiddleware(validator))
|
||||
}
|
||||
|
||||
public passingRequest(): Route<TReturn, SuffixTypeArray<THandlerParams, Request>> {
|
||||
public passingRequest(): Route<TReturn, PrefixTypeArray<Request, THandlerParams>> {
|
||||
return this.parameterMiddleware(request => right(request))
|
||||
}
|
||||
|
||||
|
@ -1,105 +0,0 @@
|
||||
import {StateEvent, WebSocketBus} from '../../support/bus'
|
||||
import {constructable, Constructable, Instantiable, TypedDependencyKey} from '../../di'
|
||||
import {Awaitable, Collection, JSONState} from '../../util'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
|
||||
export type SocketEventHandler<TState extends JSONState> = {
|
||||
eventClass: Instantiable<StateEvent<TState>>,
|
||||
handler: Constructable<(state: TState) => Awaitable<void>>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for building websocket APIs using first-class route syntax.
|
||||
* This is returned by the `Route.socket(...)` method, so you'll probably want
|
||||
* to use that.
|
||||
*
|
||||
* @see Route#socket
|
||||
* @example
|
||||
* ```ts
|
||||
* Route.socket('/ws/endpoint')
|
||||
* .connected(MyCtrl, (ctrl: MyCtrl) => ctrl.connect)
|
||||
* .event(MyEvent, MyCtrl, (ctrl: MyCtrl) => ctrl.myHandler)
|
||||
* ```
|
||||
*/
|
||||
export class SocketRouteBuilder {
|
||||
public static get(): SocketRouteBuilder {
|
||||
return new SocketRouteBuilder()
|
||||
}
|
||||
|
||||
/** Handlers that should be registered with any new socket connections. */
|
||||
protected handlers: Collection<SocketEventHandler<any>> = new Collection()
|
||||
|
||||
/** Callback to execute when a new connection is opened. */
|
||||
protected connectionCallback?: Constructable<(ws: WebSocketBus) => Awaitable<void>>
|
||||
|
||||
/**
|
||||
* Register a callback to execute each time a new socket is opened.
|
||||
* This can be used to perform basic setup/validation/authentication tasks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* Route.socket('/ws/endpoint')
|
||||
* .connected(MyCtrl, (ctrl: MyCtrl) => ctrl.connect)
|
||||
* ```
|
||||
*
|
||||
* @param key
|
||||
* @param selector
|
||||
*/
|
||||
connected<TKey>(
|
||||
key: TypedDependencyKey<TKey>,
|
||||
selector: (x: TKey) => (ws: WebSocketBus) => Awaitable<void>,
|
||||
): this {
|
||||
this.connectionCallback = constructable<TKey>(key)
|
||||
.tap(inst => Function.prototype.bind.call(selector(inst), inst as any) as ((ws: WebSocketBus) => Awaitable<void>))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a `StateEvent` listener on the socket.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* Route.socket('/ws/endpoint')
|
||||
* .event(MyEvent, MyCtrl, (ctrl: MyCtrl) => ctrl.myHandler)
|
||||
* ```
|
||||
*
|
||||
* @see StateEvent
|
||||
* @param eventClass
|
||||
* @param key
|
||||
* @param selector
|
||||
*/
|
||||
event<TState extends JSONState, TKey>(
|
||||
eventClass: Instantiable<StateEvent<TState>>,
|
||||
key: TypedDependencyKey<TKey>,
|
||||
selector: (x: TKey) => (state: TState) => Awaitable<void>,
|
||||
): this {
|
||||
const handler = constructable<TKey>(key)
|
||||
.tap(inst => Function.prototype.bind.call(selector(inst), inst as any) as ((state: TState) => Awaitable<void>))
|
||||
|
||||
this.handlers.push({
|
||||
eventClass,
|
||||
handler,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners & initial callback to the given socket. This is used
|
||||
* by as the handler for new connections.
|
||||
*
|
||||
* @see Route#socket
|
||||
* @param request
|
||||
* @param ws
|
||||
*/
|
||||
async build(request: Request, ws: WebSocketBus): Promise<void> {
|
||||
await this.handlers.promiseMap(handler => {
|
||||
ws.subscribe(handler.eventClass, (event: StateEvent<JSONState>) => {
|
||||
return handler.handler.apply(request)(event.getState())
|
||||
})
|
||||
})
|
||||
|
||||
if ( this.connectionCallback ) {
|
||||
await this.connectionCallback.apply(request)(ws)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
import {NoSessionKeyError, Session, SessionData, SessionNotLoadedError} from './Session'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Cache, Maybe} from '../../util'
|
||||
import {Config} from '../../service/Config'
|
||||
|
||||
/**
|
||||
* A Session implementation that uses the configured Cache driver for persistence.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheSession extends Session {
|
||||
@Inject()
|
||||
protected readonly cache!: Cache
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
protected key?: string
|
||||
|
||||
protected data?: SessionData
|
||||
|
||||
protected dirty = false
|
||||
|
||||
forget(key: string): void {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
delete this.data[key]
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
get(key: string, fallback?: unknown): any {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
return this.data[key] ?? fallback
|
||||
}
|
||||
|
||||
getData(): SessionData {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
return {...this.data}
|
||||
}
|
||||
|
||||
getKey(): string {
|
||||
if ( !this.key ) {
|
||||
throw new NoSessionKeyError()
|
||||
}
|
||||
|
||||
return this.key
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const json = await this.cache.fetch(this.formatKey())
|
||||
if ( json ) {
|
||||
this.data = JSON.parse(json)
|
||||
} else {
|
||||
this.data = {}
|
||||
}
|
||||
|
||||
this.dirty = false
|
||||
}
|
||||
|
||||
async persist(): Promise<void> {
|
||||
if ( !this.dirty ) {
|
||||
return
|
||||
}
|
||||
|
||||
const json = JSON.stringify(this.data)
|
||||
await this.cache.put(this.formatKey(), json, this.getExpiration())
|
||||
this.dirty = false
|
||||
}
|
||||
|
||||
private getExpiration(): Maybe<Date> {
|
||||
// Get the session expiration. By default, this is 4 hours.
|
||||
const durationMins = this.config.safe('server.session.durationMins')
|
||||
.or(4 * 60)
|
||||
.integer()
|
||||
|
||||
if ( durationMins !== 0 ) {
|
||||
const date = new Date()
|
||||
date.setMinutes(date.getMinutes() + durationMins)
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
private formatKey(): string {
|
||||
if ( !this.key ) {
|
||||
throw new NoSessionKeyError()
|
||||
}
|
||||
|
||||
const prefix = this.config.safe('app.name')
|
||||
.or('Extollo')
|
||||
.string()
|
||||
|
||||
return `${prefix}_session_${this.key}`
|
||||
}
|
||||
|
||||
set(key: string, value: unknown): void {
|
||||
if ( !this.data ) {
|
||||
throw new SessionNotLoadedError()
|
||||
}
|
||||
|
||||
this.data[key] = value
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
setData(data: SessionData): void {
|
||||
this.data = {...data}
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
setKey(key: string): void {
|
||||
this.key = key
|
||||
}
|
||||
}
|
@ -1,23 +1,87 @@
|
||||
import {Instantiable} from '../../di'
|
||||
import {Maybe} from '../../util'
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable,
|
||||
} from '../../di'
|
||||
import {Collection, ErrorWithContext} from '../../util'
|
||||
import {MemorySession} from './MemorySession'
|
||||
import {Session} from './Session'
|
||||
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Config} from '../../service/Config'
|
||||
|
||||
export class SessionFactory extends ConfiguredSingletonFactory<Session> {
|
||||
protected getConfigKey(): string {
|
||||
return 'server.session.driver'
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract Session class
|
||||
* and produces an instance of the configured session driver implementation.
|
||||
*/
|
||||
export class SessionFactory extends AbstractFactory<Session> {
|
||||
protected readonly logging: Logging
|
||||
|
||||
protected readonly config: Config
|
||||
|
||||
/** True if we have printed the memory session warning at least once. */
|
||||
private static loggedMemorySessionWarningOnce = false
|
||||
|
||||
constructor() {
|
||||
super({})
|
||||
this.logging = Container.getContainer().make<Logging>(Logging)
|
||||
this.config = Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<Session> {
|
||||
return MemorySession
|
||||
produce(): Session {
|
||||
return new (this.getSessionClass())()
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return Session
|
||||
match(something: unknown): boolean {
|
||||
return something === Session
|
||||
}
|
||||
|
||||
protected getDefaultImplementationWarning(): Maybe<string> {
|
||||
return 'You are using the default memory-based session driver. It is recommended you configure a persistent session driver instead.'
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getSessionClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getSessionClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured session backend.
|
||||
* @protected
|
||||
* @return Instantiable<Session>
|
||||
*/
|
||||
protected getSessionClass(): Instantiable<Session> {
|
||||
const SessionClass = this.config.get('server.session.driver', MemorySession)
|
||||
if ( SessionClass === MemorySession && !SessionFactory.loggedMemorySessionWarningOnce ) {
|
||||
this.logging.warn(`You are using the default memory-based session driver. It is recommended you configure a persistent session driver instead.`)
|
||||
SessionFactory.loggedMemorySessionWarningOnce = true
|
||||
}
|
||||
|
||||
if ( !isInstantiable(SessionClass) || !(SessionClass.prototype instanceof Session) ) {
|
||||
const e = new ErrorWithContext('Provided session class does not extend from @extollo/lib.Session')
|
||||
e.context = {
|
||||
configKey: 'server.session.driver',
|
||||
class: SessionClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return SessionClass
|
||||
}
|
||||
}
|
||||
|
@ -35,8 +35,6 @@ export * from './http/kernel/HTTPCookieJar'
|
||||
|
||||
export * from './http/lifecycle/Request'
|
||||
export * from './http/lifecycle/Response'
|
||||
export * from './http/lifecycle/WebSocketCloseEvent'
|
||||
export * from './http/lifecycle/WebSocketHealthCheckEvent'
|
||||
export * from './http/RequestLocalStorage'
|
||||
|
||||
export * from './make'
|
||||
@ -55,7 +53,6 @@ export * from './http/response/ViewResponseFactory'
|
||||
export * from './http/response/FileResponseFactory'
|
||||
export * from './http/response/RouteResponseFactory'
|
||||
|
||||
export * from './http/routing/SocketRouteBuilder'
|
||||
export * from './http/routing/ActivatedRoute'
|
||||
export * from './http/routing/Route'
|
||||
export * from './http/routing/RouteGroup'
|
||||
@ -66,7 +63,6 @@ export * from './http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule'
|
||||
export * from './http/session/Session'
|
||||
export * from './http/session/SessionFactory'
|
||||
export * from './http/session/MemorySession'
|
||||
export * from './http/session/CacheSession'
|
||||
|
||||
export * from './http/Controller'
|
||||
|
||||
@ -84,11 +80,9 @@ export * from './service/Config'
|
||||
export * from './service/Controllers'
|
||||
export * from './service/Files'
|
||||
export * from './service/HTTPServer'
|
||||
export * from './service/WebsocketServer'
|
||||
export * from './service/Routing'
|
||||
export * from './service/Middlewares'
|
||||
export * from './service/Discovery'
|
||||
export * from './service/Foreground'
|
||||
|
||||
export * from './support/redis/Redis'
|
||||
export * from './support/cache/MemoryCache'
|
||||
|
@ -242,22 +242,14 @@ export class Application extends Container {
|
||||
* @protected
|
||||
*/
|
||||
protected bootstrapEnvironment(): void {
|
||||
let path = this.basePath.concat('.env').toLocal
|
||||
logIfDebugging('extollo.env', `Trying .env path: ${path}`)
|
||||
if ( fs.existsSync(path) ) {
|
||||
dotenv.config({
|
||||
path,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
path = this.basePath.concat('../.env').toLocal
|
||||
logIfDebugging('extollo.env', `Trying .env path: ${path}`)
|
||||
logIfDebugging('extollo.env', `.env path: ${this.basePath.concat('.env').toLocal}`)
|
||||
const path = this.basePath.concat('.env').toLocal
|
||||
if ( fs.existsSync(path) ) {
|
||||
dotenv.config({
|
||||
path,
|
||||
})
|
||||
}
|
||||
logIfDebugging('extollo.env', process.env)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,46 +0,0 @@
|
||||
import {DatabaseService, FieldType, Migration, Schema} from '../orm'
|
||||
import {Inject} from '../di'
|
||||
|
||||
export default class CreateOAuth2TokensTableMigration extends Migration {
|
||||
@Inject()
|
||||
protected readonly db!: DatabaseService
|
||||
|
||||
async up(): Promise<void> {
|
||||
const db = this.db.get()
|
||||
const table = await db.schema().table('oauth2_tokens')
|
||||
|
||||
table.primaryKey('oauth2_token_id').required()
|
||||
|
||||
table.column('user_id')
|
||||
.type(FieldType.varchar)
|
||||
.nullable()
|
||||
|
||||
table.column('client_id')
|
||||
.type(FieldType.varchar)
|
||||
.required()
|
||||
|
||||
table.column('issued')
|
||||
.type(FieldType.timestamp)
|
||||
.default(db.dialect().currentTimestamp())
|
||||
.required()
|
||||
|
||||
table.column('expires')
|
||||
.type(FieldType.timestamp)
|
||||
.required()
|
||||
|
||||
table.column('scope')
|
||||
.type(FieldType.varchar)
|
||||
.nullable()
|
||||
|
||||
await db.schema().commit(table)
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
const schema: Schema = this.db.get().schema()
|
||||
const table = await schema.table('oauth2_tokens')
|
||||
|
||||
table.dropIfExists()
|
||||
|
||||
await schema.commit(table)
|
||||
}
|
||||
}
|
@ -9,8 +9,8 @@ import {
|
||||
SpecifiedField,
|
||||
} from '../types'
|
||||
import {Connection} from '../connection/Connection'
|
||||
import {Collectable, deepCopy, ErrorWithContext, Maybe} from '../../util'
|
||||
import {EscapeValue, QuerySafeValue, raw, ScalarEscapeValue, VectorEscapeValue} from '../dialect/SQLDialect'
|
||||
import {deepCopy, ErrorWithContext, Maybe} from '../../util'
|
||||
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
|
||||
import {ResultCollection} from './result/ResultCollection'
|
||||
import {AbstractResultIterable} from './result/AbstractResultIterable'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
@ -144,7 +144,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
* @param table
|
||||
* @param alias
|
||||
*/
|
||||
from(table: string|QuerySafeValue, alias?: string): this {
|
||||
from(table: string, alias?: string): this {
|
||||
if ( alias ) {
|
||||
this.source = { table,
|
||||
alias }
|
||||
@ -222,45 +222,17 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
return this
|
||||
}
|
||||
|
||||
/** Apply a WHERE ... IS NULL constraint to the query. */
|
||||
whereNull(field: string): this {
|
||||
return this.whereRawValue(field, 'IS', 'NULL')
|
||||
}
|
||||
|
||||
/** Apply a WHERE ... IS NOT NULL constraint to the query. */
|
||||
whereNotNull(field: string): this {
|
||||
return this.whereRawValue(field, 'IS NOT', 'NULL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE constraint to the query, without escaping `operand`. Prefer `where()`.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
whereRawValue(field: string, operator: ConstraintOperator, operand: string): this {
|
||||
whereRaw(field: string, operator: ConstraintOperator, operand: string): this {
|
||||
this.createConstraint('AND', field, operator, raw(operand))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add raw SQL as a constraint to the query.
|
||||
* @param clause
|
||||
*/
|
||||
whereRaw(clause: string|QuerySafeValue): this {
|
||||
if ( !(clause instanceof QuerySafeValue) ) {
|
||||
clause = raw(clause)
|
||||
}
|
||||
|
||||
this.constraints.push(raw(clause))
|
||||
return this
|
||||
}
|
||||
|
||||
/** Apply an impossible constraint to the query, causing it to match 0 rows. */
|
||||
whereMatchNone(): this {
|
||||
return this.whereRaw('1=0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE NOT constraint to the query.
|
||||
* @param field
|
||||
@ -310,7 +282,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
whereIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
|
||||
whereIn(field: string, values: EscapeValue): this {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'IN',
|
||||
@ -325,7 +297,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
whereNotIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
|
||||
whereNotIn(field: string, values: EscapeValue): this {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'NOT IN',
|
||||
@ -340,7 +312,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
orWhereIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
|
||||
orWhereIn(field: string, values: EscapeValue): this {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'IN',
|
||||
@ -355,7 +327,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
orWhereNotIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
|
||||
orWhereNotIn(field: string, values: EscapeValue): this {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'NOT IN',
|
||||
@ -528,35 +500,6 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
return this.registeredConnection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a batch update on all rows matched by this query, setting the values for discrete
|
||||
* rows based on some key.
|
||||
*
|
||||
* This is a more efficient way of combining discrete update queries.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* query.table('my_table')
|
||||
* .updateMany('id_col', [
|
||||
* {id_col: 1, val1_col: 'a'},
|
||||
* {id_col: 2, val2_col: 'b'},
|
||||
* ])
|
||||
* ```
|
||||
*
|
||||
* This will set the `val1_col` to `a` for rows where `id_col` is `1` and so on.
|
||||
*
|
||||
* @param key
|
||||
* @param rows
|
||||
*/
|
||||
async updateMany(key: string, rows: Collectable<{[key: string]: EscapeValue}>): Promise<QueryResult> {
|
||||
if ( !this.registeredConnection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this.registeredConnection.dialect().renderBatchUpdate(this, key, rows)
|
||||
return this.registeredConnection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a DELETE based on this query.
|
||||
*
|
||||
@ -629,15 +572,6 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
return Boolean(result.rows.first())
|
||||
}
|
||||
|
||||
/** Render the query as a string. */
|
||||
toString(): string {
|
||||
if ( !this.registeredConnection ) {
|
||||
throw new ErrorWithContext('No connection specified to render query.')
|
||||
}
|
||||
|
||||
return this.registeredConnection.dialect().renderSelect(this.finalize())
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the query manually. Overrides any builder methods.
|
||||
* @example
|
||||
@ -653,12 +587,6 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
return this
|
||||
}
|
||||
|
||||
/** Pass this instance into a callback, then return this instance for chaining. */
|
||||
tap(callback: (inst: this) => unknown): this {
|
||||
callback(this)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
|
||||
* @param preop
|
||||
|
@ -1,12 +1,11 @@
|
||||
import {Awaitable, Collection, ErrorWithContext} from '../../util'
|
||||
import {QueryResult, QueryRow} from '../types'
|
||||
import {Awaitable, ErrorWithContext} from '../../util'
|
||||
import {QueryResult} from '../types'
|
||||
import {SQLDialect} from '../dialect/SQLDialect'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
|
||||
import {Schema} from '../schema/Schema'
|
||||
import {Bus} from '../../support/bus'
|
||||
import {ModelField} from '../model/Field'
|
||||
|
||||
/**
|
||||
* Error thrown when a connection is used before it is ready.
|
||||
@ -76,17 +75,6 @@ export abstract class Connection extends AppClass {
|
||||
*/
|
||||
public abstract asTransaction<T>(closure: () => Awaitable<T>): Awaitable<T>
|
||||
|
||||
/**
|
||||
* Normalize a query row before it is used by the framework.
|
||||
* This helps account for differences in return values from the dialects.
|
||||
* @param row
|
||||
* @param fields
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public normalizeRow(row: QueryRow, fields: Collection<ModelField>): QueryRow {
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a QueryExecutedEvent for the given query string.
|
||||
* @param query
|
||||
|
@ -80,21 +80,9 @@ export class PostgresConnection extends Connection {
|
||||
}
|
||||
|
||||
await this.client.query('BEGIN')
|
||||
try {
|
||||
const result = await closure()
|
||||
await this.client.query('COMMIT')
|
||||
return result
|
||||
} catch (e) {
|
||||
await this.client.query('ROLLBACK')
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
const result = await closure()
|
||||
await this.client.query('COMMIT')
|
||||
return result
|
||||
}
|
||||
|
||||
public schema(name?: string): Schema {
|
||||
|
@ -1,117 +0,0 @@
|
||||
import {Connection, ConnectionNotReadyError} from './Connection'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Inject} from '../../di'
|
||||
import {open, Database} from 'sqlite'
|
||||
import {FieldType, QueryResult, QueryRow} from '../types'
|
||||
import {Schema} from '../schema/Schema'
|
||||
import {Awaitable, collect, Collection, hasOwnProperty, UniversalPath} from '../../util'
|
||||
import {SQLDialect} from '../dialect/SQLDialect'
|
||||
import {SQLiteDialect} from '../dialect/SQLiteDialect'
|
||||
import {SQLiteSchema} from '../schema/SQLiteSchema'
|
||||
import * as sqlite3 from 'sqlite3'
|
||||
import {ModelField} from '../model/Field'
|
||||
|
||||
export interface SQLiteConnectionConfig {
|
||||
filename: string,
|
||||
}
|
||||
|
||||
export class SQLiteConnection extends Connection {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
protected client?: Database
|
||||
|
||||
public dialect(): SQLDialect {
|
||||
return this.make(SQLiteDialect)
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
if ( this.config?.filename instanceof UniversalPath ) {
|
||||
this.config.filename = this.config.filename.toLocal
|
||||
}
|
||||
|
||||
this.logging.debug(`Opening SQLite connection ${this.name} (${this.config?.filename})...`)
|
||||
|
||||
this.client = await open({
|
||||
...this.config,
|
||||
driver: sqlite3.Database,
|
||||
})
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.logging.debug(`Closing SQLite connection ${this.name}...`)
|
||||
if ( this.client ) {
|
||||
await this.client.close()
|
||||
}
|
||||
}
|
||||
|
||||
public async query(query: string): Promise<QueryResult> {
|
||||
if ( !this.client ) {
|
||||
throw new ConnectionNotReadyError(this.name, {
|
||||
config: this.config,
|
||||
})
|
||||
}
|
||||
|
||||
this.logging.verbose(`Executing query in connection ${this.name}: \n${query.split('\n').map(x => ' ' + x)
|
||||
.join('\n')}`)
|
||||
|
||||
try {
|
||||
const result = await this.client.all(query) // FIXME: this probably won't work for non-select statements?
|
||||
await this.queryExecuted(query)
|
||||
|
||||
return {
|
||||
rows: collect(result),
|
||||
rowCount: result.length,
|
||||
}
|
||||
} catch (e) {
|
||||
if ( e instanceof Error ) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
query,
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
|
||||
if ( !this.client ) {
|
||||
throw new ConnectionNotReadyError(this.name, {
|
||||
config: this.config,
|
||||
})
|
||||
}
|
||||
|
||||
// fixme: sqlite doesn't support tx's properly in node atm
|
||||
await this.client.run('BEGIN')
|
||||
try {
|
||||
const result = await closure()
|
||||
await this.client.run('COMMIT')
|
||||
return result
|
||||
} catch (e) {
|
||||
await this.client.run('ROLLBACK')
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public normalizeRow(row: QueryRow, fields: Collection<ModelField>): QueryRow {
|
||||
fields.where('type', '=', FieldType.json)
|
||||
.pluck('databaseKey')
|
||||
.filter(key => hasOwnProperty(row, key))
|
||||
.filter(key => typeof row[key] === 'string')
|
||||
.each(key => row[key] = JSON.parse(row[key]))
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
public schema(): Schema {
|
||||
return new SQLiteSchema(this)
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
|
||||
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, QuerySource, SpecifiedField} from '../types'
|
||||
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
|
||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
|
||||
import {collect, Collectable, Collection, ErrorWithContext, hasOwnProperty, Maybe} from '../../util'
|
||||
import {ErrorWithContext, Maybe} from '../../util'
|
||||
|
||||
/**
|
||||
* An implementation of the SQLDialect specific to PostgreSQL.
|
||||
@ -14,7 +14,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
public escape(value: EscapeValue): QuerySafeValue {
|
||||
if ( value instanceof QuerySafeValue ) {
|
||||
return value
|
||||
} else if ( Array.isArray(value) || value instanceof Collection ) {
|
||||
} else if ( Array.isArray(value) ) {
|
||||
return new QuerySafeValue(value, `(${value.map(v => this.escape(v)).join(',')})`)
|
||||
} else if ( String(value).toLowerCase() === 'true' || value === true ) {
|
||||
return new QuerySafeValue(value, 'TRUE')
|
||||
@ -34,29 +34,16 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
]
|
||||
|
||||
return new QuerySafeValue(value, `'${y}-${m}-${d} ${h}:${i}:${s}'`)
|
||||
} else if ( value === null || typeof value === 'undefined' ) {
|
||||
return new QuerySafeValue(value, 'NULL')
|
||||
} else if ( !isNaN(Number(value)) ) {
|
||||
return new QuerySafeValue(value, String(Number(value)))
|
||||
} else if ( value === null || typeof value === 'undefined' ) {
|
||||
return new QuerySafeValue(value, 'NULL')
|
||||
} else {
|
||||
const escaped = value.replace(/'/g, '\'\'') // .replace(/"/g, '\\"').replace(/`/g, '\\`')
|
||||
const escaped = value.replace(/'/g, '\\\'') // .replace(/"/g, '\\"').replace(/`/g, '\\`')
|
||||
return new QuerySafeValue(value, `'${escaped}'`)
|
||||
}
|
||||
}
|
||||
|
||||
public renderQuerySource(source: QuerySource): string {
|
||||
if ( source instanceof QuerySafeValue ) {
|
||||
return String(source)
|
||||
} else if ( typeof source === 'string' ) {
|
||||
return source.replace(/"/g, '""')
|
||||
.split('.')
|
||||
.map(x => '"' + x + '"')
|
||||
.join('.')
|
||||
}
|
||||
|
||||
return `${this.renderQuerySource(source.table)} AS "${source.alias.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
public renderCount(query: string): string {
|
||||
return [
|
||||
'SELECT COUNT(*) AS "extollo_render_count"',
|
||||
@ -72,7 +59,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
'FROM (',
|
||||
...query.split('\n').map(x => ` ${x}`),
|
||||
') AS extollo_target_query',
|
||||
`OFFSET ${start} LIMIT ${start === end ? ((end - start) + 1) : (end - start)}`,
|
||||
`OFFSET ${start} LIMIT ${(end - start) + 1}`, // FIXME - the +1 is only needed when start === end
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@ -124,7 +111,10 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
// FIXME error if no source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('FROM ' + this.renderQuerySource(source))
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
queryLines.push('FROM ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
@ -164,76 +154,6 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderBatchUpdate(builder: AbstractBuilder<any>, primaryKey: string, dataRows: Collectable<{[key: string]: EscapeValue}>): string {
|
||||
const rows = Collection.normalize(dataRows)
|
||||
const rawSql = builder.appliedRawSql
|
||||
if ( rawSql ) {
|
||||
return rawSql
|
||||
}
|
||||
|
||||
const queryLines: string[] = []
|
||||
|
||||
// Add table source
|
||||
let source = builder.querySource
|
||||
if ( !source ) {
|
||||
throw new ErrorWithContext('No table specified for update query')
|
||||
}
|
||||
|
||||
source = (typeof source !== 'string' && !(source instanceof QuerySafeValue)) ? source : {
|
||||
table: source,
|
||||
alias: 'extollo_update_source',
|
||||
}
|
||||
|
||||
const sourceAlias = source.alias
|
||||
const sourceTable = source.table
|
||||
|
||||
queryLines.push('UPDATE ' + this.renderQuerySource(source))
|
||||
|
||||
queryLines.push('SET')
|
||||
const updateFields = this.getAllFieldsFromUpdateRows(rows)
|
||||
|
||||
const updateTuples = rows.map(row => {
|
||||
return updateFields.map(field => {
|
||||
if ( hasOwnProperty(row, field) ) {
|
||||
return this.escape(row[field])
|
||||
}
|
||||
|
||||
// FIXME: This is fairly inefficient. Probably a better way with a FROM ... SELECT
|
||||
// return raw(`"${sourceAlias}"."${field}"`)
|
||||
return raw(`(SELECT "${field}" FROM ${sourceTable} WHERE "${primaryKey}" = ${this.escape(row[primaryKey])})`)
|
||||
})
|
||||
})
|
||||
|
||||
queryLines.push(updateFields.map(field => ` "${field}" = "extollo_update_tuple"."${field}"`).join(',\n'))
|
||||
|
||||
queryLines.push('FROM (VALUES')
|
||||
|
||||
queryLines.push(
|
||||
updateTuples.map(tuple => ` (${tuple.implode(', ')})`).join(',\n'),
|
||||
)
|
||||
|
||||
queryLines.push(`) as extollo_update_tuple(${updateFields.map(x => `"${x}"`).join(', ')})`)
|
||||
|
||||
queryLines.push(`WHERE "extollo_update_tuple"."${primaryKey}" = "${sourceAlias}"."${primaryKey}" AND (`)
|
||||
|
||||
queryLines.push(this.renderConstraints(builder.appliedConstraints, 2))
|
||||
|
||||
queryLines.push(`)`)
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
private getAllFieldsFromUpdateRows(rows: Collection<{[key: string]: EscapeValue}>): Collection<string> {
|
||||
return rows.reduce((fields: Collection<string>, row) => {
|
||||
Object.keys(row).forEach(key => {
|
||||
if ( !fields.includes(key) ) {
|
||||
fields.push(key)
|
||||
}
|
||||
})
|
||||
return fields
|
||||
}, collect<string>())
|
||||
}
|
||||
|
||||
// TODO support FROM, RETURNING
|
||||
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
|
||||
const rawSql = builder.appliedRawSql
|
||||
@ -246,7 +166,10 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('UPDATE ' + this.renderQuerySource(source))
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
queryLines.push('UPDATE ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
|
||||
}
|
||||
|
||||
queryLines.push(this.renderUpdateSet(data))
|
||||
@ -258,14 +181,6 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
const fields = this.renderFields(builder).map(x => ` ${x}`)
|
||||
.join(',\n')
|
||||
|
||||
if ( fields ) {
|
||||
queryLines.push('RETURNING')
|
||||
queryLines.push(fields)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
@ -311,7 +226,10 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('INSERT INTO ' + this.renderQuerySource(source)
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
queryLines.push('INSERT INTO ' + (typeof source === 'string' ? table : `${table} AS "${source.alias}"`)
|
||||
+ (columns.length ? ` (${columns.map(x => `"${x}"`).join(', ')})` : ''))
|
||||
}
|
||||
|
||||
@ -354,7 +272,10 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('DELETE FROM ' + this.renderQuerySource(source))
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
queryLines.push('DELETE FROM ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
@ -377,8 +298,8 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderConstraints(allConstraints: Constraint[], startingLevel = 1): string {
|
||||
const constraintsToSql = (constraints: Constraint[], level = startingLevel): string => {
|
||||
public renderConstraints(allConstraints: Constraint[]): string {
|
||||
const constraintsToSql = (constraints: Constraint[], level = 1): string => {
|
||||
const indent = Array(level * 2).fill(' ')
|
||||
.join('')
|
||||
const statements = []
|
||||
@ -395,8 +316,6 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
const field: string = constraint.field.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
|
||||
} else if ( constraint instanceof QuerySafeValue ) {
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : 'AND '}${constraint.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -416,7 +335,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
sets.push(` "${key}" = ${this.escape(data[key])}`)
|
||||
}
|
||||
|
||||
return `SET\n${sets.join(',\n')}`
|
||||
return ['SET', ...sets].join('\n')
|
||||
}
|
||||
|
||||
public renderCreateTable(builder: TableBuilder): string {
|
||||
@ -544,7 +463,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
public renderAlterTable(builder: TableBuilder): Maybe<string> {
|
||||
public renderAlterTable(builder: TableBuilder): string {
|
||||
const alters: string[] = []
|
||||
const columns = builder.getColumns()
|
||||
|
||||
@ -628,10 +547,6 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
alters.push(` RENAME TO "${builder.getRename()}"`)
|
||||
}
|
||||
|
||||
if ( !alters.length ) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'ALTER TABLE ' + builder.name + '\n' + alters.join(',\n')
|
||||
}
|
||||
|
||||
@ -665,8 +580,4 @@ export class PostgreSQLDialect extends SQLDialect {
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
public currentTimestamp(): QuerySafeValue {
|
||||
return raw('NOW()')
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,20 @@
|
||||
import {Constraint, QuerySource} from '../types'
|
||||
import {Constraint} from '../types'
|
||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
|
||||
import {Collectable, Collection, Maybe} from '../../util'
|
||||
|
||||
/** A scalar value which can be interpolated safely into an SQL query. */
|
||||
export type ScalarEscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue;
|
||||
/**
|
||||
* A value which can be escaped to be interpolated into an SQL query.
|
||||
*/
|
||||
export type EscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue | EscapeValue[] // FIXME | Select<any>
|
||||
|
||||
/** A list of scalar escape values. */
|
||||
export type VectorEscapeValue<T extends ScalarEscapeValue> = T[] | Collection<T>
|
||||
|
||||
/** All possible escaped query values. */
|
||||
export type EscapeValue<T extends ScalarEscapeValue = ScalarEscapeValue> = T | VectorEscapeValue<T> // FIXME | Select<any>
|
||||
|
||||
/** Object mapping string field names to EscapeValue items. */
|
||||
/**
|
||||
* Object mapping string field names to EscapeValue items.
|
||||
*/
|
||||
export type EscapeValueObject = { [field: string]: EscapeValue }
|
||||
|
||||
/**
|
||||
* A wrapper class whose value is safe to inject directly into a query.
|
||||
* A wrapper class whose value is save to inject directly into a query.
|
||||
*/
|
||||
export class QuerySafeValue {
|
||||
constructor(
|
||||
@ -55,12 +52,6 @@ export abstract class SQLDialect extends AppClass {
|
||||
*/
|
||||
public abstract escape(value: EscapeValue): QuerySafeValue
|
||||
|
||||
/**
|
||||
* Render a query source object as a qualified table name string ("tablename" as "alias").
|
||||
* @param source
|
||||
*/
|
||||
public abstract renderQuerySource(source: QuerySource): string;
|
||||
|
||||
/**
|
||||
* Render the given query builder as a "SELECT ..." query string.
|
||||
*
|
||||
@ -79,15 +70,6 @@ export abstract class SQLDialect extends AppClass {
|
||||
*/
|
||||
public abstract renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string;
|
||||
|
||||
/**
|
||||
* Render the given query builder as an "UPDATE ..." query string, setting column values
|
||||
* for multiple distinct records based on their primary key.
|
||||
* @param builder
|
||||
* @param primaryKey
|
||||
* @param dataRows
|
||||
*/
|
||||
public abstract renderBatchUpdate(builder: AbstractBuilder<any>, primaryKey: string, dataRows: Collectable<{[key: string]: EscapeValue}>): string;
|
||||
|
||||
/**
|
||||
* Render the given query builder as a "DELETE ..." query string.
|
||||
*
|
||||
@ -198,7 +180,7 @@ export abstract class SQLDialect extends AppClass {
|
||||
* Given a table schema-builder, render an `ALTER TABLE...` query.
|
||||
* @param builder
|
||||
*/
|
||||
public abstract renderAlterTable(builder: TableBuilder): Maybe<string>;
|
||||
public abstract renderAlterTable(builder: TableBuilder): string;
|
||||
|
||||
/**
|
||||
* Given a table schema-builder, render a `DROP TABLE...` query.
|
||||
@ -267,12 +249,6 @@ export abstract class SQLDialect extends AppClass {
|
||||
*/
|
||||
public abstract renderTransaction(queries: string[]): string;
|
||||
|
||||
/**
|
||||
* Get the expression for the current timestamp as an escaped value.
|
||||
* @example `raw('NOW()')`
|
||||
*/
|
||||
public abstract currentTimestamp(): QuerySafeValue;
|
||||
|
||||
/**
|
||||
* Given a table schema-builder, render a series of queries as a transaction
|
||||
* that apply the given schema to database.
|
||||
@ -314,10 +290,7 @@ export abstract class SQLDialect extends AppClass {
|
||||
if ( !builder.isExisting() && builder.isDirty() ) {
|
||||
parts.push(this.renderCreateTable(builder))
|
||||
} else if ( builder.isExisting() && builder.isDirty() ) {
|
||||
const alterTable = this.renderAlterTable(builder)
|
||||
if ( alterTable ) {
|
||||
parts.push(alterTable)
|
||||
}
|
||||
parts.push(this.renderAlterTable(builder))
|
||||
}
|
||||
|
||||
// Render the various schema queries as a single transaction
|
||||
|
@ -1,617 +0,0 @@
|
||||
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
|
||||
import {Collection, ErrorWithContext, Maybe} from '../../util'
|
||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||
import {
|
||||
Constraint,
|
||||
FieldType,
|
||||
inverseFieldType,
|
||||
isConstraintGroup,
|
||||
isConstraintItem,
|
||||
QuerySource,
|
||||
SpecifiedField,
|
||||
} from '../types'
|
||||
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
|
||||
|
||||
export class SQLiteDialect extends SQLDialect {
|
||||
public escape(value: EscapeValue): QuerySafeValue {
|
||||
if ( value instanceof QuerySafeValue ) {
|
||||
return value
|
||||
} else if ( Array.isArray(value) || value instanceof Collection ) {
|
||||
return new QuerySafeValue(value, `(${value.map(v => this.escape(v)).join(',')})`)
|
||||
} else if ( String(value).toLowerCase() === 'true' || value === true ) {
|
||||
return new QuerySafeValue(value, 'TRUE')
|
||||
} else if ( String(value).toLowerCase() === 'false' || value === false ) {
|
||||
return new QuerySafeValue(value, 'FALSE')
|
||||
} else if ( typeof value === 'number' ) {
|
||||
return new QuerySafeValue(value, `${value}`)
|
||||
} else if ( value instanceof Date ) {
|
||||
const pad = (val: number) => val < 10 ? `0${val}` : `${val}`
|
||||
const [y, m, d, h, i, s] = [
|
||||
`${value.getFullYear()}`,
|
||||
`${pad(value.getMonth() + 1)}`,
|
||||
`${pad(value.getDate())}`,
|
||||
`${pad(value.getHours())}`,
|
||||
`${pad(value.getMinutes())}`,
|
||||
`${pad(value.getSeconds())}`,
|
||||
]
|
||||
|
||||
return new QuerySafeValue(value, `'${y}-${m}-${d} ${h}:${i}:${s}'`)
|
||||
} else if ( value === null || typeof value === 'undefined' ) {
|
||||
return new QuerySafeValue(value, 'NULL')
|
||||
} else if ( !isNaN(Number(value)) ) {
|
||||
return new QuerySafeValue(value, String(Number(value)))
|
||||
} else {
|
||||
const escaped = value.replace(/'/g, '\'\'')
|
||||
return new QuerySafeValue(value, `'${escaped}'`)
|
||||
}
|
||||
}
|
||||
|
||||
public renderQuerySource(source: QuerySource): string {
|
||||
if ( source instanceof QuerySafeValue ) {
|
||||
return String(source)
|
||||
} else if ( typeof source === 'string' ) {
|
||||
return source.replace(/"/g, '""')
|
||||
.split('.')
|
||||
.map(x => '"' + x + '"')
|
||||
.join('.')
|
||||
}
|
||||
|
||||
return `${this.renderQuerySource(source.table)} AS "${source.alias.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
public renderCount(query: string): string {
|
||||
return [
|
||||
'SELECT COUNT(*) AS "extollo_render_count"',
|
||||
'FROM (',
|
||||
...query.split('\n').map(x => ` ${x}`),
|
||||
') AS extollo_target_query',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
public renderRangedSelect(query: string, start: number, end: number): string {
|
||||
return [
|
||||
'SELECT *',
|
||||
'FROM (',
|
||||
...query.split('\n').map(x => ` ${x}`),
|
||||
') AS extollo_target_query',
|
||||
`LIMIT ${start === end ? ((end - start) + 1) : (end - start)} OFFSET ${start}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Render the fields from the builder class to PostgreSQL syntax. */
|
||||
protected renderFields(builder: AbstractBuilder<any>): string[] {
|
||||
return builder.appliedFields.map((field: SpecifiedField) => {
|
||||
let columnString: string
|
||||
if ( typeof field === 'string' ) {
|
||||
columnString = field.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
} else if ( field instanceof QuerySafeValue ) {
|
||||
columnString = field.toString()
|
||||
} else if ( typeof field.field === 'string' ) {
|
||||
columnString = field.field.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
} else {
|
||||
columnString = field.field.toString()
|
||||
}
|
||||
|
||||
let aliasString = ''
|
||||
if ( typeof field !== 'string' && !(field instanceof QuerySafeValue) ) {
|
||||
aliasString = ` AS "${field.alias}"`
|
||||
}
|
||||
|
||||
return `${columnString}${aliasString}`
|
||||
})
|
||||
}
|
||||
|
||||
public renderSelect(builder: AbstractBuilder<any>): string {
|
||||
const rawSql = builder.appliedRawSql
|
||||
if ( rawSql ) {
|
||||
return rawSql
|
||||
}
|
||||
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
||||
.join(' ') + item
|
||||
const queryLines = [
|
||||
`SELECT${builder.appliedDistinction ? ' DISTINCT' : ''}`,
|
||||
]
|
||||
|
||||
// Add fields
|
||||
// FIXME error if no fields
|
||||
const fields = this.renderFields(builder).map(x => indent(x))
|
||||
.join(',\n')
|
||||
|
||||
queryLines.push(fields)
|
||||
|
||||
// Add table source
|
||||
// FIXME error if no source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('FROM ' + this.renderQuerySource(source))
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
||||
if ( wheres.trim() ) {
|
||||
queryLines.push('WHERE')
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
// Add group by
|
||||
if ( builder.appliedGroupings?.length ) {
|
||||
const grouping = builder.appliedGroupings.map(group => {
|
||||
return indent(group.split('.').map(x => `"${x}"`)
|
||||
.join('.'))
|
||||
}).join(',\n')
|
||||
|
||||
queryLines.push('GROUP BY')
|
||||
queryLines.push(grouping)
|
||||
}
|
||||
|
||||
// Add order by
|
||||
if ( builder.appliedOrder?.length ) {
|
||||
const ordering = builder.appliedOrder.map(x => indent(`${x.field.split('.').map(y => '"' + y + '"')
|
||||
.join('.')} ${x.direction}`)).join(',\n')
|
||||
queryLines.push('ORDER BY')
|
||||
queryLines.push(ordering)
|
||||
}
|
||||
|
||||
// Add limit/offset
|
||||
const pagination = builder.appliedPagination
|
||||
if ( pagination.take ) {
|
||||
queryLines.push(`LIMIT ${pagination.take}${pagination.skip ? ' OFFSET ' + pagination.skip : ''}`)
|
||||
} else if ( pagination.skip ) {
|
||||
queryLines.push(`LIMIT -1 OFFSET ${pagination.skip}`)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderBatchUpdate(): string {
|
||||
throw new ErrorWithContext('SQLite dialect does not support batch updates.')
|
||||
}
|
||||
|
||||
// TODO support FROM, RETURNING
|
||||
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
|
||||
const rawSql = builder.appliedRawSql
|
||||
if ( rawSql ) {
|
||||
return rawSql
|
||||
}
|
||||
|
||||
const queryLines: string[] = []
|
||||
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('UPDATE ' + this.renderQuerySource(source))
|
||||
}
|
||||
|
||||
queryLines.push(this.renderUpdateSet(data))
|
||||
|
||||
// Add constraints
|
||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
||||
if ( wheres.trim() ) {
|
||||
queryLines.push('WHERE')
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
const fields = this.renderFields(builder).map(x => ` ${x}`)
|
||||
.join(',\n')
|
||||
|
||||
if ( fields ) {
|
||||
queryLines.push('RETURNING')
|
||||
queryLines.push(fields)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderExistential(builder: AbstractBuilder<any>): string {
|
||||
const rawSql = builder.appliedRawSql
|
||||
if ( rawSql ) {
|
||||
return `
|
||||
SELECT EXISTS(
|
||||
${rawSql}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
const query = builder.clone()
|
||||
.clearFields()
|
||||
.field(raw('TRUE'))
|
||||
.limit(1)
|
||||
|
||||
return this.renderSelect(query)
|
||||
}
|
||||
|
||||
// FIXME: subquery support here and with select
|
||||
public renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[] = []): string {
|
||||
const rawSql = builder.appliedRawSql
|
||||
if ( rawSql ) {
|
||||
return rawSql
|
||||
}
|
||||
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
||||
.join(' ') + item
|
||||
const queryLines: string[] = []
|
||||
|
||||
if ( !Array.isArray(data) ) {
|
||||
data = [data]
|
||||
}
|
||||
|
||||
if ( data.length < 1 ) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const columns = Object.keys(data[0])
|
||||
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('INSERT INTO ' + this.renderQuerySource(source)
|
||||
+ (columns.length ? ` (${columns.map(x => `"${x}"`).join(', ')})` : ''))
|
||||
}
|
||||
|
||||
if ( Array.isArray(data) && !data.length ) {
|
||||
queryLines.push('DEFAULT VALUES')
|
||||
} else {
|
||||
queryLines.push('VALUES')
|
||||
|
||||
const valueString = data.map(row => {
|
||||
const values = columns.map(x => this.escape(row[x]))
|
||||
return indent(`(${values.join(', ')})`)
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
queryLines.push(valueString)
|
||||
}
|
||||
|
||||
// Add return fields
|
||||
if ( builder.appliedFields?.length ) {
|
||||
queryLines.push('RETURNING')
|
||||
const fields = this.renderFields(builder).map(x => indent(x))
|
||||
.join(',\n')
|
||||
|
||||
queryLines.push(fields)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderDelete(builder: AbstractBuilder<any>): string {
|
||||
const rawSql = builder.appliedRawSql
|
||||
if ( rawSql ) {
|
||||
return rawSql
|
||||
}
|
||||
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
||||
.join(' ') + item
|
||||
const queryLines: string[] = []
|
||||
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
queryLines.push('DELETE FROM ' + this.renderQuerySource(source))
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
||||
if ( wheres.trim() ) {
|
||||
queryLines.push('WHERE')
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
// Add return fields
|
||||
if ( builder.appliedFields?.length ) {
|
||||
queryLines.push('RETURNING')
|
||||
|
||||
const fields = this.renderFields(builder).map(x => indent(x))
|
||||
.join(',\n')
|
||||
|
||||
queryLines.push(fields)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderConstraints(allConstraints: Constraint[], startingLevel = 1): string {
|
||||
const constraintsToSql = (constraints: Constraint[], level = startingLevel): string => {
|
||||
const indent = Array(level * 2).fill(' ')
|
||||
.join('')
|
||||
const statements = []
|
||||
|
||||
for ( const constraint of constraints ) {
|
||||
if ( isConstraintGroup(constraint) ) {
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
|
||||
} else if ( isConstraintItem(constraint) ) {
|
||||
if ( Array.isArray(constraint.operand) && !constraint.operand.length ) {
|
||||
statements.push(`${indent}1 = 0 -- ${constraint.field} ${constraint.operator} empty set`)
|
||||
continue
|
||||
}
|
||||
|
||||
const field: string = constraint.field.split('.').map(x => `"${x}"`)
|
||||
.join('.')
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
|
||||
} else if ( constraint instanceof QuerySafeValue ) {
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : 'AND '}${constraint.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
return statements.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
return constraintsToSql(allConstraints)
|
||||
}
|
||||
|
||||
public renderUpdateSet(data: {[key: string]: EscapeValue}): string {
|
||||
const sets = []
|
||||
for ( const key in data ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(data, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
sets.push(` "${key}" = ${this.escape(data[key])}`)
|
||||
}
|
||||
|
||||
return `SET\n${sets.join(',\n')}`
|
||||
}
|
||||
|
||||
public renderCreateTable(builder: TableBuilder): string {
|
||||
const cols = this.renderTableColumns(builder).map(x => ` ${x}`)
|
||||
|
||||
const builderConstraints = builder.getConstraints()
|
||||
const constraints: string[] = []
|
||||
for ( const constraintName in builderConstraints ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(builderConstraints, constraintName) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
const constraintBuilder = builderConstraints[constraintName]
|
||||
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
|
||||
if ( constraintDefinition ) {
|
||||
constraints.push(` CONSTRAINT ${constraintDefinition}`)
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`CREATE TABLE ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name} (`,
|
||||
[
|
||||
...cols,
|
||||
...constraints,
|
||||
].join(',\n'),
|
||||
`)`,
|
||||
]
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
public renderTableColumns(builder: TableBuilder): string[] {
|
||||
const defined = builder.getColumns()
|
||||
const rendered: string[] = []
|
||||
|
||||
for ( const columnName in defined ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(defined, columnName) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
const columnBuilder = defined[columnName]
|
||||
rendered.push(this.renderColumnDefinition(columnBuilder))
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a constraint schema-builder, render the constraint definition.
|
||||
* @param builder
|
||||
* @protected
|
||||
*/
|
||||
protected renderConstraintDefinition(builder: ConstraintBuilder): Maybe<string> {
|
||||
const constraintType = builder.getType()
|
||||
if ( constraintType === ConstraintType.Unique ) {
|
||||
const fields = builder.getFields()
|
||||
.map(x => `"${x}"`)
|
||||
.join(',')
|
||||
|
||||
return `${builder.name} UNIQUE(${fields})`
|
||||
} else if ( constraintType === ConstraintType.Check ) {
|
||||
const expression = builder.getExpression()
|
||||
if ( !expression ) {
|
||||
throw new ErrorWithContext('Cannot create check constraint without expression.', {
|
||||
constraintName: builder.name,
|
||||
tableName: builder.parent.name,
|
||||
})
|
||||
}
|
||||
|
||||
return `${builder.name} CHECK(${expression})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a column-builder, render the SQL-definition as used in
|
||||
* CREATE TABLE and ALTER TABLE statements.
|
||||
* @fixme Type `serial` only exists on CREATE TABLE... queries
|
||||
* @param builder
|
||||
* @protected
|
||||
*/
|
||||
protected renderColumnDefinition(builder: ColumnBuilder): string {
|
||||
const type = builder.getType()
|
||||
if ( !type ) {
|
||||
throw new ErrorWithContext(`Missing field type for column: ${builder.name}`, {
|
||||
columnName: builder.name,
|
||||
columnType: type,
|
||||
})
|
||||
}
|
||||
|
||||
let render = `${builder.name} ${inverseFieldType(this.mapFieldType(type))}`
|
||||
|
||||
if ( builder.getLength() ) {
|
||||
render += `(${builder.getLength()})`
|
||||
}
|
||||
|
||||
const defaultValue = builder.getDefaultValue()
|
||||
if ( typeof defaultValue !== 'undefined' ) {
|
||||
render += ` DEFAULT ${this.escape(defaultValue)}`
|
||||
}
|
||||
|
||||
if ( builder.isPrimary() ) {
|
||||
render += ` PRIMARY KEY`
|
||||
}
|
||||
|
||||
if ( type === FieldType.serial ) {
|
||||
render += ` AUTOINCREMENT`
|
||||
}
|
||||
|
||||
if ( builder.isUnique() ) {
|
||||
render += ` UNIQUE`
|
||||
}
|
||||
|
||||
render += ` ${builder.isNullable() ? 'NULL' : 'NOT NULL'}`
|
||||
return render
|
||||
}
|
||||
|
||||
private mapFieldType(type: FieldType): FieldType {
|
||||
if ( type === FieldType.serial ) {
|
||||
return FieldType.integer
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
public renderDropTable(builder: TableBuilder): string {
|
||||
return `DROP TABLE ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`
|
||||
}
|
||||
|
||||
public renderCreateIndex(builder: IndexBuilder): string {
|
||||
const cols = builder.getFields().map(x => `"${x}"`)
|
||||
const parts = [
|
||||
`CREATE ${builder.isUnique() ? 'UNIQUE ' : ''}INDEX ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name}`,
|
||||
` ON ${builder.parent.name}`,
|
||||
` (${cols.join(',')})`,
|
||||
]
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
public renderAlterTable(builder: TableBuilder): string {
|
||||
const alters: string[] = []
|
||||
const columns = builder.getColumns()
|
||||
|
||||
for ( const columnName in columns ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(columns, columnName) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
const columnBuilder = columns[columnName]
|
||||
if ( !columnBuilder.isExisting() ) {
|
||||
// The column doesn't exist on the table, but was added to the schema
|
||||
alters.push(` ADD COLUMN ${this.renderColumnDefinition(columnBuilder)}`)
|
||||
} else if ( columnBuilder.isDirty() && columnBuilder.originalFromSchema ) {
|
||||
// The column exists in the table, but was modified in the schema
|
||||
if ( columnBuilder.isDropping() || columnBuilder.isDroppingIfExists() ) {
|
||||
alters.push(` DROP COLUMN "${columnBuilder.name}"`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Change the data type of the column
|
||||
if ( columnBuilder.getType() !== columnBuilder.originalFromSchema.getType() ) {
|
||||
const renderedType = `${columnBuilder.getType()}${columnBuilder.getLength() ? `(${columnBuilder.getLength()})` : ''}`
|
||||
alters.push(` ALTER COLUMN "${columnBuilder.name}" TYPE ${renderedType}`)
|
||||
}
|
||||
|
||||
// Change the default value of the column
|
||||
if ( columnBuilder.getDefaultValue() !== columnBuilder.originalFromSchema.getDefaultValue() ) {
|
||||
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET default ${this.escape(columnBuilder.getDefaultValue())}`)
|
||||
}
|
||||
|
||||
// Change the nullable-status of the column
|
||||
if ( columnBuilder.isNullable() !== columnBuilder.originalFromSchema.isNullable() ) {
|
||||
if ( columnBuilder.isNullable() ) {
|
||||
alters.push(` ALTER COLUMN "${columnBuilder.name}" DROP NOT NULL`)
|
||||
} else {
|
||||
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET NOT NULL`)
|
||||
}
|
||||
}
|
||||
|
||||
// Change the name of the column
|
||||
if ( columnBuilder.getRename() ) {
|
||||
alters.push(` RENAME COLUMN "${columnBuilder.name}" TO "${columnBuilder.getRename()}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const constraints = builder.getConstraints()
|
||||
for ( const constraintName in constraints ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(constraints, constraintName) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
const constraintBuilder = constraints[constraintName]
|
||||
|
||||
// Drop the constraint if specified
|
||||
if ( constraintBuilder.isDropping() ) {
|
||||
alters.push(` DROP CONSTRAINT ${constraintBuilder.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop the constraint with IF EXISTS if specified
|
||||
if ( constraintBuilder.isDroppingIfExists() ) {
|
||||
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, drop and recreate the constraint if it was modified
|
||||
if ( constraintBuilder.isDirty() ) {
|
||||
if ( constraintBuilder.isExisting() ) {
|
||||
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
|
||||
}
|
||||
|
||||
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
|
||||
if ( constraintDefinition ) {
|
||||
alters.push(` ADD CONSTRAINT ${constraintDefinition}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( builder.getRename() ) {
|
||||
alters.push(` RENAME TO "${builder.getRename()}"`)
|
||||
}
|
||||
|
||||
return 'ALTER TABLE ' + builder.name + '\n' + alters.join(',\n')
|
||||
}
|
||||
|
||||
public renderDropIndex(builder: IndexBuilder): string {
|
||||
return `DROP INDEX ${builder.isDroppingIfExists() ? 'IF EXISTS ' : ''}${builder.name}`
|
||||
}
|
||||
|
||||
public renderTransaction(queries: string[]): string {
|
||||
return queries.join(';\n\n') // fixme: sqlite3 in node doesn't properly support transactions
|
||||
// const parts = [
|
||||
// 'BEGIN',
|
||||
// ...queries,
|
||||
// 'COMMIT;',
|
||||
// ]
|
||||
//
|
||||
// return parts.join(';\n\n')
|
||||
}
|
||||
|
||||
public renderRenameIndex(builder: IndexBuilder): string {
|
||||
return `ALTER INDEX ${builder.name} RENAME TO ${builder.getRename()}`
|
||||
}
|
||||
|
||||
public renderRecreateIndex(builder: IndexBuilder): string {
|
||||
return `${this.renderDropIndex(builder)};\n\n${this.renderCreateIndex(builder)}`
|
||||
}
|
||||
|
||||
public renderDropColumn(builder: ColumnBuilder): string {
|
||||
const parts = [
|
||||
`ALTER TABLE ${builder.parent.name} ${builder.parent.isSkippedIfExisting() ? 'IF EXISTS ' : ''}`,
|
||||
` DROP COLUMN ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`,
|
||||
]
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
public currentTimestamp(): QuerySafeValue {
|
||||
return raw('CURRENT_TIMESTAMP')
|
||||
}
|
||||
}
|
@ -7,13 +7,11 @@ export * from './builder/Builder'
|
||||
|
||||
export * from './connection/Connection'
|
||||
export * from './connection/PostgresConnection'
|
||||
export * from './connection/SQLiteConnection'
|
||||
export * from './connection/event/QueryExecutedEvent'
|
||||
export * from './connection/event/QueryExecutedEventSerializer'
|
||||
|
||||
export * from './dialect/SQLDialect'
|
||||
export * from './dialect/PostgreSQLDialect'
|
||||
export * from './dialect/SQLiteDialect'
|
||||
|
||||
export * from './model/Field'
|
||||
export * from './model/ModelBuilder'
|
||||
@ -22,15 +20,12 @@ export * from './model/ModelResultIterable'
|
||||
export * from './model/events'
|
||||
export * from './model/Model'
|
||||
export * from './model/ModelSerializer'
|
||||
export * from './model/TreeModel'
|
||||
|
||||
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/HasSubtree'
|
||||
export * from './model/relation/HasTreeParent'
|
||||
export * from './model/relation/decorators'
|
||||
|
||||
export * from './model/scope/Scope'
|
||||
@ -47,7 +42,6 @@ export * from './types'
|
||||
export * from './schema/TableBuilder'
|
||||
export * from './schema/Schema'
|
||||
export * from './schema/PostgresSchema'
|
||||
export * from './schema/SQLiteSchema'
|
||||
|
||||
export * from './services/Migrations'
|
||||
export * from './migrations/Migrator'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {Migrator} from './Migrator'
|
||||
import {DatabaseService} from '../DatabaseService'
|
||||
import {FieldType} from '../types'
|
||||
@ -14,6 +14,9 @@ export class DatabaseMigrator extends Migrator {
|
||||
@Inject()
|
||||
protected readonly db!: DatabaseService
|
||||
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** True if we've initialized the migrator. */
|
||||
protected initialized = false
|
||||
|
||||
@ -137,13 +140,12 @@ export class DatabaseMigrator extends Migrator {
|
||||
* @protected
|
||||
*/
|
||||
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
|
||||
const existing = (await this.builder()
|
||||
const existing = await this.builder()
|
||||
.connection('default')
|
||||
.select('identifier')
|
||||
.from('migrations')
|
||||
.whereIn('identifier', identifiers)
|
||||
.get()
|
||||
.collect())
|
||||
.pluck<string>('identifier')
|
||||
|
||||
return identifiers.filter(id => !existing.includes(id))
|
||||
|
@ -1,23 +1,82 @@
|
||||
import {Instantiable, FactoryProducer} from '../../di'
|
||||
import {
|
||||
AbstractFactory,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
|
||||
} from '../../di'
|
||||
import {Collection, ErrorWithContext} from '../../util'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Migrator} from './Migrator'
|
||||
import {DatabaseMigrator} from './DatabaseMigrator'
|
||||
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract Migrator class
|
||||
* and produces an instance of the configured session driver implementation.
|
||||
*/
|
||||
@Injectable()
|
||||
@FactoryProducer()
|
||||
export class MigratorFactory extends ConfiguredSingletonFactory<Migrator> {
|
||||
protected getConfigKey(): string {
|
||||
return 'database.migrations.driver'
|
||||
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
constructor() {
|
||||
super({})
|
||||
}
|
||||
|
||||
protected getDefaultImplementation(): Instantiable<Migrator> {
|
||||
return DatabaseMigrator
|
||||
produce(): Migrator {
|
||||
return new (this.getMigratorClass())()
|
||||
}
|
||||
|
||||
protected getAbstractImplementation(): any {
|
||||
return Migrator
|
||||
match(something: unknown): boolean {
|
||||
return something === Migrator
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getMigratorClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getMigratorClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured migrator backend.
|
||||
* @protected
|
||||
* @return Instantiable<Migrator>
|
||||
*/
|
||||
protected getMigratorClass(): Instantiable<Migrator> {
|
||||
const MigratorClass = this.config.get('database.migrations.driver', DatabaseMigrator)
|
||||
|
||||
if ( !isInstantiable(MigratorClass) || !(MigratorClass.prototype instanceof Migrator) ) {
|
||||
const e = new ErrorWithContext('Provided migration driver class does not extend from @extollo/lib.Migrator')
|
||||
e.context = {
|
||||
configKey: 'database.migrations.driver',
|
||||
class: MigratorClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return MigratorClass
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import {ModelKey, QueryRow, QuerySource} from '../types'
|
||||
import {Container, Instantiable, isInstantiable} from '../../di'
|
||||
import {Container, Inject, Instantiable, isInstantiable, StaticClass, StaticThis} from '../../di'
|
||||
import {DatabaseService} from '../DatabaseService'
|
||||
import {ModelBuilder} from './ModelBuilder'
|
||||
import {getFieldsMeta, ModelField} from './Field'
|
||||
import {deepCopy, Collection, uuid4, isKeyof, Pipeline, hasOwnProperty} from '../../util'
|
||||
import {EscapeValueObject, QuerySafeValue} from '../dialect/SQLDialect'
|
||||
import {deepCopy, Collection, uuid4, isKeyof, Pipeline} from '../../util'
|
||||
import {EscapeValueObject} from '../dialect/SQLDialect'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Connection} from '../connection/Connection'
|
||||
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
|
||||
import {ModelSavingEvent} from './events/ModelSavingEvent'
|
||||
@ -18,13 +19,16 @@ import {HasOne} from './relation/HasOne'
|
||||
import {HasMany} from './relation/HasMany'
|
||||
import {HasOneOrMany} from './relation/HasOneOrMany'
|
||||
import {Scope, ScopeClosure} from './scope/Scope'
|
||||
import {LocalBus} from '../../support/bus/LocalBus' // need the specific import to prevent circular dependencies
|
||||
import {LocalBus} from '../../support/bus'
|
||||
import {ModelEvent} from './events/ModelEvent'
|
||||
|
||||
/**
|
||||
* Base for classes that are mapped to tables in a database.
|
||||
*/
|
||||
export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>> {
|
||||
export abstract class Model extends LocalBus<ModelEvent> {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/**
|
||||
* The name of the connection this model should run through.
|
||||
* @type string
|
||||
@ -84,7 +88,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* Relations that should be eager-loaded by default.
|
||||
* @protected
|
||||
*/
|
||||
protected with: (keyof T)[] = []
|
||||
protected with: (keyof this)[] = []
|
||||
|
||||
/**
|
||||
* The original row fetched from the database.
|
||||
@ -92,18 +96,11 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
*/
|
||||
protected originalSourceRow?: QueryRow
|
||||
|
||||
/**
|
||||
* Database fields that should be run on the next save, even if the
|
||||
* fields are not mapped to members on the model.
|
||||
* @protected
|
||||
*/
|
||||
protected dirtySourceRow?: QueryRow
|
||||
|
||||
/**
|
||||
* 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>}>()
|
||||
public relationCache: Collection<{ accessor: string | symbol, relation: Relation<any, any, any> }> = new Collection<{accessor: string | symbol; relation: Relation<any, any, any>}>()
|
||||
|
||||
protected scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
|
||||
|
||||
@ -151,14 +148,13 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* const user = await UserModel.query<UserModel>().where('name', 'LIKE', 'John Doe').first()
|
||||
* ```
|
||||
*/
|
||||
public static query<T2 extends Model<T2>>(): ModelBuilder<T2> {
|
||||
const di = Container.getContainer()
|
||||
const builder = <ModelBuilder<T2>> di.make<ModelBuilder<T2>>(ModelBuilder, this)
|
||||
public static query<T2 extends Model>(this: StaticThis<T2, any[]> & typeof Model): ModelBuilder<T2> {
|
||||
const builder = <ModelBuilder<T2>> Container.getContainer().make<ModelBuilder<T2>>(ModelBuilder, this)
|
||||
const source: QuerySource = this.querySource()
|
||||
|
||||
builder.connection(this.getConnection())
|
||||
|
||||
if ( typeof source === 'string' || source instanceof QuerySafeValue ) {
|
||||
if ( typeof source === 'string' ) {
|
||||
builder.from(source)
|
||||
} else {
|
||||
builder.from(source.table, source.alias)
|
||||
@ -168,17 +164,36 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
builder.field(field.databaseKey)
|
||||
})
|
||||
|
||||
const inst = di.make<T2>(this)
|
||||
if ( Array.isArray(inst.with) ) {
|
||||
if ( Array.isArray(this.prototype.with) ) {
|
||||
// Try to get the eager-loaded relations statically, if possible
|
||||
for (const relation of inst.with) {
|
||||
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>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inst.applyScopes(builder)
|
||||
if ( this.prototype.scopes ) {
|
||||
// Same thing here. Try to get the scopes statically, if possible
|
||||
builder.withScopes(this.prototype.scopes)
|
||||
} else if ( this.constructor.length < 1 ) {
|
||||
// Otherwise, try to instantiate the model if possible and load the scopes that way
|
||||
const inst = Container.getContainer().make<Model>(this)
|
||||
builder.withScopes(inst.scopes)
|
||||
}
|
||||
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
@ -216,49 +231,6 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
}
|
||||
}
|
||||
|
||||
public getColumn(key: string): unknown {
|
||||
if ( this.dirtySourceRow && hasOwnProperty(this.dirtySourceRow, key) ) {
|
||||
return this.dirtySourceRow[key]
|
||||
}
|
||||
|
||||
const field = getFieldsMeta(this)
|
||||
.firstWhere('databaseKey', '=', key)
|
||||
|
||||
if ( field ) {
|
||||
return (this as any)[field.modelKey]
|
||||
}
|
||||
|
||||
return this.originalSourceRow?.[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the override row and (if applicable) associated class property
|
||||
* for the given database column.
|
||||
*
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
public setColumn(key: string, value: unknown): this {
|
||||
// Set the property on the database result row, if one exists
|
||||
if ( !this.dirtySourceRow ) {
|
||||
this.dirtySourceRow = {}
|
||||
}
|
||||
|
||||
this.dirtySourceRow[key] = value
|
||||
|
||||
// Set the property on the mapped field on the class, if one exists
|
||||
const field = getFieldsMeta(this)
|
||||
.firstWhere('databaseKey', '=', key)
|
||||
|
||||
if ( field ) {
|
||||
this.setFieldFromObject(field.modelKey, field.databaseKey, {
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a row from the database, set the properties on this model that correspond to
|
||||
* fields on that database.
|
||||
@ -275,7 +247,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
|
||||
})
|
||||
|
||||
await this.push(new ModelRetrievedEvent<T>(this as any))
|
||||
await this.push(new ModelRetrievedEvent<this>(this as any))
|
||||
return this
|
||||
}
|
||||
|
||||
@ -301,7 +273,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
/**
|
||||
* Get the value of the primary key of this model, if it exists.
|
||||
*/
|
||||
public key(): string|number {
|
||||
public key(): string {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
const field = getFieldsMeta(this)
|
||||
@ -357,14 +329,14 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* .update({ username: 'jdoe' })
|
||||
* ```
|
||||
*/
|
||||
public query(): ModelBuilder<T> {
|
||||
public query(): ModelBuilder<this> {
|
||||
const ModelClass = this.constructor as typeof Model
|
||||
const builder = <ModelBuilder<T>> this.app().make<ModelBuilder<T>>(ModelBuilder, ModelClass)
|
||||
const builder = <ModelBuilder<this>> this.app().make<ModelBuilder<this>>(ModelBuilder, ModelClass)
|
||||
const source: QuerySource = ModelClass.querySource()
|
||||
|
||||
builder.connection(ModelClass.getConnection())
|
||||
|
||||
if ( typeof source === 'string' || source instanceof QuerySafeValue ) {
|
||||
if ( typeof source === 'string' ) {
|
||||
builder.from(source)
|
||||
} else {
|
||||
builder.from(source.table, source.alias)
|
||||
@ -378,7 +350,17 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
builder.with(relation)
|
||||
}
|
||||
|
||||
this.applyScopes(builder)
|
||||
builder.withScopes(this.scopes)
|
||||
|
||||
return this.newBuilderInstance(builder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure ModelBuilder instances that query this model.
|
||||
* @param builder
|
||||
* @protected
|
||||
*/
|
||||
protected newBuilderInstance(builder: ModelBuilder<this>): ModelBuilder<this> {
|
||||
return builder
|
||||
}
|
||||
|
||||
@ -392,7 +374,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
public static async findByKey<T2 extends Model<T2>>(key: ModelKey): Promise<undefined | T2> {
|
||||
public static async findByKey<T2 extends Model>(this: StaticThis<T2, any[]> & typeof Model, key: ModelKey): Promise<undefined | T2> {
|
||||
return this.query<T2>()
|
||||
.where(this.qualifyKey(), '=', key)
|
||||
.limit(1)
|
||||
@ -403,7 +385,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
/**
|
||||
* Get an array of all instances of this model.
|
||||
*/
|
||||
public async all(): Promise<T[]> {
|
||||
public async all(): Promise<this[]> {
|
||||
return this.query().get()
|
||||
.all()
|
||||
}
|
||||
@ -489,7 +471,6 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* @param modelKey
|
||||
*/
|
||||
public static propertyToColumn(modelKey: string): string {
|
||||
console.log('propertyToColumn', modelKey, getFieldsMeta(this), this) // eslint-disable-line no-console
|
||||
return getFieldsMeta(this)
|
||||
.firstWhere('modelKey', '=', modelKey)?.databaseKey || modelKey
|
||||
}
|
||||
@ -532,10 +513,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
row[field.databaseKey] = (this as any)[field.modelKey]
|
||||
})
|
||||
|
||||
return {
|
||||
...row,
|
||||
...(this.dirtySourceRow || {}),
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
@ -647,12 +625,12 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
*
|
||||
* @param withoutTimestamps
|
||||
*/
|
||||
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
|
||||
await this.push(new ModelSavingEvent<T>(this as any))
|
||||
public async save({ withoutTimestamps = false } = {}): Promise<this> {
|
||||
await this.push(new ModelSavingEvent<this>(this))
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
if ( this.exists() && this.isDirty() ) {
|
||||
await this.push(new ModelUpdatingEvent<T>(this as any))
|
||||
await this.push(new ModelUpdatingEvent<this>(this))
|
||||
|
||||
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
|
||||
(this as any)[ctor.UPDATED_AT] = new Date()
|
||||
@ -669,15 +647,13 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
}
|
||||
|
||||
const data = result.rows.firstWhere(this.keyName(), '=', this.key())
|
||||
this.logging.debug({updata: data})
|
||||
if ( data ) {
|
||||
await this.assumeFromSource(data)
|
||||
}
|
||||
|
||||
delete this.dirtySourceRow
|
||||
await this.push(new ModelUpdatedEvent<T>(this as any))
|
||||
await this.push(new ModelUpdatedEvent<this>(this))
|
||||
} else if ( !this.exists() ) {
|
||||
await this.push(new ModelCreatingEvent<T>(this as any))
|
||||
await this.push(new ModelCreatingEvent<this>(this))
|
||||
|
||||
if ( !withoutTimestamps ) {
|
||||
if ( ctor.timestamps && ctor.CREATED_AT ) {
|
||||
@ -704,16 +680,14 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
}
|
||||
|
||||
const data = result.rows.first()
|
||||
this.logging.debug({inserta: data})
|
||||
if ( data ) {
|
||||
await this.assumeFromSource(data)
|
||||
}
|
||||
|
||||
delete this.dirtySourceRow
|
||||
await this.push(new ModelCreatedEvent<T>(this as any))
|
||||
await this.push(new ModelCreatedEvent<this>(this))
|
||||
}
|
||||
|
||||
await this.push(new ModelSavedEvent<T>(this as any))
|
||||
await this.push(new ModelSavedEvent<this>(this))
|
||||
return this
|
||||
}
|
||||
|
||||
@ -780,7 +754,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* This returns a NEW instance of the SAME record by matching on
|
||||
* the primary key. It does NOT change the current instance of the record.
|
||||
*/
|
||||
public async fresh(): Promise<Model<T> | undefined> {
|
||||
public async fresh(): Promise<this | undefined> {
|
||||
return this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.limit(1)
|
||||
@ -824,7 +798,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
*
|
||||
* @param model
|
||||
*/
|
||||
public async populate(model: T): Promise<T> {
|
||||
public async populate(model: this): Promise<this> {
|
||||
const row = this.toQueryRow()
|
||||
delete row[this.keyName()]
|
||||
await model.assumeFromSource(row)
|
||||
@ -838,7 +812,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
*
|
||||
* @param other
|
||||
*/
|
||||
public is(other: Model<any>): boolean {
|
||||
public is(other: Model): boolean {
|
||||
return this.key() === other.key() && this.qualifyKey() === other.qualifyKey()
|
||||
}
|
||||
|
||||
@ -846,7 +820,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* Inverse of `is()`.
|
||||
* @param other
|
||||
*/
|
||||
public isNot(other: Model<any>): boolean {
|
||||
public isNot(other: Model): boolean {
|
||||
return !this.is(other)
|
||||
}
|
||||
|
||||
@ -860,14 +834,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
*/
|
||||
protected get isDirtyCheck(): (field: ModelField) => boolean {
|
||||
return (field: ModelField) => {
|
||||
return Boolean(
|
||||
!this.originalSourceRow
|
||||
|| (this as any)[field.modelKey] !== this.originalSourceRow[field.databaseKey]
|
||||
|| (
|
||||
this.dirtySourceRow
|
||||
&& hasOwnProperty(this.dirtySourceRow, field.databaseKey)
|
||||
),
|
||||
)
|
||||
return !this.originalSourceRow || (this as any)[field.modelKey] !== this.originalSourceRow[field.databaseKey]
|
||||
}
|
||||
}
|
||||
|
||||
@ -892,17 +859,12 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
|
||||
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
|
||||
|
||||
const row = Pipeline.id<Collection<ModelField>>()
|
||||
return Pipeline.id<Collection<ModelField>>()
|
||||
.unless(ctor.populateKeyOnInsert, fields => {
|
||||
return fields.where('databaseKey', '!=', this.keyName())
|
||||
})
|
||||
.apply(getFieldsMeta(this))
|
||||
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
||||
|
||||
return {
|
||||
...row,
|
||||
...(this.dirtySourceRow || {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -933,8 +895,8 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* @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)
|
||||
public hasOne<T2 extends Model>(related: Instantiable<T2>, foreignKeyOverride?: keyof this & string, localKeyOverride?: keyof T2 & string): HasOne<this, T2> {
|
||||
return new HasOne<this, T2>(this, this.make(related), foreignKeyOverride, localKeyOverride)
|
||||
}
|
||||
|
||||
|
||||
@ -955,8 +917,8 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* @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)
|
||||
public hasMany<T2 extends Model>(related: Instantiable<T2>, foreignKeyOverride?: keyof this & string, localKeyOverride?: keyof T2 & string): HasMany<this, T2> {
|
||||
return new HasMany<this, T2>(this, this.make(related), foreignKeyOverride, localKeyOverride)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -982,7 +944,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* @param related
|
||||
* @param relationName
|
||||
*/
|
||||
public belongsToOne<T2 extends Model<T2>>(related: Instantiable<T2>, relationName: keyof T2): HasOne<T, T2> {
|
||||
public belongsToOne<T2 extends Model>(related: Instantiable<T2>, relationName: keyof T2): HasOne<this, T2> {
|
||||
const relatedInst = this.make(related) as T2
|
||||
const relation = relatedInst.getRelation(relationName)
|
||||
|
||||
@ -993,11 +955,11 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
const localKey = relation.localKey
|
||||
const foreignKey = relation.foreignKey
|
||||
|
||||
if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) {
|
||||
if ( !isKeyof(localKey, this) || !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)
|
||||
return new HasOne(this, relatedInst, localKey, foreignKey)
|
||||
}
|
||||
|
||||
|
||||
@ -1024,7 +986,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* @param related
|
||||
* @param relationName
|
||||
*/
|
||||
public belongsToMany<T2 extends Model<T2>>(related: Instantiable<T>, relationName: keyof T2): HasMany<T, T2> {
|
||||
public belongsToMany<T2 extends Model>(related: Instantiable<T2>, relationName: keyof T2): HasMany<this, T2> {
|
||||
const relatedInst = this.make(related) as T2
|
||||
const relation = relatedInst.getRelation(relationName)
|
||||
|
||||
@ -1035,11 +997,11 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
const localKey = relation.localKey
|
||||
const foreignKey = relation.foreignKey
|
||||
|
||||
if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) {
|
||||
if ( !isKeyof(localKey, this) || !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)
|
||||
return new HasMany(this, relatedInst, localKey, foreignKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1047,7 +1009,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
* @param name
|
||||
* @protected
|
||||
*/
|
||||
public getRelation<T2 extends Model<T2>>(name: keyof this): Relation<T, T2, RelationValue<T2>> {
|
||||
public getRelation<T2 extends Model>(name: keyof this): Relation<this, T2, RelationValue<T2>> {
|
||||
const relFn = this[name]
|
||||
|
||||
if ( relFn instanceof Relation ) {
|
||||
@ -1055,13 +1017,13 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
}
|
||||
|
||||
if ( typeof relFn === 'function' ) {
|
||||
const rel = relFn.bind(this)()
|
||||
const rel = relFn.apply(relFn, this)
|
||||
if ( rel instanceof Relation ) {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
|
||||
throw new TypeError(`Cannot get relation of name: ${String(name)}. Method does not return a Relation.`)
|
||||
throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1119,12 +1081,4 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
||||
protected hasScope(name: string | Instantiable<Scope>): boolean {
|
||||
return Boolean(this.scopes.firstWhere('accessor', '=', name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the default scopes to this model to the given query builder.
|
||||
* @param builder
|
||||
*/
|
||||
public applyScopes(builder: ModelBuilder<T>): void {
|
||||
builder.withScopes(this.scopes)
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import {Scope, ScopeClosure} from './scope/Scope'
|
||||
/**
|
||||
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||
*/
|
||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
export class ModelBuilder<T extends Model> extends AbstractBuilder<T> {
|
||||
protected eagerLoadRelations: (keyof T)[] = []
|
||||
|
||||
protected appliedScopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
|
||||
@ -78,7 +78,7 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
*/
|
||||
public with(relationName: keyof T): this {
|
||||
if ( !this.eagerLoadRelations.includes(relationName) ) {
|
||||
// Try to load the Relation, so we fail if the name is invalid
|
||||
// Try to load the Relation so we fail if the name is invalid
|
||||
this.make<T>(this.ModelClass).getRelation(relationName)
|
||||
this.eagerLoadRelations.push(relationName)
|
||||
}
|
||||
@ -86,15 +86,6 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent a relation from being eager-loaded.
|
||||
* @param relationName
|
||||
*/
|
||||
public without(relationName: keyof T): this {
|
||||
this.eagerLoadRelations = this.eagerLoadRelations.filter(name => name !== relationName)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all global scopes from this query.
|
||||
*/
|
||||
|
@ -2,15 +2,14 @@ import {Model} from './Model'
|
||||
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
||||
import {Connection} from '../connection/Connection'
|
||||
import {ModelBuilder} from './ModelBuilder'
|
||||
import {Instantiable} from '../../di'
|
||||
import {Container, Instantiable} from '../../di'
|
||||
import {QueryRow} from '../types'
|
||||
import {collect, Collection} from '../../util'
|
||||
import {getFieldsMeta} from './Field'
|
||||
|
||||
/**
|
||||
* Implementation of the result iterable that returns query results as instances of the defined model.
|
||||
*/
|
||||
export class ModelResultIterable<T extends Model<T>> extends AbstractResultIterable<T> {
|
||||
export class ModelResultIterable<T extends Model> extends AbstractResultIterable<T> {
|
||||
constructor(
|
||||
public readonly builder: ModelBuilder<T>,
|
||||
public readonly connection: Connection,
|
||||
@ -61,11 +60,8 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
|
||||
* @protected
|
||||
*/
|
||||
protected async inflateRow(row: QueryRow): Promise<T> {
|
||||
const model = this.make<T>(this.ModelClass)
|
||||
const fields = getFieldsMeta(model)
|
||||
return model.assumeFromSource(
|
||||
this.connection.normalizeRow(row, fields),
|
||||
)
|
||||
return Container.getContainer().make<T>(this.ModelClass)
|
||||
.assumeFromSource(row)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,11 +70,6 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
|
||||
* @protected
|
||||
*/
|
||||
protected async processEagerLoads(results: Collection<T>): Promise<void> {
|
||||
if ( results.isEmpty() ) {
|
||||
// Nothing to load relations for, so no reason to perform more queries
|
||||
return
|
||||
}
|
||||
|
||||
const eagers = this.builder.getEagerLoadedRelations()
|
||||
const model = this.make<T>(this.ModelClass)
|
||||
|
||||
@ -87,10 +78,9 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
|
||||
|
||||
const relation = model.getRelation(name)
|
||||
const select = relation.buildEagerQuery(this.builder, results)
|
||||
const resultCount = await select.get().count()
|
||||
|
||||
const allRelated = resultCount ? await select.get().collect() : collect()
|
||||
results.each(result => {
|
||||
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)
|
||||
|
@ -13,19 +13,19 @@ export interface ModelSerialPayload extends JSONState {
|
||||
|
||||
@ObjectSerializer()
|
||||
@Injectable()
|
||||
export class ModelSerializer extends BaseSerializer<Model<any>, ModelSerialPayload> {
|
||||
export class ModelSerializer extends BaseSerializer<Model, ModelSerialPayload> {
|
||||
@Inject()
|
||||
protected readonly canon!: Canon
|
||||
|
||||
protected async decodeSerial(serial: ModelSerialPayload): Promise<Model<any>> {
|
||||
protected async decodeSerial(serial: ModelSerialPayload): Promise<Model> {
|
||||
const ModelClass = this.canon.getFromFullyQualified(serial.canonicalResolver) as typeof Model
|
||||
if ( !ModelClass || !(ModelClass.prototype instanceof Model) || !isInstantiable<Model<any>>(ModelClass) ) {
|
||||
if ( !ModelClass || !(ModelClass.prototype instanceof Model) || !isInstantiable<Model>(ModelClass) ) {
|
||||
throw new ErrorWithContext('Cannot decode serialized model as canonical resolver is invalid', {
|
||||
serial,
|
||||
})
|
||||
}
|
||||
|
||||
let inst: Maybe<Model<any>> = this.make<Model<any>>(ModelClass)
|
||||
let inst: Maybe<Model> = this.make<Model>(ModelClass)
|
||||
if ( serial.primaryKey ) {
|
||||
inst = await ModelClass.query()
|
||||
.whereKey(serial.primaryKey)
|
||||
@ -42,7 +42,7 @@ export class ModelSerializer extends BaseSerializer<Model<any>, ModelSerialPaylo
|
||||
return inst
|
||||
}
|
||||
|
||||
protected encodeActual(actual: Model<any>): Awaitable<ModelSerialPayload> {
|
||||
protected encodeActual(actual: Model): Awaitable<ModelSerialPayload> {
|
||||
const ctor = actual.constructor as typeof Model
|
||||
const canonicalResolver = ctor.getFullyQualifiedCanonicalResolver()
|
||||
if ( !canonicalResolver ) {
|
||||
@ -62,7 +62,7 @@ export class ModelSerializer extends BaseSerializer<Model<any>, ModelSerialPaylo
|
||||
return '@extollo/lib.ModelSerializer'
|
||||
}
|
||||
|
||||
matchActual(some: Model<any>): boolean {
|
||||
matchActual(some: Model): boolean {
|
||||
return some instanceof Model
|
||||
}
|
||||
}
|
||||
|
@ -1,287 +0,0 @@
|
||||
import {Model} from './Model'
|
||||
import {collect, Collection, ErrorWithContext, Maybe} from '../../util'
|
||||
import {HasSubtree} from './relation/HasSubtree'
|
||||
import {Related} from './relation/decorators'
|
||||
import {HasTreeParent} from './relation/HasTreeParent'
|
||||
import {ModelBuilder} from './ModelBuilder'
|
||||
|
||||
/**
|
||||
* Model implementation with helpers for querying tree-structured data.
|
||||
*
|
||||
* This works by using a modified pre-order traversal to number the tree nodes
|
||||
* with a left- and right-side numbers. For example:
|
||||
*
|
||||
* ```txt
|
||||
* (1) A (14)
|
||||
* |
|
||||
* (2) B (9) (10) C (11) (12) D (14)
|
||||
* |
|
||||
* (3) E (6) (7) G (8)
|
||||
* |
|
||||
* (4) F (5)
|
||||
* ```
|
||||
*
|
||||
* These numbers are stored, by default, in `left_num` and `right_num` columns.
|
||||
* The `subtree()` method returns a `HasSubtree` relation which loads the subtree
|
||||
* of a model and recursively nests the nodes.
|
||||
*
|
||||
* You can use the `children()` helper method to get a collection of the immediate
|
||||
* children of this node, which also have the subtree set.
|
||||
*
|
||||
* To query the model without loading the entire subtree, use the `without()`
|
||||
* method on the `ModelBuilder`. For example:
|
||||
*
|
||||
* ```ts
|
||||
* MyModel.query<MyModel>().without('subtree')
|
||||
* ```
|
||||
*/
|
||||
export abstract class TreeModel<T extends TreeModel<T>> extends Model<T> {
|
||||
|
||||
/** The table column where the left tree number is stored. */
|
||||
public static readonly leftTreeField = 'left_num'
|
||||
|
||||
/** The table column where the right tree number is stored. */
|
||||
public static readonly rightTreeField = 'right_num'
|
||||
|
||||
public static readonly parentIdField = 'parent_id'
|
||||
|
||||
/** @override to include the tree fields */
|
||||
public static query<T2 extends Model<T2>>(): ModelBuilder<T2> {
|
||||
return super.query<T2>()
|
||||
.fields(this.rightTreeField, this.leftTreeField, this.parentIdField)
|
||||
}
|
||||
|
||||
/**
|
||||
* @override to eager-load the subtree by default
|
||||
* @protected
|
||||
*/
|
||||
protected with: (keyof T)[] = ['subtree']
|
||||
|
||||
protected removedChildren: Collection<TreeModel<T>> = collect()
|
||||
|
||||
/** @override to include the tree fields */
|
||||
public query(): ModelBuilder<T> {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
return super.query()
|
||||
.fields(ctor.leftTreeField, ctor.rightTreeField, ctor.parentIdField)
|
||||
}
|
||||
|
||||
/** Get the left tree number for this model. */
|
||||
public leftTreeNum(): Maybe<number> {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
return this.getColumn(ctor.leftTreeField) as number
|
||||
}
|
||||
|
||||
/** Get the right tree number for this model. */
|
||||
public rightTreeNum(): Maybe<number> {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
return this.getColumn(ctor.rightTreeField) as number
|
||||
}
|
||||
|
||||
/** Get the ID of this node's parent, if one exists. */
|
||||
public parentId(): Maybe<number> {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
return this.getColumn(ctor.parentIdField) as number
|
||||
}
|
||||
|
||||
/** Returns true if this node has no children. */
|
||||
public isLeaf(): boolean {
|
||||
const left = this.leftTreeNum()
|
||||
const right = this.rightTreeNum()
|
||||
return Boolean(left && right && (right - left === 1))
|
||||
}
|
||||
|
||||
/** Returns true if the given `node` exists within the subtree of this node. */
|
||||
public contains(node: TreeModel<T>): boolean {
|
||||
const num = node.leftTreeNum()
|
||||
const left = this.leftTreeNum()
|
||||
const right = this.rightTreeNum()
|
||||
return Boolean(num && left && right && (left < num && right > num))
|
||||
}
|
||||
|
||||
/** The subtree nodes of this model, recursively nested. */
|
||||
@Related()
|
||||
public subtree(): HasSubtree<T> {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
return this.make<HasSubtree<T>>(HasSubtree, this, ctor.leftTreeField)
|
||||
}
|
||||
|
||||
/** The parent node of this model, if one exists. */
|
||||
@Related()
|
||||
public parentNode(): HasTreeParent<T> {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
return this.make<HasTreeParent<T>>(HasTreeParent, this, ctor.leftTreeField, ctor.parentIdField)
|
||||
}
|
||||
|
||||
/** Get the immediate children of this model. */
|
||||
public children(): Collection<T> {
|
||||
return this.subtree().getValue()
|
||||
}
|
||||
|
||||
/** Get the parent of this model, if one exists. */
|
||||
public parent(): Maybe<T> {
|
||||
return this.parentNode().getValue()
|
||||
}
|
||||
|
||||
public root(): Maybe<TreeModel<T>> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
let parent: Maybe<TreeModel<T>> = this
|
||||
while ( parent?.parent() ) {
|
||||
parent = parent?.parent()
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
public rootOrFail(): TreeModel<T> {
|
||||
const root = this.root()
|
||||
if ( !root ) {
|
||||
throw new ErrorWithContext('Unable to determine tree root', {
|
||||
node: this,
|
||||
})
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Get the nearest node in the parental line that is an ancestor of both this node and the `other` node. */
|
||||
public commonAncestorWith(other: TreeModel<T>): Maybe<TreeModel<T>> {
|
||||
if ( this.contains(other) ) {
|
||||
// Handle the special case when this node is an ancestor of the other.
|
||||
return this
|
||||
}
|
||||
|
||||
if ( other.contains(this) ) {
|
||||
// Handle the special case when this node is a descendant of the other.
|
||||
return other
|
||||
}
|
||||
|
||||
// Otherwise, walk up this node's ancestral line and try to find a shared ancestor.
|
||||
// It's getting too anthropological up in here.
|
||||
let parent = this.parent()
|
||||
while ( parent ) {
|
||||
if ( parent.contains(other) ) {
|
||||
return parent
|
||||
}
|
||||
|
||||
parent = parent.parent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preorder traversal numbering for this node and its subtree.
|
||||
* After renumbering, you probably also want to save the subtree nodes to persist
|
||||
* the tree into the database.
|
||||
* @example
|
||||
* ```ts
|
||||
* await model.renumber().saveSubtree()
|
||||
* ```
|
||||
*/
|
||||
public renumber(): this {
|
||||
// Assume our leftTreeNum is -correct-.
|
||||
// Given that, renumber the children recursively, then set our rightTreeNum
|
||||
const myLeftNum = this.leftTreeNum()
|
||||
if ( !myLeftNum ) {
|
||||
return this
|
||||
}
|
||||
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
let nextChildLeftNum = myLeftNum + 1
|
||||
let myRightNum = myLeftNum + 1
|
||||
|
||||
this.children()
|
||||
.each(child => {
|
||||
child.setColumn(ctor.leftTreeField, nextChildLeftNum)
|
||||
child.setColumn(ctor.parentIdField, this.key())
|
||||
child.parentNode().setValue(this as unknown as T)
|
||||
child.renumber()
|
||||
myRightNum = (child.rightTreeNum() ?? 0) + 1
|
||||
nextChildLeftNum = (child.rightTreeNum() ?? 0) + 1
|
||||
})
|
||||
|
||||
this.setColumn(ctor.rightTreeField, myRightNum)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Get a flat collection of this node & its subtree nodes, in pre-order. */
|
||||
public flatten(): Collection<TreeModel<T>> {
|
||||
return this.flattenLevel()
|
||||
}
|
||||
|
||||
/** Recursive helper for `flatten()`. */
|
||||
private flattenLevel(topLevel = true): Collection<TreeModel<T>> {
|
||||
const flat = collect<TreeModel<T>>(topLevel ? [this] : [])
|
||||
this.children().each(child => {
|
||||
flat.push(child)
|
||||
flat.concat(child.flattenLevel(false))
|
||||
})
|
||||
return flat
|
||||
}
|
||||
|
||||
/** Call the `save()` method on all nodes in this node & its subtree. */
|
||||
public async saveSubtree(): Promise<void> {
|
||||
await this.save()
|
||||
await this.removedChildren.awaitMapCall('saveSubtree')
|
||||
this.removedChildren = collect()
|
||||
await this.children()
|
||||
.map(child => child.saveSubtree())
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the `other` node as a child of this node.
|
||||
* Returns the common ancestor from which point the tree was renumbered.
|
||||
* You should save the subtree after this.
|
||||
* @example
|
||||
* ```ts
|
||||
* await nodeA.appendChild(nodeB).saveSubtree()
|
||||
* ```
|
||||
* @param other
|
||||
* @param before - if provided, `other` will be inserted before the `before` child of this node. Otherwise, it will be added as the last child.
|
||||
*/
|
||||
public appendChild(other: T, before: Maybe<T> = undefined): TreeModel<T> {
|
||||
// Determine the index where we should insert the node in our children
|
||||
const idx = (before ? this.children().search(before) : undefined) ?? this.children().length
|
||||
|
||||
// Insert the child at that index
|
||||
this.subtree().appendSubtree(idx, other)
|
||||
|
||||
// If the child has a parent:
|
||||
const parent = other.parent()
|
||||
if ( parent ) {
|
||||
// Remove the child from the parent's children
|
||||
parent.subtree().removeSubtree(other)
|
||||
|
||||
// Get the common ancestor of the child's parent and this node
|
||||
const ancestor = this.commonAncestorWith(other)
|
||||
if ( ancestor ) {
|
||||
// Renumber from that ancestor
|
||||
return ancestor.renumber()
|
||||
}
|
||||
}
|
||||
|
||||
// If the child has no parent or a common ancestor could not be found,
|
||||
// renumber the entire tree since the total number of nodes has changed
|
||||
return this.rootOrFail().renumber()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this node from the tree structure.
|
||||
* If the node existed in the tree structure and was removed, this method
|
||||
* returns the tree node that was renumbered. You should save the subtree after this.
|
||||
* @example
|
||||
* ```ts
|
||||
* await nodeA.removeFromTree().saveSubtree()
|
||||
* ```
|
||||
*/
|
||||
public removeFromTree(): Maybe<TreeModel<T>> {
|
||||
const parent = this.parent()
|
||||
if ( parent ) {
|
||||
const ctor = this.constructor as typeof TreeModel
|
||||
parent.subtree().removeSubtree(this)
|
||||
parent.removedChildren.push(this)
|
||||
this.setColumn(ctor.leftTreeField, null)
|
||||
this.setColumn(ctor.rightTreeField, null)
|
||||
this.setColumn(ctor.parentIdField, null)
|
||||
return this.rootOrFail().renumber()
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right after a model is inserted.
|
||||
*/
|
||||
export class ModelCreatedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelCreatedEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelCreatedEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right before a model is inserted.
|
||||
*/
|
||||
export class ModelCreatingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelCreatingEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelCreatingEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right after a model is deleted.
|
||||
*/
|
||||
export class ModelDeletedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelDeletedEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelDeletedEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right before a model is deleted.
|
||||
*/
|
||||
export class ModelDeletingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelDeletingEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelDeletingEvent'
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import {Awaitable} from '../../../util'
|
||||
* Base class for events that concern an instance of a model.
|
||||
* @fixme support serialization
|
||||
*/
|
||||
export abstract class ModelEvent<T extends Model<T>> extends BaseEvent {
|
||||
export abstract class ModelEvent<T extends Model = Model> extends BaseEvent {
|
||||
constructor(
|
||||
public readonly instance: T,
|
||||
) {
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right after a model's data is loaded from the source.
|
||||
*/
|
||||
export class ModelRetrievedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelRetrievedEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelRetrievedEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right after a model is persisted to the source.
|
||||
*/
|
||||
export class ModelSavedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelSavedEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelSavedEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right before a model is persisted to the source.
|
||||
*/
|
||||
export class ModelSavingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelSavingEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelSavingEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right after a model's data is updated.
|
||||
*/
|
||||
export class ModelUpdatedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelUpdatedEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelUpdatedEvent'
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ import {ModelEvent} from './ModelEvent'
|
||||
/**
|
||||
* Event fired right before a model's data is updated.
|
||||
*/
|
||||
export class ModelUpdatingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
export class ModelUpdatingEvent<T extends Model> extends ModelEvent<T> {
|
||||
eventName = '@extollo/lib.ModelUpdatingEvent'
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ 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>> {
|
||||
export class HasMany<T extends Model, T2 extends Model> extends HasOneOrMany<T, T2, Collection<T2>> {
|
||||
protected cachedValue?: Collection<T2>
|
||||
|
||||
protected cachedLoaded = false
|
||||
|
@ -6,7 +6,7 @@ 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>> {
|
||||
export class HasOne<T extends Model, T2 extends Model> extends HasOneOrMany<T, T2, Maybe<T2>> {
|
||||
protected cachedValue?: T2
|
||||
|
||||
protected cachedLoaded = false
|
||||
|
@ -9,7 +9,7 @@ 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> {
|
||||
export abstract class HasOneOrMany<T extends Model, T2 extends Model, V extends RelationValue<T2>> extends Relation<T, T2, V> {
|
||||
protected constructor(
|
||||
parent: T,
|
||||
related: T2,
|
||||
@ -33,24 +33,14 @@ export abstract class HasOneOrMany<T extends Model<T>, T2 extends Model<T2>, V e
|
||||
return this.localKeyOverride || this.foreignKey
|
||||
}
|
||||
|
||||
public get foreignColumn(): string {
|
||||
const ctor = this.related.constructor as typeof Model
|
||||
return ctor.propertyToColumn(this.foreignKey)
|
||||
}
|
||||
|
||||
public get localColumn(): string {
|
||||
const ctor = this.related.constructor as typeof Model
|
||||
return ctor.propertyToColumn(this.localKey)
|
||||
}
|
||||
|
||||
/** Get the fully-qualified name of the foreign key. */
|
||||
public get qualifiedForeignKey(): string {
|
||||
return this.related.qualify(this.foreignColumn)
|
||||
return this.related.qualify(this.foreignKey)
|
||||
}
|
||||
|
||||
/** Get the fully-qualified name of the local key. */
|
||||
public get qualifiedLocalKey(): string {
|
||||
return this.related.qualify(this.localColumn)
|
||||
return this.related.qualify(this.localKey)
|
||||
}
|
||||
|
||||
/** Get the value of the pivot for this relation from the parent model. */
|
||||
@ -69,7 +59,7 @@ export abstract class HasOneOrMany<T extends Model<T>, T2 extends Model<T2>, V e
|
||||
public applyScope(where: AbstractBuilder<T2>): void {
|
||||
where.where(subq => {
|
||||
subq.where(this.qualifiedForeignKey, '=', this.parentValue)
|
||||
.whereNotNull(this.qualifiedForeignKey)
|
||||
.whereRaw(this.qualifiedForeignKey, 'IS NOT', 'NULL')
|
||||
})
|
||||
}
|
||||
|
||||
@ -80,11 +70,11 @@ export abstract class HasOneOrMany<T extends Model<T>, T2 extends Model<T2>, V e
|
||||
.all()
|
||||
|
||||
return this.related.query()
|
||||
.whereIn(this.foreignColumn, keys)
|
||||
.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.foreignColumn as keyof T, '=', this.parentValue)
|
||||
return possiblyRelated.where(this.foreignKey as keyof T, '=', this.parentValue)
|
||||
}
|
||||
}
|
||||
|
@ -1,191 +0,0 @@
|
||||
import {TreeModel} from '../TreeModel'
|
||||
import {Relation, RelationNotLoadedError} from './Relation'
|
||||
import {collect, Collection, Maybe} from '../../../util'
|
||||
import {RelationBuilder} from './RelationBuilder'
|
||||
import {raw} from '../../dialect/SQLDialect'
|
||||
import {AbstractBuilder} from '../../builder/AbstractBuilder'
|
||||
import {ModelBuilder} from '../ModelBuilder'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
|
||||
/**
|
||||
* A relation that recursively loads the subtree of a model using
|
||||
* modified preorder traversal.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collection<T>> {
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/**
|
||||
* When the relation is loaded, the immediate children of the node.
|
||||
* @protected
|
||||
*/
|
||||
protected instances: Maybe<Collection<T>>
|
||||
|
||||
constructor(
|
||||
protected readonly model: T,
|
||||
protected readonly leftTreeField: string,
|
||||
) {
|
||||
super(model, model)
|
||||
}
|
||||
|
||||
public flatten(): Collection<T> {
|
||||
const children = this.getValue()
|
||||
const subtrees = children.reduce((subtree, child) => subtree.concat(child.subtree().flatten()), collect())
|
||||
return children.concat(subtrees)
|
||||
}
|
||||
|
||||
/** Manually load the subtree. */
|
||||
public async load(): Promise<void> {
|
||||
this.setValue(await this.get())
|
||||
}
|
||||
|
||||
protected get parentValue(): any {
|
||||
return this.model.key()
|
||||
}
|
||||
|
||||
public query(): RelationBuilder<T> {
|
||||
return this.builder()
|
||||
.tap(b => this.model.applyScopes(b))
|
||||
.select(raw('*'))
|
||||
.orderByAscending(this.leftTreeField)
|
||||
}
|
||||
|
||||
public applyScope(where: AbstractBuilder<T>): void {
|
||||
const left = this.model.leftTreeNum()
|
||||
const right = this.model.rightTreeNum()
|
||||
if ( !left || !right ) {
|
||||
where.whereMatchNone()
|
||||
return
|
||||
}
|
||||
|
||||
where.where(this.leftTreeField, '>', left)
|
||||
.where(this.leftTreeField, '<', right)
|
||||
}
|
||||
|
||||
public buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T> {
|
||||
const query = this.model.query().without('subtree')
|
||||
|
||||
this.logging.debug(`Building eager query for parent: ${parentQuery}`)
|
||||
this.logging.debug(result)
|
||||
|
||||
if ( result.isEmpty() ) {
|
||||
return query.whereMatchNone()
|
||||
}
|
||||
|
||||
result.each(inst => {
|
||||
const left = inst.leftTreeNum()
|
||||
const right = inst.rightTreeNum()
|
||||
if ( !left || !right ) {
|
||||
return
|
||||
}
|
||||
|
||||
query.orWhere(where => {
|
||||
where.where(this.leftTreeField, '>', left)
|
||||
.where(this.leftTreeField, '<', right)
|
||||
})
|
||||
})
|
||||
|
||||
this.logging.debug(`Built eager query: ${query}`)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
public matchResults(possiblyRelated: Collection<T>): Collection<T> {
|
||||
this.logging.debug('Matching possibly related: ' + possiblyRelated.length)
|
||||
this.logging.verbose(possiblyRelated)
|
||||
const modelLeft = this.model.leftTreeNum()
|
||||
const modelRight = this.model.rightTreeNum()
|
||||
if ( !modelLeft || !modelRight ) {
|
||||
return collect()
|
||||
}
|
||||
|
||||
return possiblyRelated.filter(inst => {
|
||||
const instLeft = inst.leftTreeNum()
|
||||
return Boolean(instLeft && instLeft > modelLeft && instLeft < modelRight)
|
||||
})
|
||||
}
|
||||
|
||||
public appendSubtree(idx: number, subtree: T): void {
|
||||
if ( !this.instances ) {
|
||||
throw new RelationNotLoadedError()
|
||||
}
|
||||
|
||||
this.instances = this.instances.put(idx, subtree)
|
||||
}
|
||||
|
||||
public removeSubtree(subtree: TreeModel<T>): void {
|
||||
if ( !this.instances ) {
|
||||
throw new RelationNotLoadedError()
|
||||
}
|
||||
|
||||
this.instances = this.instances.filter(x => x.isNot(subtree))
|
||||
}
|
||||
|
||||
public setValue(related: Collection<T>): void {
|
||||
// `related` contains a flat collection of the subtree nodes, ordered by left key ascending
|
||||
// We will loop through the related nodes and recursively call `setValue` for our immediate
|
||||
// children to build the tree.
|
||||
|
||||
type ReduceState = {
|
||||
currentChild: T,
|
||||
currentSubtree: Collection<T>,
|
||||
}
|
||||
|
||||
const children = this.instances = collect()
|
||||
const firstChild = related.pop()
|
||||
if ( !firstChild ) {
|
||||
return
|
||||
}
|
||||
|
||||
const finalState = related.reduce<ReduceState>((state: ReduceState, node: T) => {
|
||||
if ( state.currentChild.contains(node) ) {
|
||||
// `node` belongs in the subtree of `currentChild`, not this node
|
||||
state.currentSubtree.push(node)
|
||||
return state
|
||||
}
|
||||
|
||||
// We've hit the end of the subtree for `currentChild`, so set the child's
|
||||
// subtree relation value and move on to the next child.
|
||||
state.currentChild.subtree().setValue(state.currentSubtree)
|
||||
children.push(state.currentChild)
|
||||
|
||||
return {
|
||||
currentChild: node,
|
||||
currentSubtree: collect(),
|
||||
}
|
||||
}, {
|
||||
currentChild: firstChild,
|
||||
currentSubtree: collect(),
|
||||
})
|
||||
|
||||
// Do this one last time, since the reducer isn't called for the last node in the collection
|
||||
if ( finalState ) {
|
||||
finalState.currentChild.subtree().setValue(finalState.currentSubtree)
|
||||
children.push(finalState.currentChild)
|
||||
}
|
||||
|
||||
// Set the parent relation on the immediate children we identified
|
||||
children.each(child => child.parentNode().setValue(this.model))
|
||||
|
||||
this.instances = children.sortBy(inst => inst.getOriginalValues()?.[this.leftTreeField])
|
||||
}
|
||||
|
||||
public getValue(): Collection<T> {
|
||||
if ( !this.instances ) {
|
||||
throw new RelationNotLoadedError()
|
||||
}
|
||||
|
||||
return this.instances
|
||||
}
|
||||
|
||||
public isLoaded(): boolean {
|
||||
return Boolean(this.instances)
|
||||
}
|
||||
|
||||
public get(): Promise<Collection<T>> {
|
||||
return this.fetch().collect()
|
||||
}
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import {Relation, RelationNotLoadedError} from './Relation'
|
||||
import {TreeModel} from '../TreeModel'
|
||||
import {RelationBuilder} from './RelationBuilder'
|
||||
import {raw} from '../../dialect/SQLDialect'
|
||||
import {AbstractBuilder} from '../../builder/AbstractBuilder'
|
||||
import {ModelBuilder} from '../ModelBuilder'
|
||||
import {Collection, Maybe} from '../../../util'
|
||||
|
||||
export class HasTreeParent<T extends TreeModel<T>> extends Relation<T, T, Maybe<T>> {
|
||||
|
||||
protected parentInstance?: T
|
||||
|
||||
protected loaded = false
|
||||
|
||||
protected constructor(
|
||||
protected model: T,
|
||||
protected readonly leftTreeField: string,
|
||||
protected readonly parentIdField: string,
|
||||
) {
|
||||
super(model, model)
|
||||
}
|
||||
|
||||
protected get parentValue(): any {
|
||||
return this.model.key()
|
||||
}
|
||||
|
||||
public query(): RelationBuilder<T> {
|
||||
return this.builder()
|
||||
.tap(b => this.model.applyScopes(b))
|
||||
.select(raw('*'))
|
||||
.orderByAscending(this.leftTreeField)
|
||||
}
|
||||
|
||||
public applyScope(where: AbstractBuilder<T>): void {
|
||||
const parentId = this.model.parentId()
|
||||
if ( !parentId ) {
|
||||
where.whereMatchNone()
|
||||
return
|
||||
}
|
||||
|
||||
where.where(this.parentIdField, '=', parentId)
|
||||
}
|
||||
|
||||
public buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T> {
|
||||
const parentIds = result.map(model => model.parentId()).whereDefined()
|
||||
return this.model.query()
|
||||
.without('subtree')
|
||||
.whereIn(this.parentIdField, parentIds)
|
||||
}
|
||||
|
||||
public matchResults(possiblyRelated: Collection<T>): Collection<T> {
|
||||
return possiblyRelated.filter(related => related.key() === this.model.parentId())
|
||||
}
|
||||
|
||||
public setValue(related: Maybe<T>): void {
|
||||
this.loaded = true
|
||||
this.parentInstance = related
|
||||
}
|
||||
|
||||
public getValue(): Maybe<T> {
|
||||
if ( !this.loaded && this.model.parentId() ) {
|
||||
throw new RelationNotLoadedError()
|
||||
}
|
||||
|
||||
return this.parentInstance
|
||||
}
|
||||
|
||||
public isLoaded(): boolean {
|
||||
return this.loaded || !this.model.parentId()
|
||||
}
|
||||
|
||||
public async get(): Promise<Maybe<T>> {
|
||||
return this.fetch().first()
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user