From 2fd3c6c22b69aa495d4782a1abc8d5d14cd46b2a Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sun, 9 Aug 2020 17:22:58 -0500 Subject: [PATCH] Basic support for lazy loaded relations --- orm/src/builder/Builder.ts | 2 +- orm/src/builder/type/WhereBuilder.ts | 11 ++++ orm/src/builder/types.ts | 2 +- orm/src/model/Model.ts | 64 +++++++++++++++++++++- orm/src/model/RelationResultOperator.ts | 19 +++++++ orm/src/model/relation/HasMany.ts | 9 ++++ orm/src/model/relation/HasOne.ts | 8 +++ orm/src/model/relation/HasOneOrMany.ts | 45 ++++++++++++++++ orm/src/model/relation/Relation.ts | 66 +++++++++++++++++++++++ orm/src/model/relation/RelationBuilder.ts | 40 ++++++++++++++ orm/src/model/relation/decorators.ts | 28 ++++++++++ 11 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 orm/src/model/RelationResultOperator.ts create mode 100644 orm/src/model/relation/HasMany.ts create mode 100644 orm/src/model/relation/HasOne.ts create mode 100644 orm/src/model/relation/HasOneOrMany.ts create mode 100644 orm/src/model/relation/Relation.ts create mode 100644 orm/src/model/relation/RelationBuilder.ts create mode 100644 orm/src/model/relation/decorators.ts diff --git a/orm/src/builder/Builder.ts b/orm/src/builder/Builder.ts index 0b3df1e..f8bddac 100644 --- a/orm/src/builder/Builder.ts +++ b/orm/src/builder/Builder.ts @@ -5,7 +5,7 @@ import {Statement} from './Statement.ts' import {Update} from './type/Update.ts' import {Insert} from './type/Insert.ts' import {Delete} from './type/Delete.ts' -import {Truncate} from "./type/Truncate.ts"; +import {Truncate} from './type/Truncate.ts' export function raw(value: string) { return new RawValue(value) diff --git a/orm/src/builder/type/WhereBuilder.ts b/orm/src/builder/type/WhereBuilder.ts index 89949a9..84c2230 100644 --- a/orm/src/builder/type/WhereBuilder.ts +++ b/orm/src/builder/type/WhereBuilder.ts @@ -5,6 +5,7 @@ import {apply_filter_to_where, QueryFilter} from '../../model/filter.ts' import {Scope} from '../Scope.ts' import {FunctionScope, ScopeFunction} from '../scope/FunctionScope.ts' import {make} from '../../../../di/src/global.ts' +import RawValue from '../RawValue.ts' export class WhereBuilder { protected _wheres: WhereStatement[] = [] @@ -68,6 +69,16 @@ export class WhereBuilder { return this } + whereRaw(field: string, operator: SQLWhereOperator, operand: string) { + this._createWhere('AND', field, operator, new RawValue(operand)) + return this + } + + orWhereRaw(field: string, operator: SQLWhereOperator, operand: string) { + this._createWhere('OR', field, operator, new RawValue(operand)) + return this + } + whereIn(field: string, values: EscapedValue) { this._wheres.push({ field, diff --git a/orm/src/builder/types.ts b/orm/src/builder/types.ts index 02eba23..e0a18a6 100644 --- a/orm/src/builder/types.ts +++ b/orm/src/builder/types.ts @@ -10,7 +10,7 @@ export type WherePreOperator = 'AND' | 'OR' | 'AND NOT' | 'OR NOT' export type WhereClause = { field: string, operator: SQLWhereOperator, operand: string, preop: WherePreOperator } export type WhereGroup = { items: WhereStatement[], preop: WherePreOperator } export type WhereStatement = WhereClause | WhereGroup -export type SQLWhereOperator = WhereOperator | 'IN' | 'NOT IN' | 'LIKE' | 'BETWEEN' | 'NOT BETWEEN' +export type SQLWhereOperator = WhereOperator | 'IN' | 'NOT IN' | 'LIKE' | 'BETWEEN' | 'NOT BETWEEN' | 'IS' | 'IS NOT' export type OrderDirection = 'ASC' | 'DESC' export type OrderStatement = { direction: OrderDirection, field: string } diff --git a/orm/src/model/Model.ts b/orm/src/model/Model.ts index 91bccf6..3707e58 100644 --- a/orm/src/model/Model.ts +++ b/orm/src/model/Model.ts @@ -12,6 +12,9 @@ import {QueryFilter} from './filter.ts' import {BehaviorSubject} from '../../../lib/src/support/BehaviorSubject.ts' import {Scope} from '../builder/Scope.ts' import {JSONState, Rehydratable} from '../../../lib/src/support/Rehydratable.ts' +import {HasOne} from './relation/HasOne.ts' +import Instantiable from '../../../di/src/type/Instantiable.ts' +import {HasMany} from './relation/HasMany.ts' // TODO separate read/write connections // TODO manual dirty flags @@ -23,11 +26,16 @@ export type ModelJSONState = { ephemeral_values?: string, } +export type CachedRelation = { + accessor_name: string | symbol, + relation: HasOne, // TODO generalize +} + /** * Base class for database models. * @extends Builder */ -export abstract class Model> extends Builder implements Rehydratable { +abstract class Model> extends Builder implements Rehydratable { /** * The name of the connection this model should run through. * @type string @@ -140,6 +148,8 @@ export abstract class Model> extends Builder implements Re */ protected deleted$ = new BehaviorSubject>() + public readonly relation_cache = new Collection() + /** * Get the model table name. * @return string @@ -148,6 +158,17 @@ export abstract class Model> extends Builder implements Re return this.table } + /** + * Get the query source for this model. + * @return QuerySource + */ + public static query_source(): QuerySource { + return { + ref: this.table, + alias: this.table, + } + } + /** * Get the model connection name. * @return string @@ -861,6 +882,25 @@ export abstract class Model> extends Builder implements Re return `${this.table_name()}.${this.key}` } + /** + * Given a column name, qualify it with this model's table name. + * @param {string} name + * @return string + */ + public static qualify_column(name: string) { + return `${this.table_name()}.${name}` + } + + /** + * Given a column name, qualify it with this model's table name. + * @param {string} name + * @return string + */ + public qualify_column(name: string) { + const constructor = this.constructor as typeof Model + return constructor.qualify_column(name) + } + /** * Like to_object, but only the fields that have changed. * @return object @@ -912,4 +952,26 @@ export abstract class Model> extends Builder implements Re this.assume(JSON.parse(state.ephemeral_values)) } } + + /** + * Get a new HasOne relation for this model. + * @param {typeof Model} related - related model class + * @param {string} [foreign_key] - the key to match on the foreign table + * @param {string} [local_key] - the key to match on the local table + */ + public has_one(related: Instantiable, foreign_key?: string, local_key?: string) { + return new HasOne(this as any, new related() as any, foreign_key, local_key) + } + + /** + * Get a new HasMany relation for this model. + * @param {typeof Model} related - related model class + * @param {string} [foreign_key] - the key to match on the foreign table + * @param {string} [local_key] - the key to match on the local table + */ + public has_many(related: Instantiable, foreign_key?: string, local_key?: string) { + return new HasMany(this as any, new related() as any, foreign_key, local_key) + } } + +export { Model } diff --git a/orm/src/model/RelationResultOperator.ts b/orm/src/model/RelationResultOperator.ts new file mode 100644 index 0000000..f6907a2 --- /dev/null +++ b/orm/src/model/RelationResultOperator.ts @@ -0,0 +1,19 @@ +import ModelResultOperator from "./ModelResultOperator.ts"; +import {Model} from "./Model.ts"; +import Instantiable from "../../../di/src/type/Instantiable.ts"; +import {HasOne} from "./relation/HasOne.ts"; +import {QueryRow} from "../db/types.ts"; + +export default class RelationResultOperator> extends ModelResultOperator { + constructor( + protected ModelClass: Instantiable, + protected relation: HasOne, + ) { + super(ModelClass) + } + + inflate_row(row: QueryRow): T { + const instance = super.inflate_row(row) + return instance + } +} diff --git a/orm/src/model/relation/HasMany.ts b/orm/src/model/relation/HasMany.ts new file mode 100644 index 0000000..7cd7a71 --- /dev/null +++ b/orm/src/model/relation/HasMany.ts @@ -0,0 +1,9 @@ +import {Model} from '../Model.ts' +import {HasOneOrMany} from './HasOneOrMany.ts' +import {Collection} from '../../../../lib/src/collection/Collection.ts' + +export class HasMany, T2 extends Model> extends HasOneOrMany { + public async get(): Promise> { + return this.fetch().collect() + } +} diff --git a/orm/src/model/relation/HasOne.ts b/orm/src/model/relation/HasOne.ts new file mode 100644 index 0000000..6239775 --- /dev/null +++ b/orm/src/model/relation/HasOne.ts @@ -0,0 +1,8 @@ +import {Model} from '../Model.ts' +import {HasOneOrMany} from './HasOneOrMany.ts' + +export class HasOne, T2 extends Model> extends HasOneOrMany { + public async get(): Promise { + return this.fetch().first() + } +} diff --git a/orm/src/model/relation/HasOneOrMany.ts b/orm/src/model/relation/HasOneOrMany.ts new file mode 100644 index 0000000..bbf3edb --- /dev/null +++ b/orm/src/model/relation/HasOneOrMany.ts @@ -0,0 +1,45 @@ +import {Model} from "../Model.ts"; +import {Relation} from "./Relation.ts"; +import ConnectionExecutable from "../../builder/type/ConnectionExecutable.ts"; +import {WhereBuilder} from "../../builder/type/WhereBuilder.ts"; + +export abstract class HasOneOrMany, T2 extends Model> extends Relation { + constructor( + protected parent: T, + public readonly related: T2, + protected foreign_key_spec?: string, + protected local_key_spec?: string, + ) { super(parent, related) } + + public get foreign_key() { + return this.foreign_key_spec || this.parent.key_name() + } + + public get local_key() { + return this.local_key_spec || this.foreign_key + } + + public get qualified_foreign_key() { + return this.related.qualify_column(this.foreign_key) + } + + public get qualified_local_key() { + return this.related.qualify_column(this.local_key) + } + + protected get parent_value() { + // @ts-ignore + return this.parent[this.local_key] + } + + public query(): ConnectionExecutable { + return this.builder().select('*') + } + + public scope_query(where: WhereBuilder) { + where.where(where => { + where.where(this.qualified_foreign_key, '=', this.parent_value) + .whereRaw(this.qualified_foreign_key, 'IS NOT', 'NULL') + }) + } +} diff --git a/orm/src/model/relation/Relation.ts b/orm/src/model/relation/Relation.ts new file mode 100644 index 0000000..4484db9 --- /dev/null +++ b/orm/src/model/relation/Relation.ts @@ -0,0 +1,66 @@ +import {Model} from '../Model.ts' +import AppClass from '../../../../lib/src/lifecycle/AppClass.ts' +import RelationResultOperator from '../RelationResultOperator.ts' +import ConnectionExecutable from '../../builder/type/ConnectionExecutable.ts' +import {AsyncCollection} from '../../../../lib/src/collection/AsyncCollection.ts' +import {Collection} from '../../../../lib/src/collection/Collection.ts' +import {WhereBuilder} from '../../builder/type/WhereBuilder.ts' +import {RelationBuilder} from './RelationBuilder.ts' +import {Select} from "../../builder/type/Select.ts"; +import {FieldSet} from "../../builder/types.ts"; +import {Update} from "../../builder/type/Update.ts"; +import {Delete} from "../../builder/type/Delete.ts"; + +export type RelationResult = T | Collection | undefined + +export abstract class Relation, T2 extends Model> extends AppClass { + constructor( + protected parent: T, + public readonly related: T2, + ) { + super() + } + + protected abstract get parent_value(): any + + public get_operator() { + const related_class = this.related.constructor as typeof Model + return this.make(RelationResultOperator, related_class, this) + } + + public abstract query(): ConnectionExecutable + + public abstract scope_query(where: WhereBuilder): void + + public fetch(): AsyncCollection { + return this.query().results() + } + + public abstract get(): Promise> + + public then(callback: (result: RelationResult) => any) { + this.get().then(callback) + } + + public get related_query_source() { + const related_class = this.related.constructor as typeof Model + return related_class.query_source() + } + + public builder(): RelationBuilder { + return new RelationBuilder(this) + } + + public select(...fields: FieldSet[]): Select { + if ( fields.length < 1 ) fields.push(this.related.qualify_column('*')) + return this.builder().select(...fields) + } + + public update(): Update { + return this.builder().update() + } + + public delete(): Delete { + return this.builder().delete() + } +} diff --git a/orm/src/model/relation/RelationBuilder.ts b/orm/src/model/relation/RelationBuilder.ts new file mode 100644 index 0000000..b1312b0 --- /dev/null +++ b/orm/src/model/relation/RelationBuilder.ts @@ -0,0 +1,40 @@ +import {Builder} from '../../builder/Builder.ts' +import {Select} from '../../builder/type/Select.ts' +import {Relation} from './Relation.ts' +import {Update} from '../../builder/type/Update.ts' +import {FieldSet, QuerySource} from '../../builder/types.ts' +import {Delete} from '../../builder/type/Delete.ts' +import {Model} from '../Model.ts' + +export class RelationBuilder> extends Builder { + constructor( + protected relation: Relation + ) { + super() + } + + public select(...fields: FieldSet[]): Select { + const select = this.relation.related.select(...fields) + .from(this.relation.related_query_source) + + select.target_operator(this.relation.get_operator()) + this.relation.scope_query(select) + return select + } + + public update(target?: QuerySource, alias?: string): Update { + const update = this.relation.related.update(this.relation.related_query_source) + + update.target_operator(this.relation.get_operator()) + this.relation.scope_query(update) + return update + } + + public delete(target?: QuerySource, alias?: string): Delete { + const delete_query = this.relation.related.delete(this.relation.related_query_source) + + delete_query.target_operator(this.relation.get_operator()) + this.relation.scope_query(delete_query) + return delete_query + } +} diff --git a/orm/src/model/relation/decorators.ts b/orm/src/model/relation/decorators.ts new file mode 100644 index 0000000..0a20a13 --- /dev/null +++ b/orm/src/model/relation/decorators.ts @@ -0,0 +1,28 @@ +import {Model} from '../Model.ts' + +export function Relation(): MethodDecorator { + return (target: any, propertyKey, descriptor) => { + console.log('relation decorator', target, propertyKey, descriptor) + // @ts-ignore + const original = descriptor.value + + // @ts-ignore + descriptor.value = function(...args) { + const model = this as Model + const cache = model.relation_cache + + const existing_relation = cache.firstWhere('accessor_name', '=', propertyKey) + if ( existing_relation ) return existing_relation.relation + + // @ts-ignore + const value = original.apply(this, args) + + cache.push({ + accessor_name: propertyKey, + relation: value, + }) + + return value + } + } +}