Implement scopes on models and support interacting with them via ModelBuilder
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
d92c8b5409
commit
0a9dd30909
@ -69,6 +69,13 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -489,7 +496,7 @@ export abstract class AbstractBuilder<T> 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<T> 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<T> 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<T> 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())
|
||||
}
|
||||
|
@ -19,6 +19,6 @@ export class Builder extends AbstractBuilder<QueryRow> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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<T extends Model<T>> 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<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>}>()
|
||||
|
||||
protected scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; scope: ScopeClosure}>()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
|
||||
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<T extends Model<T>> 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<T extends Model<T>> extends AppClass implements Bus
|
||||
* @param name
|
||||
* @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]
|
||||
|
||||
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.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
|
@ -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<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(
|
||||
/** The model class that is created for results of this query. */
|
||||
protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>,
|
||||
@ -18,12 +23,27 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
super()
|
||||
}
|
||||
|
||||
public withScopes(scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }>): this {
|
||||
this.appliedScopes = scopes.clone()
|
||||
return this
|
||||
}
|
||||
|
||||
public getNewInstance(): AbstractBuilder<T> {
|
||||
return this.app().make<ModelBuilder<T>>(ModelBuilder, this.ModelClass)
|
||||
}
|
||||
|
||||
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.
|
||||
* @param keys
|
||||
@ -66,4 +122,15 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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<T extends Model<T>> 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<Collection<T>> {
|
||||
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> {
|
||||
@ -45,7 +49,9 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
|
||||
|
||||
async all(): Promise<Collection<T>> {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
|
||||
}
|
||||
|
11
src/orm/model/scope/ActiveScope.ts
Normal file
11
src/orm/model/scope/ActiveScope.ts
Normal 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)
|
||||
}
|
||||
}
|
18
src/orm/model/scope/Scope.ts
Normal file
18
src/orm/model/scope/Scope.ts
Normal 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>
|
||||
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import {Collection} from './Collection'
|
||||
import {InjectionAware} from '../../di'
|
||||
|
||||
export type MaybeIterationItem<T> = { done: boolean, value?: T }
|
||||
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
|
||||
*/
|
||||
export abstract class Iterable<T> {
|
||||
export abstract class Iterable<T> extends InjectionAware {
|
||||
/**
|
||||
* The current index of the iterable.
|
||||
* @type number
|
||||
|
Loading…
Reference in New Issue
Block a user