UNPKG

forerunnerdb

Version:

A NoSQL document store database for browsers and Node.js.

1,879 lines (1,574 loc) 268 kB
(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(_dereq_,module,exports){ var Core = _dereq_('./core'), View = _dereq_('../lib/View'); if (typeof window !== 'undefined') { window.ForerunnerDB = Core; } module.exports = Core; },{"../lib/View":30,"./core":2}],2:[function(_dereq_,module,exports){ var Core = _dereq_('../lib/Core'), ShimIE8 = _dereq_('../lib/Shim.IE8'); if (typeof window !== 'undefined') { window.ForerunnerDB = Core; } module.exports = Core; },{"../lib/Core":7,"../lib/Shim.IE8":29}],3:[function(_dereq_,module,exports){ "use strict"; var Shared = _dereq_('./Shared'); /** * Creates an always-sorted multi-key bucket that allows ForerunnerDB to * know the index that a document will occupy in an array with minimal * processing, speeding up things like sorted views. * @param {object} orderBy An order object. * @constructor */ var ActiveBucket = function (orderBy) { var sortKey; this._primaryKey = '_id'; this._keyArr = []; this._data = []; this._objLookup = {}; this._count = 0; for (sortKey in orderBy) { if (orderBy.hasOwnProperty(sortKey)) { this._keyArr.push({ key: sortKey, dir: orderBy[sortKey] }); } } }; Shared.addModule('ActiveBucket', ActiveBucket); Shared.mixin(ActiveBucket.prototype, 'Mixin.Sorting'); /** * Gets / sets the primary key used by the active bucket. * @returns {String} The current primary key. */ Shared.synthesize(ActiveBucket.prototype, 'primaryKey'); /** * Quicksorts a single document into the passed array and * returns the index that the document should occupy. * @param {object} obj The document to calculate index for. * @param {array} arr The array the document index will be * calculated for. * @param {string} item The string key representation of the * document whose index is being calculated. * @param {function} fn The comparison function that is used * to determine if a document is sorted below or above the * document we are calculating the index for. * @returns {number} The index the document should occupy. */ ActiveBucket.prototype.qs = function (obj, arr, item, fn) { // If the array is empty then return index zero if (!arr.length) { return 0; } var lastMidwayIndex = -1, midwayIndex, lookupItem, result, start = 0, end = arr.length - 1; // Loop the data until our range overlaps while (end >= start) { // Calculate the midway point (divide and conquer) midwayIndex = Math.floor((start + end) / 2); if (lastMidwayIndex === midwayIndex) { // No more items to scan break; } // Get the item to compare against lookupItem = arr[midwayIndex]; if (lookupItem !== undefined) { // Compare items result = fn(this, obj, item, lookupItem); if (result > 0) { start = midwayIndex + 1; } if (result < 0) { end = midwayIndex - 1; } } lastMidwayIndex = midwayIndex; } if (result > 0) { return midwayIndex + 1; } else { return midwayIndex; } }; /** * Calculates the sort position of an item against another item. * @param {object} sorter An object or instance that contains * sortAsc and sortDesc methods. * @param {object} obj The document to compare. * @param {string} a The first key to compare. * @param {string} b The second key to compare. * @returns {number} Either 1 for sort a after b or -1 to sort * a before b. * @private */ ActiveBucket.prototype._sortFunc = function (sorter, obj, a, b) { var aVals = a.split('.:.'), bVals = b.split('.:.'), arr = sorter._keyArr, count = arr.length, index, sortType, castType; for (index = 0; index < count; index++) { sortType = arr[index]; castType = typeof obj[sortType.key]; if (castType === 'number') { aVals[index] = Number(aVals[index]); bVals[index] = Number(bVals[index]); } // Check for non-equal items if (aVals[index] !== bVals[index]) { // Return the sorted items if (sortType.dir === 1) { return sorter.sortAsc(aVals[index], bVals[index]); } if (sortType.dir === -1) { return sorter.sortDesc(aVals[index], bVals[index]); } } } }; /** * Inserts a document into the active bucket. * @param {object} obj The document to insert. * @returns {number} The index the document now occupies. */ ActiveBucket.prototype.insert = function (obj) { var key, keyIndex; key = this.documentKey(obj); keyIndex = this._data.indexOf(key); if (keyIndex === -1) { // Insert key keyIndex = this.qs(obj, this._data, key, this._sortFunc); this._data.splice(keyIndex, 0, key); } else { this._data.splice(keyIndex, 0, key); } this._objLookup[obj[this._primaryKey]] = key; this._count++; return keyIndex; }; /** * Removes a document from the active bucket. * @param {object} obj The document to remove. * @returns {boolean} True if the document was removed * successfully or false if it wasn't found in the active * bucket. */ ActiveBucket.prototype.remove = function (obj) { var key, keyIndex; key = this._objLookup[obj[this._primaryKey]]; if (key) { keyIndex = this._data.indexOf(key); if (keyIndex > -1) { this._data.splice(keyIndex, 1); delete this._objLookup[obj[this._primaryKey]]; this._count--; return true; } else { return false; } } return false; }; /** * Get the index that the passed document currently occupies * or the index it will occupy if added to the active bucket. * @param {object} obj The document to get the index for. * @returns {number} The index. */ ActiveBucket.prototype.index = function (obj) { var key, keyIndex; key = this.documentKey(obj); keyIndex = this._data.indexOf(key); if (keyIndex === -1) { // Get key index keyIndex = this.qs(obj, this._data, key, this._sortFunc); } return keyIndex; }; /** * The key that represents the passed document. * @param {object} obj The document to get the key for. * @returns {string} The document key. */ ActiveBucket.prototype.documentKey = function (obj) { var key = '', arr = this._keyArr, count = arr.length, index, sortType; for (index = 0; index < count; index++) { sortType = arr[index]; if (key) { key += '.:.'; } key += obj[sortType.key]; } // Add the unique identifier on the end of the key key += '.:.' + obj[this._primaryKey]; return key; }; /** * Get the number of documents currently indexed in the active * bucket instance. * @returns {number} The number of documents. */ ActiveBucket.prototype.count = function () { return this._count; }; Shared.finishModule('ActiveBucket'); module.exports = ActiveBucket; },{"./Shared":28}],4:[function(_dereq_,module,exports){ "use strict"; var Shared = _dereq_('./Shared'); var BinaryTree = function (data, compareFunc, hashFunc) { this.init.apply(this, arguments); }; BinaryTree.prototype.init = function (data, index, compareFunc, hashFunc) { this._store = []; if (index !== undefined) { this.index(index); } if (compareFunc !== undefined) { this.compareFunc(compareFunc); } if (hashFunc !== undefined) { this.hashFunc(hashFunc); } if (data !== undefined) { this.data(data); } }; Shared.addModule('BinaryTree', BinaryTree); Shared.mixin(BinaryTree.prototype, 'Mixin.ChainReactor'); Shared.mixin(BinaryTree.prototype, 'Mixin.Sorting'); Shared.mixin(BinaryTree.prototype, 'Mixin.Common'); Shared.synthesize(BinaryTree.prototype, 'compareFunc'); Shared.synthesize(BinaryTree.prototype, 'hashFunc'); Shared.synthesize(BinaryTree.prototype, 'indexDir'); Shared.synthesize(BinaryTree.prototype, 'index', function (index) { if (index !== undefined) { if (!(index instanceof Array)) { // Convert the index object to an array of key val objects index = this.keys(index); } } return this.$super.call(this, index); }); BinaryTree.prototype.keys = function (obj) { var i, keys = []; for (i in obj) { if (obj.hasOwnProperty(i)) { keys.push({ key: i, val: obj[i] }); } } return keys; }; BinaryTree.prototype.data = function (val) { if (val !== undefined) { this._data = val; if (this._hashFunc) { this._hash = this._hashFunc(val); } return this; } return this._data; }; BinaryTree.prototype.push = function (val) { if (val !== undefined) { this._store.push(val); return this; } return false; }; BinaryTree.prototype.pull = function (val) { if (val !== undefined) { var index = this._store.indexOf(val); if (index > -1) { this._store.splice(index, 1); return true; } } return false; }; /** * Default compare method. Can be overridden. * @param a * @param b * @returns {number} * @private */ BinaryTree.prototype._compareFunc = function (a, b) { // Loop the index array var i, indexData, result = 0; for (i = 0; i < this._index.length; i++) { indexData = this._index[i]; if (indexData.val === 1) { result = this.sortAsc(a[indexData.key], b[indexData.key]); } else if (indexData.val === -1) { result = this.sortDesc(a[indexData.key], b[indexData.key]); } if (result !== 0) { return result; } } return result; }; /** * Default hash function. Can be overridden. * @param obj * @private */ BinaryTree.prototype._hashFunc = function (obj) { /*var i, indexData, hash = ''; for (i = 0; i < this._index.length; i++) { indexData = this._index[i]; if (hash) { hash += '_'; } hash += obj[indexData.key]; } return hash;*/ return obj[this._index[0].key]; }; BinaryTree.prototype.insert = function (data) { var result, inserted, failed, i; if (data instanceof Array) { // Insert array of data inserted = []; failed = []; for (i = 0; i < data.length; i++) { if (this.insert(data[i])) { inserted.push(data[i]); } else { failed.push(data[i]); } } return { inserted: inserted, failed: failed }; } if (!this._data) { // Insert into this node (overwrite) as there is no data this.data(data); //this.push(data); return true; } result = this._compareFunc(this._data, data); if (result === 0) { this.push(data); // Less than this node if (this._left) { // Propagate down the left branch this._left.insert(data); } else { // Assign to left branch this._left = new BinaryTree(data, this._index, this._compareFunc, this._hashFunc); } return true; } if (result === -1) { // Greater than this node if (this._right) { // Propagate down the right branch this._right.insert(data); } else { // Assign to right branch this._right = new BinaryTree(data, this._index, this._compareFunc, this._hashFunc); } return true; } if (result === 1) { // Less than this node if (this._left) { // Propagate down the left branch this._left.insert(data); } else { // Assign to left branch this._left = new BinaryTree(data, this._index, this._compareFunc, this._hashFunc); } return true; } return false; }; BinaryTree.prototype.lookup = function (data, resultArr) { var result = this._compareFunc(this._data, data); resultArr = resultArr || []; if (result === 0) { if (this._left) { this._left.lookup(data, resultArr); } resultArr.push(this._data); if (this._right) { this._right.lookup(data, resultArr); } } if (result === -1) { if (this._right) { this._right.lookup(data, resultArr); } } if (result === 1) { if (this._left) { this._left.lookup(data, resultArr); } } return resultArr; }; BinaryTree.prototype.inOrder = function (type, resultArr) { resultArr = resultArr || []; if (this._left) { this._left.inOrder(type, resultArr); } switch (type) { case 'hash': resultArr.push(this._hash); break; case 'key': resultArr.push(this._data); break; default: resultArr.push({ key: this._key, arr: this._store }); break; } if (this._right) { this._right.inOrder(type, resultArr); } return resultArr; }; Shared.finishModule('BinaryTree'); module.exports = BinaryTree; },{"./Shared":28}],5:[function(_dereq_,module,exports){ "use strict"; var Shared, Db, Metrics, KeyValueStore, Path, IndexHashMap, IndexBinaryTree, Crc, Overload, ReactorIO; Shared = _dereq_('./Shared'); /** * Creates a new collection. Collections store multiple documents and * handle CRUD against those documents. * @constructor */ var Collection = function (name) { this.init.apply(this, arguments); }; Collection.prototype.init = function (name, options) { this._primaryKey = '_id'; this._primaryIndex = new KeyValueStore('primary'); this._primaryCrc = new KeyValueStore('primaryCrc'); this._crcLookup = new KeyValueStore('crcLookup'); this._name = name; this._data = []; this._metrics = new Metrics(); this._options = options || { changeTimestamp: false }; // Create an object to store internal protected data this._metaData = {}; this._deferQueue = { insert: [], update: [], remove: [], upsert: [] }; this._deferThreshold = { insert: 100, update: 100, remove: 100, upsert: 100 }; this._deferTime = { insert: 1, update: 1, remove: 1, upsert: 1 }; // Set the subset to itself since it is the root collection this.subsetOf(this); }; Shared.addModule('Collection', Collection); Shared.mixin(Collection.prototype, 'Mixin.Common'); Shared.mixin(Collection.prototype, 'Mixin.Events'); Shared.mixin(Collection.prototype, 'Mixin.ChainReactor'); Shared.mixin(Collection.prototype, 'Mixin.CRUD'); Shared.mixin(Collection.prototype, 'Mixin.Constants'); Shared.mixin(Collection.prototype, 'Mixin.Triggers'); Shared.mixin(Collection.prototype, 'Mixin.Sorting'); Shared.mixin(Collection.prototype, 'Mixin.Matching'); Shared.mixin(Collection.prototype, 'Mixin.Updating'); Metrics = _dereq_('./Metrics'); KeyValueStore = _dereq_('./KeyValueStore'); Path = _dereq_('./Path'); IndexHashMap = _dereq_('./IndexHashMap'); IndexBinaryTree = _dereq_('./IndexBinaryTree'); Crc = _dereq_('./Crc'); Db = Shared.modules.Db; Overload = _dereq_('./Overload'); ReactorIO = _dereq_('./ReactorIO'); /** * Returns a checksum of a string. * @param {String} string The string to checksum. * @return {String} The checksum generated. */ Collection.prototype.crc = Crc; /** * Gets / sets the current state. * @param {String=} val The name of the state to set. * @returns {*} */ Shared.synthesize(Collection.prototype, 'state'); /** * Gets / sets the name of the collection. * @param {String=} val The name of the collection to set. * @returns {*} */ Shared.synthesize(Collection.prototype, 'name'); /** * Gets / sets the metadata stored in the collection. */ Shared.synthesize(Collection.prototype, 'metaData'); /** * Get the data array that represents the collection's data. * This data is returned by reference and should not be altered outside * of the provided CRUD functionality of the collection as doing so * may cause unstable index behaviour within the collection. * @returns {Array} */ Collection.prototype.data = function () { return this._data; }; /** * Drops a collection and all it's stored data from the database. * @returns {boolean} True on success, false on failure. */ Collection.prototype.drop = function (callback) { var key; if (!this.isDropped()) { if (this._db && this._db._collection && this._name) { if (this.debug()) { console.log(this.logIdentifier() + ' Dropping'); } this._state = 'dropped'; this.emit('drop', this); delete this._db._collection[this._name]; // Remove any reactor IO chain links if (this._collate) { for (key in this._collate) { if (this._collate.hasOwnProperty(key)) { this.collateRemove(key); } } } delete this._primaryKey; delete this._primaryIndex; delete this._primaryCrc; delete this._crcLookup; delete this._name; delete this._data; delete this._metrics; if (callback) { callback(false, true); } return true; } } else { if (callback) { callback(false, true); } return true; } if (callback) { callback(false, true); } return false; }; /** * Gets / sets the primary key for this collection. * @param {String=} keyName The name of the primary key. * @returns {*} */ Collection.prototype.primaryKey = function (keyName) { if (keyName !== undefined) { if (this._primaryKey !== keyName) { this._primaryKey = keyName; // Set the primary key index primary key this._primaryIndex.primaryKey(keyName); // Rebuild the primary key index this.rebuildPrimaryKeyIndex(); } return this; } return this._primaryKey; }; /** * Handles insert events and routes changes to binds and views as required. * @param {Array} inserted An array of inserted documents. * @param {Array} failed An array of documents that failed to insert. * @private */ Collection.prototype._onInsert = function (inserted, failed) { this.emit('insert', inserted, failed); }; /** * Handles update events and routes changes to binds and views as required. * @param {Array} items An array of updated documents. * @private */ Collection.prototype._onUpdate = function (items) { this.emit('update', items); }; /** * Handles remove events and routes changes to binds and views as required. * @param {Array} items An array of removed documents. * @private */ Collection.prototype._onRemove = function (items) { this.emit('remove', items); }; /** * Handles any change to the collection. * @private */ Collection.prototype._onChange = function () { if (this._options.changeTimestamp) { // Record the last change timestamp this._metaData.lastChange = new Date(); } }; /** * Gets / sets the db instance this class instance belongs to. * @param {Db=} db The db instance. * @returns {*} */ Shared.synthesize(Collection.prototype, 'db', function (db) { if (db) { if (this.primaryKey() === '_id') { // Set primary key to the db's key by default this.primaryKey(db.primaryKey()); // Apply the same debug settings this.debug(db.debug()); } } return this.$super.apply(this, arguments); }); /** * Gets / sets mongodb emulation mode. * @param {Boolean=} val True to enable, false to disable. * @returns {*} */ Shared.synthesize(Collection.prototype, 'mongoEmulation'); /** * Sets the collection's data to the array / documents passed. If any * data already exists in the collection it will be removed before the * new data is set. * @param {Array|Object} data The array of documents or a single document * that will be set as the collections data. * @param options Optional options object. * @param callback Optional callback function. */ Collection.prototype.setData = function (data, options, callback) { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } if (data) { var op = this._metrics.create('setData'); op.start(); options = this.options(options); this.preSetData(data, options, callback); if (options.$decouple) { data = this.decouple(data); } if (!(data instanceof Array)) { data = [data]; } op.time('transformIn'); data = this.transformIn(data); op.time('transformIn'); var oldData = [].concat(this._data); this._dataReplace(data); // Update the primary key index op.time('Rebuild Primary Key Index'); this.rebuildPrimaryKeyIndex(options); op.time('Rebuild Primary Key Index'); // Rebuild all other indexes op.time('Rebuild All Other Indexes'); this._rebuildIndexes(); op.time('Rebuild All Other Indexes'); op.time('Resolve chains'); this.chainSend('setData', data, {oldData: oldData}); op.time('Resolve chains'); op.stop(); this._onChange(); this.emit('setData', this._data, oldData); } if (callback) { callback(false); } return this; }; /** * Drops and rebuilds the primary key index for all documents in the collection. * @param {Object=} options An optional options object. * @private */ Collection.prototype.rebuildPrimaryKeyIndex = function (options) { options = options || { $ensureKeys: undefined, $violationCheck: undefined }; var ensureKeys = options && options.$ensureKeys !== undefined ? options.$ensureKeys : true, violationCheck = options && options.$violationCheck !== undefined ? options.$violationCheck : true, arr, arrCount, arrItem, pIndex = this._primaryIndex, crcIndex = this._primaryCrc, crcLookup = this._crcLookup, pKey = this._primaryKey, jString; // Drop the existing primary index pIndex.truncate(); crcIndex.truncate(); crcLookup.truncate(); // Loop the data and check for a primary key in each object arr = this._data; arrCount = arr.length; while (arrCount--) { arrItem = arr[arrCount]; if (ensureKeys) { // Make sure the item has a primary key this.ensurePrimaryKey(arrItem); } if (violationCheck) { // Check for primary key violation if (!pIndex.uniqueSet(arrItem[pKey], arrItem)) { // Primary key violation throw(this.logIdentifier() + ' Call to setData on collection failed because your data violates the primary key unique constraint. One or more documents are using the same primary key: ' + arrItem[this._primaryKey]); } } else { pIndex.set(arrItem[pKey], arrItem); } // Generate a CRC string jString = this.jStringify(arrItem); crcIndex.set(arrItem[pKey], jString); crcLookup.set(jString, arrItem); } }; /** * Checks for a primary key on the document and assigns one if none * currently exists. * @param {Object} obj The object to check a primary key against. * @private */ Collection.prototype.ensurePrimaryKey = function (obj) { if (obj[this._primaryKey] === undefined) { // Assign a primary key automatically obj[this._primaryKey] = this.objectId(); } }; /** * Clears all data from the collection. * @returns {Collection} */ Collection.prototype.truncate = function () { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } this.emit('truncate', this._data); // Clear all the data from the collection this._data.length = 0; // Re-create the primary index data this._primaryIndex = new KeyValueStore('primary'); this._primaryCrc = new KeyValueStore('primaryCrc'); this._crcLookup = new KeyValueStore('crcLookup'); this._onChange(); this.deferEmit('change', {type: 'truncate'}); return this; }; /** * Modifies an existing document or documents in a collection. This will update * all matches for 'query' with the data held in 'update'. It will not overwrite * the matched documents with the update document. * * @param {Object} obj The document object to upsert or an array containing * documents to upsert. * * If the document contains a primary key field (based on the collections's primary * key) then the database will search for an existing document with a matching id. * If a matching document is found, the document will be updated. Any keys that * match keys on the existing document will be overwritten with new data. Any keys * that do not currently exist on the document will be added to the document. * * If the document does not contain an id or the id passed does not match an existing * document, an insert is performed instead. If no id is present a new primary key * id is provided for the item. * * @param {Function=} callback Optional callback method. * @returns {Object} An object containing two keys, "op" contains either "insert" or * "update" depending on the type of operation that was performed and "result" * contains the return data from the operation used. */ Collection.prototype.upsert = function (obj, callback) { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } if (obj) { var queue = this._deferQueue.upsert, deferThreshold = this._deferThreshold.upsert; var returnData = {}, query, i; // Determine if the object passed is an array or not if (obj instanceof Array) { if (obj.length > deferThreshold) { // Break up upsert into blocks this._deferQueue.upsert = queue.concat(obj); // Fire off the insert queue handler this.processQueue('upsert', callback); return {}; } else { // Loop the array and upsert each item returnData = []; for (i = 0; i < obj.length; i++) { returnData.push(this.upsert(obj[i])); } if (callback) { callback(); } return returnData; } } // Determine if the operation is an insert or an update if (obj[this._primaryKey]) { // Check if an object with this primary key already exists query = {}; query[this._primaryKey] = obj[this._primaryKey]; if (this._primaryIndex.lookup(query)[0]) { // The document already exists with this id, this operation is an update returnData.op = 'update'; } else { // No document with this id exists, this operation is an insert returnData.op = 'insert'; } } else { // The document passed does not contain an id, this operation is an insert returnData.op = 'insert'; } switch (returnData.op) { case 'insert': returnData.result = this.insert(obj); break; case 'update': returnData.result = this.update(query, obj); break; default: break; } return returnData; } else { if (callback) { callback(); } } return {}; }; /** * Executes a method against each document that matches query and returns an * array of documents that may have been modified by the method. * @param {Object} query The query object. * @param {Function} func The method that each document is passed to. If this method * returns false for a particular document it is excluded from the results. * @param {Object=} options Optional options object. * @returns {Array} */ Collection.prototype.filter = function (query, func, options) { return (this.find(query, options)).filter(func); }; /** * Executes a method against each document that matches query and then executes * an update based on the return data of the method. * @param {Object} query The query object. * @param {Function} func The method that each document is passed to. If this method * returns false for a particular document it is excluded from the update. * @param {Object=} options Optional options object passed to the initial find call. * @returns {Array} */ Collection.prototype.filterUpdate = function (query, func, options) { var items = this.find(query, options), results = [], singleItem, singleQuery, singleUpdate, pk = this.primaryKey(), i; for (i = 0; i < items.length; i++) { singleItem = items[i]; singleUpdate = func(singleItem); if (singleUpdate) { singleQuery = {}; singleQuery[pk] = singleItem[pk]; results.push(this.update(singleQuery, singleUpdate)); } } return results; }; /** * Modifies an existing document or documents in a collection. This will update * all matches for 'query' with the data held in 'update'. It will not overwrite * the matched documents with the update document. * * @param {Object} query The query that must be matched for a document to be * operated on. * @param {Object} update The object containing updated key/values. Any keys that * match keys on the existing document will be overwritten with this data. Any * keys that do not currently exist on the document will be added to the document. * @param {Object=} options An options object. * @returns {Array} The items that were updated. */ Collection.prototype.update = function (query, update, options) { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } // Decouple the update data update = this.decouple(update); // Convert queries from mongo dot notation to forerunner queries if (this.mongoEmulation()) { this.convertToFdb(query); this.convertToFdb(update); } // Handle transform update = this.transformIn(update); if (this.debug()) { console.log(this.logIdentifier() + ' Updating some data'); } var self = this, op = this._metrics.create('update'), dataSet, updated, updateCall = function (referencedDoc) { var oldDoc = self.decouple(referencedDoc), newDoc, triggerOperation, result; if (self.willTrigger(self.TYPE_UPDATE, self.PHASE_BEFORE) || self.willTrigger(self.TYPE_UPDATE, self.PHASE_AFTER)) { newDoc = self.decouple(referencedDoc); triggerOperation = { type: 'update', query: self.decouple(query), update: self.decouple(update), options: self.decouple(options), op: op }; // Update newDoc with the update criteria so we know what the data will look // like AFTER the update is processed result = self.updateObject(newDoc, triggerOperation.update, triggerOperation.query, triggerOperation.options, ''); if (self.processTrigger(triggerOperation, self.TYPE_UPDATE, self.PHASE_BEFORE, referencedDoc, newDoc) !== false) { // No triggers complained so let's execute the replacement of the existing // object with the new one result = self.updateObject(referencedDoc, newDoc, triggerOperation.query, triggerOperation.options, ''); // NOTE: If for some reason we would only like to fire this event if changes are actually going // to occur on the object from the proposed update then we can add "result &&" to the if self.processTrigger(triggerOperation, self.TYPE_UPDATE, self.PHASE_AFTER, oldDoc, newDoc); } else { // Trigger cancelled operation so tell result that it was not updated result = false; } } else { // No triggers complained so let's execute the replacement of the existing // object with the new one result = self.updateObject(referencedDoc, update, query, options, ''); } // Inform indexes of the change self._updateIndexes(oldDoc, referencedDoc); return result; }; op.start(); op.time('Retrieve documents to update'); dataSet = this.find(query, {$decouple: false}); op.time('Retrieve documents to update'); if (dataSet.length) { op.time('Update documents'); updated = dataSet.filter(updateCall); op.time('Update documents'); if (updated.length) { op.time('Resolve chains'); this.chainSend('update', { query: query, update: update, dataSet: dataSet }, options); op.time('Resolve chains'); this._onUpdate(updated); this._onChange(); this.deferEmit('change', {type: 'update', data: updated}); } } op.stop(); // TODO: Should we decouple the updated array before return by default? return updated || []; }; /** * Replaces an existing object with data from the new object without * breaking data references. * @param {Object} currentObj The object to alter. * @param {Object} newObj The new object to overwrite the existing one with. * @returns {*} Chain. * @private */ Collection.prototype._replaceObj = function (currentObj, newObj) { var i; // Check if the new document has a different primary key value from the existing one // Remove item from indexes this._removeFromIndexes(currentObj); // Remove existing keys from current object for (i in currentObj) { if (currentObj.hasOwnProperty(i)) { delete currentObj[i]; } } // Add new keys to current object for (i in newObj) { if (newObj.hasOwnProperty(i)) { currentObj[i] = newObj[i]; } } // Update the item in the primary index if (!this._insertIntoIndexes(currentObj)) { throw(this.logIdentifier() + ' Primary key violation in update! Key violated: ' + currentObj[this._primaryKey]); } // Update the object in the collection data //this._data.splice(this._data.indexOf(currentObj), 1, newObj); return this; }; /** * Helper method to update a document from it's id. * @param {String} id The id of the document. * @param {Object} update The object containing the key/values to update to. * @returns {Array} The items that were updated. */ Collection.prototype.updateById = function (id, update) { var searchObj = {}; searchObj[this._primaryKey] = id; return this.update(searchObj, update); }; /** * Internal method for document updating. * @param {Object} doc The document to update. * @param {Object} update The object with key/value pairs to update the document with. * @param {Object} query The query object that we need to match to perform an update. * @param {Object} options An options object. * @param {String} path The current recursive path. * @param {String} opType The type of update operation to perform, if none is specified * default is to set new data against matching fields. * @returns {Boolean} True if the document was updated with new / changed data or * false if it was not updated because the data was the same. * @private */ Collection.prototype.updateObject = function (doc, update, query, options, path, opType) { // TODO: This method is long, try to break it into smaller pieces update = this.decouple(update); // Clear leading dots from path path = path || ''; if (path.substr(0, 1) === '.') { path = path.substr(1, path.length -1); } //var oldDoc = this.decouple(doc), var updated = false, recurseUpdated = false, operation, tmpArray, tmpIndex, tmpCount, tempIndex, pathInstance, sourceIsArray, updateIsArray, i; // Loop each key in the update object for (i in update) { if (update.hasOwnProperty(i)) { // Reset operation flag operation = false; // Check if the property starts with a dollar (function) if (i.substr(0, 1) === '$') { // Check for commands switch (i) { case '$key': case '$index': case '$data': case '$min': case '$max': // Ignore some operators operation = true; break; case '$each': operation = true; // Loop over the array of updates and run each one tmpCount = update.$each.length; for (tmpIndex = 0; tmpIndex < tmpCount; tmpIndex++) { recurseUpdated = this.updateObject(doc, update.$each[tmpIndex], query, options, path); if (recurseUpdated) { updated = true; } } updated = updated || recurseUpdated; break; default: operation = true; // Now run the operation recurseUpdated = this.updateObject(doc, update[i], query, options, path, i); updated = updated || recurseUpdated; break; } } // Check if the key has a .$ at the end, denoting an array lookup if (this._isPositionalKey(i)) { operation = true; // Modify i to be the name of the field i = i.substr(0, i.length - 2); pathInstance = new Path(path + '.' + i); // Check if the key is an array and has items if (doc[i] && doc[i] instanceof Array && doc[i].length) { tmpArray = []; // Loop the array and find matches to our search for (tmpIndex = 0; tmpIndex < doc[i].length; tmpIndex++) { if (this._match(doc[i][tmpIndex], pathInstance.value(query)[0], options, '', {})) { tmpArray.push(tmpIndex); } } // Loop the items that matched and update them for (tmpIndex = 0; tmpIndex < tmpArray.length; tmpIndex++) { recurseUpdated = this.updateObject(doc[i][tmpArray[tmpIndex]], update[i + '.$'], query, options, path + '.' + i, opType); updated = updated || recurseUpdated; } } } if (!operation) { if (!opType && typeof(update[i]) === 'object') { if (doc[i] !== null && typeof(doc[i]) === 'object') { // Check if we are dealing with arrays sourceIsArray = doc[i] instanceof Array; updateIsArray = update[i] instanceof Array; if (sourceIsArray || updateIsArray) { // Check if the update is an object and the doc is an array if (!updateIsArray && sourceIsArray) { // Update is an object, source is an array so match the array items // with our query object to find the one to update inside this array // Loop the array and find matches to our search for (tmpIndex = 0; tmpIndex < doc[i].length; tmpIndex++) { recurseUpdated = this.updateObject(doc[i][tmpIndex], update[i], query, options, path + '.' + i, opType); updated = updated || recurseUpdated; } } else { // Either both source and update are arrays or the update is // an array and the source is not, so set source to update if (doc[i] !== update[i]) { this._updateProperty(doc, i, update[i]); updated = true; } } } else { // The doc key is an object so traverse the // update further recurseUpdated = this.updateObject(doc[i], update[i], query, options, path + '.' + i, opType); updated = updated || recurseUpdated; } } else { if (doc[i] !== update[i]) { this._updateProperty(doc, i, update[i]); updated = true; } } } else { switch (opType) { case '$inc': var doUpdate = true; // Check for a $min / $max operator if (update[i] > 0) { if (update.$max) { // Check current value if (doc[i] >= update.$max) { // Don't update doUpdate = false; } } } else if (update[i] < 0) { if (update.$min) { // Check current value if (doc[i] <= update.$min) { // Don't update doUpdate = false; } } } if (doUpdate) { this._updateIncrement(doc, i, update[i]); updated = true; } break; case '$cast': // Casts a property to the type specified if it is not already // that type. If the cast is an array or an object and the property // is not already that type a new array or object is created and // set to the property, overwriting the previous value switch (update[i]) { case 'array': if (!(doc[i] instanceof Array)) { // Cast to an array this._updateProperty(doc, i, update.$data || []); updated = true; } break; case 'object': if (!(doc[i] instanceof Object) || (doc[i] instanceof Array)) { // Cast to an object this._updateProperty(doc, i, update.$data || {}); updated = true; } break; case 'number': if (typeof doc[i] !== 'number') { // Cast to a number this._updateProperty(doc, i, Number(doc[i])); updated = true; } break; case 'string': if (typeof doc[i] !== 'string') { // Cast to a string this._updateProperty(doc, i, String(doc[i])); updated = true; } break; default: throw(this.logIdentifier() + ' Cannot update cast to unknown type: ' + update[i]); } break; case '$push': // Check if the target key is undefined and if so, create an array if (doc[i] === undefined) { // Initialise a new array this._updateProperty(doc, i, []); } // Check that the target key is an array if (doc[i] instanceof Array) { // Check for a $position modifier with an $each if (update[i].$position !== undefined && update[i].$each instanceof Array) { // Grab the position to insert at tempIndex = update[i].$position; // Loop the each array and push each item tmpCount = update[i].$each.length; for (tmpIndex = 0; tmpIndex < tmpCount; tmpIndex++) { this._updateSplicePush(doc[i], tempIndex + tmpIndex, update[i].$each[tmpIndex]); } } else if (update[i].$each instanceof Array) { // Do a loop over the each to push multiple items tmpCount = update[i].$each.length; for (tmpIndex = 0; tmpIndex < tmpCount; tmpIndex++) { this._updatePush(doc[i], update[i].$each[tmpIndex]); } } else { // Do a standard push this._updatePush(doc[i], update[i]); } updated = true; } else { throw(this.logIdentifier() + ' Cannot push to a key that is not an array! (' + i + ')'); } break; case '$pull': if (doc[i] instanceof Array) { tmpArray = []; // Loop the array and find matches to our search for (tmpIndex = 0; tmpIndex < doc[i].length; tmpIndex++) { if (this._match(doc[i][tmpIndex], update[i], options, '', {})) { tmpArray.push(tmpIndex); } } tmpCount = tmpArray.length; // Now loop the pull array and remove items to be pulled while (tmpCount--) { this._updatePull(doc[i], tmpArray[tmpCount]); updated = true; } } break; case '$pullAll': if (doc[i] instanceof Array) { if (update[i] instanceof Array) { tmpArray = doc[i]; tmpCount = tmpArray.length; if (tmpCount > 0) { // Now loop the pull array and remove items to be pulled while (tmpCount--) { for (tempIndex = 0; tempIndex < update[i].length; tempIndex++) { if (tmpArray[tmpCount] === update[i][tempIndex]) { this._updatePull(doc[i], tmpCount); tmpCount--; updated = true; } } if (tmpCount < 0) { break; } } } } else { throw(this.logIdentifier() + ' Cannot pullAll without being given an array of values to pull! (' + i + ')'); } } break; case '$addToSet': // Check if the target key is undefined and if so, create an array if (doc[i] === undefined) { // Initialise a new array this._updateProperty(doc, i, []); } // Check that the target key is an array if (doc[i] instanceof Array) { // Loop the target array and check for existence of item var targetArr = doc[i], targetArrIndex, targetArrCount = targetArr.length, objHash, addObj = true, optionObj = (options && options.$addToSet), hashMode, pathSolver; // Check if we have an options object for our operation if (update[i].$key) { hashMode = false; pathSolver = new Path(update[i].$key); objHash = pathSolver.value(update[i])[0]; // Remove the key from the object before we add it delete update[i].$key; } else if (optionObj && optionObj.key) { hashMode = false; pathSolver = new Path(optionObj.key); objHash = pathSolver.value(update[i])[0]; } else { objHash = this.jStringify(update[i]); hashMode = true; } for (targetArrIndex = 0; targetArrIndex < targetArrCount; targetArrIndex++) { if (hashMode) { // Check if objects match via a string hash (JSON) if (this.jStringify(targetArr[targetArrIndex]) === objHash) { // The object already exists, don't add it addObj = false; break; } } else { // Check if objects match based on the path if (objHash === pathSolver.value(targetArr[targetArrIndex])[0]) { // The object already exists, don't add it addObj = false; break; } } } if (addObj) { this._updatePush(doc[i], update[i]); updated = true; } } else { throw(this.logIdentifier() + ' Cannot addToSet on a key that is not an array! (' + i + ')'); } break; case '$splicePush': // Check if the target key is undefined and if so, create an array if (doc[i] === undefined) { // Initialise a new array this._updateProperty(doc, i, []); } // Check that the target key is an array if (doc[i] instanceof Array) { tempIndex = update.$index; if (tempIndex !== undefined) { delete update.$index; // Check for out of bounds index if (tempIndex > doc[i].length) { tempIndex = doc[i].length; } this._updateSplicePush(doc[i], tempIndex, update[i]); updated = true; } else { throw(this.logIdentifier() + ' Cannot splicePush without a $index integer value!'); } } else { throw(this.logIdentifier() + ' Cannot splicePush with a key that is not an array! (' + i + ')'); } break; case '$move': if (doc[i] instanceof Array) { // Loop the array and find matches to our search for (tmpIndex = 0; tmpIndex < doc[i].length; tmpIndex++) { if (this._match(doc[i][tmpIndex], update[i], options, '', {})) { var moveToIndex = update.$index; if (moveToIndex !== undefined) { delete update.$index; this._updateSpliceMove(doc[i], tmpIndex, moveToIndex); updated = true; } else { throw(this.logIdentifier() + ' Cannot move without a $index integer value!'); } break; } } } else { throw(this.logIdentifier() + ' Cannot move on a key that is not an array! (' + i + ')'); } break; case '$mul': this._updateMultiply(doc, i, update[i]); updated = true; break; case '$rename': this._updateRename(doc, i, update[i]); updated = true; break; case '$overwrite': this._updateOverwrite(doc, i, update[i]); updated = true; break; case '$unset': this._updateUnset(doc, i); updated = true; break; case '$clear': this._updateClear(doc, i); updated = true; break; case '$pop': if (doc[i] instanceof Array) { if (this._updatePop(doc[i], update[i])) { updated = true; } } else { throw(this.logIdentifier() + ' Cannot pop from a key that is not an array! (' + i + ')'); } break; case '$toggle': // Toggle the boolean property between true and false this._updateProperty(doc, i, !doc[i]); updated = true; break; default: if (doc[i] !== update[i]) { this._updateProperty(doc, i, update[i]); updated = true; } break; } } } } } return updated; }; /** * Determines if the passed key has an array positional mark (a dollar at the end * of its name). * @param {String} key The key to check. * @returns {Boolean} True if it is a positional or false if not. * @private */ Collection.prototype._isPositionalKey = function (key) { return key.substr(key.length - 2, 2) === '.$'; }; /** * Removes any documents from the collection that match the search query * key/values. * @param {Object} query The query object. * @param {Object=} options An options object. * @param {Function=} callback A callback method. * @returns {Array} An array of the documents that were removed. */ Collection.prototype.remove = function (query, options, callback) { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } var self = this, dataSet, index, arrIndex, returnArr, removeMethod, triggerOperation, doc, newDoc; if (typeof(options) === 'function') { callback = options; options = {}; } // Convert queries from mongo dot notation to forerunner queries if (this.mongoEmulation()) { this.convertToFdb(query); } if (query instanceof Array) { returnArr = []; for (arrIndex = 0; arrIndex < query.length; arrIndex++) { returnArr.push(this.remove(query[arrIndex], {noEmit: true})); } if (!options || (options && !options.noEmit)) { this._onRemove(returnArr); } if (callback) { callback(false, returnArr); } return returnArr; } else { dataSet = this.find(query, {$decouple: false}); if (dataSet.length) { removeMethod = function (dataItem) { // Remove the item from the collection's indexes self._removeFromIndexes(dataItem); // Remove data from internal stores index = self._data.indexOf(dataItem); self._dataRemoveAtIndex(index); }; // Remove the data from the collection for (var i = 0; i < dataSet.length; i++) { doc = dataSet[i]; if (self.willTrigger(self.TYPE_REMOVE, self.PHASE_BEFORE) || self.willTrigger(self.TYPE_REMOVE, self.PHASE_AFTER)) { triggerOperation = { type: 'remove' }; newDoc = self.decouple(doc); if (self.processTrigger(triggerOperation, self.TYPE_REMOVE, self.PHASE_BEFORE, newDoc, newDoc) !== false) { // The trigger didn't ask to cancel so execute the removal method removeMethod(doc); self.processTrigger(triggerOperation, self.TYPE_REMOVE, self.PHASE_AFTER, newDoc, newDoc); } } else { // No triggers to execute removeMethod(doc); } } //op.time('Resolve chains'); this.chainSend('remove', { query: query, dataSet: dataSet }, options); //op.time('Resolve chains'); if (!options || (options && !options.noEmit)) { this._onRemove(dataSet); } this._onChange(); this.deferEmit('change', {type: 'remove', data: dataSet}); } if (callback) { callback(false, dataSet); } return dataSet; } }; /** * Helper method that removes a document that matches the given id. * @param {String} id The id of the document to remove. * @returns {Array} An array of documents that were removed. */ Collection.prototype.removeById = function (id) { var searchObj = {}; searchObj[this._primaryKey] = id; return this.remove(searchObj); }; /** * Processes a deferred action queue. * @param {String} type The queue name to process. * @param {Function} callback A method to call when the queue has proc