Basic support for lazy loaded relations

This commit is contained in:
garrettmills 2020-08-09 17:22:58 -05:00
parent b5bde7d077
commit 2fd3c6c22b
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E
11 changed files with 291 additions and 3 deletions

View File

@ -5,7 +5,7 @@ import {Statement} from './Statement.ts'
import {Update} from './type/Update.ts' import {Update} from './type/Update.ts'
import {Insert} from './type/Insert.ts' import {Insert} from './type/Insert.ts'
import {Delete} from './type/Delete.ts' import {Delete} from './type/Delete.ts'
import {Truncate} from "./type/Truncate.ts"; import {Truncate} from './type/Truncate.ts'
export function raw(value: string) { export function raw(value: string) {
return new RawValue(value) return new RawValue(value)

View File

@ -5,6 +5,7 @@ import {apply_filter_to_where, QueryFilter} from '../../model/filter.ts'
import {Scope} from '../Scope.ts' import {Scope} from '../Scope.ts'
import {FunctionScope, ScopeFunction} from '../scope/FunctionScope.ts' import {FunctionScope, ScopeFunction} from '../scope/FunctionScope.ts'
import {make} from '../../../../di/src/global.ts' import {make} from '../../../../di/src/global.ts'
import RawValue from '../RawValue.ts'
export class WhereBuilder { export class WhereBuilder {
protected _wheres: WhereStatement[] = [] protected _wheres: WhereStatement[] = []
@ -68,6 +69,16 @@ export class WhereBuilder {
return this 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) { whereIn(field: string, values: EscapedValue) {
this._wheres.push({ this._wheres.push({
field, field,

View File

@ -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 WhereClause = { field: string, operator: SQLWhereOperator, operand: string, preop: WherePreOperator }
export type WhereGroup = { items: WhereStatement[], preop: WherePreOperator } export type WhereGroup = { items: WhereStatement[], preop: WherePreOperator }
export type WhereStatement = WhereClause | WhereGroup 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 OrderDirection = 'ASC' | 'DESC'
export type OrderStatement = { direction: OrderDirection, field: string } export type OrderStatement = { direction: OrderDirection, field: string }

View File

@ -12,6 +12,9 @@ import {QueryFilter} from './filter.ts'
import {BehaviorSubject} from '../../../lib/src/support/BehaviorSubject.ts' import {BehaviorSubject} from '../../../lib/src/support/BehaviorSubject.ts'
import {Scope} from '../builder/Scope.ts' import {Scope} from '../builder/Scope.ts'
import {JSONState, Rehydratable} from '../../../lib/src/support/Rehydratable.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 separate read/write connections
// TODO manual dirty flags // TODO manual dirty flags
@ -23,11 +26,16 @@ export type ModelJSONState = {
ephemeral_values?: string, ephemeral_values?: string,
} }
export type CachedRelation = {
accessor_name: string | symbol,
relation: HasOne<any, any>, // TODO generalize
}
/** /**
* Base class for database models. * Base class for database models.
* @extends Builder * @extends Builder
*/ */
export abstract class Model<T extends Model<T>> extends Builder<T> implements Rehydratable { abstract class Model<T extends Model<T>> extends Builder<T> implements Rehydratable {
/** /**
* The name of the connection this model should run through. * The name of the connection this model should run through.
* @type string * @type string
@ -140,6 +148,8 @@ export abstract class Model<T extends Model<T>> extends Builder<T> implements Re
*/ */
protected deleted$ = new BehaviorSubject<Model<T>>() protected deleted$ = new BehaviorSubject<Model<T>>()
public readonly relation_cache = new Collection<CachedRelation>()
/** /**
* Get the model table name. * Get the model table name.
* @return string * @return string
@ -148,6 +158,17 @@ export abstract class Model<T extends Model<T>> extends Builder<T> implements Re
return this.table 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. * Get the model connection name.
* @return string * @return string
@ -861,6 +882,25 @@ export abstract class Model<T extends Model<T>> extends Builder<T> implements Re
return `${this.table_name()}.${this.key}` 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. * Like to_object, but only the fields that have changed.
* @return object * @return object
@ -912,4 +952,26 @@ export abstract class Model<T extends Model<T>> extends Builder<T> implements Re
this.assume(JSON.parse(state.ephemeral_values)) 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<T>(related: Instantiable<T>, 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<T>(related: Instantiable<T>, foreign_key?: string, local_key?: string) {
return new HasMany(this as any, new related() as any, foreign_key, local_key)
}
}
export { Model }

View File

@ -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<T extends Model<T>> extends ModelResultOperator<T> {
constructor(
protected ModelClass: Instantiable<T>,
protected relation: HasOne<any, T>,
) {
super(ModelClass)
}
inflate_row(row: QueryRow): T {
const instance = super.inflate_row(row)
return instance
}
}

View File

@ -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<T extends Model<T>, T2 extends Model<T2>> extends HasOneOrMany<T, T2> {
public async get(): Promise<Collection<T2>> {
return this.fetch().collect()
}
}

View File

@ -0,0 +1,8 @@
import {Model} from '../Model.ts'
import {HasOneOrMany} from './HasOneOrMany.ts'
export class HasOne<T extends Model<T>, T2 extends Model<T2>> extends HasOneOrMany<T, T2> {
public async get(): Promise<T2 | undefined> {
return this.fetch().first()
}
}

View File

@ -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<T extends Model<T>, T2 extends Model<T2>> extends Relation<T, T2> {
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<T2> {
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')
})
}
}

View File

@ -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> = T | Collection<T> | undefined
export abstract class Relation<T extends Model<T>, T2 extends Model<T2>> 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<T2>
public abstract scope_query(where: WhereBuilder): void
public fetch(): AsyncCollection<T2> {
return this.query().results()
}
public abstract get(): Promise<RelationResult<T2>>
public then(callback: (result: RelationResult<T2>) => 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<T2> {
return new RelationBuilder(this)
}
public select(...fields: FieldSet[]): Select<T2> {
if ( fields.length < 1 ) fields.push(this.related.qualify_column('*'))
return this.builder().select(...fields)
}
public update(): Update<T2> {
return this.builder().update()
}
public delete(): Delete<T2> {
return this.builder().delete()
}
}

View File

@ -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<T extends Model<T>> extends Builder<T> {
constructor(
protected relation: Relation<any, T>
) {
super()
}
public select(...fields: FieldSet[]): Select<T> {
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<T> {
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<T> {
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
}
}

View File

@ -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<any>
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
}
}
}