forerunnerdb
Version:
A NoSQL document store database for browsers and Node.js.
1,811 lines (1,528 loc) • 312 kB
JavaScript
(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)