fogine/couchbase-odm

View on GitHub
lib/model.js

Summary

Maintainability
F
3 days
Test Coverage
const util    = require('util');
const _       = require("lodash");
const Promise = require('bluebird');

const StorageAdapter = require("./storageAdapter");
const Instance       = require("./instance.js");
const Key            = require("./key/key.js");
const UUID4Key       = require("./key/uuid4Key.js");
const RefDocKey      = require("./key/refDocKey.js");
const ModelError     = require("./error/modelError.js");
const StorageError   = require("./error/storageError.js");
const Hook           = require("./hook.js");
const schemaUtils    = require('./util/schema.js');
const validator      = require('./validator.js');

const HookTypes = Hook.types;

module.exports = Model;

/**
 * @constructor
 * @extends {Hook}
 * @throws {ModelError}
 *
 * @param {string}    name - name of the Model
 * @param {Object}    schema - structure definition of stored data
 * @param {Object}    [options]
 * @param {Key}       [options.key=UUID4Key] - The strategy used for document's `key` generation. Must inherit from base `Key` class
 * @param {RefDocKey} [options.refDocKey=RefDocKey] - The strategy used for reference document `key` generation. Must inherit from base `RefDocKey` class
 * @param {boolean}   [options.timestamps=true] - Adds automatically handled timestamp schema properties: `created_at`, `updated_at` and if `paranoid` option is true, `deleted_at` property
 * @param {boolean}   [options.paranoid=false] - if `true` is set, a document is not actually removed from `bucket` but rather `deleted_at` flag on the document is set. aka. `soft delete`
 * @param {boolean}   [options.camelCase=false] - if `true` is set, camel case notation is used for document's internal properties.
 * @param {Object}    [options.schemaSettings] - allows to modify default values used for document's `key` generation and document's property names handled by ODM
 * @param {Object}    [options.schemaSettings.key]
 * @param {string}    [options.schemaSettings.key.prefix] - defaults to Model's name
 * @param {string}    [options.schemaSettings.key.postfix=""]
 * @param {string}    [options.schemaSettings.key.delimiter="_"]
 * @param {Object}    [options.schemaSettings.doc]
 * @param {string}    [options.schemaSettings.doc.idPropertyName="_id"] - `_id` contains generated id of document (not whole document's key)
 * @param {string}    [options.schemaSettings.doc.typePropertyName="_type"] - `_type` contains the name of a Model
 * @param {Object}    [options.hooks] - allows to add one or more hooks of a hook type (eg. `afterValidate`)
 * @param {Object}    [options.classMethods] - custom method definitions which are bound to a Model.
 * @param {Object}    [options.instanceMethods] - custom method definitions which are bound to a Instance.
 * @param {Bucket}    [options.bucket] - must be instance of Couchbase.Bucket (from official nodejs couchbase sdk)
 * @param {Object}    [options.indexes]
 * @param {Object}    [options.indexes.refDocs] - reference documents definitions aka. custom application layer index support. ref. doc. is a document which reference parent document  with its string value=key
 */
