// Loaded from https://deno.land/x/denodb@v1.0.18/lib/model.ts import type { Operator, OrderByClauses, OrderDirection, QueryBuilder, QueryDescription, QueryType, } from "./query-builder.ts"; import type { Database } from "./database.ts"; import type { PivotModelSchema } from "./model-pivot.ts"; import { camelCase } from "../deps.ts"; import { DataTypes, FieldAlias, FieldOptions, FieldProps, FieldType, FieldTypeString, FieldValue, Values, } from "./data-types.ts"; /** Represents a Model class, not an instance. */ export type ModelSchema = typeof Model; export type ModelFields = { [key: string]: FieldType }; export type ModelDefaults = { [field: string]: FieldValue | (() => FieldValue); }; export type ModelPivotModels = { [modelName: string]: PivotModelSchema }; export type FieldMatchingTable = { [clientField: string]: string }; export type ModelOptions = { queryBuilder: QueryBuilder; database: Database; }; export type AggregationResult = Model & { avg?: number; count?: number; max?: number; min?: number; sum?: number; }; export type ModelEventType = | "creating" | "created" | "updating" | "updated" | "deleting" | "deleted"; export type ModelEventListenerWithModel = (model: Model) => void; export type ModelEventListenerWithoutModel = (model?: Model) => void; export type ModelEventListener = | ModelEventListenerWithoutModel | ModelEventListenerWithModel; export type ModelEventListeners = { [eventType in ModelEventType]?: ModelEventListener[]; }; /** Model that can be used with a `Database`. */ export class Model { [attribute: string]: FieldValue | Function /** Table name as it should be saved in the database. */ static table = ""; /** Should this model have `created_at` and `updated_at` fields by default. */ static timestamps = false; /** Model fields. */ static fields: ModelFields = {}; /** Default values for the model fields. */ static defaults: ModelDefaults = {}; /** Pivot table to use for a given model. */ static pivot: ModelPivotModels = {}; /** If the model has been created in the database. */ private static _isCreatedInDatabase: boolean = false; /** Query builder instance. */ private static _queryBuilder: QueryBuilder; /** Database which this model will be attached to. */ private static _database: Database; /** Model primary key. Manually found through `_findPrimaryKey()`. */ private static _primaryKey: string; /** Model field matching, from database to client and vice versa. */ private static _fieldMatching: { toDatabase: FieldMatchingTable; toClient: FieldMatchingTable; } = { toDatabase: {}, toClient: {}, }; /** Model current query being built. */ private static _currentQuery: QueryBuilder; /** Options this model was initialized with. */ private static _options: ModelOptions; /** Attached event listeners. */ private static _listeners: ModelEventListeners = {}; /** Link a model to a database. Should not be called from a child model. */ static _link(options: ModelOptions) { this._options = options; this._database = options.database; this._queryBuilder = options.queryBuilder; this._fieldMatching = this._database._computeModelFieldMatchings( this.name, this.fields, this.timestamps, ); this._currentQuery = this._queryBuilder.queryForSchema(this); this._primaryKey = this._findPrimaryKey(); } /** Drop a model in the database. */ static async drop() { const dropQuery = this._options.queryBuilder .queryForSchema(this) .table(this.table) .dropIfExists() .toDescription(); await this._options.database.query(dropQuery); this._isCreatedInDatabase = false; } /** Create a model in the database. Should not be called from a child model. */ static async createTable() { if (this._isCreatedInDatabase) { throw new Error("This model has already been initialized."); } const createQuery = this._options.queryBuilder .queryForSchema(this) .table(this.table) .createTable( this.formatFieldToDatabase(this.fields) as ModelFields, this.formatFieldToDatabase(this.defaults) as ModelDefaults, { withTimestamps: this.timestamps, ifNotExists: true, }, ) .toDescription(); await this._options.database.query(createQuery); this._isCreatedInDatabase = true; } /** Manually find the primary field by going through the schema fields. */ private static _findPrimaryField(): FieldOptions { const field = Object.entries(this.fields).find( ([_, fieldType]) => typeof fieldType === "object" && fieldType.primaryKey, ); return { name: field ? (this.formatFieldToDatabase(field[0]) as string) : "", type: field ? field[1] : DataTypes.INTEGER, defaultValue: 0, }; } /** Manually find the primary key by going through the schema fields. */ private static _findPrimaryKey(): string { return this._findPrimaryField().name; } /** Return the model computed primary key. */ static getComputedPrimaryKey(): string { if (!this._primaryKey) { this._primaryKey = this._findPrimaryKey(); } return this._primaryKey; } /** Return the field type of the primary key. */ static getComputedPrimaryType(): FieldTypeString { const field = this._findPrimaryField(); return typeof field.type === "object" ? (field.type as any).type : field.type; } /** Return the field properties of the primary key */ static getComputedPrimaryProps(): FieldProps { const field = this._findPrimaryField(); return typeof field === "object" ? field.type : {}; } /** Build the current query and run it on the associated database. */ private static async _runQuery(query: QueryDescription) { this._currentQuery = this._queryBuilder.queryForSchema(this); if (query.type) { this._runEventListeners(query.type); } const results = await this._database.query(query); if (query.type) { this._runEventListeners(query.type, results); } return results; } /** Format a field or an object of fields, following a field matching table. * Defaulting to `defaultCase` or `field` otherwise. */ private static _formatField( fieldMatching: FieldMatchingTable, field: string | { [fieldName: string]: any }, defaultCase?: (field: string) => string, ): string | { [fieldName: string]: any } { if (typeof field !== "string") { return Object.entries(field).reduce((prev: any, [fieldName, value]) => { prev[this._formatField(fieldMatching, fieldName) as string] = value; return prev; }, {}) as { [fieldName: string]: any }; } if (field in fieldMatching) { return fieldMatching[field]; } return defaultCase ? defaultCase(field) : field; } /** Format field or an object of fields from client to database. */ static formatFieldToDatabase(field: string | Object) { return this._formatField(this._fieldMatching.toDatabase, field); } /** Format field or an object of fields from database to client. */ static formatFieldToClient(field: string | Object) { return this._formatField(this._fieldMatching.toClient, field, camelCase); } /** Add an event listener for a specific operation/hook. * * Flight.on('created', (model) => console.log('New model:', model)); */ static on( this: T, eventType: ModelEventType, callback: ModelEventListener, ) { if (!(eventType in this._listeners)) { this._listeners[eventType] = []; } this._listeners[eventType]!.push(callback); return this; } /** Alias for `Model.on`, add an event listener for a specific operation/hook. * * Flight.addEventListener('created', (model) => console.log('New model:', model)); */ static addEventListener( this: T, eventType: ModelEventType, callback: ModelEventListener, ) { return this.on(eventType, callback); } static removeEventListener( eventType: ModelEventType, callback: ModelEventListener, ) { if (!(eventType in this._listeners)) { throw new Error( `There is no event listener for ${eventType}. You might be trying to remove a listener that you haven't added with Model.on('${eventType}', ...).`, ); } this._listeners[eventType] = this._listeners[eventType]!.filter(( listener, ) => listener !== callback); return this; } /** Run event listeners given a query type and results. */ private static _runEventListeners( queryType: QueryType, instances?: Model | Model[], ) { // -ing => present, -ed => past const isPastEvent = !!instances; let eventType: ModelEventType; switch (queryType) { case "insert": eventType = isPastEvent ? "created" : "creating"; break; case "update": eventType = isPastEvent ? "updated" : "updating"; break; case "delete": eventType = isPastEvent ? "deleted" : "deleting"; break; default: return; } const listeners = this._listeners[eventType]; if (!listeners) { return; } for (const listener of listeners) { if (instances) { if (Array.isArray(instances)) { if (instances.length > 0) { instances.forEach(listener); } else { (listener as ModelEventListenerWithoutModel)(); } } else { listener(instances); } } else { (listener as ModelEventListenerWithoutModel)(); } } } /** Return the table name followed by a field name. Can also rename a field using `nameAs`. * * Flight.field("departure") => "flights.departure" * * Flight.field("id", "flight_id") => { flight_id: "flights.id" } */ static field(field: string): string; static field(field: string, nameAs: string): FieldAlias; static field(field: string, nameAs?: string): string | FieldAlias { const fullField = this.formatFieldToDatabase( `${this.table}.${field}`, ) as string; if (nameAs) { return { [nameAs]: fullField }; } return fullField; } /** Run the current query. */ static async get() { return this._runQuery( this._currentQuery.table(this.table).get().toDescription(), ); } /** Fetch all the model records. * * await Flight.all(); * * await Flight.select("id").all(); */ static async all() { return this.get() as Promise; } /** Indicate which fields should be returned/selected from the query. * * await Flight.select("id").get(); * * await Flight.select("id", "destination").get(); */ static select( this: T, ...fields: (string | FieldAlias)[] ) { this._currentQuery.select( ...fields.map((field) => this.formatFieldToDatabase(field)), ); return this; } /** Create one or multiple records in the current model. * * await Flight.create({ departure: "Paris", destination: "Tokyo" }); * * await Flight.create([{ ... }, { ... }]); */ static async create(values: Values): Promise; static async create(values: Values[]): Promise; static async create(values: Values | Values[]) { const insertions = Array.isArray(values) ? values : [values]; const results = await this._runQuery( this._currentQuery.table(this.table).create( insertions.map((field) => this.formatFieldToDatabase(field) ) as Values[], ).toDescription(), ); if (!Array.isArray(values) && Array.isArray(results)) { return results[0]; } return results; } /** Find one or multiple records based on the model primary key. * * await Flight.find("1"); */ static async find(idOrIds: FieldValue): Promise; static async find(idOrIds: FieldValue[]): Promise; static async find(idOrIds: FieldValue | FieldValue[]) { const results = await this._runQuery( this._currentQuery .table(this.table) .find( this.getComputedPrimaryKey(), Array.isArray(idOrIds) ? idOrIds : [idOrIds], ) .toDescription(), ); return Array.isArray(idOrIds) ? results : (results as Model[])[0]; } /** Order query results based on a field name and an optional direction. * * await Flight.orderBy("departure").all(); * * await Flight.orderBy("departure", "desc").all(); * * await Flight.orderBy({ departure: "desc", destination: "asc" }).all(); */ static orderBy( this: T, fieldOrFields: string | OrderByClauses, orderDirection: OrderDirection = "asc", ) { if (typeof fieldOrFields === "string") { this._currentQuery.orderBy( this.formatFieldToDatabase(fieldOrFields) as string, orderDirection, ); } else { for ( const [field, orderDirectionField] of Object.entries( fieldOrFields, ) ) { this._currentQuery.orderBy( this.formatFieldToDatabase(field) as string, orderDirectionField, ); } } return this; } /** Group rows by a given field. * * await Flight.groupBy('departure').all(); */ static groupBy(this: T, field: string) { this._currentQuery.groupBy(this.formatFieldToDatabase(field) as string); return this; } /** Similar to `limit`, limit the number of results returned from the query. * * await Flight.take(10).get(); */ static take(this: T, limit: number) { return this.limit(limit); } /** Limit the number of results returned from the query. * * await Flight.limit(10).get(); */ static limit(this: T, limit: number) { this._currentQuery.limit(limit); return this; } /** Return the first record that matches the current query. * * await Flight.where("id", ">", "1").first(); */ static async first() { this.take(1); const results = await this.get(); return (results as Model[])[0]; } /** Skip n values in the results. * * await Flight.offset(10).get(); * * await Flight.offset(10).limit(2).get(); */ static offset(this: T, offset: number) { this._currentQuery.offset(offset); return this; } /** Similar to `offset`, skip n values in the results. * * await Flight.skip(10).get(); * * await Flight.skip(10).take(2).get(); */ static skip(this: T, offset: number) { return this.offset(offset); } /** Add a `where` clause to your query. * * await Flight.where("id", "1").get(); * * await Flight.where("id", ">", "1").get(); * * await Flight.where({ id: "1", departure: "Paris" }).get(); */ static where( this: T, fieldOrFields: string | Values, operatorOrFieldValue?: Operator | FieldValue, fieldValue?: FieldValue, ) { if (typeof fieldOrFields === "string") { const whereOperator: Operator = typeof fieldValue !== "undefined" ? (operatorOrFieldValue as Operator) : "="; const whereValue: FieldValue = typeof fieldValue !== "undefined" ? fieldValue : (operatorOrFieldValue as FieldValue); if (whereValue !== undefined) { this._currentQuery.where( this.formatFieldToDatabase(fieldOrFields) as string, whereOperator, whereValue, ); } } else { // TODO(eveningkid): cannot do multiple where with different operators // Need to find a great API for multiple where potentially with operators // .where({ name: 'John', age: { moreThan: 19 } }) // and then format it using Knex .andWhere(...) for (const [field, value] of Object.entries(fieldOrFields)) { if (value === undefined) { continue; } this._currentQuery.where( this.formatFieldToDatabase(field) as string, "=", value, ); } } return this; } /** Update one or multiple records. Also update `updated_at` if `timestamps` is `true`. * * await Flight.where("departure", "Dublin").update("departure", "Tokyo"); * * await Flight.where("departure", "Dublin").update({ destination: "Tokyo" }); */ static async update(fieldOrFields: string | Values, fieldValue?: FieldValue) { let fieldsToUpdate: Values = {}; if (this.timestamps) { fieldsToUpdate[ this.formatFieldToDatabase("updated_at") as string ] = new Date(); } if (typeof fieldOrFields === "string") { fieldsToUpdate[ this.formatFieldToDatabase(fieldOrFields) as string ] = fieldValue!; } else { fieldsToUpdate = { ...fieldsToUpdate, ...(this.formatFieldToDatabase(fieldOrFields) as { [fieldName: string]: any; }), }; } return this._runQuery( this._currentQuery .table(this.table) .update(fieldsToUpdate) .toDescription(), ) as Promise; } /** Delete a record by a primary key value. * * await Flight.deleteById("1"); */ static async deleteById(id: FieldValue) { return this._runQuery( this._currentQuery .table(this.table) .where(this.getComputedPrimaryKey(), "=", id) .delete() .toDescription(), ); } /** Delete selected records. * * await Flight.where("destination", "Paris").delete(); */ static async delete() { return this._runQuery( this._currentQuery.table(this.table).delete().toDescription(), ); } /** Join a table to the current query. * * await Flight.where( * Flight.field("departure"), * "Paris", * ).join( * Airport, * Airport.field("id"), * Flight.field("airportId"), * ).get() */ static join( this: T, joinTable: ModelSchema, originField: string, targetField: string, ) { this._currentQuery.join( joinTable.table, joinTable.formatFieldToDatabase(originField) as string, this.formatFieldToDatabase(targetField) as string, ); return this; } /** Join a table with left outer statement to the current query. * * await Flight.where( * Flight.field("departure"), * "Paris", * ).leftOuterJoin( * Airport, * Airport.field("id"), * Flight.field("airportId"), * ).get() */ static leftOuterJoin( this: T, joinTable: ModelSchema, originField: string, targetField: string, ) { this._currentQuery.leftOuterJoin( joinTable.table, joinTable.formatFieldToDatabase(originField) as string, this.formatFieldToDatabase(targetField) as string, ); return this; } /** Join a table with left statement to the current query. * * await Flight.where( * Flight.field("departure"), * "Paris", * ).leftJoin( * Airport, * Airport.field("id"), * Flight.field("airportId"), * ).get() */ static leftJoin( this: T, joinTable: ModelSchema, originField: string, targetField: string, ) { this._currentQuery.leftJoin( joinTable.table, joinTable.formatFieldToDatabase(originField) as string, this.formatFieldToDatabase(targetField) as string, ); return this; } /** Count the number of records of a model or filtered by a field name. * * await Flight.count(); * * await Flight.where("destination", "Dublin").count(); */ static async count(field: string = "*") { const value = await this._runQuery( this._currentQuery .table(this.table) .count(this.formatFieldToDatabase(field) as string) .toDescription(), ); return (value as AggregationResult[])[0].count; } /** Find the minimum value of a field from all the selected records. * * await Flight.min("flightDuration"); */ static async min(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .min(this.formatFieldToDatabase(field) as string) .toDescription(), ); return (value as AggregationResult[])[0].min; } /** Find the maximum value of a field from all the selected records. * * await Flight.max("flightDuration"); */ static async max(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .max(this.formatFieldToDatabase(field) as string) .toDescription(), ); return (value as AggregationResult[])[0].max; } /** Compute the sum of a field's values from all the selected records. * * await Flight.sum("flightDuration"); */ static async sum(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .sum(this.formatFieldToDatabase(field) as string) .toDescription(), ); return (value as AggregationResult[])[0].sum; } /** Compute the average value of a field's values from all the selected records. * * await Flight.avg("flightDuration"); * * await Flight.where("destination", "San Francisco").avg("flightDuration"); */ static async avg(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .avg(this.formatFieldToDatabase(field) as string) .toDescription(), ); return (value as AggregationResult[])[0].avg; } /** Find associated values for the given model for one-to-many and many-to-many relationships. * * class Airport { * static flights() { * return this.hasMany(Flight); * } * } * * Airport.where("id", "1").flights(); */ static hasMany( this: T, model: ModelSchema, ): Promise { const currentWhereValue = this._findCurrentQueryWhereClause(); if (model.name in this.pivot) { const pivot = this.pivot[model.name]; const pivotField = this.formatFieldToDatabase( pivot._pivotsFields[this.name], ) as string; const pivotOtherModel = pivot._pivotsModels[model.name]; const pivotOtherModelField = pivotOtherModel.formatFieldToDatabase( pivot._pivotsFields[model.name], ) as string; return pivot .where(pivot.field(pivotField), currentWhereValue) .join( pivotOtherModel, pivotOtherModel.field(pivotOtherModel.getComputedPrimaryKey()), pivot.field(pivotOtherModelField), ) .get(); } const foreignKeyName = this._findModelForeignKeyField(model); this._currentQuery = this._queryBuilder.queryForSchema(this); return model.where(foreignKeyName, currentWhereValue).all(); } /** Find associated values for the given model for one-to-one and one-to-many relationships. */ static async hasOne(this: T, model: ModelSchema) { const currentWhereValue = this._findCurrentQueryWhereClause(); const FKName = this._findModelForeignKeyField(model); if (!FKName) { const currentModelFKName = this._findModelForeignKeyField(this, model); const currentModelValue = await this.where( this.getComputedPrimaryKey(), currentWhereValue, ).first(); const currentModelFKValue = currentModelValue[currentModelFKName] as FieldValue; return model.where(model.getComputedPrimaryKey(), currentModelFKValue) .first(); } return model.where(FKName, currentWhereValue).first(); } /** Look for the current query's where clause for this model's primary key. */ private static _findCurrentQueryWhereClause() { if (!this._currentQuery._query.wheres) { throw new Error("The current query does not have any where clause."); } const where = this._currentQuery._query.wheres.find((where) => { return where.field === this.getComputedPrimaryKey(); }); if (!where) { throw new Error( "The current query does not have any where clause for this model primary key.", ); } return where.value; } /** Look for a `fieldName: Relationships.belongsTo(forModel)` field for a given `model`. */ private static _findModelForeignKeyField( model: ModelSchema, forModel: ModelSchema = this, ): string { const modelFK: [string, FieldType] | undefined = Object.entries( model.fields, ).find(([, type]) => { return typeof type === "object" ? type.relationship?.model === forModel : false; }); if (!modelFK) { return ""; } return modelFK[0]; } /** Return the instance current value for its primary key. */ private _getCurrentPrimaryKey() { const model = this.constructor as ModelSchema; return (this as any)[model.getComputedPrimaryKey()] as string; } /** Create a new record for the model. * * const flight = new Flight(); * flight.departure = "Toronto"; * flight.destination = "Paris"; * await flight.save(); */ async save() { const model = this.constructor as ModelSchema; const values: Values = {}; for (const field of Object.keys(model.fields)) { if (this.hasOwnProperty(field)) { values[field] = (this as any)[field]; } else if (model.defaults.hasOwnProperty(field)) { const defaultValue = model.defaults[field]; if (typeof defaultValue === "function") { values[field] = defaultValue(); } else { values[field] = defaultValue; } } } const createdInstance = await model.create(values); for (const field in createdInstance) { (this as any)[field] = (createdInstance as any)[field]; } return this; } /** Update this record using its current field values. * * flight.destination = "London"; * await flight.update(); */ async update() { const model = this.constructor as ModelSchema; const modelPK = model.getComputedPrimaryKey(); const values: Values = {}; for (const field of Object.keys(model.fields)) { if (this.hasOwnProperty(field) && field !== modelPK) { values[field] = (this as any)[field]; } } await model.where(modelPK, this._getCurrentPrimaryKey()).update( values, ); return this; } /** Delete this record from the database. * * await flight.delete(); */ async delete() { const model = this.constructor as ModelSchema; const PKCurrentValue = this._getCurrentPrimaryKey(); if (PKCurrentValue === undefined) { throw new Error( "This instance does not have a value for its primary key. It cannot be deleted.", ); } return model.deleteById(PKCurrentValue); } }