apostrophe
Version:
The Apostrophe Content Management System.
210 lines (193 loc) • 6.87 kB
JavaScript
// NOTE: This is a universal library for evaluating MongoDB-style conditions
// against a document or sub-document. Do not use any browser or node specific
// APIs here.
//
//
// Evaluate a field conditions against current schema values (doc or sub-doc).
// Expects a `conditions` object containing MongoDB-style conditions.
// Operators `$or` and `$and` are supported as top-level keys to allow
// for complex logical conditions. All comparison MongoDB operators are supported
// as object values.
// The condition object properties are field paths (dot notation is supported
// for objects), and the values are the conditions to evaluate against the
// document values.
// The `voterFn` function can be provided for evaluating
// conditions - return `false` to stop further evaluation.
// The function is called with three arguments:
// `voterFn(propName, condition, docValue)` where `propName` is the condition field path,
// `condition` is the condition value, and `docValue` is the field value extracted
// from the document.
// Example usage:
// ```js
// doc = {
// assignee: 'john',
// status: 'active',
// stats: { count: 15 },
// category: 'news'
// };
// const conditions = {
// assignee: { '$exists': true },
// status: { '$in': ['active', 'pending'] },
// 'stats.count': { '$gte': 10 },
// $or: [
// { 'category': 'news' },
// { 'category': 'blog' }
// ]
// };
// function voterFn(propName, condition, docValue) {
// if (propName === 'status' && docValue === 'inactive') {
// return false; // Reject the condition for 'status' if it's 'inactive'
// }
// // Non-boolean return values are ignored
// }
// const result = checkIfConditions(doc, conditions, voterFn);
// ```
export default function checkIfConditions(doc, conditions, voterFn = () => null) {
return Object.entries(conditions).every(([ key, condition ]) => {
if (key === '$or') {
return checkOrConditions(doc, condition, voterFn);
}
if (key === '$and') {
return checkAndConditions(doc, condition, voterFn);
}
const docValue = getNestedPropValue(doc, key);
// If the custom voter rejects the condition, we stop evaluating
// and return false immediately.
if (voterFn(key, condition, docValue) === false) {
return false;
}
// External conditions should be handled outside of this function,
// so we skip them here. Use the `voterFn` to gather external condition
// entries and evaluate them separately.
if (isExternalCondition(key)) {
return true;
}
// Otherwise, we evaluate the condition against the document value.
return evaluate(docValue, condition);
});
}
export function isExternalCondition(conditionKey) {
return conditionKey.endsWith(')');
}
function checkOrConditions(doc, conditions, voterFn) {
return conditions.some((condition) => {
return checkIfConditions(doc, condition, voterFn);
});
}
function checkAndConditions(doc, conditions, voterFn) {
return conditions.every((condition) => {
return checkIfConditions(doc, condition, voterFn);
});
}
function getNestedPropValue(doc, key) {
if (!key.includes('.')) {
return doc?.[key];
}
const keys = key.split('.');
let currentValue = doc;
while (keys.length > 0) {
if (
// The `==` comparison is intentionally used here to match
// both `null` and `undefined`
currentValue == null ||
// Support i.e. `stringField.length`
// eslint-disable-next-line valid-typeof
typeof currentValue?.[keys[0]] == null
) {
return undefined;
}
if (Array.isArray(currentValue)) {
// Support i.e. `arrayField.length` or `arrayField.0`
if (!Object.hasOwn(currentValue, keys[0])) {
return currentValue.flatMap(item => {
return getNestedPropValue(item, keys.join('.'));
});
}
}
const prop = keys.shift();
currentValue = currentValue[prop];
}
return currentValue;
}
// Comparison operators registry for MongoDB-style conditions.
// https://www.mongodb.com/docs/manual/reference/operator/query-comparison/
const opRegistry = {};
opRegistry.$eq = (docValue, conditionValue) => {
if (Array.isArray(docValue)) {
if (Array.isArray(conditionValue)) {
// Unlike MongoDB, we don't match the index order of the arrays.
return docValue.length === conditionValue.length &&
conditionValue.every(value => docValue.includes(value));
}
return docValue.includes(conditionValue);
}
return docValue === conditionValue;
};
opRegistry.$ne = (docValue, conditionValue) => (
opRegistry.$eq(docValue, conditionValue) === false
);
opRegistry.$exists = (docValue, conditionValue) => {
// Per MongoDB documentation, $exists should treat null and undefined the same.
// == null and != null are documented to match or reject null, undefined
// and nothing else.
return (conditionValue ? docValue != null : docValue == null);
};
opRegistry.$in = (docValue, conditionValue) => {
if (!Array.isArray(conditionValue)) {
throw new Error('$in and $nin operators require an array as condition value');
}
if (Array.isArray(docValue)) {
return conditionValue.some(value => docValue.includes(value));
}
return conditionValue.includes(docValue);
};
opRegistry.$nin = (docValue, conditionValue) => (
opRegistry.$in(docValue, conditionValue) === false
);
opRegistry.$gt = (docValue, conditionValue) => (
docValue > conditionValue
);
opRegistry.$lt = (docValue, conditionValue) => (
docValue < conditionValue
);
opRegistry.$gte = (docValue, conditionValue) => (
docValue >= conditionValue
);
opRegistry.$lte = (docValue, conditionValue) => (
docValue <= conditionValue
);
export function evaluate(docValue, conditionValue) {
if (
typeof conditionValue === 'object' &&
conditionValue !== null
) {
// Empty objects are not valid conditions
if (Object.keys(conditionValue).length === 0) {
return false;
}
// Evaluate every operator in the conditionValue object.
// A MongoDB-style condition object is expected.
// We check for the presence of known comparison operators and
// evaluate them accordingly.
return Object.entries(conditionValue).every(([ operator, operand ]) => {
switch (operator) {
// BC, support the min and max properties because they were already supported
// in a different routine.
case 'min':
return docValue >= operand;
case 'max':
return docValue <= operand;
default: {
if (opRegistry[operator]) {
return opRegistry[operator](docValue, operand);
}
throw new Error(`Unsupported operator: ${operator}`);
}
}
});
}
if (Array.isArray(docValue)) {
return docValue.includes(conditionValue);
}
return conditionValue === docValue;
}