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