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 export abstract class Model> extends Builder { protected static connection: string protected static table: string protected static key: string protected static readonly CREATED_AT = 'created_at' protected static readonly UPDATED_AT = 'updated_at' protected static timestamps = true protected static global_scopes: Scope[] = [] protected static appends: string[] = [] protected static masks: string[] = [] protected _original?: QueryRow protected retrieved$ = new BehaviorSubject>() protected saving$ = new BehaviorSubject>() protected saved$ = new BehaviorSubject>() protected updating$ = new BehaviorSubject>() protected updated$ = new BehaviorSubject>() protected creating$ = new BehaviorSubject>() protected created$ = new BehaviorSubject>() protected deleting$ = new BehaviorSubject>() protected deleted$ = new BehaviorSubject>() public static table_name() { return this.table } public static connection_name() { return this.connection } public static get_connection() { return make(Database).connection(this.connection_name()) } public static select(...fields: FieldSet[]) { return this.prototype.select(...fields) } public static update() { return this.prototype.update() } public static insert() { return this.prototype.insert() } public static delete() { return this.prototype.delete() } public static truncate() { return this.prototype.truncate() } public update(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.update() .to(constructor.table_name()) .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .target_operator(make(ModelResultOperator, constructor)) } public select(...fields: FieldSet[]) { const constructor = (this.constructor as typeof Model) return super.select(...fields) .from(constructor.table_name()) .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .target_operator(make(ModelResultOperator, constructor)) } public insert(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.insert() .into(constructor.table_name()) .target_connection(constructor.get_connection()) .target_operator(make(ModelResultOperator, constructor)) } public delete(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.delete() .from(constructor.table_name()) .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .target_operator(make(ModelResultOperator, constructor)) } public truncate(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.truncate() .table(constructor.table_name()) .target_connection(constructor.get_connection()) .target_operator(make(ObjectResultOperator)) } constructor( values?: any ) { super() this.boot(values) } public boot(values?: any) { if ( values ) { get_fields_meta(this).each(field_def => { // TODO special type casting // @ts-ignore this[field_def.model_key] = values[field_def.model_key] }) } } public assume_from_source(row: QueryRow) { this._original = row get_fields_meta(this).each(field_def => { // TODO special type casting // @ts-ignore this[field_def.model_key] = row[field_def.database_key] }) this.retrieved$.next(this) return this } public timestamps(): { updated_at?: Date, created_at?: Date } { const constructor = (this.constructor as typeof Model) const timestamps: { updated_at?: Date, created_at?: Date } = {} if ( constructor.timestamps ) { // @ts-ignore if ( constructor.CREATED_AT ) timestamps.created_at = this[constructor.CREATED_AT] // @ts-ignore if ( constructor.UPDATED_AT ) timestamps.updated_at = this[constructor.UPDATED_AT] } return timestamps } public static model_select(...other_fields: FieldSet[]) { const fields = get_fields_meta(this.prototype).pluck('database_key').toArray() return this.select(...[...fields, ...other_fields]) } public static async find(filter: QueryFilter = {}) { // @ts-ignore return await this.model_select().filter(filter).results() } public static async find_one(filter: QueryFilter = {}) { // @ts-ignore const result = await this.model_select().filter(filter).limit(1).results() return result.first() } public static async find_by_key(key: ModelKey): Promise { const result = await this.model_select() .where(this.qualified_key_name(), '=', key) .limit(1) .results() return result.first() } public static async count(): Promise { const result = this.model_select().results() return await result.count() } protected get _is_dirty() { return (field_def: ModelField) => { // @ts-ignore return this[field_def.model_key] !== this._original[field_def.database_key] } } public to_row(): QueryRow { const row = {} this.field_defs() .each(field_def => { // @ts-ignore row[field_def.database_key] = this[field_def.model_key] }) return row } public dirty_to_row(): QueryRow { const row = {} this.field_defs() .filter(this._is_dirty) .each(field_def => { // TODO additional casting and serializing logic here // @ts-ignore row[field_def.database_key] = this[field_def.model_key] }) return row } /** * Get a collection of field definitions that contains information * on which database fields correspond to which model fields, and * their types. * @return Collection */ public static fields(): Collection { return get_fields_meta(this.prototype) } public field_defs(): Collection { return (this.constructor as typeof Model).fields() } /** * Sets the model field metadata to the specified collection of * model field definitions. You should rarely need to use this. * @param Collection fields */ public static set_fields(fields: Collection) { set_model_fields_meta(this.prototype, fields) } /** * Get the original values of the model as they were retrieved from * the database. These are never updated when the model is modified. * @return QueryRow */ public get_original_values() { return this._original } /** * Get an object with only the fields specified as arguments. * Note that this is NOT a QueryRow. * @param {...string} fields */ public only(...fields: string[]) { const row = {} for ( const field of fields ) { // @ts-ignore row[field] = this[field] } return row } /** * Returns true if any of the defined fields have been modified from * the values that were originally fetched from the database. * @return boolean */ public is_dirty() { return this.field_defs().some(this._is_dirty) } /** * Get an array of model field names that have been modified from * the values that were originally fetched from the database. * @return string[] */ public dirty_fields() { return this.field_defs() .filter(this._is_dirty) .pluck('model_key') .toArray() } /** * Returns true if the model has an ID from, and therefore exists in, * the database backend. * @return boolean */ public exists(): boolean { return !!this._original && !!this.key() } /** * Returns true if none of the defined fields have been modified from * the values that were originally fetched from the database. * @return boolean */ public is_clean() { return !this.is_dirty() } public was_changed(field: string) { // @ts-ignore return this.field_defs().pluck('model_key').includes(field) && this[field] !== this._original[field] } // has one // morph one // belongs to // morph to // has many // has many through // belongs to many // morph to many // morphed by many // is relation loaded // load relations // load missing relations // relations // with // relations_to_row // support for soft deletes! // force delete - for soft deleted models public touch() { const constructor = (this.constructor as typeof Model) if ( constructor.timestamps ) { if ( this.exists() && constructor.UPDATED_AT ) { // @ts-ignore this[constructor.UPDATED_AT] = new Date() } if ( constructor.CREATED_AT ) { // @ts-ignore this[constructor.CREATED_AT] = new Date() } } return this } public static all() { return this.model_select().results() } public async save({ without_timestamps = false }): Promise> { await this.saving$.next(this) const constructor = (this.constructor as typeof Model) // TODO timestamps if ( this.exists() && this.is_dirty() ) { // We're updating an existing record await this.updating$.next(this) if ( !without_timestamps && constructor.timestamps && constructor.UPDATED_AT ) { // @ts-ignore this[constructor.UPDATED_AT] = new Date() } const mutable = this.update() .data(this.dirty_to_row()) .where(constructor.qualified_key_name(), '=', this.key()) .returning(...this._loaded_database_fields()) .target_operator(make(ObjectResultOperator)) .results() const result = await mutable const modified_rows = await mutable.count() if ( modified_rows !== 1 ) { logger.warn(`Model update modified ${modified_rows} rows! (Key: ${constructor.qualified_key_name()})`) } const model = result.firstWhere(this.key_name(), '=', this.key()) if ( model ) this.assume_from_source(model) await this.updated$.next(this) } else if ( !this.exists() ) { // We're inserting a new record await this.creating$.next(this) if ( !without_timestamps && constructor.timestamps && constructor.CREATED_AT ) { // @ts-ignore this[constructor.CREATED_AT] = new Date() } if ( constructor.timestamps && constructor.UPDATED_AT ) { // @ts-ignore this[constructor.UPDATED_AT] = new Date() } const insert_object: FieldValueObject = this._build_insert_field_object() const mutable = this.insert() .row(insert_object) .returning(this.key_name(), ...Object.keys(insert_object)) .target_operator(make(ObjectResultOperator)) .results() const result = await mutable const inserted_rows = await mutable.count() if ( inserted_rows !== 1 ) { logger.warn(`Model insert created ${inserted_rows} rows! (Key: ${constructor.qualified_key_name()})`) } const model = result.first() if ( model ) this.assume_from_source(model) await this.created$.next(this) } await this.saved$.next(this) return this } protected _build_insert_field_object(): FieldValueObject { const fields = this.field_defs().whereNot('model_key', '=', this.key_name()) const values = {} fields.each(field_def => { // @ts-ignore values[field_def.database_key] = this[field_def.model_key] ?? null }) return values } protected _loaded_database_fields(): string[] { if ( typeof this._original === 'undefined' ) return [] return Object.keys(this._original).map(String) } 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) const mutable = this.delete() .whereIn(constructor.qualified_key_name(), ids) .target_operator(make(ObjectResultOperator)) .results() const result = await mutable const modified_rows = await mutable.count() if ( modified_rows !== ids.length ) { logger.warn(`Model bulk destroy modified ${modified_rows} when ${ids.length} keys were provided. (Key: ${constructor.qualified_key_name()})`) } } public async destroy(): Promise { await this.deleting$.next(this) const constructor = (this.constructor as typeof Model) const mutable = this.delete() .where(constructor.qualified_key_name, '=', this.key()) .target_operator(make(ObjectResultOperator)) .results() const result = await mutable const modified_rows = await mutable.count() if ( modified_rows !== 1 ) { logger.warn(`Model delete modified ${modified_rows} rows! (Key: ${constructor.qualified_key_name()})`) } await this.deleted$.next(this) } public to_object(): { [key: string]: any } { const constructor = (this.constructor as typeof Model) const obj = {} this.field_defs() .each(field_def => { // @ts-ignore obj[field_def.model_key] = this[field_def.model_key] }) constructor.appends.forEach(appended_field => { // @ts-ignore obj[appended_field] = this[appended_field] }) constructor.masks.forEach(masked_field => { // @ts-ignore delete obj[masked_field] }) return obj } public to_json(): string { return JSON.stringify(this.to_object()) } public async fresh(): Promise> { const constructor = (this.constructor as typeof Model) const fields = this.field_defs() .whereNot('model_key', '=', this.key_name()) .pluck('database_key') .toArray() const result = await constructor.select(...fields) .where(constructor.qualified_key_name(), '=', this.key()) .limit(1) .results() return result.first() } public async refresh() { const constructor = (this.constructor as typeof Model) const values = await this.select(this._loaded_database_fields()) .where(constructor.qualified_key_name(), '=', this.key()) .limit(1) .target_operator(make(ObjectResultOperator)) .results() const row = values.first() if ( row ) this.assume_from_source(row) } public populate(other: Model): Model { const row = this.to_row() delete row[this.key_name()] other.assume_from_source(row) return other } public is(other_instance: Model): boolean { const this_constructor = (this.constructor as typeof Model) const other_constructor = (other_instance.constructor as typeof Model) return ( other_instance.key() === this.key() && this_constructor.qualified_key_name() === other_constructor.qualified_key_name() ) } public is_not(other_instance: Model): boolean { return !this.is(other_instance) } /** * Returns the field name of the primary key for this model. * @return string */ public key_name() { return (this.constructor as typeof Model).key } /** * If defined, returns the value of the primary key for this model. */ public key(): ModelKey { return this?._original?.[(this.constructor as typeof Model).key] } /** * Returns the table-qualified field name of the primary key for this model. */ public static qualified_key_name() { return `${this.table_name()}.${this.key}` } }