finish TreeModel implementation and updateMany builder method

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

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.undodir
# ---> JetBrains # ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # 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 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

View File

@ -1,6 +1,6 @@
{ {
"name": "@extollo/lib", "name": "@extollo/lib",
"version": "0.14.0", "version": "0.14.1",
"description": "The framework library that lifts up your code.", "description": "The framework library that lifts up your code.",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",

View File

@ -9,8 +9,8 @@ import {
SpecifiedField, SpecifiedField,
} from '../types' } from '../types'
import {Connection} from '../connection/Connection' import {Connection} from '../connection/Connection'
import {deepCopy, ErrorWithContext, Maybe} from '../../util' import {Collectable, deepCopy, ErrorWithContext, Maybe} from '../../util'
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect' import {EscapeValue, QuerySafeValue, raw, ScalarEscapeValue, VectorEscapeValue} from '../dialect/SQLDialect'
import {ResultCollection} from './result/ResultCollection' import {ResultCollection} from './result/ResultCollection'
import {AbstractResultIterable} from './result/AbstractResultIterable' import {AbstractResultIterable} from './result/AbstractResultIterable'
import {AppClass} from '../../lifecycle/AppClass' import {AppClass} from '../../lifecycle/AppClass'
@ -310,7 +310,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
* @param field * @param field
* @param values * @param values
*/ */
whereIn(field: string, values: EscapeValue): this { whereIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
this.constraints.push({ this.constraints.push({
field, field,
operator: 'IN', operator: 'IN',
@ -325,7 +325,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
* @param field * @param field
* @param values * @param values
*/ */
whereNotIn(field: string, values: EscapeValue): this { whereNotIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
this.constraints.push({ this.constraints.push({
field, field,
operator: 'NOT IN', operator: 'NOT IN',
@ -340,7 +340,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
* @param field * @param field
* @param values * @param values
*/ */
orWhereIn(field: string, values: EscapeValue): this { orWhereIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
this.constraints.push({ this.constraints.push({
field, field,
operator: 'IN', operator: 'IN',
@ -355,7 +355,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
* @param field * @param field
* @param values * @param values
*/ */
orWhereNotIn(field: string, values: EscapeValue): this { orWhereNotIn<TConstraint extends ScalarEscapeValue>(field: string, values: VectorEscapeValue<TConstraint>): this {
this.constraints.push({ this.constraints.push({
field, field,
operator: 'NOT IN', operator: 'NOT IN',
@ -528,6 +528,35 @@ export abstract class AbstractBuilder<T> extends AppClass {
return this.registeredConnection.query(query) 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<QueryResult> {
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. * Execute a DELETE based on this query.
* *
@ -600,6 +629,15 @@ export abstract class AbstractBuilder<T> extends AppClass {
return Boolean(result.rows.first()) 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. * Set the query manually. Overrides any builder methods.
* @example * @example
@ -615,6 +653,12 @@ export abstract class AbstractBuilder<T> extends AppClass {
return this 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. * Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
* @param preop * @param preop

View File

@ -2,7 +2,7 @@ import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types' import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
import {AbstractBuilder} from '../builder/AbstractBuilder' import {AbstractBuilder} from '../builder/AbstractBuilder'
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder' 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. * An implementation of the SQLDialect specific to PostgreSQL.
@ -14,7 +14,7 @@ export class PostgreSQLDialect extends SQLDialect {
public escape(value: EscapeValue): QuerySafeValue { public escape(value: EscapeValue): QuerySafeValue {
if ( value instanceof QuerySafeValue ) { if ( value instanceof QuerySafeValue ) {
return value 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(',')})`) return new QuerySafeValue(value, `(${value.map(v => this.escape(v)).join(',')})`)
} else if ( String(value).toLowerCase() === 'true' || value === true ) { } else if ( String(value).toLowerCase() === 'true' || value === true ) {
return new QuerySafeValue(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}'`) 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' ) { } else if ( value === null || typeof value === 'undefined' ) {
return new QuerySafeValue(value, 'NULL') return new QuerySafeValue(value, 'NULL')
} else if ( !isNaN(Number(value)) ) {
return new QuerySafeValue(value, String(Number(value)))
} else { } else {
const escaped = value.replace(/'/g, '\'\'') // .replace(/"/g, '\\"').replace(/`/g, '\\`') const escaped = value.replace(/'/g, '\'\'') // .replace(/"/g, '\\"').replace(/`/g, '\\`')
return new QuerySafeValue(value, `'${escaped}'`) return new QuerySafeValue(value, `'${escaped}'`)
@ -154,6 +154,78 @@ export class PostgreSQLDialect extends SQLDialect {
return queryLines.join('\n') return queryLines.join('\n')
} }
public renderBatchUpdate(builder: AbstractBuilder<any>, 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<string> {
return rows.reduce((fields: Collection<string>, row) => {
Object.keys(row).forEach(key => {
if ( !fields.includes(key) ) {
fields.push(key)
}
})
return fields
}, collect<string>())
}
// TODO support FROM, RETURNING // TODO support FROM, RETURNING
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string { public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
const rawSql = builder.appliedRawSql const rawSql = builder.appliedRawSql
@ -181,6 +253,14 @@ export class PostgreSQLDialect extends SQLDialect {
queryLines.push(wheres) 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') return queryLines.join('\n')
} }
@ -298,8 +378,8 @@ export class PostgreSQLDialect extends SQLDialect {
return queryLines.join('\n') return queryLines.join('\n')
} }
public renderConstraints(allConstraints: Constraint[]): string { public renderConstraints(allConstraints: Constraint[], startingLevel = 1): string {
const constraintsToSql = (constraints: Constraint[], level = 1): string => { const constraintsToSql = (constraints: Constraint[], level = startingLevel): string => {
const indent = Array(level * 2).fill(' ') const indent = Array(level * 2).fill(' ')
.join('') .join('')
const statements = [] const statements = []

View File

@ -2,15 +2,18 @@ import {Constraint} from '../types'
import {AbstractBuilder} from '../builder/AbstractBuilder' import {AbstractBuilder} from '../builder/AbstractBuilder'
import {AppClass} from '../../lifecycle/AppClass' import {AppClass} from '../../lifecycle/AppClass'
import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder' import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
import {Collectable, Collection} from '../../util'
/** /** A scalar value which can be interpolated safely into an SQL query. */
* A value which can be escaped to be interpolated into an SQL query. export type ScalarEscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue;
*/
export type EscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue | EscapeValue[] // FIXME | Select<any>
/** /** A list of scalar escape values. */
* Object mapping string field names to EscapeValue items. export type VectorEscapeValue<T extends ScalarEscapeValue> = T[] | Collection<T>
*/
/** All possible escaped query values. */
export type EscapeValue<T extends ScalarEscapeValue = ScalarEscapeValue> = T | VectorEscapeValue<T> // FIXME | Select<any>
/** Object mapping string field names to EscapeValue items. */
export type EscapeValueObject = { [field: string]: EscapeValue } export type EscapeValueObject = { [field: string]: EscapeValue }
/** /**
@ -70,6 +73,15 @@ export abstract class SQLDialect extends AppClass {
*/ */
public abstract renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string; public abstract renderUpdate(builder: AbstractBuilder<any>, 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<any>, primaryKey: string, dataRows: Collectable<{[key: string]: EscapeValue}>): string;
/** /**
* Render the given query builder as a "DELETE ..." query string. * Render the given query builder as a "DELETE ..." query string.
* *

View File

@ -28,6 +28,7 @@ export * from './model/relation/HasOneOrMany'
export * from './model/relation/HasOne' export * from './model/relation/HasOne'
export * from './model/relation/HasMany' export * from './model/relation/HasMany'
export * from './model/relation/HasSubtree' export * from './model/relation/HasSubtree'
export * from './model/relation/HasTreeParent'
export * from './model/relation/decorators' export * from './model/relation/decorators'
export * from './model/scope/Scope' export * from './model/scope/Scope'

View File

@ -3,7 +3,7 @@ import {Container, Inject, Instantiable, isInstantiable} from '../../di'
import {DatabaseService} from '../DatabaseService' import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder' import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field' 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 {EscapeValueObject} from '../dialect/SQLDialect'
import {Logging} from '../../service/Logging' import {Logging} from '../../service/Logging'
import {Connection} from '../connection/Connection' import {Connection} from '../connection/Connection'
@ -96,6 +96,13 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
*/ */
protected originalSourceRow?: QueryRow 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. * Cache of relation instances by property accessor.
* This is used by the `@Relation()` decorator to cache Relation instances. * 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 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 * Given a row from the database, set the properties on this model that correspond to
* fields on that database. * 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. * 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 ctor = this.constructor as typeof Model
const field = getFieldsMeta(this) const field = getFieldsMeta(this)
@ -333,8 +382,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
builder.with(relation) builder.with(relation)
} }
builder.withScopes(this.scopes) this.applyScopes(builder)
return builder return builder
} }
@ -445,6 +493,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
* @param modelKey * @param modelKey
*/ */
public static propertyToColumn(modelKey: string): string { public static propertyToColumn(modelKey: string): string {
console.log('propertyToColumn', modelKey, getFieldsMeta(this), this) // eslint-disable-line no-console
return getFieldsMeta(this) return getFieldsMeta(this)
.firstWhere('modelKey', '=', modelKey)?.databaseKey || modelKey .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] 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()) const data = result.rows.firstWhere(this.keyName(), '=', this.key())
this.logging.debug({updata: data})
if ( data ) { if ( data ) {
await this.assumeFromSource(data) await this.assumeFromSource(data)
} }
delete this.dirtySourceRow
await this.push(new ModelUpdatedEvent<T>(this as any)) await this.push(new ModelUpdatedEvent<T>(this as any))
} else if ( !this.exists() ) { } else if ( !this.exists() ) {
await this.push(new ModelCreatingEvent<T>(this as any)) 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() const data = result.rows.first()
this.logging.debug({inserta: data})
if ( data ) { if ( data ) {
await this.assumeFromSource(data) await this.assumeFromSource(data)
} }
delete this.dirtySourceRow
await this.push(new ModelCreatedEvent<T>(this as any)) 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 { protected get isDirtyCheck(): (field: ModelField) => boolean {
return (field: ModelField) => { 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()}`) 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 => { .unless(ctor.populateKeyOnInsert, fields => {
return fields.where('databaseKey', '!=', this.keyName()) return fields.where('databaseKey', '!=', this.keyName())
}) })
.apply(getFieldsMeta(this)) .apply(getFieldsMeta(this))
.keyMap('databaseKey', inst => (this as any)[inst.modelKey]) .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 related
* @param relationName * @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 relatedInst = this.make(related) as T2
const relation = relatedInst.getRelation(relationName) 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 { protected hasScope(name: string | Instantiable<Scope>): boolean {
return Boolean(this.scopes.firstWhere('accessor', '=', name)) 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 {Model} from './Model'
import {Collection, Maybe} from '../../util' import {collect, Collection, ErrorWithContext, Maybe} from '../../util'
import {HasSubtree} from './relation/HasSubtree' import {HasSubtree} from './relation/HasSubtree'
import {Related} from './relation/decorators' import {Related} from './relation/decorators'
import {HasTreeParent} from './relation/HasTreeParent'
/** /**
* Model implementation with helpers for querying tree-structured data. * 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. */ /** The table column where the right tree number is stored. */
public static readonly rightTreeField = 'right_num' public static readonly rightTreeField = 'right_num'
public static readonly parentIdField = 'parent_id'
/** /**
* @override to eager-load the subtree by default * @override to eager-load the subtree by default
* @protected * @protected
*/ */
protected with: (keyof T)[] = ['subtree'] protected with: (keyof T)[] = ['subtree']
protected removedChildren: Collection<TreeModel<T>> = collect()
/** Get the left tree number for this model. */ /** Get the left tree number for this model. */
public leftTreeNum(): Maybe<number> { public leftTreeNum(): Maybe<number> {
const ctor = this.constructor as typeof TreeModel 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. */ /** Get the right tree number for this model. */
public rightTreeNum(): Maybe<number> { public rightTreeNum(): Maybe<number> {
const ctor = this.constructor as typeof TreeModel 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. */ /** 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. */ /** 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 num = node.leftTreeNum()
const left = this.leftTreeNum() const left = this.leftTreeNum()
const right = this.rightTreeNum() 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) 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. */ /** Get the immediate children of this model. */
public children(): Collection<T> { public children(): Collection<T> {
return this.subtree().getValue() 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 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. */ /** Get the fully-qualified name of the foreign key. */
public get qualifiedForeignKey(): string { 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. */ /** Get the fully-qualified name of the local key. */
public get qualifiedLocalKey(): string { 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. */ /** 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() .all()
return this.related.query() 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. */ /** Given a collection of results, filter out those that are relevant to this relation. */
public matchResults(possiblyRelated: Collection<T>): Collection<T> { 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 {raw} from '../../dialect/SQLDialect'
import {AbstractBuilder} from '../../builder/AbstractBuilder' import {AbstractBuilder} from '../../builder/AbstractBuilder'
import {ModelBuilder} from '../ModelBuilder' 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 * A relation that recursively loads the subtree of a model using
* modified preorder traversal. * modified preorder traversal.
*/ */
@Injectable()
export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collection<T>> { 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. * When the relation is loaded, the immediate children of the node.
* @protected * @protected
@ -25,12 +31,24 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
super(model, model) 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 { protected get parentValue(): any {
return this.model.key() return this.model.key()
} }
public query(): RelationBuilder<T> { public query(): RelationBuilder<T> {
return this.builder() return this.builder()
.tap(b => this.model.applyScopes(b))
.select(raw('*')) .select(raw('*'))
.orderByAscending(this.leftTreeField) .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> { public buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T> {
const query = this.model.query().without('subtree') const query = this.model.query().without('subtree')
this.logging.debug(`Building eager query for parent: ${parentQuery}`)
this.logging.debug(result)
if ( result.isEmpty() ) { if ( result.isEmpty() ) {
return query.whereMatchNone() return query.whereMatchNone()
} }
@ -61,16 +82,20 @@ export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collectio
return return
} }
query.where(where => { query.orWhere(where => {
where.where(this.leftTreeField, '>', left) where.where(this.leftTreeField, '>', left)
.where(this.leftTreeField, '<', right) .where(this.leftTreeField, '<', right)
}) })
}) })
this.logging.debug(`Built eager query: ${query}`)
return query return query
} }
public matchResults(possiblyRelated: Collection<T>): Collection<T> { 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 modelLeft = this.model.leftTreeNum()
const modelRight = this.model.rightTreeNum() const modelRight = this.model.rightTreeNum()
if ( !modelLeft || !modelRight ) { 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 { public setValue(related: Collection<T>): void {
// `related` contains a flat collection of the subtree nodes, ordered by left key ascending // `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 // 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) 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]) 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()
}
}

View File

@ -1,5 +1,5 @@
import { Collection } from '../util' import { Collection } from '../util'
import {EscapeValue, QuerySafeValue} from './dialect/SQLDialect' import {QuerySafeValue, VectorEscapeValue} from './dialect/SQLDialect'
/** /**
* A single query row, as an object. * A single query row, as an object.
@ -40,7 +40,7 @@ export type ConstraintOperator = '&' | '>' | '>=' | '<' | '<=' | '!=' | '<=>' |
export interface ConstraintItem { export interface ConstraintItem {
field: string, field: string,
operator: ConstraintOperator, operator: ConstraintOperator,
operand: EscapeValue, operand: VectorEscapeValue<any>,
preop: ConstraintConnectionOperator, preop: ConstraintConnectionOperator,
} }

View File

@ -12,6 +12,7 @@ type DeterminesEquality<T> = (item: CollectionItem<T>, other: any) => boolean
type CollectionIndex = number type CollectionIndex = number
type MaybeCollectionIndex = CollectionIndex | undefined type MaybeCollectionIndex = CollectionIndex | undefined
type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem<T>) => number type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem<T>) => number
type Collectable<T> = CollectionItem<T>[] | Collection<T>
import { WhereOperator, applyWhere, whereMatch } from './where' import { WhereOperator, applyWhere, whereMatch } from './where'
import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, MethodType, right, unright} from '../support/types' import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, MethodType, right, unright} from '../support/types'
@ -39,6 +40,7 @@ export {
CollectionIndex, CollectionIndex,
MaybeCollectionIndex, MaybeCollectionIndex,
ComparisonFunction, ComparisonFunction,
Collectable,
} }
/** /**
@ -61,13 +63,16 @@ class Collection<T> {
* Filters out undefined items. * Filters out undefined items.
* @param itemOrItems * @param itemOrItems
*/ */
public static normalize<T2>(itemOrItems: (CollectionItem<T2> | undefined)[] | CollectionItem<T2> | undefined): Collection<T2> { public static normalize<T2>(itemOrItems: Collection<T2> | (CollectionItem<T2>)[] | CollectionItem<T2>): Collection<T2> {
if ( itemOrItems instanceof Collection ) {
return itemOrItems
}
if ( !Array.isArray(itemOrItems) ) { if ( !Array.isArray(itemOrItems) ) {
itemOrItems = [itemOrItems] itemOrItems = [itemOrItems]
} }
const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem<T2>[] return new Collection<T2>(itemOrItems)
return new Collection<T2>(items)
} }
/** /**
@ -1041,6 +1046,8 @@ class Collection<T> {
* @param reducer * @param reducer
* @param initialValue * @param initialValue
*/ */
reduce<T2>(reducer: KeyReducerFunction<T, T2>, initialValue: T2): T2
reduce<T2>(reducer: KeyReducerFunction<T, T2>, initialValue?: T2): T2 | undefined { reduce<T2>(reducer: KeyReducerFunction<T, T2>, initialValue?: T2): T2 | undefined {
let currentValue = initialValue let currentValue = initialValue
this.storedItems.forEach((item, index) => { this.storedItems.forEach((item, index) => {