diff --git a/src/__tests__/utils.parseFilter.test.js b/src/__tests__/utils.parseFilter.test.js new file mode 100644 index 0000000..d926796 --- /dev/null +++ b/src/__tests__/utils.parseFilter.test.js @@ -0,0 +1,41 @@ +import { describe, expect, it } from '@jest/globals'; +import { parseFilter } from '../utils.js'; +import { jobModel } from '../database/schemas/production/job.schema.js'; + +describe('parseFilter number fields', () => { + it('matches exact numeric quantity without regex', async () => { + await expect(parseFilter('quantity', '12', jobModel)).resolves.toEqual({ quantity: 12 }); + }); + + it('matches multiple quantities with $in', async () => { + await expect(parseFilter('quantity', '1|2|3', jobModel)).resolves.toEqual({ + quantity: { $in: [1, 2, 3] }, + }); + }); + + it('does not put $regex or $options on quantity for wildcards', async () => { + const result = await parseFilter('quantity', '1*', jobModel); + expect(result.quantity).toBeUndefined(); + expect(JSON.stringify(result)).not.toMatch(/\$options/); + expect(result).toEqual({ + $expr: { + $regexMatch: { + input: { $toString: '$quantity' }, + regex: '^1.*$', + options: 'i', + }, + }, + }); + }); + + it('treats non-numeric quantity text as no match', async () => { + await expect(parseFilter('quantity', 'abc', jobModel)).resolves.toEqual({ + quantity: { $in: [] }, + }); + }); + + it('still uses $regex on string fields', async () => { + const result = await parseFilter('_reference', 'JOB*', jobModel); + expect(result._reference).toEqual({ $regex: '^JOB.*$', $options: 'i' }); + }); +}); diff --git a/src/database/database.js b/src/database/database.js index 79657bc..ed789ca 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -776,6 +776,15 @@ export const getPropertyValues = async ({ model, property, filter = {} }) => { return []; } + try { + return await getPropertyValuesUnsafe({ model, property, filter }); + } catch (error) { + logger.error('getPropertyValues error:', error); + return []; + } +}; + +const getPropertyValuesUnsafe = async ({ model, property, filter = {} }) => { const convertedFilter = filter && Object.keys(filter).length > 0 ? convertObjectIdStringsInFilter(filter) : {}; diff --git a/src/routes/production/jobs.js b/src/routes/production/jobs.js index 4b4c5b5..471a0e6 100644 --- a/src/routes/production/jobs.js +++ b/src/routes/production/jobs.js @@ -40,11 +40,12 @@ import { getJobNeighborsRouteHandler, } from '../../services/production/jobs.js'; import { convertPropertiesString, getFilter, getSort } from '../../utils.js'; +import { jobModel } from '../../database/schemas/production/job.schema.js'; // list of jobs router.get('/', isAuthenticated, checkPermissions('job', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; - const filter = await getFilter(req.query, listAllowedFilters); + const filter = await getFilter(req.query, listAllowedFilters, true, jobModel); listJobsRouteHandler( req, res, @@ -70,10 +71,15 @@ router.get('/properties', checkPermissions('job', 'list'), isAuthenticated, asyn router.get('/values', checkPermissions('job', 'list'), isAuthenticated, async (req, res) => { const { property } = req.query; - const filter = await getFilter(req.query, listAllowedFilters, true); + const filter = await getFilter(req.query, listAllowedFilters, true, jobModel); var masterFilter = {}; if (req.query.masterFilter) { - masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true); + masterFilter = await getFilter( + JSON.parse(req.query.masterFilter), + listAllowedFilters, + true, + jobModel + ); } getJobPropertyValuesRouteHandler(req, res, property, filter, masterFilter); }); diff --git a/src/utils.js b/src/utils.js index 8311ecd..b7cbbcd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -84,6 +84,37 @@ function isBooleanField(property, model = null) { return found; } +function isNumberSchemaPath(path) { + if (!path) return false; + if (path.instance === 'Number') return true; + if (path.instance === 'Array') { + return getEmbeddedSchemaType(path)?.instance === 'Number'; + } + return false; +} + +function isNumberField(property, model = null) { + if (model?.schema) { + return isNumberSchemaPath(model.schema.path(property)); + } + + for (const entry of Object.values(models)) { + if (isNumberSchemaPath(entry.model?.schema?.path(property))) return true; + } + return false; +} + +function buildNumberRegexCondition(property, pattern, negated = false) { + const expr = { + $regexMatch: { + input: { $toString: `$${property}` }, + regex: pattern, + options: 'i', + }, + }; + return { query: { $expr: negated ? { $not: expr } : expr } }; +} + function isObjectIdPath(property) { return property === '_id' || property.endsWith('._id'); } @@ -392,7 +423,14 @@ function stripIgnoreCase(str) { } // Builds an equality condition (value, date range, or wildcard regex). -function buildEquality(value, isDateField, isBooleanField = false, isObjectIdField = false) { +function buildEquality( + value, + isDateField, + isBooleanField = false, + isObjectIdField = false, + isNumberField = false, + property = '' +) { if (isBooleanField) return buildBooleanEquality(value); if (isObjectIdField) { const { suffix } = parsePrefixedValue(value); @@ -410,6 +448,12 @@ function buildEquality(value, isDateField, isBooleanField = false, isObjectIdFie if (lower === 'false') return { value: false }; if (isObjectIdString(value)) return { value: new mongoose.Types.ObjectId(value) }; if (isNumeric(value)) return { value: Number(value) }; + if (isNumberField) { + if (/[*?]/.test(value)) { + return buildNumberRegexCondition(property, buildWildcardRegexPattern(value)); + } + return NO_MATCH_CONDITION; + } return { op: buildRegexOp(buildWildcardRegexPattern(value)) }; } @@ -419,7 +463,9 @@ function buildComparison( value, isDateField, isBooleanField = false, - isObjectIdField = false + isObjectIdField = false, + isNumberField = false, + property = '' ) { if (isBooleanField) return buildBooleanComparison(name, value); if (isObjectIdField) { @@ -431,7 +477,9 @@ function buildComparison( } return NO_MATCH_CONDITION; } - if (name === 'eq') return buildEquality(value, isDateField); + if (name === 'eq') { + return buildEquality(value, isDateField, false, false, isNumberField, property); + } if (name === 'ne') { if (isDateField) { @@ -440,17 +488,30 @@ function buildComparison( if (start && end) return { op: { $not: { $gte: start, $lte: end } } }; } if (/[*?]/.test(value)) { + if (isNumberField) { + return buildNumberRegexCondition(property, buildWildcardRegexPattern(value), true); + } return { op: { $not: buildRegexOp(buildWildcardRegexPattern(value)) } }; } + if (isNumberField && !isNumeric(value)) return NO_MATCH_CONDITION; return { op: { $ne: coerceScalar(value) } }; } + if (isNumberField && !isNumeric(value)) return NO_MATCH_CONDITION; + // 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) { +function parseLeafComparisonOperators( + token, + isDateField, + isBooleanField, + isObjectIdField, + isNumberField, + property +) { const operators = [ ['<>', 'ne'], ['>=', 'gte'], @@ -466,7 +527,9 @@ function parseLeafComparisonOperators(token, isDateField, isBooleanField, isObje stripIgnoreCase(token.slice(symbol.length).trim()), isDateField, isBooleanField, - isObjectIdField + isObjectIdField, + isNumberField, + property ); } } @@ -478,11 +541,13 @@ function parseLeafCondition( rawToken, isDateField, isBooleanField = false, - isObjectIdField = false + isObjectIdField = false, + isNumberField = false, + property = '' ) { const token = rawToken.trim(); if (token === '') { - if (isBooleanField || isObjectIdField) return NO_MATCH_CONDITION; + if (isBooleanField || isObjectIdField || isNumberField) return NO_MATCH_CONDITION; return { op: buildRegexOp('^$') }; } @@ -492,6 +557,11 @@ function parseLeafCondition( if (isBooleanField || isObjectIdField) return NO_MATCH_CONDITION; const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim()); const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim()); + if (isNumberField) { + if ((lo !== '' && !isNumeric(lo)) || (hi !== '' && !isNumeric(hi))) { + return NO_MATCH_CONDITION; + } + } const op = {}; if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start'); if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end'); @@ -504,11 +574,20 @@ function parseLeafCondition( token, isDateField, isBooleanField, - isObjectIdField + isObjectIdField, + isNumberField, + property ); if (comparison) return comparison; - return buildEquality(stripIgnoreCase(token), isDateField, isBooleanField, isObjectIdField); + return buildEquality( + stripIgnoreCase(token), + isDateField, + isBooleanField, + isObjectIdField, + isNumberField, + property + ); } // Converts a condition descriptor into a MongoDB query object for `property`. @@ -557,13 +636,21 @@ function buildCondition( property, isDateField, isBooleanField = false, - isObjectIdField = false + isObjectIdField = false, + isNumberField = false ) { if (node.type === 'leaf') { - return parseLeafCondition(node.token, isDateField, isBooleanField, isObjectIdField); + return parseLeafCondition( + node.token, + isDateField, + isBooleanField, + isObjectIdField, + isNumberField, + property + ); } const children = node.items.map((item) => - buildCondition(item, property, isDateField, isBooleanField, isObjectIdField) + buildCondition(item, property, isDateField, isBooleanField, isObjectIdField, isNumberField) ); return node.type === 'and' ? combineAnd(children, property) @@ -770,8 +857,16 @@ async function parseFilter(property, value, model = null) { const isDateField = looksLikeDateField(property); const isObjectIdField = fieldKind.kind === 'objectId'; + const isNumField = isNumberField(filterProperty, model); const tree = parseExpression(expression); - const condition = buildCondition(tree, filterProperty, isDateField, isBoolField, isObjectIdField); + const condition = buildCondition( + tree, + filterProperty, + isDateField, + isBoolField, + isObjectIdField, + isNumField + ); return conditionToQuery(condition, filterProperty); }