lokijs
Version:
Fast document oriented javascript in-memory database
1,559 lines (1,323 loc) • 89.7 kB
JavaScript
/**
* LokiJS
* @author Joe Minichino <joe.minichino@gmail.com>
*
* A lightweight document oriented javascript database
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else if (typeof exports === 'object') {
// CommonJS
module.exports = factory();
} else {
// Browser globals
root.loki = factory();
}
}(this, function () {
return (function () {
'use strict';
var Utils = {
copyProperties: function (src, dest) {
var prop;
for (prop in src) {
dest[prop] = src[prop];
}
}
};
var LokiOps = {
// comparison operators
$eq: function (a, b) {
return a === b;
},
$gt: function (a, b) {
return a > b;
},
$gte: function (a, b) {
return a >= b;
},
$lt: function (a, b) {
return a < b;
},
$lte: function (a, b) {
return a <= b;
},
$ne: function (a, b) {
return a !== b;
},
$regex: function (a, b) {
return b.test(a);
},
$in: function (a, b) {
return b.indexOf(a) > -1;
},
$contains: function (a, b) {
if (Array.isArray(a)) {
return a.indexOf(b) !== -1;
}
if (typeof a === 'string') {
return a.indexOf(b) !== -1;
}
if (a && typeof a === 'object') {
return a.hasOwnProperty(b);
}
}
};
var fs = (typeof exports === 'object') ? require('fs') : false;
function clone(data, method) {
var cloneMethod = method || 'parse-stringify',
cloned;
if (cloneMethod === 'parse-stringify') {
cloned = JSON.parse(JSON.stringify(data));
}
return cloned;
}
function localStorageAvailable() {
try {
return ('localStorage' in window && window['localStorage'] !== null);
} catch (e) {
return false;
}
}
/**
* LokiEventEmitter is a minimalist version of EventEmitter. It enables any
* constructor that inherits EventEmitter to emit events and trigger
* listeners that have been added to the event through the on(event, callback) method
*
* @constructor
*/
function LokiEventEmitter() {}
/**
* @prop Events property is a hashmap, with each property being an array of callbacks
*/
LokiEventEmitter.prototype.events = {};
/**
* @prop asyncListeners - boolean determines whether or not the callbacks associated with each event
* should happen in an async fashion or not
* Default is false, which means events are synchronous
*/
LokiEventEmitter.prototype.asyncListeners = false;
/**
* @prop on(eventName, listener) - adds a listener to the queue of callbacks associated to an event
* @returns {int} the index of the callback in the array of listeners for a particular event
*/
LokiEventEmitter.prototype.on = function (eventName, listener) {
var event = this.events[eventName];
if (!event) {
event = this.events[eventName] = [];
}
return event.push(listener) - 1;
};
function applyListener(listener, args) {
listener.apply(null, args);
}
/**
* @propt emit(eventName, varargs) - emits a particular event
* with the option of passing optional parameters which are going to be processed by the callback
* provided signatures match (i.e. if passing emit(event, arg0, arg1) the listener should take two parameters)
* @param {string} eventName - the name of the event
* @param {object} varargs - optional objects passed with the event
*/
LokiEventEmitter.prototype.emit = function (eventName) {
var args = Array.prototype.slice.call(arguments, 0),
self = this;
if (eventName && this.events[eventName]) {
args.splice(0, 1);
this.events[eventName].forEach(function (listener) {
if (self.asyncListeners) {
setTimeout(function () {
applyListener(listener, args);
}, 1);
} else {
applyListener(listener, args);
}
});
} else {
throw new Error('No event ' + eventName + ' defined');
}
};
/**
* @prop remove() - removes the listener at position 'index' from the event 'eventName'
*/
LokiEventEmitter.prototype.removeListener = function (eventName, index) {
if (this.events[eventName]) {
this.events[eventName].splice(index, 1);
}
};
/**
* Loki: The main database class
* @constructor
* @param {string} filename - name of the file to be saved to
* @param {object} options - config object
*/
function Loki(filename, options) {
this.filename = filename || 'loki.db';
this.collections = [];
// persist version of code which created the database to the database.
// could use for upgrade scenarios
this.databaseVersion = 1.1;
this.engineVersion = 1.1;
// autosave support (disabled by default)
// pass autosave: true, autosaveInterval: 6000 in options to set 6 second autosave
this.autosave = false;
this.autosaveInterval = 5000;
this.autosaveHandle = null;
this.options = {};
// currently keeping persistenceMethod and persistenceAdapter as loki level properties that
// will not or cannot be deserialized. You are required to configure persistence every time
// you instantiate a loki object (or use default environment detection) in order to load the database anyways.
// persistenceMethod could be 'fs', 'localStorage', or 'adapter'
// this is optional option param, otherwise environment detection will be used
// if user passes their own adapter we will force this method to 'adapter' later, so no need to pass method option.
this.persistenceMethod = null;
// retain reference to optional (non-serializable) persistenceAdapter 'instance'
this.persistenceAdapter = null;
if (typeof (options) !== 'undefined') {
this.configureOptions(options, true);
}
this.events = {
'init': [],
'flushChanges': [],
'close': [],
'changes': [],
'warning': []
};
var self = this;
var getENV = function () {
if (typeof window === 'undefined') {
return 'NODEJS';
}
if (module) {
if (typeof module !== 'undefined' && module.exports) {
return 'NODEJS';
}
}
if (!(document === undefined)) {
if (document.URL.indexOf('http://') === -1 && document.URL.indexOf('https://') === -1) {
return 'CORDOVA';
}
return 'BROWSER';
}
return 'CORDOVA';
};
this.ENV = getENV();
if (this.ENV === 'NODEJS') {
this.fs = fs;
}
this.on('init', this.clearChanges);
}
// db class is an EventEmitter
Loki.prototype = new LokiEventEmitter;
/**
* configureOptions - allows reconfiguring database options
*
* @param {object} options - configuration options to apply to loki db object
* @param {boolean} initialConfig - (optional) if this is a reconfig, don't pass this
*/
Loki.prototype.configureOptions = function (options, initialConfig) {
this.options = {};
if (typeof (options) !== 'undefined') {
this.options = options;
}
this.persistenceMethod = null;
if (this.options.hasOwnProperty('persistenceMethod')) {
this.persistenceMethod = options.persistenceMethod;
}
// retain reference to optional persistence adapter 'instance'
// currently keeping outside options because it can't be serialized
this.persistenceAdapter = null;
// if user passes adapter, set persistence mode to adapter and retain persistence adapter instance
if (this.options.hasOwnProperty('adapter')) {
this.persistenceMethod = 'adapter';
this.persistenceAdapter = options.adapter;
}
// if they want to load database on loki instantiation, now is a good time to load... after adapter set and before possible autosave initiation
if (options.hasOwnProperty('autoload') && typeof (initialConfig) !== 'undefined' && initialConfig) {
this.loadDatabase({}, options.autoloadCallback);
}
if (this.options.hasOwnProperty('autosaveInterval')) {
this.autosaveDisable();
this.autosaveInterval = parseInt(this.options.autosaveInterval);
}
if (this.options.hasOwnProperty('autosave') && this.options.autosave) {
this.autosaveDisable();
this.autosave = true;
this.autosaveEnable();
}
};
/**
* anonym() - shorthand method for quickly creating and populating an anonymous collection.
* This collection is not referenced internally so upon losing scope it will be garbage collected.
*
* Example : var results = new loki().anonym(myDocArray).find({'age': {'$gt': 30} });
*
* @param {Array} docs - document array to initialize the anonymous collection with
* @param {Array} indexesArray - (Optional) array of property names to index
* @returns {Collection} New collection which you can query or chain
*/
Loki.prototype.anonym = function (docs, indexesArray) {
var collection = new Collection('anonym', indexesArray);
collection.insert(docs);
return collection;
}
Loki.prototype.addCollection = function (name, options) {
var collection = new Collection(name, options);
this.collections.push(collection);
return collection;
};
Loki.prototype.loadCollection = function (collection) {
if (!collection.name) {
throw new Error('Collection must be have a name property to be loaded');
}
this.collections.push(collection);
};
Loki.prototype.getCollection = function (collectionName) {
var i,
len = this.collections.length;
for (i = 0; i < len; i += 1) {
if (this.collections[i].name === collectionName) {
return this.collections[i];
}
}
// no such collection
this.emit('warning', 'collection ' + collectionName + ' not found');
return null;
};
Loki.prototype.listCollections = function () {
var i = this.collections.length,
colls = [];
while (i--) {
colls.push({
name: this.collections[i].name,
type: this.collections[i].objType,
count: this.collections[i].data.length
});
}
return colls;
};
Loki.prototype.removeCollection = function (collectionName) {
var i,
len = this.collections.length;
for (i = 0; i < len; i += 1) {
if (this.collections[i].name === collectionName) {
this.collections.splice(i, 1);
return;
}
}
throw 'No such collection';
};
Loki.prototype.getName = function () {
return this.name;
};
// toJson
Loki.prototype.serialize = function () {
return JSON.stringify(this);
};
// alias of serialize
Loki.prototype.toJson = Loki.prototype.serialize;
/**
* loadJSON - inflates a loki database from a serialized JSON string
*
* @param {string} serializedDb - a serialized loki database string
* @param {object} options - apply or override collection level settings
*/
Loki.prototype.loadJSON = function (serializedDb, options) {
var obj = JSON.parse(serializedDb),
i = 0,
len = obj.collections.length,
coll,
copyColl,
clen,
j,
upgradeNeeded = false;
this.name = obj.name;
// restore database version
this.databaseVersion = 1.0;
if (obj.hasOwnProperty('databaseVersion')) {
this.databaseVersion = obj.databaseVersion;
}
if (this.databaseVersion !== this.engineVersion) {
upgradeNeeded = true;
}
this.collections = [];
for (i; i < len; i += 1) {
coll = obj.collections[i];
copyColl = this.addCollection(coll.name);
// load each element individually
clen = coll.data.length;
j = 0;
if (options && options.hasOwnProperty(coll.name)) {
var loader = options[coll.name]['inflate'] ? options[coll.name]['inflate'] : Utils.copyProperties;
for (j; j < clen; j++) {
var obj = new(options[coll.name]['proto'])();
loader(coll.data[j], obj);
copyColl.data[j] = obj;
}
} else {
for (j; j < clen; j++) {
copyColl.data[j] = coll.data[j];
}
}
// rough object upgrade, once file format stabilizes we will probably remove this
if (upgradeNeeded && this.engineVersion == 1.1) {
// we are upgrading a 1.0 database to 1.1, so initialize new properties
copyColl.transactional = false;
copyColl.cloneObjects = false;
copyColl.asyncListeners = true;
copyColl.disableChangesApi = true;
console.warn("upgrading database, loki id is now called '$loki' instead of 'id'");
// for current collection, if there is at least one document see if its missing $loki key
if (copyColl.data.length > 0) {
if (!copyColl.data[0].hasOwnProperty('$loki')) {
var dlen = copyColl.data.length;
var currDoc = null;
// for each document, set $loki to old 'id' column
// if it has 'originalId' column, move to 'id'
for (var idx = 0; idx < dlen; idx++) {
currDoc = copyColl.data[idx];
currDoc['$loki'] = currDoc['id'];
delete currDoc.id;
if (currDoc.hasOwnProperty['originalId']) {
currDoc['id'] = currDoc['originalId'];
}
}
}
}
} else {
// not an upgrade or upgrade after 1.1, so copy new collection level options
copyColl.transactional = coll.transactional;
copyColl.asyncListeners = coll.asyncListeners;
copyColl.disableChangesApi = coll.disableChangesApi;
copyColl.cloneObjects = coll.cloneObjects;
}
copyColl.maxId = (coll.data.length === 0) ? 0 : coll.maxId;
copyColl.idIndex = coll.idIndex;
// if saved in previous format recover id index out of it
if (typeof (coll.indices) !== 'undefined') {
copyColl.idIndex = coll.indices.id;
}
if (typeof (coll.binaryIndices) !== 'undefined') {
copyColl.binaryIndices = coll.binaryIndices;
}
copyColl.ensureId();
// in case they are loading a database created before we added dynamic views, handle undefined
if (typeof (coll.DynamicViews) === 'undefined') continue;
// reinflate DynamicViews and attached Resultsets
for (var idx = 0; idx < coll.DynamicViews.length; idx++) {
var colldv = coll.DynamicViews[idx];
var dv = copyColl.addDynamicView(colldv.name, colldv.persistent);
dv.resultdata = colldv.resultdata;
dv.resultsdirty = colldv.resultsdirty;
dv.filterPipeline = colldv.filterPipeline;
// now that we support multisort, if upgrading from 1.0 database, convert single criteria to array of 1 criteria
if (upgradeNeeded && typeof (colldv.sortColumn) !== 'undefined' && colldv.sortColumn != null) {
var isdesc = false;
if (typeof (colldv.sortColumnDesc) !== 'undefined') {
isdesc = colldv.sortColumnDesc;
}
dv.sortCriteria = [colldv.sortColumn, isdesc];
} else {
dv.sortCriteria = colldv.sortCriteria;
}
dv.sortFunction = null;
dv.sortDirty = colldv.sortDirty;
dv.resultset.filteredrows = colldv.resultset.filteredrows;
dv.resultset.searchIsChained = colldv.resultset.searchIsChained;
dv.resultset.filterInitialized = colldv.resultset.filterInitialized;
dv.rematerialize({
removeWhereFilters: true
});
}
}
};
/**
* close(callback) - emits the close event with an optional callback. Does not actually destroy the db
* but useful from an API perspective
*/
Loki.prototype.close = function (callback) {
// for autosave scenarios, we will let close perform final save (if dirty)
// For web use, you might call from window.onbeforeunload to shutdown database, saving pending changes
if (this.autosave) {
this.autosaveDisable();
if (this.autosaveDirty()) {
this.saveDatabase();
}
}
if (callback) {
this.on('close', callback);
}
this.emit('close');
};
/**-------------------------+
| Changes API |
+--------------------------*/
/**
* The Changes API enables the tracking the changes occurred in the collections since the beginning of the session,
* so it's possible to create a differential dataset for synchronization purposes (possibly to a remote db)
*/
/**
* generateChangesNotification() - takes all the changes stored in each
* collection and creates a single array for the entire database. If an array of names
* of collections is passed then only the included collections will be tracked.
*
* @param {array} optional array of collection names. No arg means all collections are processed.
* @returns {array} array of changes
* @see private method createChange() in Collection
*/
Loki.prototype.generateChangesNotification = function (arrayOfCollectionNames) {
function getCollName(coll) {
return coll.name;
}
var changes = [],
selectedCollections = arrayOfCollectionNames || this.collections.map(getCollName);
this.collections.forEach(function (coll) {
if (selectedCollections.indexOf(getCollName(coll)) !== -1) {
changes = changes.concat(coll.getChanges());
}
});
return changes;
};
/**
* serializeChanges() - stringify changes for network transmission
* @returns {string} string representation of the changes
*/
Loki.prototype.serializeChanges = function (collectionNamesArray) {
return JSON.stringify(this.generateChangesNotification(collectionNamesArray));
};
/**
* clearChanges() - clears all the changes in all collections.
*/
Loki.prototype.clearChanges = function () {
this.collections.forEach(function (coll) {
if (coll.flushChanges) {
coll.flushChanges();
}
})
};
/*------------------+
| PERSISTENCE |
-------------------*/
/**
* loadDatabase - Handles loading from file system, local storage, or adapter (indexeddb)
* This method utilizes loki configuration options (if provided) to determine which
* persistence method to use, or environment detection (if configuration was not provided).
*
* @param {object} options - not currently used (remove or allow overrides?)
* @param {function} callback - (Optional) user supplied async callback / error handler
*/
Loki.prototype.loadDatabase = function (options, callback) {
var cFun = callback || function (err, data) {
if (err) {
throw err;
}
return;
},
self = this;
// If user has specified a persistenceMethod, use it
if (this.persistenceMethod != null) {
if (this.persistenceMethod === 'fs') {
this.fs.readFile(this.filename, {
encoding: 'utf8'
}, function readFileCallback(err, data) {
if (err) {
return cFun(err, null);
}
self.loadJSON(data, options || {});
cFun(null, data);
});
}
if (this.persistenceMethod === 'localStorage') {
if (localStorageAvailable()) {
self.loadJSON(localStorage.getItem(this.filename));
cFun(null, data);
} else {
cFun(new Error('localStorage is not available'));
}
}
if (this.persistenceMethod === 'adapter') {
// test if user has given us an adapter reference (in loki constructor options)
if (this.persistenceAdapter !== null) {
this.persistenceAdapter.loadDatabase(this.filename, function loadDatabaseCallback(dbString) {
if (typeof (dbString) === 'undefined' || dbString === null) {
console.warn('lokijs loadDatabase : Database not found');
cFun('Database not found');
} else {
self.loadJSON(dbString);
cFun(null);
}
});
} else {
cFun(new Error('persistenceAdapter not configured'));
}
}
return;
};
// user did not provide persistenceMethod, default to environment detection
if (this.ENV === 'NODEJS') {
this.fs.readFile(this.filename, {
encoding: 'utf8'
}, function readFileCallback(err, data) {
if (err) {
return cFun(err, null);
}
self.loadJSON(data, options || {});
cFun(null, data);
});
} else if (this.ENV === 'BROWSER') {
if (localStorageAvailable()) {
self.loadJSON(localStorage.getItem(this.filename));
cFun(null, data);
} else {
cFun(new Error('localStorage is not available'));
}
} else {
//self.emit('warning', 'unknown environment');
self.ENV = 'NODEJS';
self.fs = require('fs');
self.loadDatabase(options, callback);
}
};
/**
* saveDatabase - Handles saving to file system, local storage, or adapter (indexeddb)
* This method utilizes loki configuration options (if provided) to determine which
* persistence method to use, or environment detection (if configuration was not provided).
*
* @param {object} options - not currently used (remove or allow overrides?)
* @param {function} callback - (Optional) user supplied async callback / error handler
*/
Loki.prototype.saveDatabase = function (callback) {
var cFun = callback || function (err) {
if (err) {
throw err;
}
return;
},
self = this;
// for now assume whichever method below succeeds and reset dirty flags
// in future we may move this into each if block if no exceptions occur.
this.autosaveClearFlags();
// If user has specified a persistenceMethod, use it
if (this.persistenceMethod != null) {
if (this.persistenceMethod === 'fs') {
self.fs.writeFile(self.filename, self.serialize(), cFun);
}
if (this.persistenceMethod === 'localStorage') {
if (localStorageAvailable()) {
localStorage.setItem(self.filename, self.serialize());
cFun(null);
} else {
cFun(new Error('localStorage is not available'));
}
}
if (this.persistenceMethod === 'adapter') {
// test if loki persistence adapter instance was provided in loki constructor options
if (this.persistenceAdapter !== null) {
this.persistenceAdapter.saveDatabase(this.filename, self.serialize(), function saveDatabasecallback() {
cFun(null);
});
} else {
cFun(new Error('persistenceAdapter not configured'));
}
}
return;
};
// persist in nodejs
if (this.ENV === 'NODEJS') {
self.fs.writeFile(self.filename, self.serialize(), cFun);
} else if (this.ENV === 'BROWSER' || this.ENV === 'CORDOVA') {
if (localStorageAvailable()) {
localStorage.setItem(self.filename, self.serialize());
cFun(null);
} else {
cFun(new Error('localStorage is not available'));
}
} else {
cFun(new Error('unknown environment'));
}
};
// alias
Loki.prototype.save = Loki.prototype.saveDatabase;
/**
* autosaveDirty - check whether any collections are 'dirty' meaning we need to save (entire) database
*
* @returns {boolean} - true if database has changed since last autosave, false if not.
*/
Loki.prototype.autosaveDirty = function () {
for (var idx = 0; idx < this.collections.length; idx++) {
if (this.collections[idx].dirty) {
return true;
}
}
return false;
};
/**
* autosaveClearFlags - resets dirty flags on all collections.
* Called from saveDatabase() after db is saved.
*
*/
Loki.prototype.autosaveClearFlags = function () {
for (var idx = 0; idx < this.collections.length; idx++) {
this.collections[idx].dirty = false;
}
};
/**
* autosaveEnable - begin a javascript interval to periodically save the database.
*
*/
Loki.prototype.autosaveEnable = function () {
this.autosave = true;
var delay = 5000,
self = this;
if (typeof (this.autosaveInterval) !== 'undefined' && this.autosaveInterval !== null) {
delay = this.autosaveInterval;
}
this.autosaveHandle = setInterval(function autosaveHandleInterval() {
// use of dirty flag will need to be hierarchical since mods are done at collection level with no visibility of 'db'
// so next step will be to implement collection level dirty flags set on insert/update/remove
// along with loki level isdirty() function which iterates all collections to see if any are dirty
if (self.autosaveDirty()) {
self.saveDatabase();
}
}, delay);
};
/**
* autosaveDisable - stop the autosave interval timer.
*
*/
Loki.prototype.autosaveDisable = function () {
if (typeof (this.autosaveHandle) !== 'undefined' && this.autosaveHandle !== null) {
clearInterval(this.autosaveHandle);
this.autosaveHandle = null;
}
};
/**
* Resultset class allowing chainable queries. Intended to be instanced internally.
* Collection.find(), Collection.where(), and Collection.chain() instantiate this.
*
* Example:
* mycollection.chain()
* .find({ 'doors' : 4 })
* .where(function(obj) { return obj.name === 'Toyota' })
* .data();
*
* @constructor
* @param {Collection} collection - The collection which this Resultset will query against.
* @param {string} queryObj - Optional mongo-style query object to initialize resultset with.
* @param {function} queryFunc - Optional javascript filter function to initialize resultset with.
* @param {bool} firstOnly - Optional boolean used by collection.findOne().
*/
function Resultset(collection, queryObj, queryFunc, firstOnly) {
// retain reference to collection we are querying against
this.collection = collection;
// if chain() instantiates with null queryObj and queryFunc, so we will keep flag for later
this.searchIsChained = (!queryObj && !queryFunc);
this.filteredrows = [];
this.filterInitialized = false;
// if user supplied initial queryObj or queryFunc, apply it
if (typeof (queryObj) !== "undefined" && queryObj !== null) {
return this.find(queryObj, firstOnly);
}
if (typeof (queryFunc) !== "undefined" && queryFunc !== null) {
return this.where(queryFunc);
}
// otherwise return unfiltered Resultset for future filtering
return this;
}
/**
* toJSON() - Override of toJSON to avoid circular references
*
*/
Resultset.prototype.toJSON = function () {
var copy = this.copy();
copy.collection = null;
return copy;
};
/**
* limit() - Allows you to limit the number of documents passed to next chain operation.
* A resultset copy() is made to avoid altering original resultset.
*
* @param {int} qty - The number of documents to return.
* @returns {Resultset} Returns a copy of the resultset, limited by qty, for subsequent chain ops.
*/
Resultset.prototype.limit = function (qty) {
// if this is chained resultset with no filters applied, we need to populate filteredrows first
if (this.searchIsChained && !this.filterInitialized && this.filteredrows.length === 0) {
this.filteredrows = Object.keys(this.collection.data);
}
var rscopy = this.copy();
rscopy.filteredrows = rscopy.filteredrows.slice(0, qty);
return rscopy;
};
/**
* offset() - Used for skipping 'pos' number of documents in the resultset.
*
* @param {int} pos - Number of documents to skip; all preceding documents are filtered out.
* @returns {Resultset} Returns a copy of the resultset, containing docs starting at 'pos' for subsequent chain ops.
*/
Resultset.prototype.offset = function (pos) {
// if this is chained resultset with no filters applied, we need to populate filteredrows first
if (this.searchIsChained && !this.filterInitialized && this.filteredrows.length === 0) {
this.filteredrows = Object.keys(this.collection.data);
}
var rscopy = this.copy();
rscopy.filteredrows = rscopy.filteredrows.splice(pos, rscopy.filteredrows.length);
return rscopy;
};
/**
* copy() - To support reuse of resultset in branched query situations.
*
* @returns {Resultset} Returns a copy of the resultset (set) but the underlying document references will be the same.
*/
Resultset.prototype.copy = function () {
var result = new Resultset(this.collection, null, null);
result.filteredrows = this.filteredrows.slice();
result.filterInitialized = this.filterInitialized;
return result;
};
// add branch() as alias of copy()
Resultset.prototype.branch = Resultset.prototype.copy;
/**
* sort() - User supplied compare function is provided two documents to compare. (chainable)
* Example:
* rslt.sort(function(obj1, obj2) {
* if (obj1.name === obj2.name) return 0;
* if (obj1.name > obj2.name) return 1;
* if (obj1.name < obj2.name) return -1;
* });
*
* @param {function} comparefun - A javascript compare function used for sorting.
* @returns {Resultset} Reference to this resultset, sorted, for future chain operations.
*/
Resultset.prototype.sort = function (comparefun) {
// if this is chained resultset with no filters applied, just we need to populate filteredrows first
if (this.searchIsChained && !this.filterInitialized && this.filteredrows.length === 0) {
this.filteredrows = Object.keys(this.collection.data);
}
var wrappedComparer =
(function (userComparer, rslt) {
return function (a, b) {
var obj1 = rslt.collection.data[a];
var obj2 = rslt.collection.data[b];
return userComparer(obj1, obj2);
}
})(comparefun, this);
this.filteredrows.sort(wrappedComparer);
return this;
};
/**
* simplesort() - Simpler, loose evaluation for user to sort based on a property name. (chainable)
*
* @param {string} propname - name of property to sort by.
* @param {bool} isdesc - (Optional) If true, the property will be sorted in descending order
* @returns {Resultset} Reference to this resultset, sorted, for future chain operations.
*/
Resultset.prototype.simplesort = function (propname, isdesc) {
// if this is chained resultset with no filters applied, just we need to populate filteredrows first
if (this.searchIsChained && !this.filterInitialized && this.filteredrows.length === 0) {
this.filteredrows = Object.keys(this.collection.data);
}
if (typeof (isdesc) === 'undefined') {
isdesc = false;
}
var wrappedComparer =
(function (prop, desc, rslt) {
return function (a, b) {
var obj1 = rslt.collection.data[a];
var obj2 = rslt.collection.data[b];
if (obj1[prop] === obj2[prop]) {
return 0;
}
if (desc) {
if (obj1[prop] < obj2[prop]) {
return 1;
}
if (obj1[prop] > obj2[prop]) {
return -1;
}
} else {
if (obj1[prop] > obj2[prop]) {
return 1;
}
if (obj1[prop] < obj2[prop]) {
return -1;
}
}
}
})(propname, isdesc, this);
this.filteredrows.sort(wrappedComparer);
return this;
};
/**
* compoundeval() - helper method for compoundsort(), performing individual object comparisons
*
* @param {array} properties - array of property names, in order, by which to evaluate sort order
* @param {object} obj1 - first object to compare
* @param {object} obj2 - second object to compare
* @returns {integer} 0, -1, or 1 to designate if identical (sortwise) or which should be first
*/
Resultset.prototype.compoundeval = function (properties, obj1, obj2) {
var propertyCount = properties.length;
if (propertyCount === 0) {
throw new Error("Invalid call to compoundeval, need at least one property");
}
// decode property, whether just a string property name or subarray [propname, isdesc]
var isdesc = false;
var firstProp = properties[0];
if (typeof (firstProp) !== 'string') {
if (Array.isArray(firstProp)) {
isdesc = firstProp[1];
firstProp = firstProp[0];
}
}
if (obj1[firstProp] === obj2[firstProp]) {
if (propertyCount === 1) {
return 0;
} else {
return this.compoundeval(properties.slice(1), obj1, obj2, isdesc);
}
}
if (isdesc) {
return (obj1[firstProp] < obj2[firstProp]) ? 1 : -1;
} else {
return (obj1[firstProp] > obj2[firstProp]) ? 1 : -1;
}
};
/**
* compoundsort() - Allows sorting a resultset based on multiple columns.
* Example : rs.compoundsort(['age', 'name']); to sort by age and then name (both ascending)
* Example : rs.compoundsort(['age', ['name', true]); to sort by age (ascending) and then by name (descending)
*
* @param {array} properties - array of property names or subarray of [propertyname, isdesc] used evaluate sort order
* @returns {Resultset} Reference to this resultset, sorted, for future chain operations.
*/
Resultset.prototype.compoundsort = function (properties) {
// if this is chained resultset with no filters applied, just we need to populate filteredrows first
if (this.searchIsChained && !this.filterInitialized && this.filteredrows.length === 0) {
this.filteredrows = Object.keys(this.collection.data);
}
var wrappedComparer =
(function (props, rslt) {
return function (a, b) {
var obj1 = rslt.collection.data[a];
var obj2 = rslt.collection.data[b];
return rslt.compoundeval(props, obj1, obj2);
}
})(properties, this);
this.filteredrows.sort(wrappedComparer);
return this;
};
/**
* calculateRange() - Binary Search utility method to find range/segment of values matching criteria.
* this is used for collection.find() and first find filter of resultset/dynview
* slightly different than get() binary search in that get() hones in on 1 value,
* but we have to hone in on many (range)
* @param {string} op - operation, such as $eq
* @param {string} prop - name of property to calculate range for
* @param {object} val - value to use for range calculation.
* @returns {array} [start, end] index array positions
*/
Resultset.prototype.calculateRange = function (op, prop, val) {
var rcd = this.collection.data;
var index = this.collection.binaryIndices[prop].values;
var min = 0;
var max = index.length - 1;
var mid = null;
var lbound = 0;
var ubound = index.length - 1;
// when no documents are in collection, return empty range condition
if (rcd.length == 0) {
return [0, -1];
}
var minVal = rcd[index[min]][prop];
var maxVal = rcd[index[max]][prop];
// if value falls outside of our range return [0, -1] to designate no results
switch (op) {
case '$eq':
if (val < minVal || val > maxVal) {
return [0, -1];
}
break;
case '$gt':
if (val >= maxVal) {
return [0, -1];
}
break;
case '$gte':
if (val > maxVal) {
return [0, -1];
}
break;
case '$lt':
if (val <= minVal) {
return [0, -1];
}
break;
case '$lte':
if (val < minVal) {
return [0, -1];
}
break;
}
// hone in on start position of value
while (min < max) {
mid = Math.floor((min + max) / 2);
if (rcd[index[mid]][prop] < val) {
min = mid + 1;
} else {
max = mid;
}
}
lbound = min;
min = 0;
max = index.length - 1;
// hone in on end position of value
while (min < max) {
mid = Math.floor((min + max) / 2);
if (val < rcd[index[mid]][prop]) {
max = mid;
} else {
min = mid + 1;
}
}
ubound = max;
var lval = rcd[index[lbound]][prop];
var uval = rcd[index[ubound]][prop];
switch (op) {
case '$eq':
if (lval !== val) {
return [0, -1];
}
if (uval !== val) {
ubound--;
}
return [lbound, ubound];
case '$gt':
if (uval <= val) {
return [0, -1];
}
return [ubound, rcd.length - 1];
case '$gte':
if (lval < val) {
return [0, -1];
}
return [lbound, rcd.length - 1];
case '$lt':
return [0, lbound - 1];
case '$lte':
if (uval !== val) {
ubound--;
}
return [0, ubound];
default:
return [0, rcd.length - 1];
}
};
/**
* find() - Used for querying via a mongo-style query object.
*
* @param {object} query - A mongo-style query object used for filtering current results.
* @param {boolean} firstOnly - (Optional) Used by collection.findOne()
* @returns {Resultset} this resultset for further chain ops.
*/
Resultset.prototype.find = function (query, firstOnly) {
if (this.collection.data.length === 0) {
if (this.searchIsChained) {
this.filteredrows = [];
this.filterInitialized = true;
return this;
}
return [];
}
var queryObject = query || 'getAll',
property,
value,
operator,
p,
key,
operators = {
'$eq': LokiOps.$eq,
'$gt': LokiOps.$gt,
'$gte': LokiOps.$gte,
'$lt': LokiOps.$lt,
'$lte': LokiOps.$lte,
'$ne': LokiOps.$ne,
'$regex': LokiOps.$regex,
'$in': LokiOps.$in,
'$contains': LokiOps.$contains
},
searchByIndex = false,
result = [],
index = null,
// comparison function
fun,
// collection data
t,
// collection data length
i,
len;
if (typeof (firstOnly) === 'undefined') {
firstOnly = false;
}
// apply no filters if they want all
if (queryObject === 'getAll') {
// chained queries can just do coll.chain().data() but let's
// be versatile and allow this also coll.chain().find().data()
if (this.searchIsChained) {
this.filteredrows = Object.keys(this.collection.data);
return this;
}
// not chained, so return collection data array
else {
return this.collection.data;
}
}
// if user is deep querying the object such as find('name.first': 'odin')
var usingDotNotation = false;
for (p in queryObject) {
if (queryObject.hasOwnProperty(p)) {
property = p;
if (p.indexOf('.') != -1) {
usingDotNotation = true;
}
if (typeof queryObject[p] !== 'object') {
operator = '$eq';
value = queryObject[p];
} else if (typeof queryObject[p] === 'object') {
for (key in queryObject[p]) {
if (queryObject[p].hasOwnProperty(key)) {
operator = key;
value = queryObject[p][key];
}
}
} else {
throw 'Do not know what you want to do.';
}
break;
}
}
// for regex ops, precompile
if (operator === '$regex') value = RegExp(value);
if (this.collection.data === null) {
throw new TypeError();
}
// if an index exists for the property being queried against, use it
// for now only enabling for non-chained query (who's set of docs matches index)
// or chained queries where it is the first filter applied and prop is indexed
if ((!this.searchIsChained || (this.searchIsChained && !this.filterInitialized)) &&
operator !== '$ne' && operator !== '$regex' && operator !== '$contains' && operator !== '$in' && this.collection.binaryIndices.hasOwnProperty(property)) {
// this is where our lazy index rebuilding will take place
// basically we will leave all indexes dirty until we need them
// so here we will rebuild only the index tied to this property
// ensureIndex() will only rebuild if flagged as dirty since we are not passing force=true param
this.collection.ensureIndex(property);
searchByIndex = true;
index = this.collection.binaryIndices[property];
}
// the comparison function
fun = operators[operator];
// Query executed differently depending on :
// - whether it is chained or not
// - whether the property being queried has an index defined
// - if chained, we handle first pass differently for initial filteredrows[] population
//
// For performance reasons, each case has its own if block to minimize in-loop calculations
// If not a chained query, bypass filteredrows and work directly against data
if (!this.searchIsChained) {
if (!searchByIndex) {
t = this.collection.data;
i = t.length;
if (firstOnly) {
while (i--) {
if (fun(t[i][property], value)) {
return (t[i]);
}
}
return [];
} else {
// if using dot notation then treat property as keypath such as 'name.first'.
// currently supporting dot notation for non-indexed conditions only
if (usingDotNotation) {
var root, paths;
while (i--) {
root = t[i];
paths = property.split('.');
paths.forEach(function (path) {
root = root[path];
});
if (fun(root, value)) {
result.push(t[i]);
}
}
} else {
while (i--) {
if (fun(t[i][property], value)) {
result.push(t[i]);
}
}
}
}
} else {
// searching by binary index via calculateRange() utility method
t = this.collection.data;
len = t.length;
var seg = this.calculateRange(operator, property, value, this);
// not chained so this 'find' was designated in Resultset constructor
// so return object itself
if (firstOnly) {
if (seg[1] !== -1) {
return this.data[seg[0]];
}
return [];
}
for (i = seg[0]; i <= seg[1]; i++) {
result.push(t[index.values[i]]);
}
this.filteredrows = result;
}
// not a chained query so return result as data[]
return result;
}
// Otherwise this is a chained query
else {
// If the filteredrows[] is already initialized, use it
if (this.filterInitialized) {
// not searching by index
if (!searchByIndex) {
t = this.collection.data;
i = this.filteredrows.length;
// currently supporting dot notation for non-indexed conditions only
if (usingDotNotation) {
var root, paths;
while (i--) {
root = t[this.filteredrows[i]];
paths = property.split('.');
paths.forEach(function (path) {
root = root[path];
});
if (fun(root, value)) {
result.push(this.filteredrows[i]);
}
}
} else {
while (i--) {
if (fun(t[this.filteredrows[i]][property], value)) {
result.push(this.filteredrows[i]);
}
}
}
} else {
// search by index
t = index;
i = this.filteredrows.length;
while (i--) {
if (fun(t[this.filteredrows[i]], value)) {
result.push(this.filteredrows[i]);
}
}
}
this.filteredrows = result;
return this;
}
// first chained query so work against data[] but put results in filteredrows
else {
// if not searching by index
if (!searchByIndex) {
t = this.collection.data;
i = t.length;
if (usingDotNotation) {
var root, paths;
while (i--) {
root = t[i];
paths = property.split('.');
paths.forEach(function (path) {
root = root[path];
});
if (fun(root, value)) {
result.push(i);
}
}
} else {
while (i--) {
if (fun(t[i][property], value)) {
result.push(i);
}
}
}
} else {
// search by index
t = this.collection.data;
var seg = this.calculateRange(operator, property, value, this);
for (var idx = seg[0]; idx <= seg[1]; idx++) {
result.push(index.values[idx]);
}
this.filteredrows = result;
}
this.filteredrows = result;
this.filterInitialized = true; // next time work against filteredrows[]
return this;
}
}
};
/**
* where() - Used for filtering via a javascript filter function.
*
* @param {function} fun - A javascript function used for filtering current results by.
* @returns {Resultset} this resultset for further chain ops.
*/
Resultset.prototype.where = function (fun) {
var viewFunction,
result = [];
if ('function' === typeof fun) {
viewFunction = fun;
} else {
throw 'Argument is not a stored view or a function';
}
try {
// if not a chained query then run directly against data[] and return object []
if (!this.searchIsChained) {
var i = this.collection.data.length;
while (i--) {
if (viewFunction(this.collection.data[i]) === true) {
result.push(this.collection.data[i]);
}
}
// not a chained query so returning result as data[]
return result;
}
// else chained query, so run against filteredrows
else {
// If the filteredrows[] is already initialized, use it
if (this.filterInitialized) {
var i = this.filteredrows.length;
while (i--) {
if (viewFunction(this.collection.data[this.filteredrows[i]]) === true) {
result.push(this.filteredrows[i]);
}
}
this.filteredrows = result;
return this;
}
// otherwise this is initial chained op, work against data, push into filteredrows[]
else {
var i = this.collection.data.length;
while (i--) {
if (viewFunction(this.collection.data[i]) === true) {
result.push(i);
}
}
this.filteredrows = result;
this.filterInitialized = true;
return this;
}
}
} catch (err) {
throw err;
}
};
/**
* data() - Terminates the chain and returns array of filtered documents
*
* @returns {array} Array of documents in the resultset
*/
Resultset.prototype.data = functio