UNPKG

massive

Version:

A small query tool for Postgres that embraces json and makes life simpler

282 lines (238 loc) 9.48 kB
'use strict'; const _ = require('lodash'); const quote = require('./quote'); /** * Tokenize and analyze a string representing a field in a database * relation. * @param {String} key - the string to process. * @return {Object} A complete manifest of what the key targets and how. */ const lex = function (key) { key = key.trim(); const pathShape = []; // describe path traversal: true is a field, false an array index const tokens = [[]]; // we're going to collect token arrays let buffer = tokens[0]; // start with the first token let inQuotation = false; // ensure we pick up everything in quotes let hasCast = false; // make sure we pull the appropriate token for cast let jsonAsText = false; // explicit casts must use as-text operators let i = 0; let char = key.charAt(i); do { if (inQuotation && char !== '"') { buffer.push(char); } else { switch (char) { case '"': // quoted field if (inQuotation) { // closing a quotation completes a token buffer = tokens[tokens.push([]) - 1]; } inQuotation = !inQuotation; break; case ':': // could be a cast, but only if it's the second in a row; new token, // discarding the : characters themselves to get the type next if (_.last(buffer) === ':') { buffer.pop(); if (!hasCast) { hasCast = true; jsonAsText = true; // Explicit casts must use as-text operators buffer = tokens[tokens.push([]) - 1]; } } else { buffer.push(char); } break; case '.': // json path traversal. new token, and note that it's a field to ensure // proper element/index handling later. pathShape.push(true); buffer = tokens[tokens.push([]) - 1]; break; case '[': // json array index. new token, and note that it's an index for later. pathShape.push(false); buffer = tokens[tokens.push([]) - 1]; break; case ']': // terminate json array index. starts a new token, no pathShape push buffer = tokens[tokens.push([]) - 1]; break; case ' ': case '\t': case '\r': case '\n': // whitespace; separates tokens buffer = tokens[tokens.push([]) - 1]; break; default: // eslint-disable-line no-fallthrough buffer.push(char); break; } } i++; } while (char = key.charAt(i)); // eslint-disable-line no-cond-assign return { pathShape, jsonAsText, hasCast, tokens: tokens.reduce(function (acc, p) { const str = p.join('').trim(); if (str) { acc.push(str); } return acc; }, []) }; }; /** * Parse out a criterion key or field reference into something more * intelligible. Massive is more flexible than Postgres' query parser, with more * alternate aliases for operations and looser rules about quoting, especially * with JSON fields. This necessitates some pretty gnarly parsing. * * @module parseKey * @param {String} key - A reference to a database column. The field name may * be quoted using double quotes to allow names which otherwise would not * conform with database naming conventions. Optional components include, in * order, [] and . notation to describe elements of a JSON field; ::type to * describe a cast; and finally, an argument to the appendix function. * @param {Entity} source - The relation to which the key refers. * @param {Boolean} jsonAsText A boolean to determine which JSON extraction operators to use * @return {Object} An object describing the parsed key. */ exports = module.exports = function (key, source, jsonAsText = true) { const lexed = lex(key); const tokens = lexed.tokens; const pathShape = lexed.pathShape; const hasCast = lexed.hasCast; let alias, schema, relation; if (source && source.loader === 'join') { // Join Readables get some special treatment since keys passed in a join // context may have schema and relation information prepended. There are // multiple possible cases. In almost all of them, the pathShape will have // picked up spurious initial values since schema.relation.field looks a lot // like field.jsonobject.property to the lexer, and so these values must be // removed before proceeding. if (source.name === tokens[0]) { // 1. The first token matches the origin relation's name. relation = tokens.shift(); pathShape.shift(); } else if (source.schema === tokens[0] && source.name === tokens[1]) { // 2. The first two tokens match the origin relation's schema and name. // This is the only instance in which the schema is retained, since // joined relations in schemas other than db.currentSchema are aliased. schema = tokens.shift(); relation = tokens.shift(); pathShape.splice(0, 2); } else { // no match to the origin relation, time to look in the joins const matched = source.joins.some(j => { if (j.alias.indexOf(tokens[0]) > -1) { // 3. The first token matches a known alias. alias = tokens.shift(); pathShape.shift(); } else if (j.relation === tokens[0]) { // 4. The first token matches a joined relation's name. alias = j.alias; relation = tokens.shift(); pathShape.shift(); } else if (j.schema === tokens[0] && j.relation === tokens[1]) { // 5. The first two tokens match a joined relation's schema and name. // In (5), the schema is noted but not included in the path or lhs // since the relation is aliased either explicitly in the join // definition or implicitly by reduction to the relation name. alias = j.alias; schema = tokens.shift(); relation = tokens.shift(); pathShape.splice(0, 2); } else { return false; } return true; }); if (!matched) { // 6. No tokens match any member relation. Assume it references the // origin. schema = source.schema; relation = source.name; } } } const field = tokens.shift(); const pathElements = _.compact([ // aliases may not include schemas !!alias || schema === source.db.currentSchema ? undefined : schema, alias || relation, field ]); const path = pathElements.map(quote).join('.'); let lhs = path; let jsonElements; if (pathShape.length === 1) { const operator = lexed.jsonAsText || jsonAsText ? '->>' : '->'; jsonElements = [tokens.shift()]; if (pathShape[0]) { // object keys must be quoted lhs = `${path}${operator}'${jsonElements[0]}'`; } else { // array index lhs = `${path}${operator}${jsonElements[0]}`; } } else if (pathShape.length > 0) { const operator = jsonAsText ? '#>>' : '#>'; jsonElements = tokens.splice(0, pathShape.length); lhs = `${path}${operator}'{${jsonElements.join(',')}}'`; } let cast; if (hasCast) { cast = tokens.shift(); // parens are only needed for JSON pathing lhs = pathShape.length > 0 ? `(${lhs})::${cast}` : `${lhs}::${cast}`; } const resolvedRelation = relation || alias || (source ? source.name : undefined); return { schema, relation: resolvedRelation, field, type: source && source.types // Try both "relation.field" and "field" as joins prefix types with the table alias ? source.types[`${resolvedRelation}.${field}`] || source.types[field] : undefined, pathElements, path, lhs, jsonElements: jsonElements || [], remainder: tokens.length > 0 ? tokens.join(' ').toLowerCase() : undefined, isJSON: pathShape.length > 0 }; }; exports.lex = lex; /** * Parse the provided key into a predicate by finding or assigning an operation * and attaching the right-hand side value. * * @param {String} key - A reference to a database column. The field name may * be quoted using double quotes to allow names which otherwise would not * conform with database naming conventions. Optional components include, in * order, [] and . notation to describe elements of a JSON field; ::type to * describe a cast; and finally, an argument to the appendix function. * @param {Entity} source - The relation to which the key refers. * @param {Function} appendix - A function which returns (currently) an * operation definition corresponding to the remaining part of key after all * other elements have been processed. * @param {Object} value - The right-hand side value to attach to the * predicate. * @param {Integer} offset - The offset to apply to the predicate for prepared * statement parameter indexing. * @return {Object} A predicate object extending the base output of parseKey * itself, supplemented with appended and right-hand side properties. */ exports.withAppendix = function (key, source, appendix, value, offset) { const predicate = this(key, source); predicate.offset = offset; predicate.value = value; predicate.params = []; let appended = predicate.remainder && appendix(predicate.remainder); if (!appended) { appended = appendix('='); } predicate.appended = appended; return predicate; };