Implement scopes on models and support interacting with them via ModelBuilder
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2021-11-11 16:42:37 -06:00
parent d92c8b5409
commit 0a9dd30909
9 changed files with 229 additions and 13 deletions

View File

@ -69,6 +69,13 @@ export abstract class AbstractBuilder<T> extends AppClass {
*/ */
public abstract getResultIterable(): AbstractResultIterable<T> public abstract getResultIterable(): AbstractResultIterable<T>
/**
* Get a copy of this builder with its values finalized.
*/
public finalize(): AbstractBuilder<T> {
return this.clone()
}
/** /**
* Clone the current query to a new AbstractBuilder instance with the same properties. * Clone the current query to a new AbstractBuilder instance with the same properties.
*/ */
@ -489,7 +496,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`) throw new ErrorWithContext(`No connection specified to execute update query.`)
} }
const query = this.registeredConnection.dialect().renderUpdate(this, data) const query = this.registeredConnection.dialect().renderUpdate(this.finalize(), data)
return this.registeredConnection.query(query) return this.registeredConnection.query(query)
} }
@ -515,7 +522,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`) throw new ErrorWithContext(`No connection specified to execute update query.`)
} }
const query = this.registeredConnection.dialect().renderDelete(this) const query = this.registeredConnection.dialect().renderDelete(this.finalize())
return this.registeredConnection.query(query) return this.registeredConnection.query(query)
} }
@ -548,7 +555,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`) throw new ErrorWithContext(`No connection specified to execute update query.`)
} }
const query = this.registeredConnection.dialect().renderInsert(this, rowOrRows) const query = this.registeredConnection.dialect().renderInsert(this.finalize(), rowOrRows)
return this.registeredConnection.query(query) return this.registeredConnection.query(query)
} }
@ -560,7 +567,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
throw new ErrorWithContext(`No connection specified to execute update query.`) throw new ErrorWithContext(`No connection specified to execute update query.`)
} }
const query = this.registeredConnection.dialect().renderExistential(this) const query = this.registeredConnection.dialect().renderExistential(this.finalize())
const result = await this.registeredConnection.query(query) const result = await this.registeredConnection.query(query)
return Boolean(result.rows.first()) return Boolean(result.rows.first())
} }

View File

@ -19,6 +19,6 @@ export class Builder extends AbstractBuilder<QueryRow> {
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`) throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
} }
return Container.getContainer().make<ResultIterable>(ResultIterable, this, this.registeredConnection) return Container.getContainer().make<ResultIterable>(ResultIterable, this.finalize(), this.registeredConnection)
} }
} }

View File

@ -25,6 +25,9 @@ export * from './model/relation/HasOne'
export * from './model/relation/HasMany' export * from './model/relation/HasMany'
export * from './model/relation/decorators' export * from './model/relation/decorators'
export * from './model/scope/Scope'
export * from './model/scope/ActiveScope'
export * from './support/SessionModel' export * from './support/SessionModel'
export * from './support/ORMSession' export * from './support/ORMSession'
export * from './support/CacheModel' export * from './support/CacheModel'

View File

