From a6e0bedc6fe1f8b30355f2b6a871a563b0b8337f Mon Sep 17 00:00:00 2001 From: Pranav C Date: Thu, 21 Dec 2023 09:16:58 +0000 Subject: [PATCH] feat: add method to validate and extract type --- .../nocodb-sdk/src/lib/formulaHelpers.spec.ts | 67 +- packages/nocodb-sdk/src/lib/formulaHelpers.ts | 1398 +++++++++++++++-- 2 files changed, 1293 insertions(+), 172 deletions(-) diff --git a/packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts b/packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts index 6fce60edb8..071733e6fb 100644 --- a/packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts +++ b/packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts @@ -1,6 +1,65 @@ -describe('auth', () => { - it('Formula parsing and type validation', async () => { - const response = {userId: 'fakeUserId'}; - expect(response).toEqual({ userId: 'fakeUserId' }); +import { + FormulaDataTypes, + validateFormulaAndExtractTreeWithType, +} from './formulaHelpers'; +import UITypes from './UITypes'; + +describe('Formula parsing and type validation', () => { + it('Simple formula', async () => { + const result = validateFormulaAndExtractTreeWithType('1 + 2', []); + + expect(result.dataType).toEqual(FormulaDataTypes.NUMERIC); + }); + + it('Formula with IF condition', async () => { + const result = validateFormulaAndExtractTreeWithType( + 'IF({column}, "Found", BLANK())', + [ + { + id: 'cid', + title: 'column', + uidt: UITypes.Number, + }, + ] + ); + + expect(result.dataType).toEqual(FormulaDataTypes.STRING); + }); + it('Complex formula', async () => { + const result = validateFormulaAndExtractTreeWithType( + 'SWITCH({column2},"value1",IF({column1}, "Found", BLANK()),"value2", 2)', + [ + { + id: 'id1', + title: 'column1', + uidt: UITypes.Number, + }, + { + id: 'id2', + title: 'column2', + uidt: UITypes.SingleLineText, + }, + ] + ); + + expect(result.dataType).toEqual(FormulaDataTypes.STRING); + + const result1 = validateFormulaAndExtractTreeWithType( + 'SWITCH({column2},"value1",IF({column1}, 1, 2),"value2", 2)', + [ + { + id: 'id1', + title: 'column1', + uidt: UITypes.Number, + }, + { + id: 'id2', + title: 'column2', + uidt: UITypes.SingleLineText, + }, + ] + ); + + expect(result1.dataType).toEqual(FormulaDataTypes.NUMERIC); }); }); diff --git a/packages/nocodb-sdk/src/lib/formulaHelpers.ts b/packages/nocodb-sdk/src/lib/formulaHelpers.ts index 8d5cbcbd15..c1c19b2562 100644 --- a/packages/nocodb-sdk/src/lib/formulaHelpers.ts +++ b/packages/nocodb-sdk/src/lib/formulaHelpers.ts @@ -1,7 +1,7 @@ import jsep from 'jsep'; import { ColumnType } from './Api'; -import {UITypes} from "../../build/main"; +import UITypes from './UITypes'; export const jsepCurlyHook = { name: 'curly', @@ -191,150 +191,955 @@ function escapeLiteral(v: string) { ); } +export enum FormulaDataTypes { + NUMERIC = 'numeric', + STRING = 'string', + DATE = 'date', + LOGICAL = 'logical', + COND_EXP = 'conditional_expression', + NULL = 'null', + BOOLEAN = 'boolean', +} + +export enum JSEPNode { + COMPOUND = 'Compound', + IDENTIFIER = 'Identifier', + MEMBER_EXP = 'MemberExpression', + LITERAL = 'Literal', + THIS_EXP = 'ThisExpression', + CALL_EXP = 'CallExpression', + UNARY_EXP = 'UnaryExpression', + BINARY_EXP = 'BinaryExpression', + ARRAY_EXP = 'ArrayExpression', +} + +interface FormulaMeta { + type?: string; + validation?: { + args?: { + min?: number; + max?: number; + rqd?: number; + }; + }; + description?: string; + syntax?: string; + examples?: string[]; + returnType?: ((args: any[]) => FormulaDataTypes) | FormulaDataTypes; +} + +const formulas: Record = { + AVG: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + }, + }, + description: 'Average of input parameters', + syntax: 'AVG(value1, [value2, ...])', + examples: [ + 'AVG(10, 5) => 7.5', + 'AVG({column1}, {column2})', + 'AVG({column1}, {column2}, {column3})', + ], + returnType: FormulaDataTypes.NUMERIC, + }, + ADD: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + }, + }, + description: 'Sum of input parameters', + syntax: 'ADD(value1, [value2, ...])', + examples: [ + 'ADD(5, 5) => 10', + 'ADD({column1}, {column2})', + 'ADD({column1}, {column2}, {column3})', + ], + returnType: FormulaDataTypes.NUMERIC, + }, + DATEADD: { + type: FormulaDataTypes.DATE, + validation: { + args: { + rqd: 3, + }, + }, + description: 'Adds a "count" units to Datetime.', + syntax: + 'DATEADD(date | datetime, value, ["day" | "week" | "month" | "year"])', + examples: [ + 'DATEADD({column1}, 2, "day")', + 'DATEADD({column1}, -2, "day")', + 'DATEADD({column1}, 2, "week")', + 'DATEADD({column1}, -2, "week")', + 'DATEADD({column1}, 2, "month")', + 'DATEADD({column1}, -2, "month")', + 'DATEADD({column1}, 2, "year")', + 'DATEADD({column1}, -2, "year")', + ], + returnType: FormulaDataTypes.DATE, + }, + DATETIME_DIFF: { + type: FormulaDataTypes.DATE, + validation: { + args: { + min: 2, + max: 3, + }, + }, + description: + 'Calculate the difference of two given date / datetime in specified units.', + syntax: + 'DATETIME_DIFF(date | datetime, date | datetime, ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"])', + examples: [ + 'DATEDIFF({column1}, {column2})', + 'DATEDIFF({column1}, {column2}, "seconds")', + 'DATEDIFF({column1}, {column2}, "s")', + 'DATEDIFF({column1}, {column2}, "years")', + 'DATEDIFF({column1}, {column2}, "y")', + 'DATEDIFF({column1}, {column2}, "minutes")', + 'DATEDIFF({column1}, {column2}, "m")', + 'DATEDIFF({column1}, {column2}, "days")', + 'DATEDIFF({column1}, {column2}, "d")', + ], + returnType: FormulaDataTypes.NUMERIC, + }, + AND: { + type: FormulaDataTypes.COND_EXP, + validation: { + args: { + min: 1, + }, + }, + description: 'TRUE if all expr evaluate to TRUE', + syntax: 'AND(expr1, [expr2, ...])', + examples: ['AND(5 > 2, 5 < 10) => 1', 'AND({column1} > 2, {column2} < 10)'], + returnType: FormulaDataTypes.COND_EXP, + }, + OR: { + type: FormulaDataTypes.COND_EXP, + validation: { + args: { + min: 1, + }, + }, + description: 'TRUE if at least one expr evaluates to TRUE', + syntax: 'OR(expr1, [expr2, ...])', + examples: ['OR(5 > 2, 5 < 10) => 1', 'OR({column1} > 2, {column2} < 10)'], + returnType: FormulaDataTypes.COND_EXP, + }, + CONCAT: { + type: FormulaDataTypes.STRING, + validation: { + args: { + min: 1, + }, + }, + description: 'Concatenated string of input parameters', + syntax: 'CONCAT(str1, [str2, ...])', + examples: [ + 'CONCAT("AA", "BB", "CC") => "AABBCC"', + 'CONCAT({column1}, {column2}, {column3})', + ], + returnType: FormulaDataTypes.STRING, + }, + TRIM: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Remove trailing and leading whitespaces from input parameter', + syntax: 'TRIM(str)', + examples: [ + 'TRIM(" HELLO WORLD ") => "HELLO WORLD"', + 'TRIM({column1})', + ], + returnType: FormulaDataTypes.STRING, + }, + UPPER: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Upper case converted string of input parameter', + syntax: 'UPPER(str)', + examples: ['UPPER("nocodb") => "NOCODB"', 'UPPER({column1})'], + returnType: FormulaDataTypes.STRING, + }, + LOWER: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Lower case converted string of input parameter', + syntax: 'LOWER(str)', + examples: ['LOWER("NOCODB") => "nocodb"', 'LOWER({column1})'], + returnType: FormulaDataTypes.STRING, + }, + LEN: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Input parameter character length', + syntax: 'LEN(value)', + examples: ['LEN("NocoDB") => 6', 'LEN({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + MIN: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + }, + }, + description: 'Minimum value amongst input parameters', + syntax: 'MIN(value1, [value2, ...])', + examples: ['MIN(1000, 2000) => 1000', 'MIN({column1}, {column2})'], + returnType: FormulaDataTypes.NUMERIC, + }, + MAX: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + }, + }, + description: 'Maximum value amongst input parameters', + syntax: 'MAX(value1, [value2, ...])', + examples: ['MAX(1000, 2000) => 2000', 'MAX({column1}, {column2})'], + returnType: FormulaDataTypes.NUMERIC, + }, + CEILING: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Rounded next largest integer value of input parameter', + syntax: 'CEILING(value)', + examples: ['CEILING(1.01) => 2', 'CEILING({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + FLOOR: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: + 'Rounded largest integer less than or equal to input parameter', + syntax: 'FLOOR(value)', + examples: ['FLOOR(3.1415) => 3', 'FLOOR({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + ROUND: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + max: 2, + }, + }, + description: + 'Rounded number to a specified number of decimal places or the nearest integer if not specified', + syntax: 'ROUND(value, precision), ROUND(value)', + examples: [ + 'ROUND(3.1415) => 3', + 'ROUND(3.1415, 2) => 3.14', + 'ROUND({column1}, 3)', + ], + returnType: FormulaDataTypes.NUMERIC, + }, + MOD: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 2, + }, + }, + description: 'Remainder after integer division of input parameters', + syntax: 'MOD(value1, value2)', + examples: ['MOD(1024, 1000) => 24', 'MOD({column}, 2)'], + returnType: FormulaDataTypes.NUMERIC, + }, + REPEAT: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 2, + }, + }, + description: + 'Specified copies of the input parameter string concatenated together', + syntax: 'REPEAT(str, count)', + examples: ['REPEAT("A", 5) => "AAAAA"', 'REPEAT({column}, 5)'], + returnType: FormulaDataTypes.STRING, + }, + LOG: { + type: FormulaDataTypes.NUMERIC, + validation: {}, + description: + 'Logarithm of input parameter to the base (default = e) specified', + syntax: 'LOG([base], value)', + examples: ['LOG(2, 1024) => 10', 'LOG(2, {column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + EXP: { + type: FormulaDataTypes.NUMERIC, + validation: {}, + description: 'Exponential value of input parameter (e ^ power)', + syntax: 'EXP(power)', + examples: ['EXP(1) => 2.718281828459045', 'EXP({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + POWER: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 2, + }, + }, + description: 'base to the exponent power, as in base ^ exponent', + syntax: 'POWER(base, exponent)', + examples: ['POWER(2, 10) => 1024', 'POWER({column1}, 10)'], + returnType: FormulaDataTypes.NUMERIC, + }, + SQRT: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Square root of the input parameter', + syntax: 'SQRT(value)', + examples: ['SQRT(100) => 10', 'SQRT({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + ABS: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Absolute value of the input parameter', + syntax: 'ABS(value)', + examples: ['ABS({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + NOW: { + type: FormulaDataTypes.DATE, + validation: { + args: { + rqd: 0, + }, + }, + description: 'Returns the current time and day', + syntax: 'NOW()', + examples: ['NOW() => 2022-05-19 17:20:43'], + returnType: FormulaDataTypes.DATE, + }, + REPLACE: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 3, + }, + }, + description: + 'String, after replacing all occurrences of srchStr with rplcStr', + syntax: 'REPLACE(str, srchStr, rplcStr)', + examples: [ + 'REPLACE("AABBCC", "AA", "BB") => "BBBBCC"', + 'REPLACE({column1}, {column2}, {column3})', + ], + returnType: FormulaDataTypes.STRING, + }, + SEARCH: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 2, + }, + }, + description: 'Index of srchStr specified if found, 0 otherwise', + syntax: 'SEARCH(str, srchStr)', + examples: [ + 'SEARCH("HELLO WORLD", "WORLD") => 7', + 'SEARCH({column1}, "abc")', + ], + returnType: FormulaDataTypes.NUMERIC, + }, + INT: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Integer value of input parameter', + syntax: 'INT(value)', + examples: ['INT(3.1415) => 3', 'INT({column1})'], + returnType: FormulaDataTypes.NUMERIC, + }, + RIGHT: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 2, + }, + }, + description: 'n characters from the end of input parameter', + syntax: 'RIGHT(str, n)', + examples: ['RIGHT("HELLO WORLD", 5) => WORLD', 'RIGHT({column1}, 3)'], + returnType: FormulaDataTypes.STRING, + }, + LEFT: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 2, + }, + }, + description: 'n characters from the beginning of input parameter', + syntax: 'LEFT(str, n)', + examples: ['LEFT({column1}, 2)', 'LEFT("ABCD", 2) => "AB"'], + returnType: FormulaDataTypes.STRING, + }, + SUBSTR: { + type: FormulaDataTypes.STRING, + validation: { + args: { + min: 2, + max: 3, + }, + }, + description: + 'Substring of length n of input string from the postition specified', + syntax: ' SUBTR(str, position, [n])', + examples: [ + 'SUBSTR("HELLO WORLD", 7) => WORLD', + 'SUBSTR("HELLO WORLD", 7, 3) => WOR', + 'SUBSTR({column1}, 7, 5)', + ], + returnType: FormulaDataTypes.STRING, + }, + MID: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 3, + }, + }, + description: 'Alias for SUBSTR', + syntax: 'MID(str, position, [count])', + examples: ['MID("NocoDB", 3, 2) => "co"', 'MID({column1}, 3, 2)'], + returnType: FormulaDataTypes.STRING, + }, + IF: { + type: FormulaDataTypes.COND_EXP, + validation: { + args: { + min: 2, + max: 3, + }, + }, + description: 'SuccessCase if expr evaluates to TRUE, elseCase otherwise', + syntax: 'IF(expr, successCase, elseCase)', + examples: [ + 'IF(5 > 1, "YES", "NO") => "YES"', + 'IF({column} > 1, "YES", "NO")', + ], + returnType: (argsTypes: FormulaDataTypes[]) => { + if (argsTypes.slice(1).includes(FormulaDataTypes.STRING)) { + return FormulaDataTypes.STRING; + } else if (argsTypes.slice(1).includes(FormulaDataTypes.NUMERIC)) { + return FormulaDataTypes.NUMERIC; + } else if (argsTypes.slice(1).includes(FormulaDataTypes.BOOLEAN)) { + return FormulaDataTypes.BOOLEAN; + } + + return argsTypes[1]; + }, + }, + SWITCH: { + type: FormulaDataTypes.COND_EXP, + validation: { + args: { + min: 3, + }, + }, + description: 'Switch case value based on expr output', + syntax: 'SWITCH(expr, [pattern, value, ..., default])', + examples: [ + 'SWITCH(1, 1, "One", 2, "Two", "N/A") => "One""', + 'SWITCH(2, 1, "One", 2, "Two", "N/A") => "Two"', + 'SWITCH(3, 1, "One", 2, "Two", "N/A") => "N/A"', + 'SWITCH({column1}, 1, "One", 2, "Two", "N/A")', + ], + // todo: resolve return type based on the args + returnType: (argTypes: FormulaDataTypes[]) => { + const returnArgTypes = argTypes.slice(2).filter((_, i) => i % 2 === 0); + + if (returnArgTypes.includes(FormulaDataTypes.STRING)) { + return FormulaDataTypes.STRING; + } else if (returnArgTypes.includes(FormulaDataTypes.NUMERIC)) { + return FormulaDataTypes.NUMERIC; + } else if (returnArgTypes.includes(FormulaDataTypes.BOOLEAN)) { + return FormulaDataTypes.BOOLEAN; + } + + return returnArgTypes[0]; + }, + }, + URL: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 1, + }, + }, + description: 'Convert to a hyperlink if it is a valid URL', + syntax: 'URL(str)', + examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'], + returnType: FormulaDataTypes.STRING, + }, + WEEKDAY: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + max: 2, + }, + }, + description: + 'Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default', + syntax: 'WEEKDAY(date, [startDayOfWeek])', + examples: ['WEEKDAY("2021-06-09")', 'WEEKDAY(NOW(), "sunday")'], + returnType: FormulaDataTypes.NUMERIC, + }, + + TRUE: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + max: 0, + }, + }, + description: 'Returns 1', + syntax: 'TRUE()', + examples: ['TRUE()'], + returnType: FormulaDataTypes.NUMERIC, + }, + + FALSE: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + max: 0, + }, + }, + description: 'Returns 0', + syntax: 'FALSE()', + examples: ['FALSE()'], + returnType: FormulaDataTypes.NUMERIC, + }, + + REGEX_MATCH: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 2, + }, + }, + description: + 'Returns 1 if the input text matches a regular expression or 0 if it does not.', + syntax: 'REGEX_MATCH(string, regex)', + examples: ['REGEX_MATCH({title}, "abc.*")'], + returnType: FormulaDataTypes.NUMERIC, + }, + REGEX_EXTRACT: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 2, + }, + }, + description: 'Returns the first match of a regular expression in a string.', + syntax: 'REGEX_EXTRACT(string, regex)', + examples: ['REGEX_EXTRACT({title}, "abc.*")'], + returnType: FormulaDataTypes.STRING, + }, + REGEX_REPLACE: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 3, + }, + }, + description: + 'Replaces all matches of a regular expression in a string with a replacement string', + syntax: 'REGEX_MATCH(string, regex, replacement)', + examples: ['REGEX_EXTRACT({title}, "abc.*", "abcd")'], + returnType: FormulaDataTypes.STRING, + }, + BLANK: { + type: FormulaDataTypes.STRING, + validation: { + args: { + rqd: 0, + }, + }, + description: 'Returns a blank value(null)', + syntax: 'BLANK()', + examples: ['BLANK()'], + returnType: FormulaDataTypes.NULL, + }, + XOR: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + }, + }, + description: + 'Returns true if an odd number of arguments are true, and false otherwise.', + syntax: 'XOR(expression, [exp2, ...])', + examples: ['XOR(TRUE(), FALSE(), TRUE())'], + returnType: FormulaDataTypes.BOOLEAN, + }, + EVEN: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: + 'Returns the nearest even integer that is greater than or equal to the specified value', + syntax: 'EVEN(value)', + examples: ['EVEN({column})'], + returnType: FormulaDataTypes.NUMERIC, + }, + ODD: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + rqd: 1, + }, + }, + description: + 'Returns the nearest odd integer that is greater than or equal to the specified value', + syntax: 'ODD(value)', + examples: ['ODD({column})'], + returnType: FormulaDataTypes.NUMERIC, + }, + RECORD_ID: { + validation: { + args: { + rqd: 0, + }, + }, + description: 'Returns the record id of the current record', + syntax: 'RECORD_ID()', + examples: ['RECORD_ID()'], + // todo: resolve return type based on the args + returnType: () => { + return FormulaDataTypes.STRING; + }, + }, + COUNTA: { + validation: { + args: { + min: 1, + }, + }, + description: 'Counts the number of non-empty arguments', + syntax: 'COUNTA(value1, [value2, ...])', + examples: ['COUNTA({field1}, {field2})'], + returnType: FormulaDataTypes.NUMERIC, + }, + COUNT: { + validation: { + args: { + min: 1, + }, + }, + description: 'Count the number of arguments that are numbers', + syntax: 'COUNT(value1, [value2, ...])', + examples: ['COUNT({field1}, {field2})'], + returnType: FormulaDataTypes.NUMERIC, + }, + COUNTALL: { + validation: { + args: { + min: 1, + }, + }, + description: 'Counts the number of arguments', + syntax: 'COUNTALL(value1, [value2, ...])', + examples: ['COUNTALL({field1}, {field2})'], + returnType: FormulaDataTypes.NUMERIC, + }, + ROUNDDOWN: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + max: 2, + }, + }, + description: + 'Round down the value after the decimal point to the number of decimal places given by "precision"(default is 0)', + syntax: 'ROUNDDOWN(value, [precision])', + examples: ['ROUNDDOWN({field1})', 'ROUNDDOWN({field1}, 2)'], + returnType: FormulaDataTypes.NUMERIC, + }, + ROUNDUP: { + type: FormulaDataTypes.NUMERIC, + validation: { + args: { + min: 1, + max: 2, + }, + }, + description: + 'Round up the value after the decimal point to the number of decimal places given by "precision"(default is 0)', + syntax: 'ROUNDUP(value, [precision])', + examples: ['ROUNDUP({field1})', 'ROUNDUP({field1}, 2)'], + returnType: FormulaDataTypes.NUMERIC, + }, + VALUE: { + validation: { + args: { + rqd: 1, + }, + }, + description: + 'Extract the numeric value from a string, if `%` or `-` is present, it will handle it accordingly and return the numeric value', + syntax: 'VALUE(value)', + examples: ['VALUE({field})', 'VALUE("abc10000%")', 'VALUE("$10000")'], + returnType: FormulaDataTypes.NUMERIC, + }, + // Disabling these functions for now; these act as alias for CreatedAt & UpdatedAt fields; + // Issue: Error noticed if CreatedAt & UpdatedAt fields are removed from the table after creating these formulas + // + // CREATED_TIME: { + // validation: { + // args: { + // rqd: 0, + // }, + // }, + // description: 'Returns the created time of the current record if it exists', + // syntax: 'CREATED_TIME()', + // examples: ['CREATED_TIME()'], + // }, + // LAST_MODIFIED_TIME: { + // validation: { + // args: { + // rqd: 0, + // }, + // }, + // description: 'Returns the last modified time of the current record if it exists', + // syntax: ' LAST_MODIFIED_TIME()', + // examples: [' LAST_MODIFIED_TIME()'], + // }, +}; -function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) { - let returnType: formulaTypes +/* +function validateAgainstMeta( + parsedTree: any, + errors = new Set(), + typeErrors = new Set() +) { + let returnType: FormulaDataTypes; if (parsedTree.type === JSEPNode.CALL_EXP) { - const calleeName = parsedTree.callee.name.toUpperCase() + const calleeName = parsedTree.callee.name.toUpperCase(); // validate function name if (!availableFunctions.includes(calleeName)) { - errors.add(t('msg.formula.functionNotAvailable', { function: calleeName })) + errors.add( + t('msg.formula.functionNotAvailable', { function: calleeName }) + ); } // validate arguments - const validation = formulas[calleeName] && formulas[calleeName].validation + const validation = formulas[calleeName] && formulas[calleeName].validation; if (validation && validation.args) { - if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) { - errors.add(t('msg.formula.requiredArgumentsFormula', { requiredArguments: validation.args.rqd, calleeName })) - } else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) { + if ( + validation.args.rqd !== undefined && + validation.args.rqd !== parsedTree.arguments.length + ) { + errors.add( + t('msg.formula.requiredArgumentsFormula', { + requiredArguments: validation.args.rqd, + calleeName, + }) + ); + } else if ( + validation.args.min !== undefined && + validation.args.min > parsedTree.arguments.length + ) { errors.add( t('msg.formula.minRequiredArgumentsFormula', { minRequiredArguments: validation.args.min, calleeName, - }), - ) - } else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) { + }) + ); + } else if ( + validation.args.max !== undefined && + validation.args.max < parsedTree.arguments.length + ) { errors.add( t('msg.formula.maxRequiredArgumentsFormula', { maxRequiredArguments: validation.args.max, calleeName, - }), - ) + }) + ); } } - parsedTree.arguments.map((arg: Record) => validateAgainstMeta(arg, errors)) + parsedTree.arguments.map((arg: Record) => + validateAgainstMeta(arg, errors) + ); // get args type and validate const validateResult = parsedTree.arguments.map((arg) => { - return validateAgainstMeta(arg, errors, typeErrors) - }) + return validateAgainstMeta(arg, errors, typeErrors); + }); const argsTypes = validateResult.map((v: any) => v.returnType); if (typeof validateResult[0].returnType === 'function') { - returnType = formulas[calleeName].returnType(argsTypes) + returnType = formulas[calleeName].returnType(argsTypes); } else if (validateResult[0]) { - returnType = formulas[calleeName].returnType + returnType = formulas[calleeName].returnType; } // validate data type if (parsedTree.callee.type === JSEPNode.IDENTIFIER) { - const expectedType = formulas[calleeName.toUpperCase()].type + const expectedType = formulas[calleeName.toUpperCase()].type; - if (expectedType === formulaTypes.NUMERIC) { + if (expectedType === FormulaDataTypes.NUMERIC) { if (calleeName === 'WEEKDAY') { // parsedTree.arguments[0] = date validateAgainstType( parsedTree.arguments[0], - formulaTypes.DATE, + FormulaDataTypes.DATE, (v: any) => { if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.firstParamWeekDayHaveDate')) + typeErrors.add(t('msg.formula.firstParamWeekDayHaveDate')); } }, - typeErrors, - ) + typeErrors + ); // parsedTree.arguments[1] = startDayOfWeek (optional) validateAgainstType( parsedTree.arguments[1], - formulaTypes.STRING, + FormulaDataTypes.STRING, (v: any) => { if ( typeof v !== 'string' || - !['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes(v.toLowerCase()) + ![ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ].includes(v.toLowerCase()) ) { - typeErrors.add(t('msg.formula.secondParamWeekDayHaveDate')) + typeErrors.add(t('msg.formula.secondParamWeekDayHaveDate')); } }, - typeErrors, - ) + typeErrors + ); } else { - parsedTree.arguments.map((arg: Record) => validateAgainstType(arg, expectedType, null, typeErrors, argsTypes)) + parsedTree.arguments.map((arg: Record) => + validateAgainstType(arg, expectedType, null, typeErrors, argsTypes) + ); } - } else if (expectedType === formulaTypes.DATE) { + } else if (expectedType === FormulaDataTypes.DATE) { if (calleeName === 'DATEADD') { // parsedTree.arguments[0] = date validateAgainstType( parsedTree.arguments[0], - formulaTypes.DATE, + FormulaDataTypes.DATE, (v: any) => { if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.firstParamDateAddHaveDate')) + typeErrors.add(t('msg.formula.firstParamDateAddHaveDate')); } }, - typeErrors, - ) + typeErrors + ); // parsedTree.arguments[1] = numeric validateAgainstType( parsedTree.arguments[1], - formulaTypes.NUMERIC, + FormulaDataTypes.NUMERIC, (v: any) => { if (typeof v !== 'number') { - typeErrors.add(t('msg.formula.secondParamDateAddHaveNumber')) + typeErrors.add(t('msg.formula.secondParamDateAddHaveNumber')); } }, - typeErrors, - ) + typeErrors + ); // parsedTree.arguments[2] = ["day" | "week" | "month" | "year"] validateAgainstType( parsedTree.arguments[2], - formulaTypes.STRING, + FormulaDataTypes.STRING, (v: any) => { if (!['day', 'week', 'month', 'year'].includes(v)) { - typeErrors.add(typeErrors.add(t('msg.formula.thirdParamDateAddHaveDate'))) + typeErrors.add( + typeErrors.add(t('msg.formula.thirdParamDateAddHaveDate')) + ); } }, - typeErrors, - ) + typeErrors + ); } else if (calleeName === 'DATETIME_DIFF') { // parsedTree.arguments[0] = date validateAgainstType( parsedTree.arguments[0], - formulaTypes.DATE, + FormulaDataTypes.DATE, (v: any) => { if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.firstParamDateDiffHaveDate')) + typeErrors.add(t('msg.formula.firstParamDateDiffHaveDate')); } }, - typeErrors, - ) + typeErrors + ); // parsedTree.arguments[1] = date validateAgainstType( parsedTree.arguments[1], - formulaTypes.DATE, + FormulaDataTypes.DATE, (v: any) => { if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.secondParamDateDiffHaveDate')) + typeErrors.add(t('msg.formula.secondParamDateDiffHaveDate')); } }, - typeErrors, - ) + typeErrors + ); // parsedTree.arguments[2] = ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"] validateAgainstType( parsedTree.arguments[2], - formulaTypes.STRING, + FormulaDataTypes.STRING, (v: any) => { if ( ![ @@ -358,23 +1163,27 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n 'y', ].includes(v) ) { - typeErrors.add(t('msg.formula.thirdParamDateDiffHaveDate')) + typeErrors.add(t('msg.formula.thirdParamDateDiffHaveDate')); } }, - typeErrors, - ) + typeErrors + ); } } } - errors = new Set([...errors, ...typeErrors]) + errors = new Set([...errors, ...typeErrors]); } else if (parsedTree.type === JSEPNode.IDENTIFIER) { - if (supportedColumns.value.filter((c) => !column || column.value?.id !== c.id).every((c) => c.title !== parsedTree.name)) { + if ( + supportedColumns.value + .filter((c) => !column || column.value?.id !== c.id) + .every((c) => c.title !== parsedTree.name) + ) { errors.add( t('msg.formula.columnNotAvailable', { columnName: parsedTree.name, - }), - ) + }) + ); } // check circular reference @@ -390,149 +1199,170 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n ...new Set( (c.colOptions.formula.match(/cl_\w{14}/g) || []).filter( (colId: string) => - supportedColumns.value.filter((col: ColumnType) => col.id === colId && col.uidt === UITypes.Formula).length, - ), + supportedColumns.value.filter( + (col: ColumnType) => + col.id === colId && col.uidt === UITypes.Formula + ).length + ) ), - ] + ]; if (neighbours.length > 0) { // e.g. formula column 1 -> [formula column 2, formula column3] - res.push({ [c.id]: neighbours }) + res.push({ [c.id]: neighbours }); } - return res - }, []) + return res; + }, []); // include target formula column (i.e. the one to be saved if applicable) const targetFormulaCol = supportedColumns.value.find( - (c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula, - ) + (c: ColumnType) => + c.title === parsedTree.name && c.uidt === UITypes.Formula + ); if (targetFormulaCol && column.value?.id) { formulaPaths.push({ [column.value?.id as string]: [targetFormulaCol.id], - }) + }); } - const vertices = formulaPaths.length + const vertices = formulaPaths.length; if (vertices > 0) { // perform kahn's algo for cycle detection - const adj = new Map() - const inDegrees = new Map() + const adj = new Map(); + const inDegrees = new Map(); // init adjacency list & indegree for (const [_, v] of Object.entries(formulaPaths)) { - const src = Object.keys(v)[0] - const neighbours = v[src] - inDegrees.set(src, inDegrees.get(src) || 0) + const src = Object.keys(v)[0]; + const neighbours = v[src]; + inDegrees.set(src, inDegrees.get(src) || 0); for (const neighbour of neighbours) { - adj.set(src, (adj.get(src) || new Set()).add(neighbour)) - inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1) + adj.set(src, (adj.get(src) || new Set()).add(neighbour)); + inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1); } } - const queue: string[] = [] + const queue: string[] = []; // put all vertices with in-degree = 0 (i.e. no incoming edges) to queue inDegrees.forEach((inDegree, col) => { if (inDegree === 0) { // in-degree = 0 means we start traversing from this node - queue.push(col) + queue.push(col); } - }) + }); // init count of visited vertices - let visited = 0 + let visited = 0; // BFS while (queue.length !== 0) { // remove a vertex from the queue - const src = queue.shift() + const src = queue.shift(); // if this node has neighbours, increase visited by 1 - const neighbours = adj.get(src) || new Set() + const neighbours = adj.get(src) || new Set(); if (neighbours.size > 0) { - visited += 1 + visited += 1; } // iterate each neighbouring nodes neighbours.forEach((neighbour: string) => { // decrease in-degree of its neighbours by 1 - inDegrees.set(neighbour, inDegrees.get(neighbour) - 1) + inDegrees.set(neighbour, inDegrees.get(neighbour) - 1); // if in-degree becomes 0 if (inDegrees.get(neighbour) === 0) { // then put the neighboring node to the queue - queue.push(neighbour) + queue.push(neighbour); } - }) + }); } // vertices not same as visited = cycle found if (vertices !== visited) { - errors.add(t('msg.formula.cantSaveCircularReference')) + errors.add(t('msg.formula.cantSaveCircularReference')); } } } else if (parsedTree.type === JSEPNode.BINARY_EXP) { if (!availableBinOps.includes(parsedTree.operator)) { - errors.add(t('msg.formula.operationNotAvailable', { operation: parsedTree.operator })) + errors.add( + t('msg.formula.operationNotAvailable', { + operation: parsedTree.operator, + }) + ); } - validateAgainstMeta(parsedTree.left, errors) - validateAgainstMeta(parsedTree.right, errors) + validateAgainstMeta(parsedTree.left, errors); + validateAgainstMeta(parsedTree.right, errors); // todo: type extraction for binary exps - returnType = formulaTypes.NUMERIC - } else if (parsedTree.type === JSEPNode.LITERAL || parsedTree.type === JSEPNode.UNARY_EXP) { + returnType = FormulaDataTypes.NUMERIC; + } else if ( + parsedTree.type === JSEPNode.LITERAL || + parsedTree.type === JSEPNode.UNARY_EXP + ) { if (parsedTree.type === JSEPNode.LITERAL) { if (typeof parsedTree.value === 'number') { - returnType = formulaTypes.NUMERIC + returnType = FormulaDataTypes.NUMERIC; } else if (typeof parsedTree.value === 'string') { - returnType = formulaTypes.STRING + returnType = FormulaDataTypes.STRING; } else if (typeof parsedTree.value === 'boolean') { - returnType = formulaTypes.BOOLEAN + returnType = FormulaDataTypes.BOOLEAN; } else { - returnType = formulaTypes.STRING + returnType = FormulaDataTypes.STRING; } } // do nothing } else if (parsedTree.type === JSEPNode.COMPOUND) { if (parsedTree.body.length) { - errors.add(t('msg.formula.cantSaveFieldFormulaInvalid')) + errors.add(t('msg.formula.cantSaveFieldFormulaInvalid')); } } else { - errors.add(t('msg.formula.cantSaveFieldFormulaInvalid')) + errors.add(t('msg.formula.cantSaveFieldFormulaInvalid')); } - return { errors, returnType } + return { errors, returnType }; } -function validateAgainstType(parsedTree: any, expectedType: string, func: any, typeErrors = new Set(), argTypes: formulaTypes = []) { - let type +function validateAgainstType( + parsedTree: any, + expectedType: string, + func: any, + typeErrors = new Set(), + argTypes: FormulaDataTypes = [] +) { + let type; if (parsedTree === false || typeof parsedTree === 'undefined') { - return typeErrors + return typeErrors; } if (parsedTree.type === JSEPNode.LITERAL) { if (typeof func === 'function') { - func(parsedTree.value) - } else if (expectedType === formulaTypes.NUMERIC) { + func(parsedTree.value); + } else if (expectedType === FormulaDataTypes.NUMERIC) { if (typeof parsedTree.value !== 'number') { - typeErrors.add(t('msg.formula.numericTypeIsExpected')) + typeErrors.add(t('msg.formula.numericTypeIsExpected')); } else { - type = formulaTypes.NUMERIC + type = FormulaDataTypes.NUMERIC; } - } else if (expectedType === formulaTypes.STRING) { + } else if (expectedType === FormulaDataTypes.STRING) { if (typeof parsedTree.value !== 'string') { - typeErrors.add(t('msg.formula.stringTypeIsExpected')) + typeErrors.add(t('msg.formula.stringTypeIsExpected')); } else { - type = formulaTypes.STRING + type = FormulaDataTypes.STRING; } } } else if (parsedTree.type === JSEPNode.IDENTIFIER) { - const col = supportedColumns.value.find((c) => c.title === parsedTree.name) + const col = supportedColumns.value.find((c) => c.title === parsedTree.name); if (col === undefined) { - return + return; } if (col.uidt === UITypes.Formula) { - const foundType = getRootDataType(jsep(col.colOptions?.formula_raw)) - type = foundType + const foundType = getRootDataType(jsep(col.colOptions?.formula_raw)); + type = foundType; if (foundType === 'N/A') { - typeErrors.add(t('msg.formula.notSupportedToReferenceColumn', { columnName: col.title })) + typeErrors.add( + t('msg.formula.notSupportedToReferenceColumn', { + columnName: col.title, + }) + ); } else if (expectedType !== foundType) { typeErrors.add( t('msg.formula.typeIsExpectedButFound', { type: expectedType, found: foundType, - }), - ) + }) + ); } } else { switch (col.uidt) { @@ -544,17 +1374,17 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t case UITypes.PhoneNumber: case UITypes.Email: case UITypes.URL: - if (expectedType !== formulaTypes.STRING) { + if (expectedType !== FormulaDataTypes.STRING) { typeErrors.add( t('msg.formula.columnWithTypeFoundButExpected', { columnName: parsedTree.name, - columnType: formulaTypes.STRING, + columnType: FormulaDataTypes.STRING, expectedType, - }), - ) + }) + ); } - type = formulaTypes.STRING - break + type = FormulaDataTypes.STRING; + break; // numeric case UITypes.Year: @@ -564,34 +1394,34 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t case UITypes.Count: case UITypes.AutoNumber: case UITypes.Currency: - if (expectedType !== formulaTypes.NUMERIC) { + if (expectedType !== FormulaDataTypes.NUMERIC) { typeErrors.add( t('msg.formula.columnWithTypeFoundButExpected', { columnName: parsedTree.name, - columnType: formulaTypes.NUMERIC, + columnType: FormulaDataTypes.NUMERIC, expectedType, - }), - ) + }) + ); } - type = formulaTypes.NUMERIC - break + type = FormulaDataTypes.NUMERIC; + break; // date case UITypes.Date: case UITypes.DateTime: case UITypes.CreateTime: case UITypes.LastModifiedTime: - if (expectedType !== formulaTypes.DATE) { + if (expectedType !== FormulaDataTypes.DATE) { typeErrors.add( t('msg.formula.columnWithTypeFoundButExpected', { columnName: parsedTree.name, - columnType: formulaTypes.DATE, + columnType: FormulaDataTypes.DATE, expectedType, - }), - ) + }) + ); } - type = formulaTypes.DATE - break + type = FormulaDataTypes.DATE; + break; // not supported case UITypes.ForeignKey: @@ -608,46 +1438,58 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t case UITypes.Collaborator: case UITypes.QrCode: default: - typeErrors.add(t('msg.formula.notSupportedToReferenceColumn', { columnName: parsedTree.name })) - break + typeErrors.add( + t('msg.formula.notSupportedToReferenceColumn', { + columnName: parsedTree.name, + }) + ); + break; } } - } else if (parsedTree.type === JSEPNode.UNARY_EXP || parsedTree.type === JSEPNode.BINARY_EXP) { - if (expectedType !== formulaTypes.NUMERIC) { + } else if ( + parsedTree.type === JSEPNode.UNARY_EXP || + parsedTree.type === JSEPNode.BINARY_EXP + ) { + if (expectedType !== FormulaDataTypes.NUMERIC) { // parsedTree.name won't be available here typeErrors.add( t('msg.formula.typeIsExpectedButFound', { - type: formulaTypes.NUMERIC, + type: FormulaDataTypes.NUMERIC, found: expectedType, - }), - ) + }) + ); } - type = formulaTypes.NUMERIC + type = FormulaDataTypes.NUMERIC; } else if (parsedTree.type === JSEPNode.CALL_EXP) { - const calleeName = parsedTree.callee.name.toUpperCase() - if (formulas[calleeName]?.type && expectedType !== formulas[calleeName].type) { + const calleeName = parsedTree.callee.name.toUpperCase(); + if ( + formulas[calleeName]?.type && + expectedType !== formulas[calleeName].type + ) { typeErrors.add( t('msg.formula.typeIsExpectedButFound', { type: expectedType, found: formulas[calleeName].type, - }), - ) + }) + ); } // todo: derive type from returnType - type = formulas[calleeName]?.type + type = formulas[calleeName]?.type; } - return { type, typeErrors } + return { type, typeErrors }; } function getRootDataType(parsedTree: any): any { // given a parse tree, return the data type of it if (parsedTree.type === JSEPNode.CALL_EXP) { - return formulas[parsedTree.callee.name.toUpperCase()].type + return formulas[parsedTree.callee.name.toUpperCase()].type; } else if (parsedTree.type === JSEPNode.IDENTIFIER) { - const col = supportedColumns.value.find((c) => c.title === parsedTree.name) as Record + const col = supportedColumns.value.find( + (c) => c.title === parsedTree.name + ) as Record; if (col?.uidt === UITypes.Formula) { - return getRootDataType(jsep(col?.formula_raw)) + return getRootDataType(jsep(col?.formula_raw)); } else { switch (col?.uidt) { // string @@ -658,7 +1500,7 @@ function getRootDataType(parsedTree: any): any { case UITypes.PhoneNumber: case UITypes.Email: case UITypes.URL: - return formulaTypes.STRING + return FormulaDataTypes.STRING; // numeric case UITypes.Year: @@ -667,14 +1509,14 @@ function getRootDataType(parsedTree: any): any { case UITypes.Rating: case UITypes.Count: case UITypes.AutoNumber: - return formulaTypes.NUMERIC + return FormulaDataTypes.NUMERIC; // date case UITypes.Date: case UITypes.DateTime: case UITypes.CreateTime: case UITypes.LastModifiedTime: - return formulaTypes.DATE + return FormulaDataTypes.DATE; // not supported case UITypes.ForeignKey: @@ -692,28 +1534,248 @@ function getRootDataType(parsedTree: any): any { case UITypes.Collaborator: case UITypes.QrCode: default: - return 'N/A' + return 'N/A'; } } - } else if (parsedTree.type === JSEPNode.BINARY_EXP || parsedTree.type === JSEPNode.UNARY_EXP) { - return formulaTypes.NUMERIC + } else if ( + parsedTree.type === JSEPNode.BINARY_EXP || + parsedTree.type === JSEPNode.UNARY_EXP + ) { + return FormulaDataTypes.NUMERIC; } else if (parsedTree.type === JSEPNode.LITERAL) { - return typeof parsedTree.value + return typeof parsedTree.value; } else { - return 'N/A' + return 'N/A'; } } function isCurlyBracketBalanced() { // count number of opening curly brackets and closing curly brackets - const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce( - (acc: Record, cur: number) => { - acc[cur] = (acc[cur] || 0) + 1 - return acc - }, - {}, - ) - return (cntCurlyBrackets['{'] || 0) === (cntCurlyBrackets['}'] || 0) + const cntCurlyBrackets = ( + formulaRef.value.$el.value.match(/\{|}/g) || [] + ).reduce((acc: Record, cur: number) => { + acc[cur] = (acc[cur] || 0) + 1; + return acc; + }, {}); + return (cntCurlyBrackets['{'] || 0) === (cntCurlyBrackets['}'] || 0); +} +*/ + +enum FormulaErrorType { + NOT_AVAILABLE = 'NOT_AVAILABLE', + NOT_SUPPORTED = 'NOT_SUPPORTED', + 'MIN_ARG' = 'MIN_ARG', + 'MAX_ARG' = 'MAX_ARG', + 'TYPE_MISMATCH' = 'TYPE_MISMATCH', + 'INVALID_ARG' = 'INVALID_ARG', + 'INVALID_ARG_TYPE' = 'INVALID_ARG_TYPE', + 'INVALID_ARG_VALUE' = 'INVALID_ARG_VALUE', + 'INVALID_ARG_COUNT' = 'INVALID_ARG_COUNT', } +class FormulaError extends Error { + public name: string; + public type: FormulaErrorType; + constructor( + type: FormulaErrorType, + name: string, + message: string = 'Formula Error' + ) { + super(message); + this.name = name; + this.type = type; + } +} + +export function validateFormulaAndExtractTreeWithType( + formula, + columns: ColumnType[] +) { + const colAliasToColMap = {}; + const colIdToColMap = {}; + + for (const col of columns) { + colAliasToColMap[col.title] = col; + colIdToColMap[col.id] = col; + } + + const validateAndExtract = (parsedTree: any) => { + const res: { + dataType?: FormulaDataTypes; + errors?: Set; + [key: string]: any; + } = { ...parsedTree }; + + if (parsedTree.type === JSEPNode.CALL_EXP) { + const calleeName = parsedTree.callee.name.toUpperCase(); + // validate function name + if (!formulas[calleeName]) { + throw new FormulaError( + FormulaErrorType.INVALID_ARG_TYPE, + 'Function not available' + ); + //t('msg.formula.functionNotAvailable', { function: calleeName }) + } + // validate arguments + const validation = + formulas[calleeName] && formulas[calleeName].validation; + if (validation && validation.args) { + if ( + validation.args.rqd !== undefined && + validation.args.rqd !== parsedTree.arguments.length + ) { + throw new FormulaError( + FormulaErrorType.INVALID_ARG, + calleeName, + 'Required arguments missing' + ); + + // errors.add( + // t('msg.formula.requiredArgumentsFormula', { + // requiredArguments: validation.args.rqd, + // calleeName, + // }) + // ); + } else if ( + validation.args.min !== undefined && + validation.args.min > parsedTree.arguments.length + ) { + throw new FormulaError( + FormulaErrorType.MIN_ARG, + calleeName, + 'Minimum arguments required' + ); + + // errors.add( + // t('msg.formula.minRequiredArgumentsFormula', { + // minRequiredArguments: validation.args.min, + // calleeName, + // }) + // ); + } else if ( + validation.args.max !== undefined && + validation.args.max < parsedTree.arguments.length + ) { + throw new FormulaError( + FormulaErrorType.MAX_ARG, + calleeName, + 'Maximum arguments required' + ); + + // errors.add( + // t('msg.formula.maxRequiredArgumentsFormula', { + // maxRequiredArguments: validation.args.max, + // calleeName, + // }) + // ); + } + } + // get args type and validate + const validateResult = res.arguments = parsedTree.arguments.map((arg) => { + return validateAndExtract(arg); + }); + + const argsTypes = validateResult.map((v: any) => v.dataType); + + if (typeof formulas[calleeName].returnType === 'function') { + res.dataType = (formulas[calleeName].returnType as any)?.( + argsTypes + ) as FormulaDataTypes; + } else if (formulas[calleeName].returnType) { + res.dataType = formulas[calleeName].returnType as FormulaDataTypes; + } + } else if (parsedTree.type === JSEPNode.IDENTIFIER) { + const col = columns.find[parsedTree.name] as Record< + string, + any + >; + res.name = col.id; + + if (col?.uidt === UITypes.Formula) { + // todo: check for circular reference + + // todo: extract the type and return + const formulaRes = validateFormulaAndExtractTreeWithType( + col.colOptions.formula, + columns + ); + + res.dataType = formulaRes as any; + } else { + + switch (col?.uidt) { + // string + case UITypes.SingleLineText: + case UITypes.LongText: + case UITypes.MultiSelect: + case UITypes.SingleSelect: + case UITypes.PhoneNumber: + case UITypes.Email: + case UITypes.URL: + res.dataType = FormulaDataTypes.STRING; + break; + // numeric + case UITypes.Year: + case UITypes.Number: + case UITypes.Decimal: + case UITypes.Rating: + case UITypes.Count: + case UITypes.AutoNumber: + res.dataType = FormulaDataTypes.NUMERIC; + break; + // date + case UITypes.Date: + case UITypes.DateTime: + case UITypes.CreateTime: + case UITypes.LastModifiedTime: + res.dataType = FormulaDataTypes.DATE; + break; + // not supported + case UITypes.ForeignKey: + case UITypes.Attachment: + case UITypes.ID: + case UITypes.Time: + case UITypes.Currency: + case UITypes.Percent: + case UITypes.Duration: + case UITypes.Rollup: + case UITypes.Lookup: + case UITypes.Barcode: + case UITypes.Button: + case UITypes.Checkbox: + case UITypes.Collaborator: + case UITypes.QrCode: + default: + throw new FormulaError(FormulaErrorType.NOT_SUPPORTED, ''); + } + } + } else if (parsedTree.type === JSEPNode.LITERAL) { + if (typeof parsedTree.value === 'number') { + res.dataType = FormulaDataTypes.NUMERIC; + } else if (typeof parsedTree.value === 'string') { + res.dataType = FormulaDataTypes.STRING; + } else if (typeof parsedTree.value === 'boolean') { + res.dataType = FormulaDataTypes.BOOLEAN; + } else { + res.dataType = FormulaDataTypes.STRING; + } + } else if ( + parsedTree.type === JSEPNode.BINARY_EXP || + parsedTree.type === JSEPNode.UNARY_EXP + ) { + res.left = validateAndExtract(parsedTree.left); + res.right = validateAndExtract(parsedTree.right); + res.dataType = FormulaDataTypes.NUMERIC; + } else { + // res.type= 'N/A'; + } + + return res; + }; + // register jsep curly hook + jsep.plugins.register(jsepCurlyHook); + const parsedFormula = jsep(formula); + const result = validateAndExtract(parsedFormula); + return result; +}