UNPKG

mongo-query-to-postgres-jsonb

Version:

Converts MongoDB queries to postgresql queries for jsonb fields.

590 lines (547 loc) 22.9 kB
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ const util = require('./util.js') // These are the simple operators. // Note that "is distinct from" needs to be used to ensure nulls are returned as expected, see https://modern-sql.com/feature/is-distinct-from const OPS = { $eq: '=', $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: ' IS DISTINCT FROM ', } const OTHER_OPS = { $all: true, $in: true, $nin: true, $not: true, $or: true, $and: true, $elemMatch: true, $regex: true, $type: true, $size: true, $exists: true, $mod: true, $text: true } function getMatchingArrayPath(op, arrayPaths) { if (arrayPaths === true) { // always assume array path if true is passed return true } if (!arrayPaths || !Array.isArray(arrayPaths)) { return false } return arrayPaths.find(path => op.startsWith(path)) } /** * @param path array path current key * @param op current key, might be a dotted path * @param value * @param parent * @param arrayPathStr * @returns {string|string|*} */ function createElementOrArrayQuery(path, op, value, parent, arrayPathStr) { const arrayPath = arrayPathStr.split('.') const deeperPath = op.split('.').slice(arrayPath.length) const innerPath = ['value', ...deeperPath] const pathToMaybeArray = path.concat(arrayPath) // TODO: nested array paths are not yet supported. const singleElementQuery = convertOp(path, op, value, parent, []) const text = util.pathToText(pathToMaybeArray, false) const safeArray = `jsonb_typeof(${text})='array' AND` let arrayQuery = '' const specialKeys = getSpecialKeys(path, value, true) if (typeof value === 'object' && !Array.isArray(value) && value !== null) { if (typeof value['$size'] !== 'undefined') { // size does not support array element based matching } else if (value['$elemMatch']) { const sub = convert(innerPath, value['$elemMatch'], [], false) arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})` return arrayQuery } else if (value['$in']) { const sub = convert(innerPath, value, [], true) arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})` } else if (value['$all']) { const cleanedValue = value['$all'].filter((v) => (v !== null && typeof v !== 'undefined')) arrayQuery = '(' + cleanedValue.map(function (subquery) { const sub = convert(innerPath, subquery, [], false) return `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})` }).join(' AND ') + ')' } else if (specialKeys.length === 0) { const sub = convert(innerPath, value, [], true) arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})` } else { const params = value arrayQuery = '(' + Object.keys(params).map(function (subKey) { const sub = convert(innerPath, { [subKey]: params[subKey] }, [], true) return `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})` }).join(' AND ') + ')' } } else { const sub = convert(innerPath, value, [], true) arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})` } if (!arrayQuery || arrayQuery === '()') { return singleElementQuery } return `(${singleElementQuery} OR ${arrayQuery})` } /** * @param path {string} a dotted path * @param op {string} sub path, especially the current operation to convert, e.g. $in * @param value {mixed} * @param parent {mixed} parent[path] = value * @param arrayPaths {Array} List of dotted paths that possibly need to be handled as arrays. */ function convertOp(path, op, value, parent, arrayPaths) { const arrayPath = getMatchingArrayPath(op, arrayPaths) // It seems like direct matches shouldn't be array fields, but 2D arrays are possible in MongoDB // I will need to do more testing to see if we should handle this case differently. // const arrayDirectMatch = !isSpecialOp(op) && Array.isArray(value) if (arrayPath) { return createElementOrArrayQuery(path, op, value, parent, arrayPath) } switch(op) { case '$not': return '(NOT ' + convert(path, value) + ')' case '$nor': { for (const v of value) { if (typeof v !== 'object') { throw new Error('$or/$and/$nor entries need to be full objects') } } const notted = value.map((e) => ({ $not: e })) return convertOp(path, '$and', notted, value, arrayPaths) } case '$or': case '$and': if (!Array.isArray(value)) { throw new Error('$and or $or requires an array.') } if (value.length == 0) { throw new Error('$and/$or/$nor must be a nonempty array') } else { for (const v of value) { if (typeof v !== 'object') { throw new Error('$or/$and/$nor entries need to be full objects') } } return '(' + value.map((subquery) => convert(path, subquery, arrayPaths)).join(op === '$or' ? ' OR ' : ' AND ') + ')' } // TODO (make sure this handles multiple elements correctly) case '$elemMatch': return convert(path, value, arrayPaths) //return util.pathToText(path, false) + ' @> \'' + util.stringEscape(JSON.stringify(value)) + '\'::jsonb' case '$in': case '$nin': { if (value.length === 0) { return 'FALSE' } if (value.length === 1) { return convert(path, value[0], arrayPaths) } const cleanedValue = value.filter((v) => (v !== null && typeof v !== 'undefined')) let partial = util.pathToText(path, typeof value[0] == 'string') + (op == '$nin' ? ' NOT' : '') + ' IN (' + cleanedValue.map(util.quote).join(', ') + ')' if (value.length != cleanedValue.length) { return (op === '$in' ? '(' + partial + ' OR IS NULL)' : '(' + partial + ' AND IS NOT NULL)' ) } return partial } case '$text': { const newOp = '~' + (!value['$caseSensitive'] ? '*' : '') return util.pathToText(path, true) + ' ' + newOp + ' \'' + util.stringEscape(value['$search']) + '\'' } case '$regex': { var regexOp = '~' var op2 = '' if (parent['$options'] && parent['$options'].includes('i')) { regexOp += '*' } if (!parent['$options'] || !parent['$options'].includes('s')) { // partial newline-sensitive matching op2 += '(?p)' } if (value instanceof RegExp) { value = value.source } return util.pathToText(path, true) + ' ' + regexOp + ' \'' + op2 + util.stringEscape(value) + '\'' } case '$gt': case '$gte': case '$lt': case '$lte': case '$ne': case '$eq': { const isSimpleComparision = (op === '$eq' || op === '$ne') const pathContainsArrayAccess = path.some((key) => /^\d+$/.test(key)) if (isSimpleComparision && !pathContainsArrayAccess) { // create containment query since these can use GIN indexes // See docs here, https://www.postgresql.org/docs/9.4/datatype-json.html#JSON-INDEXING const [head, ...tail] = path return `${op=='$ne' ? 'NOT ' : ''}${head} @> ` + util.pathToObject([...tail, value]) } else { var text = util.pathToText(path, typeof value == 'string') return text + OPS[op] + util.quote(value) } } case '$type': { const text = util.pathToText(path, false) const type = util.getPostgresTypeName(value) return 'jsonb_typeof(' + text + ')=' + util.quote(type) } case '$size': { if (typeof value !== 'number' || value < 0 || !Number.isInteger(value)) { throw new Error('$size only supports positive integer') } const text = util.pathToText(path, false) return 'jsonb_array_length(' + text + ')=' + value } case '$exists': { if (path.length > 1) { const key = path.pop() const text = util.pathToText(path, false) return (value ? '' : ' NOT ') + text + ' ? ' + util.quote(key) } else { const text = util.pathToText(path, false) return text + ' IS ' + (value ? 'NOT ' : '') + 'NULL' } } case '$mod': { const text = util.pathToText(path, true) if (typeof value[0] != 'number' || typeof value[1] != 'number') { throw new Error('$mod requires numeric inputs') } return 'cast(' + text + ' AS numeric) % ' + value[0] + '=' + value[1] } default: // this is likely a top level field, recurse return convert(path.concat(op.split('.')), value) } } function isSpecialOp(op) { return op in OPS || op in OTHER_OPS } // top level keys are always special, since you never exact match the whole object function getSpecialKeys(path, query, forceExact) { return Object.keys(query).filter(function (key) { return (path.length === 1 && !forceExact) || isSpecialOp(key) }) } /** * Convert a filter expression to the corresponding PostgreSQL text. * @param path {Array} The current path * @param query {Mixed} Any value * @param arrayPaths {Array} List of dotted paths that possibly need to be handled as arrays. * @param forceExact {Boolean} When true, an exact match will be required. * @returns The corresponding PSQL expression */ var convert = function (path, query, arrayPaths, forceExact=false) { if (typeof query === 'string' || typeof query === 'boolean' || typeof query == 'number' || Array.isArray(query)) { return convertOp(path, '$eq', query, {}, arrayPaths) } if (query === null) { const text = util.pathToText(path, false) return '(' + text + ' IS NULL OR ' + text + ' = \'null\'::jsonb)' } if (query instanceof RegExp) { var op = query.ignoreCase ? '~*' : '~' return util.pathToText(path, true) + ' ' + op + ' \'' + util.stringEscape(query.source) + '\'' } if (typeof query === 'object') { // Check for an empty object if (Object.keys(query).length === 0) { return 'TRUE' } const specialKeys = getSpecialKeys(path, query, forceExact) switch (specialKeys.length) { case 0: { const text = util.pathToText(path, typeof query == 'string') return text + '=' + util.quote(query) } case 1: { const key = specialKeys[0] return convertOp(path, key, query[key], query, arrayPaths) } default: return '(' + specialKeys.map(function (key) { return convertOp(path, key, query[key], query, arrayPaths) }).join(' and ') + ')' } } } module.exports = function (fieldName, query, arrays) { return convert([fieldName], query, arrays || []) } module.exports.convertDotNotation = util.convertDotNotation module.exports.pathToText = util.pathToText module.exports.countUpdateSpecialKeys = util.countUpdateSpecialKeys module.exports.convertSelect = require('./select') module.exports.convertUpdate = require('./update') module.exports.convertSort = require('./sort') },{"./select":3,"./sort":4,"./update":5,"./util.js":6}],2:[function(require,module,exports){ (function (global){(function (){ global.window.mToPsql = require('./index.js') }).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) },{"./index.js":1}],3:[function(require,module,exports){ var util = require('./util.js') function convertRecur(fieldName, input, arrayFields, prefix, prefixStrip) { if (typeof input === 'string') { return util.pathToText([fieldName].concat(input.replace(new RegExp('^' + prefixStrip), '').split('.')), false) } else { var entries = [] for (var key in input) { entries.push('\'' + key + '\'') const nestedArrayField = arrayFields.includes(prefix + key) && typeof input[key] === 'object' if (!nestedArrayField) { entries.push(convertRecur(fieldName, input[key], arrayFields, prefix + key + '.' , prefixStrip)) } else { const path = util.pathToText([fieldName].concat((prefix + key).replace(new RegExp('^' + prefixStrip), '').split('.')), false) const obj = convertRecur('v', input[key], arrayFields, prefix + key + '.', prefix + key + '.') entries.push(`(SELECT jsonb_agg(r) FROM (SELECT ${obj} as r FROM jsonb_array_elements(${path}) as v) AS obj)`) } } return 'jsonb_build_object(' + entries.join(', ') + ')' } } function convertToShellDoc(projection, prefix = '') { var shellDoc = {} var removals = [] Object.keys(projection).forEach(function(field) { var path = field.split('.') if (projection[field] === 1) { var current = shellDoc for (var i = 0; i < path.length; i++) { var key = path[i] if (i !== path.length - 1) { current[key] = current[key] || {} current = current[key] } else { current[key] = prefix + field } } } else if (projection[field] === 0) { if (field !== '_id') { removals.push('#- ' + util.toPostgresPath(path)) } } else if (typeof projection[field] === 'object' && !Array.isArray(projection[field])) { const { shellDoc: subShellDoc, removals: subRemovals } = convertToShellDoc(projection[field], prefix + field + '.') shellDoc[field] = subShellDoc removals = removals.concat(subRemovals) } else { console.error(`unexpected projection value ${projection[field]} for ${field}`) } }) return { shellDoc, removals } } const convert = function (fieldName, projection, arrayFields) { arrayFields = arrayFields || [] // Empty projection returns full document if (!projection) { return fieldName } let { shellDoc, removals } = convertToShellDoc(projection) if (Object.keys(shellDoc).length > 0 && typeof projection['_id'] === 'undefined') { shellDoc['_id'] = '_id' } else if (projection['_id'] === 0) { delete shellDoc['_id'] } if (removals.length > 0 && Object.keys(shellDoc).length > 0) { throw new Error('Projection cannot have a mix of inclusion and exclusion.') } let out = Object.keys(shellDoc).length > 0 ? convertRecur(fieldName, shellDoc, arrayFields, '', '') : fieldName if (removals.length) { out += ' ' + removals.join(' ') } if (out === fieldName){ return out } return out + ' as ' + fieldName } module.exports = convert },{"./util.js":6}],4:[function(require,module,exports){ var util = require('./util.js') var convertField = function (fieldName, field, orderingType, forceNumericSort) { const dir = (orderingType === 1 ? 'ASC NULLS FIRST' : 'DESC NULLS LAST') const value = util.pathToText([fieldName].concat(field.split('.')), forceNumericSort) if (forceNumericSort) { return `cast(${value} as double precision) ${dir}` } return `${value} ${dir}` } var convert = function (fieldName, sortParams, forceNumericSort) { const orderings = Object.keys(sortParams).map(function(key) { return convertField(fieldName, key, sortParams[key], forceNumericSort || false) }) return orderings.join(', ') } module.exports = convert },{"./util.js":6}],5:[function(require,module,exports){ const util = require('./util.js') const convertWhere = require('./index.js') function convertOp(input, op, data, fieldName, upsert) { const pathText = Object.keys(data)[0] const value = data[pathText] delete data[pathText] if (Object.keys(data).length > 0) { input = convertOp(input, op, Object.assign({}, data), fieldName, upsert) } const path = pathText.split('.') const pgPath = util.toPostgresPath(path) const pgQueryPath = util.pathToText([fieldName].concat(path), false) const pgQueryPathStr = util.pathToText([fieldName].concat(path), true) const prevNumericVal = upsert ? '0' : util.toNumeric(pgQueryPathStr) switch (op) { case '$set': // Create the necessary top level keys since jsonb_set will not create them automatically. if (path.length > 1) { for (let i = 0; i < path.length - 1; i++) { const slice = path.slice(0, i + 1) const parentPath = util.toPostgresPath(slice) if (!input.includes(parentPath)) { const parentValue = upsert ? '\'{}\'::jsonb' : `COALESCE(${util.pathToText([fieldName].concat(slice))}, '{}'::jsonb)` input = 'jsonb_set(' + input + ',' + parentPath + ',' + parentValue + ')' } } } if (path[path.length - 1] === '_id' && !upsert) { throw new Error('Mod on _id not allowed') } return 'jsonb_set(' + input + ',' + pgPath + ',' + util.quote2(value) + ')' case '$unset': return input + ' #- ' + pgPath case '$inc': // TODO: Handle null value keys (MongoDB drops the operation with "Cannot apply $inc to a value of non-numeric type null") return 'jsonb_set(' + input + ',' + pgPath + ',to_jsonb(' + prevNumericVal + '+' + value + '))' case '$mul': // TODO: Handle null value keys (MongoDB drops the operation with "Cannot apply $mul to a value of non-numeric type null") return 'jsonb_set(' + input + ',' + pgPath + ',to_jsonb(' + prevNumericVal + '*' + value + '),TRUE)' case '$min': // TODO: $min between existing key with value null with anything will output null return 'jsonb_set(' + input + ',' + pgPath + ',to_jsonb(LEAST(' + prevNumericVal + ',' + value + ')))' case '$max': // TODO: $max between existing key with value null with anything will output value return 'jsonb_set(' + input + ',' + pgPath + ',to_jsonb(GREATEST(' + prevNumericVal + ',' + value + ')))' case '$rename': { const pgNewPath = util.toPostgresPath(value.split('.')) return 'jsonb_set(' + input + ',' + pgNewPath + ',' + pgQueryPath + ') #- ' + pgPath } case '$pull': { const newArray = 'to_jsonb(ARRAY(SELECT value FROM jsonb_array_elements(' + pgQueryPath + ') WHERE NOT ' + convertWhere('value', value, upsert) + '))' return 'jsonb_set(' + input + ',' + pgPath + ',' + newArray + ')' } case '$pullAll': { const pullValues = '(' + value.map((v) => util.quote2(v)).join(',') + ')' const newArray2 = 'to_jsonb(ARRAY(SELECT value FROM jsonb_array_elements(' + pgQueryPath + ') WHERE value NOT IN ' + pullValues + '))' return 'jsonb_set(' + input + ',' + pgPath + ',' + newArray2 + ')' } case '$push': { const v2 = util.quote2(value) if (upsert) { const newArray = 'jsonb_build_array(' + v2 + ')' return 'jsonb_set(' + input + ',' + pgPath + ',' + newArray + ')' } const updatedArray2 = 'to_jsonb(array_append(ARRAY(SELECT value FROM jsonb_array_elements(' + pgQueryPath + ')),' + v2 + '))' return 'jsonb_set(' + input + ',' + pgPath + ',' + updatedArray2 + ')' } case '$addToSet': { const v = util.quote2(value) if (upsert) { const newArray = 'jsonb_build_array(' + v + ')' return 'jsonb_set(' + input + ',' + pgPath + ',' + newArray + ')' } const updatedArray = 'to_jsonb(array_append(ARRAY(SELECT value FROM jsonb_array_elements(' + pgQueryPath + ') WHERE value != ' + v + '),' + v + '))' return 'jsonb_set(' + input + ',' + pgPath + ',' + updatedArray + ')' } } } var convert = function (fieldName, update, upsert) { var specialCount = util.countUpdateSpecialKeys(update) if (specialCount === 0) { return '\'' + JSON.stringify(update) + '\'::jsonb' } var output = upsert ? '\'{}\'::jsonb' : fieldName let keys = Object.keys(update) // $set needs to happen first if (keys.includes('$set')) { keys = ['$set'].concat(keys.filter((v) => v !== '$set')) } keys.forEach(function(key) { if (!util.updateSpecialKeys.includes(key)) { throw new Error('The <update> document must contain only update operator expressions.') } output = convertOp(output, key, Object.assign({}, update[key]), fieldName, upsert) }) return output } module.exports = convert },{"./index.js":1,"./util.js":6}],6:[function(require,module,exports){ exports.updateSpecialKeys = ['$currentDate', '$inc', '$min', '$max', '$mul', '$rename', '$set', '$setOnInsert', '$unset', '$push', '$pull', '$pullAll', '$addToSet'] exports.countUpdateSpecialKeys = function(doc) { return Object.keys(doc).filter(function(n) { return exports.updateSpecialKeys.includes(n) }).length } exports.quote = function(data) { if (typeof data == 'string') return '\'' + exports.stringEscape(data) + '\'' return '\''+JSON.stringify(data)+'\'::jsonb' } exports.quote2 = function(data) { if (typeof data == 'string') return '\'"' + exports.stringEscape(data) + '"\'' return '\''+JSON.stringify(data)+'\'::jsonb' } exports.stringEscape = function(str) { return str.replace(/'/g, '\'\'') } exports.pathToText = function(path, isString) { var text = exports.stringEscape(path[0]) if (isString && path.length === 1) { return text + ' #>>\'{}\'' } for (var i = 1; i < path.length; i++) { text += (i == path.length-1 && isString ? '->>' : '->') if (/^\d+$/.test(path[i])) text += path[i] //don't wrap numbers in quotes else text += '\'' + exports.stringEscape(path[i]) + '\'' } return text } exports.pathToObject = function(path) { if (path.length === 1) { return exports.quote2(path[0]) } return '\'' + exports.pathToObjectHelper(path) + '\'' } exports.pathToObjectHelper = function(path) { if (path.length === 1) { if (typeof path[0] == 'string') { return `"${path[0]}"` } else { return JSON.stringify(path[0]) } } const [head, ...tail] = path return `{ "${head}": ${exports.pathToObjectHelper(tail)} }` } exports.convertDotNotation = function(path, pathDotNotation) { return exports.pathToText([path].concat(pathDotNotation.split('.')), true) } exports.toPostgresPath = function(path) { return '\'{' + path.join(',') + '}\'' } exports.toNumeric = function(path) { return 'COALESCE(Cast(' + path + ' as numeric),0)' } const typeMapping = { 1: 'number', 2: 'string', 3: 'object', 4: 'array', 8: 'boolean', 10: 'null', 16: 'number', 18: 'number', 19: 'number' } exports.getPostgresTypeName = function(type) { if (!['string', 'number'].includes(typeof type)) { throw { errmsg: 'argument to $type is not a number or a string', code: 14 } } return typeMapping[type] || type } },{}]},{},[2]);