Skip to content

Commit cf35408

Browse files
[sync] fix: flatten multi-select array_unique rollups (#1504) (#2813)
Synced from teableio/teable-ee@5105c9b Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent 5a5ce73 commit cf35408

7 files changed

Lines changed: 317 additions & 1 deletion

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,14 @@ class FieldCteSelectionVisitor implements IFieldVisitor<IFieldSelectName> {
294294
rowPresenceExpr?: string
295295
): string {
296296
const functionName = parseRollupFunctionName(expression);
297+
const shouldFlattenNestedArray =
298+
functionName === 'array_unique' &&
299+
((targetField?.isMultipleCellValue ?? false) || (targetField?.isConditionalLookup ?? false));
297300
return this.dialect.rollupAggregate(functionName, fieldExpression, {
298301
targetField,
299302
orderByField,
300303
rowPresenceExpr,
304+
flattenNestedArray: shouldFlattenNestedArray,
301305
});
302306
}
303307

@@ -1259,7 +1263,7 @@ export class FieldCteVisitor implements IFieldVisitor<ICteResult> {
12591263
): string {
12601264
const fn = parseRollupFunctionName(rollupExpression);
12611265
const shouldFlattenNestedArray =
1262-
fn === 'array_compact' &&
1266+
(fn === 'array_compact' || fn === 'array_unique') &&
12631267
((targetField?.isMultipleCellValue ?? false) || (targetField?.isConditionalLookup ?? false));
12641268
return this.dialect.rollupAggregate(fn, fieldExpression, {
12651269
targetField,

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,34 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
2222

2323
constructor(private readonly knex: Knex) {}
2424

25+
private buildDistinctFlattenedJsonArray(baseAggregate: string): string {
26+
return `(SELECT jsonb_agg(to_jsonb(v.val))
27+
FROM (
28+
SELECT DISTINCT val
29+
FROM (
30+
SELECT leaf #>> '{}' AS val
31+
FROM jsonb_array_elements(COALESCE(${baseAggregate}, '[]'::jsonb)) AS row_elem(elem)
32+
CROSS JOIN LATERAL jsonb_array_elements(
33+
CASE
34+
WHEN jsonb_typeof(row_elem.elem) = 'array' THEN row_elem.elem
35+
ELSE jsonb_build_array(row_elem.elem)
36+
END
37+
) AS leaf_elem(leaf)
38+
) AS flattened
39+
WHERE val IS NOT NULL AND val <> ''
40+
ORDER BY val
41+
) AS v)`;
42+
}
43+
44+
private normalizeSingleValueJsonArray(expr: string): string {
45+
return `(CASE
46+
WHEN ${expr} IS NULL THEN '[]'::jsonb
47+
WHEN jsonb_typeof(to_jsonb(${expr})) = 'array' THEN to_jsonb(${expr})
48+
WHEN jsonb_typeof(to_jsonb(${expr})) = 'null' THEN '[]'::jsonb
49+
ELSE jsonb_build_array(to_jsonb(${expr}))
50+
END)`;
51+
}
52+
2553
toText(expr: string): string {
2654
return `(${expr})::TEXT`;
2755
}
@@ -504,6 +532,12 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
504532
? `STRING_AGG(${fieldExpression}::text, ', ' ORDER BY ${orderByField})`
505533
: `STRING_AGG(${fieldExpression}::text, ', ')`;
506534
case 'array_unique':
535+
if (flattenNestedArray) {
536+
const baseAggregate = orderByField
537+
? `jsonb_agg(to_jsonb(${fieldExpression}) ORDER BY ${orderByField}) FILTER (WHERE ${fieldExpression} IS NOT NULL)`
538+
: `jsonb_agg(to_jsonb(${fieldExpression})) FILTER (WHERE ${fieldExpression} IS NOT NULL)`;
539+
return this.buildDistinctFlattenedJsonArray(baseAggregate);
540+
}
507541
return `json_agg(DISTINCT ${fieldExpression})`;
508542
case 'array_compact': {
509543
const buildAggregate = (expr: string) =>
@@ -572,6 +606,15 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
572606
case 'xor':
573607
return `(COALESCE((${fieldExpression})::boolean, false))`;
574608
case 'array_unique':
609+
if (
610+
requiresJsonArray &&
611+
(options.targetField.isMultipleCellValue || options.targetField.isConditionalLookup)
612+
) {
613+
return this.normalizeSingleValueJsonArray(fieldExpression);
614+
}
615+
return !requiresJsonArray
616+
? `${fieldExpression}`
617+
: `(CASE WHEN ${fieldExpression} IS NULL THEN '[]'::jsonb ELSE jsonb_build_array(${fieldExpression}) END)`;
575618
case 'array_compact':
576619
if (!requiresJsonArray) {
577620
return `${fieldExpression}`;

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,26 @@ export class SqliteRecordQueryDialect implements IRecordQueryDialectProvider {
183183
case 'concatenate':
184184
return `GROUP_CONCAT(${fieldExpression}, ', ')`;
185185
case 'array_unique':
186+
if (opts.flattenNestedArray) {
187+
return `(WITH outer_values AS (
188+
SELECT value
189+
FROM json_each(COALESCE(json_group_array(${fieldExpression}), json('[]')))
190+
),
191+
flattened AS (
192+
SELECT inner_elem.value AS value
193+
FROM outer_values
194+
JOIN json_each(
195+
CASE
196+
WHEN json_valid(outer_values.value) AND json_type(outer_values.value) = 'array'
197+
THEN outer_values.value
198+
ELSE json_array(outer_values.value)
199+
END
200+
) AS inner_elem
201+
)
202+
SELECT json_group_array(DISTINCT value)
203+
FROM flattened
204+
WHERE value IS NOT NULL AND CAST(value AS TEXT) <> '')`;
205+
}
186206
return `json_group_array(DISTINCT ${fieldExpression})`;
187207
case 'array_compact':
188208
return `json_group_array(CASE WHEN ${fieldExpression} IS NOT NULL AND CAST(${fieldExpression} AS TEXT) <> '' THEN ${fieldExpression} END)`;
@@ -215,6 +235,19 @@ export class SqliteRecordQueryDialect implements IRecordQueryDialectProvider {
215235
case 'xor':
216236
return `(CASE WHEN ${fieldExpression} THEN 1 ELSE 0 END)`;
217237
case 'array_unique':
238+
if (
239+
requiresJsonArray &&
240+
(options.targetField.isMultipleCellValue || options.targetField.isConditionalLookup)
241+
) {
242+
return `(CASE
243+
WHEN ${fieldExpression} IS NULL THEN json('[]')
244+
WHEN json_valid(${fieldExpression}) AND json_type(${fieldExpression}) = 'array' THEN ${fieldExpression}
245+
ELSE json_array(${fieldExpression})
246+
END)`;
247+
}
248+
return !requiresJsonArray
249+
? `${fieldExpression}`
250+
: `(CASE WHEN ${fieldExpression} IS NULL THEN json('[]') ELSE json_array(${fieldExpression}) END)`;
218251
case 'array_compact':
219252
if (!requiresJsonArray) {
220253
return `${fieldExpression}`;

apps/nestjs-backend/test/rollup.e2e-spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,74 @@ describe('OpenAPI Rollup field (e2e)', () => {
658658
}
659659
});
660660

661+
it('flattens and deduplicates multiple select values with array_unique when rolling up', async () => {
662+
const projects = await createTable(baseId, {
663+
name: 'rollup_multiselect_projects_unique',
664+
fields: [
665+
{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo,
666+
{
667+
name: 'Tags',
668+
type: FieldType.MultipleSelect,
669+
options: {
670+
choices: [
671+
{ name: 'A', color: Colors.Yellow },
672+
{ name: 'B', color: Colors.Orange },
673+
{ name: 'C', color: Colors.Green },
674+
],
675+
},
676+
} as IFieldRo,
677+
],
678+
records: [
679+
{ fields: { Name: 'P1', Tags: ['A', 'B'] } },
680+
{ fields: { Name: 'P2', Tags: ['B'] } },
681+
{ fields: { Name: 'P3', Tags: ['B', 'C'] } },
682+
],
683+
});
684+
685+
const departments = await createTable(baseId, {
686+
name: 'rollup_multiselect_departments_unique',
687+
fields: [{ name: 'Dept', type: FieldType.SingleLineText } as IFieldRo],
688+
records: [{ fields: { Dept: 'Ops' } }],
689+
});
690+
691+
try {
692+
const projectLink = await createField(departments.id, {
693+
name: 'Projects',
694+
type: FieldType.Link,
695+
options: {
696+
relationship: Relationship.OneMany,
697+
foreignTableId: projects.id,
698+
},
699+
} as IFieldRo);
700+
701+
await updateRecordField(departments.id, departments.records[0].id, projectLink.id, [
702+
{ id: projects.records[0].id },
703+
{ id: projects.records[1].id },
704+
{ id: projects.records[2].id },
705+
]);
706+
707+
const tagsField = getFieldByName(projects.fields, 'Tags');
708+
const rollup = await createField(departments.id, {
709+
name: 'project_tags_unique',
710+
type: FieldType.Rollup,
711+
options: {
712+
expression: 'array_unique({values})',
713+
},
714+
lookupOptions: {
715+
foreignTableId: projects.id,
716+
linkFieldId: projectLink.id,
717+
lookupFieldId: tagsField.id,
718+
},
719+
} as IFieldRo);
720+
721+
const record = await getRecord(departments.id, departments.records[0].id);
722+
expect(record.fields[rollup.id]).toEqual(['A', 'B', 'C']);
723+
} finally {
724+
await permanentDeleteTable(baseId, departments.id);
725+
await permanentDeleteTable(baseId, projects.id);
726+
}
727+
});
728+
661729
describe('rollup expression coverage', () => {
662730
const baseId = globalThis.testConfig.baseId;
663731
const isForceV2 = process.env.FORCE_V2_ALL === 'true';

packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1829,6 +1829,26 @@ describe('ComputedTableRecordQueryBuilder', () => {
18291829
) as "col_rollup" from "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" where "f"."__fk_fldssssssssssssssss" = "t"."__id") as "lat_fldkkkkkkkkkkkkkkkk_0" on true"
18301830
`);
18311831
});
1832+
1833+
test('rollup array_unique flattens multi-value field entries before deduplication', () => {
1834+
const db = createTestDb();
1835+
const { mainTable, foreignTable, foreignTableId } =
1836+
createMultiValueRollupTable('array_unique({values})');
1837+
1838+
const foreignTables = new Map([[foreignTableId.toString(), foreignTable]]);
1839+
const { sql } = compileQuery(
1840+
db,
1841+
new ComputedTableRecordQueryBuilder(db, { foreignTables, typeValidationStrategy }).from(
1842+
mainTable
1843+
)
1844+
);
1845+
1846+
expect(sql).toContain('jsonb_agg(to_jsonb(v.val))');
1847+
expect(sql).toContain('SELECT DISTINCT val');
1848+
expect(sql).toContain('jsonb_array_elements(COALESCE(jsonb_agg("f"."col_tags"');
1849+
expect(sql).toContain('FILTER (WHERE "f"."col_tags" IS NOT NULL)');
1850+
});
1851+
18321852
test('returns NULL rollup when rollup foreign table mismatches link foreign table', () => {
18331853
const db = createTestDb();
18341854
const baseId = BaseId.create(BASE_ID)._unsafeUnwrap();

packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,12 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder
14221422
: sql`jsonb_agg(to_jsonb(${colRef})) FILTER (WHERE ${colRef} IS NOT NULL)`;
14231423
return ok(this.buildDistinctNestedJsonTextArrayExpr(baseAggregate));
14241424
}
1425+
if (isMultipleValue) {
1426+
const baseAggregate = orderByExpr
1427+
? sql`jsonb_agg(${colRef} ORDER BY ${orderByExpr}) FILTER (WHERE ${colRef} IS NOT NULL)`
1428+
: sql`jsonb_agg(${colRef}) FILTER (WHERE ${colRef} IS NOT NULL)`;
1429+
return ok(this.buildDistinctNestedJsonTextArrayExpr(baseAggregate));
1430+
}
14251431
return ok(sql`json_agg(DISTINCT ${colRef})`);
14261432
}
14271433
case 'array_compact({values})': {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
import { beforeAll, describe, expect, test } from 'vitest';
3+
4+
import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext';
5+
6+
describe('v2 rollup ARRAYUNIQUE over multi-select values (e2e)', () => {
7+
let ctx: SharedTestContext;
8+
let fieldIdCounter = 0;
9+
10+
const createFieldId = () => {
11+
const suffix = fieldIdCounter.toString(36).padStart(16, '0');
12+
fieldIdCounter += 1;
13+
return `fld${suffix}`;
14+
};
15+
16+
beforeAll(async () => {
17+
ctx = await getSharedTestContext();
18+
});
19+
20+
test('flattens nested multi-select values before deduplicating rollup output', async () => {
21+
const foreignPrimaryFieldId = createFieldId();
22+
const foreignTagsFieldId = createFieldId();
23+
const hostPrimaryFieldId = createFieldId();
24+
const hostLinkFieldId = createFieldId();
25+
const hostRollupFieldId = createFieldId();
26+
27+
let foreignTableId: string | undefined;
28+
let hostTableId: string | undefined;
29+
30+
try {
31+
const foreignTable = await ctx.createTable({
32+
baseId: ctx.baseId,
33+
name: 'Rollup ArrayUnique Foreign',
34+
fields: [
35+
{
36+
type: 'singleLineText',
37+
id: foreignPrimaryFieldId,
38+
name: 'Name',
39+
isPrimary: true,
40+
},
41+
{
42+
type: 'multipleSelect',
43+
id: foreignTagsFieldId,
44+
name: 'Tags',
45+
options: {
46+
choices: [
47+
{ id: 'optA', name: 'A', color: 'blue' },
48+
{ id: 'optB', name: 'B', color: 'green' },
49+
{ id: 'optC', name: 'C', color: 'red' },
50+
],
51+
},
52+
},
53+
],
54+
});
55+
foreignTableId = foreignTable.id;
56+
57+
const foreignRecord1 = await ctx.createRecord(foreignTable.id, {
58+
[foreignPrimaryFieldId]: 'P1',
59+
[foreignTagsFieldId]: ['A', 'B'],
60+
});
61+
const foreignRecord2 = await ctx.createRecord(foreignTable.id, {
62+
[foreignPrimaryFieldId]: 'P2',
63+
[foreignTagsFieldId]: ['B'],
64+
});
65+
const foreignRecord3 = await ctx.createRecord(foreignTable.id, {
66+
[foreignPrimaryFieldId]: 'P3',
67+
[foreignTagsFieldId]: ['B', 'C'],
68+
});
69+
70+
const hostTable = await ctx.createTable({
71+
baseId: ctx.baseId,
72+
name: 'Rollup ArrayUnique Host',
73+
fields: [
74+
{
75+
type: 'singleLineText',
76+
id: hostPrimaryFieldId,
77+
name: 'Name',
78+
isPrimary: true,
79+
},
80+
],
81+
});
82+
hostTableId = hostTable.id;
83+
84+
await ctx.createField({
85+
baseId: ctx.baseId,
86+
tableId: hostTable.id,
87+
field: {
88+
type: 'link',
89+
id: hostLinkFieldId,
90+
name: 'Projects',
91+
options: {
92+
relationship: 'manyMany',
93+
foreignTableId: foreignTable.id,
94+
lookupFieldId: foreignPrimaryFieldId,
95+
isOneWay: true,
96+
},
97+
},
98+
});
99+
100+
await ctx.createField({
101+
baseId: ctx.baseId,
102+
tableId: hostTable.id,
103+
field: {
104+
type: 'rollup',
105+
id: hostRollupFieldId,
106+
name: 'Unique Tags',
107+
options: {
108+
expression: 'array_unique({values})',
109+
},
110+
config: {
111+
linkFieldId: hostLinkFieldId,
112+
foreignTableId: foreignTable.id,
113+
lookupFieldId: foreignTagsFieldId,
114+
},
115+
},
116+
});
117+
118+
const hostRecord = await ctx.createRecord(hostTable.id, {
119+
[hostPrimaryFieldId]: 'Ops',
120+
[hostLinkFieldId]: [
121+
{ id: foreignRecord1.id },
122+
{ id: foreignRecord2.id },
123+
{ id: foreignRecord3.id },
124+
],
125+
});
126+
127+
await ctx.drainOutbox();
128+
129+
const hostRecords = await ctx.listRecords(hostTable.id);
130+
const updatedHostRecord = hostRecords.find((record) => record.id === hostRecord.id);
131+
132+
expect(updatedHostRecord?.fields[hostRollupFieldId]).toEqual(['A', 'B', 'C']);
133+
} finally {
134+
if (hostTableId) {
135+
await ctx.deleteTable(hostTableId).catch(() => undefined);
136+
}
137+
if (foreignTableId) {
138+
await ctx.deleteTable(foreignTableId).catch(() => undefined);
139+
}
140+
}
141+
});
142+
});

0 commit comments

Comments
 (0)