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