loopback-datasource-juggler/lib/connectors/memory.js

858 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright IBM Corp. 2013,2016. All Rights Reserved.
// Node module: loopback-datasource-juggler
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
var util = require('util');
var Connector = require('loopback-connector').Connector;
var geo = require('../geo');
var utils = require('../utils');
var fs = require('fs');
var async = require('async');
/**
* Initialize the Memory connector against the given data source
*
* @param {DataSource} dataSource The loopback-datasource-juggler dataSource
* @param {Function} [callback] The callback function
*/
exports.initialize = function initializeDataSource(dataSource, callback) {
dataSource.connector = new Memory(null, dataSource.settings);
dataSource.connector.connect(callback);
};
exports.Memory = Memory;
exports.applyFilter = applyFilter;
function Memory(m, settings) {
if (m instanceof Memory) {
this.isTransaction = true;
this.cache = m.cache;
this.ids = m.ids;
this.constructor.super_.call(this, 'memory', settings);
this._models = m._models;
} else {
this.isTransaction = false;
this.cache = {};
this.ids = {};
this.constructor.super_.call(this, 'memory', settings);
}
}
util.inherits(Memory, Connector);
Memory.prototype.getDefaultIdType = function() {
return Number;
};
Memory.prototype.getTypes = function() {
return ['db', 'nosql', 'memory'];
};
Memory.prototype.connect = function(callback) {
if (this.isTransaction) {
this.onTransactionExec = callback;
} else {
this.loadFromFile(callback);
}
};
function serialize(obj) {
if (obj === null || obj === undefined) {
return obj;
}
return JSON.stringify(obj);
}
function deserialize(dbObj) {
if (dbObj === null || dbObj === undefined) {
return dbObj;
}
if (typeof dbObj === 'string') {
return JSON.parse(dbObj);
} else {
return dbObj;
}
}
Memory.prototype.getCollection = function(model) {
var modelClass = this._models[model];
if (modelClass && modelClass.settings.memory) {
model = modelClass.settings.memory.collection || model;
}
return model;
};
Memory.prototype.initCollection = function(model) {
this.collection(model, {});
this.collectionSeq(model, 1);
};
Memory.prototype.collection = function(model, val) {
model = this.getCollection(model);
if (arguments.length > 1) this.cache[model] = val;
return this.cache[model];
};
Memory.prototype.collectionSeq = function(model, val) {
model = this.getCollection(model);
if (arguments.length > 1) this.ids[model] = val;
return this.ids[model];
};
Memory.prototype.loadFromFile = function(callback) {
var self = this;
var hasLocalStorage = typeof window !== 'undefined' && window.localStorage;
var localStorage = hasLocalStorage && this.settings.localStorage;
if (self.settings.file) {
fs.readFile(self.settings.file, { encoding: 'utf8', flag: 'r' }, function(err, data) {
if (err && err.code !== 'ENOENT') {
callback && callback(err);
} else {
parseAndLoad(data);
}
});
} else if (localStorage) {
var data = window.localStorage.getItem(localStorage);
data = data || '{}';
parseAndLoad(data);
} else {
process.nextTick(callback);
}
function parseAndLoad(data) {
if (data) {
try {
data = JSON.parse(data.toString());
} catch (e) {
return callback(e);
}
self.ids = data.ids || {};
self.cache = data.models || {};
} else {
if (!self.cache) {
self.ids = {};
self.cache = {};
}
}
callback && callback();
}
};
/*!
* Flush the cache into the json file if necessary
* @param {Function} callback
*/
Memory.prototype.saveToFile = function(result, callback) {
var self = this;
var file = this.settings.file;
var hasLocalStorage = typeof window !== 'undefined' && window.localStorage;
var localStorage = hasLocalStorage && this.settings.localStorage;
if (file) {
if (!self.writeQueue) {
// Create a queue for writes
self.writeQueue = async.queue(function(task, cb) {
// Flush out the models/ids
var data = JSON.stringify({
ids: self.ids,
models: self.cache,
}, null, ' ');
fs.writeFile(self.settings.file, data, function(err) {
cb(err);
task.callback && task.callback(err, task.data);
});
}, 1);
}
// Enqueue the write
self.writeQueue.push({
data: result,
callback: callback,
});
} else if (localStorage) {
// Flush out the models/ids
var data = JSON.stringify({
ids: self.ids,
models: self.cache,
}, null, ' ');
window.localStorage.setItem(localStorage, data);
process.nextTick(function() {
callback && callback(null, result);
});
} else {
process.nextTick(function() {
callback && callback(null, result);
});
}
};
Memory.prototype.define = function defineModel(definition) {
this.constructor.super_.prototype.define.apply(this, [].slice.call(arguments));
var m = definition.model.modelName;
if (!this.collection(m)) this.initCollection(m);
};
Memory.prototype._createSync = function(model, data, fn) {
// FIXME: [rfeng] We need to generate unique ids based on the id type
// FIXME: [rfeng] We don't support composite ids yet
var currentId = this.collectionSeq(model);
if (currentId === undefined) { // First time
currentId = this.collectionSeq(model, 1);
}
var id = this.getIdValue(model, data) || currentId;
if (id > currentId) {
// If the id is passed in and the value is greater than the current id
currentId = id;
}
this.collectionSeq(model, Number(currentId) + 1);
var props = this._models[model].properties;
var idName = this.idName(model);
id = (props[idName] && props[idName].type && props[idName].type(id)) || id;
this.setIdValue(model, data, id);
if (!this.collection(model)) {
this.collection(model, {});
}
if (this.collection(model)[id])
return fn(new Error('Duplicate entry for ' + model + '.' + idName));
this.collection(model)[id] = serialize(data);
fn(null, id);
};
Memory.prototype.create = function create(model, data, options, callback) {
var self = this;
this._createSync(model, data, function(err, id) {
if (err) {
return process.nextTick(function() {
callback(err);
});
};
self.saveToFile(id, callback);
});
};
Memory.prototype.updateOrCreate = function(model, data, options, callback) {
var self = this;
this.exists(model, self.getIdValue(model, data), options, function(err, exists) {
if (exists) {
self.save(model, data, options, function(err, data) {
callback(err, data, { isNewInstance: false });
});
} else {
self.create(model, data, options, function(err, id) {
self.setIdValue(model, data, id);
callback(err, data, { isNewInstance: true });
});
}
});
};
Memory.prototype.findOrCreate = function(model, filter, data, callback) {
var self = this;
var nodes = self._findAllSkippingIncludes(model, filter);
var found = nodes[0];
if (!found) {
// Calling _createSync to update the collection in a sync way and to guarantee to create it in the same turn of even loop
return self._createSync(model, data, function(err, id) {
if (err) return callback(err);
self.saveToFile(id, function(err, id) {
self.setIdValue(model, data, id);
callback(err, data, true);
});
});
}
if (!filter || !filter.include) {
return process.nextTick(function() {
callback(null, found, false);
});
}
self._models[model].model.include(nodes[0], filter.include, {}, function(err, nodes) {
process.nextTick(function() {
if (err) return callback(err);
callback(null, nodes[0], false);
});
});
};
Memory.prototype.save = function save(model, data, options, callback) {
var self = this;
var id = this.getIdValue(model, data);
var cachedModels = this.collection(model);
var modelData = cachedModels && this.collection(model)[id];
modelData = modelData && deserialize(modelData);
if (modelData) {
data = merge(modelData, data);
}
this.collection(model)[id] = serialize(data);
this.saveToFile(data, function(err) {
callback(err, self.fromDb(model, data), { isNewInstance: !modelData });
});
};
Memory.prototype.exists = function exists(model, id, options, callback) {
process.nextTick(function() {
callback(null, this.collection(model) && this.collection(model).hasOwnProperty(id));
}.bind(this));
};
Memory.prototype.find = function find(model, id, options, callback) {
process.nextTick(function() {
callback(null, id in this.collection(model) && this.fromDb(model, this.collection(model)[id]));
}.bind(this));
};
Memory.prototype.destroy = function destroy(model, id, options, callback) {
var exists = this.collection(model)[id];
delete this.collection(model)[id];
this.saveToFile({ count: exists ? 1 : 0 }, callback);
};
Memory.prototype.fromDb = function(model, data) {
if (!data) return null;
data = deserialize(data);
var props = this._models[model].properties;
for (var key in data) {
var val = data[key];
if (val === undefined || val === null) {
continue;
}
if (props[key]) {
switch (props[key].type.name) {
case 'Date':
val = new Date(val.toString().replace(/GMT.*$/, 'GMT'));
break;
case 'Boolean':
val = Boolean(val);
break;
case 'Number':
val = Number(val);
break;
}
}
data[key] = val;
}
return data;
};
function getValue(obj, path) {
if (obj == null) {
return undefined;
}
var keys = path.split('.');
var val = obj;
for (var i = 0, n = keys.length; i < n; i++) {
val = val[keys[i]];
if (val == null) {
return val;
}
}
return val;
}
Memory.prototype._findAllSkippingIncludes = function(model, filter) {
var nodes = Object.keys(this.collection(model)).map(function(key) {
return this.fromDb(model, this.collection(model)[key]);
}.bind(this));
if (filter) {
if (!filter.order) {
var idNames = this.idNames(model);
if (idNames && idNames.length) {
filter.order = idNames;
}
}
// do we need some sorting?
if (filter.order) {
var orders = filter.order;
if (typeof filter.order === 'string') {
orders = [filter.order];
}
orders.forEach(function(key, i) {
var reverse = 1;
var m = key.match(/\s+(A|DE)SC$/i);
if (m) {
key = key.replace(/\s+(A|DE)SC/i, '');
if (m[1].toLowerCase() === 'de') reverse = -1;
}
orders[i] = { 'key': key, 'reverse': reverse };
});
nodes = nodes.sort(sorting.bind(orders));
}
var nearFilter = geo.nearFilter(filter.where);
// geo sorting
if (nearFilter) {
nodes = geo.filter(nodes, nearFilter);
}
// do we need some filtration?
if (filter.where && nodes)
nodes = nodes.filter(applyFilter(filter));
// field selection
if (filter.fields) {
nodes = nodes.map(utils.selectFields(filter.fields));
}
// limit/skip
var skip = filter.skip || filter.offset || 0;
var limit = filter.limit || nodes.length;
nodes = nodes.slice(skip, skip + limit);
}
return nodes;
function sorting(a, b) {
var undefinedA, undefinedB;
for (var i = 0, l = this.length; i < l; i++) {
var aVal = getValue(a, this[i].key);
var bVal = getValue(b, this[i].key);
undefinedB = bVal === undefined && aVal !== undefined;
undefinedA = aVal === undefined && bVal !== undefined;
if (undefinedB || aVal > bVal) {
return 1 * this[i].reverse;
} else if (undefinedA || aVal < bVal) {
return -1 * this[i].reverse;
}
}
return 0;
}
};
Memory.prototype.all = function all(model, filter, options, callback) {
var self = this;
var nodes = self._findAllSkippingIncludes(model, filter);
process.nextTick(function() {
if (filter && filter.include) {
self._models[model].model.include(nodes, filter.include, options, callback);
} else {
callback(null, nodes);
}
});
};
function applyFilter(filter) {
var where = filter.where;
if (typeof where === 'function') {
return where;
}
var keys = Object.keys(where);
return function(obj) {
return keys.every(function(key) {
if (key === 'and' || key === 'or') {
if (Array.isArray(where[key])) {
if (key === 'and') {
return where[key].every(function(cond) {
return applyFilter({ where: cond })(obj);
});
}
if (key === 'or') {
return where[key].some(function(cond) {
return applyFilter({ where: cond })(obj);
});
}
}
}
var value = getValue(obj, key);
// Support referencesMany and other embedded relations
// Also support array types. Mongo, possibly PostgreSQL
if (Array.isArray(value)) {
var matcher = where[key];
// The following condition is for the case where we are querying with
// a neq filter, and when the value is an empty array ([]).
if (matcher.neq !== undefined && value.length <= 0) {
return true;
}
return value.some(function(v, i) {
var filter = { where: {}};
filter.where[i] = matcher;
return applyFilter(filter)(value);
});
}
if (test(where[key], value)) {
return true;
}
// If we have a composed key a.b and b would resolve to a property of an object inside an array
// then, we attempt to emulate mongo db matching. Helps for embedded relations
var dotIndex = key.indexOf('.');
var subValue = obj[key.substring(0, dotIndex)];
if (dotIndex !== -1 && Array.isArray(subValue)) {
var subFilter = { where: {}};
var subKey = key.substring(dotIndex + 1);
subFilter.where[subKey] = where[key];
return subValue.some(applyFilter(subFilter));
}
return false;
});
};
function toRegExp(pattern) {
if (pattern instanceof RegExp) {
return pattern;
}
var regex = '';
// Escaping user input to be treated as a literal string within a regular expression
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Writing_a_Regular_Expression_Pattern
pattern = pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1');
for (var i = 0, n = pattern.length; i < n; i++) {
var char = pattern.charAt(i);
if (char === '\\') {
i++; // Skip to next char
if (i < n) {
regex += pattern.charAt(i);
}
continue;
} else if (char === '%') {
regex += '.*';
} else if (char === '_') {
regex += '.';
} else if (char === '.') {
regex += '\\.';
} else if (char === '*') {
regex += '\\*';
} else {
regex += char;
}
}
return regex;
}
function test(example, value) {
if (typeof value === 'string' && (example instanceof RegExp)) {
return value.match(example);
}
if (example === undefined) {
return undefined;
}
if (typeof example === 'object' && example !== null) {
if (example.regexp) {
return value ? value.match(example.regexp) : false;
}
// ignore geo near filter
if (example.near) {
return true;
}
if (example.inq) {
// if (!value) return false;
for (var i = 0; i < example.inq.length; i++) {
if (example.inq[i] == value) {
return true;
}
}
return false;
}
if (example.nin) {
for (var i = 0; i < example.nin.length; i++) {
if (example.nin[i] == value) {
return false;
}
}
return true;
}
if ('neq' in example) {
return compare(example.neq, value) !== 0;
}
if ('between' in example) {
return (testInEquality({ gte: example.between[0] }, value) &&
testInEquality({ lte: example.between[1] }, value));
}
if (example.like || example.nlike) {
var like = example.like || example.nlike;
if (typeof like === 'string') {
like = toRegExp(like);
}
if (example.like) {
return !!new RegExp(like).test(value);
}
if (example.nlike) {
return !new RegExp(like).test(value);
}
}
if (testInEquality(example, value)) {
return true;
}
}
// not strict equality
return (example !== null ? example.toString() : example) ==
(value != null ? value.toString() : value);
}
/**
* Compare two values
* @param {*} val1 The 1st value
* @param {*} val2 The 2nd value
* @returns {number} 0: =, positive: >, negative <
* @private
*/
function compare(val1, val2) {
if (val1 == null || val2 == null) {
// Either val1 or val2 is null or undefined
return val1 == val2 ? 0 : NaN;
}
if (typeof val1 === 'number') {
return val1 - val2;
}
if (typeof val1 === 'string') {
return (val1 > val2) ? 1 : ((val1 < val2) ? -1 : (val1 == val2) ? 0 : NaN);
}
if (typeof val1 === 'boolean') {
return val1 - val2;
}
if (val1 instanceof Date) {
var result = val1 - val2;
return result;
}
// Return NaN if we don't know how to compare
return (val1 == val2) ? 0 : NaN;
}
function testInEquality(example, val) {
if ('gt' in example) {
return compare(val, example.gt) > 0;
}
if ('gte' in example) {
return compare(val, example.gte) >= 0;
}
if ('lt' in example) {
return compare(val, example.lt) < 0;
}
if ('lte' in example) {
return compare(val, example.lte) <= 0;
}
return false;
}
}
Memory.prototype.destroyAll = function destroyAll(model, where, options, callback) {
var cache = this.collection(model);
var filter = null;
var count = 0;
if (where) {
filter = applyFilter({ where: where });
Object.keys(cache).forEach(function(id) {
if (!filter || filter(this.fromDb(model, cache[id]))) {
count++;
delete cache[id];
}
}.bind(this));
} else {
count = Object.keys(cache).length;
this.collection(model, {});
}
this.saveToFile({ count: count }, callback);
};
Memory.prototype.count = function count(model, where, options, callback) {
var cache = this.collection(model);
var data = Object.keys(cache);
if (where) {
var filter = { where: where };
data = data.map(function(id) {
return this.fromDb(model, cache[id]);
}.bind(this));
data = data.filter(applyFilter(filter));
}
process.nextTick(function() {
callback(null, data.length);
});
};
Memory.prototype.update =
Memory.prototype.updateAll = function updateAll(model, where, data, options, cb) {
var self = this;
var cache = this.collection(model);
var filter = null;
where = where || {};
filter = applyFilter({ where: where });
var ids = Object.keys(cache);
var count = 0;
async.each(ids, function(id, done) {
var inst = self.fromDb(model, cache[id]);
if (!filter || filter(inst)) {
count++;
// The id value from the cache is string
// Get the real id from the inst
id = self.getIdValue(model, inst);
self.updateAttributes(model, id, data, options, done);
} else {
process.nextTick(done);
}
}, function(err) {
if (err) return cb(err);
self.saveToFile({ count: count }, cb);
});
};
Memory.prototype.updateAttributes = function updateAttributes(model, id, data, options, cb) {
if (!id) {
var err = new Error('You must provide an id when updating attributes!');
if (cb) {
return cb(err);
} else {
throw err;
}
}
// Do not modify the data object passed in arguments
data = Object.create(data);
this.setIdValue(model, data, id);
var cachedModels = this.collection(model);
var modelData = cachedModels && this.collection(model)[id];
if (modelData) {
this.save(model, data, options, cb);
} else {
cb(new Error('Could not update attributes. Object with id ' + id + ' does not exist!'));
}
};
Memory.prototype.replaceById = function(model, id, data, options, cb) {
var self = this;
if (!id) {
var err = new Error('You must provide an id when replacing!');
return process.nextTick(function() { cb(err); });
}
// Do not modify the data object passed in arguments
data = Object.create(data);
this.setIdValue(model, data, id);
var cachedModels = this.collection(model);
var modelData = cachedModels && this.collection(model)[id];
if (!modelData) {
var msg = 'Could not replace. Object with id ' + id + ' does not exist!';
return process.nextTick(function() { cb(new Error(msg)); });
}
var newModelData = {};
for (var key in data) {
var val = data[key];
if (typeof val === 'function') {
continue; // Skip methods
}
newModelData[key] = val;
}
this.collection(model)[id] = serialize(newModelData);
this.saveToFile(newModelData, function(err) {
cb(err, self.fromDb(model, newModelData));
});
};
Memory.prototype.replaceOrCreate = function(model, data, options, callback) {
var self = this;
var idName = self.idNames(model)[0];
var idValue = self.getIdValue(model, data);
var filter = { where: {}};
filter.where[idName] = idValue;
var nodes = self._findAllSkippingIncludes(model, filter);
var found = nodes[0];
if (!found) {
// Calling _createSync to update the collection in a sync way and
// to guarantee to create it in the same turn of even loop
return self._createSync(model, data, function(err, id) {
if (err) return process.nextTick(function() { cb(err); });
self.saveToFile(id, function(err, id) {
self.setIdValue(model, data, id);
callback(err, self.fromDb(model, data), { isNewInstance: true });
});
});
}
var id = self.getIdValue(model, data);
self.collection(model)[id] = serialize(data);
self.saveToFile(data, function(err) {
callback(err, self.fromDb(model, data), { isNewInstance: false });
});
};
Memory.prototype.transaction = function() {
return new Memory(this);
};
Memory.prototype.exec = function(callback) {
this.onTransactionExec();
setTimeout(callback, 50);
};
Memory.prototype.buildNearFilter = function(filter) {
// noop
};
Memory.prototype.automigrate = function(models, cb) {
var self = this;
if ((!cb) && ('function' === typeof models)) {
cb = models;
models = undefined;
}
// First argument is a model name
if ('string' === typeof models) {
models = [models];
}
models = models || Object.keys(self._models);
if (models.length === 0) {
return process.nextTick(cb);
}
var invalidModels = models.filter(function(m) {
return !(m in self._models);
});
if (invalidModels.length) {
return process.nextTick(function() {
cb(new Error('Cannot migrate models not attached to this datasource: ' +
invalidModels.join(' ')));
});
}
models.forEach(function(m) {
self.initCollection(m);
});
if (cb) process.nextTick(cb);
};
function merge(base, update) {
if (!base) {
return update;
}
// We cannot use Object.keys(update) if the update is an instance of the model
// class as the properties are defined at the ModelClass.prototype level
for (var key in update) {
var val = update[key];
if (typeof val === 'function') {
continue; // Skip methods
}
base[key] = val;
}
return base;
}