@ -1,5 +1,5 @@
import {ModelKey, QueryRow, QuerySource} from '../types' import {ModelKey, QueryRow, QuerySource} from '../types'
import {Container, Inject, Instantiable, StaticClass} from '../../di' import {Container, Inject, Instantiable, isInstantiable, StaticClass} 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'
@ -21,6 +21,7 @@ import {Relation, RelationValue} from './relation/Relation'
import {HasOne} from './relation/HasOne' import {HasOne} from './relation/HasOne'
import {HasMany} from './relation/HasMany' import {HasMany} from './relation/HasMany'
import {HasOneOrMany} from './relation/HasOneOrMany' import {HasOneOrMany} from './relation/HasOneOrMany'
import {Scope, ScopeClosure} from './scope/Scope'
/** /**
* Base for classes that are mapped to tables in a database. * Base for classes that are mapped to tables in a database.
@ -87,6 +88,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
*/ */
protected static masks: string[] = [] protected static masks: string[] = []
/**
* Relations that should be eager-loaded by default.
* @protected
*/
protected with: (keyof T)[] = []
/** /**
* The original row fetched from the database. * The original row fetched from the database.
* @protected * @protected
@ -105,6 +112,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
*/ */
public relationCache: Collection<{ accessor: string | symbol, relation: Relation<T, any, any> }> = new Collection<{accessor: string | symbol; relation: Relation<T, any, any>}>() public relationCache: Collection<{ accessor: string | symbol, relation: Relation<T, any, any> }> = new Collection<{accessor: string | symbol; relation: Relation<T, any, any>}>()
protected scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
/** /**
* Get the table name for this model. * Get the table name for this model.
*/ */
@ -165,6 +174,14 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
builder.field(field.databaseKey) builder.field(field.databaseKey)
}) })
for ( const relation of this.prototype.with ) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
builder.with(relation)
}
builder.withScopes(this.prototype.scopes)
return builder return builder
} }
@ -317,6 +334,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
builder.field(field.databaseKey) builder.field(field.databaseKey)
}) })
for ( const relation of this.with ) {
builder.with(relation)
}
builder.withScopes(this.scopes)
return builder return builder
} }
@ -1013,7 +1036,7 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
* @param name * @param name
* @protected * @protected
*/ */
protected getRelation<T2 extends Model<T2>>(name: keyof this): Relation<T, T2, RelationValue<T2>> { public getRelation<T2 extends Model<T2>>(name: keyof this): Relation<T, T2, RelationValue<T2>> {
const relFn = this[name] const relFn = this[name]
if ( relFn instanceof Relation ) { if ( relFn instanceof Relation ) {
@ -1029,4 +1052,60 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`) throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`)
} }
/**
* Register a scope on the model.
* @param scope
* @protected
*/
protected scope(scope: Instantiable<Scope> | ScopeClosure): this {
if ( isInstantiable(scope) ) {
if ( !this.hasScope(scope) ) {
this.scopes.push({
accessor: scope,
scope: builder => (this.make<Scope>(scope)).apply(builder),
})
}
} else {
this.scopes.push({
accessor: uuid4(),
scope,
})
}
return this
}
/**
* Register a scope on the model with a specific name.
* @param name
* @param scope
* @protected
*/
protected namedScope(name: string, scope: Instantiable<Scope> | ScopeClosure): this {
if ( isInstantiable(scope) ) {
if ( !this.hasScope(scope) ) {
this.scopes.push({
accessor: name,
scope: builder => (this.make<Scope>(scope)).apply(builder),
})
}
} else {
this.scopes.push({
accessor: name,
scope,
})
}
return this
}
/**
* Returns true if the current model has a scope with the given identifier.
* @param name
* @protected
*/
protected hasScope(name: string | Instantiable<Scope>): boolean {
return Boolean(this.scopes.firstWhere('accessor', '=', name))
}
} }

View File

@ -6,11 +6,16 @@ import {ModelResultIterable} from './ModelResultIterable'
import {Collection} from '../../util' import {Collection} from '../../util'
import {ConstraintOperator, ModelKey, ModelKeys} from '../types' import {ConstraintOperator, ModelKey, ModelKeys} from '../types'
import {EscapeValue} from '../dialect/SQLDialect' import {EscapeValue} from '../dialect/SQLDialect'
import {Scope, ScopeClosure} from './scope/Scope'
/** /**
* Implementation of the abstract builder whose results yield instances of a given Model, `T`. * Implementation of the abstract builder whose results yield instances of a given Model, `T`.
*/ */
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> { export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
protected eagerLoadRelations: (keyof T)[] = []
protected appliedScopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
constructor( constructor(
/** The model class that is created for results of this query. */ /** The model class that is created for results of this query. */
protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>, protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>,
@ -18,12 +23,27 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
super() super()
} }
public withScopes(scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }>): this {
this.appliedScopes = scopes.clone()
return this
}
public getNewInstance(): AbstractBuilder<T> { public getNewInstance(): AbstractBuilder<T> {
return this.app().make<ModelBuilder<T>>(ModelBuilder, this.ModelClass) return this.app().make<ModelBuilder<T>>(ModelBuilder, this.ModelClass)
} }
public getResultIterable(): AbstractResultIterable<T> { public getResultIterable(): AbstractResultIterable<T> {
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass) return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this.finalize(), this.registeredConnection, this.ModelClass)
}
/**
* Get a copy of this builder with all of its values finalized.
* @override to apply scopes
*/
public finalize(): AbstractBuilder<T> {
const inst = super.finalize()
this.appliedScopes.each(rec => rec.scope(inst))
return inst
} }
/** /**
@ -52,6 +72,42 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
) )
} }
/**
* Mark a relation to be eager-loaded.
* @param relationName
*/
public with(relationName: keyof T): this {
if ( !this.eagerLoadRelations.includes(relationName) ) {
// Try to load the Relation so we fail if the name is invalid
this.make<T>(this.ModelClass).getRelation(relationName)
this.eagerLoadRelations.push(relationName)
}
return this
}
/**
* Remove all global scopes from this query.
*/
public withoutGlobalScopes(): this {
this.appliedScopes = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
return this
}
/**
* Remove a specific scope from this query by its identifier.
* @param name
*/
public withoutGlobalScope(name: string | Instantiable<Scope>): this {
this.appliedScopes = this.appliedScopes.where('accessor', '=', name)
return this
}
/** Get the list of relations to eager-load. */
public getEagerLoadedRelations(): (keyof T)[] {
return [...this.eagerLoadRelations]
}
/** /**
* Given some format of keys of the model, try to normalize them to a flat array. * Given some format of keys of the model, try to normalize them to a flat array.
* @param keys * @param keys
@ -66,4 +122,15 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
return [keys] return [keys]
} }
/**
* Create a copy of this builder.
* @override to add implementation-specific pass-alongs.
*/
public clone(): ModelBuilder<T> {
const inst = super.clone() as ModelBuilder<T>
inst.eagerLoadRelations = [...this.eagerLoadRelations]
inst.appliedScopes = this.appliedScopes.clone()
return inst
}
} }

