dojox
Version:
Dojo eXtensions, a rollup of many useful sub-projects and varying states of maturity – from very stable and robust, to alpha and experimental. See individual projects contain README files for details.
675 lines (637 loc) • 21.6 kB
JavaScript
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' &&
storeConfig[index].indexed !== false) {
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 && indexDefinition.indexed !== false) {
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, filterValue.excludeFrom, filterValue.excludeTo) :
IDBKeyRange.lowerBound(from, filterValue.excludeFrom) :
IDBKeyRange.upperBound(to, filterValue.excludeTo)
};
})(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);
}
});
});