Real-world recursion: MongoDB nested query clause addition and removal
See the code at ./src/add-or-clause.js.
The parameters are query and orClause.
query is a MongoDB query which might or might not already contain an $or and/or $and clause.
orClause is an object containing and $or clause (it's a fully-fledged MongoDB query in its own right) eg.
const orClause = {
$or: [
{createdAt: {$exists: false}},
{createdAt: someDate}
]
};There is initially just 1 thing to look out for:
- the query does not contain an $or clause
- the query contains an $or clause
If there is no $or clause, we can simply spread our orClause query and the query parameter, ie.
const newQuery = {
...query,
...orClause
};That is unless there's and $and in there somewhere, in which case we want to add our orClause to the $and:
const newQuery = {
...query,
$and: [...query.$and, orClause]
};If there is an $or clause, we can't just overwrite it, we need to $and the two $or queries.
We should also keep existing $and clause contents which yields:
const newQuery = {
...queryWithoutOrRemoved,
$and: [
...(query.$and || []),
{ $or: query.$or },
orClause
]
};In this case we're creating a function that takes 2 parameters: query (MongoDB query as above) and fieldName (name of the field we want to remove references to).
The simplest thing to do is remove references to the field at the top-level of the object.
We can create a simple omit function using destructuring and recursion
const omit = (obj, [field, ...nextFields]) => {
const {[field]: ignore, ...rest} = obj;
return nextFields.length > 0 ? omit(rest, nextFields) : rest;
};And use it:
const newQuery = omit(query, [fieldName]);To remove fields in an $or clause (which is a fully-fledged query) is as simple as taking the $or value (which is an array) and running a recursion of the function onto it.
This will remove fields at the top-level of the $or sub-queries and in nest $or fields' sub-queries.
We want to make sure to remove empty $or sub-queries, since { $or: [ { }, {} ]} is an invalid query.
We default the query's $or to an empty array and check length before spreading it back into the newQuery. This is because { $or: [] } is an invalid query.
We're also careful to remove the top-level $or when spreading filteredTopLevel so that if the new $or is an empty array, the old $or is ommitted.
function removeFieldReferences (query, fieldName) {
const filteredTopLevel = omit(query, [fieldName]);
const newOr = (filteredTopLevel.$or || [])
.map(q => removeFieldReferences(q, fieldName))
.filter(q => Object.keys(q).length > 0);
return {
...omit(filteredTopLevel, ['$or']),
...(newOr.length > 0 ? {$or: newOr} : {})
};
}The rationale for the $and solution is the same as for the $or solution.
We recurse and check that we're not generating an invalid query by omitting empty arrays and objects:
function removeFieldReferences (query, fieldName) {
const filteredTopLevel = omit(query, [fieldName]);
const newAnd = (filteredTopLevel.$and || [])
.map(q => removeFieldReferences(q, fieldName))
.filter(q => Object.keys(q).length > 0);
return {
...omit(filteredTopLevel, ['$and']),
...(newAnd.length > 0 ? {$and: newAnd} : {})
};
}The actual implementation has a maxDepth 3rd parameter defaulted to 5.
When maxDepth is equal to 0, we return the query without any treatment (arguably we should run the top-level filter).
On recursive calls to removeFieldReferences we pass (q, fieldName, maxDepth - 1) so that we're not going any deeper than we need to by accident.
This avoids RangeError: Maximum call stack size exceeded.
Tests are in .test.js files co-located with the modules they're testing.
See ./src/add-or-clause.test.js and ./src/remove-field-references.test.js