From 0a9dd3090965e849e4273e7fe711d9a43cc2c217 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Thu, 11 Nov 2021 16:42:37 -0600 Subject: [PATCH] Implement scopes on models and support interacting with them via ModelBuilder --- src/orm/builder/AbstractBuilder.ts | 15 +++-- src/orm/builder/Builder.ts | 2 +- src/orm/index.ts | 3 + src/orm/model/Model.ts | 83 +++++++++++++++++++++++++++- src/orm/model/ModelBuilder.ts | 69 ++++++++++++++++++++++- src/orm/model/ModelResultIterable.ts | 38 +++++++++++-- src/orm/model/scope/ActiveScope.ts | 11 ++++ src/orm/model/scope/Scope.ts | 18 ++++++ src/util/collection/Iterable.ts | 3 +- 9 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 src/orm/model/scope/ActiveScope.ts create mode 100644 src/orm/model/scope/Scope.ts diff --git a/src/orm/builder/AbstractBuilder.ts b/src/orm/builder/AbstractBuilder.ts index a658025..6d641c2 100644 --- a/src/orm/builder/AbstractBuilder.ts +++ b/src/orm/builder/AbstractBuilder.ts @@ -69,6 +69,13 @@ export abstract class AbstractBuilder extends AppClass { */ public abstract getResultIterable(): AbstractResultIterable + /** + * Get a copy of this builder with its values finalized. + */ + public finalize(): AbstractBuilder { + return this.clone() + } + /** * Clone the current query to a new AbstractBuilder instance with the same properties. */ @@ -489,7 +496,7 @@ export abstract class AbstractBuilder extends AppClass { 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) } @@ -515,7 +522,7 @@ export abstract class AbstractBuilder extends AppClass { 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) } @@ -548,7 +555,7 @@ export abstract class AbstractBuilder extends AppClass { 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) } @@ -560,7 +567,7 @@ export abstract class AbstractBuilder extends AppClass { 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) return Boolean(result.rows.first()) } diff --git a/src/orm/builder/Builder.ts b/src/orm/builder/Builder.ts index e7a758d..ac92a4e 100644 --- a/src/orm/builder/Builder.ts +++ b/src/orm/builder/Builder.ts @@ -19,6 +19,6 @@ export class Builder extends AbstractBuilder { throw new ErrorWithContext(`No connection specified to fetch iterator for query.`) } - return Container.getContainer().make(ResultIterable, this, this.registeredConnection) + return Container.getContainer().make(ResultIterable, this.finalize(), this.registeredConnection) } } diff --git a/src/orm/index.ts b/src/orm/index.ts index 7512bad..5bdb45e 100644 --- a/src/orm/index.ts +++ b/src/orm/index.ts @@ -25,6 +25,9 @@ export * from './model/relation/HasOne' export * from './model/relation/HasMany' export * from './model/relation/decorators' +export * from './model/scope/Scope' +export * from './model/scope/ActiveScope' + export * from './support/SessionModel' export * from './support/ORMSession' export * from './support/CacheModel' diff --git a/src/orm/model/Model.ts b/src/orm/model/Model.ts index dafa996..504e51d 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -1,5 +1,5 @@ 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 {ModelBuilder} from './ModelBuilder' import {getFieldsMeta, ModelField} from './Field' @@ -21,6 +21,7 @@ import {Relation, RelationValue} from './relation/Relation' import {HasOne} from './relation/HasOne' import {HasMany} from './relation/HasMany' import {HasOneOrMany} from './relation/HasOneOrMany' +import {Scope, ScopeClosure} from './scope/Scope' /** * Base for classes that are mapped to tables in a database. @@ -87,6 +88,12 @@ export abstract class Model> extends AppClass implements Bus */ protected static masks: string[] = [] + /** + * Relations that should be eager-loaded by default. + * @protected + */ + protected with: (keyof T)[] = [] + /** * The original row fetched from the database. * @protected @@ -105,6 +112,8 @@ export abstract class Model> extends AppClass implements Bus */ public relationCache: Collection<{ accessor: string | symbol, relation: Relation }> = new Collection<{accessor: string | symbol; relation: Relation}>() + protected scopes: Collection<{ accessor: string | Instantiable, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable; scope: ScopeClosure}>() + /** * Get the table name for this model. */ @@ -165,6 +174,14 @@ export abstract class Model> extends AppClass implements Bus 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 } @@ -317,6 +334,12 @@ export abstract class Model> extends AppClass implements Bus builder.field(field.databaseKey) }) + for ( const relation of this.with ) { + builder.with(relation) + } + + builder.withScopes(this.scopes) + return builder } @@ -1013,7 +1036,7 @@ export abstract class Model> extends AppClass implements Bus * @param name * @protected */ - protected getRelation>(name: keyof this): Relation> { + public getRelation>(name: keyof this): Relation> { const relFn = this[name] if ( relFn instanceof Relation ) { @@ -1029,4 +1052,60 @@ export abstract class Model> extends AppClass implements Bus 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 | ScopeClosure): this { + if ( isInstantiable(scope) ) { + if ( !this.hasScope(scope) ) { + this.scopes.push({ + accessor: scope, + scope: builder => (this.make(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 | ScopeClosure): this { + if ( isInstantiable(scope) ) { + if ( !this.hasScope(scope) ) { + this.scopes.push({ + accessor: name, + scope: builder => (this.make(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): boolean { + return Boolean(this.scopes.firstWhere('accessor', '=', name)) + } } diff --git a/src/orm/model/ModelBuilder.ts b/src/orm/model/ModelBuilder.ts index 2de5d5a..a775413 100644 --- a/src/orm/model/ModelBuilder.ts +++ b/src/orm/model/ModelBuilder.ts @@ -6,11 +6,16 @@ import {ModelResultIterable} from './ModelResultIterable' import {Collection} from '../../util' import {ConstraintOperator, ModelKey, ModelKeys} from '../types' 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`. */ export class ModelBuilder> extends AbstractBuilder { + protected eagerLoadRelations: (keyof T)[] = [] + + protected appliedScopes: Collection<{ accessor: string | Instantiable, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable; scope: ScopeClosure}>() + constructor( /** The model class that is created for results of this query. */ protected readonly ModelClass: StaticClass & Instantiable, @@ -18,12 +23,27 @@ export class ModelBuilder> extends AbstractBuilder { super() } + public withScopes(scopes: Collection<{ accessor: string | Instantiable, scope: ScopeClosure }>): this { + this.appliedScopes = scopes.clone() + return this + } + public getNewInstance(): AbstractBuilder { return this.app().make>(ModelBuilder, this.ModelClass) } public getResultIterable(): AbstractResultIterable { - return this.app().make>(ModelResultIterable, this, this.registeredConnection, this.ModelClass) + return this.app().make>(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 { + const inst = super.finalize() + this.appliedScopes.each(rec => rec.scope(inst)) + return inst } /** @@ -52,6 +72,42 @@ export class ModelBuilder> extends AbstractBuilder { ) } + /** + * 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(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: ScopeClosure}>() + return this + } + + /** + * Remove a specific scope from this query by its identifier. + * @param name + */ + public withoutGlobalScope(name: string | Instantiable): 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. * @param keys @@ -66,4 +122,15 @@ export class ModelBuilder> extends AbstractBuilder { return [keys] } + + /** + * Create a copy of this builder. + * @override to add implementation-specific pass-alongs. + */ + public clone(): ModelBuilder { + const inst = super.clone() as ModelBuilder + inst.eagerLoadRelations = [...this.eagerLoadRelations] + inst.appliedScopes = this.appliedScopes.clone() + return inst + } } diff --git a/src/orm/model/ModelResultIterable.ts b/src/orm/model/ModelResultIterable.ts index aa11c2c..5aaec8b 100644 --- a/src/orm/model/ModelResultIterable.ts +++ b/src/orm/model/ModelResultIterable.ts @@ -4,7 +4,7 @@ import {Connection} from '../connection/Connection' import {ModelBuilder} from './ModelBuilder' import {Container, Instantiable} from '../../di' 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. @@ -28,13 +28,17 @@ export class ModelResultIterable> extends AbstractResultItera const row = (await this.connection.query(query)).rows.first() 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> { const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end) - return (await this.connection.query(query)).rows.promiseMap(row => this.inflateRow(row)) + const inflated = await (await this.connection.query(query)).rows.promiseMap(row => this.inflateRow(row)) + await this.processEagerLoads(inflated) + return inflated } async count(): Promise { @@ -45,7 +49,9 @@ export class ModelResultIterable> extends AbstractResultItera async all(): Promise> { const result = await this.connection.query(this.selectSQL) - return result.rows.promiseMap(row => this.inflateRow(row)) + const inflated = await result.rows.promiseMap(row => this.inflateRow(row)) + await this.processEagerLoads(inflated) + return inflated } /** @@ -58,6 +64,30 @@ export class ModelResultIterable> extends AbstractResultItera .assumeFromSource(row) } + /** + * Eager-load eager-loaded relations for the models in the query result. + * @param results + * @protected + */ + protected async processEagerLoads(results: Collection): Promise { + const eagers = this.builder.getEagerLoadedRelations() + const model = this.make(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 { return new ModelResultIterable(this.builder, this.connection, this.ModelClass) } diff --git a/src/orm/model/scope/ActiveScope.ts b/src/orm/model/scope/ActiveScope.ts new file mode 100644 index 0000000..a9ed3a6 --- /dev/null +++ b/src/orm/model/scope/ActiveScope.ts @@ -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): void { + query.where('active', '=', true) + } +} diff --git a/src/orm/model/scope/Scope.ts b/src/orm/model/scope/Scope.ts new file mode 100644 index 0000000..e600729 --- /dev/null +++ b/src/orm/model/scope/Scope.ts @@ -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) => Awaitable + +/** + * Base class for scopes that can be applied to queries. + */ +@Injectable() +export abstract class Scope extends InjectionAware { + + abstract apply(query: AbstractBuilder): Awaitable + +} diff --git a/src/util/collection/Iterable.ts b/src/util/collection/Iterable.ts index a118fa1..7d24e06 100644 --- a/src/util/collection/Iterable.ts +++ b/src/util/collection/Iterable.ts @@ -1,4 +1,5 @@ import {Collection} from './Collection' +import {InjectionAware} from '../../di' export type MaybeIterationItem = { done: boolean, value?: T } export type ChunkCallback = (items: Collection) => any @@ -9,7 +10,7 @@ export class StopIteration extends Error {} * Abstract class representing an iterable, lazy-loaded dataset. * @abstract */ -export abstract class Iterable { +export abstract class Iterable extends InjectionAware { /** * The current index of the iterable. * @type number