Skip to content

Commit 2461557

Browse files
github-actions[bot]caoxing9tea-artistboris-wJocky-Teable
committed
[sync] feat: grid selection statistic backend aggregation (T3905)
Synced from teableio/teable-ee@11ebd64 Co-authored-by: Aries X <caoxing9@gmail.com> Co-authored-by: Bieber <artist@teable.io> Co-authored-by: Boris <boris2code@outlook.com> Co-authored-by: Jocky-Teable <jocky@teable.ai> Co-authored-by: Jun Lu <hammond@teable.io> Co-authored-by: SkyHuang <sky.huang.fe@gmail.com> Co-authored-by: Uno <uno@teable.ai> Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent bda82ee commit 2461557

168 files changed

Lines changed: 6892 additions & 2264 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/nestjs-backend/scripts/validate-dual-db-cutover.mjs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,16 +162,24 @@ const main = async () => {
162162
]);
163163

164164
const schemaDiff = diffSets(sourceSchemas, targetSchemas);
165-
const schemaTableCountDiffs = compareCountMaps(sourceSchemaTableCounts, targetSchemaTableCounts);
165+
const schemaTableCountDiffs = compareCountMaps(
166+
sourceSchemaTableCounts,
167+
targetSchemaTableCounts
168+
);
166169

167-
const [sourceDataCounts, targetDataCounts, sourceMetaCounts, targetMetaCounts, undoFunctionExists] =
168-
await Promise.all([
169-
getTableCounts(sourceClient, DATA_PLANE_TABLES),
170-
getTableCounts(dataClient, DATA_PLANE_TABLES),
171-
getTableCounts(sourceClient, META_PLANE_TABLES),
172-
getTableCounts(metaClient, META_PLANE_TABLES),
173-
getFunctionExists(dataClient, '__teable_capture_undo_row'),
174-
]);
170+
const [
171+
sourceDataCounts,
172+
targetDataCounts,
173+
sourceMetaCounts,
174+
targetMetaCounts,
175+
undoFunctionExists,
176+
] = await Promise.all([
177+
getTableCounts(sourceClient, DATA_PLANE_TABLES),
178+
getTableCounts(dataClient, DATA_PLANE_TABLES),
179+
getTableCounts(sourceClient, META_PLANE_TABLES),
180+
getTableCounts(metaClient, META_PLANE_TABLES),
181+
getFunctionExists(dataClient, '__teable_capture_undo_row'),
182+
]);
175183

176184
const dataCountDiffs = compareCountMaps(sourceDataCounts, targetDataCounts);
177185
const metaCountDiffs = compareCountMaps(sourceMetaCounts, targetMetaCounts);

