Skip to content

Commit eb98a0f

Browse files
committed
fix: include dbFieldType for improved JSON handling and add tests for multi-value lookups
1 parent b92db12 commit eb98a0f

File tree

6 files changed

+124
-6
lines changed

6 files changed

+124
-6
lines changed

‎apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ export class FieldSelectVisitor implements IFieldVisitor<IFieldSelectName> {
174174
const flattenedExpr = this.dialect.flattenLookupCteValue(
175175
cteName,
176176
field.id,
177-
!!field.isMultipleCellValue
177+
!!field.isMultipleCellValue,
178+
field.dbFieldType
178179
);
179180
if (flattenedExpr) {
180181
this.state.setSelection(field.id, flattenedExpr);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { DbFieldType } from '@teable/core';
2+
import type { Knex } from 'knex';
3+
import { describe, expect, it } from 'vitest';
4+
import { PgRecordQueryDialect } from './pg-record-query-dialect';
5+
6+
describe('PgRecordQueryDialect#flattenLookupCteValue', () => {
7+
const dialect = new PgRecordQueryDialect({} as unknown as Knex);
8+
9+
it('returns null for single-value lookups', () => {
10+
const result = dialect.flattenLookupCteValue(
11+
'cte_lookup',
12+
'fld_single',
13+
false,
14+
DbFieldType.Text
15+
);
16+
expect(result).toBeNull();
17+
});
18+
19+
it('keeps jsonb payloads when field is stored as json', () => {
20+
const sql = dialect.flattenLookupCteValue('cte_lookup', 'fld_json', true, DbFieldType.Json);
21+
expect(sql).toContain('"cte_lookup"."lookup_fld_json"::jsonb');
22+
expect(sql).not.toContain('to_jsonb("cte_lookup"."lookup_fld_json")');
23+
});
24+
25+
it('wraps scalar payloads with to_jsonb for non-json fields', () => {
26+
const sql = dialect.flattenLookupCteValue('cte_lookup', 'fld_scalar', true, DbFieldType.Text);
27+
expect(sql).toContain('to_jsonb("cte_lookup"."lookup_fld_scalar")');
28+
});
29+
});

‎apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,19 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
9999
)`;
100100
}
101101

102-
flattenLookupCteValue(cteName: string, fieldId: string, isMultiple: boolean): string | null {
102+
flattenLookupCteValue(
103+
cteName: string,
104+
fieldId: string,
105+
isMultiple: boolean,
106+
dbFieldType: DbFieldType
107+
): string | null {
103108
if (!isMultiple) return null;
109+
const columnRef = `"${cteName}"."lookup_${fieldId}"`;
110+
const normalized =
111+
dbFieldType === DbFieldType.Json ? `${columnRef}::jsonb` : `to_jsonb(${columnRef})`;
104112
return `(
105113
WITH RECURSIVE f(e) AS (
106-
SELECT "${cteName}"."lookup_${fieldId}"::jsonb
114+
SELECT ${normalized}
107115
UNION ALL
108116
SELECT jsonb_array_elements(f.e)
109117
FROM f

‎apps/nestjs-backend/src/features/record/query-builder/providers/sqlite-record-query-dialect.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ export class SqliteRecordQueryDialect implements IRecordQueryDialectProvider {
100100
)`;
101101
}
102102

103-
flattenLookupCteValue(_cteName: string, _fieldId: string, _isMultiple: boolean): string | null {
103+
flattenLookupCteValue(
104+
_cteName: string,
105+
_fieldId: string,
106+
_isMultiple: boolean,
107+
_dbFieldType: DbFieldType
108+
): string | null {
104109
return null;
105110
}
106111

‎apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,15 @@ export interface IRecordQueryDialectProvider {
175175
* Return null when no special handling is required.
176176
* @example
177177
* ```ts
178-
* dialect.flattenLookupCteValue('CTE_main_link', 'fld_123', true) // => WITH RECURSIVE ... jsonb_array_elements ...
178+
* dialect.flattenLookupCteValue('CTE_main_link', 'fld_123', true, DbFieldType.Json) // => WITH RECURSIVE ... jsonb_array_elements ...
179179
* ```
180180
*/
181-
flattenLookupCteValue(cteName: string, fieldId: string, isMultiple: boolean): string | null;
181+
flattenLookupCteValue(
182+
cteName: string,
183+
fieldId: string,
184+
isMultiple: boolean,
185+
dbFieldType: DbFieldType
186+
): string | null;
182187

183188
// JSON aggregation helpers
184189

‎apps/nestjs-backend/test/formula.e2e-spec.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,76 @@ describe('OpenAPI formula (e2e)', () => {
986986
}
987987
});
988988

989+
it('should flatten multi-value lookup formulas returning scalar text', async () => {
990+
const foreign = await createTable(baseId, {
991+
name: 'formula-lookup-flatten-foreign',
992+
fields: [
993+
{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo,
994+
{ name: 'Scheduled', type: FieldType.Date } as IFieldRo,
995+
],
996+
records: [
997+
{ fields: { Title: 'Task A', Scheduled: '2025-10-31T08:10:24.894Z' } },
998+
{ fields: { Title: 'Task B', Scheduled: '2025-11-05T10:00:00.000Z' } },
999+
],
1000+
});
1001+
let host: ITableFullVo | undefined;
1002+
try {
1003+
const scheduledFieldId = foreign.fields.find((field) => field.name === 'Scheduled')!.id;
1004+
const taggedFormula = await createField(foreign.id, {
1005+
name: 'Schedule Tag',
1006+
type: FieldType.Formula,
1007+
options: {
1008+
expression: `CONCATENATE(DATETIME_FORMAT({${scheduledFieldId}}, 'YYYY-MM-DD'), "-tag")`,
1009+
},
1010+
});
1011+
1012+
host = await createTable(baseId, {
1013+
name: 'formula-lookup-flatten-host',
1014+
fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],
1015+
records: [{ fields: { Project: 'Main' } }],
1016+
});
1017+
1018+
const linkField = await createField(host.id, {
1019+
name: 'Related Tasks',
1020+
type: FieldType.Link,
1021+
options: {
1022+
relationship: Relationship.ManyMany,
1023+
foreignTableId: foreign.id,
1024+
} as ILinkFieldOptionsRo,
1025+
} as IFieldRo);
1026+
1027+
const lookupField = await createField(host.id, {
1028+
name: 'Tagged Schedules',
1029+
type: FieldType.Formula,
1030+
isLookup: true,
1031+
lookupOptions: {
1032+
foreignTableId: foreign.id,
1033+
lookupFieldId: taggedFormula.id,
1034+
linkFieldId: linkField.id,
1035+
} as ILookupOptionsRo,
1036+
} as IFieldRo);
1037+
1038+
const hostRecordId = host.records[0].id;
1039+
await updateRecordByApi(
1040+
host.id,
1041+
hostRecordId,
1042+
linkField.id,
1043+
foreign.records.map((record) => ({ id: record.id }))
1044+
);
1045+
1046+
const updatedRecord = await getRecord(host.id, hostRecordId);
1047+
expect(updatedRecord.data.fields[lookupField.name]).toEqual([
1048+
'2025-10-31-tag',
1049+
'2025-11-05-tag',
1050+
]);
1051+
} finally {
1052+
if (host) {
1053+
await permanentDeleteTable(baseId, host.id);
1054+
}
1055+
await permanentDeleteTable(baseId, foreign.id);
1056+
}
1057+
});
1058+
9891059
it('should calculate numeric formulas using lookup fields', async () => {
9901060
const foreign = await createTable(baseId, {
9911061
name: 'formula-lookup-numeric-foreign',

0 commit comments

Comments
 (0)