Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial implementation of local storage (IndexedDB, and WebSQL) store…

… implementation
  • Loading branch information...
commit fd6ab82182aa07f1a4aa53f7c62bba6f7e37a621 1 parent 47297c2
@kriszyp kriszyp authored
View
8 store/LocalDB.js
@@ -0,0 +1,8 @@
+define(["./db/has!indexeddb?./db/IndexedDB:./db/SQL"],
+ function(LocalDB){
+ // module:
+ // ./store/LocalDB
+ // summary:
+ // The module defines an object store based on local database access
+ return LocalDB;
+});
View
671 store/db/IndexedDB.js
@@ -0,0 +1,671 @@
+define(['dojo/_base/declare', 'dojo/_base/lang', 'dojo/Deferred', 'dojo/when', 'dojo/promise/all', 'dojo/store/util/SimpleQueryEngine', 'dojo/store/util/QueryResults'],
+ function(declare, lang, Deferred, when, all, SimpleQueryEngine, QueryResults){
+
+ function makePromise(request) {
+ var deferred = new Deferred();
+ request.onsuccess = function(event) {
+ deferred.resolve(event.target.result);
+ };
+ request.onerror = function() {
+ request.error.message = request.webkitErrorMessage;
+ deferred.reject(request.error);
+ };
+ return deferred.promise;
+ }
+
+ // we keep a queue of cursors, so we can prioritize the traversal of result sets
+ var cursorQueue = [];
+ var maxConcurrent = 1;
+ var cursorsRunning = 0;
+ var wildcardRe = /(.*)\*$/;
+ function queueCursor(cursor, priority, retry) {
+ // process the cursor queue, possibly submitting a cursor for continuation
+ if (cursorsRunning || cursorQueue.length) {
+ // actively processing
+ if (cursor) {
+ // add to queue
+ cursorQueue.push({cursor: cursor, priority: priority, retry: retry});
+ // keep the queue in priority order
+ cursorQueue.sort(function(a, b) {
+ return a.priority > b.priority ? 1 : -1;
+ });
+ }
+ if (cursorsRunning >= maxConcurrent) {
+ return;
+ }
+ var cursorObject = cursorQueue.pop();
+ cursor = cursorObject && cursorObject.cursor;
+ }//else nothing in the queue, just shortcut directly to continuing the cursor
+ if (cursor) {
+ try {
+ // submit the continuation of the highest priority cursor
+ cursor['continue']();
+ cursorsRunning++;
+ } catch(e) {
+ if ((e.name === 'TransactionInactiveError' || e.name === 0) && cursorObject) { // == 0 is IndexedDBShim
+ // if the cursor has been interrupted we usually need to create a new transaction,
+ // handing control back to the query/filter function to open the cursor again
+ cursorObject.retry();
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+ function yes(){
+ return true;
+ }
+
+ // a query results API based on a source with a filter method that is expected to be called only once. All iterative methods are
+ // implemented in terms of forEach that will call the filter only once and subsequently use the promised results.
+ // will also copy the `total` property as well.
+ function queryFromFilter(source) {
+ var promisedResults, started, callbacks = [];
+ // this is the main iterative function that will ensure we will only do a low level iteratation of the result set once.
+ function forEach(callback, thisObj) {
+ if (started) {
+ // we have already iterated the query results, just hook into the existing promised results
+ callback && promisedResults.then(function(results) {
+ results.forEach(callback, thisObj);
+ });
+ } else {
+ // first call, start the filter iterator, getting the results as a promise, so we can connect to that each subsequent time
+ callback && callbacks.push(callback);
+ if(!promisedResults){
+ promisedResults = source.filter(function(value) {
+ started = true;
+ for(var i = 0, l = callbacks.length; i < l; i++){
+ callbacks[i].call(thisObj, value);
+ }
+ return true;
+ });
+ }
+ }
+ return promisedResults;
+ }
+
+ return {
+ total: source.total,
+ filter: function(callback, thisObj) {
+ var done;
+ return forEach(function(value) {
+ if (!done) {
+ done = !callback.call(thisObj, value);
+ }
+ });
+ },
+ forEach: forEach,
+ map: function(callback, thisObj) {
+ var mapped = [];
+ return forEach(function(value) {
+ mapped.push(callback.call(thisObj, value));
+ }).then(function() {
+ return mapped;
+ });
+ },
+ then: function(callback, errback) {
+ return forEach().then(callback, errback);
+ }
+ };
+ }
+
+ var IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange;
+ return declare(null, {
+ // summary:
+ // This is a basic store for IndexedDB. It implements dojo/store/api/Store.
+
+ constructor: function(options) {
+ // summary:
+ // This is a basic store for IndexedDB.
+ // options:
+ // This provides any configuration information that will be mixed into the store
+
+ declare.safeMixin(this, options);
+ var store = this;
+ var dbConfig = this.dbConfig;
+ this.indices = dbConfig.stores[this.storeName];
+ this.cachedCount = {};
+ for (var index in store.indices) {
+ var value = store.indices[index];
+ if (typeof value === 'number') {
+ store.indices[index] = {
+ preference: value
+ };
+ }
+ }
+ this.db = this.db || dbConfig.db;
+
+ if (!this.db) {
+ var openRequest = dbConfig.openRequest;
+ if (!openRequest) {
+ openRequest = dbConfig.openRequest = window.indexedDB.open(dbConfig.name || 'dojo-db',
+ parseInt(dbConfig.version, 10));
+ openRequest.onupgradeneeded = function() {
+ var db = store.db = openRequest.result;
+ for (var storeName in dbConfig.stores) {
+ var storeConfig = dbConfig.stores[storeName];
+ if (!db.objectStoreNames.contains(storeName)) {
+ var idProperty = storeConfig.idProperty || 'id';
+ var idbStore = db.createObjectStore(storeName, {
+ keyPath: idProperty,
+ autoIncrement: storeConfig[idProperty] &&
+ storeConfig[idProperty].autoIncrement || false
+ });
+ } else {
+ idbStore = openRequest.transaction.objectStore(storeName);
+ }
+ for (var index in storeConfig) {
+ if (!idbStore.indexNames.contains(index) && index !== 'autoIncrement') {
+ idbStore.createIndex(index, index, storeConfig[index]);
+ }
+ }
+ }
+ };
+ dbConfig.available = makePromise(openRequest);
+ }
+ this.available = dbConfig.available.then(function(db){
+ return store.db = db;
+ });
+ }
+ },
+
+ // idProperty: String
+ // Indicates the property to use as the identity property. The values of this
+ // property should be unique.
+ idProperty: 'id',
+
+ storeName: '',
+
+ // indices:
+ // a hash of the preference of indices, indices that are likely to have very
+ // unique values should have the highest numbers
+ // as a reference, sorting is always set at 1, so properties that are higher than
+ // one will trigger filtering with index and then sort the whole set.
+ // we recommend setting boolean values at 0.1.
+ indices: {
+ // property: {
+ // preference: 1,
+ // multiEntry: true
+ // }
+ },
+
+ queryEngine: SimpleQueryEngine,
+
+ transaction: function() {
+ var store = this;
+ this._currentTransaction = null;// get rid of the last transaction
+ return {
+ abort: function() {
+ store._currentTransaction.abort();
+ },
+ commit: function() {
+ // noop, idb does auto-commits
+ store._currentTransaction = null;// get rid of the last transaction
+ }
+ }
+ },
+
+ _getTransaction: function() {
+ if (!this._currentTransaction) {
+ this._currentTransaction = this.db.transaction([this.storeName], 'readwrite');
+ var store = this;
+ this._currentTransaction.oncomplete = function() {
+ // null it out so we will use a new one next time
+ store._currentTransaction = null;
+ };
+ this._currentTransaction.onerror = function(error) {
+ console.error(error);
+ };
+ }
+ return this._currentTransaction;
+ },
+
+ _callOnStore: function(method, args, index, returnRequest) {
+ // calls a method on the IndexedDB store
+ var store = this;
+ return when(this.available, function callOnStore() {
+ var currentTransaction = store._currentTransaction;
+ if (currentTransaction) {
+ var allowRetry = true;
+ } else {
+ currentTransaction = store._getTransaction();
+ }
+ var request, idbStore;
+ if (allowRetry) {
+ try {
+ idbStore = currentTransaction.objectStore(store.storeName);
+ if (index) {
+ idbStore = idbStore.index(index);
+ }
+ request = idbStore[method].apply(idbStore, args);
+ } catch(e) {
+ if (e.name === 'TransactionInactiveError' || e.name === 'InvalidStateError') {
+ store._currentTransaction = null;
+ //retry
+ return callOnStore();
+ } else {
+ throw e;
+ }
+ }
+ } else {
+ idbStore = currentTransaction.objectStore(store.storeName);
+ if (index) {
+ idbStore = idbStore.index(index);
+ }
+ request = idbStore[method].apply(idbStore, args);
+ }
+ return returnRequest ? request : makePromise(request);
+ });
+ },
+
+ get: function(id) {
+ // summary:
+ // Retrieves an object by its identity.
+ // id: Number
+ // The identity to use to lookup the object
+ // options: Object?
+ // returns: dojo//Deferred
+
+ return this._callOnStore('get',[id]);
+ },
+
+ getIdentity: function(object) {
+ // summary:
+ // Returns an object's identity
+ // object: Object
+ // The object to get the identity from
+ // returns: Number
+
+ return object[this.idProperty];
+ },
+
+ put: function(object, options) {
+ // summary:
+ // Stores an object.
+ // object: Object
+ // The object to store.
+ // options: __PutDirectives?
+ // Additional metadata for storing the data. Includes an "id"
+ // property if a specific id is to be used.
+ // returns: dojo/Deferred
+
+ options = options || {};
+ this.cachedCount = {}; // clear the count cache
+ return this._callOnStore(options.overwrite === false ? 'add' : 'put',[object]);
+ },
+
+ add: function(object, options) {
+ // summary:
+ // Adds an object.
+ // object: Object
+ // The object to store.
+ // options: __PutDirectives?
+ // Additional metadata for storing the data. Includes an "id"
+ // property if a specific id is to be used.
+ // returns: dojo/Deferred
+
+ options = options || {};
+ options.overwrite = false;
+ return this.put(object, options);
+ },
+
+ remove: function(id) {
+ // summary:
+ // Deletes an object by its identity.
+ // id: Number
+ // The identity to use to delete the object
+ // returns: dojo/Deferred
+
+ this.cachedCount = {}; // clear the count cache
+ return this._callOnStore('delete', [id]);
+ },
+
+ query: function(query, options) {
+ // summary:
+ // Queries the store for objects.
+ // query: Object
+ // The query to use for retrieving objects from the store.
+ // options: __QueryOptions?
+ // The optional arguments to apply to the resultset.
+ // returns: dojo/store/api/Store.QueryResults
+ // The results of the query, extended with iterative methods.
+
+ options = options || {};
+ var start = options.start || 0;
+ var count = options.count || Infinity;
+ var sortOption = options.sort;
+ var store = this;
+
+ // an array, do a union
+ if (query.forEach) {
+ var sortOptions = {sort: sortOption};
+ var sorter = this.queryEngine({}, sortOptions);
+ var totals = [];
+ var collectedCount = 0;
+ var inCount = 0;
+ return queryFromFilter({
+ total: {
+ then: function() {
+ // do it lazily again
+ return all(totals).then(function(totals) {
+ return totals.reduce(function(a, b) {
+ return a + b;
+ }) * collectedCount / (inCount || 1);
+ }).then.apply(this, arguments);
+ }
+ },
+ filter: function(callback, thisObj) {
+ var index = 0;
+ var queues = [];
+ var done;
+ var collected = {};
+ var results = [];
+ // wait for all the union segments to complete
+ return all(query.map(function(part, i) {
+ var queue = queues[i] = [];
+ function addToQueue(object) {
+ // to the queue that is kept for each individual query for merge sorting
+ queue.push(object);
+ var nextInQueues = []; // so we can index of the selected choice
+ var toMerge = [];
+ while(queues.every(function(queue) {
+ if (queue.length > 0) {
+ var next = queue[0];
+ if (next) {
+ toMerge.push(next);
+ }
+ return nextInQueues.push(next);
+ }
+ })){
+ if (index >= start + count || toMerge.length === 0) {
+ done = true;
+ return; // exit filter loop
+ }
+ var nextSelected = sorter(toMerge)[0];
+ // shift it off the selected queue
+ queues[nextInQueues.indexOf(nextSelected)].shift();
+ if (index++ >= start) {
+ results.push(nextSelected);
+ if (!callback.call(thisObj, nextSelected)) {
+ done = true;
+ return;
+ }
+ }
+ nextInQueues = [];// reset
+ toMerge = [];
+ }
+ return true;
+
+ }
+ var queryResults = store.query(part, sortOptions);
+ totals[i] = queryResults.total;
+ return queryResults.filter(function(object) {
+ if (done) {
+ return;
+ }
+ var id = store.getIdentity(object);
+ inCount++;
+ if (id in collected) {
+ return true;
+ }
+ collectedCount++;
+ collected[id] = true;
+ return addToQueue(object);
+ }).then(function(results) {
+ // null signifies the end of this particular query result
+ addToQueue(null);
+ return results;
+ });
+ })).then(function() {
+ return results;
+ });
+ }
+ });
+ }
+
+ var keyRange;
+ var alreadySearchedProperty;
+ var queryId = JSON.stringify(query) + '-' + JSON.stringify(options.sort);
+ var advance;
+ var bestIndex, bestIndexQuality = 0;
+ var indexTries = 0;
+ var filterValue;
+
+ function tryIndex(indexName, quality, factor) {
+ indexTries++;
+ var indexDefinition = store.indices[indexName];
+ if (indexDefinition) {
+ quality = quality || indexDefinition.preference * (factor || 1) || 0.001;
+ if (quality > bestIndexQuality) {
+ bestIndexQuality = quality;
+ bestIndex = indexName;
+ return true;
+ }
+ }
+ indexTries++;
+ }
+
+ for (var i in query) {
+ // test all the filters as possible indices to drive the query
+ filterValue = query[i];
+ var range = false;
+ var wildcard, newFilterValue = null;
+
+ if (typeof filterValue === 'boolean') {
+ // can't use booleans as filter keys
+ continue;
+ }
+
+ if (filterValue) {
+ if (filterValue.from || filterValue.to) {
+ range = true;
+ (function(from, to) {
+ // convert a to/from object to a testable object with a keyrange
+ newFilterValue = {
+ test: function(value) {
+ return !from || from <= value &&
+ (!to || to >= value);
+ },
+ keyRange: from ?
+ to ?
+ IDBKeyRange.bound(from, to) :
+ IDBKeyRange.lowerBound(from) :
+ IDBKeyRange.upperBound(to)
+ };
+ })(filterValue.from, filterValue.to);
+ } else if (typeof filterValue === 'object' && filterValue.contains) {
+ // contains is for matching any value in a given array to any value in the target indices array
+ // this expects a multiEntry: true index
+ (function(contains) {
+ var keyRange, first = contains[0];
+
+ var wildcard = first && first.match && first.match(wildcardRe);
+ if (wildcard) {
+ first = wildcard[1];
+ keyRange = IDBKeyRange.bound(first, first + '~');
+ } else {
+ keyRange = IDBKeyRange.only(first);
+ }
+ newFilterValue = {
+ test: function(value) {
+ return contains.every(function(item) {
+ var wildcard = item && item.match && item.match(wildcardRe);
+ if (wildcard) {
+ item = wildcard[1];
+ return value && value.some(function(part) {
+ return part.slice(0, item.length) === item;
+ });
+ }
+ return value && value.indexOf(item) > -1;
+ } );
+ },
+ keyRange: keyRange
+ };
+ })(filterValue.contains);
+ } else if((wildcard = filterValue.match && filterValue.match(wildcardRe))) {
+ // wildcard matching
+ var matchStart = wildcard[1];
+ newFilterValue = new RegExp('^' + matchStart);
+ newFilterValue.keyRange = IDBKeyRange.bound(matchStart, matchStart + '~');
+ }
+ }
+ if (newFilterValue) {
+ query[i] = newFilterValue;
+ }
+ tryIndex(i, null, range ? 0.1 : 1);
+ }
+ var descending;
+ if (sortOption) {
+ // this isn't necessarily the best heuristic to determine the best index
+ var mainSort = sortOption[0];
+ if (mainSort.attribute === bestIndex || tryIndex(mainSort.attribute, 1)) {
+ descending = mainSort.descending;
+ } else {
+ // we need to sort afterwards now
+ var postSorting = true;
+ // we have to retrieve everything in this case
+ start = 0;
+ count = Infinity;
+ }
+ }
+ var cursorRequestArgs;
+ if (bestIndex) {
+ if (bestIndex in query) {
+ // we are filtering
+ filterValue = query[bestIndex];
+ if (filterValue && (filterValue.keyRange)) {
+ keyRange = filterValue.keyRange;
+ } else {
+ keyRange = IDBKeyRange.only(filterValue);
+ }
+ alreadySearchedProperty = bestIndex;
+ } else {
+ keyRange = null;
+ }
+ cursorRequestArgs = [keyRange, descending ? 'prev' : 'next'];
+ } else {
+ // no index, no arguments required
+ cursorRequestArgs = [];
+ }
+ // console.log("using index", bestIndex);
+ var cachedPosition = store.cachedPosition;
+ if (cachedPosition && cachedPosition.queryId === queryId &&
+ cachedPosition.offset < start && indexTries > 1) {
+ advance = cachedPosition.preFilterOffset + 1;
+ // make a new copy, so we don't have concurrency issues
+ store.cachedPosition = cachedPosition = lang.mixin({}, cachedPosition);
+ } else {
+ // cache of the position, tracking our traversal progress
+ cachedPosition = store.cachedPosition = {
+ offset: -1,
+ preFilterOffset: -1,
+ queryId: queryId
+ };
+ if (indexTries < 2) {
+ // can skip to advance
+ cachedPosition.offset = cachedPosition.preFilterOffset = (advance = start) - 1;
+ }
+ }
+ var filter = this.queryEngine(query);
+ // this is adjusted so we can compute the total more accurately
+ var filteredResults = {
+ total: {
+ then: function(callback) {
+ // make this a lazy promise, only executing if we need to
+ var cachedCount = store.cachedCount[queryId];
+ if (cachedCount){
+ return callback(adjustTotal(cachedCount));
+ } else {
+ var countPromise = (keyRange ? store._callOnStore('count', [keyRange], bestIndex) : store._callOnStore('count'));
+ return (this.then = countPromise.then(adjustTotal)).then.apply(this, arguments);
+ }
+ function adjustTotal(total) {
+ // we estimate the total count base on the matching rate
+ store.cachedCount[queryId] = total;
+ return Math.round((cachedPosition.offset + 1.01) / (cachedPosition.preFilterOffset + 1.01) * total);
+ }
+ }
+ },
+ filter: function(callback, thisObj) {
+ // this is main implementation of the the query results traversal, forEach and map use this method
+ var deferred = new Deferred();
+ var all = [];
+ function openCursor() {
+ // get the cursor
+ when(store._callOnStore('openCursor', cursorRequestArgs, bestIndex, true), function(cursorRequest) {
+ // this will be called for each iteration in the traversal
+ cursorsRunning++;
+ cursorRequest.onsuccess = function(event) {
+ cursorsRunning--;
+ var cursor = event.target.result;
+ if (cursor) {
+ if (advance) {
+ // we can advance through and wait for the completion
+ cursor.advance(advance);
+ cursorsRunning++;
+ advance = false;
+ return;
+ }
+ cachedPosition.preFilterOffset++;
+ try {
+ var item = cursor.value;
+ if (options.join) {
+ item = options.join(item);
+ }
+ return when(item, function(item) {
+ if (filter.matches(item)) {
+ cachedPosition.offset++;
+ if (cachedPosition.offset >= start) { // make sure we are after the start
+ all.push(item);
+ if (!callback.call(thisObj, item) || cachedPosition.offset >= start + count - 1) {
+ // finished
+ cursorRequest.lastCursor = cursor;
+ deferred.resolve(all);
+ queueCursor();
+ return;
+ }
+ }
+ }
+ // submit our cursor to the priority queue for continuation, now or when our turn comes up
+ return queueCursor(cursor, options.priority, function() {
+ // retry function, that we provide to the queue to use if the cursor can't be continued due to interruption
+ // if called, open the cursor again, and continue from our current position
+ advance = cachedPosition.preFilterOffset;
+ openCursor();
+ });
+ });
+ } catch(e) {
+ deferred.reject(e);
+ }
+ } else {
+ deferred.resolve(all);
+ }
+ // let any other cursors start executing now
+ queueCursor();
+ };
+ cursorRequest.onerror = function(error) {
+ cursorsRunning--;
+ deferred.reject(error);
+ queueCursor();
+ };
+ });
+ }
+ openCursor();
+ return deferred.promise;
+ }
+ };
+
+ if (postSorting) {
+ // we are using the index to do filtering, so we are going to have to sort the entire list
+ var sorter = this.queryEngine({}, options);
+ var sortedResults = lang.delegate(filteredResults.filter(yes).then(function(results) {
+ return sorter(results);
+ }));
+ sortedResults.total = filteredResults.total;
+ return new QueryResults(sortedResults);
+ }
+ return options.rawResults ? filteredResults : queryFromFilter(filteredResults);
+ }
+ });
+
+});
View
315 store/db/SQL.js
@@ -0,0 +1,315 @@
+define(['dojo/_base/declare', 'dojo/Deferred', 'dojo/when', 'dojo/store/util/QueryResults', 'dojo/_base/lang', 'dojo/promise/all'], function(declare, Deferred, when, QueryResults, lang, all) {
+ // module:
+ // ./store/db/SQL
+ // summary:
+ // This module implements the Dojo object store API using the WebSQL database
+ var wildcardRe = /(.*)\*$/;
+ function convertExtra(object){
+ // converts the 'extra' data on sql rows that can contain expando properties outside of the defined column
+ return object && lang.mixin(object, JSON.parse(object.__extra));
+ }
+ return declare([], {
+ constructor: function(config){
+ var dbConfig = config.dbConfig;
+ // open the database and get it configured
+ // args are short_name, version, display_name, and size
+ this.database = openDatabase(config.dbName || "dojo-db", '1.0', 'dojo-db', 4*1024*1024);
+ var indexPrefix = this.indexPrefix = config.indexPrefix || "idx_";
+ var storeName = config.table || config.storeName;
+ this.table = (config.table || config.storeName).replace(/[^\w]/g, '_');
+ var promises = []; // for all the structural queries
+ // the indices for this table
+ this.indices = dbConfig.stores[storeName];
+ this.repeatingIndices = {};
+ for(var index in this.indices){
+ // we support multiEntry property to simulate the similar behavior in IndexedDB, we track these becaues we use the
+ if(this.indices[index].multiEntry){
+ this.repeatingIndices[index] = true;
+ }
+ }
+ if(!dbConfig.available){
+ // the configuration where we create any necessary tables and indices
+ for(var storeName in dbConfig.stores){
+ var storeConfig = dbConfig.stores[storeName];
+ var table = storeName.replace(/[^\w]/g, '_');
+ // the __extra property contains any expando properties in JSON form
+ var idConfig = storeConfig[this.idProperty];
+ var indices = ['__extra', this.idProperty + ' ' + ((idConfig && idConfig.autoIncrement) ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'PRIMARY KEY')];
+ var repeatingIndices = [this.idProperty];
+ for(var index in storeConfig){
+ if(index != this.idProperty){
+ indices.push(index);
+ }
+ }
+ promises.push(this.executeSql("CREATE TABLE IF NOT EXISTS " + table+ ' ('
+ + indices.join(',') +
+ ')'));
+ for(var index in storeConfig){
+ if(index != this.idProperty){
+ if(storeConfig[index].multiEntry){
+ // it is "repeating" property, meaning that we expect it to have an array, and we want to index each item in the array
+ // we will search on it using a nested select
+ repeatingIndices.push(index);
+ var repeatingTable = table+ '_repeating_' + index;
+ promises.push(this.executeSql("CREATE TABLE IF NOT EXISTS " + repeatingTable + ' (id,value)'));
+ promises.push(this.executeSql("CREATE INDEX IF NOT EXISTS idx_" + repeatingTable + '_id ON ' + repeatingTable + '(id)'));
+ promises.push(this.executeSql("CREATE INDEX IF NOT EXISTS idx_" + repeatingTable + '_value ON ' + repeatingTable + '(value)'));
+ }else{
+ promises.push(this.executeSql("ALTER TABLE " + table + ' ADD ' + index).then(null, function(){
+ /* suppress failed alter table statements*/
+ }));
+ // otherwise, a basic index will do
+ promises.push(this.executeSql("CREATE INDEX IF NOT EXISTS " + indexPrefix + table + '_' + index + ' ON ' + table + '(' + index + ')'));
+ }
+ }
+ }
+ }
+ dbConfig.available = all(promises);
+ }
+ this.available = dbConfig.available;
+ },
+ idProperty: "id",
+ selectColumns: ["*"],
+ get: function(id){
+ // basic get() operation, query by id property
+ return when(this.executeSql("SELECT " + this.selectColumns.join(",") + " FROM " + this.table + " WHERE " + this.idProperty + "=?", [id]), function(result){
+ return result.rows.length > 0 ? convertExtra(result.rows.item(0)) : undefined;
+ });
+ },
+ getIdentity: function(object){
+ return object[this.idProperty];
+ },
+ remove: function(id){
+ return this.executeSql("DELETE FROM " + this.table + " WHERE " + this.idProperty + "=?", [id]); // Promise
+ // TODO: remove from repeating rows too
+ },
+ identifyGeneratedKey: true,
+ add: function(object, directives){
+ // An add() wiill translate to an INSERT INTO in SQL
+ var params = [], vals = [], cols = [];
+ var extra = {};
+ var actionsWithId = [];
+ var store = this;
+ for(var i in object){
+ if(object.hasOwnProperty(i)){
+ if(i in this.indices || i == this.idProperty){
+ if(this.repeatingIndices[i]){
+ // we will need to add to the repeating table for the given field/column,
+ // but it must take place after the insert, so we know the id
+ actionsWithId.push(function(id){
+ var array = object[i];
+ return all(array.map(function(value){
+ return store.executeSql('INSERT INTO ' + store.table + '_repeating_' + i + ' (value, id) VALUES (?, ?)', [value, id]);
+ }));
+ });
+ }else{
+ // add to the columns and values for SQL statement
+ cols.push(i);
+ vals.push('?');
+ params.push(object[i]);
+ }
+ }else{
+ extra[i] = object[i];
+ }
+ }
+ }
+ // add the "extra" expando data as well
+ cols.push('__extra');
+ vals.push('?');
+ params.push(JSON.stringify(extra));
+
+ var idColumn = this.idProperty;
+ if(this.identifyGeneratedKey){
+ params.idColumn = idColumn;
+ }
+ var sql = "INSERT INTO " + this.table + " (" + cols.join(',') + ") VALUES (" + vals.join(',') + ")";
+ return when(this.executeSql(sql, params), function(results) {
+ var id = results.insertId;
+ object[idColumn] = id;
+ // got the id now, perform the insertions for the repeating data
+ return all(actionsWithId.map(function(func){
+ return func(id);
+ })).then(function(){
+ return id;
+ });
+ });
+ },
+ put: function(object, directives){
+ // put, if overwrite is not specified, we have to do a get() to determine if we need to do an INSERT INTO (via add), or an UPDATE
+ directives = directives || {};
+ var id = directives.id || object[this.idProperty];
+ var overwrite = directives.overwrite;
+ if(overwrite === undefined){
+ // can't tell if we need to do an INSERT or UPDATE, do a get() to find out
+ var store = this;
+ return this.get(id).then(function(previous){
+ if((directives.overwrite = !!previous)){
+ directives.overwrite = true;
+ return store.put(object, directives);
+ }else{
+ return store.add(object, directives);
+ }
+ });
+ }
+ if(!overwrite){
+ return store.add(object, directives);
+ }
+ var sql = "UPDATE " + this.table + " SET ";
+ var params = [];
+ var cols = [];
+ var extra = {};
+ var promises = [];
+ for(var i in object){
+ if(object.hasOwnProperty(i)){
+ if(i in this.indices || i == this.idProperty){
+ if(this.repeatingIndices[i]){
+ // update the repeating value tables
+ this.executeSql('DELETE FROM ' + this.table + '_repeating_' + i + ' WHERE id=?', [id]);
+ var array = object[i];
+ for(var j = 0; j < array.length; j++){
+ this.executeSql('INSERT INTO ' + this.table + '_repeating_' + i + ' (value, id) VALUES (?, ?)', [array[j], id]);
+ }
+ }else{
+ cols.push(i + "=?");
+ params.push(object[i]);
+ }
+ }else{
+ extra[i] = object[i];
+ }
+ }
+ }
+ cols.push("__extra=?");
+ params.push(JSON.stringify(extra));
+ // create the SETs for the SQL
+ sql += cols.join(',') + " WHERE " + this.idProperty + "=?";
+ params.push(object[this.idProperty]);
+
+ return when(this.executeSql(sql, params), function(result){
+ return id;
+ });
+ },
+ query: function(query, options){
+ options = options || {};
+ var from = 'FROM ' + this.table;
+ var condition;
+ var addedWhere;
+ var store = this;
+ var table = this.table;
+ var params = [];
+ if(query.forEach){
+ // a set of OR'ed conditions
+ condition = query.map(processObjectQuery).join(') OR (');
+ if(condition){
+ condition = '(' + condition + ')';
+ }
+ }else{
+ // regular query
+ condition = processObjectQuery(query);
+ }
+ if(condition){
+ condition = ' WHERE ' + condition;
+ }
+ function processObjectQuery(query){
+ // we are processing an object query, that needs to be translated to WHERE conditions, AND'ed
+ var conditions = [];
+ for(var i in query){
+ var filterValue = query[i];
+ function convertWildcard(value){
+ // convert to LIKE if it ends with a *
+ var wildcard = value && value.match && value.match(wildcardRe);
+ if(wildcard){
+ params.push(wildcard[1] + '%');
+ return ' LIKE ?';
+ }
+ params.push(value);
+ return '=?';
+ }
+ if(filterValue){
+ if(filterValue.contains){
+ // search within the repeating table
+ var repeatingTable = store.table + '_repeating_' + i;
+ conditions.push(filterValue.contains.map(function(value){
+ return store.idProperty + ' IN (SELECT id FROM ' + repeatingTable + ' WHERE ' +
+ 'value' + convertWildcard(value) + ')';
+ }).join(' AND '));
+ continue;
+ }else if(typeof filterValue == 'object' && ("from" in filterValue || "to" in filterValue)){
+ // a range object, convert to appropriate SQL operators
+ if("from" in filterValue){
+ params.push(filterValue.from);
+ if("to" in filterValue){
+ params.push(filterValue.to);
+ conditions.push('(' + table + '.' + i + '>=? AND ' + table + '.' + i + '<=?)');
+ }else{
+ conditions.push(table + '.' + i + '>=?');
+ }
+ }else{
+ params.push(filterValue.to);
+ conditions.push(table + '.' + i + '<=?');
+ }
+ continue;
+ }
+ }
+ // regular value equivalence
+ conditions.push(table + '.' + i + convertWildcard(filterValue));
+ }
+ return conditions.join(' AND ');
+ }
+
+ if(options.sort){
+ condition += ' ORDER BY ' +
+ options.sort.map(function(sort){
+ return table + '.' + sort.attribute + ' ' + (sort.descending ? 'desc' : 'asc');
+ });
+ }
+
+ var limitedCondition = condition;
+ if(options.count){
+ limitedCondition += " LIMIT " + options.count;
+ }
+ if(options.start){
+ limitedCondition += " OFFSET " + options.start;
+ }
+ var results = lang.delegate(this.executeSql('SELECT * ' + from + limitedCondition, params).then(function(sqlResults){
+ // get the results back and do any conversions on it
+ var results = [];
+ for(var i = 0; i < sqlResults.rows.length; i++){
+ results.push(convertExtra(sqlResults.rows.item(i)));
+ }
+ return results;
+ }));
+ var store = this;
+ results.total = {
+ then: function(callback,errback){
+ // lazily do a total, using the same query except with a COUNT(*) and without the limits
+ return store.executeSql('SELECT COUNT(*) ' + from + condition, params).then(function(sqlResults){
+ return sqlResults.rows.item(0)['COUNT(*)'];
+ }).then(callback,errback);
+ }
+ };
+ return new QueryResults(results);
+ },
+
+ executeSql: function(sql, parameters){
+ // send it off to the DB
+ var deferred = new Deferred();
+ var result, error;
+ this.database.transaction(function(transaction){
+ transaction.executeSql(sql, parameters, function(transaction, value){
+ deferred.resolve(result = value);
+ }, function(transaction, e){
+ deferred.reject(error = e);
+ });
+ });
+ // return synchronously if the data is already available.
+ if(result){
+ return result;
+ }
+ if(error){
+ throw error;
+ }
+ return deferred.promise;
+ }
+
+ });
+});
View
6 store/db/has.js
@@ -0,0 +1,6 @@
+define(['dojo/has', 'dojo/sniff'], function(has){
+ // summary:
+ // has() test for indexeddb.
+ has.add('indexeddb', !!(window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB));
+ return has;
+});
View
121 store/tests-intern/LocalStorage.js
@@ -0,0 +1,121 @@
+define([
+ 'intern!object',
+ 'intern/chai!assert',
+ '../db/has!indexeddb?../db/IndexedDB',
+ '../db/SQL',
+ 'dojo/promise/all'
+], function (registerSuite, assert, IndexedDB, SQL, all) {
+ var data = [
+ {id: 1, name: 'one', prime: false, mappedTo: 'E', words: ['bananna']},
+ {id: 2, name: 'two', even: true, prime: true, mappedTo: 'D', words: ['bananna', 'orange']},
+ {id: 3, name: 'three', prime: true, mappedTo: 'C', words: ['apple', 'orange']},
+ {id: 4, name: 'four', even: true, prime: false, mappedTo: null},
+ {id: 5, name: 'five', prime: true, mappedTo: 'A'}
+ ];
+ var dbConfig = {
+ version: 3,
+ stores: {
+ test: {
+ name: 10,
+ even: {},
+ id: {
+ autoIncrement: true,
+ preference: 20
+ },
+ words: {
+ multiEntry: true,
+ preference: 5
+ }
+ }
+ }
+ };
+ if (IndexedDB) {
+ registerSuite(testsForDB('dojox/store/db/IndexedDB', IndexedDB));
+ }
+ if (window.openDatabase) {
+ registerSuite(testsForDB('dojox/store/db/SQL', SQL, true));
+ }
+ function testsForDB(name, DB, nonIndexed){
+ var db = new DB({dbConfig: dbConfig, storeName: 'test'});
+ function testQuery(query, options, results){
+ if(!results){
+ results = options;
+ options = undefined;
+ }else if(options.nonIndexed && nonIndexed){
+ // skip these tests
+ return function(){};
+ }
+ return function(){
+ var i = 0;
+ var queryResults = db.query(query, options);
+ var total = queryResults.total;
+ return queryResults.forEach(function(object){
+ assert.strictEqual(results[i++], object.id);
+ }).then(function(){
+ assert.strictEqual(results.length, i);
+ if(!options){
+ return queryResults.total.then(function(total){
+ assert.strictEqual(results.length, total);
+ });
+ }
+ });
+ };
+ }
+ return {
+ name: name,
+ setup: function(){
+ var results = [];
+ return db.query({}).forEach(function(object){
+ // clear the data
+ results.push(db.remove(object.id));
+ }).then(function(){
+ return all(results);
+ }).then(function(){
+ results = [];
+ // load new data
+ for (var i = 0; i < data.length; i++) {
+ results.push(db.put(data[i]));
+ }
+ return all(results);
+ });
+ },
+ "{id: 2}": testQuery({id: 2}, [2]),
+ "{name: 'four'}": testQuery({name: 'four'}, [4]),
+ "{name: 'two'}": testQuery({name: 'two'}, [2]),
+ "{even: true}": testQuery({even: true}, [2, 4]),
+ "{even: true, name: 'two'}": testQuery({even: true, name: 'two'}, [2]),
+ // test non-indexed values
+ "{mappedTo: 'C'}": testQuery({mappedTo: 'C'}, {nonIndexed: true}, [3]),
+ // union
+ "[{name: 'two'}, {mappedTo: 'C'}, {mappedTo: 'D'}]": testQuery([{name: 'two'}, {mappedTo: 'C'}, {mappedTo: 'D'}], {nonIndexed: true}, [2, 3]),
+ "{id: {from: 1, to: 3}}": testQuery({id: {from: 1, to: 3}}, [1, 2, 3]),
+ "{name: {from: 'm', to: 'three'}}": testQuery({name: {from: 'm', to: 'three'}}, [1, 3]),
+ "{name: 't*'}": testQuery({name: 't*'}, {sort:[{attribute: "name"}]}, [3, 2]),
+ "{name: 'not a number'}": testQuery({name: 'not a number'}, []),
+ "{words: {contains: ['orange']}}": testQuery({words: {contains: ['orange']}}, [2, 3]),
+ "{words: {contains: ['or*']}}": testQuery({words: {contains: ['or*']}}, [2, 3]),
+ "{words: {contains: ['apple', 'bananna']}}": testQuery({words: {contains: ['apple', 'bananna']}}, []),
+ "{words: {contains: ['orange', 'bananna']}}": testQuery({words: {contains: ['orange', 'bananna']}}, [2]),
+ "{id: {from: 0, to: 4}, words: {contains: ['orange', 'bananna']}}": testQuery({id: {from: 0, to: 4}, words: {contains: ['orange', 'bananna']}}, [2]),
+ // "{name: '*e'}": testQuery({name: '*e'}, [5, 1, 3]), don't know if we even support this yet
+ "{id: {from: 1, to: 3}}, sort by name +": testQuery({id: {from: 1, to: 3}}, {sort:[{attribute: "name"}]}, [1, 3, 2]),
+ "{id: {from: 1, to: 3}}, sort by name -": testQuery({id: {from: 1, to: 3}}, {sort:[{attribute: "name", descending: true}]}, [2, 3, 1]),
+ "{id: {from: 0, to: 4}}, paged": testQuery({id: {from: 0, to: 4}}, {start: 1, count: 2}, [2, 3]),
+ 'db interaction': function(t){
+ return db.get(1).then(function(one){
+ assert.strictEqual(one.id, 1);
+ assert.strictEqual(one.name, 'one');
+ assert.strictEqual(one.prime, false);
+ assert.strictEqual(one.mappedTo, 'E');
+ return all([db.remove(2), db.remove(4), db.add({id: 6, name: 'six', prime: false, words: ['pineapple', 'orange juice']})]).then(function(){
+ return all([
+ testQuery({name: {from: 's', to: 'u'}}, [6, 3])(),
+ testQuery({words: {contains: ['orange*']}}, [3, 6])()
+ ]);
+ })
+ });
+ },
+
+ };
+ }
+});
View
3  store/tests-intern/all.js
@@ -0,0 +1,3 @@
+define([
+ './LocalStorage'
+], function(){});
View
71 store/tests-intern/intern.js
@@ -0,0 +1,71 @@
+// Learn more about configuring this file at <https://github.com/theintern/intern/wiki/Configuring-Intern>.
+// These default settings work OK for most people. The options that *must* be changed below are the
+// packages, suites, excludeInstrumentation, and (if you want functional tests) functionalSuites.
+define({
+ // The port on which the instrumenting proxy will listen
+ proxyPort: 9000,
+
+ // A fully qualified URL to the Intern proxy
+ proxyUrl: 'http://localhost:9001/',
+
+ // Default desired capabilities for all environments. Individual capabilities can be overridden by any of the
+ // specified browser environments in the `environments` array below as well. See
+ // https://code.google.com/p/selenium/wiki/DesiredCapabilities for standard Selenium capabilities and
+ // https://saucelabs.com/docs/additional-config#desired-capabilities for Sauce Labs capabilities.
+ // Note that the `build` capability will be filled in with the current commit ID from the Travis CI environment
+ // automatically
+ capabilities: {
+ 'selenium-version': '2.37.0'
+ },
+
+ // Browsers to run integration testing against. Note that version numbers must be strings if used with Sauce
+ // OnDemand. Options that will be permutated are browserName, version, platform, and platformVersion; any other
+ // capabilities options specified for an environment will be copied as-is
+ environments: [
+ { browserName: 'internet explorer', version: '11', platform: 'Windows 8.1', 'prerun': 'http://localhost:9001/tests-intern/support/prerun.bat' },
+ { browserName: 'internet explorer', version: '10', platform: 'Windows 8', 'prerun': 'http://localhost:9001/tests-intern/support/prerun.bat' },
+ { browserName: 'internet explorer', version: [ '8', '9', '10' ], platform: 'Windows 7', 'prerun': 'http://localhost:9001/tests-intern/support/prerun.bat' },
+ { browserName: 'internet explorer', version: [ '6', '7', '8' ], platform: 'Windows XP', 'prerun': 'http://localhost:9001/tests-intern/support/prerun.bat' },
+ { browserName: 'firefox', version: '25', platform: [ 'OS X 10.6', 'Windows 7', 'Windows XP', 'Linux' ] },
+ { browserName: 'chrome', version: '', platform: [ 'Linux', 'OS X 10.8', 'OS X 10.9', 'Windows XP', 'Windows 7', 'Windows 8', 'Windows 8.1' ] },
+ { browserName: 'safari', version: '6', platform: 'OS X 10.8' },
+ { browserName: 'safari', version: '7', platform: 'OS X 10.9' }
+ ],
+
+ // Maximum number of simultaneous integration tests that should be executed on the remote WebDriver service
+ maxConcurrency: 3,
+
+ // Whether or not to start Sauce Connect before running tests
+ useSauceConnect: true,
+
+ // Connection information for the remote WebDriver service. If using Sauce Labs, keep your username and password
+ // in the SAUCE_USERNAME and SAUCE_ACCESS_KEY environment variables unless you are sure you will NEVER be
+ // publishing this configuration file somewhere
+ webdriver: {
+ host: 'localhost',
+ port: 4444
+ },
+
+ // Configuration options for the module loader; any AMD configuration options supported by the specified AMD loader
+ // can be used here
+ loader: {
+ packages: [
+ { name: 'dojo', location: 'dojo' },
+ { name: 'dojox', location: 'dojox' }
+ ]/*
+ // Packages that should be registered with the loader in each testing environment
+ packages: [ { name: 'dojo-testing', location: '.' } ],
+ map: {
+ 'dojo-testing': {
+ 'dojo': 'dojo-testing',
+ 'intern/dojo': 'intern/node_modules/dojo'
+ }
+ }*/
+ },
+
+ // Non-functional test suite(s) to run in each browser
+ suites: [ 'dojox/store/tests-intern/all' ],
+
+ // A regular expression matching URLs to files that should not be included in code coverage analysis
+ excludeInstrumentation: /^(?:node_modules|tests-intern|tests)\//
+});
View
16 store/tests-intern/intern.local.js
@@ -0,0 +1,16 @@
+define([
+ './intern'
+], function (intern) {
+ intern.useSauceConnect = false;
+ intern.webdriver = {
+ host: 'localhost',
+ port: 4444
+ };
+
+ intern.environments = [
+ { browserName: 'firefox' },
+ { browserName: 'chrome' }
+ ];
+
+ return intern;
+});
View
7 store/tests-intern/intern.proxy.js
@@ -0,0 +1,7 @@
+define([
+ './intern'
+], function (config) {
+ config.excludeInstrumentation = /^.*/;
+
+ return config;
+});
Please sign in to comment.
Something went wrong with that request. Please try again.