Browse Source

Merge pull request #7268 from nocodb/nc-feat/formula-type

Formula type extraction and improvements
pull/7274/head
Pranav C 1 year ago committed by GitHub
parent
commit
beabf159c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 591
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  2. 623
      packages/nc-gui/utils/formulaUtils.ts
  3. 5
      packages/nocodb-sdk/jest.config.js
  4. 5
      packages/nocodb-sdk/package.json
  5. 6
      packages/nocodb-sdk/src/lib/Api.ts
  6. 77
      packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts
  7. 1638
      packages/nocodb-sdk/src/lib/formulaHelpers.ts
  8. 1
      packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts
  9. 2
      packages/nocodb-sdk/src/lib/sqlUi/MysqlUi.ts
  10. 1
      packages/nocodb-sdk/src/lib/sqlUi/OracleUi.ts
  11. 1
      packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts
  12. 5
      packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts
  13. 2
      packages/nocodb-sdk/tsconfig.json
  14. 103
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  15. 160
      packages/nocodb/src/db/functionMappings/commonFns.ts
  16. 15
      packages/nocodb/src/db/functionMappings/mysql.ts
  17. 65
      packages/nocodb/src/db/functionMappings/pg.ts
  18. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  19. 16
      packages/nocodb/src/meta/migrations/v2/nc_038_formula_parsed_tree_column.ts
  20. 7
      packages/nocodb/src/models/Column.ts
  21. 30
      packages/nocodb/src/models/FormulaColumn.ts
  22. 52
      packages/nocodb/src/services/columns.service.ts
  23. 18
      packages/nocodb/tests/unit/factory/column.ts
  24. 2
      packages/nocodb/tests/unit/rest/index.test.ts
  25. 125
      packages/nocodb/tests/unit/rest/tests/formula.test.ts
  26. 440
      pnpm-lock.yaml
  27. 4
      tests/playwright/tests/db/columns/columnFormula.spec.ts

591
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -2,34 +2,28 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue' import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep' import jsep from 'jsep'
import type { ColumnType, FormulaType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { import {
FormulaError,
UITypes, UITypes,
isLinksOrLTAR,
isNumericCol,
isSystemColumn,
jsepCurlyHook, jsepCurlyHook,
substituteColumnIdWithAliasInFormula, substituteColumnIdWithAliasInFormula,
validateDateWithUnknownFormat, validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk' } from 'nocodb-sdk'
import type { ColumnType, FormulaType } from 'nocodb-sdk'
import { import {
MetaInj, MetaInj,
NcAutocompleteTree, NcAutocompleteTree,
computed, computed,
formulaList, formulaList,
formulaTypes,
formulas, formulas,
getUIDTIcon, getUIDTIcon,
getWordUntilCaret, getWordUntilCaret,
iconMap, iconMap,
inject, inject,
insertAtCursor, insertAtCursor,
isDate,
nextTick, nextTick,
onMounted, onMounted,
ref, ref,
storeToRefs,
useBase,
useColumnCreateStoreOrThrow, useColumnCreateStoreOrThrow,
useDebounceFn, useDebounceFn,
useI18n, useI18n,
@ -52,59 +46,38 @@ const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCrea
const { t } = useI18n() const { t } = useI18n()
const baseStore = useBase()
const { tables } = storeToRefs(baseStore)
const { predictFunction: _predictFunction } = useNocoEe() 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 meta = inject(MetaInj, ref())
const supportedColumns = computed( const supportedColumns = computed(
() => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [], () => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [],
) )
const { metas } = useMetas() const { getMeta } = 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<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]
})
const validators = { const validators = {
formula_raw: [ formula_raw: [
{ {
validator: (_: any, formula: any) => { validator: (_: any, formula: any) => {
return new Promise<void>((resolve, reject) => { return (async () => {
if (!formula?.trim()) return reject(new Error('Required')) if (!formula?.trim()) throw new Error('Required')
const res = parseAndValidateFormula(formula)
if (res !== true) { try {
return reject(new Error(res)) 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 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<string, any>) => 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<string, any>) => 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<string, any>[], c: Record<string, any>) => {
// 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<string, any>
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() { function isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets // count number of opening curly brackets and closing curly brackets
const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce( const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce(
@ -796,10 +253,6 @@ setAdditionalValidations({
onMounted(() => { onMounted(() => {
jsep.plugins.register(jsepCurlyHook) jsep.plugins.register(jsepCurlyHook)
}) })
// const predictFunction = async () => {
// await _predictFunction(formState, meta, supportedColumns, suggestionsList, vModel)
// }
</script> </script>
<template> <template>

623
packages/nc-gui/utils/formulaUtils.ts

@ -1,624 +1,5 @@
import type { Input as AntInput } from 'ant-design-vue' import type { Input as AntInput } from 'ant-design-vue'
import { formulas } from 'nocodb-sdk'
const formulaTypes = {
NUMERIC: 'numeric',
STRING: 'string',
DATE: 'date',
LOGICAL: 'logical',
COND_EXP: 'conditional_expression',
}
const formulas: Record<string, any> = {
AVG: {
type: formulaTypes.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})'],
},
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: {
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")',
],
},
DATETIME_DIFF: {
type: formulaTypes.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")',
],
},
AND: {
type: formulaTypes.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)'],
},
OR: {
type: formulaTypes.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)'],
},
CONCAT: {
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: {
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: {
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: {
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)'],
},
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: {
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: {
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})'],
},
WEEKDAY: {
type: formulaTypes.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")'],
},
TRUE: {
type: formulaTypes.NUMERIC,
validation: {
args: {
max: 0,
},
},
description: 'Returns 1',
syntax: 'TRUE()',
examples: ['TRUE()'],
},
FALSE: {
type: formulaTypes.NUMERIC,
validation: {
args: {
max: 0,
},
},
description: 'Returns 0',
syntax: 'FALSE()',
examples: ['FALSE()'],
},
REGEX_MATCH: {
type: formulaTypes.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.*")'],
},
REGEX_EXTRACT: {
type: formulaTypes.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.*")'],
},
REGEX_REPLACE: {
type: formulaTypes.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")'],
},
BLANK: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 0,
},
},
description: 'Returns a blank value(null)',
syntax: 'BLANK()',
examples: ['BLANK()'],
},
XOR: {
type: formulaTypes.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())'],
},
EVEN: {
type: formulaTypes.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})'],
},
ODD: {
type: formulaTypes.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})'],
},
RECORD_ID: {
validation: {
args: {
rqd: 0,
},
},
description: 'Returns the record id of the current record',
syntax: 'RECORD_ID()',
examples: ['RECORD_ID()'],
},
COUNTA: {
validation: {
args: {
min: 1,
},
},
description: 'Counts the number of non-empty arguments',
syntax: 'COUNTA(value1, [value2, ...])',
examples: ['COUNTA({field1}, {field2})'],
},
COUNT: {
validation: {
args: {
min: 1,
},
},
description: 'Count the number of arguments that are numbers',
syntax: 'COUNT(value1, [value2, ...])',
examples: ['COUNT({field1}, {field2})'],
},
COUNTALL: {
validation: {
args: {
min: 1,
},
},
description: 'Counts the number of arguments',
syntax: 'COUNTALL(value1, [value2, ...])',
examples: ['COUNTALL({field1}, {field2})'],
},
ROUNDDOWN: {
type: formulaTypes.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)'],
},
ROUNDUP: {
type: formulaTypes.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)'],
},
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")'],
},
// 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()'],
// },
}
const formulaList = Object.keys(formulas) const formulaList = Object.keys(formulas)
@ -671,4 +52,4 @@ function GetCaretPosition(ctrl: typeof AntInput) {
return CaretPos return CaretPos
} }
export { formulaList, formulas, formulaTypes, getWordUntilCaret, insertAtCursor } export { formulaList, formulas, getWordUntilCaret, insertAtCursor }

5
packages/nocodb-sdk/jest.config.js

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

5
packages/nocodb-sdk/package.json

@ -30,6 +30,7 @@
"fix:prettier": "prettier \"src/**/*.ts\" --write", "fix:prettier": "prettier \"src/**/*.ts\" --write",
"fix:lint": "eslint src --ext .ts --fix", "fix:lint": "eslint src --ext .ts --fix",
"test": "run-s build test:*", "test": "run-s build test:*",
"test:unit": "ENV_FILE=./config/.env.test jest",
"test:lint": "eslint src --ext .ts", "test:lint": "eslint src --ext .ts",
"test:prettier": "prettier \"src/**/*.ts\" --list-different", "test:prettier": "prettier \"src/**/*.ts\" --list-different",
"test:spelling": "cspell \"{README.md,.github/*.md,src/**/*.ts}\"", "test:spelling": "cspell \"{README.md,.github/*.md,src/**/*.ts}\"",
@ -45,6 +46,7 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"@types/jest": "^29.5.2",
"cspell": "^4.2.8", "cspell": "^4.2.8",
"eslint": "^8.54.0", "eslint": "^8.54.0",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^8.10.0",
@ -56,7 +58,8 @@
"prettier": "^2.8.8", "prettier": "^2.8.8",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"tsc-alias": "^1.8.8", "tsc-alias": "^1.8.8",
"typescript": "^5.3.3" "typescript": "^5.3.3",
"ts-jest": "^29.1.1"
}, },
"files": [ "files": [
"build/main", "build/main",

6
packages/nocodb-sdk/src/lib/Api.ts

@ -3341,7 +3341,7 @@ export class Api<
}), }),
/** /**
* @description Regenerate user refresh token * @description Creates a new refresh token and JWT auth token for the user. The refresh token is sent as a cookie, while the JWT auth token is included in the response body.
* *
* @tags Auth * @tags Auth
* @name TokenRefresh * @name TokenRefresh
@ -3349,7 +3349,7 @@ export class Api<
* @request POST:/api/v1/auth/token/refresh * @request POST:/api/v1/auth/token/refresh
* @response `200` `{ * @response `200` `{
\** \**
* New access token for user * New JWT auth token for user
* @example 96751db2d53fb834382b682268874a2ea9ee610e4d904e688d1513f11d3c30d62d36d9e05dec0d63 * @example 96751db2d53fb834382b682268874a2ea9ee610e4d904e688d1513f11d3c30d62d36d9e05dec0d63
*\ *\
token?: string, token?: string,
@ -3365,7 +3365,7 @@ export class Api<
this.request< this.request<
{ {
/** /**
* New access token for user * New JWT auth token for user
* @example 96751db2d53fb834382b682268874a2ea9ee610e4d904e688d1513f11d3c30d62d36d9e05dec0d63 * @example 96751db2d53fb834382b682268874a2ea9ee610e4d904e688d1513f11d3c30d62d36d9e05dec0d63
*/ */
token?: string; token?: string;

