/**
* @module flitter-orm/src/model/Model
*/
const { Injectable } = require('flitter-di')
const Schema = require('../schema/Schema')
const Filter = require('../filter/Filter')
const { Cursor, Collection, ObjectId } = require('mongodb')
const Scope = require('./Scope')
const ResultCache = require('./ResultCache')
/**
* The base model class. All model implementations should extend from this.
* @extends module:flitter-di/src/Injectable~Injectable
*/
class Model extends Injectable {
/**
* The services required by this model.
* Note that the 'scaffold' service must be provided.
* @returns {Array<string>}
*/
static get services() {
return [...super.services, 'scaffold']
}
/**
* User defined schema for the model.
* This should be implemented by subclasses.
* @returns {object}
*/
static get schema() { return {} }
/**
* The holding variable for the instantiated schema.
* @private
* @type {module:flitter-orm/src/schema/Schema~Schema|boolean}
*/
static __schema_instance = false
/**
* Array of instantiated references to Scopes that should be applied
* to this model.
* @type {Array<module:flitter-orm/src/model/Scope~Scope>}
*/
static scopes = []
/**
* The instantiated schema for this model.
* @returns {module:flitter-orm/src/schema/Schema~Schema}
* @private
*/
static get __schema() {
return new Schema(this.schema)
}
/**
* Get the MongoDB ObjectId class.
* @returns {mongodb/ObjectId}
* @constructor
*/
static get ObjectId() {
return ObjectId
}
/**
* Optionally, the name of the collection where this model should
* have its records stored. If none is provided, the collection will be the
* name of the class.
* @type {string|boolean}
*/
static collection = false
/**
* True if the model is embedded within a parent.
* @type {boolean}
* @private
*/
#__embedded = false
/**
* If the model is embedded within a parent, this is
* the reference to that parent. Otherwise, false.
* @type {module:flitter-orm/src/model/Model~Model|boolean}
* @private
*/
#__embedded_parent = false
/**
* Cache for results of relationship lookups for the instance.
* @type {module:flitter-orm/src/model/ResultCache~ResultCache}
* @private
*/
#__relation_cache = new ResultCache()
/**
* The collection name for this model. If specified, this will be the value
* of the collection static member. If not, it will be the name of the class.
* @returns {string}
* @private
*/
static get __name() { return this.collection ? this.collection : this.name }
/**
* Get a lookup cursor for this model's collection with the specified filters.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {object} [filter = {}] - the query filters for the cursor
* @param {object} [opts = {}] - optional settings
* @returns {mongodb/Cursor}
*/
static async cursor(filter = {}, opts = {}) {
filter = (await this.filter()).absorb(filter).write()
return this.prototype.scaffold.collection(this.__name).find(filter, opts)
}
/**
* Lookup an array of instances of this model with the specified filters.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {object} [filter = {}] - the query filters for the cursor
* @param {object} [opts = {}] - optional settings
* @returns {Promise<Array<module:flitter-orm/src/model/Model~Model>>}
*/
static async find(filter, opts) {
const cursor = await this.cursor(filter, opts)
return this.from_cursor(cursor)
}
/**
* Returns an array of model instances from the specified cursor.
* @param {mongodb/cursor} cursor - the cursor
* @returns {Promise<module:flitter-orm/src/model/Model~Model>}
*/
static async from_cursor(cursor) {
const records = await cursor.toArray()
const collection = []
for ( const record of records ) {
collection.push(new this(record))
}
return collection
}
/**
* Returns an instantiated ObjectId for the given string.
* This is preferred to importing mongodb manually and casting it,
* because it insures that flitter-orm's instanceof checks are still
* satisfied.
*
* @param {string} string - the string of the ID
* @returns {mongodb/ObjectId} - the equivalent Object ID
*/
static to_object_id(string) {
return ObjectId(string)
}
/**
* Lookup a single instance of this model by the provided ID.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {string|ObjectId} id - the ID of the model to query
* @param {object} [opts = {}] - optional settings
* @returns {Promise<Array<module:flitter-orm/src/model/Model~Model>>}
*/
static async findById(id, opts = {}) {
if ( !(id instanceof ObjectId) ) {
try {
const oid = ObjectId(id)
id = oid
} catch (e) {}
}
return this.findOne({ _id: id })
}
/**
* Lookup a single instance of this model with the specified filters.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {object} [filter = {}] - the query filters for the cursor
* @param {object} [opts = {}] - optional settings
* @returns {Promise<Array<module:flitter-orm/src/model/Model~Model>>}
*/
static async findOne(filter, opts) {
const cursor = await this.cursor(filter, opts)
const records = await this.from_cursor(cursor.limit(1))
if ( records.length > 0 ) {
return records[0]
}
}
/**
* Delete a single instance of this model with the specified filters.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {object} [filter = {}] - the query filters for the cursor
* @param {object} [opts = {}] - optional settings
* @returns {Promise<undefined>}
*/
static async deleteOne(filter = {}, opts = {}) {
filter = (await this.filter()).absorb(filter).write()
await this.prototype.scaffold.collection(this.__name).deleteOne(filter, opts)
}
/**
* Delete all instances of this model with the specified filters.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {object} [filter = {}] - the query filters for the cursor
* @param {object} [opts = {}] - optional settings
* @returns {Promise<undefined>}
*/
static async deleteMany(filter = {}, opts = {}) {
filter = (await this.filter()).absorb(filter).write()
await this.prototype.scaffold.collection(this.__name).deleteMany(filter, opts)
}
/**
* Limit the results to a specific number of records.
* @param {number} to
* @returns {module:flitter-orm/src/proxy/model/LimitProxy~LimitProxy}
*/
static limit(to) {
const LimitProxy = require('../proxy/model/LimitProxy')
return new LimitProxy(this, to)
}
/**
* Sort the results by the specified key or keys.
* @param {string} sorts... - any number of sort specifications
* @example
* Model.sort('+last_name', '+first_name', '-create_date')
* @returns {module:flitter-orm/src/proxy/model/SortProxy~SortProxy}
*/
static sort(...sorts) {
const SortProxy = require('../proxy/model/SortProxy')
return new SortProxy(this, sorts)
}
/**
* Count the number of instances of this model with the specified filters.
*
* See: http://mongodb.github.io/node-mongodb-native/3.5/api/Collection.html#find
* for information on optional settings.
*
* @param {object} [filter = {}] - the query filters for the cursor
* @param {object} [opts = {}] - optional settings
* @returns {Promise<number>}
*/
static async count(filter = {}, opts = {}) {
filter = (await this.filter()).absorb(filter).write()
return this.prototype.scaffold.collection(this.__name).countDocuments(filter, opts)
}
/**
* Create a new programmatic filter for this class,
* pre-loaded with any scopes.
* @returns {module:flitter-orm/src/filter/Filter~Filter}
*/
static async filter(ref = false) {
if ( !ref ) ref = this
let filter = new Filter(ref)
for ( const scope of this.scopes ) {
filter = await scope.filter(filter)
}
return filter
}
/**
* Create a new instance of this model.
* @param {object} [data = {}] - data to preload the model with
* @param {module:flitter-orm/src/model/Model~Model|boolean} [embedded_parent = false] - if specified, sets the embedded parent of this model for saving
*/
constructor(data = {}, embedded_parent = false) {
super()
this.__set_values(data)
this.#__embedded = !!embedded_parent
this.#__embedded_parent = embedded_parent
}
/**
* If defined, will return the string-form ID of this model.
* @returns {string|undefined}
*/
get id() {
if ( this._id ) {
return String(this._id)
}
}
/**
* Persist this model instance to the database. This will store only values defined
* in the schema for this model, and, in so doing, will cast those values and fill in
* the specified defaults. These changes will be added to this instance after the save.
*
* @returns {Promise<module:flitter-orm/src/model/Model~Model>} - the current instance with updated properties
*/
async save() {
const schema = this.constructor.__schema
const shallow_object = {}
for ( const prop in schema.schema ) {
if ( prop in this ) {
shallow_object[prop] = this[prop]
}
}
const db_object = await this.__scope_limit_save(schema.cast_to_schema(shallow_object))
if ( !this.#__embedded ) {
if ( this._id ) {
await this.__collection().updateOne({ _id: this._id }, { $set: db_object })
} else {
const result = await this.__collection().insertOne(db_object)
this._id = result.insertedId
}
} else {
if ( this._id ) {
await this.#__embedded_parent.save()
} else {
this._id = new ObjectId
await this.#__embedded_parent.save()
}
}
this.__set_values(db_object)
return this
}
/**
* A convenience method. Set the specified field on this model equal to the
* specified property and immediately save the record.
* @param {string} field
* @param {*} value
* @returns {Promise<module:flitter-orm/src/model/Model~Model>}
*/
async set(field, value) {
this[field] = value
return this.save()
}
/**
* Delete the current instance of this model from the database.
* This will remove the model's ID from this instance. Other properties
* will remain unchanged.
* @returns {Promise<module:flitter-orm/src/model/Model~Model>}
*/
async delete() {
if ( this._id ) {
await this.__collection().deleteOne({ _id: this._id })
delete this._id
}
return this
}
/**
* Get the MongoDB collection instance for this model.
* @returns {Collection}
* @private
*/
__collection() {
return this.scaffold.collection(this.constructor.__name)
}
/**
* Get the MongoDB collection instance for this model.
* @returns {Collection}
* @private
*/
static __collection() {
return this.prototype.scaffold.collection(this.__name)
}
/**
* Shallow copy the values from the specified object to this model.
* @param {object} data
* @param {object} [current_object = false] - for recursion. The current object scope.
* @param {object} [current_schema = false] - for recursion. The current schema level.
* @private
*/
__set_values(data, current_object = false, current_schema = false) {
if ( !current_object ) current_object = this
if ( !current_schema ) current_schema = this.constructor.__schema.schema
if ( Array.isArray(data) ) {
current_object = []
const schemata = current_schema._default
const type = schemata.type
for ( const val of data ) {
if ( type === Schema.types.Model ) {
current_object.push(new schemata.def(val, this))
} else if ( type.prototype instanceof Schema.types.Object ) {
if ( !(prop in current_object) ) current_object[prop] = {}
current_object.push(this.__set_values(val, current_object[prop], schemata.children))
} else if ( type.prototype instanceof Schema.types.Array ) {
current_object.push(this.__set_values(val, [], schemata.children))
} else {
current_object.push(val)
}
}
} else if ( typeof data === 'object' ) {
for ( const prop in current_schema ) {
if ( !current_schema.hasOwnProperty(prop) ) continue
const schemata = current_schema[prop]
const type = schemata.type
const has_val = prop in data
if ( type === Schema.types.Model ) {
if (has_val) current_object[prop] = new schemata.def(data[prop], this)
} else if ( type === Schema.types.Object ) {
if ( !(prop in current_object) ) current_object[prop] = {}
if ( has_val ) {
if ( schemata.children ) this.__set_values(data[prop], current_object[prop], schemata.children)
else current_object[prop] = data[prop]
}
} else if ( type === Schema.types.Array ) {
if ( !(prop in current_object) ) current_object[prop] = []
if ( has_val ) current_object[prop] = this.__set_values(data[prop], current_object[prop], schemata.children)
} else {
if ( has_val ) current_object[prop] = data[prop]
}
}
if ( '_id' in data ) current_object._id = data._id
}
return current_object
}
/**
* Allow all of the model's registerd scopes to modify the
* schema-cast database object before it is persisted.
* @param {object} db_object
* @returns {Promise<object>} - modified db_object
* @private
*/
async __scope_limit_save(db_object) {
for ( const scope of this.constructor.scopes ) {
db_object = await scope.save(db_object)
}
return db_object
}
/**
* Associates a single record of another model with this model based
* on a local/foreign key relationship.
*
* Normally, this would be automatically returned by a named method
* on the sub-class that implements the relationship.
*
* This returns a cached result. So, it will only return a promise
* on the first call. Subsequent calls will return the results from
* the cache and are, therefore, synchronous.
*
* @param {module:flitter-orm/src/model/Model~Model} OtherModel - static class of the other model
* @param {string} local_key - local key of the field to match
* @param {string} [foreign_key = ''] - foreign key of the field to match (if none provided, assume the same as local_key)
* @returns {Promise<module:flitter-orm/src/model/Model~Model>|module:flitter-orm/src/model/Model~Model} - the matching model instance
*/
has_one(OtherModel, local_key, foreign_key = '') {
if ( !foreign_key ) foreign_key = local_key
const identifier = `${foreign_key}_${local_key}_many`
if ( this.#__relation_cache.has(OtherModel.__name, identifier) ) {
return this.#__relation_cache.get(OtherModel.__name, identifier)
} else {
return new Promise(resolve => {
OtherModel.filter().then(filter => {
const proxy = filter.equal(foreign_key, () => this[local_key]).end()
proxy.findOne().then(results => {
this.#__relation_cache.store(OtherModel.__name, identifier, results)
resolve(results)
})
})
})
}
}
/**
* Associates a single record of another model with this model based
* on a local/foreign key relationship.
*
* Normally, this would be automatically returned by a named method
* on the sub-class that implements the relationship.
*
* This returns a cached result. So, it will only return a promise
* on the first call. Subsequent calls will return the results from
* the cache and are, therefore, synchronous.
*
* @param {module:flitter-orm/src/model/Model~Model} OtherModel - static class of the other model
* @param {string} local_key - local key of the field to match
* @param {string} [foreign_key = ''] - foreign key of the field to match (if none provided, assume the same as local_key)
* @returns {Promise<module:flitter-orm/src/model/Model~Model|undefined>|module:flitter-orm/src/model/Model~Model} - the matching model instance
*/
belongs_to_one(OtherModel, local_key, foreign_key = '') {
return this.has_one(OtherModel, local_key, foreign_key)
}
/**
* Associates many records of another model with this model based
* on a local/foreign key relationship.
*
* Normally, this would be automatically returned by a named method
* on the sub-class that implements the relationship.
*
* This returns a cached result. So, it will only return a promise
* on the first call. Subsequent calls will return the results from
* the cache and are, therefore, synchronous.
*
* @param {module:flitter-orm/src/model/Model~Model} OtherModel - static class of the other model
* @param {string} local_key - local key of the field to match
* @param {string} [foreign_key = ''] - foreign key of the field to match (if none provided, assume the same as local_key)
* @returns {Promise<Array<module:flitter-orm/src/model/Model~Model>>|Array<module:flitter-orm/src/model/Model~Model>} - the matching model instances
*/
has_many(OtherModel, local_key, foreign_key = '') {
if ( !foreign_key ) foreign_key = local_key
const identifier = `${foreign_key}_${local_key}_many`
if ( this.#__relation_cache.has(OtherModel.__name, identifier) ) {
return this.#__relation_cache.get(OtherModel.__name, identifier)
} else {
return new Promise(resolve => {
OtherModel.filter().then(filter => {
const proxy = filter.in(foreign_key, () => this[local_key]).end()
proxy.find().then(results => {
this.#__relation_cache.store(OtherModel.__name, identifier, results)
resolve(results)
})
})
})
}
}
/**
* Associates many records of another model with this model based
* on a local/foreign key relationship.
*
* Normally, this would be automatically returned by a named method
* on the sub-class that implements the relationship.
*
* This returns a cached result. So, it will only return a promise
* on the first call. Subsequent calls will return the results from
* the cache and are, therefore, synchronous.
*
* @param {module:flitter-orm/src/model/Model~Model} OtherModel - static class of the other model
* @param {string} local_key - local key of the field to match
* @param {string} [foreign_key = ''] - foreign key of the field to match (if none provided, assume the same as local_key)
* @returns {Promise<Array<module:flitter-orm/src/model/Model~Model>>|Array<module:flitter-orm/src/model/Model~Model>} - the matching model instances
*/
belongs_to_many(OtherModel, local_key, foreign_key = '') {
return this.has_many(OtherModel, local_key, foreign_key)
}
}
module.exports = exports = Model