diff --git a/package-lock.json b/package-lock.json index af83eac..f52cab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@time-loop/hot-formula-parser", - "version": "4.1.0", + "version": "4.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@time-loop/hot-formula-parser", - "version": "4.1.0", + "version": "4.2.0", "dependencies": { "@formulajs/formulajs": "^2.3.0", "json5": "^2.2.3", diff --git a/package.json b/package.json index 7668777..b0006c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@time-loop/hot-formula-parser", - "version": "4.1.2", + "version": "4.2.0", "description": "Formula parser", "type": "commonjs", "main": "dist/index.js", diff --git a/src/clickup/formulajsProxy.ts b/src/clickup/formulajsProxy.ts new file mode 100644 index 0000000..18e8887 --- /dev/null +++ b/src/clickup/formulajsProxy.ts @@ -0,0 +1,52 @@ +import * as formulajs from '@formulajs/formulajs'; + +const hasNil = (...args: unknown[]) => + args.some((arg) => arg === null || arg === undefined || arg === '' || arg === false); +const nullToZero = (arg: unknown) => (hasNil(arg) ? 0 : arg); + +const overrides = { + DATE: (year: unknown, month: unknown, day: unknown) => + hasNil(year, month, day) ? Number.NaN : formulajs.DATE(year, month, day), + DATEVALUE: (dateText: unknown) => (hasNil(dateText) ? Number.NaN : formulajs.DATEVALUE(dateText)), + DAY: (date: unknown) => (hasNil(date) ? Number.NaN : formulajs.DAY(date)), + DAYS: (startDate: unknown, endDate: unknown) => + hasNil(startDate, endDate) ? Number.NaN : formulajs.DAYS(startDate, endDate), + DAYS360: (startDate: unknown, endDate: unknown, method: unknown) => + hasNil(startDate, endDate) ? Number.NaN : formulajs.DAYS360(startDate, endDate, method), + EDATE: (startDate: unknown, months: unknown) => + hasNil(startDate) ? Number.NaN : formulajs.EDATE(startDate, nullToZero(months)), + EOMONTH: (startDate: unknown, months: unknown) => + hasNil(startDate) ? Number.NaN : formulajs.EOMONTH(startDate, nullToZero(months)), + HOUR: (date: unknown) => (hasNil(date) ? Number.NaN : formulajs.HOUR(date)), + INTERVAL: (seconds: unknown) => formulajs.INTERVAL(nullToZero(seconds)), + ISOWEEKNUM: (date: unknown) => (hasNil(date) ? Number.NaN : formulajs.ISOWEEKNUM(date)), + MINUTE: (serialNumber: unknown) => (hasNil(serialNumber) ? Number.NaN : formulajs.MINUTE(serialNumber)), + MONTH: (date: unknown) => (hasNil(date) ? Number.NaN : formulajs.MONTH(date)), + SECOND: (serialNumber: unknown) => (hasNil(serialNumber) ? Number.NaN : formulajs.SECOND(serialNumber)), + TIME: (hour: unknown, minute: unknown, second: unknown) => + hasNil(hour, minute, second) ? Number.NaN : formulajs.TIME(hour, minute, second), + TIMEVALUE: (timeText: unknown) => (hasNil(timeText) ? Number.NaN : formulajs.TIMEVALUE(timeText)), + WEEKDAY: (serialNumber: unknown, returnType: unknown) => + hasNil(serialNumber) ? Number.NaN : formulajs.WEEKDAY(serialNumber, returnType), + WEEKNUM: (serialNumber: unknown, returnType: unknown) => + hasNil(serialNumber) ? Number.NaN : formulajs.WEEKNUM(serialNumber, returnType), + YEAR: (date: unknown) => (hasNil(date) ? Number.NaN : formulajs.YEAR(date)), + YEARFRAC: (startDate: unknown, endDate: unknown, basis: unknown) => + hasNil(startDate, endDate) ? Number.NaN : formulajs.YEARFRAC(startDate, endDate, basis), + WORKDAY: (startDate: unknown, days: unknown, holidays: unknown) => + hasNil(startDate) ? Number.NaN : formulajs.WORKDAY(startDate, nullToZero(days), holidays), + NETWORKDAYS: (startDate: unknown, endDate: unknown, holidays: unknown) => + hasNil(startDate, endDate) ? Number.NaN : formulajs.NETWORKDAYS(startDate, endDate, holidays), +}; + +export const formulajsProxy = new Proxy(formulajs, { + get: (target, prop) => { + if (prop in overrides) { + return overrides[prop as keyof typeof overrides]; + } + if (prop in target) { + return target[prop]; + } + return null; + }, +}); diff --git a/src/evaluate-by-operator/operator/formula-function.js b/src/evaluate-by-operator/operator/formula-function.js index 8f2a11b..ef58f2b 100644 --- a/src/evaluate-by-operator/operator/formula-function.js +++ b/src/evaluate-by-operator/operator/formula-function.js @@ -1,4 +1,4 @@ -import * as formulajs from '@formulajs/formulajs'; +import { formulajsProxy as formulajs } from '../../clickup/formulajsProxy'; import SUPPORTED_FORMULAS from '../../supported-formulas'; import { ERROR_NAME } from '../../error'; diff --git a/src/helper/number.js b/src/helper/number.js index 8b902cd..a2da138 100644 --- a/src/helper/number.js +++ b/src/helper/number.js @@ -1,4 +1,4 @@ -import * as formulajs from '@formulajs/formulajs'; +import { formulajsProxy as formulajs } from '../clickup/formulajsProxy'; import splitFormula from './formula'; import { getNumberOfDaysSinceEpoch, isDate } from './date'; diff --git a/test/integration/parsing/formula/date-time.js b/test/integration/parsing/formula/date-time.test.js similarity index 59% rename from test/integration/parsing/formula/date-time.js rename to test/integration/parsing/formula/date-time.test.js index 94a8a1c..4721b1c 100644 --- a/test/integration/parsing/formula/date-time.js +++ b/test/integration/parsing/formula/date-time.test.js @@ -3,7 +3,10 @@ import Parser from '../../../../src/parser'; describe('.parse() date & time formulas', () => { it.each([new Parser(), ClickUpParser.create()])('DATE', (parser) => { - expect(parser.parse('DATE()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('DATE()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DATE(null, 5, 12)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DATE(2001, null, 12)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DATE(2001, 5, null)')).toMatchObject({ error: null, result: Number.NaN }); const { error, result } = parser.parse('DATE(2001, 5, 12)'); @@ -14,85 +17,107 @@ describe('.parse() date & time formulas', () => { }); it.each([new Parser(), ClickUpParser.create()])('DATEVALUE', (parser) => { - expect(parser.parse('DATEVALUE()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('DATEVALUE()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DATEVALUE(null)')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('DATEVALUE("1/1/1900")')).toMatchObject({ error: null, result: 1 }); expect(parser.parse('DATEVALUE("1/1/2000")')).toMatchObject({ error: null, result: 36526 }); }); it.each([new Parser(), ClickUpParser.create()])('DAY', (parser) => { - expect(parser.parse('DAY()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('DAY()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('DAY(1)')).toMatchObject({ error: null, result: 1 }); expect(parser.parse('DAY(2958465)')).toMatchObject({ error: null, result: 31 }); expect(parser.parse('DAY("2958465")')).toMatchObject({ error: null, result: 31 }); + expect(parser.parse('DAY(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('DAYS', (parser) => { - expect(parser.parse('DAYS()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('DAYS(1)')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('DAYS()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DAYS(1)')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('DAYS(1, 6)')).toMatchObject({ error: null, result: -5 }); expect(parser.parse('DAYS("1/2/2000", "1/10/2001")')).toMatchObject({ error: null, result: -374 }); + expect(parser.parse('DAYS(null, 1)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DAYS(1, null)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DAYS(null, null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('DAYS360', (parser) => { - expect(parser.parse('DAYS360()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('DAYS360(1)')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('DAYS360()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DAYS360(1)')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('DAYS360(1, 6)')).toMatchObject({ error: '#VALUE!', result: null }); expect(parser.parse('DAYS360("1/1/1901", "2/1/1901", TRUE)')).toMatchObject({ error: null, result: 30 }); expect(parser.parse('DAYS360("1/1/1901", "12/31/1901", FALSE)')).toMatchObject({ error: null, result: 360 }); + expect(parser.parse('DAYS360(null, "1/1/1901")')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DAYS360("1/1/1901", null)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('DAYS360(null, null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('EDATE', (parser) => { - expect(parser.parse('EDATE()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('EDATE(1)')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('EDATE()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('EDATE(1)')).toMatchObject({ error: null, result: 1 }); expect(parser.parse('EDATE("1/1/1900", 1)')).toMatchObject({ error: null, result: 32 }); + expect(parser.parse('EDATE(null, 1)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('EDATE("1/1/1900", null)')).toMatchObject({ error: null, result: 1 }); + expect(parser.parse('EDATE(null, null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('EOMONTH', (parser) => { - expect(parser.parse('EOMONTH()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('EOMONTH(1)')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('EOMONTH()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('EOMONTH(1)')).toMatchObject({ error: null, result: 31 }); expect(parser.parse('EOMONTH("1/1/1900", 1)')).toMatchObject({ error: null, result: 59 }); + expect(parser.parse('EOMONTH(null, 1)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('EOMONTH("1/1/1900", null)')).toMatchObject({ error: null, result: 31 }); + expect(parser.parse('EOMONTH(null, null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('HOUR', (parser) => { - expect(parser.parse('HOUR()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('HOUR()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('HOUR("1/1/1900 16:33")')).toMatchObject({ error: null, result: 16 }); + expect(parser.parse('HOUR(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('INTERVAL', (parser) => { - expect(parser.parse('INTERVAL()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('INTERVAL()')).toMatchObject({ error: null, result: 'PT' }); expect(parser.parse('INTERVAL(0)')).toMatchObject({ error: null, result: 'PT' }); expect(parser.parse('INTERVAL(1)')).toMatchObject({ error: null, result: 'PT1S' }); expect(parser.parse('INTERVAL(60)')).toMatchObject({ error: null, result: 'PT1M' }); expect(parser.parse('INTERVAL(10000000)')).toMatchObject({ error: null, result: 'P3M25DT17H46M40S' }); + expect(parser.parse('INTERVAL(null)')).toMatchObject({ error: null, result: 'PT' }); }); it.each([new Parser(), ClickUpParser.create()])('ISOWEEKNUM', (parser) => { - expect(parser.parse('ISOWEEKNUM()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('ISOWEEKNUM()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('ISOWEEKNUM("1/8/1901")')).toMatchObject({ error: null, result: 2 }); expect(parser.parse('ISOWEEKNUM("6/6/1902")')).toMatchObject({ error: null, result: 23 }); + expect(parser.parse('ISOWEEKNUM(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('MINUTE', (parser) => { - expect(parser.parse('MINUTE()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('MINUTE()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('MINUTE("1/1/1901 1:01")')).toMatchObject({ error: null, result: 1 }); expect(parser.parse('MINUTE("1/1/1901 15:36")')).toMatchObject({ error: null, result: 36 }); + expect(parser.parse('MINUTE(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('MONTH', (parser) => { - expect(parser.parse('MONTH()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('MONTH()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('MONTH("2/1/1901")')).toMatchObject({ error: null, result: 2 }); expect(parser.parse('MONTH("10/1/1901")')).toMatchObject({ error: null, result: 10 }); + expect(parser.parse('MONTH(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('NETWORKDAYS', (parser) => { - expect(parser.parse('NETWORKDAYS()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('NETWORKDAYS("2/1/1901")')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('NETWORKDAYS()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('NETWORKDAYS("2/1/1901")')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('NETWORKDAYS("2013-12-04", "2013-12-05")')).toMatchObject({ error: null, result: 2 }); expect(parser.parse('NETWORKDAYS("2013-11-04", "2013-12-05")')).toMatchObject({ error: null, result: 24 }); expect(parser.parse('NETWORKDAYS("10/1/2012", "3/1/2013", [\'11/22/2012\'])')).toMatchObject({ error: null, result: 109, }); + expect(parser.parse('NETWORKDAYS(null, "2013-12-05")')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('NETWORKDAYS("2013-12-05", null)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('NETWORKDAYS(null, null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('NOW', (parser) => { @@ -104,26 +129,31 @@ describe('.parse() date & time formulas', () => { }); it.each([new Parser(), ClickUpParser.create()])('SECOND', (parser) => { - expect(parser.parse('SECOND()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('SECOND()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('SECOND("2/1/1901 13:33:12")')).toMatchObject({ error: null, result: 12 }); + expect(parser.parse('SECOND(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('TIME', (parser) => { - expect(parser.parse('TIME()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('TIME(0)')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('TIME(0, 0)')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('TIME()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('TIME(0)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('TIME(0, 0)')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('TIME(0, 0, 0)')).toMatchObject({ error: null, result: 0 }); expect(parser.parse('TIME(1, 1, 1)')).toMatchObject({ error: null, result: 0.04237268518518519 }); expect(parser.parse('TIME(24, 0, 0)')).toMatchObject({ error: null, result: 1 }); + expect(parser.parse('TIME(null, 0, 0)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('TIME(0, null, 0)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('TIME(0, 0, null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('TIMEVALUE', (parser) => { - expect(parser.parse('TIMEVALUE()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('TIMEVALUE()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('TIMEVALUE("1/1/1900 00:00:00")')).toMatchObject({ error: null, result: 0 }); expect(parser.parse('TIMEVALUE("1/1/1900 23:00:00")')).toMatchObject({ error: null, result: 0.9583333333333334, }); + expect(parser.parse('TIMEVALUE(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('TODAY', (parser) => { @@ -135,20 +165,25 @@ describe('.parse() date & time formulas', () => { }); it.each([new Parser(), ClickUpParser.create()])('WEEKDAY', (parser) => { - expect(parser.parse('WEEKDAY()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('WEEKDAY()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('WEEKDAY("1/1/1901")')).toMatchObject({ error: null, result: 3 }); expect(parser.parse('WEEKDAY("1/1/1901", 2)')).toMatchObject({ error: null, result: 2 }); + expect(parser.parse('WEEKDAY(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('WEEKNUM', (parser) => { - expect(parser.parse('WEEKNUM()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('WEEKNUM()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('WEEKNUM("2/1/1900")')).toMatchObject({ error: null, result: 5 }); expect(parser.parse('WEEKNUM("2/1/1909", 2)')).toMatchObject({ error: null, result: 6 }); + expect(parser.parse('WEEKNUM(null)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('WEEKNUM("a")')).toMatchObject({ error: '#VALUE!', result: null }); }); it.each([new Parser(), ClickUpParser.create()])('WORKDAY', (parser) => { - expect(parser.parse('WORKDAY()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('WORKDAY("1/1/1900")')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('WORKDAY()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('WORKDAY("1/1/1900")')).toMatchObject({ error: null, result: new Date(1900, 0, 1) }); + expect(parser.parse('WORKDAY(null)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('WORKDAY("1/1/1900", null)')).toMatchObject({ error: null, result: new Date(1900, 0, 1) }); const { result, error } = parser.parse('WORKDAY("1/1/1900", 1)'); @@ -157,17 +192,21 @@ describe('.parse() date & time formulas', () => { }); it.each([new Parser(), ClickUpParser.create()])('YEAR', (parser) => { - expect(parser.parse('YEAR()')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('YEAR()')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('YEAR("1/1/1904")')).toMatchObject({ error: null, result: 1904 }); expect(parser.parse('YEAR("12/12/2001")')).toMatchObject({ error: null, result: 2001 }); + expect(parser.parse('YEAR(null)')).toMatchObject({ error: null, result: Number.NaN }); }); it.each([new Parser(), ClickUpParser.create()])('YEARFRAC', (parser) => { - expect(parser.parse('YEARFRAC()')).toMatchObject({ error: '#VALUE!', result: null }); - expect(parser.parse('YEARFRAC("1/1/1904")')).toMatchObject({ error: '#VALUE!', result: null }); + expect(parser.parse('YEARFRAC()')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('YEARFRAC("1/1/1904")')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('YEARFRAC("1/1/1900", "1/2/1900")')).toMatchObject({ error: null, result: 0.002777777777777778, }); + expect(parser.parse('YEARFRAC(null, "1/2/1900")')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('YEARFRAC("1/1/1900", null)')).toMatchObject({ error: null, result: Number.NaN }); + expect(parser.parse('YEARFRAC(null, null)')).toMatchObject({ error: null, result: Number.NaN }); }); });