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.

413 lines
12 KiB
JavaScript

"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
});
};