diff --git a/packages/nc-gui/components/smartsheet/column/FormulaOptions.vue b/packages/nc-gui/components/smartsheet/column/FormulaOptions.vue index f87585f490..e52df87238 100644 --- a/packages/nc-gui/components/smartsheet/column/FormulaOptions.vue +++ b/packages/nc-gui/components/smartsheet/column/FormulaOptions.vue @@ -2,34 +2,28 @@ import type { Ref } from 'vue' import type { ListItem as AntListItem } from 'ant-design-vue' import jsep from 'jsep' -import type { ColumnType, FormulaType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import { + FormulaError, UITypes, - isLinksOrLTAR, - isNumericCol, - isSystemColumn, jsepCurlyHook, substituteColumnIdWithAliasInFormula, - validateDateWithUnknownFormat, + validateFormulaAndExtractTreeWithType, } from 'nocodb-sdk' +import type { ColumnType, FormulaType } from 'nocodb-sdk' import { MetaInj, NcAutocompleteTree, computed, formulaList, - formulaTypes, formulas, getUIDTIcon, getWordUntilCaret, iconMap, inject, insertAtCursor, - isDate, nextTick, onMounted, ref, - storeToRefs, - useBase, useColumnCreateStoreOrThrow, useDebounceFn, useI18n, @@ -52,59 +46,38 @@ const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCrea const { t } = useI18n() -const baseStore = useBase() - -const { tables } = storeToRefs(baseStore) - const { predictFunction: _predictFunction } = useNocoEe() -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', -} - const meta = inject(MetaInj, ref()) const supportedColumns = computed( () => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [], ) -const { metas } = useMetas() - -const refTables = computed(() => { - if (!tables.value || !tables.value.length || !meta.value || !meta.value.columns) { - return [] - } - - const _refTables = meta.value.columns - .filter((column) => isLinksOrLTAR(column) && !column.system && column.source_id === meta.value?.source_id) - .map((column) => ({ - col: column.colOptions, - column, - ...tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id), - })) - .filter((table) => (table.col as LinkToAnotherRecordType)?.fk_related_model_id === table.id && !table.mm) - return _refTables as Required }>[] -}) +const { getMeta } = useMetas() const validators = { formula_raw: [ { validator: (_: any, formula: any) => { - return new Promise((resolve, reject) => { - if (!formula?.trim()) return reject(new Error('Required')) - const res = parseAndValidateFormula(formula) - if (res !== true) { - return reject(new Error(res)) + return (async () => { + if (!formula?.trim()) throw new Error('Required') + + try { + await validateFormulaAndExtractTreeWithType({ + column: column.value, + formula, + columns: supportedColumns.value, + clientOrSqlUi: sqlUi.value, + getMeta, + }) + } catch (e: any) { + if (e instanceof FormulaError && e.extra?.key) { + throw new Error(t(e.extra.key, e.extra)) + } + + throw new Error(e.message) } - resolve() - }) + })() }, }, ], @@ -176,522 +149,6 @@ const acTree = computed(() => { return ref }) -function parseAndValidateFormula(formula: string) { - try { - const parsedTree = jsep(formula) - const metaErrors = validateAgainstMeta(parsedTree) - if (metaErrors.size) { - return [...metaErrors].join(', ') - } - return true - } catch (e: any) { - return e.message - } -} - -function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) { - if (parsedTree.type === JSEPNode.CALL_EXP) { - const calleeName = parsedTree.callee.name.toUpperCase() - // validate function name - if (!availableFunctions.includes(calleeName)) { - errors.add(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) { - 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) { - errors.add(t('msg.formula.maxRequiredArgumentsFormula', { maxRequiredArguments: validation.args.max, calleeName })) - } - } - parsedTree.arguments.map((arg: Record) => validateAgainstMeta(arg, errors)) - - // validate data type - if (parsedTree.callee.type === JSEPNode.IDENTIFIER) { - const expectedType = formulas[calleeName.toUpperCase()].type - if (expectedType === formulaTypes.NUMERIC) { - if (calleeName === 'WEEKDAY') { - // parsedTree.arguments[0] = date - validateAgainstType( - parsedTree.arguments[0], - formulaTypes.DATE, - (v: any) => { - if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.firstParamWeekDayHaveDate')) - } - }, - typeErrors, - ) - // parsedTree.arguments[1] = startDayOfWeek (optional) - validateAgainstType( - parsedTree.arguments[1], - formulaTypes.STRING, - (v: any) => { - if ( - typeof v !== 'string' || - !['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes(v.toLowerCase()) - ) { - typeErrors.add(t('msg.formula.secondParamWeekDayHaveDate')) - } - }, - typeErrors, - ) - } else { - parsedTree.arguments.map((arg: Record) => validateAgainstType(arg, expectedType, null, typeErrors)) - } - } else if (expectedType === formulaTypes.DATE) { - if (calleeName === 'DATEADD') { - // parsedTree.arguments[0] = date - validateAgainstType( - parsedTree.arguments[0], - formulaTypes.DATE, - (v: any) => { - if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.firstParamDateAddHaveDate')) - } - }, - typeErrors, - ) - // parsedTree.arguments[1] = numeric - validateAgainstType( - parsedTree.arguments[1], - formulaTypes.NUMERIC, - (v: any) => { - if (typeof v !== 'number') { - typeErrors.add(t('msg.formula.secondParamDateAddHaveNumber')) - } - }, - typeErrors, - ) - // parsedTree.arguments[2] = ["day" | "week" | "month" | "year"] - validateAgainstType( - parsedTree.arguments[2], - formulaTypes.STRING, - (v: any) => { - if (!['day', 'week', 'month', 'year'].includes(v)) { - typeErrors.add(typeErrors.add(t('msg.formula.thirdParamDateAddHaveDate'))) - } - }, - typeErrors, - ) - } else if (calleeName === 'DATETIME_DIFF') { - // parsedTree.arguments[0] = date - validateAgainstType( - parsedTree.arguments[0], - formulaTypes.DATE, - (v: any) => { - if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.firstParamDateDiffHaveDate')) - } - }, - typeErrors, - ) - // parsedTree.arguments[1] = date - validateAgainstType( - parsedTree.arguments[1], - formulaTypes.DATE, - (v: any) => { - if (!validateDateWithUnknownFormat(v)) { - typeErrors.add(t('msg.formula.secondParamDateDiffHaveDate')) - } - }, - 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, - (v: any) => { - if ( - ![ - 'milliseconds', - 'ms', - 'seconds', - 's', - 'minutes', - 'm', - 'hours', - 'h', - 'days', - 'd', - 'weeks', - 'w', - 'months', - 'M', - 'quarters', - 'Q', - 'years', - 'y', - ].includes(v) - ) { - typeErrors.add(t('msg.formula.thirdParamDateDiffHaveDate')) - } - }, - 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)) { - errors.add( - t('msg.formula.columnNotAvailable', { - columnName: parsedTree.name, - }), - ) - } - - // check circular reference - // e.g. formula1 -> formula2 -> formula1 should return circular reference error - - // get all formula columns excluding itself - const formulaPaths = supportedColumns.value - .filter((c) => c.id !== column.value?.id && c.uidt === UITypes.Formula) - .reduce((res: Record[], c: Record) => { - // in `formula`, get all the (unique) target neighbours - // i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type - const neighbours = [ - ...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, - ), - ), - ] - 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 = supportedColumns.value.find( - (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 - 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: 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) - } - }) - // 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: string) => { - // 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(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 })) - } - validateAgainstMeta(parsedTree.left, errors) - validateAgainstMeta(parsedTree.right, errors) - } else if (parsedTree.type === JSEPNode.LITERAL || parsedTree.type === JSEPNode.UNARY_EXP) { - // do nothing - } else if (parsedTree.type === JSEPNode.COMPOUND) { - if (parsedTree.body.length) { - errors.add(t('msg.formula.cantSaveFieldFormulaInvalid')) - } - } else { - errors.add(t('msg.formula.cantSaveFieldFormulaInvalid')) - } - return errors -} - -function validateAgainstType(parsedTree: any, expectedType: string, func: any, typeErrors = new Set()) { - if (parsedTree === false || typeof parsedTree === 'undefined') { - return typeErrors - } - if (parsedTree.type === JSEPNode.LITERAL) { - if (typeof func === 'function') { - func(parsedTree.value) - } else if (expectedType === formulaTypes.NUMERIC) { - if (typeof parsedTree.value !== 'number') { - typeErrors.add(t('msg.formula.numericTypeIsExpected')) - } - } else if (expectedType === formulaTypes.STRING) { - if (typeof parsedTree.value !== 'string') { - typeErrors.add(t('msg.formula.stringTypeIsExpected')) - } - } - } else if (parsedTree.type === JSEPNode.IDENTIFIER) { - const col = supportedColumns.value.find((c) => c.title === parsedTree.name) - - if (col === undefined) { - return - } - - if (col.uidt === UITypes.Formula) { - const foundType = getRootDataType(jsep(col.colOptions?.formula_raw)) - if (foundType === 'N/A') { - 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) { - // 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( - t('msg.formula.columnWithTypeFoundButExpected', { - columnName: parsedTree.name, - columnType: formulaTypes.STRING, - expectedType, - }), - ) - } - break - - // numeric - case UITypes.Year: - case UITypes.Number: - case UITypes.Decimal: - case UITypes.Rating: - case UITypes.Count: - case UITypes.AutoNumber: - case UITypes.Currency: - if (expectedType !== formulaTypes.NUMERIC) { - typeErrors.add( - t('msg.formula.columnWithTypeFoundButExpected', { - columnName: parsedTree.name, - columnType: formulaTypes.NUMERIC, - expectedType, - }), - ) - } - break - - // date - case UITypes.Date: - case UITypes.DateTime: - case UITypes.CreateTime: - case UITypes.LastModifiedTime: - if (expectedType !== formulaTypes.DATE) { - typeErrors.add( - t('msg.formula.columnWithTypeFoundButExpected', { - columnName: parsedTree.name, - columnType: formulaTypes.DATE, - expectedType, - }), - ) - } - break - - case UITypes.Rollup: { - const rollupFunction = col.colOptions.rollup_function - if (['count', 'avg', 'sum', 'countDistinct', 'sumDistinct', 'avgDistinct'].includes(rollupFunction)) { - // these functions produce a numeric value, which can be used in numeric functions - if (expectedType !== formulaTypes.NUMERIC) { - typeErrors.add( - t('msg.formula.columnWithTypeFoundButExpected', { - columnName: parsedTree.name, - columnType: formulaTypes.NUMERIC, - expectedType, - }), - ) - } - } else { - // the value is based on the foreign rollup column type - const selectedTable = refTables.value.find((t) => t.column.id === col.colOptions.fk_relation_column_id) - const refTableColumns = metas.value[selectedTable.id].columns.filter( - (c: ColumnType) => - vModel.value.fk_lookup_column_id === c.id || - (!isSystemColumn(c) && c.id !== vModel.value.id && c.uidt !== UITypes.Links), - ) - const childFieldColumn = refTableColumns.find( - (column: ColumnType) => column.id === col.colOptions.fk_rollup_column_id, - ) - const abstractType = sqlUi.value.getAbstractType(childFieldColumn) - - if (expectedType === formulaTypes.DATE && !isDate(childFieldColumn, sqlUi.value.getAbstractType(childFieldColumn))) { - typeErrors.add( - t('msg.formula.columnWithTypeFoundButExpected', { - columnName: parsedTree.name, - columnType: abstractType, - expectedType, - }), - ) - } else if (expectedType === formulaTypes.NUMERIC && !isNumericCol(childFieldColumn)) { - typeErrors.add( - t('msg.formula.columnWithTypeFoundButExpected', { - columnName: parsedTree.name, - columnType: abstractType, - expectedType, - }), - ) - } - } - break - } - - // not supported - case UITypes.ForeignKey: - case UITypes.Attachment: - case UITypes.ID: - case UITypes.Time: - case UITypes.Percent: - case UITypes.Duration: - case UITypes.Lookup: - case UITypes.Barcode: - case UITypes.Button: - case UITypes.Checkbox: - case UITypes.Collaborator: - case UITypes.QrCode: - default: - 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) { - // parsedTree.name won't be available here - typeErrors.add( - t('msg.formula.typeIsExpectedButFound', { - type: formulaTypes.NUMERIC, - found: expectedType, - }), - ) - } - } else if (parsedTree.type === JSEPNode.CALL_EXP) { - 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, - }), - ) - } - } - return 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 - } else if (parsedTree.type === JSEPNode.IDENTIFIER) { - const col = supportedColumns.value.find((c) => c.title === parsedTree.name) as Record - if (col?.uidt === UITypes.Formula) { - return getRootDataType(jsep(col?.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 formulaTypes.STRING - - // numeric - case UITypes.Year: - case UITypes.Number: - case UITypes.Decimal: - case UITypes.Rating: - case UITypes.Count: - case UITypes.AutoNumber: - return formulaTypes.NUMERIC - - // date - case UITypes.Date: - case UITypes.DateTime: - case UITypes.CreateTime: - case UITypes.LastModifiedTime: - return formulaTypes.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: - case UITypes.QrCode: - default: - return 'N/A' - } - } - } else if (parsedTree.type === JSEPNode.BINARY_EXP || parsedTree.type === JSEPNode.UNARY_EXP) { - return formulaTypes.NUMERIC - } else if (parsedTree.type === JSEPNode.LITERAL) { - return typeof parsedTree.value - } else { - return 'N/A' - } -} - function isCurlyBracketBalanced() { // count number of opening curly brackets and closing curly brackets const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce( @@ -796,10 +253,6 @@ setAdditionalValidations({ onMounted(() => { jsep.plugins.register(jsepCurlyHook) }) - -// const predictFunction = async () => { -// await _predictFunction(formState, meta, supportedColumns, suggestionsList, vModel) -// }