@synatic/noql
Version:
Convert SQL statements to mongo queries or aggregates
518 lines (480 loc) • 18.7 kB
JavaScript
const $copy = require('clone-deep');
const $json = require('@synatic/json-magic');
const $hash = require('object-hash');
const projectIsRoot = require('./projectIsRoot');
const projectIsSimple = require('./projectIsSimple');
const lodash = require('lodash');
const arraySequenceIndexOf = require('./arraySequenceIndexOf');
const $check = require('check-types');
/**
* Extracts and returns the names of the stages in a MongoDB aggregation pipeline.
* @param {Array<object>} mongoAggregate - An array representing the MongoDB aggregation pipeline,
* where each element is an object containing a stage name and its definition.
* @returns {Array<string>} An array of stage names extracted from the pipeline.
*/
function _getStageNames(mongoAggregate) {
return mongoAggregate.map((stage) => Object.keys(stage)[0]);
}
/**
* Modifies a MongoDB aggregation pipeline by identifying and replacing stages that match a given pattern.
* @param {Array} mongoAggregate - The original MongoDB aggregation pipeline, represented as an array of stages.
* @param {Array} pattern - An array representing the sequence of stage names to be matched within the pipeline.
* @param {Function} fixFunction - A callback function that receives the matched stages as input and returns the replacement stages.
* @param {object} [options] - Optional settings for the function.
* @param {boolean} [options.copy] - Whether to create a copy of the pipeline before applying changes. Defaults to false.
* @returns {Array} - A new MongoDB aggregation pipeline with the matched stages replaced, or the original pipeline if no matches were found.
*/
function _patternFixer(mongoAggregate, pattern, fixFunction, options = {}) {
let newAggregate = options.copy ? $copy(mongoAggregate) : mongoAggregate;
let sequenceIndex = arraySequenceIndexOf(
pattern,
_getStageNames(newAggregate),
0,
(a, b) => a === b
);
// just in case something goes awry
let iterations = 0;
while (sequenceIndex > -1 && iterations < 50) {
iterations++;
const fixedValues = fixFunction(
newAggregate.slice(sequenceIndex, sequenceIndex + pattern.length)
);
if (fixedValues && fixedValues.length > 0) {
newAggregate = (
sequenceIndex > 0 ? newAggregate.slice(0, sequenceIndex) : []
)
.concat(fixedValues)
.concat(newAggregate.slice(sequenceIndex + pattern.length));
sequenceIndex = arraySequenceIndexOf(
pattern,
_getStageNames(newAggregate),
0,
(a, b) => a === b
);
} else {
sequenceIndex = arraySequenceIndexOf(
pattern,
_getStageNames(newAggregate),
sequenceIndex + 1,
(a, b) => a === b
);
}
}
return newAggregate;
}
/**
* Modifies the properties and values within an object by updating keys and references
* based on a given reference key.
* @param {object} value - The object whose keys and values will be modified.
* @param {string} referenceKey - The reference string used to identify and update
* keys and values within the object.
* @returns {object} - A new object with updated keys and values according to the
* specified reference key.
*/
function _changeReference(value, referenceKey) {
return _changeReferenceValue(
_changeReferenceKey(value, referenceKey),
referenceKey
);
}
function _changeReferenceKey(value, referenceKey) {
let newVal = $copy(value);
newVal = $json.renameKey(newVal, (key) => {
if (key.startsWith && key.startsWith(referenceKey + '.')) {
return key.substring(referenceKey.length + 1);
} else {
return key;
}
});
return newVal;
}
function _changeReferenceValue(value, referenceKey) {
let newVal = $copy(value);
newVal = $json.changeValue(newVal, (value) => {
if (
value &&
value.startsWith &&
value.startsWith('$' + referenceKey + '.')
) {
return '$' + value.substring(referenceKey.length + 2);
} else {
return value;
}
});
return newVal;
}
// pattern match and fix functions
const _patternsToFix = [
{
name: 'removeUnneededProjectRootProject',
description:
'Removes root project when followed by a project since setting it to root then using the project is unneeded',
pattern: ['$project', '$project'],
fixerFn: (stages) => {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
if (projectIsRoot(stages[0]) && !projectIsRoot(stages[1])) {
const project = stages[1]['$project'];
const newProject = _changeReferenceValue(
project,
testProject1Key
);
return [{$project: newProject}];
} else {
return null;
}
},
},
{
name: 'removeUnneededProjectRootSetBack',
description:
'Removes redundant project when start and end project are set roots.',
pattern: ['$project', '$project', '$project'],
fixerFn: (stages) => {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
const testProject2 = stages[2]['$project'];
if (
projectIsRoot(stages[0]) &&
!projectIsRoot(stages[1]) &&
projectIsRoot(stages[2])
) {
const project = stages[1]['$project'];
const newProject = _changeReferenceValue(
project,
testProject1Key
);
return [{$project: newProject}, {$project: testProject2}];
} else {
return null;
}
},
},
{
name: 'removeUnneededProjectRootWithMatch',
description: 'Removes redundant project preceeding a match',
pattern: ['$project', '$match', '$project', '$project', '$project'],
fixerFn: (stages) => {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
const lastProject = stages[4]['$project'];
if (projectIsRoot(stages[0]) && projectIsRoot(stages[4])) {
const newMatch = _changeReference(
stages[1]['$match'],
testProject1Key
);
const newProject1 = _changeReferenceValue(
stages[2]['$project'],
testProject1Key
);
return [
{$match: newMatch},
{$project: newProject1},
stages[3],
{$project: lastProject},
];
} else {
return null;
}
},
},
{
name: 'removeUnneededProjectWithGroup',
description: 'Removes redundant projects with groups',
pattern: ['$project', '$match', '$project', '$project', '$group'],
fixerFn: (stages) => {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
const testProject2Key = Object.keys(stages[3]['$project'])[0];
if (projectIsRoot(stages[0]) && projectIsRoot(stages[3])) {
const newMatch = _changeReference(
stages[1]['$match'],
testProject1Key
);
const newProject = _changeReferenceValue(
stages[2]['$project'],
testProject1Key
);
const newGroup = _changeReferenceValue(
stages[4]['$group'],
testProject2Key
);
return [
{$match: newMatch},
{$project: newProject},
{$group: newGroup},
];
} else {
return null;
}
},
},
{
name: 'removeUnneededProjectRootWithGroup',
description: 'Removes redundant root project before a group',
pattern: ['$project', '$group'],
fixerFn: (stages) => {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
if (projectIsRoot(stages[0])) {
const newGroup = _changeReferenceValue(
stages[1]['$group'],
testProject1Key
);
return [{$group: newGroup}];
} else {
return null;
}
},
},
{
name: 'removeUnneededProjectRootWithSort',
description: 'Removes redundant project root stage before sort',
pattern: ['$project', '$sort', '$project'],
fixerFn: (stages) => {
if (projectIsRoot(stages[0])) {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
const newSort = _changeReferenceKey(
stages[1]['$sort'],
testProject1Key
);
const newProject = _changeReferenceValue(
stages[2]['$project'],
testProject1Key
);
return [{$sort: newSort}, {$project: newProject}];
} else {
return null;
}
},
},
{
name: 'removeDuplicateProjects',
description: 'Removes redundant projects when theyre both simple',
pattern: ['$project', '$project'],
fixerFn: (stages) => {
if (projectIsSimple(stages[1])) {
const project1 = stages[0]['$project'];
const project2 = stages[1]['$project'];
const project2Keys = Object.keys(project2);
const newProject = {};
for (const project2Key of project2Keys) {
const project2Val = project2[project2Key];
const project2KeyVal =
project2Val.startsWith && project2Val.startsWith('$')
? project2Val.substring(1)
: null;
// if it's still not a proper key, exit
if (!project2KeyVal || !project1[project2KeyVal]) {
return null;
}
newProject[project2Key] = project1[project2KeyVal];
}
return [{$project: newProject}];
} else {
return null;
}
},
},
{
name: 'removeDuplicateProjectsWithSort',
description: 'Removes redundant projects when theyre both simple',
pattern: ['$project', '$sort', '$project'],
fixerFn: (stages) => {
if (lodash.isEqual(stages[0]['$project'], stages[2]['$project'])) {
return [stages[0], stages[1]];
} else {
return null;
}
},
},
{
name: 'removeDuplicateProjectsWithMatch',
description: 'Removes redundant projects when theyre both simple',
pattern: ['$project', '$match', '$project'],
fixerFn: (stages) => {
if (lodash.isEqual(stages[0]['$project'], stages[2]['$project'])) {
return [stages[0], stages[1]];
} else {
return null;
}
},
},
{
name: 'removeUnneededProjectBeforeGroup',
description: 'Removes redundant project before group if its simple',
pattern: ['$project', '$group'],
fixerFn: (stages) => {
if (projectIsSimple(stages[0])) {
const project = stages[0]['$project'];
const group = $copy(stages[1]['$group']);
const newGroup = $json.changeValue(group, (value) => {
if (value?.startsWith && value.startsWith('$')) {
const valKey = value.substring(1);
if (!project[valKey]) {
return null;
}
return project[valKey];
} else {
return value;
}
});
return [{$group: newGroup}];
} else {
return null;
}
},
},
{
name: 'removeRedundantProjectRootBeforeMatch',
description: 'Removes redundant root before group if its simple',
pattern: ['$project', '$match', '$project'],
fixerFn: (stages) => {
if (projectIsRoot(stages[0]) && projectIsSimple(stages[2])) {
const testProject1Key = Object.keys(stages[0]['$project'])[0];
const newMatch = _changeReference(
stages[1]['$match'],
testProject1Key
);
const newProject = _changeReferenceValue(
stages[2]['$project'],
testProject1Key
);
return [{$match: newMatch}, {$project: newProject}];
} else {
return null;
}
},
},
{
name: 'switchSortMatch',
description: 'Always switch adjacent sort and match stages',
pattern: ['$sort', '$match'],
fixerFn: (stages) => {
return [stages[1], stages[0]];
},
},
];
/**
* Determines whether the provided object value satisfies a "simple match" condition based on the given prefix.
* @param {any} objVal - The object or value to evaluate. It can be an object, array, string, or other types.
* @param {string} prefix - The prefix string used for validating keys or string values in the object.
* @returns {boolean} Returns true if the object or value matches the "simple match" criteria with the prefix, otherwise false.
*/
function _matchPieceIsSimple(objVal, prefix) {
if ($check.object(objVal)) {
let isSimple = false;
for (const objKey of Object.keys(objVal)) {
if (!objKey.startsWith('$')) {
if (!objKey.startsWith(prefix)) {
return false;
} else {
isSimple =
isSimple || _matchPieceIsSimple(objVal[objKey], prefix);
}
} else {
isSimple =
isSimple || _matchPieceIsSimple(objVal[objKey], prefix);
}
}
return isSimple;
} else if ($check.array(objVal)) {
for (const obj of objVal) {
if (!_matchPieceIsSimple(obj, prefix)) {
return false;
}
}
return true;
} else if ($check.string(objVal)) {
return objVal.startsWith('$') ? objVal.startsWith('$' + prefix) : true;
} else {
return true;
}
}
/**
* Determines if a given match stage in a pipeline is simple.
* @param {object} stage - The pipeline stage to evaluate.
* @param {string} prefix - The prefix to use when processing the match object.
* @returns {boolean} Returns true if the match stage is simple, false otherwise.
*/
function _matchIsSimple(stage, prefix) {
if (!stage) {
return false;
}
if (!stage['$match']) {
return false;
}
const match = stage['$match'];
return _matchPieceIsSimple(match, prefix + '.');
}
/**
* Adjusts the order and reference of the `$match` and `$project` stages in a MongoDB aggregation pipeline.
* Used when the where is further down the pipeline stack.
* Ensures that the pipeline maintains proper structure when specific `$match` and `$project` stages are present.
* @param {Array} mongoAggregate - The MongoDB aggregation pipeline to be modified.
* @returns {Array} The modified MongoDB aggregation pipeline with corrected stage order and references.
*/
function _fixEndWhere(mongoAggregate) {
const stages = _getStageNames(mongoAggregate);
const firstStage = mongoAggregate[0];
if (projectIsRoot(firstStage) && stages.includes('$match')) {
const projectRootField = Object.keys(firstStage['$project'])[0];
let lastMatch = null;
let lastMatchIndex = -1;
for (let i = 1; i < mongoAggregate.length; i++) {
const stage = mongoAggregate[i];
if (stage['$project']) {
break;
}
if (stage['$match'] && _matchIsSimple(stage, projectRootField)) {
lastMatch = stage;
lastMatchIndex = i;
break;
}
}
if (lastMatch) {
mongoAggregate.splice(lastMatchIndex, 1);
mongoAggregate.unshift(
_changeReference(lastMatch, projectRootField)
);
}
}
return mongoAggregate;
}
/**
* Optimizes a given MongoDB aggregation pipeline by repeatedly applying transformation rules
* to remove redundant or unneeded operations for improved performance.
* @param {Array} mongoAggregate - The original MongoDB aggregation pipeline to be optimized.
* @param {object} [options] - Optional configuration settings.
* @param {number} [options.iterations] - Maximum number of optimization iterations to perform.
* @returns {Array} - The optimized MongoDB aggregation pipeline.
*/
function optimizeMongoAggregate(mongoAggregate, options = {}) {
let newAggregate = $copy(mongoAggregate);
let lastHash = '';
let iteration = options.iterations || 10;
while (iteration > 0 && lastHash !== $hash(newAggregate)) {
lastHash = $hash(newAggregate);
for (const pipelineStage of newAggregate) {
if (
pipelineStage.$lookup &&
pipelineStage.$lookup.pipeline &&
pipelineStage.$lookup.pipeline.length > 0
) {
pipelineStage.$lookup.pipeline = optimizeMongoAggregate(
pipelineStage.$lookup.pipeline,
options
);
}
}
for (const pattern of _patternsToFix) {
newAggregate = _patternFixer(
newAggregate,
pattern.pattern,
pattern.fixerFn,
{copy: false}
);
}
newAggregate = _fixEndWhere(newAggregate);
iteration--;
}
return newAggregate;
}
module.exports = {
optimizeMongoAggregate: optimizeMongoAggregate,
};