77
packages/nocodb-sdk/src/lib/formulaHelpers.spec.ts

@ -0,0 +1,77 @@
import {
FormulaDataTypes,
validateFormulaAndExtractTreeWithType,
} from './formulaHelpers';
import UITypes from './UITypes';
describe('Formula parsing and type validation', () => {
it('Simple formula', async () => {
const result = await validateFormulaAndExtractTreeWithType({
formula: '1 + 2',
columns: [],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result.dataType).toEqual(FormulaDataTypes.NUMERIC);
});
it('Formula with IF condition', async () => {
const result = await validateFormulaAndExtractTreeWithType({
formula: 'IF({column}, "Found", BLANK())',
columns: [
{
id: 'cid',
title: 'column',
uidt: UITypes.Number,
},
],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result.dataType).toEqual(FormulaDataTypes.STRING);
});
it('Complex formula', async () => {
const result = await validateFormulaAndExtractTreeWithType({
formula:
'SWITCH({column2},"value1",IF({column1}, "Found", BLANK()),"value2", 2)',
columns: [
{
id: 'id1',
title: 'column1',
uidt: UITypes.Number,
},
{
id: 'id2',
title: 'column2',
uidt: UITypes.SingleLineText,
},
],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result.dataType).toEqual(FormulaDataTypes.STRING);
const result1 = await validateFormulaAndExtractTreeWithType({
formula: 'SWITCH({column2},"value1",IF({column1}, 1, 2),"value2", 2)',
columns: [
{
id: 'id1',
title: 'column1',
uidt: UITypes.Number,
},
{
id: 'id2',
title: 'column2',
uidt: UITypes.SingleLineText,
},
],
clientOrSqlUi: 'mysql2',
getMeta: async () => ({}),
});
expect(result1.dataType).toEqual(FormulaDataTypes.NUMERIC);
});
});

1638
packages/nocodb-sdk/src/lib/formulaHelpers.ts

File diff suppressed because it is too large Load Diff

1
packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts

@ -919,6 +919,7 @@ export class MssqlUi {
'COUNT', 'COUNT',
'ROUNDDOWN', 'ROUNDDOWN',
'ROUNDUP', 'ROUNDUP',
'DATESTR',
]; ];
} }
} }

2
packages/nocodb-sdk/src/lib/sqlUi/MysqlUi.ts

