Skip to content

Instantly share code, notes, and snippets.

@annoyingmouse
Last active December 3, 2015 01:16
Show Gist options
  • Save annoyingmouse/d2429cd02e32c3ee27ec to your computer and use it in GitHub Desktop.
Save annoyingmouse/d2429cd02e32c3ee27ec to your computer and use it in GitHub Desktop.
lokiFileSystemAdapter test
<!DOCTYPE html>
<html>
<head>
<script src="lokijs.js"></script>
<script src="lokiFileSystemAdapter.js"></script>
<script src="http://code.jquery.com/jquery-2.1.4.min.js"></script>
</head>
<body>
<table>
<thead>
<tr>
<th>Person</th>
<th>Age</th>
</tr>
</thead>
<tbody id="peopleTable">
</tbody>
</table>
<script>
var requestedBytes = 1025*1024*10 // 10MB
var fs = null;
var db = null;
(function(){
window.requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
navigator.webkitPersistentStorage.requestQuota (
requestedBytes,
function(grantedBytes) {
window.requestFileSystem(
PERSISTENT,
requestedBytes,
function(fs){
fs.root.getDirectory(
'test_directory',
{
create: true
},
function(dirEntry) {
var fsAdapter = FileSystemAdapter({
"base_dir": "test_directory",
"file_system": fs
});
db = new loki('db.json', {"adapter": fsAdapter});
var users = db.addCollection('users', {indices: ['email']});
var odin = users.insert({name: 'odin', email: 'odin.soap@lokijs.org', age: 38});
var thor = users.insert({name: 'thor', email: 'thor.soap@lokijs.org', age: 25});
var stan = users.insert({name: 'stan', email: 'stan.soap@lokijs.org', age: 29});
var oliver = users.insert({name: 'oliver', email: 'oliver.soap@lokijs.org', age: 31});
var hector = users.insert({name: 'hector', email: 'hector.soap@lokijs.org', age: 15});
var achilles = users.insert({name: 'achilles', email: 'achilles.soap@lokijs.org', age: 31});
$.each(users.chain().find().data(), function(k, v){
$("#peopleTable")
.append($("<tr></tr>")
.append($("<td></td>")
.append($("<a></a>",{
"href": "mailto:" + v.email,
"text": v.name})))
.append($("<td></td>",{
"text": v.age})))
});
console.log(db);
// error here:
db.saveDatabase()
},
function(e) {
console.error(e);
}
);
},
function(e) {
console.error(e);
}
);
},
function(e) {
console.error(e);
}
);
})();
</script>
</body>
</html>
/**
* Created by Dom on 19/05/2015.
*/
(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.FileSystemAdapter = factory();
}
}(this, function () {
return (function (options) {
'use strict';
function FileSystemAdapterError() {}
FileSystemAdapterError.prototype = new Error();
if (!options) {
throw new FileSystemAdapterError('No options configured in FileSystemAdapter');
}
if (!options.base_dir) {
throw new FileSystemAdapterError('No base directory specified in FileSystemAdapter');
}
if (!options.file_system) {
throw new FileSystemAdapterError('No file system specified in FileSystemAdapter');
}
/**
* this adapter assumes an object options is passed,
* containing the following properties:
* base_dir: location where the file is to be saved
* file_system: filesystem Object
*/
function FileSystemAdapter(options) {
this.options = options;
if (!this.checkAvailability()) {
console.error('requestFileSystem does not seem to be supported for your environment');
}
}
/**
* Checks to see if the File System is available
* @returns {boolean}
*/
FileSystemAdapter.prototype.checkAvailability = function(){
// Handle vendor prefixes.
window.requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
// Check for support.
if(window.requestFileSystem) {
return true;
} else {
return false;
}
};
/**
* Attempts to get the file, then write to it, throws errors on failure of each attempt
* @param name
* @param data
* @param callback
*/
FileSystemAdapter.prototype.saveDatabase = function (name, data, callback) {
this.options.file_system.root.getFile(
this.options.base_dir + '/' + name,
{
create: true
},
function(fileEntry) {
fileEntry.createWriter(
function(fileWriter) {
fileWriter.onwriteend = function() {
if (fileWriter.length === 0) {
var blob = new Blob(
[data.serialize()],
{
type: 'text/plain'
}
);
fileWriter.write(blob);
}
};
fileWriter.truncate(0);
},
function(err){
throw new FileSystemAdapterError("Unable to write file" + JSON.stringify(err));
}
);
},
function(err){
throw new FileSystemAdapterError("Unable to get file" + JSON.stringify(err));
}
);
};
/**
* Attempts to get the file and then read from it, throws errors on failure of each attempt
* @param name
* @param callback
*/
FileSystemAdapter.prototype.loadDatabase = function (name, callback) {
this.options.file_system.root.getFile(
this.options.base_dir + '/' + name,
{
create: false
},
function(fileEntry){
fileEntry.file(function(file) {
var reader = new FileReader();
reader.onloadend = function(event) {
var contents = event.target.result;
callback(contents);
};
reader.readAsText(file);
}, function(err){
throw new FileSystemAdapterError("Unable to read file" + JSON.stringify(err));
});
},
function(err){
throw new FileSystemAdapterError("Unable to get file" + JSON.stringify(err));
}
);
};
return FileSystemAdapter;
});
}));
/**
* 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];
}
}
};
// Sort helper that support null and undefined
function ltHelper(prop1, prop2, equal) {
if (prop1 === prop2) {
if (equal) {
return true;
} else {
return false;
}
}
if (prop1 === undefined) {
return true;
}
if (prop2 === undefined) {
return false;
}
if (prop1 === null) {
return true;
}
if (prop2 === null) {
return false;
}
return prop1 < prop2;
}
function gtHelper(prop1, prop2, equal) {
if (prop1 === prop2) {
if (equal) {
return true;
} else {
return false;
}
}
if (prop1 === undefined) {
return false;
}
if (prop2 === undefined) {
return true;
}
if (prop1 === null) {
return false;
}
if (prop2 === null) {
return true;
}
return prop1 > prop2;
}
function sortHelper(prop1, prop2, desc) {
if (prop1 === prop2) {
return 0;
}
if (desc) {
if (ltHelper(prop1, prop2)) {
return 1;
} else {
return -1;
}
} else {
if (gtHelper(prop1, prop2)) {
return 1;
} else {
return -1;
}
}
}
function containsCheckFn(a, b) {
if (Array.isArray(a)) {
return function (curr) {
return a.indexOf(curr) !== -1;
};
} else if (typeof a === 'string') {
return function (curr) {
return a.indexOf(curr) !== -1;
};
} else if (a && typeof a === 'object') {
return function (curr) {
return a.hasOwnProperty(curr);
};
}
}
var LokiOps = {
// comparison operators
$eq: function (a, b) {
return a === b;
},
$gt: function (a, b) {
return gtHelper(a, b);
},
$gte: function (a, b) {
return gtHelper(a, b, true);
},
$lt: function (a, b) {
return ltHelper(a, b);
},
$lte: function (a, b) {
return ltHelper(a, b, true);
},
$ne: function (a, b) {
return a !== b;
},
$regex: function (a, b) {
return b.test(a);
},
$in: function (a, b) {
return b.indexOf(a) > -1;
},
$containsAny: function (a, b) {
var checkFn;
if (!Array.isArray(b)) {
b = [b];
}
checkFn = containsCheckFn(a, b) || function () {
return false;
};
return b.reduce(function (prev, curr) {
if (prev) {
return prev;
}
return checkFn(curr);
}, false);
},
$contains: function (a, b) {
var checkFn;
if (!Array.isArray(b)) {
b = [b];
}
checkFn = containsCheckFn(a, b) || function () {
return true;
};
return b.reduce(function (prev, curr) {
if (!prev) {
return prev;
}
return checkFn(curr);
}, true);
}
};
var 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,
'$containsAny': LokiOps.$containsAny
};
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] = [];
}
event.push(listener);
return listener;
};
/**
* @propt emit(eventName, data) - 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} data - optional object passed with the event
*/
LokiEventEmitter.prototype.emit = function (eventName, data) {
var self = this;
if (eventName && this.events[eventName]) {
this.events[eventName].forEach(function (listener) {
if (self.asyncListeners) {
setTimeout(function () {
listener(data);
}, 1);
} else {
listener(data);
}
});
} 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, listener) {
if (this.events[eventName]) {
var listeners = this.events[eventName];
listeners.splice(listeners.indexOf(listener), 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;
this.events = {
'init': [],
'flushChanges': [],
'close': [],
'changes': [],
'warning': []
};
var getENV = function () {
if (typeof window === 'undefined') {
return 'NODEJS';
}
if (typeof global !== 'undefined' && global.window) {
return 'NODEJS'; //node-webkit
}
if (typeof document !== 'undefined') {
if (document.URL.indexOf('http://') === -1 && document.URL.indexOf('https://') === -1) {
return 'CORDOVA';
}
return 'BROWSER';
}
return 'CORDOVA';
};
// refactored environment detection due to invalid detection for browser environments.
// if they do not specify an options.env we want to detect env rather than default to nodejs.
// currently keeping two properties for similar thing (options.env and options.persistenceMethod)
// might want to review whether we can consolidate.
if (options && options.hasOwnProperty('env')) {
this.ENV = options.env;
} else {
this.ENV = getENV();
}
// not sure if this is necessary now that i have refactored the line above
if (this.ENV === 'undefined') {
this.ENV = 'NODEJS';
}
//if (typeof (options) !== 'undefined') {
this.configureOptions(options, true);
//}
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) {
var defaultPersistence = {
'NODEJS': 'fs',
'BROWSER': 'localStorage',
'CORDOVA': 'localStorage'
},
persistenceMethods = {
'fs': LokiFsAdapter,
'localStorage': LokiLocalStorageAdapter
};
this.options = {};
this.persistenceMethod = null;
// retain reference to optional persistence adapter 'instance'
// currently keeping outside options because it can't be serialized
this.persistenceAdapter = null;
// process the options
if (typeof (options) !== 'undefined') {
this.options = options;
if (this.options.hasOwnProperty('persistenceMethod')) {
// check if the specified persistence method is known
if (typeof (persistenceMethods[options.persistenceMethod]) == 'function') {
this.persistenceMethod = options.persistenceMethod;
this.persistenceAdapter = new persistenceMethods[options.persistenceMethod]();
}
// should be throw an error here, or just fall back to defaults ??
}
// 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) {
// for autoload, let the constructor complete before firing callback
var self = this;
setTimeout(function () {
self.loadDatabase(options, options.autoloadCallback);
}, 1);
}
if (this.options.hasOwnProperty('autosaveInterval')) {
this.autosaveDisable();
this.autosaveInterval = parseInt(this.options.autosaveInterval, 10);
}
if (this.options.hasOwnProperty('autosave') && this.options.autosave) {
this.autosaveDisable();
this.autosave = true;
this.autosaveEnable();
}
} // end of options processing
// if by now there is no adapter specified by user nor derived from persistenceMethod: use sensible defaults
if (this.persistenceAdapter === null) {
this.persistenceMethod = defaultPersistence[this.ENV];
if (this.persistenceMethod) {
this.persistenceAdapter = new persistenceMethods[this.persistenceMethod]();
}
}
};
/**
* 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;
}
}
};
Loki.prototype.getName = function () {
return this.name;
};
/**
* serializeReplacer - used to prevent certain properties from being serialized
*
*/
Loki.prototype.serializeReplacer = function (key, value) {
switch (key) {
case 'autosaveHandle':
return null;
case 'persistenceAdapter':
return null;
default:
return value;
}
};
// toJson
Loki.prototype.serialize = function () {
return JSON.stringify(this, this.serializeReplacer);
};
// 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;
this.name = obj.name;
// restore database version
this.databaseVersion = 1.0;
if (obj.hasOwnProperty('databaseVersion')) {
this.databaseVersion = obj.databaseVersion;
}
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 collObj = new(options[coll.name].proto)();
loader(coll.data[j], collObj);
copyColl.data[j] = collObj;
}
} else {
for (j; j < clen; j++) {
copyColl.data[j] = coll.data[j];
}
}
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;
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 |
-------------------*/
/** there are two build in persistence adapters for internal use
* fs for use in Nodejs type environments
* localStorage for use in browser environment
* defined as helper classes here so its easy and clean to use
*/
/**
* constructor for fs
*/
function LokiFsAdapter() {
this.fs = require('fs');
}
/**
* loadDatabase() - Load data from file, will throw an error if the file does not exist
* @param {string} dbname - the filename of the database to load
* @param {function} callback - the callback to handle the result
*/
LokiFsAdapter.prototype.loadDatabase = function loadDatabase(dbname, callback) {
this.fs.readFile(dbname, {
encoding: 'utf8'
}, function readFileCallback(err, data) {
if (err) {
callback(new Error(err));
} else {
callback(data);
}
});
};
/**
* saveDatabase() - save data to file, will throw an error if the file can't be saved
* might want to expand this to avoid dataloss on partial save
* @param {string} dbname - the filename of the database to load
* @param {function} callback - the callback to handle the result
*/
LokiFsAdapter.prototype.saveDatabase = function saveDatabase(dbname, dbstring, callback) {
this.fs.writeFile(dbname, dbstring, callback);
};
/**
* constructor for local storage
*/
function LokiLocalStorageAdapter() {}
/**
* loadDatabase() - Load data from localstorage
* @param {string} dbname - the name of the database to load
* @param {function} callback - the callback to handle the result
*/
LokiLocalStorageAdapter.prototype.loadDatabase = function loadDatabase(dbname, callback) {
if (localStorageAvailable()) {
callback(localStorage.getItem(dbname));
} else {
callback(new Error('localStorage is not available'));
}
};
/**
* saveDatabase() - save data to localstorage, will throw an error if the file can't be saved
* might want to expand this to avoid dataloss on partial save
* @param {string} dbname - the filename of the database to load
* @param {function} callback - the callback to handle the result
*/
LokiLocalStorageAdapter.prototype.saveDatabase = function saveDatabase(dbname, dbstring, callback) {
if (localStorageAvailable()) {
localStorage.setItem(dbname, dbstring);
callback(null);
} else {
callback(new Error('localStorage is not available'));
}
};
/**
* 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;
// the persistenceAdapter should be present if all is ok, but check to be sure.
if (this.persistenceAdapter !== null) {
this.persistenceAdapter.loadDatabase(this.filename, function loadDatabaseCallback(dbString) {
if (typeof (dbString) === 'string') {
self.loadJSON(dbString, options || {});
cFun(null);
} else {
console.warn('lokijs loadDatabase : Database not found');
if (typeof (dbString) === "object") {
cFun(dbString);
} else {
cFun('Database not found');
}
}
});
} else {
cFun(new Error('persistenceAdapter not configured'));
}
};
/**
* 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;
// the persistenceAdapter should be present if all is ok, but check to be sure.
if (this.persistenceAdapter !== null) {
this.persistenceAdapter.saveDatabase(this.filename, self.serialize(), function saveDatabasecallback() {
// for now assume that save went ok and reset dirty flags
// in future we may move this into each if block if no exceptions occur.
self.autosaveClearFlags();
cFun(null);
});
} else {
cFun(new Error('persistenceAdapter not configured'));
}
};
// 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];
return sortHelper(obj1[prop], obj2[prop], desc);
};
})(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);
}
}
return sortHelper(obj1[firstProp], obj2[firstProp], isdesc);
};
/**
* 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 (ltHelper(val, minVal) || gtHelper(val, maxVal)) {
return [0, -1];
}
break;
case '$gt':
if (gtHelper(val, maxVal, true)) {
return [0, -1];
}
break;
case '$gte':
if (gtHelper(val, maxVal)) {
return [0, -1];
}
break;
case '$lt':
if (ltHelper(val, minVal, true)) {
return [0, -1];
}
break;
case '$lte':
if (ltHelper(val, minVal)) {
return [0, -1];
}
break;
}
// hone in on start position of value
while (min < max) {
mid = Math.floor((min + max) / 2);
if (ltHelper(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 (ltHelper(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 (ltHelper(uval, val, true)) {
return [0, -1];
}
return [ubound, rcd.length - 1];
case '$gte':
if (ltHelper(lval, val)) {
return [0, -1];
}
return [lbound, rcd.length - 1];
case '$lt':
if (lbound === 0 && ltHelper(lval, val)) {
return [0, 0];
}
return [0, lbound - 1];
case '$lte':
if (uval !== val) {
ubound--;
}
if (ubound === 0 && ltHelper(uval, val)) {
return [0, 0];
}
return [0, ubound];
default:
return [0, rcd.length - 1];
}
};
/**
* findOr() - oversee the operation of OR'ed query expressions.
* OR'ed expression evaluation runs each expression individually against the full collection,
* and finally does a set OR on each expression's results.
* Each evaluation can utilize a binary index to prevent multiple linear array scans.
*
* @param {array} expressionArray - array of expressions
* @returns {Resultset} this resultset for further chain ops.
*/
Resultset.prototype.findOr = function (expressionArray) {
var fri = 0,
ei = 0,
fr = null,
docset = [],
expResultset = null;
// if filter is already initialized we need to query against only those items already in filter.
// This means no index utilization for fields, so hopefully its filtered to a smallish filteredrows.
if (this.filterInitialized) {
docset = [];
for (ei = 0; ei < expressionArray.length; ei++) {
// we need to branch existing query to run each filter separately and combine results
expResultset = this.branch();
expResultset.find(expressionArray[ei]);
expResultset.data();
// add any document 'hits'
fr = expResultset.filteredrows;
for (fri = 0; fri < fr.length; fri++) {
if (docset.indexOf(fr[fri]) === -1) {
docset.push(fr[fri]);
}
}
}
this.filteredrows = docset;
} else {
for (ei = 0; ei < expressionArray.length; ei++) {
// we will let each filter run independently against full collection and mashup document hits later
expResultset = this.collection.chain();
expResultset.find(expressionArray[ei]);
expResultset.data();
// add any document 'hits'
fr = expResultset.filteredrows;
for (fri = 0; fri < fr.length; fri++) {
if (this.filteredrows.indexOf(fr[fri]) === -1) {
this.filteredrows.push(fr[fri]);
}
}
}
}
this.filterInitialized = true;
// possibly sort indexes
return this;
};
/**
* findAnd() - oversee the operation of AND'ed query expressions.
* AND'ed expression evaluation runs each expression progressively against the full collection,
* internally utilizing existing chained resultset functionality.
* Only the first filter can utilize a binary index.
*
* @param {array} expressionArray - array of expressions
* @returns {Resultset} this resultset for further chain ops.
*/
Resultset.prototype.findAnd = function (expressionArray) {
// we have already implementing method chaining in this (our Resultset class)
// so lets just progressively apply user supplied and filters
for (var i = 0; i < expressionArray.length; i++) {
this.find(expressionArray[i]);
}
return this;
};
/**
* dotSubScan - helper function used for dot notation queries.
*/
Resultset.prototype.dotSubScan = function (root, property, fun, value) {
var arrayRef = null;
var pathIndex, subIndex;
var paths = property.split('.');
var path;
for (pathIndex = 0; pathIndex < paths.length; pathIndex++) {
path = paths[pathIndex];
// foreach already detected parent was array so this must be where we iterate
if (arrayRef) {
// iterate all sub-array items to see if any yield hits
for (subIndex = 0; subIndex < arrayRef.length; subIndex++) {
if (fun(arrayRef[subIndex][path], value)) {
return true;
}
}
}
// else not yet determined if subarray scan is involved
else {
root = root[path];
if (Array.isArray(root)) {
arrayRef = root;
}
}
}
// made it this far so must be dot notation on non-array property
return fun(root, value);
};
/**
* 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,
searchByIndex = false,
result = [],
index = null,
// comparison function
fun,
// collection data
t,
// collection data length
i,
emptyQO = true;
// if this was note invoked via findOne()
firstOnly = firstOnly || false;
// if passed in empty object {}, interpret as 'getAll'
// more performant than object.keys
for (p in queryObject) {
emptyQO = false;
break;
}
if (emptyQO) {
queryObject = 'getAll';
}
// 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;
// injecting $and and $or expression tree evaluation here.
if (p === '$and') {
if (this.searchIsChained) {
this.findAnd(queryObject[p]);
// for chained find with firstonly,
if (firstOnly && this.filteredrows.length > 1) {
this.filteredrows = this.filteredrows.slice(0, 1);
}
return this;
} else {
// our $and operation internally chains filters
result = this.collection.chain().findAnd(queryObject[p]).data();
// if this was coll.findOne() return first object or empty array if null
// since this is invoked from a constructor we can't return null, so we will
// make null in coll.findOne();
if (firstOnly) {
if (result.length === 0) return [];
return result[0];
}
// not first only return all results
return result;
}
}
if (p === '$or') {
if (this.searchIsChained) {
this.findOr(queryObject[p]);
if (firstOnly && this.filteredrows.length > 1) {
this.filteredrows = this.filteredrows.slice(0, 1);
}
return this;
} else {
// call out to helper function to determine $or results
result = this.collection.chain().findOr(queryObject[p]).data();
if (firstOnly) {
if (result.length === 0) return [];
return result[0];
}
// not first only return all results
return result;
}
}
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 = new 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 !== '$containsAny' && 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) {
while (i--) {
if (this.dotSubScan(t[i], property, fun, 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;
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 t[index.values[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) {
while (i--) {
if (this.dotSubScan(t[this.filteredrows[i]], property, fun, 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) {
while (i--) {
if (this.dotSubScan(t[i], property, fun, 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 segm = this.calculateRange(operator, property, value, this);
for (var idx = segm[0]; idx <= segm[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 j = this.filteredrows.length;
while (j--) {
if (viewFunction(this.collection.data[this.filteredrows[j]]) === true) {
result.push(this.filteredrows[j]);
}
}
this.filteredrows = result;
return this;
}
// otherwise this is initial chained op, work against data, push into filteredrows[]
else {
var k = this.collection.data.length;
while (k--) {
if (viewFunction(this.collection.data[k]) === true) {
result.push(k);
}
}
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 = function () {
var result = [];
// if this is chained resultset with no filters applied, just return collection.data
if (this.searchIsChained && !this.filterInitialized) {
if (this.filteredrows.length === 0) {
return this.collection.data;
} else {
// filteredrows must have been set manually, so use it
this.filterInitialized = true;
}
}
var data = this.collection.data,
fr = this.filteredrows;
var i,
len = this.filteredrows.length;
for (i = 0; i < len; i++) {
result.push(data[fr[i]]);
}
return result;
};
/**
* update() - used to run an update operation on all documents currently in the resultset.
*
* @param {function} updateFunction - User supplied updateFunction(obj) will be executed for each document object.
* @returns {Resultset} this resultset for further chain ops.
*/
Resultset.prototype.update = function (updateFunction) {
if (typeof (updateFunction) !== "function") {
throw 'Argument is not a function';
}
// 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 len = this.filteredrows.length,
rcd = this.collection.data;
for (var idx = 0; idx < len; idx++) {
// pass in each document object currently in resultset to user supplied updateFunction
updateFunction(rcd[this.filteredrows[idx]]);
// notify collection we have changed this object so it can update meta and allow DynamicViews to re-evaluate
this.collection.update(rcd[this.filteredrows[idx]]);
}
return this;
};
/**
* remove() - removes all document objects which are currently in resultset from collection (as well as resultset)
*
* @returns {Resultset} this (empty) resultset for further chain ops.
*/
Resultset.prototype.remove = function () {
// 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 len = this.filteredrows.length;
for (var idx = 0; idx < len; idx++) {
this.collection.remove(this.filteredrows[idx]);
}
this.filteredrows = [];
return this;
};
/**
* mapReduce() - data transformation via user supplied functions
*
* @param {function} mapFunction - this function accepts a single document for you to transform and return
* @param {function} reduceFunction - this function accepts many (array of map outputs) and returns single value
* @returns The output of your reduceFunction
*/
Resultset.prototype.mapReduce = function (mapFunction, reduceFunction) {
try {
return reduceFunction(this.data().map(mapFunction));
} catch (err) {
throw err;
}
};
/**
* eqJoin() - Left joining two sets of data. Join keys can be defined or calculated properties
* eqJoin expects the right join key values to be unique. Otherwise left data will be joined on the last joinData object with that key
* @param {Array} joinData - Data array to join to.
* @param {String,function} leftJoinKey - Property name in this result set to join on or a function to produce a value to join on
* @param {String,function} rightJoinKey - Property name in the joinData to join on or a function to produce a value to join on
* @param {function} (optional) mapFun - A function that receives each matching pair and maps them into output objects - function(left,right){return joinedObject}
* @returns {Resultset} A resultset with data in the format [{left: leftObj, right: rightObj}]
*/
Resultset.prototype.eqJoin = function (joinData, leftJoinKey, rightJoinKey, mapFun) {
var leftData = [],
leftDataLength,
rightData = [],
rightDataLength,
key,
result = [],
obj,
leftKeyisFunction = typeof leftJoinKey === 'function',
rightKeyisFunction = typeof rightJoinKey === 'function',
joinMap = {};
//get the left data
leftData = this.data();
leftDataLength = leftData.length;
//get the right data
if (joinData instanceof Resultset) {
rightData = joinData.data();
} else if (Array.isArray(joinData)) {
rightData = joinData;
} else {
throw new TypeError('joinData needs to be an array or result set');
}
rightDataLength = rightData.length;
//construct a lookup table
for (var i = 0; i < rightDataLength; i++) {
key = rightKeyisFunction ? rightJoinKey(rightData[i]) : rightData[i][rightJoinKey];
joinMap[key] = rightData[i];
}
if (!mapFun) {
mapFun = function (left, right) {
return {
left: left,
right: right
};
};
}
//Run map function over each object in the resultset
for (var j = 0; j < leftDataLength; j++) {
key = leftKeyisFunction ? leftJoinKey(leftData[j]) : leftData[j][leftJoinKey];
result.push(mapFun(leftData[j], joinMap[key] || {}));
}
//return return a new resultset with no filters
this.collection = new Collection('joinData');
this.collection.insert(result);
this.filteredrows = [];
this.filterInitialized = false;
return this;
};
Resultset.prototype.map = function (mapFun) {
var data = this.data().map(mapFun);
//return return a new resultset with no filters
this.collection = new Collection('mappedData');
this.collection.insert(data);
this.filteredrows = [];
this.filterInitialized = false;
return this;
};
/**
* DynamicView class is a versatile 'live' view class which can have filters and sorts applied.
* Collection.addDynamicView(name) instantiates this DynamicView object and notifies it
* whenever documents are add/updated/removed so it can remain up-to-date. (chainable)
*
* Examples:
* var mydv = mycollection.addDynamicView('test'); // default is non-persistent
* mydv.applyWhere(function(obj) { return obj.name === 'Toyota'; });
* mydv.applyFind({ 'doors' : 4 });
* var results = mydv.data();
*
* @constructor
* @param {Collection} collection - A reference to the collection to work against
* @param {string} name - The name of this dynamic view
* @param {boolean} persistent - (Optional) If true, the results will be copied into an internal array for read efficiency or binding to.
*/
function DynamicView(collection, name, persistent) {
this.collection = collection;
this.name = name;
this.persistent = false;
if (typeof (persistent) !== 'undefined') this.persistent = persistent;
this.resultset = new Resultset(collection);
this.resultdata = [];
this.resultsdirty = false;
this.cachedresultset = null;
// keep ordered filter pipeline
this.filterPipeline = [];
// sorting member variables
// we only support one active search, applied using applySort() or applySimpleSort()
this.sortFunction = null;
this.sortCriteria = null;
this.sortDirty = false;
// for now just have 1 event for when we finally rebuilt lazy view
// once we refactor transactions, i will tie in certain transactional events
this.events = {
'rebuild': []
};
}
DynamicView.prototype = new LokiEventEmitter();
/**
* rematerialize() - intended for use immediately after deserialization (loading)
* This will clear out and reapply filterPipeline ops, recreating the view.
* Since where filters do not persist correctly, this method allows
* restoring the view to state where user can re-apply those where filters.
*
* @param {Object} options - (Optional) allows specification of 'removeWhereFilters' option
* @returns {DynamicView} This dynamic view for further chained ops.
*/
DynamicView.prototype.rematerialize = function (options) {
var fpl,
fpi,
idx;
options = options || {};
this.resultdata = [];
this.resultsdirty = true;
this.resultset = new Resultset(this.collection);
if (this.sortFunction || this.sortCriteria) {
this.sortDirty = true;
}
if (options.hasOwnProperty('removeWhereFilters')) {
// for each view see if it had any where filters applied... since they don't
// serialize those functions lets remove those invalid filters
fpl = this.filterPipeline.length;
fpi = fpl;
while (fpi--) {
if (this.filterPipeline[fpi].type === 'where') {
if (fpi !== this.filterPipeline.length - 1) {
this.filterPipeline[fpi] = this.filterPipeline[this.filterPipeline.length - 1];
}
this.filterPipeline.length--;
}
}
}
// back up old filter pipeline, clear filter pipeline, and reapply pipeline ops
var ofp = this.filterPipeline;
this.filterPipeline = [];
// now re-apply 'find' filterPipeline ops
fpl = ofp.length;
for (idx = 0; idx < fpl; idx++) {
this.applyFind(ofp[idx].val);
}
// during creation of unit tests, i will remove this forced refresh and leave lazy
this.data();
// emit rebuild event in case user wants to be notified
this.emit('rebuild', this);
return this;
};
/**
* branchResultset() - Makes a copy of the internal resultset for branched queries.
* Unlike this dynamic view, the branched resultset will not be 'live' updated,
* so your branched query should be immediately resolved and not held for future evaluation.
*
* @returns {Resultset} A copy of the internal resultset for branched queries.
*/
DynamicView.prototype.branchResultset = function () {
return this.resultset.copy();
};
/**
* toJSON() - Override of toJSON to avoid circular references
*
*/
DynamicView.prototype.toJSON = function () {
var copy = new DynamicView(this.collection, this.name, this.persistent);
copy.resultset = this.resultset;
copy.resultdata = []; // let's not save data (copy) to minimize size
copy.resultsdirty = true;
copy.filterPipeline = this.filterPipeline;
copy.sortFunction = this.sortFunction;
copy.sortCriteria = this.sortCriteria;
copy.sortDirty = this.sortDirty;
// avoid circular reference, reapply in db.loadJSON()
copy.collection = null;
return copy;
};
/**
* applySort() - Used to apply a sort to the dynamic view
*
* @param {function} comparefun - a javascript compare function used for sorting
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.applySort = function (comparefun) {
this.sortFunction = comparefun;
this.sortCriteria = null;
this.queueSortPhase();
return this;
};
/**
* applySimpleSort() - Used to specify a property used for view translation.
*
* @param {string} propname - Name of property by which to sort.
* @param {boolean} isdesc - (Optional) If true, the sort will be in descending order.
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.applySimpleSort = function (propname, isdesc) {
if (typeof (isdesc) === 'undefined') {
isdesc = false;
}
this.sortCriteria = [
[propname, isdesc]
];
this.sortFunction = null;
this.queueSortPhase();
return this;
};
/**
* applySortCriteria() - Allows sorting a resultset based on multiple columns.
* Example : dv.applySortCriteria(['age', 'name']); to sort by age and then name (both ascending)
* Example : dv.applySortCriteria(['age', ['name', true]); to sort by age (ascending) and then by name (descending)
* Example : dv.applySortCriteria(['age', true], ['name', true]); to sort by age (descending) and then by name (descending)
*
* @param {array} properties - array of property names or subarray of [propertyname, isdesc] used evaluate sort order
* @returns {DynamicView} Reference to this DynamicView, sorted, for future chain operations.
*/
DynamicView.prototype.applySortCriteria = function (criteria) {
this.sortCriterial = criteria;
this.sortFunction = null;
this.queueSortPhase();
return this;
};
/**
* startTransaction() - marks the beginning of a transaction.
*
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.startTransaction = function () {
this.cachedresultset = this.resultset.copy();
return this;
};
/**
* commit() - commits a transaction.
*
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.commit = function () {
this.cachedresultset = null;
return this;
};
/**
* rollback() - rolls back a transaction.
*
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.rollback = function () {
this.resultset = this.cachedresultset;
if (this.persistent) {
// for now just rebuild the persistent dynamic view data in this worst case scenario
// (a persistent view utilizing transactions which get rolled back), we already know the filter so not too bad.
this.resultdata = this.resultset.data();
this.emit('rebuild', this);
}
return this;
};
/**
* applyFind() - Adds a mongo-style query option to the DynamicView filter pipeline
*
* @param {object} query - A mongo-style query object to apply to pipeline
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.applyFind = function (query) {
this.filterPipeline.push({
type: 'find',
val: query
});
// Apply immediately to Resultset; if persistent we will wait until later to build internal data
this.resultset.find(query);
if (this.sortFunction || this.sortCriteria) {
this.sortDirty = true;
this.queueSortPhase();
}
if (this.persistent) {
this.resultsdirty = true;
this.queueSortPhase();
}
return this;
};
/**
* applyWhere() - Adds a javascript filter function to the DynamicView filter pipeline
*
* @param {function} fun - A javascript filter function to apply to pipeline
* @returns {DynamicView} this DynamicView object, for further chain ops.
*/
DynamicView.prototype.applyWhere = function (fun) {
this.filterPipeline.push({
type: 'where',
val: fun
});
// Apply immediately to Resultset; if persistent we will wait until later to build internal data
this.resultset.where(fun);
if (this.sortFunction || this.sortCriteria) {
this.sortDirty = true;
this.queueSortPhase();
}
if (this.persistent) {
this.resultsdirty = true;
this.queueSortPhase();
}
return this;
};
/**
* data() - resolves and pending filtering and sorting, then returns document array as result.
*
* @returns {array} An array of documents representing the current DynamicView contents.
*/
DynamicView.prototype.data = function () {
// using final sort phase as 'catch all' for a few use cases which require full rebuild
if (this.sortDirty || this.resultsdirty || !this.resultset.filterInitialized) {
this.performSortPhase();
}
if (!this.persistent) {
return this.resultset.data();
}
return this.resultdata;
};
/**
*
*/
DynamicView.prototype.queueSortPhase = function () {
var self = this;
// already queued? exit without queuing again
if (this.sortDirty) {
return;
}
this.sortDirty = true;
// queue async call to performSortPhase()
setTimeout(function () {
self.performSortPhase();
}, 1);
};
/**
* performSortPhase() - invoked synchronously or asynchronously to perform final sort phase (if needed)
*
*/
DynamicView.prototype.performSortPhase = function () {
// async call to this may have been pre-empted by synchronous call to data before async could fire
if (!this.sortDirty && !this.resultsdirty && this.resultset.filterInitialized) {
return;
}
if (this.sortFunction) {
this.resultset.sort(this.sortFunction);
}
if (this.sortCriteria) {
this.resultset.compoundsort(this.sortCriteria);
}
if (!this.persistent) {
this.sortDirty = false;
return;
}
// persistent view, rebuild local resultdata array
this.resultdata = this.resultset.data();
this.resultsdirty = false;
this.sortDirty = false;
this.emit('rebuild', this);
};
/**
* evaluateDocument() - internal method for (re)evaluating document inclusion.
* Called by : collection.insert() and collection.update().
*
* @param {int} objIndex - index of document to (re)run through filter pipeline.
*/
DynamicView.prototype.evaluateDocument = function (objIndex) {
var ofr = this.resultset.filteredrows;
var oldPos = ofr.indexOf(objIndex);
var oldlen = ofr.length;
// creating a 1-element resultset to run filter chain ops on to see if that doc passes filters;
// mostly efficient algorithm, slight stack overhead price (this function is called on inserts and updates)
var evalResultset = new Resultset(this.collection);
evalResultset.filteredrows = [objIndex];
evalResultset.filterInitialized = true;
for (var idx = 0; idx < this.filterPipeline.length; idx++) {
switch (this.filterPipeline[idx].type) {
case 'find':
evalResultset.find(this.filterPipeline[idx].val);
break;
case 'where':
evalResultset.where(this.filterPipeline[idx].val);
break;
}
}
// not a true position, but -1 if not pass our filter(s), 0 if passed filter(s)
var newPos = (evalResultset.filteredrows.length === 0) ? -1 : 0;
// wasn't in old, shouldn't be now... do nothing
if (oldPos == -1 && newPos == -1) return;
// wasn't in resultset, should be now... add
if (oldPos === -1 && newPos !== -1) {
ofr.push(objIndex);
if (this.persistent) {
this.resultdata.push(this.collection.data[objIndex]);
}
// need to re-sort to sort new document
if (this.sortFunction || this.sortCriteria) {
this.queueSortPhase();
}
return;
}
// was in resultset, shouldn't be now... delete
if (oldPos !== -1 && newPos === -1) {
if (oldPos < oldlen - 1) {
// http://dvolvr.davidwaterston.com/2013/06/09/restating-the-obvious-the-fastest-way-to-truncate-an-array-in-javascript/comment-page-1/
ofr[oldPos] = ofr[oldlen - 1];
ofr.length = oldlen - 1;
if (this.persistent) {
this.resultdata[oldPos] = this.resultdata[oldlen - 1];
this.resultdata.length = oldlen - 1;
}
} else {
ofr.length = oldlen - 1;
if (this.persistent) {
this.resultdata.length = oldlen - 1;
}
}
// in case changes to data altered a sort column
if (this.sortFunction || this.sortCriteria) {
this.queueSortPhase();
}
return;
}
// was in resultset, should still be now... (update persistent only?)
if (oldPos !== -1 && newPos !== -1) {
if (this.persistent) {
// in case document changed, replace persistent view data with the latest collection.data document
this.resultdata[oldPos] = this.collection.data[objIndex];
}
// in case changes to data altered a sort column
if (this.sortFunction || this.sortCriteria) {
this.sortDirty = true;
}
return;
}
};
/**
* removeDocument() - internal function called on collection.delete()
*/
DynamicView.prototype.removeDocument = function (objIndex) {
var ofr = this.resultset.filteredrows;
var oldPos = ofr.indexOf(objIndex);
var oldlen = ofr.length;
var idx;
if (oldPos !== -1) {
// if not last row in resultdata, swap last to hole and truncate last row
if (oldPos < oldlen - 1) {
ofr[oldPos] = ofr[oldlen - 1];
ofr.length = oldlen - 1;
if (this.persistent) {
this.resultdata[oldPos] = this.resultdata[oldlen - 1];
this.resultdata.length = oldlen - 1;
}
}
// last row, so just truncate last row
else {
ofr.length = oldlen - 1;
if (this.persistent) {
this.resultdata.length = oldlen - 1;
}
}
// in case changes to data altered a sort column
if (this.sortFunction || this.sortCriteria) {
this.queueSortPhase();
}
}
// since we are using filteredrows to store data array positions
// if they remove a document (whether in our view or not),
// we need to adjust array positions -1 for all document array references after that position
oldlen = ofr.length;
for (idx = 0; idx < oldlen; idx++) {
if (ofr[idx] > objIndex) {
ofr[idx] --;
}
}
};
/**
* mapReduce() - data transformation via user supplied functions
*
* @param {function} mapFunction - this function accepts a single document for you to transform and return
* @param {function} reduceFunction - this function accepts many (array of map outputs) and returns single value
* @returns The output of your reduceFunction
*/
DynamicView.prototype.mapReduce = function (mapFunction, reduceFunction) {
try {
return reduceFunction(this.data().map(mapFunction));
} catch (err) {
throw err;
}
};
/**
* @constructor
* Collection class that handles documents of same type
* @param {stirng} collection name
* @param {array} array of property names to be indicized
* @param {object} configuration object
*/
function Collection(name, options) {
// the name of the collection
this.name = name;
// the data held by the collection
this.data = [];
this.idIndex = []; // index of id
this.binaryIndices = {}; // user defined indexes
this.constraints = {
unique: {},
exact: {}
};
// the object type of the collection
this.objType = name;
// in autosave scenarios we will use collection level dirty flags to determine whether save is needed.
// currently, if any collection is dirty we will autosave the whole database if autosave is configured.
// defaulting to true since this is called from addCollection and adding a collection should trigger save
this.dirty = true;
// private holders for cached data
this.cachedIndex = null;
this.cachedBinaryIndex = null;
this.cachedData = null;
var self = this;
/* OPTIONS */
options = options || {};
// exact match and unique constraints
if (options.hasOwnProperty('unique')) {
if (!Array.isArray(options.unique)) {
options.unique = [options.unique];
}
options.unique.forEach(function (prop) {
self.constraints.unique[prop] = new UniqueIndex(prop);
});
}
if (options.hasOwnProperty('exact')) {
options.exact.forEach(function (prop) {
self.constraints.exact[prop] = new ExactIndex(prop);
});
}
// is collection transactional
this.transactional = options.hasOwnProperty('transactional') ? options.transactional : false;
// options to clone objects when inserting them
this.cloneObjects = options.hasOwnProperty('clone') ? options.clone : false;
// option to make event listeners async, default is sync
this.asyncListeners = options.hasOwnProperty('asyncListeners') ? options.asyncListeners : false;
// disable track changes
this.disableChangesApi = options.hasOwnProperty('disableChangesApi') ? options.disableChangesApi : true;
// currentMaxId - change manually at your own peril!
this.maxId = 0;
this.DynamicViews = [];
// events
this.events = {
'insert': [],
'update': [],
'pre-insert': [],
'pre-update': [],
'close': [],
'flushbuffer': [],
'error': [],
'delete': [],
'warning': []
};
// changes are tracked by collection and aggregated by the db
this.changes = [];
// initialize the id index
this.ensureId();
var indices = [];
// initialize optional user-supplied indices array ['age', 'lname', 'zip']
//if (typeof (indices) !== 'undefined') {
if (options && options.indices) {
if (Object.prototype.toString.call(options.indices) === '[object Array]') {
indices = options.indices;
} else if (typeof options.indices === 'string') {
indices = [options.indices];
} else {
throw new TypeError('Indices needs to be a string or an array of strings');
}
}
for (var idx = 0; idx < indices.length; idx++) {
this.ensureIndex(indices[idx]);
}
/**
* This method creates a clone of the current status of an object and associates operation and collection name,
* so the parent db can aggregate and generate a changes object for the entire db
*/
function createChange(name, op, obj) {
self.changes.push({
name: name,
operation: op,
obj: JSON.parse(JSON.stringify(obj))
});
}
// clear all the changes
function flushChanges() {
self.changes = [];
}
this.getChanges = function () {
return self.changes;
};
this.flushChanges = flushChanges;
/**
* If the changes API is disabled make sure only metadata is added without re-evaluating everytime if the changesApi is enabled
*/
function insertMeta(obj) {
if (!obj) {
return;
}
if (!obj.meta) {
obj.meta = {};
}
obj.meta.created = (new Date()).getTime();
obj.meta.revision = 0;
}
function updateMeta(obj) {
if (!obj) {
return;
}
obj.meta.updated = (new Date()).getTime();
obj.meta.revision += 1;
}
function createInsertChange(obj) {
createChange(self.name, 'I', obj);
}
function createUpdateChange(obj) {
createChange(self.name, 'U', obj);
}
function insertMetaWithChange(obj) {
insertMeta(obj);
createInsertChange(obj);
}
function updateMetaWithChange(obj) {
updateMeta(obj);
createUpdateChange(obj);
}
/* assign correct handler based on ChangesAPI flag */
var insertHandler, updateHandler;
function setHandlers() {
insertHandler = self.disableChangesApi ? insertMeta : insertMetaWithChange;
updateHandler = self.disableChangesApi ? updateMeta : updateMetaWithChange;
}
setHandlers();
this.setChangesApi = function (enabled) {
self.disableChangesApi = !enabled;
setHandlers();
};
/**
* built-in events
*/
this.on('insert', function insertCallback(obj) {
insertHandler(obj);
});
this.on('update', function updateCallback(obj) {
updateHandler(obj);
});
this.on('delete', function deleteCallback(obj) {
if (!self.disableChangesApi) {
createChange(self.name, 'R', obj);
}
});
this.on('warning', console.warn);
// for de-serialization purposes
flushChanges();
}
Collection.prototype = new LokiEventEmitter();
/*----------------------------+
| INDEXING |
+----------------------------*/
/**
* Ensure binary index on a certain field
*/
Collection.prototype.ensureIndex = function (property, force) {
// optional parameter to force rebuild whether flagged as dirty or not
if (typeof (force) === 'undefined') {
force = false;
}
if (property === null || property === undefined) {
throw 'Attempting to set index without an associated property';
}
if (this.binaryIndices.hasOwnProperty(property) && !force) {
if (!this.binaryIndices[property].dirty) return;
}
this.binaryIndices[property] = {
'name': property,
'dirty': true,
'values': []
};
var index, len = this.data.length,
i = 0;
index = this.binaryIndices[property];
// initialize index values
for (i; i < len; i += 1) {
index.values.push(i);
}
var wrappedComparer =
(function (prop, coll) {
return function (a, b) {
var obj1 = coll.data[a];
var obj2 = coll.data[b];
if (obj1[prop] === obj2[prop]) return 0;
if (gtHelper(obj1[prop], obj2[prop])) return 1;
if (ltHelper(obj1[prop], obj2[prop])) return -1;
};
})(property, this);
index.values.sort(wrappedComparer);
index.dirty = false;
this.dirty = true; // for autosave scenarios
};
Collection.prototype.ensureUniqueIndex = function (field) {
var index = this.constraints.unique[field];
if (!index) {
this.constraints.unique[field] = new UniqueIndex(field);
}
var self = this;
this.data.forEach(function (obj) {
self.constraints.unique[field].set(obj);
});
};
/**
* Ensure all binary indices
*/
Collection.prototype.ensureAllIndexes = function (force) {
var objKeys = Object.keys(this.binaryIndices);
var i = objKeys.length;
while (i--) {
this.ensureIndex(objKeys[i], force);
}
};
Collection.prototype.flagBinaryIndexesDirty = function () {
var objKeys = Object.keys(this.binaryIndices);
var i = objKeys.length;
while (i--) {
this.binaryIndices[objKeys[i]].dirty = true;
}
};
Collection.prototype.count = function () {
return this.data.length;
};
/**
* Rebuild idIndex
*/
Collection.prototype.ensureId = function () {
var len = this.data.length,
i = 0;
this.idIndex = [];
for (i; i < len; i += 1) {
this.idIndex.push(this.data[i].$loki);
}
};
/**
* Rebuild idIndex async with callback - useful for background syncing with a remote server
*/
Collection.prototype.ensureIdAsync = function (callback) {
this.async(function () {
this.ensureId();
}, callback);
};
/**
* Each collection maintains a list of DynamicViews associated with it
**/
Collection.prototype.addDynamicView = function (name, persistent) {
var dv = new DynamicView(this, name, persistent);
this.DynamicViews.push(dv);
return dv;
};
Collection.prototype.removeDynamicView = function (name) {
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
if (this.DynamicViews[idx].name === name) {
this.DynamicViews.splice(idx, 1);
}
}
};
Collection.prototype.getDynamicView = function (name) {
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
if (this.DynamicViews[idx].name === name) {
return this.DynamicViews[idx];
}
}
return null;
};
/**
* find and update: pass a filtering function to select elements to be updated
* and apply the updatefunctino to those elements iteratively
*/
Collection.prototype.findAndUpdate = function (filterFunction, updateFunction) {
var results = this.where(filterFunction),
i = 0,
obj;
try {
for (i; i < results.length; i++) {
obj = updateFunction(results[i]);
this.update(obj);
}
} catch (err) {
this.rollback();
console.error(err.message);
}
};
/**
* generate document method - ensure objects have id and objType properties
* @param {object} the document to be inserted (or an array of objects)
* @returns document or documents (if passed an array of objects)
*/
Collection.prototype.insert = function (doc) {
if (!doc) {
var error = new Error('Object cannot be null');
this.emit('error', error);
throw error;
}
var self = this;
// holder to the clone of the object inserted if collections is set to clone objects
var obj;
var docs = Array.isArray(doc) ? doc : [doc];
var results = [];
docs.forEach(function (d) {
if (typeof d !== 'object') {
throw new TypeError('Document needs to be an object');
}
obj = self.cloneObjects ? JSON.parse(JSON.stringify(d)) : d;
if (typeof obj.meta === 'undefined') {
obj.meta = {
revision: 0,
created: 0
};
}
self.emit('pre-insert', obj);
if (self.add(obj)) {
self.emit('insert', obj);
results.push(obj);
} else {
return undefined;
}
});
return results.length === 1 ? results[0] : results;
};
Collection.prototype.clear = function () {
this.data = [];
this.idIndex = [];
this.binaryIndices = {};
this.cachedIndex = null;
this.cachedData = null;
this.maxId = 0;
this.DynamicViews = [];
this.dirty = true;
};
/**
* Update method
*/
Collection.prototype.update = function (doc) {
if (Object.keys(this.binaryIndices).length > 0) {
this.flagBinaryIndexesDirty();
}
if (Array.isArray(doc)) {
var k = 0,
len = doc.length;
for (k; k < len; k += 1) {
this.update(doc[k]);
}
return;
}
// verify object is a properly formed document
if (!doc.hasOwnProperty('$loki')) {
throw 'Trying to update unsynced document. Please save the document first by using insert() or addMany()';
}
try {
this.startTransaction();
var arr = this.get(doc.$loki, true),
obj,
position,
self = this;
if (!arr) {
throw new Error('Trying to update a document not in collection.');
}
this.emit('pre-update', doc);
obj = arr[0];
Object.keys(this.constraints.unique).forEach(function (key) {
self.constraints.unique[key].update(obj);
});
// get current position in data array
position = arr[1];
// operate the update
this.data[position] = doc;
// now that we can efficiently determine the data[] position of newly added document,
// submit it for all registered DynamicViews to evaluate for inclusion/exclusion
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
this.DynamicViews[idx].evaluateDocument(position);
}
this.idIndex[position] = obj.$loki;
this.commit();
this.dirty = true; // for autosave scenarios
this.emit('update', doc);
} catch (err) {
this.rollback();
console.error(err.message);
this.emit('error', err);
throw (err); // re-throw error so user does not think it succeeded
}
};
/**
* Add object to collection
*/
Collection.prototype.add = function (obj) {
var dvlen = this.DynamicViews.length;
// if parameter isn't object exit with throw
if ('object' !== typeof obj) {
throw 'Object being added needs to be an object';
}
/*
* try adding object to collection
*/
if (Object.keys(this.binaryIndices).length > 0) {
this.flagBinaryIndexesDirty();
}
// if object you are adding already has id column it is either already in the collection
// or the object is carrying its own 'id' property. If it also has a meta property,
// then this is already in collection so throw error, otherwise rename to originalId and continue adding.
if (typeof (obj.$loki) !== "undefined") {
throw 'Document is already in collection, please use update()';
}
try {
this.startTransaction();
this.maxId++;
if (isNaN(this.maxId)) {
this.maxId = (this.data[this.data.length - 1].$loki + 1);
}
obj.$loki = this.maxId;
obj.meta.version = 0;
// add the object
this.data.push(obj);
var self = this;
Object.keys(this.constraints.unique).forEach(function (key) {
self.constraints.unique[key].set(obj);
});
// now that we can efficiently determine the data[] position of newly added document,
// submit it for all registered DynamicViews to evaluate for inclusion/exclusion
for (var i = 0; i < dvlen; i++) {
this.DynamicViews[i].evaluateDocument(this.data.length - 1);
}
// add new obj id to idIndex
this.idIndex.push(obj.$loki);
this.commit();
this.dirty = true; // for autosave scenarios
return obj;
} catch (err) {
this.rollback();
console.error(err.message);
}
};
Collection.prototype.removeWhere = function (query) {
var list;
if (typeof query === 'function') {
list = this.data.filter(query);
} else {
list = new Resultset(this, query);
}
var len = list.length;
while (len--) {
this.remove(list[len]);
}
var dv;
for (dv in this.DynamicViews) {
this.DynamicViews[dv].rematerialize();
}
};
Collection.prototype.removeDataOnly = function () {
this.removeWhere(function (obj) {
return true;
});
};
/**
* delete wrapped
*/
Collection.prototype.remove = function (doc) {
if (typeof doc === 'number') {
doc = this.get(doc);
}
if ('object' !== typeof doc) {
throw new Error('Parameter is not an object');
}
if (Array.isArray(doc)) {
var k = 0,
len = doc.length;
for (k; k < len; k += 1) {
this.remove(doc[k]);
}
return;
}
if (!doc.hasOwnProperty('$loki')) {
throw new Error('Object is not a document stored in the collection');
}
if (Object.keys(this.binaryIndices).length > 0) {
this.flagBinaryIndexesDirty();
}
try {
this.startTransaction();
var arr = this.get(doc.$loki, true),
// obj = arr[0],
position = arr[1];
var self = this;
Object.keys(this.constraints.unique).forEach(function (key) {
self.constraints.unique[key].remove(doc);
});
// now that we can efficiently determine the data[] position of newly added document,
// submit it for all registered DynamicViews to remove
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
this.DynamicViews[idx].removeDocument(position);
}
this.data.splice(position, 1);
// remove id from idIndex
this.idIndex.splice(position, 1);
this.commit();
this.dirty = true; // for autosave scenarios
this.emit('delete', arr[0]);
delete doc.$loki;
delete doc.meta;
return doc;
} catch (err) {
this.rollback();
console.error(err.message);
this.emit('error', err);
return null;
}
};
/*---------------------+
| Finding methods |
+----------------------*/
/**
* Get by Id - faster than other methods because of the searching algorithm
*/
Collection.prototype.get = function (id, returnPosition) {
var retpos = returnPosition || false,
data = this.idIndex,
max = data.length - 1,
min = 0,
mid = Math.floor(min + (max - min) / 2);
id = typeof id === 'number' ? id : parseInt(id, 10);
if (isNaN(id)) {
throw 'Passed id is not an integer';
}
while (data[min] < data[max]) {
mid = Math.floor((min + max) / 2);
if (data[mid] < id) {
min = mid + 1;
} else {
max = mid;
}
}
if (max === min && data[min] === id) {
if (retpos) {
return [this.data[min], min];
}
return this.data[min];
}
return null;
};
Collection.prototype.by = function (field, value) {
var self;
if (!value) {
self = this;
return function (value) {
return self.by(field, value);
};
}
return this.constraints.unique[field].get(value);
};
/**
* Find one object by index property, by property equal to value
*/
Collection.prototype.findOne = function (query) {
// Instantiate Resultset and exec find op passing firstOnly = true param
var result = new Resultset(this, query, null, true);
if (Array.isArray(result) && result.length === 0) {
return null;
} else {
return result;
}
};
/**
* Chain method, used for beginning a series of chained find() and/or view() operations
* on a collection.
*/
Collection.prototype.chain = function () {
return new Resultset(this, null, null);
};
/**
* Find method, api is similar to mongodb except for now it only supports one search parameter.
* for more complex queries use view() and storeView()
*/
Collection.prototype.find = function (query) {
if (typeof (query) === 'undefined') {
query = 'getAll';
}
// find logic moved into Resultset class
return new Resultset(this, query, null);
};
/**
* Find object by unindexed field by property equal to value,
* simply iterates and returns the first element matching the query
*/
Collection.prototype.findOneUnindexed = function (prop, value) {
var i = this.data.length,
doc;
while (i--) {
if (this.data[i][prop] === value) {
doc = this.data[i];
return doc;
}
}
return null;
};
/**
* Transaction methods
*/
/** start the transation */
Collection.prototype.startTransaction = function () {
if (this.transactional) {
this.cachedData = clone(this.data, 'parse-stringify');
this.cachedIndex = this.idIndex;
this.cachedBinaryIndex = this.binaryIndices;
// propagate startTransaction to dynamic views
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
this.DynamicViews[idx].startTransaction();
}
}
};
/** commit the transation */
Collection.prototype.commit = function () {
if (this.transactional) {
this.cachedData = null;
this.cachedIndex = null;
this.cachedBinaryIndices = null;
// propagate commit to dynamic views
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
this.DynamicViews[idx].commit();
}
}
};
/** roll back the transation */
Collection.prototype.rollback = function () {
if (this.transactional) {
if (this.cachedData !== null && this.cachedIndex !== null) {
this.data = this.cachedData;
this.idIndex = this.cachedIndex;
this.binaryIndices = this.cachedBinaryIndex;
}
// propagate rollback to dynamic views
for (var idx = 0; idx < this.DynamicViews.length; idx++) {
this.DynamicViews[idx].rollback();
}
}
};
// async executor. This is only to enable callbacks at the end of the execution.
Collection.prototype.async = function (fun, callback) {
setTimeout(function () {
if (typeof fun === 'function') {
fun();
callback();
} else {
throw 'Argument passed for async execution is not a function';
}
}, 0);
};
/**
* Create view function - filter
*/
Collection.prototype.where = function (fun) {
// find logic moved into Resultset class
return new Resultset(this, null, fun);
};
/**
* Map Reduce
*/
Collection.prototype.mapReduce = function (mapFunction, reduceFunction) {
try {
return reduceFunction(this.data.map(mapFunction));
} catch (err) {
throw err;
}
};
/**
* eqJoin - Join two collections on specified properties
*/
Collection.prototype.eqJoin = function (joinData, leftJoinProp, rightJoinProp, mapFun) {
// logic in Resultset class
return new Resultset(this).eqJoin(joinData, leftJoinProp, rightJoinProp, mapFun);
};
/* ------ STAGING API -------- */
/**
* stages: a map of uniquely identified 'stages', which hold copies of objects to be
* manipulated without affecting the data in the original collection
*/
Collection.prototype.stages = {};
/**
* create a stage and/or retrieve it
*/
Collection.prototype.getStage = function (name) {
if (!this.stages[name]) {
this.stages[name] = {};
}
return this.stages[name];
};
/**
* a collection of objects recording the changes applied through a commmitStage
*/
Collection.prototype.commitLog = [];
/**
* create a copy of an object and insert it into a stage
*/
Collection.prototype.stage = function (stageName, obj) {
var copy = JSON.parse(JSON.stringify(obj));
this.getStage(stageName)[obj.$loki] = copy;
return copy;
};
/**
* re-attach all objects to the original collection, so indexes and views can be rebuilt
* then create a message to be inserted in the commitlog
*/
Collection.prototype.commitStage = function (stageName, message) {
var stage = this.getStage(stageName),
prop,
timestamp = new Date().getTime();
for (prop in stage) {
this.update(stage[prop]);
this.commitLog.push({
timestamp: timestamp,
message: message,
data: JSON.parse(JSON.stringify(stage[prop]))
});
}
this.stages[stageName] = {};
};
Collection.prototype.no_op = function () {
return;
};
Collection.prototype.extract = function (field) {
var i = 0,
len = this.data.length,
isDotNotation = isDeepProperty(field),
result = [];
for (i; i < len; i += 1) {
result.push(deepProperty(this.data[i], field, isDotNotation));
}
return result;
};
Collection.prototype.max = function (field) {
return Math.max.apply(null, this.extract(field));
};
Collection.prototype.min = function (field) {
return Math.min.apply(null, this.extract(field));
};
Collection.prototype.maxRecord = function (field) {
var i = 0,
len = this.data.length,
deep = isDeepProperty(field),
result = {
index: 0,
value: undefined
},
max;
for (i; i < len; i += 1) {
if (max !== undefined) {
if (max < deepProperty(this.data[i], field, deep)) {
max = deepProperty(this.data[i], field, deep);
result.index = this.data[i].$loki;
}
} else {
max = deepProperty(this.data[i], field, deep);
result.index = this.data[i].$loki;
}
}
result.value = max;
return result;
};
Collection.prototype.minRecord = function (field) {
var i = 0,
len = this.data.length,
deep = isDeepProperty(field),
result = {
index: 0,
value: undefined
},
min;
for (i; i < len; i += 1) {
if (min !== undefined) {
if (min > deepProperty(this.data[i], field, deep)) {
min = deepProperty(this.data[i], field, deep);
result.index = this.data[i].$loki;
}
} else {
min = deepProperty(this.data[i], field, deep);
result.index = this.data[i].$loki;
}
}
result.value = min;
return result;
};
Collection.prototype.extractNumerical = function (field) {
return this.extract(field).map(parseBase10).filter(Number).filter(function (n) {
return !(isNaN(n));
});
};
Collection.prototype.avg = function (field) {
return average(this.extractNumerical(field));
};
Collection.prototype.stdDev = function (field) {
return standardDeviation(this.extractNumerical(field));
};
Collection.prototype.mode = function (field) {
var dict = {},
data = this.extract(field);
data.forEach(function (obj) {
if (dict[obj]) {
dict[obj] += 1;
} else {
dict[obj] = 1;
}
});
var max,
prop, mode;
for (prop in dict) {
if (max) {
if (max < dict[prop]) {
mode = prop;
}
} else {
mode = prop;
max = dict[prop];
}
}
return mode;
};
Collection.prototype.median = function (field) {
var values = this.extractNumerical(field);
values.sort(sub);
var half = Math.floor(values.length / 2);
if (values.length % 2) {
return values[half];
} else {
return (values[half - 1] + values[half]) / 2.0;
}
};
/**
* General utils, including statistical functions
*/
function isDeepProperty(field) {
return field.indexOf('.') !== -1;
}
function parseBase10(num) {
return parseFloat(num, 10);
}
function isNotUndefined(obj) {
return obj !== undefined;
}
function add(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
function median(values) {
values.sort(sub);
var half = Math.floor(values.length / 2);
return (values.length % 2) ? values[half] : ((values[half - 1] + values[half]) / 2.0);
}
function average(array) {
return (array.reduce(add, 0)) / array.length;
}
function standardDeviation(values) {
var avg = average(values);
var squareDiffs = values.map(function (value) {
var diff = value - avg;
var sqrDiff = diff * diff;
return sqrDiff;
});
var avgSquareDiff = average(squareDiffs);
var stdDev = Math.sqrt(avgSquareDiff);
return stdDev;
}
function deepProperty(obj, property, isDeep) {
if (isDeep === false) {
// pass without processing
return obj[property];
}
var pieces = property.split('.'),
root = obj;
while (pieces.length > 0) {
root = root[pieces.shift()];
}
return root;
}
function binarySearch(array, item, fun) {
var lo = 0,
hi = array.length,
compared,
mid;
while (lo < hi) {
mid = ((lo + hi) / 2) | 0;
compared = fun.apply(null, [item, array[mid]]);
if (compared === 0) {
return {
found: true,
index: mid
};
} else if (compared < 0) {
hi = mid;
} else {
lo = mid + 1;
}
}
return {
found: false,
index: hi
};
}
function BSonSort(fun) {
return function (array, item) {
return binarySearch(array, item, fun);
};
}
function KeyValueStore() {}
KeyValueStore.prototype = {
keys: [],
values: [],
sort: function (a, b) {
return (a < b) ? -1 : ((a > b) ? 1 : 0);
},
setSort: function (fun) {
this.bs = new BSonSort(fun);
},
bs: function () {
return new BSonSort(this.sort);
},
set: function (key, value) {
var pos = this.bs(this.keys, key);
if (pos.found) {
this.values[pos.index] = value;
} else {
this.keys.splice(pos.index, 0, key);
this.values.splice(pos.index, 0, value);
}
},
get: function (key) {
return this.values[binarySearch(this.keys, key, this.sort).index];
}
};
function UniqueIndex(uniqueField) {
this.field = uniqueField;
this.keyMap = {};
this.lokiMap = {};
}
UniqueIndex.prototype.keyMap = {};
UniqueIndex.prototype.lokiMap = {};
UniqueIndex.prototype.set = function (obj) {
if (this.keyMap[obj[this.field]]) {
throw new Error('Duplicate key for property ' + this.field + ': ' + obj[this.field]);
} else {
this.keyMap[obj[this.field]] = obj;
this.lokiMap[obj.$loki] = obj[this.field];
}
};
UniqueIndex.prototype.get = function (key) {
return this.keyMap[key];
};
UniqueIndex.prototype.byId = function (id) {
return this.keyMap[this.lokiMap[id]];
};
UniqueIndex.prototype.update = function (obj) {
if (this.lokiMap[obj.$loki] !== obj[this.field]) {
var old = this.lokiMap[obj.$loki];
this.set(obj);
// make the old key fail bool test, while avoiding the use of delete (mem-leak prone)
this.keyMap[old] = undefined;
} else {
this.keyMap[obj[this.field]] = obj;
}
};
UniqueIndex.prototype.remove = function (key) {
var obj = this.keyMap[key];
this.keyMap[key] = undefined;
this.lokiMap[obj.$loki] = undefined;
};
UniqueIndex.prototype.clear = function () {
this.keyMap = {};
this.lokiMap = {};
};
// UniqueIndex.prototype = {
// keyMap: {},
// lokiMap: {},
// set: function (obj) {
// console.log('setting obj', obj);
// if (this.keyMap[obj[this.field]]) {
// throw new Error('Duplicate key for property ' + this.field + ': ' + obj[this.field]);
// } else {
// this.keyMap[obj[this.field]] = obj;
// this.lokiMap[obj.$loki] = obj[this.field];
// }
// },
// get: function (key) {
// return this.keyMap[key];
// },
// byId: function (id) {
// return this.keyMap[this.lokiMap[id]];
// },
// update: function (obj) {
// if (this.lokiMap[obj.$loki] !== obj[this.field]) {
// var old = this.lokiMap[obj.$loki];
// this.set(obj);
// // make the old key fail bool test, while avoiding the use of delete (mem-leak prone)
// this.keyMap[old] = undefined;
// } else {
// this.keyMap[obj[this.field]] = obj;
// }
// },
// remove: function (key) {
// var obj = this.keyMap[key];
// this.keyMap[key] = undefined;
// this.lokiMap[obj.$loki] = undefined;
// },
// clear: function () {
// this.keyMap = {};
// this.lokiMap = {};
// }
// };
function ExactIndex(exactField) {
this.index = {};
this.field = exactField;
}
// add the value you want returned to the key in the index
ExactIndex.prototype = {
set: function add(key, val) {
if (this.index[key]) {
this.index[key].push(val);
} else {
this.index[key] = [val];
}
},
// remove the value from the index, if the value was the last one, remove the key
remove: function remove(key, val) {
var idxSet = this.index[key];
for (var i in idxSet) {
if (idxSet[i] == val) {
idxSet.splice(i, 1);
}
}
if (idxSet.length < 1) {
this.index[key] = undefined;
}
},
// get the values related to the key, could be more than one
get: function get(key) {
return this.index[key];
},
// clear will zap the index
clear: function clear(key) {
this.index = {};
}
};
function SortedIndex(sortedField) {
this.field = sortedField;
}
SortedIndex.prototype = {
keys: [],
values: [],
// set the default sort
sort: function (a, b) {
return (a < b) ? -1 : ((a > b) ? 1 : 0);
},
bs: function () {
return new BSonSort(this.sort);
},
// and allow override of the default sort
setSort: function (fun) {
this.bs = new BSonSort(fun);
},
// add the value you want returned to the key in the index
set: function (key, value) {
var pos = binarySearch(this.keys, key, this.sort);
if (pos.found) {
this.values[pos.index].push(value);
} else {
this.keys.splice(pos.index, 0, key);
this.values.splice(pos.index, 0, [value]);
}
},
// get all values which have a key == the given key
get: function (key) {
var bsr = binarySearch(this.keys, key, this.sort);
if (bsr.found) {
return this.values[bsr.index];
} else {
return [];
}
},
// get all values which have a key < the given key
getLt: function (key) {
var bsr = binarySearch(this.keys, key, this.sort);
var pos = bsr.index;
if (bsr.found) pos--;
return this.getAll(key, 0, pos);
},
// get all values which have a key > the given key
getGt: function (key) {
var bsr = binarySearch(this.keys, key, this.sort);
var pos = bsr.index;
if (bsr.found) pos++;
return this.getAll(key, pos, this.keys.length);
},
// get all vals from start to end
getAll: function (key, start, end) {
var results = [];
for (var i = start; i < end; i++) {
results = results.concat(this.values[i]);
}
return results;
},
// just in case someone wants to do something smart with ranges
getPos: function (key) {
return binarySearch(this.keys, key, this.sort);
},
// remove the value from the index, if the value was the last one, remove the key
remove: function (key, value) {
var pos = binarySearch(this.keys, key, this.sort).index;
var idxSet = this.values[pos];
for (var i in idxSet) {
if (idxSet[i] == value) idxSet.splice(i, 1);
}
if (idxSet.length < 1) {
this.keys.splice(pos, 1);
this.values.splice(pos, 1);
}
},
// clear will zap the index
clear: function (key) {
this.keys = [];
this.values = [];
}
};
Loki.Collection = Collection;
Loki.KeyValueStore = KeyValueStore;
return Loki;
}());
}));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment