arrow-orm
Version:
API Builder ORM
286 lines (259 loc) • 8.88 kB
JavaScript
const _ = require('lodash');
const DEFAULT_QUERY_OPTIONS = { where: 1, sel: 1, unsel: 1, order: 1, skip: 1, limit: 1 };
const DEFAULT_SKIP = 0;
const DEFAULT_LIMIT = 10;
/**
* Parses a `value` as an expected `type`.
* @param {*} value - The `value` to parse
* @param {string} type - The type that `value` should be parsed as
* @returns {*} the parsed value
*/
function parseValue(value, type) {
if (type === 'number') {
return parseNumber(value);
} else if (type === 'boolean') {
return parseBoolean(value);
} else if (type === 'date') {
return parseDate(value);
}
return value;
}
/**
* Parses an input object to Boolean.
* @param {object} obj - An input object representing a 'boolean'
* @returns {*} the parsed value
*/
function parseBoolean(obj) {
if (typeof obj === 'string') {
return /^(1|true|yes|ok)$/.test(String(obj).toLowerCase());
}
return obj;
}
/**
* Parses an input object to Date.
* @param {object} obj - An input object representing a 'date'
* @returns {*} the parsed value
*/
function parseDate(obj) {
if (typeof obj === 'string') {
const date = new Date(obj);
// Check if the date is valid by running it via "isNaN" which checks the
// timestamp value. Anything that failed to create a valid date will not
// have a value which is a valid number.
if (isNaN(date)) {
throw new Error(`Failed to parse "${obj}" as a date`);
}
obj = date;
}
return obj;
}
/**
* Parses an input object to Number.
* @param {object} obj - An input object representing a 'number'
* @returns {*} the parsed value
*/
function parseNumber(obj) {
// "1234" => 1234
// "+1234" => 1234
// "-1234" => -1234
// we might consider doubles in the future
if (typeof obj === 'string' && /^(-|\+)?\d+$/.test(obj)) {
obj = parseInt(obj, 10);
}
return obj;
}
/**
* Takes a "where" query object and tries to be smart and parse all the values
* into the underlying field type. It will try and parse string values into
* a boolean, number or date.
* @param {Model} model The model for the query being processed
* @param {object} where query object containing values and operators to match fields in a database
*/
function translateWhereValuesForPayload(model, where) {
if (!where || typeof where !== 'object') {
return;
}
for (const field in where) {
if (!model.fields.hasOwnProperty(field)) {
// model does not have the field, so no translation required
continue;
}
const { type } = model.fields[field];
// Search the query for values which should be parsed. We will only do it where
// we know that we will do it correctly.
if (typeof where[field] === 'string') {
// Handle the parsing on the value when we lack any comparitor
where[field] = parseValue(where[field], type);
} else if (typeof where[field] === 'object') {
for (const operator in where[field]) {
// We only really handle known operators across each connector here.
// If the connector is pretty complex like mongo then we won't
// be thorough enough.
// Note, this only touches known operators which should have their values
// parsed to their underlying type. $like, for example, is not here because it's
// always going to be expected to be a string for any connector.
if ([ '$eq', '$lt', '$lte', '$gte', '$gt', '$ne' ].includes(operator)) {
// all these operators have a raw value of the type of the field
where[field][operator] = parseValue(where[field][operator], type);
} else if ([ '$in', '$nin' ].includes(operator)) {
// $in and $nin can be single values or array of values to try and match
const vals = where[field][operator];
if (Array.isArray(vals)) {
where[field][operator] = vals.map(val => parseValue(val, type));
} else {
// normalise $in and $nin to be an array
where[field][operator] = [ parseValue(vals, type) ];
}
}
}
}
}
}
function parseProperties(object) {
for (var key in object) {
if (object.hasOwnProperty(key)) {
var val = object[key];
if (val && typeof val === 'string' && val[0] === '{') {
try {
val = JSON.parse(val);
object[key] = val;
} catch (err) {
err.message = `Failed to parse ${key} as JSON: ${err.message}`;
throw err;
}
}
}
}
}
function translateCSVToObject(str) {
var retVal = {},
split = str.split(',');
for (var i = 0; i < split.length; i++) {
retVal[split[i].trim()] = 1;
}
return retVal;
}
/**
* Prepare the options provided for a query, assigning appropriate defaults
* and making necessary transformations.
* @param {object} model - The model.
* @param {object} [options = {}] - The query options.
* @param {int} [options.skip = 0] - The number of items to skip.
* @param {int} [options.limit = 10] - The maximum number of items to return.
* @param {string[]|object} [options.sel] - The fields to select.
* @param {string[]|object} [options.unsel] - The fields to exclude.
* @param {object} [options.order] - A object of one or more fields specifying sorting of results.
* @param {string|object} [options.where] - An object using the expression query language. Can be
* JSON encoded.
* @param {object} validOptions - An object that restrict the `options`
* that can be provided. Note that `where` is always a valid option.
* @return {object} the translated query
*/
function prepareQueryOptions(model, options, validOptions = DEFAULT_QUERY_OPTIONS) {
options = options || {};
// Look for JSON for us to parse.
parseProperties(options);
// TODO: RDPP-4605 should pull in apibuilderQuery options at some point and do the following:
// 1) Supply the validOptions
// 2) Apply any spec-driven defaults
// 3) Validate any maxs/mins
// Allow mixed casing on the parameters.
for (var casedKey in options) {
if (options.hasOwnProperty(casedKey)) {
if (!validOptions[casedKey] && validOptions[casedKey.toLowerCase()]) {
options[casedKey.toLowerCase()] = options[casedKey];
delete options[casedKey];
}
}
}
// If `options` does not contain any of possible valid options, then assume that
// the `options` are intended for a where clause. NOTE: the current behavior is
// that `where` is always a `validOption` because there are no cases where it is
// ever excluded.
if (!_.some(validOptions, (val, key) => options[key] !== undefined)) {
options = { where: options };
}
// Translate sel and unsel, if specified.
if (options.sel) {
// sel and unsel are mutual exclusive
delete options.unsel;
}
if (typeof options.sel === 'string') {
options.sel = translateCSVToObject(options.sel);
}
if (typeof options.unsel === 'string') {
options.unsel = translateCSVToObject(options.unsel);
}
if (model.defaultQueryOptions) {
options = _.merge(model.defaultQueryOptions, options);
}
// Ensure skip and limit are set
if (validOptions.skip) {
options.skip = +options.skip || DEFAULT_SKIP;
}
if (validOptions.limit) {
options.limit = +options.limit || DEFAULT_LIMIT;
}
if (options.where !== undefined) {
if (model.getConnector().translateWhereRegex) {
translateQueryRegex(options.where);
}
translateWhereValuesForPayload(model, options.where);
}
return options;
}
// tests '%' or '_'. Note that double %% is escaped, so is a like expression
const IS_LIKE_EXPRESSION = new RegExp(/(?:(%|_))/);
/**
* Recurses through query and translates $like and $notLike operators
* into $regex and $not: { $regex }
* For connectors which do not support $like. They must support $regex and $not
* @param {object} where - The query options to be translated.
* @return {object} the translated query
*/
function translateQueryRegex(where) {
for (const key in where) {
const val = where[key];
if (key === '$like' || key === '$notLike') {
if (IS_LIKE_EXPRESSION.test(val)) {
// It has a wildcard so translate $like to $regex
const regex = '^' + val
// %% is an escaped percentage. Translate this into a regular escaped %
.replace(/%{2}/g, '\\%')
// replace % with a regex "match any"
.replace(/(^|[^\\])%/g, '$1.*')
// replace single character match with "match one"
.replace(/(^|[^\\])_/g, '$1.') + '$';
if (key === '$like') {
where.$regex = regex;
} else {
where.$not = { $regex: regex };
}
} else if (key === '$like') {
// `val` is not a string or does not have a wildcard so translate to $eq
where.$eq = val;
} else {
// `val` is not a string, and is $notLike
where.$ne = val;
}
// delete $like or $notLike
delete where[key];
} else if (Array.isArray(val)) {
for (const item of val) {
if (_.isObject(item)) {
translateQueryRegex(item);
}
}
} else if (_.isObject(val)) {
translateQueryRegex(val);
}
}
return where;
}
module.exports = {
parseValue,
prepareQueryOptions,
translateQueryRegex,
DEFAULT_SKIP,
DEFAULT_LIMIT
};