From 6c694d7d48d94d6f3c2c9cc9b7956f399a9162f9 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 1 Aug 2026 14:33:03 +0100 Subject: [PATCH] Add boolean field handling to filtering logic in utils.js This update introduces functions to manage boolean fields in the filtering process, including parsing boolean operands and building equality/comparison conditions. The `buildCondition`, `parseLeafCondition`, and `parseFilter` functions have been modified to accommodate boolean field checks, enhancing the overall filtering capabilities across the application. --- src/utils.js | 92 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 14 deletions(-) diff --git a/src/utils.js b/src/utils.js index b15751b..9fcd29a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -10,6 +10,7 @@ import config from './config.js'; import crypto from 'crypto'; import canonicalize from 'canonical-json'; import { getModelByName } from './services/misc/model.js'; +import { models } from './database/schemas/models.js'; import { createEmailRenderAuthCode } from './services/misc/emailRenderAuth.js'; import { Worker } from 'worker_threads'; import path from 'path'; @@ -63,6 +64,50 @@ function looksLikeDateField(property) { 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(models)) { + const path = entry.model?.schema?.path(property); + if (!path) continue; + found = true; + if (path.instance !== 'Boolean') return false; + } + return found; +} + +function parseBooleanOperand(value) { + const lower = String(value).trim().toLowerCase(); + if (lower === 'yes') return true; + if (lower === 'no') return false; + 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); } @@ -197,7 +242,8 @@ function stripIgnoreCase(str) { } // Builds an equality condition (value, date range, or wildcard regex). -function buildEquality(value, isDateField) { +function buildEquality(value, isDateField, isBooleanField = false) { + if (isBooleanField) return buildBooleanEquality(value); if (isDateField) { const start = parseDateOperand(value, 'start'); const end = parseDateOperand(value, 'end'); @@ -212,7 +258,8 @@ function buildEquality(value, isDateField) { } // Builds a comparison condition for a single operator. -function buildComparison(name, value, isDateField) { +function buildComparison(name, value, isDateField, isBooleanField = false) { + if (isBooleanField) return buildBooleanComparison(name, value); if (name === 'eq') return buildEquality(value, isDateField); if (name === 'ne') { @@ -233,15 +280,21 @@ function buildComparison(name, value, isDateField) { } // Parses a leaf token (range, comparison, or plain value) into a condition. -function parseLeafCondition(rawToken, isDateField) { +function parseLeafCondition(rawToken, isDateField, isBooleanField = false) { const token = rawToken.trim(); if (token === '') { + if (isBooleanField) return NO_MATCH_CONDITION; return { op: { $regex: '^$', $options: 'i' } }; } // Interval: a..b, ..b, a.. const rangeIdx = token.indexOf('..'); if (rangeIdx !== -1) { + if (isBooleanField) { + const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim()); + const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim()); + if (lo !== '' || hi !== '') return NO_MATCH_CONDITION; + } const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim()); const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim()); const op = {}; @@ -263,11 +316,16 @@ function parseLeafCondition(rawToken, isDateField) { ]; for (const [symbol, name] of operators) { if (token.startsWith(symbol)) { - return buildComparison(name, stripIgnoreCase(token.slice(symbol.length).trim()), isDateField); + return buildComparison( + name, + stripIgnoreCase(token.slice(symbol.length).trim()), + isDateField, + isBooleanField + ); } } - return buildEquality(stripIgnoreCase(token), isDateField); + return buildEquality(stripIgnoreCase(token), isDateField, isBooleanField); } // Converts a condition descriptor into a MongoDB query object for `property`. @@ -309,26 +367,33 @@ function combineOr(children, property) { return { query: { $or: children.map((child) => conditionToQuery(child, property)) } }; } -function buildCondition(node, property, isDateField) { +function buildCondition(node, property, isDateField, isBooleanField = false) { if (node.type === 'leaf') { - return parseLeafCondition(node.token, isDateField); + return parseLeafCondition(node.token, isDateField, isBooleanField); } - const children = node.items.map((item) => buildCondition(item, property, isDateField)); + const children = node.items.map((item) => + buildCondition(item, property, isDateField, isBooleanField) + ); return node.type === 'and' ? combineAnd(children, property) : combineOr(children, property); } -function parseFilter(property, value) { +function parseFilter(property, value, model = null) { // Normalize state filter to state.type for schemas with state: { type } if (property === 'state') { property = 'state.type'; } + const isBoolField = isBooleanField(property, model); + if (value?._id !== undefined && value?._id !== null) { return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } }; } - // Non-string values (actual booleans, numbers, objects, etc.) pass through. + // Non-string values pass through; boolean fields only accept actual booleans. if (typeof value !== 'string') { + if (isBoolField && typeof value !== 'boolean') { + return { [property]: { $in: [] } }; + } return { [property]: value }; } @@ -340,7 +405,7 @@ function parseFilter(property, value) { const isDateField = looksLikeDateField(property); const tree = parseExpression(trimmed); - const condition = buildCondition(tree, property, isDateField); + const condition = buildCondition(tree, property, isDateField, isBoolField); return conditionToQuery(condition, property); } @@ -1136,14 +1201,13 @@ function mergeFilterClauses(clauses) { } // Returns a filter object based on allowed filters and req.query -function getFilter(query, allowedFilters, parse = true) { +function getFilter(query, allowedFilters, parse = true, model = null) { const clauses = []; for (const [key, value] of Object.entries(query)) { if (allowedFilters.includes(key)) { - clauses.push(parse ? parseFilter(key, value) : { [key]: value }); + clauses.push(parse ? parseFilter(key, value, model) : { [key]: value }); } } - console.log('clauses', clauses); return mergeFilterClauses(clauses); }