function Model(name, schema, options) {

    if (!name || (typeof name !== "string" && !(name instanceof String) )) {
        throw new ModelError("Model's name must be non-empty string value");
    }

    options.schemaSettings.key.prefix = options.schemaSettings.key.prefix || name;
    options.name = name;
    options.schema = schema;

    if (typeof options.key !== "function") {
        throw new ModelError("The `key` is not a constructor object (function)");
    } else if(!(options.key.prototype instanceof Key)) {
        const constructorName = options.key.prototype.constructor.name;
        throw new ModelError("The " + constructorName + " class must inherit from `Key` abstract class");
    }

    /**
     * _private
     * @name Model#schema
     * @instance
     * @type {Object}
     */
    Object.defineProperty(this, "schema", {
        enumerable: true,
        configurable: false,
        writable: false,
        value: schema
    });

    /**
     * _private
     * @name Model#options
     * @instance
     * @type {Object}
     */
    Object.defineProperty(this, "options", {
        enumerable: true,
        configurable: false,
        value: options
    });

    /**
     * @name Model#name
     * @instance
     * @type {string}
     */
    Object.defineProperty(this, "name", {
        enumerable: true,
        writable: false,
        configurable: false,
        value: name
    });

    /**
     * @name Model#storage
     * @instance
     * @type {StorageAdapter}
     */
    Object.defineProperty(this, "storage", {
        enumerable: true,
        configurable: false,
        value: new StorageAdapter({bucket: options.bucket})
    });

    /**
     * @private
     * @name Model#relations
     * @instance
     * @type {Array<Object>}
     */
    Object.defineProperty(this, "relations", {
        enumerable: true,
        configurable: false,
        writable: true,
        value: []
    });

    /**
     * @private
     * @name Model#validator
     * @instance
     * @type {Ajv}
     */
    Object.defineProperty(this, "validator", {
        enumerable: true,
        configurable: false,
        writable: false,
        value: Model.validator
    });

    /**
     * @private
     * @name Model#DEFAULTS_SCHEMA_NAME
     * @instance
     * @type {Ajv}
     */
    Object.defineProperty(this, "DEFAULTS_SCHEMA_ID", {
        enumerable: false,
        configurable: false,
        writable: false,
        value: this.name + '-pre-validate'
    });

    /**
     * @private
     * @name Model#POST_FETCH_SCHEMA_NAME
     * @instance
     * @type {Ajv}
     */
    Object.defineProperty(this, "POST_FETCH_SCHEMA_ID", {
        enumerable: false,
        configurable: false,
        writable: false,
        value: this.name + '-post-fetch'
    });
}

Model.validator = validator;

/**
 * attach hooks methods to Model, Model.prototype
 */
Hook.applyTo(Model);

/**
 * Sets up dependencies and registers the Model in cache register
 *
 * @throws {ModelError}
 *
 * @private
 * @return {undefined}
 */
Model.prototype._init = function(modelManager) {

    const self = this;

    this._modelManager = modelManager;

    if (!this.options.key.hasOwnProperty('dataType')) {
        throw new ModelError('`options.key` class must expose `dataType` property with valid data type');
    }

    this.relations = schemaUtils.extractAssociations(this.schema);

    if (this._dataHasRootAssociation()) {
        this.options.timestamps = false; //not supported
    }

    if (this._dataHasObjectStructure() && !this._dataHasRootAssociation()) {
        schemaUtils.addTimestampProperties.call(this);
        //add schema properties like `_type`, `_id`
        schemaUtils.addInternalDocProperties.call(this);
    }

    this.validator.addSchema(this.schema, this.name);
    this.validator.addSchema(
        schemaUtils.extract(this.schema, {default: true}),
        this.DEFAULTS_SCHEMA_ID
    );
    this.validator.addSchema(
        schemaUtils.extract(this.schema, {timestamps: {$toDate: {}}}),
        this.POST_FETCH_SCHEMA_ID
    );

    //bind Key class
    this.Key = function ModelKey() {
        self.options.key.apply(this, arguments);
    };
    util.inherits(this.Key, this.options.key);

    //bind RefDocKey class
    this.RefDocKey = function ModelRefDocKey() {
        self.options.refDocKey.apply(this, arguments);
    };
    util.inherits(this.RefDocKey, this.options.refDocKey);

    //bind Instance class
    this.Instance = function ModelInstance() {
        Instance.apply(this, arguments);
    };
    util.inherits(this.Instance, Instance);
    this.Instance.prototype.Model = this;

    //attach class methods
    const classMethods = this.options.classMethods;
    Object.keys(classMethods).forEach(function(name) {
        if (typeof self[name] == 'function') {
            self[name] = classMethods[name].bind(self, self[name]);
        } else if(!self[name]) {
            self[name] = classMethods[name].bind(self);
        }
    });

    //attach instance methods
    const instanceMethods = this.options.instanceMethods;
    Object.keys(instanceMethods).forEach(function(name) {
        if ( typeof self.Instance.prototype[name] == 'function') {
            const _parentMethod = self.Instance.prototype[name];
            const method = function() {
                return instanceMethods[name].call(this, _parentMethod);
            };
            self.Instance.prototype[name] = method;
        } else if(!self.Instance.prototype[name]) {
            self.Instance.prototype[name] = instanceMethods[name];
        }
    });

    //attach "get by reference document" methods
    attachGetByRefDocMethods.call(this);

    this.validator._registerCouchbaseType(this.name, this.Instance);
};

