@synatic/noql
Version:
Convert SQL statements to mongo queries or aggregates
399 lines (385 loc) • 12.4 kB
JavaScript
const {functionByName} = require('../MongoFunctions');
module.exports = {applyPivot, applyUnpivot, applyMultipleUnpivots};
/**
*
* @param {string} pivotString
* @param {import('../types').PipelineFn[]} pipeline
* @param {import('../types').NoqlContext} context
*/
function applyPivot(pivotString, pipeline, context) {
const pivot = createJSONFromPivotString('pivot', pivotString);
pivot.fields = pivot.fields.map((field) => {
const functionName = field.split('(')[0];
const foundFunction = functionByName(functionName);
if (!foundFunction) {
throw new Error(
`Unable to find function "${functionName}" in pivot.fields."`
);
}
let argumentString = field.replace(functionName, '');
let name = '';
if (argumentString.indexOf(' as ') >= 0) {
const parts = argumentString.split(' as ');
argumentString = parts[0];
name = parts[1];
}
argumentString = argumentString.substring(1, argumentString.length - 1);
const rawArguments = argumentString.split(',');
if (!name) {
name = rawArguments[0];
}
const parsedArguments = rawArguments.map((arg) =>
isNumeric(arg) ? parseFloat(arg) : `$${arg}`
);
return {
name,
foundFunction,
parsedArguments,
};
});
pipeline.push({
$group: {
_id: `$${pivot.for}`,
...pivot.fields.reduce((previousValue, currentValue) => {
const res = currentValue.foundFunction.parse(
...currentValue.parsedArguments
);
previousValue[currentValue.name] = res;
return previousValue;
}, {}),
},
});
pipeline.push({
$group: {
_id: null,
data: {
$push: {
k: {
$toString: '$_id',
},
v:
pivot.fields.length > 1
? {
...pivot.fields.reduce(
(previousValue, currentValue) => {
previousValue[currentValue.name] =
`$${currentValue.name}`;
return previousValue;
},
{}
),
}
: `$${pivot.fields[0].name}`,
},
},
},
});
pipeline.push({
$project: {
_id: 0,
data: {
$arrayToObject: '$data',
},
},
});
pipeline.push({
$project: {
result: {
$mergeObjects: [
{
...pivot.columns.reduce(
(previousValue, currentValue) => {
previousValue[currentValue] = null;
return previousValue;
},
{}
),
},
'$data',
],
},
},
});
pipeline.push({
$replaceRoot: {
newRoot: '$result',
},
});
}
/**
*
* @param {string} str
* @returns {boolean}
*/
function isNumeric(str) {
if (typeof str != 'string') {
return false;
}
return !isNaN(str) && !isNaN(parseFloat(str));
}
/**
*
* @param {'pivot'|'unpivot'} type
* @param {string} inputString
* @returns {{columns: (*|*[]), for: (*|string), fields: *[]}}
*/
function createJSONFromPivotString(type, inputString) {
// Split the string by '|'
const parts = inputString.split('|');
const formatErrorMessage = `The ${type} operation had the wrong format`;
// Extract the field from the pivot function
let pivotPart = parts[1];
let fieldMatch;
if (type === 'pivot') {
fieldMatch = pivotPart.match(/pivot\(\[(.*?)\]/);
} else {
fieldMatch = pivotPart.match(/unpivot\((.*?),/);
}
if (!fieldMatch || !fieldMatch[1] || !fieldMatch[1].trim()) {
throw new Error(formatErrorMessage);
}
const fields = fieldMatch
? fieldMatch[1]
.split(',')
.map((f) => f.trim())
.filter(Boolean)
: [];
if (!fields.length) {
throw new Error(formatErrorMessage);
}
pivotPart = pivotPart.replace(fieldMatch[0], '');
if (type === 'unpivot') {
pivotPart = ',' + pivotPart;
}
// Extract the 'for' part
const forMatch = pivotPart.match(/,(.*?),\[/);
const forPart = forMatch ? forMatch[1].trim() : '';
if (!forPart) {
throw new Error(formatErrorMessage);
}
pivotPart = pivotPart.replace(forMatch[0], '');
// Extract the columns
const columnsMatch = pivotPart.match(/(.*?)\]/);
const columns = columnsMatch
? columnsMatch[1]
.split(',')
.map((c) => c.trim())
.filter(Boolean)
: [];
if (!columns.length) {
throw new Error(formatErrorMessage);
}
// Construct the JSON object
return {
fields: fields,
for: forPart,
columns: columns,
};
}
/**
*
* @param {string} pivotString
* @param {import('../types').PipelineFn[]} pipeline
* @param {import('../types').PipelineFn} projection
* @param {import('../types').NoqlContext} context
*/
function applyUnpivot(pivotString, pipeline, projection, context) {
const unpivot = createJSONFromPivotString('unpivot', pivotString);
const columnsToExclude = [unpivot.for, '_id']
.concat(unpivot.columns)
.concat(unpivot.fields);
const columns = Object.keys(projection.$project).filter(
(val) => columnsToExclude.indexOf(val) === -1
);
pipeline.push({
$project: {
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = `$${currentValue}`;
return previousValue;
}, {}),
fields: {
$objectToArray: '$$ROOT',
},
},
});
pipeline.push({
$project: {
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = 1;
return previousValue;
}, {}),
fields: {
$filter: {
input: '$fields',
as: 'field',
cond: {
$and: columns
.map((c) => ({$ne: ['$$field.k', c]}))
.concat([
{
$or: unpivot.columns.map((c) => ({
$eq: ['$$field.k', c],
})),
},
]),
},
},
},
},
});
pipeline.push({
$unwind: '$fields',
});
pipeline.push({
$project: {
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = 1;
return previousValue;
}, {}),
[unpivot.for]: '$fields.k',
[unpivot.fields[0]]: '$fields.v', // Assuming 'Orders' should be numeric
},
});
}
/**
*
* @param {string[]} pivotStrings
* @param pivotString
* @param {import('../types').PipelineFn[]} pipeline
* @param {import('../types').PipelineFn} projection
* @param {import('../types').NoqlContext} context
*/
function applyMultipleUnpivots(pivotStrings, pipeline, projection, context) {
const unpivots = pivotStrings.map((s) =>
createJSONFromPivotString('unpivot', s)
);
const unpivot = unpivots.reduce(
(previousValue, currentValue) => {
previousValue.for.push(currentValue.for);
previousValue.fields.push(...currentValue.fields);
previousValue.columns.push(...currentValue.columns);
return previousValue;
},
{fields: [], for: [], columns: []}
);
const columnsToExclude = [...unpivot.for, '_id']
.concat(unpivot.columns)
.concat(unpivot.fields);
const columns = Object.entries(projection.$project)
.filter(
([key, expression]) =>
columnsToExclude.indexOf(key) === -1 &&
expression.indexOf('.') > 0
)
.map(([key]) => key);
pipeline.push({
$project: {
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = `$${currentValue}`;
return previousValue;
}, {}),
fields: {
$objectToArray: '$$ROOT',
},
},
});
pipeline.push({
$project: {
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = 1;
return previousValue;
}, {}),
fields: {
$filter: {
input: '$fields',
as: 'field',
cond: {
$and: columns
.map((c) => ({$ne: ['$$field.k', c]}))
.concat([
{
$or: unpivot.columns.map((c) => ({
$eq: ['$$field.k', c],
})),
},
]),
},
},
},
},
});
pipeline.push({
$unwind: '$fields',
});
const columnProjection = {
$project: {
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = 1;
return previousValue;
}, {}),
},
};
for (const unpivotOp of unpivots) {
columnProjection.$project[unpivotOp.fields[0]] = {
$cond: {
if: {
$or: unpivotOp.columns.map((c) => ({
$eq: ['$fields.k', c],
})),
},
then: '$fields.v',
else: '$$REMOVE',
},
};
}
pipeline.push(columnProjection);
pipeline.push({
$group: {
_id: columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = `$${currentValue}`;
return previousValue;
}, {}),
...unpivots.reduce((previousValue, currentValue) => {
previousValue[currentValue.for] = {
$push: `$${currentValue.fields[0]}`,
};
return previousValue;
}, {}),
},
});
pipeline.push({
$unwind: {
path: `$${unpivot.for[0]}`,
includeArrayIndex: '__unwindIndex',
preserveNullAndEmptyArrays: false,
},
});
const restOfFor = unpivot.for.splice(1);
const restOfFields = unpivot.fields.splice(1);
pipeline.push({
$project: {
_id: 0,
...columns.reduce((previousValue, currentValue) => {
previousValue[currentValue] = `$_id.${currentValue}`;
return previousValue;
}, {}),
[unpivot.fields[0]]: `$${unpivot.for[0]}`,
...restOfFields.reduce(
(previousValue, currentValue, currentIndex) => {
previousValue[currentValue] = {
$arrayElemAt: [
`$${restOfFor[currentIndex]}`,
'$__unwindIndex',
],
};
return previousValue;
},
{}
),
},
});
console.log('');
/**
*
*/
}