Implement handling for empty values in filter parsing
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

This commit introduces a new utility function, `isEmptyOperand`, to treat empty strings as null or missing values in filter parsing. It updates the `parseFilter` function and related utility functions to handle cases where empty values are provided, ensuring consistent behavior across different field types. Additionally, corresponding tests are added to verify the correct handling of empty values in various scenarios, enhancing the robustness of the filtering capabilities.
This commit is contained in:
Tom Butcher 2026-09-05 02:28:28 +01:00
parent bbec009337
commit 9defa3e30b
2 changed files with 69 additions and 0 deletions

View File

@ -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();

View File

@ -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);