import {ModelKey, QueryRow, QuerySource} from '../types' import {Container, Inject, Instantiable, StaticClass} from '../../di' import {DatabaseService} from '../DatabaseService' import {ModelBuilder} from './ModelBuilder' import {getFieldsMeta, ModelField} from './Field' import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util' import {EscapeValueObject} from '../dialect/SQLDialect' import {AppClass} from '../../lifecycle/AppClass' import {Logging} from '../../service/Logging' import {Connection} from '../connection/Connection' import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from '../../event/types' import {ModelRetrievedEvent} from './events/ModelRetrievedEvent' import {ModelSavingEvent} from './events/ModelSavingEvent' import {ModelSavedEvent} from './events/ModelSavedEvent' import {ModelUpdatingEvent} from './events/ModelUpdatingEvent' import {ModelUpdatedEvent} from './events/ModelUpdatedEvent' import {ModelCreatingEvent} from './events/ModelCreatingEvent' import {ModelCreatedEvent} from './events/ModelCreatedEvent' import {EventBus} from '../../event/EventBus' /** * Base for classes that are mapped to tables in a database. */ export abstract class Model> extends AppClass implements Bus { @Inject() protected readonly logging!: Logging @Inject() protected readonly bus!: EventBus /** * The name of the connection this model should run through. * @type string */ protected static connection = '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 populateKeyOnInsert = 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 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 row fetched from the database. * @protected */ protected originalSourceRow?: QueryRow /** * Collection of event subscribers, by their events. * @protected */ protected modelEventBusSubscribers: Collection> = new Collection>() /** * Get the table name for this model. */ public static tableName(): string { return this.table } /** * Get the QuerySource object for this model as it should be applied to query builders. * * This sets the alias for the model table equal to the table name itself, so it can be * referenced explicitly in queries if necessary. */ public static querySource(): QuerySource { return { table: this.table, alias: this.table, } } /** * Get the name of the connection where this model's table is found. */ public static connectionName(): string { return this.connection } /** * Get the database connection instance for this model's connection. */ public static getConnection(): Connection { return Container.getContainer().make(DatabaseService) .get(this.connectionName()) } /** * Get a new query builder that yields instances of this model, * pre-configured with this model's QuerySource, connection, and fields. * * @example * ```typescript * const user = await UserModel.query().where('name', 'LIKE', 'John Doe').first() * ``` */ public static query>(): ModelBuilder { const builder = > Container.getContainer().make>(ModelBuilder, this) const source: QuerySource = this.querySource() builder.connection(this.getConnection()) if ( typeof source === 'string' ) { builder.from(source) } else { builder.from(source.table, source.alias) } getFieldsMeta(this.prototype).each(field => { builder.field(field.databaseKey) }) return builder } constructor( /** * Pre-fill the model's properties from the given values. * Calls `boot()` under the hood. */ values?: {[key: string]: any}, ) { super() this.initialize() this.boot(values) } /** * Called when the model is instantiated. Use for any setup of events, &c. * @protected */ protected initialize(): void {} // eslint-disable-line @typescript-eslint/no-empty-function /** * Initialize the model's properties from the given values and do any other initial setup. * * `values` can optionally be an object mapping model properties to the values of those * properties. Only properties with `@Field()` annotations will be set. * * @param values */ public boot(values?: {[key: string]: unknown}): void { if ( values ) { getFieldsMeta(this).each(field => { this.setFieldFromObject(field.modelKey, String(field.modelKey), values) }) } } /** * Given a row from the database, set the properties on this model that correspond to * fields on that database. * * The `row` maps database fields to values, and the values are set for the properties * that they correspond to based on the model's `@Field()` annotations. * * @param row */ public async assumeFromSource(row: QueryRow): Promise { this.originalSourceRow = row getFieldsMeta(this).each(field => { this.setFieldFromObject(field.modelKey, field.databaseKey, row) }) await this.dispatch(new ModelRetrievedEvent(this as any)) return this } /** * Similar to assumeFromSource, but instead of mapping database fields to model * properties, this function assumes the `object` contains a mapping of model properties * to the values of those properties. * * Only properties with `@Field()` annotations will be set. * * @param object */ public async assume(object: { [key: string]: any }): Promise { getFieldsMeta(this).each(field => { if ( field.modelKey in object ) { this.setFieldFromObject(field.modelKey, String(field.modelKey), object) } }) return this } /** * Get the value of the primary key of this model, if it exists. */ public key(): string { const ctor = this.constructor as typeof Model const field = getFieldsMeta(this) .firstWhere('databaseKey', '=', ctor.key) if ( field ) { return (this as any)[field.modelKey] } return (this as any)[ctor.key] } /** * Returns true if this instance's record has been persisted into the database. */ public exists(): boolean { return Boolean(this.originalSourceRow) && Boolean(this.key()) } /** * Get normalized values of the configured CREATED_AT/UPDATED_AT fields for this model. * * @example * ``` * user.timestamps() // => {updated: Date, created: Date} * ``` */ public timestamps(): { updated?: Date, created?: Date } { const ctor = this.constructor as typeof Model const timestamps: { updated?: Date, created?: Date } = {} if ( ctor.timestamps ) { if ( ctor.CREATED_AT ) { timestamps.created = (this as any)[ctor.CREATED_AT] } if ( ctor.UPDATED_AT ) { timestamps.updated = (this as any)[ctor.UPDATED_AT] } } return timestamps } /** * Get a new query builder that yields instances of this model, * pre-configured with this model's QuerySource, connection, and fields. * * @example * ```typescript * await user.query() * .where('name', 'LIKE', 'John Doe') * .update({ username: 'jdoe' }) * ``` */ public query(): ModelBuilder { const ModelClass = this.constructor as typeof Model const builder = > this.app().make>(ModelBuilder, ModelClass) const source: QuerySource = ModelClass.querySource() builder.connection(ModelClass.getConnection()) if ( typeof source === 'string' ) { builder.from(source) } else { builder.from(source.table, source.alias) } getFieldsMeta(this).each(field => { builder.field(field.databaseKey) }) return builder } /** * Find the first instance of this model where the primary key matches `key`. * * @example * ``` * const user = await UserModel.findByKey(45) * ``` * * @param key */ public static async findByKey>(key: ModelKey): Promise { return this.query() .where(this.qualifyKey(), '=', key) .limit(1) .get() .first() } /** * Get an array of all instances of this model. */ public async all(): Promise { return this.query().get() .all() } /** * Count all instances of this model in the database. */ public async count(): Promise { return this.query().get() .count() } /** * Given the name of a column, return the qualified name of the column as it * could appear in a query. * * @example * ```typescript * modelInstance.qualify('id') // => 'model_table_name.id' * ``` * * @param column */ public qualify(column: string): string { const ctor = this.constructor as typeof Model return `${ctor.tableName()}.${column}` } /** * Return the qualified name of the column corresponding to the model's primary key. * * @example * ```typescript * class A extends Model { * protected static table = 'table_a' * protected static key = 'a_id' * } * * const a = new A() * a.qualifyKey() // => 'table_a.a_id' * ``` */ public qualifyKey(): string { const ctor = this.constructor as typeof Model return this.qualify(ctor.key) } /** * Given the name of a column, return the qualified name of the column as it * could appear in a query. * * @example * ```typescript * SomeModel.qualify('col_name') // => 'model_table_name.col_name' * ``` * * @param column */ public static qualify(column: string): string { return `${this.tableName()}.${column}` } /** * Return the qualified name of the column corresponding to the model's primary key. * * @example * ```typescript * class A extends Model { * protected static table = 'table_a' * protected static key = 'a_id' * } * * A.qualifyKey() // => 'table_a.a_id' * ``` */ public static qualifyKey(): string { return this.qualify(this.key) } /** * Given the name of a property on the model with a `@Field()` annotation, * return the unqualified name of the database column it corresponds to. * @param modelKey */ public static propertyToColumn(modelKey: string): string { return getFieldsMeta(this) .firstWhere('modelKey', '=', modelKey)?.databaseKey || modelKey } /** * Get the unqualified name of the column corresponding to the primary key of this model. */ public keyName(): string { const ctor = this.constructor as typeof Model return ctor.key } /** * Cast the model to the base QueryRow object. The resultant object maps * DATABASE fields to values, NOT MODEL fields to values. * * Only fields with `@Field()` annotations will be included. */ public toQueryRow(): QueryRow { const row: QueryRow = {} getFieldsMeta(this).each(field => { row[field.databaseKey] = (this as any)[field.modelKey] }) return row } /** * Get a query row mapping database columns to values for properties on this * model that (1) have `@Field()` annotations and (2) have been modified since * the record was fetched from the database or created. */ public dirtyToQueryRow(): QueryRow { const row: QueryRow = {} getFieldsMeta(this) .filter(this.isDirtyCheck) .each(field => { row[field.databaseKey] = (this as any)[field.modelKey] }) return row } /** * Get an object of the database field => value mapping that was originally * fetched from the database. Excludes changes to model properties. */ public getOriginalValues(): QueryRow | undefined { return deepCopy(this.originalSourceRow) } /** * Return an object of only the given properties on this model. * * @example * Assume `a` is an instance of some model `A` with the given fields. * ```typescript * const a = new A({ field1: 'field1 value', field2: 'field2 value', id: 123 }) * * a.only('field1', 'id) // => {field1: 'field1 value', id: 123} * ``` * * @param fields */ public only(...fields: string[]): QueryRow { const row: QueryRow = {} for ( const field of fields ) { row[field] = (this as any)[field] } return row } /** * Returns true if any of the fields on this model have been modified since they * were fetched from the database (or ones that were never saved to the database). * * Only fields with `@Field()` annotations are checked. */ public isDirty(): boolean { return getFieldsMeta(this).some(this.isDirtyCheck) } /** * Returns true if none of the fields on this model have been modified since they * were fetched from the database (and all exist in the database). * * Only fields with `@Field()` annotations are checked. */ public isClean(): boolean { return !this.isDirty() } /** * Returns true if the given field has changed since this model was fetched from * the database, or if the given field never existed in the database. * @param field */ public wasChanged(field: string): boolean { return ( getFieldsMeta(this) .pluck('modelKey') .includes(field) && ( !this.originalSourceRow || (this as any)[field] !== this.originalSourceRow[field] ) ) } /** * Returns an array of MODEL fields that have been modified since this record * was fetched from the database or created. */ public getDirtyFields(): string[] { return getFieldsMeta(this) .filter(this.isDirtyCheck) .pluck('modelKey') .toArray() } /** * Updates the timestamps for this model, if they are configured. * * If the model doesn't yet exist, set the CREATED_AT date. Always * sets the UPDATED_AT date. */ public touch(): this { const constructor = (this.constructor as typeof Model) if ( constructor.timestamps ) { if ( constructor.UPDATED_AT ) { (this as any)[constructor.UPDATED_AT] = new Date() } if ( !this.exists() && constructor.CREATED_AT ) { (this as any)[constructor.CREATED_AT] = new Date() } } return this } /** * Persist the model into the database. If the model already exists, perform an * update on its fields. Otherwise, insert a new row with its fields. * * Passing the `withoutTimestamps` will prevent the configured CREATED_AT/UPDATED_AT * timestamps from being updated. * * @param withoutTimestamps */ public async save({ withoutTimestamps = false } = {}): Promise> { await this.dispatch(new ModelSavingEvent(this as any)) const ctor = this.constructor as typeof Model if ( this.exists() && this.isDirty() ) { await this.dispatch(new ModelUpdatingEvent(this as any)) if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) { (this as any)[ctor.UPDATED_AT] = new Date() } const result = await this.query() .where(this.qualifyKey(), '=', this.key()) .clearFields() .returning(...this.getLoadedDatabaseFields()) .update(this.dirtyToQueryRow()) if ( result.rowCount !== 1 ) { this.logging.warn(`Model update modified ${result.rowCount} rows! Expected 1. (Key: ${this.qualifyKey()})`) } const data = result.rows.firstWhere(this.keyName(), '=', this.key()) if ( data ) { await this.assumeFromSource(data) } await this.dispatch(new ModelUpdatedEvent(this as any)) } else if ( !this.exists() ) { await this.dispatch(new ModelCreatingEvent(this as any)) if ( !withoutTimestamps ) { if ( ctor.timestamps && ctor.CREATED_AT ) { (this as any)[ctor.CREATED_AT] = new Date() } if ( ctor.timestamps && ctor.UPDATED_AT ) { (this as any)[ctor.UPDATED_AT] = new Date() } } const row = this.buildInsertFieldObject() const returnable = new Collection([this.keyName(), ...Object.keys(row)]) const result = await this.query() .clearFields() .returning(...returnable.unique().toArray()) .insert(row) if ( result.rowCount !== 1 ) { this.logging.warn(`Model insert created ${result.rowCount} rows! Expected 1. (Key: ${this.qualifyKey()})`) } const data = result.rows.first() if ( data ) { await this.assumeFromSource(result) } await this.dispatch(new ModelCreatedEvent(this as any)) } await this.dispatch(new ModelSavedEvent(this as any)) return this } /** * Delete the current model from the database, if it exists. */ async delete(): Promise { if ( !this.exists() ) { return } await this.query() .where(this.qualifyKey(), '=', this.key()) .delete() const ctor = this.constructor as typeof Model const field = getFieldsMeta(this) .firstWhere('databaseKey', '=', ctor.key) if ( field ) { delete (this as any)[field.modelKey] return } delete (this as any)[ctor.key] } /** * Cast this model to a simple object mapping model fields to their values. * * Only fields with `@Field()` annotations are included. */ public toObject(): QueryRow { const ctor = this.constructor as typeof Model const obj: QueryRow = {} getFieldsMeta(this).each(field => { obj[String(field.modelKey)] = (this as any)[field.modelKey] }) ctor.appends.forEach(field => { obj[field] = (this as any)[field] }) ctor.masks.forEach(field => { delete obj[field] }) return obj } /** * Cast the model to an JSON string object. * * Only fields with `@Field()` annotations are included. */ public toJSON(): QueryRow { return this.toObject() } /** * Fetch a fresh instance of this record from the database. * * This returns a NEW instance of the SAME record by matching on * the primary key. It does NOT change the current instance of the record. */ public async fresh(): Promise | undefined> { return this.query() .where(this.qualifyKey(), '=', this.key()) .limit(1) .get() .first() } /** * Re-load the currently-loaded database fields from the table. * * Overwrites any un-persisted changes in the current instance. */ public async refresh(): Promise { const results = this.query() .clearFields() .fields(...this.getLoadedDatabaseFields()) .where(this.qualifyKey(), '=', this.key()) .limit(1) .get() const row = await results.first() if ( row ) { await this.assumeFromSource(row) } } /** * Populates an instance of the model with the same database fields that * are set on this model, with the exclusion of the primary key. * * Useful for inserting copies of records. * * @example * Assume a record, `a`, is an instance of some model `A` with the given fields. * * ```typescript * const a = A.find(123) // => A{id: 123, name: 'some_name', other_field: 'a value'} * * const b = a.populate(new A) // => A{name: 'some_name', other_field: 'a value'} * ``` * * @param model */ public async populate(model: T): Promise { const row = this.toQueryRow() delete row[this.keyName()] await model.assumeFromSource(row) return model } /** * Returns true if the `other` model refers to the same database record as this instance. * * This is done by comparing the qualified primary keys. * * @param other */ public is(other: Model): boolean { return this.key() === other.key() && this.qualifyKey() === other.qualifyKey() } /** * Inverse of `is()`. * @param other */ public isNot(other: Model): boolean { return !this.is(other) } /** * Creates a new Pipe instance containing this model instance. */ public pipe(): Pipe { return Pipe.wrap(this) } /** * Get a wrapped function that compares whether the given model field * on the current instance differs from the originally fetched value. * * Used to filter for dirty fields. * * @protected */ protected get isDirtyCheck(): (field: ModelField) => boolean { return (field: ModelField) => { return !this.originalSourceRow || (this as any)[field.modelKey] !== this.originalSourceRow[field.databaseKey] } } /** * Returns a list of DATABASE fields that have been loaded for the current instance. * @protected */ protected getLoadedDatabaseFields(): string[] { if ( !this.originalSourceRow ) { return [] } return Object.keys(this.originalSourceRow).map(String) } /** * Build an object mapping database fields to the values that should be inserted for them. * @private */ private buildInsertFieldObject(): EscapeValueObject { const ctor = this.constructor as typeof Model return getFieldsMeta(this) .pipe() .unless(ctor.populateKeyOnInsert, fields => { return fields.where('modelKey', '!=', this.keyName()) }) .get() .keyMap('databaseKey', inst => (this as any)[inst.modelKey]) } /** * Sets a property on `this` to the value of a given property in `object`. * @param thisFieldName * @param objectFieldName * @param object * @protected */ protected setFieldFromObject(thisFieldName: string | symbol, objectFieldName: string, object: QueryRow): void { (this as any)[thisFieldName] = object[objectFieldName] } subscribe(event: StaticClass>, subscriber: EventSubscriber): Awaitable { const entry: EventSubscriberEntry = { id: uuid4(), event, subscriber, } this.modelEventBusSubscribers.push(entry) return this.buildSubscription(entry.id) } unsubscribe(subscriber: EventSubscriber): Awaitable { this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('subscriber', '!=', subscriber) } async dispatch(event: Dispatchable): Promise { const eventClass: StaticClass = event.constructor as StaticClass await this.modelEventBusSubscribers.where('event', '=', eventClass) .promiseMap(entry => entry.subscriber(event)) await this.bus.dispatch(event) } /** * Build an EventSubscription object for the subscriber of the given ID. * @param id * @protected */ protected buildSubscription(id: string): EventSubscription { let subscribed = true return { unsubscribe: (): Awaitable => { if ( subscribed ) { this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('id', '!=', id) subscribed = false } }, } } }