Add TreeModel and HasSubtree implementation
This commit is contained in:
@@ -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.`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
88
src/orm/model/TreeModel.ts
Normal file
88
src/orm/model/TreeModel.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
147
src/orm/model/relation/HasSubtree.ts
Normal file
147
src/orm/model/relation/HasSubtree.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user