pocketjs
Version:
localStorage wrapper which exposes a simple MongoDB-like syntax
802 lines (720 loc) • 19.8 kB
JavaScript
/**
* Pocket.js v2.2.0
*
* @file A blazing fast lightweight storage library
* @author Vincent Racine vincentracine@hotmail.co.uk
* @license MIT
*/
function Pocket(options){
'use strict';
var Utils = {
/**
* Checks a value if of type array
* @param {*} arg
* @returns {boolean}
*/
isArray: function(arg){
return Object.prototype.toString.call(arg) === '[object Array]';
},
/**
* Checks a value if of type object
* @param {*} arg
* @returns {boolean}
*/
isObject: function(arg){
return Object.prototype.toString.call(arg) === '[object Object]';
},
/**
* Recursively merge two objects
* @param obj1
* @param obj2
* @returns {*}
*/
merge: function(obj1, obj2){
for (var p in obj2) {
try {
if(obj2[p].constructor == Object) {
obj1[p] = Utils.merge(obj1[p], obj2[p]);
}else{
obj1[p] = obj2[p];
}
}catch(e) {
obj1[p] = obj2[p];
}
}
return obj1;
},
/**
* Clone object
*/
clone: function(arg){
return (JSON.parse(JSON.stringify(arg)));
},
/**
* Resolve object field value passed on string path.
* Thank you http://stackoverflow.com/a/22129960/5678694!
* @param path
* @param object
* @returns {*}
*/
resolve: function(path, object){
return path.split('.').reduce(function(prev, curr) {
return prev ? prev[curr] : undefined
}, object || self)
},
/**
* Generates an id with a extremely low chance of collision
* @returns {string} ID
*/
uuid: function(){
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {var r = Math.random()*16|0,v=c=='x'?r:r&0x3|0x8;return v.toString(16);});
}
};
var Query = {
/**
* Formats a DB query
* @param {object|string|number} [query] DB query to format
*/
format: function(query){
if(!query) return {};
if(typeof query === 'string' || typeof query === 'number') return {_id:query};
return query;
},
/**
* Finds documents which are valid based on a query
*
* @param document
* @param query
* @returns {boolean} valid
*/
compare: function(document, query){
var keys = Object.keys(query),
condition,
operator;
for (var i = 0; i < keys.length; i++) {
condition = { name: keys[i], value: query[keys[i]] };
// Actual field value
var value = Utils.resolve(condition.name, document);
if(typeof value === 'undefined' && typeof Query.Operators[condition.name] !== 'function') {
return false;
}
if(typeof Query.Operators[condition.name] === 'function'){
return Query.Operators[condition.name](document, condition.value)
}else if(typeof condition.value === 'object'){
operator = Object.keys(condition.value)[0];
if(typeof Query.Operators[operator] === 'function'){
return Query.Operators[operator](value, condition.value[operator])
}else{
throw new Error("Unrecognised operator '" + operator + "'");
}
}else{
return Query.Operators.$eq(value, condition.value);
}
}
return true;
},
/**
* Comparison operators
* @see https://docs.mongodb.org/manual/reference/operator/query-comparison/
*/
Operators: {
/**
* Equality test
*
* @example
* Examples.find({ forename: { $eq: 'Foo' } });
*
* @example
* Examples.find({ forename: 'Foo' }); // Shorthand
* Examples.find({ forename: { $eq: 'Foo' } });
*
* @param a
* @param b
* @return {boolean} result
*/
'$eq': function(a,b){
return a == b;
},
/**
* Inequality test
*
* @example
* Examples.find({ forename: { $ne: 'Foo' } });
*
* @param a
* @param b
* @return {boolean} result
*/
'$ne': function(a,b){
return a != b;
},
/**
* Or test
*
* @example
* Examples.find({ $or: [{ name:'Foo' },{ name:'Bar' }] });
*
* @param a
* @param b
*/
'$or': function(a,b){
// Throw an error if not passed an array of possibilities
if(!Utils.isArray(b)){
throw new Error('$or Operator expects an Array')
}
var i;
if(Utils.isObject(a)){
for (i = 0; i < b.length; i++) {
if(Query.compare(a, b[i])){
return true;
}
}
}else{
// Test each value from array of possibilities
for (i = b.length; i >= 0; i--) {
if(this.$eq(a,b[i])){
// Satisfied, return true
return true;
}
}
}
// Failed to satisfy, return false
return false;
},
/**
* Greater than test
*
* @example
* Examples.find({ age: { $gt: 17 } });
*
* @param a
* @param b
*/
'$gt': function(a,b){
return a > b;
},
/**
* Greater than or equal test
*
* @example
* Examples.find({ age: { $gte: 18 } });
*
* @param a
* @param b
*/
'$gte': function(a,b){
return a >= b;
},
/**
* Less than test
*
* @example
* Examples.find({ age: { $lt: 18 } });
*
* @param a
* @param b
*/
'$lt': function(a,b){
return a < b;
},
/**
* Less than or equal test
*
* @example
* Examples.find({ age: { $lte: 18 } });
*
* @param a
* @param b
*/
'$lte': function(a,b){
return a <= b;
},
/**
* Contains test for strings
*
* @example
* Examples.find({ name: { $contains: "foo" } });
*
* @param a
* @param b
*/
'$contains': function(a,b){
return a.indexOf(b) > -1;
},
/**
* Check whether a key exists within an array
*
* @example
* Examples.find({ age:{ $in: [16,17,18] } });
*
* @param a
* @param b
* @returns {boolean}
*/
'$in': function(a,b){
// Throw an error if not passed an array of possibilities
if(!Utils.isArray(b)){
throw new Error('$in Operator expects an Array')
}
return b.indexOf(a) > -1;
},
/**
* Check whether a key does not exist within an array
*
* @example
* Examples.find({ age:{ $nin: [16,17,18] } });
*
* @param a
* @param b
* @returns {boolean}
*/
'$nin': function(a,b){
// Throw an error if not passed an array of possibilities
if(!Utils.isArray(b)){
throw new Error('$nin Operator expects an Array')
}
return b.indexOf(a) === -1;
},
/**
* Check whether key is data type. Uses standard javascript object types.
*
* @example
* Examples.find({ age:{ $type: "number" } });
*
* @param a
* @param b
*/
'$type': function(a,b){
// Null
if(b === "null"){
return a === null;
}
// Arrays
if(b === "array"){
return Utils.isArray(a);
}
// All other supported types
return typeof a === b;
}
}
};
/**
* Store Object
*
* @example
* var store = new Store();
*
* @returns {Store}
*/
function Store(options){
this.version = '2.2.0';
this.collections = {};
this.options = Utils.merge({autoCommit: true, dbname: "pocket", driver:Pocket.Drivers.DEFAULT}, options || {});
if(!this.options.driver)
throw new Error('Storage driver was not found');
if(this.options.driver === Pocket.Drivers.WEBSQL){
if(!window.hasOwnProperty("openDatabase"))
throw new Error('Web SQL is not supported in your browser');
this.options.driver = openDatabase(this.options.dbname, '1.0', 'Pocket.js datastore', 10 * 1024 * 1024);
}
}
/**
* Collection Object
* @param name Collection name
* @param options Options additional options
* @returns {Collection}
*/
function Collection(name, options){
if(!name)
throw new Error('Collection requires a name');
this.name = name;
this.documents = [];
this.options = options || {};
this.length = 0;
return this;
}
/**
* Document Object
* @param {object} object Document data
* @returns {object} Document data
*/
function Document(object){
if(!Utils.isObject(object))
throw new Error('Invalid argument. Expected an Object.');
if(object.hasOwnProperty('_id') === false)
object._id = Utils.uuid();
this.object = object;
return this.object;
}
Store.prototype = {
/**
* Retrieve a collection from the store.
* If the collection does not exist, one will be created
* using the name passed to the function
*
* @example
* var Examples = Store.collection('example');
*
* @param {string} name Collection name
* @param {object} [options] Options when creating a new collection
* @returns {Collection}
*/
collection: function(name, options){
if(!name)
throw new Error('Invalid argument. Expected a collection name.');
var collection = this.collections[name];
if(!collection){
collection = new Collection(name, options || this.options);
this.collections[name] = collection;
}
return collection;
},
/**
* Removes a collection from the store
*
* @example
* Store.removeCollection('example');
*
* @param {string} name Collection name
* @returns {Store}
*/
removeCollection: function(name){
if(!name)
return this;
var collection = this.collections[name];
if(collection){
collection.destroy();
delete this.collections[name];
}
return this;
},
/**
* Stores a collection into local storage
*
* @param {Collection} [name] Collection name to store into local storage
* @param {Function} [callback] Async callback
*/
commit: function(name, callback){
if(!name)
throw new Error('Invalid arguments. Expected collection name');
var collection = this.collections[name];
if(collection){
collection.commit(callback);
}
return this;
},
/**
* Restore previous version of the store.
* @param options
* @param callback
*/
restore: function(options, callback) {
var self = this,
driver = this.options.driver;
if (typeof options === 'function'){
callback = options;
options = {};
}
callback = callback || function(){};
if(this.options.driver === Pocket.Drivers.DEFAULT ||
this.options.driver === Pocket.Drivers.SESSION_STORAGE){
var storage = this.options.driver;
var len = storage.length;
for(; len--;){
var key = storage.key(len);
if(key.indexOf(this.options.dbname) == 0){
var row = storage.getItem(key);
if(typeof row === 'string'){
var data = JSON.parse(row),
collection;
collection = new Collection(data.name, data.options);
collection.options.driver = driver;
collection.documents = data.documents;
collection.length = data.documents.length;
this.collections[collection.name] = collection;
}
}
}
}
if(this.options.driver.toString() === "[object Database]"){
this.options.driver.transaction(function(tx) {
tx.executeSql('SELECT tbl_name from sqlite_master WHERE type = "table" AND tbl_name != "__WebKitDatabaseInfoTable__"', [], function(tx, results){
var rows = results.rows, count = 0, length = rows.length;
// No tables
if(length == 0){
return callback(null);
}
// Has tables
for (var i = 0, len = rows.length; i < len; i++) {
tx.executeSql('SELECT json from ' + rows.item(i).tbl_name + ' LIMIT 1', [], function(tx, results){
var rows = results.rows;
for (var i = 0, len = rows.length; i < len; i++) {
var json = rows.item(i).json;
if(typeof json === 'string'){
var data = JSON.parse(json),
collection;
collection = new Collection(data.name, data.options);
collection.options.driver = driver;
collection.documents = data.documents;
collection.length = data.documents.length;
self.collections[collection.name] = collection;
}
// Increment count or exit
if(count == length - 1){
callback(null);
}else{
count++;
}
}
});
}
}, function(tx, error){
callback(error);
});
});
}
return this;
},
/**
* Clean-up after ourselves
*/
destroy: function(){
for (var collection in this.collections) {
if(this.collections.hasOwnProperty(collection)){
if(collection instanceof Collection){
collection.destroy();
delete this.collections[collection];
}
}
}
this.collections = [];
}
};
Collection.prototype = {
/**
* Inserts data into a collection
*
* @example
* var Examples = Store.addCollection('example');
* Examples.insert({ forename: 'Foo', surname: 'Bar' });
* Examples.insert([{ forename: 'Pete', surname: 'Johnson' }, { forename: 'Joe', surname: 'Bloggs' }])
*
* @param {object|Array} doc Data to be inserted into the collection. Can also be array of data.
* @param {Function} [callback] Async callback
* @returns {Document|Array}
*/
insert: function(doc, callback){
var document;
if(Utils.isArray(doc)){
document = doc.map(function(document){
document = new Document(document);
this.documents.push(document);
return document;
}, this);
}else{
document = new Document(doc);
this.documents.push(document);
}
this.length = this.documents.length;
if(this.options.autoCommit){
this.commit(callback);
}
return document;
},
/**
* Returns an array of documents which satisfy the query given
*
* @example
* var Examples = Store.addCollection('example');
* Examples.insert({ _id: '1', forename: 'Foo', surname: 'Bar' });
* Examples.insert({ _id: '2', forename: 'Bar', surname: 'Foo' });
* Examples.insert({ _id: '3', forename: 'Foo', surname: 'Bar' });
* console.log(Examples.length) // 2
*
* var results = Examples.find({ forename: 'Foo' });
* console.log(results) // [{ _id: '1', forename: 'Foo', surname: 'Bar' }, { _id: '3', forename: 'Foo', surname: 'Bar' }]
*
* @param {object|number|string} [query] Query which tests for valid documents
* @return {Collection[]}
*/
find: function(query){
var keys,
results;
// Get clone of documents in collection
results = this.documents.slice(0);
query = Query.format(query);
// Get query keys
keys = Object.keys(query);
while(keys.length > 0){
// Break out of loop if we have 0 documents in result
if(results.length === 0){
break;
}
results = results.filter(function(document){
var part = {};
part[keys[0]] = query[keys[0]];
return Query.compare(document, part)
});
// Remove query key
keys.splice(0,1);
}
// Return results to caller
return results;
},
/**
* Returns the first document which satisfied the query given
*
* @example
* var Examples = Store.addCollection('example');
* Examples.insert({ _id: '1', forename: 'Foo', surname: 'Bar' });
* Examples.insert({ _id: '2', forename: 'Foo', surname: 'Bar' });
* console.log(Examples.length) // 2
*
* var result = Examples.findOne({ forename: 'Foo', surname: 'Bar' });
* console.log(result) // { _id: '1', forename: 'Foo', surname: 'Bar' }
*
* @param {object|number|string} [query] Query which tests for valid documents
* @return {Collection}
*/
findOne: function(query){
return this.find(query)[0] || null;
},
/**
* Updates an existing document inside the collection
* Supports partial updates
*
* @example
* var Examples = Store.addCollection('example');
* Examples.insert({ _id: 0, forename: 'Foo', surname: 'Bar' });
* Examples.update({ _id: 0 },{ title: 'Mrs' });
*
* var result = Examples.findOne({ _id:0 });
* console.log(result) // { _id: '0', forename: 'Foo', surname: 'Bar', title: 'Mrs' }
*
* @param {object|number|string} [query] Query which tests for valid documents
* @param {object} doc Data to be inserted into the collection
* @param {Function} [callback] Async callback
* @returns {Collection}
*/
update: function(query, doc, callback){
var documents = this.find(Query.format(query));
// Iterate through query results and update
documents.forEach(function(document){
// Get index of document in the collection
var index = this.documents.indexOf(document);
// If index is not -1 (means it wasn't found in the array)
if(index !== -1){
// Merge currently record with update object
this.documents[index] = new Document(Utils.merge(this.documents[index], doc));
}
}, this);
if(this.options.autoCommit){
this.commit(callback);
}
// Return collection
return this;
},
/**
* Removes documents which satisfy the query given
*
* @example
* var Examples = Store.addCollection('example');
* Examples.insert({ _id: '394', forename: 'Foo', surname: 'Bar' });
* console.log(Examples.length) // 1
* Examples.remove({ _id: '394' });
* console.log(Examples.length) // 0
*
* @example
* var Examples = Store.addCollection('example');
* Examples.insert({ _id: '394', forename: 'Foo', surname: 'Bar' });
* console.log(Examples.length) // 1
* Examples.remove({ forename: 'Foo' });
* console.log(Examples.length) // 0
*
* @param {object|number|string} [query] Query which tests for valid documents
* @param {Function} [callback] Async callback
* @return {Collection}
*/
remove: function(query, callback){
var documents = this.find(Query.format(query));
// Iterate through query results
documents.forEach(function(document){
// Get index of document in the collection
var index = this.documents.indexOf(document);
// If index is not -1 (means it wasn't found in the array)
if(index !== -1){
// If found in the array, remove it
this.documents.splice(index, 1);
// Update the length of the collection
this.length--;
}
}, this);
if(this.options.autoCommit){
this.commit(callback);
}
// Return collection
return this;
},
/**
* Stores the collection into local storage
*
* @return {Collection}
*/
commit: function(callback){
var name = this.name,
collection = JSON.parse(JSON.stringify(this));
// Convert storage
delete collection.options.driver;
// Convert to JSON
var json = JSON.stringify(collection);
callback = callback || function(){};
if(this.options.driver === Pocket.Drivers.DEFAULT ||
this.options.driver === Pocket.Drivers.SESSION_STORAGE){
this.options.driver.setItem(this.options.dbname.concat("." + this.name), json);
}
if(this.options.driver.toString() === "[object Database]"){
this.options.driver.transaction(function(tx) {
tx.executeSql('DROP TABLE IF EXISTS ' + name);
tx.executeSql('CREATE TABLE ' + name + ' (json)');
tx.executeSql('INSERT INTO ' + name + ' (json) VALUES (?)', [json], function(tx, result){
callback(null, tx, result);
}, function(tx, error){
callback(error);
});
});
}
return this;
},
/**
* Returns the size of the collection
* @returns {Number} size of collection
*/
size: function(){
return this.documents.length;
},
/**
* Delete collection contents
*/
destroy: function(){
// Force auto commit
if(!this.options.autoCommit)
this.options.autoCommit = true;
// Remove all documents in collection
this.remove();
this.documents = this.options = this.name = null;
}
};
return new Store(options);
}
Pocket.Drivers = {
'DEFAULT': window.localStorage,
'LOCAL_STORAGE': window.localStorage,
'SESSION_STORAGE': window.sessionStorage,
'WEBSQL': 'WEBSQL'
};
if(typeof exports !== 'undefined') {
if( typeof module !== 'undefined' && module.exports ) {
exports = module.exports = Pocket
}
exports.Pocket = Pocket
}