Skip to content

Commit fa16152

Browse files
authored
Merge pull request #2296 from teableio/T1326
fix: t1326
1 parent 75f04ee commit fa16152

5 files changed

Lines changed: 236 additions & 2 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { DbFieldType } from '@teable/core';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { GeneratedColumnQueryPostgres } from './generated-column-query.postgres';
5+
6+
describe('GeneratedColumnQueryPostgres if', () => {
7+
it('coerces json-like numeric branches in IF to avoid CASE jsonb/integer mismatches', () => {
8+
const query = new GeneratedColumnQueryPostgres();
9+
query.setContext({} as unknown as never);
10+
query.setCallMetadata([
11+
{ type: 'string', isFieldReference: false },
12+
{
13+
type: 'string',
14+
isFieldReference: true,
15+
field: {
16+
id: 'fldJsonNumeric',
17+
isMultiple: true,
18+
isLookup: true,
19+
dbFieldName: '__json_numeric',
20+
dbFieldType: DbFieldType.Json,
21+
cellValueType: 'number',
22+
},
23+
},
24+
{ type: 'number', isFieldReference: false },
25+
] as unknown as never);
26+
27+
const sql = query.if('__cond', '"__json_numeric"', '0');
28+
expect(sql).toContain('to_jsonb("__json_numeric")');
29+
expect(sql).toContain('jsonb_array_elements_text');
30+
expect(sql).toContain('double precision');
31+
});
32+
});

apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,35 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
271271
return this.getExpressionFieldType(value) === DbFieldType.Text;
272272
}
273273

274+
private isNumericLikeExpression(value: string, metadataIndex?: number): boolean {
275+
if (this.isNumericLiteral(value)) {
276+
return true;
277+
}
278+
279+
const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
280+
if (paramInfo?.hasMetadata) {
281+
if (
282+
paramInfo.type === 'number' ||
283+
isTrustedNumeric(paramInfo) ||
284+
isBooleanLikeParam(paramInfo)
285+
) {
286+
return true;
287+
}
288+
if (
289+
paramInfo.fieldDbType === DbFieldType.Real ||
290+
paramInfo.fieldDbType === DbFieldType.Integer
291+
) {
292+
return true;
293+
}
294+
if (paramInfo.fieldCellValueType === 'number') {
295+
return true;
296+
}
297+
}
298+
299+
const expressionFieldType = this.getExpressionFieldType(value);
300+
return expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer;
301+
}
302+
274303
private getExpressionFieldType(value: string): DbFieldType | undefined {
275304
const trimmed = this.stripOuterParentheses(value);
276305
const columnMatch = trimmed.match(/^"([^"]+)"$/) ?? trimmed.match(/^"[^"]+"\."([^"]+)"$/);
@@ -1207,6 +1236,7 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
12071236
const falseIsText = this.isTextLikeExpression(valueIfFalse, 2);
12081237
const trueIsHardText = this.isHardTextExpression(valueIfTrue);
12091238
const falseIsHardText = this.isHardTextExpression(valueIfFalse);
1239+
const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);
12101240
const numericWithBlank =
12111241
(trueIsBlank && !falseIsHardText && !falseIsText) ||
12121242
(falseIsBlank && !trueIsHardText && !trueIsText);
@@ -1215,7 +1245,13 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
12151245
const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
12161246
return `CASE WHEN (${booleanCondition}) THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
12171247
}
1218-
const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);
1248+
const hasNumericBranch =
1249+
this.isNumericLikeExpression(valueIfTrue, 1) || this.isNumericLikeExpression(valueIfFalse, 2);
1250+
if (hasNumericBranch && !hasTextBranch) {
1251+
const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);
1252+
const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
1253+
return `CASE WHEN (${booleanCondition}) THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
1254+
}
12191255
const blankPresent = trueIsBlank || falseIsBlank;
12201256
const hasTextAfterBlank = blankPresent ? false : hasTextBranch;
12211257
const normalizeBlankAsNull = !hasTextAfterBlank && blankPresent;

apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2+
import { DbFieldType } from '@teable/core';
23
import { describe, expect, it } from 'vitest';
34

45
import { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';
@@ -38,4 +39,33 @@ describe('SelectQueryPostgres truthinessScore', () => {
3839
const sql = query.if("('true')::text", "'yes'", "'no'");
3940
expect(sql).toContain("COALESCE((('true')::text)::boolean, FALSE)");
4041
});
42+
43+
it('coerces json-like numeric branches in IF to avoid CASE jsonb/integer mismatches', () => {
44+
const query = new SelectQueryPostgres();
45+
query.setContext({
46+
timeZone: 'Asia/Shanghai',
47+
targetDbFieldType: DbFieldType.Real,
48+
} as unknown as never);
49+
query.setCallMetadata([
50+
{ type: 'string', isFieldReference: false },
51+
{
52+
type: 'string',
53+
isFieldReference: true,
54+
field: {
55+
id: 'fldJsonNumeric',
56+
isMultiple: true,
57+
isLookup: true,
58+
dbFieldName: '__json_numeric',
59+
dbFieldType: DbFieldType.Json,
60+
cellValueType: 'number',
61+
},
62+
},
63+
{ type: 'number', isFieldReference: false },
64+
] as unknown as never);
65+
66+
const sql = query.if('__cond', '"__json_numeric"', '0');
67+
expect(sql).toContain('to_jsonb("__json_numeric")');
68+
expect(sql).toContain('jsonb_array_elements_text');
69+
expect(sql).toContain('double precision');
70+
});
4171
});

apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,35 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
304304
return this.getExpressionFieldType(value) === DbFieldType.Text;
305305
}
306306

