waterline-postgresql
Version:
PostgreSQL Adapter for Sails and Waterline
911 lines (717 loc) • 22 kB
JavaScript
/**
* Module dependencies
*/
var _ = require('lodash');
var utils = require('./utils');
var hop = utils.object.hasOwnProperty;
var operators = [
'<',
'lessThan',
'<=',
'lessThanOrEqual',
'>',
'greaterThan',
'>=',
'greaterThanOrEqual',
'!',
'not',
'like',
'contains',
'startsWith',
'endsWith'
];
/**
* Process Criteria
*
* Processes a query criteria object
*/
var CriteriaProcessor = module.exports = function CriteriaProcessor(currentTable, schema, options) {
if(!currentTable || !schema) {
throw new Error('Incorrect usage of CriteriaProcessor. Must include the currentTable and schema arguments.');
}
this.currentTable = currentTable;
this.schema = schema;
this.currentSchema = schema[currentTable].attributes;
this.tableScope = null;
this.queryString = '';
this.values = [];
this.paramCount = 1;
this.parameterized = true;
this.caseSensitive = true;
this.escapeCharacter = '"';
this.wlNext = {};
if(options && utils.object.hasOwnProperty(options, 'parameterized')) {
this.parameterized = options.parameterized;
}
if(options && utils.object.hasOwnProperty(options, 'caseSensitive')) {
this.caseSensitive = options.caseSensitive;
}
if(options && utils.object.hasOwnProperty(options, 'escapeCharacter')) {
this.escapeCharacter = options.escapeCharacter;
}
if(options && utils.object.hasOwnProperty(options, 'paramCount')) {
this.paramCount = options.paramCount;
}
if(options && utils.object.hasOwnProperty(options, 'wlNext')) {
this.wlNext = options.wlNext;
}
if(options && hop(options, 'schemaName')) {
this.schemaName = options.schemaName;
}
return this;
};
/**
* Read criteria object and expand it into a sequel string.
*
* @param {Object} options
*/
CriteriaProcessor.prototype.read = function read(options) {
var self = this;
var _options;
if(options.where) {
_options = options.where;
}
else {
_options = _.cloneDeep(options);
}
// Remove SUM, AVERAGE, MAX, MIN
delete _options.sum;
delete _options.average;
delete _options.max;
delete _options.min;
delete _options.groupBy;
if(_options.where !== null) {
_.keys(_options).forEach(function(key) {
self.expand(key, _options[key]);
});
}
// Remove trailing 'AND'
this.queryString = this.queryString.slice(0, -4);
if(options.groupBy) this.group(options.groupBy);
if(options.sort) this.sort(options.sort);
if(hop(options, 'limit')) this.limit(options.limit);
// Ensure a limit was used if skip was used
if(hop(options, 'skip') && !hop(options, 'limit')) {
this.limit(null);
}
if(hop(options, 'skip')) this.skip(options.skip);
return {
query: this.queryString,
values: this.values
};
};
/**
* Expand a criteria piece.
*
* Given a key on a criteria object, expand it into it's sequel parts by inspecting which type
* of operator to use (`or`, `and`, `in` or `like`) and then recursively process that key if needed.
*
* @param {String} key
* @param {String || Object} val
* @return
*/
CriteriaProcessor.prototype.expand = function expand(key, val) {
var self = this;
switch(key.toLowerCase()) {
case 'or':
self.or(val);
return;
case 'like':
self.like(val);
return;
// Key/Value
default:
// `IN`
if(Array.isArray(val)) {
self._in(key, val);
return;
}
// `AND`
self.and(key, val);
return;
}
};
/**
* Handle `OR` Criteria
*/
CriteriaProcessor.prototype.or = function or(val) {
var self = this;
if(!Array.isArray(val)) {
throw new Error('`or` statements must be in an array.');
}
// Wrap the entire OR clause
this.queryString += '(';
val.forEach(function(statement) {
self.queryString += '(';
// Recursively call expand. Assumes no nesting of `or` statements
_.keys(statement).forEach(function(key) {
self.expand(key, statement[key]);
});
if(self.queryString.slice(-4) === 'AND ') {
self.queryString = self.queryString.slice(0, -5);
}
self.queryString += ') OR ';
});
// Remove trailing OR if it exists
if(self.queryString.slice(-3) === 'OR ') {
self.queryString = self.queryString.slice(0, -4);
}
self.queryString += ') AND ';
};
/**
* Handle `LIKE` Criteria
*/
CriteriaProcessor.prototype.like = function like(val) {
var self = this;
var expandBlock = function(parent) {
var caseSensitive = true;
// Check if parent is a string, if so make sure it's case sensitive.
if(self.currentSchema[parent] &&
(self.currentSchema[parent] === 'text' ||
self.currentSchema[parent] === 'string' ||
self.currentSchema[parent].type === 'string' ||
self.currentSchema[parent].type === 'text')) {
caseSensitive = false;
}
var comparator = self.caseSensitive ? 'ILIKE' : 'LIKE';
// Override comparator with WL Next features
if(hop(self.wlNext, 'caseSensitive') && self.wlNext.caseSensitive) {
comparator = 'LIKE';
}
self.process(parent, val[parent], comparator, caseSensitive);
self.queryString += ' AND ';
};
_.keys(val).forEach(function(parent) {
expandBlock(parent);
});
};
/**
* Handle `AND` Criteria
*/
CriteriaProcessor.prototype.and = function and(key, val) {
var caseSensitive = true;
// Check if key is a string
if(this.currentSchema[key] && this.currentSchema[key] === 'string') {
caseSensitive = false;
}
// Override case sensitive with WL Next features
if(hop(this.wlNext, 'caseSensitive') && this.wlNext.caseSensitive) {
caseSensitive = true;
}
this.process(key, val, '=', caseSensitive);
this.queryString += ' AND ';
};
/**
* Get the current table scope.
*
* @returns {string}
*/
CriteriaProcessor.prototype.getTableScope = function () {
return this.tableScope || this.currentTable;
};
/**
* Get the alias for the current table scope
*
* @returns {string}
*/
CriteriaProcessor.prototype.getTableAlias = function () {
if (this.tableScope) {
return utils.populationAlias(this.tableScope)
}
return this.currentTable;
};
/**
* Handle `IN` Criteria
*/
CriteriaProcessor.prototype._in = function _in(key, val) {
var self = this;
// Set case sensitive by default
var caseSensitivity = true;
// Set lower logic to false
var lower = false;
// Check if key is a string
var schema = self.currentSchema && self.currentSchema[key];
if(!_.isPlainObject(schema)) {
schema = { type: schema };
}
if(schema && schema.type === 'text' || schema.type === 'string') {
caseSensitivity = false;
lower = true;
}
// Override caseSensitivity for databases that don't support it
if(this.caseSensitive) {
caseSensitivity = false;
}
// Add support for overriding case sensitivity with WL Next features
if(hop(self.wlNext, 'caseSensitive') && self.wlNext.caseSensitive) {
caseSensitivity = true;
}
// Check case sensitivity to decide if LOWER logic is used
if(!caseSensitivity) {
if(lower) {
key = 'LOWER(' + utils.escapeName(self.getTableAlias(), self.escapeCharacter, self.schemaName) + '.' + utils.escapeName(key, self.escapeCharacter) + ')';
} else {
key = utils.escapeName(self.getTableAlias(), self.escapeCharacter, self.schemaName) + '.' + utils.escapeName(key, self.escapeCharacter);
}
self.queryString += key + ' IN (';
} else {
self.queryString += utils.escapeName(self.getTableAlias(), self.escapeCharacter, self.schemaName) + '.' + utils.escapeName(key, self.escapeCharacter) + ' IN (';
}
// Append each value to query
val.forEach(function(value) {
// If case sensitivity if off lowercase the value
if(!caseSensitivity && _.isString(value)) {
value = value.toLowerCase();
}
// Use either a paramterized value or escaped value
if(self.parameterized) {
self.queryString += '$' + self.paramCount + ',';
self.paramCount++;
}
else {
if(_.isString(value)) {
value = '"' + utils.escapeString(value) + '"';
}
self.queryString += value + ',';
}
self.values.push(value);
});
// Strip last comma and close criteria
self.queryString = self.queryString.slice(0, -1) + ')';
self.queryString += ' AND ';
};
/**
* Build a param.
*
* @param {string} tableName
* @param {string} property
* @param {boolean} caseSensitive
*
* @returns {string}
*/
CriteriaProcessor.prototype.buildParam = function buildParam (tableName, property, caseSensitive) {
var escape = utils.escapeName,
param;
param = escape(tableName, this.escapeCharacter, this.schemaName) + '.' + escape(property, this.escapeCharacter);
if (caseSensitive) {
param = 'LOWER(' + param + ')';
}
return param;
};
/**
* Simple method which returns if supplied method is, or is not an operator.
*
* @param {string} subject
*
* @returns {boolean}
*/
CriteriaProcessor.prototype.isOperator = function isOperator (subject) {
return operators.indexOf(subject) > -1;
};
/**
* Check if given `child` is in fact a child in the currentSchema, and if so return the key.
*
* @param {string} child
*
* @returns {boolean}
*/
CriteriaProcessor.prototype.findChild = function findChild (child) {
var schema = this.currentSchema,
definitionKey = schema[child] ? child : _.findKey(schema, {columnName: child});
return definitionKey && _.isPlainObject(schema[definitionKey]) && schema[definitionKey].foreignKey
? definitionKey
: null;
};
/**
* Process simple criteria.
*
* @param {string} tableName
* @param {string} parent
* @param {string} value
* @param {string} combinator
* @param {boolean} [sensitive]
* @param {string} [alias]
*/
CriteriaProcessor.prototype.processSimple = function processSimple (tableName, parent, value, combinator, sensitive) {
// Set lower logic to true
var sensitiveTypes = ['text', 'string'],
currentSchema = this.schema[tableName].attributes,
self = this,
parentType,
lower;
if (currentSchema[parent]) {
parentType = currentSchema[parent].type || currentSchema[parent];
}
lower = parentType && sensitiveTypes.indexOf(parentType) > -1;
// Check if value is a string and if so add LOWER logic
// to work with case in-sensitive queries
if(!sensitive && lower && _.isString(value)) {
// Add LOWER to parent
parent = this.buildParam(this.getTableAlias(), parent, true);
value = value.toLowerCase();
} else {
// Escape parent
parent = this.buildParam(this.getTableAlias(), parent);
}
if(value === null) {
return this.queryString += parent + ' IS NULL';
}
// Simple Key/Value attributes
if(self.parameterized) {
this.queryString += parent + ' ' + combinator + ' $' + this.paramCount;
this.values.push(value);
this.paramCount++;
return;
}
// Check if the value is a DATE and if it's not a date turn it into one
if(parentType === 'date' && !_.isDate(value)) {
value = new Date(value);
}
if(_.isDate(value)) {
var date = value;
date = date.getFullYear() + '-' +
('00' + (date.getMonth()+1)).slice(-2) + '-' +
('00' + date.getDate()).slice(-2) + ' ' +
('00' + date.getHours()).slice(-2) + ':' +
('00' + date.getMinutes()).slice(-2) + ':' +
('00' + date.getSeconds()).slice(-2);
value = date;
}
if (_.isString(value)) {
value = '"' + utils.escapeString(value) +'"';
}
this.queryString += parent + ' ' + combinator + ' ' + value;
};
/**
* Process object criteria.
*
* @param {string} parent
* @param {string} value
* @param {string} combinator
* @param {boolean} sensitive
* @param {string} [alias]
*/
CriteriaProcessor.prototype.processObject = function processObject (tableName, parent, value, combinator, sensitive) {
var currentSchema = this.schema[tableName].attributes,
self = this,
parentType;
expandCriteria(value);
// Remove trailing `AND`
this.queryString = this.queryString.slice(0, -4);
// Expand criteria object
function expandCriteria (obj) {
var child = self.findChild(parent),
sensitiveTypes = ['text', 'string'], // haha, "sensitive types". "I'll watch 'the notebook' with you, babe."
lower;
_.keys(obj).forEach(function(key) {
if (child && !self.isOperator(key)) {
self.tableScope = child;
self.expand(key, obj[key]);
self.tableScope = null;
return;
}
// If value is an object, recursivly expand it
if(_.isPlainObject(obj[key])) {
return expandCriteria(obj[key]);
}
if (currentSchema[parent]) {
parentType = currentSchema[parent].type || currentSchema[parent]
}
lower = parentType && sensitiveTypes.indexOf(parentType) > -1;
if (!sensitive && _.isString(obj[key]) && lower) {
obj[key] = obj[key].toLowerCase();
}
// Check if the value is a DATE and if it's not a date turn it into one
if(parentType === 'date' && !_.isDate(obj[key])) {
var date = new Date(obj[key]);
date = date.getFullYear() + '-' +
('00' + (date.getMonth()+1)).slice(-2) + '-' +
('00' + date.getDate()).slice(-2) + ' ' +
('00' + date.getHours()).slice(-2) + ':' +
('00' + date.getMinutes()).slice(-2) + ':' +
('00' + date.getSeconds()).slice(-2);
obj[key] = date;
}
// Check if value is a string and if so add LOWER logic
// to work with case in-sensitive queries
self.queryString += self.buildParam(self.getTableAlias(), parent, !sensitive && _.isString(obj[key]) && lower) + ' ';
self.prepareCriterion(key, obj[key]);
self.queryString += ' AND ';
});
}
};
/**
* Process Criteria
*/
CriteriaProcessor.prototype.process = function process(parent, value, combinator, caseSensitive) {
var tableName = this.getTableScope();
// Override caseSensitivity for databases that don't support it
if(this.caseSensitive) {
caseSensitive = false;
}
// Add support for overriding case sensitivity with WL Next features
if(hop(this.wlNext, 'caseSensitive') && this.wlNext.caseSensitive) {
caseSensitive = true;
}
var processMethod = _.isPlainObject(value) ? this.processObject : this.processSimple;
processMethod.apply(this, [tableName, parent, value, combinator, caseSensitive]);
};
/**
* Prepare Criterion
*
* Processes comparators in a query.
*/
CriteriaProcessor.prototype.prepareCriterion = function prepareCriterion(key, value) {
var self = this;
var str;
var comparator;
var escapedDate = false;
var bumpParamCount = true;
// Check value for a date type
if(_.isDate(value)) {
value = value.getFullYear() + '-' +
('00' + (value.getMonth()+1)).slice(-2) + '-' +
('00' + value.getDate()).slice(-2) + ' ' +
('00' + value.getHours()).slice(-2) + ':' +
('00' + value.getMinutes()).slice(-2) + ':' +
('00' + value.getSeconds()).slice(-2);
value = '"' + value + '"';
escapedDate = true;
}
switch(key) {
case '<':
case 'lessThan':
if(this.parameterized) {
this.values.push(value);
str = '< ' + '$' + this.paramCount;
}
else {
if(_.isString(value) && !escapedDate) {
value = '"' + utils.escapeString(value) + '"';
}
str = '< ' + value;
}
break;
case '<=':
case 'lessThanOrEqual':
if(this.parameterized) {
this.values.push(value);
str = '<= ' + '$' + this.paramCount;
}
else {
if(_.isString(value) && !escapedDate) {
value = '"' + utils.escapeString(value) + '"';
}
str = '<= ' + value;
}
break;
case '>':
case 'greaterThan':
if(this.parameterized) {
this.values.push(value);
str = '> ' + '$' + this.paramCount;
}
else {
if(_.isString(value) && !escapedDate) {
value = '"' + utils.escapeString(value) + '"';
}
str = '> ' + value;
}
break;
case '>=':
case 'greaterThanOrEqual':
if(this.parameterized) {
this.values.push(value);
str = '>= ' + '$' + this.paramCount;
}
else {
if(_.isString(value) && !escapedDate) {
value = '"' + utils.escapeString(value) + '"';
}
str = '>= ' + value;
}
break;
case '!':
case 'not':
if(value === null) {
str = 'IS NOT NULL';
bumpParamCount = false;
}
else {
// For array values, do a "NOT IN"
if (Array.isArray(value)) {
if(self.parameterized) {
var params = [];
this.values = this.values.concat(value);
str = 'NOT IN (';
value.forEach(function() {
params.push('$' + self.paramCount++);
});
str += params.join(',') + ')';
// Roll back one since we bump the count at the end
this.paramCount--;
}
else {
str = 'NOT IN (';
value.forEach(function(val) {
if(_.isString(val)) {
val = '"' + utils.escapeString(val) + '"';
}
str += val + ',';
});
str = str.slice(0, -1) + ')';
}
}
// Otherwise do a regular <>
else {
if(this.parameterized) {
this.values.push(value);
str = '<> ' + '$' + this.paramCount;
}
else {
if(_.isString(value)) {
value = '"' + utils.escapeString(value) + '"';
}
str = '<> ' + value;
}
}
}
break;
case 'like':
if(this.caseSensitive) {
comparator = 'ILIKE';
}
else {
comparator = 'LIKE';
}
// Override comparator with WL Next features
if(hop(self.wlNext, 'caseSensitive') && self.wlNext.caseSensitive) {
comparator = 'LIKE';
}
if(this.parameterized) {
this.values.push(value);
str = comparator + ' ' + '$' + this.paramCount;
}
else {
// Note that wildcards are not escaped out of like criterion intentionally
str = comparator + ' "' + utils.escapeString(value) + '"';
}
break;
case 'contains':
if(this.caseSensitive) {
comparator = 'ILIKE';
}
else {
comparator = 'LIKE';
}
// Override comparator with WL Next features
if(hop(self.wlNext, 'caseSensitive') && self.wlNext.caseSensitive) {
comparator = 'LIKE';
}
if(this.parameterized) {
this.values.push('%' + value + '%');
str = comparator + ' ' + '$' + this.paramCount;
}
else {
str = comparator + ' "%' + utils.escapeString(value, true) + '%"';
}
break;
case 'startsWith':
if(this.caseSensitive) {
comparator = 'ILIKE';
}
else {
comparator = 'LIKE';
}
// Override comparator with WL Next features
if(hop(self.wlNext, 'caseSensitive') && self.wlNext.caseSensitive) {
comparator = 'LIKE';
}
if(this.parameterized) {
this.values.push(value + '%');
str = comparator + ' ' + '$' + this.paramCount;
}
else {
str = comparator + ' "' + utils.escapeString(value, true) + '%"';
}
break;
case 'endsWith':
if(this.caseSensitive) {
comparator = 'ILIKE';
}
else {
comparator = 'LIKE';
}
// Override comparator with WL Next features
if(hop(self.wlNext, 'caseSensitive') && self.wlNext.caseSensitive) {
comparator = 'LIKE';
}
if(this.parameterized) {
this.values.push('%' + value);
str = comparator + ' ' + '$' + this.paramCount;
}
else {
str = comparator + ' "%' + utils.escapeString(value, true) + '"';
}
break;
}
// Bump paramCount
if(bumpParamCount) {
this.paramCount++;
}
// Add str to query
this.queryString += str;
};
/**
* Specify a `limit` condition
*/
CriteriaProcessor.prototype.limit = function(options) {
// Some MySQL hackery here. For details, see:
// http://stackoverflow.com/questions/255517/mysql-offset-infinite-rows
if(options === null || options === undefined) {
this.queryString += ' LIMIT 184467440737095516 ';
}
else {
this.queryString += ' LIMIT ' + options;
}
};
/**
* Specify a `skip` condition
*/
CriteriaProcessor.prototype.skip = function(options) {
this.queryString += ' OFFSET ' + options;
};
/**
* Specify a `sort` condition
*/
CriteriaProcessor.prototype.sort = function(options) {
var keys = Object.keys(options);
if (!keys.length) { return; }
var self = this;
this.queryString += ' ORDER BY ';
keys.forEach(function(key) {
var direction = options[key] === 1 ? 'ASC' : 'DESC';
self.queryString += utils.escapeName(self.currentTable, self.escapeCharacter, self.schemaName) + '.' + utils.escapeName(key, self.escapeCharacter) + ' ' + direction + ', ';
});
// Remove trailing comma
this.queryString = this.queryString.slice(0, -2);
};
/**
* Specify a `group by` condition
*/
CriteriaProcessor.prototype.group = function(options) {
var self = this;
this.queryString += ' GROUP BY ';
// Normalize to array
if(!Array.isArray(options)) options = [options];
options.forEach(function(key) {
// Check whether we are grouping by a column or an expression.
if (_.includes(_.keys(self.currentSchema), key)) {
self.queryString += utils.escapeName(self.currentTable, self.escapeCharacter, self.schemaName) + '.' + utils.escapeName(key, self.escapeCharacter) + ', ';
} else {
self.queryString += key + ', ';
}
});
// Remove trailing comma
this.queryString = this.queryString.slice(0, -2);
};