diff --git a/packages/nc-gui/components/virtual-cell/Formula.vue b/packages/nc-gui/components/virtual-cell/Formula.vue index 164c0f3b48..7c32d4af85 100644 --- a/packages/nc-gui/components/virtual-cell/Formula.vue +++ b/packages/nc-gui/components/virtual-cell/Formula.vue @@ -2,7 +2,7 @@ import { handleTZ } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk' import type { Ref } from 'vue' -import { CellValueInj, ColumnInj, computed, inject, renderValue, replaceUrlsWithLink, useBase } from '#imports' +import { CellValueInj, ColumnInj, computed, inject, renderValue, replaceUrlsWithLink, useBase, useGlobal } from '#imports' // todo: column type doesn't have required property `error` - throws in typecheck const column = inject(ColumnInj) as Ref @@ -11,6 +11,8 @@ const cellValue = inject(CellValueInj) const { isPg } = useBase() +const { showNull } = useGlobal() + const result = computed(() => isPg(column.value.source_id) ? renderValue(handleTZ(cellValue?.value)) : renderValue(cellValue?.value), ) @@ -30,6 +32,8 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ ERR! + {{ $t('general.null') }} +
diff --git a/packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts b/packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts index e36ed59f2c..665f6bd6d9 100644 --- a/packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts +++ b/packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts @@ -873,6 +873,43 @@ async function _formulaQueryBuilder( ); } + // if operator is == or !=, then handle comparison with BLANK which should accept NULL and empty string + if (pt.operator === '==' || pt.operator === '!=') { + if (pt.left.callee?.name !== pt.right.callee?.name) { + // if left/right is BLANK, accept both NULL and empty string + for (const operand of ['left', 'right']) { + if ( + pt[operand].type === 'CallExpression' && + pt[operand].callee.name === 'BLANK' + ) { + const isString = + pt[operand === 'left' ? 'right' : 'left'].dataType === + FormulaDataTypes.STRING; + let calleeName; + + if (pt.operator === '==') { + calleeName = isString ? 'ISBLANK' : 'ISNULL'; + } else { + calleeName = isString ? 'ISNOTBLANK' : 'ISNOTNULL'; + } + + return fn( + { + type: 'CallExpression', + arguments: [operand === 'left' ? pt.right : pt.left], + callee: { + type: 'Identifier', + name: calleeName, + }, + }, + alias, + prevBinaryOp, + ); + } + } + } + } + if (pt.operator === '==') { pt.operator = '='; // if left/right is of different type, convert to string and compare diff --git a/packages/nocodb/src/db/functionMappings/commonFns.ts b/packages/nocodb/src/db/functionMappings/commonFns.ts index e2ab573a91..376086d65c 100644 --- a/packages/nocodb/src/db/functionMappings/commonFns.ts +++ b/packages/nocodb/src/db/functionMappings/commonFns.ts @@ -33,7 +33,6 @@ async function treatArgAsConditionalExp( } export default { - // todo: handle default case SWITCH: async (args: MapFnArgs) => { const count = Math.floor((args.pt.arguments.length - 1) / 2); let query = ''; @@ -55,6 +54,9 @@ export default { const switchVal = (await args.fn(args.pt.arguments[0])).builder.toQuery(); + // used it for null value check + let elseValPrefix = ''; + for (let i = 0; i < count; i++) { let val; // cast to string if the return value types are different @@ -73,13 +75,34 @@ export default { val = (await args.fn(args.pt.arguments[i * 2 + 2])).builder.toQuery(); } - query += args.knex - .raw( - `\n\tWHEN ${( - await args.fn(args.pt.arguments[i * 2 + 1]) - ).builder.toQuery()} THEN ${val}`, - ) - .toQuery(); + if ( + args.pt.arguments[i * 2 + 1].type === 'CallExpression' && + args.pt.arguments[i * 2 + 1].callee?.name === 'BLANK' + ) { + elseValPrefix += args.knex + .raw( + `\n\tWHEN ${switchVal} IS NULL ${ + args.pt.arguments[i * 2 + 1].dataType === FormulaDataTypes.STRING + ? `OR ${switchVal} = ''` + : '' + } THEN ${val}`, + ) + .toQuery(); + } else if ( + args.pt.arguments[i * 2 + 1].dataType === FormulaDataTypes.NULL + ) { + elseValPrefix += args.knex + .raw(`\n\tWHEN ${switchVal} IS NULL THEN ${val}`) + .toQuery(); + } else { + query += args.knex + .raw( + `\n\tWHEN ${( + await args.fn(args.pt.arguments[i * 2 + 1]) + ).builder.toQuery()} THEN ${val}`, + ) + .toQuery(); + } } if (args.pt.arguments.length % 2 === 0) { let val; @@ -100,8 +123,13 @@ export default { await args.fn(args.pt.arguments[args.pt.arguments.length - 1]) ).builder.toQuery(); } - - query += `\n\tELSE ${val}`; + if (elseValPrefix) { + query += `\n\tELSE (CASE ${elseValPrefix} ELSE ${val} END)`; + } else { + query += `\n\tELSE ${val}`; + } + } else if (elseValPrefix) { + query += `\n\tELSE (CASE ${elseValPrefix} END)`; } return { builder: args.knex.raw( @@ -321,4 +349,36 @@ export default { ), }; }, + ISBLANK: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const { builder: valueBuilder } = await fn(pt.arguments[0]); + + return { + builder: knex.raw( + `(${valueBuilder} IS NULL OR ${valueBuilder} = '')${colAlias}`, + ), + }; + }, + ISNULL: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const { builder: valueBuilder } = await fn(pt.arguments[0]); + + return { + builder: knex.raw(`(${valueBuilder} IS NULL)${colAlias}`), + }; + }, + ISNOTBLANK: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const { builder: valueBuilder } = await fn(pt.arguments[0]); + + return { + builder: knex.raw( + `(${valueBuilder} IS NOT NULL AND ${valueBuilder} != '')${colAlias}`, + ), + }; + }, + ISNOTNULL: async ({ fn, knex, pt, colAlias }: MapFnArgs) => { + const { builder: valueBuilder } = await fn(pt.arguments[0]); + + return { + builder: knex.raw(`(${valueBuilder} IS NOT NULL)${colAlias}`), + }; + }, }; diff --git a/packages/nocodb/src/helpers/formulaHelpers.ts b/packages/nocodb/src/helpers/formulaHelpers.ts index e69de29bb2..3dd0b525df 100644 --- a/packages/nocodb/src/helpers/formulaHelpers.ts +++ b/packages/nocodb/src/helpers/formulaHelpers.ts @@ -0,0 +1,35 @@ +import jsep from 'jsep'; +import { UITypes } from 'nocodb-sdk'; +import type FormulaColumn from '../models/FormulaColumn'; +import type { Column } from '~/models'; + +export async function getFormulasReferredTheColumn({ + column, + columns, +}: { + column: Column; + columns: Column[]; +}): Promise { + const fn = (pt) => { + if (pt.type === 'CallExpression') { + return pt.arguments.some((arg) => fn(arg)); + } else if (pt.type === 'Literal') { + } else if (pt.type === 'Identifier') { + return [column.id, column.title].includes(pt.name); + } else if (pt.type === 'BinaryExpression') { + return fn(pt.left) || fn(pt.right); + } + }; + + return columns.reduce(async (columnsPromise, c) => { + const columns = await columnsPromise; + if (c.uidt !== UITypes.Formula) return columns; + + const formula = await c.getColOptions(); + + if (fn(jsep(formula.formula))) { + columns.push(c); + } + return columns; + }, Promise.resolve([])); +} diff --git a/packages/nocodb/src/models/Column.ts b/packages/nocodb/src/models/Column.ts index 2d76ed8860..6b4b07805d 100644 --- a/packages/nocodb/src/models/Column.ts +++ b/packages/nocodb/src/models/Column.ts @@ -3,6 +3,7 @@ import { isLinksOrLTAR, UITypes, } from 'nocodb-sdk'; +import { Logger } from '@nestjs/common'; import type { ColumnReqType, ColumnType } from 'nocodb-sdk'; import FormulaColumn from '~/models/FormulaColumn'; import LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn'; @@ -28,6 +29,7 @@ import { } from '~/utils/globals'; import NocoCache from '~/cache/NocoCache'; import { parseMetaProp, stringifyMetaProp } from '~/utils/modelUtils'; +import { getFormulasReferredTheColumn } from '~/helpers/formulaHelpers'; const selectColors = [ '#cfdffe', @@ -42,6 +44,8 @@ const selectColors = [ '#eeeeee', ]; +const logger = new Logger('Column'); + export default class Column implements ColumnType { public fk_model_id: string; public base_id: string; @@ -1159,6 +1163,26 @@ export default class Column implements ColumnType { // on column update, delete any optimised single query cache await NocoCache.delAll(CacheScope.SINGLE_QUERY, `${oldCol.fk_model_id}:*`); + + const updatedColumn = await Column.get({ colId }); + + // invalidate formula parsed-tree in which current column is used + // whenever a new request comes for that formula, it will be populated again + getFormulasReferredTheColumn({ + column: updatedColumn, + columns: await Column.list({ fk_model_id: column.fk_model_id }), + }) + .then(async (formulas) => { + for (const formula of formulas) { + await FormulaColumn.update(formula.id, { + parsed_tree: null, + }); + } + }) + // ignore the error and continue, if formula is no longer valid it will be captured in the next run + .catch((err) => { + logger.error(err); + }); } static async updateAlias( diff --git a/packages/nocodb/src/models/FormulaColumn.ts b/packages/nocodb/src/models/FormulaColumn.ts index f807737286..c9bd744f73 100644 --- a/packages/nocodb/src/models/FormulaColumn.ts +++ b/packages/nocodb/src/models/FormulaColumn.ts @@ -74,8 +74,6 @@ export default class FormulaColumn { 'parsed_tree', ]); - updateObj.parsed_tree = stringifyMetaProp(updateObj, 'parsed_tree'); - // get existing cache const key = `${CacheScope.COL_FORMULA}:${id}`; let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); @@ -84,6 +82,8 @@ export default class FormulaColumn { // set cache await NocoCache.set(key, o); } + if ('parsed_tree' in updateObj) + updateObj.parsed_tree = stringifyMetaProp(updateObj, 'parsed_tree'); // set meta await ncMeta.metaUpdate(null, null, MetaTable.COL_FORMULA, updateObj, id); }