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

1076 lines
33 KiB

import {ModelKey, QueryRow, QuerySource} from '../types'
import {Container, Inject, Instantiable, isInstantiable} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, Collection, uuid4, isKeyof, Pipeline} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {Logging} from '../../service/Logging'
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'
import {ModelEvent} from './events/ModelEvent'
/**
* Base for classes that are mapped to tables in a database.
*/
export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>> {
@Inject()
protected readonly logging!: Logging
/**
* 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
/**
* 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<T, any, any> }> = new Collection<{accessor: string | symbol; relation: Relation<T, any, any>}>()
protected scopes: Collection<{ accessor: string | Instantiable<Scope>, scope: ScopeClosure }> = new Collection<{accessor: string | Instantiable<Scope>; 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>(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>>(): ModelBuilder<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)
})
if ( Array.isArray(this.prototype.with) ) {
// Try to get the eager-loaded relations statically, if possible
for (const relation of this.prototype.with) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
builder.with(relation)
}
} else if ( this.constructor.length < 1 ) {
// Otherwise, if we can instantiate the model without any arguments,
// do that and get the eager-loaded relations directly.
const inst = Container.getContainer().make<Model<any>>(this)
if ( Array.isArray(inst.with) ) {
for (const relation of inst.with) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
builder.with(relation)
}
}
}
if ( this.prototype.scopes ) {
// Same thing here. Try to get the scopes statically, if possible
builder.withScopes(this.prototype.scopes)
} else if ( this.constructor.length < 1 ) {
// Otherwise, try to instantiate the model if possible and load the scopes that way
const inst = Container.getContainer().make<Model<any>>(this)
builder.withScopes(inst.scopes)
}
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)
})
}
}
/**
* 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> {
this.originalSourceRow = row
getFieldsMeta(this).each(field => {
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
})
await this.push(new ModelRetrievedEvent<T>(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<this> {
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 {
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<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)
})
for ( const relation of this.with ) {
builder.with(relation)
}
builder.withScopes(this.scopes)
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(): Promise<T[]> {
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): 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(): 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<A> {
* 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 {
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
}
/**
* 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<Model<T>> {
await this.push(new ModelSavingEvent<T>(this as any))
const ctor = this.constructor as typeof Model
if ( this.exists() && this.isDirty() ) {
await this.push(new ModelUpdatingEvent<T>(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())
if ( data ) {
await this.assumeFromSource(data)
}
await this.push(new ModelUpdatedEvent<T>(this as any))
} else if ( !this.exists() ) {
await this.push(new ModelCreatingEvent<T>(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<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(data)
}
await this.push(new ModelCreatedEvent<T>(this as any))
}
await this.push(new ModelSavedEvent<T>(this as any))
return this
}
/**
* Delete the current model from the database, if it exists.
*/
async delete(): Promise<void> {
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<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(): Promise<void> {
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)
}
/**
* 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 !this.originalSourceRow || (this as any)[field.modelKey] !== this.originalSourceRow[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()}`)
return Pipeline.id<Collection<ModelField>>()
.unless(ctor.populateKeyOnInsert, fields => {
return fields.where('databaseKey', '!=', this.keyName())
})
.apply(getFieldsMeta(this))
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
}
/**
* 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<MyModel> {
* @Related()
* public otherModel() {
* return this.hasOne(MyOtherModel)
* }
* }
* ```
*
* @param related
* @param foreignKeyOverride
* @param localKeyOverride
*/
public hasOne<T2 extends Model<T2>>(related: Instantiable<T2>, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasOne<T, T2> {
return new HasOne<T, T2>(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<MyModel> {
* @Related()
* public otherModels() {
* return this.hasMany(MyOtherModel)
* }
* }
* ```
*
* @param related
* @param foreignKeyOverride
* @param localKeyOverride
*/
public hasMany<T2 extends Model<T2>>(related: Instantiable<T2>, foreignKeyOverride?: keyof T & string, localKeyOverride?: keyof T2 & string): HasMany<T, T2> {
return new HasMany<T, T2>(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<MyModel> {
* @Related()
* public otherModel() {
* return this.hasOne(MyOtherModel)
* }
* }
*
* class MyOtherModel extends Model<MyOtherModel> {
* @Related()
* public myModel() {
* return this.belongsToOne(MyModel, 'otherModel')
* }
* }
* ```
*
* @param related
* @param relationName
*/
public belongsToOne<T2 extends Model<T2>>(related: Instantiable<T>, relationName: keyof T2): HasOne<T, T2> {
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<T, T2>(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<MyModel> {
* @Related()
* public otherModels() {
* return this.hasMany(MyOtherModel)
* }
* }
*
* class MyOtherModel extends Model<MyOtherModel> {
* @Related()
* public myModels() {
* return this.belongsToMany(MyModel, 'otherModels')
* }
* }
* ```
*
* @param related
* @param relationName
*/
public belongsToMany<T2 extends Model<T2>>(related: Instantiable<T>, relationName: keyof T2): HasMany<T, T2> {
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<T, T2>(this as unknown as T, relatedInst, localKey, foreignKey)
}
/**
* Get the relation instance returned by a method on this model.
* @param name
* @protected
*/
public getRelation<T2 extends Model<T2>>(name: keyof this): Relation<T, T2, RelationValue<T2>> {
const relFn = this[name]
if ( relFn instanceof Relation ) {
return relFn
}
if ( typeof relFn === 'function' ) {
const rel = relFn.apply(relFn, this)
if ( rel instanceof Relation ) {
return rel
}
}
throw new TypeError(`Cannot get relation of name: ${name}. Method does not return a Relation.`)
}
/**
* Register a scope on the model.
* @param scope
* @protected
*/
protected withScope(scope: Instantiable<Scope> | ScopeClosure): this {
if ( isInstantiable(scope) ) {
if ( !this.hasScope(scope) ) {
this.scopes.push({
accessor: scope,
scope: builder => (this.make<Scope>(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<Scope> | ScopeClosure): this {
if ( isInstantiable(scope) ) {
if ( !this.hasScope(scope) ) {
this.scopes.push({
accessor: name,
scope: builder => (this.make<Scope>(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<Scope>): boolean {
return Boolean(this.scopes.firstWhere('accessor', '=', name))
}
}