import {ModelKey, QueryRow, QuerySource} from '../types' import {Container, Instantiable, isInstantiable} from '../../di' import {DatabaseService} from '../DatabaseService' import {ModelBuilder} from './ModelBuilder' import {getFieldsMeta, ModelField} from './Field' import {deepCopy, Collection, uuid4, isKeyof, Pipeline, hasOwnProperty} from '../../util' import {EscapeValueObject, QuerySafeValue} from '../dialect/SQLDialect' import {Connection} from '../connection/Connection' 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 {Relation, RelationValue} from './relation/Relation' import {HasOne} from './relation/HasOne' import {HasMany} from './relation/HasMany' import {HasOneOrMany} from './relation/HasOneOrMany' import {Scope, ScopeClosure} from './scope/Scope' import {LocalBus} from '../../support/bus/LocalBus' // need the specific import to prevent circular dependencies import {ModelEvent} from './events/ModelEvent' /** * Base for classes that are mapped to tables in a database. */ export abstract class Model> extends LocalBus> { /** * 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[] = [] /** * Relations that should be eager-loaded by default. * @protected */ protected with: (keyof T)[] = [] /** * The original row fetched from the database. * @protected */ protected originalSourceRow?: QueryRow /** * Database fields that should be run on the next save, even if the * fields are not mapped to members on the model. * @protected */ protected dirtySourceRow?: QueryRow /** * Cache of relation instances by property accessor. * This is used by the `@Relation()` decorator to cache Relation instances. */ public relationCache: Collection<{ accessor: string | symbol, relation: Relation }> = new Collection<{accessor: string | symbol; relation: Relation}>() protected scopes: Collection<{ accessor: string | Instantiable, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable; scope: ScopeClosure}>() /** * 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 di = Container.getContainer() const builder = > di.make>(ModelBuilder, this) const source: QuerySource = this.querySource() builder.connection(this.getConnection()) if ( typeof source === 'string' || source instanceof QuerySafeValue ) { builder.from(source) } else { builder.from(source.table, source.alias) } getFieldsMeta(this.prototype).each(field => { builder.field(field.databaseKey) }) const inst = di.make(this) if ( Array.isArray(inst.with) ) { // Try to get the eager-loaded relations statically, if possible for (const relation of inst.with) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore builder.with(relation) } } inst.applyScopes(builder) 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) }) } } public getColumn(key: string): unknown { if ( this.dirtySourceRow && hasOwnProperty(this.dirtySourceRow, key) ) { return this.dirtySourceRow[key] } const field = getFieldsMeta(this) .firstWhere('databaseKey', '=', key) if ( field ) { return (this as any)[field.modelKey] } return this.originalSourceRow?.[key] } /** * Sets a value in the override row and (if applicable) associated class property * for the given database column. * * @param key * @param value */ public setColumn(key: string, value: unknown): this { // Set the property on the database result row, if one exists if ( !this.dirtySourceRow ) { this.dirtySourceRow = {} } this.dirtySourceRow[key] = value // Set the property on the mapped field on the class, if one exists const field = getFieldsMeta(this) .firstWhere('databaseKey', '=', key) if ( field ) { this.setFieldFromObject(field.modelKey, field.databaseKey, { [key]: value, }) } return this } /** * 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.push(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|number { 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' || source instanceof QuerySafeValue ) { builder.from(source) } else { builder.from(source.table, source.alias) } getFieldsMeta(this).each(field => { builder.field(field.databaseKey) }) for ( const relation of this.with ) { builder.with(relation) } this.applyScopes(builder) 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 { console.log('propertyToColumn', modelKey, getFieldsMeta(this), this) // eslint-disable-line no-console 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, ...(this.dirtySourceRow || {}), } } /** * 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.push(new ModelSavingEvent(this as any)) const ctor = this.constructor as typeof Model if ( this.exists() && this.isDirty() ) { await this.push(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()) this.logging.debug({updata: data}) if ( data ) { await this.assumeFromSource(data) } delete this.dirtySourceRow await this.push(new ModelUpdatedEvent(this as any)) } else if ( !this.exists() ) { await this.push(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() this.logging.debug('Insert field object:') this.logging.debug(row) 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() this.logging.debug({inserta: data}) if ( data ) { await this.assumeFromSource(data) } delete this.dirtySourceRow await this.push(new ModelCreatedEvent(this as any)) } await this.push(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) } /** * 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 Boolean( !this.originalSourceRow || (this as any)[field.modelKey] !== this.originalSourceRow[field.databaseKey] || ( this.dirtySourceRow && hasOwnProperty(this.dirtySourceRow, 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 this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`) const row = Pipeline.id>() .unless(ctor.populateKeyOnInsert, fields => { return fields.where('databaseKey', '!=', this.keyName()) }) .apply(getFieldsMeta(this)) .keyMap('databaseKey', inst => (this as any)[inst.modelKey]) return { ...row, ...(this.dirtySourceRow || {}), } } /** * 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] } /** * Create a new one-to-one relation instance. Should be called from a method on the model: * * @example * ```ts * class MyModel extends Model { * @Related() * public otherModel() { * return this.hasOne(MyOtherModel) * } * } * ``` * * @param related * @param foreignKeyOverride * @param localKeyOverride */ public hasOne>(related: Instantiable, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasOne { return new HasOne(this as unknown as T, this.make(related), foreignKeyOverride, localKeyOverride) } /** * Create a new one-to-one relation instance. Should be called from a method on the model: * * @example * ```ts * class MyModel extends Model { * @Related() * public otherModels() { * return this.hasMany(MyOtherModel) * } * } * ``` * * @param related * @param foreignKeyOverride * @param localKeyOverride */ public hasMany>(related: Instantiable, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasMany { return new HasMany(this as unknown as T, this.make(related), foreignKeyOverride, localKeyOverride) } /** * Create the inverse of a one-to-one relation. Should be called from a method on the model: * * @example * ```ts * class MyModel extends Model { * @Related() * public otherModel() { * return this.hasOne(MyOtherModel) * } * } * * class MyOtherModel extends Model { * @Related() * public myModel() { * return this.belongsToOne(MyModel, 'otherModel') * } * } * ``` * * @param related * @param relationName */ public belongsToOne>(related: Instantiable, relationName: keyof T2): HasOne { const relatedInst = this.make(related) as T2 const relation = relatedInst.getRelation(relationName) if ( !(relation instanceof HasOneOrMany) ) { throw new TypeError(`Cannot create belongs to one relation. Inverse relation must be HasOneOrMany.`) } const localKey = relation.localKey const foreignKey = relation.foreignKey if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) { throw new TypeError('Local or foreign keys do not exist on the base model.') } return new HasOne(this as unknown as T, relatedInst, localKey, foreignKey) } /** * Create the inverse of a one-to-many relation. Should be called from a method on the model: * * @example * ```ts * class MyModel extends Model { * @Related() * public otherModels() { * return this.hasMany(MyOtherModel) * } * } * * class MyOtherModel extends Model { * @Related() * public myModels() { * return this.belongsToMany(MyModel, 'otherModels') * } * } * ``` * * @param related * @param relationName */ public belongsToMany>(related: Instantiable, relationName: keyof T2): HasMany { const relatedInst = this.make(related) as T2 const relation = relatedInst.getRelation(relationName) if ( !(relation instanceof HasOneOrMany) ) { throw new TypeError(`Cannot create belongs to one relation. Inverse relation must be HasOneOrMany.`) } const localKey = relation.localKey const foreignKey = relation.foreignKey if ( !isKeyof(localKey, this as unknown as T) || !isKeyof(foreignKey, relatedInst) ) { throw new TypeError('Local or foreign keys do not exist on the base model.') } return new HasMany(this as unknown as T, relatedInst, localKey, foreignKey) } /** * Get the relation instance returned by a method on this model. * @param name * @protected */ public getRelation>(name: keyof this): Relation> { const relFn = this[name] if ( relFn instanceof Relation ) { return relFn } if ( typeof relFn === 'function' ) { const rel = relFn.bind(this)() if ( rel instanceof Relation ) { return rel } } throw new TypeError(`Cannot get relation of name: ${String(name)}. Method does not return a Relation.`) } /** * Register a scope on the model. * @param scope * @protected */ protected withScope(scope: Instantiable | ScopeClosure): this { if ( isInstantiable(scope) ) { if ( !this.hasScope(scope) ) { this.scopes.push({ accessor: scope, scope: builder => (this.make(scope)).apply(builder), }) } } else { this.scopes.push({ accessor: uuid4(), scope, }) } return this } /** * Register a scope on the model with a specific name. * @param name * @param scope * @protected */ protected namedScope(name: string, scope: Instantiable | ScopeClosure): this { if ( isInstantiable(scope) ) { if ( !this.hasScope(scope) ) { this.scopes.push({ accessor: name, scope: builder => (this.make(scope)).apply(builder), }) } } else { this.scopes.push({ accessor: name, scope, }) } return this } /** * Returns true if the current model has a scope with the given identifier. * @param name * @protected */ protected hasScope(name: string | Instantiable): boolean { return Boolean(this.scopes.firstWhere('accessor', '=', name)) } /** * Apply the default scopes to this model to the given query builder. * @param builder */ public applyScopes(builder: ModelBuilder): void { builder.withScopes(this.scopes) } }