590 lines
16 KiB
JavaScript
590 lines
16 KiB
JavaScript
// Copyright IBM Corp. 2012,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
|
|
'use strict';
|
|
|
|
exports.safeRequire = safeRequire;
|
|
exports.fieldsToArray = fieldsToArray;
|
|
exports.selectFields = selectFields;
|
|
exports.removeUndefined = removeUndefined;
|
|
exports.parseSettings = parseSettings;
|
|
exports.mergeSettings = exports.deepMerge = mergeSettings;
|
|
exports.isPlainObject = isPlainObject;
|
|
exports.defineCachedRelations = defineCachedRelations;
|
|
exports.sortObjectsByIds = sortObjectsByIds;
|
|
exports.setScopeValuesFromWhere = setScopeValuesFromWhere;
|
|
exports.mergeQuery = mergeQuery;
|
|
exports.mergeIncludes = mergeIncludes;
|
|
exports.createPromiseCallback = createPromiseCallback;
|
|
exports.uniq = uniq;
|
|
exports.toRegExp = toRegExp;
|
|
exports.hasRegExpFlags = hasRegExpFlags;
|
|
exports.idEquals = idEquals;
|
|
exports.findIndexOf = findIndexOf;
|
|
|
|
var g = require('strong-globalize')();
|
|
var traverse = require('traverse');
|
|
var assert = require('assert');
|
|
var Promise = require('bluebird');
|
|
|
|
function safeRequire(module) {
|
|
try {
|
|
return require(module);
|
|
} catch (e) {
|
|
g.log('Run "{{npm install loopback-datasource-juggler}} %s" command ',
|
|
'to use {{loopback-datasource-juggler}} using %s database engine',
|
|
module, module);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Extracting fixed property values for the scope from the where clause into
|
|
* the data object
|
|
*
|
|
* @param {Object} The data object
|
|
* @param {Object} The where clause
|
|
*/
|
|
function setScopeValuesFromWhere(data, where, targetModel) {
|
|
for (var i in where) {
|
|
if (i === 'and') {
|
|
// Find fixed property values from each subclauses
|
|
for (var w = 0, n = where[i].length; w < n; w++) {
|
|
setScopeValuesFromWhere(data, where[i][w], targetModel);
|
|
}
|
|
continue;
|
|
}
|
|
var prop = targetModel.definition.properties[i];
|
|
if (prop) {
|
|
var val = where[i];
|
|
if (typeof val !== 'object' || val instanceof prop.type ||
|
|
prop.type.name === 'ObjectID') { // MongoDB key
|
|
// Only pick the {propertyName: propertyValue}
|
|
data[i] = where[i];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge include options of default scope with runtime include option.
|
|
* exhibits the _.extend behaviour. Property value of source overrides
|
|
* property value of destination if property name collision occurs
|
|
* @param {String|Array|Object} destination The default value of `include` option
|
|
* @param {String|Array|Object} source The runtime value of `include` option
|
|
* @returns {Object}
|
|
*/
|
|
function mergeIncludes(destination, source) {
|
|
var destArray = convertToArray(destination);
|
|
var sourceArray = convertToArray(source);
|
|
if (destArray.length === 0) {
|
|
return sourceArray;
|
|
}
|
|
if (sourceArray.length === 0) {
|
|
return destArray;
|
|
}
|
|
var relationNames = [];
|
|
var resultArray = [];
|
|
for (var j in sourceArray) {
|
|
var sourceEntry = sourceArray[j];
|
|
var sourceEntryRelationName = (typeof (sourceEntry.rel || sourceEntry.relation) === 'string') ?
|
|
sourceEntry.relation : Object.keys(sourceEntry)[0];
|
|
relationNames.push(sourceEntryRelationName);
|
|
resultArray.push(sourceEntry);
|
|
}
|
|
for (var i in destArray) {
|
|
var destEntry = destArray[i];
|
|
var destEntryRelationName = (typeof (destEntry.rel || destEntry.relation) === 'string') ?
|
|
destEntry.relation : Object.keys(destEntry)[0];
|
|
if (relationNames.indexOf(destEntryRelationName) === -1) {
|
|
resultArray.push(destEntry);
|
|
}
|
|
}
|
|
return resultArray;
|
|
}
|
|
|
|
/**
|
|
* Converts input parameter into array of objects which wraps the value.
|
|
* "someValue" is converted to [{"someValue":true}]
|
|
* ["someValue"] is converted to [{"someValue":true}]
|
|
* {"someValue":true} is converted to [{"someValue":true}]
|
|
* @param {String|Array|Object} param - Input parameter to be converted
|
|
* @returns {Array}
|
|
*/
|
|
function convertToArray(include) {
|
|
if (typeof include === 'string') {
|
|
var obj = {};
|
|
obj[include] = true;
|
|
return [obj];
|
|
} else if (isPlainObject(include)) {
|
|
//if include is of the form - {relation:'',scope:''}
|
|
if (include.rel || include.relation) {
|
|
return [include];
|
|
}
|
|
// Build an array of key/value pairs
|
|
var newInclude = [];
|
|
for (var key in include) {
|
|
var obj = {};
|
|
obj[key] = include[key];
|
|
newInclude.push(obj);
|
|
}
|
|
return newInclude;
|
|
} else if (Array.isArray(include)) {
|
|
var normalized = [];
|
|
for (var i in include) {
|
|
var includeEntry = include[i];
|
|
if (typeof includeEntry === 'string') {
|
|
var obj = {};
|
|
obj[includeEntry] = true;
|
|
normalized.push(obj);
|
|
} else {
|
|
normalized.push(includeEntry);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/*!
|
|
* Merge query parameters
|
|
* @param {Object} base The base object to contain the merged results
|
|
* @param {Object} update The object containing updates to be merged
|
|
* @param {Object} spec Optionally specifies parameters to exclude (set to false)
|
|
* @returns {*|Object} The base object
|
|
* @private
|
|
*/
|
|
function mergeQuery(base, update, spec) {
|
|
if (!update) {
|
|
return;
|
|
}
|
|
spec = spec || {};
|
|
base = base || {};
|
|
|
|
if (update.where && Object.keys(update.where).length > 0) {
|
|
if (base.where && Object.keys(base.where).length > 0) {
|
|
base.where = {and: [base.where, update.where]};
|
|
} else {
|
|
base.where = update.where;
|
|
}
|
|
}
|
|
|
|
// Merge inclusion
|
|
if (spec.include !== false && update.include) {
|
|
if (!base.include) {
|
|
base.include = update.include;
|
|
} else {
|
|
if (spec.nestedInclude === true) {
|
|
//specify nestedInclude=true to force nesting of inclusions on scoped
|
|
//queries. e.g. In physician.patients.getAsync({include: 'address'}),
|
|
//inclusion should be on patient model, not on physician model.
|
|
var saved = base.include;
|
|
base.include = {};
|
|
base.include[update.include] = saved;
|
|
} else {
|
|
//default behaviour of inclusion merge - merge inclusions at the same
|
|
//level. - https://github.com/strongloop/loopback-datasource-juggler/pull/569#issuecomment-95310874
|
|
base.include = mergeIncludes(base.include, update.include);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (spec.collect !== false && update.collect) {
|
|
base.collect = update.collect;
|
|
}
|
|
|
|
// Overwrite fields
|
|
if (spec.fields !== false && update.fields !== undefined) {
|
|
base.fields = update.fields;
|
|
} else if (update.fields !== undefined) {
|
|
base.fields = [].concat(base.fields).concat(update.fields);
|
|
}
|
|
|
|
// set order
|
|
if ((!base.order || spec.order === false) && update.order) {
|
|
base.order = update.order;
|
|
}
|
|
|
|
// overwrite pagination
|
|
if (spec.limit !== false && update.limit !== undefined) {
|
|
base.limit = update.limit;
|
|
}
|
|
|
|
var skip = spec.skip !== false && spec.offset !== false;
|
|
|
|
if (skip && update.skip !== undefined) {
|
|
base.skip = update.skip;
|
|
}
|
|
|
|
if (skip && update.offset !== undefined) {
|
|
base.offset = update.offset;
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* Normalize fields to an array of included properties
|
|
* @param {String|String[]|Object} fields Fields filter
|
|
* @param {String[]} properties Property names
|
|
* @param {Boolean} excludeUnknown To exclude fields that are unknown properties
|
|
* @returns {String[]} An array of included property names
|
|
*/
|
|
function fieldsToArray(fields, properties, excludeUnknown) {
|
|
if (!fields) return;
|
|
|
|
// include all properties by default
|
|
var result = properties;
|
|
var i, n;
|
|
|
|
if (typeof fields === 'string') {
|
|
result = [fields];
|
|
} else if (Array.isArray(fields) && fields.length > 0) {
|
|
// No empty array, including all the fields
|
|
result = fields;
|
|
} else if ('object' === typeof fields) {
|
|
// { field1: boolean, field2: boolean ... }
|
|
var included = [];
|
|
var excluded = [];
|
|
var keys = Object.keys(fields);
|
|
if (!keys.length) return;
|
|
|
|
for (i = 0, n = keys.length; i < n; i++) {
|
|
var k = keys[i];
|
|
if (fields[k]) {
|
|
included.push(k);
|
|
} else if ((k in fields) && !fields[k]) {
|
|
excluded.push(k);
|
|
}
|
|
}
|
|
if (included.length > 0) {
|
|
result = included;
|
|
} else if (excluded.length > 0) {
|
|
for (i = 0, n = excluded.length; i < n; i++) {
|
|
var index = result.indexOf(excluded[i]);
|
|
result.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
var fieldArray = [];
|
|
if (excludeUnknown) {
|
|
for (i = 0, n = result.length; i < n; i++) {
|
|
if (properties.indexOf(result[i]) !== -1) {
|
|
fieldArray.push(result[i]);
|
|
}
|
|
}
|
|
} else {
|
|
fieldArray = result;
|
|
}
|
|
return fieldArray;
|
|
}
|
|
|
|
function selectFields(fields) {
|
|
// map function
|
|
return function(obj) {
|
|
var result = {};
|
|
var key;
|
|
|
|
for (var i = 0; i < fields.length; i++) {
|
|
key = fields[i];
|
|
|
|
result[key] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove undefined values from the queury object
|
|
* @param query
|
|
* @param handleUndefined {String} either "nullify", "throw" or "ignore" (default: "ignore")
|
|
* @returns {exports.map|*}
|
|
*/
|
|
function removeUndefined(query, handleUndefined) {
|
|
if (typeof query !== 'object' || query === null) {
|
|
return query;
|
|
}
|
|
// WARNING: [rfeng] Use map() will cause mongodb to produce invalid BSON
|
|
// as traverse doesn't transform the ObjectId correctly
|
|
return traverse(query).forEach(function(x) {
|
|
if (x === undefined) {
|
|
switch (handleUndefined) {
|
|
case 'nullify':
|
|
this.update(null);
|
|
break;
|
|
case 'throw':
|
|
throw new Error(g.f('Unexpected `undefined` in query'));
|
|
break;
|
|
case 'ignore':
|
|
default:
|
|
this.remove();
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(x) && (typeof x === 'object' && x !== null &&
|
|
x.constructor !== Object)) {
|
|
// This object is not a plain object
|
|
this.update(x, true); // Stop navigating into this object
|
|
return x;
|
|
}
|
|
|
|
return x;
|
|
});
|
|
}
|
|
|
|
var url = require('url');
|
|
var qs = require('qs');
|
|
|
|
/**
|
|
* Parse a URL into a settings object
|
|
* @param {String} urlStr The URL for connector settings
|
|
* @returns {Object} The settings object
|
|
*/
|
|
function parseSettings(urlStr) {
|
|
if (!urlStr) {
|
|
return {};
|
|
}
|
|
var uri = url.parse(urlStr, false);
|
|
var settings = {};
|
|
settings.connector = uri.protocol && uri.protocol.split(':')[0]; // Remove the trailing :
|
|
settings.host = settings.hostname = uri.hostname;
|
|
settings.port = uri.port && Number(uri.port); // port is a string
|
|
settings.user = settings.username = uri.auth && uri.auth.split(':')[0]; // <username>:<password>
|
|
settings.password = uri.auth && uri.auth.split(':')[1];
|
|
settings.database = uri.pathname && uri.pathname.split('/')[1]; // remove the leading /
|
|
settings.url = urlStr;
|
|
if (uri.query) {
|
|
var params = qs.parse(uri.query);
|
|
for (var p in params) {
|
|
settings[p] = params[p];
|
|
}
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
/**
|
|
* Merge model settings
|
|
*
|
|
* Folked from https://github.com/nrf110/deepmerge/blob/master/index.js
|
|
*
|
|
* The original function tries to merge array items if they are objects
|
|
*
|
|
* @param {Object} target The target settings object
|
|
* @param {Object} src The source settings object
|
|
* @returns {Object} The merged settings object
|
|
*/
|
|
function mergeSettings(target, src) {
|
|
var array = Array.isArray(src);
|
|
var dst = array && [] || {};
|
|
|
|
if (array) {
|
|
target = target || [];
|
|
// Add items from target into dst
|
|
dst = dst.concat(target);
|
|
// Add non-existent items from source into dst
|
|
src.forEach(function(e) {
|
|
if (dst.indexOf(e) === -1) {
|
|
dst.push(e);
|
|
}
|
|
});
|
|
} else {
|
|
if (target != null && typeof target === 'object') {
|
|
// Add properties from target to dst
|
|
Object.keys(target).forEach(function(key) {
|
|
dst[key] = target[key];
|
|
});
|
|
}
|
|
if (src != null && typeof src === 'object') {
|
|
// Source is an object
|
|
Object.keys(src).forEach(function(key) {
|
|
var srcValue = src[key];
|
|
if (srcValue == null || typeof srcValue !== 'object') {
|
|
// The source item value is null, undefined or not an object
|
|
dst[key] = srcValue;
|
|
} else {
|
|
// The source item value is an object
|
|
if (target == null || typeof target !== 'object' ||
|
|
target[key] == null) {
|
|
// If target is not an object or target item value
|
|
dst[key] = srcValue;
|
|
} else {
|
|
dst[key] = mergeSettings(target[key], src[key]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return dst;
|
|
}
|
|
|
|
/**
|
|
* Define an non-enumerable __cachedRelations property
|
|
* @param {Object} obj The obj to receive the __cachedRelations
|
|
*/
|
|
function defineCachedRelations(obj) {
|
|
if (!obj.__cachedRelations) {
|
|
Object.defineProperty(obj, '__cachedRelations', {
|
|
writable: true,
|
|
enumerable: false,
|
|
configurable: true,
|
|
value: {},
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the argument is plain object
|
|
* @param {*) obj The obj value
|
|
* @returns {boolean}
|
|
*/
|
|
function isPlainObject(obj) {
|
|
return (typeof obj === 'object') && (obj !== null) &&
|
|
(obj.constructor === Object);
|
|
}
|
|
|
|
function sortObjectsByIds(idName, ids, objects, strict) {
|
|
ids = ids.map(function(id) {
|
|
return (typeof id === 'object') ? String(id) : id;
|
|
});
|
|
|
|
var indexOf = function(x) {
|
|
var isObj = (typeof x[idName] === 'object'); // ObjectID
|
|
var id = isObj ? String(x[idName]) : x[idName];
|
|
return ids.indexOf(id);
|
|
};
|
|
|
|
var heading = [];
|
|
var tailing = [];
|
|
|
|
objects.forEach(function(x) {
|
|
if (typeof x === 'object') {
|
|
var idx = indexOf(x);
|
|
if (strict && idx === -1) return;
|
|
idx === -1 ? tailing.push(x) : heading.push(x);
|
|
}
|
|
});
|
|
|
|
heading.sort(function(x, y) {
|
|
var a = indexOf(x);
|
|
var b = indexOf(y);
|
|
if (a === -1 || b === -1) return 1; // last
|
|
if (a === b) return 0;
|
|
if (a > b) return 1;
|
|
if (a < b) return -1;
|
|
});
|
|
|
|
return heading.concat(tailing);
|
|
};
|
|
|
|
function createPromiseCallback() {
|
|
var cb;
|
|
var promise = new Promise(function(resolve, reject) {
|
|
cb = function(err, data) {
|
|
if (err) return reject(err);
|
|
return resolve(data);
|
|
};
|
|
});
|
|
cb.promise = promise;
|
|
return cb;
|
|
}
|
|
|
|
/**
|
|
* Dedupe an array
|
|
* @param {Array} an array
|
|
* @returns {Array} an array with unique items
|
|
*/
|
|
function uniq(a) {
|
|
var uniqArray = [];
|
|
if (!a) {
|
|
return uniqArray;
|
|
}
|
|
assert(Array.isArray(a), 'array argument is required');
|
|
for (var i = 0, n = a.length; i < n; i++) {
|
|
if (a.indexOf(a[i]) === i) {
|
|
uniqArray.push(a[i]);
|
|
}
|
|
}
|
|
return uniqArray;
|
|
}
|
|
|
|
/**
|
|
* Converts a string, regex literal, or a RegExp object to a RegExp object.
|
|
* @param {String|Object} The string, regex literal, or RegExp object to convert
|
|
* @returns {Object} A RegExp object
|
|
*/
|
|
function toRegExp(regex) {
|
|
var isString = typeof regex === 'string';
|
|
var isRegExp = regex instanceof RegExp;
|
|
|
|
if (!(isString || isRegExp))
|
|
return new Error(g.f('Invalid argument, must be a string, {{regex}} literal, or ' +
|
|
'{{RegExp}} object'));
|
|
|
|
if (isRegExp)
|
|
return regex;
|
|
|
|
if (!hasRegExpFlags(regex))
|
|
return new RegExp(regex);
|
|
|
|
// only accept i, g, or m as valid regex flags
|
|
var flags = regex.split('/').pop().split('');
|
|
var validFlags = ['i', 'g', 'm'];
|
|
var invalidFlags = [];
|
|
flags.forEach(function(flag) {
|
|
if (validFlags.indexOf(flag) === -1)
|
|
invalidFlags.push(flag);
|
|
});
|
|
|
|
var hasInvalidFlags = invalidFlags.length > 0;
|
|
if (hasInvalidFlags)
|
|
return new Error(g.f('Invalid {{regex}} flags: %s', invalidFlags));
|
|
|
|
// strip regex delimiter forward slashes
|
|
var expression = regex.substr(1, regex.lastIndexOf('/') - 1);
|
|
return new RegExp(expression, flags.join(''));
|
|
}
|
|
|
|
function hasRegExpFlags(regex) {
|
|
return regex instanceof RegExp ?
|
|
regex.toString().split('/').pop() :
|
|
!!regex.match(/.*\/.+$/);
|
|
}
|
|
|
|
// Compare two id values to decide if updateAttributes is trying to change
|
|
// the id value for a given instance
|
|
function idEquals(id1, id2) {
|
|
if (id1 === id2) {
|
|
return true;
|
|
}
|
|
// Allows number/string conversions
|
|
if ((typeof id1 === 'number' && typeof id2 === 'string') ||
|
|
(typeof id1 === 'string' && typeof id2 === 'number')) {
|
|
return id1 == id2;
|
|
}
|
|
// For complex id types such as MongoDB ObjectID
|
|
id1 = JSON.stringify(id1);
|
|
id2 = JSON.stringify(id2);
|
|
if (id1 === id2) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Defaults to native Array.prototype.indexOf when no idEqual is present
|
|
// Otherwise, returns the lowest index for which isEqual(arr[]index, target) is true
|
|
function findIndexOf(arr, target, isEqual) {
|
|
if (!isEqual) {
|
|
return arr.indexOf(target);
|
|
}
|
|
|
|
for (var i = 0; i < arr.length; i++) {
|
|
if (isEqual(arr[i], target)) { return i; }
|
|
};
|
|
|
|
return -1;
|
|
}
|