Refactor database utility functions and enhance document job schema
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
- Reformatted the `NOTIFICATION_EXCLUDED_MODELS` array for improved readability in `utils.js`. - Updated the `editNotification` and `createNotification` functions for better code clarity by breaking long lines. - Added a new `quantity` field to the `documentJobSchema`, ensuring it is required with a default value of 1 and a minimum of 1. - Introduced new filtering capabilities in `updatemanager.js` to handle complex filter conditions, enhancing the overall filtering logic. - Updated tests to mock new filter functionalities, ensuring proper integration and functionality verification.
This commit is contained in:
parent
f5e74ac471
commit
e563538e89
922
src/database/filter.js
Normal file
922
src/database/filter.js
Normal file
@ -0,0 +1,922 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import { loadConfig } from '../config.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const logger = log4js.getLogger('Filter');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
let modelsCache = null;
|
||||||
|
|
||||||
|
async function getModels() {
|
||||||
|
if (!modelsCache) {
|
||||||
|
modelsCache = (await import('./schemas/models.js')).models;
|
||||||
|
}
|
||||||
|
return modelsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelsSync() {
|
||||||
|
return modelsCache || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelByName(name) {
|
||||||
|
return (
|
||||||
|
Object.values(getModelsSync()).find(entry => entry.type === name) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelByPrefix(prefix) {
|
||||||
|
return getModelsSync()[prefix] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObjectTypeModel(objectType) {
|
||||||
|
const models = await getModels();
|
||||||
|
const entry = Object.values(models).find(
|
||||||
|
item => item.type === objectType || item.model?.modelName === objectType
|
||||||
|
);
|
||||||
|
return entry?.model ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter expression parsing (Microsoft Dynamics NAV / Business Central style)
|
||||||
|
//
|
||||||
|
// Supported syntax (all compiled down to MongoDB query operators):
|
||||||
|
// = equal to 377 -> { field: 377 }
|
||||||
|
// <> not equal to <>0 -> { field: { $ne: 0 } }
|
||||||
|
// > greater than >1200 -> { field: { $gt: 1200 } }
|
||||||
|
// >= greater than or equal >=1200 -> { field: { $gte: 1200 } }
|
||||||
|
// < less than <1200 -> { field: { $lt: 1200 } }
|
||||||
|
// <= less than or equal <=1200 -> { field: { $lte: 1200 } }
|
||||||
|
// .. interval 1100..2100 -> { field: { $gte: 1100, $lte: 2100 } }
|
||||||
|
// open-ended intervals ..2500 / 23..
|
||||||
|
// | either / or 1200|1300 -> { field: { $in: [1200, 1300] } }
|
||||||
|
// & and <2000&>1000 -> { field: { $gt: 1000, $lt: 2000 } }
|
||||||
|
// ( ) grouping / precedence 30|(>=10&<=20)
|
||||||
|
// * any number of characters Co* / *Co / *Co*
|
||||||
|
// ? a single character Hans?n
|
||||||
|
// @ ignore case @location (text matching is case-insensitive)
|
||||||
|
//
|
||||||
|
// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare
|
||||||
|
// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the
|
||||||
|
// current month/year, "22..23" => start of 22 through end of 23.
|
||||||
|
//
|
||||||
|
// Note: BC's space-delimited date shorthand is locale dependent; here numeric
|
||||||
|
// date operands are read day-first ("D M Y H Min S") with missing components
|
||||||
|
// filled from the current date and the interval boundary.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildWildcardRegexPattern(input) {
|
||||||
|
// Escape all regex special chars except * and ? (which we treat as wildcards)
|
||||||
|
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||||
|
// * matches any run of characters, ? matches exactly one character
|
||||||
|
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
// Anchor so that, without wildcards, this is an exact match
|
||||||
|
return `^${withWildcards}$`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeDateField(property) {
|
||||||
|
const last = String(property).split('.').pop();
|
||||||
|
return (
|
||||||
|
/[a-z](?:At|Date|Time)$/.test(last) ||
|
||||||
|
/^(?:date|time|datetime)$/i.test(last)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_MATCH_CONDITION = { op: { $in: [] } };
|
||||||
|
|
||||||
|
function isBooleanField(property, model = null) {
|
||||||
|
const schema = model?.schema;
|
||||||
|
if (schema) {
|
||||||
|
const path = schema.path(property);
|
||||||
|
return path?.instance === 'Boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (const entry of Object.values(getModelsSync())) {
|
||||||
|
const path = entry.model?.schema?.path(property);
|
||||||
|
if (!path) continue;
|
||||||
|
found = true;
|
||||||
|
if (path.instance !== 'Boolean') return false;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectIdPath(property) {
|
||||||
|
return property === '_id' || property.endsWith('._id');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseProperty(property) {
|
||||||
|
if (property === '_id') return '_id';
|
||||||
|
if (property.endsWith('._id')) return property.slice(0, -4);
|
||||||
|
return property;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmbeddedSchemaType(path) {
|
||||||
|
if (!path) return null;
|
||||||
|
return (
|
||||||
|
path.embeddedSchemaType ?? path.$embeddedSchemaType ?? path.caster ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectIdSchemaTypeFromPath(path) {
|
||||||
|
if (!path) return null;
|
||||||
|
const schemaType =
|
||||||
|
path.instance === 'Array' ? getEmbeddedSchemaType(path) : path;
|
||||||
|
if (!schemaType) return null;
|
||||||
|
if (
|
||||||
|
schemaType.instance === 'ObjectId' ||
|
||||||
|
schemaType.instance === 'ObjectID'
|
||||||
|
) {
|
||||||
|
return schemaType;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchemaPathFromModels(property, model = null) {
|
||||||
|
if (model?.schema?.path(property)) return model.schema.path(property);
|
||||||
|
for (const entry of Object.values(getModelsSync())) {
|
||||||
|
const path = entry.model?.schema?.path(property);
|
||||||
|
if (path) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterFieldKind(property, model = null) {
|
||||||
|
const baseProperty = getBaseProperty(property);
|
||||||
|
|
||||||
|
const inspectPath = path => {
|
||||||
|
if (!path) return null;
|
||||||
|
|
||||||
|
const objectIdType = getObjectIdSchemaTypeFromPath(path);
|
||||||
|
if (isObjectIdPath(property) || objectIdType) {
|
||||||
|
const ref = objectIdType?.options?.ref ?? path.options?.ref;
|
||||||
|
if (property.endsWith('._id') && property !== '_id' && ref) {
|
||||||
|
return { kind: 'objectId', property: baseProperty };
|
||||||
|
}
|
||||||
|
if (ref) {
|
||||||
|
return { kind: 'objectRef', property: baseProperty, ref };
|
||||||
|
}
|
||||||
|
return { kind: 'objectId', property: baseProperty };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'default', property: baseProperty };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
return (
|
||||||
|
inspectPath(model.schema?.path(baseProperty)) || {
|
||||||
|
kind: 'default',
|
||||||
|
property: baseProperty
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getSchemaPathFromModels(baseProperty);
|
||||||
|
const kind = inspectPath(path);
|
||||||
|
if (kind) return kind;
|
||||||
|
|
||||||
|
return { kind: 'default', property: baseProperty };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrefixedValue(value) {
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
if (trimmed.length > 4 && trimmed.charAt(3) === ':') {
|
||||||
|
return {
|
||||||
|
prefix: trimmed.slice(0, 3).toUpperCase(),
|
||||||
|
suffix: trimmed.slice(4).trim(),
|
||||||
|
hadPrefix: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { prefix: null, suffix: trimmed, hadPrefix: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegexOp(pattern, useOptions = true) {
|
||||||
|
const op = { $regex: pattern };
|
||||||
|
if (useOptions) op.$options = 'i';
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchemaRefName(property, model = null) {
|
||||||
|
const baseProperty = getBaseProperty(property);
|
||||||
|
const path =
|
||||||
|
model?.schema?.path(baseProperty) ?? getSchemaPathFromModels(baseProperty);
|
||||||
|
const embeddedType = getEmbeddedSchemaType(path);
|
||||||
|
return path?.options?.ref ?? embeddedType?.options?.ref ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefModelEntryFromSchemaRef(refName) {
|
||||||
|
if (!refName) return null;
|
||||||
|
|
||||||
|
let refModel;
|
||||||
|
try {
|
||||||
|
refModel = mongoose.model(refName);
|
||||||
|
} catch {
|
||||||
|
refModel = getModelByName(refName)?.model ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refModel) return null;
|
||||||
|
|
||||||
|
const registryEntry = getModelByName(refName);
|
||||||
|
return {
|
||||||
|
model: refModel,
|
||||||
|
idField: registryEntry?.idField ?? '_id',
|
||||||
|
referenceField: registryEntry?.referenceField ?? '_reference'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefModelEntryFromPrefix(prefix) {
|
||||||
|
const entry = getModelByPrefix(prefix);
|
||||||
|
if (!entry?.model) return null;
|
||||||
|
return {
|
||||||
|
model: entry.model,
|
||||||
|
idField: entry.idField ?? '_id',
|
||||||
|
referenceField: entry.referenceField ?? '_reference'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrefixFromOperand(operand) {
|
||||||
|
const { suffix, hadPrefix } = parsePrefixedValue(String(operand).trim());
|
||||||
|
return hadPrefix ? suffix : String(operand).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefModelEntryForToken(token, fallbackRefName) {
|
||||||
|
const { prefix, hadPrefix } = parsePrefixedValue(String(token).trim());
|
||||||
|
if (hadPrefix) {
|
||||||
|
const entry = getRefModelEntryFromPrefix(prefix);
|
||||||
|
if (entry) return entry;
|
||||||
|
}
|
||||||
|
return getRefModelEntryFromSchemaRef(fallbackRefName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBooleanOperand(value) {
|
||||||
|
const lower = String(value).trim().toLowerCase();
|
||||||
|
if (lower === 'yes') return true;
|
||||||
|
if (lower === 'no') return false;
|
||||||
|
if (lower === 'true') return true;
|
||||||
|
if (lower === 'false') return false;
|
||||||
|
if (lower === '1') return true;
|
||||||
|
if (lower === '0') return false;
|
||||||
|
if (lower === 'on') return true;
|
||||||
|
if (lower === 'off') return false;
|
||||||
|
if (lower === 'y') return true;
|
||||||
|
if (lower === 'n') return false;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBooleanEquality(value) {
|
||||||
|
const bool = parseBooleanOperand(value);
|
||||||
|
if (bool === undefined) return NO_MATCH_CONDITION;
|
||||||
|
return { value: bool };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBooleanComparison(name, value) {
|
||||||
|
if (name === 'eq') return buildBooleanEquality(value);
|
||||||
|
|
||||||
|
if (name === 'ne') {
|
||||||
|
const bool = parseBooleanOperand(value);
|
||||||
|
if (bool === undefined) return NO_MATCH_CONDITION;
|
||||||
|
return { op: { $ne: bool } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return NO_MATCH_CONDITION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectIdString(value) {
|
||||||
|
return /^[a-f\d]{24}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumeric(value) {
|
||||||
|
return value.trim() !== '' && !isNaN(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeYear(year) {
|
||||||
|
if (year >= 100) return year;
|
||||||
|
return year < 70 ? 2000 + year : 1900 + year;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a single date operand to a Date, filling missing parts from the
|
||||||
|
// current date and the supplied interval boundary ('start' | 'end').
|
||||||
|
function parseDateOperand(value, boundary = 'start') {
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const end = boundary === 'end';
|
||||||
|
|
||||||
|
// Values with explicit separators (ISO and similar) are parsed directly.
|
||||||
|
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (isNaN(parsed.getTime())) return null;
|
||||||
|
if (!/[T:]/.test(text)) {
|
||||||
|
return end ? endOfDay(parsed) : startOfDay(parsed);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric, day-first form: "D [M] [Y] [H] [Min] [S]".
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
if (!parts.every(part => /^\d+$/.test(part))) return null;
|
||||||
|
const nums = parts.map(Number);
|
||||||
|
const now = new Date();
|
||||||
|
const day = nums[0];
|
||||||
|
const month = nums.length >= 2 ? nums[1] : now.getMonth() + 1;
|
||||||
|
const year = nums.length >= 3 ? normalizeYear(nums[2]) : now.getFullYear();
|
||||||
|
const hour = nums.length >= 4 ? nums[3] : end ? 23 : 0;
|
||||||
|
const minute = nums.length >= 5 ? nums[4] : end ? 59 : 0;
|
||||||
|
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0;
|
||||||
|
const ms = end ? 999 : 0;
|
||||||
|
const date = new Date(year, month - 1, day, hour, minute, second, ms);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerces a literal to its strongest matching scalar type.
|
||||||
|
function coerceScalar(value) {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (lower === 'true') return true;
|
||||||
|
if (lower === 'false') return false;
|
||||||
|
if (isObjectIdString(value)) return new mongoose.Types.ObjectId(value);
|
||||||
|
if (isNumeric(value)) return Number(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerces an interval/comparison boundary, preferring a date for date fields.
|
||||||
|
function coerceBoundary(value, isDateField, boundary) {
|
||||||
|
if (isDateField) {
|
||||||
|
const date = parseDateOperand(value, boundary);
|
||||||
|
if (date) return date;
|
||||||
|
}
|
||||||
|
return coerceScalar(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits on a separator while respecting parentheses depth.
|
||||||
|
function splitTopLevel(str, separator) {
|
||||||
|
const parts = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = '';
|
||||||
|
for (const ch of str) {
|
||||||
|
if (ch === '(') depth++;
|
||||||
|
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||||
|
|
||||||
|
if (ch === separator && depth === 0) {
|
||||||
|
parts.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(current);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a single outer pair of parentheses wraps the whole token.
|
||||||
|
function isWrappedInParens(str) {
|
||||||
|
if (!str.startsWith('(') || !str.endsWith(')')) return false;
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (str[i] === '(') depth++;
|
||||||
|
else if (str[i] === ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0 && i < str.length - 1) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a filter expression into an AST of or / and / leaf nodes.
|
||||||
|
function parseExpression(str) {
|
||||||
|
const orParts = splitTopLevel(str, '|');
|
||||||
|
if (orParts.length > 1) {
|
||||||
|
return { type: 'or', items: orParts.map(parseExpression) };
|
||||||
|
}
|
||||||
|
const andParts = splitTopLevel(str, '&');
|
||||||
|
if (andParts.length > 1) {
|
||||||
|
return { type: 'and', items: andParts.map(parseExpression) };
|
||||||
|
}
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (isWrappedInParens(trimmed)) {
|
||||||
|
return parseExpression(trimmed.slice(1, -1));
|
||||||
|
}
|
||||||
|
return { type: 'leaf', token: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strips a leading @ (ignore-case marker); text matching is already case-insensitive.
|
||||||
|
function stripIgnoreCase(str) {
|
||||||
|
return str.startsWith('@') ? str.slice(1) : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds an equality condition (value, date range, or wildcard regex).
|
||||||
|
function buildEquality(
|
||||||
|
value,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField = false,
|
||||||
|
isObjectIdField = false
|
||||||
|
) {
|
||||||
|
if (isBooleanField) return buildBooleanEquality(value);
|
||||||
|
if (isObjectIdField) {
|
||||||
|
const { suffix } = parsePrefixedValue(value);
|
||||||
|
if (/[*?]/.test(suffix)) return NO_MATCH_CONDITION;
|
||||||
|
if (isObjectIdString(suffix))
|
||||||
|
return { value: new mongoose.Types.ObjectId(suffix) };
|
||||||
|
return NO_MATCH_CONDITION;
|
||||||
|
}
|
||||||
|
if (isDateField) {
|
||||||
|
const start = parseDateOperand(value, 'start');
|
||||||
|
const end = parseDateOperand(value, 'end');
|
||||||
|
if (start && end) return { op: { $gte: start, $lte: end } };
|
||||||
|
}
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (lower === 'true') return { value: true };
|
||||||
|
if (lower === 'false') return { value: false };
|
||||||
|
if (isObjectIdString(value))
|
||||||
|
return { value: new mongoose.Types.ObjectId(value) };
|
||||||
|
if (isNumeric(value)) return { value: Number(value) };
|
||||||
|
return { op: buildRegexOp(buildWildcardRegexPattern(value)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a comparison condition for a single operator.
|
||||||
|
function buildComparison(
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField = false,
|
||||||
|
isObjectIdField = false
|
||||||
|
) {
|
||||||
|
if (isBooleanField) return buildBooleanComparison(name, value);
|
||||||
|
if (isObjectIdField) {
|
||||||
|
const { suffix } = parsePrefixedValue(value);
|
||||||
|
if (/[*?]/.test(suffix)) return NO_MATCH_CONDITION;
|
||||||
|
if (name === 'eq') return buildEquality(suffix, false, false, true);
|
||||||
|
if (name === 'ne' && isObjectIdString(suffix)) {
|
||||||
|
return { op: { $ne: new mongoose.Types.ObjectId(suffix) } };
|
||||||
|
}
|
||||||
|
return NO_MATCH_CONDITION;
|
||||||
|
}
|
||||||
|
if (name === 'eq') return buildEquality(value, isDateField);
|
||||||
|
|
||||||
|
if (name === 'ne') {
|
||||||
|
if (isDateField) {
|
||||||
|
const start = parseDateOperand(value, 'start');
|
||||||
|
const end = parseDateOperand(value, 'end');
|
||||||
|
if (start && end) return { op: { $not: { $gte: start, $lte: end } } };
|
||||||
|
}
|
||||||
|
if (/[*?]/.test(value)) {
|
||||||
|
return { op: { $not: buildRegexOp(buildWildcardRegexPattern(value)) } };
|
||||||
|
}
|
||||||
|
return { op: { $ne: coerceScalar(value) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dates, < and >= align to the start of the day, > and <= to the end.
|
||||||
|
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
|
||||||
|
return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLeafComparisonOperators(
|
||||||
|
token,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField,
|
||||||
|
isObjectIdField
|
||||||
|
) {
|
||||||
|
const operators = [
|
||||||
|
['<>', 'ne'],
|
||||||
|
['>=', 'gte'],
|
||||||
|
['<=', 'lte'],
|
||||||
|
['>', 'gt'],
|
||||||
|
['<', 'lt'],
|
||||||
|
['=', 'eq']
|
||||||
|
];
|
||||||
|
for (const [symbol, name] of operators) {
|
||||||
|
if (token.startsWith(symbol)) {
|
||||||
|
return buildComparison(
|
||||||
|
name,
|
||||||
|
stripIgnoreCase(token.slice(symbol.length).trim()),
|
||||||
|
isDateField,
|
||||||
|
isBooleanField,
|
||||||
|
isObjectIdField
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a leaf token (range, comparison, or plain value) into a condition.
|
||||||
|
function parseLeafCondition(
|
||||||
|
rawToken,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField = false,
|
||||||
|
isObjectIdField = false
|
||||||
|
) {
|
||||||
|
const token = rawToken.trim();
|
||||||
|
if (token === '') {
|
||||||
|
if (isBooleanField || isObjectIdField) return NO_MATCH_CONDITION;
|
||||||
|
return { op: buildRegexOp('^$') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interval: a..b, ..b, a..
|
||||||
|
const rangeIdx = token.indexOf('..');
|
||||||
|
if (rangeIdx !== -1) {
|
||||||
|
if (isBooleanField || isObjectIdField) return NO_MATCH_CONDITION;
|
||||||
|
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
|
||||||
|
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
|
||||||
|
const op = {};
|
||||||
|
if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start');
|
||||||
|
if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end');
|
||||||
|
// A bare ".." places no constraint on the field.
|
||||||
|
if (Object.keys(op).length === 0) return { query: {} };
|
||||||
|
return { op };
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = parseLeafComparisonOperators(
|
||||||
|
token,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField,
|
||||||
|
isObjectIdField
|
||||||
|
);
|
||||||
|
if (comparison) return comparison;
|
||||||
|
|
||||||
|
return buildEquality(
|
||||||
|
stripIgnoreCase(token),
|
||||||
|
isDateField,
|
||||||
|
isBooleanField,
|
||||||
|
isObjectIdField
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a condition descriptor into a MongoDB query object for `property`.
|
||||||
|
function conditionToQuery(cond, property) {
|
||||||
|
if (cond.query) return cond.query;
|
||||||
|
if (cond.op) return { [property]: cond.op };
|
||||||
|
return { [property]: cond.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnlyRegex(op) {
|
||||||
|
return Object.keys(op).every(key => key === '$regex' || key === '$options');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combines AND-ed conditions, merging operator objects on the same field where possible.
|
||||||
|
function combineAnd(children, property) {
|
||||||
|
if (children.some(child => child.query)) {
|
||||||
|
return {
|
||||||
|
query: { $and: children.map(child => conditionToQuery(child, property)) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const merged = {};
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.value !== undefined) {
|
||||||
|
merged.$eq = child.value;
|
||||||
|
} else if (child.op) {
|
||||||
|
Object.assign(merged, child.op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { op: merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combines OR-ed conditions, collapsing to $in or a single regex when possible.
|
||||||
|
function combineOr(children, property, isObjectIdField = false) {
|
||||||
|
if (children.every(child => child.value !== undefined)) {
|
||||||
|
return { op: { $in: children.map(child => child.value) } };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isObjectIdField &&
|
||||||
|
children.every(child => child.op && isOnlyRegex(child.op))
|
||||||
|
) {
|
||||||
|
const pattern = children.map(child => child.op.$regex).join('|');
|
||||||
|
const op = { $regex: pattern };
|
||||||
|
if (children[0].op.$options) op.$options = children[0].op.$options;
|
||||||
|
return { op };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
query: { $or: children.map(child => conditionToQuery(child, property)) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCondition(
|
||||||
|
node,
|
||||||
|
property,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField = false,
|
||||||
|
isObjectIdField = false
|
||||||
|
) {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
return parseLeafCondition(
|
||||||
|
node.token,
|
||||||
|
isDateField,
|
||||||
|
isBooleanField,
|
||||||
|
isObjectIdField
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const children = node.items.map(item =>
|
||||||
|
buildCondition(item, property, isDateField, isBooleanField, isObjectIdField)
|
||||||
|
);
|
||||||
|
return node.type === 'and'
|
||||||
|
? combineAnd(children, property)
|
||||||
|
: combineOr(children, property, isObjectIdField);
|
||||||
|
}
|
||||||
|
|
||||||
|
function idsToCondition(ids) {
|
||||||
|
if (!ids || ids.length === 0) return NO_MATCH_CONDITION;
|
||||||
|
if (ids.length === 1) return { value: ids[0] };
|
||||||
|
return { op: { $in: ids } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractObjectIdFromOperand(operand) {
|
||||||
|
const { suffix } = parsePrefixedValue(
|
||||||
|
stripIgnoreCase(String(operand).trim())
|
||||||
|
);
|
||||||
|
if (isObjectIdString(suffix)) {
|
||||||
|
return new mongoose.Types.ObjectId(suffix);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REF_OPERATOR_SYMBOLS = {
|
||||||
|
eq: '',
|
||||||
|
ne: '<>',
|
||||||
|
gt: '>',
|
||||||
|
gte: '>=',
|
||||||
|
lt: '<',
|
||||||
|
lte: '<='
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRefListExpression(operator, operand) {
|
||||||
|
const text = String(operand).trim();
|
||||||
|
if (operator === 'eq') return text;
|
||||||
|
return `${REF_OPERATOR_SYMBOLS[operator]}${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-list the referenced model, matching the expression against name and/or _reference via parseFilter.
|
||||||
|
async function listRefModelIds(refModelEntry, expression) {
|
||||||
|
const refModel = refModelEntry.model;
|
||||||
|
const orClauses = [];
|
||||||
|
|
||||||
|
if (refModel.schema.path('name')) {
|
||||||
|
orClauses.push(await parseFilter('name', expression, refModel));
|
||||||
|
}
|
||||||
|
if (refModel.schema.path('_reference')) {
|
||||||
|
orClauses.push(await parseFilter('_reference', expression, refModel));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orClauses.length === 0) return [];
|
||||||
|
|
||||||
|
const query = orClauses.length === 1 ? orClauses[0] : { $or: orClauses };
|
||||||
|
const docs = await refModel.find(query).select('_id').lean();
|
||||||
|
return docs.map(doc => doc._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionToIds(cond) {
|
||||||
|
if (cond.query) return null;
|
||||||
|
if (cond.value !== undefined) return [cond.value];
|
||||||
|
if (cond.op?.$in) return cond.op.$in;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineOrRefIds(children) {
|
||||||
|
const ids = new Set();
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.query) return child;
|
||||||
|
const childIds = conditionToIds(child);
|
||||||
|
if (!childIds) return child;
|
||||||
|
childIds.forEach(id => ids.add(String(id)));
|
||||||
|
}
|
||||||
|
return idsToCondition([...ids].map(id => new mongoose.Types.ObjectId(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineAndRefIds(children) {
|
||||||
|
let ids = null;
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.query) return child;
|
||||||
|
const childIds = conditionToIds(child);
|
||||||
|
if (!childIds || childIds.length === 0) return NO_MATCH_CONDITION;
|
||||||
|
const set = new Set(childIds.map(String));
|
||||||
|
if (ids === null) {
|
||||||
|
ids = set;
|
||||||
|
} else {
|
||||||
|
ids = new Set([...ids].filter(id => set.has(id)));
|
||||||
|
}
|
||||||
|
if (ids.size === 0) return NO_MATCH_CONDITION;
|
||||||
|
}
|
||||||
|
return idsToCondition([...ids].map(id => new mongoose.Types.ObjectId(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRefOperand(operand, refModelEntry, operator = 'eq') {
|
||||||
|
if (!refModelEntry?.model) return NO_MATCH_CONDITION;
|
||||||
|
|
||||||
|
const lookupOperand = String(operand).trim();
|
||||||
|
const objectId = extractObjectIdFromOperand(lookupOperand);
|
||||||
|
|
||||||
|
if (objectId) {
|
||||||
|
if (operator === 'ne') return { op: { $ne: objectId } };
|
||||||
|
if (operator === 'eq') return { value: objectId };
|
||||||
|
return NO_MATCH_CONDITION;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = buildRefListExpression(operator, lookupOperand);
|
||||||
|
const ids = await listRefModelIds(refModelEntry, expression);
|
||||||
|
|
||||||
|
if (operator === 'ne') {
|
||||||
|
if (ids.length === 0) return { query: {} };
|
||||||
|
return { op: { $nin: ids } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return idsToCondition(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRefLeaf(rawToken, fallbackRefName) {
|
||||||
|
const token = rawToken.trim();
|
||||||
|
if (token === '') return NO_MATCH_CONDITION;
|
||||||
|
|
||||||
|
const refModelEntry = getRefModelEntryForToken(token, fallbackRefName);
|
||||||
|
const lookupToken = stripPrefixFromOperand(token);
|
||||||
|
|
||||||
|
if (!refModelEntry?.model) {
|
||||||
|
const objectId = extractObjectIdFromOperand(lookupToken);
|
||||||
|
if (objectId) return { value: objectId };
|
||||||
|
return NO_MATCH_CONDITION;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeIdx = lookupToken.indexOf('..');
|
||||||
|
if (rangeIdx !== -1) {
|
||||||
|
const ids = await listRefModelIds(refModelEntry, lookupToken);
|
||||||
|
return idsToCondition(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const operators = [
|
||||||
|
['<>', 'ne'],
|
||||||
|
['>=', 'gte'],
|
||||||
|
['<=', 'lte'],
|
||||||
|
['>', 'gt'],
|
||||||
|
['<', 'lt'],
|
||||||
|
['=', 'eq']
|
||||||
|
];
|
||||||
|
for (const [symbol, name] of operators) {
|
||||||
|
if (lookupToken.startsWith(symbol)) {
|
||||||
|
return resolveRefOperand(
|
||||||
|
lookupToken.slice(symbol.length),
|
||||||
|
refModelEntry,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectId = extractObjectIdFromOperand(lookupToken);
|
||||||
|
if (objectId) return { value: objectId };
|
||||||
|
|
||||||
|
const ids = await listRefModelIds(refModelEntry, lookupToken);
|
||||||
|
return idsToCondition(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRefCondition(node, fallbackRefName) {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
return resolveRefLeaf(node.token, fallbackRefName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = await Promise.all(
|
||||||
|
node.items.map(item => resolveRefCondition(item, fallbackRefName))
|
||||||
|
);
|
||||||
|
|
||||||
|
return node.type === 'and'
|
||||||
|
? combineAndRefIds(children)
|
||||||
|
: combineOrRefIds(children);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveObjectRefFilter(
|
||||||
|
filterProperty,
|
||||||
|
expression,
|
||||||
|
fallbackRefName
|
||||||
|
) {
|
||||||
|
const tree = parseExpression(expression);
|
||||||
|
const condition = await resolveRefCondition(tree, fallbackRefName);
|
||||||
|
return conditionToQuery(condition, filterProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFilter(property, value, model = null) {
|
||||||
|
// Normalize state filter to state.type for schemas with state: { type }
|
||||||
|
if (property === 'state') {
|
||||||
|
property = 'state.type';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldKind = getFilterFieldKind(property, model);
|
||||||
|
const filterProperty = fieldKind.property;
|
||||||
|
const isBoolField = isBooleanField(property, model);
|
||||||
|
|
||||||
|
if (value?._id !== undefined && value?._id !== null) {
|
||||||
|
return { [filterProperty]: new mongoose.Types.ObjectId(value._id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-string values pass through; boolean fields only accept actual booleans.
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
if (isBoolField && typeof value !== 'boolean') {
|
||||||
|
return { [filterProperty]: { $in: [] } };
|
||||||
|
}
|
||||||
|
return { [filterProperty]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
(fieldKind.kind === 'objectId' || fieldKind.kind === 'objectRef') &&
|
||||||
|
property != '_id'
|
||||||
|
) {
|
||||||
|
const refName = fieldKind.ref ?? getSchemaRefName(property, model);
|
||||||
|
return resolveObjectRefFilter(filterProperty, trimmed, refName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expression = trimmed;
|
||||||
|
if (fieldKind.kind === 'default' && expression.charAt(3) === ':') {
|
||||||
|
const afterColon = value.split(':')[1];
|
||||||
|
expression = afterColon != null ? afterColon.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDateField = looksLikeDateField(property);
|
||||||
|
const isObjectIdField = fieldKind.kind === 'objectId';
|
||||||
|
const tree = parseExpression(expression);
|
||||||
|
const condition = buildCondition(
|
||||||
|
tree,
|
||||||
|
filterProperty,
|
||||||
|
isDateField,
|
||||||
|
isBoolField,
|
||||||
|
isObjectIdField
|
||||||
|
);
|
||||||
|
return conditionToQuery(condition, filterProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merges per-field filter clauses, falling back to $and when clauses use
|
||||||
|
// logical operators or target the same field (so nothing is silently lost).
|
||||||
|
function mergeFilterClauses(clauses) {
|
||||||
|
const valid = clauses.filter(
|
||||||
|
clause => clause && Object.keys(clause).length > 0
|
||||||
|
);
|
||||||
|
if (valid.length === 0) return {};
|
||||||
|
if (valid.length === 1) return valid[0];
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
let needsAnd = false;
|
||||||
|
for (const clause of valid) {
|
||||||
|
for (const key of Object.keys(clause)) {
|
||||||
|
if (key === '$or' || key === '$and' || seen.has(key)) {
|
||||||
|
needsAnd = true;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsAnd ? { $and: valid } : Object.assign({}, ...valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilterQuery(query = {}) {
|
||||||
|
const queryClean = { ...query };
|
||||||
|
|
||||||
|
// qs turns `order._id=` into `{ order: { _id } }`. Lift it so it can be parsed
|
||||||
|
// as the order object filter instead of a nested query object.
|
||||||
|
if (
|
||||||
|
queryClean.order &&
|
||||||
|
typeof queryClean.order === 'object' &&
|
||||||
|
!Array.isArray(queryClean.order)
|
||||||
|
) {
|
||||||
|
if (queryClean.order._id !== undefined) {
|
||||||
|
queryClean['order._id'] = queryClean.order._id;
|
||||||
|
}
|
||||||
|
delete queryClean.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryClean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a filter object based on allowed filters and req.query
|
||||||
|
async function getFilter(query, allowedFilters, parse = true, model = null) {
|
||||||
|
await getModels();
|
||||||
|
const clauses = [];
|
||||||
|
const queryClean = normalizeFilterQuery(query);
|
||||||
|
for (const key of ['sortProperty', 'sortOrder', 'page', 'limit']) {
|
||||||
|
if (key in queryClean) delete queryClean[key];
|
||||||
|
}
|
||||||
|
logger.info('queryExcludingSortAndOrder', queryClean);
|
||||||
|
if (
|
||||||
|
!parse &&
|
||||||
|
queryClean['order._id'] !== undefined &&
|
||||||
|
allowedFilters.includes('order')
|
||||||
|
) {
|
||||||
|
queryClean.order = queryClean['order._id'];
|
||||||
|
delete queryClean['order._id'];
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(queryClean)) {
|
||||||
|
if (allowedFilters.includes(key)) {
|
||||||
|
clauses.push(
|
||||||
|
parse ? await parseFilter(key, value, model) : { [key]: value }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeFilterClauses(clauses);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseFilter, getFilter, getObjectTypeModel };
|
||||||
@ -31,6 +31,12 @@ const documentJobSchema = new Schema(
|
|||||||
ref: 'documentPrinter',
|
ref: 'documentPrinter',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
quantity: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
default: 1,
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { natsServer } from './nats.js';
|
|||||||
|
|
||||||
import { customAlphabet } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
const NOTIFICATION_EXCLUDED_MODELS = ['notification', 'userNotifier', 'auditLog'];
|
const NOTIFICATION_EXCLUDED_MODELS = [
|
||||||
|
'notification',
|
||||||
|
'userNotifier',
|
||||||
|
'auditLog'
|
||||||
|
];
|
||||||
const SENSITIVE_KEYS = ['secret'];
|
const SENSITIVE_KEYS = ['secret'];
|
||||||
|
|
||||||
function omitSensitive(obj) {
|
function omitSensitive(obj) {
|
||||||
@ -504,8 +508,12 @@ async function editNotification(
|
|||||||
const user = notificationUserFromOwner(owner, ownerType);
|
const user = notificationUserFromOwner(owner, ownerType);
|
||||||
const objectName =
|
const objectName =
|
||||||
oldValue?.name ?? newValue?.name ?? modelEntry?.label ?? parentType;
|
oldValue?.name ?? newValue?.name ?? modelEntry?.label ?? parentType;
|
||||||
const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true));
|
const changedOldValues = omitSensitive(
|
||||||
const changedNewValues = omitSensitive(getChangedValues(oldValue, newValue, false));
|
getChangedValues(oldValue, newValue, true)
|
||||||
|
);
|
||||||
|
const changedNewValues = omitSensitive(
|
||||||
|
getChangedValues(oldValue, newValue, false)
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Object.keys(changedOldValues).length === 0 ||
|
Object.keys(changedOldValues).length === 0 ||
|
||||||
@ -556,7 +564,13 @@ async function notfiyObjectUserNotifiers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNotification(user, title, message, type = 'info', metadata) {
|
async function createNotification(
|
||||||
|
user,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
metadata
|
||||||
|
) {
|
||||||
const notification = new notificationModel({
|
const notification = new notificationModel({
|
||||||
user,
|
user,
|
||||||
title,
|
title,
|
||||||
@ -637,17 +651,7 @@ function expandObjectIds(input) {
|
|||||||
return expand(input);
|
return expand(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a filter object based on allowed filters and req.query
|
export { getFilter } from './filter.js';
|
||||||
function getFilter(query, allowedFilters, parse = true) {
|
|
||||||
let filter = {};
|
|
||||||
for (const [key, value] of Object.entries(query)) {
|
|
||||||
if (allowedFilters.includes(key)) {
|
|
||||||
const parsedFilter = parse ? parseFilter(key, value) : { [key]: value };
|
|
||||||
filter = { ...filter, ...parsedFilter };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converts a properties argument (string or array) to an array of strings
|
// Converts a properties argument (string or array) to an array of strings
|
||||||
function convertPropertiesString(properties) {
|
function convertPropertiesString(properties) {
|
||||||
@ -675,6 +679,5 @@ export {
|
|||||||
editNotification,
|
editNotification,
|
||||||
notfiyObjectUserNotifiers,
|
notfiyObjectUserNotifiers,
|
||||||
createNotification,
|
createNotification,
|
||||||
getFilter, // <-- add here
|
|
||||||
convertPropertiesString
|
convertPropertiesString
|
||||||
};
|
};
|
||||||
|
|||||||
@ -28,8 +28,14 @@ jest.unstable_mockModule('../../config.js', () => ({
|
|||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../database/filter.js', () => ({
|
||||||
|
getFilter: jest.fn(async query => query),
|
||||||
|
getObjectTypeModel: jest.fn(async () => null)
|
||||||
|
}));
|
||||||
|
|
||||||
const { UpdateManager } = await import('../updatemanager.js');
|
const { UpdateManager } = await import('../updatemanager.js');
|
||||||
const { natsServer } = await import('../../database/nats.js');
|
const { natsServer } = await import('../../database/nats.js');
|
||||||
|
const { getFilter } = await import('../../database/filter.js');
|
||||||
|
|
||||||
describe('UpdateManager', () => {
|
describe('UpdateManager', () => {
|
||||||
let mockSocketClient;
|
let mockSocketClient;
|
||||||
@ -79,6 +85,12 @@ describe('UpdateManager', () => {
|
|||||||
|
|
||||||
await updateManager.subscribeToObjectNew('printer', filter);
|
await updateManager.subscribeToObjectNew('printer', filter);
|
||||||
|
|
||||||
|
expect(getFilter).toHaveBeenCalledWith(
|
||||||
|
filter,
|
||||||
|
Object.keys(filter),
|
||||||
|
true,
|
||||||
|
null
|
||||||
|
);
|
||||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
'printers.new',
|
'printers.new',
|
||||||
'test-socket-id:{"state.type":"ready"}',
|
'test-socket-id:{"state.type":"ready"}',
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import _ from 'lodash';
|
|||||||
import { loadConfig } from '../config.js';
|
import { loadConfig } from '../config.js';
|
||||||
import { natsServer } from '../database/nats.js';
|
import { natsServer } from '../database/nats.js';
|
||||||
import { expandObjectIds } from '../database/utils.js';
|
import { expandObjectIds } from '../database/utils.js';
|
||||||
|
import { getFilter, getObjectTypeModel } from '../database/filter.js';
|
||||||
import { formatTraceData } from '../utils.js';
|
import { formatTraceData } from '../utils.js';
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
@ -13,6 +14,18 @@ logger.level = config.server.logLevel;
|
|||||||
const normalizeFilter = filter =>
|
const normalizeFilter = filter =>
|
||||||
filter && typeof filter === 'object' && !Array.isArray(filter) ? filter : {};
|
filter && typeof filter === 'object' && !Array.isArray(filter) ? filter : {};
|
||||||
|
|
||||||
|
const unwrapId = value => {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
value._id != null
|
||||||
|
) {
|
||||||
|
return value._id;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
const getFilterValue = (object, key) => {
|
const getFilterValue = (object, key) => {
|
||||||
if (key.endsWith('._id')) {
|
if (key.endsWith('._id')) {
|
||||||
const refPath = key.slice(0, -4);
|
const refPath = key.slice(0, -4);
|
||||||
@ -23,21 +36,94 @@ const getFilterValue = (object, key) => {
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _.get(object, key);
|
return unwrapId(_.get(object, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const valuesMatch = (actual, expected) => {
|
const valuesMatch = (actual, expected) => {
|
||||||
if (actual == expected) {
|
const left = unwrapId(actual);
|
||||||
|
const right = unwrapId(expected);
|
||||||
|
|
||||||
|
if (left == right) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actual != null && expected != null) {
|
if (left != null && right != null) {
|
||||||
return String(actual) === String(expected);
|
return String(left) === String(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOperatorObject = value => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value instanceof Date || value._bsontype === 'ObjectId') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return keys.length > 0 && keys.every(key => key.startsWith('$'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareValues = (actual, expected, operator) => {
|
||||||
|
if (actual == null || expected == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (operator === '$gt') return actual > expected;
|
||||||
|
if (operator === '$gte') return actual >= expected;
|
||||||
|
if (operator === '$lt') return actual < expected;
|
||||||
|
if (operator === '$lte') return actual <= expected;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesOperators = (actual, operators) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, '$eq')) {
|
||||||
|
if (!valuesMatch(actual, operators.$eq)) return false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, '$ne')) {
|
||||||
|
if (valuesMatch(actual, operators.$ne)) return false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, '$in')) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(operators.$in) ||
|
||||||
|
!operators.$in.some(value => valuesMatch(actual, value))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, '$nin')) {
|
||||||
|
if (
|
||||||
|
Array.isArray(operators.$nin) &&
|
||||||
|
operators.$nin.some(value => valuesMatch(actual, value))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, '$regex')) {
|
||||||
|
const regex = new RegExp(operators.$regex, operators.$options || '');
|
||||||
|
if (!regex.test(String(actual ?? ''))) return false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, '$not')) {
|
||||||
|
if (matchesExpected(actual, operators.$not)) return false;
|
||||||
|
}
|
||||||
|
for (const operator of ['$gt', '$gte', '$lt', '$lte']) {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(operators, operator) &&
|
||||||
|
!compareValues(actual, operators[operator], operator)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesExpected = (actual, expected) => {
|
||||||
|
if (isOperatorObject(expected)) {
|
||||||
|
return matchesOperators(actual, expected);
|
||||||
|
}
|
||||||
|
return valuesMatch(actual, expected);
|
||||||
|
};
|
||||||
|
|
||||||
const matchesFilter = (object, filter) => {
|
const matchesFilter = (object, filter) => {
|
||||||
if (!filter || Object.keys(filter).length === 0) {
|
if (!filter || Object.keys(filter).length === 0) {
|
||||||
return true;
|
return true;
|
||||||
@ -52,8 +138,20 @@ const matchesFilter = (object, filter) => {
|
|||||||
? object
|
? object
|
||||||
: { _id: object };
|
: { _id: object };
|
||||||
|
|
||||||
|
if (Array.isArray(filter.$and)) {
|
||||||
|
return filter.$and.every(clause => matchesFilter(normalizedObject, clause));
|
||||||
|
}
|
||||||
|
if (Array.isArray(filter.$or)) {
|
||||||
|
return filter.$or.some(clause => matchesFilter(normalizedObject, clause));
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, expectedValue] of Object.entries(filter)) {
|
for (const [key, expectedValue] of Object.entries(filter)) {
|
||||||
if (!valuesMatch(getFilterValue(normalizedObject, key), expectedValue)) {
|
if (key === '$and' || key === '$or') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!matchesExpected(getFilterValue(normalizedObject, key), expectedValue)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,6 +159,22 @@ const matchesFilter = (object, filter) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveObjectTypeFilter = async (objectType, filter = {}) => {
|
||||||
|
const normalizedFilter = normalizeFilter(filter);
|
||||||
|
if (Object.keys(normalizedFilter).length === 0) {
|
||||||
|
return { normalizedFilter, processedFilter: normalizedFilter };
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await getObjectTypeModel(objectType);
|
||||||
|
const processedFilter = await getFilter(
|
||||||
|
normalizedFilter,
|
||||||
|
Object.keys(normalizedFilter),
|
||||||
|
true,
|
||||||
|
model
|
||||||
|
);
|
||||||
|
return { normalizedFilter, processedFilter };
|
||||||
|
};
|
||||||
|
|
||||||
const stableStringify = value => {
|
const stableStringify = value => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return `[${value.map(stableStringify).join(',')}]`;
|
return `[${value.map(stableStringify).join(',')}]`;
|
||||||
@ -94,11 +208,17 @@ export class UpdateManager {
|
|||||||
return matchesFilter(value, normalizeFilter(filter));
|
return matchesFilter(value, normalizeFilter(filter));
|
||||||
}
|
}
|
||||||
|
|
||||||
emitObjectTypeEvent(eventName, objectType, filter, value) {
|
emitObjectTypeEvent(
|
||||||
|
eventName,
|
||||||
|
objectType,
|
||||||
|
filter,
|
||||||
|
value,
|
||||||
|
matchFilter = filter
|
||||||
|
) {
|
||||||
const normalizedFilter = normalizeFilter(filter);
|
const normalizedFilter = normalizeFilter(filter);
|
||||||
const matches = this.matchesObjectTypeFilter(
|
const matches = this.matchesObjectTypeFilter(
|
||||||
objectType,
|
objectType,
|
||||||
normalizedFilter,
|
matchFilter,
|
||||||
value
|
value
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -121,7 +241,10 @@ export class UpdateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async subscribeToObjectNew(objectType, filter = {}) {
|
async subscribeToObjectNew(objectType, filter = {}) {
|
||||||
const normalizedFilter = normalizeFilter(filter);
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
filter
|
||||||
|
);
|
||||||
const subject = `${objectType}s.new`;
|
const subject = `${objectType}s.new`;
|
||||||
const owner = getSubscriptionOwner(
|
const owner = getSubscriptionOwner(
|
||||||
this.socketClient.socketId,
|
this.socketClient.socketId,
|
||||||
@ -134,7 +257,8 @@ export class UpdateManager {
|
|||||||
'objectNew',
|
'objectNew',
|
||||||
objectType,
|
objectType,
|
||||||
normalizedFilter,
|
normalizedFilter,
|
||||||
value
|
value,
|
||||||
|
processedFilter
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -143,7 +267,10 @@ export class UpdateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async subscribeToObjectDelete(objectType, filter = {}) {
|
async subscribeToObjectDelete(objectType, filter = {}) {
|
||||||
const normalizedFilter = normalizeFilter(filter);
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
filter
|
||||||
|
);
|
||||||
const subject = `${objectType}s.delete`;
|
const subject = `${objectType}s.delete`;
|
||||||
const owner = getSubscriptionOwner(
|
const owner = getSubscriptionOwner(
|
||||||
this.socketClient.socketId,
|
this.socketClient.socketId,
|
||||||
@ -156,7 +283,8 @@ export class UpdateManager {
|
|||||||
'objectDelete',
|
'objectDelete',
|
||||||
objectType,
|
objectType,
|
||||||
normalizedFilter,
|
normalizedFilter,
|
||||||
value
|
value,
|
||||||
|
processedFilter
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user