You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

484 lines
15 KiB
JavaScript

"use strict";
// FIXME: Make sure to account for placeholder nodes *within* arrays passed as single parameters, in query optimization/compilation/execution!
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 isValue = require("@validatem/is-value");
const allowExtraProperties = require("@validatem/allow-extra-properties");
let isPlaceholder = {
__raqbASTNode: isValue(true),
type: isValue("placeholder"),
name: isString
};
// 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 ]
}
];
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, arrayOf(is$Object) ]
});
return $object({
query: items
.map((item) => item.query)
.join(joiner),
params: items
.map((item) => item.params)
.flat(),
placeholders: items
.map((item) => item.placeholders)
.flat()
});
}
function $joinWhereClauses(joiner, clauses) {
let $handledClauses = clauses.map((clause) => {
console.log("CONSIDERING CLAUSE", require("util").inspect(clause, { colors: true, depth: null }));
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 columnList({ onlyColumns, addColumns }) {
let primaryColumns = (onlyColumns.length > 0)
? onlyColumns
: [ { type: "allColumns" } ];
return $join(", ", $handleAll(concat([ primaryColumns, addColumns ])));
}
function $parenthesize(query) {
return $combine`(${query})`;
}
function $maybeParenthesize($query) {
if ($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 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) {
let containsSQLExpressions = Array.isArray(items) && items.some((item) => typeOf(item) === "sqlExpression");
if (typeOf(items) === "placeholder") {
return $handle(items);
} else if (containsSQLExpressions) {
// If the list contains 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 });
}
}
// TODO: Eventually have some sort of generic Babel-esque AST transformation setup for all of these unpacking functions?
function unpackNotConditions(expression) {
// This translates `expression(notCondition(condition))` to `notExpression(expression(condition))`, including for multiple nested levels of `notCondition`
let notLevels = 0;
let currentItem = expression.condition;
while(typeOf(currentItem) === "notCondition") {
notLevels += 1;
currentItem = currentItem.condition;
}
// This handles double negatives, which may occur if someone is unknowingly nesting `not` conditions; eg. when generating query chunks with a third-party library and inverting the result
// FIXME: Generalize not-deduplication to work for user-provided `notExpression` as well? -> This should be moved to generic AST optimization infrastructure anyway!
let hasNot = (notLevels % 2) === 1;
let unpackedExpression = merge(expression, { condition: currentItem });
if (hasNot) {
return operations.not(unpackedExpression);
} else {
return unpackedExpression;
}
}
function flattenConditionList(conditionList) {
let ownType = typeOf(conditionList);
// TODO: Replace with Array#flat when Node 10 goes EOL (April 2021)
return flatten(conditionList.items.map((item) => {
let itemType = typeOf(item);
if (itemType === "condition") {
return item;
} else if (itemType === ownType) {
return flattenConditionList(item);
} else {
// Inverse type (eg. any instead of all); just return it as-is, and let the caller deal with that and the necessary recursion
return item;
}
}));
}
let allConditionTypes = [ "moreThan", "equals", "lessThan", "list" ];
function unpackListConditions(expression) {
// This (recursively) translates `expression(anyOfConditions([ condition1, condition2 ]))` into anyOfExpressions([ expression(condition1), expression(condition2) ]), with some batching of like-typed conditions for more compact queries
// FIXME: Merge multiple `where` clauses, prior to having it processed here, eg. in the `select` handler or an abstraction that can be used in other places (eg. for CHECK constraints)
function conditionsToExpressions(conditionList) {
let listType = typeOf(conditionList);
if (listType === "condition") {
// Nothing to unpack here, this is not actually a list.
return expression;
} else if (listType === "anyOfConditions" || listType === "allOfConditions") {
// FIXME: Detect unexpected conditionType
let conditionsByType = syncpipe(conditionList, [
(_) => flattenConditionList(_),
(_) => splitFilterN(_, allConditionTypes, (condition) => defaultValue(condition.conditionType, "list"))
]);
let newExpressions = syncpipe(allConditionTypes, [
(_) => _.map((conditionType) => {
if (conditionType === "list") {
return conditionsByType.list.map((subList) => {
return conditionsToExpressions(subList);
});
} else {
let relevantConditions = conditionsByType[conditionType];
if (relevantConditions.length === 0) {
return null;
} else if (relevantConditions.length === 1) {
return operations.expression({
left: expression.left,
condition: relevantConditions[0]
});
} else {
// [lessThan(a), lessThan(b)] -> lessThan(_internalArray([a, b]))
let internalArrayType = matchValue(listType, {
anyOfConditions: "_internalAnyOfArray",
allOfConditions: "_internalAllOfArray",
});
// FIXME: Can this be represented with standard operations alone? Or would that result in an infinite cycle of list condition unpacking? The dynamic access on a public API is also pretty nasty...
return operations.expression({
left: expression.left,
condition: operations[conditionType]({
type: internalArrayType,
items: relevantConditions.map((condition) => condition.expression)
})
});
}
}
}),
(_) => _.filter((expression) => expression != null),
(_) => flatten(_)
]);
if (newExpressions.length === 0) {
unreachable("Left with 0 new expressions after unpacking");
} else if (newExpressions.length === 1) {
return newExpressions[0];
} else {
let wrapperType = matchValue.literal(listType, {
anyOfConditions: operations.anyOf,
allOfConditions: operations.allOf
});
return wrapperType(newExpressions);
}
} else {
unreachable(`Unexpected condition type '${listType}'`);
}
}
return conditionsToExpressions(expression.condition);
}
// FIXME: createQueryObject/$ wrapper, including properties like relation mappings and dependent query queue
let process = {
select: function ({ table, clauses }) {
let $table = $handle(table);
let expectedClauseTypes = [ "where", "addColumns", "onlyColumns" ];
let clausesByType = splitFilterN(clauses, expectedClauseTypes, (clause) => clause.type);
let onlyColumns = clausesByType.onlyColumns.map((node) => node.columns).flat();
let addColumns = clausesByType.addColumns.map((node) => node.columns).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 $columnSelection = columnList({ onlyColumns, addColumns });
return $combine`SELECT ${$columnSelection} FROM ${$table} ${$whereClause}`;
},
tableName: function ({ name }) {
// FIXME: escape
return $object({
query: name,
params: []
});
},
allColumns: function () {
// Special value used in column selection, to signify any/all columns
return $object({
query: "*",
params: []
});
},
columnName: function ({ name }) {
// FIXME: escape
return $object({
query: name,
params: []
});
},
alias: function ({ column, expression }) {
// FIXME: escape
let $column = $handle(column);
let $expression = $handle(expression);
return $combine`${$expression} AS ${$column}`;
},
sqlExpression: function ({ sql, parameters }) {
return $object({
query: sql,
params: parameters
});
},
literalValue: function ({ value }) {
// FIXME: Check if this is valid in column selections / aliases
return $object({
query: "?",
params: [ value ]
});
},
expression: function (expression) {
// FIXME: wrap nested expression in parens
let unpackedExpression = syncpipe(expression, [
(_) => unpackNotConditions(expression),
(_) => unpackListConditions(expression)
]);
if (typeOf(unpackedExpression) !== "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(unpackedExpression);
} else {
let { left, condition } = unpackedExpression;
return $combine`${$handle(left)} ${$handle(condition)}`;
}
},
notExpression: function ({ expression }) {
return $combine`NOT (${$handle(expression)})`;
},
_internalAnyOfArray: function ({ items }) {
// anyOfConditions: function ({ items }) {
return $combine`ANY(${$maybeParenthesize($arrayFrom(items))})`;
},
_internalAllOfArray: function ({ items }) {
// allOfConditions: function ({ items }) {
return $combine`ALL(${$maybeParenthesize($arrayFrom(items))})`;
},
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 ]
});
}
};
function $handle(node) {
validateArguments(arguments, {
node: allowExtraProperties({
__raqbASTNode: isValue(true)
})
});
let processor = process[node.type];
if (processor != null) {
return processor(node);
} else {
throw new Error(`Unrecognized node type: ${node.type}`);
}
}
// FIXME: Disallow stringifying things that are not top-level queries! Eg. `columnName`
module.exports = function astToQuery(ast) {
let result = $handle(ast);
return $object({
query: result.query + ";",
params: result.params,
placeholders: result.placeholders
});
};