/**
 * @private
 * @return {boolean}
 */
Model.prototype._dataHasJsonStructure = function() {
    if (   this.schema.type === 'object'
        || this.schema.type === 'array'
    ) {
        return true;
    }
    return false;
};


/**
 * @private
 * @return {boolean}
 */
Model.prototype._dataHasRootAssociation = function() {
    if (this.relations.length === 1 && !this.relations[0].path.length) {
        return true;
    }
    return false;
};

/**
 * @private
 * @return {boolean}
 */
Model.prototype._dataHasObjectStructure = function() {
    if (this.schema.type === 'object') {
        return true;
    }
    return false;
};

/**
 * @private
 * @return {Object}
 */
Model.prototype._getTimestampPropertyNames = function() {
    const namesMap = {
        createdAt: 'created_at',
        updatedAt: 'updated_at',
        deletedAt: 'deleted_at'
    };
    if (this.options.camelCase === true) {
        namesMap.createdAt = 'createdAt';
        namesMap.updatedAt = 'updatedAt';
        namesMap.deletedAt = 'deletedAt';
    }
    return namesMap;
};

/**
 * return a new instance of document's primary key
 *
 * @param {string}  [id]
 * @param {Object}  [options]
 * @param {boolean} [options.parse=false] - if true, `id` argument will be
 *                             treated as whole document's `key` string.
 *                             By default `id` is presumed to be only "dynamic part of document's key"
 * @return {Key}
 */
Model.prototype.buildKey = function(id, options) {
    const defaults = {
        parse: false
    };

    options = _.assign(defaults, options);

    const keyOptions = _.cloneDeep(this.options.schemaSettings.key);
    if (id !== undefined && !options.parse) {
        keyOptions.id = id;
    }
    const key = new this.Key(keyOptions);

    if (options.parse) {
        key.parse(id); //id = whole document's key here
    }
    return key;
};

/**
 * return a new instance of RefDocKey = primary key of a document referencing parent
 * document with it's value
 *
 * @param {string|Array} [id]
 * @param {Object}       options
 * @param {String}       options.index - index name - one of the keys from `options.indexes.refDocs` object of Model options
 * @param {Boolean}      [options.parse=false]
 *
 * @return {RefDocKey}
 */
Model.prototype.buildRefDocKey = function(id, options) {
    const defaults = {
        parse: false
    };

    options = _.assign(defaults, options);
    let indexOptions = _.cloneDeep(this.options.indexes.refDocs[options.index]) || {};
    indexOptions.ref = indexOptions.keys;
    delete indexOptions.keys;

    if (!options.parse) {
        indexOptions.id = id;
    }

    indexOptions = _.assign({}, this.options.schemaSettings.key, indexOptions);
    const key = new this.RefDocKey(indexOptions);

    if (options.parse) {
        key.parse(id); //id = whole document's key string here
    }

    return key;
};

/**
 * builds a new Model instance object
 *
 * @param {mixed}      data - In case an Object or an Array is provided, the data value is NOT cloned!
 * @param {Object}     [options]
 * @param {boolean}    [options.isNewRecord=true]
 * @param {boolean}    [options.sanitize=false] - if true, data values are validated, an attempt is made to convert property values to correct type
 * @param {CAS}        [options.cas]
 * @param {Key|string} [options.key] - instance of Key or valid string `id` (-> dynamic part of Key) should be provided if the isNewRecord=false that meens the document is persisted in storage
 *
 * @throws {ValidationError}
 * @return {Instance}
 */
Model.prototype.build = function(data, options) {
    const defaults = {
        isNewRecord: true,
        sanitize: false
    };
    options = _.assign(defaults, options);

    //prepare options object
    if (options.key === undefined || typeof options.key === "string") {
        //options.key is `id` here (not whole key string)
        options.key = this.buildKey(options.key);
    }

    //apply default values
    if (options.isNewRecord) {
        //applies default values
        let container = {data: data};
        this.validator.validate(this.DEFAULTS_SCHEMA_ID, container);
        data = container.data;
    } else {
        this.validator.validate(this.POST_FETCH_SCHEMA_ID, data);
    }

    //build
    const instance = new this.Instance(data, {
        isNewRecord: options.isNewRecord,
        key: options.key,
        cas: options.cas
    });

    instance._initRelations();

    if (options.sanitize === true) {
        instance.sanitize();
    }

    const copy = instance.clone();
    instance._original = copy;

    return instance;
};

