scalra
Version:
node.js framework to prototype and scale rapidly
852 lines (703 loc) • 22.4 kB
JavaScript
//
// datastore.js
//
// generic API for accessing an in-memory cache of some persistent data (currently stored to DB)
//
// history:
// 2016-04-18 first version
//
// functions:
//
// init({models}, onDone) // load initial DB content to memory
// get({name, query}, onDone) // get a given data set by 'name', returns either an array or object depending on whether 'query' terms are specified
// map({name, key}, onDone) // build and return a mapping relations from key value to stored elements in array
var l_name = 'SR.DS';
/*
example types:
name : String,
surname : String,
age : Number, // FLOAT
male : Boolean,
continent : [ "Europe", "America", "Asia", "Africa", "Australia", "Antartica" ], // ENUM type
photo : Buffer, // BLOB/BINARY
data : Object // JSON encoded
*/
// mapping from type name to actual type object
// supported types ref: https://github.com/dresende/node-orm2/wiki/Model-Properties
// see example: https://github.com/dresende/node-orm2
var l_types = {
'string': String,
'boolean': Boolean,
'number': Number,
'date': Date,
'object': Object,
'buffer': Buffer, // used for binary data such as photos
'array': Object
};
var l_rebuildObjects = function (obj) {
// ver 3: both objects & strings
for (var key in obj) {
// we leave off functions such as 'save'
if (typeof obj[key] === 'function' || typeof obj[key] === 'undefined')
continue;
// for objects
if (typeof obj[key] === 'object') {
var newitem = {};
var olditem = obj[key];
// copy existing values
for (var k in olditem) {
newitem[k] = olditem[k];
}
// re-assign back
obj[key] = newitem;
} else {
// for string/number/boolean
var temp = obj[key];
obj[key] = undefined;
if (typeof temp === 'number' && isNaN(temp)) {
LOG.warn('NaN found! ignore storing', l_name);
} else {
obj[key] = temp;
}
}
}
return obj;
/*
// ver 2: just 1st-level attributes
// NOTE: won't work, as .save() is not transferred correctly
var newobj = {};
for (var key in obj) {
newobj[key] = obj[key];
}
return newobj;
*/
/* ver 1: just objects
for (var key in obj) {
if (typeof obj[key] !== 'object')
continue;
var newitem = {};
var olditem = obj[key];
// copy existing values
for (var k in olditem) {
newitem[k] = olditem[k];
}
obj[key] = newitem;
}
return obj;
*/
};
// create new array for all elements of array type
// TODO: find more efficient approach?
var l_remakeArray = function (obj) {
for (var key in obj) {
if (typeof obj[key] === 'object') {
if (obj[key] instanceof Array) {
LOG.warn('need to remake array for [' + key + ']', l_name);
var oldarr = obj[key];
var arr = [];
for (var i=0; i < oldarr.length; i++)
arr.push(oldarr[i]);
// assigne new array as value to key
obj[key] = arr;
} else {
// keep processing recursively
LOG.warn('need to further process [' + key + ']', l_name);
l_remakeArray(obj[key]);
}
}
}
};
// build a query with only string elements (possibly will contain the key of the object)
// TODO: more precise approach based on knowledge of object's key?
var l_buildQuery = function (obj) {
var query = {};
for (var key in obj) {
if (typeof obj[key] === 'string') {
query[key] = obj[key];
}
}
return query;
};
// check if a given datastore needs to build key-based mapping
var l_checkMapper = function (name, data, onDone) {
//LOG.warn('data to check:');
//LOG.warn(data);
// check if data contains key (if key is defined for this structure)
if (l_mappers.hasOwnProperty(name)) {
var key = l_mappers[name].key;
if (data.hasOwnProperty(key) === false || data[key] === '' ||
(typeof data[key] !== 'string' && typeof data[key] !== 'number')) {
var errmsg = 'new record does not have a valid string for key field [' + key + ']';
LOG.error(errmsg, l_name);
UTIL.safeCall(onDone, errmsg);
return false;
}
}
return true;
};
// build key-record mapping, if key index exists for a given datastore
// optionally can remove a previous key "old_key"
var l_buildMapper = function (name, record, remove_old) {
// store to mapper (if exists)
if (l_mappers.hasOwnProperty(name)) {
var key = l_mappers[name].key;
var mapping = l_mappers[name].mapping;
// this check here should be redundent
if (record.hasOwnProperty(key) === false) {
LOG.error('new record stored does not have specified key [' + key + ']', l_name);
return false;
}
if (remove_old === true) {
for (var record_key in mapping) {
if (mapping[record_key] === record) {
delete mapping[record_key];
LOG.warn('existing record [' + record_key + '] found, remove it!', l_name);
}
}
}
mapping[record[key]] = record;
}
return true;
};
// modify field content from object to array if the type is 'array'
var l_convertArray = function (obj, array_names) {
for (var i=0; i < array_names.length; i++) {
var key = array_names[i];
if (typeof obj[key] !== 'object') {
continue;
}
var array_obj = obj[key];
LOG.warn('array field [' + key + '] is treated as object!', l_name);
LOG.warn(array_obj, l_name);
// convert array object to js array
var temp_arr = [];
for (var idx_key in array_obj) {
temp_arr.push(array_obj[idx_key]);
}
LOG.warn('convert to array with element size: ' + temp_arr.length, l_name);
LOG.warn(temp_arr, l_name);
obj[key] = temp_arr;
}
};
var l_createSync = function (name, src, array_fields) {
return function (onSyncDone) {
// make sure all arrays are re-built
//l_remakeArray(src);
// make sure object items have new structure
// FIXME TODO: investigate why this is necessary or are there better ways to do it
src = l_rebuildObjects(src);
// direct save version
// NOTE: very important to pass in a callback for errors, otherwise API server won't follow:
// NOTE: too that onSyncDone may not be defined
// ref: https://github.com/dresende/node-orm2/issues/439
//src.save(function (err) {
// UTIL.safeCall(onSyncDone, err);
//});
var build_mapper = false;
if (l_checkMapper(name, src)) {
// first check if 'key' has been modified
var mapping = l_mappers[name].mapping;
var key = l_mappers[name].key;
//LOG.warn('key [' + key + '] exists for DS [' + name + ']', l_name);
if (mapping.hasOwnProperty(src[key]) === false) {
build_mapper = true;
} else {
if (mapping[src[key]] !== src) {
// new key collides with an existing object with same key name
return UTIL.safeCall(onSyncDone, 'new key [' + src[key] + '] collides with existing key!');
}
}
LOG.warn('build_mapper: ' + build_mapper);
}
// save twice version
src.save(function (err) {
if (err) {
return UTIL.safeCall(onSyncDone, err);
}
src = l_rebuildObjects(src);
src.save(function (err) {
// rebuild key-based mapping
if (build_mapper) {
l_buildMapper(name, src, true);
}
// convert 'array' types to array form instead of object
l_convertArray(src, array_fields);
UTIL.safeCall(onSyncDone, err);
});
});
/*
var query = l_buildQuery(src);
// get version
l_get({
name: name,
query: query
}, function (err, obj) {
if (err) {
return UTIL.safeCall(onSyncDone, err);
}
//LOG.warn('[sync] obj found:', l_name);
//LOG.warn(obj, l_name);
// NOTE: it's VERY important to pass in callback for results otherwise .save() will fail
// ORM's author does not like empty callbacks
obj.save(function (err) {
UTIL.safeCall(onSyncDone, err);
});
});
*/
// ORM version
/*
SR.ORM.read({
name: name,
query: query
}, function (err, result) {
if (err) {
return UTIL.safeCall(onSyncDone, err);
}
LOG.warn(result.length + ' records found for [' + name + '] in DB...', l_name);
//LOG.warn(result, l_name);
var obj = result[0];
LOG.warn('[sync] obj found:', l_name);
LOG.warn(obj, l_name);
// store new object
obj.save(onSyncDone);
});
*/
};
};
// load DB data to memory cache in array form
var l_load = function (arr, name, model, cache, onDone) {
// DB schema definition
var names = {};
// determine attributes & their types based on a sample object
var attr = {};
// fields that are of array-type (used to convert object content to array format)
var array_fields = [];
for (var key in model) {
var type = model[key];
if (typeof type === 'string' && l_types.hasOwnProperty(type)) {
// for basic system-supported types
attr[key] = l_types[type];
// store fields of type 'array', to be used later
if (type === 'array') {
array_fields.push(key);
}
} else if (type instanceof Array) {
// for enum type (such as: [ "USA", "Canada", "Rest of the World" ])
// see: https://github.com/dresende/node-orm2/wiki/Model-Properties
attr[key] = type;
} else {
LOG.error('unknown type: ' + key, l_name);
LOG.error(type, l_name);
}
}
LOG.sys('[' + name + '] init with attributes:', l_name);
LOG.sys(attr, l_name);
// TODO: able to specify methods & validations in future?
names[name] = {
attributes: attr,
methods: {
//fullName: function () {
// return this.name + ' ' + this.surname;
//}
},
validations: {
//age: {lower: 18, upper: undefined, msg: 'under-age'}
}
};
var settings = SR.DB.getSettings();
// attempt to get from DB
SR.ORM.init({
DB_type: settings.DB_type,
username: settings.account,
password: settings.password,
DB: settings.DB_name,
names: names
}, function (err) {
if (err) {
return UTIL.safeCall(onDone, err);
}
//LOG.warn('[' + settings.DB_name + '] init success, read to cache...', l_name);
// read existing data
SR.ORM.read({
name: name
}, function (err, result) {
if (err) {
return UTIL.safeCall(onDone, err);
}
//LOG.debug(result.length + ' records found for [' + name + '] in DB...', l_name);
//LOG.warn(result, l_name);
// store to memory cache
// TODO: check do we need to clone/copy value? or simply use reference is fine?
// right now we only use references
for (var i=0; i < result.length; i++) {
result[i].sync = l_createSync(name, result[i], array_fields);
// convert result to array form, if type is specified as 'array'
l_convertArray(result[i], array_fields);
//result[i].remake = l_remakeArray;
arr.push(result[i]);
}
// attach function to sync in-memory data to DB
// TODO: use prototype not dynamic function?
// external dependency: name, arr, l_mappers
// returns the size of the data items stored
arr.size = function () {
return arr.length;
};
arr.add = function (data, onAddDone) {
LOG.sys('add new [' + name + '] entry:', l_name);
LOG.sys(data, l_name);
if (l_checkMapper(name, data, onAddDone) === false) {
return;
}
// add a new entry
SR.ORM.create({
name: name,
data: data
}, function (err, record) {
if (err) {
return UTIL.safeCall(onAddDone, err);
}
// attach sync function
record.sync = l_createSync(name, record, array_fields);
// NOTE: we need to store into the existing master array
// this is an important step as subsequent update to this entry needs to be done
// via this returned object instead of on the original JSON object
// (which doesn't have methods such as 'save')
//record.remake = l_remakeArray;
arr.push(record);
LOG.debug('[' + name + '] now has ' + arr.length + ' records', l_name);
l_buildMapper(name, record);
UTIL.safeCall(onAddDone, null, record);
});
};
arr.update = function (account, data, onDone) {
const record = arr.find((r) => r.account === account);
Object.keys(data).forEach((key) => {
record[key] = data[key];
});
SR.ORM.update({
name,
data,
query: { account }
}, onDone);
};
// external dependency: name, arr, l_mappers
// remove a given element based on query (from both memory & DB)
arr.remove = function (query, onRemoveDone) {
LOG.warn('remove entry with query:', l_name);
LOG.warn(query, l_name);
// remove an entry
SR.ORM.delete({
name: name,
query: query
}, function (err) {
if (err) {
return UTIL.safeCall(onRemoveDone, err);
}
// NOTE: we need to remove also from master array
// TODO: cleaner approach?
var index = (-1);
for (var i=0; i < arr.length; i++) {
var matched = true;
for (var key in query) {
//LOG.warn('arr[' + i + '][' + key + ']: ' + arr[i][key] + ' query[' + key + ']: ' + query[key], l_name);
// if the query item mismatch we terminate
if (query[key] && arr[i][key] !== query[key]) {
matched = false;
break;
}
}
if (matched) {
index = i;
//LOG.warn('match found with index: ' + index, l_name);
break;
}
}
var errmsg = null;
if (index === (-1)) {
errmsg = 'no matching records found for query';
} else {
// remove from mapper (if exists)
if (l_mappers.hasOwnProperty(name)) {
var record = arr[index];
var key = l_mappers[name].key;
var mapping = l_mappers[name].mapping;
if (record.hasOwnProperty(key) === false) {
LOG.error('record stored does not have the specified key [' + key + ']', l_name);
} else {
delete mapping[record[key]];
}
}
arr.splice(index, 1);
}
LOG.warn('[' + name + '] now has ' + arr.length + ' records', l_name);
UTIL.safeCall(onRemoveDone, errmsg);
});
};
//LOG.warn('[' + name + '] checking cache info...', l_name);
//LOG.warn(cache, l_name);
// cache it to memory
if (typeof cache.map === 'object') {
var map = l_map({
name: name,
key: cache.key,
map: cache.map
});
if (map) {
UTIL.safeCall(onDone, null, {name: name, array: arr, map: map});
} else {
// only array is built, map is not built successfully
UTIL.safeCall(onDone, 'build mapping failed', {name: name, array: arr});
}
} else {
// return immediately
//LOG.warn('[' + name + '] load success with length: ' + arr.length, l_name);
UTIL.safeCall(onDone, null, {name: name, array: arr});
}
});
});
};
/*
args: {
models: ['object'] // base model mapping from collection name to record content formats
}
models example:
{
'StreamGroups': {
id: 'string',
name: 'string',
streams: 'object', // array of vid
schedule: 'object' // array of numbers indicating schedule type (0, 1, 2)
}
}
caches example:
{
'Segments': {
key: 'vid',
map: l_segments
},
'Recordings': {
array: l_recordings
},
'StreamGroups': {
array: l_groups
}
}
*/
// load initial DB content to memory
var l_init = exports.init = function (args, onDone) {
// check if DB module is enabled
if (SR.DB.isEnabled() === false) {
return onDone('Datastore cannot init: DB module is not loaded');
}
var models = args.models;
var caches = args.caches || {};
if (typeof models !== 'object') {
return UTIL.safeCall(onDone, 'models are not specified in object form');
}
var total = Object.keys(models).length;
var curr = 0;
// first check for unique key indication (by * in front of name)
var keys = {};
for (var name in models) {
var model = models[name];
for (var key in model) {
var type = model[key];
if (type.charAt(0) === '*') {
if (keys.hasOwnProperty(name)) {
LOG.error('duplicate attribute name [' + type + '] are indicated as key, ignore it...', l_name);
continue;
}
// record key index
keys[name] = key;
// remove the '*'
model[key] = type.substring(1);
}
}
}
// references to loaded models, key is model name, value is an array or map
// of the loaded model
var ref = {};
// go through and load each model
for (var name in models) {
var model = models[name];
if (typeof model !== 'object') {
LOG.error('model is not specified in object form', l_name);
continue;
}
// get reference to states in array format
var arr = SR.State.get(name + 'Array', 'array');
// get reference to states in key-value map format
var map = undefined;
// use specified cache reference or build one from '*' if exists
var cache = caches[name] || {};
if (Object.keys(cache).length === 0 && keys[name]) {
map = SR.State.get(name + 'Map');
cache = {key: keys[name], map: map};
}
var errmsg = [];
l_load(arr, name, model, cache, function (err, result) {
if (err) {
LOG.error(err, l_name);
errmsg.push(err);
} else {
LOG.warn('[' + result.name + '] model init successfully with ' + result.array.length + ' records.', l_name);
// store successfully initialized model
// NOTE: result contains name, array, map (optional) attributes
ref[result.name] = (result.map ? result.map : result.array);
}
// check if all are loaded
if (++curr === total) {
return UTIL.safeCall(onDone, (errmsg.length === 0 ? null : errmsg), ref);
}
});
}
};
/*
args: {
name: 'string', // name of state set to get
query: 'object', // query condition to limit result
}
*/
// get a particular set of DB states
var l_get = exports.get = function (args, onDone) {
var name = args.name;
var q = args.query;
var select = args.select; // fields the callers is interested to get
// try to get it from memory first, if not found then try to get it from DB
// TODO: get from DB
var arr = SR.State.get(name + 'Array', 'array');
// filter query content to be only valid parameters
// TODO: better approach?
var query = {};
for (var key in q) {
// keep only valid query terms
if (typeof q[key] !== 'undefined') {
query[key] = q[key];
}
}
LOG.warn('query term:', l_name);
LOG.warn(query, l_name);
// perform query if available (filter out irrelevant data to caller)
var result = [];
if (typeof query === 'object') {
for (var i=0; i < arr.length; i++) {
var matched = true;
for (var key in query) {
// if the query item mismatch we terminate
if (query[key] && arr[i][key] !== query[key]) {
matched = false;
break;
}
}
if (matched) {
result.push(arr[i]);
}
}
} else {
result = arr;
}
// NOTE: at this point each element in 'result' is still a full ORM object (with getter/setter methods)
//LOG.warn('result after query:');
//LOG.warn(result);
// when 'select' is specified, only keep properties that caller is interested
// 'select' is an array of attribute names (in string forms)
// NOTE: after select the result is no longer ORM objects so updates cannot (and should not) be made to individual object
// select should only be used for pure query-style actions
if (select instanceof Array) {
// TODO: instead of creating new array, why not purge uninterested fields directly?
var r = [];
for (var i=0; i < result.length; i++) {
// store only indicated attributes
// NOTE: by storing only certain attributes, the obj here will possibly lose getter/setter properties
var obj = {};
for (var j=0; j < select.length; j++) {
var key = select[j];
obj[key] = result[i][key];
}
r.push(obj);
}
result = r;
}
// append functions
// NOTE: this works even if select is specified and the attributes returned is only a subset
result.add = arr.add;
result.remove = arr.remove;
result.update = arr.update;
result.size = arr.size;
//LOG.warn('result after select:');
//LOG.warn(result);
// if query is specified, we may need to return only the object that matches the query
if (Object.keys(query).length > 0) {
var obj = null;
// check if only one matching result found using query terms (returns the matched object only)
if (result.length === 1) {
obj = result[0];
LOG.warn('return [' + name + '] query result as single object: ', l_name);
}
// we return an object and the full array
// NOTE: object is valid if only one matched result is found, otherwise it'd be 'null'
UTIL.safeCall(onDone, null, obj, result);
} else {
UTIL.safeCall(onDone, null, result);
}
};
// locally created mappers
var l_mappers = {};
/*
args: {
name: 'string', // name of state set to build mapping
key: 'string', // which key will be used as main index
map: 'object' // an externally provided js object to store the mapping into (optional)
}
*/
// build and return a mapping relations from key value to stored elements in array
var l_map = exports.map = function (args, onDone) {
var errmsg = null;
// get data in array form first
var arr = SR.State.get(args.name + 'Array', 'array');
var map = args.map || {};
var key = args.key;
for (var i=0; i < arr.length; i++) {
if (typeof arr[i] === 'function')
continue;
var record = arr[i];
if (record.hasOwnProperty(key) === false) {
errmsg = '[' + args.name + '] no key [' + key + '] can be found to build mapper';
LOG.error(errmsg, l_name);
UTIL.safeCall(onDone, errmsg);
return undefined;
}
if (record[key] === '') {
LOG.error('[' + args.name + '] record has empty key value!', l_name);
continue;
}
if (map.hasOwnProperty(record[key]) === true) {
LOG.error('[' + args.name + '] key [' + record[key] + '] exists, duplicate keys found at row: ' + (i+1) + '!', l_name);
}
map[record[key]] = record;
}
// append add/remove element functions
map.add = arr.add;
map.remove = arr.remove;
map.update = arr.update;
map.size = arr.size;
// store the mapping relations & the key name used
l_mappers[args.name] = {
mapping: map,
key: key
};
UTIL.safeCall(onDone, null, map);
return map;
};
// fill in the value for a key with multiple element into an object
var l_fill = exports.fill = function (obj, key, value) {
try {
var expression = 'obj[\'' + key.replace('.' ,'\'][\'') + '\'] = value;';
eval(expression);
return true;
} catch (e) {
LOG.error(e);
return false;
}
};