| // Copyright 2006 The Closure Library Authors. All Rights Reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS-IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| /** |
| * @fileoverview |
| * Central class for registering and accessing data sources |
| * Also handles processing of data events. |
| * |
| * There is a shared global instance that most client code should access via |
| * goog.ds.DataManager.getInstance(). However you can also create your own |
| * DataManager using new |
| * |
| * Implements DataNode to provide the top element in a data registry |
| * Prepends '$' to top level data names in path to denote they are root object |
| * |
| */ |
| goog.provide('goog.ds.DataManager'); |
| |
| goog.require('goog.ds.BasicNodeList'); |
| goog.require('goog.ds.DataNode'); |
| goog.require('goog.ds.Expr'); |
| goog.require('goog.object'); |
| goog.require('goog.string'); |
| goog.require('goog.structs'); |
| goog.require('goog.structs.Map'); |
| |
| |
| |
| /** |
| * Create a DataManger |
| * @extends {goog.ds.DataNode} |
| * @constructor |
| * @final |
| */ |
| goog.ds.DataManager = function() { |
| this.dataSources_ = new goog.ds.BasicNodeList(); |
| this.autoloads_ = new goog.structs.Map(); |
| this.listenerMap_ = {}; |
| this.listenersByFunction_ = {}; |
| this.aliases_ = {}; |
| this.eventCount_ = 0; |
| this.indexedListenersByFunction_ = {}; |
| }; |
| |
| |
| /** |
| * Global instance |
| * @private |
| */ |
| goog.ds.DataManager.instance_ = null; |
| goog.inherits(goog.ds.DataManager, goog.ds.DataNode); |
| |
| |
| /** |
| * Get the global instance |
| * @return {!goog.ds.DataManager} The data manager singleton. |
| */ |
| goog.ds.DataManager.getInstance = function() { |
| if (!goog.ds.DataManager.instance_) { |
| goog.ds.DataManager.instance_ = new goog.ds.DataManager(); |
| } |
| return goog.ds.DataManager.instance_; |
| }; |
| |
| |
| /** |
| * Clears the global instance (for unit tests to reset state). |
| */ |
| goog.ds.DataManager.clearInstance = function() { |
| goog.ds.DataManager.instance_ = null; |
| }; |
| |
| |
| /** |
| * Add a data source |
| * @param {goog.ds.DataNode} ds The data source. |
| * @param {boolean=} opt_autoload Whether to automatically load the data, |
| * defaults to false. |
| * @param {string=} opt_name Optional name, can also get name |
| * from the datasource. |
| */ |
| goog.ds.DataManager.prototype.addDataSource = function(ds, opt_autoload, |
| opt_name) { |
| var autoload = !!opt_autoload; |
| var name = opt_name || ds.getDataName(); |
| if (!goog.string.startsWith(name, '$')) { |
| name = '$' + name; |
| } |
| ds.setDataName(name); |
| this.dataSources_.add(ds); |
| this.autoloads_.set(name, autoload); |
| }; |
| |
| |
| /** |
| * Create an alias for a data path, very similar to assigning a variable. |
| * For example, you can set $CurrentContact -> $Request/Contacts[5], and all |
| * references to $CurrentContact will be procesed on $Request/Contacts[5]. |
| * |
| * Aliases will hide datasources of the same name. |
| * |
| * @param {string} name Alias name, must be a top level path ($Foo). |
| * @param {string} dataPath Data path being aliased. |
| */ |
| goog.ds.DataManager.prototype.aliasDataSource = function(name, dataPath) { |
| if (!this.aliasListener_) { |
| this.aliasListener_ = goog.bind(this.listenForAlias_, this); |
| } |
| if (this.aliases_[name]) { |
| var oldPath = this.aliases_[name].getSource(); |
| this.removeListeners(this.aliasListener_, oldPath + '/...', name); |
| } |
| this.aliases_[name] = goog.ds.Expr.create(dataPath); |
| this.addListener(this.aliasListener_, dataPath + '/...', name); |
| this.fireDataChange(name); |
| }; |
| |
| |
| /** |
| * Listener function for matches of paths that have been aliased. |
| * Fires a data change on the alias as well. |
| * |
| * @param {string} dataPath Path of data event fired. |
| * @param {string} name Name of the alias. |
| * @private |
| */ |
| goog.ds.DataManager.prototype.listenForAlias_ = function(dataPath, name) { |
| var aliasedExpr = this.aliases_[name]; |
| |
| if (aliasedExpr) { |
| // If it's a subpath, appends the subpath to the alias name |
| // otherwise just fires on the top level alias |
| var aliasedPath = aliasedExpr.getSource(); |
| if (dataPath.indexOf(aliasedPath) == 0) { |
| this.fireDataChange(name + dataPath.substring(aliasedPath.length)); |
| } else { |
| this.fireDataChange(name); |
| } |
| } |
| }; |
| |
| |
| /** |
| * Gets a named child node of the current node. |
| * |
| * @param {string} name The node name. |
| * @return {goog.ds.DataNode} The child node, |
| * or null if no node of this name exists. |
| */ |
| goog.ds.DataManager.prototype.getDataSource = function(name) { |
| if (this.aliases_[name]) { |
| return this.aliases_[name].getNode(); |
| } else { |
| return this.dataSources_.get(name); |
| } |
| }; |
| |
| |
| /** |
| * Get the value of the node |
| * @return {!Object} The value of the node. |
| * @override |
| */ |
| goog.ds.DataManager.prototype.get = function() { |
| return this.dataSources_; |
| }; |
| |
| |
| /** @override */ |
| goog.ds.DataManager.prototype.set = function(value) { |
| throw Error('Can\'t set on DataManager'); |
| }; |
| |
| |
| /** @override */ |
| goog.ds.DataManager.prototype.getChildNodes = function(opt_selector) { |
| if (opt_selector) { |
| return new goog.ds.BasicNodeList( |
| [this.getChildNode(/** @type {string} */(opt_selector))]); |
| } else { |
| return this.dataSources_; |
| } |
| }; |
| |
| |
| /** |
| * Gets a named child node of the current node |
| * @param {string} name The node name. |
| * @return {goog.ds.DataNode} The child node, |
| * or null if no node of this name exists. |
| * @override |
| */ |
| goog.ds.DataManager.prototype.getChildNode = function(name) { |
| return this.getDataSource(name); |
| }; |
| |
| |
| /** @override */ |
| goog.ds.DataManager.prototype.getChildNodeValue = function(name) { |
| var ds = this.getDataSource(name); |
| return ds ? ds.get() : null; |
| }; |
| |
| |
| /** |
| * Get the name of the node relative to the parent node |
| * @return {string} The name of the node. |
| * @override |
| */ |
| goog.ds.DataManager.prototype.getDataName = function() { |
| return ''; |
| }; |
| |
| |
| /** |
| * Gets the a qualified data path to this node |
| * @return {string} The data path. |
| * @override |
| */ |
| goog.ds.DataManager.prototype.getDataPath = function() { |
| return ''; |
| }; |
| |
| |
| /** |
| * Load or reload the backing data for this node |
| * only loads datasources flagged with autoload |
| * @override |
| */ |
| goog.ds.DataManager.prototype.load = function() { |
| var len = this.dataSources_.getCount(); |
| for (var i = 0; i < len; i++) { |
| var ds = this.dataSources_.getByIndex(i); |
| var autoload = this.autoloads_.get(ds.getDataName()); |
| if (autoload) { |
| ds.load(); |
| } |
| } |
| }; |
| |
| |
| /** |
| * Gets the state of the backing data for this node |
| * @return {goog.ds.LoadState} The state. |
| * @override |
| */ |
| goog.ds.DataManager.prototype.getLoadState = goog.abstractMethod; |
| |
| |
| /** |
| * Whether the value of this node is a homogeneous list of data |
| * @return {boolean} True if a list. |
| * @override |
| */ |
| goog.ds.DataManager.prototype.isList = function() { |
| return false; |
| }; |
| |
| |
| /** |
| * Get the total count of events fired (mostly for debugging) |
| * @return {number} Count of events. |
| */ |
| goog.ds.DataManager.prototype.getEventCount = function() { |
| return this.eventCount_; |
| }; |
| |
| |
| /** |
| * Adds a listener |
| * Listeners should fire when any data with path that has dataPath as substring |
| * is changed. |
| * TODO(user) Look into better listener handling |
| * |
| * @param {Function} fn Callback function, signature function(dataPath, id). |
| * @param {string} dataPath Fully qualified data path. |
| * @param {string=} opt_id A value passed back to the listener when the dataPath |
| * is matched. |
| */ |
| goog.ds.DataManager.prototype.addListener = function(fn, dataPath, opt_id) { |
| // maxAncestor sets how distant an ancestor you can be of the fired event |
| // and still fire (you always fire if you are a descendant). |
| // 0 means you don't fire if you are an ancestor |
| // 1 means you only fire if you are parent |
| // 1000 means you will fire if you are ancestor (effectively infinite) |
| var maxAncestors = 0; |
| if (goog.string.endsWith(dataPath, '/...')) { |
| maxAncestors = 1000; |
| dataPath = dataPath.substring(0, dataPath.length - 4); |
| } else if (goog.string.endsWith(dataPath, '/*')) { |
| maxAncestors = 1; |
| dataPath = dataPath.substring(0, dataPath.length - 2); |
| } |
| |
| opt_id = opt_id || ''; |
| var key = dataPath + ':' + opt_id + ':' + goog.getUid(fn); |
| var listener = {dataPath: dataPath, id: opt_id, fn: fn}; |
| var expr = goog.ds.Expr.create(dataPath); |
| |
| var fnUid = goog.getUid(fn); |
| if (!this.listenersByFunction_[fnUid]) { |
| this.listenersByFunction_[fnUid] = {}; |
| } |
| this.listenersByFunction_[fnUid][key] = {listener: listener, items: []}; |
| |
| while (expr) { |
| var listenerSpec = {listener: listener, maxAncestors: maxAncestors}; |
| var matchingListeners = this.listenerMap_[expr.getSource()]; |
| if (matchingListeners == null) { |
| matchingListeners = {}; |
| this.listenerMap_[expr.getSource()] = matchingListeners; |
| } |
| matchingListeners[key] = listenerSpec; |
| maxAncestors = 0; |
| expr = expr.getParent(); |
| this.listenersByFunction_[fnUid][key].items.push( |
| {key: key, obj: matchingListeners}); |
| } |
| }; |
| |
| |
| /** |
| * Adds an indexed listener. |
| * |
| * Indexed listeners allow for '*' in data paths. If a * exists, will match |
| * all values and return the matched values in an array to the callback. |
| * |
| * Currently uses a promiscuous match algorithm: Matches everything before the |
| * first '*', and then does a regex match for all of the returned events. |
| * Although this isn't optimized, it is still an improvement as you can collapse |
| * 100's of listeners into a single regex match |
| * |
| * @param {Function} fn Callback function, signature (dataPath, id, indexes). |
| * @param {string} dataPath Fully qualified data path. |
| * @param {string=} opt_id A value passed back to the listener when the dataPath |
| * is matched. |
| */ |
| goog.ds.DataManager.prototype.addIndexedListener = function(fn, dataPath, |
| opt_id) { |
| var firstStarPos = dataPath.indexOf('*'); |
| // Just need a regular listener |
| if (firstStarPos == -1) { |
| this.addListener(fn, dataPath, opt_id); |
| return; |
| } |
| |
| var listenPath = dataPath.substring(0, firstStarPos) + '...'; |
| |
| // Create regex that matches * to any non '\' character |
| var ext = '$'; |
| if (goog.string.endsWith(dataPath, '/...')) { |
| dataPath = dataPath.substring(0, dataPath.length - 4); |
| ext = ''; |
| } |
| var regExpPath = goog.string.regExpEscape(dataPath); |
| var matchRegExp = regExpPath.replace(/\\\*/g, '([^\\\/]+)') + ext; |
| |
| // Matcher function applies the regex and calls back the original function |
| // if the regex matches, passing in an array of the matched values |
| var matchRegExpRe = new RegExp(matchRegExp); |
| var matcher = function(path, id) { |
| var match = matchRegExpRe.exec(path); |
| if (match) { |
| match.shift(); |
| fn(path, opt_id, match); |
| } |
| }; |
| this.addListener(matcher, listenPath, opt_id); |
| |
| // Add the indexed listener to the map so that we can remove it later. |
| var fnUid = goog.getUid(fn); |
| if (!this.indexedListenersByFunction_[fnUid]) { |
| this.indexedListenersByFunction_[fnUid] = {}; |
| } |
| var key = dataPath + ':' + opt_id; |
| this.indexedListenersByFunction_[fnUid][key] = { |
| listener: {dataPath: listenPath, fn: matcher, id: opt_id} |
| }; |
| }; |
| |
| |
| /** |
| * Removes indexed listeners with a given callback function, and optional |
| * matching datapath and matching id. |
| * |
| * @param {Function} fn Callback function, signature function(dataPath, id). |
| * @param {string=} opt_dataPath Fully qualified data path. |
| * @param {string=} opt_id A value passed back to the listener when the dataPath |
| * is matched. |
| */ |
| goog.ds.DataManager.prototype.removeIndexedListeners = function( |
| fn, opt_dataPath, opt_id) { |
| this.removeListenersByFunction_( |
| this.indexedListenersByFunction_, true, fn, opt_dataPath, opt_id); |
| }; |
| |
| |
| /** |
| * Removes listeners with a given callback function, and optional |
| * matching dataPath and matching id |
| * |
| * @param {Function} fn Callback function, signature function(dataPath, id). |
| * @param {string=} opt_dataPath Fully qualified data path. |
| * @param {string=} opt_id A value passed back to the listener when the dataPath |
| * is matched. |
| */ |
| goog.ds.DataManager.prototype.removeListeners = function(fn, opt_dataPath, |
| opt_id) { |
| |
| // Normalize data path root |
| if (opt_dataPath && goog.string.endsWith(opt_dataPath, '/...')) { |
| opt_dataPath = opt_dataPath.substring(0, opt_dataPath.length - 4); |
| } else if (opt_dataPath && goog.string.endsWith(opt_dataPath, '/*')) { |
| opt_dataPath = opt_dataPath.substring(0, opt_dataPath.length - 2); |
| } |
| |
| this.removeListenersByFunction_( |
| this.listenersByFunction_, false, fn, opt_dataPath, opt_id); |
| }; |
| |
| |
| /** |
| * Removes listeners with a given callback function, and optional |
| * matching dataPath and matching id from the given listenersByFunction |
| * data structure. |
| * |
| * @param {Object} listenersByFunction The listeners by function. |
| * @param {boolean} indexed Indicates whether the listenersByFunction are |
| * indexed or not. |
| * @param {Function} fn Callback function, signature function(dataPath, id). |
| * @param {string=} opt_dataPath Fully qualified data path. |
| * @param {string=} opt_id A value passed back to the listener when the dataPath |
| * is matched. |
| * @private |
| */ |
| goog.ds.DataManager.prototype.removeListenersByFunction_ = function( |
| listenersByFunction, indexed, fn, opt_dataPath, opt_id) { |
| var fnUid = goog.getUid(fn); |
| var functionMatches = listenersByFunction[fnUid]; |
| if (functionMatches != null) { |
| for (var key in functionMatches) { |
| var functionMatch = functionMatches[key]; |
| var listener = functionMatch.listener; |
| if ((!opt_dataPath || opt_dataPath == listener.dataPath) && |
| (!opt_id || opt_id == listener.id)) { |
| if (indexed) { |
| this.removeListeners( |
| listener.fn, listener.dataPath, listener.id); |
| } |
| if (functionMatch.items) { |
| for (var i = 0; i < functionMatch.items.length; i++) { |
| var item = functionMatch.items[i]; |
| delete item.obj[item.key]; |
| } |
| } |
| delete functionMatches[key]; |
| } |
| } |
| } |
| }; |
| |
| |
| /** |
| * Get the total number of listeners (per expression listened to, so may be |
| * more than number of times addListener() has been called |
| * @return {number} Number of listeners. |
| */ |
| goog.ds.DataManager.prototype.getListenerCount = function() { |
| var count = 0; |
| goog.object.forEach(this.listenerMap_, function(matchingListeners) { |
| count += goog.structs.getCount(matchingListeners); |
| }); |
| return count; |
| }; |
| |
| |
| /** |
| * Disables the sending of all data events during the execution of the given |
| * callback. This provides a way to avoid useless notifications of small changes |
| * when you will eventually send a data event manually that encompasses them |
| * all. |
| * |
| * Note that this function can not be called reentrantly. |
| * |
| * @param {Function} callback Zero-arg function to execute. |
| */ |
| goog.ds.DataManager.prototype.runWithoutFiringDataChanges = function(callback) { |
| if (this.disableFiring_) { |
| throw Error('Can not nest calls to runWithoutFiringDataChanges'); |
| } |
| |
| this.disableFiring_ = true; |
| try { |
| callback(); |
| } finally { |
| this.disableFiring_ = false; |
| } |
| }; |
| |
| |
| /** |
| * Fire a data change event to all listeners |
| * |
| * If the path matches the path of a listener, the listener will fire |
| * |
| * If your path is the parent of a listener, the listener will fire. I.e. |
| * if $Contacts/bob@bob.com changes, then we will fire listener for |
| * $Contacts/bob@bob.com/Name as well, as the assumption is that when |
| * a parent changes, all children are invalidated. |
| * |
| * If your path is the child of a listener, the listener may fire, depending |
| * on the ancestor depth. |
| * |
| * A listener for $Contacts might only be interested if the contact name changes |
| * (i.e. $Contacts doesn't fire on $Contacts/bob@bob.com/Name), |
| * while a listener for a specific contact might |
| * (i.e. $Contacts/bob@bob.com would fire on $Contacts/bob@bob.com/Name). |
| * Adding "/..." to a lisetener path listens to all children, and adding "/*" to |
| * a listener path listens only to direct children |
| * |
| * @param {string} dataPath Fully qualified data path. |
| */ |
| goog.ds.DataManager.prototype.fireDataChange = function(dataPath) { |
| if (this.disableFiring_) { |
| return; |
| } |
| |
| var expr = goog.ds.Expr.create(dataPath); |
| var ancestorDepth = 0; |
| |
| // Look for listeners for expression and all its parents. |
| // Parents of listener expressions are all added to the listenerMap as well, |
| // so this will evaluate inner loop every time the dataPath is a child or |
| // an ancestor of the original listener path |
| while (expr) { |
| var matchingListeners = this.listenerMap_[expr.getSource()]; |
| if (matchingListeners) { |
| for (var id in matchingListeners) { |
| var match = matchingListeners[id]; |
| var listener = match.listener; |
| if (ancestorDepth <= match.maxAncestors) { |
| listener.fn(dataPath, listener.id); |
| } |
| } |
| } |
| ancestorDepth++; |
| expr = expr.getParent(); |
| } |
| this.eventCount_++; |
| }; |