/**
* Angular ResourceCacheService
* Copyright 2016 Andreas Stocker
* MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
* OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
(function () {
'use strict';
var
module = angular.module('ngResourceFactory');
/**
* Factory that gives a cache class whose instances can be used for caching resources. Instances of this cache class
* are capable of invalidating related caches, updating resource list instances when getting data from detail calls
* and respecting the `dataAttr` of resource instances.
*
* @name ResourceCacheService
* @ngdoc service
* @param {String} name Name of the cache
* @param {String} pkAttr Name of the primary key attribute on cached instances
* @param {Object} [options] Additional configuration
* @param {String|null} options.urlAttr Name of the attribute to get the URL of the objects (default: `null`)
* @param {String|null} options.dataAttr Name of the attribute to get the actual data from (default: `null`)
* @param {Boolean} options.wrapObjectsInDataAttr When object cache entries are created from list results, use the `dataAttr` to wrap the data (default: `false`)
* @param {String[]} options.dependent List of dependent cache names (default: `[]`)
* @param {int} options.ttl Time to live for cache entries in seconds (default: `3600`)
* @class
*
* @example
* // Directly using `ResourceCacheService`
* inject(function (ResourceCacheService) {
* var
* exampleValue = {pk: 123, attr1: 1, attr2: 2},
* cacheInstance = new ResourceCacheService('MyExampleResourceCache', 'pk');
*
* cacheInstance.put('example-1', exampleValue, false);
* expect(cacheInstance.get('example-1', false)).toEqual(exampleValue);
* });
*
* @example
* // Using `ResourceCacheService` for `$http`
* inject(function (ResourceCacheService, $http) {
* var
* cacheInstance = new ResourceCacheService('MyExampleResourceCache', 'pk'),
* httpInstance = $http({cache: cacheInstance.withoutDataAttr});
*
* httpInstance.get('http://example.com/api/endpoint-1/');
* });
*/
module.factory('ResourceCacheService',
function () {
'ngInject';
var
caches = {};
function constructor (name, pkAttr, options) {
var
self = this,
/**
* The cache object
* @type {{}}
* @private
*/
cache = {},
/**
* Mapping of cache keys to boolean that indicates whether to use the `dataAttr` or not
* @type {{}}
* @private
*/
cacheUseDataAttr = {},
/**
* Mapping of cache keys to boolean that indicates whether the value is managed or not
* @type {{}}
* @private
*/
cacheIsManaged = {},
/**
* Mapping of cache keys to timestamps for automatic invalidation
* @type {{}}
* @private
*/
cacheTimestamps = {};
options = angular.extend({
/**
* Name of the attribute to get the URL of the objects
* @type {String|null}
* @private
*/
urlAttr: null,
/**
* Name of the attribute to get the actual data from
* @type {String|null}
* @private
*/
dataAttr: null,
/**
* When object cache entries are created from list results, use the `dataAttr`
* to wrap the data.
* @type {Boolean}
* @private
*/
wrapObjectsInDataAttr: false,
/**
* Dependent caches
* @type {Array<String>}
* @private
*/
dependent: [],
/**
* Time to live for cache entries in seconds
* @type {int}
* @private
*/
ttl: 60 * 60
}, options || {});
// initialize the cache
init();
/**
* Gets the first item from cache that matches the given PK value. If there is no item on the
* cache that matches, this method returns `undefined`. Note that the cache TTL is ignored.
*
* @memberOf ResourceCacheService
* @function firstByPk
* @param {String|int} pkValue PK value to search for
* @returns {Object|undefined} Search result data
* @instance
*/
self.firstByPk = function (pkValue) {
for (var key in cacheIsManaged) {
if (cacheIsManaged.hasOwnProperty(key) && cacheIsManaged[key]) {
var
data = getDataForKey(key),
isArray = angular.isArray(data),
isObject = angular.isObject(data);
if (isArray) {
for (var i = 0; i < data.length; i++) {
var
dataItem = data[i],
dataItemIsObject = angular.isObject(dataItem);
if (dataItemIsObject && dataItem[pkAttr] == pkValue) {
return dataItem;
}
}
}
else if (isObject && data[pkAttr] == pkValue) {
return data;
}
}
}
return undefined;
};
/**
* Gets all items from cache that match the given PK value. If there is no item on the
* cache that matches, this method returns an empty array. Note that the cache TTL is ignored.
*
* @memberOf ResourceCacheService
* @function findByPk
* @param {String|int} pkValue PK value to search for
* @returns {Object[]} Search results data
* @instance
*/
self.findByPk = function (pkValue) {
var
result = [];
for (var key in cacheIsManaged) {
if (cacheIsManaged.hasOwnProperty(key) && cacheIsManaged[key]) {
var
data = getDataForKey(key),
isArray = angular.isArray(data),
isObject = angular.isObject(data);
if (isArray) {
for (var i = 0; i < data.length; i++) {
var
dataItem = data[i],
dataItemIsObject = angular.isObject(dataItem);
if (dataItemIsObject && dataItem[pkAttr] == pkValue) {
result.push(dataItem);
}
}
}
else if (isObject && data[pkAttr] == pkValue) {
result.push(data);
}
}
}
return result;
};
/**
* Refreshes the cache entries with the new value or values. The existing objects in the cache
* are matched by the `pkAttr` value, and additionally by the `urlAttr`, if available.
*
* @memberOf ResourceCacheService
* @function refresh
* @param {Object|Array<Object>} value Instance or list of instances used to refresh data on the cache
* @instance
*/
self.refresh = function (value) {
// refresh the existing values in the cache with the new entries
if (angular.isArray(value)) {
console.log("ResourceCacheService: Refresh existing entries with list of new entries on the cache '" + name + "'.");
refreshEach(value);
}
// refresh the existing values in the cache with the new entry
else if (angular.isObject(value)) {
console.log("ResourceCacheService: Refresh existing entries with new entry on the cache '" + name + "'.");
refreshSingle(value);
}
else {
console.log("ResourceCacheService: Unable to refresh existing entries on the cache '" + name + "' as given value is neither an array nor an object.");
}
};
/**
* Creates a cache entry for the given value and puts it on the cache.
*
* @memberOf ResourceCacheService
* @function insert
* @param {String} key Cache entry key
* @param {*} value Cache entry value
* @param {Boolean} useDataAttr Use the `dataAttr` to get actual cache entry value
* @param {Boolean} [refresh] Refresh the existing cache entries by using the new value
* @instance
*/
self.insert = function (key, value, useDataAttr, refresh) {
console.log("ResourceCacheService: Insert value with key '" + key + "' on the cache '" + name + "'.");
var
isManaged = angular.isObject(value) || angular.isArray(value),
status = 200,
headers = isManaged ? {'content-type': 'application/json'} : {},
statusText = 'OK',
entry = [status, value, headers, statusText];
useDataAttr = !!useDataAttr;
refresh = angular.isUndefined(refresh) ? true : !!refresh;
if (key) {
cache[key] = entry;
cacheUseDataAttr[key] = useDataAttr && isManaged;
cacheIsManaged[key] = isManaged;
createOrUpdateTimestamp(key);
// only refresh existing data if `refresh` parameter was not set to false
if (refresh) {
self.refresh(getDataForEntry(entry, useDataAttr));
}
}
};
/**
* Puts the given entry with the given key on the cache.
*
* @memberOf ResourceCacheService
* @function put
* @param {String} key Cache entry key
* @param {*} value Cache entry value
* @param {Boolean} useDataAttr Use the `dataAttr` to get actual cache entry value
* @instance
*/
self.put = function (key, value, useDataAttr) {
console.log("ResourceCacheService: Put entry with key '" + key + "' on the cache '" + name + "'.");
useDataAttr = !!useDataAttr;
var
/**
* Indicates if value is managed by the cache, which means it is refreshed if new calls
* return the same object.
* @type {boolean}
*/
isManaged = false;
if (key) {
// store the actual data object, not the serialized string, for JSON
if (value && value[2] && isJsonContentType(value[2]['content-type'])) {
console.log("ResourceCacheService: Use deserialized data for key '" + key + "' on the cache '" + name + "'.");
value[1] = value[1] ? angular.fromJson(value[1]) : null;
isManaged = true;
}
else {
console.log("ResourceCacheService: Use raw data for key '" + key + "' on the cache '" + name + "'.");
useDataAttr = false;
isManaged = false;
}
cache[key] = value;
cacheUseDataAttr[key] = useDataAttr;
cacheIsManaged[key] = isManaged;
createOrUpdateTimestamp(key);
// only refresh the cache entries if the value is already a cache entry (which is
// always an array or object), not a promise.
if (isManaged) {
self.refresh(getDataForEntry(value, useDataAttr));
}
}
};
/**
* Gets the entry with the given key from the cache, or undefined.
*
* @memberOf ResourceCacheService
* @function get
* @param {String} key Cache entry key
* @param {Boolean} [useCacheTtl] If `true` this method will return `undefined` when the TTL of the entry is outreached (default: `true`)
* @returns {*} Cache entry
* @instance
*/
self.get = function (key, useCacheTtl) {
var
value = undefined;
// `useCacheTtl` should default to true
useCacheTtl = angular.isUndefined(useCacheTtl) || !!useCacheTtl ? true : false;
if (cache.hasOwnProperty(key)) {
if (!useCacheTtl || isEntryAlive(key)) {
console.log("ResourceCacheService: Get entry with key '" + key + "' from the cache '" + name + "'.");
value = cache[key];
// serialize to string for managed objects
if (cacheIsManaged[key]) {
value = angular.copy(value);
value[1] = angular.toJson(value[1]);
}
}
else {
console.log("ResourceCacheService: Entry with key '" + key + "' exceeded TTL on the cache '" + name + "'.");
self.remove(key);
}
}
else {
console.log("ResourceCacheService: Unable to get entry with key '" + key + "' from the cache '" + name + "'.");
}
return value;
};
/**
* Removes the entry with the given key from the cache.
*
* @memberOf ResourceCacheService
* @function remove
* @param {String} key Cache entry key
* @instance
*/
self.remove = function (key) {
if (cache.hasOwnProperty(key)) {
console.log("ResourceCacheService: Remove entry with key '" + key + "' from the cache '" + name + "'.");
delete cache[key];
delete cacheTimestamps[key];
delete cacheUseDataAttr[key];
delete cacheIsManaged[key];
}
};
/**
* Removes all entries from the cache.
*
* @memberOf ResourceCacheService
* @function removeAll
* @instance
*/
self.removeAll = function () {
console.log("ResourceCacheService: Remove all entries from the cache '" + name + "'.");
for (var key in cache) {
if (cache.hasOwnProperty(key)) {
delete cache[key];
delete cacheTimestamps[key];
delete cacheUseDataAttr[key];
delete cacheIsManaged[key];
}
}
};
/**
* Removes all list entries from the cache.
*
* @memberOf ResourceCacheService
* @function removeAllLists
* @instance
*/
self.removeAllLists = function () {
console.log("ResourceCacheService: Remove all list entries from the cache '" + name + "'.");
for (var key in cache) {
if (cache.hasOwnProperty(key) && angular.isArray(getDataForKey(key))) {
delete cache[key];
delete cacheTimestamps[key];
delete cacheUseDataAttr[key];
delete cacheIsManaged[key];
}
}
};
/**
* Removes all list entries from the cache.
*
* @memberOf ResourceCacheService
* @function removeAllObjects
* @instance
*/
self.removeAllObjects = function () {
console.log("ResourceCacheService: Remove all object entries from the cache '" + name + "'.");
for (var key in cache) {
if (cache.hasOwnProperty(key) && angular.isObject(getDataForKey(key))) {
delete cache[key];
delete cacheTimestamps[key];
delete cacheUseDataAttr[key];
delete cacheIsManaged[key];
}
}
};
/**
* Removes all raw entries from the cache.
*
* @memberOf ResourceCacheService
* @function removeAllRaw
* @instance
*/
self.removeAllRaw = function () {
console.log("ResourceCacheService: Remove all raw entries from the cache '" + name + "'.");
for (var key in cache) {
if (cache.hasOwnProperty(key) && !cacheIsManaged[key]) {
delete cache[key];
delete cacheTimestamps[key];
delete cacheUseDataAttr[key];
delete cacheIsManaged[key];
}
}
};
/**
* Removes all entries of the dependent caches, including the dependent caches of the
* dependent caches (and so on ...).
*
* @memberOf ResourceCacheService
* @function removeAllDependent
* @instance
*/
self.removeAllDependent = function () {
var
dependentCacheNames = collectDependentCacheNames(self, []);
for (var i = 0; i < dependentCacheNames.length; i++) {
caches[dependentCacheNames[i]].removeAll();
}
};
/**
* Destroys the cache object.
*
* @memberOf ResourceCacheService
* @function destroy
* @instance
*/
self.destroy = function () {
var
cacheIndex = caches.indexOf(self),
isManaged = cacheIndex !== -1;
if (isManaged) {
console.log("ResourceCacheService: Destroy the cache '" + name + "'.");
self.removeAll();
caches.splice(cacheIndex, 1);
}
};
/**
* Retrieve information regarding the cache.
*
* @memberOf ResourceCacheService
* @function info
* @returns {ResourceCacheServiceMeta} Information about the cache instance
* @instance
*/
self.info = function () {
console.log("ResourceCacheService: Get cache information from the cache '" + name + "'.");
var
size = 0;
// calculate the cache size
for (var key in cache) {
if (cache.hasOwnProperty(key)) {
size++;
}
}
return {
'id': name,
'size': size,
'options': options
}
};
/**
* Cache interface to put entries using `dataAttr` on the cache.
*
* @memberOf ResourceCacheService
* @member withDataAttr
* @type {HttpCacheInstance}
* @instance
*/
self.withDataAttr = {
put: function (key, value) {
return self.put(key, value, true);
},
get: function (key) {
return self.get(key, true);
},
remove: self.remove,
removeAll: self.removeAll,
info: self.info
};
/**
* Cache interface to put entries without using `dataAttr` on the cache.
*
* @memberOf ResourceCacheService
* @member withoutDataAttr
* @type {HttpCacheInstance}
* @instance
*/
self.withoutDataAttr = {
put: function (key, value) {
return self.put(key, value, false);
},
get: function (key) {
return self.get(key, true);
},
remove: self.remove,
removeAll: self.removeAll,
info: self.info
};
/**
* Cache interface to put entries using `dataAttr` on the cache and ignoring TTL.
*
* @memberOf ResourceCacheService
* @member withDataAttrNoTtl
* @type {HttpCacheInstance}
* @instance
*/
self.withDataAttrNoTtl = {
put: function (key, value) {
return self.put(key, value, true);
},
get: function (key) {
return self.get(key, false);
},
remove: self.remove,
removeAll: self.removeAll,
info: self.info
};
/**
* Cache interface to put entries without using `dataAttr` on the cache and ignoring TTL.
*
* @memberOf ResourceCacheService
* @member withoutDataAttrNoTtl
* @type {HttpCacheInstance}
* @instance
*/
self.withoutDataAttrNoTtl = {
put: function (key, value) {
return self.put(key, value, false);
},
get: function (key) {
return self.get(key, false);
},
remove: self.remove,
removeAll: self.removeAll,
info: self.info
};
/**
* Checks if the given content type string indicates JSON.
*
* @memberOf ResourceCacheService
* @function isJsonContentType
* @param {String} contentType Content type string to check for
* @return {Boolean}
* @private
*/
function isJsonContentType (contentType) {
if (!contentType) {
return false;
}
return (contentType === 'application/json' || String(contentType).indexOf('application/json;') === 0);
}
/**
* Gets the cache data for the given key.
*
* @memberOf ResourceCacheService
* @function getDataForKey
* @param {String} key Cache entry key
* @private
*/
function getDataForKey (key) {
if (cache.hasOwnProperty(key)) {
var
entry = cache[key],
useDataAttr = cacheUseDataAttr[key];
return getDataForEntry(entry, useDataAttr);
}
}
/**
* Creates a cache entry from the given data.
*
* @memberOf ResourceCacheService
* @function createEntryForData
* @param {Object} data Object to create a cache entry from
* @returns {*}
* @private
*/
function createEntryForData (data) {
var
entry = {};
if (options.wrapObjectsInDataAttr) {
entry[options.dataAttr] = data;
}
else {
entry = data;
}
return entry;
}
/**
* Gets the cache data for the given cache entry.
*
* @memberOf ResourceCacheService
* @function getDataForEntry
* @param {Object} value Object to get the actual value from
* @param {Boolean} useDataAttr Use the `dataAttr` to get the actual value from the given object
* @returns {*}
* @private
*/
function getDataForEntry (value, useDataAttr) {
var
data = value[1];
if (useDataAttr && options.dataAttr && data) {
return data[options.dataAttr]
}
else {
return data;
}
}
/**
* Sets the cache data for the given key.
*
* @memberOf ResourceCacheService
* @function setDataForKey
* @param {String} key Cache entry key
* @param {*} newData New value for the cache entry
* @private
*/
function setDataForKey (key, newData) {
if (cache.hasOwnProperty(key)) {
var
entry = cache[key],
entryUseDataAttr = cacheUseDataAttr[key],
entryData = entry[1];
if (entryUseDataAttr && options.dataAttr && entryData) {
entryData[options.dataAttr] = newData;
}
else {
entryData = newData;
}
entry[1] = entryData;
}
}
/**
* Returns the current unix epoch in seconds.
*
* @memberOf ResourceCacheService
* @function getCurrentTimestamp
* @returns {int} Current unix epoch
* @private
*/
function getCurrentTimestamp () {
return Math.floor(Date.now() / 1000);
}
/**
* Sets the timestamp for the given key to the current unix epoch in seconds.
*
* @memberOf ResourceCacheService
* @function createOrUpdateTimestamp
* @param {String} key Cache entry key
* @returns {int} Current unix epoch
* @private
*/
function createOrUpdateTimestamp (key) {
cacheTimestamps[key] = getCurrentTimestamp();
return cacheTimestamps[key];
}
/**
* Checks if the cache entry for the given key is still alive. Also returns
* `false` if there is no cache entry for the given key.
*
* @memberOf ResourceCacheService
* @function isEntryAlive
* @param {String} key Cache entry key
* @returns {Boolean} Cache entry alive or not alive
* @private
*/
function isEntryAlive (key) {
if (cache.hasOwnProperty(key)) {
var
entryAge = getCurrentTimestamp() - cacheTimestamps[key];
return entryAge <= options.ttl;
}
return false;
}
/**
* Takes a new cache entry and refreshes the existing instances of the entry, matching by the
* `pkAttr` value.
*
* @memberOf ResourceCacheService
* @function refreshSingle
* @param {Object} newData New data to refresh the cache with
* @private
*/
function refreshSingle (newData) {
var
urlAttr = options.urlAttr;
// inserts the data on the cache as individual entry, if we have the URL information on the data
if (urlAttr && newData && newData[urlAttr]) {
self.insert(newData[urlAttr], createEntryForData(newData), options.wrapObjectsInDataAttr, false);
}
for (var key in cache) {
if (cache.hasOwnProperty(key) && cacheIsManaged[key]) {
var
entry = cache[key],
entryUseDataAttr = cacheUseDataAttr[key],
entryData = getDataForEntry(entry, entryUseDataAttr),
isList = angular.isArray(entryData);
// refresh the objects matching the new object within the list entries in the cache
if (isList) {
for (var i = 0; i < entryData.length; i++) {
if (entryData[i][pkAttr] === newData[pkAttr]) {
// additionally compare the `urlAttr`, if available
if (!urlAttr || (urlAttr && entryData[i][urlAttr] === newData[urlAttr])) {
entryData[i] = newData;
}
}
}
// update the cache entry with the new data
setDataForKey(key, entryData);
}
// refresh the objects matching the new object in the cache
else {
if (entryData[pkAttr] === newData[pkAttr]) {
// additionally compare the `urlAttr`, if available
if (!urlAttr || (urlAttr && entryData[urlAttr] === newData[urlAttr])) {
setDataForKey(key, newData);
// for object entries we can update the entries timestamp
createOrUpdateTimestamp(key);
}
}
}
}
}
}
/**
* Refreshes each entry in the given list using the `refreshSingle` method.
*
* @memberOf ResourceCacheService
* @function refreshEach
* @param {Array<Object>} newEntries List of new data to refresh the cache with
* @private
*/
function refreshEach (newEntries) {
for (var i = 0; i < newEntries.length; i++) {
if (angular.isObject(newEntries[i])) {
refreshSingle(newEntries[i]);
}
}
}
/**
* Initializes the cache object.
*
* @memberOf ResourceCacheService
* @function init
* @private
*/
function init () {
// make sure the given name is not used yet
if (caches.hasOwnProperty(name)) {
throw Error("Name '" + name + "' is already used by another cache.");
}
caches[name] = self;
}
}
/**
* Calls the removeAll method on all managed caches.
*
* @memberOf ResourceCacheService
* @function removeAll
* @static
*/
constructor.removeAll = function () {
for (var key in caches) {
if (caches.hasOwnProperty(key)) {
caches[key].removeAll();
}
}
};
/**
* Gets the cache with the given name, or null.
*
* @memberOf ResourceCacheService
* @function get
* @param {String} key Cache name
* @returns {ResourceCacheService} Cache instance with the given name
* @static
*/
constructor.get = function (key) {
if (caches.hasOwnProperty(key)) {
return caches[key];
}
console.log("ResourceCacheService: Cache '" + key + "' does not exist.");
return null;
};
/**
* Gets the cache information for all managed caches as mapping of cacheId to the result
* of the info method on the cache.
*
* @memberOf ResourceCacheService
* @function info
* @returns {Object} Information object for all caches
* @static
*/
constructor.info = function () {
var
infos = {};
for (var key in caches) {
if (caches.hasOwnProperty(key)) {
var
info = caches[key].info();
infos[info.id] = info;
}
}
return infos;
};
/**
* Collects all dependent caches of the given cache, including the dependent caches of the dependent
* caches (and so on ...).
*
* @memberOf ResourceCacheService
* @function collectDependentCacheNames
* @param {Object} cache
* @param {Array<String>|undefined} collectedDependentCacheNames
* @returns {Array<String>}
* @private
*/
function collectDependentCacheNames (cache, collectedDependentCacheNames) {
var
cacheDependentCacheNames = cache.info()['options']['dependent'];
// default `collectedDependentCacheNames` to empty list
collectedDependentCacheNames = collectedDependentCacheNames || [];
for (var i = 0; i < cacheDependentCacheNames.length; i++) {
var
cacheDependentCacheName = cacheDependentCacheNames[i],
cacheDependentCache = caches[cacheDependentCacheName];
if (cacheDependentCache) {
// push cache name to the collected dependent caches, if existing
collectedDependentCacheNames.push(cacheDependentCacheName);
// only collect cache dependencies if not already collected, to prevent circles
if (collectedDependentCacheNames.indexOf(cacheDependentCacheName) === -1) {
collectDependentCacheNames(cacheDependentCache, collectedDependentCacheNames)
}
}
}
return collectedDependentCacheNames;
}
return constructor;
}
);
})();