From c9669044189ab5a7888a905c154187254c71f2b8 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Mon, 12 Sep 2022 12:36:33 -0500 Subject: [PATCH] finish TreeModel implementation and updateMany builder method --- .gitignore | 2 + package.json | 2 +- src/orm/builder/AbstractBuilder.ts | 56 ++++++- src/orm/dialect/PostgreSQLDialect.ts | 92 ++++++++++- src/orm/dialect/SQLDialect.ts | 26 +++- src/orm/index.ts | 1 + src/orm/model/Model.ts | 96 ++++++++++-- src/orm/model/TreeModel.ts | 193 +++++++++++++++++++++++- src/orm/model/relation/HasOneOrMany.ts | 18 ++- src/orm/model/relation/HasSubtree.ts | 46 +++++- src/orm/model/relation/HasTreeParent.ts | 75 +++++++++ src/orm/types.ts | 4 +- src/util/collection/Collection.ts | 13 +- 13 files changed, 580 insertions(+), 44 deletions(-) create mode 100644 src/orm/model/relation/HasTreeParent.ts diff --git a/.gitignore b/.gitignore index 2840a2c..67a9ce5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.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 diff --git a/package.json b/package.json index af87dee..ebe2977 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@extollo/lib", - "version": "0.14.0", + "version": "0.14.1", "description": "The framework library that lifts up your code.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/orm/builder/AbstractBuilder.ts b/src/orm/builder/AbstractBuilder.ts index 198638f..331d491 100644 --- a/src/orm/builder/AbstractBuilder.ts +++ b/src/orm/builder/AbstractBuilder.ts @@ -9,8 +9,8 @@ import { SpecifiedField, } from '../types' import {Connection} from '../connection/Connection' -import {deepCopy, ErrorWithContext, Maybe} from '../../util' -import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect' +import {Collectable, deepCopy, ErrorWithContext, Maybe} from '../../util' +import {EscapeValue, QuerySafeValue, raw, ScalarEscapeValue, VectorEscapeValue} from '../dialect/SQLDialect' import {ResultCollection} from './result/ResultCollection' import {AbstractResultIterable} from './result/AbstractResultIterable' import {AppClass} from '../../lifecycle/AppClass' @@ -310,7 +310,7 @@ export abstract class AbstractBuilder extends AppClass { * @param field * @param values */ - whereIn(field: string, values: EscapeValue): this { + whereIn(field: string, values: VectorEscapeValue): this { this.constraints.push({ field, operator: 'IN', @@ -325,7 +325,7 @@ export abstract class AbstractBuilder extends AppClass { * @param field * @param values */ - whereNotIn(field: string, values: EscapeValue): this { + whereNotIn(field: string, values: VectorEscapeValue): this { this.constraints.push({ field, operator: 'NOT IN', @@ -340,7 +340,7 @@ export abstract class AbstractBuilder extends AppClass { * @param field * @param values */ - orWhereIn(field: string, values: EscapeValue): this { + orWhereIn(field: string, values: VectorEscapeValue): this { this.constraints.push({ field, operator: 'IN', @@ -355,7 +355,7 @@ export abstract class AbstractBuilder extends AppClass { * @param field * @param values */ - orWhereNotIn(field: string, values: EscapeValue): this { + orWhereNotIn(field: string, values: VectorEscapeValue): this { this.constraints.push({ field, operator: 'NOT IN', @@ -528,6 +528,35 @@ export abstract class AbstractBuilder 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 { + 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. * @@ -600,6 +629,15 @@ export abstract class AbstractBuilder 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 @@ -615,6 +653,12 @@ export abstract class AbstractBuilder 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 diff --git a/src/orm/dialect/PostgreSQLDialect.ts b/src/orm/dialect/PostgreSQLDialect.ts index 7a3b34e..a4b4b85 100644 --- a/src/orm/dialect/PostgreSQLDialect.ts +++ b/src/orm/dialect/PostgreSQLDialect.ts @@ -2,7 +2,7 @@ import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect' import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types' import {AbstractBuilder} from '../builder/AbstractBuilder' import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder' -import {ErrorWithContext, Maybe} from '../../util' +import {collect, Collectable, Collection, ErrorWithContext, hasOwnProperty, 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) ) { + } 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') @@ -34,10 +34,10 @@ export class PostgreSQLDialect extends SQLDialect { ] return new QuerySafeValue(value, `'${y}-${m}-${d} ${h}:${i}:${s}'`) - } 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 if ( !isNaN(Number(value)) ) { + return new QuerySafeValue(value, String(Number(value))) } else { const escaped = value.replace(/'/g, '\'\'') // .replace(/"/g, '\\"').replace(/`/g, '\\`') return new QuerySafeValue(value, `'${escaped}'`) @@ -154,6 +154,78 @@ export class PostgreSQLDialect extends SQLDialect { return queryLines.join('\n') } + public renderBatchUpdate(builder: AbstractBuilder, 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 + const source = builder.querySource + let sourceAlias = 'extollo_update_source' + if ( !source ) { + throw new ErrorWithContext('No table specified for update query') + } + + const tableString = typeof source === 'string' ? source : source.table + const table: string = tableString.split('.') + .map(x => `"${x}"`) + .join('.') + + if ( typeof source !== 'string' && source.alias ) { + sourceAlias = source.alias + } + + queryLines.push('UPDATE ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`)) + + 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 ${table} 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 { + return rows.reduce((fields: Collection, row) => { + Object.keys(row).forEach(key => { + if ( !fields.includes(key) ) { + fields.push(key) + } + }) + return fields + }, collect()) + } + // TODO support FROM, RETURNING public renderUpdate(builder: AbstractBuilder, data: {[key: string]: EscapeValue}): string { const rawSql = builder.appliedRawSql @@ -181,6 +253,14 @@ 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') } @@ -298,8 +378,8 @@ export class PostgreSQLDialect extends SQLDialect { return queryLines.join('\n') } - public renderConstraints(allConstraints: Constraint[]): string { - const constraintsToSql = (constraints: Constraint[], level = 1): string => { + public renderConstraints(allConstraints: Constraint[], startingLevel = 1): string { + const constraintsToSql = (constraints: Constraint[], level = startingLevel): string => { const indent = Array(level * 2).fill(' ') .join('') const statements = [] diff --git a/src/orm/dialect/SQLDialect.ts b/src/orm/dialect/SQLDialect.ts index b997120..8762b35 100644 --- a/src/orm/dialect/SQLDialect.ts +++ b/src/orm/dialect/SQLDialect.ts @@ -2,15 +2,18 @@ import {Constraint} from '../types' import {AbstractBuilder} from '../builder/AbstractBuilder' import {AppClass} from '../../lifecycle/AppClass' import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder' +import {Collectable, Collection} from '../../util' -/** - * 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 +/** A scalar value which can be interpolated safely into an SQL query. */ +export type ScalarEscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue; -/** - * Object mapping string field names to EscapeValue items. - */ +/** A list of scalar escape values. */ +export type VectorEscapeValue = T[] | Collection + +/** All possible escaped query values. */ +export type EscapeValue = T | VectorEscapeValue // FIXME | Select + +/** Object mapping string field names to EscapeValue items. */ export type EscapeValueObject = { [field: string]: EscapeValue } /** @@ -70,6 +73,15 @@ export abstract class SQLDialect extends AppClass { */ public abstract renderUpdate(builder: AbstractBuilder, 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, primaryKey: string, dataRows: Collectable<{[key: string]: EscapeValue}>): string; + /** * Render the given query builder as a "DELETE ..." query string. * diff --git a/src/orm/index.ts b/src/orm/index.ts index 5ff5503..7e507d8 100644 --- a/src/orm/index.ts +++ b/src/orm/index.ts @@ -28,6 +28,7 @@ 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' diff --git a/src/orm/model/Model.ts b/src/orm/model/Model.ts index ef64972..25ce4ba 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -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> extends LocalBus> */ 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> extends LocalBus> } } - builder.withScopes(inst.scopes) - + inst.applyScopes(builder) return builder } @@ -214,6 +220,49 @@ export abstract class Model> extends LocalBus> } } + 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> extends LocalBus> /** * 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> extends LocalBus> builder.with(relation) } - builder.withScopes(this.scopes) - + this.applyScopes(builder) return builder } @@ -445,6 +493,7 @@ export abstract class Model> extends LocalBus> * @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> extends LocalBus> row[field.databaseKey] = (this as any)[field.modelKey] }) - return row + return { + ...row, + ...(this.dirtySourceRow || {}), + } } /** @@ -621,10 +673,12 @@ export abstract class Model> extends LocalBus> } 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(this as any)) } else if ( !this.exists() ) { await this.push(new ModelCreatingEvent(this as any)) @@ -654,10 +708,12 @@ export abstract class Model> extends LocalBus> } const data = result.rows.first() + this.logging.debug({inserta: data}) if ( data ) { await this.assumeFromSource(data) } + delete this.dirtySourceRow await this.push(new ModelCreatedEvent(this as any)) } @@ -808,7 +864,14 @@ export abstract class Model> extends LocalBus> */ 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> extends LocalBus> this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`) - return Pipeline.id>() + const row = Pipeline.id>() .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> extends LocalBus> * @param related * @param relationName */ - public belongsToOne>(related: Instantiable, relationName: keyof T2): HasOne { + public belongsToOne>(related: Instantiable, relationName: keyof T2): HasOne { const relatedInst = this.make(related) as T2 const relation = relatedInst.getRelation(relationName) @@ -1055,4 +1123,12 @@ export abstract class Model> extends LocalBus> protected hasScope(name: string | Instantiable): 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): void { + builder.withScopes(this.scopes) + } } diff --git a/src/orm/model/TreeModel.ts b/src/orm/model/TreeModel.ts index af71141..8166175 100644 --- a/src/orm/model/TreeModel.ts +++ b/src/orm/model/TreeModel.ts @@ -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> extends Model { /** 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> = collect() + /** Get the left tree number for this model. */ public leftTreeNum(): Maybe { 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 { 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 { + 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> extends Model { } /** Returns true if the given `node` exists within the subtree of this node. */ - public contains(node: this): boolean { + public contains(node: TreeModel): boolean { const num = node.leftTreeNum() const left = this.leftTreeNum() const right = this.rightTreeNum() @@ -81,8 +92,182 @@ export abstract class TreeModel> extends Model { return this.make>(HasSubtree, this, ctor.leftTreeField) } + /** The parent node of this model, if one exists. */ + @Related() + public parentNode(): HasTreeParent { + const ctor = this.constructor as typeof TreeModel + return this.make>(HasTreeParent, this, ctor.leftTreeField, ctor.parentIdField) + } + /** Get the immediate children of this model. */ public children(): Collection { return this.subtree().getValue() } + + /** Get the parent of this model, if one exists. */ + public parent(): Maybe { + return this.parentNode().getValue() + } + + public root(): Maybe> { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let parent: Maybe> = this + while ( parent?.parent() ) { + parent = parent?.parent() + } + return parent + } + + public rootOrFail(): TreeModel { + 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): Maybe> { + 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> { + return this.flattenLevel() + } + + /** Recursive helper for `flatten()`. */ + private flattenLevel(topLevel = true): Collection> { + const flat = collect>(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 { + 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 = undefined): TreeModel { + // 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> { + 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() + } + } } diff --git a/src/orm/model/relation/HasOneOrMany.ts b/src/orm/model/relation/HasOneOrMany.ts index a5c4e9d..15cf897 100644 --- a/src/orm/model/relation/HasOneOrMany.ts +++ b/src/orm/model/relation/HasOneOrMany.ts @@ -33,14 +33,24 @@ export abstract class HasOneOrMany, T2 extends Model, 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, T2 extends Model, 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): Collection { - return possiblyRelated.where(this.foreignKey as keyof T, '=', this.parentValue) + return possiblyRelated.where(this.foreignColumn as keyof T, '=', this.parentValue) } } diff --git a/src/orm/model/relation/HasSubtree.ts b/src/orm/model/relation/HasSubtree.ts index 0abe937..e03eb72 100644 --- a/src/orm/model/relation/HasSubtree.ts +++ b/src/orm/model/relation/HasSubtree.ts @@ -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> extends Relation> { + @Inject() + protected readonly logging!: Logging + /** * When the relation is loaded, the immediate children of the node. * @protected @@ -25,12 +31,24 @@ export class HasSubtree> extends Relation { + 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 { + this.setValue(await this.get()) + } + protected get parentValue(): any { return this.model.key() } public query(): RelationBuilder { return this.builder() + .tap(b => this.model.applyScopes(b)) .select(raw('*')) .orderByAscending(this.leftTreeField) } @@ -50,6 +68,9 @@ export class HasSubtree> extends Relation, result: Collection): ModelBuilder { 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> extends Relation { + 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): Collection { + 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> extends Relation): void { + if ( !this.instances ) { + throw new RelationNotLoadedError() + } + + this.instances = this.instances.filter(x => x.isNot(subtree)) + } + 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 @@ -126,6 +167,9 @@ export class HasSubtree> extends Relation child.parentNode().setValue(this.model)) + this.instances = children.sortBy(inst => inst.getOriginalValues()?.[this.leftTreeField]) } diff --git a/src/orm/model/relation/HasTreeParent.ts b/src/orm/model/relation/HasTreeParent.ts new file mode 100644 index 0000000..149e63f --- /dev/null +++ b/src/orm/model/relation/HasTreeParent.ts @@ -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> extends Relation> { + + 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 { + return this.builder() + .tap(b => this.model.applyScopes(b)) + .select(raw('*')) + .orderByAscending(this.leftTreeField) + } + + public applyScope(where: AbstractBuilder): void { + const parentId = this.model.parentId() + if ( !parentId ) { + where.whereMatchNone() + return + } + + where.where(this.parentIdField, '=', parentId) + } + + public buildEagerQuery(parentQuery: ModelBuilder, result: Collection): ModelBuilder { + const parentIds = result.map(model => model.parentId()).whereDefined() + return this.model.query() + .without('subtree') + .whereIn(this.parentIdField, parentIds) + } + + public matchResults(possiblyRelated: Collection): Collection { + return possiblyRelated.filter(related => related.key() === this.model.parentId()) + } + + public setValue(related: Maybe): void { + this.loaded = true + this.parentInstance = related + } + + public getValue(): Maybe { + 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> { + return this.fetch().first() + } +} diff --git a/src/orm/types.ts b/src/orm/types.ts index 75699b6..d872a09 100644 --- a/src/orm/types.ts +++ b/src/orm/types.ts @@ -1,5 +1,5 @@ import { Collection } from '../util' -import {EscapeValue, QuerySafeValue} from './dialect/SQLDialect' +import {QuerySafeValue, VectorEscapeValue} from './dialect/SQLDialect' /** * A single query row, as an object. @@ -40,7 +40,7 @@ export type ConstraintOperator = '&' | '>' | '>=' | '<' | '<=' | '!=' | '<=>' | export interface ConstraintItem { field: string, operator: ConstraintOperator, - operand: EscapeValue, + operand: VectorEscapeValue, preop: ConstraintConnectionOperator, } diff --git a/src/util/collection/Collection.ts b/src/util/collection/Collection.ts index bde42ca..6cf13a3 100644 --- a/src/util/collection/Collection.ts +++ b/src/util/collection/Collection.ts @@ -12,6 +12,7 @@ type DeterminesEquality = (item: CollectionItem, other: any) => boolean type CollectionIndex = number type MaybeCollectionIndex = CollectionIndex | undefined type ComparisonFunction = (item: CollectionItem, otherItem: CollectionItem) => number +type Collectable = CollectionItem[] | Collection import { WhereOperator, applyWhere, whereMatch } from './where' import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, MethodType, right, unright} from '../support/types' @@ -39,6 +40,7 @@ export { CollectionIndex, MaybeCollectionIndex, ComparisonFunction, + Collectable, } /** @@ -61,13 +63,16 @@ class Collection { * Filters out undefined items. * @param itemOrItems */ - public static normalize(itemOrItems: (CollectionItem | undefined)[] | CollectionItem | undefined): Collection { + public static normalize(itemOrItems: Collection | (CollectionItem)[] | CollectionItem): Collection { + if ( itemOrItems instanceof Collection ) { + return itemOrItems + } + if ( !Array.isArray(itemOrItems) ) { itemOrItems = [itemOrItems] } - const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem[] - return new Collection(items) + return new Collection(itemOrItems) } /** @@ -1041,6 +1046,8 @@ class Collection { * @param reducer * @param initialValue */ + reduce(reducer: KeyReducerFunction, initialValue: T2): T2 + reduce(reducer: KeyReducerFunction, initialValue?: T2): T2 | undefined { let currentValue = initialValue this.storedItems.forEach((item, index) => {