/**
 * @param {Object}     data
 * @param {Object}     [options] - see {@link StorageAdaper#insert} options
 * @param {string|Key} [options.key] - the key under which a document should be saved. Can be instance of `Key` or valid string `id` (-> dynamic part of Key)
 *
 * @function
 * @return Promise<Instance>
 */
Model.prototype.create = Promise.method(function(data, options) {
    let key;

    if (_.isPlainObject(options)) {
        key = options.key;
        delete options.key;
    }

    const doc = this.build(data, {key: key});

    return doc.save(options).return(doc);
});

/**
 * if a key is not found, it returns rejected promise with StorageError which has appropriate error code
 *
 * @param {string|Key}    id
 * @param {object}        [options]
 * @param {boolean}       [options.plain] - if true, raw data are returned
 * @param {integer}       [options.lockTime] - time in seconds (max=30)
 * @param {integer}       [options.expiry] - time in seconds. if provided, the method performs `touch` operation on document, returning the most recent document data
 * @param {boolean}       [options.hooks=true] - if false, `hooks` are not run
 * @param {boolean}       [options.paranoid=true] - if false, both deleted and non-deleted documents will be returned. Only applies if `options.paranoid` is true for the model.
 *
 * @function
 * @return Promise<Instance>
 */
Model.prototype.getByIdOrFail = Promise.method(function(id, options) {
    let key = id;
    const actions = [];
    const defaults = {
        paranoid: true,
        hooks: true,
    };
    options = _.merge(defaults, options);

    if (!(id instanceof this.Key)) {
        key = this.buildKey(id);
    }

    //beforeGet hooks
    var promise;
    if (options.hooks === true) {
        promise = this.runHooks(HookTypes.beforeGet, key, options);
    } else {
        promise = Promise.resolve();
    }

    //manage action performed according to expiry & lockTime options
    //note: getAndTouch is same as touch method of sdk bucket

    //if paranoid option is set it will perform extra get query to check whether the doc is soft-deleted
    if (this.options.paranoid === true) {//model.options.paranoid
        actions.push(get);
    }
    if (options.expiry) actions.push(getAndTouch);
    if (options.lockTime) actions.push(getAndLock);
    if (!this.options.paranoid && !actions.length) actions.push(get);

    //perform get
    return promise.bind(this).then(function() {
        const self = this;
        const opt = _.clone(options);
        if (this.options.paranoid === true) {
            //we don't want to expose internal options to eg.: hooks methods
            opt.deletedAtPropName = this._getTimestampPropertyNames().deletedAt;
        }
        return Promise.mapSeries(actions, function(action, index) {
            return action(self.storage, key, opt);
        });
    }).then(function(results) { // build instance
        const doc = results.pop();

        if (options.plain === true) {
            return doc;
        }

        return this.build(doc.value, {
            key: key,
            cas: doc.cas,
            sanitize: false,
            isNewRecord: false
        });
    }).then(function(instance) { //set expiration for each reference document
        if (options.expiry) {
            return instance.getRefDocs().map(function(doc){
                return doc.touch(options.expiry, {cas: doc.getCAS()});
            }).return(instance);
        }

        return instance;

    }).tap(function(doc) { // run afterGet hooks
        if (options.hooks === false) {
            return doc;
        }
        return this.runHooks(HookTypes.afterGet, doc, options);
    });

    //method definitions
    function getAndLock(storage, key, options) {
        return storage.getAndLock(key, {lockTime: options.lockTime});
    }
    function getAndTouch(storage, key, options) {
        return storage.getAndTouch(key, options.expiry);
    }
    function get(storage, key, options) {
        return storage.get(key, options);
    }
});

/**
 * if a key is not found, it returns resolved promise with null value
 *
 * @see {@link Model#getByIdOrFail} arguments
 * @function
 * @return Promise<Instance|null>
 */
