mirror of
https://github.com/nocodb/nocodb.git
synced 2026-04-25 02:55:29 +00:00
handle time formula operations for PG
This commit is contained in:
@@ -2,4 +2,8 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^\~/(.+)$': '<rootDir>/src/$1'
|
||||
},
|
||||
testMatch: ['**/src/**/*.(spec|test).ts']
|
||||
};
|
||||
@@ -74,4 +74,68 @@ describe('Formula parsing and type validation', () => {
|
||||
|
||||
expect(result1.dataType).toEqual(FormulaDataTypes.NUMERIC);
|
||||
});
|
||||
|
||||
describe.only('Date and time interaction', () => {
|
||||
it('Time - time equals numeric', async () => {
|
||||
const result = await validateFormulaAndExtractTreeWithType({
|
||||
formula: '{Time1} - {Time2}',
|
||||
columns: [
|
||||
{
|
||||
id: 'TUrXeTf4JUHdnRvn',
|
||||
title: 'Time1',
|
||||
uidt: UITypes.Time,
|
||||
},
|
||||
{
|
||||
id: 'J3aD/yLDT2GF6NEB',
|
||||
title: 'Time2',
|
||||
uidt: UITypes.Time,
|
||||
},
|
||||
],
|
||||
clientOrSqlUi: 'pg',
|
||||
getMeta: async () => ({}),
|
||||
});
|
||||
expect(result.dataType).toEqual(FormulaDataTypes.NUMERIC);
|
||||
});
|
||||
it('Time + time equals numeric', async () => {
|
||||
const result = await validateFormulaAndExtractTreeWithType({
|
||||
formula: '{Time1} - {Time2}',
|
||||
columns: [
|
||||
{
|
||||
id: 'TUrXeTf4JUHdnRvn',
|
||||
title: 'Time1',
|
||||
uidt: UITypes.Time,
|
||||
},
|
||||
{
|
||||
id: 'J3aD/yLDT2GF6NEB',
|
||||
title: 'Time2',
|
||||
uidt: UITypes.Time,
|
||||
},
|
||||
],
|
||||
clientOrSqlUi: 'pg',
|
||||
getMeta: async () => ({}),
|
||||
});
|
||||
expect(result.dataType).toEqual(FormulaDataTypes.NUMERIC);
|
||||
});
|
||||
it('Date + time equals date', async () => {
|
||||
const result = await validateFormulaAndExtractTreeWithType({
|
||||
formula: '{Date1} + {Time2}',
|
||||
columns: [
|
||||
{
|
||||
id: 'TUrXeTf4JUHdnRvn',
|
||||
title: 'Date1',
|
||||
uidt: UITypes.Date,
|
||||
},
|
||||
{
|
||||
id: 'J3aD/yLDT2GF6NEB',
|
||||
title: 'Time2',
|
||||
uidt: UITypes.Time,
|
||||
},
|
||||
],
|
||||
clientOrSqlUi: 'pg',
|
||||
getMeta: async () => ({}),
|
||||
});
|
||||
console.log(result);
|
||||
expect(result.dataType).toEqual(FormulaDataTypes.DATE);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -6,6 +6,44 @@ import dayjs from 'dayjs';
|
||||
import { MssqlUi, MysqlUi, PgUi, SnowflakeUi, SqlUiFactory } from './sqlUi';
|
||||
import { dateFormats } from './dateTimeHelper';
|
||||
|
||||
export const ArithmeticOperators = ['+', '-', '*', '/'] as const;
|
||||
export const ComparisonOperators = ['==', '<', '>', '<=', '>=', '!='] as const;
|
||||
type ArithmeticOperator = (typeof ArithmeticOperators)[number];
|
||||
type ComparisonOperator = (typeof ComparisonOperators)[number];
|
||||
type BaseFormulaNode = {
|
||||
type: JSEPNode;
|
||||
dataType: FormulaDataTypes;
|
||||
};
|
||||
interface BinaryExpressionNode extends BaseFormulaNode {
|
||||
operator: ArithmeticOperator | ComparisonOperator;
|
||||
type: JSEPNode.BINARY_EXP;
|
||||
right: ParsedFormulaNode;
|
||||
left: ParsedFormulaNode;
|
||||
}
|
||||
interface CallExpressionNode extends BaseFormulaNode {
|
||||
type: JSEPNode.CALL_EXP;
|
||||
arguments: ParsedFormulaNode[];
|
||||
callee: {
|
||||
type: 'Identifier';
|
||||
name: 'DATETIME_DIFF';
|
||||
};
|
||||
}
|
||||
interface IdentifierNode extends BaseFormulaNode {
|
||||
type: JSEPNode.IDENTIFIER;
|
||||
name: string;
|
||||
raw: string;
|
||||
}
|
||||
interface LiteralNode extends BaseFormulaNode {
|
||||
type: JSEPNode.LITERAL;
|
||||
value: string;
|
||||
raw: string;
|
||||
}
|
||||
type ParsedFormulaNode =
|
||||
| BinaryExpressionNode
|
||||
| CallExpressionNode
|
||||
| IdentifierNode
|
||||
| LiteralNode;
|
||||
|
||||
// opening and closing string code
|
||||
const OCURLY_CODE = 123; // '{'
|
||||
const CCURLY_CODE = 125; // '}'
|
||||
@@ -245,6 +283,7 @@ export enum FormulaDataTypes {
|
||||
COND_EXP = 'conditional_expression',
|
||||
NULL = 'null',
|
||||
BOOLEAN = 'boolean',
|
||||
INTERVAL = 'interval',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
@@ -1609,6 +1648,9 @@ async function extractColumnIdentifierType({
|
||||
res.dataType = FormulaDataTypes.NUMERIC;
|
||||
}
|
||||
break;
|
||||
case UITypes.Time:
|
||||
res.dataType = FormulaDataTypes.INTERVAL;
|
||||
break;
|
||||
case UITypes.ID:
|
||||
case UITypes.ForeignKey:
|
||||
case UITypes.SpecificDBType:
|
||||
@@ -1632,7 +1674,6 @@ async function extractColumnIdentifierType({
|
||||
}
|
||||
break;
|
||||
// not supported
|
||||
case UITypes.Time:
|
||||
case UITypes.Lookup:
|
||||
case UITypes.Barcode:
|
||||
case UITypes.Button:
|
||||
@@ -1929,7 +1970,21 @@ export async function validateFormulaAndExtractTreeWithType({
|
||||
res.left = await validateAndExtract(parsedTree.left);
|
||||
res.right = await validateAndExtract(parsedTree.right);
|
||||
|
||||
if (['==', '<', '>', '<=', '>=', '!='].includes(parsedTree.operator)) {
|
||||
if (
|
||||
handleBinaryExpressionForDateAndTime({ sourceBinaryNode: res as any })
|
||||
) {
|
||||
Object.assign(
|
||||
res,
|
||||
handleBinaryExpressionForDateAndTime({ sourceBinaryNode: res as any })
|
||||
);
|
||||
if (res.type !== JSEPNode.BINARY_EXP) {
|
||||
delete res.left;
|
||||
delete res.right;
|
||||
delete res.operator;
|
||||
}
|
||||
} else if (
|
||||
['==', '<', '>', '<=', '>=', '!='].includes(parsedTree.operator)
|
||||
) {
|
||||
res.dataType = FormulaDataTypes.COND_EXP;
|
||||
} else if (parsedTree.operator === '+') {
|
||||
res.dataType = FormulaDataTypes.NUMERIC;
|
||||
@@ -1970,7 +2025,6 @@ export async function validateFormulaAndExtractTreeWithType({
|
||||
'Compound statement is not supported'
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -1981,6 +2035,157 @@ export async function validateFormulaAndExtractTreeWithType({
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleBinaryExpressionForDateAndTime(params: {
|
||||
sourceBinaryNode: BinaryExpressionNode;
|
||||
}): BaseFormulaNode | undefined {
|
||||
const { sourceBinaryNode } = params;
|
||||
let res: BaseFormulaNode;
|
||||
|
||||
if (
|
||||
[FormulaDataTypes.DATE, FormulaDataTypes.INTERVAL].includes(
|
||||
sourceBinaryNode.left.dataType
|
||||
) &&
|
||||
[FormulaDataTypes.DATE, FormulaDataTypes.INTERVAL].includes(
|
||||
sourceBinaryNode.right.dataType
|
||||
) &&
|
||||
sourceBinaryNode.operator === '-'
|
||||
) {
|
||||
// when it's interval and interval, we return diff in minute (numeric)
|
||||
if (
|
||||
[FormulaDataTypes.INTERVAL].includes(sourceBinaryNode.left.dataType) &&
|
||||
[FormulaDataTypes.INTERVAL].includes(sourceBinaryNode.right.dataType)
|
||||
) {
|
||||
res = {
|
||||
type: JSEPNode.CALL_EXP,
|
||||
arguments: [
|
||||
sourceBinaryNode.left,
|
||||
sourceBinaryNode.right,
|
||||
{
|
||||
type: 'Literal',
|
||||
value: 'minute',
|
||||
raw: '"minute"',
|
||||
dataType: 'string',
|
||||
},
|
||||
],
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: 'DATETIME_DIFF',
|
||||
},
|
||||
dataType: FormulaDataTypes.NUMERIC,
|
||||
} as CallExpressionNode;
|
||||
}
|
||||
// else interval and date can be addedd seamlessly A - B
|
||||
// with result as DATE
|
||||
// may be changed if we find other db use case
|
||||
else if (
|
||||
[FormulaDataTypes.INTERVAL, FormulaDataTypes.DATE].includes(
|
||||
sourceBinaryNode.left.dataType
|
||||
) &&
|
||||
[FormulaDataTypes.INTERVAL, FormulaDataTypes.DATE].includes(
|
||||
sourceBinaryNode.right.dataType
|
||||
) &&
|
||||
sourceBinaryNode.left.dataType != sourceBinaryNode.right.dataType
|
||||
) {
|
||||
res = {
|
||||
type: JSEPNode.BINARY_EXP,
|
||||
left: sourceBinaryNode.left,
|
||||
right: sourceBinaryNode.right,
|
||||
operator: '-',
|
||||
dataType: FormulaDataTypes.DATE,
|
||||
} as BinaryExpressionNode;
|
||||
}
|
||||
} else if (
|
||||
[FormulaDataTypes.DATE, FormulaDataTypes.INTERVAL].includes(
|
||||
sourceBinaryNode.left.dataType
|
||||
) &&
|
||||
[FormulaDataTypes.DATE, FormulaDataTypes.INTERVAL].includes(
|
||||
sourceBinaryNode.right.dataType
|
||||
) &&
|
||||
sourceBinaryNode.operator === '+'
|
||||
) {
|
||||
// when it's interval and interval, we return addition in minute (numeric)
|
||||
if (
|
||||
[FormulaDataTypes.INTERVAL].includes(sourceBinaryNode.left.dataType) &&
|
||||
[FormulaDataTypes.INTERVAL].includes(sourceBinaryNode.right.dataType)
|
||||
) {
|
||||
const left = {
|
||||
type: JSEPNode.CALL_EXP,
|
||||
arguments: [
|
||||
sourceBinaryNode.left,
|
||||
{
|
||||
type: 'Literal',
|
||||
value: '00:00:00',
|
||||
raw: '"00:00:00"',
|
||||
dataType: 'string',
|
||||
},
|
||||
{
|
||||
type: 'Literal',
|
||||
value: 'minute',
|
||||
raw: '"minute"',
|
||||
dataType: 'string',
|
||||
},
|
||||
],
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: 'DATETIME_DIFF',
|
||||
},
|
||||
dataType: FormulaDataTypes.NUMERIC,
|
||||
} as CallExpressionNode;
|
||||
const right = {
|
||||
type: JSEPNode.CALL_EXP,
|
||||
arguments: [
|
||||
sourceBinaryNode.right,
|
||||
{
|
||||
type: 'Literal',
|
||||
value: '00:00:00',
|
||||
raw: '"00:00:00"',
|
||||
dataType: 'string',
|
||||
},
|
||||
{
|
||||
type: 'Literal',
|
||||
value: 'minute',
|
||||
raw: '"minute"',
|
||||
dataType: 'string',
|
||||
},
|
||||
],
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: 'DATETIME_DIFF',
|
||||
},
|
||||
dataType: FormulaDataTypes.NUMERIC,
|
||||
} as CallExpressionNode;
|
||||
return {
|
||||
type: JSEPNode.BINARY_EXP,
|
||||
left,
|
||||
right,
|
||||
operator: '+',
|
||||
dataType: FormulaDataTypes.NUMERIC,
|
||||
} as BinaryExpressionNode;
|
||||
}
|
||||
// else interval and date can be addedd seamlessly A + B
|
||||
// with result as DATE
|
||||
// may be changed if we find other db use case
|
||||
else if (
|
||||
[FormulaDataTypes.INTERVAL, FormulaDataTypes.DATE].includes(
|
||||
sourceBinaryNode.left.dataType
|
||||
) &&
|
||||
[FormulaDataTypes.INTERVAL, FormulaDataTypes.DATE].includes(
|
||||
sourceBinaryNode.right.dataType
|
||||
) &&
|
||||
sourceBinaryNode.left.dataType != sourceBinaryNode.right.dataType
|
||||
) {
|
||||
res = {
|
||||
type: JSEPNode.BINARY_EXP,
|
||||
left: sourceBinaryNode.left,
|
||||
right: sourceBinaryNode.right,
|
||||
operator: '+',
|
||||
dataType: FormulaDataTypes.DATE,
|
||||
} as BinaryExpressionNode;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function checkForCircularFormulaRef(formulaCol, parsedTree, columns) {
|
||||
// check circular reference
|
||||
// e.g. formula1 -> formula2 -> formula1 should return circular reference error
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import jsep from 'jsep';
|
||||
import {
|
||||
ComparisonOperators,
|
||||
FormulaDataTypes,
|
||||
jsepCurlyHook,
|
||||
LongTextAiMetaProp,
|
||||
@@ -1246,44 +1247,46 @@ async function _formulaQueryBuilder(params: {
|
||||
let right = (await fn(pt.right, null, pt.operator)).builder.toQuery();
|
||||
let sql = `${left} ${pt.operator} ${right}${colAlias}`;
|
||||
|
||||
// comparing a date with empty string would throw
|
||||
// `ERROR: zero-length delimited identifier` in Postgres
|
||||
if (
|
||||
knex.clientType() === 'pg' &&
|
||||
columnIdToUidt[pt.left.name] === UITypes.Date
|
||||
) {
|
||||
// The correct way to compare with Date should be using
|
||||
// `IS_AFTER`, `IS_BEFORE`, or `IS_SAME`
|
||||
// This is to prevent empty data returned to UI due to incorrect SQL
|
||||
if (pt.right.value === '') {
|
||||
if (pt.operator === '=') {
|
||||
sql = `${left} IS NULL ${colAlias}`;
|
||||
} else {
|
||||
if (ComparisonOperators.includes(pt.operator)) {
|
||||
// comparing a date with empty string would throw
|
||||
// `ERROR: zero-length delimited identifier` in Postgres
|
||||
if (
|
||||
knex.clientType() === 'pg' &&
|
||||
columnIdToUidt[pt.left.name] === UITypes.Date
|
||||
) {
|
||||
// The correct way to compare with Date should be using
|
||||
// `IS_AFTER`, `IS_BEFORE`, or `IS_SAME`
|
||||
// This is to prevent empty data returned to UI due to incorrect SQL
|
||||
if (pt.right.value === '') {
|
||||
if (pt.operator === '=') {
|
||||
sql = `${left} IS NULL ${colAlias}`;
|
||||
} else {
|
||||
sql = `${left} IS NOT NULL ${colAlias}`;
|
||||
}
|
||||
} else if (!validateDateWithUnknownFormat(pt.right.value)) {
|
||||
// left tree value is date but right tree value is not date
|
||||
// return true if left tree value is not null, else false
|
||||
sql = `${left} IS NOT NULL ${colAlias}`;
|
||||
}
|
||||
} else if (!validateDateWithUnknownFormat(pt.right.value)) {
|
||||
// left tree value is date but right tree value is not date
|
||||
// return true if left tree value is not null, else false
|
||||
sql = `${left} IS NOT NULL ${colAlias}`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
knex.clientType() === 'pg' &&
|
||||
columnIdToUidt[pt.right.name] === UITypes.Date
|
||||
) {
|
||||
// The correct way to compare with Date should be using
|
||||
// `IS_AFTER`, `IS_BEFORE`, or `IS_SAME`
|
||||
// This is to prevent empty data returned to UI due to incorrect SQL
|
||||
if (pt.left.value === '') {
|
||||
if (pt.operator === '=') {
|
||||
sql = `${right} IS NULL ${colAlias}`;
|
||||
} else {
|
||||
if (
|
||||
knex.clientType() === 'pg' &&
|
||||
columnIdToUidt[pt.right.name] === UITypes.Date
|
||||
) {
|
||||
// The correct way to compare with Date should be using
|
||||
// `IS_AFTER`, `IS_BEFORE`, or `IS_SAME`
|
||||
// This is to prevent empty data returned to UI due to incorrect SQL
|
||||
if (pt.left.value === '') {
|
||||
if (pt.operator === '=') {
|
||||
sql = `${right} IS NULL ${colAlias}`;
|
||||
} else {
|
||||
sql = `${right} IS NOT NULL ${colAlias}`;
|
||||
}
|
||||
} else if (!validateDateWithUnknownFormat(pt.left.value)) {
|
||||
// right tree value is date but left tree value is not date
|
||||
// return true if right tree value is not null, else false
|
||||
sql = `${right} IS NOT NULL ${colAlias}`;
|
||||
}
|
||||
} else if (!validateDateWithUnknownFormat(pt.left.value)) {
|
||||
// right tree value is date but left tree value is not date
|
||||
// return true if right tree value is not null, else false
|
||||
sql = `${right} IS NOT NULL ${colAlias}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,14 +76,16 @@ const pg = {
|
||||
const rawUnit = pt.arguments[2]
|
||||
? (await fn(pt.arguments[2])).builder.bindings[0]
|
||||
: 'seconds';
|
||||
const expr1_typecast =
|
||||
pt.arguments[0].dataType === FormulaDataTypes.UNKNOWN
|
||||
? ''
|
||||
: '::TIMESTAMP';
|
||||
const expr2_typecast =
|
||||
pt.arguments[1].dataType === FormulaDataTypes.UNKNOWN
|
||||
? ''
|
||||
: '::TIMESTAMP';
|
||||
const expr1_typecast = [FormulaDataTypes.DATE].includes(
|
||||
pt.arguments[0].dataType,
|
||||
)
|
||||
? '::TIMESTAMP'
|
||||
: '';
|
||||
const expr2_typecast = [FormulaDataTypes.DATE].includes(
|
||||
pt.arguments[1].dataType,
|
||||
)
|
||||
? '::TIMESTAMP'
|
||||
: '';
|
||||
|
||||
let sql;
|
||||
const unit = convertUnits(rawUnit, 'pg');
|
||||
|
||||
Reference in New Issue
Block a user