handle time formula operations for PG

This commit is contained in:
Fendy Heryanto
2025-02-04 03:29:41 +00:00
parent def56352f6
commit fb6d22209b
5 changed files with 322 additions and 44 deletions

View File

@@ -2,4 +2,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^\~/(.+)$': '<rootDir>/src/$1'
},
testMatch: ['**/src/**/*.(spec|test).ts']
};

View File

@@ -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);
});
})
});

View File

@@ -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

View File

@@ -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}`;
}
}

View File

@@ -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');