apps/nestjs-backend/src/configs/threshold.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const thresholdConfig = registerAs('threshold', () => ({
3636
jitter: Number(process.env.BACKEND_DB_DEADLOCK_JITTER ?? 1.0),
3737
},
3838
baseNodeMaxFolderDepth: Number(process.env.BASE_NODE_MAX_FOLDER_DEPTH ?? 2),
39+
maxOwnedSpaceCount: Number(process.env.MAX_SPACE_OWNER_COUNT ?? 10),
3940
changeEmailSendCodeMailRate: Number(process.env.BACKEND_CHANGE_EMAIL_SEND_CODE_MAIL_RATE ?? 30),
4041
resetPasswordSendMailRate: Number(process.env.BACKEND_RESET_PASSWORD_SEND_MAIL_RATE ?? 30),
4142
signupVerificationSendCodeMailRate: Number(

apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IFilter, IGroup, StatisticsFunc } from '@teable/core';
1+
import type { IFilter, IGroup, ISortItem, StatisticsFunc } from '@teable/core';
22
import type {
33
IAggregationField,
44
IQueryBaseRo,
@@ -32,6 +32,9 @@ export interface IAggregationService {
3232
withView?: IWithView;
3333
search?: [string, string?, boolean?];
3434
useQueryModel?: boolean;
35+
skip?: number;
36+
take?: number;
37+
orderBy?: ISortItem[];
3538
}): Promise<IRawAggregationValue>;
3639

3740
/**

apps/nestjs-backend/src/features/aggregation/aggregation.service.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import {
77
identify,
88
IdPrefix,
99
mergeWithDefaultFilter,
10+
mergeWithDefaultSort,
1011
nullsToUndefined,
1112
ViewType,
1213
} from '@teable/core';
13-
import type { IGridColumnMeta, IFilter, IGroup } from '@teable/core';
14+
import type { IGridColumnMeta, IFilter, IGroup, ISortItem } from '@teable/core';
15+
import { DataPrismaService } from '@teable/db-data-prisma';
1416
import type { Prisma } from '@teable/db-main-prisma';
1517
import { PrismaService } from '@teable/db-main-prisma';
16-
import { DataPrismaService } from '@teable/db-data-prisma';
1718
import { StatisticsFunc } from '@teable/openapi';
1819
import type {
1920
IAggregationField,
@@ -59,6 +60,10 @@ type IStatisticsData = {
5960
viewId?: string;
6061
filter?: IFilter;
6162
statisticFields?: IAggregationField[];
63+
// Resolved view.sort merged with caller-supplied orderBy. Used for the BASE
64+
// CTE order when skip/take is applied; aggregation itself is order-invariant
65+
// so this is undefined unless the caller is paginating.
66+
sort?: ISortItem[];
6267
};
6368
/**
6469
* Version 2 implementation of the aggregation service
@@ -92,21 +97,39 @@ export class AggregationService implements IAggregationService {
9297
withView?: IWithView;
9398
search?: [string, string?, boolean?];
9499
useQueryModel?: boolean;
100+
// Optional row-range slice: when provided, the aggregation is computed
101+
// over rows [skip, skip+take) of the view's filtered + sorted output. Used
102+
// by the grid selection statistic endpoint; existing callers (footer
103+
// aggregation, row count) leave them undefined for unchanged behavior.
104+
skip?: number;
105+
take?: number;
106+
orderBy?: ISortItem[];
95107
}): Promise<IRawAggregationValue> {
96-
const { tableId, withFieldIds, withView, search, useQueryModel } = params;
108+
const { tableId, withFieldIds, withView, search, useQueryModel, skip, take, orderBy } = params;
97109
// Retrieve the current user's ID to build user-related query conditions
98110
const currentUserId = this.cls.get('user.id');
99111

100112
const { statisticsData, fieldInstanceMap } = await this.fetchStatisticsParams({
101113
tableId,
102114
withView,
103115
withFieldIds,
116+
extraOrderBy: orderBy,
104117
});
105118

106119
const dbTableName = await this.getDbTableName(this.prisma, tableId);
107120

108-
const { filter, statisticFields } = statisticsData;
121+
const { filter, statisticFields, sort: resolvedSort } = statisticsData;
109122
const groupBy = withView?.groupBy;
123+
124+
// When paginating, BASE CTE needs a deterministic ORDER BY so [skip, take)
125+
// matches what the grid renders. Sort = group + view-sort, falling back to
126+
// the view's row-order column (or __auto_number) for a stable tiebreaker.
127+
const isPaginated = take !== undefined;
128+
const baseSort = isPaginated ? [...(groupBy ?? []), ...(resolvedSort ?? [])] : undefined;
129+
const defaultOrderField = isPaginated
130+
? await this.recordService.getBasicOrderIndexField(dbTableName, withView?.viewId)
131+
: undefined;
132+
110133
const rawAggregationData = await this.handleAggregation({
111134
dbTableName,
112135
fieldInstanceMap,
@@ -117,6 +140,10 @@ export class AggregationService implements IAggregationService {
117140
withUserId: currentUserId,
118141
withView,
119142
useQueryModel,
143+
skip,
144+
take,
145+
sort: baseSort && baseSort.length ? baseSort : undefined,
146+
defaultOrderField,
120147
});
121148

122149
const aggregationResult = rawAggregationData && rawAggregationData[0];
@@ -210,6 +237,13 @@ export class AggregationService implements IAggregationService {
210237
withUserId?: string;
211238
withView?: IWithView;
212239
useQueryModel?: boolean;
240+
// Optional row-range slice + ordering. Only set when the caller is
241+
// paginating (selection aggregation); footer/row-count callers leave them
242+
// undefined and the BASE CTE sees the full filtered set.
243+
skip?: number;
244+
take?: number;
245+
sort?: ISortItem[];
246+
defaultOrderField?: string;
213247
}) {
214248
const {
215249
dbTableName,
@@ -222,6 +256,10 @@ export class AggregationService implements IAggregationService {
222256
withView,
223257
tableId,
224258
useQueryModel,
259+
skip,
260+
take,
261+
sort,
262+
defaultOrderField,
225263
} = params;
226264

227265
if (!statisticFields?.length) {
@@ -267,6 +305,10 @@ export class AggregationService implements IAggregationService {
267305
projection,
268306
useQueryModel,
269307
builder: permissionProbe.builder,
308+
sort,
309+
defaultOrderField,
310+
limit: take,
311+
offset: skip,
270312
}
271313
);
272314

@@ -490,6 +532,7 @@ export class AggregationService implements IAggregationService {
490532
});
491533
return tableMeta.dbTableName;
492534
}
535+
493536
private async handleRowCount(params: {
494537
tableId: string;
495538
dbTableName: string;
@@ -585,11 +628,12 @@ export class AggregationService implements IAggregationService {
585628
tableId: string;
586629
withView?: IWithView;
587630
withFieldIds?: string[];
631+
extraOrderBy?: ISortItem[];
588632
}): Promise<{
589633
statisticsData: IStatisticsData;
590634
fieldInstanceMap: Record<string, IFieldInstance>;
591635
}> {
592-
const { tableId, withView, withFieldIds } = params;
636+
const { tableId, withView, withFieldIds, extraOrderBy } = params;
593637

594638
const viewRaw = await this.findView(tableId, withView);
595639

@@ -600,7 +644,12 @@ export class AggregationService implements IAggregationService {
600644
withFieldIds
601645
);
602646

603-
const statisticsData = this.buildStatisticsData(filteredFieldInstances, viewRaw, withView);
647+
const statisticsData = this.buildStatisticsData(
648+
filteredFieldInstances,
649+
viewRaw,
650+
withView,
651+
extraOrderBy
652+
);
604653

605654
return { statisticsData, fieldInstanceMap };
606655
}
@@ -616,6 +665,7 @@ export class AggregationService implements IAggregationService {
616665
id: true,
617666
type: true,
618667
filter: true,
668+
sort: true,
619669
group: true,
620670
options: true,
621671
columnMeta: true,
@@ -652,10 +702,12 @@ export class AggregationService implements IAggregationService {
652702
id: string | undefined;
653703
columnMeta: string | undefined;
654704
filter: string | undefined;
705+
sort: string | undefined;
655706
group: string | undefined;
656707
}
657708
| undefined,
658-
withView?: IWithView
709+
withView?: IWithView,
710+
extraOrderBy?: ISortItem[]
659711
) {
660712
let statisticsData: IStatisticsData = {
661713
viewId: viewRaw?.id,
@@ -666,6 +718,12 @@ export class AggregationService implements IAggregationService {
666718
statisticsData = { ...statisticsData, filter };
667719
}
668720

721+
if (viewRaw?.sort || extraOrderBy) {
722+
// Same recipe as record list: caller's orderBy overrides view.sort.
723+
const sort = mergeWithDefaultSort(viewRaw?.sort, extraOrderBy);
724+
statisticsData = { ...statisticsData, sort };
725+
}
726+
669727
if (viewRaw?.id || withView?.customFieldStats) {
670728
const statisticFields = this.getStatisticFields(
671729
filteredFieldInstances,

apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
searchIndexByQueryRoSchema,
2828
IRecordIndexRo,
2929
recordIndexRoSchema,
30+
ISelectionAggregationRo,
31+
selectionAggregationRoSchema,
3032
} from '@teable/openapi';
3133
import { ClsService } from 'nestjs-cls';
3234
import { PerformanceCacheService } from '../../../performance-cache';
@@ -176,6 +178,16 @@ export class AggregationOpenApiController {
176178
);
177179
}
178180

181+
@Get('/selection')
182+
@Permissions('table|read')
183+
async getSelectionAggregation(
184+
@Param('tableId') tableId: string,
185+
@Query(new ZodValidationPipe(selectionAggregationRoSchema), TqlPipe)
186+
query: ISelectionAggregationRo
187+
): Promise<IAggregationVo> {
188+
return await this.aggregationOpenApiService.getSelectionAggregation(tableId, query);
189+
}
190+
179191
@Get('/task-status-collection')
180192
@Permissions('table|read')
181193
async getTaskStatusCollection(

apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Module } from '@nestjs/common';
2+
import { RecordModule } from '../../record/record.module';
23
import { AggregationModule } from '../aggregation.module';
34
import { AggregationOpenApiController } from './aggregation-open-api.controller';
45
import { AggregationOpenApiService } from './aggregation-open-api.service';
56

67
@Module({
78
controllers: [AggregationOpenApiController],
8-
imports: [AggregationModule],
9+
imports: [AggregationModule, RecordModule],
910
providers: [AggregationOpenApiService],
1011
exports: [AggregationOpenApiService],
1112
})

apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@ import type {
1414
ISearchCountRo,
1515
IRecordIndexRo,
1616
IRecordIndexVo,
17+
ISelectionAggregationRo,
1718
} from '@teable/openapi';
1819
import { forIn, isEmpty, map } from 'lodash';
20+
import { RecordService } from '../../record/record.service';
1921
import { IAggregationService } from '../aggregation.service.interface';
2022
import type { IWithView } from '../aggregation.service.interface';
2123
import { InjectAggregationService } from '../aggregation.service.provider';
2224

2325
@Injectable()
2426
export class AggregationOpenApiService {
2527
constructor(
26-
@InjectAggregationService() private readonly aggregationService: IAggregationService
28+
@InjectAggregationService() private readonly aggregationService: IAggregationService,
29+
private readonly recordService: RecordService
2730
) {}
2831

2932
async getAggregation(tableId: string, query?: IAggregationRo): Promise<IAggregationVo> {
@@ -133,4 +136,88 @@ export class AggregationOpenApiService {
133136
) {
134137
return await this.aggregationService.getRecordIndexBySearchOrder(tableId, queryRo, projection);
135138
}
139+
140+
// Selection aggregation = the existing aggregation flow + a row-range slice.
141+
// Same recipe as getAggregation: build customFieldStats, validate them, then
142+
// delegate to performAggregation. Two deltas:
143+
// 1. skip/take/orderBy thread through to scope the BASE CTE to the slice.
144+
// 2. groupBy (if any) is folded INTO orderBy as a sort prefix and NOT
145+
// passed via withView. Two reasons:
146+
// a. `performGroupedAggregation` keys aggregations by fieldId, so a
147+
// request asking multiple funcs for the same field (chip asks
148+
// Sum + Filled) loses all but the last entry. Bypassing it keeps
149+
// every (fieldId, aggFunc) result intact.
150+
// b. The same routine re-runs handleAggregation without skip/take,
151+
// which would compute group totals over the whole view instead of
152+
// the slice — pointless work for the chip, which only reads
153+
// `total`.
154+
// The group prefix in orderBy preserves grid row order (records list
155+
// uses [...groupBy, ...orderBy] for its sort, mirrored here).
156+
async getSelectionAggregation(
157+
tableId: string,
158+
query: ISelectionAggregationRo
159+
): Promise<IAggregationVo> {
160+
const {
161+
viewId,
162+
filter: customFilter,
163+
field: aggregationFields,
164+
groupBy,
165+
collapsedGroupIds,
166+
ignoreViewQuery,
167+
skip,
168+
take,
169+
orderBy,
170+
} = query;
171+
172+
const sortWithGroup = [...(groupBy ?? []), ...(orderBy ?? [])];
173+
174+
// Translate collapsedGroupIds into a SQL filter (records in collapsed
175+
// groups are excluded from the BASE CTE) so skip/take indexes the same
176+
// visible-record sequence the grid renders. Same recipe records list uses.
177+
let filterWithCollapsed = customFilter;
178+
if (groupBy?.length && collapsedGroupIds?.length) {
179+
const { filter } = await this.recordService.getGroupRelatedData(tableId, {
180+
viewId,
181+
ignoreViewQuery,
182+
filter: customFilter,
183+
groupBy,
184+
collapsedGroupIds,
185+
search: query.search,
186+
});
187+
filterWithCollapsed = filter;
188+
}
189+
190+
let withView: IWithView = {
191+
viewId: ignoreViewQuery ? undefined : viewId,
192+
customFilter: filterWithCollapsed,
193+
// Intentionally NOT passing groupBy (folded into orderBy above).
194+
};
195+
196+
const fieldStatistics: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> = [];
197+
forIn(aggregationFields, (value: string[], key) => {
198+
const fieldStats = map(value, (item) => ({
199+
fieldId: item,
200+
statisticFunc: key as StatisticsFunc,
201+
}));
202+
fieldStatistics.push(...fieldStats);
203+
});
204+
205+
const validFieldStats = await this.validFieldStats(tableId, fieldStatistics);
206+
if (validFieldStats) {
207+
withView = { ...withView, customFieldStats: validFieldStats };
208+
}
209+
210+
const result = await this.aggregationService.performAggregation({
211+
tableId,
212+
withView,
213+
search: query.search,
214+
// useQueryModel must stay false here: the tableCache path skips BASE CTE
215+
// pagination, which would silently aggregate the entire view.
216+
useQueryModel: false,
217+
skip,
218+
take,
219+
orderBy: sortWithGroup.length ? sortWithGroup : undefined,
220+
});
221+
return { aggregations: result?.aggregations };
222+
}
136223
}

0 commit comments

Comments
 (0)