From c0777f77b529c1418ba4632854ceb9c36f2e6e56 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sun, 19 Jul 2020 18:39:56 -0500 Subject: [PATCH] Flesh out model; add comments --- orm/src/model/Model.ts | 284 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 280 insertions(+), 4 deletions(-) diff --git a/orm/src/model/Model.ts b/orm/src/model/Model.ts index 2fcda14..919b967 100644 --- a/orm/src/model/Model.ts +++ b/orm/src/model/Model.ts @@ -1,76 +1,203 @@ -import { Builder } from '../builder/Builder.ts' +import Database from '../service/Database.ts' +import ModelResultOperator from './ModelResultOperator.ts' +import ObjectResultOperator from '../builder/type/result/ObjectResultOperator.ts' +import {Builder} from '../builder/Builder.ts' import {FieldSet, FieldValueObject, QuerySource} from '../builder/types.ts' import {make} from '../../../di/src/global.ts' -import Database from '../service/Database.ts' import {QueryRow, ModelKey} from '../db/types.ts' -import ModelResultOperator from './ModelResultOperator.ts' import {get_fields_meta, ModelField, set_model_fields_meta} from './Field.ts' import {Collection} from '../../../lib/src/collection/Collection.ts' import {logger} from '../../../lib/src/service/logging/global.ts' -import ObjectResultOperator from '../builder/type/result/ObjectResultOperator.ts' import {QueryFilter} from './filter.ts' import {BehaviorSubject} from '../../../lib/src/support/BehaviorSubject.ts' import {Scope} from '../builder/Scope.ts' // TODO separate read/write connections // TODO manual dirty flags + +/** + * Base class for database models. + * @extends Builder + */ export abstract class Model> extends Builder { + /** + * The name of the connection this model should run through. + * @type string + */ protected static connection: string + + /** + * The name of the table this model is stored in. + * @type string + */ protected static table: string + + /** + * The name of the column that uniquely identifies this model. + * @type string + */ protected static key: string + /** + * Optionally, the timestamp field set on creation. + * @type string + */ protected static readonly CREATED_AT = 'created_at' + + /** + * Optionally, the timestamp field set op update. + * @type string + */ protected static readonly UPDATED_AT = 'updated_at' + + /** + * If true, the CREATED_AT and UPDATED_AT columns will be automatically set. + * @type boolean + */ protected static timestamps = true + /** + * Array of global scopes to be applied to this model. + * @type Scope[] + */ protected static global_scopes: Scope[] = [] + + /** + * Array of additional fields on the class that should + * be included in the object serializations. + * @type string[] + */ protected static appends: string[] = [] + + /** + * Array of fields on the class that should be excluded + * from the object serializations. + * @type string[] + */ protected static masks: string[] = [] + /** + * The original query row that was fetched from the database. + * @type QueryRow + */ protected _original?: QueryRow + /** + * Behavior subject that fires after the model is populated. + */ protected retrieved$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right before the model is saved. + */ protected saving$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right after the model is saved. + */ protected saved$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right before the model is updated. + */ protected updating$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right after the model is updated. + */ protected updated$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right before the model is inserted. + */ protected creating$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right after the model is inserted. + */ protected created$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right before the model is deleted. + */ protected deleting$ = new BehaviorSubject>() + + /** + * Behavior subject that fires right after the model is deleted. + */ protected deleted$ = new BehaviorSubject>() + /** + * Get the model table name. + * @return string + */ public static table_name() { return this.table } + /** + * Get the model connection name. + * @return string + */ public static connection_name() { return this.connection } + /** + * Get the model connection. + * @return Connection + */ public static get_connection() { return make(Database).connection(this.connection_name()) } + /** + * Get a SELECT statement scoped to the model. + * @param {...string[]} fields + * @return Select + */ public static select(...fields: FieldSet[]) { return this.prototype.select(...fields) } + /** + * Get an UPDATE query scoped to this model. + * @return Update + */ public static update() { return this.prototype.update() } + /** + * Get an INSERT query scoped to this model. + * @return Insert + */ public static insert() { return this.prototype.insert() } + /** + * Get a DELETE query scoped to this model. + * @return Delete + */ public static delete() { return this.prototype.delete() } + /** + * Get a TRUNCATE query scoped to this model. + * @return Truncate + */ public static truncate() { return this.prototype.truncate() } + /** + * Get an UPDATE query scoped to this model. + * @param {QuerySource} [target] + * @param {string} [alias] + * @return Update + */ public update(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.update() @@ -80,6 +207,10 @@ export abstract class Model> extends Builder { .target_operator(make(ModelResultOperator, constructor)) } + /** + * Get a SELECT query scoped to this model. + * @param {...FieldSet} fields + */ public select(...fields: FieldSet[]) { const constructor = (this.constructor as typeof Model) return super.select(...fields) @@ -89,6 +220,12 @@ export abstract class Model> extends Builder { .target_operator(make(ModelResultOperator, constructor)) } + /** + * Get an INSERT query scoped to this model. + * @param {QuerySource} [target] + * @param {string} [alias] + * @return Insert + */ public insert(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.insert() @@ -97,6 +234,12 @@ export abstract class Model> extends Builder { .target_operator(make(ModelResultOperator, constructor)) } + /** + * Get a DELETE query scoped to this model. + * @param {QuerySource} [target] + * @param {string} [alias] + * @return Delete + */ public delete(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.delete() @@ -106,6 +249,12 @@ export abstract class Model> extends Builder { .target_operator(make(ModelResultOperator, constructor)) } + /** + * Get a TRUNCATE query scoped to this model. + * @param {QuerySource} [target] + * @param {string} [alias] + * @return Truncate + */ public truncate(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.truncate() @@ -114,6 +263,10 @@ export abstract class Model> extends Builder { .target_operator(make(ObjectResultOperator)) } + /** + * Instantiate the model. + * @param {object} [values] - optionally, row values to populate the model + */ constructor( values?: any ) { @@ -121,6 +274,10 @@ export abstract class Model> extends Builder { this.boot(values) } + /** + * Boot the model after creation. + * @param [values] + */ public boot(values?: any) { if ( values ) { get_fields_meta(this).each(field_def => { @@ -131,6 +288,11 @@ export abstract class Model> extends Builder { } } + /** + * Given a query row, load the values into this model. + * @param {QueryRow} row + * @return Model + */ public assume_from_source(row: QueryRow) { this._original = row get_fields_meta(this).each(field_def => { @@ -142,6 +304,10 @@ export abstract class Model> extends Builder { return this } + /** + * If applicable get an object of normalized timestamps. + * @return { updated_at?: Date, created_at?: Date } + */ public timestamps(): { updated_at?: Date, created_at?: Date } { const constructor = (this.constructor as typeof Model) const timestamps: { updated_at?: Date, created_at?: Date } = {} @@ -155,22 +321,41 @@ export abstract class Model> extends Builder { return timestamps } + /** + * Get a Select statement to pull back this model's database fields. + * @param {...FieldSet} other_fields + */ public static model_select(...other_fields: FieldSet[]) { const fields = get_fields_meta(this.prototype).pluck('database_key').toArray() return this.select(...[...fields, ...other_fields]) } + /** + * Find a collection of model instances given a filter. + * @param {QueryFilter} filter + * @return ResultCollection + */ public static async find(filter: QueryFilter = {}) { // @ts-ignore return await this.model_select().filter(filter).results() } + /** + * Find a single instance of the model given a filter. + * @param {QueryFilter} filter + * @return undefined | Model + */ public static async find_one(filter: QueryFilter = {}) { // @ts-ignore const result = await this.model_select().filter(filter).limit(1).results() return result.first() } + /** + * Find an instance of this model by primary key. + * @param {ModelKey} key + * @return undefined | Model + */ public static async find_by_key(key: ModelKey): Promise { const result = await this.model_select() .where(this.qualified_key_name(), '=', key) @@ -180,11 +365,19 @@ export abstract class Model> extends Builder { return result.first() } + /** + * Count the number of records of this model. + * @return Promise + */ public static async count(): Promise { const result = this.model_select().results() return await result.count() } + /** + * Get a filter checking if a model field is dirty. + * @private + */ protected get _is_dirty() { return (field_def: ModelField) => { // @ts-ignore @@ -192,6 +385,10 @@ export abstract class Model> extends Builder { } } + /** + * Get a query row containing all database values from this model. + * @return QueryRow + */ public to_row(): QueryRow { const row = {} this.field_defs() @@ -202,6 +399,10 @@ export abstract class Model> extends Builder { return row } + /** + * Get a query row containing the changed values from this model. + * @return QueryRow + */ public dirty_to_row(): QueryRow { const row = {} this.field_defs() @@ -224,6 +425,12 @@ export abstract class Model> extends Builder { return get_fields_meta(this.prototype) } + /** + * Get a collection of field definitions that contains information + * on which database fields correspond to which model fields, and + * their types. + * @return Collection + */ public field_defs(): Collection { return (this.constructor as typeof Model).fields() } @@ -299,6 +506,12 @@ export abstract class Model> extends Builder { return !this.is_dirty() } + /** + * Returns true if the given field has been changed from the original + * value fetched from the database. + * @param string field + * @return boolean + */ public was_changed(field: string) { // @ts-ignore return this.field_defs().pluck('model_key').includes(field) && this[field] !== this._original[field] @@ -323,6 +536,9 @@ export abstract class Model> extends Builder { // support for soft deletes! // force delete - for soft deleted models + /** + * Touch the UPDATED_AT/CREATED_AT timestamps where appropriate. + */ public touch() { const constructor = (this.constructor as typeof Model) if ( constructor.timestamps ) { @@ -339,10 +555,20 @@ export abstract class Model> extends Builder { return this } + /** + * Get the result collection for all records of this model. + * @return ResultCollection + */ public static all() { return this.model_select().results() } + /** + * Persist the model's record into the database. If we have a key, + * update the record. Otherwise, perform an insert. + * @param without_timestamps - if true, the UPDATED_AT/CREATED_AT timestamps will not be touched + * @return Promise + */ public async save({ without_timestamps = false }): Promise> { await this.saving$.next(this) const constructor = (this.constructor as typeof Model) @@ -409,6 +635,12 @@ export abstract class Model> extends Builder { return this } + /** + * Build a field value object from the values on this model to + * populate an INSERT query. + * @private + * @return FieldValueObject + */ protected _build_insert_field_object(): FieldValueObject { const fields = this.field_defs().whereNot('model_key', '=', this.key_name()) const values = {} @@ -419,11 +651,20 @@ export abstract class Model> extends Builder { return values } + /** + * Return an array of fields that were loaded from the DB. + * @private + * @return string[] + */ protected _loaded_database_fields(): string[] { if ( typeof this._original === 'undefined' ) return [] return Object.keys(this._original).map(String) } + /** + * Delete one or more records from this model's table by ID. + * @param {ModelKey | ModelKey[]} id_or_ids + */ public static async destroy(id_or_ids: ModelKey | ModelKey[]) { const ids = Array.isArray(id_or_ids) ? id_or_ids : [id_or_ids] const constructor = (this.constructor as typeof Model) @@ -440,6 +681,9 @@ export abstract class Model> extends Builder { } } + /** + * Delete this model's record from the database. + */ public async destroy(): Promise { await this.deleting$.next(this) @@ -459,6 +703,10 @@ export abstract class Model> extends Builder { await this.deleted$.next(this) } + /** + * Convert this model's record to a plain object. + * @return object + */ public to_object(): { [key: string]: any } { const constructor = (this.constructor as typeof Model) const obj = {} @@ -481,10 +729,18 @@ export abstract class Model> extends Builder { return obj } + /** + * Convert this model's record to a JSON string. + * @return string + */ public to_json(): string { return JSON.stringify(this.to_object()) } + /** + * Fetch a fresh instance of this model's record from the database. + * @return Model + */ public async fresh(): Promise> { const constructor = (this.constructor as typeof Model) const fields = this.field_defs() @@ -499,6 +755,9 @@ export abstract class Model> extends Builder { return result.first() } + /** + * Re-fetch the fields on this model from the database. + */ public async refresh() { const constructor = (this.constructor as typeof Model) const values = await this.select(this._loaded_database_fields()) @@ -511,6 +770,11 @@ export abstract class Model> extends Builder { if ( row ) this.assume_from_source(row) } + /** + * Populate the fields on the given model from the fields on this model, except for the key. + * @param Model other + * @return Model + */ public populate(other: Model): Model { const row = this.to_row() delete row[this.key_name()] @@ -518,6 +782,12 @@ export abstract class Model> extends Builder { return other } + /** + * Given another model, determines if that model represents the same + * record as this model. + * @param Model other_instance + * @return boolean + */ public is(other_instance: Model): boolean { const this_constructor = (this.constructor as typeof Model) const other_constructor = (other_instance.constructor as typeof Model) @@ -527,6 +797,12 @@ export abstract class Model> extends Builder { ) } + /** + * Given another model, determines if that model represents a different + * record as this model. + * @param Model other_instance + * @return boolean + */ public is_not(other_instance: Model): boolean { return !this.is(other_instance) }