Files
actual-actualbudget/packages/loot-core/src/server/aql/compiler.js
James Long df8f5853e8 Route aggregate queries in transaction grouped mode through the correct layer to remove deleted transactions (#247)
I recently migrated my personal usage of Actual over to the open-source
version and imported a bunch of transactions. I have a _lot_ of history
in Actual, including a lot of weird edge cases like deleted split
transactions. While reconciling I noticed that my account balance shown
at the top was incorrect, even though the running balance was current.

Digging into this, I discovered that we aren't correctly handling
aggregate queries when querying transactions in the "grouped" mode.
Aggregate queries don't make sense in the "grouped" mode. Grouped means
that you want a list of transactions that include both the parent and
child transactions (when they are split). If you are summing up all the
amount, you only want to consider non-parent transactions. So we switch
it back to "inline" mode, but the way we did this previously was to
manually stitch the query together.

Even though was add SQL to ignore deleted transactions, we still
possibly include them. A child transaction may not be marked as deleted,
even though the parent transaction is deleted. When a parent transaction
is deleted, all child transactions should be considered deleted as well,
regardless of their tombstone status. This is what the
`v_transactions_internal_alive` view does. Previously we weren't going
through this view though, so we could still potentially include split
transactions even though they've been deleted.

This is little hacky, but it fixes the immediate problem. We fall back
to the inline mode by modifying the where clause, and we also adjust the
view that it queries to use the correct one.
2023-03-12 23:00:15 +00:00

1108 lines
28 KiB
JavaScript

let _uid = 0;
function resetUid() {
_uid = 0;
}
function uid(tableName) {
_uid++;
return tableName + _uid;
}
class CompileError extends Error {}
function nativeDateToInt(date) {
let pad = x => (x < 10 ? '0' : '') + x;
return date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate());
}
function dateToInt(date) {
return parseInt(date.replace(/-/g, ''));
}
export function addTombstone(schema, tableName, tableId, whereStr) {
let hasTombstone = schema[tableName].tombstone != null;
return hasTombstone ? `${whereStr} AND ${tableId}.tombstone = 0` : whereStr;
}
function popPath(path) {
let parts = path.split('.');
return { path: parts.slice(0, -1).join('.'), field: parts[parts.length - 1] };
}
function isKeyword(str) {
return str === 'group';
}
export function quoteAlias(alias) {
return alias.indexOf('.') === -1 && !isKeyword(alias) ? alias : `"${alias}"`;
}
function typed(value, type, { literal = false } = {}) {
return { value, type, literal };
}
function getFieldDescription(schema, tableName, field) {
if (schema[tableName] == null) {
throw new CompileError(`Table "${tableName}" does not exist in the schema`);
}
let fieldDesc = schema[tableName][field];
if (fieldDesc == null) {
throw new CompileError(
`Field "${field}" does not exist in table "${tableName}"`,
);
}
return fieldDesc;
}
function makePath(state, path) {
let { schema, paths } = state;
let parts = path.split('.');
if (parts.length < 2) {
throw new CompileError('Invalid path: ' + path);
}
let initialTable = parts[0];
let tableName = parts.slice(1).reduce((tableName, field) => {
let table = schema[tableName];
if (table == null) {
throw new CompileError(`Path error: ${tableName} table does not exist`);
}
if (!table[field] || table[field].ref == null) {
throw new CompileError(
`Field not joinable on table ${tableName}: "${field}"`,
);
}
return table[field].ref;
}, initialTable);
let joinTable;
let parentParts = parts.slice(0, -1);
if (parentParts.length === 1) {
joinTable = parentParts[0];
} else {
let parentPath = parentParts.join('.');
let parentDesc = paths.get(parentPath);
if (!parentDesc) {
throw new CompileError('Path does not exist: ' + parentPath);
}
joinTable = parentDesc.tableId;
}
return {
tableName: tableName,
tableId: uid(tableName),
joinField: parts[parts.length - 1],
joinTable,
};
}
function resolvePath(state, path) {
let paths = path.split('.');
paths = paths.reduce(
(acc, name) => {
let fullName = acc.context + '.' + name;
return {
context: fullName,
path: [...acc.path, fullName],
};
},
{ context: state.implicitTableName, path: [] },
).path;
paths.forEach(path => {
if (!state.paths.get(path)) {
state.paths.set(path, makePath(state, path));
}
});
let pathInfo = state.paths.get(paths[paths.length - 1]);
return pathInfo;
}
function transformField(state, name) {
if (typeof name !== 'string') {
throw new CompileError('Invalid field name, must be a string');
}
let { path, field } = popPath(name);
let pathInfo;
if (path === '') {
pathInfo = {
tableName: state.implicitTableName,
tableId: state.implicitTableId,
};
} else {
pathInfo = resolvePath(state, path);
}
let fieldDesc = getFieldDescription(state.schema, pathInfo.tableName, field);
// If this is a field that references an item in another table, that
// item could have been deleted. If that's the case, we want to
// return `null` instead of an id pointing to a deleted item. This
// converts an id reference into a path that pulls the id through a
// table join which will filter out dead items, resulting in a
// `null` id if the item is deleted
if (
state.validateRefs &&
fieldDesc.ref &&
fieldDesc.type === 'id' &&
field !== 'id'
) {
let refPath = state.implicitTableName + '.' + name;
let refPathInfo = state.paths.get(refPath);
if (!refPathInfo) {
refPathInfo = makePath(state, refPath);
refPathInfo.noMapping = true;
state.paths.set(refPath, refPathInfo);
}
field = 'id';
pathInfo = refPathInfo;
}
let fieldStr = pathInfo.tableId + '.' + field;
return typed(fieldStr, fieldDesc.type);
}
function parseDate(str) {
let m = str.match(/^(\d{4}-\d{2}-\d{2})$/);
if (m) {
return typed(dateToInt(m[1]), 'date', { literal: true });
}
return null;
}
function parseMonth(str) {
let m = str.match(/^(\d{4}-\d{2})$/);
if (m) {
return typed(dateToInt(m[1]), 'date', { literal: true });
}
return null;
}
function parseYear(str) {
let m = str.match(/^(\d{4})$/);
if (m) {
return typed(dateToInt(m[1]), 'date', { literal: true });
}
return null;
}
function badDateFormat(str, type) {
throw new CompileError(`Bad ${type} format: ${str}`);
}
function inferParam(param, type) {
let existingType = param.paramType;
if (existingType) {
let casts = {
date: ['string'],
'date-month': ['date'],
'date-year': ['date', 'date-month'],
id: ['string'],
float: ['integer'],
};
if (
existingType !== type &&
(!casts[type] || !casts[type].includes(existingType))
) {
throw new Error(
`Parameter "${param.paramName}" can't convert to ${type} (already inferred as ${existingType})`,
);
}
} else {
param.paramType = type;
}
}
function castInput(state, expr, type) {
if (expr.type === type) {
return expr;
} else if (expr.type === 'param') {
inferParam(expr, type);
return typed(expr.value, type);
} else if (expr.type === 'null') {
if (!expr.literal) {
throw new CompileError("A non-literal null doesn't make sense");
}
if (type === 'boolean') {
return typed(0, 'boolean', { literal: true });
}
return expr;
}
// These are all things that can be safely casted automatically
if (type === 'date') {
if (expr.type === 'string') {
if (expr.literal) {
return parseDate(expr.value) || badDateFormat(expr.value, 'date');
} else {
throw new CompileError(
'Casting string fields to dates is not supported',
);
}
}
throw new CompileError(`Can't cast ${expr.type} to date`);
} else if (type === 'date-month') {
let expr2;
if (expr.type === 'date') {
expr2 = expr;
} else if (expr.type === 'string' || expr.type === 'any') {
expr2 =
parseMonth(expr.value) ||
parseDate(expr.value) ||
badDateFormat(expr.value, 'date-month');
} else {
throw new CompileError(`Can't cast ${expr.type} to date-month`);
}
if (expr2.literal) {
return typed(
dateToInt(expr2.value.toString().slice(0, 6)),
'date-month',
{ literal: true },
);
} else {
return typed(
`CAST(SUBSTR(${expr2.value}, 1, 6) AS integer)`,
'date-month',
);
}
} else if (type === 'date-year') {
let expr2;
if (expr.type === 'date' || expr.type === 'date-month') {
expr2 = expr;
} else if (expr.type === 'string') {
expr2 =
parseYear(expr.value) ||
parseMonth(expr.value) ||
parseDate(expr.value) ||
badDateFormat(expr.value, 'date-year');
} else {
throw new CompileError(`Can't cast ${expr.type} to date-year`);
}
if (expr2.literal) {
return typed(dateToInt(expr2.value.toString().slice(0, 4)), 'date-year', {
literal: true,
});
} else {
return typed(
`CAST(SUBSTR(${expr2.value}, 1, 4) AS integer)`,
'date-year',
);
}
} else if (type === 'id') {
if (expr.type === 'string') {
return typed(expr.value, 'id', { literal: expr.literal });
}
} else if (type === 'float') {
if (expr.type === 'integer') {
return typed(expr.value, 'float', { literal: expr.literal });
}
}
if (expr.type === 'any') {
return typed(expr.value, type, { literal: expr.literal });
}
throw new CompileError(`Can't convert ${expr.type} to ${type}`);
}
// TODO: remove state from these functions
function val(state, expr, type) {
let castedExpr = expr;
// Cast the type if necessary
if (type) {
castedExpr = castInput(state, expr, type);
}
if (castedExpr.literal) {
if (castedExpr.type === 'id') {
return `'${castedExpr.value}'`;
} else if (castedExpr.type === 'string') {
// Escape quotes
let value = castedExpr.value.replace(/'/g, "''");
return `'${value}'`;
}
}
return castedExpr.value;
}
function valArray(state, arr, types) {
return arr.map((value, idx) => val(state, value, types ? types[idx] : null));
}
function validateArgLength(arr, min, max) {
if (max == null) {
max = min;
}
if (min != null && arr.length < min) {
throw new CompileError('Too few arguments');
}
if (max != null && arr.length > max) {
throw new CompileError('Too many arguments');
}
}
//// Nice errors
function saveStack(type, func) {
return (state, ...args) => {
if (state == null || state.compileStack == null) {
throw new CompileError(
'This function cannot track error data. ' +
'It needs to accept the compiler state as the first argument.',
);
}
state.compileStack.push({ type, args });
let ret = func(state, ...args);
state.compileStack.pop();
return ret;
};
}
function prettyValue(value) {
if (typeof value === 'string') {
return value;
} else if (value === undefined) {
return 'undefined';
}
let str = JSON.stringify(value);
if (str.length > 70) {
let expanded = JSON.stringify(value, null, 2);
return expanded.split('\n').join('\n ');
}
return str;
}
function getCompileError(error, stack) {
if (stack.length === 0) {
return error;
}
let stackStr = stack
.slice(1)
.reverse()
.map(entry => {
switch (entry.type) {
case 'expr':
case 'function':
return prettyValue(entry.args[0]);
case 'op': {
let [fieldRef, opData] = entry.args;
return prettyValue({ [fieldRef]: opData });
}
case 'value':
return prettyValue(entry.value);
default:
return '';
}
})
.map(str => '\n ' + str)
.join('');
const rootMethod = stack[0].type;
const methodArgs = stack[0].args[0];
stackStr += `\n ${rootMethod}(${prettyValue(
methodArgs.length === 1 ? methodArgs[0] : methodArgs,
)})`;
// In production, hide internal stack traces
if (process.env.NODE_ENV === 'production') {
const err = new CompileError();
err.message = `${error.message}\n\nExpression stack:` + stackStr;
err.stack = null;
return err;
}
error.message = `${error.message}\n\nExpression stack:` + stackStr;
return error;
}
//// Compiler
function compileLiteral(value) {
if (value === undefined) {
throw new CompileError('`undefined` is not a valid query value');
} else if (value === null) {
return typed('NULL', 'null', { literal: true });
} else if (value instanceof Date) {
return typed(nativeDateToInt(value), 'date', { literal: true });
} else if (typeof value === 'string') {
// Allow user to escape $, and quote the string to make it a
// string literal in the output
value = value.replace(/\\\$/g, '$');
return typed(value, 'string', { literal: true });
} else if (typeof value === 'boolean') {
return typed(value ? 1 : 0, 'boolean', { literal: true });
} else if (typeof value === 'number') {
return typed(value, Number.isInteger(value) ? 'integer' : 'float', {
literal: true,
});
} else if (Array.isArray(value)) {
return typed(value, 'array', { literal: true });
} else {
throw new CompileError(
'Unsupported type of expression: ' + JSON.stringify(value),
);
}
}
const compileExpr = saveStack('expr', (state, expr) => {
if (typeof expr === 'string') {
// Field reference
if (expr[0] === '$') {
let fieldRef = expr === '$' ? state.implicitField : expr.slice(1);
if (fieldRef == null || fieldRef === '') {
throw new CompileError('Invalid field reference: ' + expr);
}
return transformField(state, fieldRef);
}
// Named parameter
if (expr[0] === ':') {
let param = { value: '?', type: 'param', paramName: expr.slice(1) };
state.namedParameters.push(param);
return param;
}
}
if (expr !== null) {
if (Array.isArray(expr)) {
return compileLiteral(expr);
} else if (
typeof expr === 'object' &&
Object.keys(expr).find(k => k[0] === '$')
) {
// It's a function call
return compileFunction(state, expr);
}
}
return compileLiteral(expr);
});
const compileFunction = saveStack('function', (state, func) => {
let [name] = Object.keys(func);
let argExprs = func[name];
if (!Array.isArray(argExprs)) {
argExprs = [argExprs];
}
if (name[0] !== '$') {
throw new CompileError(
`Unknown property "${name}". Did you mean to call a function? Try prefixing it with $`,
);
}
let args = argExprs;
// `$condition` is a special-case where it will be evaluated later
if (name !== '$condition') {
args = argExprs.map(arg => compileExpr(state, arg));
}
switch (name) {
// aggregate functions
case '$sum': {
validateArgLength(args, 1);
let [arg1] = valArray(state, args, ['float']);
return typed(`SUM(${arg1})`, args[0].type);
}
case '$sumOver': {
let [arg1] = valArray(state, args, ['float']);
let order = state.orders
? 'ORDER BY ' + compileOrderBy(state, state.orders)
: '';
return typed(
`(SUM(${arg1}) OVER (${order} ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING))`,
args[0].type,
);
}
case '$count': {
validateArgLength(args, 1);
let [arg1] = valArray(state, args);
return typed(`COUNT(${arg1})`, 'integer');
}
// string functions
case '$substr': {
validateArgLength(args, 2, 3);
let [arg1, arg2, arg3] = valArray(state, args, [
'string',
'integer',
'integer',
]);
return typed(`SUBSTR(${arg1}, ${arg2}, ${arg3})`, 'string');
}
case '$lower': {
validateArgLength(args, 1);
let [arg1] = valArray(state, args, ['string']);
return typed(`LOWER(${arg1})`, 'string');
}
// integer/float functions
case '$neg': {
validateArgLength(args, 1);
valArray(state, args, ['float']);
return typed(`(-${val(state, args[0])})`, args[0].type);
}
case '$abs': {
validateArgLength(args, 1);
valArray(state, args, ['float']);
return typed(`ABS(${val(state, args[0])})`, args[0].type);
}
case '$idiv': {
validateArgLength(args, 2);
valArray(state, args, ['integer', 'integer']);
return typed(
`(${val(state, args[0])} / ${val(state, args[1])})`,
args[0].type,
);
}
// date functions
case '$month': {
validateArgLength(args, 1);
return castInput(state, args[0], 'date-month');
}
case '$year': {
validateArgLength(args, 1);
return castInput(state, args[0], 'date-year');
}
// various functions
case '$condition':
validateArgLength(args, 1);
let conds = compileConditions(state, args[0]);
return typed(conds.join(' AND '), 'boolean');
case '$nocase':
validateArgLength(args, 1);
let [arg1] = valArray(state, args, ['string']);
return typed(`${arg1} COLLATE NOCASE`, args[0].type);
case '$literal': {
validateArgLength(args, 1);
if (!args[0].literal) {
throw new CompileError('Literal not passed to $literal');
}
return args[0];
}
default:
throw new CompileError(`Unknown function: ${name}`);
}
});
const compileOp = saveStack('op', (state, fieldRef, opData) => {
let { $transform, ...opExpr } = opData;
let [op] = Object.keys(opExpr);
let rhs = compileExpr(state, opData[op]);
let lhs;
if ($transform) {
lhs = compileFunction(
{ ...state, implicitField: fieldRef },
typeof $transform === 'string' ? { [$transform]: '$' } : $transform,
);
} else {
lhs = compileExpr(state, '$' + fieldRef);
}
switch (op) {
case '$gte': {
let [left, right] = valArray(state, [lhs, rhs], [null, lhs.type]);
return `${left} >= ${right}`;
}
case '$lte': {
let [left, right] = valArray(state, [lhs, rhs], [null, lhs.type]);
return `${left} <= ${right}`;
}
case '$gt': {
let [left, right] = valArray(state, [lhs, rhs], [null, lhs.type]);
return `${left} > ${right}`;
}
case '$lt': {
let [left, right] = valArray(state, [lhs, rhs], [null, lhs.type]);
return `${left} < ${right}`;
}
case '$eq': {
if (castInput(state, rhs, lhs.type).type === 'null') {
return `${val(state, lhs)} IS NULL`;
}
let [left, right] = valArray(state, [lhs, rhs], [null, lhs.type]);
if (rhs.type === 'param') {
let orders = state.namedParameters.map(param => {
return param === rhs || param === lhs ? [param, { ...param }] : param;
});
state.namedParameters = [].concat.apply([], orders);
return `CASE
WHEN ${left} IS NULL THEN ${right} IS NULL
ELSE ${left} = ${right}
END`;
}
return `${left} = ${right}`;
}
case '$oneof': {
let [left, right] = valArray(state, [lhs, rhs], [null, 'array']);
// Dedupe the ids
let ids = [...new Set(right)];
return `${left} IN (` + ids.map(id => `'${id}'`).join(',') + ')';
}
case '$like': {
let [left, right] = valArray(state, [lhs, rhs], ['string', 'string']);
return `${left} LIKE ${right}`;
}
default:
throw new CompileError(`Unknown operator: ${op}`);
}
});
function compileConditions(state, conds) {
if (!Array.isArray(conds)) {
// Convert the object form `{foo: 1, bar:2}` into the array form
// `[{foo: 1}, {bar:2}]`
conds = Object.entries(conds).map(cond => {
return { [cond[0]]: cond[1] };
});
}
return conds.filter(Boolean).reduce((res, condsObj) => {
let compiled = Object.entries(condsObj)
.map(([field, cond]) => {
// Allow a falsy value in the lhs of $and and $or to allow for
// quick forms like `$or: amount != 0 && ...`
if (field === '$and') {
if (!cond) {
return null;
}
return compileAnd(state, cond);
} else if (field === '$or') {
if (!cond) {
return null;
}
return compileOr(state, cond);
}
if (
typeof cond === 'string' ||
typeof cond === 'number' ||
typeof cond === 'boolean' ||
cond instanceof Date ||
cond == null
) {
return compileOp(state, field, { $eq: cond });
}
if (Array.isArray(cond)) {
// An array of conditions for a field is implicitly an `and`
return cond.map(c => compileOp(state, field, c)).join(' AND ');
}
return compileOp(state, field, cond);
})
.filter(Boolean);
return [...res, ...compiled];
}, []);
}
function compileOr(state, conds) {
// Same as above
if (!conds) {
return '0';
}
let res = compileConditions(state, conds);
if (res.length === 0) {
return '0';
}
return '(' + res.join('\n OR ') + ')';
}
function compileAnd(state, conds) {
// Same as above
if (!conds) {
return '1';
}
let res = compileConditions(state, conds);
if (res.length === 0) {
return '1';
}
return '(' + res.join('\n AND ') + ')';
}
const compileWhere = saveStack('filter', (state, conds) => {
return compileAnd(state, conds);
});
function compileJoins(state, tableRef, internalTableFilters) {
let joins = [];
state.paths.forEach((desc, path) => {
let { tableName, tableId, joinField, joinTable, noMapping } =
state.paths.get(path);
let on = `${tableId}.id = ${tableRef(joinTable)}.${quoteAlias(joinField)}`;
let filters = internalTableFilters(tableName);
if (filters.length > 0) {
on +=
' AND ' +
compileAnd(
{ ...state, implicitTableName: tableName, implicitTableId: tableId },
filters,
);
}
joins.push(
`LEFT JOIN ${
noMapping ? tableName : tableRef(tableName, true)
} ${tableId} ON ${addTombstone(state.schema, tableName, tableId, on)}`,
);
if (state.dependencies.indexOf(tableName) === -1) {
state.dependencies.push(tableName);
}
});
return joins.join('\n');
}
function expandStar(state, expr) {
let path;
let pathInfo;
if (expr === '*') {
pathInfo = {
tableName: state.implicitTableName,
tableId: state.implicitTableId,
};
} else if (expr.match(/\.\*$/)) {
let result = popPath(expr);
path = result.path;
pathInfo = resolvePath(state, result.path);
}
let table = state.schema[pathInfo.tableName];
if (table == null) {
throw new Error(`Table "${pathInfo.tableName}" does not exist`);
}
return Object.keys(table).map(field => (path ? `${path}.${field}` : field));
}
const compileSelect = saveStack(
'select',
(state, exprs, isAggregate, orders) => {
// Always include the id if it's not an aggregate
if (!isAggregate && !exprs.includes('id') && !exprs.includes('*')) {
exprs = exprs.concat(['id']);
}
let select = exprs.map(expr => {
if (typeof expr === 'string') {
if (expr.indexOf('*') !== -1) {
let fields = expandStar(state, expr);
return fields
.map(field => {
let compiled = compileExpr(state, '$' + field);
state.outputTypes.set(field, compiled.type);
return compiled.value + ' AS ' + quoteAlias(field);
})
.join(', ');
}
let compiled = compileExpr(state, '$' + expr);
state.outputTypes.set(expr, compiled.type);
return compiled.value + ' AS ' + quoteAlias(expr);
}
let [name, value] = Object.entries(expr)[0];
if (name[0] === '$') {
state.compileStack.push({ type: 'value', value: expr });
throw new CompileError(
`Invalid field "${name}", are you trying to select a function? You need to name the expression`,
);
}
if (typeof value === 'string') {
let compiled = compileExpr(state, '$' + value);
state.outputTypes.set(name, compiled.type);
return `${compiled.value} AS ${quoteAlias(name)}`;
}
let compiled = compileFunction({ ...state, orders }, value);
state.outputTypes.set(name, compiled.type);
return compiled.value + ` AS ${quoteAlias(name)}`;
});
return select.join(', ');
},
);
const compileGroupBy = saveStack('groupBy', (state, exprs) => {
let groupBy = exprs.map(expr => {
if (typeof expr === 'string') {
return compileExpr(state, '$' + expr).value;
}
return compileFunction(state, expr).value;
});
return groupBy.join(', ');
});
const compileOrderBy = saveStack('orderBy', (state, exprs) => {
let orderBy = exprs.map(expr => {
let compiled;
let dir = null;
if (typeof expr === 'string') {
compiled = compileExpr(state, '$' + expr).value;
} else {
let entries = Object.entries(expr);
let entry = entries[0];
// Check if this is a field reference
if (entries.length === 1 && entry[0][0] !== '$') {
dir = entry[1];
compiled = compileExpr(state, '$' + entry[0]).value;
} else {
// Otherwise it's a function
let { $dir, ...func } = expr;
dir = $dir;
compiled = compileFunction(state, func).value;
}
}
if (dir != null) {
if (dir !== 'desc' && dir !== 'asc') {
throw new CompileError('Invalid order direction: ' + dir);
}
return `${compiled} ${dir}`;
}
return compiled;
});
return orderBy.join(', ');
});
let AGGREGATE_FUNCTIONS = ['$sum', '$count'];
function isAggregateFunction(expr) {
if (typeof expr !== 'object' || Array.isArray(expr)) {
return false;
}
let [name, argExprs] = Object.entries(expr)[0];
if (!Array.isArray(argExprs)) {
argExprs = [argExprs];
}
if (AGGREGATE_FUNCTIONS.indexOf(name) !== -1) {
return true;
}
return !!argExprs.find(ex => isAggregateFunction(ex));
}
export function isAggregateQuery(queryState) {
// it's aggregate if:
// either an aggregate function is used in `select`
// or a `groupBy` exists
if (queryState.groupExpressions.length > 0) {
return true;
}
return !!queryState.selectExpressions.find(expr => {
if (typeof expr !== 'string') {
let [_, value] = Object.entries(expr)[0];
return isAggregateFunction(value);
}
return false;
});
}
export function compileQuery(queryState, schema, schemaConfig = {}) {
let { withDead, validateRefs = true, tableOptions, rawMode } = queryState;
let {
tableViews = {},
tableFilters = name => [],
customizeQuery = queryState => queryState,
} = schemaConfig;
let internalTableFilters = name => {
let filters = tableFilters(name);
// These filters cannot join tables and must be simple strings
for (let filter of filters) {
if (Array.isArray(filter)) {
throw new CompileError(
'Invalid internal table filter: only object filters are supported',
);
}
if (Object.keys(filter)[0].indexOf('.') !== -1) {
throw new CompileError(
'Invalid internal table filter: field names cannot contain paths',
);
}
}
return filters;
};
let tableRef = (name, isJoin) => {
let view =
typeof tableViews === 'function'
? tableViews(name, { withDead, isJoin, tableOptions })
: tableViews[name];
return view || name;
};
let tableName = queryState.table;
let {
filterExpressions,
selectExpressions,
groupExpressions,
orderExpressions,
limit,
offset,
} = customizeQuery(queryState);
let select = '';
let where = '';
let joins = '';
let groupBy = '';
let orderBy = '';
let state = {
schema,
implicitTableName: tableName,
implicitTableId: tableRef(tableName),
paths: new Map(),
dependencies: [tableName],
compileStack: [],
outputTypes: new Map(),
validateRefs,
namedParameters: [],
};
resetUid();
try {
select = compileSelect(
state,
selectExpressions,
isAggregateQuery(queryState),
orderExpressions,
);
if (filterExpressions.length > 0) {
let result = compileWhere(state, filterExpressions);
where = 'WHERE ' + result;
} else {
where = 'WHERE 1';
}
if (!rawMode) {
let filters = internalTableFilters(tableName);
if (filters.length > 0) {
where += ' AND ' + compileAnd(state, filters);
}
}
if (groupExpressions.length > 0) {
let result = compileGroupBy(state, groupExpressions);
groupBy = 'GROUP BY ' + result;
}
// Orders don't matter if doing a single calculation
if (orderExpressions.length > 0) {
let result = compileOrderBy(state, orderExpressions);
orderBy = 'ORDER BY ' + result;
}
if (state.paths.size > 0) {
joins = compileJoins(state, tableRef, internalTableFilters);
}
} catch (e) {
if (e instanceof CompileError) {
throw getCompileError(e, state.compileStack);
}
throw e;
}
let sqlPieces = {
select,
from: tableRef(tableName),
joins,
where,
groupBy,
orderBy,
limit,
offset,
};
return {
sqlPieces,
state,
};
}
export function defaultConstructQuery(queryState, state, sqlPieces) {
let s = sqlPieces;
let where = queryState.withDead
? s.where
: addTombstone(
state.schema,
state.implicitTableName,
state.implicitTableId,
s.where,
);
return `
SELECT ${s.select} FROM ${s.from}
${s.joins}
${where}
${s.groupBy}
${s.orderBy}
${s.limit != null ? `LIMIT ${s.limit}` : ''}
${s.offset != null ? `OFFSET ${s.offset}` : ''}
`;
}
export function generateSQLWithState(queryState, schema, schemaConfig) {
let { sqlPieces, state } = compileQuery(queryState, schema, schemaConfig);
return { sql: defaultConstructQuery(queryState, state, sqlPieces), state };
}
export function generateSQL(queryState) {
return generateSQLWithState(queryState).sql;
}