"use strict"; // FIXME: Make sure to account for placeholder nodes *within* arrays passed as single parameters, in query optimization/compilation/execution! // FIXME: Modularize stringification as well? // NOTE: The stringification code *intentionally* does not allow combining strings and $objects in a single template string! All literal strings that need to be inserted into a query, must be wrapped in an $object to explicitly signal that they are considered safe to insert. require("array.prototype.flat").shim(); // FIXME: Use `flatten` method instead const splitFilterN = require("split-filter-n"); const asExpression = require("as-expression"); const defaultValue = require("default-value"); const matchValue = require("match-value"); const syncpipe = require("syncpipe"); const flatten = require("flatten"); const operations = require("./operations"); const concat = require("./concat"); const typeOf = require("./type-of"); const merge = require("./merge"); const unreachable = require("./unreachable"); const isLiteralValue = require("./validators/is-literal-value"); let NoQuery = Symbol("NoQuery"); // NOTE: A function or variable/property starting with a $, signifies that it returns/contains an (array of) query objects, like { query, params } const { validateArguments, validateOptions, RemainingArguments } = require("@validatem/core"); const required = require("@validatem/required"); const either = require("@validatem/either"); const arrayOf = require("@validatem/array-of"); const isString = require("@validatem/is-string"); const isBoolean = require("@validatem/is-boolean"); const isValue = require("@validatem/is-value"); const allowExtraProperties = require("@validatem/allow-extra-properties"); let isPlaceholder = { __raqbASTNode: isValue(true), type: isValue("placeholder"), name: isString, parameterizable: isBoolean }; // FIXME: Do we want a nested array here? Probably not, since PostgreSQL might not support arbitrarily nested arrays let isValidParameter = either([ isLiteralValue, isPlaceholder, arrayOf(either([ isLiteralValue, isPlaceholder ])), ]); let is$ObjectParameters = { query: [ isString ], params: arrayOf(isValidParameter), placeholders: [ arrayOf([ required, isString ]) ] }; let is$Object = [ is$ObjectParameters, { query: [ required ], params: [ required ], placeholders: [ required ] } ]; let is$ObjectArray = [ arrayOf([ required, either([ [ isValue(NoQuery) ], [ is$Object ] ]) ]), (items) => items.filter((item) => item !== NoQuery) ]; function $object({ query, params, placeholders }) { validateOptions(arguments, is$ObjectParameters); return { query: defaultValue(query, ""), params: defaultValue(params, []), placeholders: defaultValue(placeholders, []) }; } function $join(joiner, items) { validateArguments(arguments, { joiner: [ required, isString ], items: [ required, is$ObjectArray ] }); let nonEmptyItems = items.filter((item) => item !== NoQuery); return $object({ query: nonEmptyItems .map((item) => item.query) .join(joiner), params: nonEmptyItems .map((item) => item.params) .flat(), placeholders: nonEmptyItems .map((item) => item.placeholders) .flat() }); } function $joinWhereClauses(joiner, clauses) { let $handledClauses = clauses.map((clause) => { let isSimpleClause = (typeOf(clause) === "expression"); let $handled = $handle(clause); if (isSimpleClause) { return $handled; } else { return $parenthesize($handled); } }); return $join(joiner, $handledClauses); } function $or(clauses) { return $joinWhereClauses(" OR ", clauses); } function $and(clauses) { return $joinWhereClauses(" AND ", clauses); } function $handleAll(nodes) { return nodes.map((node) => $handle(node)); } function $combine(_strings, ... _nodes) { let [ strings, nodes ] = validateArguments(arguments, { strings: [ required, arrayOf(isString) ], [RemainingArguments]: [ required, arrayOf([ required, either([ is$Object, isValue(NoQuery) ]) ])] }); // FIXME: Make or find template literal abstraction, to make this more readable and declarative // Maybe something that passes in a single array of interspersed strings/nodes with a property to indicate which each value is? let query = ""; let params = []; let placeholders = []; nodes.forEach((node, i) => { query += strings[i]; // NOTE: Uses a NoQuery symbol to indicate "don't generate anything here", to avoid interpreting accidental nulls as intentional omissions if (node !== NoQuery ) { query += node.query; // FIXME: Investigate whether the below is faster with concat params.push(... node.params); placeholders.push(... node.placeholders); } }); query += strings[strings.length - 1]; return $object({ query, params, placeholders }); } function $fieldList({ onlyColumns, addColumns }) { let primaryColumns = (onlyColumns.length > 0) ? onlyColumns : [ { type: "allFields" } ]; return $join(", ", $handleAll(concat([ primaryColumns, addColumns ]))); } function $parenthesize(query) { return $combine`(${query})`; } // FIXME: This is a bit hacky; we should probably have a `$parenthesizedHandle` or something instead... let simpleColumnNameRegex = /^[a-z_]+(?:\.[a-z_]+)?$/; function $maybeParenthesize($query) { if ($query.query === "?" || simpleColumnNameRegex.test($query.query)) { // We don't want to bloat the generated query with unnecessary parentheses, so we leave them out for things that only evaluated to placeholders or field names anyway. Other simple cases may be added here in the future, though we're limited in what we can *safely and reliably* analyze from already-generated SQL output. return $query; } else { return $parenthesize($query); } } function $parenthesizeAll(nodes) { return nodes.map((node) => $parenthesize(node)); } function $maybeParenthesizeAll(nodes) { return nodes.map((node) => $maybeParenthesize(node)); } function $arrayFrom(items, canBeParameterized) { if (typeOf(items) === "placeholder") { return $handle(items); } else if (!canBeParameterized) { // If the list contains eg. SQL expressions, we cannot pass it in as a param at query time; we need to explicitly compile the expressions into the query let $items = items.map((item) => $maybeParenthesize($handle(item))); return $combine`ARRAY[${$join(", ", $items)}]`; } else { let encounteredPlaceHolders = []; // FIXME: This currently assumes everything is a literal or placeholder, we should maybe add a guard to ensure that? Or is this already validated elsewhere? let values = items.map((item) => { if (typeOf(item) === "placeholder") { encounteredPlaceHolders.push(item.name); return item; } else { return item.value; } }); return $object({ query: "?", params: [ values ], placeholders: encounteredPlaceHolders }); } } // FIXME: createQueryObject/$ wrapper, including properties like relation mappings and dependent query queue let process = { select: function ({ collection, clauses }) { let $collection = $handle(collection); let expectedClauseTypes = [ "where", "addFields", "onlyFields", "collapseBy" ]; let clausesByType = splitFilterN(clauses, expectedClauseTypes, (clause) => clause.type); let onlyFields = clausesByType.onlyFields.map((node) => node.fields).flat(); let addFields = clausesByType.addFields.map((node) => node.fields).flat(); let whereExpressions = clausesByType.where.map((node) => node.expression); // FIXME: Fold relations // FIXME: This logic should move to an AST optimization phase let $whereClause = asExpression(() => { if (whereExpressions.length > 0) { return $combine`WHERE ${$handle(operations.allOf(whereExpressions))}`; } else { return NoQuery; } }); let $groupByClause = asExpression(() => { if (clausesByType.collapseBy.length === 1) { // NOTE: We currently only support a single collapseBy clause let collapseFields = clausesByType.collapseBy[0].fields.fields; return $combine`GROUP BY ${$join(", ", $handleAll(collapseFields))}`; } else if (clausesByType.collapseBy.length > 1) { // FIXME: Is this the correct place to check this? throw new Error(`Encountered multiple collapseBy clauses in the same query; this is not currently supported`); } else { return NoQuery; } }); let $fieldSelection = $fieldList({ onlyColumns: onlyFields, addColumns: addFields }); let $orderedClauses = [ $collection, $whereClause, $groupByClause ]; return $combine`SELECT ${$fieldSelection} FROM ${$join(" ", $orderedClauses)}`; }, collection: function ({ name }) { // FIXME: escape return $object({ query: name, params: [] }); }, allFields: function () { // Special value used in field selection, to signify any/all field return $object({ query: "*", params: [] }); }, field: function ({ name }) { // FIXME: escape return $object({ query: name, params: [] }); }, // FIXME: Shouldn't there by some remoteField/foreignColumn handling here? hierarchical: function ({ fields }) { return $combine`ROLLUP (${$join(", ", $handleAll(fields))})`; }, alias: function ({ field, expression }) { // FIXME: escape let $field = $handle(field); let $expression = $handle(expression); return $combine`${$expression} AS ${$field}`; }, sqlExpression: function ({ sql, parameters }) { return $object({ query: sql, params: parameters }); }, literalValue: function ({ value }) { // FIXME: Check if this is valid in field selections / aliases return $object({ query: "?", params: [ value ] }); }, expression: function (expression) { if (typeOf(expression) !== "expression") { // We got back an `expression` that was wrapped in a `notExpression`/`anyOfExpression`/`allOfExpression`, so let the respective handler deal with that wrapper first, and we'll come back here later when that handler invokes $handle again with a (now unpackable-condition-less) expression. return $handle(expression); } else { let { left, condition } = expression; return $combine`${$handle(left)} ${$handle(condition)}`; } }, notExpression: function ({ expression }) { return $combine`NOT (${$handle(expression)})`; }, _arrayOf: function ({ listType, items, canBeParameterized }) { let $keyword = $object({ query: matchValue(listType, { anyOf: "ANY", allOf: "ALL" }) }); // return $combine`${keyword}(${$maybeParenthesize($arrayFrom(items))})`; return $combine`${$keyword}(${$arrayFrom(items, canBeParameterized)})`; }, anyOfExpressions: function ({ items }) { if (items.length === 1) { return $handle(items[0]); } else { return $or(items); } }, allOfExpressions: function ({ items }) { if (items.length === 1) { return $handle(items[0]); } else { return $and(items); } }, condition: function ({ conditionType, expression }) { if (conditionType === "equals") { return $combine`= ${$handle(expression)}`; } else if (conditionType === "lessThan") { return $combine`< ${$handle(expression)}`; } else if (conditionType === "moreThan") { return $combine`> ${$handle(expression)}`; } else { // FIXME: unreachable marker throw new Error(`Unrecognized condition type: ${conditionType}`); } }, placeholder: function (placeholder) { return $object({ query: "?", params: [ placeholder ], placeholders: [ placeholder.name ] }); }, aggregrateFunction: function ({ functionName, args }) { let $functionName = $object({ query: functionName.toUpperCase() }); return $combine`${$functionName}(${$join(", ", $handleAll(args))})`; } }; function $handle(node) { validateArguments(arguments, { node: allowExtraProperties({ __raqbASTNode: isValue(true) }) }); let processor = process[node.type]; if (processor != null) { return processor(node); } else { // FIXME: unreachable throw new Error(`Unrecognized node type: ${node.type}`); } } // FIXME: Disallow stringifying things that are not top-level queries! Eg. `field` module.exports = function astToQuery(ast) { // console.log(require("util").inspect({ast}, {colors: true,depth:null})); let result = $handle(ast); return $object({ query: result.query + ";", params: result.params, placeholders: result.placeholders }); };