diff --git a/packages/nc-gui/components/project/spreadsheet/components/editColumn/FormulaOptions.vue b/packages/nc-gui/components/project/spreadsheet/components/editColumn/FormulaOptions.vue index 03f1a503ae..6ab52c5bab 100644 --- a/packages/nc-gui/components/project/spreadsheet/components/editColumn/FormulaOptions.vue +++ b/packages/nc-gui/components/project/spreadsheet/components/editColumn/FormulaOptions.vue @@ -37,18 +37,31 @@ > @@ -96,7 +109,7 @@ import debounce from 'debounce' import jsep from 'jsep' import { UITypes, jsepCurlyHook } from 'nocodb-sdk' -import formulaList, { validations } from '../../../../../helpers/formulaList' +import formulaList, { formulas, formulaTypes } from '../../../../../helpers/formulaList' import { getWordUntilCaret, insertAtCursor } from '@/helpers' import NcAutocompleteTree from '@/helpers/NcAutocompleteTree' @@ -125,7 +138,10 @@ export default { return [ ...this.availableFunctions.filter(fn => !unsupportedFnList.includes(fn)).map(fn => ({ text: fn + '()', - type: 'function' + type: 'function', + description: formulas[fn].description, + syntax: formulas[fn].syntax, + examples: formulas[fn].examples })), ...this.meta.columns.filter(c => !this.column || this.column.id !== c.id).map(c => ({ text: c.title, @@ -209,44 +225,314 @@ export default { }, parseAndValidateFormula(formula) { try { - const pt = jsep(formula) - const err = this.validateAgainstMeta(pt) - if (err.length) { - return err.join(', ') + const parsedTree = jsep(formula) + const metaErrors = this.validateAgainstMeta(parsedTree) + if (metaErrors.size) { + return [...metaErrors].join(', ') } return true } catch (e) { return e.message } }, - validateAgainstMeta(pt, arr = []) { - if (pt.type === 'CallExpression') { - if (!this.availableFunctions.includes(pt.callee.name)) { - arr.push(`'${pt.callee.name}' function is not available`) + validateAgainstMeta(parsedTree, errors = new Set(), typeErrors = new Set()) { + if (parsedTree.type === jsep.CALL_EXP) { + // validate function name + if (!this.availableFunctions.includes(parsedTree.callee.name)) { + errors.add(`'${parsedTree.callee.name}' function is not available`) } - const validation = validations[pt.callee.name] && validations[pt.callee.name].validation + // validate arguments + const validation = formulas[parsedTree.callee.name] && formulas[parsedTree.callee.name].validation if (validation && validation.args) { - if (validation.args.rqd !== undefined && validation.args.rqd !== pt.arguments.length) { - arr.push(`'${pt.callee.name}' required ${validation.args.rqd} arguments`) - } else if (validation.args.min !== undefined && validation.args.min > pt.arguments.length) { - arr.push(`'${pt.callee.name}' required minimum ${validation.args.min} arguments`) - } else if (validation.args.max !== undefined && validation.args.max < pt.arguments.length) { - arr.push(`'${pt.callee.name}' required maximum ${validation.args.max} arguments`) + if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) { + errors.add(`'${parsedTree.callee.name}' required ${validation.args.rqd} arguments`) + } else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) { + errors.add(`'${parsedTree.callee.name}' required minimum ${validation.args.min} arguments`) + } else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) { + errors.add(`'${parsedTree.callee.name}' required maximum ${validation.args.max} arguments`) + } + } + parsedTree.arguments.map(arg => this.validateAgainstMeta(arg, errors)) + + // validate data type + if (parsedTree.callee.type === jsep.IDENTIFIER) { + const expectedType = formulas[parsedTree.callee.name].type + if ( + expectedType === formulaTypes.NUMERIC || + expectedType === formulaTypes.STRING + ) { + parsedTree.arguments.map(arg => this.validateAgainstType(arg, expectedType, null, typeErrors)) + } else if (expectedType === formulaTypes.DATE) { + if (parsedTree.callee.name === 'DATEADD') { + // parsedTree.arguments[0] = date + this.validateAgainstType(parsedTree.arguments[0], formulaTypes.DATE, (v) => { + if (!(v instanceof Date)) { + typeErrors.add('The first parameter of DATEADD() should have date value') + } + }, typeErrors) + // parsedTree.arguments[1] = numeric + this.validateAgainstType(parsedTree.arguments[1], formulaTypes.NUMERIC, (v) => { + if (typeof v !== 'number') { + typeErrors.add('The second parameter of DATEADD() should have numeric value') + } + }, typeErrors) + // parsedTree.arguments[2] = ["day" | "week" | "month" | "year"] + this.validateAgainstType(parsedTree.arguments[2], formulaTypes.STRING, (v) => { + if (!['day', 'week', 'month', 'year'].includes(v)) { + typeErrors.add('The third parameter of DATEADD() should have the value either "day", "week", "month" or "year"') + } + }, typeErrors) + } + } + } + + errors = new Set([...errors, ...typeErrors]) + } else if (parsedTree.type === jsep.IDENTIFIER) { + if (this.meta.columns.filter(c => !this.column || this.column.id !== c.id).every(c => c.title !== parsedTree.name)) { + errors.add(`Column '${parsedTree.name}' is not available`) + } + + // check circular reference + // e.g. formula1 -> formula2 -> formula1 should return circular reference error + + // get all formula columns excluding itself + const formulaPaths = this.meta.columns.filter(c => c.id !== this.column.id && c.uidt === UITypes.Formula).reduce((res, c) => { + // in `formula`, get all the target neighbours + // i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type + const neighbours = (c.colOptions.formula.match(/cl_\w{14}/g) || []).filter(colId => (this.meta.columns.filter(col => (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 }) + } + return res + }, []) + // include target formula column (i.e. the one to be saved if applicable) + const targetFormulaCol = this.meta.columns.find(c => c.title === parsedTree.name && c.uidt === UITypes.Formula) + if (targetFormulaCol) { + formulaPaths.push({ + [this.column.id]: [targetFormulaCol.id] + }) + } + const vertices = formulaPaths.length + if (vertices > 0) { + // perform kahn's algo for cycle detection + 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) + for (const neighbour of neighbours) { + adj.set(src, (adj.get(src) || new Set()).add(neighbour)) + inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1) + } + } + const queue = [] + // 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) + } + }) + // init count of visited vertices + let visited = 0 + // BFS + while (queue.length !== 0) { + // remove a vertex from the queue + const src = queue.shift() + // if this node has neighbours, increase visited by 1 + const neighbours = adj.get(src) || new Set() + if (neighbours.size > 0) { + visited += 1 + } + // iterate each neighbouring nodes + neighbours.forEach((neighbour) => { + // decrease in-degree of its neighbours by 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) + } + }) } + // vertices not same as visited = cycle found + if (vertices !== visited) { + errors.add('Can’t save field because it causes a circular reference') + } + } + } else if (parsedTree.type === jsep.BINARY_EXP) { + if (!this.availableBinOps.includes(parsedTree.operator)) { + errors.add(`'${parsedTree.operator}' operation is not available`) } - pt.arguments.map(arg => this.validateAgainstMeta(arg, arr)) - } else if (pt.type === 'Identifier') { - if (this.meta.columns.filter(c => !this.column || this.column.id !== c.id).every(c => c.title !== pt.name)) { - arr.push(`Column '${pt.name}' is not available`) + this.validateAgainstMeta(parsedTree.left, errors) + this.validateAgainstMeta(parsedTree.right, errors) + } else if (parsedTree.type === jsep.LITERAL || parsedTree.type === jsep.UNARY_EXP) { + // do nothing + } else if (parsedTree.type === jsep.COMPOUND) { + if (parsedTree.body.length) { + errors.add('Can’t save field because the formula is invalid') } - } else if (pt.type === 'BinaryExpression') { - if (!this.availableBinOps.includes(pt.operator)) { - arr.push(`'${pt.operator}' operation is not available`) + } else { + errors.add('Can’t save field because the formula is invalid') + } + return errors + }, + validateAgainstType(parsedTree, expectedType, func, typeErrors = new Set()) { + if (parsedTree === false || typeof parsedTree === 'undefined') { return typeErrors } + if (parsedTree.type === jsep.LITERAL) { + if (typeof func === 'function') { + func(parsedTree.value) + } else if (expectedType === formulaTypes.NUMERIC) { + if (typeof parsedTree.value !== 'number') { + typeErrors.add('Numeric type is expected') + } + } else if (expectedType === formulaTypes.STRING) { + if (typeof parsedTree.value !== 'string') { + typeErrors.add('string type is expected') + } } - this.validateAgainstMeta(pt.left, arr) - this.validateAgainstMeta(pt.right, arr) + } else if (parsedTree.type === jsep.IDENTIFIER) { + const col = this.meta.columns.find(c => c.title === parsedTree.name) + if (col === undefined) { return } + if (col.uidt === UITypes.Formula) { + const foundType = this.getRootDataType(jsep(col.colOptions.formula_raw)) + if (foundType === 'N/A') { + typeErrors.add(`Not supported to reference column ${c.title}`) + } else if (expectedType !== foundType) { + typeErrors.add(`Type ${expectedType} is expected but found Type ${foundType}`) + } + } 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: + if (expectedType !== formulaTypes.STRING) { + typeErrors.add(`Column '${parsedTree.name}' with ${formulaTypes.STRING} type is found but ${expectedType} type is expected`) + } + break + + // numeric + case UITypes.Year: + case UITypes.Number: + case UITypes.Decimal: + case UITypes.Rating: + case UITypes.Count: + case UITypes.AutoNumber: + if (expectedType !== formulaTypes.NUMERIC) { + typeErrors.add(`Column '${parsedTree.name}' with ${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`) + } + break + + // date + case UITypes.Date: + case UITypes.DateTime: + case UITypes.CreateTime: + case UITypes.LastModifiedTime: + if (expectedType !== formulaTypes.DATE) { + typeErrors.add(`Column '${parsedTree.name}' with ${formulaTypes.DATE} type is found but ${expectedType} type is expected`) + } + 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: + default: + typeErrors.add(`Not supported to reference column '${parsedTree.name}'`) + break + } + } + } else if (parsedTree.type === jsep.UNARY_EXP || parsedTree.type === jsep.BINARY_EXP) { + if (expectedType !== formulaTypes.NUMERIC) { + // parsedTree.name won't be available here + typeErrors.add(`${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`) + } + } else if (parsedTree.type === jsep.CALL_EXP) { + if (formulas[parsedTree.callee.name]?.type && expectedType !== formulas[parsedTree.callee.name].type) { + typeErrors.add(`${expectedType} not matched with ${formulas[parsedTree.callee.name].type}`) + } + } + return typeErrors + }, + getRootDataType(parsedTree) { + // given a parse tree, return the data type of it + if (parsedTree.type === jsep.CALL_EXP) { + return formulas[parsedTree.callee.name].type + } else if (parsedTree.type === jsep.IDENTIFIER) { + const col = this.meta.columns.find(c => c.title === parsedTree.name) + if (col.uidt === UITypes.Formula) { + return this.getRootDataType(jsep(col.colOptions.formula_raw)) + } 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: + return 'string' + + // numeric + case UITypes.Year: + case UITypes.Number: + case UITypes.Decimal: + case UITypes.Rating: + case UITypes.Count: + case UITypes.AutoNumber: + return 'number' + + // date + case UITypes.Date: + case UITypes.DateTime: + case UITypes.CreateTime: + case UITypes.LastModifiedTime: + return 'date' + + // 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: + default: + return 'N/A' + } + } + } else if (parsedTree.type === jsep.BINARY_EXP || parsedTree.type === jsep.UNARY_EXP) { + return 'number' + } else if (parsedTree.type === jsep.LITERAL) { + return typeof parsedTree.value + } else { + return 'N/A' } - return arr }, appendText(it) { const text = it.text diff --git a/packages/nc-gui/components/project/spreadsheet/components/virtualCell/FormulaCell.vue b/packages/nc-gui/components/project/spreadsheet/components/virtualCell/FormulaCell.vue index 25f9db470d..4a217ffd4d 100644 --- a/packages/nc-gui/components/project/spreadsheet/components/virtualCell/FormulaCell.vue +++ b/packages/nc-gui/components/project/spreadsheet/components/virtualCell/FormulaCell.vue @@ -1,17 +1,29 @@ @@ -21,6 +33,9 @@ import dayjs from 'dayjs' export default { name: 'FormulaCell', props: { column: Object, row: Object, client: String }, + data: () => ({ + showEditFormulaWarning: false + }), computed: { result() { if (this.client === 'pg') { @@ -53,11 +68,25 @@ export default { return val.replace(/((?:-?(?:[1-9][0-9]*)?[0-9]{4})-(?:1[0-2]|0[1-9])-(?:3[01]|0[1-9]|[12][0-9])T(?:2[0-3]|[01][0-9]):(?:[0-5][0-9]):(?:[0-5][0-9])(?:\.[0-9]+)?(?:Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9]))/g, (i, v) => { return dayjs(v).format('YYYY-MM-DD HH:mm') }) + }, + showEditFormulaWarningMessage() { + this.showEditFormulaWarning = true + setTimeout(() => { + this.showEditFormulaWarning = false + }, 3000) } } } - diff --git a/packages/nc-gui/helpers/formulaList.js b/packages/nc-gui/helpers/formulaList.js index 0ed3f9777d..044764630c 100644 --- a/packages/nc-gui/helpers/formulaList.js +++ b/packages/nc-gui/helpers/formulaList.js @@ -1,62 +1,478 @@ -const validations = { +const formulaTypes = { + NUMERIC: "numeric", + STRING: "string", + DATE: "date", + LOGICAL: "logical", + COND_EXP: "conditional_expression" +} + +const formulas = { AVG: { + type: formulaTypes.NUMERIC, validation: { - args: { min: 1 } - } + 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})' + ] }, ADD: { + type: formulaTypes.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})' + ] + }, + DATEADD: { + type: formulaTypes.DATE, validation: { - args: { min: 1 } - } + 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")' + ] }, - DATEADD: { validation: { args: { rqd: 3 } } }, AND: { + type: formulaTypes.COND_EXP, validation: { - args: { min: 1 } - } + 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)' + ] }, OR: { + type: formulaTypes.COND_EXP, validation: { - args: { min: 1 } - } + 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)' + ] }, CONCAT: { - validation: { args: { min: 1 } } + type: formulaTypes.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})' + ] }, TRIM: { - validation: { args: { min: 1 } } + type: formulaTypes.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})' + ] }, UPPER: { - validation: { args: { rqd: 1 } } - }, - LOWER: { validation: { args: { rqd: 1 } } }, - LEN: { validation: { args: { rqd: 1 } } }, - MIN: { validation: { args: { min: 1 } } }, - MAX: { validation: { args: { min: 1 } } }, - CEILING: { validation: { args: { rqd: 1 } } }, - FLOOR: { validation: { args: { rqd: 1 } } }, - ROUND: { validation: { args: { rqd: 1 } } }, - MOD: { validation: { args: { rqd: 2 } } }, - REPEAT: { validation: { args: { rqd: 2 } } }, - LOG: { validation: {} }, - EXP: { validation: {} }, - POWER: { validation: { args: { rqd: 2 } } }, - SQRT: { validation: { args: { rqd: 1 } } }, - ABS: { validation: { args: { rqd: 1 } } }, - NOW: { validation: { args: { rqd: 0 } } }, - REPLACE: { validation: { args: { rqd: 3 } } }, - SEARCH: { validation: { args: { rqd: 2 } } }, - INT: { validation: { args: { rqd: 1 } } }, - RIGHT: { validation: { args: { rqd: 2 } } }, + type: formulaTypes.STRING, + validation: { + args: { + rqd: 1 + } + }, + description: 'Upper case converted string of input parameter', + syntax: 'UPPER(str)', + examples: [ + 'UPPER("nocodb") => "NOCODB"', + 'UPPER({column1})' + ] + }, + LOWER: { + type: formulaTypes.STRING, + validation: { + args: { + rqd: 1 + } + }, + description: 'Lower case converted string of input parameter', + syntax: 'LOWER(str)', + examples: [ + 'LOWER("NOCODB") => "nocodb"', + 'LOWER({column1})' + ] + }, + LEN: { + type: formulaTypes.STRING, + validation: { + args: { + rqd: 1 + } + }, + description: 'Input parameter character length', + syntax: 'LEN(value)', + examples: [ + 'LEN("NocoDB") => 6', + 'LEN({column1})' + ] + }, + MIN: { + type: formulaTypes.NUMERIC, + validation: { + args: { + min: 1 + } + }, + description: 'Minimum value amongst input parameters', + syntax: 'MIN(value1, [value2, ...])', + examples: [ + 'MIN(1000, 2000) => 1000', + 'MIN({column1}, {column2})' + ] + }, + MAX: { + type: formulaTypes.NUMERIC, + validation: { + args: { + min: 1 + } + }, + description: 'Maximum value amongst input parameters', + syntax: 'MAX(value1, [value2, ...])', + examples: [ + 'MAX(1000, 2000) => 2000', + 'MAX({column1}, {column2})' + ] + }, + CEILING: { + type: formulaTypes.NUMERIC, + validation: { + args: { + rqd: 1 + } + }, + description: 'Rounded next largest integer value of input parameter', + syntax: 'CEILING(value)', + examples: [ + 'CEILING(1.01) => 2', + 'CEILING({column1})' + ] + }, + FLOOR: { + type: formulaTypes.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})' + ] + }, + ROUND: { + type: formulaTypes.NUMERIC, + validation: { + args: { + rqd: 1 + } + }, + description: 'Nearest integer to the input parameter', + syntax: 'ROUND(value)', + examples: [ + 'ROUND(3.1415) => 3', + 'ROUND({column1})' + ] + }, + MOD: { + type: formulaTypes.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)' + ] + }, + REPEAT: { + type: formulaTypes.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)' + ] + }, + LOG: { + type: formulaTypes.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})' + ] + }, + EXP: { + type: formulaTypes.NUMERIC, + validation: {}, + description: 'Exponential value of input parameter (e ^ power)', + syntax: 'EXP(power)', + examples: [ + 'EXP(1) => 2.718281828459045', + 'EXP({column1})' + ] + }, + POWER: { + type: formulaTypes.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)' + ] + }, + SQRT: { + type: formulaTypes.NUMERIC, + validation: { + args: { + rqd: 1 + } + }, + description: 'Square root of the input parameter', + syntax: 'SQRT(value)', + examples: [ + 'SQRT(100) => 10', + 'SQRT({column1})' + ] + }, + ABS: { + type: formulaTypes.NUMERIC, + validation: { + args: { + rqd: 1 + } + }, + description: 'Absolute value of the input parameter', + syntax: 'ABS(value)', + examples: [ + 'ABS({column1})' + ] + }, + NOW: { + type: formulaTypes.DATE, + validation: { + args: { + rqd: 0 + } + }, + description: 'Returns the current time and day', + syntax: 'NOW()', + examples: [ + 'NOW() => 2022-05-19 17:20:43' + ] + }, + REPLACE: { + type: formulaTypes.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})' + ] + }, + SEARCH: { + type: formulaTypes.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")' + ] + }, + INT: { + type: formulaTypes.NUMERIC, + validation: { + args: { + rqd: 1 + } + }, + description: 'Integer value of input parameter', + syntax: 'INT(value)', + examples: [ + 'INT(3.1415) => 3', + 'INT({column1})' + ] + }, + RIGHT: { + type: formulaTypes.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)' + ] + }, LEFT: { - validation: { args: { rqd: 1 } } + type: formulaTypes.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"' + ] }, - SUBSTR: { validation: { args: { min: 2, max: 3 } } }, - MID: { validation: { args: { rqd: 1 } } }, - IF: { validation: { args: { min: 2, max: 3 } } }, - SWITCH: { validation: { args: { min: 3 } } }, - URL: { validation: { args: { rqd: 1 } } } + SUBSTR: { + type: formulaTypes.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)' + ] + }, + MID: { + type: formulaTypes.STRING, + validation: { + args: { + rqd: 3 + } + }, + description: 'Alias for SUBSTR', + syntax: 'MID(str, position, [count])', + examples: [ + 'MID("NocoDB", 3, 2) => "co"', + 'MID({column1}, 3, 2)' + ] + }, + IF: { + type: formulaTypes.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")' + ] + }, + SWITCH: { + type: formulaTypes.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")' + ] + }, + URL: { + type: formulaTypes.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})' + ] + } } -export default Object.keys(validations) -export { validations } +export default Object.keys(formulas) +export { formulas, formulaTypes } diff --git a/packages/noco-docs/content/en/setup-and-usages/formulas.md b/packages/noco-docs/content/en/setup-and-usages/formulas.md index c667cfb27c..c933063622 100644 --- a/packages/noco-docs/content/en/setup-and-usages/formulas.md +++ b/packages/noco-docs/content/en/setup-and-usages/formulas.md @@ -66,13 +66,13 @@ Example: ({Column1} + ({Column2} * {Column3}) / (3 - $Column4$ )) | Name | Syntax | Sample | Output | |-------------|----------------------------------|---------------------------------|---------------------------------------------------------------------------| | **CONCAT** | `CONCAT(str1, [str2,...])` | `CONCAT({Column1}, ' ', {Column2})` | Concatenated string of input parameters | -| **LEFT** | `LEFT(str1, [str2,...])` | `LEFT({Column}, 3)` | `n` characters from the beginning of input parameter | +| **LEFT** | `LEFT(str1, n)` | `LEFT({Column}, 3)` | `n` characters from the beginning of input parameter | | **LEN** | `LEN(str)` | `LEN({Column})` | Input parameter character length | | **LOWER** | `LOWER(str)` | `LOWER({Column})` | Lower case converted string of input parameter | -| **MID** | `SUBTR(str, position, [count])` | `MID({Column}, 3, 2)` | Alias for `SUBSTR` | +| **MID** | `MID(str, position, [count])` | `MID({Column}, 3, 2)` | Alias for `SUBSTR` | | **REPEAT** | `REPEAT(str, count)` | `REPEAT({Column}, 2)` | Specified copies of the input parameter string concatenated together | | **REPLACE** | `REPLACE(str, srchStr, rplcStr)` | `REPLACE({Column}, 'int', 'num')` | String, after replacing all occurrences of `srchStr` with `rplcStr` | -| **RIGHT** | `RIGHT(str, count)` | `RIGHT({Column}, 3)` | `n` characters from the end of input parameter | +| **RIGHT** | `RIGHT(str, n)` | `RIGHT({Column}, 3)` | `n` characters from the end of input parameter | | **SEARCH** | `SEARCH(str, srchStr)` | `SEARCH({Column}, 'str')` | Index of `srchStr` specified if found, 0 otherwise | | **SUBSTR** | `SUBTR(str, position, [count])` | `SUBSTR({Column}, 3, 2)` | Substring of length 'count' of input string, from the postition specified | | **TRIM** | `TRIM(str)` | `TRIM({Column})` | Remove trailing and leading whitespaces from input parameter | @@ -83,11 +83,13 @@ Example: ({Column1} + ({Column2} * {Column3}) / (3 - $Column4$ )) | Name | Syntax | Sample | Output | Remark | |---|---|---|---|---| -| **DATEADD** | `DATEADD({DATE_COL}, 1, 'day')` | `DATEADD(date, 1, 'day')` | Supposing {DATE_COL} is 2022-03-14. The result is 2022-03-15. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'day')` | -| | `DATEADD({DATE_COL}, 2, 'month')` | `DATEADD(date, 2, 'month')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2022-05-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -2, 'month')` | +| **NOW** | `NOW()` | `NOW()` | 2022-05-19 17:20:43 | Returns the current time and day | | | `IF(NOW() < {DATE_COL}, "true", "false")` | `IF(NOW() < date, "true", "false")` | If current date is less than {DATE_COL}, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. | -| | `IF(NOW() < DATEADD({DATE_COL},10,'day'), "true", "false")` | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. | - +| **DATEADD** | `DATEADD(date \| datetime, value, ["day" \| "week" \| "month" \| "year"])` | `DATEADD(date, 1, 'day')` | Supposing {DATE_COL} is 2022-03-14. The result is 2022-03-15. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'day')` | +| | | `DATEADD(date, 1, 'week')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2022-03-21 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'week')` | +| | | `DATEADD(date, 1, 'month')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2022-04-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'month')` | +| | | `DATEADD(date, 1, 'year')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2023-03-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'year')` | +| | | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. | ### Logical Operators | Operator | Sample | Description | @@ -104,7 +106,7 @@ Example: ({Column1} + ({Column2} * {Column3}) / (3 - $Column4$ )) | Name | Syntax | Sample | Output | |------------|------------------------------------------------|---------------------------------------------|-------------------------------------------------------------| -| **IF** | `IF(expr, successCase, [failCase])` | `IF({Column} > 1, Value1, Value2)` | successCase if `expr` evaluates to TRUE, elseCase otherwise | +| **IF** | `IF(expr, successCase, elseCase)` | `IF({Column} > 1, Value1, Value2)` | successCase if `expr` evaluates to TRUE, elseCase otherwise | | **SWITCH** | `SWITCH(expr, [pattern, value, ..., default])` | `SWITCH({Column}, 1, 'One', 2, 'Two', '--')` | Switch case value based on `expr` output | | **AND** | `AND(expr1, [expr2,...])` | `AND({Column} > 2, {Column} < 10)` | TRUE if all `expr` evaluate to TRUE | | **OR** | `OR(expr1, [expr2,...])` | `OR({Column} > 2, {Column} < 10)` | TRUE if at least one `expr` evaluates to TRUE |