307+
private isNumericLikeExpression(value: string, metadataIndex?: number): boolean {
308+
if (this.isNumericLiteral(value)) {
309+
return true;
310+
}
311+
312+
const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
313+
if (paramInfo?.hasMetadata) {
314+
if (
315+
paramInfo.type === 'number' ||
316+
isTrustedNumeric(paramInfo) ||
317+
isBooleanLikeParam(paramInfo)
318+
) {
319+
return true;
320+
}
321+
if (
322+
paramInfo.fieldDbType === DbFieldType.Real ||
323+
paramInfo.fieldDbType === DbFieldType.Integer
324+
) {
325+
return true;
326+
}
327+
if (paramInfo.fieldCellValueType === 'number') {
328+
return true;
329+
}
330+
}
331+
332+
const expressionFieldType = this.getExpressionFieldType(value);
333+
return expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer;
334+
}
335+
307336
private getExpressionFieldType(value: string): DbFieldType | undefined {
308337
const trimmed = this.stripOuterParentheses(value);
309338
const columnMatch = trimmed.match(/^"([^"]+)"$/) ?? trimmed.match(/^"[^"]+"\."([^"]+)"$/);
@@ -1460,6 +1489,7 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
14601489
const falseIsText = this.isTextLikeExpression(valueIfFalse, 2);
14611490
const trueIsHardText = this.isHardTextExpression(valueIfTrue);
14621491
const falseIsHardText = this.isHardTextExpression(valueIfFalse);
1492+
const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);
14631493
const numericWithBlank =
14641494
(trueIsBlank && !falseIsHardText && !falseIsText) ||
14651495
(falseIsBlank && !trueIsHardText && !trueIsText);
@@ -1468,7 +1498,14 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
14681498
const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
14691499
return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
14701500
}
1471-
const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);
1501+
const targetIsNumeric = targetType === DbFieldType.Real || targetType === DbFieldType.Integer;
1502+
const hasNumericBranch =
1503+
this.isNumericLikeExpression(valueIfTrue, 1) || this.isNumericLikeExpression(valueIfFalse, 2);
1504+
if (targetIsNumeric || (hasNumericBranch && !hasTextBranch)) {
1505+
const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);
1506+
const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
1507+
return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
1508+
}
14721509
const blankPresent = trueIsBlank || falseIsBlank;
14731510
const hasTextAfterBlank = blankPresent ? false : hasTextBranch;
14741511
const normalizeBlankAsNull = !hasTextAfterBlank && blankPresent;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
import type { INestApplication } from '@nestjs/common';
3+
import type { IFieldRo, IFilter, ILookupOptionsRo } from '@teable/core';
4+
import { FieldType, getRandomString } from '@teable/core';
5+
import {
6+
createField,
7+
createTable,
8+
getRecord,
9+
initApp,
10+
permanentDeleteTable,
11+
} from './utils/init-app';
12+
13+
/**
14+
* Regression: numeric formulas containing IF branches that return conditional-lookup
15+
* (json/jsonb array) values must coerce both branches to a numeric type. Otherwise Postgres
16+
* errors with "CASE types integer and jsonb cannot be matched" during computed updates.
17+
*/
18+
describe('Formula conditional lookup numeric IF (regression)', () => {
19+
let app: INestApplication;
20+
const baseId = globalThis.testConfig.baseId as string;
21+
22+
beforeAll(async () => {
23+
const ctx = await initApp();
24+
app = ctx.app;
25+
});
26+
27+
afterAll(async () => {
28+
await app.close();
29+
});
30+
31+
it('evaluates numeric IF branches containing conditional lookup arrays', async () => {
32+
const suffix = getRandomString(8);
33+
34+
const foreign = await createTable(baseId, {
35+
name: `t1326_cl_foreign_${suffix}`,
36+
fields: [
37+
{ name: 'Key', type: FieldType.SingleLineText } as IFieldRo,
38+
{ name: 'Amount', type: FieldType.Number } as IFieldRo,
39+
],
40+
records: [{ fields: { Key: 'A', Amount: 5 } }],
41+
});
42+
43+
const host = await createTable(baseId, {
44+
name: `t1326_cl_host_${suffix}`,
45+
fields: [{ name: 'Key', type: FieldType.SingleLineText } as IFieldRo],
46+
records: [{ fields: { Key: 'A' } }, { fields: { Key: 'B' } }],
47+
});
48+
49+
try {
50+
const foreignKeyFieldId = foreign.fields.find((field) => field.name === 'Key')!.id;
51+
const foreignAmountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id;
52+
const hostKeyFieldId = host.fields.find((field) => field.name === 'Key')!.id;
53+
54+
const keyMatchFilter: IFilter = {
55+
conjunction: 'and',
56+
filterSet: [
57+
{
58+
fieldId: foreignKeyFieldId,
59+
operator: 'is',
60+
value: { type: 'field', fieldId: hostKeyFieldId },
61+
},
62+
],
63+
};
64+
65+
const conditionalLookup = await createField(host.id, {
66+
name: 'Lookup Amounts',
67+
type: FieldType.Number,
68+
isLookup: true,
69+
isConditionalLookup: true,
70+
lookupOptions: {
71+
foreignTableId: foreign.id,
72+
lookupFieldId: foreignAmountFieldId,
73+
filter: keyMatchFilter,
74+
} as ILookupOptionsRo,
75+
} as IFieldRo);
76+
77+
const formulaField = await createField(host.id, {
78+
name: 'Amount Delta',
79+
type: FieldType.Formula,
80+
options: {
81+
expression: `1 - IF({${conditionalLookup.id}}, {${conditionalLookup.id}}, 0)`,
82+
formatting: { type: 'decimal', precision: 2 },
83+
},
84+
} as IFieldRo);
85+
86+
const recordA = await getRecord(host.id, host.records[0].id);
87+
const recordB = await getRecord(host.id, host.records[1].id);
88+
89+
expect(recordA.fields[hostKeyFieldId]).toBe('A');
90+
expect(recordB.fields[hostKeyFieldId]).toBe('B');
91+
92+
expect(recordA.fields[formulaField.id]).toBe(-4);
93+
expect(recordB.fields[formulaField.id]).toBe(1);
94+
} finally {
95+
await permanentDeleteTable(baseId, host.id);
96+
await permanentDeleteTable(baseId, foreign.id);
97+
}
98+
});
99+
});

0 commit comments

Comments
 (0)