mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 02:29:58 -05:00
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.
1108 lines
28 KiB
JavaScript
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;
|
|
}
|