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 {QueryRow, ModelKey} from '../db/types.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 {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' // TODO separate read/write connections // TODO manual dirty flags /** * Base class for database models. * @extends Builder */ export abstract class Model> extends Builder implements Rehydratable { /** * The name of the connection this model should run through. * @type string */ protected static connection: string = 'default' /** * 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 /** * If false (default), the primary key will be excluded from INSERTs. */ protected static populate_key_on_insert: boolean = false /** * Optionally, the timestamp field set on creation. * @type string */ protected static readonly CREATED_AT: string | null = 'created_at' /** * Optionally, the timestamp field set op update. * @type string */ protected static readonly UPDATED_AT: string | null = '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() .to(constructor.table_name()) .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .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) .from(constructor.table_name()) .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .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() .into(constructor.table_name()) .target_connection(constructor.get_connection()) .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() .from(constructor.table_name()) .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .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() .table(constructor.table_name()) .target_connection(constructor.get_connection()) .target_operator(make(ObjectResultOperator)) } /** * Instantiate the model. * @param {object} [values] - optionally, row values to populate the model */ constructor( values?: any ) { super() this.boot(values) } /** * Boot the model after creation. * @param [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] }) } } /** * 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 => { // TODO special type casting // @ts-ignore this[field_def.model_key] = row[field_def.database_key] }) this.retrieved$.next(this) 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 } = {} 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 } /** * 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) .limit(1) .results() 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 return this[field_def.model_key] !== this._original[field_def.database_key] } } /** * Get a query row containing all database values from this model. * @return QueryRow */ 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 } /** * Get a query row containing the changed values from this model. * @return QueryRow */ 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) } /** * 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() } /** * 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() } /** * 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] } // 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 /** * Touch the UPDATED_AT/CREATED_AT timestamps where appropriate. */ 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 } /** * 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) // 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 } /** * 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 { let fields = this.field_defs() if ( !(this.constructor as typeof Model).populate_key_on_insert ) fields = fields.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 } /** * 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) 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()})`) } } /** * Delete this model's record from the database. */ 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) } /** * 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 = {} 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 } /** * 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() .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() } /** * 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()) .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) } /** * 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()] other.assume_from_source(row) 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) return ( other_instance.key() === this.key() && this_constructor.qualified_key_name() === other_constructor.qualified_key_name() ) } /** * 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) } /** * 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}` } /** * Dehydrate the model. Implements Rehydratable interface. */ public async dehydrate(): Promise { return this.to_object() } /** * Rehydrate the model. Implements Rehydratable interface. * @param state */ public async rehydrate(state: JSONState) { this.assume_from_source(state) } }