Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
78
src/orm/model/Field.ts
Normal file
78
src/orm/model/Field.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {Collection} from "../../util";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/** The reflection metadata key containing information about the model's fields. */
|
||||
export const EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY = 'extollo:orm:Field.ts'
|
||||
|
||||
/**
|
||||
* Abstract representation of a field on a model.
|
||||
*/
|
||||
export interface ModelField {
|
||||
databaseKey: string,
|
||||
modelKey: string | symbol,
|
||||
type: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a collection of ModelField metadata from the given model.
|
||||
* @param model
|
||||
*/
|
||||
export function getFieldsMeta(model: any): Collection<ModelField> {
|
||||
const fields = Reflect.getMetadata(EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY, model.constructor)
|
||||
if ( !(fields instanceof Collection) ) {
|
||||
return new Collection<ModelField>()
|
||||
}
|
||||
|
||||
return fields as Collection<ModelField>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the collection of ModelField metadata as the field data for the given model.
|
||||
* @param model
|
||||
* @param fields
|
||||
*/
|
||||
export function setFieldsMeta(model: any, fields: Collection<ModelField>) {
|
||||
Reflect.defineMetadata(EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY, fields, model.constructor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that maps the given property to a database column of the specified FieldType.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyModel extends Model<MyModel> {
|
||||
* // Maps the 'name' VARCHAR column in the database to this property
|
||||
* @Field(FieldType.Varchar)
|
||||
* public name!: string
|
||||
*
|
||||
* // Maps the 'first_name' VARCHAR column in the database to this property
|
||||
* @Field(FieldType.Varchar, 'first_name')
|
||||
* public firstName!: string
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param type
|
||||
* @param databaseKey
|
||||
* @constructor
|
||||
*/
|
||||
export function Field(type: FieldType, databaseKey?: string): PropertyDecorator {
|
||||
return (target, modelKey) => {
|
||||
if ( !databaseKey ) databaseKey = String(modelKey)
|
||||
const fields = getFieldsMeta(target)
|
||||
|
||||
const existingField = fields.firstWhere('modelKey', '=', modelKey)
|
||||
if ( existingField ) {
|
||||
existingField.databaseKey = databaseKey
|
||||
existingField.type = type
|
||||
return setFieldsMeta(target, fields)
|
||||
}
|
||||
|
||||
fields.push({
|
||||
databaseKey,
|
||||
modelKey,
|
||||
type,
|
||||
})
|
||||
|
||||
setFieldsMeta(target, fields)
|
||||
}
|
||||
}
|
||||
813
src/orm/model/Model.ts
Normal file
813
src/orm/model/Model.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
25
src/orm/model/ModelBuilder.ts
Normal file
25
src/orm/model/ModelBuilder.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {Model} from "./Model";
|
||||
import {AbstractBuilder} from "../builder/AbstractBuilder";
|
||||
import {AbstractResultIterable} from "../builder/result/AbstractResultIterable";
|
||||
import {Instantiable} from "../../di";
|
||||
import {ModelResultIterable} from "./ModelResultIterable";
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||
*/
|
||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
constructor(
|
||||
/** The model class that is created for results of this query. */
|
||||
protected readonly ModelClass: Instantiable<T>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public getNewInstance(): AbstractBuilder<T> {
|
||||
return this.app().make<ModelBuilder<T>>(ModelBuilder)
|
||||
}
|
||||
|
||||
public getResultIterable(): AbstractResultIterable<T> {
|
||||
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this._connection, this.ModelClass)
|
||||
}
|
||||
}
|
||||
61
src/orm/model/ModelResultIterable.ts
Normal file
61
src/orm/model/ModelResultIterable.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {Model} from "./Model";
|
||||
import {AbstractResultIterable} from "../builder/result/AbstractResultIterable";
|
||||
import {Connection} from "../connection/Connection";
|
||||
import {ModelBuilder} from "./ModelBuilder";
|
||||
import {Container, Instantiable} from "../../di";
|
||||
import {QueryRow} from "../types";
|
||||
import {Collection} from "../../util";
|
||||
|
||||
/**
|
||||
* Implementation of the result iterable that returns query results as instances of the defined model.
|
||||
*/
|
||||
export class ModelResultIterable<T extends Model<T>> extends AbstractResultIterable<T> {
|
||||
constructor(
|
||||
public readonly builder: ModelBuilder<T>,
|
||||
public readonly connection: Connection,
|
||||
/** The model that should be instantiated for each row. */
|
||||
protected readonly ModelClass: Instantiable<T>
|
||||
) { super(builder, connection) }
|
||||
|
||||
public get selectSQL() {
|
||||
return this.connection.dialect().renderSelect(this.builder)
|
||||
}
|
||||
|
||||
async at(i: number) {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, i, i + 1)
|
||||
const row = (await this.connection.query(query)).rows.first()
|
||||
|
||||
if ( row ) {
|
||||
return this.inflateRow(row)
|
||||
}
|
||||
}
|
||||
|
||||
async range(start: number, end: number): Promise<Collection<T>> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
|
||||
return (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row))
|
||||
}
|
||||
|
||||
async count() {
|
||||
const query = this.connection.dialect().renderCount(this.selectSQL)
|
||||
const result = (await this.connection.query(query)).rows.first()
|
||||
return result?.extollo_render_count ?? 0
|
||||
}
|
||||
|
||||
async all(): Promise<Collection<T>> {
|
||||
const result = await this.connection.query(this.selectSQL)
|
||||
return result.rows.promiseMap<T>(row => this.inflateRow(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a query row, create an instance of the configured model class from it.
|
||||
* @param row
|
||||
* @protected
|
||||
*/
|
||||
protected async inflateRow(row: QueryRow): Promise<T> {
|
||||
return Container.getContainer().make<T>(this.ModelClass).assumeFromSource(row)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user