Add TreeModel and HasSubtree implementation

This commit is contained in:
2022-08-20 16:21:06 -05:00
parent 3d836afa59
commit f63891ef99
22 changed files with 380 additions and 108 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,6 @@ export abstract class AbstractFactory<T> {
return this.token
}
return this.token.name ?? '(unknown token)'
return this.token?.name ?? '(unknown token)'
}
}

View File

@@ -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<T> extends AbstractFactory<T> {
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)
}

View File

@@ -222,17 +222,45 @@ 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
*/
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

View File

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

View File

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

View File

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

View File

@@ -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<T extends Model<T>> extends LocalBus<ModelEvent<T>>
* ```
*/
public static query<T2 extends Model<T2>>(): ModelBuilder<T2> {
const builder = <ModelBuilder<T2>> Container.getContainer().make<ModelBuilder<T2>>(ModelBuilder, this)
const di = Container.getContainer()
const builder = <ModelBuilder<T2>> di.make<ModelBuilder<T2>>(ModelBuilder, this)
const source: QuerySource = this.querySource()
builder.connection(this.getConnection())
@@ -164,35 +165,17 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
builder.field(field.databaseKey)
})
if ( Array.isArray(this.prototype.with) ) {
const inst = di.make<T2>(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<Model<any>>(this)
if ( Array.isArray(inst.with) ) {
for (const relation of inst.with) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
builder.with(relation)
}
}
}
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<any>>(this)
builder.withScopes(inst.scopes)
}
builder.withScopes(inst.scopes)
return builder
}
@@ -1008,13 +991,13 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
}
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.`)
}
/**

View File

@@ -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,6 +86,15 @@ 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.
*/

View File

@@ -70,6 +70,11 @@ 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)
@@ -78,9 +83,10 @@ 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 = 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)

View File

@@ -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<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'
/**
* @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<number> {
const ctor = this.constructor as typeof TreeModel
return this.originalSourceRow?.[ctor.leftTreeField]
}
/** Get the right tree number for this model. */
public rightTreeNum(): Maybe<number> {
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<T> {
const ctor = this.constructor as typeof TreeModel
return this.make<HasSubtree<T>>(HasSubtree, this, ctor.leftTreeField)
}
/** Get the immediate children of this model. */
public children(): Collection<T> {
return this.subtree().getValue()
}
}

View File

@@ -59,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)
.whereRaw(this.qualifiedForeignKey, 'IS NOT', 'NULL')
.whereNotNull(this.qualifiedForeignKey)
})
}

View File

@@ -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<T extends TreeModel<T>> extends Relation<T, T, Collection<T>> {
/**
* 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)
}
protected get parentValue(): any {
return this.model.key()
}
public query(): RelationBuilder<T> {
return this.builder()
.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')
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<T>): Collection<T> {
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<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)
}
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()
}
}

View File

@@ -59,7 +59,9 @@ export abstract class Relation<T extends Model<T>, T2 extends Model<T2>, V exten
/** Get a collection of the results of this relation. */
public fetch(): ResultCollection<T2> {
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<T extends Model<T>, T2 extends Model<T2>, V exten
/** Get a new builder instance for this relation. */
public builder(): RelationBuilder<T2> {
return this.make(RelationBuilder, this)
const relatedCtor = this.related.constructor as typeof Model
return this.make<RelationBuilder<T2>>(RelationBuilder, this)
.connection(relatedCtor.getConnection())
.from(relatedCtor.tableName())
}
}

View File

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

View File

@@ -14,11 +14,11 @@ type MaybeCollectionIndex = CollectionIndex | undefined
type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem<T>) => 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 = <T>(items: CollectionItem<T>[]): Collection<T> => Collection.collect(items)
const collect = <T>(items: CollectionItem<T>[] = []): Collection<T> => Collection.collect(items)
const toString = (item: unknown): string => String(item)
export {
@@ -381,8 +381,12 @@ class Collection<T> {
* @param method
* @param params
*/
mapCall<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<T[T2]>): Collection<ReturnType<T[T2]>> {
return this.map(x => x[method](...params))
mapCall<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<MethodType<T, T2>>): Collection<ReturnType<MethodType<T, T2>>> {
// 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<T, T2>, 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<T> {
* @param method
* @param params
*/
async awaitMapCall<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<T[T2]>): Promise<Collection<Awaited<ReturnType<T[T2]>>>> {
async awaitMapCall<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<MethodType<T, T2>>): Promise<Collection<Awaited<ReturnType<MethodType<T, T2>>>>> {
return this.mapCall(method, ...params).awaitAll()
}

View File

@@ -73,6 +73,10 @@ export type MethodsOf<T, TMethod = (...args: any[]) => any> = {
[K in keyof T]: T[K] extends TMethod ? K : never
}[keyof T]
export type MethodType<TClass, TKey extends keyof TClass, TMethod = (...args: any[]) => any> = {
[K in keyof TClass]: TClass[K] extends TMethod ? TClass[K] : never
}[TKey]
export type Awaited<T> = T extends PromiseLike<infer U> ? U : T
export type Integer = TypeTag<'@extollo/lib.Integer'> & number