diff --git a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts index 12249d597..37d60840a 100644 --- a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts +++ b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts @@ -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, diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts new file mode 100644 index 000000000..325942f1e --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts @@ -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; +} diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts new file mode 100644 index 000000000..b2287a8ad --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts @@ -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, + ]); + } +} diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts new file mode 100644 index 000000000..d987ce4ef --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts @@ -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, + ]); + } +} diff --git a/apps/nestjs-backend/src/db-provider/postgres.provider.ts b/apps/nestjs-backend/src/db-provider/postgres.provider.ts index 143f822a6..77b03cdd8 100644 --- a/apps/nestjs-backend/src/db-provider/postgres.provider.ts +++ b/apps/nestjs-backend/src/db-provider/postgres.provider.ts @@ -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, diff --git a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts index b3d861cac..b3bc8e347 100644 --- a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts +++ b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts @@ -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, diff --git a/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts b/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts index a4f8fdaf6..efe2307d6 100644 --- a/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts +++ b/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts @@ -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); } diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts index 9367934bb..f9eb37cac 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts @@ -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 }, }); diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts index b22500ca2..dd9e1317d 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts @@ -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 { + return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo); + } + @Delete(':tableId') @Permissions('table|delete') async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) { diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts index 1afd9bdfa..8446b80dd 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts @@ -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 {} diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts index 5f51d347a..557eec6c1 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts @@ -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); } diff --git a/apps/nestjs-backend/src/features/table/table-dupicate.service.ts b/apps/nestjs-backend/src/features/table/table-dupicate.service.ts new file mode 100644 index 000000000..43a4615f4 --- /dev/null +++ b/apps/nestjs-backend/src/features/table/table-dupicate.service.ts @@ -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, + 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, + sourceToTargetFieldMap: Record + ) { + 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 = {}; + + 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 + ) { + 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 + ) { + 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 + ) { + 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, + 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 + ) { + 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, + 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 + ) { + 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; + 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 + ); + + 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, + sourceToTargetFieldMap: Record + ) { + 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 + ); + + 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, + sourceToTargetViewMap: Record, + 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)); + } +} diff --git a/apps/nestjs-backend/src/features/table/table-index.service.ts b/apps/nestjs-backend/src/features/table/table-index.service.ts index c0d7492e8..8a4498eec 100644 --- a/apps/nestjs-backend/src/features/table/table-index.service.ts +++ b/apps/nestjs-backend/src/features/table/table-index.service.ts @@ -31,7 +31,7 @@ export class TableIndexService { tableId: string, type: TableIndex = TableIndex.search ): Promise { - 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); } } diff --git a/apps/nestjs-backend/src/features/table/table.service.ts b/apps/nestjs-backend/src/features/table/table.service.ts index d97cdf096..215ef26d2 100644 --- a/apps/nestjs-backend/src/features/table/table.service.ts +++ b/apps/nestjs-backend/src/features/table/table.service.ts @@ -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 { - const tableVo = await this.createDBTable(baseId, snapshot); + async createTable( + baseId: string, + snapshot: ICreateTableRo, + createTable: boolean = true + ): Promise { + const tableVo = await this.createDBTable(baseId, snapshot, createTable); await this.batchService.saveRawOps(baseId, RawOpType.Create, IdPrefix.Table, [ { docId: tableVo.id, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts index d61dd4041..f10ae4ff2 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts @@ -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 }, }); diff --git a/apps/nestjs-backend/test/table-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/table-duplicate.e2e-spec.ts new file mode 100644 index 000000000..8eb56e085 --- /dev/null +++ b/apps/nestjs-backend/test/table-duplicate.e2e-spec.ts @@ -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']), + })) + ); + }); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/TableList.tsx b/apps/nextjs-app/src/features/app/blocks/table-list/TableList.tsx index caa8a2f49..b111d4895 100644 --- a/apps/nextjs-app/src/features/app/blocks/table-list/TableList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/table-list/TableList.tsx @@ -80,7 +80,7 @@ export const TableList: React.FC = () => { fileType={fileType} open={dialogVisible} onOpenChange={(open) => setDialogVisible(open)} - > + /> )}
diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx b/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx index 6eb6b806d..125e913d7 100644 --- a/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx +++ b/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx @@ -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')} + {menuPermission.duplicateTable && ( + setDuplicateSetting(true)}> + + {t('table:import.menu.duplicate')} + + )} {menuPermission.exportTable && ( { @@ -195,6 +240,46 @@ export const TableOperation = (props: ITableOperationProps) => { onCancel={() => setDeleteConfirm(false)} onConfirm={deleteTable} /> + + +
+ + { + const value = e.target.value; + setDuplicateOption((prev) => ({ ...prev, name: value })); + }} + /> +
+ +
+ { + setDuplicateOption((prev) => ({ ...prev, includeRecords: val })); + }} + /> + +
+
+ } + onCancel={() => setDuplicateSetting(false)} + onConfirm={async () => { + duplicateTableFn(); + }} + /> ); }; diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index 85c9487d5..53a5dbcc1 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -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", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index b4fc33ec3..9113b7404 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -358,7 +358,10 @@ "cancel": "取消", "leave": "取消", "downAsCsv": "下载 CSV", - "importData": "导入数据" + "importData": "导入数据", + "duplicate": "复制数据表", + "importing": "导入中", + "includeRecords": "包含记录" }, "tips": { "importWayTip": "点击或者拖拽到此区域上传", diff --git a/packages/openapi/src/table/duplicate.ts b/packages/openapi/src/table/duplicate.ts new file mode 100644 index 000000000..57db056de --- /dev/null +++ b/packages/openapi/src/table/duplicate.ts @@ -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; + +export type IDuplicateTableRo = z.infer; + +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( + urlBuilder(DUPLICATE_TABLE, { + baseId, + tableId, + }), + duplicateRo + ); +}; diff --git a/packages/openapi/src/table/index.ts b/packages/openapi/src/table/index.ts index eefc104bf..19f17107f 100644 --- a/packages/openapi/src/table/index.ts +++ b/packages/openapi/src/table/index.ts @@ -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';