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

4 years ago
"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.
4 years ago
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");
4 years ago
const isBoolean = require("@validatem/is-boolean");
4 years ago
const isValue = require("@validatem/is-value");
const allowExtraProperties = require("@validatem/allow-extra-properties");
let isPlaceholder = {
__raqbASTNode: isValue(true),
type: isValue("placeholder"),
4 years ago
name: isString,
parameterizable: isBoolean
4 years ago
};
// 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 ]
}
];
4 years ago
let is$ObjectArray = [
arrayOf([
required,
either([
[ isValue(NoQuery) ],
[ is$Object ]
])
]),
(items) => items.filter((item) => item !== NoQuery)
];
4 years ago
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 ],
4 years ago
items: [ required, is$ObjectArray ]
4 years ago
});
4 years ago
let nonEmptyItems = items.filter((item) => item !== NoQuery);
4 years ago
return $object({
4 years ago
query: nonEmptyItems
4 years ago
.map((item) => item.query)
.join(joiner),
4 years ago
params: nonEmptyItems
4 years ago
.map((item) => item.params)
.flat(),
4 years ago
placeholders: nonEmptyItems
4 years ago
.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 });
}
4 years ago
function $fieldList({ onlyColumns, addColumns }) {
4 years ago
let primaryColumns = (onlyColumns.length > 0)
? onlyColumns
4 years ago
: [ { type: "allFields" } ];
4 years ago
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_]+)?$/;
4 years ago
function $maybeParenthesize($query) {
if ($query.query === "?" || simpleColumnNameRegex.test($query.query)) {
4 years ago
// 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.
4 years ago
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) {
4 years ago
if (typeOf(items) === "placeholder") {
return $handle(items);
} else if (!canBeParameterized) {
4 years ago
// 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
4 years ago
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 = {
4 years ago
select: function ({ collection, clauses }) {
let $collection = $handle(collection);
4 years ago
4 years ago
let expectedClauseTypes = [ "where", "addFields", "onlyFields", "collapseBy" ];
4 years ago
let clausesByType = splitFilterN(clauses, expectedClauseTypes, (clause) => clause.type);
4 years ago
let onlyFields = clausesByType.onlyFields.map((node) => node.fields).flat();
let addFields = clausesByType.addFields.map((node) => node.fields).flat();
4 years ago
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;
}
});
4 years ago
let $groupByClause = asExpression(() => {
4 years ago
if (clausesByType.collapseBy.length === 1) {
4 years ago
// NOTE: We currently only support a single collapseBy clause
4 years ago
let collapseFields = clausesByType.collapseBy[0].fields.fields;
4 years ago
4 years ago
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`);
4 years ago
} else {
return NoQuery;
}
});
4 years ago
4 years ago
let $fieldSelection = $fieldList({ onlyColumns: onlyFields, addColumns: addFields });
4 years ago
4 years ago
let $orderedClauses = [
4 years ago
$collection,
4 years ago
$whereClause,
$groupByClause
];
4 years ago
return $combine`SELECT ${$fieldSelection} FROM ${$join(" ", $orderedClauses)}`;
4 years ago
},
4 years ago
collection: function ({ name }) {
4 years ago
// FIXME: escape
return $object({
query: name,
params: []
});
},
4 years ago
allFields: function () {
// Special value used in field selection, to signify any/all field
4 years ago
return $object({
query: "*",
params: []
});
},
4 years ago
field: function ({ name }) {
4 years ago
// FIXME: escape
return $object({
query: name,
params: []
});
},
4 years ago
// FIXME: Shouldn't there by some remoteField/foreignColumn handling here?
hierarchical: function ({ fields }) {
return $combine`ROLLUP (${$join(", ", $handleAll(fields))})`;
4 years ago
},
4 years ago
alias: function ({ field, expression }) {
4 years ago
// FIXME: escape
4 years ago
let $field = $handle(field);
4 years ago
let $expression = $handle(expression);
4 years ago
return $combine`${$expression} AS ${$field}`;
4 years ago
},
sqlExpression: function ({ sql, parameters }) {
return $object({
query: sql,
params: parameters
});
},
literalValue: function ({ value }) {
4 years ago
// FIXME: Check if this is valid in field selections / aliases
4 years ago
return $object({
query: "?",
params: [ value ]
});
},
expression: function (expression) {
if (typeOf(expression) !== "expression") {
4 years ago
// 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);
4 years ago
} else {
let { left, condition } = expression;
4 years ago
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)})`;
4 years ago
},
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 ]
});
4 years ago
},
aggregrateFunction: function ({ functionName, args }) {
let $functionName = $object({
query: functionName.toUpperCase()
});
return $combine`${$functionName}(${$join(", ", $handleAll(args))})`;
4 years ago
}
};
function $handle(node) {
validateArguments(arguments, {
node: allowExtraProperties({
__raqbASTNode: isValue(true)
})
});
let processor = process[node.type];
if (processor != null) {
return processor(node);
} else {
4 years ago
// FIXME: unreachable
4 years ago
throw new Error(`Unrecognized node type: ${node.type}`);
}
}
4 years ago
// FIXME: Disallow stringifying things that are not top-level queries! Eg. `field`
4 years ago
module.exports = function astToQuery(ast) {
4 years ago
// console.log(require("util").inspect({ast}, {colors: true,depth:null}));
4 years ago
let result = $handle(ast);
return $object({
query: result.query + ";",
params: result.params,
placeholders: result.placeholders
});
};