From f63891ef99fe1d28d45287ab03a124e99a2e8986 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sat, 20 Aug 2022 16:21:06 -0500 Subject: [PATCH] Add TreeModel and HasSubtree implementation --- package.json | 4 +- pnpm-lock.yaml | 89 +++++++------ src/cli/directive/ShellDirective.ts | 18 +-- src/di/Container.ts | 5 +- src/di/decorator/injection.ts | 7 +- src/di/error/InvalidDependencyKeyError.ts | 2 +- src/di/factory/AbstractFactory.ts | 2 +- src/di/factory/Factory.ts | 3 +- src/orm/builder/AbstractBuilder.ts | 30 ++++- src/orm/dialect/PostgreSQLDialect.ts | 2 + src/orm/dialect/SQLDialect.ts | 2 +- src/orm/index.ts | 2 + src/orm/model/Model.ts | 35 ++---- src/orm/model/ModelBuilder.ts | 11 +- src/orm/model/ModelResultIterable.ts | 10 +- src/orm/model/TreeModel.ts | 88 +++++++++++++ src/orm/model/relation/HasOneOrMany.ts | 2 +- src/orm/model/relation/HasSubtree.ts | 147 ++++++++++++++++++++++ src/orm/model/relation/Relation.ts | 9 +- src/orm/types.ts | 2 +- src/util/collection/Collection.ts | 14 ++- src/util/support/types.ts | 4 + 22 files changed, 380 insertions(+), 108 deletions(-) create mode 100644 src/orm/model/TreeModel.ts create mode 100644 src/orm/model/relation/HasSubtree.ts diff --git a/package.json b/package.json index e8c74a8..af87dee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@extollo/lib", - "version": "0.13.10", + "version": "0.14.0", "description": "The framework library that lifts up your code.", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -47,7 +47,7 @@ "typedoc": "^0.20.36", "typedoc-plugin-pages-fork": "^0.0.1", "typedoc-plugin-sourcefile-url": "^1.0.6", - "typescript": "^4.2.3", + "typescript": "^4.7.4", "uuid": "^8.3.2", "ws": "^8.8.0", "zod": "^3.11.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e54937d..40e02c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: 5.3 +lockfileVersion: 5.4 specifiers: '@atao60/fse-cli': ^0.1.6 @@ -50,7 +50,7 @@ specifiers: typedoc: ^0.20.36 typedoc-plugin-pages-fork: ^0.0.1 typedoc-plugin-sourcefile-url: ^1.0.6 - typescript: ^4.2.3 + typescript: ^4.7.4 uuid: ^8.3.2 ws: ^8.8.0 wtfnode: ^0.9.1 @@ -92,11 +92,11 @@ dependencies: reflect-metadata: 0.1.13 rimraf: 3.0.2 ssh2: 1.1.0 - ts-node: 9.1.1_typescript@4.2.3 - typedoc: 0.20.36_typescript@4.2.3 + ts-node: 9.1.1_typescript@4.7.4 + typedoc: 0.20.36_typescript@4.7.4 typedoc-plugin-pages-fork: 0.0.1 typedoc-plugin-sourcefile-url: 1.0.6_typedoc@0.20.36 - typescript: 4.2.3 + typescript: 4.7.4 uuid: 8.3.2 ws: 8.8.0 zod: 3.11.6 @@ -106,8 +106,8 @@ devDependencies: '@types/mocha': 9.0.0 '@types/sinon': 10.0.6 '@types/wtfnode': 0.7.0 - '@typescript-eslint/eslint-plugin': 4.26.0_942c48837be95e76bb4156c78358cdbe - '@typescript-eslint/parser': 4.26.0_eslint@7.27.0+typescript@4.2.3 + '@typescript-eslint/eslint-plugin': 4.26.0_cgxss3zaaghocyaq4idsceujcy + '@typescript-eslint/parser': 4.26.0_4x6mfymjsyurtmri255xnln3g4 chai: 4.3.4 eslint: 7.27.0 mocha: 9.1.3 @@ -160,6 +160,8 @@ packages: resolution: {integrity: sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==} engines: {node: '>=6.0.0'} hasBin: true + dependencies: + '@babel/types': 7.13.14 dev: false /@babel/runtime/7.14.6: @@ -216,8 +218,8 @@ packages: dotenv: 10.0.0 mkdirp: 1.0.4 rimraf: 3.0.2 - ts-node: 10.4.0_9033393ac6e1960c4e4feb229fa8feef - typescript: 4.5.2 + ts-node: 10.4.0_7qobpvvcbeswdxz4g6mzc6lzhy + typescript: 4.7.4 uuid: 8.3.2 transitivePeerDependencies: - '@swc/core' @@ -465,7 +467,7 @@ packages: resolution: {integrity: sha512-kdBHgE9+M1Os7UqWZtiLhKye5reFl8cPBYyCsP2fatwZRz7F7GdIxIHZ20Kkc0hYBfbXE+lzPOTUU1I0qgjtHA==} dev: true - /@typescript-eslint/eslint-plugin/4.26.0_942c48837be95e76bb4156c78358cdbe: + /@typescript-eslint/eslint-plugin/4.26.0_cgxss3zaaghocyaq4idsceujcy: resolution: {integrity: sha512-yA7IWp+5Qqf+TLbd8b35ySFOFzUfL7i+4If50EqvjT6w35X8Lv0eBHb6rATeWmucks37w+zV+tWnOXI9JlG6Eg==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -476,8 +478,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/experimental-utils': 4.26.0_eslint@7.27.0+typescript@4.2.3 - '@typescript-eslint/parser': 4.26.0_eslint@7.27.0+typescript@4.2.3 + '@typescript-eslint/experimental-utils': 4.26.0_4x6mfymjsyurtmri255xnln3g4 + '@typescript-eslint/parser': 4.26.0_4x6mfymjsyurtmri255xnln3g4 '@typescript-eslint/scope-manager': 4.26.0 debug: 4.3.1 eslint: 7.27.0 @@ -485,13 +487,13 @@ packages: lodash: 4.17.21 regexpp: 3.1.0 semver: 7.3.5 - tsutils: 3.21.0_typescript@4.2.3 - typescript: 4.2.3 + tsutils: 3.21.0_typescript@4.7.4 + typescript: 4.7.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/experimental-utils/4.26.0_eslint@7.27.0+typescript@4.2.3: + /@typescript-eslint/experimental-utils/4.26.0_4x6mfymjsyurtmri255xnln3g4: resolution: {integrity: sha512-TH2FO2rdDm7AWfAVRB5RSlbUhWxGVuxPNzGT7W65zVfl8H/WeXTk1e69IrcEVsBslrQSTDKQSaJD89hwKrhdkw==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -500,7 +502,7 @@ packages: '@types/json-schema': 7.0.7 '@typescript-eslint/scope-manager': 4.26.0 '@typescript-eslint/types': 4.26.0 - '@typescript-eslint/typescript-estree': 4.26.0_typescript@4.2.3 + '@typescript-eslint/typescript-estree': 4.26.0_typescript@4.7.4 eslint: 7.27.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0_eslint@7.27.0 @@ -509,7 +511,7 @@ packages: - typescript dev: true - /@typescript-eslint/parser/4.26.0_eslint@7.27.0+typescript@4.2.3: + /@typescript-eslint/parser/4.26.0_4x6mfymjsyurtmri255xnln3g4: resolution: {integrity: sha512-b4jekVJG9FfmjUfmM4VoOItQhPlnt6MPOBUL0AQbiTmm+SSpSdhHYlwayOm4IW9KLI/4/cRKtQCmDl1oE2OlPg==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -521,10 +523,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 4.26.0 '@typescript-eslint/types': 4.26.0 - '@typescript-eslint/typescript-estree': 4.26.0_typescript@4.2.3 + '@typescript-eslint/typescript-estree': 4.26.0_typescript@4.7.4 debug: 4.3.1 eslint: 7.27.0 - typescript: 4.2.3 + typescript: 4.7.4 transitivePeerDependencies: - supports-color dev: true @@ -542,7 +544,7 @@ packages: engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} dev: true - /@typescript-eslint/typescript-estree/4.26.0_typescript@4.2.3: + /@typescript-eslint/typescript-estree/4.26.0_typescript@4.7.4: resolution: {integrity: sha512-GHUgahPcm9GfBuy3TzdsizCcPjKOAauG9xkz9TR8kOdssz2Iz9jRCSQm6+aVFa23d5NcSpo1GdHGSQKe0tlcbg==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -557,8 +559,8 @@ packages: globby: 11.0.3 is-glob: 4.0.1 semver: 7.3.5 - tsutils: 3.21.0_typescript@4.2.3 - typescript: 4.2.3 + tsutils: 3.21.0_typescript@4.7.4 + typescript: 4.7.4 transitivePeerDependencies: - supports-color dev: true @@ -1024,7 +1026,7 @@ packages: engines: {node: '>=8.0.0'} requiresBuild: true dependencies: - nan: 2.14.2 + nan: 2.16.0 dev: false optional: true @@ -1550,7 +1552,7 @@ packages: source-map: 0.6.1 wordwrap: 1.0.0 optionalDependencies: - uglify-js: 3.13.8 + uglify-js: 3.17.0 dev: false /has-flag/3.0.0: @@ -2061,8 +2063,8 @@ packages: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: false - /nan/2.14.2: - resolution: {integrity: sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==} + /nan/2.16.0: + resolution: {integrity: sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==} dev: false optional: true @@ -2720,7 +2722,7 @@ packages: bcrypt-pbkdf: 1.0.2 optionalDependencies: cpu-features: 0.0.2 - nan: 2.14.2 + nan: 2.16.0 dev: false /standard-as-callback/2.1.0: @@ -2870,7 +2872,7 @@ packages: resolution: {integrity: sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ=} dev: false - /ts-node/10.4.0_9033393ac6e1960c4e4feb229fa8feef: + /ts-node/10.4.0_7qobpvvcbeswdxz4g6mzc6lzhy: resolution: {integrity: sha512-g0FlPvvCXSIO1JDF6S232P5jPYqBkRL9qly81ZgAOSU7rwI0stphCgd2kLiCrU9DjQCrJMWEqcNSjQL02s6d8A==} hasBin: true peerDependencies: @@ -2896,11 +2898,11 @@ packages: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.5.2 + typescript: 4.7.4 yn: 3.1.1 dev: false - /ts-node/9.1.1_typescript@4.2.3: + /ts-node/9.1.1_typescript@4.7.4: resolution: {integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==} engines: {node: '>=10.0.0'} hasBin: true @@ -2912,7 +2914,7 @@ packages: diff: 4.0.2 make-error: 1.3.6 source-map-support: 0.5.19 - typescript: 4.2.3 + typescript: 4.7.4 yn: 3.1.1 dev: false @@ -2923,14 +2925,14 @@ packages: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} dev: false - /tsutils/3.21.0_typescript@4.2.3: + /tsutils/3.21.0_typescript@4.7.4: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 4.2.3 + typescript: 4.7.4 dev: true /tweetnacl/0.14.5: @@ -2993,10 +2995,10 @@ packages: peerDependencies: typedoc: '>=0.16.0' dependencies: - typedoc: 0.20.36_typescript@4.2.3 + typedoc: 0.20.36_typescript@4.7.4 dev: false - /typedoc/0.20.36_typescript@4.2.3: + /typedoc/0.20.36_typescript@4.7.4: resolution: {integrity: sha512-qFU+DWMV/hifQ9ZAlTjdFO9wbUIHuUBpNXzv68ZyURAP9pInjZiO4+jCPeAzHVcaBCHER9WL/+YzzTt6ZlN/Nw==} engines: {node: '>= 10.8.0'} hasBin: true @@ -3014,23 +3016,16 @@ packages: shelljs: 0.8.4 shiki: 0.9.3 typedoc-default-themes: 0.12.10 - typescript: 4.2.3 + typescript: 4.7.4 dev: false - /typescript/4.2.3: - resolution: {integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==} + /typescript/4.7.4: + resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==} engines: {node: '>=4.2.0'} hasBin: true - dev: false - - /typescript/4.5.2: - resolution: {integrity: sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==} - engines: {node: '>=4.2.0'} - hasBin: true - dev: false - /uglify-js/3.13.8: - resolution: {integrity: sha512-PvFLMFIQHfIjFFlvAch69U2IvIxK9TNzNWt1SxZGp9JZ/v70yvqIQuiJeVPPtUMOzoNt+aNRDk4wgxb34wvEqA==} + /uglify-js/3.17.0: + resolution: {integrity: sha512-aTeNPVmgIMPpm1cxXr2Q/nEbvkmV8yq66F3om7X3P/cvOXQ0TMQ64Wk63iyT1gPlmdmGzjGpyLh1f3y8MZWXGg==} engines: {node: '>=0.8.0'} hasBin: true requiresBuild: true diff --git a/src/cli/directive/ShellDirective.ts b/src/cli/directive/ShellDirective.ts index 93a11ca..8f17037 100644 --- a/src/cli/directive/ShellDirective.ts +++ b/src/cli/directive/ShellDirective.ts @@ -63,24 +63,24 @@ export class ShellDirective extends Directive { globalRegistry.forceContextOverride() // Create the ts-node compiler service. - const replService = tsNode.createRepl() - const service = tsNode.create({...replService.evalAwarePartialHost}) - replService.setService(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']; - `) + // 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, + // eval: !this.option('js', false) ? (...args) => replService.nodeEval(...args) : undefined, prompt: this.options.prompt, useGlobal: true, useColors: true, diff --git a/src/di/Container.ts b/src/di/Container.ts index 0580813..92278d6 100644 --- a/src/di/Container.ts +++ b/src/di/Container.ts @@ -12,12 +12,11 @@ import { Awaitable, collect, Collection, - ErrorWithContext, globalRegistry, hasOwnProperty, logIfDebugging, - withErrorContext, } from '../util' +import {ErrorWithContext, withErrorContext} from '../util/error/ErrorWithContext' import {Factory} from './factory/Factory' import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError' import {ClosureFactory} from './factory/ClosureFactory' @@ -529,7 +528,7 @@ export class Container { return realized } }, { - makeStack: Container.makeStack, + makeStack: Container.makeStack.map(x => typeof x === 'string' ? x : (x?.name || 'unknown')).toArray(), }) if ( result ) { diff --git a/src/di/decorator/injection.ts b/src/di/decorator/injection.ts index 33d9aaf..94461c0 100644 --- a/src/di/decorator/injection.ts +++ b/src/di/decorator/injection.ts @@ -1,5 +1,6 @@ import 'reflect-metadata' -import {collect, Collection, logIfDebugging} from '../../util' +import {collect, Collection} from '../../util' +import {logIfDebugging} from '../../util/support/debug' import { DependencyKey, DependencyRequirement, @@ -100,9 +101,7 @@ export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDec } } - if ( debug ) { - logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type) - } + logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type) Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target) } diff --git a/src/di/error/InvalidDependencyKeyError.ts b/src/di/error/InvalidDependencyKeyError.ts index 2666500..aebfdf1 100644 --- a/src/di/error/InvalidDependencyKeyError.ts +++ b/src/di/error/InvalidDependencyKeyError.ts @@ -1,5 +1,5 @@ import {DependencyKey} from '../types' -import {ErrorWithContext} from '../../util' +import {ErrorWithContext} from '../../util/error/ErrorWithContext' /** * Error thrown when a dependency key that has not been registered is passed to a resolver. diff --git a/src/di/factory/AbstractFactory.ts b/src/di/factory/AbstractFactory.ts index 585d2bf..ef28ca7 100644 --- a/src/di/factory/AbstractFactory.ts +++ b/src/di/factory/AbstractFactory.ts @@ -51,6 +51,6 @@ export abstract class AbstractFactory { return this.token } - return this.token.name ?? '(unknown token)' + return this.token?.name ?? '(unknown token)' } } diff --git a/src/di/factory/Factory.ts b/src/di/factory/Factory.ts index 88f6d6a..f5bd387 100644 --- a/src/di/factory/Factory.ts +++ b/src/di/factory/Factory.ts @@ -6,7 +6,7 @@ import { Instantiable, PropertyDependency, } from '../types' -import {Collection} from '../../util' +import {Collection, logIfDebugging} from '../../util' import 'reflect-metadata' /** @@ -58,6 +58,7 @@ export class Factory extends AbstractFactory { do { const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken) + logIfDebugging('extollo.di.injection', 'Factory.getInjectedProperties() target:', currentToken, 'loaded:', loadedMeta) if ( loadedMeta ) { meta.concat(loadedMeta) } diff --git a/src/orm/builder/AbstractBuilder.ts b/src/orm/builder/AbstractBuilder.ts index 6d641c2..198638f 100644 --- a/src/orm/builder/AbstractBuilder.ts +++ b/src/orm/builder/AbstractBuilder.ts @@ -222,17 +222,45 @@ export abstract class AbstractBuilder 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 */ - whereRaw(field: string, operator: ConstraintOperator, operand: string): this { + whereRawValue(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 diff --git a/src/orm/dialect/PostgreSQLDialect.ts b/src/orm/dialect/PostgreSQLDialect.ts index 911387b..7a3b34e 100644 --- a/src/orm/dialect/PostgreSQLDialect.ts +++ b/src/orm/dialect/PostgreSQLDialect.ts @@ -316,6 +316,8 @@ 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()}`) } } diff --git a/src/orm/dialect/SQLDialect.ts b/src/orm/dialect/SQLDialect.ts index f061ea1..b997120 100644 --- a/src/orm/dialect/SQLDialect.ts +++ b/src/orm/dialect/SQLDialect.ts @@ -14,7 +14,7 @@ export type EscapeValue = null | undefined | string | number | boolean | Date | export type EscapeValueObject = { [field: string]: EscapeValue } /** - * A wrapper class whose value is save to inject directly into a query. + * A wrapper class whose value is safe to inject directly into a query. */ export class QuerySafeValue { constructor( diff --git a/src/orm/index.ts b/src/orm/index.ts index 099ec9a..5ff5503 100644 --- a/src/orm/index.ts +++ b/src/orm/index.ts @@ -20,12 +20,14 @@ 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/decorators' export * from './model/scope/Scope' diff --git a/src/orm/model/Model.ts b/src/orm/model/Model.ts index c60bf6e..ef64972 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -19,7 +19,7 @@ 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' +import {LocalBus} from '../../support/bus/LocalBus' // need the specific import to prevent circular dependencies import {ModelEvent} from './events/ModelEvent' /** @@ -149,7 +149,8 @@ export abstract class Model> extends LocalBus> * ``` */ public static query>(): ModelBuilder { - const builder = > Container.getContainer().make>(ModelBuilder, this) + const di = Container.getContainer() + const builder = > di.make>(ModelBuilder, this) const source: QuerySource = this.querySource() builder.connection(this.getConnection()) @@ -164,35 +165,17 @@ export abstract class Model> extends LocalBus> builder.field(field.databaseKey) }) - if ( Array.isArray(this.prototype.with) ) { + const inst = di.make(this) + if ( Array.isArray(inst.with) ) { // Try to get the eager-loaded relations statically, if possible - for (const relation of this.prototype.with) { + for (const relation of inst.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>(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) - } - } - } - - 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>(this) - builder.withScopes(inst.scopes) } + builder.withScopes(inst.scopes) return builder } @@ -1008,13 +991,13 @@ export abstract class Model> extends LocalBus> } if ( typeof relFn === 'function' ) { - const rel = relFn.apply(relFn, this) + const rel = relFn.bind(this)() if ( rel instanceof Relation ) { return rel } } - throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`) + throw new TypeError(`Cannot get relation of name: ${String(name)}. Method does not return a Relation.`) } /** diff --git a/src/orm/model/ModelBuilder.ts b/src/orm/model/ModelBuilder.ts index a775413..bf6f3ed 100644 --- a/src/orm/model/ModelBuilder.ts +++ b/src/orm/model/ModelBuilder.ts @@ -78,7 +78,7 @@ export class ModelBuilder> extends AbstractBuilder { */ 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(this.ModelClass).getRelation(relationName) this.eagerLoadRelations.push(relationName) } @@ -86,6 +86,15 @@ export class ModelBuilder> extends AbstractBuilder { 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. */ diff --git a/src/orm/model/ModelResultIterable.ts b/src/orm/model/ModelResultIterable.ts index 5aaec8b..563b5e4 100644 --- a/src/orm/model/ModelResultIterable.ts +++ b/src/orm/model/ModelResultIterable.ts @@ -70,6 +70,11 @@ export class ModelResultIterable> extends AbstractResultItera * @protected */ protected async processEagerLoads(results: Collection): Promise { + 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(this.ModelClass) @@ -78,9 +83,10 @@ export class ModelResultIterable> extends AbstractResultItera const relation = model.getRelation(name) const select = relation.buildEagerQuery(this.builder, results) + const resultCount = await select.get().count() - const allRelated = await select.get().collect() - allRelated.each(result => { + const allRelated = resultCount ? await select.get().collect() : collect() + results.each(result => { const resultRelation = result.getRelation(name as any) const resultRelated = resultRelation.matchResults(allRelated as any) resultRelation.setValue(resultRelated as any) diff --git a/src/orm/model/TreeModel.ts b/src/orm/model/TreeModel.ts new file mode 100644 index 0000000..af71141 --- /dev/null +++ b/src/orm/model/TreeModel.ts @@ -0,0 +1,88 @@ +import {Model} from './Model' +import {Collection, Maybe} from '../../util' +import {HasSubtree} from './relation/HasSubtree' +import {Related} from './relation/decorators' + +/** + * 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().without('subtree') + * ``` + */ +export abstract class TreeModel> extends Model { + + /** 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' + + /** + * @override to eager-load the subtree by default + * @protected + */ + protected with: (keyof T)[] = ['subtree'] + + /** Get the left tree number for this model. */ + public leftTreeNum(): Maybe { + const ctor = this.constructor as typeof TreeModel + return this.originalSourceRow?.[ctor.leftTreeField] + } + + /** Get the right tree number for this model. */ + public rightTreeNum(): Maybe { + const ctor = this.constructor as typeof TreeModel + return this.originalSourceRow?.[ctor.rightTreeField] + } + + /** 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: this): 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 { + const ctor = this.constructor as typeof TreeModel + return this.make>(HasSubtree, this, ctor.leftTreeField) + } + + /** Get the immediate children of this model. */ + public children(): Collection { + return this.subtree().getValue() + } +} diff --git a/src/orm/model/relation/HasOneOrMany.ts b/src/orm/model/relation/HasOneOrMany.ts index c800136..a5c4e9d 100644 --- a/src/orm/model/relation/HasOneOrMany.ts +++ b/src/orm/model/relation/HasOneOrMany.ts @@ -59,7 +59,7 @@ export abstract class HasOneOrMany, T2 extends Model, V e public applyScope(where: AbstractBuilder): void { where.where(subq => { subq.where(this.qualifiedForeignKey, '=', this.parentValue) - .whereRaw(this.qualifiedForeignKey, 'IS NOT', 'NULL') + .whereNotNull(this.qualifiedForeignKey) }) } diff --git a/src/orm/model/relation/HasSubtree.ts b/src/orm/model/relation/HasSubtree.ts new file mode 100644 index 0000000..0abe937 --- /dev/null +++ b/src/orm/model/relation/HasSubtree.ts @@ -0,0 +1,147 @@ +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' + +/** + * A relation that recursively loads the subtree of a model using + * modified preorder traversal. + */ +export class HasSubtree> extends Relation> { + + /** + * When the relation is loaded, the immediate children of the node. + * @protected + */ + protected instances: Maybe> + + constructor( + protected readonly model: T, + protected readonly leftTreeField: string, + ) { + super(model, model) + } + + protected get parentValue(): any { + return this.model.key() + } + + public query(): RelationBuilder { + return this.builder() + .select(raw('*')) + .orderByAscending(this.leftTreeField) + } + + public applyScope(where: AbstractBuilder): 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, result: Collection): ModelBuilder { + const query = this.model.query().without('subtree') + + if ( result.isEmpty() ) { + return query.whereMatchNone() + } + + result.each(inst => { + const left = inst.leftTreeNum() + const right = inst.rightTreeNum() + if ( !left || !right ) { + return + } + + query.where(where => { + where.where(this.leftTreeField, '>', left) + .where(this.leftTreeField, '<', right) + }) + }) + + return query + } + + public matchResults(possiblyRelated: Collection): Collection { + 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 setValue(related: Collection): 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, + } + + const children = this.instances = collect() + const firstChild = related.pop() + if ( !firstChild ) { + return + } + + const finalState = related.reduce((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) + } + + this.instances = children.sortBy(inst => inst.getOriginalValues()?.[this.leftTreeField]) + } + + public getValue(): Collection { + if ( !this.instances ) { + throw new RelationNotLoadedError() + } + + return this.instances + } + + public isLoaded(): boolean { + return Boolean(this.instances) + } + + public get(): Promise> { + return this.fetch().collect() + } +} diff --git a/src/orm/model/relation/Relation.ts b/src/orm/model/relation/Relation.ts index 17a91c4..32bee20 100644 --- a/src/orm/model/relation/Relation.ts +++ b/src/orm/model/relation/Relation.ts @@ -59,7 +59,9 @@ export abstract class Relation, T2 extends Model, V exten /** Get a collection of the results of this relation. */ public fetch(): ResultCollection { - return this.query().get() + return this.query() + .where(where => this.applyScope(where)) + .get() } /** Resolve the result of this relation. */ @@ -106,6 +108,9 @@ export abstract class Relation, T2 extends Model, V exten /** Get a new builder instance for this relation. */ public builder(): RelationBuilder { - return this.make(RelationBuilder, this) + const relatedCtor = this.related.constructor as typeof Model + return this.make>(RelationBuilder, this) + .connection(relatedCtor.getConnection()) + .from(relatedCtor.tableName()) } } diff --git a/src/orm/types.ts b/src/orm/types.ts index dcf9f9e..75699b6 100644 --- a/src/orm/types.ts +++ b/src/orm/types.ts @@ -77,7 +77,7 @@ export function isConstraintItem(what: unknown): what is ConstraintItem { /** * Type alias for something that can be either a single constraint or a group of them. */ -export type Constraint = ConstraintItem | ConstraintGroup +export type Constraint = ConstraintItem | ConstraintGroup | QuerySafeValue /** * Type alias for an item that refers to a field on a table. diff --git a/src/util/collection/Collection.ts b/src/util/collection/Collection.ts index 7767551..bde42ca 100644 --- a/src/util/collection/Collection.ts +++ b/src/util/collection/Collection.ts @@ -14,11 +14,11 @@ type MaybeCollectionIndex = CollectionIndex | undefined type ComparisonFunction = (item: CollectionItem, otherItem: CollectionItem) => number import { WhereOperator, applyWhere, whereMatch } from './where' -import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, right, unright} from '../support/types' +import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, MethodType, right, unright} from '../support/types' import {AsyncCollection} from './AsyncCollection' import {ArrayIterable} from './ArrayIterable' -const collect = (items: CollectionItem[]): Collection => Collection.collect(items) +const collect = (items: CollectionItem[] = []): Collection => Collection.collect(items) const toString = (item: unknown): string => String(item) export { @@ -381,8 +381,12 @@ class Collection { * @param method * @param params */ - mapCall>(method: T2, ...params: Parameters): Collection> { - return this.map(x => x[method](...params)) + mapCall>(method: T2, ...params: Parameters>): Collection>> { + // This is dumb, but I'm not sure how else to resolve it. The types check out, but TypeScript loses track of the fact that + // typeof x[method] === MethodType, so it assumes we're indexing an object incorrectly. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.map((x: T) => x[method](...params)) } /** @@ -390,7 +394,7 @@ class Collection { * @param method * @param params */ - async awaitMapCall>(method: T2, ...params: Parameters): Promise>>> { + async awaitMapCall>(method: T2, ...params: Parameters>): Promise>>>> { return this.mapCall(method, ...params).awaitAll() } diff --git a/src/util/support/types.ts b/src/util/support/types.ts index 37421c0..0d006a0 100644 --- a/src/util/support/types.ts +++ b/src/util/support/types.ts @@ -73,6 +73,10 @@ export type MethodsOf any> = { [K in keyof T]: T[K] extends TMethod ? K : never }[keyof T] +export type MethodType any> = { + [K in keyof TClass]: TClass[K] extends TMethod ? TClass[K] : never +}[TKey] + export type Awaited = T extends PromiseLike ? U : T export type Integer = TypeTag<'@extollo/lib.Integer'> & number