diff --git a/src/__tests__/utils.parseFilter.test.js b/src/__tests__/utils.parseFilter.test.js index df7e562..fa2a5ba 100644 --- a/src/__tests__/utils.parseFilter.test.js +++ b/src/__tests__/utils.parseFilter.test.js @@ -112,6 +112,50 @@ describe('parseFilter number fields', () => { }); }); +describe('parseFilter empty values', () => { + it("treats '' as null or missing", async () => { + await expect(parseFilter('_reference', "''", jobModel)).resolves.toEqual({ + _reference: null, + }); + }); + + it("treats ='' as equal to null or missing", async () => { + await expect(parseFilter('_reference', "=''", jobModel)).resolves.toEqual({ + _reference: null, + }); + }); + + it("treats <>'' as present and not null", async () => { + await expect(parseFilter('_reference', "<>''", jobModel)).resolves.toEqual({ + _reference: { $ne: null }, + }); + }); + + it("treats '' as empty on number fields", async () => { + await expect(parseFilter('quantity', "''", jobModel)).resolves.toEqual({ + quantity: null, + }); + }); + + it("treats <>'' as not empty on date fields", async () => { + await expect(parseFilter('createdAt', "<>''", jobModel)).resolves.toEqual({ + createdAt: { $ne: null }, + }); + }); + + it("treats '' as empty on object references", async () => { + await expect(parseFilter('gcodeFile', "''", jobModel)).resolves.toEqual({ + gcodeFile: null, + }); + }); + + it("treats <>'' as not empty on object references", async () => { + await expect(parseFilter('gcodeFile', "<>''", jobModel)).resolves.toEqual({ + gcodeFile: { $ne: null }, + }); + }); +}); + describe('parseFilter date fields', () => { it('matches TODAY as the current calendar day', async () => { const now = new Date(); diff --git a/src/utils.js b/src/utils.js index 43dffe0..f55b4e1 100644 --- a/src/utils.js +++ b/src/utils.js @@ -41,6 +41,7 @@ logger.level = config.server.logLevel; // * any number of characters Co* / *Co / *Co* // ? a single character Hans?n // @ ignore case @location (text matching is case-insensitive) +// '' empty (null or missing) '' / ='' / <>'' // // Date-typed fields (schema Date) interpret bare values as calendar // days/datetimes, e.g. "22" => the whole of day 22 of the current month/year, @@ -87,6 +88,17 @@ function buildWildcardRegexPattern(input) { } const NO_MATCH_CONDITION = { op: { $in: [] } }; +const EMPTY_OPERAND = "''"; + +function isEmptyOperand(value) { + return String(value).trim() === EMPTY_OPERAND; +} + +// MongoDB null equality matches both null and missing (undefined) fields. +function emptyCondition(operator = 'eq') { + if (operator === 'ne') return { op: { $ne: null } }; + return { value: null }; +} function isBooleanField(property, model = null) { const schema = model?.schema; @@ -620,6 +632,7 @@ function isValidDateOperand(value) { function isValidDateFilterLeaf(rawToken) { const token = rawToken.trim(); if (token === '') return true; + if (isEmptyOperand(token)) return true; const rangeIdx = token.indexOf('..'); if (rangeIdx !== -1) { @@ -634,6 +647,7 @@ function isValidDateFilterLeaf(rawToken) { for (const symbol of operators) { if (token.startsWith(symbol)) { const operand = stripIgnoreCase(token.slice(symbol.length).trim()); + if ((symbol === '=' || symbol === '<>') && isEmptyOperand(operand)) return true; return operand !== '' && isValidDateOperand(operand); } } @@ -733,6 +747,7 @@ function buildEquality( isNumberField = false, property = '' ) { + if (isEmptyOperand(value)) return emptyCondition('eq'); if (isBooleanField) return buildBooleanEquality(value); if (isObjectIdField) { const { suffix } = parsePrefixedValue(value); @@ -770,6 +785,10 @@ function buildComparison( isNumberField = false, property = '' ) { + if (isEmptyOperand(value)) { + if (name === 'eq' || name === 'ne') return emptyCondition(name); + return NO_MATCH_CONDITION; + } if (isBooleanField) return buildBooleanComparison(name, value); if (isObjectIdField) { const { suffix } = parsePrefixedValue(value); @@ -856,6 +875,7 @@ function parseLeafCondition( if (isBooleanField || isObjectIdField || isNumberField) return NO_MATCH_CONDITION; return { op: buildRegexOp('^$') }; } + if (isEmptyOperand(token)) return emptyCondition('eq'); // Interval: a..b, ..b, a.. const rangeIdx = token.indexOf('..'); @@ -1052,6 +1072,10 @@ function combineAndRefIds(children) { async function resolveRefOperand(operand, refModelEntry, operator = 'eq') { const lookupOperand = String(operand).trim(); + if (isEmptyOperand(lookupOperand)) { + if (operator === 'eq' || operator === 'ne') return emptyCondition(operator); + return NO_MATCH_CONDITION; + } const { prefix, suffix, hadPrefix } = parsePrefixedValue(lookupOperand); if (hadPrefix) { const prefixEntry = getRefModelEntryFromPrefix(prefix); @@ -1084,6 +1108,7 @@ async function resolveRefOperand(operand, refModelEntry, operator = 'eq') { async function resolveRefLeaf(rawToken, fallbackRefName) { const token = rawToken.trim(); if (token === '') return NO_MATCH_CONDITION; + if (isEmptyOperand(token)) return emptyCondition('eq'); const refModelEntry = getRefModelEntryForToken(token, fallbackRefName); const lookupToken = stripPrefixFromOperand(token);