Model.prototype.getById = Promise.method(function() {
    return this.getByIdOrFail.apply(this, arguments)
    .catch(function(err) { // mute the keyNotFound error

        if (   err instanceof StorageError
                && err.code === StorageAdapter.errorCodes.keyNotFound
           ) {
            return null;
        }
        return Promise.reject(err);
    });
});

/**
 * if a operation fails to get a `id` from `ids` array,
 * a StorageError is returned in place of resulting value
 * in returned collection/map.
 *
 * @example
 * //PSEUDOCODE:
 *
 * var ids = [
 *     '829f52b2-f168-4538-8884-b5ad9054e391',
 *     'nonexistent-id'
 * ];
 *
 * // Indexed results
 * Model.getMulti(ids, {indexed: true}).then((result) => {
 *     console.log(result);
 * });
 *
 * // The above prints:
 *     {
 *         data: {
 *             '829f52b2-f168-4538-8884-b5ad9054e391': Document, // object
 *             'nonexistent-id': Error // object
 *         },
 *         failed: ['nonexistend-id'],
 *         resolved: [Document]
 *     }
 *
 * // Non-indexed results
 * Model.getMulti(ids, {indexed: false}).then((result) => {
 *     console.log(result);
 * });
 *
 * // The above prints:
 *     {
 *         data: [
 *             Document, //object
 *             Error //object
 *         ],
 *         failed: [1], // collection of indexes referencing data array items
 *         resolved: [Document]
 *     }
 *
 * @param {Array<string|Key>} ids
 * @param {Object}            [options]
 * @param {boolean}           [options.plain=false] - if true, Instances are not built and raw results are collected instead
 * @param {integer}           [options.lockTime] - time in seconds (max=30)
 * @param {integer}           [options.expiry] - time in seconds
 * @param {boolean}           [options.hooks=true] - if false, `hooks` are not run
 * @param {boolean}           [options.indexed=true] - if true, a results are indexed by document's `id` value, otherwise an array of results is collected
 * @param {boolean}           [options.individualHooks=false] - if true, `hooks` are run for each get query. if set to false, hooks are only run once before and after `getMulti` operation
 * @param {boolean}           [options.paranoid=true] - if false, both deleted and non-deleted documents will be returned. Only applies if `options.paranoid` is true for the model.
 *
 * @throws {KeyError}
 * @function
 * @return {Promise<Object>}
 */
Model.prototype.getMulti = Promise.method(function(ids, options) {

    const defaults = {
        indexed         : true,
        hooks           : true,
        paranoid        : true,
        individualHooks : false,
        plain           : false
    };

    options = _.assign(defaults, options);

    const self = this;
    var promise;
    const out = {
        failed: [],
        resolved: [],
        data: options.indexed === true ? {} : []
    };

    const runHooks = options.hooks;

    if (runHooks !== false && options.individualHooks !== true) {
        promise = this.runHooks(HookTypes.beforeGet, ids, options).return(ids);
    } else {
        promise = Promise.resolve(ids);
    }

    if (!options.individualHooks) {
        options.hooks = false;
    }

    return promise.map(function(id, index) {

        if (options.indexed === true) {
            if (id instanceof self.Key) {
                index = id.getId();
            } else {
                index = id.toString();
            }
        }

        return self.getByIdOrFail(id, options).then(function(doc) {
            out.data[index] = doc;
            out.resolved.push(doc);
        }).catch(function(err) {
            out.data[index] = err;
            out.failed.push(index);
        });
    }).return(out).tap(function(out) {
        if (runHooks !== false && options.individualHooks !== true) {
            return self.runHooks(HookTypes.afterGet, out, options);
        }
    });
});

/**
 * @param {string|Key} id
 *
 * @function
 * @return {Promise<Instance>}
 */
Model.prototype.remove = Promise.method(function(id) {
    //must be handled this way, because of reference decuments
    return Model.prototype.getByIdOrFail.call(this, id).then(function(instance) {
        return instance.destroy();
    });
});

/**
 * @param {string|Key} id
 * @param {integer}    expiry
 *
 * See {@link StorageAdapter#touch} for more information
 *
 * @function
 * @return {Promise<Object>}
 */
Model.prototype.touch = Promise.method(function(id, expiry) {
    let key = id;
    if (!(id instanceof this.Key)) {
        key = this.buildKey(id);
    }

    return this.storage.touch(key, expiry);
});

