blob: 5e1d1548b074f0726d30fb78b575d61ca105f687 [file] [log] [blame]
/*
Copyright (c) 2004-2006, The Dojo Foundation
All Rights Reserved.
Licensed under the Academic Free License version 2.1 or above OR the
modified BSD license. For more information on Dojo licensing, see:
http://dojotoolkit.org/community/licensing.shtml
*/
dojo.provide("dojo.data.core.RemoteStore");
dojo.require("dojo.data.core.Read");
dojo.require("dojo.data.core.Write");
dojo.require("dojo.data.core.Result");
dojo.require("dojo.experimental");
dojo.require("dojo.Deferred");
dojo.require("dojo.lang.declare");
dojo.require("dojo.json");
dojo.require("dojo.io.*");
/* summary:
* RemoteStore is an implemention the dojo.data.core.Read and Write APIs.
* It is designed to serve as a base class for dojo.data stores which interact
* with stateless web services that can querying and modifying record-oriented
* data. Its features include asynchronous and synchronous querying and saving;
* caching of queries; transactions; and datatype mapping.
*/
/**************************************************************************
Classes derived from RemoteStore should implement the following three
methods, which are each described in the documentation below:
_setupQueryRequest(result, requestKw)
_resultToQueryData(responseData)
_setupSaveRequest(saveKeywordArgs, requestKw)
Data Consistency Guarantees
* if two references to the same item are obtained (e.g. from two different query results) any changes to one item will be reflected in the other item reference.
* If an item has changed on the server and the item is retrieved via a new query, any previously obtained references to the item will (silently) reflect these new values.
* However, any uncommitted changes will not be "overwritten".
* If server queries are made while there are uncommitted changes, no attempt is made to evaluate whether the modifications would change the query result, e.g. add any uncommitted new items that match the query.
* However, uncomitted deleted items are removed from the query result.
* The transaction isolation level is equivalent to JDBC's "Read Committed":
each store instance is treated as separate transaction; since there is no row or table locking so nonrepeatable and phantom reads are possible.
Memory Usage
Because Javascript doesn't support weak references or user-defined finalize methods, there is a tradeoff between data consistency and memory usage.
In order to implement the above consistency guarantees (and to provide caching), RemoteStore remembers all the queries and items retrieved.
To reduce memory consumption, use the method forgetResults(query);
Store assumptions
RemoteStore makes some assumptions about the nature of the remote store, things may break if these aren't true:
* that the items contained in a query response include all the attributes of the item (e.g. all the columns of a row).
(to fix: changes need to record add and removes and fix self._data[key] = [ attributeDict, refCount]; )
* the query result may contain references to items that are not available to the client; use isItem() to test for the presence of the item.
* that modification to an item's attributes won't change it's primary key.
**************************************************************************/
/* dojo.data API issues to resolve:
* save should returns a Deferred, might want to add keyword argument with 'sync'
*/
dojo.experimental("dojo.data.core.RemoteStore");
dojo.lang.declare("dojo.data.core.RemoteStore", [dojo.data.core.Read, dojo.data.core.Write], {
_datatypeMap: {
//map datatype strings to constructor function
},
//set to customize json serialization
_jsonRegistry: dojo.json.jsonRegistry,
initializer: function(/* object */ kwArgs) {
if (!kwArgs) {
kwArgs = {};
}
this._serverQueryUrl = kwArgs.queryUrl || "";
this._serverSaveUrl = kwArgs.saveUrl || "";
this._deleted = {}; // deleted items {id: 1}
this._changed = {}; // {id: {attr: [new values]}} // [] if attribute is removed
this._added = {}; // {id: 1} list of added items
this._results = {}; // {query: [ id1, ]}; // todo: make MRUDict of queries
/* data is a dictionary that conforms to this format:
{ id-string: { attribute-string: [ value1, value2 ] } }
where value is either an atomic JSON data type or
{ 'id': string } for references to items
or
{ 'type': 'name', 'value': 'value' } for user-defined datatypes
*/
this._data = {}; // {id: [values, refcount]} // todo: handle refcount
this._numItems = 0;
},
_setupQueryRequest: function(/* dojo.data.core.Result */ result, /* object */ requestKw) {
/* summary:
* Classes derived from RemoteStore should override this method to
* provide their own implementations.
* This function prepares the query request by populating requestKw,
* an associative array that will be passed to dojo.io.bind.
*/
result.query = result.query || "";
requestKw.url = this._serverQueryUrl + encodeURIComponent(result.query);
requestKw.method = 'get';
requestKw.mimetype = "text/json";
},
_resultToQueryMetadata: function(/* varies */ serverResponseData) {
/* summary:
* Classes derived from RemoteStore should override this method to
* provide their own implementations.
* Converts the server response data into the resultMetadata object
* that will be returned to the caller.
* returns:
* This simple default implementation just returns the entire raw
* serverResponseData, allowing the caller complete access to the
* raw response data and metadata.
*/
return serverResponseData;
},
_resultToQueryData: function(/* varies */ serverResponseData) {
/* summary:
* Classes derived from RemoteStore should override this method to
* provide their own implementations.
* Converts the server response data into the internal data structure
* used by RemoteStore.
* returns:
* The RemoteStore implementation requires _resultToQueryData() to
* return an object that looks like:
* {item1-identifier-string: {
* attribute1-string: [ value1, value2, ... ],
* attribute2-string: [ value3, value4, ... ],
* ...
* },
* item2-identifier-string: {
* attribute1-string: [ value10, value11, ... ],
* attribute2-string: [ value12, value13, ... ],
* ...
* }
* }
* where value is either an atomic JSON data type or
* {'id': string } for references to items
* or
* {'type': 'name', 'value': 'value' } for user-defined datatypes
* data:
* This simple default implementation assumes that the *serverResponseData*
* argument is an object that looks like:
* { data:{ ... }, format:'format identifier', other metadata }
*
*/
return serverResponseData.data;
},
_remoteToLocalValues: function(/* object */ attributes) {
for (var key in attributes) {
var values = attributes[key];
for (var i = 0; i < values.length; i++) {
var value = values[i];
var type = value.datatype || value.type;
if (type) {
// todo: better error handling?
var localValue = value.value;
if (this._datatypeMap[type])
localValue = this._datatypeMap[type](value);
values[i] = localValue;
}
}
}
return attributes; // object (attributes argument, modified in-place)
},
_queryToQueryKey: function(query) {
/* summary:
* Convert the query to a string that uniquely represents this query.
* (Used by the query cache.)
*/
if (typeof query == "string")
return query;
else
return dojo.json.serialize(query);
},
_assertIsItem: function(/* item */ item) {
if (!this.isItem(item)) {
throw new Error("dojo.data.RemoteStore: a function was passed an item argument that was not an item");
}
},
get: function(/* item */ item, /* attribute || string */ attribute, /* value? */ defaultValue) {
// summary: See dojo.data.core.Read.get()
var valueArray = this.getValues(item, attribute);
if (valueArray.length == 0) {
return defaultValue;
}
return valueArray[0]; // value
},
getValues: function(/* item */ item, /* attribute || string */ attribute) {
// summary: See dojo.data.core.Read.getValues()
var itemIdentity = this.getIdentity(item);
this._assertIsItem(itemIdentity);
var changes = this._changed[itemIdentity];
if (changes) {
var newvalues = changes[attribute];
if (newvalues !== undefined) {
return newvalues; // Array
}
else {
return []; // Array
}
}
// return item.atts[attribute];
return this._data[itemIdentity][0][attribute]; // Array
},
getAttributes: function(/* item */ item) {
// summary: See dojo.data.core.Read.getAttributes()
var itemIdentity = this.getIdentity(item);
if (!itemIdentity)
return undefined; //todo: raise exception
var atts = [];
//var attrDict = item.attrs;
var attrDict = this._data[itemIdentity][0];
for (var att in attrDict) {
atts.push(att);
}
return atts; // Array
},
hasAttribute: function(/* item */ item, /* attribute || string */ attribute) {
// summary: See dojo.data.core.Read.hasAttribute()
var valueArray = this.getValues(item, attribute);
return valueArray.length ? true : false; // Boolean
},
containsValue: function(/* item */ item, /* attribute || string */ attribute, /* value */ value) {
// summary: See dojo.data.core.Read.containsValue()
var valueArray = this.getValues(item, attribute);
for (var i=0; i < valueArray.length; i++) {
if (valueArray[i] == value) {
return true; // Boolean
}
}
return false; // Boolean
},
isItem: function(/* anything */ something) {
// summary: See dojo.data.core.Read.isItem()
if (!something) { return false; }
var itemIdentity = something;
// var id = something.id ? something.id : something;
// if (!id) { return false; }
if (this._deleted[itemIdentity]) { return false; } //todo: do this?
if (this._data[itemIdentity]) { return true; }
if (this._added[itemIdentity]) { return true; }
return false; // Boolean
},
find: function(/* object? || dojo.data.core.Result */ keywordArgs) {
// summary: See dojo.data.core.Read.find()
/* description:
* In addition to the keywordArgs parameters described in the
* dojo.data.core.Read.find() documentation, the keywordArgs for
* the RemoteStore find() method may include a bindArgs parameter,
* which the RemoteStore will pass to dojo.io.bind when it sends
* the query. The bindArgs parameter should be a keyword argument
* object, as described in the dojo.io.bind documentation.
*/
var result = null;
if (keywordArgs instanceof dojo.data.core.Result) {
result = keywordArgs;
result.store = this;
} else {
result = new dojo.data.core.Result(keywordArgs, this);
}
var query = result.query;
//todo: use this._results to implement caching
var self = this;
var bindfunc = function(type, data, evt) {
var scope = result.scope || dj_global;
if(type == "load") {
//dojo.debug("loaded 1 " + dojo.json.serialize(data) );
result.resultMetadata = self._resultToQueryMetadata(data);
var dataDict = self._resultToQueryData(data);
//dojo.debug("loaded 2 " + dojo.json.serialize(dataDict) );
if (result.onbegin) {
result.onbegin.call(scope, result);
}
var count = 0;
var resultData = [];
var newItemCount = 0;
for (var key in dataDict) {
if (result._aborted) {
break;
}
if (!self._deleted[key]) { //skip deleted items
//todo if in _added, remove from _added
var values = dataDict[key];
var attributeDict = self._remoteToLocalValues(values);
var existingValue = self._data[key];
var refCount = 1;
if (existingValue) {
refCount = ++existingValue[1]; //increment ref count
} else {
newItemCount++;
}
//note: if the item already exists, we replace the item with latest set of attributes
//this assumes queries always return complete records
self._data[key] = [ attributeDict, refCount];
resultData.push(key);
count++;
if (result.onnext) {
result.onnext.call(scope, key, result);
}
}
}
self._results[self._queryToQueryKey(query)] = resultData;
self._numItems += newItemCount;
result.length = count;
if (result.saveResult) {
result.items = resultData;
}
if (!result._aborted && result.oncompleted) {
result.oncompleted.call(scope, result);
}
} else if(type == "error" || type == 'timeout') {
// here, "data" is our error object
//todo: how to handle timeout?
dojo.debug("find error: " + dojo.json.serialize(data));
if (result.onerror) {
result.onerror.call(scope, data);
}
}
};
var bindKw = keywordArgs.bindArgs || {};
bindKw.sync = result.sync;
bindKw.handle = bindfunc;
this._setupQueryRequest(result, bindKw);
var request = dojo.io.bind(bindKw);
//todo: error if not bind success
//dojo.debug( "bind success " + request.bindSuccess);
result._abortFunc = request.abort;
return result;
},
getIdentity: function(item) {
// summary: See dojo.data.core.Read.getIdentity()
if (!this.isItem(item)) {
return null;
}
return (item.id ? item.id : item); // Identity
},
/*
findByIdentity: function(id) {
var item = this._latestData[id];
var idQuery = "/" + "*[.='"+id+"']";
//if (!item) item = this.find(idQuery, {async=0}); //todo: support bind(async=0)
if (item)
return new _Item(id, item, this);
return null;
},
*/
/****
Write API
***/
newItem: function(/* object? */ attributes, /* object? */ keywordArgs) {
var itemIdentity = keywordArgs['identity'];
if (this._deleted[itemIdentity]) {
delete this._deleted[itemIdentity];
} else {
this._added[itemIdentity] = 1;
//todo? this._numItems++; ?? but its not in this._data
}
if (attributes) {
// FIXME:
for (var attribute in attributes) {
var valueOrArrayOfValues = attributes[attribute];
if (dojo.lang.isArray(valueOrArrayOfValues)) {
this.setValues(itemIdentity, attribute, valueOrArrayOfValues);
} else {
this.set(itemIdentity, attribute, valueOrArrayOfValues);
}
}
}
return { id: itemIdentity };
},
deleteItem: function(/* item */ item) {
var identity = this.getIdentity(item);
if (!identity) {
return false;
}
if (this._added[identity]) {
delete this._added[identity];
} else {
this._deleted[identity] = 1;
//todo? this._numItems--; ?? but its still in this._data
}
if (this._changed[identity]) {
delete this._changed[identity];
}
return true;
},
setValues: function(/* item */ item, /* attribute || string */ attribute, /* array */ values) {
var identity = this.getIdentity(item);
if (!identity) {
return undefined; //todo: raise exception
}
var changes = this._changed[identity];
if (!changes) {
changes = {}
this._changed[identity] = changes;
}
changes[attribute] = values;
return true; // boolean
},
set: function(/* item */ item, /* attribute || string */ attribute, /* almost anything */ value) {
return this.setValues(item, attribute, [value]);
},
unsetAttribute: function(/* item */ item, /* attribute || string */ attribute) {
return this.setValues(item, attribute, []);
},
_initChanges: function() {
this._deleted = {};
this._changed = {};
this._added = {};
},
_setupSaveRequest: function(saveKeywordArgs, requestKw) {
/* summary:
* This function prepares the save request by populating requestKw,
* an associative array that will be passed to dojo.io.bind.
*/
requestKw.url = this._serverSaveUrl;
requestKw.method = 'post';
requestKw.mimetype = "text/plain";
var deleted = [];
for (var key in this._deleted) {
deleted.push(key);
}
//don't need _added in saveStruct, changed covers that info
var saveStruct = {'changed': this._changed, 'deleted': deleted };
var oldRegistry = dojo.json.jsonRegistry;
dojo.json.jsonRegistry = this._jsonRegistry;
var jsonString = dojo.json.serialize(saveStruct);
dojo.json.jsonRegistry = oldRegistry;
requestKw.postContent = jsonString;
},
save: function(/* object? */ keywordArgs) {
/* summary:
* Saves all the changes that have been made.
* keywordArgs:
* The optional keywordArgs parameter may contain 'sync' to specify
* whether the save operation is asynchronous or not. The default is
* asynchronous.
* examples:
* store.save();
* store.save({sync:true});
* store.save({sync:false});
*/
keywordArgs = keywordArgs || {};
var result = new dojo.Deferred();
var self = this;
var bindfunc = function(type, data, evt) {
if(type == "load"){
if (result.fired == 1) {
//it seems that mysteriously "load" sometime
//gets called after "error"
//so check if an error has already occurred
//and stop if it has
return;
}
//update this._data upon save
var key = null;
for (key in self._added) {
if (!self._data[key])
self._data[key] = [{} , 1];
}
for (key in self._changed) {
var existing = self._data[key];
var changes = self._changed[key];
if (existing) {
existing[0] = changes;
} else {
self._data[key] = [changes, 1];
}
}
for (key in self._deleted) {
if (self._data[key]) {
delete self._data[key];
}
}
self._initChanges();
result.callback(true); //todo: what result to pass?
} else if(type == "error" || type == 'timeout'){
result.errback(data); //todo: how to handle timeout
}
};
var bindKw = { sync: keywordArgs["sync"], handle: bindfunc };
this._setupSaveRequest(keywordArgs, bindKw);
var request = dojo.io.bind(bindKw);
result.canceller = function(deferred) { request.abort(); };
return result;
},
revert: function() {
this._initChanges();
return true;
},
isDirty: function(/*item?*/ item) {
if (item) {
// return true if this item is dirty
var identity = item.id || item;
return this._deleted[identity] || this._changed[identity];
} else {
// return true if any item is dirty
var key = null;
for (key in this._changed) {
return true;
}
for (key in this._deleted) {
return true;
}
for (key in this._added) {
return true;
}
return false;
}
},
/**
additional public methods
*/
createReference: function(idstring) {
return { id : idstring };
},
getSize: function() {
return this._numItems;
},
forgetResults: function(query) {
var queryKey = this._queryToQueryKey(query);
var results = this._results[queryKey];
if (!results) return false;
var removed = 0;
for (var i = 0; i < results.length; i++) {
var key = results[i];
var existingValue = this._data[key];
if (existingValue[1] <= 1) {
delete this._data[key];
removed++;
}
else
existingValue[1] = --existingValue[1];
}
delete this._results[queryKey];
this._numItems -= removed;
return true;
}
});