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.
357 lines
10 KiB
357 lines
10 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} 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'
|
|
|
|
// TODO separate read/write connections
|
|
// TODO manual dirty flags
|
|
export abstract class Model extends Builder {
|
|
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 = false
|
|
|
|
protected _original?: QueryRow
|
|
|
|
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 update(target?: QuerySource, alias?: string) {
|
|
const constructor = (this.constructor as typeof Model)
|
|
return super.update()
|
|
.to(constructor.table_name())
|
|
.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())
|
|
.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))
|
|
}
|
|
|
|
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]
|
|
})
|
|
return this
|
|
}
|
|
|
|
// changes
|
|
// date format
|
|
// appends
|
|
// caching
|
|
// relations
|
|
// timestamps
|
|
// hidden
|
|
// fillable
|
|
// guarded
|
|
// key type
|
|
// with
|
|
// per page
|
|
// exists
|
|
|
|
protected get _is_dirty() {
|
|
return (field_def: ModelField) => {
|
|
// @ts-ignore
|
|
return this[field_def.model_key] !== this._original[field_def.database_key]
|
|
}
|
|
}
|
|
|
|
// to_row
|
|
public to_row(): QueryRow {
|
|
const data = {}
|
|
const meta = (this.constructor as typeof Model).fields()
|
|
meta.each(field => {
|
|
|
|
})
|
|
return {}
|
|
}
|
|
|
|
// relations_to_row
|
|
// dirty_to_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
|
|
}
|
|
|
|
// attributes
|
|
|
|
/**
|
|
* 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()
|
|
}
|
|
|
|
// was changed - pass attribute(s)
|
|
// observe/observers - retrieved, saving, saved, updating, updated, creating, created, deleting, deleted
|
|
// global scopes
|
|
// non-global scopes
|
|
|
|
// 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
|
|
// touch - update update timestamp, created if necessary
|
|
// touch created - update update and created timestamp
|
|
// set created at/set updated at
|
|
// is fillable
|
|
// is guarded
|
|
// without touching
|
|
|
|
// all
|
|
// load relations
|
|
// load missing relations
|
|
// increment column
|
|
// decrement column
|
|
|
|
// update - bulk
|
|
// push - update single
|
|
|
|
// save - update or create instance
|
|
public async save(): Promise<Model> {
|
|
const constructor = (this.constructor as typeof Model)
|
|
|
|
// TODO timestamps
|
|
if ( this.exists() && this.is_dirty() ) { // We're updating an existing record
|
|
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()})`)
|
|
}
|
|
|
|
this.assume_from_source(result.firstWhere(this.key_name(), '=', this.key()))
|
|
} else if ( !this.exists() ) { // We're inserting a new record
|
|
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()})`)
|
|
}
|
|
|
|
this.assume_from_source(result.first())
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
protected _build_insert_field_object(): FieldValueObject {
|
|
const fields = this.field_defs()
|
|
const values = {}
|
|
fields.each(field_def => {
|
|
// @ts-ignore
|
|
values[field_def.database_key] = this[field_def.model_key]
|
|
})
|
|
return values
|
|
}
|
|
|
|
protected _loaded_database_fields(): string[] {
|
|
if ( typeof this._original === 'undefined' ) return []
|
|
return Object.keys(this._original).map(String)
|
|
}
|
|
|
|
// destroy - bulk
|
|
// delete single
|
|
// force delete - for soft deleted models
|
|
// without scope
|
|
// without global scope
|
|
// without global scopes
|
|
|
|
// to object
|
|
// to json
|
|
// fresh - get new instance of this model
|
|
// refresh - reload this instance
|
|
// replicate to new instance
|
|
// is - check if two models are the same
|
|
// isNot
|
|
|
|
/**
|
|
* 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() {
|
|
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}`
|
|
}
|
|
}
|