/**
 * @param {string|Key} id
 * @param {CAS} cas
 *
 * @function
 * @return {Promise<Object>}
 */
Model.prototype.unlock = Promise.method(function(id, cas) {
    let key = id;
    if (!(id instanceof this.Key)) {
        key = this.buildKey(id);
    }

    return this.storage.unlock(key, cas);
});

/**
 * @param {string|Key} id
 *
 * @function
 * @return {Promise<Object>}
 */
Model.prototype.exists = Promise.method(function(id) {
    let key = id;
    if (!(id instanceof this.Key)) {
        key = this.buildKey(id);
    }

    return this.storage.exists(key);
});

/**
 * binds methods like: getBy{schemaPropertyName}
 * eg.: getByUsername()
 *
 * @private
 * @return {undefined}
 */
function attachGetByRefDocMethods() {
    const self = this;
    const getByRefDocPrefix = 'getBy';
    const refDocs = this.options.indexes.refDocs;

    Object.keys(refDocs).forEach(function(indexName) {
        const getByRefDocFnName = getByRefDocPrefix + _.upperFirst(_.camelCase(indexName));
        const getByRefDocOrFailFnName = getByRefDocPrefix + _.upperFirst(_.camelCase(indexName)) + 'OrFail';

        //register getByRefDoc method
        //id can be string|Array|RefDocKey see Model._getByRefdoc method for more info
        self[getByRefDocFnName] = Promise.method(function(id, options) {
            options = _.clone(options || {});
            if (!(id instanceof self.RefDocKey)) {
                id = self.buildRefDocKey(id, {
                    index: indexName,
                    parse: false
                });
            }

            return self._getByRefDoc(id, options);
        });

        //register getByRefDocOrFail method
        //id can be string|Array|RefDocKey see Model._getByRefDocOrFail method for more info
        self[getByRefDocOrFailFnName] = Promise.method(function(id, options) {
            options = _.clone(options || {});
            if (!(id instanceof self.RefDocKey)) {
                id = self.buildRefDocKey(id, {
                    index: indexName,
                    parse: false
                });
            }

            return self._getByRefDocOrFail(id, options);
        });
    });
}

/**
 * @param {RefDocKey} key - unique index value/s a document should be searched by. Eg.: username/email value
 * @param {Object}    [options] - see {@link Model.getByRefDoc} options
 *
 * @private
 * @function
 * @return {Promise<Instance|Object>}
 */
Model.prototype._getByRefDoc = Promise.method(function(key, options) {
    //get the reference document
    return this._getByRefDocOrFail(key, options).catch(function(err) {
        if (   err instanceof StorageError
            && err.code === StorageAdapter.errorCodes.keyNotFound
           ) {
            return null;
        }
        return Promise.reject(err);
    });
});

/**
 * @param {RefDocKey} key - unique index value/s a document should be searched by. Eg.: username/email value
 * @param {Object}    [options]
 * @param {boolean}   [options.plain] - if true, raw data are returned no instance is build
 * @param {integer}   [options.lockTime] - period of time in seconds for which parent document should be locked
 * @param {integer}   [options.expiry] - expiry time of parent document in seconds
 * @param {boolean}   [options.lean] - if true, parent document's `Key` object is returned, no document's data are fetched
 * @param {boolean}   [options.paranoid=true] - if false, both deleted and non-deleted documents will be returned. Only applies if global `options.paranoid` is true for the model.
 *
 * @private
 * @function
 * @return {Promise<Instance|Object>}
 */
Model.prototype._getByRefDocOrFail = Promise.method(function(key, options) {
    //get the reference document
    return this.storage.get(key)
    .bind(this)
    .then(function(refDoc) {
        const parentKey = this.buildKey(refDoc.value, {parse: true});
        if (options.lean === true) {
            return parentKey;
        }
        //get the Instance with data
        return this.getByIdOrFail(parentKey, options);
    });
});

/**
 * @return {string}
 * @private
 */
Model.prototype.toString = function() {
    return '[object CouchbaseModel:' + this.name + ']';
};

/**
 * @private
 * @return {string}
 */
Model.prototype.inspect = Model.prototype.toString;