diff --git a/src/di/InjectionAware.ts b/src/di/InjectionAware.ts new file mode 100644 index 0000000..0fc7f3b --- /dev/null +++ b/src/di/InjectionAware.ts @@ -0,0 +1,37 @@ +import {Container} from './Container' +import {TypedDependencyKey} from './types' + +/** + * Base class for Injection-aware classes that automatically + * pass along their configured container to instances created + * via their `make` method. + */ +export class InjectionAware { + private ci: Container + + constructor() { + this.ci = Container.getContainer() + } + + /** Set the container for this instance. */ + public setContainer(ci: Container): this { + this.ci = ci + return this + } + + /** Get the container for this instance. */ + public getContainer(): Container { + return this.ci + } + + /** Instantiate a new injectable using the container. */ + public make(target: TypedDependencyKey, ...parameters: any[]): T { + const inst = this.ci.make(target, ...parameters) + + if ( inst instanceof InjectionAware ) { + inst.setContainer(this.ci) + } + + return inst + } +} diff --git a/src/di/index.ts b/src/di/index.ts index acfa927..17d393e 100644 --- a/src/di/index.ts +++ b/src/di/index.ts @@ -13,3 +13,4 @@ export * from './ScopedContainer' export * from './types' export * from './decorator/injection' +export * from './InjectionAware' diff --git a/src/lifecycle/AppClass.ts b/src/lifecycle/AppClass.ts index e551dce..e5693da 100644 --- a/src/lifecycle/AppClass.ts +++ b/src/lifecycle/AppClass.ts @@ -1,5 +1,5 @@ import {Application} from './Application' -import {Container, DependencyKey, Injectable} from '../di' +import {Container, Injectable, TypedDependencyKey} from '../di' /** * Base type for a class that supports binding methods by string. @@ -43,7 +43,7 @@ export class AppClass { } /** Call the `make()` method on the global container. */ - protected make(target: DependencyKey, ...parameters: any[]): T { + protected make(target: TypedDependencyKey, ...parameters: any[]): T { return this.container().make(target, ...parameters) } diff --git a/src/orm/index.ts b/src/orm/index.ts index 61b8526..7512bad 100644 --- a/src/orm/index.ts +++ b/src/orm/index.ts @@ -18,6 +18,13 @@ export * from './model/ModelResultIterable' export * from './model/events' export * from './model/Model' +export * from './model/relation/RelationBuilder' +export * from './model/relation/Relation' +export * from './model/relation/HasOneOrMany' +export * from './model/relation/HasOne' +export * from './model/relation/HasMany' +export * from './model/relation/decorators' + 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 1ea4b08..dafa996 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -3,7 +3,7 @@ import {Container, Inject, Instantiable, StaticClass} from '../../di' import {DatabaseService} from '../DatabaseService' import {ModelBuilder} from './ModelBuilder' import {getFieldsMeta, ModelField} from './Field' -import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util' +import {deepCopy, Pipe, Collection, Awaitable, uuid4, isKeyof} from '../../util' import {EscapeValueObject} from '../dialect/SQLDialect' import {AppClass} from '../../lifecycle/AppClass' import {Logging} from '../../service/Logging' @@ -17,6 +17,10 @@ import {ModelUpdatedEvent} from './events/ModelUpdatedEvent' import {ModelCreatingEvent} from './events/ModelCreatingEvent' import {ModelCreatedEvent} from './events/ModelCreatedEvent' import {EventBus} from '../../event/EventBus' +import {Relation, RelationValue} from './relation/Relation' +import {HasOne} from './relation/HasOne' +import {HasMany} from './relation/HasMany' +import {HasOneOrMany} from './relation/HasOneOrMany' /** * Base for classes that are mapped to tables in a database. @@ -95,6 +99,12 @@ export abstract class Model> extends AppClass implements Bus */ protected modelEventBusSubscribers: Collection> = new Collection>() + /** + * Cache of relation instances by property accessor. + * This is used by the `@Relation()` decorator to cache Relation instances. + */ + public relationCache: Collection<{ accessor: string | symbol, relation: Relation }> = new Collection<{accessor: string | symbol; relation: Relation}>() + /** * Get the table name for this model. */ @@ -871,4 +881,152 @@ export abstract class Model> extends AppClass implements Bus }, } } + + /** + * Create a new one-to-one relation instance. Should be called from a method on the model: + * + * @example + * ```ts + * class MyModel extends Model { + * @Related() + * public otherModel() { + * return this.hasOne(MyOtherModel) + * } + * } + * ``` + * + * @param related + * @param foreignKeyOverride + * @param localKeyOverride + */ + public hasOne>(related: Instantiable, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasOne { + return new HasOne(this as unknown as T, this.make(related), foreignKeyOverride, localKeyOverride) + } + + + /** + * Create a new one-to-one relation instance. Should be called from a method on the model: + * + * @example + * ```ts + * class MyModel extends Model { + * @Related() + * public otherModels() { + * return this.hasMany(MyOtherModel) + * } + * } + * ``` + * + * @param related + * @param foreignKeyOverride + * @param localKeyOverride + */ + public hasMany>(related: Instantiable, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasMany { + return new HasMany(this as unknown as T, this.make(related), foreignKeyOverride, localKeyOverride) + } + + /** + * Create the inverse of a one-to-one relation. Should be called from a method on the model: + * + * @example + * ```ts + * class MyModel extends Model { + * @Related() + * public otherModel() { + * return this.hasOne(MyOtherModel) + * } + * } + * + * class MyOtherModel extends Model { + * @Related() + * public myModel() { + * return this.belongsToOne(MyModel, 'otherModel') + * } + * } + * ``` + * + * @param related + * @param relationName + */ + public belongsToOne>(related: Instantiable, relationName: keyof T2): HasOne { + const relatedInst = this.make(related) as T2 + const relation = relatedInst.getRelation(relationName) + + if ( !(relation instanceof HasOneOrMany) ) { + throw new TypeError(`Cannot create belongs to one relation. Inverse relation must be HasOneOrMany.`) + } + + const localKey = relation.localKey + const foreignKey = relation.foreignKey + + if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) { + throw new TypeError('Local or foreign keys do not exist on the base model.') + } + + return new HasOne(this as unknown as T, relatedInst, localKey, foreignKey) + } + + + /** + * Create the inverse of a one-to-many relation. Should be called from a method on the model: + * + * @example + * ```ts + * class MyModel extends Model { + * @Related() + * public otherModels() { + * return this.hasMany(MyOtherModel) + * } + * } + * + * class MyOtherModel extends Model { + * @Related() + * public myModels() { + * return this.belongsToMany(MyModel, 'otherModels') + * } + * } + * ``` + * + * @param related + * @param relationName + */ + public belongsToMany>(related: Instantiable, relationName: keyof T2): HasMany { + const relatedInst = this.make(related) as T2 + const relation = relatedInst.getRelation(relationName) + + if ( !(relation instanceof HasOneOrMany) ) { + throw new TypeError(`Cannot create belongs to one relation. Inverse relation must be HasOneOrMany.`) + } + + const localKey = relation.localKey + const foreignKey = relation.foreignKey + + if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) { + throw new TypeError('Local or foreign keys do not exist on the base model.') + } + + return new HasMany(this as unknown as T, relatedInst, localKey, foreignKey) + } + + /** + * Get the relation instance returned by a method on this model. + * @param name + * @protected + */ + protected getRelation>(name: keyof this): Relation> { + const relFn = this[name] + + if ( relFn instanceof Relation ) { + return relFn + } + + if ( typeof relFn === 'function' ) { + const rel = relFn.apply(relFn, this) + if ( rel instanceof Relation ) { + return rel + } + } + + throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`) + } } diff --git a/src/orm/model/ModelBuilder.ts b/src/orm/model/ModelBuilder.ts index c91c544..2de5d5a 100644 --- a/src/orm/model/ModelBuilder.ts +++ b/src/orm/model/ModelBuilder.ts @@ -19,7 +19,7 @@ export class ModelBuilder> extends AbstractBuilder { } public getNewInstance(): AbstractBuilder { - return this.app().make>(ModelBuilder) + return this.app().make>(ModelBuilder, this.ModelClass) } public getResultIterable(): AbstractResultIterable { diff --git a/src/orm/model/relation/HasMany.ts b/src/orm/model/relation/HasMany.ts new file mode 100644 index 0000000..4770723 --- /dev/null +++ b/src/orm/model/relation/HasMany.ts @@ -0,0 +1,47 @@ +import {Model} from '../Model' +import {HasOneOrMany} from './HasOneOrMany' +import {Collection} from '../../../util' +import {RelationNotLoadedError} from './Relation' + +/** + * One-to-many relation implementation. + */ +export class HasMany, T2 extends Model> extends HasOneOrMany> { + protected cachedValue?: Collection + + protected cachedLoaded = false + + constructor( + parent: T, + related: T2, + foreignKeyOverride?: keyof T & string, + localKeyOverride?: keyof T2 & string, + ) { + super(parent, related, foreignKeyOverride, localKeyOverride) + } + + /** Resolve the result of this relation. */ + public get(): Promise> { + return this.fetch().collect() + } + + /** Set the value of this relation. */ + public setValue(related: Collection): void { + this.cachedValue = related.clone() + this.cachedLoaded = true + } + + /** Get the value of this relation. */ + public getValue(): Collection { + if ( !this.cachedValue ) { + throw new RelationNotLoadedError() + } + + return this.cachedValue + } + + /** Returns true if the relation has been loaded. */ + public isLoaded(): boolean { + return this.cachedLoaded + } +} diff --git a/src/orm/model/relation/HasOne.ts b/src/orm/model/relation/HasOne.ts new file mode 100644 index 0000000..4941133 --- /dev/null +++ b/src/orm/model/relation/HasOne.ts @@ -0,0 +1,47 @@ +import {Model} from '../Model' +import {HasOneOrMany} from './HasOneOrMany' +import {RelationNotLoadedError} from './Relation' +import {Maybe} from '../../../util' + +/** + * One-to-one relation implementation. + */ +export class HasOne, T2 extends Model> extends HasOneOrMany> { + protected cachedValue?: T2 + + protected cachedLoaded = false + + constructor( + parent: T, + related: T2, + foreignKeyOverride?: keyof T & string, + localKeyOverride?: keyof T2 & string, + ) { + super(parent, related, foreignKeyOverride, localKeyOverride) + } + + /** Resolve the result of this relation. */ + async get(): Promise> { + return this.fetch().first() + } + + /** Set the value of this relation. */ + public setValue(related: T2): void { + this.cachedValue = related + this.cachedLoaded = true + } + + /** Get the value of this relation. */ + public getValue(): Maybe { + if ( !this.cachedLoaded ) { + throw new RelationNotLoadedError() + } + + return this.cachedValue + } + + /** Returns true if the relation has been loaded. */ + public isLoaded(): boolean { + return this.cachedLoaded + } +} diff --git a/src/orm/model/relation/HasOneOrMany.ts b/src/orm/model/relation/HasOneOrMany.ts new file mode 100644 index 0000000..c800136 --- /dev/null +++ b/src/orm/model/relation/HasOneOrMany.ts @@ -0,0 +1,80 @@ +import {Model} from '../Model' +import {Relation, RelationValue} from './Relation' +import {RelationBuilder} from './RelationBuilder' +import {raw} from '../../dialect/SQLDialect' +import {AbstractBuilder} from '../../builder/AbstractBuilder' +import {ModelBuilder} from '../ModelBuilder' +import {Collection, toString} from '../../../util' + +/** + * Base class for 1:1 and 1:M relations. + */ +export abstract class HasOneOrMany, T2 extends Model, V extends RelationValue> extends Relation { + protected constructor( + parent: T, + related: T2, + + /** Override the foreign key property. */ + protected foreignKeyOverride?: keyof T & string, + + /** Override the local key property. */ + protected localKeyOverride?: keyof T2 & string, + ) { + super(parent, related) + } + + /** Get the name of the foreign key for this relation. */ + public get foreignKey(): string { + return this.foreignKeyOverride || this.parent.keyName() + } + + /** Get the name of the local key for this relation. */ + public get localKey(): string { + return this.localKeyOverride || this.foreignKey + } + + /** Get the fully-qualified name of the foreign key. */ + public get qualifiedForeignKey(): string { + return this.related.qualify(this.foreignKey) + } + + /** Get the fully-qualified name of the local key. */ + public get qualifiedLocalKey(): string { + return this.related.qualify(this.localKey) + } + + /** Get the value of the pivot for this relation from the parent model. */ + public get parentValue(): any { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.parent[this.localKey] + } + + /** Create a new query for this relation. */ + public query(): RelationBuilder { + return this.builder().select(raw('*')) + } + + /** Apply the relation's constraints on a model query. */ + public applyScope(where: AbstractBuilder): void { + where.where(subq => { + subq.where(this.qualifiedForeignKey, '=', this.parentValue) + .whereRaw(this.qualifiedForeignKey, 'IS NOT', 'NULL') + }) + } + + /** Create an eager-load query matching this relation's models. */ + public buildEagerQuery(parentQuery: ModelBuilder, result: Collection): ModelBuilder { + const keys = result.pluck(this.localKey as keyof T) + .map(toString) + .all() + + return this.related.query() + .whereIn(this.foreignKey, keys) + } + + /** Given a collection of results, filter out those that are relevant to this relation. */ + public matchResults(possiblyRelated: Collection): Collection { + return possiblyRelated.where(this.foreignKey as keyof T, '=', this.parentValue) + } +} diff --git a/src/orm/model/relation/Relation.ts b/src/orm/model/relation/Relation.ts new file mode 100644 index 0000000..17a91c4 --- /dev/null +++ b/src/orm/model/relation/Relation.ts @@ -0,0 +1,111 @@ +import {Model} from '../Model' +import {ModelBuilder} from '../ModelBuilder' +import {AbstractBuilder} from '../../builder/AbstractBuilder' +import {ResultCollection} from '../../builder/result/ResultCollection' +import {Collection, ErrorWithContext, Maybe} from '../../../util' +import {QuerySource} from '../../types' +import {RelationBuilder} from './RelationBuilder' +import {InjectionAware} from '../../../di' + +/** Type alias for possible values of a relation. */ +export type RelationValue = Maybe | T2> + +/** Error thrown when a relation result is accessed synchronously before it is loaded. */ +export class RelationNotLoadedError extends ErrorWithContext { + constructor( + context: {[key: string]: any} = {}, + ) { + super('Attempted to get value of relation that has not yet been loaded.', context) + } +} + +/** + * Base class for inter-model relation implementations. + */ +export abstract class Relation, T2 extends Model, V extends RelationValue> extends InjectionAware { + protected constructor( + /** The model related from. */ + protected parent: T, + + /** The model related to. */ + public readonly related: T2, + ) { + super() + } + + /** Get the value of the key field from the parent model. */ + protected abstract get parentValue(): any + + /** Create a new relation builder query for this relation instance. */ + public abstract query(): RelationBuilder + + /** Limit the results of the builder to only this relation's rows. */ + public abstract applyScope(where: AbstractBuilder): void + + /** Create a relation query that will eager-load the result of this relation for a set of models. */ + public abstract buildEagerQuery(parentQuery: ModelBuilder, result: Collection): ModelBuilder + + /** Given a set of possibly-related instances, filter out the ones that are relevant to the parent. */ + public abstract matchResults(possiblyRelated: Collection): Collection + + /** Set the value of the relation. */ + public abstract setValue(related: V): void + + /** Get the value of the relation. */ + public abstract getValue(): V + + /** Returns true if the relation has been loaded. */ + public abstract isLoaded(): boolean + + /** Get a collection of the results of this relation. */ + public fetch(): ResultCollection { + return this.query().get() + } + + /** Resolve the result of this relation. */ + public abstract get(): Promise + + /** + * Makes the relation "thenable" so relation methods on models can be awaited + * to yield the result of the relation. + * + * @example + * ```ts + * const rows = await myModelInstance.myHasManyRelation() -- rows is a Collection + * ``` + * + * @param resolve + * @param reject + */ + public then(resolve: (result: V) => unknown, reject: (e: Error) => unknown): void { + if ( this.isLoaded() ) { + resolve(this.getValue()) + } else { + this.get() + .then(result => { + if ( result instanceof Collection ) { + this.setValue(result) + } + + resolve(result) + }) + .catch(reject) + } + } + + /** Get the value of this relation. */ + public get value(): V { + return this.getValue() + } + + /** Get the query source for the related model in this relation. */ + public get relatedQuerySource(): QuerySource { + const related = this.related.constructor as typeof Model + return related.querySource() + } + + /** Get a new builder instance for this relation. */ + public builder(): RelationBuilder { + return this.make(RelationBuilder, this) + } +} diff --git a/src/orm/model/relation/RelationBuilder.ts b/src/orm/model/relation/RelationBuilder.ts new file mode 100644 index 0000000..8eb5310 --- /dev/null +++ b/src/orm/model/relation/RelationBuilder.ts @@ -0,0 +1,14 @@ +import {Model} from '../Model' +import {ModelBuilder} from '../ModelBuilder' +import {Relation} from './Relation' + +/** + * ModelBuilder instance that queries the related model in a relation. + */ +export class RelationBuilder> extends ModelBuilder { + constructor( + protected relation: Relation, + ) { + super(relation.related.constructor as any) + } +} diff --git a/src/orm/model/relation/decorators.ts b/src/orm/model/relation/decorators.ts new file mode 100644 index 0000000..5c7c41a --- /dev/null +++ b/src/orm/model/relation/decorators.ts @@ -0,0 +1,37 @@ +import {Model} from '../Model' + +/** + * Decorator for relation methods on a Model implementation. + * Caches the relation instances between uses for the life of the model. + * @constructor + */ +export function Related(): MethodDecorator { + return (target, propertyKey, descriptor) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const original = descriptor.value + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + descriptor.value = function(...args) { + const model = this as Model + const cache = model.relationCache + + const existing = cache.firstWhere('accessor', '=', propertyKey) + if ( existing ) { + return existing.relation + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const value = original.apply(this, args) + + cache.push({ + accessor: propertyKey, + relation: value, + }) + + return value + } + } +} diff --git a/src/orm/types.ts b/src/orm/types.ts index 934913d..dcf9f9e 100644 --- a/src/orm/types.ts +++ b/src/orm/types.ts @@ -131,6 +131,7 @@ export enum FieldType { json = 'json', line = 'line', lseg = 'lseg', + ltree = 'ltree', macaddr = 'macaddr', money = 'money', numeric = 'numeric', @@ -189,6 +190,7 @@ export function inverseFieldType(type: FieldType): string { json: 'json', line: 'line', lseg: 'lseg', + ltree: 'ltree', macaddr: 'macaddr', money: 'money', numeric: 'numeric', diff --git a/src/util/collection/Collection.ts b/src/util/collection/Collection.ts index 0ef86f3..ce18547 100644 --- a/src/util/collection/Collection.ts +++ b/src/util/collection/Collection.ts @@ -16,8 +16,11 @@ type ComparisonFunction = (item: CollectionItem, otherItem: CollectionItem import { WhereOperator, applyWhere, whereMatch } from './where' const collect = (items: CollectionItem[]): Collection => Collection.collect(items) +const toString = (item: unknown): string => String(item) + export { collect, + toString, Collection, // Types diff --git a/src/util/support/types.ts b/src/util/support/types.ts index 833d7fb..9c99599 100644 --- a/src/util/support/types.ts +++ b/src/util/support/types.ts @@ -9,3 +9,12 @@ export type ParameterizedCallback = ((arg: T) => any) /** A key-value form of a given type. */ export type KeyValue = {key: string, value: T} + +/** Simple helper method to verify that a key is a keyof some object. */ +export function isKeyof(key: unknown, obj: T): key is keyof T { + if ( typeof key !== 'string' && typeof key !== 'symbol' ) { + return false + } + + return key in obj +}