@ -1289,6 +1289,6 @@ export class MysqlUi {
} }
static getUnsupportedFnList() { static getUnsupportedFnList() {
return ['COUNTA', 'COUNT']; return ['COUNTA', 'COUNT', 'DATESTR'];
} }
} }

1
packages/nocodb-sdk/src/lib/sqlUi/OracleUi.ts

@ -948,6 +948,7 @@ export class OracleUi {
'COUNT', 'COUNT',
'ROUNDDOWN', 'ROUNDDOWN',
'ROUNDUP', 'ROUNDUP',
'DATESTR',
]; ];
} }
} }

1
packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts

@ -980,6 +980,7 @@ export class SnowflakeUi {
'VALUE', 'VALUE',
'COUNTA', 'COUNTA',
'COUNT', 'COUNT',
'DATESTR',
]; ];
} }
} }

5
packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts

@ -882,7 +882,10 @@ export class SqliteUi {
'COUNT', 'COUNT',
'ROUNDDOWN', 'ROUNDDOWN',
'ROUNDUP', 'ROUNDUP',
'DATESTR',
'DAY',
'MONTH',
'HOUR',
]; ];
} }
} }

2
packages/nocodb-sdk/tsconfig.json

@ -38,7 +38,7 @@
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"lib": ["es2017","dom"], "lib": ["es2017","dom"],
"types": [], "types": ["jest"],
"typeRoots": ["node_modules/@types", "src/types"], "typeRoots": ["node_modules/@types", "src/types"],
"baseUrl": "./src", "baseUrl": "./src",
"paths": { "paths": {

103
packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts

@ -1,23 +1,26 @@
import jsep from 'jsep'; import jsep from 'jsep';
import { import {
FormulaDataTypes,
jsepCurlyHook, jsepCurlyHook,
UITypes, UITypes,
validateDateWithUnknownFormat, validateDateWithUnknownFormat,
validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import { Logger } from '@nestjs/common';
import mapFunctionName from '../mapFunctionName'; import mapFunctionName from '../mapFunctionName';
import genRollupSelectv2 from '../genRollupSelectv2'; import genRollupSelectv2 from '../genRollupSelectv2';
import type Column from '~/models/Column';
import type Model from '~/models/Model';
import type RollupColumn from '~/models/RollupColumn'; import type RollupColumn from '~/models/RollupColumn';
import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn'; import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn';
import type LookupColumn from '~/models/LookupColumn'; import type LookupColumn from '~/models/LookupColumn';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type Column from '~/models/Column';
import Model from '~/models/Model';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope } from '~/utils/globals'; import { CacheGetType, CacheScope } from '~/utils/globals';
import { convertDateFormatForConcat } from '~/helpers/formulaFnHelper'; import { convertDateFormatForConcat } from '~/helpers/formulaFnHelper';
import FormulaColumn from '~/models/FormulaColumn'; import FormulaColumn from '~/models/FormulaColumn';
// todo: switch function based on database const logger = new Logger('FormulaQueryBuilderv2');
// @ts-ignore // @ts-ignore
const getAggregateFn: (fnName: string) => (args: { qb; knex?; cn }) => any = ( const getAggregateFn: (fnName: string) => (args: { qb; knex?; cn }) => any = (
@ -59,17 +62,56 @@ async function _formulaQueryBuilder(
model: Model, model: Model,
aliasToColumn: Record<string, () => Promise<{ builder: any }>> = {}, aliasToColumn: Record<string, () => Promise<{ builder: any }>> = {},
tableAlias?: string, tableAlias?: string,
parsedTree?: any,
column: Column = null,
) { ) {
const knex = baseModelSqlv2.dbDriver; const knex = baseModelSqlv2.dbDriver;
// formula may include double curly brackets in previous version const columns = await model.getColumns();
// convert to single curly bracket here for compatibility
const tree = jsep(_tree.replaceAll('{{', '{').replaceAll('}}', '}')); let tree = parsedTree;
if (!tree) {
// formula may include double curly brackets in previous version
// convert to single curly bracket here for compatibility
// const _tree1 = jsep(_tree.replaceAll('{{', '{').replaceAll('}}', '}'));
tree = await validateFormulaAndExtractTreeWithType({
formula: _tree.replaceAll('{{', '{').replaceAll('}}', '}'),
columns,
column,
clientOrSqlUi: baseModelSqlv2.clientType as
| 'mysql'
| 'pg'
| 'sqlite3'
| 'mssql'
| 'mysql2'
| 'oracledb'
| 'mariadb'
| 'sqlite'
| 'snowflake',
getMeta: async (modelId) => {
const model = await Model.get(modelId);
await model.getColumns();
return model;
},
});
// populate and save parsedTree to column if not exist
if (column) {
FormulaColumn.update(column.id, { parsed_tree: tree }).then(
() => {
// ignore
},
(err) => {
logger.error(err);
},
);
}
}
const columnIdToUidt = {}; const columnIdToUidt = {};
// todo: improve - implement a common solution for filter, sort, formula, etc // todo: improve - implement a common solution for filter, sort, formula, etc
for (const col of await model.getColumns()) { for (const col of columns) {
columnIdToUidt[col.id] = col.uidt; columnIdToUidt[col.id] = col.uidt;
if (col.id in aliasToColumn) continue; if (col.id in aliasToColumn) continue;
switch (col.uidt) { switch (col.uidt) {
@ -84,6 +126,7 @@ async function _formulaQueryBuilder(
model, model,
{ ...aliasToColumn, [col.id]: null }, { ...aliasToColumn, [col.id]: null },
tableAlias, tableAlias,
formulOption.getParsedTree(),
); );
builder.sql = '(' + builder.sql + ')'; builder.sql = '(' + builder.sql + ')';
return { return {
@ -404,6 +447,7 @@ async function _formulaQueryBuilder(
'', '',
lookupModel, lookupModel,
aliasToColumn, aliasToColumn,
formulaOption.getParsedTree(),
); );
if (isMany) { if (isMany) {
const qb = selectQb; const qb = selectQb;
@ -792,8 +836,48 @@ async function _formulaQueryBuilder(
); );
} }
// if operator is + and expected return type is string, convert to concat
if (pt.operator === '+' && pt.dataType === FormulaDataTypes.STRING) {
return fn(
{
type: 'CallExpression',
arguments: [pt.left, pt.right],
callee: {
type: 'Identifier',
name: 'CONCAT',
},
},
alias,
prevBinaryOp,
);
}
if (pt.operator === '==') { if (pt.operator === '==') {
pt.operator = '='; pt.operator = '=';
// if left/right is of different type, convert to string and compare
if (
pt.left.dataType !== pt.right.dataType &&
[pt.left.dataType, pt.right.dataType].every(
(type) => type !== FormulaDataTypes.NULL,
)
) {
pt.left = {
type: 'CallExpression',
arguments: [pt.left],
callee: {
type: 'Identifier',
name: 'STRING',
},
};
pt.right = {
type: 'CallExpression',
arguments: [pt.right],
callee: {
type: 'Identifier',
name: 'STRING',
},
};
}
} }
if (pt.operator === '/') { if (pt.operator === '/') {
@ -947,6 +1031,7 @@ export default async function formulaQueryBuilderv2(
aliasToColumn = {}, aliasToColumn = {},
tableAlias?: string, tableAlias?: string,
validateFormula = false, validateFormula = false,
parsedTree?: any,
) { ) {
const knex = baseModelSqlv2.dbDriver; const knex = baseModelSqlv2.dbDriver;
// register jsep curly hook once only // register jsep curly hook once only
@ -959,6 +1044,10 @@ export default async function formulaQueryBuilderv2(
model, model,
aliasToColumn, aliasToColumn,
tableAlias, tableAlias,
parsedTree ??
(await column
?.getColOptions<FormulaColumn>()
.then((formula) => formula?.getParsedTree())),
); );
if (!validateFormula) return qb; if (!validateFormula) return qb;

160
packages/nocodb/src/db/functionMappings/commonFns.ts

@ -1,33 +1,107 @@
import { FormulaDataTypes } from 'nocodb-sdk';
import type { MapFnArgs } from '../mapFunctionName'; import type { MapFnArgs } from '../mapFunctionName';
import { NcError } from '~/helpers/catchError'; import { NcError } from '~/helpers/catchError';
async function treatArgAsConditionalExp(
args: MapFnArgs,
argument = args.pt?.arguments?.[0],
) {
const condArg = (await args.fn(argument)).builder.toQuery();
let cond = condArg;
// based on the data type of the argument, we need to handle the condition
// if string - value is not null and not empty then true
// if number - value is not null and not 0 then true
// if boolean - value is not null and not false then true
// if date - value is not null then true
switch (argument.dataType as FormulaDataTypes) {
case FormulaDataTypes.NUMERIC:
cond = `(${condArg}) IS NOT NULL AND (${condArg}) != 0`;
break;
case FormulaDataTypes.STRING:
cond = `(${condArg}) IS NOT NULL AND (${condArg}) != ''`;
break;
case FormulaDataTypes.BOOLEAN:
cond = `(${condArg}) IS NOT NULL AND (${condArg}) != false`;
break;
case FormulaDataTypes.DATE:
cond = `(${condArg}) IS NOT NULL`;
break;
}
return { builder: args.knex.raw(cond) };
}
export default { export default {
// todo: handle default case // todo: handle default case
SWITCH: async (args: MapFnArgs) => { SWITCH: async (args: MapFnArgs) => {
const count = Math.floor((args.pt.arguments.length - 1) / 2); const count = Math.floor((args.pt.arguments.length - 1) / 2);
let query = ''; let query = '';
const returnArgsType = new Set(
args.pt.arguments
.filter(
(type, i) => i > 1 && i % 2 === 0 && type !== FormulaDataTypes.NULL,
)
.map((type) => type.dataType),
);
// if else case present then push that to types
if (args.pt.arguments.length % 2 === 0) {
returnArgsType.add(
args.pt.arguments[args.pt.arguments.length - 1].dataType,
);
}
const switchVal = (await args.fn(args.pt.arguments[0])).builder.toQuery(); const switchVal = (await args.fn(args.pt.arguments[0])).builder.toQuery();
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
let val;
// cast to string if the return value types are different
if (returnArgsType.size > 1) {
val = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[i * 2 + 2]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder.toQuery();
} else {
val = (await args.fn(args.pt.arguments[i * 2 + 2])).builder.toQuery();
}
query += args.knex query += args.knex
.raw( .raw(
`\n\tWHEN ${( `\n\tWHEN ${(
await args.fn(args.pt.arguments[i * 2 + 1]) await args.fn(args.pt.arguments[i * 2 + 1])
).builder.toQuery()} THEN ${( ).builder.toQuery()} THEN ${val}`,
await args.fn(args.pt.arguments[i * 2 + 2])
).builder.toQuery()}`,
) )
.toQuery(); .toQuery();
} }
if (args.pt.arguments.length % 2 === 0) { if (args.pt.arguments.length % 2 === 0) {
query += args.knex let val;
.raw( // cast to string if the return value types are different
`\n\tELSE ${( if (returnArgsType.size > 1) {
await args.fn(args.pt.arguments[args.pt.arguments.length - 1]) val = (
).builder.toQuery()}`, await args.fn({
) type: 'CallExpression',
.toQuery(); arguments: [args.pt.arguments[args.pt.arguments.length - 1]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder.toQuery();
} else {
val = (
await args.fn(args.pt.arguments[args.pt.arguments.length - 1])
).builder.toQuery();
}
query += `\n\tELSE ${val}`;
} }
return { return {
builder: args.knex.raw( builder: args.knex.raw(
@ -36,24 +110,52 @@ export default {
}; };
}, },
IF: async (args: MapFnArgs) => { IF: async (args: MapFnArgs) => {
let query = args.knex const cond = (await treatArgAsConditionalExp(args)).builder;
.raw( let thenArg;
`\n\tWHEN ${( let elseArg;
await args.fn(args.pt.arguments[0]) const returnArgsType = new Set(
).builder.toQuery()} THEN ${( [args.pt.arguments[1].dataType, args.pt.arguments[2].dataType].filter(
await args.fn(args.pt.arguments[1]) (type) => type !== FormulaDataTypes.NULL,
).builder.toQuery()}`, ),
) );
.toQuery(); // cast to string if the return value types are different
if (returnArgsType.size > 1) {
thenArg = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[1]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder;
elseArg = (
await args.fn({
type: 'CallExpression',
arguments: [args.pt.arguments[2]],
callee: {
type: 'Identifier',
name: 'STRING',
},
} as any)
).builder;
} else {
thenArg = (await args.fn(args.pt.arguments[1])).builder.toQuery();
elseArg = (await args.fn(args.pt.arguments[2])).builder.toQuery();
}
let query = args.knex.raw(`\n\tWHEN ${cond} THEN ${thenArg}`).toQuery();
if (args.pt.arguments[2]) { if (args.pt.arguments[2]) {
query += args.knex query += args.knex.raw(`\n\tELSE ${elseArg}`).toQuery();
.raw(
`\n\tELSE ${(await args.fn(args.pt.arguments[2])).builder.toQuery()}`,
)
.toQuery();
} }
return { builder: args.knex.raw(`CASE ${query}\n END${args.colAlias}`) }; return { builder: args.knex.raw(`CASE ${query}\n END${args.colAlias}`) };
}, },
// used only for casting to string internally, this one is dummy function
// and will work as fallback for dbs which don't support/implemented CAST
STRING(args: MapFnArgs) {
return args.fn(args.pt?.arguments?.[0]);
},
AND: async (args: MapFnArgs) => { AND: async (args: MapFnArgs) => {
return { return {
builder: args.knex.raw( builder: args.knex.raw(
@ -62,7 +164,7 @@ export default {
`${( `${(
await Promise.all( await Promise.all(
args.pt.arguments.map(async (ar) => args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery(), (await treatArgAsConditionalExp(args, ar)).builder.toQuery(),
), ),
) )
).join(' AND ')}`, ).join(' AND ')}`,
@ -80,7 +182,7 @@ export default {
`${( `${(
await Promise.all( await Promise.all(
args.pt.arguments.map(async (ar) => args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery(), (await treatArgAsConditionalExp(args, ar)).builder.toQuery(),
), ),
) )
).join(' OR ')}`, ).join(' OR ')}`,
@ -124,7 +226,7 @@ export default {
const query = (await args.fn(args.pt.arguments[0])).builder; const query = (await args.fn(args.pt.arguments[0])).builder;
return { return {
builder: args.knex.raw( builder: args.knex.raw(
`CASE WHEN ${query} % 2 = 0 THEN CEIL(${query})\nELSE CEIL(${query} / 2.0) * 2\n END${args.colAlias}`, `CASE WHEN ${query} >= 0 THEN CEIL((${query}) / 2.0) * 2 \n ELSE FLOOR((${query} + 2) / 2.0) * 2 - 2\n END${args.colAlias}`,
), ),
}; };
}, },
@ -201,7 +303,7 @@ export default {
return { return {
builder: knex.raw( builder: knex.raw(
`ROUND(FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`, `(FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`,
), ),
}; };
}, },
@ -215,7 +317,7 @@ export default {
return { return {
builder: knex.raw( builder: knex.raw(
`ROUND(CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`, `(CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))${colAlias}`,
), ),
}; };
}, },

15
packages/nocodb/src/db/functionMappings/mysql.ts

@ -125,7 +125,9 @@ const mysql2 = {
const source = (await fn(pt.arguments[0])).builder; const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder; const pattern = (await fn(pt.arguments[1])).builder;
return { return {
builder: knex.raw(`REGEXP_SUBSTR(${source}, ${pattern}) ${colAlias}`), builder: knex.raw(
`REGEXP_SUBSTR(${source}, ${pattern}, 1, 1, 'c') ${colAlias}`,
),
}; };
}, },
REGEX_REPLACE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { REGEX_REPLACE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
@ -134,7 +136,7 @@ const mysql2 = {
const replacement = (await fn(pt.arguments[2])).builder; const replacement = (await fn(pt.arguments[2])).builder;
return { return {
builder: knex.raw( builder: knex.raw(
`REGEXP_REPLACE(${source}, ${pattern}, ${replacement}) ${colAlias}`, `REGEXP_REPLACE(${source}, ${pattern}, ${replacement}, 1, 0, 'c') ${colAlias}`,
), ),
}; };
}, },
@ -160,6 +162,15 @@ END) ${colAlias}`,
), ),
}; };
}, },
STRING: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} AS CHAR) ${
args.colAlias
}`,
),
};
},
}; };
export default mysql2; export default mysql2;

65
packages/nocodb/src/db/functionMappings/pg.ts

@ -1,4 +1,5 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FormulaDataTypes } from 'nocodb-sdk';
import commonFns from './commonFns'; import commonFns from './commonFns';
import type { MapFnArgs } from '../mapFunctionName'; import type { MapFnArgs } from '../mapFunctionName';
import { convertUnits } from '~/helpers/convertUnits'; import { convertUnits } from '~/helpers/convertUnits';
@ -57,9 +58,9 @@ const pg = {
DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return { return {
builder: knex.raw( builder: knex.raw(
`${(await fn(pt.arguments[0])).builder} + (${ `(${(await fn(pt.arguments[0])).builder})${
(await fn(pt.arguments[1])).builder pt.arguments[0].dataType !== FormulaDataTypes.DATE ? '::DATE' : ''
} || } + (${(await fn(pt.arguments[1])).builder} ||
'${String((await fn(pt.arguments[2])).builder).replace( '${String((await fn(pt.arguments[2])).builder).replace(
/["']/g, /["']/g,
'', '',
@ -134,6 +135,42 @@ const pg = {
), ),
}; };
}, },
DATESTR: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`TO_CHAR((${
(await fn(pt?.arguments[0])).builder
}), 'YYYY-MM-DD')::text ${colAlias}`,
),
};
},
DAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`EXTRACT('Day' FROM ((${
(await fn(pt?.arguments[0])).builder
})::TIMESTAMP)) ${colAlias}`,
),
};
},
MONTH: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`EXTRACT('Month' FROM ((${
(await fn(pt?.arguments[0])).builder
})::TIMESTAMP)) ${colAlias}`,
),
};
},
HOUR: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`EXTRACT('Hour' FROM ((${
(await fn(pt?.arguments[0])).builder
})::TIMESTAMP)) ${colAlias}`,
),
};
},
AND: async (args: MapFnArgs) => { AND: async (args: MapFnArgs) => {
return { return {
builder: args.knex.raw( builder: args.knex.raw(
@ -191,15 +228,17 @@ const pg = {
}, },
REGEX_MATCH: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { REGEX_MATCH: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const source = (await fn(pt.arguments[0])).builder; const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder; const pattern = (await fn(pt.arguments[1])).builder;
return { return {
builder: knex.raw( builder: knex.raw(
`CASE WHEN (${source}::TEXT ~ ${pattern}::TEXT) THEN 1 ELSE 0 END ${colAlias}`, `CASE WHEN REGEXP_MATCH(${source}::TEXT, ${pattern}::TEXT) IS NULL THEN 0 ELSE 1 END ${colAlias}`,
), ),
}; };
}, },
REGEX_EXTRACT: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { REGEX_EXTRACT: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const source = (await fn(pt.arguments[0])).builder; const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder; const pattern = (await fn(pt.arguments[1])).builder;
return { return {
builder: knex.raw( builder: knex.raw(
@ -212,6 +251,7 @@ const pg = {
REGEX_REPLACE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { REGEX_REPLACE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const source = (await fn(pt.arguments[0])).builder; const source = (await fn(pt.arguments[0])).builder;
const pattern = (await fn(pt.arguments[1])).builder; const pattern = (await fn(pt.arguments[1])).builder;
const replacement = (await fn(pt.arguments[2])).builder; const replacement = (await fn(pt.arguments[2])).builder;
return { return {
builder: knex.raw( builder: knex.raw(
@ -263,11 +303,11 @@ const pg = {
return { return {
builder: knex.raw( builder: knex.raw(
`ROUND(CASE `CASE
WHEN ${value} IS NULL OR REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g') IN ('.', '') OR LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^.]+', '', 'g')) > 1 THEN NULL WHEN ${value} IS NULL OR REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g') IN ('.', '') OR LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^.]+', '', 'g')) > 1 THEN NULL
WHEN LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^%]', '','g')) > 0 THEN POW(-1, LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^-]','', 'g'))) * (REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g'))::NUMERIC / 100 WHEN LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^%]', '','g')) > 0 THEN POW(-1, LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^-]','', 'g'))) * (REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g'))::NUMERIC / 100
ELSE POW(-1, LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^-]', '', 'g'))) * (REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g'))::NUMERIC ELSE POW(-1, LENGTH(REGEXP_REPLACE(${value}::TEXT, '[^-]', '', 'g'))) * (REGEXP_REPLACE(${value}::TEXT, '[^\\d.]+', '', 'g'))::NUMERIC
END) ${colAlias}`, END ${colAlias}`,
), ),
}; };
}, },
@ -281,7 +321,7 @@ END) ${colAlias}`,
return { return {
builder: knex.raw( builder: knex.raw(
`ROUND((FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder})::numeric(30,${precisionBuilder})))${colAlias}`, `(FLOOR((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder})::numeric(30,${precisionBuilder}))${colAlias}`,
), ),
}; };
}, },
@ -295,7 +335,16 @@ END) ${colAlias}`,
return { return {
builder: knex.raw( builder: knex.raw(
`ROUND((CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder}))::numeric(30,${precisionBuilder}))${colAlias}`, `(CEIL((${valueBuilder}) * POWER(10, ${precisionBuilder})) / POWER(10, ${precisionBuilder})::numeric(30,${precisionBuilder}))${colAlias}`,
),
};
},
STRING: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`(${(await args.fn(args.pt.arguments[0])).builder})::text ${
args.colAlias
}`,
), ),
}; };
}, },

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -24,6 +24,7 @@ import * as nc_034_erd_filter_and_notification from '~/meta/migrations/v2/nc_034
import * as nc_035_add_username_to_users from '~/meta/migrations/v2/nc_035_add_username_to_users'; import * as nc_035_add_username_to_users from '~/meta/migrations/v2/nc_035_add_username_to_users';
import * as nc_036_base_deleted from '~/meta/migrations/v2/nc_036_base_deleted'; import * as nc_036_base_deleted from '~/meta/migrations/v2/nc_036_base_deleted';
import * as nc_037_rename_project_and_base from '~/meta/migrations/v2/nc_037_rename_project_and_base'; import * as nc_037_rename_project_and_base from '~/meta/migrations/v2/nc_037_rename_project_and_base';
import * as nc_038_formula_parsed_tree_column from '~/meta/migrations/v2/nc_038_formula_parsed_tree_column';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -59,6 +60,7 @@ export default class XcMigrationSourcev2 {
'nc_035_add_username_to_users', 'nc_035_add_username_to_users',
'nc_036_base_deleted', 'nc_036_base_deleted',
'nc_037_rename_project_and_base', 'nc_037_rename_project_and_base',
'nc_038_formula_parsed_tree_column',
]); ]);
} }
@ -120,6 +122,8 @@ export default class XcMigrationSourcev2 {
return nc_036_base_deleted; return nc_036_base_deleted;
case 'nc_037_rename_project_and_base': case 'nc_037_rename_project_and_base':
return nc_037_rename_project_and_base; return nc_037_rename_project_and_base;
case 'nc_038_formula_parsed_tree_column':
return nc_038_formula_parsed_tree_column;
} }
} }
} }

