From f41f1b1bc0e5944a24615264af9e2939a0105a5a Mon Sep 17 00:00:00 2001 From: Trinmar Boado Date: Mon, 10 Nov 2025 21:11:25 +0800 Subject: [PATCH 1/2] Fix: O2M relationship 2 items with quantities 2 and 3 showed NaN instead of 5 User's template {{ ROUND(ASUM(procurement_schedule, quantity)) }} was showing NaN instead of correct values Root cause: Directus stores quantities as strings, causing string concatenation instead of arithmetic addition Specific case: 2 schedules with quantities 2 and 3 showed 0 instead of 5 --- src/operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/operations.ts b/src/operations.ts index c91aeb59..da3f15c4 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -236,7 +236,7 @@ function _parseExpression( // aggregated operators if (op === 'ASUM') { // aggregated sum - return (values[args[0]] as unknown[])?.reduce((acc, item) => acc + parseExpression(args[1], item as typeof values, {}, debug), 0) ?? 0; + return (values[args[0]] as unknown[])?.reduce((acc, item) => acc + (+parseExpression(args[1], item as typeof values, {}, debug) || 0), 0) ?? 0; } if (op === 'AMIN') { // aggregated min From b0fab40a4ac43d8935f40e40a11819d746749f0d Mon Sep 17 00:00:00 2001 From: Trinmar Boado Date: Mon, 10 Nov 2025 22:44:31 +0800 Subject: [PATCH 2/2] Fix: NaN Issues in Expression Parser for One-to-Many Relationships --- src/operations.test.ts | 139 +++++++++++++++++++++++++++++++++++++++++ src/operations.ts | 68 ++++++++++---------- 2 files changed, 173 insertions(+), 34 deletions(-) diff --git a/src/operations.test.ts b/src/operations.test.ts index 31865cd3..12f9fd51 100644 --- a/src/operations.test.ts +++ b/src/operations.test.ts @@ -538,26 +538,91 @@ describe('Test parseExpression', () => { expect(parseExpression('ASUM(a, MULTIPLY(b, c))', { a: [{b: 5, c: 1}, {b: 10, c: 2}, {b: 1000, c: 0}, {b: 15, c: 10}] })).toBe(175); }); + test('ASUM op with string quantities (fix NaN issue)', () => { + // Test case: 2 schedules with string quantities "2" and "3" should sum to 5 + expect(parseExpression('ASUM(a, b)', { a: [{b: "2"}, {b: "3"}] })).toBe(5); + + // Test case: Mixed string and number quantities + expect(parseExpression('ASUM(a, b)', { a: [{b: "2"}, {b: 3}, {b: "4"}] })).toBe(9); + + // Test case: Missing/undefined quantities should not cause NaN + expect(parseExpression('ASUM(a, b)', { a: [{b: "2"}, {b: undefined}, {b: "3"}] })).toBe(5); + expect(parseExpression('ASUM(a, b)', { a: [{b: null}, {b: "5"}] })).toBe(5); + expect(parseExpression('ASUM(a, b)', { a: [{b: undefined}] })).toBe(0); + }); + test('AMIN op', () => { expect(parseExpression('AMIN(a, b)', { a: [{b: 5}, {b: 10}, {b: -5}, {b: 15}] })).toBe(-5); expect(parseExpression('AMIN(a, SUM(b, c))', { a: [{b: 5, c: -5}, {b: 10, c: 0}, {b: -5, c: 5}, {b: 15, c: -30}] })).toBe(-15); }); + test('AMIN op with string quantities (fix NaN issue)', () => { + // Test case: string quantities should be handled gracefully + expect(parseExpression('AMIN(a, b)', { a: [{b: "5"}, {b: "3"}, {b: "10"}] })).toBe(3); + + // Test case: Mixed string and number quantities + expect(parseExpression('AMIN(a, b)', { a: [{b: "5"}, {b: 3}, {b: "10"}] })).toBe(3); + + // Test case: Missing/undefined quantities should not cause NaN + expect(parseExpression('AMIN(a, b)', { a: [{b: "5"}, {b: undefined}, {b: "10"}] })).toBe(0); + expect(parseExpression('AMIN(a, b)', { a: [{b: null}, {b: "5"}] })).toBe(0); + expect(parseExpression('AMIN(a, b)', { a: [{b: undefined}] })).toBe(0); + }); + test('AMAX op', () => { expect(parseExpression('AMAX(a, b)', { a: [{b: 5}, {b: 10}, {b: -5}, {b: 15}] })).toBe(15); expect(parseExpression('AMAX(a, SUM(b, c))', { a: [{b: 5, c: -5}, {b: 10, c: 0}, {b: -5, c: 5}, {b: 15, c: -30}] })).toBe(10); }); + test('AMAX op with string quantities (fix NaN issue)', () => { + // Test case: string quantities should be handled gracefully + expect(parseExpression('AMAX(a, b)', { a: [{b: "5"}, {b: "3"}, {b: "10"}] })).toBe(10); + + // Test case: Mixed string and number quantities + expect(parseExpression('AMAX(a, b)', { a: [{b: "5"}, {b: 3}, {b: "10"}] })).toBe(10); + + // Test case: Missing/undefined quantities should not cause NaN + expect(parseExpression('AMAX(a, b)', { a: [{b: "5"}, {b: undefined}, {b: "10"}] })).toBe(10); + expect(parseExpression('AMAX(a, b)', { a: [{b: null}, {b: "5"}] })).toBe(5); + expect(parseExpression('AMAX(a, b)', { a: [{b: undefined}] })).toBe(0); + }); + test('AAVG op', () => { expect(parseExpression('AAVG(a, b)', { a: [{b: 5}, {b: 10}, {b: 0}, {b: 15}] })).toBe(7.5); expect(parseExpression('AAVG(a, SUM(b, c))', { a: [{b: 5, c: -5}, {b: 10, c: 0}, {b: -5, c: 5}, {b: 15, c: -30}] })).toBe(-1.25); }); + test('AAVG op with string quantities (fix NaN issue)', () => { + // Test case: string quantities should be handled gracefully + expect(parseExpression('AAVG(a, b)', { a: [{b: "5"}, {b: "3"}, {b: "10"}] })).toBe(6); + + // Test case: Mixed string and number quantities + expect(parseExpression('AAVG(a, b)', { a: [{b: "5"}, {b: 3}, {b: "10"}] })).toBe(6); + + // Test case: Missing/undefined quantities should not cause NaN + expect(parseExpression('AAVG(a, b)', { a: [{b: "5"}, {b: undefined}, {b: "10"}] })).toBe(7.5); + expect(parseExpression('AAVG(a, b)', { a: [{b: null}, {b: "5"}] })).toBe(2.5); + expect(parseExpression('AAVG(a, b)', { a: [{b: undefined}] })).toBe(0); + }); + test('AMUL op', () => { expect(parseExpression('AMUL(a, b)', { a: [{b: 5}, {b: 10}, {b: 1}, {b: 15}] })).toBe(750); expect(parseExpression('AMUL(a, SUM(b, c))', { a: [{b: 10, c: 0}, {b: 15, c: -30}] })).toBe(-150); }); + test('AMUL op with string quantities (fix NaN issue)', () => { + // Test case: string quantities should be handled gracefully + expect(parseExpression('AMUL(a, b)', { a: [{b: "5"}, {b: "2"}, {b: "3"}] })).toBe(30); + + // Test case: Mixed string and number quantities + expect(parseExpression('AMUL(a, b)', { a: [{b: "5"}, {b: 2}, {b: "3"}] })).toBe(30); + + // Test case: Missing/undefined quantities should not cause NaN + expect(parseExpression('AMUL(a, b)', { a: [{b: "5"}, {b: undefined}, {b: "3"}] })).toBe(0); + expect(parseExpression('AMUL(a, b)', { a: [{b: null}, {b: "5"}] })).toBe(0); + expect(parseExpression('AMUL(a, b)', { a: [{b: undefined}] })).toBe(0); + }); + test('AAND op', () => { expect(parseExpression('AAND(a, b)', { a: [{b: true}, {b: true}, {b: true}, {b: true}] })).toBe(true); expect(parseExpression('AAND(a, b)', { a: [{b: true}, {b: true}, {b: false}, {b: true}] })).toBe(false); @@ -800,4 +865,78 @@ A thousand generations live in you now.`)) expect(toSlug(new Date())).toBe(''); expect(toSlug(new RegExp('123'))).toBe(''); }); +describe('NaN prevention tests', () => { + test('SUM binary op with string values', () => { + // Test that string values from Directus are properly converted to numbers + expect(parseExpression('SUM(a, b)', { a: "5", b: "3" })).toBe(8); + expect(parseExpression('SUM(a, b)', { a: "5", b: undefined })).toBe(5); + expect(parseExpression('SUM(a, b)', { a: null, b: "3" })).toBe(3); + expect(parseExpression('SUM(a, b)', { a: undefined, b: undefined })).toBe(0); + }); + + test('SUBTRACT binary op with string values', () => { + expect(parseExpression('SUBTRACT(a, b)', { a: "10", b: "3" })).toBe(7); + expect(parseExpression('SUBTRACT(a, b)', { a: "10", b: undefined })).toBe(10); + expect(parseExpression('SUBTRACT(a, b)', { a: null, b: "3" })).toBe(-3); + }); + + test('MULTIPLY binary op with string values', () => { + expect(parseExpression('MULTIPLY(a, b)', { a: "5", b: "3" })).toBe(15); + expect(parseExpression('MULTIPLY(a, b)', { a: "5", b: undefined })).toBe(0); + }); + + test('DIVIDE binary op with string values', () => { + expect(parseExpression('DIVIDE(a, b)', { a: "15", b: "3" })).toBe(5); + expect(parseExpression('DIVIDE(a, b)', { a: "15", b: undefined })).toBe(Infinity); + }); + + test('ABS unary op with string values', () => { + expect(parseExpression('ABS(a)', { a: "-5" })).toBe(5); + expect(parseExpression('ABS(a)', { a: undefined })).toBe(0); + expect(parseExpression('ABS(a)', { a: null })).toBe(0); + }); + + test('SQRT unary op with string values', () => { + expect(parseExpression('SQRT(a)', { a: "16" })).toBe(4); + expect(parseExpression('SQRT(a)', { a: undefined })).toBe(0); + }); + + test('SUM unary op with array containing strings', () => { + expect(parseExpression('SUM(a)', { a: ["1", "2", "3"] })).toBe(6); + expect(parseExpression('SUM(a)', { a: ["1", undefined, "3"] })).toBe(4); + expect(parseExpression('SUM(a)', { a: [null, "2", null] })).toBe(2); + }); + + test('AVERAGE unary op with array containing strings', () => { + expect(parseExpression('AVERAGE(a)', { a: ["2", "4", "6"] })).toBe(4); + expect(parseExpression('AVERAGE(a)', { a: ["2", undefined, "6"] })).toBe(4); + }); + + test('MAX unary op with array containing strings', () => { + expect(parseExpression('MAX(a)', { a: ["1", "5", "3"] })).toBe(5); + expect(parseExpression('MAX(a)', { a: ["1", undefined, "5"] })).toBe(5); + expect(parseExpression('MAX(a)', { a: [null, null, null] })).toBe(0); + }); + + test('MIN unary op with array containing strings', () => { + expect(parseExpression('MIN(a)', { a: ["1", "5", "3"] })).toBe(1); + expect(parseExpression('MIN(a)', { a: ["1", undefined, "5"] })).toBe(1); + expect(parseExpression('MIN(a)', { a: [null, null, null] })).toBe(0); + }); + + test('CEIL unary op with string values', () => { + expect(parseExpression('CEIL(a)', { a: "1.234" })).toBe(2); + expect(parseExpression('CEIL(a)', { a: undefined })).toBe(0); + }); + + test('FLOOR unary op with string values', () => { + expect(parseExpression('FLOOR(a)', { a: "1.234" })).toBe(1); + expect(parseExpression('FLOOR(a)', { a: undefined })).toBe(0); + }); + + test('ROUND unary op with string values', () => { + expect(parseExpression('ROUND(a)', { a: "1.234" })).toBe(1); + expect(parseExpression('ROUND(a)', { a: undefined })).toBe(0); + }); +}); }); diff --git a/src/operations.ts b/src/operations.ts index da3f15c4..3140e149 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -54,7 +54,7 @@ function _parseExpression( const opMatch = parseOp(exp); if (opMatch) { - const { op, args } = opMatch; + const { op, args } = opMatch as { op: string; args: [string, string, string, ...string[]] }; // unary operators if (args.length === 1) { @@ -104,7 +104,7 @@ function _parseExpression( } if (['YEAR', 'MONTH', 'GET_DATE', 'DAY', 'HOURS', 'MINUTES', 'SECONDS', 'TIME'].includes(op)) { if (valueA instanceof Date) { - const op2func = { + const op2func: any = { YEAR: 'getFullYear', MONTH: 'getMonth', GET_DATE: 'getDate', @@ -114,53 +114,53 @@ function _parseExpression( SECONDS: 'getSeconds', TIME: 'getTime', }; - return valueA[op2func[op]](); + return (valueA as any)[op2func[op]](); } return 0; } // arithmetic if (op === 'ABS') { - return Math.abs(valueA); + return Math.abs(+valueA || 0); } if (op === 'SQRT') { - return Math.sqrt(valueA); + return Math.sqrt(+valueA || 0); } if (op === 'SUM') { if (valueA instanceof Array) { - return valueA.reduce((partialSum, a) => partialSum + a, 0); + return valueA.reduce((partialSum: number, a: any) => partialSum + (+a || 0), 0); } return 0; } if (op === 'AVERAGE') { if (valueA instanceof Array) { - return valueA.reduce((partialSum, a) => partialSum + a, 0) / valueA.length; + return valueA.reduce((partialSum: number, a: any) => partialSum + (+a || 0), 0) / valueA.length; } return 0; } if (op === 'CEIL') { - return Math.ceil(valueA); + return Math.ceil(+valueA || 0); } if (op === 'FLOOR') { - return Math.floor(valueA); + return Math.floor(+valueA || 0); } if (op === 'ROUND') { - return Math.round(valueA); + return Math.round(+valueA || 0); } if (op === 'EXP') { - return Math.exp(valueA); + return Math.exp(+valueA || 0); } if (op === 'LOG') { - return Math.log(valueA); + return Math.log(+valueA || 0); } if (op === 'MAX') { if (valueA instanceof Array) { - return Math.max(...valueA); + return Math.max(...valueA.map(v => +v || 0)); } return 0; } if (op === 'MIN') { if (valueA instanceof Array) { - return Math.min(...valueA); + return Math.min(...valueA.map(v => +v || 0)); } return 0; } @@ -236,36 +236,36 @@ function _parseExpression( // aggregated operators if (op === 'ASUM') { // aggregated sum - return (values[args[0]] as unknown[])?.reduce((acc, item) => acc + (+parseExpression(args[1], item as typeof values, {}, debug) || 0), 0) ?? 0; + return (values[args[0]] as unknown[])?.reduce((acc: number, item) => acc + (+parseExpression(args[1], item as typeof values, {}, debug) || 0), 0) ?? 0; } if (op === 'AMIN') { // aggregated min - return (values[args[0]] as unknown[])?.reduce((acc, item) => Math.min(acc, parseExpression(args[1], item as typeof values, {}, debug)), Infinity) ?? 0; + return (values[args[0]] as unknown[])?.reduce((acc: number, item) => Math.min(acc, (+parseExpression(args[1], item as typeof values, {}, debug) || 0)), Infinity) ?? 0; } if (op === 'AMAX') { // aggregated max - return (values[args[0]] as unknown[])?.reduce((acc, item) => Math.max(acc, parseExpression(args[1], item as typeof values, {}, debug)), -Infinity) ?? 0; + return (values[args[0]] as unknown[])?.reduce((acc: number, item) => Math.max(acc, (+parseExpression(args[1], item as typeof values, {}, debug) || 0)), -Infinity) ?? 0; } if (op === 'AAVG') { // aggregated average const arr = (values[args[0]] as unknown[]) ?? []; - return arr.reduce((acc, item) => acc + parseExpression(args[1], item as typeof values, {}, debug), 0) / arr.length; + return arr.reduce((acc: number, item) => acc + (+parseExpression(args[1], item as typeof values, {}, debug) || 0), 0) / arr.length; } if (op === 'AMUL') { // aggregated multiplication - return (values[args[0]] as unknown[])?.reduce((acc, item) => acc * parseExpression(args[1], item as typeof values, {}, debug), 1) ?? 0; + return (values[args[0]] as unknown[])?.reduce((acc: number, item) => acc * (+parseExpression(args[1], item as typeof values, {}, debug) || 0), 1) ?? 0; } if (op === 'AAND') { // aggregated and - return (values[args[0]] as unknown[])?.reduce((acc, item) => acc && parseExpression(args[1], item as typeof values, {}, debug), true) ?? false; + return (values[args[0]] as unknown[])?.reduce((acc: boolean, item) => acc && parseExpression(args[1], item as typeof values, {}, debug), true) ?? false; } if (op === 'AOR') { // aggregated or - return (values[args[0]] as unknown[])?.reduce((acc, item) => acc || parseExpression(args[1], item as typeof values, {}, debug), false) ?? false; + return (values[args[0]] as unknown[])?.reduce((acc: boolean, item) => acc || parseExpression(args[1], item as typeof values, {}, debug), false) ?? false; } if (op === 'ACOUNT') { // aggregated count - return (values[args[0]] as unknown[])?.reduce((acc, item) => acc + (parseExpression(args[1], item as typeof values, {}, debug) ? 1 : 0), 0) ?? 0; + return (values[args[0]] as unknown[])?.reduce((acc: number, item) => acc + (parseExpression(args[1], item as typeof values, {}, debug) ? 1 : 0), 0) ?? 0; } // loop operators if (op === 'MAP') { @@ -296,31 +296,31 @@ function _parseExpression( // arithmetic if (op === 'SUM') { - return valueA + valueB; + return (+valueA || 0) + (+valueB || 0); } if (op === 'SUBTRACT') { - return valueA - valueB; + return (+valueA || 0) - (+valueB || 0); } if (op === 'MULTIPLY') { - return valueA * valueB; + return (+valueA || 0) * (+valueB || 0); } if (op === 'DIVIDE') { - return valueA / valueB; + return (+valueA || 0) / (+valueB || 0); } if (op === 'REMAINDER') { - return valueA % valueB; + return (+valueA || 0) % (+valueB || 0); } if (op === 'ROUND') { - return (valueA as number).toFixed(valueB); + return ((+valueA || 0) as number).toFixed(+valueB || 0); } if (op === 'MAX') { - return Math.max(valueA, valueB); + return Math.max(+valueA || 0, +valueB || 0); } if (op === 'MIN') { - return Math.min(valueA, valueB); + return Math.min(+valueA || 0, +valueB || 0); } if (op === 'POWER') { - return Math.pow(valueA, valueB); + return Math.pow(+valueA || 0, +valueB || 0); } // string if (op === 'LEFT') { @@ -473,8 +473,8 @@ function _parseExpression( } else if (args.length % 2 === 0) { if (op === 'IFS') { for (let i = 0; i < args.length; i += 2) { - if (parseExpression(args[i], values, defaultValues, debug) === true) { - return parseExpression(args[i + 1], values, defaultValues, debug); + if (parseExpression(args[i]!, values, defaultValues, debug) === true) { + return parseExpression(args[i + 1]!, values, defaultValues, debug); } } return null; @@ -554,4 +554,4 @@ export function toSlug(str: unknown) { .replace(/-+/g, '-'); // collapse dashes return res; -} +} \ No newline at end of file