// 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 /** number */ 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_++; };