apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts
import {
BadRequestException,
NotFoundException,
Injectable,
Logger,
ForbiddenException,
} from '@nestjs/common';
import type {
FieldAction,
IFieldRo,
IFieldVo,
ILinkFieldOptions,
ILookupOptionsVo,
IViewRo,
RecordAction,
IRole,
TableAction,
ViewAction,
} from '@teable/core';
import {
ActionPrefix,
FieldKeyType,
FieldType,
actionPrefixMap,
getBasePermission,
} from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import {
ResourceType,
type ICreateRecordsRo,
type ICreateTableRo,
type ICreateTableWithDefault,
type ITableFullVo,
type ITablePermissionVo,
type ITableVo,
type IUpdateOrderRo,
} from '@teable/openapi';
import { nanoid } from 'nanoid';
import { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config';
import { InjectDbProvider } from '../../../db-provider/db.provider';
import { IDbProvider } from '../../../db-provider/db.provider.interface';
import { updateOrder } from '../../../utils/update-order';
import { PermissionService } from '../../auth/permission.service';
import { LinkService } from '../../calculation/link.service';
import { FieldCreatingService } from '../../field/field-calculate/field-creating.service';
import { FieldSupplementService } from '../../field/field-calculate/field-supplement.service';
import { createFieldInstanceByVo } from '../../field/model/factory';
import { FieldOpenApiService } from '../../field/open-api/field-open-api.service';
import { GraphService } from '../../graph/graph.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 { TableService } from '../table.service';
@Injectable()
export class TableOpenApiService {
private logger = new Logger(TableOpenApiService.name);
constructor(
private readonly prismaService: PrismaService,
private readonly recordOpenApiService: RecordOpenApiService,
private readonly viewOpenApiService: ViewOpenApiService,
private readonly graphService: GraphService,
private readonly recordService: RecordService,
private readonly tableService: TableService,
private readonly linkService: LinkService,
private readonly fieldOpenApiService: FieldOpenApiService,
private readonly fieldCreatingService: FieldCreatingService,
private readonly fieldSupplementService: FieldSupplementService,
private readonly permissionService: PermissionService,
@InjectDbProvider() private readonly dbProvider: IDbProvider,
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
) {}
private async createView(tableId: string, viewRos: IViewRo[]) {
const viewCreationPromises = viewRos.map(async (viewRo) => {
return this.viewOpenApiService.createView(tableId, viewRo);
});
return await Promise.all(viewCreationPromises);
}
private async createField(tableId: string, fieldVos: IFieldVo[]) {
const fieldSnapshots: IFieldVo[] = [];
const fieldNameSet = new Set<string>();
for (const fieldVo of fieldVos) {
if (fieldNameSet.has(fieldVo.name)) {
throw new BadRequestException(`duplicate field name: ${fieldVo.name}`);
}
fieldNameSet.add(fieldVo.name);
const fieldInstance = createFieldInstanceByVo(fieldVo);
await this.fieldCreatingService.alterCreateField(tableId, fieldInstance);
fieldSnapshots.push(fieldVo);
}
return fieldSnapshots;
}
private async createRecords(tableId: string, data: ICreateRecordsRo) {
return this.recordOpenApiService.createRecords(tableId, data);
}
private async prepareFields(tableId: string, fieldRos: IFieldRo[]) {
const fields: IFieldVo[] = [];
const simpleFields: IFieldRo[] = [];
const computeFields: IFieldRo[] = [];
fieldRos.forEach((field) => {
if (field.type === FieldType.Link || field.type === FieldType.Formula || field.isLookup) {
computeFields.push(field);
} else {
simpleFields.push(field);
}
});
for (const fieldRo of simpleFields) {
fields.push(await this.fieldSupplementService.prepareCreateField(tableId, fieldRo));
}
const allFieldRos = simpleFields.concat(computeFields);
for (const fieldRo of computeFields) {
fields.push(
await this.fieldSupplementService.prepareCreateField(
tableId,
fieldRo,
allFieldRos.filter((ro) => ro !== fieldRo) as IFieldVo[]
)
);
}
const repeatedDbFieldNames = fields
.map((f) => f.dbFieldName)
.filter((value, index, self) => self.indexOf(value) !== index);
// generator dbFieldName may repeat, this is fix it.
return fields.map((f) => {
const newField = { ...f };
const { dbFieldName } = newField;
if (repeatedDbFieldNames.includes(dbFieldName)) {
newField.dbFieldName = `${dbFieldName}_${nanoid(3)}`;
}
return newField;
});
}
async createTable(baseId: string, tableRo: ICreateTableWithDefault): Promise<ITableFullVo> {
const schema = await this.prismaService.$tx(async () => {
const tableVo = await this.createTableMeta(baseId, tableRo);
const tableId = tableVo.id;
const preparedFields = await this.prepareFields(tableId, tableRo.fields);
// create teable should not set computed field isPending, because noting need to calculate when create
preparedFields.forEach((field) => delete field.isPending);
const fieldVos = await this.createField(tableId, preparedFields);
const viewVos = await this.createView(tableId, tableRo.views);
return {
...tableVo,
total: tableRo.records?.length || 0,
fields: fieldVos,
views: viewVos,
defaultViewId: viewVos[0].id,
};
});
const records = await this.prismaService.$tx(async () => {
const recordsVo =
tableRo.records?.length &&
(await this.createRecords(schema.id, {
records: tableRo.records,
fieldKeyType: tableRo.fieldKeyType ?? FieldKeyType.Name,
}));
return recordsVo ? recordsVo.records : [];
});
return {
...schema,
records,
};
}
async createTableMeta(baseId: string, tableRo: ICreateTableRo) {
return await this.tableService.createTable(baseId, tableRo);
}
async getTable(baseId: string, tableId: string): Promise<ITableVo> {
return await this.tableService.getTableMeta(baseId, tableId);
}
async getTables(baseId: string, includeTableIds?: string[]): Promise<ITableVo[]> {
const tablesMeta = await this.prismaService.txClient().tableMeta.findMany({
orderBy: { order: 'asc' },
where: {
baseId,
deletedTime: null,
id: includeTableIds ? { in: includeTableIds } : undefined,
},
});
const tableIds = tablesMeta.map((tableMeta) => tableMeta.id);
const tableTime = await this.tableService.getTableLastModifiedTime(tableIds);
const tableDefaultViewIds = await this.tableService.getTableDefaultViewId(tableIds);
return tablesMeta.map((tableMeta, i) => {
const time = tableTime[i];
const defaultViewId = tableDefaultViewIds[i];
if (!defaultViewId) {
throw new Error('defaultViewId is not found');
}
return {
...tableMeta,
description: tableMeta.description ?? undefined,
icon: tableMeta.icon ?? undefined,
lastModifiedTime: time || tableMeta.lastModifiedTime?.toISOString(),
defaultViewId,
};
});
}
async detachLink(tableId: string) {
// handle the link field in this table
const linkFields = await this.prismaService.txClient().field.findMany({
where: { tableId, type: FieldType.Link, isLookup: null, deletedTime: null },
select: { id: true },
});
for (const field of linkFields) {
await this.fieldOpenApiService.convertField(tableId, field.id, {
type: FieldType.SingleLineText,
});
}
// handle the link field in related tables
const relatedLinkFieldRaws = await this.linkService.getRelatedLinkFieldRaws(tableId);
for (const field of relatedLinkFieldRaws) {
await this.fieldOpenApiService.convertField(field.tableId, field.id, {
type: FieldType.SingleLineText,
});
}
}
async permanentDeleteTables(baseId: string, tableIds: string[]) {
// If the table has already been deleted, exceptions may occur
// If the table hasn't been deleted and permanent deletion is executed directly,
// we need to handle the deletion of associated data
try {
for (const tableId of tableIds) {
await this.detachLink(tableId);
}
} catch (e) {
console.log('Permanent delete tables error:', e);
}
return await this.prismaService.$tx(
async () => {
await this.dropTables(tableIds);
await this.cleanTablesRelatedData(baseId, tableIds);
},
{
timeout: this.thresholdConfig.bigTransactionTimeout,
}
);
}
async dropTables(tableIds: string[]) {
const tables = await this.prismaService.txClient().tableMeta.findMany({
where: { id: { in: tableIds } },
select: { dbTableName: true },
});
for (const table of tables) {
await this.prismaService
.txClient()
.$executeRawUnsafe(this.dbProvider.dropTable(table.dbTableName));
}
}
async cleanReferenceFieldIds(tableIds: string[]) {
const fields = await this.prismaService.txClient().field.findMany({
where: { tableId: { in: tableIds }, type: { in: [FieldType.Link, FieldType.Formula] } },
select: { id: true },
});
const fieldIds = fields.map((field) => field.id);
await this.prismaService.txClient().reference.deleteMany({
where: { OR: [{ fromFieldId: { in: fieldIds } }, { toFieldId: { in: fieldIds } }] },
});
}
async cleanTablesRelatedData(baseId: string, tableIds: string[]) {
// delete field for table
await this.prismaService.txClient().field.deleteMany({
where: { tableId: { in: tableIds } },
});
// delete view for table
await this.prismaService.txClient().view.deleteMany({
where: { tableId: { in: tableIds } },
});
// clean attachment for table
await this.prismaService.txClient().attachmentsTable.deleteMany({
where: { tableId: { in: tableIds } },
});
// clear ops for view/field/record
await this.prismaService.txClient().ops.deleteMany({
where: { collection: { in: tableIds } },
});
// clean ops for table
await this.prismaService.txClient().ops.deleteMany({
where: { collection: baseId, docId: { in: tableIds } },
});
await this.prismaService.txClient().tableMeta.deleteMany({
where: { id: { in: tableIds } },
});
// clean record history for table
await this.prismaService.txClient().recordHistory.deleteMany({
where: { tableId: { in: tableIds } },
});
// clean trash for table
await this.prismaService.txClient().trash.deleteMany({
where: { resourceId: { in: tableIds }, resourceType: ResourceType.Table },
});
}
async deleteTable(baseId: string, tableId: string) {
try {
await this.detachLink(tableId);
} catch (e) {
console.log(`Detach link error in table ${tableId}:`, e);
}
return await this.prismaService.$tx(
async (prisma) => {
const deletedTime = new Date();
await this.tableService.deleteTable(baseId, tableId, deletedTime);
await prisma.field.updateMany({
where: { tableId, deletedTime: null },
data: { deletedTime },
});
await prisma.view.updateMany({
where: { tableId, deletedTime: null },
data: { deletedTime },
});
},
{
timeout: this.thresholdConfig.bigTransactionTimeout,
}
);
}
async restoreTable(baseId: string, tableId: string) {
return await this.prismaService.$tx(
async (prisma) => {
const { deletedTime } = await prisma.trash.findFirstOrThrow({
where: { resourceId: tableId, resourceType: ResourceType.Table },
});
if (!deletedTime) {
throw new ForbiddenException(
'Unable to restore this table because it is not in the trash'
);
}
await this.tableService.restoreTable(baseId, tableId);
await prisma.field.updateMany({
where: { tableId, deletedTime },
data: { deletedTime: null },
});
await prisma.view.updateMany({
where: { tableId, deletedTime },
data: { deletedTime: null },
});
await prisma.trash.deleteMany({
where: { resourceId: tableId },
});
},
{
timeout: this.thresholdConfig.bigTransactionTimeout,
}
);
}
async sqlQuery(tableId: string, viewId: string, sql: string) {
this.logger.log('sqlQuery:sql: ' + sql);
const { queryBuilder } = await this.recordService.buildFilterSortQuery(tableId, {
viewId,
});
const baseQuery = queryBuilder.toString();
const { dbTableName } = await this.prismaService.tableMeta.findFirstOrThrow({
where: { id: tableId, deletedTime: null },
select: { dbTableName: true },
});
const combinedQuery = `
WITH base AS (${baseQuery})
${sql.replace(dbTableName, 'base')};
`;
this.logger.log('sqlQuery:sql:combine: ' + combinedQuery);
return this.prismaService.$queryRawUnsafe(combinedQuery);
}
async getGraph(tableId: string, cell: [string, string]) {
return this.graphService.getGraph(tableId, cell);
}
async updateName(baseId: string, tableId: string, name: string) {
await this.prismaService.$tx(async () => {
await this.tableService.updateTable(baseId, tableId, { name });
});
}
async updateIcon(baseId: string, tableId: string, icon: string) {
await this.prismaService.$tx(async () => {
await this.tableService.updateTable(baseId, tableId, { icon });
});
}
async updateDescription(baseId: string, tableId: string, description: string | null) {
await this.prismaService.$tx(async () => {
await this.tableService.updateTable(baseId, tableId, { description });
});
}
async updateDbTableName(baseId: string, tableId: string, dbTableNameRo: string) {
const dbTableName = this.dbProvider.joinDbTableName(baseId, dbTableNameRo);
const existDbTableName = await this.prismaService.tableMeta
.findFirst({
where: { baseId, dbTableName, deletedTime: null },
select: { id: true },
})
.catch(() => {
throw new NotFoundException(`table ${tableId} not found`);
});
if (existDbTableName) {
throw new BadRequestException(`dbTableName ${dbTableNameRo} already exists`);
}
const { dbTableName: oldDbTableName } = await this.prismaService.tableMeta
.findFirstOrThrow({
where: { id: tableId, baseId, deletedTime: null },
select: { dbTableName: true },
})
.catch(() => {
throw new NotFoundException(`table ${tableId} not found`);
});
const linkFieldsRaw = await this.prismaService.field.findMany({
where: { table: { baseId }, type: FieldType.Link },
select: { id: true, options: true },
});
const relationalFieldsRaw = await this.prismaService.field.findMany({
where: { table: { baseId }, lookupOptions: { not: null } },
select: { id: true, lookupOptions: true },
});
await this.prismaService.$tx(async (prisma) => {
await Promise.all(
linkFieldsRaw
.map((field) => ({
...field,
options: JSON.parse(field.options as string) as ILinkFieldOptions,
}))
.filter((field) => {
return field.options.fkHostTableName === oldDbTableName;
})
.map((field) => {
return prisma.field.update({
where: { id: field.id },
data: { options: JSON.stringify({ ...field.options, fkHostTableName: dbTableName }) },
});
})
);
await Promise.all(
relationalFieldsRaw
.map((field) => ({
...field,
lookupOptions: JSON.parse(field.lookupOptions as string) as ILookupOptionsVo,
}))
.filter((field) => {
return field.lookupOptions.fkHostTableName === oldDbTableName;
})
.map((field) => {
return prisma.field.update({
where: { id: field.id },
data: {
lookupOptions: JSON.stringify({
...field.lookupOptions,
fkHostTableName: dbTableName,
}),
},
});
})
);
await this.tableService.updateTable(baseId, tableId, { dbTableName });
const renameSql = this.dbProvider.renameTableName(oldDbTableName, dbTableName);
for (const sql of renameSql) {
await prisma.$executeRawUnsafe(sql);
}
});
}
async shuffle(baseId: string) {
const tables = await this.prismaService.tableMeta.findMany({
where: { baseId, deletedTime: null },
select: { id: true },
orderBy: { order: 'asc' },
});
this.logger.log(`lucky table shuffle! ${baseId}`, 'shuffle');
await this.prismaService.$tx(async () => {
for (let i = 0; i < tables.length; i++) {
const table = tables[i];
await this.tableService.updateTable(baseId, table.id, { order: i });
}
});
}
async updateOrder(baseId: string, tableId: string, orderRo: IUpdateOrderRo) {
const { anchorId, position } = orderRo;
const table = await this.prismaService.tableMeta
.findFirstOrThrow({
select: { order: true, id: true },
where: { baseId, id: tableId, deletedTime: null },
})
.catch(() => {
throw new NotFoundException(`Table ${tableId} not found`);
});
const anchorTable = await this.prismaService.tableMeta
.findFirstOrThrow({
select: { order: true, id: true },
where: { baseId, id: anchorId, deletedTime: null },
})
.catch(() => {
throw new NotFoundException(`Anchor ${anchorId} not found`);
});
await updateOrder({
query: baseId,
position,
item: table,
anchorItem: anchorTable,
getNextItem: async (whereOrder, align) => {
return this.prismaService.tableMeta.findFirst({
select: { order: true, id: true },
where: {
baseId,
deletedTime: null,
order: whereOrder,
},
orderBy: { order: align },
});
},
update: async (
parentId: string,
id: string,
data: { newOrder: number; oldOrder: number }
) => {
await this.prismaService.$tx(async () => {
await this.tableService.updateTable(parentId, id, { order: data.newOrder });
});
},
shuffle: this.shuffle.bind(this),
});
}
async getPermission(baseId: string, tableId: string): Promise<ITablePermissionVo> {
let role: IRole | null = await this.permissionService.getRoleByBaseId(baseId);
if (!role) {
const { spaceId } = await this.permissionService.getUpperIdByBaseId(baseId);
role = await this.permissionService.getRoleBySpaceId(spaceId);
}
if (!role) {
throw new NotFoundException(`Role not found`);
}
return this.getPermissionByRole(tableId, role);
}
async getPermissionByRole(tableId: string, role: IRole) {
const permissionMap = getBasePermission(role);
const tablePermission = actionPrefixMap[ActionPrefix.Table].reduce(
(acc, action) => {
acc[action] = permissionMap[action];
return acc;
},
{} as Record<TableAction, boolean>
);
const viewPermission = actionPrefixMap[ActionPrefix.View].reduce(
(acc, action) => {
acc[action] = permissionMap[action];
return acc;
},
{} as Record<ViewAction, boolean>
);
const recordPermission = actionPrefixMap[ActionPrefix.Record].reduce(
(acc, action) => {
acc[action] = permissionMap[action];
return acc;
},
{} as Record<RecordAction, boolean>
);
const fields = await this.prismaService.field.findMany({
where: {
tableId,
deletedTime: null,
},
});
const excludeFieldCreate = actionPrefixMap[ActionPrefix.Field].filter(
(action) => action !== 'field|create'
);
const fieldPermission = fields.reduce(
(acc, field) => {
acc[field.id] = excludeFieldCreate.reduce(
(acc, action) => {
acc[action] = permissionMap[action];
return acc;
},
{} as Record<FieldAction, boolean>
);
return acc;
},
{} as Record<string, Record<FieldAction, boolean>>
);
return {
table: tablePermission,
field: {
fields: fieldPermission,
create: permissionMap['field|create'],
},
record: recordPermission,
view: viewPermission,
};
}
}