feat: support duplicate table (#1335)

* feat: support duplicate table

* fix: duplicate lookup field type unmatch

* fix: duplicate table with record order crash

* fix: update duplicate table auth

* fix: unmatch column when duplicate table data

* fix: duplicate table lose icon and desciption

* chore: add duplicate table summary
This commit is contained in:
Aries X 2025-02-28 18:34:19 +08:00 committed by GitHub
parent 8643f89924
commit 4fd2275bc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1723 additions and 21 deletions

View File

@ -7,6 +7,7 @@ import type { DateFieldDto } from '../features/field/model/field-dto/date-field.
import type { SchemaType } from '../features/field/util';
import type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';
import type { BaseQueryAbstract } from './base-query/abstract';
import type { DuplicateTableQueryAbstract } from './duplicate-table/abstract';
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
import type { IndexBuilderAbstract } from './index-query/index-abstract-builder';
@ -159,6 +160,8 @@ export interface IDbProvider {
searchIndex(): IndexBuilderAbstract;
duplicateTableQuery(queryBuilder: Knex.QueryBuilder): DuplicateTableQueryAbstract;
shareFilterCollaboratorsQuery(
originQueryBuilder: Knex.QueryBuilder,
dbFieldName: string,

View File

@ -0,0 +1,12 @@
import type { Knex } from 'knex';
export abstract class DuplicateTableQueryAbstract {
constructor(protected readonly queryBuilder: Knex.QueryBuilder) {}
abstract duplicateTableData(
sourceTable: string,
targetTable: string,
newColumns: string[],
oldColumns: string[]
): Knex.QueryBuilder;
}

View File

@ -0,0 +1,24 @@
import type { Knex } from 'knex';
import { DuplicateTableQueryAbstract } from './abstract';
export class DuplicateTableQueryPostgres extends DuplicateTableQueryAbstract {
protected knex: Knex.Client;
constructor(queryBuilder: Knex.QueryBuilder) {
super(queryBuilder);
this.knex = queryBuilder.client;
}
duplicateTableData(
sourceTable: string,
targetTable: string,
newColumns: string[],
oldColumns: string[]
) {
const newColumnList = newColumns.map((col) => `"${col}"`).join(', ');
const oldColumnList = oldColumns.map((col) => `"${col}"`).join(', ');
return this.knex.raw(`INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ??`, [
targetTable,
sourceTable,
]);
}
}

View File

@ -0,0 +1,18 @@
import type { Knex } from 'knex';
import { DuplicateTableQueryAbstract } from './abstract';
export class DuplicateTableQuerySqlite extends DuplicateTableQueryAbstract {
protected knex: Knex.Client;
constructor(queryBuilder: Knex.QueryBuilder) {
super(queryBuilder);
this.knex = queryBuilder.client;
}
duplicateTableData(sourceTable: string, targetTable: string, columns: string[]) {
const columnList = columns.map((col) => `"${col}"`).join(', ');
return this.knex.raw(`INSERT INTO ?? (${columnList}) SELECT ${columnList} FROM ??`, [
targetTable,
sourceTable,
]);
}
}

View File

@ -18,6 +18,7 @@ import type {
IFilterQueryExtra,
ISortQueryExtra,
} from './db.provider.interface';
import { DuplicateTableQueryPostgres } from './duplicate-table/duplicate-query.postgres';
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
import { FilterQueryPostgres } from './filter-query/postgres/filter-query.postgres';
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
@ -380,6 +381,10 @@ export class PostgresProvider implements IDbProvider {
return new IndexBuilderPostgres();
}
duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {
return new DuplicateTableQueryPostgres(queryBuilder);
}
shareFilterCollaboratorsQuery(
originQueryBuilder: Knex.QueryBuilder,
dbFieldName: string,

View File

@ -18,6 +18,7 @@ import type {
IFilterQueryExtra,
ISortQueryExtra,
} from './db.provider.interface';
import { DuplicateTableQuerySqlite } from './duplicate-table/duplicate-query.sqlite';
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
import { FilterQuerySqlite } from './filter-query/sqlite/filter-query.sqlite';
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
@ -337,6 +338,10 @@ export class SqliteProvider implements IDbProvider {
return new IndexBuilderSqlite();
}
duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {
return new DuplicateTableQuerySqlite(queryBuilder);
}
shareFilterCollaboratorsQuery(
originQueryBuilder: Knex.QueryBuilder,
dbFieldName: string,

View File

@ -139,7 +139,9 @@ export class FieldCalculationService {
@Timing()
async getRowCount(dbTableName: string) {
const query = this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery();
const [{ count }] = await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(query);
const [{ count }] = await this.prismaService
.txClient()
.$queryRawUnsafe<{ count: bigint }[]>(query);
return Number(count);
}

View File

@ -102,7 +102,7 @@ export class FieldSupplementService {
}
private async getDefaultLinkName(foreignTableId: string) {
const tableRaw = await this.prismaService.tableMeta.findUnique({
const tableRaw = await this.prismaService.txClient().tableMeta.findUnique({
where: { id: foreignTableId },
select: { name: true },
});
@ -195,7 +195,7 @@ export class FieldSupplementService {
const dbTableName = await this.getDbTableName(tableId);
const foreignTableName = await this.getDbTableName(foreignTableId);
const { id: lookupFieldId } = await this.prismaService.field.findFirstOrThrow({
const { id: lookupFieldId } = await this.prismaService.txClient().field.findFirstOrThrow({
where: { tableId: foreignTableId, isPrimary: true },
select: { id: true },
});
@ -374,7 +374,7 @@ export class FieldSupplementService {
}
const { linkFieldId, lookupFieldId, foreignTableId } = lookupOptions;
const linkFieldRaw = await this.prismaService.field.findFirst({
const linkFieldRaw = await this.prismaService.txClient().field.findFirst({
where: { id: linkFieldId, deletedTime: null, type: FieldType.Link },
select: { name: true, options: true, isMultipleCellValue: true },
});
@ -392,7 +392,7 @@ export class FieldSupplementService {
throw new BadRequestException(`foreignTableId ${foreignTableId} is invalid`);
}
const lookupFieldRaw = await this.prismaService.field.findFirst({
const lookupFieldRaw = await this.prismaService.txClient().field.findFirst({
where: { id: lookupFieldId, deletedTime: null },
});
@ -555,7 +555,7 @@ export class FieldSupplementService {
throw new BadRequestException('expression parse error');
}
const fieldRaws = await this.prismaService.field.findMany({
const fieldRaws = await this.prismaService.txClient().field.findMany({
where: { id: { in: fieldIds }, deletedTime: null },
});

View File

@ -1,6 +1,12 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import type { IGetAbnormalVo, ITableFullVo, ITableListVo, ITableVo } from '@teable/openapi';
import type {
IDuplicateTableVo,
IGetAbnormalVo,
ITableFullVo,
ITableListVo,
ITableVo,
} from '@teable/openapi';
import {
tableRoSchema,
ICreateTableWithDefault,
@ -17,6 +23,8 @@ import {
IToggleIndexRo,
toggleIndexRoSchema,
TableIndex,
duplicateTableRoSchema,
IDuplicateTableRo,
} from '@teable/openapi';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { Permissions } from '../../auth/decorators/permissions.decorator';
@ -123,6 +131,18 @@ export class TableController {
return await this.tableOpenApiService.createTable(baseId, createTableRo);
}
@Permissions('table|create')
@Permissions('table|read')
@Post(':tableId/duplicate')
async duplicateTable(
@Param('baseId') baseId: string,
@Param('tableId') tableId: string,
@Body(new ZodValidationPipe(duplicateTableRoSchema), TablePipe)
duplicateTableRo: IDuplicateTableRo
): Promise<IDuplicateTableVo> {
return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo);
}
@Delete(':tableId')
@Permissions('table|delete')
async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {

View File

@ -8,6 +8,7 @@ import { GraphModule } from '../../graph/graph.module';
import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';
import { RecordModule } from '../../record/record.module';
import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';
import { TableDuplicateService } from '../table-dupicate.service';
import { TableIndexService } from '../table-index.service';
import { TableModule } from '../table.module';
import { TableController } from './table-open-api.controller';
@ -26,7 +27,7 @@ import { TableOpenApiService } from './table-open-api.service';
GraphModule,
],
controllers: [TableController],
providers: [DbProvider, TableOpenApiService, TableIndexService],
providers: [DbProvider, TableOpenApiService, TableIndexService, TableDuplicateService],
exports: [TableOpenApiService],
})
export class TableOpenApiModule {}

View File

@ -30,6 +30,7 @@ import type {
ICreateRecordsRo,
ICreateTableRo,
ICreateTableWithDefault,
IDuplicateTableRo,
ITableFullVo,
ITablePermissionVo,
ITableVo,
@ -51,6 +52,7 @@ import { FieldOpenApiService } from '../../field/open-api/field-open-api.service
import { RecordOpenApiService } from '../../record/open-api/record-open-api.service';
import { RecordService } from '../../record/record.service';
import { ViewOpenApiService } from '../../view/open-api/view-open-api.service';
import { TableDuplicateService } from '../table-dupicate.service';
import { TableService } from '../table.service';
@Injectable()
@ -67,6 +69,7 @@ export class TableOpenApiService {
private readonly fieldCreatingService: FieldCreatingService,
private readonly fieldSupplementService: FieldSupplementService,
private readonly permissionService: PermissionService,
private readonly tableDuplicateService: TableDuplicateService,
@InjectDbProvider() private readonly dbProvider: IDbProvider,
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
@ -196,6 +199,10 @@ export class TableOpenApiService {
};
}
async duplicateTable(baseId: string, tableId: string, tableRo: IDuplicateTableRo) {
return await this.tableDuplicateService.duplicateTable(baseId, tableId, tableRo);
}
async createTableMeta(baseId: string, tableRo: ICreateTableRo) {
return await this.tableService.createTable(baseId, tableRo);
}

View File

@ -0,0 +1,879 @@
import { BadGatewayException, Injectable, Logger } from '@nestjs/common';
import type { IFormulaFieldOptions, ILinkFieldOptions, ILookupOptionsRo } from '@teable/core';
import {
generateViewId,
generateShareId,
FieldType,
ViewType,
generatePluginInstallId,
} from '@teable/core';
import type { View } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
import type { IDuplicateTableRo, IDuplicateTableVo } from '@teable/openapi';
import { Knex } from 'knex';
import { get, pick } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';
import { InjectDbProvider } from '../../db-provider/db.provider';
import { IDbProvider } from '../../db-provider/db.provider.interface';
import type { IClsStore } from '../../types/cls';
import type { IFieldInstance } from '../field/model/factory';
import { createFieldInstanceByRaw, rawField2FieldObj } from '../field/model/factory';
import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';
import { FieldOpenApiService } from '../field/open-api/field-open-api.service';
import { ROW_ORDER_FIELD_PREFIX } from '../view/constant';
import { createViewVoByRaw } from '../view/model/factory';
import { ViewOpenApiService } from './../view/open-api/view-open-api.service';
import { TableService } from './table.service';
@Injectable()
export class TableDuplicateService {
private logger = new Logger(TableDuplicateService.name);
constructor(
private readonly cls: ClsService<IClsStore>,
private readonly prismaService: PrismaService,
private readonly tableService: TableService,
private readonly fieldOpenService: FieldOpenApiService,
private readonly viewOpenService: ViewOpenApiService,
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
@InjectDbProvider() private readonly dbProvider: IDbProvider,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}
async duplicateTable(baseId: string, tableId: string, duplicateRo: IDuplicateTableRo) {
const { includeRecords, name } = duplicateRo;
const {
id: sourceTableId,
icon,
description,
dbTableName,
} = await this.prismaService.tableMeta.findUniqueOrThrow({
where: { id: tableId },
});
return await this.prismaService.$tx(
async () => {
const newTableVo = await this.tableService.createTable(baseId, {
name,
icon,
description,
});
const sourceToTargetFieldMap = await this.duplicateFields(sourceTableId, newTableVo.id);
const sourceToTargetViewMap = await this.duplicateViews(
sourceTableId,
newTableVo.id,
sourceToTargetFieldMap
);
await this.repairDuplicateOmit(
sourceToTargetFieldMap,
sourceToTargetViewMap,
newTableVo.id
);
includeRecords &&
(await this.duplicateTableData(
dbTableName,
newTableVo.dbTableName,
sourceToTargetViewMap,
sourceToTargetFieldMap
));
const viewPlain = await this.prismaService.txClient().view.findMany({
where: {
tableId: newTableVo.id,
deletedTime: null,
},
});
const fieldPlain = await this.prismaService.txClient().field.findMany({
where: {
tableId: newTableVo.id,
deletedTime: null,
},
});
return {
...newTableVo,
views: viewPlain.map((v) => createViewVoByRaw(v)),
fields: fieldPlain.map((f) => rawField2FieldObj(f)),
viewMap: sourceToTargetViewMap,
fieldMap: sourceToTargetFieldMap,
} as IDuplicateTableVo;
},
{
timeout: this.thresholdConfig.bigTransactionTimeout,
}
);
}
async duplicateTableData(
sourceDbTableName: string,
targetDbTableName: string,
sourceToTargetViewMap: Record<string, string>,
sourceToTargetFieldMap: Record<string, string>
) {
const prisma = this.prismaService.txClient();
const qb = this.knex.queryBuilder();
const columnInfoQuery = this.dbProvider.columnInfo(sourceDbTableName);
const oldOriginColumns = (
await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(columnInfoQuery)
).map(({ name }) => name);
const oldFieldColumns = oldOriginColumns.filter(
(name) => !name.startsWith(ROW_ORDER_FIELD_PREFIX) && !name.startsWith('__fk_fld')
);
const oldRowColumns = oldOriginColumns.filter((name) =>
name.startsWith(ROW_ORDER_FIELD_PREFIX)
);
const oldFkColumns = oldOriginColumns.filter((name) => name.startsWith('__fk_fld'));
const newRowColumns = oldRowColumns.map((name) =>
sourceToTargetViewMap[name.slice(6)] ? `__row_${sourceToTargetViewMap[name.slice(6)]}` : name
);
const newFkColumns = oldFkColumns.map((name) =>
sourceToTargetFieldMap[name.slice(5)] ? `__fk_${sourceToTargetFieldMap[name.slice(5)]}` : name
);
for (const name of newRowColumns) {
await this.createRowOrderField(targetDbTableName, name.slice(6));
}
for (const name of newFkColumns) {
await this.createFkField(targetDbTableName, name.slice(5));
}
const oldColumns = oldFieldColumns.concat(oldRowColumns).concat(oldFkColumns);
const newColumns = oldFieldColumns.concat(newRowColumns).concat(newFkColumns);
const sql = this.dbProvider
.duplicateTableQuery(qb)
.duplicateTableData(sourceDbTableName, targetDbTableName, newColumns, oldColumns)
.toQuery();
await prisma.$executeRawUnsafe(sql);
}
private async createRowOrderField(dbTableName: string, viewId: string) {
const prisma = this.prismaService.txClient();
const rowIndexFieldName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
const columnExists = await this.dbProvider.checkColumnExist(
dbTableName,
rowIndexFieldName,
prisma
);
if (!columnExists) {
// add a field for maintain row order number
const addRowIndexColumnSql = this.knex.schema
.alterTable(dbTableName, (table) => {
table.double(rowIndexFieldName);
})
.toQuery();
await prisma.$executeRawUnsafe(addRowIndexColumnSql);
}
// create index
const indexName = `idx_${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
const createRowIndexSQL = this.knex
.raw(
`
CREATE INDEX IF NOT EXISTS ?? ON ?? (??)
`,
[indexName, dbTableName, rowIndexFieldName]
)
.toQuery();
await prisma.$executeRawUnsafe(createRowIndexSQL);
}
private async createFkField(dbTableName: string, fieldId: string) {
const prisma = this.prismaService.txClient();
const fkFieldName = `__fk_${fieldId}`;
const columnExists = await this.dbProvider.checkColumnExist(dbTableName, fkFieldName, prisma);
if (!columnExists) {
const addFkColumnSql = this.knex.schema
.alterTable(dbTableName, (table) => {
table.string(fkFieldName);
})
.toQuery();
await prisma.$executeRawUnsafe(addFkColumnSql);
}
}
private async duplicateFields(sourceTableId: string, targetTableId: string) {
const fieldsRaw = await this.prismaService.txClient().field.findMany({
where: { tableId: sourceTableId, deletedTime: null },
});
const fieldsInstances = fieldsRaw.map((f) => createFieldInstanceByRaw(f));
const sourceToTargetFieldMap: Record<string, string> = {};
const commonFields = fieldsInstances.filter(
(f) => !f.isLookup && ![FieldType.Formula, FieldType.Link].includes(f.type as FieldType)
);
for (let i = 0; i < commonFields.length; i++) {
const { type, dbFieldName, name, options, isPrimary, id, unique, notNull, description } =
commonFields[i];
const newField = await this.fieldOpenService.createField(targetTableId, {
type,
dbFieldName: dbFieldName,
name,
options,
description,
});
if (isPrimary || unique || notNull) {
const updateData: {
isPrimary?: boolean;
unique?: boolean;
notNull?: boolean;
} = {
isPrimary,
};
if (unique !== undefined) updateData.unique = unique;
if (notNull !== undefined) updateData.notNull = notNull;
if (Object.keys(updateData).length > 0) {
await this.prismaService.txClient().field.update({
where: {
id: newField?.id,
},
data: updateData,
});
}
}
sourceToTargetFieldMap[id] = newField.id;
}
// these field require other field, we need to merge them and ensure a specific order
const linkFields = fieldsInstances.filter((f) => f.type === FieldType.Link && !f.isLookup);
await this.duplicateLinkFields(
sourceTableId,
targetTableId,
linkFields,
sourceToTargetFieldMap
);
await this.duplicateDependFields(
sourceTableId,
targetTableId,
fieldsInstances,
sourceToTargetFieldMap
);
return sourceToTargetFieldMap;
}
// field could not set constraint when create
private async replenishmentConstraint(
fId: string,
targetTableId: string,
{ notNull, unique, dbFieldName }: { notNull?: boolean; unique?: boolean; dbFieldName: string }
) {
if (notNull || unique) {
const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
where: {
id: targetTableId,
deletedTime: null,
},
select: {
dbTableName: true,
},
});
await this.prismaService.txClient().field.update({
where: {
id: fId,
},
data: {
notNull,
unique,
},
});
const fieldValidationQuery = this.knex.schema
.alterTable(dbTableName, (table) => {
if (unique) table.dropUnique([dbFieldName]);
if (notNull) table.setNullable(dbFieldName);
})
.toQuery();
await this.prismaService.txClient().$executeRawUnsafe(fieldValidationQuery);
}
}
private async duplicateLinkFields(
sourceTableId: string,
targetTableId: string,
linkFields: IFieldInstance[],
sourceToTargetFieldMap: Record<string, string>
) {
const twoWaySelfLinkFields = linkFields.filter((f) => {
const options = f.options as ILinkFieldOptions;
return options.foreignTableId === sourceTableId;
});
const mergedTwoWaySelfLinkFields = [] as [IFieldInstance, IFieldInstance][];
twoWaySelfLinkFields.forEach((f) => {
// two-way self link field should only create one of it
if (!mergedTwoWaySelfLinkFields.some((group) => group.some(({ id: fId }) => fId === f.id))) {
const groupField = twoWaySelfLinkFields.find(
({ options }) => get(options, 'symmetricFieldId') === f.id
);
groupField && mergedTwoWaySelfLinkFields.push([f, groupField]);
}
});
const otherLinkFields = linkFields.filter(
(f) => !twoWaySelfLinkFields.map((f) => f.id).includes(f.id)
);
// self link field
for (let i = 0; i < mergedTwoWaySelfLinkFields.length; i++) {
const f = mergedTwoWaySelfLinkFields[i][0];
const { notNull, unique, description } = f;
const groupField = mergedTwoWaySelfLinkFields[i][1] as LinkFieldDto;
const { name, type, dbFieldName, id } = f;
const options = f.options as ILinkFieldOptions;
const newField = await this.fieldOpenService.createField(targetTableId, {
type: type as FieldType,
dbFieldName,
name,
description,
options: {
...pick(options, [
'relationship',
'isOneWay',
'filterByViewId',
'filter',
'visibleFieldIds',
]),
foreignTableId: targetTableId,
},
});
await this.replenishmentConstraint(newField.id, targetTableId, {
notNull,
unique,
dbFieldName,
});
if (notNull || unique) {
await this.prismaService.txClient().field.update({
where: {
id: newField?.id,
},
data: {
unique,
notNull,
},
});
}
sourceToTargetFieldMap[id] = newField.id;
sourceToTargetFieldMap[options.symmetricFieldId!] = (
newField.options as ILinkFieldOptions
).symmetricFieldId!;
// self link should updated the opposite field dbFieldName and name
const { dbTableName: targetDbTableName } = await this.prismaService
.txClient()
.tableMeta.findUniqueOrThrow({
where: {
id: targetTableId,
},
select: {
dbTableName: true,
},
});
const { dbFieldName: genDbFieldName } = await this.prismaService
.txClient()
.field.findUniqueOrThrow({
where: {
id: sourceToTargetFieldMap[groupField.id],
},
select: {
dbFieldName: true,
},
});
await this.prismaService.txClient().field.update({
where: {
id: sourceToTargetFieldMap[groupField.id],
},
data: {
dbFieldName: groupField.dbFieldName,
name: groupField.name,
options: JSON.stringify({ ...groupField.options, foreignTableId: targetTableId }),
},
});
const alterTableSql = this.dbProvider.renameColumn(
targetDbTableName,
genDbFieldName,
groupField.dbFieldName
);
for (const sql of alterTableSql) {
await this.prismaService.txClient().$executeRawUnsafe(sql);
}
}
// other common link field
for (let i = 0; i < otherLinkFields.length; i++) {
const f = otherLinkFields[i];
const { type, description, name, notNull, unique, options, dbFieldName } = f;
const newField = await this.fieldOpenService.createField(targetTableId, {
type: type as FieldType,
description,
dbFieldName,
name,
options: {
...pick(options, [
'relationship',
'foreignTableId',
'isOneWay',
'filterByViewId',
'filter',
'visibleFieldIds',
]),
},
});
await this.replenishmentConstraint(newField.id, targetTableId, {
notNull,
unique,
dbFieldName,
});
sourceToTargetFieldMap[f.id] = newField.id;
}
}
private async duplicateDependFields(
sourceTableId: string,
targetTableId: string,
fieldsInstances: IFieldInstance[],
sourceToTargetFieldMap: Record<string, string>
) {
const dependFields = fieldsInstances.filter((f) => f.isLookup || f.type === FieldType.Formula);
if (!dependFields.length) return;
const checkedField = [] as IFieldInstance[];
while (dependFields.length) {
const curField = dependFields.shift();
if (!curField) continue;
const isChecked = checkedField.some((f) => f.id === curField.id);
// InDegree all ready
const isInDegreeReady = this.isInDegreeReady(curField, sourceTableId, sourceToTargetFieldMap);
if (isInDegreeReady) {
await this.duplicateSingleDependField(
sourceTableId,
targetTableId,
curField,
sourceToTargetFieldMap
);
continue;
}
if (isChecked) {
if (curField.hasError) {
await this.duplicateSingleDependField(
sourceTableId,
targetTableId,
curField,
sourceToTargetFieldMap,
true
);
} else {
throw new BadGatewayException('Create circular field');
}
} else {
dependFields.push(curField);
checkedField.push(curField);
}
}
}
private isInDegreeReady(
field: IFieldInstance,
sourceTableId: string,
sourceToTargetFieldMap: Record<string, string>
) {
if (field.type === FieldType.Formula) {
const formulaOptions = field.options as IFormulaFieldOptions;
const referencedFields = this.extractFieldIds(formulaOptions.expression);
const keys = Object.keys(sourceToTargetFieldMap);
return referencedFields.every((field) => keys.includes(field));
}
if (field.isLookup) {
const { lookupOptions } = field;
const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptions as ILookupOptionsRo;
const isSelfLink = foreignTableId === sourceTableId;
return isSelfLink
? Boolean(sourceToTargetFieldMap[lookupFieldId] && sourceToTargetFieldMap[linkFieldId])
: true;
}
return false;
}
private async duplicateSingleDependField(
sourceTableId: string,
targetTableId: string,
field: IFieldInstance,
sourceToTargetFieldMap: Record<string, string>,
hasError = false
) {
if (field.type === FieldType.Formula) {
await this.duplicateFormulaField(targetTableId, field, sourceToTargetFieldMap, hasError);
} else if (field.isLookup) {
await this.duplicateLookupField(sourceTableId, targetTableId, field, sourceToTargetFieldMap);
}
}
private async duplicateLookupField(
sourceTableId: string,
targetTableId: string,
fieldInstance: IFieldInstance,
sourceToTargetFieldMap: Record<string, string>
) {
const {
dbFieldName,
name,
lookupOptions,
id,
hasError,
options,
notNull,
unique,
description,
} = fieldInstance;
const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptions as ILookupOptionsRo;
const isSelfLink = foreignTableId === sourceTableId;
const { type: lookupFieldType } = await this.prismaService.txClient().field.findUniqueOrThrow({
where: {
id: lookupFieldId,
},
select: {
type: true,
},
});
const mockFieldId = Object.values(sourceToTargetFieldMap)[0];
const { type: mockType } = await this.prismaService.txClient().field.findUniqueOrThrow({
where: {
id: mockFieldId,
deletedTime: null,
},
select: {
type: true,
},
});
const newField = await this.fieldOpenService.createField(targetTableId, {
type: (hasError ? mockType : lookupFieldType) as FieldType,
dbFieldName,
description,
isLookup: true,
lookupOptions: {
foreignTableId: isSelfLink ? targetTableId : foreignTableId,
linkFieldId: isSelfLink ? sourceToTargetFieldMap[linkFieldId] : linkFieldId,
lookupFieldId: isSelfLink
? hasError
? mockFieldId
: sourceToTargetFieldMap[lookupFieldId]
: hasError
? mockFieldId
: lookupFieldId,
},
name,
});
await this.replenishmentConstraint(newField.id, targetTableId, {
notNull,
unique,
dbFieldName,
});
sourceToTargetFieldMap[id] = newField.id;
if (hasError) {
await this.prismaService.txClient().field.update({
where: {
id: newField.id,
},
data: {
hasError,
type: lookupFieldType,
lookupOptions: JSON.stringify({
...newField.lookupOptions,
lookupFieldId: lookupFieldId,
}),
options: JSON.stringify(options),
},
});
}
}
private async duplicateFormulaField(
targetTableId: string,
fieldInstance: IFieldInstance,
sourceToTargetFieldMap: Record<string, string>,
hasError: boolean = false
) {
const { type, dbFieldName, name, options, id, notNull, unique, description } = fieldInstance;
const { expression } = options as IFormulaFieldOptions;
let newExpression = expression;
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
newExpression = newExpression.replaceAll(key, value);
});
const mockFieldId = Object.values(sourceToTargetFieldMap)[0];
const newField = await this.fieldOpenService.createField(targetTableId, {
type,
dbFieldName: dbFieldName,
description,
options: {
...options,
expression: hasError ? `{${mockFieldId}}` : newExpression,
},
name,
});
await this.replenishmentConstraint(newField.id, targetTableId, {
notNull,
unique,
dbFieldName,
});
sourceToTargetFieldMap[id] = newField.id;
if (hasError) {
await this.prismaService.txClient().field.update({
where: {
id: newField.id,
},
data: {
hasError,
options: JSON.stringify({
...options,
expression: newExpression,
}),
},
});
}
}
private async duplicateViews(
sourceTableId: string,
targetTableId: string,
sourceToTargetFieldMap: Record<string, string>
) {
const views = await this.prismaService.view.findMany({
where: { tableId: sourceTableId, deletedTime: null },
});
const viewsWithoutPlugin = views.filter((v) => v.type !== ViewType.Plugin);
const pluginViews = views.filter(({ type }) => type === ViewType.Plugin);
const sourceToTargetViewMap = {} as Record<string, string>;
const userId = this.cls.get('user.id');
const prisma = this.prismaService.txClient();
await prisma.view.createMany({
data: viewsWithoutPlugin.map((view) => {
const fieldsToReplace = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;
const updatedFields = fieldsToReplace.reduce(
(acc, field) => {
if (view[field]) {
acc[field] = Object.entries(sourceToTargetFieldMap).reduce(
(result, [key, value]) => result.replaceAll(key, value),
view[field]!
);
}
return acc;
},
{} as Partial<typeof view>
);
const newViewId = generateViewId();
sourceToTargetViewMap[view.id] = newViewId;
return {
...view,
createdTime: new Date().toISOString(),
createdBy: userId,
version: 1,
tableId: targetTableId,
id: newViewId,
shareId: generateShareId(),
...updatedFields,
};
}),
});
// duplicate plugin view
await this.duplicatePluginViews(
targetTableId,
pluginViews,
sourceToTargetViewMap,
sourceToTargetFieldMap
);
return sourceToTargetViewMap;
}
private async duplicatePluginViews(
targetTableId: string,
pluginViews: View[],
sourceToTargetViewMap: Record<string, string>,
sourceToTargetFieldMap: Record<string, string>
) {
const prisma = this.prismaService.txClient();
if (!pluginViews.length) return;
const pluginData = await prisma.pluginInstall.findMany({
where: {
id: {
in: pluginViews.map((v) => (v.options ? JSON.parse(v.options).pluginInstallId : null)),
},
},
});
for (const view of pluginViews) {
const plugin = view.options ? JSON.parse(view.options) : null;
if (!plugin) {
throw new BadGatewayException('Duplicate plugin view error: pluginId not found');
}
const { pluginInstallId, pluginId } = plugin;
const newPluginInsId = generatePluginInstallId();
const newViewId = generateViewId();
sourceToTargetViewMap[view.id] = newViewId;
const pluginInfo = pluginData.find((p) => p.id === pluginInstallId);
if (!pluginInfo) continue;
let curPluginStorage = pluginInfo?.storage;
let pluginOptions = plugin.options;
if (curPluginStorage) {
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
curPluginStorage = curPluginStorage?.replaceAll(key, value) || null;
});
}
if (pluginOptions) {
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
pluginOptions = pluginOptions.replaceAll(key, value);
});
pluginOptions = pluginOptions.replaceAll(pluginId, newPluginInsId);
}
const fieldsToReplace = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;
const updatedFields = fieldsToReplace.reduce(
(acc, field) => {
if (view[field]) {
acc[field] = Object.entries(sourceToTargetFieldMap).reduce(
(result, [key, value]) => result.replaceAll(key, value),
view[field]!
);
}
return acc;
},
{} as Partial<typeof view>
);
await prisma.pluginInstall.create({
data: {
...pluginInfo,
createdBy: this.cls.get('user.id'),
id: newPluginInsId,
createdTime: new Date().toISOString(),
lastModifiedBy: null,
lastModifiedTime: null,
storage: curPluginStorage,
positionId: newViewId,
},
});
await prisma.view.create({
data: {
...view,
createdTime: new Date().toISOString(),
createdBy: this.cls.get('user.id'),
version: 1,
tableId: targetTableId,
id: newViewId,
shareId: generateShareId(),
options: pluginOptions,
...updatedFields,
},
});
}
return sourceToTargetViewMap;
}
private async repairDuplicateOmit(
sourceToTargetFieldMap: Record<string, string>,
sourceToTargetViewMap: Record<string, string>,
targetTableId: string
) {
const fieldRaw = await this.prismaService.txClient().field.findMany({
where: {
tableId: targetTableId,
deletedTime: null,
},
});
const selfLinkFields = fieldRaw.filter(
({ type, options }) =>
type === FieldType.Link &&
options &&
(JSON.parse(options) as ILinkFieldOptions)?.foreignTableId === targetTableId
);
for (const field of selfLinkFields) {
const { id: fieldId, options } = field;
if (!options) continue;
let newOptions = options;
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
newOptions = newOptions.replaceAll(key, value);
});
Object.entries(sourceToTargetViewMap).forEach(([key, value]) => {
newOptions = newOptions.replaceAll(key, value);
});
await this.prismaService.txClient().field.update({
where: {
id: fieldId,
},
data: {
options: newOptions,
},
});
}
}
private extractFieldIds(expression: string): string[] {
const matches = expression.match(/\{fld[a-zA-Z0-9]+\}/g);
if (!matches) {
return [];
}
return matches.map((match) => match.slice(1, -1));
}
}

View File

@ -31,7 +31,7 @@ export class TableIndexService {
tableId: string,
type: TableIndex = TableIndex.search
): Promise<TableIndex[]> {
const { dbTableName } = await this.prismaService.tableMeta.findUniqueOrThrow({
const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
where: {
id: tableId,
},
@ -142,7 +142,7 @@ export class TableIndexService {
const index = await this.getActivatedTableIndexes(tableId);
const sql = this.dbProvider.searchIndex().createSingleIndexSql(dbTableName, fieldInstance);
if (index.includes(TableIndex.search) && sql) {
await this.prismaService.$executeRawUnsafe(sql);
await this.prismaService.txClient().$executeRawUnsafe(sql);
}
}

View File

@ -38,7 +38,7 @@ export class TableService implements IReadonlyAdapterService {
return convertNameToValidCharacter(name, 40);
}
private async createDBTable(baseId: string, tableRo: ICreateTableRo) {
private async createDBTable(baseId: string, tableRo: ICreateTableRo, createTable = true) {
const userId = this.cls.get('user.id');
const tableRaws = await this.prismaService.txClient().tableMeta.findMany({
where: { baseId, deletedTime: null },
@ -92,6 +92,10 @@ export class TableService implements IReadonlyAdapterService {
data,
});
if (!createTable) {
return tableMeta;
}
const createTableSchema = this.knex.schema.createTable(dbTableName, (table) => {
table.string('__id').unique().notNullable();
table.increments('__auto_number').primary();
@ -204,8 +208,12 @@ export class TableService implements IReadonlyAdapterService {
return viewRaw;
}
async createTable(baseId: string, snapshot: ICreateTableRo): Promise<ITableVo> {
const tableVo = await this.createDBTable(baseId, snapshot);
async createTable(
baseId: string,
snapshot: ICreateTableRo,
createTable: boolean = true
): Promise<ITableVo> {
const tableVo = await this.createDBTable(baseId, snapshot, createTable);
await this.batchService.saveRawOps(baseId, RawOpType.Create, IdPrefix.Table, [
{
docId: tableVo.id,

View File

@ -785,7 +785,7 @@ export class ViewOpenApiService {
async pluginInstall(tableId: string, ro: IViewInstallPluginRo) {
const userId = this.cls.get('user.id');
const { name, pluginId } = ro;
const plugin = await this.prismaService.plugin.findUnique({
const plugin = await this.prismaService.txClient().plugin.findUnique({
where: { id: pluginId, status: PluginStatus.Published },
select: { id: true, name: true, logo: true, positions: true },
});

View File

@ -0,0 +1,562 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable sonarjs/cognitive-complexity */
import type { INestApplication } from '@nestjs/common';
import type { IFieldVo, IFilterRo, ILinkFieldOptions, IViewGroupRo, IViewVo } from '@teable/core';
import { FieldType, ViewType, RowHeightLevel, SortFunc } from '@teable/core';
import type { IDuplicateTableVo, ITableFullVo } from '@teable/openapi';
import {
createField,
getFields,
duplicateTable,
installViewPlugin,
updateViewColumnMeta,
updateViewSort,
updateViewGroup,
updateViewOptions,
getField,
} from '@teable/openapi';
import { omit } from 'lodash';
import { x_20 } from './data-helpers/20x';
import { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';
import {
createTable,
permanentDeleteTable,
initApp,
getViews,
deleteField,
createView,
updateViewFilter,
} from './utils/init-app';
describe('OpenAPI TableController for duplicate (e2e)', () => {
let app: INestApplication;
const baseId = globalThis.testConfig.baseId;
beforeAll(async () => {
const appCtx = await initApp();
app = appCtx.app;
});
afterAll(async () => {
await app.close();
});
describe('duplicate table with all kind field', () => {
let table: ITableFullVo;
let subTable: ITableFullVo;
let duplicateTableData: IDuplicateTableVo;
beforeAll(async () => {
table = await createTable(baseId, {
name: 'record_query_x_20',
fields: x_20.fields,
records: x_20.records,
});
const x20Link = x_20_link(table);
subTable = await createTable(baseId, {
name: 'lookup_filter_x_20',
fields: x20Link.fields,
records: x20Link.records,
});
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
for (const field of x20LinkFromLookups.fields) {
await createField(subTable.id, field);
}
table.fields = (await getFields(table.id)).data;
table.views = await getViews(table.id);
subTable.fields = (await getFields(subTable.id)).data;
duplicateTableData = (
await duplicateTable(baseId, table.id, {
name: 'duplicated_table',
includeRecords: false,
})
).data;
});
afterAll(async () => {
await permanentDeleteTable(baseId, table.id);
await permanentDeleteTable(baseId, subTable.id);
await permanentDeleteTable(baseId, duplicateTableData.id);
});
it('should duplicate all fields and views', () => {
const { fields: sourceFields, views: sourceViews } = table;
const { fields: targetFields, views: targetViews, viewMap, fieldMap } = duplicateTableData;
expect(targetFields.length).toBe(sourceFields.length);
expect(sourceViews.length).toBe(targetViews.length);
let sourceViewsString = JSON.stringify(sourceViews);
let sourceFieldsString = JSON.stringify(sourceFields);
for (const [key, value] of Object.entries(viewMap)) {
sourceViewsString = sourceViewsString.replaceAll(key, value);
sourceFieldsString = sourceFieldsString.replaceAll(key, value);
}
for (const [key, value] of Object.entries(fieldMap)) {
sourceViewsString = sourceViewsString.replaceAll(key, value);
sourceFieldsString = sourceFieldsString.replaceAll(key, value);
}
const assertField = JSON.parse(sourceFieldsString) as IFieldVo[];
const assertViews = JSON.parse(sourceViewsString) as IViewVo[];
const assertLinkField = assertField
.filter(({ type }) => type === FieldType.Link)
.map((f) => ({
...f,
options: omit(
{
...f.options,
isOneWay: !!(f?.options as ILinkFieldOptions)?.isOneWay,
},
['fkHostTableName', 'selfKeyName', 'symmetricFieldId']
),
}));
const duplicatedLinkField = targetFields
.filter(({ type }) => type === FieldType.Link)
.map((f) => ({
...f,
options: omit(
{
...f.options,
isOneWay: !!(f?.options as ILinkFieldOptions)?.isOneWay,
},
['fkHostTableName', 'selfKeyName', 'symmetricFieldId']
),
}));
const otherFieldsWithOutLink = assertField
.filter(({ type, isLookup }) => type !== FieldType.Link && !isLookup)
.map((f) => omit(f, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy']));
const otherAssertFieldsWithOutLink = targetFields
.filter(({ type, isLookup }) => type !== FieldType.Link && !isLookup)
.map((f) => omit(f, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy']));
const duplicatedViews = targetViews.map((v) =>
omit(v, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy', 'shareId'])
);
const assertPureViews = assertViews.map((v) =>
omit(v, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy', 'shareId'])
);
const sortById = (a: any, b: any) => a.id.localeCompare(b.id);
expect(assertPureViews).toEqual(duplicatedViews);
expect(assertLinkField).toEqual(duplicatedLinkField);
expect(otherFieldsWithOutLink.sort(sortById)).toEqual(
otherAssertFieldsWithOutLink.sort(sortById)
);
});
it('should create a link field in linked table when link field is two-way-link', async () => {
const fields = (await getFields(subTable.id)).data;
const { fields: targetFields } = duplicateTableData;
const assertField = targetFields.find(({ type }) => type === FieldType.Link)!;
const duplicatedLinkField = fields.find(
(f) =>
f.type === FieldType.Link &&
(f.options as ILinkFieldOptions).symmetricFieldId === assertField.id!
);
expect(duplicatedLinkField).toBeDefined();
});
});
describe('duplicate table with error field(formula or lookup field)', () => {
let table: ITableFullVo;
let subTable: ITableFullVo;
let duplicateTableData: IDuplicateTableVo;
let lookupField: IFieldVo;
let formulaField: IFieldVo;
beforeAll(async () => {
table = await createTable(baseId, {
name: 'record_query_x_20',
fields: x_20.fields,
records: x_20.records,
});
const x20Link = x_20_link(table);
subTable = await createTable(baseId, {
name: 'lookup_filter_x_20',
fields: x20Link.fields,
records: x20Link.records,
});
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
for (const field of x20LinkFromLookups.fields) {
await createField(subTable.id, field);
}
table.fields = (await getFields(table.id)).data;
table.views = await getViews(table.id);
subTable.fields = (await getFields(subTable.id)).data;
const primaryField = table.fields.find((f) => f.isPrimary)!;
const numberField = table.fields.find((f) => f.type === FieldType.Number)!;
const linkField = table.fields.find((f) => f.type === FieldType.Link)!;
const lookupedField = subTable.fields.find((f) => f.type === FieldType.Number)!;
// create a formula field and a lookup field both in degree same field, then delete the field, causing field hasError
formulaField = (
await createField(table.id, {
name: 'error_formulaField',
type: FieldType.Formula,
options: {
expression: `{${primaryField.id}}+{${numberField.id}}`,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
).data;
lookupField = (
await createField(table.id, {
name: 'error_lookupField',
type: lookupedField.type,
isLookup: true,
lookupOptions: {
foreignTableId: subTable.id,
linkFieldId: linkField.id,
lookupFieldId: lookupedField.id,
},
})
).data;
await deleteField(table.id, numberField.id);
await deleteField(subTable.id, lookupedField.id);
duplicateTableData = (
await duplicateTable(baseId, table.id, {
name: 'duplicated_table',
includeRecords: false,
})
).data;
});
afterAll(async () => {
await permanentDeleteTable(baseId, table.id);
await permanentDeleteTable(baseId, subTable.id);
await permanentDeleteTable(baseId, duplicateTableData.id);
});
it('duplicated formula and lookup field should has error', async () => {
const sourceFields = (await getFields(table.id)).data;
const { fields: targetFields, fieldMap } = duplicateTableData;
const sourceErrorFormulaField = sourceFields.find((f) => f.id === formulaField.id);
const sourceErrorLookupField = sourceFields.find((f) => f.id === lookupField.id);
expect(sourceErrorFormulaField?.hasError).toBe(true);
expect(sourceErrorLookupField?.hasError).toBe(true);
const targetErrorFormulaField = targetFields.find((f) => f.id === fieldMap[formulaField.id]);
const targetErrorLookupField = targetFields.find((f) => f.id === fieldMap[lookupField.id]);
expect(targetErrorFormulaField?.hasError).toBe(true);
expect(targetErrorLookupField?.hasError).toBe(true);
let assertErrorFormulaFieldString = JSON.stringify(sourceErrorFormulaField);
// let assertErrorLookupFieldString = JSON.stringify(sourceErrorLookupField);
for (const [key, value] of Object.entries(fieldMap)) {
assertErrorFormulaFieldString = assertErrorFormulaFieldString.replaceAll(key, value);
// assertErrorLookupFieldString = assertErrorLookupFieldString.replaceAll(key, value);
}
const assertErrorFormulaField = JSON.parse(assertErrorFormulaFieldString);
// const assertErrorLookupField = JSON.parse(assertErrorLookupFieldString);
expect(assertErrorFormulaField).toEqual(targetErrorFormulaField);
expect(targetErrorLookupField?.hasError).toBe(true);
});
});
describe('duplicate table with self link', () => {
let table: ITableFullVo;
let subTable: ITableFullVo;
let duplicateTableData: IDuplicateTableVo;
beforeAll(async () => {
table = await createTable(baseId, {
name: 'record_query_x_20',
fields: x_20.fields,
records: x_20.records,
});
const x20Link = x_20_link(table);
subTable = await createTable(baseId, {
name: 'lookup_filter_x_20',
fields: x20Link.fields,
records: x20Link.records,
});
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
for (const field of x20LinkFromLookups.fields) {
await createField(subTable.id, field);
}
table.fields = (await getFields(table.id)).data;
table.views = await getViews(table.id);
subTable.fields = (await getFields(subTable.id)).data;
await createField(table.id, {
name: 'self_link',
type: FieldType.Link,
options: {
visibleFieldIds: null,
foreignTableId: table.id,
relationship: 'manyMany',
filter: null,
filterByViewId: null,
},
});
duplicateTableData = (
await duplicateTable(baseId, table.id, {
name: 'duplicated_table',
includeRecords: false,
})
).data;
});
afterAll(async () => {
await permanentDeleteTable(baseId, table.id);
await permanentDeleteTable(baseId, subTable.id);
await permanentDeleteTable(baseId, duplicateTableData.id);
});
it('should duplicate self link fields', async () => {
const { fields, id } = duplicateTableData;
const selfLinkFields = fields.filter(
(f) => f.type === FieldType.Link && (f.options as ILinkFieldOptions)?.foreignTableId === id
);
expect(selfLinkFields.length).toBe(2);
expect((selfLinkFields[0].options as ILinkFieldOptions).fkHostTableName).toBe(
(selfLinkFields[1].options as ILinkFieldOptions).fkHostTableName
);
});
});
describe('duplicate table with all type view', () => {
let table: ITableFullVo;
let subTable: ITableFullVo;
let duplicateTableData: IDuplicateTableVo;
beforeAll(async () => {
table = await createTable(baseId, {
name: 'record_query_x_20',
fields: x_20.fields,
records: x_20.records,
});
const x20Link = x_20_link(table);
subTable = await createTable(baseId, {
name: 'lookup_filter_x_20',
fields: x20Link.fields,
records: x20Link.records,
});
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
for (const field of x20LinkFromLookups.fields) {
await createField(subTable.id, field);
}
table.fields = (await getFields(table.id)).data;
table.views = await getViews(table.id);
subTable.fields = (await getFields(subTable.id)).data;
await createField(table.id, {
name: 'self_link',
type: FieldType.Link,
options: {
visibleFieldIds: null,
foreignTableId: table.id,
relationship: 'manyMany',
filter: null,
filterByViewId: null,
},
});
});
afterAll(async () => {
await permanentDeleteTable(baseId, table.id);
await permanentDeleteTable(baseId, subTable.id);
await permanentDeleteTable(baseId, duplicateTableData.id);
});
it('should duplicate all kind of views', async () => {
const gridView = (await getViews(table.id))[0];
const filterRo: IFilterRo = {
filter: {
conjunction: 'and',
filterSet: [
{
fieldId: table.fields.find((f) => f.isPrimary)!.id,
operator: 'contains',
value: 'text field',
},
{
conjunction: 'and',
filterSet: [
{
fieldId: table.fields.find((f) => f.type === FieldType.Number)!.id,
operator: 'isGreater',
value: 1,
},
],
},
{
fieldId: table.fields.find((f) => f.type === FieldType.SingleSelect)!.id,
operator: 'is',
value: 'x',
},
{
fieldId: table.fields.find((f) => f.type === FieldType.Checkbox)!.id,
operator: 'is',
value: null,
},
],
},
};
const groupRo: IViewGroupRo = {
group: [
{
fieldId: table.fields.find((f) => f.isPrimary)!.id,
order: SortFunc.Asc,
},
],
};
const sortRo = {
sort: {
sortObjs: [
{
fieldId: table.fields.find((f) => f.type === FieldType.MultipleSelect)!.id,
order: SortFunc.Asc,
},
{
fieldId: table.fields.find((f) => f.type === FieldType.Formula)!.id,
order: SortFunc.Desc,
},
],
},
};
await createView(table.id, {
name: 'gallery',
type: ViewType.Gallery,
filter: filterRo.filter,
group: groupRo.group,
sort: sortRo.sort,
enableShare: true,
});
await createView(table.id, {
name: 'kanban',
type: ViewType.Kanban,
group: groupRo.group,
sort: sortRo.sort,
options: {
stackFieldId: table.fields.find((f) => f.isPrimary)!.id,
},
});
await createView(table.id, {
name: 'calendar',
type: ViewType.Calendar,
filter: filterRo.filter,
});
await createView(table.id, {
name: 'table',
type: ViewType.Form,
columnMeta: {
[table.fields.find((f) => f.isPrimary)!.id]: {
visible: true,
order: 1,
},
[table.fields.find((f) => f.type === FieldType.Number)!.id]: {
visible: true,
order: 2,
},
[table.fields.find((f) => f.type === FieldType.SingleSelect)!.id]: {
visible: true,
order: 3,
},
},
});
await installViewPlugin(table.id, {
name: 'sheet',
pluginId: 'plgsheetform',
});
await updateViewFilter(table.id, gridView.id, filterRo);
await updateViewColumnMeta(table.id, gridView.id, [
{
fieldId: table.fields.find((f) => f.type === FieldType.User)!.id,
columnMeta: { hidden: true },
},
]);
await updateViewSort(table.id, gridView.id, sortRo);
await updateViewGroup(table.id, gridView.id, groupRo);
await updateViewOptions(table.id, gridView.id, {
options: {
rowHeight: RowHeightLevel.Tall,
},
});
const sourceViews = await getViews(table.id);
duplicateTableData = (
await duplicateTable(baseId, table.id, {
name: 'duplicated_table',
includeRecords: false,
})
).data;
const targetViews = await getViews(duplicateTableData.id);
const { fieldMap } = duplicateTableData;
expect(sourceViews.length).toBe(targetViews.length);
let assertViewsString = JSON.stringify(
sourceViews
.filter((f) => f.type !== ViewType.Plugin)
.map((v) => ({
...omit(v, [
'createdBy',
'createdTime',
'lastModifiedBy',
'lastModifiedTime',
'shareId',
'id',
]),
options: omit(v.options, ['pluginId', 'pluginInstallId']),
}))
);
for (const [key, value] of Object.entries(fieldMap)) {
assertViewsString = assertViewsString.replaceAll(key, value);
}
const assertViews = JSON.parse(assertViewsString);
expect(assertViews).toEqual(
targetViews
.filter((f) => f.type !== ViewType.Plugin)
.map((v) => ({
...omit(v, [
'createdBy',
'createdTime',
'lastModifiedBy',
'lastModifiedTime',
'shareId',
'id',
]),
options: omit(v.options, ['pluginId', 'pluginInstallId']),
}))
);
});
});
});

View File

@ -80,7 +80,7 @@ export const TableList: React.FC = () => {
fileType={fileType}
open={dialogVisible}
onOpenChange={(open) => setDialogVisible(open)}
></TableImport>
/>
)}
<div className="overflow-y-auto px-3">

View File

@ -1,4 +1,5 @@
import { useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getUniqName } from '@teable/core';
import {
MoreHorizontal,
Pencil,
@ -8,10 +9,11 @@ import {
Import,
FileCsv,
FileExcel,
Copy,
} from '@teable/icons';
import { SUPPORTEDTYPE } from '@teable/openapi';
import { duplicateTable, SUPPORTEDTYPE } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBase, useTables } from '@teable/sdk/hooks';
import { useBase, useBasePermission, useTables } from '@teable/sdk/hooks';
import type { Table } from '@teable/sdk/model';
import { ConfirmDialog } from '@teable/ui-lib/base';
import {
@ -23,6 +25,9 @@ import {
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
Switch,
Label,
Input,
} from '@teable/ui-lib/shadcn';
import Link from 'next/link';
import { useRouter } from 'next/router';
@ -44,8 +49,10 @@ export const TableOperation = (props: ITableOperationProps) => {
const { table, className, onRename, open, setOpen } = props;
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [importVisible, setImportVisible] = useState(false);
const [duplicateSetting, setDuplicateSetting] = useState(false);
const [importType, setImportType] = useState(SUPPORTEDTYPE.CSV);
const base = useBase();
const permission = useBasePermission();
const tables = useTables();
const router = useRouter();
const queryClient = useQueryClient();
@ -53,14 +60,29 @@ export const TableOperation = (props: ITableOperationProps) => {
const { t } = useTranslation(tableConfig.i18nNamespaces);
const { trigger } = useDownload({ downloadUrl: `/api/export/${table.id}`, key: 'table' });
const defaultTableName = useMemo(
() =>
getUniqName(
`${table?.name} ${t('space:baseModal.copy')}`,
tables.map((t) => t.name)
),
[t, table?.name, tables]
);
const [duplicateOption, setDuplicateOption] = useState({
name: defaultTableName,
includeRecords: true,
});
const menuPermission = useMemo(() => {
return {
deleteTable: table.permission?.['table|delete'],
updateTable: table.permission?.['table|update'],
duplicateTable: table.permission?.['table|read'] && permission?.['table|create'],
exportTable: table.permission?.['table|export'],
importTable: table.permission?.['table|import'],
};
}, [table.permission]);
}, [permission, table.permission]);
const deleteTable = async () => {
const tableId = table?.id;
@ -86,6 +108,23 @@ export const TableOperation = (props: ITableOperationProps) => {
}
};
const { mutateAsync: duplicateTableFn, isLoading } = useMutation({
mutationFn: () => duplicateTable(baseId as string, table.id, duplicateOption),
onSuccess: (data) => {
const {
data: { id },
} = data;
queryClient.invalidateQueries({
queryKey: ReactQueryKeys.tableList(baseId as string),
});
setDuplicateSetting(false);
router.push({
pathname: '/base/[baseId]/[tableId]',
query: { baseId, tableId: id },
});
},
});
if (!Object.values(menuPermission).some(Boolean)) {
return null;
}
@ -122,6 +161,12 @@ export const TableOperation = (props: ITableOperationProps) => {
{t('table:table.design')}
</Link>
</DropdownMenuItem>
{menuPermission.duplicateTable && (
<DropdownMenuItem onClick={() => setDuplicateSetting(true)}>
<Copy className="mr-2" />
{t('table:import.menu.duplicate')}
</DropdownMenuItem>
)}
{menuPermission.exportTable && (
<DropdownMenuItem
onClick={() => {
@ -195,6 +240,46 @@ export const TableOperation = (props: ITableOperationProps) => {
onCancel={() => setDeleteConfirm(false)}
onConfirm={deleteTable}
/>
<ConfirmDialog
open={duplicateSetting}
onOpenChange={setDuplicateSetting}
title={`${t('common:actions.duplicate')} ${table?.name}`}
cancelText={t('common:actions.cancel')}
confirmText={t('common:actions.duplicate')}
confirmLoading={isLoading}
content={
<div className="flex flex-col space-y-2 text-sm">
<div className="flex flex-col gap-2">
<Label>
{t('common:noun.table')} {t('common:name')}
</Label>
<Input
defaultValue={defaultTableName}
onChange={(e) => {
const value = e.target.value;
setDuplicateOption((prev) => ({ ...prev, name: value }));
}}
/>
</div>
<div className="flex items-center gap-1">
<Switch
id="include-record"
checked={duplicateOption.includeRecords}
onCheckedChange={(val) => {
setDuplicateOption((prev) => ({ ...prev, includeRecords: val }));
}}
/>
<Label htmlFor="include-record">{t('table:import.menu.includeRecords')}</Label>
</div>
</div>
}
onCancel={() => setDuplicateSetting(false)}
onConfirm={async () => {
duplicateTableFn();
}}
/>
</div>
);
};

View File

@ -359,7 +359,9 @@
"cancel": "Cancel",
"leave": "Leave",
"downAsCsv": "Download CSV",
"importData": "Import Data"
"importData": "Import Data",
"duplicate": "Duplicate table",
"includeRecords": "Include records"
},
"tips": {
"importWayTip": "Click or drag file to this area to upload",

View File

@ -358,7 +358,10 @@
"cancel": "取消",
"leave": "取消",
"downAsCsv": "下载 CSV",
"importData": "导入数据"
"importData": "导入数据",
"duplicate": "复制数据表",
"importing": "导入中",
"includeRecords": "包含记录"
},
"tips": {
"importWayTip": "点击或者拖拽到此区域上传",

View File

@ -0,0 +1,65 @@
import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
import { axios } from '../axios';
import { registerRoute, urlBuilder } from '../utils';
import { z } from '../zod';
import { tableFullVoSchema } from './create';
export const DUPLICATE_TABLE = '/base/{baseId}/table/{tableId}/duplicate';
export const duplicateTableRoSchema = z.object({
name: z.string(),
includeRecords: z.boolean(),
});
export const duplicateTableVoSchema = tableFullVoSchema
.omit({
records: true,
})
.extend({
viewMap: z.record(z.string()),
fieldMap: z.record(z.string()),
});
export type IDuplicateTableVo = z.infer<typeof duplicateTableVoSchema>;
export type IDuplicateTableRo = z.infer<typeof duplicateTableRoSchema>;
export const DuplicateTableRoute: RouteConfig = registerRoute({
method: 'post',
path: DUPLICATE_TABLE,
description: 'Duplicate a table',
summary: 'Duplicate a table',
request: {
params: z.object({
baseId: z.string(),
tableId: z.string(),
}),
body: {
content: {
'application/json': {
schema: duplicateTableRoSchema,
},
},
},
},
responses: {
200: {
description: 'Duplicate successfully',
},
},
tags: ['table'],
});
export const duplicateTable = async (
baseId: string,
tableId: string,
duplicateRo: IDuplicateTableRo
) => {
return axios.post<IDuplicateTableVo>(
urlBuilder(DUPLICATE_TABLE, {
baseId,
tableId,
}),
duplicateRo
);
};

View File

@ -14,3 +14,4 @@ export * from './toggle-table-index';
export * from './get-activated-index';
export * from './get-abnormal-index';
export * from './repair-table-index';
export * from './duplicate';