orm/src/model/Model.js

/**
 * @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