View File

@ -4,7 +4,7 @@ import {Connection} from '../connection/Connection'
import {ModelBuilder} from './ModelBuilder' import {ModelBuilder} from './ModelBuilder'
import {Container, Instantiable} from '../../di' import {Container, Instantiable} from '../../di'
import {QueryRow} from '../types' import {QueryRow} from '../types'
import {Collection} from '../../util' import {collect, Collection} from '../../util'
/** /**
* Implementation of the result iterable that returns query results as instances of the defined model. * Implementation of the result iterable that returns query results as instances of the defined model.
@ -28,13 +28,17 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
const row = (await this.connection.query(query)).rows.first() const row = (await this.connection.query(query)).rows.first()
if ( row ) { if ( row ) {
return this.inflateRow(row) const inflated = await this.inflateRow(row)
await this.processEagerLoads(collect([inflated]))
return inflated
} }
} }
async range(start: number, end: number): Promise<Collection<T>> { async range(start: number, end: number): Promise<Collection<T>> {
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end) const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
return (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row)) const inflated = await (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row))
await this.processEagerLoads(inflated)
return inflated
} }
async count(): Promise<number> { async count(): Promise<number> {
@ -45,7 +49,9 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
async all(): Promise<Collection<T>> { async all(): Promise<Collection<T>> {
const result = await this.connection.query(this.selectSQL) const result = await this.connection.query(this.selectSQL)
return result.rows.promiseMap<T>(row => this.inflateRow(row)) const inflated = await result.rows.promiseMap<T>(row => this.inflateRow(row))
await this.processEagerLoads(inflated)
return inflated
} }
/** /**
@ -58,6 +64,30 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
.assumeFromSource(row) .assumeFromSource(row)
} }
/**
* Eager-load eager-loaded relations for the models in the query result.
* @param results
* @protected
*/
protected async processEagerLoads(results: Collection<T>): Promise<void> {
const eagers = this.builder.getEagerLoadedRelations()
const model = this.make<T>(this.ModelClass)
for ( const name of eagers ) {
// TODO support nested eager loads?
const relation = model.getRelation(name)
const select = relation.buildEagerQuery(this.builder, results)
const allRelated = await select.get().collect()
allRelated.each(result => {
const resultRelation = result.getRelation(name as any)
const resultRelated = resultRelation.matchResults(allRelated as any)
resultRelation.setValue(resultRelated as any)
})
}
}
clone(): ModelResultIterable<T> { clone(): ModelResultIterable<T> {
return new ModelResultIterable(this.builder, this.connection, this.ModelClass) return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
} }

View File

@ -0,0 +1,11 @@
import {Scope} from './Scope'
import {AbstractBuilder} from '../../builder/AbstractBuilder'
/**
* A basic scope to limit results where `active` = true.
*/
export class ActiveScope extends Scope {
apply(query: AbstractBuilder<any>): void {
query.where('active', '=', true)
}
}

View File

@ -0,0 +1,18 @@
import {Injectable, InjectionAware} from '../../../di'
import {Awaitable} from '../../../util'
import {AbstractBuilder} from '../../builder/AbstractBuilder'
/**
* A closure that takes a query and applies some scope to it.
*/
export type ScopeClosure = (query: AbstractBuilder<any>) => Awaitable<void>
/**
* Base class for scopes that can be applied to queries.
*/
@Injectable()
export abstract class Scope extends InjectionAware {
abstract apply(query: AbstractBuilder<any>): Awaitable<void>
}

View File

@ -1,4 +1,5 @@
import {Collection} from './Collection' import {Collection} from './Collection'
import {InjectionAware} from '../../di'
export type MaybeIterationItem<T> = { done: boolean, value?: T } export type MaybeIterationItem<T> = { done: boolean, value?: T }
export type ChunkCallback<T> = (items: Collection<T>) => any export type ChunkCallback<T> = (items: Collection<T>) => any
@ -9,7 +10,7 @@ export class StopIteration extends Error {}
* Abstract class representing an iterable, lazy-loaded dataset. * Abstract class representing an iterable, lazy-loaded dataset.
* @abstract * @abstract
*/ */
export abstract class Iterable<T> { export abstract class Iterable<T> extends InjectionAware {
/** /**
* The current index of the iterable. * The current index of the iterable.
* @type number * @type number