16
packages/nocodb/src/meta/migrations/v2/nc_038_formula_parsed_tree_column.ts

@ -0,0 +1,16 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_FORMULA, (table) => {
table.text('parsed_tree');
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.COL_FORMULA, (table) => {
table.dropColumn('parsed_tree');
});
};
export { up, down };

7
packages/nocodb/src/models/Column.ts

@ -308,6 +308,7 @@ export default class Column<T = any> implements ColumnType {
fk_column_id: colId, fk_column_id: colId,
formula: column.formula, formula: column.formula,
formula_raw: column.formula_raw, formula_raw: column.formula_raw,
parsed_tree: column.parsed_tree,
}, },
ncMeta, ncMeta,
); );
@ -713,7 +714,11 @@ export default class Column<T = any> implements ColumnType {
title: col?.title, title: col?.title,
}) })
) )
await FormulaColumn.update(formulaCol.id, formula, ncMeta); await FormulaColumn.update(
formulaCol.id,
formula as FormulaColumn & { parsed_tree?: any },
ncMeta,
);
} }
} }

30
packages/nocodb/src/models/FormulaColumn.ts

@ -2,19 +2,23 @@ import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps'; import { extractProps } from '~/helpers/extractProps';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { parseMetaProp, stringifyMetaProp } from '~/utils/modelUtils';
export default class FormulaColumn { export default class FormulaColumn {
formula: string; formula: string;
formula_raw: string; formula_raw: string;
fk_column_id: string; fk_column_id: string;
error: string; error: string;
private parsed_tree?: any;
constructor(data: Partial<FormulaColumn>) { constructor(data: Partial<FormulaColumn> & { parsed_tree?: any }) {
Object.assign(this, data); const { parsed_tree, ...rest } = data;
this.parsed_tree = parsed_tree;
Object.assign(this, rest);
} }
public static async insert( public static async insert(
formulaColumn: Partial<FormulaColumn>, formulaColumn: Partial<FormulaColumn> & { parsed_tree?: any },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
) { ) {
const insertObj = extractProps(formulaColumn, [ const insertObj = extractProps(formulaColumn, [
@ -22,11 +26,16 @@ export default class FormulaColumn {
'formula_raw', 'formula_raw',
'formula', 'formula',
'error', 'error',
'parsed_tree',
]); ]);
insertObj.parsed_tree = stringifyMetaProp(insertObj, 'parsed_tree');
await ncMeta.metaInsert2(null, null, MetaTable.COL_FORMULA, insertObj); await ncMeta.metaInsert2(null, null, MetaTable.COL_FORMULA, insertObj);
return this.read(formulaColumn.fk_column_id, ncMeta); return this.read(formulaColumn.fk_column_id, ncMeta);
} }
public static async read(columnId: string, ncMeta = Noco.ncMeta) { public static async read(columnId: string, ncMeta = Noco.ncMeta) {
let column = let column =
columnId && columnId &&
@ -41,7 +50,10 @@ export default class FormulaColumn {
MetaTable.COL_FORMULA, MetaTable.COL_FORMULA,
{ fk_column_id: columnId }, { fk_column_id: columnId },
); );
await NocoCache.set(`${CacheScope.COL_FORMULA}:${columnId}`, column); if (column) {
column.parsed_tree = parseMetaProp(column, 'parsed_tree');
await NocoCache.set(`${CacheScope.COL_FORMULA}:${columnId}`, column);
}
} }
return column ? new FormulaColumn(column) : null; return column ? new FormulaColumn(column) : null;
@ -51,7 +63,7 @@ export default class FormulaColumn {
static async update( static async update(
id: string, id: string,
formula: Partial<FormulaColumn>, formula: Partial<FormulaColumn> & { parsed_tree?: any },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
) { ) {
const updateObj = extractProps(formula, [ const updateObj = extractProps(formula, [
@ -59,7 +71,11 @@ export default class FormulaColumn {
'formula_raw', 'formula_raw',
'fk_column_id', 'fk_column_id',
'error', 'error',
'parsed_tree',
]); ]);
updateObj.parsed_tree = stringifyMetaProp(updateObj, 'parsed_tree');
// get existing cache // get existing cache
const key = `${CacheScope.COL_FORMULA}:${id}`; const key = `${CacheScope.COL_FORMULA}:${id}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -71,4 +87,8 @@ export default class FormulaColumn {
// set meta // set meta
await ncMeta.metaUpdate(null, null, MetaTable.COL_FORMULA, updateObj, id); await ncMeta.metaUpdate(null, null, MetaTable.COL_FORMULA, updateObj, id);
} }
public getParsedTree() {
return this.parsed_tree;
}
} }

52
packages/nocodb/src/services/columns.service.ts

@ -6,6 +6,7 @@ import {
substituteColumnAliasWithIdInFormula, substituteColumnAliasWithIdInFormula,
substituteColumnIdWithAliasInFormula, substituteColumnIdWithAliasInFormula,
UITypes, UITypes,
validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection'; import { pluralize, singularize } from 'inflection';
import hash from 'object-hash'; import hash from 'object-hash';
@ -184,6 +185,7 @@ export class ColumnsService {
let colBody = { ...param.column } as Column & { let colBody = { ...param.column } as Column & {
formula?: string; formula?: string;
formula_raw?: string; formula_raw?: string;
parsed_tree?: any;
}; };
if ( if (
[ [
@ -208,6 +210,17 @@ export class ColumnsService {
colBody.formula_raw || colBody.formula, colBody.formula_raw || colBody.formula,
table.columns, table.columns,
); );
colBody.parsed_tree = await validateFormulaAndExtractTreeWithType({
formula: colBody.formula || colBody.formula_raw,
columns: table.columns,
column,
clientOrSqlUi: source.type,
getMeta: async (modelId) => {
const model = await Model.get(modelId);
await model.getColumns();
return model;
},
});
try { try {
const baseModel = await reuseOrSave('baseModel', reuse, async () => const baseModel = await reuseOrSave('baseModel', reuse, async () =>
@ -227,6 +240,7 @@ export class ColumnsService {
{}, {},
null, null,
true, true,
colBody.parsed_tree,
); );
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -931,6 +945,17 @@ export class ColumnsService {
]); ]);
await FormulaColumn.update(c.id, { await FormulaColumn.update(c.id, {
formula_raw: new_formula_raw, formula_raw: new_formula_raw,
parsed_tree: await validateFormulaAndExtractTreeWithType({
formula: new_formula_raw,
columns: table.columns,
column,
clientOrSqlUi: source.type,
getMeta: async (modelId) => {
const model = await Model.get(modelId);
await model.getColumns();
return model;
},
}),
}); });
} }
} }
@ -992,6 +1017,17 @@ export class ColumnsService {
]); ]);
await FormulaColumn.update(c.id, { await FormulaColumn.update(c.id, {
formula_raw: new_formula_raw, formula_raw: new_formula_raw,
parsed_tree: await validateFormulaAndExtractTreeWithType({
formula: new_formula_raw,
columns: table.columns,
column,
clientOrSqlUi: source.type,
getMeta: async (modelId) => {
const model = await Model.get(modelId);
await model.getColumns();
return model;
},
}),
}); });
} }
} }
@ -1197,6 +1233,22 @@ export class ColumnsService {
colBody.formula_raw || colBody.formula, colBody.formula_raw || colBody.formula,
table.columns, table.columns,
); );
colBody.parsed_tree = await validateFormulaAndExtractTreeWithType({
// formula may include double curly brackets in previous version
// convert to single curly bracket here for compatibility
formula: colBody.formula,
column: {
...colBody,
colOptions: colBody,
},
columns: table.columns,
clientOrSqlUi: source.type,
getMeta: async (modelId) => {
const model = await Model.get(modelId);
await model.getColumns();
return model;
},
});
try { try {
const baseModel = await reuseOrSave('baseModel', reuse, async () => const baseModel = await reuseOrSave('baseModel', reuse, async () =>

18
packages/nocodb/tests/unit/factory/column.ts

@ -386,6 +386,23 @@ const updateViewColumn = async (
return updatedColumn; return updatedColumn;
}; };
const updateColumn = async (
context,
{ table, column, attr }: { column: Column; table: Model; attr: any },
) => {
const res = await request(context.app)
.patch(`/api/v2/meta/columns/${column.id}`)
.set('xc-auth', context.token)
.send({
...attr,
});
const updatedColumn: Column = (await table.getColumns()).find(
(column) => column.id === column.id,
);
return updatedColumn;
};
export { export {
customColumns, customColumns,
defaultColumns, defaultColumns,
@ -396,4 +413,5 @@ export {
createLookupColumn, createLookupColumn,
createLtarColumn, createLtarColumn,
updateViewColumn, updateViewColumn,
updateColumn,
}; };

2
packages/nocodb/tests/unit/rest/index.test.ts

@ -10,6 +10,7 @@ import attachmentTests from './tests/attachment.test';
import filterTest from './tests/filter.test'; import filterTest from './tests/filter.test';
import newDataApisTest from './tests/newDataApis.test'; import newDataApisTest from './tests/newDataApis.test';
import groupByTest from './tests/groupby.test'; import groupByTest from './tests/groupby.test';
import formulaTests from './tests/formula.test';
let workspaceTest = () => {}; let workspaceTest = () => {};
if (process.env.EE === 'true') { if (process.env.EE === 'true') {
@ -31,6 +32,7 @@ function restTests() {
newDataApisTest(); newDataApisTest();
groupByTest(); groupByTest();
workspaceTest(); workspaceTest();
formulaTests();
// Enable for dashboard feature // Enable for dashboard feature
// widgetTest(); // widgetTest();

125
packages/nocodb/tests/unit/rest/tests/formula.test.ts

@ -0,0 +1,125 @@
import 'mocha';
import { UITypes } from 'nocodb-sdk';
import { expect } from 'chai';
import init from '../../init';
import { createProject } from '../../factory/base';
import { createTable } from '../../factory/table';
import { createBulkRows, listRow, rowMixedValue } from '../../factory/row';
import { updateColumn } from '../../factory/column';
import type Model from '../../../../src/models/Model';
import type Base from '~/models/Base';
let context;
let base: Base;
let table: Model;
let columns: any[];
let unfilteredRecords: any[] = [];
function formulaRegExpBased() {
// prepare data for test cases
beforeEach(async function () {
context = await init();
base = await createProject(context);
table = await createTable(context, base, {
table_name: 'sampleTable',
title: 'sampleTable',
columns: [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Title',
title: 'Title',
uidt: UITypes.SingleLineText,
},
{
column_name: 'formula',
title: 'formula',
uidt: UITypes.Formula,
formula: '20',
},
],
});
columns = await table.getColumns();
const rowAttributes = [];
for (let i = 0; i < 100; i++) {
const row = {
Title: rowMixedValue(columns[1], i),
};
rowAttributes.push(row);
}
await createBulkRows(context, {
base,
table,
values: rowAttributes,
});
unfilteredRecords = await listRow({ base, table });
// verify length of unfiltered records to be 800
expect(unfilteredRecords.length).to.equal(100);
});
it('Type: REGEX_MATCH ', async () => {
// if not pg or mysql, skip regex test since it is not implemented for other databases
if (!['pg', 'mysql2', 'mysql'].includes(base.sources[0].type)) {
return;
}
const formulaList = [
`REGEX_MATCH("123-45-6789", "\\d{3}-\\d{2}-\\d{4}")`,
'REGEX_MATCH("123-45-6789", "\\d{3}-\\d{2}-\\d{4}")',
'REGEX_MATCH("123-45-6789", "\\d{3}-\\d{2}-\\d{4}")',
'REGEX_MATCH("ABC-45-6789", "\\w{3}-\\d{2}-\\d{4}")',
'REGEX_MATCH("123-XY-6789", "\\d{3}-\\D{2}-\\d{4}")',
'REGEX_MATCH("123-45-$#@!", "123-45-[\\s\\S]{4}")',
'REGEX_MATCH("123456789", "1?2?3?-?4?5-?6?7?8?9?")',
'REGEX_MATCH("123-456789", "\\d{3}-?\\d{2}-?\\d{4}")',
'REGEX_MATCH("123-45-6789", "123-\\d{2}-6789")',
'REGEX_MATCH("abc123", "[a-z]{3}\\d{3}")',
'REGEX_MATCH("A1B2C3", "[A-Z]\\d[A-Z]\\d[A-Z]\\d")',
'REGEX_MATCH("hello123world", "\\w{5}\\d{3}\\w{5}")',
'REGEX_MATCH("email@example.com", "[a-zA-Z]+@[a-zA-Z]+\\.[a-zA-Z]+")',
'REGEX_MATCH("2023-12-14", "\\d{4}-\\d{2}-\\d{2}")',
'REGEX_MATCH("USD 100.50", "USD \\d+\\.\\d{2}")',
'REGEX_MATCH("http://www.example.com", "https?://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")',
'REGEX_MATCH("555-1234", "\\d{3}-\\d{4}")',
'REGEX_MATCH("username123", "[a-zA-Z]+\\d{3}")',
'REGEX_MATCH("apple, orange, banana", "\\w+, \\w+, \\w+")',
'REGEX_MATCH("aaaabbcc", "(\\w{2})\\1")',
'REGEX_MATCH("1234567890", "\\d{10}")',
'REGEX_MATCH("12.34", "\\d+\\.\\d{2}")',
'REGEX_MATCH("123 Main St, City", "\\d+ [a-zA-Z]+ St, [a-zA-Z]+")',
'REGEX_MATCH("X1Y2Z3", "[A-Z]\\d[A-Z]\\d[A-Z]\\d")',
'REGEX_MATCH("555-555-5555", "\\d{3}-\\d{3}-\\d{4}")',
'REGEX_MATCH("password123", "^(?=.*\\d)(?=.*[a-zA-Z]).{8,}$")',
'REGEX_MATCH("12345", "^[0-9]{5}$")',
'REGEX_MATCH("abc123!@#", "[a-zA-Z0-9!@#]+")',
'REGEX_MATCH("12-December-2023", "\\d{2}-[a-zA-Z]+-\\d{4}")',
];
for (let i = 0; i < formulaList.length; i++) {
await updateColumn(context, {
table,
column: columns[2],
attr: {
formula: formulaList[i],
formula_raw: formulaList[i],
title: 'formula',
uidt: UITypes.Formula,
},
});
unfilteredRecords = await listRow({ base, table });
expect(unfilteredRecords[0].formula).to.equal(1);
}
});
}
export default function () {
describe('Formula: REGEXP based', formulaRegExpBased);
}

440
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

4
tests/playwright/tests/db/columns/columnFormula.spec.ts

@ -127,11 +127,11 @@ const formulaDataByDbType = (context: NcContext, index: number) => {
{ {
formula: 'ROUNDDOWN({CityId} + 2.49, 1)', formula: 'ROUNDDOWN({CityId} + 2.49, 1)',
result: ['3', '4', '5', '6', '7'], result: ['3.4', '4.4', '5.4', '6.4', '7.4'],
}, },
{ {
formula: 'ROUNDUP({CityId} + 2.49, 1)', formula: 'ROUNDUP({CityId} + 2.49, 1)',
result: ['4', '5', '6', '7', '8'], result: ['3.5', '4.5', '5.5', '6.5', '7.5'],
}, },
{ {
formula: 'RECORD_ID()', formula: 'RECORD_ID()',

Loading…
Cancel
Save