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.

556 lines
18 KiB

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<T extends Model<T>> extends Builder<T> {
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<Model<T>>()
protected saving$ = new BehaviorSubject<Model<T>>()
protected saved$ = new BehaviorSubject<Model<T>>()
protected updating$ = new BehaviorSubject<Model<T>>()
protected updated$ = new BehaviorSubject<Model<T>>()
protected creating$ = new BehaviorSubject<Model<T>>()
protected created$ = new BehaviorSubject<Model<T>>()
protected deleting$ = new BehaviorSubject<Model<T>>()
protected deleted$ = new BehaviorSubject<Model<T>>()
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<undefined | typeof Model> {
const result = await this.model_select()
.where(this.qualified_key_name(), '=', key)
.limit(1)
.results()
return result.first()
}
public static async count(): Promise<number> {
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<ModelField>
*/
public static fields(): Collection<ModelField> {
return get_fields_meta(this.prototype)
}
public field_defs(): Collection<ModelField> {
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<ModelField> fields
*/
public static set_fields(fields: Collection<ModelField>) {
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<Model<T>> {
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<void> {
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<Model<T>> {
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<T>): Model<T> {
const row = this.to_row()
delete row[this.key_name()]
other.assume_from_source(row)
return other
}
public is(other_instance: Model<any>): 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<any>): 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}`
}
}