You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/orm/model/Model.ts

814 lines
23 KiB

import {ModelKey, QueryRow, QuerySource} from "../types";
import {Container, Inject} from "../../di";
import {DatabaseService} from "../DatabaseService";
import {ModelBuilder} from "./ModelBuilder";
import {getFieldsMeta, ModelField} from "./Field";
import {deepCopy, BehaviorSubject, Pipe, Collection} from "../../util";
import {EscapeValueObject} from "../dialect/SQLDialect";
import {AppClass} from "../../lifecycle/AppClass";
import {Logging} from "../../service/Logging";
/**
* Base for classes that are mapped to tables in a database.
*/
export abstract class Model<T extends Model<T>> extends AppClass {
@Inject()
protected readonly logging!: Logging;
/**
* 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 populateKeyOnInsert: 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 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 _original?: QueryRow
/**
* Behavior subject that fires after the model is populated.
*/
protected retrieved$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is saved.
*/
protected saving$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is saved.
*/
protected saved$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is updated.
*/
protected updating$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is updated.
*/
protected updated$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is inserted.
*/
protected creating$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is inserted.
*/
protected created$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is deleted.
*/
protected deleting$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is deleted.
*/
protected deleted$ = new BehaviorSubject<Model<T>>()
/**
* Get the table name for this model.
*/
public static tableName() {
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() {
return this.connection
}
/**
* Get the database connection instance for this model's connection.
*/
public static getConnection() {
return Container.getContainer().make<DatabaseService>(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<UserModel>().where('name', 'LIKE', 'John Doe').first()
* ```
*/
public static query<T2 extends Model<T2>>() {
const builder = <ModelBuilder<T2>> Container.getContainer().make<ModelBuilder<T2>>(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.boot(values)
}
/**
* 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?: any) {
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) {
this._original = row
getFieldsMeta(this).each(field => {
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
})
await this.retrieved$.next(this)
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 }) {
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() {
const ctor = this.constructor as typeof Model
const field = getFieldsMeta(this)
.firstWhere('databaseKey', '=', ctor.key)
if ( field ) {
// @ts-ignore
return this[field.modelKey]
}
// @ts-ignore
return this[ctor.key]
}
/**
* Returns true if this instance's record has been persisted into the database.
*/
public exists() {
return !!this._original && !!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 ) {
// @ts-ignore
if ( ctor.CREATED_AT ) timestamps.created = this[ctor.CREATED_AT]
// @ts-ignore
if ( ctor.UPDATED_AT ) timestamps.updated = this[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<T> {
const ModelClass = this.constructor as typeof Model
const builder = <ModelBuilder<T>> this.app().make<ModelBuilder<T>>(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<T2 extends Model<T2>>(key: ModelKey): Promise<undefined | T2> {
return this.query<T2>()
.where(this.qualifyKey(), '=', key)
.limit(1)
.get()
.first()
}
/**
* Get an array of all instances of this model.
*/
public async all() {
return this.query().get().all()
}
/**
* Count all instances of this model in the database.
*/
public async count(): Promise<number> {
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) {
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<A> {
* protected static table = 'table_a'
* protected static key = 'a_id'
* }
*
* const a = new A()
* a.qualifyKey() // => 'table_a.a_id'
* ```
*/
public qualifyKey() {
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) {
return `${this.tableName()}.${column}`
}
/**
* Return the qualified name of the column corresponding to the model's primary key.
*
* @example
* ```typescript
* class A extends Model<A> {
* protected static table = 'table_a'
* protected static key = 'a_id'
* }
*
* A.qualifyKey() // => 'table_a.a_id'
* ```
*/
public static qualifyKey() {
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) {
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() {
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 = {}
getFieldsMeta(this).each(field => {
// @ts-ignore
row[field.databaseKey] = this[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 = {}
getFieldsMeta(this)
.filter(this._isDirty)
.each(field => {
// @ts-ignore
row[field.databaseKey] = this[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() {
return deepCopy(this._original)
}
/**
* 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[]) {
const row = {}
for ( const field of fields ) {
// @ts-ignore
row[field] = this[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() {
return getFieldsMeta(this).some(this._isDirty)
}
/**
* 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() {
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) {
// @ts-ignore
return getFieldsMeta(this).pluck('modelKey').includes(field) && this[field] !== this._original[field]
}
/**
* Returns an array of MODEL fields that have been modified since this record
* was fetched from the database or created.
*/
public getDirtyFields() {
return getFieldsMeta(this)
.filter(this._isDirty)
.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() {
const constructor = (this.constructor as typeof Model)
if ( constructor.timestamps ) {
if ( constructor.UPDATED_AT ) {
// @ts-ignore
this[constructor.UPDATED_AT] = new Date()
}
if ( !this.exists() && constructor.CREATED_AT ) {
// @ts-ignore
this[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<Model<T>> {
await this.saving$.next(this)
const ctor = this.constructor as typeof Model
if ( this.exists() && this.isDirty() ) {
await this.updating$.next(this)
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
// @ts-ignore
this[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.updated$.next(this)
} else if ( !this.exists() ) {
await this.creating$.next(this)
if ( !withoutTimestamps ) {
if ( ctor.timestamps && ctor.CREATED_AT ) {
// @ts-ignore
this[ctor.CREATED_AT] = new Date()
}
if ( ctor.timestamps && ctor.UPDATED_AT ) {
// @ts-ignore
this[ctor.UPDATED_AT] = new Date()
}
}
const row = this._buildInsertFieldObject()
const returnable = new Collection<string>([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.created$.next(this)
}
await this.saved$.next(this)
return this
}
/**
* Cast this model to a simple object mapping model fields to their values.
*
* Only fields with `@Field()` annotations are included.
*/
public toObject(): { [key: string]: any } {
const ctor = this.constructor as typeof Model
const obj = {}
getFieldsMeta(this).each(field => {
// @ts-ignore
obj[field.modelKey] = this[field.modelKey]
})
ctor.appends.forEach(field => {
// @ts-ignore
obj[field] = this[field]
})
ctor.masks.forEach(field => {
// @ts-ignore
delete obj[field]
})
return obj
}
/**
* Cast the model to an JSON string object.
*
* Only fields with `@Field()` annotations are included.
*/
public toJSON(): string {
return JSON.stringify(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<Model<T> | 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() {
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<T> {
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<any>): boolean {
return this.key() === other.key() && this.qualifyKey() === other.qualifyKey()
}
/**
* Inverse of `is()`.
* @param other
*/
public isNot(other: Model<any>): boolean {
return !this.is(other)
}
/**
* Creates a new Pipe instance containing this model instance.
*/
public pipe(): Pipe<this> {
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 _isDirty() {
return (field: ModelField) => {
// @ts-ignore
return this[field.modelKey] !== this._original[field.databaseKey]
}
}
/**
* Returns a list of DATABASE fields that have been loaded for the current instance.
* @protected
*/
protected getLoadedDatabaseFields(): string[] {
if ( !this._original ) return []
return Object.keys(this._original).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()
// @ts-ignore
.keyMap('databaseKey', inst => this[inst.modelKey])
}
/**
* Sets a property on `this` to the value of a given property in `object`.
* @param this_field_name
* @param object_field_name
* @param object
* @protected
*/
protected setFieldFromObject(this_field_name: string | symbol, object_field_name: string, object: { [key: string]: any }) {
// @ts-ignore
this[this_field_name] = object[object_field_name]
}
}