'use strict'; const { EventEmitter } = require('events'); const cloneDeep = require('rfdc')(); const Promise = require('bluebird'); const { parseArgs, getProp, setGetter, shuffle } = require('./util'); const Document = require('./document'); const Query = require('./query'); const Schema = require('./schema'); const Types = require('./types'); const WarehouseError = require('./error'); const PopulationError = require('./error/population'); const Mutex = require('./mutex'); class Model extends EventEmitter { /** * Model constructor. * * @param {string} name Model name * @param {Schema|object} [schema] Schema */ constructor(name, schema_) { super(); let schema; // Define schema if (schema_ instanceof Schema) { schema = schema_; } else if (typeof schema_ === 'object') { schema = new Schema(schema_); } else { schema = new Schema(); } // Set `_id` path for schema if (!schema.path('_id')) { schema.path('_id', {type: Types.CUID, required: true}); } this.name = name; this.data = {}; this._mutex = new Mutex(); this.schema = schema; this.length = 0; class _Document extends Document { constructor(data) { super(data); // Apply getters schema._applyGetters(this); } } this.Document = _Document; _Document.prototype._model = this; _Document.prototype._schema = schema; class _Query extends Query {} this.Query = _Query; _Query.prototype._model = this; _Query.prototype._schema = schema; // Apply static methods Object.assign(this, schema.statics); // Apply instance methods Object.assign(_Document.prototype, schema.methods); } /** * Creates a new document. * * @param {object} data * @return {Document} */ new(data) { return new this.Document(data); } /** * Finds a document by its identifier. * * @param {*} id * @param {object} options * @param {boolean} [options.lean=false] Returns a plain JavaScript object * @return {Document|object} */ findById(id, options_) { const raw = this.data[id]; if (!raw) return; const options = Object.assign({ lean: false }, options_); const data = cloneDeep(raw); return options.lean ? data : this.new(data); } /** * Checks if the model contains a document with the specified id. * * @param {*} id * @return {boolean} */ has(id) { return Boolean(this.data[id]); } /** * Acquires write lock. * * @param {*} id * @return {Promise} * @private */ _acquireWriteLock(id) { const mutex = this._mutex; return new Promise((resolve, reject) => { mutex.lock(resolve); }).disposer(() => { mutex.unlock(); }); } /** * Inserts a document. * * @param {Document|object} data * @return {Promise} * @private */ _insertOne(data_) { const schema = this.schema; // Apply getters const data = data_ instanceof this.Document ? data_ : this.new(data_); const id = data._id; // Check ID if (!id) { return Promise.reject(new WarehouseError('ID is not defined', WarehouseError.ID_UNDEFINED)); } if (this.has(id)) { return Promise.reject(new WarehouseError('ID `' + id + '` has been used', WarehouseError.ID_EXIST)); } // Apply setters const result = data.toObject(); schema._applySetters(result); // Pre-hooks return execHooks(schema, 'pre', 'save', data).then(data => { // Insert data this.data[id] = result; this.length++; this.emit('insert', data); return execHooks(schema, 'post', 'save', data); }); } /** * Inserts a document. * * @param {object} data * @param {function} [callback] * @return {Promise} */ insertOne(data, callback) { return Promise.using(this._acquireWriteLock(), () => this._insertOne(data)).asCallback(callback); } /** * Inserts documents. * * @param {object|array} data * @param {function} [callback] * @return {Promise} */ insert(data, callback) { if (Array.isArray(data)) { return Promise.mapSeries(data, item => this.insertOne(item)).asCallback(callback); } return this.insertOne(data, callback); } /** * Inserts the document if it does not exist; otherwise updates it. * * @param {object} data * @param {function} [callback] * @return {Promise} */ save(data, callback) { const id = data._id; if (!id) return this.insertOne(data, callback); return Promise.using(this._acquireWriteLock(), () => { if (this.has(id)) { return this._replaceById(id, data); } return this._insertOne(data); }).asCallback(callback); } /** * Updates a document with a compiled stack. * * @param {*} id * @param {array} stack * @return {Promise} * @private */ _updateWithStack(id, stack) { const schema = this.schema; const data = this.data[id]; if (!data) { return Promise.reject(new WarehouseError('ID `' + id + '` does not exist', WarehouseError.ID_NOT_EXIST)); } // Clone data let result = cloneDeep(data); // Update for (let i = 0, len = stack.length; i < len; i++) { stack[i](result); } // Apply getters const doc = this.new(result); // Apply setters result = doc.toObject(); schema._applySetters(result); // Pre-hooks return execHooks(schema, 'pre', 'save', doc).then(data => { // Update data this.data[id] = result; this.emit('update', data); return execHooks(schema, 'post', 'save', data); }); } /** * Finds a document by its identifier and update it. * * @param {*} id * @param {object} update * @param {function} [callback] * @return {Promise} */ updateById(id, update, callback) { return Promise.using(this._acquireWriteLock(), () => { const stack = this.schema._parseUpdate(update); return this._updateWithStack(id, stack); }).asCallback(callback); } /** * Updates matching documents. * * @param {object} query * @param {object} data * @param {function} [callback] * @return {Promise} */ update(query, data, callback) { return this.find(query).update(data, callback); } /** * Finds a document by its identifier and replace it. * * @param {*} id * @param {object} data * @return {Promise} * @private */ _replaceById(id, data_) { const schema = this.schema; if (!this.has(id)) { return Promise.reject(new WarehouseError('ID `' + id + '` does not exist', WarehouseError.ID_NOT_EXIST)); } data_._id = id; // Apply getters const data = data_ instanceof this.Document ? data_ : this.new(data_); // Apply setters const result = data.toObject(); schema._applySetters(result); // Pre-hooks return execHooks(schema, 'pre', 'save', data).then(data => { // Replace data this.data[id] = result; this.emit('update', data); return execHooks(schema, 'post', 'save', data); }); } /** * Finds a document by its identifier and replace it. * * @param {*} id * @param {object} data * @param {function} [callback] * @return {Promise} */ replaceById(id, data, callback) { return Promise.using(this._acquireWriteLock(), () => this._replaceById(id, data)).asCallback(callback); } /** * Replaces matching documents. * * @param {object} query * @param {object} data * @param {function} [callback] * @return {Promise} */ replace(query, data, callback) { return this.find(query).replace(data, callback); } /** * Finds a document by its identifier and remove it. * * @param {*} id * @param {function} [callback] * @return {Promise} * @private */ _removeById(id) { const schema = this.schema; const data = this.data[id]; if (!data) { return Promise.reject(new WarehouseError('ID `' + id + '` does not exist', WarehouseError.ID_NOT_EXIST)); } // Pre-hooks return execHooks(schema, 'pre', 'remove', data).then(data => { // Remove data this.data[id] = null; this.length--; this.emit('remove', data); return execHooks(schema, 'post', 'remove', data); }); } /** * Finds a document by its identifier and remove it. * * @param {*} id * @param {function} [callback] * @return {Promise} */ removeById(id, callback) { return Promise.using(this._acquireWriteLock(), () => this._removeById(id)).asCallback(callback); } /** * Removes matching documents. * * @param {object} query * @param {object} [callback] * @return {Promise} */ remove(query, callback) { return this.find(query).remove(callback); } /** * Deletes a model. */ destroy() { this._database._models[this.name] = null; } /** * Returns the number of elements. * * @return {number} */ count() { return this.length; } /** * Iterates over all documents. * * @param {function} iterator * @param {object} [options] See {@link Model#findById}. */ forEach(iterator, options) { const keys = Object.keys(this.data); let num = 0; for (let i = 0, len = keys.length; i < len; i++) { const data = this.findById(keys[i], options); if (data) iterator(data, num++); } } /** * Returns an array containing all documents. * * @param {Object} [options] See {@link Model#findById}. * @return {Array} */ toArray(options) { const result = new Array(this.length); this.forEach((item, i) => { result[i] = item; }, options); return result; } /** * Finds matching documents. * * @param {Object} query * @param {Object} [options] * @param {Number} [options.limit=0] Limits the number of documents returned. * @param {Number} [options.skip=0] Skips the first elements. * @param {Boolean} [options.lean=false] Returns a plain JavaScript object. * @return {Query|Array} */ find(query, options_) { const options = options_ || {}; const filter = this.schema._execQuery(query); const keys = Object.keys(this.data); const len = keys.length; let limit = options.limit || this.length; let skip = options.skip; const data = this.data; const arr = []; for (let i = 0; limit && i < len; i++) { const key = keys[i]; const item = data[key]; if (item && filter(item)) { if (skip) { skip--; } else { arr.push(this.findById(key, options)); limit--; } } } return options.lean ? arr : new this.Query(arr); } /** * Finds the first matching documents. * * @param {Object} query * @param {Object} [options] * @param {Number} [options.skip=0] Skips the first elements. * @param {Boolean} [options.lean=false] Returns a plain JavaScript object. * @return {Document|Object} */ findOne(query, options_) { const options = options_ || {}; options.limit = 1; const result = this.find(query, options); return options.lean ? result[0] : result.data[0]; } /** * Sorts documents. See {@link Query#sort}. * * @param {String|Object} orderby * @param {String|Number} [order] * @return {Query} */ sort(orderby, order) { const sort = parseArgs(orderby, order); const fn = this.schema._execSort(sort); return new this.Query(this.toArray().sort(fn)); } /** * Returns the document at the specified index. `num` can be a positive or * negative number. * * @param {Number} i * @param {Object} [options] See {@link Model#findById}. * @return {Document|Object} */ eq(i_, options) { let index = i_ < 0 ? this.length + i_ : i_; const data = this.data; const keys = Object.keys(data); for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; const item = data[key]; if (!item) continue; if (index) { index--; } else { return this.findById(key, options); } } } /** * Returns the first document. * * @param {Object} [options] See {@link Model#findById}. * @return {Document|Object} */ first(options) { return this.eq(0, options); } /** * Returns the last document. * * @param {Object} [options] See {@link Model#findById}. * @return {Document|Object} */ last(options) { return this.eq(-1, options); } /** * Returns the specified range of documents. * * @param {Number} start * @param {Number} [end] * @return {Query} */ slice(start_, end_) { const total = this.length; let start = start_ | 0; if (start < 0) start += total; if (start > total - 1) return new this.Query([]); let end = end_ | 0 || total; if (end < 0) end += total; let len = start > end ? 0 : end - start; if (len > total) len = total - start; if (!len) return new this.Query([]); const arr = new Array(len); const keys = Object.keys(this.data); const keysLen = keys.length; let num = 0; for (let i = 0; num < len && i < keysLen; i++) { const data = this.findById(keys[i]); if (!data) continue; if (start) { start--; } else { arr[num++] = data; } } return new this.Query(arr); } /** * Limits the number of documents returned. * * @param {Number} i * @return {Query} */ limit(i) { return this.slice(0, i); } /** * Specifies the number of items to skip. * * @param {Number} i * @return {Query} */ skip(i) { return this.slice(i); } /** * Returns documents in a reversed order. * * @return {Query} */ reverse() { return new this.Query(this.toArray().reverse()); } /** * Returns documents in random order. * * @return {Query} */ shuffle() { return new this.Query(shuffle(this.toArray())); } /** * Creates an array of values by iterating each element in the collection. * * @param {Function} iterator * @param {Object} [options] * @return {Array} */ map(iterator, options) { const result = new Array(this.length); const keys = Object.keys(this.data); const len = keys.length; for (let i = 0, num = 0; i < len; i++) { const data = this.findById(keys[i], options); if (data) { result[num] = iterator(data, num); num++; } } return result; } /** * Reduces a collection to a value which is the accumulated result of iterating * each element in the collection. * * @param {Function} iterator * @param {*} [initial] By default, the initial value is the first document. * @return {*} */ reduce(iterator, initial) { const arr = this.toArray(); const len = this.length; let i, result; if (initial === undefined) { i = 1; result = arr[0]; } else { i = 0; result = initial; } for (; i < len; i++) { result = iterator(result, arr[i], i); } return result; } /** * Reduces a collection to a value which is the accumulated result of iterating * each element in the collection from right to left. * * @param {Function} iterator * @param {*} [initial] By default, the initial value is the last document. * @return {*} */ reduceRight(iterator, initial) { const arr = this.toArray(); const len = this.length; let i, result; if (initial === undefined) { i = len - 2; result = arr[len - 1]; } else { i = len - 1; result = initial; } for (; i >= 0; i--) { result = iterator(result, arr[i], i); } return result; } /** * Creates a new array with all documents that pass the test implemented by the * provided function. * * @param {Function} iterator * @param {Object} [options] * @return {Query} */ filter(iterator, options) { const arr = []; this.forEach((item, i) => { if (iterator(item, i)) arr.push(item); }, options); return new this.Query(arr); } /** * Tests whether all documents pass the test implemented by the provided * function. * * @param {Function} iterator * @return {Boolean} */ every(iterator) { const keys = Object.keys(this.data); const len = keys.length; let num = 0; if (!len) return true; for (let i = 0; i < len; i++) { const data = this.findById(keys[i]); if (data) { if (!iterator(data, num++)) return false; } } return true; } /** * Tests whether some documents pass the test implemented by the provided * function. * * @param {Function} iterator * @return {Boolean} */ some(iterator) { const keys = Object.keys(this.data); const len = keys.length; let num = 0; if (!len) return false; for (let i = 0; i < len; i++) { const data = this.findById(keys[i]); if (data) { if (iterator(data, num++)) return true; } } return false; } /** * Returns a getter function for normal population. * * @param {Object} data * @param {Model} model * @param {Object} options * @return {Function} * @private */ _populateGetter(data, model, options) { let hasCache = false; let cache; return () => { if (!hasCache) { cache = model.findById(data); hasCache = true; } return cache; }; } /** * Returns a getter function for array population. * * @param {Object} data * @param {Model} model * @param {Object} options * @return {Function} * @private */ _populateGetterArray(data, model, options) { const Query = model.Query; let hasCache = false; let cache; return () => { if (!hasCache) { let arr = []; for (let i = 0, len = data.length; i < len; i++) { arr.push(model.findById(data[i])); } if (options.match) { cache = new Query(arr).find(options.match, options); } else if (options.skip) { if (options.limit) { arr = arr.slice(options.skip, options.skip + options.limit); } else { arr = arr.slice(options.skip); } cache = new Query(arr); } else if (options.limit) { cache = new Query(arr.slice(0, options.limit)); } else { cache = new Query(arr); } if (options.sort) { cache = cache.sort(options.sort); } hasCache = true; } return cache; }; } /** * Populates document references with a compiled stack. * * @param {Object} data * @param {Array} stack * @return {Object} * @private */ _populate(data, stack) { const models = this._database._models; for (let i = 0, len = stack.length; i < len; i++) { const item = stack[i]; const model = models[item.model]; if (!model) { throw new PopulationError('Model `' + item.model + '` does not exist'); } const path = item.path; const prop = getProp(data, path); if (Array.isArray(prop)) { setGetter(data, path, this._populateGetterArray(prop, model, item)); } else { setGetter(data, path, this._populateGetter(prop, model, item)); } } return data; } /** * Populates document references. * * @param {String|Object} path * @return {Query} */ populate(path) { if (!path) throw new TypeError('path is required'); const stack = this.schema._parsePopulate(path); const arr = new Array(this.length); this.forEach((item, i) => { arr[i] = this._populate(item, stack); }); return new Query(arr); } /** * Imports data. * * @param {Array} arr * @private */ _import(arr) { const len = arr.length; const data = this.data; const schema = this.schema; for (let i = 0; i < len; i++) { const item = arr[i]; data[item._id] = schema._parseDatabase(item); } this.length = len; } /** * Exports data. * * @return {String} * @private */ _export() { return JSON.stringify(this.toJSON()); } toJSON() { const result = new Array(this.length); const { data, schema } = this; const keys = Object.keys(data); const { length } = keys; for (let i = 0, num = 0; i < length; i++) { const raw = data[keys[i]]; if (raw) { result[num++] = schema._exportDatabase(cloneDeep(raw)); } } return result; } } Model.prototype.get = Model.prototype.findById; function execHooks(schema, type, event, data) { const hooks = schema.hooks[type][event]; if (!hooks.length) return Promise.resolve(data); return Promise.each(hooks, hook => hook(data)).thenReturn(data); } Model.prototype.size = Model.prototype.count; Model.prototype.each = Model.prototype.forEach; Model.prototype.random = Model.prototype.shuffle; module.exports = Model;