Skip to content

Commit 8171ffb

Browse files
[sync] T2445: support v2 table duplication (#1491) (#2811)
Synced from teableio/teable-ee@1c2e2c9 Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent 3c40521 commit 8171ffb

42 files changed

Lines changed: 4071 additions & 78 deletions

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/src/features/base-node/base-node.controller.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type IBaseNodeTreeVo,
2525
type IBaseNodeVo,
2626
type IDeleteBaseNodeVo,
27+
type V2Feature,
2728
} from '@teable/openapi';
2829
import type { Response } from 'express';
2930
import { ClsService } from 'nestjs-cls';
@@ -35,6 +36,7 @@ import { AllowAnonymous, AllowAnonymousType } from '../auth/decorators/allow-ano
3536
import { BaseNodePermissions } from '../auth/decorators/base-node-permissions.decorator';
3637
import { Permissions } from '../auth/decorators/permissions.decorator';
3738
import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
39+
import type { IV2Decision } from '../canary/canary.service';
3840
import {
3941
X_TEABLE_V2_FEATURE_HEADER,
4042
X_TEABLE_V2_HEADER,
@@ -49,6 +51,7 @@ import { BaseNodeAction } from './types';
4951
@AllowAnonymous(AllowAnonymousType.RESOURCE)
5052
export class BaseNodeController {
5153
protected static readonly createTableV2Feature = 'createTable';
54+
protected static readonly duplicateTableV2Feature = 'duplicateTable';
5255
protected static readonly deleteTableV2Feature = 'deleteTable';
5356

5457
constructor(
@@ -160,8 +163,11 @@ export class BaseNodeController {
160163
async duplicate(
161164
@Param('baseId') baseId: string,
162165
@Param('nodeId') nodeId: string,
163-
@Body(new ZodValidationPipe(duplicateBaseNodeRoSchema)) ro: IDuplicateBaseNodeRo
166+
@Body(new ZodValidationPipe(duplicateBaseNodeRoSchema)) ro: IDuplicateBaseNodeRo,
167+
@Headers('x-window-id') windowId: string | undefined,
168+
@Res({ passthrough: true }) response: Response
164169
): Promise<IBaseNodeVo> {
170+
await this.prepareDuplicateTableCanary(baseId, nodeId, response, windowId);
165171
return this.baseNodeService.duplicate(baseId, nodeId, ro);
166172
}
167173

@@ -221,55 +227,84 @@ export class BaseNodeController {
221227
nodeId: string,
222228
response: Response,
223229
windowId?: string
230+
): Promise<void> {
231+
await this.prepareTableNodeCanary(
232+
baseId,
233+
nodeId,
234+
response,
235+
BaseNodeController.deleteTableV2Feature,
236+
windowId
237+
);
238+
}
239+
240+
protected async prepareDuplicateTableCanary(
241+
baseId: string,
242+
nodeId: string,
243+
response: Response,
244+
windowId?: string
245+
): Promise<void> {
246+
await this.prepareTableNodeCanary(
247+
baseId,
248+
nodeId,
249+
response,
250+
BaseNodeController.duplicateTableV2Feature,
251+
windowId
252+
);
253+
}
254+
255+
protected async prepareCreateTableCanary(
256+
baseId: string,
257+
createRo: ICreateBaseNodeRo,
258+
response: Response,
259+
windowId?: string
224260
): Promise<void> {
225261
if (windowId) {
226262
this.cls.set('windowId', windowId);
227263
}
228264

229-
const node = await this.baseNodeService.getNode(baseId, nodeId);
230-
if (node.resourceType !== BaseNodeResourceType.Table) {
265+
if (createRo.resourceType !== BaseNodeResourceType.Table) {
231266
return;
232267
}
233268

234-
const decision = await this.baseNodeService.getDeleteTableV2Decision(baseId, nodeId);
269+
const decision = await this.baseNodeService.getCreateTableV2Decision(baseId);
235270
if (!decision) {
236271
return;
237272
}
238273

239-
this.cls.set('useV2', decision.useV2);
240-
this.cls.set('v2Feature', BaseNodeController.deleteTableV2Feature);
241-
this.cls.set('v2Reason', decision.reason);
242-
243-
response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');
244-
response.setHeader(X_TEABLE_V2_FEATURE_HEADER, BaseNodeController.deleteTableV2Feature);
245-
response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);
274+
this.applyV2Decision(response, BaseNodeController.createTableV2Feature, decision);
246275
}
247276

248-
protected async prepareCreateTableCanary(
277+
protected async prepareTableNodeCanary(
249278
baseId: string,
250-
createRo: ICreateBaseNodeRo,
279+
nodeId: string,
251280
response: Response,
281+
feature: V2Feature,
252282
windowId?: string
253283
): Promise<void> {
254284
if (windowId) {
255285
this.cls.set('windowId', windowId);
256286
}
257287

258-
if (createRo.resourceType !== BaseNodeResourceType.Table) {
288+
const node = await this.baseNodeService.getNode(baseId, nodeId);
289+
if (node.resourceType !== BaseNodeResourceType.Table) {
259290
return;
260291
}
261292

262-
const decision = await this.baseNodeService.getCreateTableV2Decision(baseId);
293+
const decision = await this.baseNodeService.getTableV2Decision(baseId, nodeId, feature);
263294
if (!decision) {
264295
return;
265296
}
266297

298+
this.applyV2Decision(response, feature, decision);
299+
}
300+
301+
protected applyV2Decision(response: Response, feature: V2Feature, decision: IV2Decision) {
267302
this.cls.set('useV2', decision.useV2);
268-
this.cls.set('v2Feature', BaseNodeController.createTableV2Feature);
303+
this.cls.set('v2Feature', feature);
269304
this.cls.set('v2Reason', decision.reason);
270305

271306
response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');
272-
response.setHeader(X_TEABLE_V2_FEATURE_HEADER, BaseNodeController.createTableV2Feature);
307+
response.setHeader(X_TEABLE_V2_FEATURE_HEADER, feature);
273308
response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);
274309
}
275310

apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TestingModule } from '@nestjs/testing';
22
import { Test } from '@nestjs/testing';
3+
import { BaseNodeResourceType } from '@teable/openapi';
34
import type { Knex } from 'knex';
45
import { GlobalModule } from '../../global/global.module';
56
import { BaseNodeModule } from './base-node.module';
@@ -9,6 +10,24 @@ import { buildBatchUpdateSql } from './helper';
910
describe('BaseNodeService', () => {
1011
let service: BaseNodeService;
1112
let knex: Knex;
13+
const baseId = 'bse1';
14+
const tableId = 'tbl1';
15+
const tableName = 'Projects Copy';
16+
const tableIcon = '📋';
17+
18+
type IDuplicateResourceInvoker = {
19+
duplicateResource: (
20+
baseId: string,
21+
type: BaseNodeResourceType,
22+
id: string,
23+
duplicateRo: { name: string; includeRecords: boolean }
24+
) => Promise<{
25+
id: string;
26+
name: string;
27+
icon?: string;
28+
defaultViewId?: string;
29+
}>;
30+
};
1231

1332
beforeEach(async () => {
1433
const module: TestingModule = await Test.createTestingModule({
@@ -131,4 +150,94 @@ describe('BaseNodeService', () => {
131150
expect(result).toBe(expectedSql);
132151
});
133152
});
153+
154+
describe('duplicateResource', () => {
155+
const createDuplicateRoutingService = (useV2: boolean) => {
156+
const tableOpenApiV2Service = {
157+
duplicateTable: vi.fn().mockResolvedValue({
158+
id: 'tbl-v2-copy',
159+
name: tableName,
160+
icon: tableIcon,
161+
defaultViewId: 'viwV2',
162+
}),
163+
};
164+
const tableDuplicateService = {
165+
duplicateTable: vi.fn().mockResolvedValue({
166+
id: 'tbl-v1-copy',
167+
name: tableName,
168+
icon: tableIcon,
169+
defaultViewId: 'viwLegacy',
170+
}),
171+
};
172+
const routingService = new BaseNodeService(
173+
{} as never,
174+
{} as never,
175+
{} as never,
176+
{} as never,
177+
{} as never,
178+
{
179+
get: vi.fn((key: string) => (key === 'useV2' ? useV2 : undefined)),
180+
set: vi.fn(),
181+
} as never,
182+
{} as never,
183+
{} as never,
184+
{} as never,
185+
tableOpenApiV2Service as never,
186+
tableDuplicateService as never,
187+
{} as never
188+
);
189+
190+
return {
191+
routingService,
192+
tableOpenApiV2Service,
193+
tableDuplicateService,
194+
};
195+
};
196+
197+
it('routes table duplication through v2 when useV2 is enabled', async () => {
198+
const { routingService, tableOpenApiV2Service, tableDuplicateService } =
199+
createDuplicateRoutingService(true);
200+
const duplicateRo = { name: tableName, includeRecords: true };
201+
202+
const result = await (
203+
routingService as unknown as IDuplicateResourceInvoker
204+
).duplicateResource(baseId, BaseNodeResourceType.Table, tableId, duplicateRo);
205+
206+
expect(tableOpenApiV2Service.duplicateTable).toHaveBeenCalledWith(
207+
baseId,
208+
tableId,
209+
duplicateRo
210+
);
211+
expect(tableDuplicateService.duplicateTable).not.toHaveBeenCalled();
212+
expect(result).toEqual({
213+
id: 'tbl-v2-copy',
214+
name: tableName,
215+
icon: tableIcon,
216+
defaultViewId: 'viwV2',
217+
});
218+
});
219+
220+
it('keeps the legacy duplicate path when useV2 is disabled', async () => {
221+
const { routingService, tableOpenApiV2Service, tableDuplicateService } =
222+
createDuplicateRoutingService(false);
223+
const duplicateRo = { name: tableName, includeRecords: false };
224+
225+
const result = await (
226+
routingService as unknown as IDuplicateResourceInvoker
227+
).duplicateResource(baseId, BaseNodeResourceType.Table, tableId, duplicateRo);
228+
229+
expect(tableDuplicateService.duplicateTable).toHaveBeenCalledWith(
230+
baseId,
231+
tableId,
232+
duplicateRo
233+
);
234+
expect(tableOpenApiV2Service.duplicateTable).not.toHaveBeenCalled();
235+
expect(result).toEqual({
236+
id: 'tbl-v1-copy',
237+
name: tableName,
238+
icon: tableIcon,
239+
defaultViewId: 'viwLegacy',
240+
});
241+
});
242+
});
134243
});

apps/nestjs-backend/src/features/base-node/base-node.service.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
IBaseNodePresenceDeletePayload,
2121
IBaseNodePresenceUpdatePayload,
2222
IBaseNodeTableResourceMeta,
23+
V2Feature,
2324
} from '@teable/openapi';
2425
import { BaseNodeResourceType } from '@teable/openapi';
2526
import { Knex } from 'knex';
@@ -124,7 +125,11 @@ export class BaseNodeService {
124125
};
125126
}
126127

127-
async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise<IV2Decision | undefined> {
128+
async getTableV2Decision(
129+
baseId: string,
130+
nodeId: string,
131+
feature: V2Feature
132+
): Promise<IV2Decision | undefined> {
128133
const node = await this.prismaService.baseNode.findFirst({
129134
where: { baseId, id: nodeId },
130135
select: { resourceType: true },
@@ -143,7 +148,11 @@ export class BaseNodeService {
143148
return { useV2: false, reason: 'disabled' };
144149
}
145150

146-
return this.canaryService.shouldUseV2WithReason(base.spaceId, 'deleteTable');
151+
return this.canaryService.shouldUseV2WithReason(base.spaceId, feature);
152+
}
153+
154+
async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise<IV2Decision | undefined> {
155+
return this.getTableV2Decision(baseId, nodeId, 'deleteTable');
147156
}
148157

149158
async getCreateTableV2Decision(baseId: string): Promise<IV2Decision | undefined> {
@@ -621,11 +630,17 @@ export class BaseNodeService {
621630
): Promise<IBaseNodeResourceMetaWithId> {
622631
switch (type) {
623632
case BaseNodeResourceType.Table: {
624-
const table = await this.tableDuplicateService.duplicateTable(
625-
baseId,
626-
id,
627-
duplicateRo as IDuplicateTableRo
628-
);
633+
const table = this.cls.get('useV2')
634+
? await this.tableOpenApiV2Service.duplicateTable(
635+
baseId,
636+
id,
637+
duplicateRo as IDuplicateTableRo
638+
)
639+
: await this.tableDuplicateService.duplicateTable(
640+
baseId,
641+
id,
642+
duplicateRo as IDuplicateTableRo
643+
);
629644

630645
return {
631646
id: table.id,

0 commit comments

Comments
 (0)