UNPKG

lokijs

Version:

Fast document oriented javascript in-memory database

1,559 lines (1,323 loc) 89.7 kB
/** * 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