UNPKG

forerunnerdb

Version:

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

1,811 lines (1,528 loc) 312 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_('../lib/Core'), ShimIE8 = _dereq_('../lib/Shim.IE8'); if (typeof window !== 'undefined') { window.ForerunnerDB = Core; } module.exports = Core; },{"../lib/Core":5,"../lib/Shim.IE8":29}],2:[function(_dereq_,module,exports){ "use strict"; var Shared = _dereq_('./Shared'), Path = _dereq_('./Path'), sharedPathSolver = new Path(); var BinaryTree = function (data, compareFunc, hashFunc) { this.init.apply(this, arguments); }; BinaryTree.prototype.init = function (data, index, primaryKey, compareFunc, hashFunc) { this._store = []; this._keys = []; if (primaryKey !== undefined) { this.primaryKey(primaryKey); } 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, 'primaryKey'); Shared.synthesize(BinaryTree.prototype, 'keys'); Shared.synthesize(BinaryTree.prototype, 'index', function (index) { if (index !== undefined) { if (this.debug()) { console.log('Setting index', index, sharedPathSolver.parse(index, true)); } // Convert the index object to an array of key val objects this.keys(sharedPathSolver.parse(index, true)); } return this.$super.call(this, index); }); /** * Remove all data from the binary tree. */ BinaryTree.prototype.clear = function () { delete this._data; delete this._left; delete this._right; this._store = []; }; /** * Sets this node's data object. All further inserted documents that * match this node's key and value will be pushed via the push() * method into the this._store array. When deciding if a new data * should be created left, right or middle (pushed) of this node the * new data is checked against the data set via this method. * @param val * @returns {*} */ BinaryTree.prototype.data = function (val) { if (val !== undefined) { this._data = val; if (this._hashFunc) { this._hash = this._hashFunc(val); } return this; } return this._data; }; /** * Pushes an item to the binary tree node's store array. * @param {*} val The item to add to the store. * @returns {*} */ BinaryTree.prototype.push = function (val) { if (val !== undefined) { this._store.push(val); return this; } return false; }; /** * Pulls an item from the binary tree node's store array. * @param {*} val The item to remove from the store. * @returns {*} */ BinaryTree.prototype.pull = function (val) { if (val !== undefined) { var index = this._store.indexOf(val); if (index > -1) { this._store.splice(index, 1); return this; } } 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._keys.length; i++) { indexData = this._keys[i]; if (indexData.value === 1) { result = this.sortAscIgnoreUndefined(sharedPathSolver.get(a, indexData.path), sharedPathSolver.get(b, indexData.path)); } else if (indexData.value === -1) { result = this.sortDescIgnoreUndefined(sharedPathSolver.get(a, indexData.path), sharedPathSolver.get(b, indexData.path)); } if (this.debug()) { console.log('Compared %s with %s order %d in path %s and result was %d', sharedPathSolver.get(a, indexData.path), sharedPathSolver.get(b, indexData.path), indexData.value, indexData.path, result); } if (result !== 0) { if (this.debug()) { console.log('Retuning result %d', result); } return result; } } if (this.debug()) { console.log('Retuning result %d', 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._keys.length; i++) { indexData = this._keys[i]; if (hash) { hash += '_'; } hash += obj[indexData.path]; } return hash;*/ return obj[this._keys[0].path]; }; /** * Removes (deletes reference to) either left or right child if the passed * node matches one of them. * @param {BinaryTree} node The node to remove. */ BinaryTree.prototype.removeChildNode = function (node) { if (this._left === node) { // Remove left delete this._left; } else if (this._right === node) { // Remove right delete this._right; } }; /** * Returns the branch this node matches (left or right). * @param node * @returns {String} */ BinaryTree.prototype.nodeBranch = function (node) { if (this._left === node) { return 'left'; } else if (this._right === node) { return 'right'; } }; /** * Inserts a document into the binary tree. * @param data * @returns {*} */ 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.debug()) { console.log('Inserting', data); } if (!this._data) { if (this.debug()) { console.log('Node has no data, setting data', 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) { if (this.debug()) { console.log('Data is equal (currrent, new)', this._data, data); } //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._binaryTree, this._compareFunc, this._hashFunc); this._left._parent = this; } return true; } if (result === -1) { if (this.debug()) { console.log('Data is greater (currrent, new)', this._data, data); } // 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._binaryTree, this._compareFunc, this._hashFunc); this._right._parent = this; } return true; } if (result === 1) { if (this.debug()) { console.log('Data is less (currrent, new)', this._data, 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._binaryTree, this._compareFunc, this._hashFunc); this._left._parent = this; } return true; } return false; }; BinaryTree.prototype.remove = function (data) { var pk = this.primaryKey(), result, removed, i; if (data instanceof Array) { // Insert array of data removed = []; for (i = 0; i < data.length; i++) { if (this.remove(data[i])) { removed.push(data[i]); } } return removed; } if (this.debug()) { console.log('Removing', data); } if (this._data[pk] === data[pk]) { // Remove this node return this._remove(this); } // Compare the data to work out which branch to send the remove command down result = this._compareFunc(this._data, data); if (result === -1 && this._right) { return this._right.remove(data); } if (result === 1 && this._left) { return this._left.remove(data); } return false; }; BinaryTree.prototype._remove = function (node) { var leftNode, rightNode; if (this._left) { // Backup branch data leftNode = this._left; rightNode = this._right; // Copy data from left node this._left = leftNode._left; this._right = leftNode._right; this._data = leftNode._data; this._store = leftNode._store; if (rightNode) { // Attach the rightNode data to the right-most node // of the leftNode leftNode.rightMost()._right = rightNode; } } else if (this._right) { // Backup branch data rightNode = this._right; // Copy data from right node this._left = rightNode._left; this._right = rightNode._right; this._data = rightNode._data; this._store = rightNode._store; } else { this.clear(); } return true; }; BinaryTree.prototype.leftMost = function () { if (!this._left) { return this; } else { return this._left.leftMost(); } }; BinaryTree.prototype.rightMost = function () { if (!this._right) { return this; } else { return this._right.rightMost(); } }; /** * Searches the binary tree for all matching documents based on the data * passed (query). * @param {Object} data The data / document to use for lookups. * @param {Object} options An options object. * @param {Operation} op An optional operation instance. Pass undefined * if not being used. * @param {Array=} resultArr The results passed between recursive calls. * Do not pass anything into this argument when calling externally. * @returns {*|Array} */ BinaryTree.prototype.lookup = function (data, options, op, resultArr) { var result = this._compareFunc(this._data, data); resultArr = resultArr || []; if (result === 0) { if (this._left) { this._left.lookup(data, options, op, resultArr); } resultArr.push(this._data); if (this._right) { this._right.lookup(data, options, op, resultArr); } } if (result === -1) { if (this._right) { this._right.lookup(data, options, op, resultArr); } } if (result === 1) { if (this._left) { this._left.lookup(data, options, op, resultArr); } } return resultArr; }; /** * Returns the entire binary tree ordered. * @param {String} type * @param resultArr * @returns {*|Array} */ 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 'data': resultArr.push(this._data); break; default: resultArr.push({ key: this._data, arr: this._store }); break; } if (this._right) { this._right.inOrder(type, resultArr); } return resultArr; }; /** * Searches the binary tree for all matching documents based on the regular * expression passed. * @param path * @param val * @param regex * @param {Array=} resultArr The results passed between recursive calls. * Do not pass anything into this argument when calling externally. * @returns {*|Array} */ BinaryTree.prototype.startsWith = function (path, val, regex, resultArr) { var reTest, thisDataPathVal = sharedPathSolver.get(this._data, path), thisDataPathValSubStr = thisDataPathVal.substr(0, val.length), result; //regex = regex || new RegExp('^' + val); resultArr = resultArr || []; if (resultArr._visitedCount === undefined) { resultArr._visitedCount = 0; } resultArr._visitedCount++; resultArr._visitedNodes = resultArr._visitedNodes || []; resultArr._visitedNodes.push(thisDataPathVal); result = this.sortAscIgnoreUndefined(thisDataPathValSubStr, val); reTest = thisDataPathValSubStr === val; if (result === 0) { if (this._left) { this._left.startsWith(path, val, regex, resultArr); } if (reTest) { resultArr.push(this._data); } if (this._right) { this._right.startsWith(path, val, regex, resultArr); } } if (result === -1) { if (reTest) { resultArr.push(this._data); } if (this._right) { this._right.startsWith(path, val, regex, resultArr); } } if (result === 1) { if (this._left) { this._left.startsWith(path, val, regex, resultArr); } if (reTest) { resultArr.push(this._data); } } return resultArr; }; /*BinaryTree.prototype.find = function (type, search, resultArr) { resultArr = resultArr || []; if (this._left) { this._left.find(type, search, resultArr); } // Check if this node's data is greater or less than the from value var fromResult = this.sortAsc(this._data[key], from), toResult = this.sortAsc(this._data[key], to); if ((fromResult === 0 || fromResult === 1) && (toResult === 0 || toResult === -1)) { // This data node is greater than or equal to the from value, // and less than or equal to the to value so include it switch (type) { case 'hash': resultArr.push(this._hash); break; case 'data': resultArr.push(this._data); break; default: resultArr.push({ key: this._data, arr: this._store }); break; } } if (this._right) { this._right.find(type, search, resultArr); } return resultArr; };*/ /** * * @param {String} type * @param {String} key The data key / path to range search against. * @param {Number} from Range search from this value (inclusive) * @param {Number} to Range search to this value (inclusive) * @param {Array=} resultArr Leave undefined when calling (internal use), * passes the result array between recursive calls to be returned when * the recursion chain completes. * @param {Path=} pathResolver Leave undefined when calling (internal use), * caches the path resolver instance for performance. * @returns {Array} Array of matching document objects */ BinaryTree.prototype.findRange = function (type, key, from, to, resultArr, pathResolver) { resultArr = resultArr || []; pathResolver = pathResolver || new Path(key); if (this._left) { this._left.findRange(type, key, from, to, resultArr, pathResolver); } // Check if this node's data is greater or less than the from value var pathVal = pathResolver.value(this._data), fromResult = this.sortAscIgnoreUndefined(pathVal, from), toResult = this.sortAscIgnoreUndefined(pathVal, to); if ((fromResult === 0 || fromResult === 1) && (toResult === 0 || toResult === -1)) { // This data node is greater than or equal to the from value, // and less than or equal to the to value so include it switch (type) { case 'hash': resultArr.push(this._hash); break; case 'data': resultArr.push(this._data); break; default: resultArr.push({ key: this._data, arr: this._store }); break; } } if (this._right) { this._right.findRange(type, key, from, to, resultArr, pathResolver); } return resultArr; }; /*BinaryTree.prototype.findRegExp = function (type, key, pattern, resultArr) { resultArr = resultArr || []; if (this._left) { this._left.findRegExp(type, key, pattern, resultArr); } // Check if this node's data is greater or less than the from value var fromResult = this.sortAsc(this._data[key], from), toResult = this.sortAsc(this._data[key], to); if ((fromResult === 0 || fromResult === 1) && (toResult === 0 || toResult === -1)) { // This data node is greater than or equal to the from value, // and less than or equal to the to value so include it switch (type) { case 'hash': resultArr.push(this._hash); break; case 'data': resultArr.push(this._data); break; default: resultArr.push({ key: this._data, arr: this._store }); break; } } if (this._right) { this._right.findRegExp(type, key, pattern, resultArr); } return resultArr; };*/ /** * Determines if the passed query and options object will be served * by this index successfully or not and gives a score so that the * DB search system can determine how useful this index is in comparison * to other indexes on the same collection. * @param query * @param queryOptions * @param matchOptions * @returns {{matchedKeys: Array, totalKeyCount: Number, score: number}} */ BinaryTree.prototype.match = function (query, queryOptions, matchOptions) { // Check if the passed query has data in the keys our index // operates on and if so, is the query sort matching our order var indexKeyArr, queryArr, matchedKeys = [], matchedKeyCount = 0, i; indexKeyArr = sharedPathSolver.parseArr(this._index, { verbose: true }); queryArr = sharedPathSolver.parseArr(query, matchOptions && matchOptions.pathOptions ? matchOptions.pathOptions : { ignore:/\$/, verbose: true }); // Loop the query array and check the order of keys against the // index key array to see if this index can be used for (i = 0; i < indexKeyArr.length; i++) { if (queryArr[i] === indexKeyArr[i]) { matchedKeyCount++; matchedKeys.push(queryArr[i]); } } return { matchedKeys: matchedKeys, totalKeyCount: queryArr.length, score: matchedKeyCount }; //return sharedPathSolver.countObjectPaths(this._keys, query); }; Shared.finishModule('BinaryTree'); module.exports = BinaryTree; },{"./Path":25,"./Shared":28}],3:[function(_dereq_,module,exports){ "use strict"; var Shared, Db, Metrics, KeyValueStore, Path, IndexHashMap, IndexBinaryTree, Index2d, Overload, ReactorIO, Condition, sharedPathSolver; Shared = _dereq_('./Shared'); /** * Creates a new collection. Collections store multiple documents and * handle CRUD against those documents. * @constructor * @class */ var Collection = function (name, options) { this.init.apply(this, arguments); }; /** * Creates a new collection. Collections store multiple documents and * handle CRUD against those documents. */ Collection.prototype.init = function (name, options) { // Ensure we have an options object options = options || {}; // Set internals this.sharedPathSolver = sharedPathSolver; this._primaryKey = options.primaryKey || '_id'; this._primaryIndex = new KeyValueStore('primary', {primaryKey: this.primaryKey()}); this._primaryCrc = new KeyValueStore('primaryCrc', {primaryKey: this.primaryKey()}); this._crcLookup = new KeyValueStore('crcLookup', {primaryKey: this.primaryKey()}); this._name = name; this._data = []; this._metrics = new Metrics(); this._options = options || { changeTimestamp: false }; if (this._options.db) { this.db(this._options.db); } // Create an object to store internal protected data this._metaData = {}; this._deferQueue = { insert: [], update: [], remove: [], upsert: [], async: [] }; this._deferThreshold = { insert: 100, update: 100, remove: 100, upsert: 100 }; this._deferTime = { insert: 1, update: 1, remove: 1, upsert: 1 }; this._deferredCalls = true; // 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'); Shared.mixin(Collection.prototype, 'Mixin.Tags'); Metrics = _dereq_('./Metrics'); KeyValueStore = _dereq_('./KeyValueStore'); Path = _dereq_('./Path'); IndexHashMap = _dereq_('./IndexHashMap'); IndexBinaryTree = _dereq_('./IndexBinaryTree'); Index2d = _dereq_('./Index2d'); Db = Shared.modules.Db; Overload = _dereq_('./Overload'); ReactorIO = _dereq_('./ReactorIO'); Condition = _dereq_('./Condition'); sharedPathSolver = new Path(); /** * Gets / sets the deferred calls flag. If set to true (default) * then operations on large data sets can be broken up and done * over multiple CPU cycles (creating an async state). For purely * synchronous behaviour set this to false. * @param {Boolean=} val The value to set. * @returns {Boolean} */ Shared.synthesize(Collection.prototype, 'deferredCalls'); /** * 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. * @param {Object=} val The data to set. * @returns {*} */ Shared.synthesize(Collection.prototype, 'metaData'); /** * Gets / sets boolean to determine if the collection should be * capped or not. * @param {Boolean=} val The value to set. * @returns {*} */ Shared.synthesize(Collection.prototype, 'capped'); /** * Gets / sets capped collection size. This is the maximum number * of records that the capped collection will store. * @param {Number=} val The value to set. * @returns {*} */ Shared.synthesize(Collection.prototype, 'cappedSize'); /** * Adds a job id to the async queue to signal to other parts * of the application that some async work is currently being * done. * @param {String} key The id of the async job. * @private */ Collection.prototype._asyncPending = function (key) { this._deferQueue.async.push(key); }; /** * Removes a job id from the async queue to signal to other * parts of the application that some async work has been * completed. If no further async jobs exist on the queue then * the "ready" event is emitted from this collection instance. * @param {String} key The id of the async job. * @private */ Collection.prototype._asyncComplete = function (key) { // Remove async flag for this type var index = this._deferQueue.async.indexOf(key); while (index > -1) { this._deferQueue.async.splice(index, 1); index = this._deferQueue.async.indexOf(key); } if (this._deferQueue.async.length === 0) { this.deferEmit('ready'); } }; /** * 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. * @param {Function=} callback A callback method to call once the * operation has completed. * @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._data; delete this._metrics; delete this._listeners; if (callback) { callback.call(this, false, true); } return true; } } else { if (callback) { callback.call(this, false, true); } return true; } if (callback) { callback.call(this, 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) { var oldKey = this._primaryKey; this._primaryKey = keyName; // Set the primary key index primary key this._primaryIndex.primaryKey(keyName); // Rebuild the primary key index this.rebuildPrimaryKeyIndex(); // Propagate change down the chain this.chainSend('primaryKey', { keyName: keyName, oldData: oldKey }); } 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 by updating the * lastChange timestamp on the collection's metaData. This * only happens if the changeTimestamp option is enabled * on the collection (it is disabled by default). * @private */ Collection.prototype._onChange = function () { if (this._options.changeTimestamp) { // Record the last change timestamp this._metaData.lastChange = this.serialiser.convert(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'); Collection.prototype.setData = new Overload('Collection.prototype.setData', { /** * 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 via the remove() method, and the remove event will * fire as well. * @name setData * @method Collection.setData * @param {Array|Object} data The array of documents or a single document * that will be set as the collections data. */ '*': function (data) { return this.$main.call(this, data, {}); }, /** * 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 via the remove() method, and the remove event will * fire as well. * @name setData * @method Collection.setData * @param {Array|Object} data The array of documents or a single document * that will be set as the collections data. * @param {Object} options Optional options object. */ '*, object': function (data, options) { return this.$main.call(this, data, options); }, /** * 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 via the remove() method, and the remove event will * fire as well. * @name setData * @method Collection.setData * @param {Array|Object} data The array of documents or a single document * that will be set as the collections data. * @param {Function} callback Optional callback function. */ '*, function': function (data, callback) { return this.$main.call(this, data, {}, callback); }, /** * 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 via the remove() method, and the remove event will * fire as well. * @name setData * @method Collection.setData * @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 {Function} callback Optional callback function. */ '*, *, function': function (data, options, callback) { return this.$main.call(this, data, options, callback); }, /** * 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 via the remove() method, and the remove event will * fire as well. * @name setData * @method Collection.setData * @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. */ '*, *, *': function (data, options, callback) { return this.$main.call(this, data, options, callback); }, /** * 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 via the remove() method, and the remove event will * fire as well. * @name setData * @method Collection.setData * @param {Array|Object} data The array of documents or a single document * that will be set as the collections data. * @param {Object} options Optional options object. * @param {Function} callback Optional callback function. */ '$main': function (data, options, callback) { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } if (data) { var deferredSetting = this.deferredCalls(), oldData = [].concat(this._data); // Switch off deferred calls since setData should be // a synchronous call this.deferredCalls(false); options = this.options(options); if (options.$decouple) { data = this.decouple(data); } if (!(data instanceof Array)) { data = [data]; } // Remove all items from the collection this.remove({}); // Insert the new data this.insert(data); // Switch deferred calls back to previous settings this.deferredCalls(deferredSetting); this._onChange(); this.emit('setData', this._data, oldData); } if (callback) { callback.call(this); } 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 hash string jString = this.hash(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 () { var i; if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } // TODO: This should use remove so that chain reactor events are properly // TODO: handled, but ensure that chunking is switched off 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', {primaryKey: this.primaryKey()}); this._primaryCrc = new KeyValueStore('primaryCrc', {primaryKey: this.primaryKey()}); this._crcLookup = new KeyValueStore('crcLookup', {primaryKey: this.primaryKey()}); // Re-create any existing collection indexes // TODO: This might not be the most efficient way to do this, perhaps just re-creating // the indexes would be faster than calling rebuild? for (i in this._indexByName) { if (this._indexByName.hasOwnProperty(i)) { this._indexByName[i].rebuild(); } } this._onChange(); this.emit('immediateChange', {type: 'truncate'}); this.deferEmit('change', {type: 'truncate'}); return this; }; /** * Inserts a new document or updates an existing document in a * collection depending on if a matching primary key exists in * the collection already or not. * * 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 * document and the document is inserted. * * @param {Object} obj The document object to upsert or an array * containing documents to upsert. * @param {Function=} callback Optional callback method. * @returns {Array} An array containing an object for each operation * performed. Each object contains two keys, "op" contains either "none", * "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, returnData = {}, query, i; // Determine if the object passed is an array or not if (obj instanceof Array) { if (this._deferredCalls && obj.length > deferThreshold) { // Break up upsert into blocks this._deferQueue.upsert = queue.concat(obj); this._asyncPending('upsert'); // 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])[0]); } if (callback) { callback.call(this, returnData); } 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, callback); break; case 'update': returnData.result = this.update(query, obj, {}, callback); break; default: break; } if (callback) { callback.call(this, [returnData]); } return [returnData]; } else { if (callback) { callback.call(this, {op: 'none'}); } } 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 optional query object. * @param {Object=} options Optional options object. If you specify an options object * you MUST also specify a 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. If you * return a modified object from the one passed to it will be included in the results * as the modified version but will not affect the data in the collection at all. * Your function will be called with a single object as the first argument and will * be called once for every document in your initial query result. * @returns {Array} */ Collection.prototype.filter = function (query, options, func) { var temp; if (typeof query === 'function') { func = query; query = {}; options = {}; } if (typeof options === 'function') { if (func) { temp = func; } func = options; options = temp || {}; } 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. * @param {Function=} callback The callback method to call when * the update is complete. * @returns {Array} The items that were updated. */ Collection.prototype.update = function (query, update, options, callback) { if (this.isDropped()) { throw(this.logIdentifier() + ' Cannot operate in a dropped state!'); } // Convert queries from mongo dot notation to forerunner queries if (this.mongoEmulation()) { this.convertToFdb(query); this.convertToFdb(update); } else { // Decouple the update data update = this.decouple(update); } // Detect $replace operations and set flag if (update.$replace) { // Make sure we have an options object options = options || {}; // Set the $replace flag in the options object options.$replace = true; // Move the replacement object out into the main update object update = update.$replace; } // Handle transform update = this.transformIn(update); return this._handleUpdate(query, update, options, callback); }; /** * Handles the update operation that was initiated by a call to update(). * @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. * @param {Function=} callback The callback method to call when * the update is complete. * @returns {Array} The items that were updated. * @private */ Collection.prototype._handleUpdate = function (query, update, options, callback) { 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 self._removeFromIndexes(referencedDoc); result = self.updateObject(referencedDoc, newDoc, triggerOperation.query, triggerOperation.options, ''); self._insertIntoIndexes(referencedDoc); // 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 self._removeFromIndexes(referencedDoc); result = self.updateObject(referencedDoc, update, query, options, ''); self._insertIntoIndexes(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) { if (this.debug()) { console.log(this.logIdentifier() + ' Updated some data'); } op.time('Resolve chains'); if (this.chainWillSend()) { this.chainSend('update', { query: query, update: update, dataSet: this.decouple(updated) }, options); } op.time('Resolve chains'); this._onUpdate(updated); this._onChange(); if (callback) { callback.call(this, updated || []); } this.emit('immediateChange', {type: 'update', data: updated}); this.deferEmit('change', {type: 'update', data: updated}); } else { if (callback) { callback.call(this, updated || []); } } } else { if (callback) { callback.call(this, 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. It does this by removing existing keys * from the base object and then adding the passed object's keys to * the existing base object, thereby maintaining any references to * the existing base object but effectively replacing the object with * the new one. * @param {Object} currentObj The base 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 via it's id. * @param {String} id The id of the document. * @param {Object} update The object containing the key/values to * update to. * @param {Object=} options An options object. * @param {Function=} callback The callback method to call when * the update is complete. * @returns {Object} The document that was updated or undefined * if no document was updated. */ Collection.prototype.updateById = function (id, update, options, callback) { var searchObj = {}, wrappedCallback; searchObj[this._primaryKey] = id; if (callback) { wrappedCallback = function (data) { callback(data[0]); }; } return this.update(searchObj, update, options, wrappedCallback)[0]; }; /** * 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, tempKey, replaceObj, pk, pathInstance, sourceIsArray, updateIsArray, i; // Check if we have a $replace flag in the options object if (options && options.$replace === true) { operation = true; replaceObj = update; pk = this.primaryKey(); // Loop the existing item properties and compare with // the replacement (never remove primary key) for (tempKey in doc) { if (doc.hasOwnProperty(tempKey) && tempKey !== pk) { if (replaceObj[tempKey] === undefined) { // The new document doesn't have this field, remove it from the doc this._updateUnset(doc, tempKey); updated = true; } } } // Loop the new item props and update the doc for (tempKey in replaceObj) { if (replaceObj.hasOwnProperty(tempKey) && tempKey !== pk) { this._updateOverwrite(doc, tempKey, replaceObj[tempKey]); updated = true; } } // Early exit return updated; } // DEVS PLEASE NOTE -- Early exit could have occurred above and code below will never be reached - Rob Evans - CEO - 05/08/2016 // 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 (!operation && 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; case '$replace': operation = true; replaceObj = update.$replace; pk = this.primaryKey(); // Loop the existing item properties and compare with // the replacement (never remove primary key)