UNPKG

arrow-orm

Version:

API Builder ORM

286 lines (259 loc) 8.88 kB
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 };