@graphistry/falcor
Version:
A JavaScript library for efficient data fetching.
597 lines (527 loc) • 22.5 kB
JavaScript
var Call = require('./request/Call');
var ModelRoot = require('./ModelRoot');
var FalcorJSON = require('./cache/get/json/FalcorJSON');
var ModelDataSourceAdapter = require('./ModelDataSourceAdapter');
var TimeoutScheduler = require('./schedulers/TimeoutScheduler');
var ImmediateScheduler = require('./schedulers/ImmediateScheduler');
var collapse = require('@graphistry/falcor-path-utils/lib/collapse');
var getSize = require('./support/getSize');
var isObject = require('./support/isObject');
var isJSONEnvelope = require('./support/isJSONEnvelope');
var getCachePosition = require('./cache/getCachePosition');
var isJSONGraphEnvelope = require('./support/isJSONGraphEnvelope');
var setCache = require('./cache/set/setPathMaps');
var setJSONGraphs = require('./cache/set/setJSONGraphs');
var getJSON = require('./cache/get/json');
var getCache = require('./cache/getCache');
var getJSONGraph = require('./cache/get/jsonGraph');
module.exports = Model;
/**
* This callback is invoked when the Model's cache is changed.
* @callback Model~onChange
*/
/**
* This function is invoked on every JSONGraph Error retrieved from the DataSource. This function allows Error objects to be transformed before being stored in the Model's cache.
* @callback Model~errorSelector
* @param {Object} jsonGraphError - the JSONGraph Error object to transform before it is stored in the Model's cache.
* @returns {Object} the JSONGraph Error object to store in the Model cache.
*/
/**
* This function is invoked every time a value in the Model cache is about to be replaced with a new value. If the function returns true, the existing value is replaced with a new value and the version flag on all of the value's ancestors in the tree are incremented.
* @callback Model~comparator
* @param {Object} existingValue - the current value in the Model cache.
* @param {Object} newValue - the value about to be set into the Model cache.
* @returns {Boolean} the Boolean value indicating whether the new value and the existing value are equal.
*/
/**
* A Model object is used to execute commands against a {@link JSONGraph} object. {@link Model}s can work with a local JSONGraph cache, or it can work with a remote {@link JSONGraph} object through a {@link DataSource}.
* @constructor
* @param {?Object} options - a set of options to customize behavior
* @param {?DataSource} options.source - a data source to retrieve and manage the {@link JSONGraph}
* @param {?JSONGraph} options.cache - initial state of the {@link JSONGraph}
* @param {?number} options.maxSize - the maximum size of the cache. This value roughly correlates to item count (where itemCount = maxSize / 50). Each item by default is given a metadata `$size` of 50 (or its length when it's an array or string). You can get better control of falcor's memory usage by tweaking `$size`
* @param {?number} options.collectRatio - the ratio of the maximum size to collect when the maxSize is exceeded
* @param {?Model~errorSelector} options.errorSelector - a function used to translate errors before they are returned
* @param {?Model~onChange} options.onChange - a function called whenever the Model's cache is changed
* @param {?Model~comparator} options.comparator - a function called whenever a value in the Model's cache is about to be replaced with a new value.
*/
function Model(opts) {
var options = opts || {};
this._node = options._node;
this._path = options._path || [];
this._source = options.source || options._source;
this._root = options._root || new ModelRoot(options, this);
this._recycleJSON = options.recycleJSON === true || options._recycleJSON;
this._scheduler = options.scheduler || options._scheduler || new ImmediateScheduler();
if (options._seed) {
this._recycleJSON = true;
this._seed = options._seed;
if (typeof options.treatErrorsAsValues === 'undefined') {
options.treatErrorsAsValues = true;
}
} else if (this._recycleJSON) {
this._seed = {};
this._seed.__proto__ = FalcorJSON.prototype;
if (typeof options.treatErrorsAsValues === 'undefined') {
options.treatErrorsAsValues = true;
}
}
this._boxed = options.boxed === true || options._boxed || false;
this._materialized = options.materialized === true || options._materialized || false;
this._treatErrorsAsValues = options.treatErrorsAsValues === true || options._treatErrorsAsValues || false;
this._allowFromWhenceYouCame = options.allowFromWhenceYouCame === true || options._allowFromWhenceYouCame || false;
if (options.cache) {
this.setCache(options.cache);
}
}
Model.prototype.constructor = Model;
/**
* The get method retrieves several {@link Path}s or {@link PathSet}s from a {@link Model}. The get method loads each value into a JSON object and returns in a ModelResponse.
* @function
* @param {...PathSet} path - the path(s) to retrieve
* @return {ModelResponse.<JSONEnvelope>} - the requested data as JSON
*/
Model.prototype.get = function get(...args) {
var seed = this._seed;
if (!seed) {
seed = {};
seed.__proto__ = FalcorJSON.prototype;
}
return new Call('get', this, args)._toJSON(seed, []);
}
/**
* Sets the value at one or more places in the JSONGraph model. The set method accepts one or more {@link PathValue}s, each of which is a combination of a location in the document and the value to place there. In addition to accepting {@link PathValue}s, the set method also returns the values after the set operation is complete.
* @function
* @return {ModelResponse.<JSONEnvelope>} - an {@link Observable} stream containing the values in the JSONGraph model after the set was attempted
*/
Model.prototype.set = function set(...args) {
var seed = {};
seed.__proto__ = FalcorJSON.prototype;
return new Call('set', this, args)._toJSON(seed, []);
}
/**
* The preload method retrieves several {@link Path}s or {@link PathSet}s from a {@link Model} and loads them into the Model cache.
* @function
* @param {...PathSet} path - the path(s) to retrieve
* @return {ModelResponse.<JSONEnvelope>} - a ModelResponse that completes when the data has been loaded into the cache.
*/
Model.prototype.preload = function preload(...args) {
return new Call('get', this, args)._toJSON(null, []);
}
/**
* Invokes a function in the JSON Graph.
* @function
* @param {Path} functionPath - the path to the function to invoke
* @param {Array.<Object>} args - the arguments to pass to the function
* @param {Array.<PathSet>} refPaths - the paths to retrieve from the JSON Graph References in the message returned from the function
* @param {Array.<PathSet>} thisPaths - the paths to retrieve from function's this object after successful function execution
* @return {ModelResponse.<JSONEnvelope> - a JSONEnvelope contains the values returned from the function
*/
Model.prototype.call = function call(...args) {
var seed = {};
seed.__proto__ = FalcorJSON.prototype;
return new Call('call', this, args)._toJSON(seed, []);
}
/**
* The invalidate method synchronously removes several {@link Path}s or {@link PathSet}s from a {@link Model} cache.
* @function
* @param {...PathSet} path - the paths to remove from the {@link Model}'s cache.
*/
Model.prototype.invalidate = function invalidate(...args) {
return new Call('invalidate', this, args)._toJSON(null, null).then();
}
/**
* Returns a new {@link Model} bound to a location within the {@link
* JSONGraph}. The bound location is never a {@link Reference}: any {@link
* Reference}s encountered while resolving the bound {@link Path} are always
* replaced with the {@link Reference}s target value. For subsequent operations
* on the {@link Model}, all paths will be evaluated relative to the bound
* path. Deref allows you to:
* - Expose only a fragment of the {@link JSONGraph} to components, rather than
* the entire graph
* - Hide the location of a {@link JSONGraph} fragment from components
* - Optimize for executing multiple operations and path looksup at/below the
* same location in the {@link JSONGraph}
* @method
* @param {Object} responseObject - an object previously retrieved from the
* Model
* @return {Model} - the dereferenced {@link Model}
* @example
var Model = falcor.Model;
var model = new Model({
cache: {
users: [
Model.ref(['usersById', 32])
],
usersById: {
32: {
name: 'Steve',
surname: 'McGuire'
}
}
}
});
model.
get(['users', 0, 'name']).
subscribe(function(jsonEnv) {
var userModel = model.deref(jsonEnv.json.users[0]);
console.log(model.getPath());
console.log(userModel.getPath());
});
});
// prints the following:
// []
// ['usersById', 32] - because userModel refers to target of reference at ['users', 0]
*/
Model.prototype.deref = require('./deref');
/**
* A dereferenced model can become invalid when the reference from which it was
* built has been removed/collected/expired/etc etc. To fix the issue, a request
* from the parent should be made (no parent, then from the root) for a valid
* path and re-dereference performed to update where the model is bound.
*
* @method
* @private
* @return {Boolean} - If the currently deref'd model is still considered a
* valid deref.
*/
Model.prototype._hasValidParentReference = require('./deref/hasValidParentReference');
/**
* Get data for a single {@link Path}.
* @param {Path} path - the path to retrieve
* @return {Observable.<*>} - the value for the path
* @example
var model = new falcor.Model({source: new falcor.HttpDataSource('/model.json') });
model.
getValue('user.name').
subscribe(function(name) {
console.log(name);
});
// The code above prints 'Jim' to the console.
*/
Model.prototype.getValue = function getValue(path) {
return new Call('get', this, [path])
._toJSON({}, [])
.lift(function(subscriber) {
return this.subscribe({
onNext: function(data) {
var depth = -1;
var x = data.json;
var length = path.length;
while (x && !x.$type && ++depth < length) {
x = x[path[depth]];
}
subscriber.onNext(x);
},
onError: subscriber.onError.bind(subscriber),
onCompleted: subscriber.onCompleted.bind(subscriber)
})
});
}
/**
* Set value for a single {@link Path}.
* @param {Path} path - the path to set
* @param {Object} value - the value to set
* @return {Observable.<*>} - the value for the path
* @example
var model = new falcor.Model({source: new falcor.HttpDataSource('/model.json') });
model.
setValue('user.name', 'Jim').
subscribe(function(name) {
console.log(name);
});
// The code above prints 'Jim' to the console.
*/
Model.prototype.setValue = function setValue(path, value) {
path = arguments.length === 1 ? path.path : path;
value = arguments.length === 1 ? path : {path:path,value:value};
return new Call('set', this, [value])
._toJSON({}, [])
.lift(function(subscriber) {
return this.subscribe({
onNext: function(data) {
var depth = -1;
var x = data.json;
var length = path.length;
while (x && !x.$type && ++depth < length) {
x = x[path[depth]];
}
subscriber.onNext(x);
},
onError: subscriber.onError.bind(subscriber),
onCompleted: subscriber.onCompleted.bind(subscriber)
})
});
}
/**
* Set the local cache to a {@link JSONGraph} fragment. This method can be a useful way of mocking a remote document, or restoring the local cache from a previously stored state.
* @param {JSONGraph} jsonGraph - the {@link JSONGraph} fragment to use as the local cache
*/
Model.prototype.setCache = function modelSetCache(cacheOrJSONGraphEnvelope) {
var modelRoot = this._root;
var cache = modelRoot.cache;
if (cacheOrJSONGraphEnvelope !== cache) {
var options = {
_path: [],
_boxed: false,
_root: modelRoot,
_materialized: false,
_treatErrorsAsValues: false
};
modelRoot.cache = this._node = {};
if (typeof cache !== 'undefined') {
modelRoot.expired = [];
modelRoot[f_head] = undefined;
modelRoot[f_tail] = undefined;
if (this._recycleJSON) {
this._seed = {};
this._seed.__proto__ = FalcorJSON.prototype;
}
}
var results, rootOnChangeHandler;
if (isJSONGraphEnvelope(cacheOrJSONGraphEnvelope)) {
results = setJSONGraphs(options, [cacheOrJSONGraphEnvelope]);
} else if (isJSONEnvelope(cacheOrJSONGraphEnvelope)) {
results = setCache(options, [cacheOrJSONGraphEnvelope]);
} else if (isObject(cacheOrJSONGraphEnvelope)) {
results = setCache(options, [{ json: cacheOrJSONGraphEnvelope }]);
}
if (results) {
// performs promotion without producing output.
if (results[0].length) {
getJSON(options, results[0], null, false, false);
}
if (results[2] && (rootOnChangeHandler = modelRoot.onChange)) {
rootOnChangeHandler.call(modelRoot.topLevelModel);
}
}
} else if (typeof cache === 'undefined') {
this._root.cache = {};
}
return this;
};
/**
* Get the local {@link JSONGraph} cache. This method can be a useful to store the state of the cache.
* @param {...Array.<PathSet>} [pathSets] - The path(s) to retrieve. If no paths are specified, the entire {@link JSONGraph} is returned.
* @return {JSONGraph} all of the {@link JSONGraph} data in the {@link Model} cache.
* @example
// Storing the boxshot of the first 10 titles in the first 10 genreLists to local storage.
localStorage.setItem('cache', JSON.stringify(model.getCache('genreLists[0...10][0...10].boxshot')));
*/
Model.prototype.getCache = function _getCache(...paths) {
if (paths.length === 0) {
return getCache(this, this._root.cache);
}
var seed = {};
seed.__proto__ = FalcorJSON.prototype;
var env = getJSONGraph({
_path: [],
_root: this._root,
_boxed: this._boxed,
_materialized: this._materialized,
_treatErrorsAsValues: this._treatErrorsAsValues
}, paths, seed).data;
env.paths = collapse(paths);
return env;
};
/**
* Retrieves a number which is incremented every single time a value is changed underneath the Model or the object at an optionally-provided Path beneath the Model.
* @param {Path?} path - a path at which to retrieve the version number
* @return {Number} a version number which changes whenever a value is changed underneath the Model or provided Path
*/
Model.prototype.getVersion = function getVersion(path) {
path = path || [];
if (Array.isArray(path) === false) {
throw new Error('Model#getVersion must be called with an Array path.');
}
if (this._path.length) {
path = this._path.concat(path);
}
return this._getVersion(this, path);
};
/* eslint-disable guard-for-in */
Model.prototype._clone = function cloneModel(opts) {
var clone = new Model(this);
if (opts) {
for (var key in opts) {
var value = opts[key];
if (value === 'delete') {
delete clone[key];
} else if (key === '_path') {
clone[key] = value;
if (false === opts.hasOwnProperty('_node')) {
delete clone['_node'];
}
} else {
clone[key] = value;
}
}
}
if (clone._path.length > 0) {
clone.setCache = void 0;
}
return clone;
};
/* eslint-enable */
/**
* Returns a clone of the {@link Model} that enables batching. Within the configured time period, paths for get operations are collected and sent to the {@link DataSource} in a batch. Batching can be more efficient if the {@link DataSource} access the network, potentially reducing the number of HTTP requests to the server.
* @param {?Scheduler|number} schedulerOrDelay - Either a {@link Scheduler} that determines when to send a batch to the {@link DataSource}, or the number in milliseconds to collect a batch before sending to the {@link DataSource}. If this parameter is omitted, then batch collection ends at the end of the next tick.
* @return {Model} a Model which schedules a batch of get requests to the DataSource.
*/
Model.prototype.batch = function batch(schedulerOrDelay) {
var scheduler;
if (typeof schedulerOrDelay === 'number') {
scheduler = new TimeoutScheduler(Math.round(Math.abs(schedulerOrDelay)));
} else if (!schedulerOrDelay) {
scheduler = new TimeoutScheduler(1);
} else if (typeof schedulerOrDelay.schedule === 'function') {
scheduler = schedulerOrDelay;
} else if (typeof schedulerOrDelay === 'function') {
scheduler = { scheudle: schedulerOrDelay };
}
return this._clone({ _scheduler: scheduler });
};
/**
* Returns a clone of the {@link Model} that disables batching. This is the default mode. Each get operation will be executed on the {@link DataSource} separately.
* @name unbatch
* @memberof Model.prototype
* @function
* @return {Model} a {@link Model} that batches requests of the same type and sends them to the data source together
*/
Model.prototype.unbatch = function unbatch() {
return this._clone({ _scheduler: new ImmediateScheduler() });
};
/**
* Returns a clone of the {@link Model} that treats errors as values. Errors will be reported in the same callback used to report data. Errors will appear as objects in responses, rather than being sent to the {@link Observable~onErrorCallback} callback of the {@link ModelResponse}.
* @return {Model}
*/
Model.prototype.treatErrorsAsValues = function treatErrorsAsValues() {
return this._clone({ _treatErrorsAsValues: true });
};
/**
* Adapts a Model to the {@link DataSource} interface.
* @return {DataSource}
* @example
var model =
new falcor.Model({
cache: {
user: {
name: 'Steve',
surname: 'McGuire'
}
}
}),
proxyModel = new falcor.Model({ source: model.asDataSource() });
// Prints 'Steve'
proxyModel.getValue('user.name').
then(function(name) {
console.log(name);
});
*/
Model.prototype.asDataSource = function asDataSource() {
return new ModelDataSourceAdapter(this);
};
Model.prototype._materialize = function materialize() {
return this._clone({
_materialized: true
});
};
Model.prototype._dematerialize = function dematerialize() {
return this._clone({
_materialized: 'delete'
});
};
/**
* Returns a clone of the {@link Model} that boxes values returning the wrapper ({@link Atom}, {@link Reference}, or {@link Error}), rather than the value inside it. This allows any metadata attached to the wrapper to be inspected.
* @return {Model}
*/
Model.prototype.boxValues = function boxValues() {
return this._clone({
_boxed: true
});
};
/**
* Returns a clone of the {@link Model} that unboxes values, returning the value inside of the wrapper ({@link Atom}, {@link Reference}, or {@link Error}), rather than the wrapper itself. This is the default mode.
* @return {Model}
*/
Model.prototype.unboxValues = function unboxValues() {
return this._clone({
_boxed: 'delete'
});
};
/**
* Returns a clone of the {@link Model} that only uses the local {@link JSONGraph} and never uses a {@link DataSource} to retrieve missing paths.
* @return {Model}
*/
Model.prototype.withoutDataSource = function withoutDataSource() {
return this._clone({
_source: 'delete'
});
};
/* implement inspect method for node's inspect utility */
Model.prototype.inspect = function inspect() {
return '{ v: ' + this.getVersion() + ' p: [' + this._path.join(', ') + '] }';
}
Model.prototype.toJSON = function toJSON() {
return {
$type: 'ref',
value: this.getPath()
};
};
/**
* Returns the {@link Path} to the object within the JSON Graph that this Model references.
* @return {Path}
* @example
var Model = falcor.Model;
var model = new Model({
cache: {
users: [
Model.ref(['usersById', 32])
],
usersById: {
32: {
name: 'Steve',
surname: 'McGuire'
}
}
}
});
model.
get(['users', 0, 'name']).
subscribe(function(jsonEnv) {
var userModel = model.deref(jsonEnv.json.users[0]);
console.log(model.getPath());
console.log(userModel.getPath());
});
});
// prints the following:
// []
// ['usersById', 32] - because userModel refers to target of reference at ['users', 0]
*/
Model.prototype.getPath = function getPath() {
return this._path.slice(0);
};
/**
* This one is actually private. I would not use this without talking to
* jhusain, sdesai, or michaelbpaulson (github).
* @private
*/
Model.prototype._fromWhenceYouCame = function fromWhenceYouCame(allow) {
return this._clone({
_allowFromWhenceYouCame: allow === undefined ? true : allow
});
};
Model.prototype._optimizePath = function _optimizePath(path) {
var node = getCachePosition(this._root.cache, path);
var abs_path = node && node[f_abs_path] || [];
return abs_path.slice(0);
};
Model.prototype._getVersion = require('./cache/getVersion');
Model.prototype._getPathValuesAsPathMap = getJSON;
Model.prototype._getPathValuesAsJSONG = getJSONGraph;
Model.prototype._setPathValues = require('./cache/set/setPathValues');
Model.prototype._setPathMaps = require('./cache/set/setPathMaps');
Model.prototype._setJSONGs = require('./cache/set/setJSONGraphs');
Model.prototype._setCache = require('./cache/set/setPathMaps');
Model.prototype._invalidatePathValues = require('./cache/invalidate/invalidatePathSets');
Model.prototype._invalidatePathMaps = require('./cache/invalidate/invalidatePathMaps');