finish TreeModel implementation and updateMany builder method

This commit is contained in:
2022-09-12 12:36:33 -05:00
parent f63891ef99
commit c966904418
13 changed files with 580 additions and 44 deletions

View File

@@ -3,7 +3,7 @@ import {Container, Inject, Instantiable, isInstantiable} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, Collection, uuid4, isKeyof, Pipeline} from '../../util'
import {deepCopy, Collection, uuid4, isKeyof, Pipeline, hasOwnProperty} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {Logging} from '../../service/Logging'
import {Connection} from '../connection/Connection'
@@ -96,6 +96,13 @@ 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.
@@ -175,8 +182,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
}
}
builder.withScopes(inst.scopes)
inst.applyScopes(builder)
return builder
}
@@ -214,6 +220,49 @@ 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.
@@ -256,7 +305,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 {
public key(): string|number {
const ctor = this.constructor as typeof Model
const field = getFieldsMeta(this)
@@ -333,8 +382,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
builder.with(relation)
}
builder.withScopes(this.scopes)
this.applyScopes(builder)
return builder
}
@@ -445,6 +493,7 @@ 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
}
@@ -487,7 +536,10 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
row[field.databaseKey] = (this as any)[field.modelKey]
})
return row
return {
...row,
...(this.dirtySourceRow || {}),
}
}
/**
@@ -621,10 +673,12 @@ 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))
} else if ( !this.exists() ) {
await this.push(new ModelCreatingEvent<T>(this as any))
@@ -654,10 +708,12 @@ 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))
}
@@ -808,7 +864,14 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
*/
protected get isDirtyCheck(): (field: ModelField) => boolean {
return (field: ModelField) => {
return !this.originalSourceRow || (this as any)[field.modelKey] !== this.originalSourceRow[field.databaseKey]
return Boolean(
!this.originalSourceRow
|| (this as any)[field.modelKey] !== this.originalSourceRow[field.databaseKey]
|| (
this.dirtySourceRow
&& hasOwnProperty(this.dirtySourceRow, field.databaseKey)
),
)
}
}
@@ -833,12 +896,17 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
return Pipeline.id<Collection<ModelField>>()
const row = 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 || {}),
}
}
/**
@@ -918,7 +986,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<T>, relationName: keyof T2): HasOne<T, T2> {
public belongsToOne<T2 extends Model<T2>>(related: Instantiable<T2>, relationName: keyof T2): HasOne<T, T2> {
const relatedInst = this.make(related) as T2
const relation = relatedInst.getRelation(relationName)
@@ -1055,4 +1123,12 @@ 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)
}
}

View File

@@ -1,7 +1,8 @@
import {Model} from './Model'
import {Collection, Maybe} from '../../util'
import {collect, Collection, ErrorWithContext, Maybe} from '../../util'
import {HasSubtree} from './relation/HasSubtree'
import {Related} from './relation/decorators'
import {HasTreeParent} from './relation/HasTreeParent'
/**
* Model implementation with helpers for querying tree-structured data.
@@ -41,22 +42,32 @@ export abstract class TreeModel<T extends TreeModel<T>> extends Model<T> {
/** The table column where the right tree number is stored. */
public static readonly rightTreeField = 'right_num'
public static readonly parentIdField = 'parent_id'
/**
* @override to eager-load the subtree by default
* @protected
*/
protected with: (keyof T)[] = ['subtree']
protected removedChildren: Collection<TreeModel<T>> = collect()
/** Get the left tree number for this model. */
public leftTreeNum(): Maybe<number> {
const ctor = this.constructor as typeof TreeModel
return this.originalSourceRow?.[ctor.leftTreeField]
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.originalSourceRow?.[ctor.rightTreeField]
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. */
@@ -67,7 +78,7 @@ export abstract class TreeModel<T extends TreeModel<T>> extends Model<T> {
}
/** Returns true if the given `node` exists within the subtree of this node. */
public contains(node: this): boolean {
public contains(node: TreeModel<T>): boolean {
const num = node.leftTreeNum()
const left = this.leftTreeNum()
const right = this.rightTreeNum()
@@ -81,8 +92,182 @@ export abstract class TreeModel<T extends TreeModel<T>> extends Model<T> {
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()
}
}
}

View File

@@ -33,14 +33,24 @@ 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.foreignKey)
return this.related.qualify(this.foreignColumn)
}
/** Get the fully-qualified name of the local key. */
public get qualifiedLocalKey(): string {
return this.related.qualify(this.localKey)
return this.related.qualify(this.localColumn)
}
/** Get the value of the pivot for this relation from the parent model. */
@@ -70,11 +80,11 @@ export abstract class HasOneOrMany<T extends Model<T>, T2 extends Model<T2>, V e
.all()
return this.related.query()
.whereIn(this.foreignKey, keys)
.whereIn(this.foreignColumn, keys)
}
/** Given a collection of results, filter out those that are relevant to this relation. */
public matchResults(possiblyRelated: Collection<T>): Collection<T> {
return possiblyRelated.where(this.foreignKey as keyof T, '=', this.parentValue)
return possiblyRelated.where(this.foreignColumn as keyof T, '=', this.parentValue)
}
}

View File

@@ -5,13 +5,19 @@ 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
@@ -25,12 +31,24 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
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)
}
@@ -50,6 +68,9 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
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()
}
@@ -61,16 +82,20 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
return
}
query.where(where => {
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 ) {
@@ -83,6 +108,22 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
})
}
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
@@ -126,6 +167,9 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
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])
}

View File

@@ -0,0 +1,75 @@
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()
}
}