offshore
Version:
An ORM for Node.js
456 lines (361 loc) • 14.4 kB
JavaScript
var _ = require('lodash');
var util = require('./helpers');
var hop = util.object.hasOwnProperty;
var switchback = require('switchback');
var errorify = require('../error');
var WLUsageError = require('../error/WLUsageError');
module.exports = {
// Expand Primary Key criteria into objects
expandPK: function(context, options) {
// Default to id as primary key
var pk = 'id';
// If autoPK is not used, attempt to find a primary key
if (!context.autoPK) {
// Check which attribute is used as primary key
for (var key in context.attributes) {
if (!util.object.hasOwnProperty(context.attributes[key], 'primaryKey')) continue;
// Check if custom primaryKey value is falsy
if (!context.attributes[key].primaryKey) continue;
// If a custom primary key is defined, use it
pk = key;
break;
}
}
// Check if options is an integer or string and normalize criteria
// to object, using the specified primary key field.
if (_.isNumber(options) || _.isString(options) || Array.isArray(options)) {
// Temporary store the given criteria
var pkCriteria = _.clone(options);
// Make the criteria object, with the primary key
options = {};
options[pk] = pkCriteria;
}
// If we're querying by primary key, create a coercion function for it
// depending on the data type of the key
if (options && options[pk]) {
var coercePK;
if(!context.attributes[pk]) {
return pk;
}
if (context.attributes[pk].type == 'integer') {
coercePK = function(pk) {return +pk;};
} else if (context.attributes[pk].type == 'string') {
coercePK = function(pk) {return String(pk).toString();};
// If the data type is unspecified, return the key as-is
} else {
coercePK = function(pk) {return pk;};
}
// If the criteria is an array of PKs, coerce them all
if (Array.isArray(options[pk])) {
options[pk] = options[pk].map(coercePK);
// Otherwise just coerce the one
} else {
if (!_.isObject(options[pk])) {
options[pk] = coercePK(options[pk]);
}
}
}
return options;
},
where: function(criteria) {
var self = this;
// If an IN was specified in the top level query and is an empty array, we can return an
// empty object without running the query because nothing will match anyway. Let's return
// false from here so the query knows to exit out.
if (criteria && _.isObject(criteria)) {
var falsy = false;
Object.keys(criteria).forEach(function(key) {
if (Array.isArray(criteria[key])) {
var newCriteria = [];
criteria[key].forEach(function(criterion) {
var normalizedCriterion = self.where(criterion);
if (normalizedCriterion !== false) {
newCriteria.push(normalizedCriterion);
} else if (key !== "or") {
falsy = true;
}
});
criteria[key] = newCriteria;
if (criteria[key].length === 0) {
falsy = true;
}
}
});
if (falsy) {
return false;
}
return criteria;
} else {
return criteria;
}
},
// Normalize the different ways of specifying criteria into a uniform object
criteria: function(origCriteria) {
var criteria = _.cloneDeep(origCriteria);
// If original criteria is already false, keep it that way.
if (criteria === false) return criteria;
if (!criteria) {
return {
where: null
};
}
// Let the calling method normalize array criteria. It could be an IN query
// where we need the PK of the collection or a .findOrCreateEach
if (Array.isArray(criteria)) return criteria;
// Empty undefined values from criteria object
_.each(criteria, function(val, key) {
if (_.isUndefined(val)) criteria[key] = null;
});
// Convert non-objects (ids) into a criteria
// TODO: use customizable primary key attribute
if (!_.isObject(criteria)) {
criteria = {
id: +criteria || criteria
};
}
if (_.isObject(criteria) && !criteria.where && criteria.where !== null) {
criteria = { where: criteria };
}
// Return string to indicate an error
if (!_.isObject(criteria)) throw new WLUsageError('Invalid options/criteria :: ' + criteria);
// If criteria doesn't seem to contain operational keys, assume all the keys are criteria
if (!criteria.where && !criteria.joins && !criteria.join && !criteria.limit && !criteria.skip &&
!criteria.sort && !criteria.sum && !criteria.average &&
!criteria.groupBy && !criteria.min && !criteria.max && !criteria.select) {
// Delete any residuals and then use the remaining keys as attributes in a criteria query
delete criteria.where;
delete criteria.joins;
delete criteria.join;
delete criteria.limit;
delete criteria.skip;
delete criteria.sort;
criteria = {
where: criteria
};
// If where is null, turn it into an object
} else if (_.isNull(criteria.where)) criteria.where = {};
// Move Limit, Skip, sort outside the where criteria
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'limit')) {
criteria.limit = parseInt(_.clone(criteria.where.limit), 10);
if (criteria.limit < 0) criteria.limit = 0;
delete criteria.where.limit;
} else if (hop(criteria, 'limit')) {
criteria.limit = parseInt(criteria.limit, 10);
if (criteria.limit < 0) criteria.limit = 0;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'skip')) {
criteria.skip = parseInt(_.clone(criteria.where.skip), 10);
if (criteria.skip < 0) criteria.skip = 0;
delete criteria.where.skip;
} else if (hop(criteria, 'skip')) {
criteria.skip = parseInt(criteria.skip, 10);
if (criteria.skip < 0) criteria.skip = 0;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'sort')) {
criteria.sort = _.clone(criteria.where.sort);
delete criteria.where.sort;
}
// Pull out aggregation keys from where key
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'sum')) {
criteria.sum = _.clone(criteria.where.sum);
delete criteria.where.sum;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'average')) {
criteria.average = _.clone(criteria.where.average);
delete criteria.where.average;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'groupBy')) {
criteria.groupBy = _.clone(criteria.where.groupBy);
delete criteria.where.groupBy;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'min')) {
criteria.min = _.clone(criteria.where.min);
delete criteria.where.min;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'max')) {
criteria.max = _.clone(criteria.where.max);
delete criteria.where.max;
}
if (hop(criteria, 'where') && criteria.where !== null && hop(criteria.where, 'select') || hop(criteria, 'select')) {
if(criteria.where.select) {
criteria.select = _.clone(criteria.where.select);
}
// If the select contains a '*' then remove the whole projection, a '*'
// will always return all records.
if(!_.isArray(criteria.select)) {
criteria.select = [criteria.select];
}
if(_.includes(criteria.select, '*')) {
delete criteria.select;
}
delete criteria.where.select;
}
// If WHERE is {}, always change it back to null
if (criteria.where && _.keys(criteria.where).length === 0) {
criteria.where = null;
}
criteria.where = this.where(criteria.where);
if (criteria.where === false) {
return false;
}
// Normalize sort criteria
if (hop(criteria, 'sort') && criteria.sort !== null) {
// Split string into attr and sortDirection parts (default to 'asc')
if (_.isString(criteria.sort)) {
var parts = criteria.sort.split(' ');
// Set default sort to asc
parts[1] = parts[1] ? parts[1].toLowerCase() : 'asc';
// Expand criteria.sort into object
criteria.sort = {};
criteria.sort[parts[0]] = parts[1];
}
if (_.isArray(criteria.sort)) {
var sort = {};
criteria.sort.forEach(function(attr) {
if (_.isString(attr)) {
var parts = attr.split(' ');
// Set default sort to asc
parts[1] = parts[1] ? parts[1].toLowerCase() : 'asc';
sort[parts[0]] = parts[1];
}
});
criteria.sort = sort;
}
// normalize ASC/DESC notation
Object.keys(criteria.sort).forEach(function(attr) {
if (_.isString(criteria.sort[attr])) {
criteria.sort[attr] = criteria.sort[attr].toLowerCase();
// Throw error on invalid sort order
if (criteria.sort[attr] !== 'asc' && criteria.sort[attr] !== 'desc') {
throw new WLUsageError('Invalid sort criteria :: ' + criteria.sort);
}
}
if (criteria.sort[attr] === 'asc') criteria.sort[attr] = 1;
if (criteria.sort[attr] === 'desc') criteria.sort[attr] = -1;
});
// normalize binary sorting criteria
Object.keys(criteria.sort).forEach(function(attr) {
if (criteria.sort[attr] === 0) criteria.sort[attr] = -1;
});
// Verify that user either specified a proper object
// or provided explicit comparator function
if (!_.isObject(criteria.sort) && !_.isFunction(criteria.sort)) {
throw new WLUsageError('Invalid sort criteria for ' + attrName + ' :: ' + direction);
}
}
return criteria;
},
// Normalize the capitalization and % wildcards in a like query
// Returns false if criteria is invalid,
// otherwise returns normalized criteria obj.
// Enhancer is an optional function to run on each criterion to preprocess the string
likeCriteria: function(criteria, attributes, enhancer) {
// Only accept criteria as an object
if (criteria !== Object(criteria)) return false;
criteria = _.clone(criteria);
if (!criteria.where) criteria = { where: criteria };
// Apply enhancer to each
if (enhancer) criteria.where = util.objMap(criteria.where, enhancer);
criteria.where = { like: criteria.where };
return criteria;
},
// Normalize a result set from an adapter
resultSet: function(resultSet) {
// Ensure that any numbers that can be parsed have been
return util.pluralize(resultSet, numberizeModel);
},
/**
* Normalize the different ways of specifying callbacks in built-in Offshore methods.
* Switchbacks vs. Callbacks (but not deferred objects/promises)
*
* @param {Function|Handlers} cb
* @return {Handlers}
*/
callback: function(cb) {
// Build modified callback:
// (only works for functions currently)
var wrappedCallback;
if (_.isFunction(cb)) {
wrappedCallback = function(err) {
// If no error occurred, immediately trigger the original callback
// without messing up the context or arguments:
if (!err) {
return applyInOriginalCtx(cb, arguments);
}
// If an error argument is present, upgrade it to a WLError
// (if it isn't one already)
err = errorify(err);
var modifiedArgs = Array.prototype.slice.call(arguments, 1);
modifiedArgs.unshift(err);
// Trigger callback without messing up the context or arguments:
return applyInOriginalCtx(cb, modifiedArgs);
};
}
//
// TODO: Make it clear that switchback support it experimental.
//
// Push switchback support off until >= v0.11
// or at least add a warning about it being a `stage 1: experimental`
// feature.
//
if (!_.isFunction(cb)) wrappedCallback = cb;
return switchback(wrappedCallback, {
invalid: 'error', // Redirect 'invalid' handler to 'error' handler
error: function _defaultErrorHandler() {
console.error.apply(console, Array.prototype.slice.call(arguments));
}
});
// ????
// TODO: determine support target for 2-way switchback usage
// ????
// Allow callback to be -HANDLED- in different ways
// at the app-level.
// `cb` may be passed in (at app-level) as either:
// => an object of handlers
// => or a callback function
//
// If a callback function was provided, it will be
// automatically upgraded to a simplerhandler object.
// var cb_fromApp = switchback(cb);
// Allow callback to be -INVOKED- in different ways.
// (adapter def)
// var cb_fromAdapter = cb_fromApp;
}
};
// If any attribute looks like a number, but it's a string
// cast it to a number
function numberizeModel(model) {
return util.objMap(model, numberize);
}
// If specified attr looks like a number, but it's a string, cast it to a number
function numberize(attr) {
if (_.isString(attr) && isNumbery(attr) && parseInt(attr, 10) < Math.pow(2, 53)) return +attr;
else return attr;
}
// Returns whether this value can be successfully parsed as a finite number
function isNumbery(value) {
return Math.pow(+value, 2) > 0;
}
// Replace % with %%%
function escapeLikeQuery(likeCriterion) {
return likeCriterion.replace(/[^%]%[^%]/g, '%%%');
}
// Replace %%% with %
function unescapeLikeQuery(likeCriterion) {
return likeCriterion.replace(/%%%/g, '%');
}
/**
* Like _.partial, but accepts an array of arguments instead of
* comma-seperated args (if _.partial is `call`, this is `apply`.)
* The biggest difference from `_.partial`, other than the usage,
* is that this helper actually CALLS the partially applied function.
*
* This helper is mainly useful for callbacks.
*
* @param {Function} fn [description]
* @param {[type]} args [description]
* @return {[type]} [description]
*/
function applyInOriginalCtx(fn, args) {
return (_.partial.apply(null, [fn].concat(Array.prototype.slice.call(args))))();
}