mirror of
https://github.com/teableio/teable.git
synced 2026-03-23 00:04:56 +08:00
[sync] [T2308] rework v2 delete table side effects (#1421)
Synced from teableio/teable-ee@da6e555
This commit is contained in:
parent
b0ef11296d
commit
2c7efe8f72
@ -1,7 +1,18 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { IBaseNodeTreeVo, IBaseNodeVo, IDeleteBaseNodeVo } from '@teable/openapi';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
BaseNodeResourceType,
|
||||
moveBaseNodeRoSchema,
|
||||
createBaseNodeRoSchema,
|
||||
duplicateBaseNodeRoSchema,
|
||||
@ -10,7 +21,11 @@ import {
|
||||
IMoveBaseNodeRo,
|
||||
updateBaseNodeRoSchema,
|
||||
IUpdateBaseNodeRo,
|
||||
type IBaseNodeTreeVo,
|
||||
type IBaseNodeVo,
|
||||
type IDeleteBaseNodeVo,
|
||||
} from '@teable/openapi';
|
||||
import type { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';
|
||||
import { Events } from '../../event-emitter/events';
|
||||
@ -20,6 +35,11 @@ import { AllowAnonymous, AllowAnonymousType } from '../auth/decorators/allow-ano
|
||||
import { BaseNodePermissions } from '../auth/decorators/base-node-permissions.decorator';
|
||||
import { Permissions } from '../auth/decorators/permissions.decorator';
|
||||
import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
|
||||
import {
|
||||
X_TEABLE_V2_FEATURE_HEADER,
|
||||
X_TEABLE_V2_HEADER,
|
||||
X_TEABLE_V2_REASON_HEADER,
|
||||
} from '../canary/interceptors/v2-indicator.interceptor';
|
||||
import { checkBaseNodePermission } from './base-node.permission.helper';
|
||||
import { BaseNodeService } from './base-node.service';
|
||||
import { BaseNodeAction } from './types';
|
||||
@ -28,6 +48,8 @@ import { BaseNodeAction } from './types';
|
||||
@UseGuards(BaseNodePermissionGuard)
|
||||
@AllowAnonymous(AllowAnonymousType.RESOURCE)
|
||||
export class BaseNodeController {
|
||||
protected static readonly deleteTableV2Feature = 'deleteTable';
|
||||
|
||||
constructor(
|
||||
private readonly baseNodeService: BaseNodeService,
|
||||
private readonly cls: ClsService<IClsStore>
|
||||
@ -167,8 +189,11 @@ export class BaseNodeController {
|
||||
@EmitControllerEvent(Events.BASE_NODE_DELETE)
|
||||
async delete(
|
||||
@Param('baseId') baseId: string,
|
||||
@Param('nodeId') nodeId: string
|
||||
@Param('nodeId') nodeId: string,
|
||||
@Headers('x-window-id') windowId: string | undefined,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
): Promise<IDeleteBaseNodeVo> {
|
||||
await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);
|
||||
return this.baseNodeService.delete(baseId, nodeId);
|
||||
}
|
||||
|
||||
@ -178,12 +203,44 @@ export class BaseNodeController {
|
||||
@EmitControllerEvent(Events.BASE_NODE_DELETE)
|
||||
async permanentDelete(
|
||||
@Param('baseId') baseId: string,
|
||||
@Param('nodeId') nodeId: string
|
||||
@Param('nodeId') nodeId: string,
|
||||
@Headers('x-window-id') windowId: string | undefined,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
): Promise<IDeleteBaseNodeVo> {
|
||||
await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);
|
||||
const result = await this.baseNodeService.delete(baseId, nodeId, true);
|
||||
return { ...result, permanent: true };
|
||||
}
|
||||
|
||||
protected async prepareDeleteTableCanary(
|
||||
baseId: string,
|
||||
nodeId: string,
|
||||
response: Response,
|
||||
windowId?: string
|
||||
): Promise<void> {
|
||||
if (windowId) {
|
||||
this.cls.set('windowId', windowId);
|
||||
}
|
||||
|
||||
const node = await this.baseNodeService.getNode(baseId, nodeId);
|
||||
if (node.resourceType !== BaseNodeResourceType.Table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = await this.baseNodeService.getDeleteTableV2Decision(baseId, nodeId);
|
||||
if (!decision) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cls.set('useV2', decision.useV2);
|
||||
this.cls.set('v2Feature', BaseNodeController.deleteTableV2Feature);
|
||||
this.cls.set('v2Reason', decision.reason);
|
||||
|
||||
response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');
|
||||
response.setHeader(X_TEABLE_V2_FEATURE_HEADER, BaseNodeController.deleteTableV2Feature);
|
||||
response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);
|
||||
}
|
||||
|
||||
protected async getPermissionContext(_baseId: string) {
|
||||
const permissions = this.cls.get('permissions');
|
||||
const permissionSet = new Set(permissions);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ShareDbModule } from '../../share-db/share-db.module';
|
||||
import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
|
||||
import { CanaryModule } from '../canary/canary.module';
|
||||
import { DashboardModule } from '../dashboard/dashboard.module';
|
||||
import { FieldDuplicateModule } from '../field/field-duplicate/field-duplicate.module';
|
||||
import { FieldOpenApiModule } from '../field/open-api/field-open-api.module';
|
||||
@ -15,6 +16,7 @@ import { BaseNodeFolderModule } from './folder/base-node-folder.module';
|
||||
imports: [
|
||||
BaseNodeFolderModule,
|
||||
ShareDbModule,
|
||||
CanaryModule,
|
||||
DashboardModule,
|
||||
TableOpenApiModule,
|
||||
TableModule,
|
||||
|
||||
@ -38,7 +38,10 @@ import type { IPerformanceCacheStore } from '../../performance-cache/types';
|
||||
import { ShareDbService } from '../../share-db/share-db.service';
|
||||
import type { IClsStore } from '../../types/cls';
|
||||
import { updateOrder } from '../../utils/update-order';
|
||||
import type { IV2Decision } from '../canary/canary.service';
|
||||
import { CanaryService } from '../canary/canary.service';
|
||||
import { DashboardService } from '../dashboard/dashboard.service';
|
||||
import { TableOpenApiV2Service } from '../table/open-api/table-open-api-v2.service';
|
||||
import { TableOpenApiService } from '../table/open-api/table-open-api.service';
|
||||
import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
|
||||
import { TableDuplicateService } from '../table/table-duplicate.service';
|
||||
@ -67,7 +70,9 @@ export class BaseNodeService {
|
||||
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
|
||||
private readonly cls: ClsService<IClsStore & { ignoreBaseNodeListener?: boolean }>,
|
||||
private readonly baseNodeFolderService: BaseNodeFolderService,
|
||||
private readonly canaryService: CanaryService,
|
||||
private readonly tableOpenApiService: TableOpenApiService,
|
||||
private readonly tableOpenApiV2Service: TableOpenApiV2Service,
|
||||
private readonly tableDuplicateService: TableDuplicateService,
|
||||
private readonly dashboardService: DashboardService
|
||||
) {}
|
||||
@ -119,6 +124,28 @@ export class BaseNodeService {
|
||||
};
|
||||
}
|
||||
|
||||
async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise<IV2Decision | undefined> {
|
||||
const node = await this.prismaService.baseNode.findFirst({
|
||||
where: { baseId, id: nodeId },
|
||||
select: { resourceType: true },
|
||||
});
|
||||
|
||||
if (node?.resourceType !== BaseNodeResourceType.Table) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = await this.prismaService.txClient().base.findUnique({
|
||||
where: { id: baseId, deletedTime: null },
|
||||
select: { spaceId: true },
|
||||
});
|
||||
|
||||
if (!base?.spaceId) {
|
||||
return { useV2: false, reason: 'disabled' };
|
||||
}
|
||||
|
||||
return this.canaryService.shouldUseV2WithReason(base.spaceId, 'deleteTable');
|
||||
}
|
||||
|
||||
private generateDefaultUrl(
|
||||
baseId: string,
|
||||
resourceType: BaseNodeResourceType,
|
||||
@ -750,6 +777,14 @@ export class BaseNodeService {
|
||||
await this.baseNodeFolderService.deleteFolder(baseId, id);
|
||||
break;
|
||||
case BaseNodeResourceType.Table:
|
||||
if (this.cls.get('useV2')) {
|
||||
await this.tableOpenApiV2Service.deleteTable(
|
||||
baseId,
|
||||
id,
|
||||
permanent ? 'permanent' : undefined
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (permanent) {
|
||||
await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
|
||||
} else {
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { executeDeleteTableEndpoint } from '@teable/v2-contract-http-implementation/handlers';
|
||||
import { v2CoreTokens } from '@teable/v2-core';
|
||||
import type { ICommandBus } from '@teable/v2-core';
|
||||
import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';
|
||||
import { V2ContainerService } from '../../v2/v2-container.service';
|
||||
import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';
|
||||
|
||||
const internalServerError = 'Internal server error';
|
||||
|
||||
@Injectable()
|
||||
export class TableOpenApiV2Service {
|
||||
constructor(
|
||||
private readonly v2ContainerService: V2ContainerService,
|
||||
private readonly v2ContextFactory: V2ExecutionContextFactory
|
||||
) {}
|
||||
|
||||
private throwV2Error(
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
details?: Readonly<Record<string, unknown>>;
|
||||
},
|
||||
status: number
|
||||
): never {
|
||||
throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {
|
||||
domainCode: error.code,
|
||||
domainTags: error.tags,
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTable(
|
||||
baseId: string,
|
||||
tableId: string,
|
||||
mode: 'soft' | 'permanent' = 'soft'
|
||||
): Promise<void> {
|
||||
const container = await this.v2ContainerService.getContainer();
|
||||
const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);
|
||||
const context = await this.v2ContextFactory.createContext();
|
||||
|
||||
const result = await executeDeleteTableEndpoint(
|
||||
context,
|
||||
{
|
||||
baseId,
|
||||
tableId,
|
||||
mode,
|
||||
},
|
||||
commandBus
|
||||
);
|
||||
|
||||
if (result.status === 200 && result.body.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.body.ok) {
|
||||
this.throwV2Error(result.body.error, result.status);
|
||||
}
|
||||
|
||||
throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,17 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
IDuplicateTableVo,
|
||||
IGetAbnormalVo,
|
||||
@ -26,15 +38,23 @@ import {
|
||||
duplicateTableRoSchema,
|
||||
IDuplicateTableRo,
|
||||
} from '@teable/openapi';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { IClsStore } from '../../../types/cls';
|
||||
import { ZodValidationPipe } from '../../../zod.validation.pipe';
|
||||
import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';
|
||||
import { Permissions } from '../../auth/decorators/permissions.decorator';
|
||||
import { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';
|
||||
import { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';
|
||||
import { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';
|
||||
import { TableIndexService } from '../table-index.service';
|
||||
import { TablePermissionService } from '../table-permission.service';
|
||||
import { TableService } from '../table.service';
|
||||
import { TableOpenApiV2Service } from './table-open-api-v2.service';
|
||||
import { TableOpenApiService } from './table-open-api.service';
|
||||
import { TablePipe } from './table.pipe';
|
||||
|
||||
@UseGuards(V2FeatureGuard)
|
||||
@UseInterceptors(V2IndicatorInterceptor)
|
||||
@Controller('api/base/:baseId/table')
|
||||
@AllowAnonymous()
|
||||
export class TableController {
|
||||
@ -42,7 +62,9 @@ export class TableController {
|
||||
private readonly tableService: TableService,
|
||||
private readonly tableOpenApiService: TableOpenApiService,
|
||||
private readonly tableIndexService: TableIndexService,
|
||||
private readonly tablePermissionService: TablePermissionService
|
||||
private readonly tablePermissionService: TablePermissionService,
|
||||
private readonly tableOpenApiV2Service: TableOpenApiV2Service,
|
||||
private readonly cls: ClsService<IClsStore>
|
||||
) {}
|
||||
|
||||
@Permissions('table|read')
|
||||
@ -145,15 +167,25 @@ export class TableController {
|
||||
return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo);
|
||||
}
|
||||
|
||||
@UseV2Feature('deleteTable')
|
||||
@Delete(':tableId')
|
||||
@Permissions('table|delete')
|
||||
async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
|
||||
if (this.cls.get('useV2')) {
|
||||
await this.tableOpenApiV2Service.deleteTable(baseId, tableId);
|
||||
return;
|
||||
}
|
||||
return await this.tableOpenApiService.deleteTable(baseId, tableId);
|
||||
}
|
||||
|
||||
@UseV2Feature('deleteTable')
|
||||
@Delete(':tableId/permanent')
|
||||
@Permissions('table|delete')
|
||||
permanentDeleteTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
|
||||
async permanentDeleteTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
|
||||
if (this.cls.get('useV2')) {
|
||||
await this.tableOpenApiV2Service.deleteTable(baseId, tableId, 'permanent');
|
||||
return;
|
||||
}
|
||||
return this.tableOpenApiService.permanentDeleteTables(baseId, [tableId]);
|
||||
}
|
||||
|
||||
|
||||
@ -2,16 +2,19 @@ import { Module } from '@nestjs/common';
|
||||
import { DbProvider } from '../../../db-provider/db.provider';
|
||||
import { ShareDbModule } from '../../../share-db/share-db.module';
|
||||
import { CalculationModule } from '../../calculation/calculation.module';
|
||||
import { CanaryModule } from '../../canary/canary.module';
|
||||
import { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module';
|
||||
import { FieldDuplicateModule } from '../../field/field-duplicate/field-duplicate.module';
|
||||
import { FieldOpenApiModule } from '../../field/open-api/field-open-api.module';
|
||||
import { GraphModule } from '../../graph/graph.module';
|
||||
import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';
|
||||
import { RecordModule } from '../../record/record.module';
|
||||
import { V2Module } from '../../v2/v2.module';
|
||||
import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';
|
||||
import { TableDuplicateService } from '../table-duplicate.service';
|
||||
import { TableIndexService } from '../table-index.service';
|
||||
import { TableModule } from '../table.module';
|
||||
import { TableOpenApiV2Service } from './table-open-api-v2.service';
|
||||
import { TableController } from './table-open-api.controller';
|
||||
import { TableOpenApiService } from './table-open-api.service';
|
||||
|
||||
@ -27,9 +30,17 @@ import { TableOpenApiService } from './table-open-api.service';
|
||||
ShareDbModule,
|
||||
CalculationModule,
|
||||
GraphModule,
|
||||
V2Module,
|
||||
CanaryModule,
|
||||
],
|
||||
controllers: [TableController],
|
||||
providers: [DbProvider, TableOpenApiService, TableIndexService, TableDuplicateService],
|
||||
exports: [TableOpenApiService, TableDuplicateService],
|
||||
providers: [
|
||||
DbProvider,
|
||||
TableOpenApiService,
|
||||
TableOpenApiV2Service,
|
||||
TableIndexService,
|
||||
TableDuplicateService,
|
||||
],
|
||||
exports: [TableOpenApiService, TableOpenApiV2Service, TableDuplicateService],
|
||||
})
|
||||
export class TableOpenApiModule {}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useV2Feature = () => () => undefined;
|
||||
|
||||
vi.mock('../table.service', () => ({
|
||||
TableService: class TableService {},
|
||||
}));
|
||||
|
||||
vi.mock('./table-open-api.service', () => ({
|
||||
TableOpenApiService: class TableOpenApiService {},
|
||||
}));
|
||||
|
||||
vi.mock('../table-index.service', () => ({
|
||||
TableIndexService: class TableIndexService {},
|
||||
}));
|
||||
|
||||
vi.mock('../table-permission.service', () => ({
|
||||
TablePermissionService: class TablePermissionService {},
|
||||
}));
|
||||
|
||||
vi.mock('./table-open-api-v2.service', () => ({
|
||||
TableOpenApiV2Service: class TableOpenApiV2Service {},
|
||||
}));
|
||||
|
||||
vi.mock('../../canary/decorators/use-v2-feature.decorator', () => ({
|
||||
UseV2Feature: useV2Feature,
|
||||
}));
|
||||
|
||||
vi.mock('../../canary/guards/v2-feature.guard', () => ({
|
||||
V2FeatureGuard: class V2FeatureGuard {},
|
||||
}));
|
||||
|
||||
vi.mock('../../canary/interceptors/v2-indicator.interceptor', () => ({
|
||||
V2IndicatorInterceptor: class V2IndicatorInterceptor {},
|
||||
}));
|
||||
|
||||
vi.mock('@teable/db-main-prisma', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
let tableControllerClass: new (...args: unknown[]) => {
|
||||
archiveTable: (baseId: string, tableId: string) => Promise<unknown>;
|
||||
permanentDeleteTable: (baseId: string, tableId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
describe('TableController.archiveTable', () => {
|
||||
beforeAll(async () => {
|
||||
const module = await import('./table-open-api.controller');
|
||||
tableControllerClass = module.TableController as typeof tableControllerClass;
|
||||
});
|
||||
|
||||
const createController = (useV2: boolean) => {
|
||||
const tableOpenApiService = {
|
||||
deleteTable: vi.fn(),
|
||||
permanentDeleteTables: vi.fn(),
|
||||
};
|
||||
const tableOpenApiV2Service = {
|
||||
deleteTable: vi.fn(),
|
||||
};
|
||||
const cls = {
|
||||
get: vi.fn((key: string) => (key === 'useV2' ? useV2 : undefined)),
|
||||
};
|
||||
|
||||
const controller = new tableControllerClass(
|
||||
{} as never,
|
||||
tableOpenApiService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
tableOpenApiV2Service as never,
|
||||
cls as never
|
||||
);
|
||||
|
||||
return {
|
||||
controller,
|
||||
tableOpenApiService,
|
||||
tableOpenApiV2Service,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('routes delete-table through v2 when useV2 is enabled', async () => {
|
||||
const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);
|
||||
|
||||
await controller.archiveTable('bse1', 'tbl1');
|
||||
|
||||
expect(tableOpenApiV2Service.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1');
|
||||
expect(tableOpenApiService.deleteTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the legacy delete-table path when useV2 is disabled', async () => {
|
||||
const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(false);
|
||||
|
||||
await controller.archiveTable('bse1', 'tbl1');
|
||||
|
||||
expect(tableOpenApiService.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1');
|
||||
expect(tableOpenApiV2Service.deleteTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes permanent delete through v2 when useV2 is enabled', async () => {
|
||||
const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);
|
||||
|
||||
await controller.permanentDeleteTable('bse1', 'tbl1');
|
||||
|
||||
expect(tableOpenApiV2Service.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1', 'permanent');
|
||||
expect(tableOpenApiService.permanentDeleteTables).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,63 @@
|
||||
import { ResourceType } from '@teable/openapi';
|
||||
import { ActorId, BaseId, TableId, TableName, TableTrashed } from '@teable/v2-core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@teable/db-main-prisma', () => ({
|
||||
PrismaModule: class PrismaModule {},
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import { V2TableTrashedProjection } from './v2-table-trash.service';
|
||||
|
||||
describe('V2TableTrashedProjection', () => {
|
||||
it('writes a table trash entry for soft-deleted tables', async () => {
|
||||
const deletedTime = new Date('2026-03-12T00:00:00.000Z');
|
||||
const prisma = {
|
||||
tableMeta: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
baseId: 'bseaaaaaaaaaaaaaaaa',
|
||||
deletedTime,
|
||||
}),
|
||||
},
|
||||
trash: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const projection = new V2TableTrashedProjection(prisma as never);
|
||||
const context = {
|
||||
actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(),
|
||||
};
|
||||
const event = TableTrashed.create({
|
||||
tableId: TableId.create('tblaaaaaaaaaaaaaaaa')._unsafeUnwrap(),
|
||||
baseId: BaseId.create('bseaaaaaaaaaaaaaaaa')._unsafeUnwrap(),
|
||||
tableName: TableName.create('Trash Me')._unsafeUnwrap(),
|
||||
fieldIds: [],
|
||||
viewIds: [],
|
||||
});
|
||||
|
||||
const result = await projection.handle(context, event);
|
||||
|
||||
expect(result._unsafeUnwrap()).toBeUndefined();
|
||||
expect(prisma.tableMeta.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'tblaaaaaaaaaaaaaaaa' },
|
||||
select: { baseId: true, deletedTime: true },
|
||||
});
|
||||
expect(prisma.trash.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
resourceId: 'tblaaaaaaaaaaaaaaaa',
|
||||
resourceType: ResourceType.Table,
|
||||
},
|
||||
});
|
||||
expect(prisma.trash.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
resourceId: 'tblaaaaaaaaaaaaaaaa',
|
||||
resourceType: ResourceType.Table,
|
||||
parentId: 'bseaaaaaaaaaaaaaaaa',
|
||||
deletedTime,
|
||||
deletedBy: 'usrTestUserId',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -2,9 +2,12 @@ import type { OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { IRecord } from '@teable/core';
|
||||
import { generateOperationId } from '@teable/core';
|
||||
import { PrismaService } from '@teable/db-main-prisma';
|
||||
import { ResourceType } from '@teable/openapi';
|
||||
import {
|
||||
ProjectionHandler,
|
||||
RecordsDeleted,
|
||||
TableTrashed,
|
||||
TableQueryService,
|
||||
ok,
|
||||
v2CoreTokens,
|
||||
@ -22,7 +25,7 @@ import { TableTrashListener } from './listener/table-trash.listener';
|
||||
import { resolveV2TrashRecordDisplayName } from './v2-trash-record-name';
|
||||
|
||||
@ProjectionHandler(RecordsDeleted)
|
||||
class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDeleted> {
|
||||
export class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDeleted> {
|
||||
constructor(
|
||||
private readonly tableTrashListener: TableTrashListener,
|
||||
private readonly tableQueryService: TableQueryService
|
||||
@ -77,7 +80,7 @@ class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDelet
|
||||
}
|
||||
|
||||
@ProjectionHandler(RecordsDeleted)
|
||||
class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDeleted> {
|
||||
export class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDeleted> {
|
||||
constructor(private readonly attachmentsTableService: AttachmentsTableService) {}
|
||||
|
||||
async handle(
|
||||
@ -97,6 +100,44 @@ class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDelet
|
||||
}
|
||||
}
|
||||
|
||||
@ProjectionHandler(TableTrashed)
|
||||
export class V2TableTrashedProjection implements IEventHandler<TableTrashed> {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async handle(
|
||||
context: IExecutionContext,
|
||||
event: TableTrashed
|
||||
): Promise<Result<void, DomainError>> {
|
||||
const table = await this.prisma.tableMeta.findUnique({
|
||||
where: { id: event.tableId.toString() },
|
||||
select: { baseId: true, deletedTime: true },
|
||||
});
|
||||
|
||||
if (!table?.deletedTime) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
await this.prisma.trash.deleteMany({
|
||||
where: {
|
||||
resourceId: event.tableId.toString(),
|
||||
resourceType: ResourceType.Table,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.trash.create({
|
||||
data: {
|
||||
resourceId: event.tableId.toString(),
|
||||
resourceType: ResourceType.Table,
|
||||
parentId: table.baseId,
|
||||
deletedTime: table.deletedTime,
|
||||
deletedBy: context.actorId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit {
|
||||
private readonly logger = new Logger(V2TableTrashService.name);
|
||||
@ -104,7 +145,8 @@ export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit
|
||||
constructor(
|
||||
private readonly v2ContainerService: V2ContainerService,
|
||||
private readonly tableTrashListener: TableTrashListener,
|
||||
private readonly attachmentsTableService: AttachmentsTableService
|
||||
private readonly attachmentsTableService: AttachmentsTableService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@ -112,7 +154,7 @@ export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit
|
||||
}
|
||||
|
||||
registerProjections(container: DependencyContainer): void {
|
||||
this.logger.log('Registering V2 record delete projections');
|
||||
this.logger.log('Registering V2 trash projections');
|
||||
|
||||
const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);
|
||||
|
||||
@ -125,5 +167,6 @@ export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit
|
||||
V2RecordsDeletedAttachmentProjection,
|
||||
new V2RecordsDeletedAttachmentProjection(this.attachmentsTableService)
|
||||
);
|
||||
container.registerInstance(V2TableTrashedProjection, new V2TableTrashedProjection(this.prisma));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import type { INestApplication } from '@nestjs/common';
|
||||
import { FieldType, Role, ViewType } from '@teable/core';
|
||||
import type { IBaseNodeTableResourceMeta, IBaseNodeVo } from '@teable/openapi';
|
||||
import {
|
||||
axios,
|
||||
createBaseNode,
|
||||
getBaseNodeTree,
|
||||
getBaseNode,
|
||||
@ -37,6 +38,8 @@ const originalName = 'Original Name';
|
||||
const testFolder = 'Test Folder';
|
||||
const updatedName = 'Updated Name';
|
||||
const testTableName = 'Test Table';
|
||||
const windowIdHeader = 'x-window-id';
|
||||
const isForceV2 = process.env.FORCE_V2_ALL === 'true';
|
||||
|
||||
describe('BaseNodeController (e2e) /api/base/:baseId/node', () => {
|
||||
let app: INestApplication;
|
||||
@ -399,6 +402,32 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => {
|
||||
const error = await getError(() => deleteBaseNode(baseId, folder.id));
|
||||
expect(error?.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('should expose delete-table canary headers when deleting a table node', async () => {
|
||||
const table = await createBaseNode(baseId, {
|
||||
resourceType: BaseNodeResourceType.Table,
|
||||
name: 'Delete Via Node Route',
|
||||
fields: [{ name: 'Name', type: FieldType.SingleLineText }],
|
||||
views: [{ name: 'Grid view', type: ViewType.Grid }],
|
||||
});
|
||||
|
||||
const response = await axios.delete(
|
||||
urlBuilder(DELETE_BASE_NODE, { baseId, nodeId: table.data.id }),
|
||||
{
|
||||
headers: {
|
||||
[windowIdHeader]: 'win-base-node-delete-table',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false');
|
||||
expect(response.headers['x-teable-v2-feature']).toBe('deleteTable');
|
||||
expect(response.headers['x-teable-v2-reason']).toBeTruthy();
|
||||
|
||||
const error = await getError(() => getBaseNode(baseId, table.data.id));
|
||||
expect(error?.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/base/:baseId/node/:nodeId/move - Move node', () => {
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
updateTableName,
|
||||
deleteTable as apiDeleteTable,
|
||||
} from '@teable/openapi';
|
||||
import { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres';
|
||||
import type { ComputedUpdateWorker } from '@teable/v2-adapter-table-repository-postgres';
|
||||
import { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';
|
||||
import type { IDbProvider } from '../src/db-provider/db.provider.interface';
|
||||
import { Events } from '../src/event-emitter/events';
|
||||
@ -19,6 +21,7 @@ import type {
|
||||
ViewCreateEvent,
|
||||
RecordCreateEvent,
|
||||
} from '../src/event-emitter/events';
|
||||
import { V2ContainerService } from '../src/features/v2/v2-container.service';
|
||||
import {
|
||||
createField,
|
||||
createRecords,
|
||||
@ -31,6 +34,9 @@ import {
|
||||
updateRecord,
|
||||
} from './utils/init-app';
|
||||
|
||||
const isForceV2 = process.env.FORCE_V2_ALL === 'true';
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const assertData: ICreateTableRo = {
|
||||
name: 'Project Management',
|
||||
description: 'A table for managing projects',
|
||||
@ -120,6 +126,7 @@ describe('OpenAPI TableController (e2e)', () => {
|
||||
let tableId = '';
|
||||
let dbProvider: IDbProvider;
|
||||
let event: EventEmitter2;
|
||||
let v2ContainerService: V2ContainerService;
|
||||
|
||||
const baseId = globalThis.testConfig.baseId;
|
||||
beforeAll(async () => {
|
||||
@ -127,6 +134,7 @@ describe('OpenAPI TableController (e2e)', () => {
|
||||
app = appCtx.app;
|
||||
dbProvider = app.get(DB_PROVIDER_SYMBOL);
|
||||
event = app.get(EventEmitter2);
|
||||
v2ContainerService = app.get(V2ContainerService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@ -137,6 +145,80 @@ describe('OpenAPI TableController (e2e)', () => {
|
||||
await permanentDeleteTable(baseId, tableId);
|
||||
});
|
||||
|
||||
async function processV2Outbox(times = 1): Promise<void> {
|
||||
if (!isForceV2) return;
|
||||
|
||||
const container = await v2ContainerService.getContainer();
|
||||
const worker = container.resolve<ComputedUpdateWorker>(
|
||||
v2RecordRepositoryPostgresTokens.computedUpdateWorker
|
||||
);
|
||||
|
||||
for (let i = 0; i < times; i++) {
|
||||
const maxIterations = 100;
|
||||
let iterations = 0;
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
const result = await worker.runOnce({
|
||||
workerId: 'table-delete-test-worker',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error(`Outbox processing failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.value === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
iterations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDeleteTableCleanup(
|
||||
targetTableId: string,
|
||||
options: {
|
||||
twoWayLinkFieldId: string;
|
||||
oneWayLinkFieldId: string;
|
||||
lookupFieldId: string;
|
||||
rollupFieldId: string;
|
||||
}
|
||||
) {
|
||||
const maxRetries = isForceV2 ? 40 : 1;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (isForceV2) {
|
||||
await processV2Outbox();
|
||||
}
|
||||
|
||||
const fields = await getFields(targetTableId);
|
||||
const { records } = await getRecords(targetTableId, { fieldKeyType: FieldKeyType.Id });
|
||||
const twoWayLinkField = fields.find((field) => field.id === options.twoWayLinkFieldId);
|
||||
const oneWayLinkField = fields.find((field) => field.id === options.oneWayLinkFieldId);
|
||||
const lookupField = fields.find((field) => field.id === options.lookupFieldId);
|
||||
const rollupField = fields.find((field) => field.id === options.rollupFieldId);
|
||||
|
||||
const deleteSettled =
|
||||
twoWayLinkField?.type === FieldType.SingleLineText &&
|
||||
oneWayLinkField?.type === FieldType.SingleLineText &&
|
||||
records[0]?.fields[options.twoWayLinkFieldId] === 'A' &&
|
||||
records[0]?.fields[options.oneWayLinkFieldId] === 'A' &&
|
||||
Boolean(lookupField?.hasError) &&
|
||||
Boolean(rollupField?.hasError);
|
||||
|
||||
if (deleteSettled) {
|
||||
return { fields, records };
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
const fields = await getFields(targetTableId);
|
||||
const { records } = await getRecords(targetTableId, { fieldKeyType: FieldKeyType.Id });
|
||||
return { fields, records };
|
||||
}
|
||||
|
||||
it('/api/table/ (POST) with assertData data', async () => {
|
||||
let eventCount = 0;
|
||||
event.once(Events.TABLE_CREATE, async (payload: TableCreateEvent) => {
|
||||
@ -349,8 +431,10 @@ describe('OpenAPI TableController (e2e)', () => {
|
||||
},
|
||||
};
|
||||
|
||||
await createField(table2.id, lookupFieldRo);
|
||||
await createField(table2.id, rollupFieldRo);
|
||||
const lookupField = await createField(table2.id, lookupFieldRo);
|
||||
const rollupField = await createField(table2.id, rollupFieldRo);
|
||||
const lookupFieldId = lookupField.id;
|
||||
const rollupFieldId = rollupField.id;
|
||||
|
||||
await updateRecord(table2.id, table2.records[0].id, {
|
||||
record: {
|
||||
@ -364,12 +448,30 @@ describe('OpenAPI TableController (e2e)', () => {
|
||||
|
||||
await apiDeleteTable(baseId, table1.id);
|
||||
|
||||
const fields = await getFields(table2.id);
|
||||
const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });
|
||||
const { fields, records } = await waitForDeleteTableCleanup(table2.id, {
|
||||
twoWayLinkFieldId: twoWayLink.id,
|
||||
oneWayLinkFieldId: oneWayLink.id,
|
||||
lookupFieldId,
|
||||
rollupFieldId,
|
||||
});
|
||||
const twoWayLinkField = fields.find((field) => field.id === twoWayLink.id);
|
||||
const oneWayLinkField = fields.find((field) => field.id === oneWayLink.id);
|
||||
const refreshedLookupField = fields.find((field) => field.id === lookupFieldId);
|
||||
const refreshedRollupField = fields.find((field) => field.id === rollupFieldId);
|
||||
|
||||
expect(fields[1].type).toEqual(FieldType.SingleLineText);
|
||||
expect(records[0].fields[fields[1].id]).toEqual('A');
|
||||
expect(fields[2].hasError).toBeTruthy();
|
||||
expect(fields[3].hasError).toBeTruthy();
|
||||
if (!isForceV2) {
|
||||
expect(fields[1].type).toEqual(FieldType.SingleLineText);
|
||||
expect(records[0].fields[fields[1].id]).toEqual('A');
|
||||
expect(fields[2].hasError).toBeTruthy();
|
||||
expect(fields[3].hasError).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(twoWayLinkField?.type).toEqual(FieldType.SingleLineText);
|
||||
expect(oneWayLinkField?.type).toEqual(FieldType.SingleLineText);
|
||||
expect(records[0].fields[twoWayLink.id]).toEqual('A');
|
||||
expect(records[0].fields[oneWayLink.id]).toEqual('A');
|
||||
expect(refreshedLookupField?.hasError).toBeTruthy();
|
||||
expect(refreshedRollupField?.hasError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,6 +25,21 @@ import {
|
||||
createField,
|
||||
} from './utils/init-app';
|
||||
|
||||
const isForceV2 = process.env.FORCE_V2_ALL === 'true';
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const waitForBaseTrashItems = async (baseId: string, expectedCount = 1, maxRetries = 100) => {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const result = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
|
||||
if (result.data.trashItems.length >= expectedCount) {
|
||||
return result;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
return await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
|
||||
};
|
||||
|
||||
describe('Trash (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let eventEmitterService: EventEmitterService;
|
||||
@ -32,6 +47,8 @@ describe('Trash (e2e)', () => {
|
||||
let awaitWithSpaceEvent: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
let awaitWithBaseEvent: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
let awaitWithTableEvent: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
const awaitWithTableDeleteSync = async <T>(fn: () => Promise<T>) =>
|
||||
isForceV2 ? await fn() : awaitWithTableEvent(fn);
|
||||
|
||||
beforeAll(async () => {
|
||||
const appCtx = await initApp();
|
||||
@ -83,9 +100,9 @@ describe('Trash (e2e)', () => {
|
||||
|
||||
it('should retrieve trash items for base when a table is deleted', async () => {
|
||||
const tableId = (await createTable(baseId, {})).id;
|
||||
await awaitWithTableEvent(() => deleteTable(baseId, tableId));
|
||||
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));
|
||||
|
||||
const res = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
|
||||
const res = await waitForBaseTrashItems(baseId, 1);
|
||||
|
||||
expect(res.data.trashItems.length).toBe(1);
|
||||
expect((res.data.trashItems[0] as ITrashItemVo).resourceId).toBe(tableId);
|
||||
@ -103,9 +120,9 @@ describe('Trash (e2e)', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await awaitWithTableEvent(() => deleteTable(baseId, foreignTableId));
|
||||
await awaitWithTableDeleteSync(() => deleteTable(baseId, foreignTableId));
|
||||
|
||||
const res = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
|
||||
const res = await waitForBaseTrashItems(baseId, 1);
|
||||
|
||||
expect(res.data.trashItems.length).toBe(1);
|
||||
expect((res.data.trashItems[0] as ITrashItemVo).resourceId).toBe(foreignTableId);
|
||||
@ -150,10 +167,9 @@ describe('Trash (e2e)', () => {
|
||||
});
|
||||
|
||||
it('should restore table successfully', async () => {
|
||||
await awaitWithTableEvent(() => deleteTable(baseId, tableId));
|
||||
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));
|
||||
|
||||
const trash = (await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }))
|
||||
.data;
|
||||
const trash = (await waitForBaseTrashItems(baseId, 1)).data;
|
||||
const restored = await restoreTrash(trash.trashItems[0].id);
|
||||
|
||||
expect(restored.status).toEqual(201);
|
||||
@ -182,12 +198,11 @@ describe('Trash (e2e)', () => {
|
||||
const tableId2 = (await createTable(baseId, {})).id;
|
||||
const tableId3 = (await createTable(baseId, {})).id;
|
||||
|
||||
await awaitWithTableEvent(() => deleteTable(baseId, tableId1));
|
||||
await awaitWithTableEvent(() => deleteTable(baseId, tableId2));
|
||||
await awaitWithTableEvent(() => deleteTable(baseId, tableId3));
|
||||
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId1));
|
||||
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId2));
|
||||
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId3));
|
||||
|
||||
const trash = (await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }))
|
||||
.data;
|
||||
const trash = (await waitForBaseTrashItems(baseId, 3)).data;
|
||||
|
||||
expect(trash.trashItems.length).toEqual(3);
|
||||
|
||||
|
||||
@ -656,6 +656,7 @@ export const v2FeatureSchema = z.enum([
|
||||
'importRecords',
|
||||
'createField',
|
||||
'deleteField',
|
||||
'deleteTable',
|
||||
'duplicateField',
|
||||
'updateField',
|
||||
'convertField',
|
||||
|
||||
@ -556,6 +556,124 @@ describe('PostgresTableRepository (pg)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('finds host tables by incoming references across bases', async () => {
|
||||
const c = container.createChildContainer();
|
||||
const db = await createPgDb(pgContainer.getConnectionUri());
|
||||
await registerV2PostgresStateAdapter(c, {
|
||||
db,
|
||||
ensureSchema: true,
|
||||
});
|
||||
const repo = c.resolve<ITableRepository>(v2CoreTokens.tableRepository);
|
||||
|
||||
try {
|
||||
const foreignBaseId = BaseId.generate()._unsafeUnwrap();
|
||||
const hostBaseId = BaseId.generate()._unsafeUnwrap();
|
||||
const actorId = ActorId.create('system')._unsafeUnwrap();
|
||||
const context = { actorId };
|
||||
const spaceId = `spc${getRandomString(16)}`;
|
||||
|
||||
await db
|
||||
.insertInto('space')
|
||||
.values({ id: spaceId, name: 'Reference Space', created_by: actorId.toString() })
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto('base')
|
||||
.values([
|
||||
{
|
||||
id: foreignBaseId.toString(),
|
||||
space_id: spaceId,
|
||||
name: 'Foreign Base',
|
||||
order: 1,
|
||||
created_by: actorId.toString(),
|
||||
},
|
||||
{
|
||||
id: hostBaseId.toString(),
|
||||
space_id: spaceId,
|
||||
name: 'Host Base',
|
||||
order: 2,
|
||||
created_by: actorId.toString(),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const foreignBuilder = Table.builder()
|
||||
.withBaseId(foreignBaseId)
|
||||
.withName(TableName.create('Foreign')._unsafeUnwrap());
|
||||
foreignBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(FieldName.create('Title')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
foreignBuilder.view().defaultGrid().done();
|
||||
const foreignTable = foreignBuilder.build()._unsafeUnwrap();
|
||||
(await repo.insert(context, foreignTable))._unsafeUnwrap();
|
||||
|
||||
const linkFieldId = FieldId.generate()._unsafeUnwrap();
|
||||
const hostBuilder = Table.builder()
|
||||
.withBaseId(hostBaseId)
|
||||
.withName(TableName.create('Host')._unsafeUnwrap());
|
||||
hostBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(FieldName.create('Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
hostBuilder
|
||||
.field()
|
||||
.link()
|
||||
.withId(linkFieldId)
|
||||
.withName(FieldName.create('Foreign Link')._unsafeUnwrap())
|
||||
.withConfig(
|
||||
LinkFieldConfig.create({
|
||||
baseId: foreignBaseId.toString(),
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: foreignTable.id().toString(),
|
||||
lookupFieldId: foreignTable.primaryFieldId().toString(),
|
||||
isOneWay: true,
|
||||
})._unsafeUnwrap()
|
||||
)
|
||||
.done();
|
||||
hostBuilder.view().defaultGrid().done();
|
||||
const hostTable = hostBuilder.build()._unsafeUnwrap();
|
||||
(await repo.insert(context, hostTable))._unsafeUnwrap();
|
||||
|
||||
const unrelatedBuilder = Table.builder()
|
||||
.withBaseId(hostBaseId)
|
||||
.withName(TableName.create('Unrelated')._unsafeUnwrap());
|
||||
unrelatedBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(FieldName.create('Title 2')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
unrelatedBuilder.view().defaultGrid().done();
|
||||
const unrelatedTable = unrelatedBuilder.build()._unsafeUnwrap();
|
||||
(await repo.insert(context, unrelatedTable))._unsafeUnwrap();
|
||||
|
||||
await db
|
||||
.insertInto('reference')
|
||||
.values({
|
||||
id: `ref_${getRandomString(21)}`,
|
||||
from_field_id: foreignTable.primaryFieldId().toString(),
|
||||
to_field_id: linkFieldId.toString(),
|
||||
})
|
||||
.onConflict((oc) => oc.columns(['to_field_id', 'from_field_id']).doNothing())
|
||||
.execute();
|
||||
|
||||
const specResult = Table.specs().byIncomingReferenceToTable(foreignTable.id()).build();
|
||||
specResult._unsafeUnwrap();
|
||||
|
||||
const tables = (await repo.find(context, specResult._unsafeUnwrap()))._unsafeUnwrap();
|
||||
|
||||
expect(tables.map((table) => table.id().toString())).toEqual([hostTable.id().toString()]);
|
||||
expect(tables.some((table) => table.id().equals(unrelatedTable.id()))).toBe(false);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes legacy dateRange filter values in view filters', async () => {
|
||||
const c = container.createChildContainer();
|
||||
const db = await createPgDb(pgContainer.getConnectionUri());
|
||||
|
||||
@ -21,6 +21,9 @@ import {
|
||||
const formatSpecDetails = (specInfo: TableWhereSpecInfo): string => {
|
||||
const parts: string[] = [];
|
||||
if (specInfo.tableId) parts.push(`tableId=${specInfo.tableId}`);
|
||||
if (specInfo.incomingReferenceToTableId) {
|
||||
parts.push(`incomingReferenceToTableId=${specInfo.incomingReferenceToTableId}`);
|
||||
}
|
||||
if (specInfo.baseId) parts.push(`baseId=${specInfo.baseId}`);
|
||||
if (specInfo.tableIds?.length) parts.push(`tableIds=${specInfo.tableIds.join(',')}`);
|
||||
if (specInfo.tableName) parts.push(`tableName=${specInfo.tableName}`);
|
||||
@ -497,9 +500,10 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
@core.TraceSpan()
|
||||
async findOne(
|
||||
context: core.IExecutionContext,
|
||||
spec: core.ISpecification<core.Table, core.ITableSpecVisitor>
|
||||
spec: core.ISpecification<core.Table, core.ITableSpecVisitor>,
|
||||
options?: Pick<core.TableFindOptions, 'state'>
|
||||
): Promise<Result<core.Table, DomainError>> {
|
||||
const visitor = new TableWhereVisitor();
|
||||
const visitor = new TableWhereVisitor(options?.state);
|
||||
const acceptResult = spec.accept(visitor);
|
||||
if (acceptResult.isErr()) return err(acceptResult.error);
|
||||
|
||||
@ -516,6 +520,9 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
if (specInfo.tableId) {
|
||||
attributes[core.TeableSpanAttributes.TABLE_ID] = specInfo.tableId;
|
||||
}
|
||||
if (specInfo.incomingReferenceToTableId) {
|
||||
attributes['teable.incoming_reference_to_table_id'] = specInfo.incomingReferenceToTableId;
|
||||
}
|
||||
if (specInfo.baseId) {
|
||||
attributes['teable.base_id'] = specInfo.baseId;
|
||||
}
|
||||
@ -533,48 +540,63 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
|
||||
try {
|
||||
const db = resolvePostgresDb(this.db, context);
|
||||
const effectiveState = options?.state ?? 'active';
|
||||
const fieldsLateral = db
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('field')
|
||||
.select([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'options',
|
||||
'meta',
|
||||
'ai_config',
|
||||
'cell_value_type',
|
||||
'is_multiple_cell_value',
|
||||
'not_null',
|
||||
'unique',
|
||||
'is_primary',
|
||||
'is_computed',
|
||||
'is_lookup',
|
||||
'is_conditional_lookup',
|
||||
'has_error',
|
||||
'lookup_linked_field_id',
|
||||
'lookup_options',
|
||||
'db_field_name',
|
||||
'db_field_type',
|
||||
])
|
||||
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.where('deleted_time', 'is', null)
|
||||
.orderBy('order')
|
||||
(() => {
|
||||
let query = eb
|
||||
.selectFrom('field')
|
||||
.select([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'options',
|
||||
'meta',
|
||||
'ai_config',
|
||||
'cell_value_type',
|
||||
'is_multiple_cell_value',
|
||||
'not_null',
|
||||
'unique',
|
||||
'is_primary',
|
||||
'is_computed',
|
||||
'is_lookup',
|
||||
'is_conditional_lookup',
|
||||
'has_error',
|
||||
'lookup_linked_field_id',
|
||||
'lookup_options',
|
||||
'db_field_name',
|
||||
'db_field_type',
|
||||
])
|
||||
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.orderBy('order');
|
||||
if (effectiveState === 'active') {
|
||||
query = query.where('deleted_time', 'is', null);
|
||||
} else if (effectiveState === 'deleted') {
|
||||
query = query.where('deleted_time', 'is not', null);
|
||||
}
|
||||
return query;
|
||||
})()
|
||||
).as('fields'),
|
||||
])
|
||||
.as('fields');
|
||||
const viewsLateral = db
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('view')
|
||||
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
|
||||
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.where('deleted_time', 'is', null)
|
||||
.orderBy('order')
|
||||
(() => {
|
||||
let query = eb
|
||||
.selectFrom('view')
|
||||
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
|
||||
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.orderBy('order');
|
||||
if (effectiveState === 'active') {
|
||||
query = query.where('deleted_time', 'is', null);
|
||||
} else if (effectiveState === 'deleted') {
|
||||
query = query.where('deleted_time', 'is not', null);
|
||||
}
|
||||
return query;
|
||||
})()
|
||||
).as('views'),
|
||||
])
|
||||
.as('views');
|
||||
@ -617,9 +639,9 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
async find(
|
||||
context: core.IExecutionContext,
|
||||
spec: core.ISpecification<core.Table, core.ITableSpecVisitor>,
|
||||
options?: core.IFindOptions<core.TableSortKey>
|
||||
options?: core.TableFindOptions
|
||||
): Promise<Result<ReadonlyArray<core.Table>, DomainError>> {
|
||||
const visitor = new TableWhereVisitor();
|
||||
const visitor = new TableWhereVisitor(options?.state);
|
||||
const acceptResult = spec.accept(visitor);
|
||||
if (acceptResult.isErr()) return err(acceptResult.error);
|
||||
|
||||
@ -629,48 +651,63 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
|
||||
try {
|
||||
const db = resolvePostgresDb(this.db, context);
|
||||
const effectiveState = options?.state ?? 'active';
|
||||
const fieldsLateral = db
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('field')
|
||||
.select([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'options',
|
||||
'meta',
|
||||
'ai_config',
|
||||
'cell_value_type',
|
||||
'is_multiple_cell_value',
|
||||
'not_null',
|
||||
'unique',
|
||||
'is_primary',
|
||||
'is_computed',
|
||||
'is_lookup',
|
||||
'is_conditional_lookup',
|
||||
'has_error',
|
||||
'lookup_linked_field_id',
|
||||
'lookup_options',
|
||||
'db_field_name',
|
||||
'db_field_type',
|
||||
])
|
||||
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.where('deleted_time', 'is', null)
|
||||
.orderBy('order')
|
||||
(() => {
|
||||
let query = eb
|
||||
.selectFrom('field')
|
||||
.select([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'options',
|
||||
'meta',
|
||||
'ai_config',
|
||||
'cell_value_type',
|
||||
'is_multiple_cell_value',
|
||||
'not_null',
|
||||
'unique',
|
||||
'is_primary',
|
||||
'is_computed',
|
||||
'is_lookup',
|
||||
'is_conditional_lookup',
|
||||
'has_error',
|
||||
'lookup_linked_field_id',
|
||||
'lookup_options',
|
||||
'db_field_name',
|
||||
'db_field_type',
|
||||
])
|
||||
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.orderBy('order');
|
||||
if (effectiveState === 'active') {
|
||||
query = query.where('deleted_time', 'is', null);
|
||||
} else if (effectiveState === 'deleted') {
|
||||
query = query.where('deleted_time', 'is not', null);
|
||||
}
|
||||
return query;
|
||||
})()
|
||||
).as('fields'),
|
||||
])
|
||||
.as('fields');
|
||||
const viewsLateral = db
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('view')
|
||||
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
|
||||
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.where('deleted_time', 'is', null)
|
||||
.orderBy('order')
|
||||
(() => {
|
||||
let query = eb
|
||||
.selectFrom('view')
|
||||
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
|
||||
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
|
||||
.orderBy('order');
|
||||
if (effectiveState === 'active') {
|
||||
query = query.where('deleted_time', 'is', null);
|
||||
} else if (effectiveState === 'deleted') {
|
||||
query = query.where('deleted_time', 'is not', null);
|
||||
}
|
||||
return query;
|
||||
})()
|
||||
).as('views'),
|
||||
])
|
||||
.as('views');
|
||||
@ -842,14 +879,52 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
@core.TraceSpan()
|
||||
async delete(
|
||||
context: core.IExecutionContext,
|
||||
table: core.Table
|
||||
table: core.Table,
|
||||
options?: core.TableDeleteOptions
|
||||
): Promise<Result<void, DomainError>> {
|
||||
const now = new Date();
|
||||
const actorId = context.actorId.toString();
|
||||
const tableId = table.id().toString();
|
||||
const mode = options?.mode ?? 'soft';
|
||||
|
||||
try {
|
||||
const db = resolvePostgresDb(this.db, context);
|
||||
if (mode === 'permanent') {
|
||||
const statements: CompiledQuery[] = [
|
||||
sql`
|
||||
DELETE FROM "reference"
|
||||
WHERE "from_field_id" IN (SELECT "id" FROM "field" WHERE "table_id" = ${tableId})
|
||||
OR "to_field_id" IN (SELECT "id" FROM "field" WHERE "table_id" = ${tableId})
|
||||
`.compile(db),
|
||||
];
|
||||
|
||||
if (await relationExists(db, 'public.record_trash')) {
|
||||
statements.push(
|
||||
sql`DELETE FROM "record_trash" WHERE "table_id" = ${tableId}`.compile(db)
|
||||
);
|
||||
}
|
||||
if (await relationExists(db, 'public.table_trash')) {
|
||||
statements.push(sql`DELETE FROM "table_trash" WHERE "table_id" = ${tableId}`.compile(db));
|
||||
}
|
||||
if (await relationExists(db, 'public.trash')) {
|
||||
statements.push(
|
||||
sql`
|
||||
DELETE FROM "trash"
|
||||
WHERE "resource_id" = ${tableId} AND "resource_type" = 'table'
|
||||
`.compile(db)
|
||||
);
|
||||
}
|
||||
|
||||
statements.push(
|
||||
sql`DELETE FROM "view" WHERE "table_id" = ${tableId}`.compile(db),
|
||||
sql`DELETE FROM "field" WHERE "table_id" = ${tableId}`.compile(db),
|
||||
sql`DELETE FROM "table_meta" WHERE "id" = ${tableId}`.compile(db)
|
||||
);
|
||||
|
||||
await executeCompiledQueries(db, statements);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const tableUpdate = await db
|
||||
.updateTable('table_meta')
|
||||
.set({
|
||||
@ -1790,3 +1865,13 @@ const executeCompiledQueries = async <DB>(
|
||||
await db.executeQuery(statement);
|
||||
}
|
||||
};
|
||||
|
||||
const relationExists = async <DB>(
|
||||
db: Kysely<DB> | Transaction<DB>,
|
||||
relationName: string
|
||||
): Promise<boolean> => {
|
||||
const result = await db.executeQuery<{ exists: boolean }>(
|
||||
sql`SELECT to_regclass(${relationName}) IS NOT NULL as "exists"`.compile(db)
|
||||
);
|
||||
return result.rows[0]?.exists === true;
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
TableRemoveFieldSpec,
|
||||
TableByBaseIdSpec,
|
||||
TableByIdSpec,
|
||||
TableByIncomingReferenceToTableSpec,
|
||||
TableByIdsSpec,
|
||||
TableByNameLikeSpec,
|
||||
TableByNameSpec,
|
||||
@ -276,6 +277,16 @@ export class TableMetaUpdateVisitor
|
||||
);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_: TableByIncomingReferenceToTableSpec
|
||||
): Result<ReadonlyArray<TableUpdateBuilder>, DomainError> {
|
||||
return err(
|
||||
domainError.validation({
|
||||
message: 'TableByIncomingReferenceToTableSpec is not supported for table updates',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
visitTableByIds(_: TableByIdsSpec): Result<ReadonlyArray<TableUpdateBuilder>, DomainError> {
|
||||
return err(
|
||||
domainError.validation({ message: 'TableByIdsSpec is not supported for table updates' })
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
TableRenameSpec,
|
||||
TableByBaseIdSpec,
|
||||
TableByIdSpec,
|
||||
TableByIncomingReferenceToTableSpec,
|
||||
TableByIdsSpec,
|
||||
TableByNameLikeSpec,
|
||||
TableByNameSpec,
|
||||
@ -62,9 +63,10 @@ import {
|
||||
type UpdateRollupShowAsSpec,
|
||||
type UpdateRollupTimeZoneSpec,
|
||||
type RemoveSymmetricLinkFieldSpec,
|
||||
type TableQueryState,
|
||||
} from '@teable/v2-core';
|
||||
import type { V1TeableDatabase } from '@teable/v2-postgres-schema';
|
||||
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
|
||||
import { sql, type Expression, type ExpressionBuilder, type SqlBool } from 'kysely';
|
||||
import { err } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
|
||||
@ -75,6 +77,7 @@ export type ITableMetaWhere = (
|
||||
export type TableWhereSpecInfo = {
|
||||
readonly specName?: string;
|
||||
readonly tableId?: string;
|
||||
readonly incomingReferenceToTableId?: string;
|
||||
readonly baseId?: string;
|
||||
readonly tableIds?: ReadonlyArray<string>;
|
||||
readonly tableName?: string;
|
||||
@ -87,9 +90,13 @@ export class TableWhereVisitor
|
||||
{
|
||||
private specInfo: TableWhereSpecInfo = {};
|
||||
|
||||
constructor() {
|
||||
constructor(private readonly state: TableQueryState = 'active') {
|
||||
super();
|
||||
this.addCond((eb) => eb.eb('deleted_time', 'is', null));
|
||||
if (state === 'active') {
|
||||
this.addCond((eb) => eb.eb('deleted_time', 'is', null));
|
||||
} else if (state === 'deleted') {
|
||||
this.addCond((eb) => eb.eb('deleted_time', 'is not', null));
|
||||
}
|
||||
}
|
||||
|
||||
describe(): TableWhereSpecInfo {
|
||||
@ -169,6 +176,32 @@ export class TableWhereVisitor
|
||||
return this.addCond(cond).map(() => cond);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
spec: TableByIncomingReferenceToTableSpec
|
||||
): Result<ITableMetaWhere, DomainError> {
|
||||
const incomingReferenceToTableId = spec.tableId().toString();
|
||||
const targetFieldDeletedPredicate =
|
||||
this.state === 'deleted'
|
||||
? sql`"target_field"."deleted_time" is not null`
|
||||
: sql`"target_field"."deleted_time" is null`;
|
||||
const cond: ITableMetaWhere = () => sql<boolean>`
|
||||
exists (
|
||||
select 1
|
||||
from "reference"
|
||||
inner join "field" as "source_field" on "source_field"."id" = "reference"."from_field_id"
|
||||
inner join "field" as "target_field" on "target_field"."id" = "reference"."to_field_id"
|
||||
where "source_field"."table_id" = ${incomingReferenceToTableId}
|
||||
and ${targetFieldDeletedPredicate}
|
||||
and "target_field"."table_id" = ${sql.ref('table_meta.id')}
|
||||
)
|
||||
`;
|
||||
this.mergeSpecInfo({
|
||||
specName: 'TableByIncomingReferenceToTableSpec',
|
||||
incomingReferenceToTableId,
|
||||
});
|
||||
return this.addCond(cond).map(() => cond);
|
||||
}
|
||||
|
||||
visitTableByIds(spec: TableByIdsSpec): Result<ITableMetaWhere, DomainError> {
|
||||
const ids = spec.tableIds().map((id) => id.toString());
|
||||
if (ids.length === 0)
|
||||
@ -633,7 +666,7 @@ export class TableWhereVisitor
|
||||
}
|
||||
|
||||
clone(): this {
|
||||
return new TableWhereVisitor() as this;
|
||||
return new TableWhereVisitor(this.state) as this;
|
||||
}
|
||||
|
||||
and(left: ITableMetaWhere, right: ITableMetaWhere): ITableMetaWhere {
|
||||
|
||||
@ -561,7 +561,15 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository {
|
||||
}
|
||||
|
||||
@TraceSpan()
|
||||
async delete(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
|
||||
async delete(
|
||||
context: IExecutionContext,
|
||||
table: Table,
|
||||
options?: { mode?: 'soft' | 'permanent' }
|
||||
): Promise<Result<void, DomainError>> {
|
||||
if ((options?.mode ?? 'soft') !== 'permanent') {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const repository = this;
|
||||
return await safeTry<void, DomainError>(async function* () {
|
||||
const { schema, tableName } = yield* table
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
TableAddSelectOptionsSpec,
|
||||
TableByBaseIdSpec,
|
||||
TableByIdSpec,
|
||||
TableByIncomingReferenceToTableSpec,
|
||||
TableByIdsSpec,
|
||||
TableByNameLikeSpec,
|
||||
TableByNameSpec,
|
||||
@ -150,6 +151,12 @@ export class DependencyChangeDetectorVisitor implements ITableSpecVisitor<void>
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_spec: TableByIncomingReferenceToTableSpec
|
||||
): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
TableAddSelectOptionsSpec,
|
||||
TableByBaseIdSpec,
|
||||
TableByIdSpec,
|
||||
TableByIncomingReferenceToTableSpec,
|
||||
TableByIdsSpec,
|
||||
TableByNameLikeSpec,
|
||||
TableByNameSpec,
|
||||
@ -159,6 +160,12 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor<void>
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_spec: TableByIncomingReferenceToTableSpec
|
||||
): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
TableAddSelectOptionsSpec,
|
||||
TableByBaseIdSpec,
|
||||
TableByIdSpec,
|
||||
TableByIncomingReferenceToTableSpec,
|
||||
TableByIdsSpec,
|
||||
TableByNameLikeSpec,
|
||||
TableByNameSpec,
|
||||
@ -116,6 +117,12 @@ export class TableAddFieldCollectorVisitor implements ITableSpecVisitor<void> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_spec: TableByIncomingReferenceToTableSpec
|
||||
): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
TableRemoveFieldSpec,
|
||||
TableByBaseIdSpec,
|
||||
TableByIdSpec,
|
||||
TableByIncomingReferenceToTableSpec,
|
||||
TableByIdsSpec,
|
||||
TableByNameLikeSpec,
|
||||
TableByNameSpec,
|
||||
@ -524,6 +525,16 @@ export class TableSchemaUpdateVisitor
|
||||
);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_: TableByIncomingReferenceToTableSpec
|
||||
): Result<ReadonlyArray<TableSchemaStatementBuilder>, DomainError> {
|
||||
return err(
|
||||
domainError.validation({
|
||||
message: 'TableByIncomingReferenceToTableSpec is not supported for table schema updates',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
visitTableByIds(
|
||||
_: TableByIdsSpec
|
||||
): Result<ReadonlyArray<TableSchemaStatementBuilder>, DomainError> {
|
||||
|
||||
@ -877,6 +877,19 @@ describe('FieldTypeConversionVisitor', () => {
|
||||
expect(mappingSql).toContain('WHERE f."__id" = src.foreign_id');
|
||||
});
|
||||
|
||||
it('should push link -> text conversion into a single SQL mapping statement', () => {
|
||||
const sqls = getConversionSqls(mkManyOneLinkField(), mkTextField());
|
||||
const mappingSql = sqls.find((sql) => sql.includes('DO $v2_link_to_text$'));
|
||||
expect(mappingSql).toBeDefined();
|
||||
expect(mappingSql).toContain('WITH parsed AS');
|
||||
expect(mappingSql).toContain('mapped AS');
|
||||
expect(mappingSql).toContain('reduced AS');
|
||||
expect(mappingSql).toContain('JOIN %I.%I AS f ON f.__id = p.link_id');
|
||||
expect(mappingSql).toContain('string_agg(title');
|
||||
expect(mappingSql).not.toContain('tmp_computed_dirty');
|
||||
expect(mappingSql).not.toContain('record_id');
|
||||
});
|
||||
|
||||
it('should preserve title-based mapping for link -> link when foreign table changes', () => {
|
||||
const oldLink = mkManyOneLinkFieldWithForeign('srcLink', `tbl${'b'.repeat(16)}`);
|
||||
const newLink = mkManyOneLinkFieldWithForeign('tgtLink', `tbl${'c'.repeat(16)}`);
|
||||
|
||||
156
packages/v2/command-explain/src/analyzers/DeleteTableAnalyzer.ts
Normal file
156
packages/v2/command-explain/src/analyzers/DeleteTableAnalyzer.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { inject, injectable } from '@teable/v2-di';
|
||||
import { err, ok, safeTry } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
import {
|
||||
DeleteTableCommand,
|
||||
DeleteTableHandler,
|
||||
FieldCrossTableUpdateSideEffectService,
|
||||
FieldUpdateSideEffectService,
|
||||
LinkFieldUpdateSideEffectService,
|
||||
NoopLogger,
|
||||
TableByIdSpec,
|
||||
TableDeletionSideEffectService,
|
||||
type DomainError,
|
||||
type IExecutionContext,
|
||||
type ITableRepository,
|
||||
v2CoreTokens,
|
||||
} from '@teable/v2-core';
|
||||
import {
|
||||
v2RecordRepositoryPostgresTokens,
|
||||
type ComputedUpdatePlanner,
|
||||
} from '@teable/v2-adapter-table-repository-postgres';
|
||||
import { formulaSqlPgTokens, type IPgTypeValidationStrategy } from '@teable/v2-formula-sql-pg';
|
||||
import type { Kysely } from 'kysely';
|
||||
import type { V1TeableDatabase } from '@teable/v2-postgres-schema';
|
||||
|
||||
import type { ICommandAnalyzer } from './ICommandAnalyzer';
|
||||
import {
|
||||
buildFieldSqlExplains,
|
||||
createFieldExplainDryRunEnvironment,
|
||||
} from './FieldCommandAnalyzeHelpers';
|
||||
import type { CommandExplainInfo, ExplainOptions, ExplainResult } from '../types';
|
||||
import { DEFAULT_EXPLAIN_OPTIONS } from '../types';
|
||||
import { v2CommandExplainTokens } from '../di/tokens';
|
||||
import { ComplexityCalculator } from '../utils/ComplexityCalculator';
|
||||
import { NoopEventBus, NoopUnitOfWork } from '../utils/FieldCommandExplainHarness';
|
||||
import { SqlExplainRunner } from '../utils/SqlExplainRunner';
|
||||
|
||||
@injectable()
|
||||
export class DeleteTableAnalyzer implements ICommandAnalyzer<DeleteTableCommand> {
|
||||
constructor(
|
||||
@inject(v2RecordRepositoryPostgresTokens.db)
|
||||
private readonly db: Kysely<V1TeableDatabase>,
|
||||
@inject(v2CoreTokens.tableRepository)
|
||||
private readonly tableRepository: ITableRepository,
|
||||
@inject(v2RecordRepositoryPostgresTokens.computedUpdatePlanner)
|
||||
private readonly computedUpdatePlanner: ComputedUpdatePlanner,
|
||||
@inject(v2CommandExplainTokens.sqlExplainRunner)
|
||||
private readonly sqlExplainRunner: SqlExplainRunner,
|
||||
@inject(v2CommandExplainTokens.complexityCalculator)
|
||||
private readonly complexityCalculator: ComplexityCalculator,
|
||||
@inject(formulaSqlPgTokens.typeValidationStrategy)
|
||||
private readonly typeValidationStrategy: IPgTypeValidationStrategy
|
||||
) {}
|
||||
|
||||
async analyze(
|
||||
context: IExecutionContext,
|
||||
command: DeleteTableCommand,
|
||||
options: ExplainOptions,
|
||||
startTime: number
|
||||
): Promise<Result<ExplainResult, DomainError>> {
|
||||
const analyzer = this;
|
||||
const mergedOptions = { ...DEFAULT_EXPLAIN_OPTIONS, ...options };
|
||||
|
||||
return safeTry<ExplainResult, DomainError>(async function* () {
|
||||
const beforeTableResult = await analyzer.tableRepository.findOne(
|
||||
context,
|
||||
TableByIdSpec.create(command.tableId)
|
||||
);
|
||||
if (beforeTableResult.isErr()) {
|
||||
return err(beforeTableResult.error);
|
||||
}
|
||||
const beforeTable = beforeTableResult.value;
|
||||
|
||||
const dryRun = createFieldExplainDryRunEnvironment({
|
||||
db: analyzer.db,
|
||||
tableRepository: analyzer.tableRepository,
|
||||
computedUpdatePlanner: analyzer.computedUpdatePlanner,
|
||||
typeValidationStrategy: analyzer.typeValidationStrategy,
|
||||
});
|
||||
|
||||
const fieldCrossTableUpdateSideEffectService = new FieldCrossTableUpdateSideEffectService(
|
||||
dryRun.overlayTableRepository,
|
||||
dryRun.tableUpdateFlow
|
||||
);
|
||||
const linkFieldUpdateSideEffectService = new LinkFieldUpdateSideEffectService(
|
||||
dryRun.tableUpdateFlow
|
||||
);
|
||||
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
|
||||
dryRun.tableUpdateFlow,
|
||||
dryRun.overlayTableRepository,
|
||||
linkFieldUpdateSideEffectService,
|
||||
fieldCrossTableUpdateSideEffectService
|
||||
);
|
||||
const tableDeletionSideEffectService = new TableDeletionSideEffectService(
|
||||
dryRun.overlayTableRepository,
|
||||
dryRun.tableUpdateFlow,
|
||||
fieldUpdateSideEffectService
|
||||
);
|
||||
|
||||
const handler = new DeleteTableHandler(
|
||||
dryRun.overlayTableRepository,
|
||||
dryRun.captureTableSchemaRepository,
|
||||
tableDeletionSideEffectService,
|
||||
new NoopEventBus(),
|
||||
new NoopLogger(),
|
||||
new NoopUnitOfWork()
|
||||
);
|
||||
|
||||
const commandResult = await handler.handle(context, command);
|
||||
if (commandResult.isErr()) {
|
||||
return err(commandResult.error);
|
||||
}
|
||||
|
||||
const deletedTable = commandResult.value.table;
|
||||
const commandInfo: CommandExplainInfo = {
|
||||
type: 'DeleteTable',
|
||||
tableId: deletedTable.id().toString(),
|
||||
tableName: beforeTable.name().toString(),
|
||||
recordIds: [],
|
||||
changeType: 'delete',
|
||||
};
|
||||
|
||||
const sqlExplainStartTime = Date.now();
|
||||
const sqlExplains = mergedOptions.includeSql
|
||||
? await buildFieldSqlExplains(
|
||||
analyzer.sqlExplainRunner,
|
||||
analyzer.db,
|
||||
dryRun.captureTableSchemaRepository.getStatements(),
|
||||
mergedOptions.analyze
|
||||
)
|
||||
: [];
|
||||
const sqlExplainMs = Date.now() - sqlExplainStartTime;
|
||||
|
||||
const complexity = analyzer.complexityCalculator.calculate({
|
||||
commandInfo,
|
||||
computedImpact: null,
|
||||
sqlExplains,
|
||||
});
|
||||
|
||||
return ok({
|
||||
command: commandInfo,
|
||||
computedImpact: null,
|
||||
computedLocks: null,
|
||||
linkLocks: null,
|
||||
sqlExplains,
|
||||
complexity,
|
||||
timing: {
|
||||
totalMs: Date.now() - startTime,
|
||||
dependencyGraphMs: 0,
|
||||
planningMs: 0,
|
||||
sqlExplainMs,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -4,5 +4,6 @@ export * from './UpdateRecordAnalyzer';
|
||||
export * from './CreateRecordAnalyzer';
|
||||
export * from './UpdateFieldAnalyzer';
|
||||
export * from './DeleteFieldAnalyzer';
|
||||
export * from './DeleteTableAnalyzer';
|
||||
export * from './DeleteRecordsAnalyzer';
|
||||
export * from './PasteCommandAnalyzer';
|
||||
|
||||
@ -10,6 +10,7 @@ import { UpdateRecordAnalyzer } from '../analyzers/UpdateRecordAnalyzer';
|
||||
import { CreateRecordAnalyzer } from '../analyzers/CreateRecordAnalyzer';
|
||||
import { UpdateFieldAnalyzer } from '../analyzers/UpdateFieldAnalyzer';
|
||||
import { DeleteFieldAnalyzer } from '../analyzers/DeleteFieldAnalyzer';
|
||||
import { DeleteTableAnalyzer } from '../analyzers/DeleteTableAnalyzer';
|
||||
import { DeleteRecordsAnalyzer } from '../analyzers/DeleteRecordsAnalyzer';
|
||||
import { PasteCommandAnalyzer } from '../analyzers/PasteCommandAnalyzer';
|
||||
|
||||
@ -35,6 +36,9 @@ export const registerCommandExplainModule = (container: DependencyContainer): vo
|
||||
container.register(v2CommandExplainTokens.deleteFieldAnalyzer, DeleteFieldAnalyzer, {
|
||||
lifecycle: Lifecycle.Singleton,
|
||||
});
|
||||
container.register(v2CommandExplainTokens.deleteTableAnalyzer, DeleteTableAnalyzer, {
|
||||
lifecycle: Lifecycle.Singleton,
|
||||
});
|
||||
container.register(v2CommandExplainTokens.updateRecordAnalyzer, UpdateRecordAnalyzer, {
|
||||
lifecycle: Lifecycle.Singleton,
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ export const v2CommandExplainTokens = {
|
||||
createFieldAnalyzer: Symbol('v2.commandExplain.createFieldAnalyzer'),
|
||||
updateFieldAnalyzer: Symbol('v2.commandExplain.updateFieldAnalyzer'),
|
||||
deleteFieldAnalyzer: Symbol('v2.commandExplain.deleteFieldAnalyzer'),
|
||||
deleteTableAnalyzer: Symbol('v2.commandExplain.deleteTableAnalyzer'),
|
||||
updateRecordAnalyzer: Symbol('v2.commandExplain.updateRecordAnalyzer'),
|
||||
createRecordAnalyzer: Symbol('v2.commandExplain.createRecordAnalyzer'),
|
||||
deleteRecordsAnalyzer: Symbol('v2.commandExplain.deleteRecordsAnalyzer'),
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
type IExecutionContext,
|
||||
domainError,
|
||||
DeleteFieldCommand,
|
||||
DeleteTableCommand,
|
||||
UpdateRecordCommand,
|
||||
CreateRecordCommand,
|
||||
CreateFieldCommand,
|
||||
@ -22,6 +23,7 @@ import type { UpdateRecordAnalyzer } from '../analyzers/UpdateRecordAnalyzer';
|
||||
import type { CreateRecordAnalyzer } from '../analyzers/CreateRecordAnalyzer';
|
||||
import type { UpdateFieldAnalyzer } from '../analyzers/UpdateFieldAnalyzer';
|
||||
import type { DeleteFieldAnalyzer } from '../analyzers/DeleteFieldAnalyzer';
|
||||
import type { DeleteTableAnalyzer } from '../analyzers/DeleteTableAnalyzer';
|
||||
import type { DeleteRecordsAnalyzer } from '../analyzers/DeleteRecordsAnalyzer';
|
||||
import type { PasteCommandAnalyzer } from '../analyzers/PasteCommandAnalyzer';
|
||||
|
||||
@ -49,6 +51,8 @@ export class ExplainService implements IExplainService {
|
||||
private readonly updateFieldAnalyzer: UpdateFieldAnalyzer,
|
||||
@inject(v2CommandExplainTokens.deleteFieldAnalyzer)
|
||||
private readonly deleteFieldAnalyzer: DeleteFieldAnalyzer,
|
||||
@inject(v2CommandExplainTokens.deleteTableAnalyzer)
|
||||
private readonly deleteTableAnalyzer: DeleteTableAnalyzer,
|
||||
@inject(v2CommandExplainTokens.updateRecordAnalyzer)
|
||||
private readonly updateRecordAnalyzer: UpdateRecordAnalyzer,
|
||||
@inject(v2CommandExplainTokens.createRecordAnalyzer)
|
||||
@ -88,6 +92,10 @@ export class ExplainService implements IExplainService {
|
||||
return this.deleteFieldAnalyzer.analyze(context, command, mergedOptions, startTime);
|
||||
}
|
||||
|
||||
if (command instanceof DeleteTableCommand) {
|
||||
return this.deleteTableAnalyzer.analyze(context, command, mergedOptions, startTime);
|
||||
}
|
||||
|
||||
if (command instanceof UpdateRecordCommand) {
|
||||
return this.updateRecordAnalyzer.analyze(context, command, mergedOptions, startTime);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ export type CommandExplainInfo = {
|
||||
| 'CreateField'
|
||||
| 'UpdateField'
|
||||
| 'DeleteField'
|
||||
| 'DeleteTable'
|
||||
| 'CreateRecord'
|
||||
| 'UpdateRecord'
|
||||
| 'DeleteRecords'
|
||||
|
||||
@ -276,7 +276,31 @@ export class CaptureTableSchemaRepository implements ITableSchemaRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async delete(_context: IExecutionContext, _table: Table): Promise<Result<void, DomainError>> {
|
||||
async delete(
|
||||
_context: IExecutionContext,
|
||||
table: Table,
|
||||
options?: { mode?: 'soft' | 'permanent' }
|
||||
): Promise<Result<void, DomainError>> {
|
||||
if ((options?.mode ?? 'soft') !== 'permanent') {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const dbTableNameResult = table
|
||||
.dbTableName()
|
||||
.andThen((name) => name.split({ defaultSchema: null }));
|
||||
if (dbTableNameResult.isErr()) {
|
||||
return err(dbTableNameResult.error);
|
||||
}
|
||||
|
||||
const { schema, tableName } = dbTableNameResult.value;
|
||||
const schemaBuilder = schema
|
||||
? this.options.db.schema.withSchema(schema)
|
||||
: this.options.db.schema;
|
||||
this.captureCompiledStatement(
|
||||
`Schema delete: table ${table.name().toString()}`,
|
||||
schemaBuilder.dropTable(tableName).ifExists().compile()
|
||||
);
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
CreateRecordCommand,
|
||||
DeleteFieldCommand,
|
||||
DeleteRecordsCommand,
|
||||
DeleteTableCommand,
|
||||
RecordId,
|
||||
TableId,
|
||||
UpdateFieldCommand,
|
||||
@ -75,6 +76,15 @@ export interface IExplainDeleteRecordsInput {
|
||||
includeLocks?: boolean;
|
||||
}
|
||||
|
||||
export interface IExplainDeleteTableInput {
|
||||
baseId: string;
|
||||
tableId: string;
|
||||
analyze?: boolean;
|
||||
includeSql?: boolean;
|
||||
includeGraph?: boolean;
|
||||
includeLocks?: boolean;
|
||||
}
|
||||
|
||||
export const executeExplainCreateFieldEndpoint = async (
|
||||
context: IExecutionContext,
|
||||
input: IExplainCreateFieldInput,
|
||||
@ -189,6 +199,44 @@ export const executeExplainDeleteFieldEndpoint = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const executeExplainDeleteTableEndpoint = async (
|
||||
context: IExecutionContext,
|
||||
input: IExplainDeleteTableInput,
|
||||
explainService: IExplainService
|
||||
): Promise<IExplainEndpointResult> => {
|
||||
const commandResult = DeleteTableCommand.create(input);
|
||||
if (commandResult.isErr()) {
|
||||
const error = commandResult.error;
|
||||
return {
|
||||
status: mapDomainErrorToHttpStatus(error),
|
||||
body: { ok: false, error: mapDomainErrorToHttpError(error) },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await explainService.explain(context, commandResult.value, {
|
||||
analyze: input.analyze ?? false,
|
||||
includeSql: input.includeSql ?? true,
|
||||
includeGraph: input.includeGraph ?? false,
|
||||
includeLocks: input.includeLocks ?? true,
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.error;
|
||||
return {
|
||||
status: mapDomainErrorToHttpStatus(error),
|
||||
body: { ok: false, error: mapDomainErrorToHttpError(error) },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
data: result.value as IExplainResultDto,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const executeExplainCreateRecordEndpoint = async (
|
||||
context: IExecutionContext,
|
||||
input: IExplainCreateRecordInput,
|
||||
|
||||
@ -13,20 +13,23 @@ import {
|
||||
|
||||
import { executeCreateBaseEndpoint } from './handlers/bases/createBase';
|
||||
import { executeListBasesEndpoint } from './handlers/bases/listBases';
|
||||
import { executeClearEndpoint } from './handlers/tables/clear';
|
||||
import { executeCreateFieldEndpoint } from './handlers/tables/createField';
|
||||
import { executeUpdateFieldEndpoint } from './handlers/tables/updateField';
|
||||
import { executeCreateRecordEndpoint } from './handlers/tables/createRecord';
|
||||
import { executeSubmitRecordEndpoint } from './handlers/tables/submitRecord';
|
||||
import { executeCreateRecordsEndpoint } from './handlers/tables/createRecords';
|
||||
import { executeCreateTableEndpoint } from './handlers/tables/createTable';
|
||||
import { executeCreateTablesEndpoint } from './handlers/tables/createTables';
|
||||
import { executeDeleteByRangeEndpoint } from './handlers/tables/deleteByRange';
|
||||
import { executeDeleteFieldEndpoint } from './handlers/tables/deleteField';
|
||||
import { executeDeleteRecordsEndpoint } from './handlers/tables/deleteRecords';
|
||||
import { executeDeleteTableEndpoint } from './handlers/tables/deleteTable';
|
||||
import { executeDuplicateFieldEndpoint } from './handlers/tables/duplicateField';
|
||||
import { executeDuplicateRecordEndpoint } from './handlers/tables/duplicateRecord';
|
||||
import {
|
||||
executeExplainCreateFieldEndpoint,
|
||||
executeExplainCreateRecordEndpoint,
|
||||
executeExplainDeleteFieldEndpoint,
|
||||
executeExplainDeleteTableEndpoint,
|
||||
executeExplainDeleteRecordsEndpoint,
|
||||
executeExplainUpdateFieldEndpoint,
|
||||
executeExplainUpdateRecordEndpoint,
|
||||
@ -38,14 +41,12 @@ import { executeImportRecordsEndpoint } from './handlers/tables/importRecords';
|
||||
import { executeListTableRecordsEndpoint } from './handlers/tables/listTableRecords';
|
||||
import { executeListTablesEndpoint } from './handlers/tables/listTables';
|
||||
import { executePasteEndpoint } from './handlers/tables/paste';
|
||||
import { executeClearEndpoint } from './handlers/tables/clear';
|
||||
import { executeDeleteByRangeEndpoint } from './handlers/tables/deleteByRange';
|
||||
import { executeRenameTableEndpoint } from './handlers/tables/renameTable';
|
||||
import { executeReorderRecordsEndpoint } from './handlers/tables/reorderRecords';
|
||||
import { executeSubmitRecordEndpoint } from './handlers/tables/submitRecord';
|
||||
import { executeUpdateFieldEndpoint } from './handlers/tables/updateField';
|
||||
import { executeUpdateRecordEndpoint } from './handlers/tables/updateRecord';
|
||||
import { executeUpdateRecordsEndpoint } from './handlers/tables/updateRecords';
|
||||
import { executeReorderRecordsEndpoint } from './handlers/tables/reorderRecords';
|
||||
import { executeDuplicateFieldEndpoint } from './handlers/tables/duplicateField';
|
||||
import { executeDuplicateRecordEndpoint } from './handlers/tables/duplicateRecord';
|
||||
|
||||
export interface IV2OrpcRouterOptions {
|
||||
createContainer?: () => IHandlerResolver | Promise<IHandlerResolver>;
|
||||
@ -986,6 +987,36 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => {
|
||||
throwDomainError('INTERNAL_SERVER_ERROR', result.body.error);
|
||||
});
|
||||
|
||||
const tablesExplainDeleteTable = os.tables.explainDeleteTable.handler(async ({ input }) => {
|
||||
const container = await resolveContainer();
|
||||
|
||||
let executionContext: IExecutionContext;
|
||||
try {
|
||||
executionContext = await createExecutionContext();
|
||||
} catch {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: executionContextErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
const explainService = container.resolve<IExplainService>(
|
||||
v2CommandExplainTokens.explainService
|
||||
);
|
||||
const result = await executeExplainDeleteTableEndpoint(executionContext, input, explainService);
|
||||
|
||||
if (result.status === 200) return result.body;
|
||||
|
||||
if (result.status === 400) {
|
||||
throwDomainError('BAD_REQUEST', result.body.error);
|
||||
}
|
||||
|
||||
if (result.status === 404) {
|
||||
throwDomainError('NOT_FOUND', result.body.error);
|
||||
}
|
||||
|
||||
throwDomainError('INTERNAL_SERVER_ERROR', result.body.error);
|
||||
});
|
||||
|
||||
const tablesExplainUpdateRecord = os.tables.explainUpdateRecord.handler(async ({ input }) => {
|
||||
const container = await resolveContainer();
|
||||
|
||||
@ -1080,6 +1111,7 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => {
|
||||
deleteRecords: tablesDeleteRecords,
|
||||
deleteField: tablesDeleteField,
|
||||
explainDeleteField: tablesExplainDeleteField,
|
||||
explainDeleteTable: tablesExplainDeleteTable,
|
||||
delete: tablesDelete,
|
||||
getById: tablesGetById,
|
||||
getRecord: tablesGetRecord,
|
||||
|
||||
@ -48,6 +48,7 @@ import {
|
||||
explainCreateFieldInputSchema,
|
||||
explainCreateRecordInputSchema,
|
||||
explainDeleteFieldInputSchema,
|
||||
explainDeleteTableInputSchema,
|
||||
explainDeleteRecordsInputSchema,
|
||||
explainOkResponseSchema,
|
||||
explainUpdateFieldInputSchema,
|
||||
@ -83,6 +84,7 @@ const TABLES_EXPLAIN_CREATE_RECORD_PATH = '/tables/explainCreateRecord';
|
||||
const TABLES_EXPLAIN_UPDATE_FIELD_PATH = '/tables/explainUpdateField';
|
||||
const TABLES_EXPLAIN_UPDATE_RECORD_PATH = '/tables/explainUpdateRecord';
|
||||
const TABLES_EXPLAIN_DELETE_FIELD_PATH = '/tables/explainDeleteField';
|
||||
const TABLES_EXPLAIN_DELETE_TABLE_PATH = '/tables/explainDeleteTable';
|
||||
const TABLES_EXPLAIN_DELETE_RECORDS_PATH = '/tables/explainDeleteRecords';
|
||||
const TABLES_GET_PATH = '/tables/get';
|
||||
const TABLES_GET_RECORD_PATH = '/tables/getRecord';
|
||||
@ -255,6 +257,16 @@ export const v2Contract: AnyContractRouter = {
|
||||
})
|
||||
.input(explainDeleteFieldInputSchema)
|
||||
.output(explainOkResponseSchema),
|
||||
explainDeleteTable: oc
|
||||
.route({
|
||||
method: 'POST',
|
||||
path: TABLES_EXPLAIN_DELETE_TABLE_PATH,
|
||||
successStatus: 200,
|
||||
summary: 'Explain delete table',
|
||||
tags: ['tables'],
|
||||
})
|
||||
.input(explainDeleteTableInputSchema)
|
||||
.output(explainOkResponseSchema),
|
||||
delete: oc
|
||||
.route({
|
||||
method: 'DELETE',
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createFieldInputSchema,
|
||||
deleteFieldInputSchema,
|
||||
deleteTableInputSchema,
|
||||
updateFieldInputSchema,
|
||||
} from '@teable/v2-core';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
apiOkResponseDtoSchema,
|
||||
@ -36,6 +37,13 @@ export const explainDeleteFieldInputSchema = deleteFieldInputSchema.extend({
|
||||
includeLocks: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const explainDeleteTableInputSchema = deleteTableInputSchema.extend({
|
||||
analyze: z.boolean().optional().default(false),
|
||||
includeSql: z.boolean().optional().default(true),
|
||||
includeGraph: z.boolean().optional().default(false),
|
||||
includeLocks: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const explainCreateRecordInputSchema = z.object({
|
||||
tableId: z.string(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
@ -67,6 +75,7 @@ export const explainDeleteRecordsInputSchema = z.object({
|
||||
export type IExplainCreateFieldInput = z.infer<typeof explainCreateFieldInputSchema>;
|
||||
export type IExplainUpdateFieldInput = z.infer<typeof explainUpdateFieldInputSchema>;
|
||||
export type IExplainDeleteFieldInput = z.infer<typeof explainDeleteFieldInputSchema>;
|
||||
export type IExplainDeleteTableInput = z.infer<typeof explainDeleteTableInputSchema>;
|
||||
export type IExplainCreateRecordInput = z.infer<typeof explainCreateRecordInputSchema>;
|
||||
export type IExplainUpdateRecordInput = z.infer<typeof explainUpdateRecordInputSchema>;
|
||||
export type IExplainDeleteRecordsInput = z.infer<typeof explainDeleteRecordsInputSchema>;
|
||||
@ -344,6 +353,7 @@ const commandExplainInfoSchema = z.object({
|
||||
'CreateField',
|
||||
'UpdateField',
|
||||
'DeleteField',
|
||||
'DeleteTable',
|
||||
'CreateRecord',
|
||||
'UpdateRecord',
|
||||
'DeleteRecords',
|
||||
|
||||
@ -17,6 +17,9 @@ Declaration: If the folder I belong to changes, please update me, especially cor
|
||||
effects after field deletion (e.g. remove symmetric link fields).
|
||||
- `ForeignTableLoaderService.ts` - Role: application service; Purpose: load foreign tables once and
|
||||
validate missing references.
|
||||
- `TableDeletionSideEffectService.ts` - Role: application service; Purpose: dispatch explicit
|
||||
`OnTeableTableDeleted` reactions in other tables before deleting a table, including link-to-text
|
||||
conversion and dependent metadata cleanup.
|
||||
- `TableQueryService.ts` - Role: application service; Purpose: common table lookup operations
|
||||
(getById, getByIdInBase, exists) used across CommandHandlers and QueryHandlers.
|
||||
- `TableUpdateFlow.ts` - Role: application service; Purpose: shared table update workflow (mutate + persist + publish).
|
||||
|
||||
@ -0,0 +1,445 @@
|
||||
import { err, ok } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../domain/base/BaseId';
|
||||
import { ActorId } from '../../domain/shared/ActorId';
|
||||
import { domainError, type DomainError } from '../../domain/shared/DomainError';
|
||||
import type { IDomainEvent } from '../../domain/shared/DomainEvent';
|
||||
import type { ISpecification } from '../../domain/shared/specification/ISpecification';
|
||||
import { FieldId } from '../../domain/table/fields/FieldId';
|
||||
import { FieldName } from '../../domain/table/fields/FieldName';
|
||||
import { ConditionalLookupField } from '../../domain/table/fields/types/ConditionalLookupField';
|
||||
import { ConditionalLookupOptions } from '../../domain/table/fields/types/ConditionalLookupOptions';
|
||||
import { ConditionalRollupConfig } from '../../domain/table/fields/types/ConditionalRollupConfig';
|
||||
import { ConditionalRollupField } from '../../domain/table/fields/types/ConditionalRollupField';
|
||||
import { LinkField } from '../../domain/table/fields/types/LinkField';
|
||||
import { LinkFieldConfig } from '../../domain/table/fields/types/LinkFieldConfig';
|
||||
import { LookupField } from '../../domain/table/fields/types/LookupField';
|
||||
import { LookupOptions } from '../../domain/table/fields/types/LookupOptions';
|
||||
import { RollupExpression } from '../../domain/table/fields/types/RollupExpression';
|
||||
import { RollupField } from '../../domain/table/fields/types/RollupField';
|
||||
import { RollupFieldConfig } from '../../domain/table/fields/types/RollupFieldConfig';
|
||||
import { SingleLineTextField } from '../../domain/table/fields/types/SingleLineTextField';
|
||||
import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor';
|
||||
import { Table as TableAggregate } from '../../domain/table/Table';
|
||||
import type { Table } from '../../domain/table/Table';
|
||||
import { TableName } from '../../domain/table/TableName';
|
||||
import type { TableSortKey } from '../../domain/table/TableSortKey';
|
||||
import type { IEventBus } from '../../ports/EventBus';
|
||||
import type { IExecutionContext, IUnitOfWorkTransaction } from '../../ports/ExecutionContext';
|
||||
import type { IFindOptions } from '../../ports/RepositoryQuery';
|
||||
import type { ITableRepository } from '../../ports/TableRepository';
|
||||
import type { ITableSchemaRepository } from '../../ports/TableSchemaRepository';
|
||||
import type { IUnitOfWork, UnitOfWorkOperation } from '../../ports/UnitOfWork';
|
||||
import { FieldCrossTableUpdateSideEffectService } from './FieldCrossTableUpdateSideEffectService';
|
||||
import { FieldUpdateSideEffectService } from './FieldUpdateSideEffectService';
|
||||
import { LinkFieldUpdateSideEffectService } from './LinkFieldUpdateSideEffectService';
|
||||
import { TableDeletionSideEffectService } from './TableDeletionSideEffectService';
|
||||
import { TableUpdateFlow } from './TableUpdateFlow';
|
||||
|
||||
const createContext = (): IExecutionContext => ({
|
||||
actorId: ActorId.create('system')._unsafeUnwrap(),
|
||||
});
|
||||
|
||||
const createDeletedTable = () => {
|
||||
const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap();
|
||||
const table = TableAggregate.builder()
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('Source')._unsafeUnwrap());
|
||||
table
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(FieldName.create('Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
table.field().singleLineText().withName(FieldName.create('Value')._unsafeUnwrap()).done();
|
||||
table.field().number().withName(FieldName.create('Score')._unsafeUnwrap()).done();
|
||||
table.view().defaultGrid().done();
|
||||
return table.build()._unsafeUnwrap();
|
||||
};
|
||||
|
||||
const createHostTable = (
|
||||
deletedTable: Table,
|
||||
options?: {
|
||||
baseId?: BaseId;
|
||||
crossBase?: boolean;
|
||||
}
|
||||
) => {
|
||||
const primaryFieldId = deletedTable.primaryFieldId();
|
||||
const valueField = deletedTable.getFields((field) => field.name().toString() === 'Value').at(0);
|
||||
const scoreField = deletedTable.getFields((field) => field.name().toString() === 'Score').at(0);
|
||||
const linkFieldId = FieldId.create(`fld${'b'.repeat(16)}`)._unsafeUnwrap();
|
||||
const lookupFieldId = FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap();
|
||||
const lookupInnerFieldId = FieldId.create(`fld${'d'.repeat(16)}`)._unsafeUnwrap();
|
||||
const rollupFieldId = FieldId.create(`fld${'e'.repeat(16)}`)._unsafeUnwrap();
|
||||
const conditionalLookupFieldId = FieldId.create(`fld${'f'.repeat(16)}`)._unsafeUnwrap();
|
||||
const conditionalLookupInnerFieldId = FieldId.create(`fld${'g'.repeat(16)}`)._unsafeUnwrap();
|
||||
const conditionalRollupFieldId = FieldId.create(`fld${'h'.repeat(16)}`)._unsafeUnwrap();
|
||||
if (!valueField || !scoreField) throw new Error('Missing deleted source fields');
|
||||
const hostBaseId = options?.baseId ?? deletedTable.baseId();
|
||||
const isCrossBase = options?.crossBase ?? false;
|
||||
|
||||
const host = TableAggregate.builder()
|
||||
.withBaseId(hostBaseId)
|
||||
.withName(TableName.create('Host')._unsafeUnwrap());
|
||||
|
||||
host
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(FieldName.create('Host Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
|
||||
host.view().defaultGrid().done();
|
||||
let builtHost = host.build()._unsafeUnwrap();
|
||||
|
||||
builtHost = builtHost
|
||||
.addField(
|
||||
LinkField.create({
|
||||
id: linkFieldId,
|
||||
name: FieldName.create('Link')._unsafeUnwrap(),
|
||||
config: LinkFieldConfig.create({
|
||||
...(isCrossBase ? { baseId: deletedTable.baseId().toString() } : {}),
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: deletedTable.id().toString(),
|
||||
lookupFieldId: primaryFieldId.toString(),
|
||||
isOneWay: true,
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
{ foreignTables: [deletedTable] }
|
||||
)
|
||||
._unsafeUnwrap();
|
||||
|
||||
builtHost = builtHost
|
||||
.addField(
|
||||
LookupField.create({
|
||||
id: lookupFieldId,
|
||||
name: FieldName.create('Lookup')._unsafeUnwrap(),
|
||||
innerField: SingleLineTextField.create({
|
||||
id: lookupInnerFieldId,
|
||||
name: FieldName.create('Lookup Inner')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
lookupOptions: LookupOptions.create({
|
||||
linkFieldId: linkFieldId.toString(),
|
||||
foreignTableId: deletedTable.id().toString(),
|
||||
lookupFieldId: valueField.id().toString(),
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
{ foreignTables: [deletedTable] }
|
||||
)
|
||||
._unsafeUnwrap();
|
||||
|
||||
builtHost = builtHost
|
||||
.addField(
|
||||
RollupField.create({
|
||||
id: rollupFieldId,
|
||||
name: FieldName.create('Rollup')._unsafeUnwrap(),
|
||||
config: RollupFieldConfig.create({
|
||||
linkFieldId: linkFieldId.toString(),
|
||||
foreignTableId: deletedTable.id().toString(),
|
||||
lookupFieldId: valueField.id().toString(),
|
||||
})._unsafeUnwrap(),
|
||||
expression: RollupExpression.create('countall({values})')._unsafeUnwrap(),
|
||||
valuesField: valueField,
|
||||
})._unsafeUnwrap(),
|
||||
{ foreignTables: [deletedTable] }
|
||||
)
|
||||
._unsafeUnwrap();
|
||||
|
||||
builtHost = builtHost
|
||||
.addField(
|
||||
ConditionalLookupField.create({
|
||||
id: conditionalLookupFieldId,
|
||||
name: FieldName.create('Conditional Lookup')._unsafeUnwrap(),
|
||||
innerField: SingleLineTextField.create({
|
||||
id: conditionalLookupInnerFieldId,
|
||||
name: FieldName.create('Conditional Lookup Inner')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
conditionalLookupOptions: ConditionalLookupOptions.create({
|
||||
foreignTableId: deletedTable.id().toString(),
|
||||
lookupFieldId: valueField.id().toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: valueField.id().toString(), operator: 'isNotEmpty' }],
|
||||
},
|
||||
sort: { fieldId: scoreField.id().toString(), order: 'asc' },
|
||||
limit: 1,
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
{ foreignTables: [deletedTable] }
|
||||
)
|
||||
._unsafeUnwrap();
|
||||
|
||||
builtHost = builtHost
|
||||
.addField(
|
||||
ConditionalRollupField.create({
|
||||
id: conditionalRollupFieldId,
|
||||
name: FieldName.create('Conditional Rollup')._unsafeUnwrap(),
|
||||
config: ConditionalRollupConfig.create({
|
||||
foreignTableId: deletedTable.id().toString(),
|
||||
lookupFieldId: scoreField.id().toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: valueField.id().toString(), operator: 'isNotEmpty' }],
|
||||
},
|
||||
sort: { fieldId: scoreField.id().toString(), order: 'asc' },
|
||||
limit: 1,
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
expression: RollupExpression.create('sum({values})')._unsafeUnwrap(),
|
||||
valuesField: scoreField,
|
||||
})._unsafeUnwrap(),
|
||||
{ foreignTables: [deletedTable] }
|
||||
)
|
||||
._unsafeUnwrap();
|
||||
|
||||
return builtHost;
|
||||
};
|
||||
|
||||
class FakeTableRepository implements ITableRepository {
|
||||
constructor(private readonly tablesById: Map<string, Table>) {}
|
||||
|
||||
async insert(_: IExecutionContext, table: Table): Promise<Result<Table, DomainError>> {
|
||||
this.tablesById.set(table.id().toString(), table);
|
||||
return ok(table);
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
_: IExecutionContext,
|
||||
tables: ReadonlyArray<Table>
|
||||
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
|
||||
tables.forEach((table) => this.tablesById.set(table.id().toString(), table));
|
||||
return ok([...tables]);
|
||||
}
|
||||
|
||||
async findOne(
|
||||
_: IExecutionContext,
|
||||
spec: ISpecification<Table, ITableSpecVisitor>
|
||||
): Promise<Result<Table, DomainError>> {
|
||||
const table = [...this.tablesById.values()].find((candidate) => spec.isSatisfiedBy(candidate));
|
||||
if (!table) return err(domainError.notFound({ message: 'Table not found' }));
|
||||
return ok(table);
|
||||
}
|
||||
|
||||
async find(
|
||||
_: IExecutionContext,
|
||||
spec: ISpecification<Table, ITableSpecVisitor>,
|
||||
__?: IFindOptions<TableSortKey>
|
||||
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
|
||||
return ok([...this.tablesById.values()].filter((candidate) => spec.isSatisfiedBy(candidate)));
|
||||
}
|
||||
|
||||
async updateOne(
|
||||
_: IExecutionContext,
|
||||
table: Table,
|
||||
__: ISpecification<Table, ITableSpecVisitor>
|
||||
): Promise<Result<void, DomainError>> {
|
||||
this.tablesById.set(table.id().toString(), table);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async delete(_: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
|
||||
this.tablesById.delete(table.id().toString());
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTableSchemaRepository implements ITableSchemaRepository {
|
||||
async insert(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
_: IExecutionContext,
|
||||
__: ReadonlyArray<Table>
|
||||
): Promise<Result<void, DomainError>> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async update(
|
||||
_: IExecutionContext,
|
||||
table: Table,
|
||||
__: ISpecification<Table, ITableSpecVisitor>
|
||||
): Promise<Result<Table, DomainError>> {
|
||||
return ok(table);
|
||||
}
|
||||
|
||||
async delete(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEventBus implements IEventBus {
|
||||
async publish(_: IExecutionContext, __: IDomainEvent): Promise<Result<void, DomainError>> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async publishMany(
|
||||
_: IExecutionContext,
|
||||
__: ReadonlyArray<IDomainEvent>
|
||||
): Promise<Result<void, DomainError>> {
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeUnitOfWork implements IUnitOfWork {
|
||||
async withTransaction<T>(
|
||||
context: IExecutionContext,
|
||||
work: UnitOfWorkOperation<T>
|
||||
): Promise<Result<T, DomainError>> {
|
||||
const transaction: IUnitOfWorkTransaction = { kind: 'unitOfWorkTransaction' };
|
||||
return work({ ...context, transaction });
|
||||
}
|
||||
}
|
||||
|
||||
class CountingUnitOfWork extends FakeUnitOfWork {
|
||||
transactionCount = 0;
|
||||
|
||||
override async withTransaction<T>(
|
||||
context: IExecutionContext,
|
||||
work: UnitOfWorkOperation<T>
|
||||
): Promise<Result<T, DomainError>> {
|
||||
this.transactionCount += 1;
|
||||
return super.withTransaction(context, work);
|
||||
}
|
||||
}
|
||||
|
||||
describe('TableDeletionSideEffectService', () => {
|
||||
it('converts incoming links to text and marks all foreign-table dependents errored', async () => {
|
||||
const deletedTable = createDeletedTable();
|
||||
const hostTable = createHostTable(deletedTable);
|
||||
const tableRepository = new FakeTableRepository(
|
||||
new Map([
|
||||
[deletedTable.id().toString(), deletedTable],
|
||||
[hostTable.id().toString(), hostTable],
|
||||
])
|
||||
);
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
new FakeTableSchemaRepository(),
|
||||
new FakeEventBus(),
|
||||
new FakeUnitOfWork()
|
||||
);
|
||||
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
|
||||
tableUpdateFlow,
|
||||
tableRepository,
|
||||
new LinkFieldUpdateSideEffectService(tableUpdateFlow),
|
||||
new FieldCrossTableUpdateSideEffectService(tableRepository, tableUpdateFlow)
|
||||
);
|
||||
const service = new TableDeletionSideEffectService(
|
||||
tableRepository,
|
||||
tableUpdateFlow,
|
||||
fieldUpdateSideEffectService
|
||||
);
|
||||
|
||||
const result = await service.execute(createContext(), { table: deletedTable });
|
||||
const updatedTables = result._unsafeUnwrap().updatedTables;
|
||||
const updatedHost = updatedTables.find((table) => table.id().equals(hostTable.id()));
|
||||
expect(updatedHost).toBeDefined();
|
||||
if (!updatedHost) return;
|
||||
|
||||
const linkField = updatedHost.getFields((field) => field.name().toString() === 'Link').at(0);
|
||||
const lookupField = updatedHost
|
||||
.getFields((field) => field.name().toString() === 'Lookup')
|
||||
.at(0);
|
||||
const rollupField = updatedHost
|
||||
.getFields((field) => field.name().toString() === 'Rollup')
|
||||
.at(0);
|
||||
const conditionalLookupField = updatedHost
|
||||
.getFields((field) => field.name().toString() === 'Conditional Lookup')
|
||||
.at(0);
|
||||
const conditionalRollupField = updatedHost
|
||||
.getFields((field) => field.name().toString() === 'Conditional Rollup')
|
||||
.at(0);
|
||||
|
||||
expect(linkField?.type().toString()).toBe('singleLineText');
|
||||
expect(lookupField?.hasError().isError()).toBe(true);
|
||||
expect(rollupField?.hasError().isError()).toBe(true);
|
||||
expect(conditionalLookupField?.hasError().isError()).toBe(true);
|
||||
expect(conditionalRollupField?.hasError().isError()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps hook-bearing link conversion isolated and batches the remaining delete-table reactions', async () => {
|
||||
const deletedTable = createDeletedTable();
|
||||
const hostTable = createHostTable(deletedTable);
|
||||
const tableRepository = new FakeTableRepository(
|
||||
new Map([
|
||||
[deletedTable.id().toString(), deletedTable],
|
||||
[hostTable.id().toString(), hostTable],
|
||||
])
|
||||
);
|
||||
const unitOfWork = new CountingUnitOfWork();
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
new FakeTableSchemaRepository(),
|
||||
new FakeEventBus(),
|
||||
unitOfWork
|
||||
);
|
||||
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
|
||||
tableUpdateFlow,
|
||||
tableRepository,
|
||||
new LinkFieldUpdateSideEffectService(tableUpdateFlow),
|
||||
new FieldCrossTableUpdateSideEffectService(tableRepository, tableUpdateFlow)
|
||||
);
|
||||
const service = new TableDeletionSideEffectService(
|
||||
tableRepository,
|
||||
tableUpdateFlow,
|
||||
fieldUpdateSideEffectService
|
||||
);
|
||||
|
||||
const result = await service.execute(createContext(), { table: deletedTable });
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(unitOfWork.transactionCount).toBe(4);
|
||||
});
|
||||
|
||||
it('reacts to cross-base incoming references when deleting a foreign table', async () => {
|
||||
const deletedTable = createDeletedTable();
|
||||
const otherBaseId = BaseId.create(`bse${'z'.repeat(16)}`)._unsafeUnwrap();
|
||||
const hostTable = createHostTable(deletedTable, {
|
||||
baseId: otherBaseId,
|
||||
crossBase: true,
|
||||
});
|
||||
const tableRepository = new FakeTableRepository(
|
||||
new Map([
|
||||
[deletedTable.id().toString(), deletedTable],
|
||||
[hostTable.id().toString(), hostTable],
|
||||
])
|
||||
);
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
new FakeTableSchemaRepository(),
|
||||
new FakeEventBus(),
|
||||
new FakeUnitOfWork()
|
||||
);
|
||||
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
|
||||
tableUpdateFlow,
|
||||
tableRepository,
|
||||
new LinkFieldUpdateSideEffectService(tableUpdateFlow),
|
||||
new FieldCrossTableUpdateSideEffectService(tableRepository, tableUpdateFlow)
|
||||
);
|
||||
const service = new TableDeletionSideEffectService(
|
||||
tableRepository,
|
||||
tableUpdateFlow,
|
||||
fieldUpdateSideEffectService
|
||||
);
|
||||
|
||||
const result = await service.execute(createContext(), { table: deletedTable });
|
||||
const updatedTables = result._unsafeUnwrap().updatedTables;
|
||||
const updatedHost = updatedTables.find((table) => table.id().equals(hostTable.id()));
|
||||
expect(updatedHost).toBeDefined();
|
||||
if (!updatedHost) return;
|
||||
|
||||
const linkField = updatedHost.getFields((field) => field.name().toString() === 'Link').at(0);
|
||||
const lookupField = updatedHost
|
||||
.getFields((field) => field.name().toString() === 'Lookup')
|
||||
.at(0);
|
||||
|
||||
expect(updatedHost.baseId().equals(otherBaseId)).toBe(true);
|
||||
expect(linkField?.type().toString()).toBe('singleLineText');
|
||||
expect(lookupField?.hasError().isError()).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,337 @@
|
||||
import { inject, injectable } from '@teable/v2-di';
|
||||
import { err, ok, safeTry } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
|
||||
import type { DomainError } from '../../domain/shared/DomainError';
|
||||
import type { IDomainEvent } from '../../domain/shared/DomainEvent';
|
||||
import { composeAndSpecs } from '../../domain/shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../domain/shared/specification/ISpecification';
|
||||
import type { Field } from '../../domain/table/fields/Field';
|
||||
import type { FieldId } from '../../domain/table/fields/FieldId';
|
||||
import { FieldType } from '../../domain/table/fields/FieldType';
|
||||
import {
|
||||
implementsOnTeableTableDeleted,
|
||||
type TableDeletionAfterPersistHook,
|
||||
type TableDeletionContext,
|
||||
type TableDeletionReaction,
|
||||
} from '../../domain/table/OnTeableTableDeleted';
|
||||
import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor';
|
||||
import { Table as TableAggregate } from '../../domain/table/Table';
|
||||
import type { Table } from '../../domain/table/Table';
|
||||
import type { TableId } from '../../domain/table/TableId';
|
||||
import { TableUpdateResult } from '../../domain/table/TableMutator';
|
||||
import * as ExecutionContextPort from '../../ports/ExecutionContext';
|
||||
import * as TableRepositoryPort from '../../ports/TableRepository';
|
||||
import { v2CoreTokens } from '../../ports/tokens';
|
||||
import { TraceSpan } from '../../ports/TraceSpan';
|
||||
import { FieldUpdateSideEffectService } from './FieldUpdateSideEffectService';
|
||||
import { TableUpdateFlow } from './TableUpdateFlow';
|
||||
|
||||
export type TableDeletionSideEffectServiceInput = {
|
||||
table: Table;
|
||||
};
|
||||
|
||||
export type TableDeletionSideEffectServiceResult = {
|
||||
events: ReadonlyArray<IDomainEvent>;
|
||||
postPersistEvents: ReadonlyArray<IDomainEvent>;
|
||||
updatedTables: ReadonlyArray<Table>;
|
||||
};
|
||||
|
||||
type TableDeletionTypeReaction = {
|
||||
reaction: TableDeletionReactionWithAfterPersist;
|
||||
};
|
||||
|
||||
type TableDeletionReactionCollection = {
|
||||
isolatedReactions: ReadonlyArray<TableDeletionTypeReaction>;
|
||||
batchableSpecs: ReadonlyArray<ISpecification<Table, ITableSpecVisitor>>;
|
||||
};
|
||||
|
||||
type TableDeletionReactionWithAfterPersist = TableDeletionReaction & {
|
||||
readonly afterPersist: TableDeletionAfterPersistHook;
|
||||
};
|
||||
|
||||
@injectable()
|
||||
// Application service: orchestrates cross-table side effects before a table is deleted.
|
||||
// Data changes still flow through explicit table-deletion hooks and table specs so adapters can keep work in SQL.
|
||||
export class TableDeletionSideEffectService {
|
||||
constructor(
|
||||
@inject(v2CoreTokens.tableRepository)
|
||||
private readonly tableRepository: TableRepositoryPort.ITableRepository,
|
||||
@inject(v2CoreTokens.tableUpdateFlow)
|
||||
private readonly tableUpdateFlow: TableUpdateFlow,
|
||||
@inject(v2CoreTokens.fieldUpdateSideEffectService)
|
||||
private readonly fieldUpdateSideEffectService: FieldUpdateSideEffectService
|
||||
) {}
|
||||
|
||||
@TraceSpan()
|
||||
async execute(
|
||||
context: ExecutionContextPort.IExecutionContext,
|
||||
input: TableDeletionSideEffectServiceInput
|
||||
): Promise<Result<TableDeletionSideEffectServiceResult, DomainError>> {
|
||||
const service = this;
|
||||
return safeTry<TableDeletionSideEffectServiceResult, DomainError>(async function* () {
|
||||
const candidateTables = yield* await service.loadCandidateTables(context, input.table);
|
||||
if (candidateTables.length === 0) {
|
||||
return ok({ events: [], postPersistEvents: [], updatedTables: [] });
|
||||
}
|
||||
|
||||
const updatedTables: Table[] = [];
|
||||
const events: IDomainEvent[] = [];
|
||||
const postPersistEvents: IDomainEvent[] = [];
|
||||
|
||||
for (const candidateTable of candidateTables) {
|
||||
let latestTable = candidateTable;
|
||||
const reactingFieldIds = service.prioritizeReactingFieldIds(latestTable, input.table);
|
||||
const deletionContext = service.createDeletionContext(latestTable);
|
||||
const reactions = yield* service.collectDeletionReactions(
|
||||
latestTable,
|
||||
reactingFieldIds,
|
||||
input.table,
|
||||
deletionContext
|
||||
);
|
||||
|
||||
for (const reaction of reactions.isolatedReactions) {
|
||||
const reactionResult = yield* await service.reactWithAfterPersistReaction(
|
||||
context,
|
||||
latestTable,
|
||||
input.table,
|
||||
reaction.reaction
|
||||
);
|
||||
if (!reactionResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
latestTable = reactionResult.table;
|
||||
events.push(...reactionResult.events);
|
||||
postPersistEvents.push(...reactionResult.postPersistEvents);
|
||||
}
|
||||
|
||||
const refreshedBatchableReactions = yield* service.collectDeletionReactions(
|
||||
latestTable,
|
||||
service.prioritizeReactingFieldIds(latestTable, input.table),
|
||||
input.table,
|
||||
service.createDeletionContext(latestTable)
|
||||
);
|
||||
const batchReactionResult = yield* await service.reactWithBatchableSpecs(
|
||||
context,
|
||||
latestTable,
|
||||
refreshedBatchableReactions.batchableSpecs
|
||||
);
|
||||
if (batchReactionResult) {
|
||||
latestTable = batchReactionResult.table;
|
||||
events.push(...batchReactionResult.events);
|
||||
postPersistEvents.push(...batchReactionResult.postPersistEvents);
|
||||
}
|
||||
|
||||
updatedTables.push(latestTable);
|
||||
}
|
||||
|
||||
return ok({
|
||||
events,
|
||||
postPersistEvents,
|
||||
updatedTables,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async loadCandidateTables(
|
||||
context: ExecutionContextPort.IExecutionContext,
|
||||
deletedTable: Table
|
||||
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
|
||||
const specResult = TableAggregate.specs()
|
||||
.byIncomingReferenceToTable(deletedTable.id())
|
||||
.not((builder) => builder.byId(deletedTable.id()))
|
||||
.build();
|
||||
if (specResult.isErr()) {
|
||||
return err(specResult.error);
|
||||
}
|
||||
|
||||
const tablesResult = await this.tableRepository.find(context, specResult.value);
|
||||
if (tablesResult.isErr()) {
|
||||
return err(tablesResult.error);
|
||||
}
|
||||
|
||||
return ok(tablesResult.value);
|
||||
}
|
||||
|
||||
private collectDeletionReactions(
|
||||
table: Table,
|
||||
reactingFieldIds: ReadonlyArray<FieldId>,
|
||||
deletedTable: Table,
|
||||
deletionContext: TableDeletionContext
|
||||
): Result<TableDeletionReactionCollection, DomainError> {
|
||||
const isolatedReactions: TableDeletionTypeReaction[] = [];
|
||||
const batchableSpecs: Array<ISpecification<Table, ITableSpecVisitor>> = [];
|
||||
|
||||
for (const fieldId of reactingFieldIds) {
|
||||
const field = table.getField((candidate) => candidate.id().equals(fieldId));
|
||||
if (field.isErr() || !implementsOnTeableTableDeleted(field.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reactionResult = field.value.onTableDeleted(deletedTable, deletionContext);
|
||||
if (reactionResult.isErr()) {
|
||||
return err(reactionResult.error);
|
||||
}
|
||||
if (!reactionResult.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.hasAfterPersist(reactionResult.value)) {
|
||||
isolatedReactions.push({
|
||||
reaction: reactionResult.value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
batchableSpecs.push(reactionResult.value.spec);
|
||||
}
|
||||
|
||||
return ok({
|
||||
isolatedReactions,
|
||||
batchableSpecs,
|
||||
});
|
||||
}
|
||||
|
||||
private prioritizeReactingFieldIds(table: Table, deletedTable: Table): ReadonlyArray<FieldId> {
|
||||
return [...table.getFields()]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
this.reactionPriority(left, deletedTable) - this.reactionPriority(right, deletedTable)
|
||||
)
|
||||
.map((field) => field.id());
|
||||
}
|
||||
|
||||
private reactionPriority(field: Field, deletedTable: Table): number {
|
||||
const foreignTableId = this.getForeignTableId(field);
|
||||
|
||||
if (!foreignTableId || !foreignTableId.equals(deletedTable.id())) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return field.type().equals(FieldType.link()) ? 0 : 1;
|
||||
}
|
||||
|
||||
private hasAfterPersist(
|
||||
reaction: TableDeletionReaction
|
||||
): reaction is TableDeletionReactionWithAfterPersist {
|
||||
return reaction.afterPersist != null;
|
||||
}
|
||||
|
||||
private getForeignTableId(field: Field): TableId | undefined {
|
||||
const candidate = field as Field & {
|
||||
foreignTableId?: () => TableId;
|
||||
};
|
||||
|
||||
if (typeof candidate.foreignTableId !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return candidate.foreignTableId();
|
||||
}
|
||||
|
||||
private async reactWithAfterPersistReaction(
|
||||
context: ExecutionContextPort.IExecutionContext,
|
||||
table: Table,
|
||||
deletedTable: Table,
|
||||
reaction: TableDeletionReactionWithAfterPersist
|
||||
): Promise<
|
||||
Result<
|
||||
| {
|
||||
table: Table;
|
||||
events: ReadonlyArray<IDomainEvent>;
|
||||
postPersistEvents: ReadonlyArray<IDomainEvent>;
|
||||
}
|
||||
| undefined,
|
||||
DomainError
|
||||
>
|
||||
> {
|
||||
const updateResult = await this.tableUpdateFlow.execute(
|
||||
context,
|
||||
{ table },
|
||||
(candidate) =>
|
||||
reaction.spec
|
||||
.mutate(candidate)
|
||||
.map((updated) => TableUpdateResult.create(updated, reaction.spec)),
|
||||
{
|
||||
hooks: {
|
||||
afterPersist: (transactionContext, updatedTable) =>
|
||||
reaction.afterPersist(transactionContext, updatedTable, deletedTable),
|
||||
},
|
||||
publishEvents: false,
|
||||
}
|
||||
);
|
||||
|
||||
return updateResult.map((result) => result);
|
||||
}
|
||||
|
||||
private createDeletionContext(table: Table): TableDeletionContext {
|
||||
return {
|
||||
table,
|
||||
hooks: {
|
||||
createFieldUpdateAfterPersistHook: (fieldId, updateSpec) => {
|
||||
return async (context, updatedTable, currentDeletedTable) => {
|
||||
const updatedField = updatedTable.getField((candidate) =>
|
||||
candidate.id().equals(fieldId)
|
||||
);
|
||||
if (updatedField.isErr()) {
|
||||
return err(updatedField.error);
|
||||
}
|
||||
|
||||
const sideEffectResult = await this.fieldUpdateSideEffectService.execute(context, {
|
||||
table: updatedTable,
|
||||
updatedField: updatedField.value,
|
||||
updateSpecs: [updateSpec],
|
||||
foreignTables: [currentDeletedTable],
|
||||
});
|
||||
if (sideEffectResult.isErr()) {
|
||||
return err(sideEffectResult.error);
|
||||
}
|
||||
|
||||
return ok({
|
||||
events: sideEffectResult.value.events,
|
||||
table: sideEffectResult.value.updatedTable,
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async reactWithBatchableSpecs(
|
||||
context: ExecutionContextPort.IExecutionContext,
|
||||
table: Table,
|
||||
specs: ReadonlyArray<ISpecification<Table, ITableSpecVisitor>>
|
||||
): Promise<
|
||||
Result<
|
||||
| {
|
||||
table: Table;
|
||||
events: ReadonlyArray<IDomainEvent>;
|
||||
postPersistEvents: ReadonlyArray<IDomainEvent>;
|
||||
}
|
||||
| undefined,
|
||||
DomainError
|
||||
>
|
||||
> {
|
||||
if (specs.length === 0) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const composedSpec = composeAndSpecs(specs);
|
||||
if (composedSpec.isErr()) {
|
||||
return err(composedSpec.error);
|
||||
}
|
||||
|
||||
const updateResult = await this.tableUpdateFlow.execute(
|
||||
context,
|
||||
{ table },
|
||||
(candidate) =>
|
||||
composedSpec.value
|
||||
.mutate(candidate)
|
||||
.map((updated) => TableUpdateResult.create(updated, composedSpec.value)),
|
||||
{ publishEvents: false }
|
||||
);
|
||||
|
||||
return updateResult.map((result) => result);
|
||||
}
|
||||
}
|
||||
@ -65,6 +65,7 @@ type TableUpdateFlowHooks = {
|
||||
export type TableUpdateFlowResult = {
|
||||
table: Table;
|
||||
events: ReadonlyArray<IDomainEvent>;
|
||||
postPersistEvents: ReadonlyArray<IDomainEvent>;
|
||||
};
|
||||
|
||||
const normalizeHookResult = (
|
||||
@ -184,7 +185,7 @@ export class TableUpdateFlow {
|
||||
yield* await handler.eventBus.publishMany(context, postPersistEvents);
|
||||
}
|
||||
}
|
||||
return ok({ table: latestTable, events: normalizedEvents });
|
||||
return ok({ table: latestTable, events: normalizedEvents, postPersistEvents });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@ import { OccurredAt } from '../domain/shared/OccurredAt';
|
||||
import type { ISpecification } from '../domain/shared/specification/ISpecification';
|
||||
import { FieldId } from '../domain/table/fields/FieldId';
|
||||
import { FieldName } from '../domain/table/fields/FieldName';
|
||||
import { LinkField } from '../domain/table/fields/types/LinkField';
|
||||
import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig';
|
||||
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
|
||||
import { Table } from '../domain/table/Table';
|
||||
import { TableId } from '../domain/table/TableId';
|
||||
@ -41,8 +43,20 @@ const noopUndoRedoService = {
|
||||
},
|
||||
} as unknown as UndoRedoService;
|
||||
|
||||
const noopFieldUndoRedoSnapshotService = {
|
||||
async capture(_context: IExecutionContext, _table: Table, fieldId: FieldId) {
|
||||
class FakeFieldUndoRedoSnapshotService {
|
||||
captured: Array<{ tableId: TableId; fieldId: FieldId; includeRecords?: boolean }> = [];
|
||||
|
||||
async capture(
|
||||
_context: IExecutionContext,
|
||||
table: Table,
|
||||
fieldId: FieldId,
|
||||
options?: { includeRecords?: boolean }
|
||||
) {
|
||||
this.captured.push({
|
||||
tableId: table.id(),
|
||||
fieldId,
|
||||
includeRecords: options?.includeRecords,
|
||||
});
|
||||
return ok({
|
||||
field: {
|
||||
id: fieldId.toString(),
|
||||
@ -51,8 +65,8 @@ const noopFieldUndoRedoSnapshotService = {
|
||||
},
|
||||
views: [],
|
||||
});
|
||||
},
|
||||
} as unknown as FieldUndoRedoSnapshotService;
|
||||
}
|
||||
}
|
||||
|
||||
const buildTable = () => {
|
||||
const baseId = BaseId.create(`bse${'d'.repeat(16)}`)._unsafeUnwrap();
|
||||
@ -245,6 +259,7 @@ describe('DeleteFieldHandler', () => {
|
||||
sideEffectService.events = [buildEvent()];
|
||||
|
||||
const foreignTableLoader = new FakeForeignTableLoaderService();
|
||||
const fieldUndoRedoSnapshotService = new FakeFieldUndoRedoSnapshotService();
|
||||
|
||||
const handler = new DeleteFieldHandler(
|
||||
tableRepository,
|
||||
@ -252,7 +267,7 @@ describe('DeleteFieldHandler', () => {
|
||||
sideEffectService as unknown as FieldDeletionSideEffectService,
|
||||
foreignTableLoader as unknown as ForeignTableLoaderService,
|
||||
noopUndoRedoService,
|
||||
noopFieldUndoRedoSnapshotService
|
||||
fieldUndoRedoSnapshotService as unknown as FieldUndoRedoSnapshotService
|
||||
);
|
||||
|
||||
const commandResult = DeleteFieldCommand.create({
|
||||
@ -270,6 +285,7 @@ describe('DeleteFieldHandler', () => {
|
||||
expect(eventBus.published.length).toBeGreaterThan(0);
|
||||
expect(unitOfWork.transactions.length).toBe(1);
|
||||
expect(foreignTableLoader.lastBaseId?.equals(baseId)).toBe(true);
|
||||
expect(fieldUndoRedoSnapshotService.captured).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns not found when field is missing', async () => {
|
||||
@ -288,7 +304,7 @@ describe('DeleteFieldHandler', () => {
|
||||
new FakeFieldDeletionSideEffectService() as unknown as FieldDeletionSideEffectService,
|
||||
new FakeForeignTableLoaderService() as unknown as ForeignTableLoaderService,
|
||||
noopUndoRedoService,
|
||||
noopFieldUndoRedoSnapshotService
|
||||
new FakeFieldUndoRedoSnapshotService() as unknown as FieldUndoRedoSnapshotService
|
||||
);
|
||||
|
||||
const commandResult = DeleteFieldCommand.create({
|
||||
@ -300,4 +316,112 @@ describe('DeleteFieldHandler', () => {
|
||||
const result = await handler.handle(createContext(), commandResult._unsafeUnwrap());
|
||||
expect(result._unsafeUnwrapErr().message).toBe('Field not found');
|
||||
});
|
||||
|
||||
it('captures related undo snapshots from explicit field deletion reactions', async () => {
|
||||
const baseId = BaseId.create(`bse${'f'.repeat(16)}`)._unsafeUnwrap();
|
||||
const sourceTableId = TableId.create(`tbl${'g'.repeat(16)}`)._unsafeUnwrap();
|
||||
const hostTableId = TableId.create(`tbl${'h'.repeat(16)}`)._unsafeUnwrap();
|
||||
const sourcePrimaryFieldId = FieldId.create(`fld${'i'.repeat(16)}`)._unsafeUnwrap();
|
||||
const sourceDisplayFieldId = FieldId.create(`fld${'j'.repeat(16)}`)._unsafeUnwrap();
|
||||
const hostPrimaryFieldId = FieldId.create(`fld${'k'.repeat(16)}`)._unsafeUnwrap();
|
||||
const hostLinkFieldId = FieldId.create(`fld${'l'.repeat(16)}`)._unsafeUnwrap();
|
||||
|
||||
const sourceBuilder = Table.builder()
|
||||
.withId(sourceTableId)
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('Source')._unsafeUnwrap());
|
||||
sourceBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(sourcePrimaryFieldId)
|
||||
.withName(FieldName.create('Title')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
sourceBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(sourceDisplayFieldId)
|
||||
.withName(FieldName.create('Display')._unsafeUnwrap())
|
||||
.done();
|
||||
sourceBuilder.view().defaultGrid().done();
|
||||
const sourceTable = sourceBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const hostBuilder = Table.builder()
|
||||
.withId(hostTableId)
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('Host')._unsafeUnwrap());
|
||||
hostBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(hostPrimaryFieldId)
|
||||
.withName(FieldName.create('Host Title')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
hostBuilder.view().defaultGrid().done();
|
||||
const hostBaseTable = hostBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const hostLinkField = LinkField.create({
|
||||
id: hostLinkFieldId,
|
||||
name: FieldName.create('Source Link')._unsafeUnwrap(),
|
||||
config: LinkFieldConfig.create({
|
||||
relationship: 'oneOne',
|
||||
foreignTableId: sourceTableId.toString(),
|
||||
lookupFieldId: sourceDisplayFieldId.toString(),
|
||||
isOneWay: true,
|
||||
fkHostTableName: 'host_source_link',
|
||||
selfKeyName: '__id',
|
||||
foreignKeyName: '__fk_source_link',
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const hostTable = hostBaseTable
|
||||
.addField(hostLinkField, { foreignTables: [sourceTable] })
|
||||
._unsafeUnwrap();
|
||||
|
||||
const tableRepository = new FakeTableRepository();
|
||||
tableRepository.tables.push(sourceTable, hostTable);
|
||||
|
||||
const snapshotService = new FakeFieldUndoRedoSnapshotService();
|
||||
const handlerWithSnapshots = new DeleteFieldHandler(
|
||||
tableRepository,
|
||||
new TableUpdateFlow(
|
||||
tableRepository,
|
||||
new FakeTableSchemaRepository(),
|
||||
new FakeEventBus(),
|
||||
new FakeUnitOfWork()
|
||||
),
|
||||
new FakeFieldDeletionSideEffectService() as unknown as FieldDeletionSideEffectService,
|
||||
new FakeForeignTableLoaderService() as unknown as ForeignTableLoaderService,
|
||||
noopUndoRedoService,
|
||||
snapshotService as unknown as FieldUndoRedoSnapshotService
|
||||
);
|
||||
|
||||
const command = DeleteFieldCommand.create({
|
||||
baseId: baseId.toString(),
|
||||
tableId: sourceTableId.toString(),
|
||||
fieldId: sourceDisplayFieldId.toString(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = await handlerWithSnapshots.handle(createContext(), command);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
expect(
|
||||
snapshotService.captured.map(({ tableId, fieldId, includeRecords }) => ({
|
||||
tableId: tableId.toString(),
|
||||
fieldId: fieldId.toString(),
|
||||
includeRecords,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
tableId: sourceTableId.toString(),
|
||||
fieldId: sourceDisplayFieldId.toString(),
|
||||
includeRecords: undefined,
|
||||
},
|
||||
{
|
||||
tableId: hostTableId.toString(),
|
||||
fieldId: hostLinkFieldId.toString(),
|
||||
includeRecords: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,28 +2,24 @@ import { inject, injectable } from '@teable/v2-di';
|
||||
import { err, ok, safeTry } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
|
||||
import { FieldUndoRedoSnapshotService } from '../application/services/FieldUndoRedoSnapshotService';
|
||||
import { FieldDeletionSideEffectService } from '../application/services/FieldDeletionSideEffectService';
|
||||
import { FieldUndoRedoSnapshotService } from '../application/services/FieldUndoRedoSnapshotService';
|
||||
import { ForeignTableLoaderService } from '../application/services/ForeignTableLoaderService';
|
||||
import { TableUpdateFlow } from '../application/services/TableUpdateFlow';
|
||||
import { UndoRedoService } from '../application/services/UndoRedoService';
|
||||
import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError';
|
||||
import type { IDomainEvent } from '../domain/shared/DomainEvent';
|
||||
import {
|
||||
composeAndSpecsOrUndefined,
|
||||
flattenAndSpecs,
|
||||
} from '../domain/shared/specification/composeAndSpecs';
|
||||
import { composeAndSpecsOrUndefined } from '../domain/shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../domain/shared/specification/ISpecification';
|
||||
import { UpdateLinkConfigSpec } from '../domain/table/specs/field-updates/UpdateLinkConfigSpec';
|
||||
import { Field } from '../domain/table/fields/Field';
|
||||
import type { FieldId } from '../domain/table/fields/FieldId';
|
||||
import { LinkForeignTableReferenceVisitor } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
|
||||
import {
|
||||
implementsOnTeableFieldDeleted,
|
||||
type FieldDeletionContext,
|
||||
type FieldDeletionReaction,
|
||||
} from '../domain/table/OnTeableFieldDeleted';
|
||||
import { Field } from '../domain/table/fields/Field';
|
||||
import { LinkForeignTableReferenceVisitor } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
|
||||
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../domain/table/specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../domain/table/specs/TableUpdateFieldTypeSpec';
|
||||
import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec';
|
||||
import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec';
|
||||
import { Table as TableAggregate } from '../domain/table/Table';
|
||||
@ -61,6 +57,11 @@ export class DeleteFieldResult {
|
||||
}
|
||||
}
|
||||
|
||||
type FieldDeletionCleanup = {
|
||||
readonly spec?: ISpecification<Table, ITableSpecVisitor>;
|
||||
readonly relatedFieldIds: ReadonlyArray<FieldId>;
|
||||
};
|
||||
|
||||
@CommandHandler(DeleteFieldCommand)
|
||||
@injectable()
|
||||
export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, DeleteFieldResult> {
|
||||
@ -242,13 +243,13 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
|
||||
? latestSourceTable
|
||||
: table;
|
||||
|
||||
const cleanupSpecResult = handler.buildDeletionCleanupSpecs(candidateTable, deletedField, {
|
||||
const cleanupResult = handler.buildDeletionCleanup(candidateTable, deletedField, {
|
||||
table: candidateTable,
|
||||
sourceTable: latestSourceTable,
|
||||
previousSourceTable,
|
||||
});
|
||||
if (cleanupSpecResult.isErr()) return err(cleanupSpecResult.error);
|
||||
const cleanupSpec = cleanupSpecResult.value;
|
||||
if (cleanupResult.isErr()) return err(cleanupResult.error);
|
||||
const cleanupSpec = cleanupResult.value.spec;
|
||||
if (!cleanupSpec) {
|
||||
continue;
|
||||
}
|
||||
@ -304,36 +305,16 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
|
||||
}> = [];
|
||||
|
||||
for (const candidateTable of orderedTables) {
|
||||
const cleanupSpecResult = handler.buildDeletionCleanupSpecs(candidateTable, deletedField, {
|
||||
const cleanupResult = handler.buildDeletionCleanup(candidateTable, deletedField, {
|
||||
table: candidateTable,
|
||||
sourceTable,
|
||||
previousSourceTable: sourceTable,
|
||||
});
|
||||
if (cleanupSpecResult.isErr()) {
|
||||
return err(cleanupSpecResult.error);
|
||||
if (cleanupResult.isErr()) {
|
||||
return err(cleanupResult.error);
|
||||
}
|
||||
|
||||
const relatedFieldIds = new Map(
|
||||
flattenAndSpecs(cleanupSpecResult.value)
|
||||
.filter(
|
||||
(
|
||||
spec
|
||||
): spec is
|
||||
| TableUpdateFieldHasErrorSpec
|
||||
| UpdateLinkConfigSpec
|
||||
| TableUpdateFieldTypeSpec =>
|
||||
spec instanceof TableUpdateFieldHasErrorSpec ||
|
||||
spec instanceof UpdateLinkConfigSpec ||
|
||||
spec instanceof TableUpdateFieldTypeSpec
|
||||
)
|
||||
.map((spec) => {
|
||||
const fieldId =
|
||||
spec instanceof TableUpdateFieldTypeSpec ? spec.oldField().id() : spec.fieldId();
|
||||
return [fieldId.toString(), fieldId] as const;
|
||||
})
|
||||
);
|
||||
|
||||
for (const relatedFieldId of relatedFieldIds.values()) {
|
||||
for (const relatedFieldId of cleanupResult.value.relatedFieldIds) {
|
||||
const snapshot = yield* await handler.fieldUndoRedoSnapshotService.capture(
|
||||
context,
|
||||
candidateTable,
|
||||
@ -352,12 +333,13 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
|
||||
});
|
||||
}
|
||||
|
||||
private buildDeletionCleanupSpecs(
|
||||
private buildDeletionCleanup(
|
||||
candidateTable: Table,
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionCleanup, DomainError> {
|
||||
const specs: Array<ISpecification<Table, ITableSpecVisitor>> = [];
|
||||
const relatedFieldIds = new Map<string, FieldId>();
|
||||
|
||||
for (const view of candidateTable.views()) {
|
||||
if (!implementsOnTeableViewFieldDeleted(view)) {
|
||||
@ -395,10 +377,23 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
|
||||
const result = field.onFieldDeleted(deletedField, context);
|
||||
if (result.isErr()) return err(result.error);
|
||||
if (result.value) {
|
||||
specs.push(result.value);
|
||||
specs.push(result.value.spec);
|
||||
this.collectRelatedFieldIds(relatedFieldIds, result.value);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(composeAndSpecsOrUndefined(specs));
|
||||
return ok({
|
||||
spec: composeAndSpecsOrUndefined(specs),
|
||||
relatedFieldIds: [...relatedFieldIds.values()],
|
||||
});
|
||||
}
|
||||
|
||||
private collectRelatedFieldIds(
|
||||
accumulator: Map<string, FieldId>,
|
||||
reaction: FieldDeletionReaction
|
||||
): void {
|
||||
for (const fieldId of reaction.relatedFieldIds) {
|
||||
accumulator.set(fieldId.toString(), fieldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,10 +19,23 @@ describe('DeleteTableCommand', () => {
|
||||
baseId: baseIdResult._unsafeUnwrap().toString(),
|
||||
tableId: tableIdResult._unsafeUnwrap().toString(),
|
||||
});
|
||||
commandResult._unsafeUnwrap();
|
||||
expect(commandResult._unsafeUnwrap().mode).toBe('soft');
|
||||
});
|
||||
|
||||
it('rejects invalid input', () => {
|
||||
DeleteTableCommand.create({ baseId: 'bad', tableId: 'bad' })._unsafeUnwrapErr();
|
||||
});
|
||||
|
||||
it('accepts an explicit permanent mode', () => {
|
||||
const baseId = createBaseId('b')._unsafeUnwrap();
|
||||
const tableId = createTableId('b')._unsafeUnwrap();
|
||||
|
||||
const command = DeleteTableCommand.create({
|
||||
baseId: baseId.toString(),
|
||||
tableId: tableId.toString(),
|
||||
mode: 'permanent',
|
||||
})._unsafeUnwrap();
|
||||
|
||||
expect(command.mode).toBe('permanent');
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,17 +6,22 @@ import { BaseId } from '../domain/base/BaseId';
|
||||
import { domainError, type DomainError } from '../domain/shared/DomainError';
|
||||
import { TableId } from '../domain/table/TableId';
|
||||
|
||||
export const deleteTableModeSchema = z.enum(['soft', 'permanent']);
|
||||
|
||||
export const deleteTableInputSchema = z.object({
|
||||
baseId: z.string(),
|
||||
tableId: z.string(),
|
||||
mode: deleteTableModeSchema.default('soft'),
|
||||
});
|
||||
|
||||
export type IDeleteTableCommandInput = z.input<typeof deleteTableInputSchema>;
|
||||
export type DeleteTableMode = z.infer<typeof deleteTableModeSchema>;
|
||||
|
||||
export class DeleteTableCommand {
|
||||
private constructor(
|
||||
readonly baseId: BaseId,
|
||||
readonly tableId: TableId
|
||||
readonly tableId: TableId,
|
||||
readonly mode: DeleteTableMode
|
||||
) {}
|
||||
|
||||
static create(raw: unknown): Result<DeleteTableCommand, DomainError> {
|
||||
@ -25,7 +30,9 @@ export class DeleteTableCommand {
|
||||
return err(domainError.validation({ message: 'Invalid DeleteTableCommand input' }));
|
||||
|
||||
return BaseId.create(parsed.data.baseId).andThen((baseId) =>
|
||||
TableId.create(parsed.data.tableId).map((tableId) => new DeleteTableCommand(baseId, tableId))
|
||||
TableId.create(parsed.data.tableId).map(
|
||||
(tableId) => new DeleteTableCommand(baseId, tableId, parsed.data.mode)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,15 @@ import { err, ok } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TableDeletionSideEffectServiceResult } from '../application/services/TableDeletionSideEffectService';
|
||||
import { BaseId } from '../domain/base/BaseId';
|
||||
import { ActorId } from '../domain/shared/ActorId';
|
||||
import { domainError, type DomainError } from '../domain/shared/DomainError';
|
||||
import type { IDomainEvent } from '../domain/shared/DomainEvent';
|
||||
import type { ISpecification } from '../domain/shared/specification/ISpecification';
|
||||
import { TableActionTriggerRequested } from '../domain/table/events/TableActionTriggerRequested';
|
||||
import { TableDeleted } from '../domain/table/events/TableDeleted';
|
||||
import { TableTrashed } from '../domain/table/events/TableTrashed';
|
||||
import { FieldName } from '../domain/table/fields/FieldName';
|
||||
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
|
||||
import type { Table } from '../domain/table/Table';
|
||||
@ -18,7 +21,11 @@ import type { IEventBus } from '../ports/EventBus';
|
||||
import type { IExecutionContext, IUnitOfWorkTransaction } from '../ports/ExecutionContext';
|
||||
import type { ILogger, LogContext } from '../ports/Logger';
|
||||
import type { IFindOptions } from '../ports/RepositoryQuery';
|
||||
import type { ITableRepository } from '../ports/TableRepository';
|
||||
import type {
|
||||
ITableRepository,
|
||||
TableDeleteOptions,
|
||||
TableFindOptions,
|
||||
} from '../ports/TableRepository';
|
||||
import type { ITableSchemaRepository } from '../ports/TableSchemaRepository';
|
||||
import type { IUnitOfWork, UnitOfWorkOperation } from '../ports/UnitOfWork';
|
||||
import { DeleteTableCommand } from './DeleteTableCommand';
|
||||
@ -43,6 +50,8 @@ const buildTable = (baseIdSeed: string): Table => {
|
||||
class FakeTableRepository implements ITableRepository {
|
||||
tables: Table[] = [];
|
||||
deleted: Table[] = [];
|
||||
deleteModes: Array<'soft' | 'permanent'> = [];
|
||||
deletedTableIds = new Set<string>();
|
||||
failDelete: DomainError | undefined;
|
||||
|
||||
async insert(_: IExecutionContext, table: Table): Promise<Result<Table, DomainError>> {
|
||||
@ -60,9 +69,16 @@ class FakeTableRepository implements ITableRepository {
|
||||
|
||||
async findOne(
|
||||
_: IExecutionContext,
|
||||
spec: ISpecification<Table, ITableSpecVisitor>
|
||||
spec: ISpecification<Table, ITableSpecVisitor>,
|
||||
options?: Pick<TableFindOptions, 'state'>
|
||||
): Promise<Result<Table, DomainError>> {
|
||||
const found = this.tables.find((table) => spec.isSatisfiedBy(table));
|
||||
const state = options?.state ?? 'active';
|
||||
const found = this.tables.find((table) => {
|
||||
const isDeleted = this.deletedTableIds.has(table.id().toString());
|
||||
if (state === 'active' && isDeleted) return false;
|
||||
if (state === 'deleted' && !isDeleted) return false;
|
||||
return spec.isSatisfiedBy(table);
|
||||
});
|
||||
if (!found) return err(domainError.notFound({ message: 'Not found' }));
|
||||
return ok(found);
|
||||
}
|
||||
@ -83,15 +99,22 @@ class FakeTableRepository implements ITableRepository {
|
||||
return err(domainError.notImplemented({ message: 'Not implemented' }));
|
||||
}
|
||||
|
||||
async delete(_: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
|
||||
async delete(
|
||||
_: IExecutionContext,
|
||||
table: Table,
|
||||
options?: TableDeleteOptions
|
||||
): Promise<Result<void, DomainError>> {
|
||||
if (this.failDelete) return err(this.failDelete);
|
||||
this.deleted.push(table);
|
||||
this.deleteModes.push(options?.mode ?? 'soft');
|
||||
this.deletedTableIds.add(table.id().toString());
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTableSchemaRepository implements ITableSchemaRepository {
|
||||
deleted: Table[] = [];
|
||||
deleteModes: Array<'soft' | 'permanent'> = [];
|
||||
failDelete: DomainError | undefined;
|
||||
|
||||
async insert(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
|
||||
@ -113,9 +136,16 @@ class FakeTableSchemaRepository implements ITableSchemaRepository {
|
||||
return ok(table);
|
||||
}
|
||||
|
||||
async delete(_: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
|
||||
async delete(
|
||||
_: IExecutionContext,
|
||||
table: Table,
|
||||
options?: TableDeleteOptions
|
||||
): Promise<Result<void, DomainError>> {
|
||||
if (this.failDelete) return err(this.failDelete);
|
||||
this.deleted.push(table);
|
||||
this.deleteModes.push(options?.mode ?? 'soft');
|
||||
if ((options?.mode ?? 'soft') === 'permanent') {
|
||||
this.deleted.push(table);
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
@ -137,6 +167,23 @@ class FakeEventBus implements IEventBus {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTableDeletionSideEffectService {
|
||||
events: IDomainEvent[] = [];
|
||||
postPersistEvents: IDomainEvent[] = [];
|
||||
calls = 0;
|
||||
failExecute: DomainError | undefined;
|
||||
|
||||
async execute(): Promise<Result<TableDeletionSideEffectServiceResult, DomainError>> {
|
||||
this.calls += 1;
|
||||
if (this.failExecute) return err(this.failExecute);
|
||||
return ok({
|
||||
events: [...this.events],
|
||||
postPersistEvents: [...this.postPersistEvents],
|
||||
updatedTables: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FakeLogger implements ILogger {
|
||||
readonly messages: string[] = [];
|
||||
|
||||
@ -180,12 +227,13 @@ class FakeUnitOfWork implements IUnitOfWork {
|
||||
}
|
||||
|
||||
describe('DeleteTableHandler', () => {
|
||||
it('deletes tables and publishes events', async () => {
|
||||
it('soft deletes tables without dropping schema and publishes TableTrashed', async () => {
|
||||
const table = buildTable('a');
|
||||
const repo = new FakeTableRepository();
|
||||
repo.tables.push(table);
|
||||
const schemaRepo = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const sideEffectService = new FakeTableDeletionSideEffectService();
|
||||
const logger = new FakeLogger();
|
||||
const unitOfWork = new FakeUnitOfWork();
|
||||
|
||||
@ -195,22 +243,135 @@ describe('DeleteTableHandler', () => {
|
||||
});
|
||||
commandResult._unsafeUnwrap();
|
||||
|
||||
const handler = new DeleteTableHandler(repo, schemaRepo, eventBus, logger, unitOfWork);
|
||||
const handler = new DeleteTableHandler(
|
||||
repo,
|
||||
schemaRepo,
|
||||
sideEffectService as never,
|
||||
eventBus,
|
||||
logger,
|
||||
unitOfWork
|
||||
);
|
||||
const result = await handler.handle(createContext(), commandResult._unsafeUnwrap());
|
||||
result._unsafeUnwrap();
|
||||
|
||||
expect(schemaRepo.deleted).toHaveLength(1);
|
||||
expect(schemaRepo.deleted).toHaveLength(0);
|
||||
expect(schemaRepo.deleteModes).toEqual(['soft']);
|
||||
expect(repo.deleted).toHaveLength(1);
|
||||
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(true);
|
||||
expect(repo.deleteModes).toEqual(['soft']);
|
||||
expect(eventBus.published.some((event) => event instanceof TableTrashed)).toBe(true);
|
||||
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(false);
|
||||
expect(unitOfWork.transactions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('publishes side-effect post-persist events without returning them in the response payload', async () => {
|
||||
const table = buildTable('s');
|
||||
const repo = new FakeTableRepository();
|
||||
repo.tables.push(table);
|
||||
const schemaRepo = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const sideEffectService = new FakeTableDeletionSideEffectService();
|
||||
sideEffectService.postPersistEvents = [
|
||||
TableActionTriggerRequested.create({
|
||||
tableId: table.id(),
|
||||
baseId: table.baseId(),
|
||||
actionKey: 'setRecord',
|
||||
payload: { tableId: table.id().toString(), fieldIds: [] },
|
||||
}),
|
||||
];
|
||||
|
||||
const handler = new DeleteTableHandler(
|
||||
repo,
|
||||
schemaRepo,
|
||||
sideEffectService as never,
|
||||
eventBus,
|
||||
new FakeLogger(),
|
||||
new FakeUnitOfWork()
|
||||
);
|
||||
|
||||
const command = DeleteTableCommand.create({
|
||||
baseId: table.baseId().toString(),
|
||||
tableId: table.id().toString(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = await handler.handle(createContext(), command);
|
||||
const payload = result._unsafeUnwrap();
|
||||
const responseEventNames = payload.events.map((event) => event.name.toString());
|
||||
const publishedEventNames = eventBus.published.map((event) => event.name.toString());
|
||||
|
||||
expect(responseEventNames).not.toContain('TableActionTriggerRequested');
|
||||
expect(publishedEventNames).toContain('TableActionTriggerRequested');
|
||||
expect(publishedEventNames).toContain('TableTrashed');
|
||||
});
|
||||
|
||||
it('permanently deletes tables and publishes TableDeleted', async () => {
|
||||
const table = buildTable('p');
|
||||
const repo = new FakeTableRepository();
|
||||
repo.tables.push(table);
|
||||
const schemaRepo = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const handler = new DeleteTableHandler(
|
||||
repo,
|
||||
schemaRepo,
|
||||
new FakeTableDeletionSideEffectService() as never,
|
||||
eventBus,
|
||||
new FakeLogger(),
|
||||
new FakeUnitOfWork()
|
||||
);
|
||||
|
||||
const command = DeleteTableCommand.create({
|
||||
baseId: table.baseId().toString(),
|
||||
tableId: table.id().toString(),
|
||||
mode: 'permanent',
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = await handler.handle(createContext(), command);
|
||||
result._unsafeUnwrap();
|
||||
|
||||
expect(schemaRepo.deleted).toHaveLength(1);
|
||||
expect(schemaRepo.deleteModes).toEqual(['permanent']);
|
||||
expect(repo.deleteModes).toEqual(['permanent']);
|
||||
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(true);
|
||||
});
|
||||
|
||||
it('permanently deletes an already trashed table without rerunning side effects', async () => {
|
||||
const table = buildTable('q');
|
||||
const repo = new FakeTableRepository();
|
||||
repo.tables.push(table);
|
||||
repo.deletedTableIds.add(table.id().toString());
|
||||
const schemaRepo = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const sideEffectService = new FakeTableDeletionSideEffectService();
|
||||
const handler = new DeleteTableHandler(
|
||||
repo,
|
||||
schemaRepo,
|
||||
sideEffectService as never,
|
||||
eventBus,
|
||||
new FakeLogger(),
|
||||
new FakeUnitOfWork()
|
||||
);
|
||||
|
||||
const command = DeleteTableCommand.create({
|
||||
baseId: table.baseId().toString(),
|
||||
tableId: table.id().toString(),
|
||||
mode: 'permanent',
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = await handler.handle(createContext(), command);
|
||||
result._unsafeUnwrap();
|
||||
|
||||
expect(sideEffectService.calls).toBe(0);
|
||||
expect(schemaRepo.deleteModes).toEqual(['permanent']);
|
||||
expect(repo.deleteModes).toEqual(['permanent']);
|
||||
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not found when table is missing', async () => {
|
||||
const table = buildTable('b');
|
||||
const repo = new FakeTableRepository();
|
||||
const handler = new DeleteTableHandler(
|
||||
repo,
|
||||
new FakeTableSchemaRepository(),
|
||||
new FakeTableDeletionSideEffectService() as never,
|
||||
new FakeEventBus(),
|
||||
new FakeLogger(),
|
||||
new FakeUnitOfWork()
|
||||
@ -232,11 +393,13 @@ describe('DeleteTableHandler', () => {
|
||||
const repo = new FakeTableRepository();
|
||||
repo.tables.push(table);
|
||||
const schemaRepo = new FakeTableSchemaRepository();
|
||||
const sideEffectService = new FakeTableDeletionSideEffectService();
|
||||
const eventBus = new FakeEventBus();
|
||||
|
||||
const handler = new DeleteTableHandler(
|
||||
repo,
|
||||
schemaRepo,
|
||||
sideEffectService as never,
|
||||
eventBus,
|
||||
new FakeLogger(),
|
||||
new FakeUnitOfWork()
|
||||
@ -248,6 +411,11 @@ describe('DeleteTableHandler', () => {
|
||||
});
|
||||
commandResult._unsafeUnwrap();
|
||||
|
||||
sideEffectService.failExecute = domainError.unexpected({ message: 'side effect failed' });
|
||||
const sideEffectResult = await handler.handle(createContext(), commandResult._unsafeUnwrap());
|
||||
expect(sideEffectResult._unsafeUnwrapErr().message).toBe('side effect failed');
|
||||
|
||||
sideEffectService.failExecute = undefined;
|
||||
schemaRepo.failDelete = domainError.unexpected({ message: 'schema delete failed' });
|
||||
const schemaResult = await handler.handle(createContext(), commandResult._unsafeUnwrap());
|
||||
expect(schemaResult._unsafeUnwrapErr().message).toBe('schema delete failed');
|
||||
|
||||
@ -2,6 +2,7 @@ import { inject, injectable } from '@teable/v2-di';
|
||||
import { err, ok, safeTry } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
|
||||
import { TableDeletionSideEffectService } from '../application/services/TableDeletionSideEffectService';
|
||||
import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError';
|
||||
import type { IDomainEvent } from '../domain/shared/DomainEvent';
|
||||
import type { Table } from '../domain/table/Table';
|
||||
@ -36,6 +37,8 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
|
||||
private readonly tableRepository: TableRepositoryPort.ITableRepository,
|
||||
@inject(v2CoreTokens.tableSchemaRepository)
|
||||
private readonly tableSchemaRepository: TableSchemaRepositoryPort.ITableSchemaRepository,
|
||||
@inject(v2CoreTokens.tableDeletionSideEffectService)
|
||||
private readonly tableDeletionSideEffectService: TableDeletionSideEffectService,
|
||||
@inject(v2CoreTokens.eventBus)
|
||||
private readonly eventBus: EventBusPort.IEventBus,
|
||||
@inject(v2CoreTokens.logger)
|
||||
@ -52,6 +55,7 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
|
||||
const logger = this.logger.scope('command', { name: DeleteTableHandler.name }).child({
|
||||
baseId: command.baseId.toString(),
|
||||
tableId: command.tableId.toString(),
|
||||
mode: command.mode,
|
||||
});
|
||||
logger.debug('DeleteTableHandler.start', {
|
||||
actorId: context.actorId.toString(),
|
||||
@ -59,11 +63,24 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
|
||||
|
||||
const tableRepository = this.tableRepository;
|
||||
const tableSchemaRepository = this.tableSchemaRepository;
|
||||
const tableDeletionSideEffectService = this.tableDeletionSideEffectService;
|
||||
const unitOfWork = this.unitOfWork;
|
||||
const eventBus = this.eventBus;
|
||||
const result = await safeTry<DeleteTableResult, DomainError>(async function* () {
|
||||
const specResult = yield* TableAggregate.specs(command.baseId).byId(command.tableId).build();
|
||||
const tableResult = await tableRepository.findOne(context, specResult);
|
||||
const activeTableResult = await tableRepository.findOne(context, specResult);
|
||||
let tableResult = activeTableResult;
|
||||
let shouldRunSideEffects = activeTableResult.isOk();
|
||||
|
||||
if (
|
||||
command.mode === 'permanent' &&
|
||||
activeTableResult.isErr() &&
|
||||
isNotFoundError(activeTableResult.error)
|
||||
) {
|
||||
tableResult = await tableRepository.findOne(context, specResult, { state: 'all' });
|
||||
shouldRunSideEffects = false;
|
||||
}
|
||||
|
||||
if (tableResult.isErr()) {
|
||||
if (isNotFoundError(tableResult.error)) {
|
||||
return err(domainError.notFound({ code: 'table.not_found', message: 'Table not found' }));
|
||||
@ -71,18 +88,39 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
|
||||
return err(tableResult.error);
|
||||
}
|
||||
const table = tableResult.value;
|
||||
const sideEffectEvents: IDomainEvent[] = [];
|
||||
const sideEffectPostPersistEvents: IDomainEvent[] = [];
|
||||
yield* await unitOfWork.withTransaction(context, async (transactionContext) => {
|
||||
const resultAsync = safeTry<void, DomainError>(async function* () {
|
||||
yield* await tableSchemaRepository.delete(transactionContext, table);
|
||||
yield* await tableRepository.delete(transactionContext, table);
|
||||
if (shouldRunSideEffects) {
|
||||
const sideEffectResult = yield* await tableDeletionSideEffectService.execute(
|
||||
transactionContext,
|
||||
{ table }
|
||||
);
|
||||
sideEffectEvents.push(...sideEffectResult.events);
|
||||
sideEffectPostPersistEvents.push(...sideEffectResult.postPersistEvents);
|
||||
}
|
||||
yield* await tableSchemaRepository.delete(transactionContext, table, {
|
||||
mode: command.mode,
|
||||
});
|
||||
yield* await tableRepository.delete(transactionContext, table, {
|
||||
mode: command.mode,
|
||||
});
|
||||
return ok(undefined);
|
||||
});
|
||||
return await resultAsync;
|
||||
});
|
||||
yield* table.markDeleted();
|
||||
const events = table.pullDomainEvents();
|
||||
yield* await eventBus.publishMany(context, events);
|
||||
return ok(DeleteTableResult.create(table, events));
|
||||
if (command.mode === 'permanent') {
|
||||
yield* table.markDeleted();
|
||||
} else {
|
||||
yield* table.markTrashed();
|
||||
}
|
||||
const responseEvents = [...sideEffectEvents, ...table.pullDomainEvents()];
|
||||
yield* await eventBus.publishMany(context, [
|
||||
...responseEvents,
|
||||
...sideEffectPostPersistEvents,
|
||||
]);
|
||||
return ok(DeleteTableResult.create(table, responseEvents));
|
||||
});
|
||||
if (result.isOk()) {
|
||||
logger.debug('DeleteTableHandler.success', {
|
||||
|
||||
@ -17,6 +17,7 @@ import { RecordMutationSpecResolverService } from '../application/services/Recor
|
||||
import { RecordWriteSideEffectService } from '../application/services/RecordWriteSideEffectService';
|
||||
import { RecordWriteUndoRedoPlanService } from '../application/services/RecordWriteUndoRedoPlanService';
|
||||
import { TableCreationService } from '../application/services/TableCreationService';
|
||||
import { TableDeletionSideEffectService } from '../application/services/TableDeletionSideEffectService';
|
||||
import { TableQueryService } from '../application/services/TableQueryService';
|
||||
import { TableUpdateFlow } from '../application/services/TableUpdateFlow';
|
||||
import { UndoRedoService } from '../application/services/UndoRedoService';
|
||||
@ -49,6 +50,7 @@ import { v2CoreTokens } from '../ports/tokens';
|
||||
* | fieldCreationSideEffectService | FieldCreationSideEffectService | Cross-table field creation side effects |
|
||||
* | fieldDeletionSideEffectService | FieldDeletionSideEffectService | Cross-table field deletion side effects |
|
||||
* | fieldUpdateSideEffectService | FieldUpdateSideEffectService | Cascading field updates for dependent fields |
|
||||
* | tableDeletionSideEffectService | TableDeletionSideEffectService | Cross-table cleanup before table deletion |
|
||||
* | foreignTableLoaderService | ForeignTableLoaderService | Load and validate foreign table references |
|
||||
* | linkTitleResolverService | LinkTitleResolverService | Resolve link titles to record IDs |
|
||||
* | attachmentValueResolverService | AttachmentValueResolverService | Resolve attachment values on writes |
|
||||
@ -141,6 +143,16 @@ export const registerV2CoreServices = (
|
||||
});
|
||||
}
|
||||
|
||||
if (!container.isRegistered(v2CoreTokens.tableDeletionSideEffectService)) {
|
||||
container.register(
|
||||
v2CoreTokens.tableDeletionSideEffectService,
|
||||
TableDeletionSideEffectService,
|
||||
{
|
||||
lifecycle,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!container.isRegistered(v2CoreTokens.fieldUndoRedoSnapshotService)) {
|
||||
container.register(v2CoreTokens.fieldUndoRedoSnapshotService, FieldUndoRedoSnapshotService, {
|
||||
lifecycle,
|
||||
|
||||
@ -26,6 +26,10 @@ export class DomainEventName extends ValueObject {
|
||||
return new DomainEventName('TableDeleted');
|
||||
}
|
||||
|
||||
static tableTrashed(): DomainEventName {
|
||||
return new DomainEventName('TableTrashed');
|
||||
}
|
||||
|
||||
static tableRenamed(): DomainEventName {
|
||||
return new DomainEventName('TableRenamed');
|
||||
}
|
||||
|
||||
@ -3,9 +3,15 @@ import type { Result } from 'neverthrow';
|
||||
import type { DomainError } from '../shared/DomainError';
|
||||
import type { ISpecification } from '../shared/specification/ISpecification';
|
||||
import type { Field } from './fields/Field';
|
||||
import type { FieldId } from './fields/FieldId';
|
||||
import type { ITableSpecVisitor } from './specs/ITableSpecVisitor';
|
||||
import type { Table } from './Table';
|
||||
|
||||
export type FieldDeletionReaction = {
|
||||
readonly spec: ISpecification<Table, ITableSpecVisitor>;
|
||||
readonly relatedFieldIds: ReadonlyArray<FieldId>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context provided to entities when processing field-deletion side effects.
|
||||
*/
|
||||
@ -32,7 +38,7 @@ export interface OnTeableFieldDeleted {
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError>;
|
||||
): Result<FieldDeletionReaction | undefined, DomainError>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
66
packages/v2/core/src/domain/table/OnTeableTableDeleted.ts
Normal file
66
packages/v2/core/src/domain/table/OnTeableTableDeleted.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { Result } from 'neverthrow';
|
||||
|
||||
import type { IExecutionContext } from '../../ports/ExecutionContext';
|
||||
import type { DomainError } from '../shared/DomainError';
|
||||
import type { IDomainEvent } from '../shared/DomainEvent';
|
||||
import type { ISpecification } from '../shared/specification/ISpecification';
|
||||
import type { FieldId } from './fields/FieldId';
|
||||
import type { ITableSpecVisitor } from './specs/ITableSpecVisitor';
|
||||
import type { Table } from './Table';
|
||||
|
||||
export type TableDeletionAfterPersistHook = (
|
||||
context: IExecutionContext,
|
||||
updatedTable: Table,
|
||||
deletedTable: Table
|
||||
) => Promise<Result<{ events: ReadonlyArray<IDomainEvent>; table: Table }, DomainError>>;
|
||||
|
||||
export type TableDeletionReaction = {
|
||||
readonly spec: ISpecification<Table, ITableSpecVisitor>;
|
||||
readonly afterPersist?: TableDeletionAfterPersistHook;
|
||||
};
|
||||
|
||||
export type TableDeletionHookFactory = {
|
||||
readonly createFieldUpdateAfterPersistHook: (
|
||||
fieldId: FieldId,
|
||||
updateSpec: ISpecification<Table, ITableSpecVisitor>
|
||||
) => TableDeletionAfterPersistHook;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context provided to entities when processing table-deletion side effects.
|
||||
*/
|
||||
export type TableDeletionContext = {
|
||||
/** The table containing the entity that is handling the deletion */
|
||||
readonly table: Table;
|
||||
/** Hook factory implemented by the application layer */
|
||||
readonly hooks: TableDeletionHookFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for entities that need to respond when a table is deleted.
|
||||
*/
|
||||
export interface OnTeableTableDeleted {
|
||||
/**
|
||||
* Called when a table is deleted.
|
||||
*
|
||||
* @param deletedTable The table that is being deleted
|
||||
* @param context Additional context including the host table state
|
||||
* @returns A composed spec to apply in response to deletion, or undefined
|
||||
*/
|
||||
onTableDeleted(
|
||||
deletedTable: Table,
|
||||
context: TableDeletionContext
|
||||
): Result<TableDeletionReaction | undefined, DomainError>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an entity implements OnTeableTableDeleted.
|
||||
*/
|
||||
export function implementsOnTeableTableDeleted(entity: unknown): entity is OnTeableTableDeleted {
|
||||
return (
|
||||
entity != null &&
|
||||
typeof entity === 'object' &&
|
||||
'onTableDeleted' in entity &&
|
||||
typeof (entity as OnTeableTableDeleted).onTableDeleted === 'function'
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { FieldDeleted } from './events/FieldDeleted';
|
||||
import { FieldUpdated } from './events/FieldUpdated';
|
||||
import { TableCreated } from './events/TableCreated';
|
||||
import { TableDeleted } from './events/TableDeleted';
|
||||
import { TableTrashed } from './events/TableTrashed';
|
||||
import { TableRenamed } from './events/TableRenamed';
|
||||
import { DbFieldName } from './fields/DbFieldName';
|
||||
import { Field } from './fields/Field';
|
||||
@ -111,6 +112,31 @@ describe('Table', () => {
|
||||
expect(events[0]).toBeInstanceOf(TableDeleted);
|
||||
});
|
||||
|
||||
it('emits TableTrashed when marking trashed', () => {
|
||||
const baseIdResult = createBaseId('y');
|
||||
const tableNameResult = TableName.create('Trash');
|
||||
const fieldNameResult = FieldName.create('Title');
|
||||
const viewNameResult = ViewName.create('Grid');
|
||||
[baseIdResult, tableNameResult, fieldNameResult, viewNameResult].forEach((r) =>
|
||||
r._unsafeUnwrap()
|
||||
);
|
||||
|
||||
const builder = Table.builder()
|
||||
.withBaseId(baseIdResult._unsafeUnwrap())
|
||||
.withName(tableNameResult._unsafeUnwrap());
|
||||
builder.field().singleLineText().withName(fieldNameResult._unsafeUnwrap()).done();
|
||||
builder.view().grid().withName(viewNameResult._unsafeUnwrap()).done();
|
||||
|
||||
const table = builder.build()._unsafeUnwrap();
|
||||
table.pullDomainEvents();
|
||||
|
||||
table.markTrashed()._unsafeUnwrap();
|
||||
|
||||
const events = table.pullDomainEvents();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toBeInstanceOf(TableTrashed);
|
||||
});
|
||||
|
||||
it('rehydrates without emitting events', () => {
|
||||
const baseIdResult = createBaseId('b');
|
||||
const tableIdResult = createTableId('b');
|
||||
|
||||
@ -16,6 +16,7 @@ import type { RecordCreateSource } from './events/RecordFieldValuesDTO';
|
||||
import { TableActionTriggerRequested } from './events/TableActionTriggerRequested';
|
||||
import { TableCreated } from './events/TableCreated';
|
||||
import { TableDeleted } from './events/TableDeleted';
|
||||
import { TableTrashed } from './events/TableTrashed';
|
||||
import type { DbFieldName } from './fields/DbFieldName';
|
||||
import type { Field } from './fields/Field';
|
||||
import type { FieldId } from './fields/FieldId';
|
||||
@ -584,6 +585,19 @@ export class Table extends AggregateRoot<TableId> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
markTrashed(): Result<void, DomainError> {
|
||||
this.addDomainEvent(
|
||||
TableTrashed.create({
|
||||
tableId: this.id(),
|
||||
baseId: this.baseIdValue,
|
||||
tableName: this.nameValue,
|
||||
fieldIds: this.fieldIds(),
|
||||
viewIds: this.viewIds(),
|
||||
})
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
requestActionTrigger(params: {
|
||||
actionKey: ITableActionKey;
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
37
packages/v2/core/src/domain/table/events/TableTrashed.ts
Normal file
37
packages/v2/core/src/domain/table/events/TableTrashed.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { BaseId } from '../../base/BaseId';
|
||||
import type { IDomainEvent } from '../../shared/DomainEvent';
|
||||
import { DomainEventName } from '../../shared/DomainEventName';
|
||||
import { OccurredAt } from '../../shared/OccurredAt';
|
||||
import type { FieldId } from '../fields/FieldId';
|
||||
import type { TableId } from '../TableId';
|
||||
import type { TableName } from '../TableName';
|
||||
import type { ViewId } from '../views/ViewId';
|
||||
|
||||
export class TableTrashed implements IDomainEvent {
|
||||
readonly name = DomainEventName.tableTrashed();
|
||||
readonly occurredAt = OccurredAt.now();
|
||||
|
||||
private constructor(
|
||||
readonly tableId: TableId,
|
||||
readonly baseId: BaseId,
|
||||
readonly tableName: TableName,
|
||||
readonly fieldIds: ReadonlyArray<FieldId>,
|
||||
readonly viewIds: ReadonlyArray<ViewId>
|
||||
) {}
|
||||
|
||||
static create(params: {
|
||||
tableId: TableId;
|
||||
baseId: BaseId;
|
||||
tableName: TableName;
|
||||
fieldIds: ReadonlyArray<FieldId>;
|
||||
viewIds: ReadonlyArray<ViewId>;
|
||||
}): TableTrashed {
|
||||
return new TableTrashed(
|
||||
params.tableId,
|
||||
params.baseId,
|
||||
params.tableName,
|
||||
[...params.fieldIds],
|
||||
[...params.viewIds]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { ok } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../../base/BaseId';
|
||||
@ -14,6 +15,7 @@ import { CellValueMultiplicity } from './CellValueMultiplicity';
|
||||
import { CellValueType } from './CellValueType';
|
||||
import { ConditionalLookupField } from './ConditionalLookupField';
|
||||
import { ConditionalLookupOptions } from './ConditionalLookupOptions';
|
||||
import { FieldHasError } from './FieldHasError';
|
||||
import { FormulaExpression } from './FormulaExpression';
|
||||
import { FormulaField } from './FormulaField';
|
||||
import { LongTextField } from './LongTextField';
|
||||
@ -649,9 +651,13 @@ describe('ConditionalLookupField.onDependencyUpdated', () => {
|
||||
previousSourceTable: foreignTable,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()).toBeInstanceOf(TableUpdateFieldTypeSpec);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldTypeSpec);
|
||||
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([
|
||||
conditionalLookup.id().toString(),
|
||||
]);
|
||||
|
||||
const spec = result._unsafeUnwrap() as TableUpdateFieldTypeSpec;
|
||||
const spec = reaction?.spec as TableUpdateFieldTypeSpec;
|
||||
const nextField = spec.newField() as ConditionalLookupField;
|
||||
const nextCondition = nextField.conditionalLookupOptions().condition().toDto();
|
||||
|
||||
@ -662,4 +668,142 @@ describe('ConditionalLookupField.onDependencyUpdated', () => {
|
||||
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'isNotEmpty' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing error when the foreign sort field is deleted', () => {
|
||||
const foreignTableId = createTableId('q');
|
||||
const lookupFieldId = createFieldId('r');
|
||||
const sortFieldId = createFieldId('s');
|
||||
const hostPrimaryFieldId = createFieldId('t');
|
||||
const conditionalLookupFieldId = createFieldId('u');
|
||||
const conditionalLookup = ConditionalLookupField.create({
|
||||
id: conditionalLookupFieldId,
|
||||
name: FieldName.create('Conditional Lookup Sorted')._unsafeUnwrap(),
|
||||
innerField: SingleLineTextField.create({
|
||||
id: createFieldId('v'),
|
||||
name: FieldName.create('Title')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
conditionalLookupOptions: ConditionalLookupOptions.create({
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'isNotEmpty' }],
|
||||
},
|
||||
sort: { fieldId: sortFieldId.toString(), order: 'desc' },
|
||||
limit: 2,
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const hostTableBuilder = Table.builder()
|
||||
.withId(createTableId('w'))
|
||||
.withBaseId(createBaseId('x'))
|
||||
.withName(TableName.create('Host')._unsafeUnwrap());
|
||||
hostTableBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(hostPrimaryFieldId)
|
||||
.withName(FieldName.create('Host Primary')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
hostTableBuilder.view().defaultGrid().done();
|
||||
const hostTable = hostTableBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const foreignTableBuilder = Table.builder()
|
||||
.withId(foreignTableId)
|
||||
.withBaseId(createBaseId('x'))
|
||||
.withName(TableName.create('Foreign')._unsafeUnwrap());
|
||||
foreignTableBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(lookupFieldId)
|
||||
.withName(FieldName.create('Lookup')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
foreignTableBuilder
|
||||
.field()
|
||||
.number()
|
||||
.withId(sortFieldId)
|
||||
.withName(FieldName.create('Score')._unsafeUnwrap())
|
||||
.done();
|
||||
foreignTableBuilder.view().defaultGrid().done();
|
||||
const foreignTable = foreignTableBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const hostWithConditionalLookup = hostTable
|
||||
.addField(conditionalLookup, { foreignTables: [foreignTable] })
|
||||
._unsafeUnwrap();
|
||||
const fieldInHost = hostWithConditionalLookup
|
||||
.getField((field) => field.id().equals(conditionalLookupFieldId))
|
||||
._unsafeUnwrap() as ConditionalLookupField;
|
||||
fieldInHost.setHasError(FieldHasError.error());
|
||||
|
||||
const deletedField = foreignTable.getFields().find((field) => field.id().equals(sortFieldId));
|
||||
expect(deletedField).toBeDefined();
|
||||
if (!deletedField) return;
|
||||
|
||||
const result = fieldInHost.onFieldDeleted(deletedField, {
|
||||
table: hostWithConditionalLookup,
|
||||
sourceTable: foreignTable,
|
||||
previousSourceTable: foreignTable,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()?.relatedFieldIds.map((id) => id.toString())).toEqual([
|
||||
conditionalLookupFieldId.toString(),
|
||||
]);
|
||||
|
||||
const updatedTable = result._unsafeUnwrap()?.spec.mutate(hostWithConditionalLookup);
|
||||
expect(updatedTable?.isOk()).toBe(true);
|
||||
const updatedField = updatedTable
|
||||
?._unsafeUnwrap()
|
||||
.getField((field) => field.id().equals(conditionalLookupFieldId));
|
||||
expect(updatedField?.isOk()).toBe(true);
|
||||
|
||||
const nextField = updatedField?._unsafeUnwrap() as ConditionalLookupField;
|
||||
expect(nextField.hasError().isError()).toBe(true);
|
||||
expect(nextField.conditionalLookupOptions().condition().toDto().sort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets hasError when the foreign table is deleted', () => {
|
||||
const foreignTableId = createTableId('y');
|
||||
const lookupFieldId = createFieldId('z');
|
||||
const sortFieldId = createFieldId('a');
|
||||
|
||||
const conditionalLookup = ConditionalLookupField.create({
|
||||
id: createFieldId('b'),
|
||||
name: FieldName.create('Conditional Lookup')._unsafeUnwrap(),
|
||||
innerField: SingleLineTextField.create({
|
||||
id: createFieldId('c'),
|
||||
name: FieldName.create('Lookup Inner')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
conditionalLookupOptions: ConditionalLookupOptions.create({
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'isNotEmpty' }],
|
||||
},
|
||||
sort: { fieldId: sortFieldId.toString(), order: 'desc' },
|
||||
limit: 2,
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = conditionalLookup.onTableDeleted({ id: () => foreignTableId } as never, {
|
||||
table: {} as never,
|
||||
hooks: {
|
||||
createFieldUpdateAfterPersistHook: () => async () =>
|
||||
ok({
|
||||
events: [],
|
||||
table: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
|
||||
expect(reaction?.afterPersist).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,16 @@ import { domainError, type DomainError } from '../../../shared/DomainError';
|
||||
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../../shared/specification/ISpecification';
|
||||
import { ForeignTable } from '../../ForeignTable';
|
||||
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
FieldDeletionContext,
|
||||
FieldDeletionReaction,
|
||||
OnTeableFieldDeleted,
|
||||
} from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
OnTeableTableDeleted,
|
||||
TableDeletionContext,
|
||||
TableDeletionReaction,
|
||||
} from '../../OnTeableTableDeleted';
|
||||
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
@ -64,7 +73,11 @@ import { SingleSelectField } from './SingleSelectField';
|
||||
*/
|
||||
export class ConditionalLookupField
|
||||
extends Field
|
||||
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
|
||||
implements
|
||||
ForeignTableRelatedField,
|
||||
OnTeableFieldUpdated,
|
||||
OnTeableFieldDeleted,
|
||||
OnTeableTableDeleted
|
||||
{
|
||||
private innerFieldValue: Field | undefined;
|
||||
private innerOptionsPatchValue: Readonly<Record<string, unknown>> | undefined;
|
||||
@ -574,7 +587,7 @@ export class ConditionalLookupField
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionReaction | undefined, DomainError> {
|
||||
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
|
||||
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
|
||||
const optionsDto = this.conditionalLookupOptionsValue.toDto();
|
||||
@ -626,7 +639,22 @@ export class ConditionalLookupField
|
||||
return err(nextFieldResult.error);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldTypeSpec.create(this, nextFieldResult.value));
|
||||
const specs: Array<ISpecification<Table, ITableSpecVisitor>> = [
|
||||
TableUpdateFieldTypeSpec.create(this, nextFieldResult.value),
|
||||
];
|
||||
if (this.hasError().isError()) {
|
||||
specs.push(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
}
|
||||
|
||||
const spec = composeAndSpecsOrUndefined(specs);
|
||||
if (!spec) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok({
|
||||
spec,
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
const shouldSetError =
|
||||
@ -638,7 +666,23 @@ export class ConditionalLookupField
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
onTableDeleted(
|
||||
deletedTable: Table,
|
||||
_context: TableDeletionContext
|
||||
): Result<TableDeletionReaction | undefined, DomainError> {
|
||||
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
});
|
||||
}
|
||||
|
||||
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import { ok } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../../base/BaseId';
|
||||
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import { Table } from '../../Table';
|
||||
import { TableId } from '../../TableId';
|
||||
import { TableName } from '../../TableName';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
|
||||
import { TableId } from '../../TableId';
|
||||
import { CellValueMultiplicity } from './CellValueMultiplicity';
|
||||
import { CellValueType } from './CellValueType';
|
||||
import { ConditionalRollupConfig } from './ConditionalRollupConfig';
|
||||
import { ConditionalRollupField } from './ConditionalRollupField';
|
||||
import { FieldHasError } from './FieldHasError';
|
||||
import { LongTextField } from './LongTextField';
|
||||
import { NumberField } from './NumberField';
|
||||
import { RollupExpression } from './RollupExpression';
|
||||
@ -20,6 +25,7 @@ import { SingleSelectField } from './SingleSelectField';
|
||||
|
||||
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
const createTableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
|
||||
const createConditionalRollupField = (statusFieldId: FieldId) => {
|
||||
const config = ConditionalRollupConfig.create({
|
||||
@ -322,9 +328,11 @@ describe('ConditionalRollupField.onFieldDeleted', () => {
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()).toBeInstanceOf(TableUpdateFieldTypeSpec);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldTypeSpec);
|
||||
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([field.id().toString()]);
|
||||
|
||||
const spec = result._unsafeUnwrap() as TableUpdateFieldTypeSpec;
|
||||
const spec = reaction?.spec as TableUpdateFieldTypeSpec;
|
||||
const nextField = spec.newField() as ConditionalRollupField;
|
||||
const nextCondition = nextField.config().condition().toDto();
|
||||
expect(nextCondition.filter).toEqual({
|
||||
@ -334,4 +342,144 @@ describe('ConditionalRollupField.onFieldDeleted', () => {
|
||||
expect(nextCondition.sort).toBeUndefined();
|
||||
expect(nextCondition.limit).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves existing error when the foreign sort field is deleted', () => {
|
||||
const foreignTableId = createTableId('d');
|
||||
const lookupFieldId = createFieldId('e');
|
||||
const sortFieldId = createFieldId('f');
|
||||
const hostPrimaryFieldId = createFieldId('g');
|
||||
const conditionalRollupFieldId = createFieldId('h');
|
||||
const conditionalRollup = ConditionalRollupField.createPending({
|
||||
id: conditionalRollupFieldId,
|
||||
name: FieldName.create('Conditional Rollup Sorted')._unsafeUnwrap(),
|
||||
config: ConditionalRollupConfig.create({
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'is', value: 'Active' }],
|
||||
},
|
||||
sort: { fieldId: sortFieldId.toString(), order: 'asc' },
|
||||
limit: 1,
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
expression: RollupExpression.default(),
|
||||
resultType: {
|
||||
cellValueType: CellValueType.number(),
|
||||
isMultipleCellValue: CellValueMultiplicity.single(),
|
||||
},
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const hostTableBuilder = Table.builder()
|
||||
.withId(createTableId('i'))
|
||||
.withBaseId(createBaseId('j'))
|
||||
.withName(TableName.create('Host')._unsafeUnwrap());
|
||||
hostTableBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(hostPrimaryFieldId)
|
||||
.withName(FieldName.create('Host Primary')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
hostTableBuilder.view().defaultGrid().done();
|
||||
const hostTable = hostTableBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const foreignTableBuilder = Table.builder()
|
||||
.withId(foreignTableId)
|
||||
.withBaseId(createBaseId('j'))
|
||||
.withName(TableName.create('Foreign')._unsafeUnwrap());
|
||||
foreignTableBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(lookupFieldId)
|
||||
.withName(FieldName.create('Lookup')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
foreignTableBuilder
|
||||
.field()
|
||||
.number()
|
||||
.withId(sortFieldId)
|
||||
.withName(FieldName.create('Score')._unsafeUnwrap())
|
||||
.done();
|
||||
foreignTableBuilder.view().defaultGrid().done();
|
||||
const foreignTable = foreignTableBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const hostWithConditionalRollup = hostTable
|
||||
.addField(conditionalRollup, { foreignTables: [foreignTable] })
|
||||
._unsafeUnwrap();
|
||||
const fieldInHost = hostWithConditionalRollup
|
||||
.getField((field) => field.id().equals(conditionalRollupFieldId))
|
||||
._unsafeUnwrap() as ConditionalRollupField;
|
||||
fieldInHost.setHasError(FieldHasError.error());
|
||||
|
||||
const deletedField = foreignTable.getFields().find((field) => field.id().equals(sortFieldId));
|
||||
expect(deletedField).toBeDefined();
|
||||
if (!deletedField) return;
|
||||
|
||||
const result = fieldInHost.onFieldDeleted(deletedField, {
|
||||
table: hostWithConditionalRollup,
|
||||
sourceTable: foreignTable,
|
||||
previousSourceTable: foreignTable,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()?.relatedFieldIds.map((id) => id.toString())).toEqual([
|
||||
conditionalRollupFieldId.toString(),
|
||||
]);
|
||||
|
||||
const updatedTable = result._unsafeUnwrap()?.spec.mutate(hostWithConditionalRollup);
|
||||
expect(updatedTable?.isOk()).toBe(true);
|
||||
const updatedField = updatedTable
|
||||
?._unsafeUnwrap()
|
||||
.getField((field) => field.id().equals(conditionalRollupFieldId));
|
||||
expect(updatedField?.isOk()).toBe(true);
|
||||
|
||||
const nextField = updatedField?._unsafeUnwrap() as ConditionalRollupField;
|
||||
expect(nextField.hasError().isError()).toBe(true);
|
||||
expect(nextField.config().condition().toDto().sort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets hasError when the foreign table is deleted', () => {
|
||||
const foreignTableId = createTableId('k');
|
||||
const lookupFieldId = createFieldId('l');
|
||||
const sortFieldId = createFieldId('m');
|
||||
|
||||
const conditionalRollup = ConditionalRollupField.create({
|
||||
id: createFieldId('n'),
|
||||
name: FieldName.create('Conditional Rollup')._unsafeUnwrap(),
|
||||
config: ConditionalRollupConfig.create({
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'is', value: 'Active' }],
|
||||
},
|
||||
sort: { fieldId: sortFieldId.toString(), order: 'asc' },
|
||||
limit: 1,
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
expression: RollupExpression.create('sum({values})')._unsafeUnwrap(),
|
||||
valuesField: NumberField.create({
|
||||
id: lookupFieldId,
|
||||
name: FieldName.create('Amount')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = conditionalRollup.onTableDeleted({ id: () => foreignTableId } as never, {
|
||||
table: {} as never,
|
||||
hooks: {
|
||||
createFieldUpdateAfterPersistHook: () => async () =>
|
||||
ok({
|
||||
events: [],
|
||||
table: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
|
||||
expect(reaction?.afterPersist).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,8 +4,17 @@ import type { Result } from 'neverthrow';
|
||||
import { domainError, type DomainError } from '../../../shared/DomainError';
|
||||
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../../shared/specification/ISpecification';
|
||||
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
|
||||
import { ForeignTable } from '../../ForeignTable';
|
||||
import type {
|
||||
FieldDeletionContext,
|
||||
FieldDeletionReaction,
|
||||
OnTeableFieldDeleted,
|
||||
} from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
OnTeableTableDeleted,
|
||||
TableDeletionContext,
|
||||
TableDeletionReaction,
|
||||
} from '../../OnTeableTableDeleted';
|
||||
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
@ -77,7 +86,11 @@ type ConditionalRollupValuesType = {
|
||||
*/
|
||||
export class ConditionalRollupField
|
||||
extends Field
|
||||
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
|
||||
implements
|
||||
ForeignTableRelatedField,
|
||||
OnTeableFieldUpdated,
|
||||
OnTeableFieldDeleted,
|
||||
OnTeableTableDeleted
|
||||
{
|
||||
private constructor(
|
||||
id: FieldId,
|
||||
@ -618,7 +631,7 @@ export class ConditionalRollupField
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionReaction | undefined, DomainError> {
|
||||
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
|
||||
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
|
||||
const configDto = this.configValue.toDto();
|
||||
@ -666,7 +679,22 @@ export class ConditionalRollupField
|
||||
return err(nextFieldResult.error);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldTypeSpec.create(this, nextFieldResult.value));
|
||||
const specs: Array<ISpecification<Table, ITableSpecVisitor>> = [
|
||||
TableUpdateFieldTypeSpec.create(this, nextFieldResult.value),
|
||||
];
|
||||
if (this.hasError().isError()) {
|
||||
specs.push(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
}
|
||||
|
||||
const spec = composeAndSpecsOrUndefined(specs);
|
||||
if (!spec) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok({
|
||||
spec,
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
const shouldSetError =
|
||||
@ -678,7 +706,23 @@ export class ConditionalRollupField
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
onTableDeleted(
|
||||
deletedTable: Table,
|
||||
_context: TableDeletionContext
|
||||
): Result<TableDeletionReaction | undefined, DomainError> {
|
||||
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
});
|
||||
}
|
||||
|
||||
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../../base/BaseId';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { Table } from '../../Table';
|
||||
import { TableId } from '../../TableId';
|
||||
import { TableName } from '../../TableName';
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
import { CellValueMultiplicity } from './CellValueMultiplicity';
|
||||
@ -237,4 +242,60 @@ describe('FormulaField', () => {
|
||||
const notPersisted = fieldWithoutMetaValue.isPersistedAsGeneratedColumn();
|
||||
expect(notPersisted._unsafeUnwrap()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns field deletion reaction metadata when a dependency is deleted', () => {
|
||||
const baseId = BaseId.create(`bse${'q'.repeat(16)}`)._unsafeUnwrap();
|
||||
const tableId = TableId.create(`tbl${'r'.repeat(16)}`)._unsafeUnwrap();
|
||||
const primaryFieldId = createFieldId('s')._unsafeUnwrap();
|
||||
const deletedFieldId = createFieldId('t')._unsafeUnwrap();
|
||||
const formulaFieldId = createFieldId('u')._unsafeUnwrap();
|
||||
|
||||
const builder = Table.builder()
|
||||
.withId(tableId)
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('Formula Host')._unsafeUnwrap());
|
||||
builder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(primaryFieldId)
|
||||
.withName(FieldName.create('Title')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
builder
|
||||
.field()
|
||||
.number()
|
||||
.withId(deletedFieldId)
|
||||
.withName(FieldName.create('Amount')._unsafeUnwrap())
|
||||
.done();
|
||||
builder.view().defaultGrid().done();
|
||||
const baseTable = builder.build()._unsafeUnwrap();
|
||||
|
||||
const formulaField = FormulaField.create({
|
||||
id: formulaFieldId,
|
||||
name: FieldName.create('Derived')._unsafeUnwrap(),
|
||||
expression: FormulaExpression.create('1')._unsafeUnwrap(),
|
||||
timeZone: TimeZone.default(),
|
||||
resultType: {
|
||||
cellValueType: CellValueType.number(),
|
||||
isMultipleCellValue: CellValueMultiplicity.single(),
|
||||
},
|
||||
dependencies: [deletedFieldId],
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const deletedField = baseTable
|
||||
.getField((field) => field.id().equals(deletedFieldId))
|
||||
._unsafeUnwrap();
|
||||
|
||||
const result = formulaField.onFieldDeleted(deletedField, {
|
||||
table: baseTable,
|
||||
sourceTable: baseTable,
|
||||
previousSourceTable: baseTable,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
|
||||
expect(result._unsafeUnwrap()?.relatedFieldIds.map((id) => id.toString())).toEqual([
|
||||
formulaFieldId.toString(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,11 @@ import type { Result } from 'neverthrow';
|
||||
import { domainError, type DomainError } from '../../../shared/DomainError';
|
||||
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../../shared/specification/ISpecification';
|
||||
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
FieldDeletionContext,
|
||||
FieldDeletionReaction,
|
||||
OnTeableFieldDeleted,
|
||||
} from '../../OnTeableFieldDeleted';
|
||||
import { UpdateFormulaExpressionSpec } from '../../specs/field-updates/UpdateFormulaExpressionSpec';
|
||||
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
@ -343,7 +347,7 @@ export class FormulaField extends Field implements OnTeableFieldUpdated, OnTeabl
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionReaction | undefined, DomainError> {
|
||||
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
|
||||
if (!deletedFromHostTable || this.hasError().isError()) {
|
||||
return ok(undefined);
|
||||
@ -354,7 +358,10 @@ export class FormulaField extends Field implements OnTeableFieldUpdated, OnTeabl
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
private dependsOnDeletedField(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Result } from 'neverthrow';
|
||||
import { ok, type Result } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../../base/BaseId';
|
||||
@ -6,6 +6,7 @@ import type { DomainError } from '../../../shared/DomainError';
|
||||
import { ForeignTable } from '../../ForeignTable';
|
||||
import { UpdateLinkConfigSpec } from '../../specs/field-updates/UpdateLinkConfigSpec';
|
||||
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import { Table } from '../../Table';
|
||||
import { TableId } from '../../TableId';
|
||||
import { TableName } from '../../TableName';
|
||||
@ -13,6 +14,7 @@ import { ViewId } from '../../views/ViewId';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
import { FieldHasError } from './FieldHasError';
|
||||
import { LinkField } from './LinkField';
|
||||
import { LinkFieldConfig } from './LinkFieldConfig';
|
||||
import { LinkFieldMeta } from './LinkFieldMeta';
|
||||
@ -989,9 +991,13 @@ describe('LinkField', () => {
|
||||
previousSourceTable: foreignTable,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()).toBeInstanceOf(UpdateLinkConfigSpec);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(UpdateLinkConfigSpec);
|
||||
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([
|
||||
linkFieldId.toString(),
|
||||
]);
|
||||
|
||||
const spec = result._unsafeUnwrap() as UpdateLinkConfigSpec;
|
||||
const spec = reaction?.spec as UpdateLinkConfigSpec;
|
||||
expect(spec.nextConfig().lookupFieldId().equals(foreignPrimaryFieldId)).toBe(true);
|
||||
});
|
||||
|
||||
@ -1072,11 +1078,103 @@ describe('LinkField', () => {
|
||||
previousSourceTable: foreignTable,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()).toBeInstanceOf(UpdateLinkConfigSpec);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(UpdateLinkConfigSpec);
|
||||
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([
|
||||
linkFieldId.toString(),
|
||||
]);
|
||||
|
||||
const spec = result._unsafeUnwrap() as UpdateLinkConfigSpec;
|
||||
const spec = reaction?.spec as UpdateLinkConfigSpec;
|
||||
expect(spec.nextConfig().filter()).toBeNull();
|
||||
expect(spec.nextConfig().visibleFieldIds()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTableDeleted', () => {
|
||||
it('converts a link field to singleLineText when its foreign table is deleted', () => {
|
||||
const baseId = createBaseId('m')._unsafeUnwrap();
|
||||
const hostTableId = createTableId('n')._unsafeUnwrap();
|
||||
const foreignTableId = createTableId('o')._unsafeUnwrap();
|
||||
const hostPrimaryFieldId = createFieldId('p')._unsafeUnwrap();
|
||||
const foreignPrimaryFieldId = createFieldId('q')._unsafeUnwrap();
|
||||
const linkFieldId = createFieldId('r')._unsafeUnwrap();
|
||||
|
||||
const foreignBuilder = Table.builder()
|
||||
.withId(foreignTableId)
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('Foreign Delete')._unsafeUnwrap());
|
||||
foreignBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(foreignPrimaryFieldId)
|
||||
.withName(FieldName.create('Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
foreignBuilder.view().defaultGrid().done();
|
||||
const foreignTable = foreignBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const hostBuilder = Table.builder()
|
||||
.withId(hostTableId)
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('Host Delete')._unsafeUnwrap());
|
||||
hostBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(hostPrimaryFieldId)
|
||||
.withName(FieldName.create('Title')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
hostBuilder.view().defaultGrid().done();
|
||||
const hostTable = hostBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const linkField = LinkField.create({
|
||||
id: linkFieldId,
|
||||
name: FieldName.create('Foreign Link')._unsafeUnwrap(),
|
||||
config: LinkFieldConfig.create({
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: foreignPrimaryFieldId.toString(),
|
||||
isOneWay: true,
|
||||
fkHostTableName: 'delete_link_table',
|
||||
selfKeyName: '__id',
|
||||
foreignKeyName: '__fk_delete_link',
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
linkField.setHasError(FieldHasError.error());
|
||||
linkField.setDescription('preserve me')._unsafeUnwrap();
|
||||
|
||||
const hostWithLink = hostTable
|
||||
.addField(linkField, { foreignTables: [foreignTable] })
|
||||
._unsafeUnwrap();
|
||||
const fieldInHost = hostWithLink
|
||||
.getField((field) => field.id().equals(linkFieldId))
|
||||
._unsafeUnwrap() as LinkField;
|
||||
|
||||
const result = fieldInHost.onTableDeleted(foreignTable, {
|
||||
table: hostWithLink,
|
||||
hooks: {
|
||||
createFieldUpdateAfterPersistHook: () => async () =>
|
||||
ok({
|
||||
events: [],
|
||||
table: hostWithLink,
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldTypeSpec);
|
||||
expect(typeof reaction?.afterPersist).toBe('function');
|
||||
|
||||
const updatedTable = reaction?.spec.mutate(hostWithLink);
|
||||
expect(updatedTable?.isOk()).toBe(true);
|
||||
const updatedField = updatedTable
|
||||
?._unsafeUnwrap()
|
||||
.getField((field) => field.id().equals(linkFieldId));
|
||||
expect(updatedField?.isOk()).toBe(true);
|
||||
|
||||
expect(updatedField?._unsafeUnwrap().type().toString()).toBe('singleLineText');
|
||||
expect(updatedField?._unsafeUnwrap().hasError().isError()).toBe(true);
|
||||
expect(updatedField?._unsafeUnwrap().description()).toBe('preserve me');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,12 +5,22 @@ import type { BaseId } from '../../../base/BaseId';
|
||||
import { domainError, type DomainError } from '../../../shared/DomainError';
|
||||
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../../shared/specification/ISpecification';
|
||||
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
|
||||
import { DbTableName } from '../../DbTableName';
|
||||
import { ForeignTable } from '../../ForeignTable';
|
||||
import type {
|
||||
FieldDeletionContext,
|
||||
FieldDeletionReaction,
|
||||
OnTeableFieldDeleted,
|
||||
} from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
OnTeableTableDeleted,
|
||||
TableDeletionContext,
|
||||
TableDeletionReaction,
|
||||
} from '../../OnTeableTableDeleted';
|
||||
import { UpdateLinkConfigSpec } from '../../specs/field-updates/UpdateLinkConfigSpec';
|
||||
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import type { Table } from '../../Table';
|
||||
import type { TableId } from '../../TableId';
|
||||
import type { ViewId } from '../../views/ViewId';
|
||||
@ -41,10 +51,15 @@ import {
|
||||
} from './LinkFieldConfig';
|
||||
import { LinkFieldMeta, type LinkFieldMetaValue } from './LinkFieldMeta';
|
||||
import type { LinkRelationship } from './LinkRelationship';
|
||||
import { SingleLineTextField } from './SingleLineTextField';
|
||||
|
||||
export class LinkField
|
||||
extends Field
|
||||
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
|
||||
implements
|
||||
ForeignTableRelatedField,
|
||||
OnTeableFieldUpdated,
|
||||
OnTeableFieldDeleted,
|
||||
OnTeableTableDeleted
|
||||
{
|
||||
private constructor(
|
||||
id: FieldId,
|
||||
@ -365,7 +380,7 @@ export class LinkField
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionReaction | undefined, DomainError> {
|
||||
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
|
||||
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
|
||||
const filter = this.configValue.filter();
|
||||
@ -400,7 +415,10 @@ export class LinkField
|
||||
if (this.hasError().isError()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
return this.configDto().andThen((currentDto) =>
|
||||
@ -409,14 +427,23 @@ export class LinkField
|
||||
lookupFieldId: fallbackLookupFieldId.toString(),
|
||||
...(shouldCleanForeignFilter ? { filter: nextFilter } : {}),
|
||||
...(shouldCleanVisibleFieldIds ? { visibleFieldIds: normalizedVisibleFieldIds } : {}),
|
||||
}).map((nextConfig) =>
|
||||
composeAndSpecsOrUndefined([
|
||||
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
|
||||
...(this.hasError().isError()
|
||||
? [TableUpdateFieldHasErrorSpec.clearError(this.id(), this.hasError())]
|
||||
: []),
|
||||
])
|
||||
)
|
||||
})
|
||||
.map((nextConfig) =>
|
||||
composeAndSpecsOrUndefined([
|
||||
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
|
||||
...(this.hasError().isError()
|
||||
? [TableUpdateFieldHasErrorSpec.clearError(this.id(), this.hasError())]
|
||||
: []),
|
||||
])
|
||||
)
|
||||
.map((spec) =>
|
||||
spec
|
||||
? {
|
||||
spec,
|
||||
relatedFieldIds: [this.id()],
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -433,11 +460,20 @@ export class LinkField
|
||||
...currentDto,
|
||||
...(shouldCleanForeignFilter ? { filter: nextFilter } : {}),
|
||||
...(shouldCleanVisibleFieldIds ? { visibleFieldIds: normalizedVisibleFieldIds } : {}),
|
||||
}).map((nextConfig) =>
|
||||
composeAndSpecsOrUndefined([
|
||||
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
|
||||
])
|
||||
)
|
||||
})
|
||||
.map((nextConfig) =>
|
||||
composeAndSpecsOrUndefined([
|
||||
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
|
||||
])
|
||||
)
|
||||
.map((spec) =>
|
||||
spec
|
||||
? {
|
||||
spec,
|
||||
relatedFieldIds: [this.id()],
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -450,7 +486,52 @@ export class LinkField
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
onTableDeleted(
|
||||
deletedTable: Table,
|
||||
context: TableDeletionContext
|
||||
): Result<TableDeletionReaction | undefined, DomainError> {
|
||||
if (!deletedTable.id().equals(this.foreignTableId())) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const nextFieldResult = SingleLineTextField.create({
|
||||
id: this.id(),
|
||||
name: this.name(),
|
||||
});
|
||||
if (nextFieldResult.isErr()) {
|
||||
return err(nextFieldResult.error);
|
||||
}
|
||||
|
||||
const nextField = nextFieldResult.value;
|
||||
const setDescriptionResult = nextField.setDescription(this.description());
|
||||
if (setDescriptionResult.isErr()) {
|
||||
return err(setDescriptionResult.error);
|
||||
}
|
||||
const setAiConfigResult = nextField.setAiConfig(this.aiConfig());
|
||||
if (setAiConfigResult.isErr()) {
|
||||
return err(setAiConfigResult.error);
|
||||
}
|
||||
const setNotNullResult = nextField.setNotNull(this.notNull());
|
||||
if (setNotNullResult.isErr()) {
|
||||
return err(setNotNullResult.error);
|
||||
}
|
||||
const setUniqueResult = nextField.setUnique(this.unique());
|
||||
if (setUniqueResult.isErr()) {
|
||||
return err(setUniqueResult.error);
|
||||
}
|
||||
nextField.setHasError(this.hasError());
|
||||
|
||||
const updateSpec = TableUpdateFieldTypeSpec.create(this, nextField);
|
||||
return ok({
|
||||
spec: updateSpec,
|
||||
afterPersist: context.hooks.createFieldUpdateAfterPersistHook(this.id(), updateSpec),
|
||||
});
|
||||
}
|
||||
|
||||
setDbConfig(params: LinkFieldDbConfig): Result<void, DomainError> {
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { Result } from 'neverthrow';
|
||||
import { ok, type Result } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../../base/BaseId';
|
||||
import type { DomainError } from '../../../shared/DomainError';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import { UpdateLinkRelationshipSpec } from '../../specs/field-updates/UpdateLinkRelationshipSpec';
|
||||
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
|
||||
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import { Table } from '../../Table';
|
||||
import { TableId } from '../../TableId';
|
||||
import { TableName } from '../../TableName';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
|
||||
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
|
||||
import { UpdateLinkRelationshipSpec } from '../../specs/field-updates/UpdateLinkRelationshipSpec';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import type { Field } from '../Field';
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
@ -1552,4 +1552,42 @@ describe('LookupField', () => {
|
||||
expect(isMultiple.isMultiple()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTableDeleted', () => {
|
||||
it('sets hasError when the foreign table is deleted', () => {
|
||||
const linkFieldId = createFieldId('j')._unsafeUnwrap();
|
||||
const foreignTableId = createTableId('k')._unsafeUnwrap();
|
||||
const lookupTargetId = createFieldId('l')._unsafeUnwrap();
|
||||
|
||||
const lookupField = LookupField.create({
|
||||
id: createFieldId('m')._unsafeUnwrap(),
|
||||
name: FieldName.create('Lookup')._unsafeUnwrap(),
|
||||
innerField: SingleLineTextField.create({
|
||||
id: createFieldId('n')._unsafeUnwrap(),
|
||||
name: FieldName.create('Lookup Inner')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
lookupOptions: LookupOptions.create({
|
||||
linkFieldId: linkFieldId.toString(),
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupTargetId.toString(),
|
||||
})._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = lookupField.onTableDeleted({ id: () => foreignTableId } as never, {
|
||||
table: {} as never,
|
||||
hooks: {
|
||||
createFieldUpdateAfterPersistHook: () => async () =>
|
||||
ok({
|
||||
events: [],
|
||||
table: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
|
||||
expect(reaction?.afterPersist).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,16 +4,25 @@ import type { Result } from 'neverthrow';
|
||||
import { domainError, type DomainError } from '../../../shared/DomainError';
|
||||
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../../shared/specification/ISpecification';
|
||||
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
|
||||
import { ForeignTable } from '../../ForeignTable';
|
||||
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
|
||||
import type {
|
||||
FieldDeletionContext,
|
||||
FieldDeletionReaction,
|
||||
OnTeableFieldDeleted,
|
||||
} from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
OnTeableTableDeleted,
|
||||
TableDeletionContext,
|
||||
TableDeletionReaction,
|
||||
} from '../../OnTeableTableDeleted';
|
||||
import { UpdateLinkRelationshipSpec } from '../../specs/field-updates/UpdateLinkRelationshipSpec';
|
||||
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
|
||||
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import type { Table } from '../../Table';
|
||||
import type { TableId } from '../../TableId';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import type { DbFieldName } from '../DbFieldName';
|
||||
import { Field } from '../Field';
|
||||
import type { FieldDuplicateParams } from '../Field';
|
||||
import type { FieldId } from '../FieldId';
|
||||
@ -56,7 +65,11 @@ import { SingleSelectField } from './SingleSelectField';
|
||||
*/
|
||||
export class LookupField
|
||||
extends Field
|
||||
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
|
||||
implements
|
||||
ForeignTableRelatedField,
|
||||
OnTeableFieldUpdated,
|
||||
OnTeableFieldDeleted,
|
||||
OnTeableTableDeleted
|
||||
{
|
||||
private innerFieldValue: Field | undefined;
|
||||
private innerOptionsPatchValue: Readonly<Record<string, unknown>> | undefined;
|
||||
@ -629,7 +642,7 @@ export class LookupField
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionReaction | undefined, DomainError> {
|
||||
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
|
||||
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
|
||||
const condition = this.lookupOptionsValue.condition();
|
||||
@ -644,7 +657,23 @@ export class LookupField
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
onTableDeleted(
|
||||
deletedTable: Table,
|
||||
_context: TableDeletionContext
|
||||
): Result<TableDeletionReaction | undefined, DomainError> {
|
||||
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
});
|
||||
}
|
||||
|
||||
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { ok } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
import { TableId } from '../../TableId';
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
import { LongTextField } from './LongTextField';
|
||||
import { NumberField } from './NumberField';
|
||||
import { RollupExpression } from './RollupExpression';
|
||||
@ -90,4 +91,42 @@ describe('RollupField.onDependencyUpdated', () => {
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
|
||||
});
|
||||
|
||||
it('sets hasError when the foreign table is deleted', () => {
|
||||
const linkFieldId = createFieldId('i');
|
||||
const lookupFieldId = createFieldId('j');
|
||||
const foreignTableId = createTableId('k');
|
||||
const valuesField = NumberField.create({
|
||||
id: lookupFieldId,
|
||||
name: FieldName.create('Amount')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const rollupField = RollupField.create({
|
||||
id: createFieldId('l'),
|
||||
name: FieldName.create('Amount Sum')._unsafeUnwrap(),
|
||||
config: RollupFieldConfig.create({
|
||||
linkFieldId: linkFieldId.toString(),
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
})._unsafeUnwrap(),
|
||||
expression: RollupExpression.create('sum({values})')._unsafeUnwrap(),
|
||||
valuesField,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const result = rollupField.onTableDeleted({ id: () => foreignTableId } as never, {
|
||||
table: {} as never,
|
||||
hooks: {
|
||||
createFieldUpdateAfterPersistHook: () => async () =>
|
||||
ok({
|
||||
events: [],
|
||||
table: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const reaction = result._unsafeUnwrap();
|
||||
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
|
||||
expect(reaction?.afterPersist).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,8 +4,17 @@ import type { Result } from 'neverthrow';
|
||||
import { domainError, type DomainError } from '../../../shared/DomainError';
|
||||
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
|
||||
import type { ISpecification } from '../../../shared/specification/ISpecification';
|
||||
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
|
||||
import { ForeignTable } from '../../ForeignTable';
|
||||
import type {
|
||||
FieldDeletionContext,
|
||||
FieldDeletionReaction,
|
||||
OnTeableFieldDeleted,
|
||||
} from '../../OnTeableFieldDeleted';
|
||||
import type {
|
||||
OnTeableTableDeleted,
|
||||
TableDeletionContext,
|
||||
TableDeletionReaction,
|
||||
} from '../../OnTeableTableDeleted';
|
||||
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
|
||||
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
|
||||
@ -56,7 +65,11 @@ type RollupValuesType = {
|
||||
|
||||
export class RollupField
|
||||
extends Field
|
||||
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
|
||||
implements
|
||||
ForeignTableRelatedField,
|
||||
OnTeableFieldUpdated,
|
||||
OnTeableFieldDeleted,
|
||||
OnTeableTableDeleted
|
||||
{
|
||||
private constructor(
|
||||
id: FieldId,
|
||||
@ -547,7 +560,7 @@ export class RollupField
|
||||
onFieldDeleted(
|
||||
deletedField: Field,
|
||||
context: FieldDeletionContext
|
||||
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
|
||||
): Result<FieldDeletionReaction | undefined, DomainError> {
|
||||
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
|
||||
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
|
||||
|
||||
@ -559,7 +572,23 @@ export class RollupField
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
relatedFieldIds: [this.id()],
|
||||
});
|
||||
}
|
||||
|
||||
onTableDeleted(
|
||||
deletedTable: Table,
|
||||
_context: TableDeletionContext
|
||||
): Result<TableDeletionReaction | undefined, DomainError> {
|
||||
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
return ok({
|
||||
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
|
||||
});
|
||||
}
|
||||
|
||||
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {
|
||||
|
||||
@ -47,14 +47,15 @@ import type { TableAddSelectOptionsSpec } from './TableAddSelectOptionsSpec';
|
||||
import type { TableByBaseIdSpec } from './TableByBaseIdSpec';
|
||||
import type { TableByIdSpec } from './TableByIdSpec';
|
||||
import type { TableByIdsSpec } from './TableByIdsSpec';
|
||||
import type { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec';
|
||||
import type { TableByNameLikeSpec } from './TableByNameLikeSpec';
|
||||
import type { TableByNameSpec } from './TableByNameSpec';
|
||||
import type { TableDuplicateFieldSpec } from './TableDuplicateFieldSpec';
|
||||
import type { TableRemoveFieldSpec } from './TableRemoveFieldSpec';
|
||||
import type { TableRenameSpec } from './TableRenameSpec';
|
||||
import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec';
|
||||
import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec';
|
||||
import type { TableUpdateFieldDbFieldNameSpec } from './TableUpdateFieldDbFieldNameSpec';
|
||||
import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec';
|
||||
import type { TableUpdateFieldDescriptionSpec } from './TableUpdateFieldDescriptionSpec';
|
||||
import type { TableUpdateFieldHasErrorSpec } from './TableUpdateFieldHasErrorSpec';
|
||||
import type { TableUpdateFieldNameSpec } from './TableUpdateFieldNameSpec';
|
||||
@ -75,6 +76,9 @@ export interface ITableSpecVisitor<TResult = unknown> extends ISpecVisitor {
|
||||
visitTableRename(spec: TableRenameSpec): Result<TResult, DomainError>;
|
||||
visitTableByBaseId(spec: TableByBaseIdSpec): Result<TResult, DomainError>;
|
||||
visitTableById(spec: TableByIdSpec): Result<TResult, DomainError>;
|
||||
visitTableByIncomingReferenceToTable(
|
||||
spec: TableByIncomingReferenceToTableSpec
|
||||
): Result<TResult, DomainError>;
|
||||
visitTableByIds(spec: TableByIdsSpec): Result<TResult, DomainError>;
|
||||
visitTableByName(spec: TableByNameSpec): Result<TResult, DomainError>;
|
||||
visitTableByNameLike(spec: TableByNameLikeSpec): Result<TResult, DomainError>;
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { ok } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
|
||||
import type { DomainError } from '../../shared/DomainError';
|
||||
import type { ISpecification } from '../../shared/specification/ISpecification';
|
||||
import { LinkForeignTableReferenceVisitor } from '../fields/visitors/LinkForeignTableReferenceVisitor';
|
||||
import type { Table } from '../Table';
|
||||
import type { TableId } from '../TableId';
|
||||
import type { ITableSpecVisitor } from './ITableSpecVisitor';
|
||||
|
||||
export class TableByIncomingReferenceToTableSpec<V extends ITableSpecVisitor = ITableSpecVisitor>
|
||||
implements ISpecification<Table, V>
|
||||
{
|
||||
private readonly linkReferenceVisitor = new LinkForeignTableReferenceVisitor();
|
||||
|
||||
private constructor(private readonly tableIdValue: TableId) {}
|
||||
|
||||
static create(tableId: TableId): TableByIncomingReferenceToTableSpec {
|
||||
return new TableByIncomingReferenceToTableSpec(tableId);
|
||||
}
|
||||
|
||||
tableId(): TableId {
|
||||
return this.tableIdValue;
|
||||
}
|
||||
|
||||
isSatisfiedBy(t: Table): boolean {
|
||||
const referencesResult = this.linkReferenceVisitor.collect(t.getFields());
|
||||
if (referencesResult.isErr()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return referencesResult.value.some((reference) =>
|
||||
reference.foreignTableId.equals(this.tableIdValue)
|
||||
);
|
||||
}
|
||||
|
||||
mutate(t: Table): Result<Table, DomainError> {
|
||||
return ok(t);
|
||||
}
|
||||
|
||||
accept(v: V): Result<void, DomainError> {
|
||||
return v.visitTableByIncomingReferenceToTable(this).map(() => undefined);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../base/BaseId';
|
||||
import { FieldName } from '../fields/FieldName';
|
||||
import { LinkFieldConfig } from '../fields/types/LinkFieldConfig';
|
||||
import { Table } from '../Table';
|
||||
import { TableName } from '../TableName';
|
||||
|
||||
@ -19,6 +20,24 @@ const buildTable = (baseId: BaseId, name: TableName) => {
|
||||
return tableResult._unsafeUnwrap();
|
||||
};
|
||||
|
||||
const buildHostTableReferencing = (hostBaseId: BaseId, foreignTable: Table, name: TableName) => {
|
||||
const hostNameField = FieldName.create('Host Name')._unsafeUnwrap();
|
||||
const linkFieldName = FieldName.create('Foreign Link')._unsafeUnwrap();
|
||||
const linkConfig = LinkFieldConfig.create({
|
||||
baseId: foreignTable.baseId().toString(),
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: foreignTable.id().toString(),
|
||||
lookupFieldId: foreignTable.primaryFieldId().toString(),
|
||||
isOneWay: true,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const builder = Table.builder().withBaseId(hostBaseId).withName(name);
|
||||
builder.field().singleLineText().withName(hostNameField).primary().done();
|
||||
builder.field().link().withName(linkFieldName).withConfig(linkConfig).done();
|
||||
builder.view().defaultGrid().done();
|
||||
return builder.build()._unsafeUnwrap();
|
||||
};
|
||||
|
||||
describe('TableSpecBuilder', () => {
|
||||
it('includes base id spec by default', () => {
|
||||
const baseIdResult = BaseId.create(`bse${'a'.repeat(16)}`);
|
||||
@ -153,4 +172,23 @@ describe('TableSpecBuilder', () => {
|
||||
expect(specResult._unsafeUnwrap().isSatisfiedBy(table)).toBe(true);
|
||||
expect(specResult._unsafeUnwrap().isSatisfiedBy(otherTable)).toBe(false);
|
||||
});
|
||||
|
||||
it('supports incoming-reference specs across bases', () => {
|
||||
const foreignBaseId = BaseId.create(`bse${'i'.repeat(16)}`)._unsafeUnwrap();
|
||||
const hostBaseId = BaseId.create(`bse${'j'.repeat(16)}`)._unsafeUnwrap();
|
||||
const foreignName = TableName.create('Foreign')._unsafeUnwrap();
|
||||
const hostName = TableName.create('Host')._unsafeUnwrap();
|
||||
const unrelatedName = TableName.create('Unrelated')._unsafeUnwrap();
|
||||
|
||||
const foreignTable = buildTable(foreignBaseId, foreignName);
|
||||
const hostTable = buildHostTableReferencing(hostBaseId, foreignTable, hostName);
|
||||
const unrelatedTable = buildTable(hostBaseId, unrelatedName);
|
||||
|
||||
const specResult = Table.specs().byIncomingReferenceToTable(foreignTable.id()).build();
|
||||
specResult._unsafeUnwrap();
|
||||
|
||||
expect(specResult._unsafeUnwrap().isSatisfiedBy(foreignTable)).toBe(false);
|
||||
expect(specResult._unsafeUnwrap().isSatisfiedBy(hostTable)).toBe(true);
|
||||
expect(specResult._unsafeUnwrap().isSatisfiedBy(unrelatedTable)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import type { ITableSpecVisitor } from './ITableSpecVisitor';
|
||||
import { TableByBaseIdSpec } from './TableByBaseIdSpec';
|
||||
import { TableByIdSpec } from './TableByIdSpec';
|
||||
import { TableByIdsSpec } from './TableByIdsSpec';
|
||||
import { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec';
|
||||
import { TableByNameLikeSpec } from './TableByNameLikeSpec';
|
||||
import { TableByNameSpec } from './TableByNameSpec';
|
||||
|
||||
@ -52,6 +53,11 @@ export class TableSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, Tabl
|
||||
return this;
|
||||
}
|
||||
|
||||
byIncomingReferenceToTable(tableId: TableId): TableSpecBuilder {
|
||||
this.addSpec(TableByIncomingReferenceToTableSpec.create(tableId));
|
||||
return this;
|
||||
}
|
||||
|
||||
byIds(tableIds: ReadonlyArray<TableId>): TableSpecBuilder {
|
||||
this.addSpec(TableByIdsSpec.create(tableIds));
|
||||
return this;
|
||||
|
||||
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { BaseId } from '../../base/BaseId';
|
||||
import type { ISpecification } from '../../shared/specification/ISpecification';
|
||||
import { FieldName } from '../fields/FieldName';
|
||||
import { LinkFieldConfig } from '../fields/types/LinkFieldConfig';
|
||||
import { Table } from '../Table';
|
||||
import { TableName } from '../TableName';
|
||||
import type {
|
||||
@ -52,13 +53,15 @@ import type { TableAddSelectOptionsSpec } from './TableAddSelectOptionsSpec';
|
||||
import { TableByBaseIdSpec } from './TableByBaseIdSpec';
|
||||
import { TableByIdSpec } from './TableByIdSpec';
|
||||
import { TableByIdsSpec } from './TableByIdsSpec';
|
||||
import { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec';
|
||||
import { TableByNameLikeSpec } from './TableByNameLikeSpec';
|
||||
import { TableByNameSpec } from './TableByNameSpec';
|
||||
import type { TableDuplicateFieldSpec } from './TableDuplicateFieldSpec';
|
||||
import type { TableRemoveFieldSpec } from './TableRemoveFieldSpec';
|
||||
import type { TableRenameSpec } from './TableRenameSpec';
|
||||
import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec';
|
||||
import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec';
|
||||
import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec';
|
||||
import type { TableUpdateFieldDbFieldNameSpec } from './TableUpdateFieldDbFieldNameSpec';
|
||||
import type { TableUpdateFieldDescriptionSpec } from './TableUpdateFieldDescriptionSpec';
|
||||
import type { TableUpdateFieldHasErrorSpec } from './TableUpdateFieldHasErrorSpec';
|
||||
import type { TableUpdateFieldNameSpec } from './TableUpdateFieldNameSpec';
|
||||
@ -123,6 +126,13 @@ class SpyVisitor implements ITableSpecVisitor {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_: TableByIncomingReferenceToTableSpec
|
||||
): ReturnType<ITableSpecVisitor['visitTableByIncomingReferenceToTable']> {
|
||||
this.calls.push('TableByIncomingReferenceToTableSpec');
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIds(_: TableByIdsSpec): ReturnType<ITableSpecVisitor['visitTableByIds']> {
|
||||
this.calls.push('TableByIdsSpec');
|
||||
return ok(undefined);
|
||||
@ -153,7 +163,7 @@ class SpyVisitor implements ITableSpecVisitor {
|
||||
}
|
||||
|
||||
visitTableUpdateFieldDbFieldName(
|
||||
_: any
|
||||
_: TableUpdateFieldDbFieldNameSpec
|
||||
): ReturnType<ITableSpecVisitor['visitTableUpdateFieldDbFieldName']> {
|
||||
this.calls.push('TableUpdateFieldDbFieldNameSpec');
|
||||
return ok(undefined);
|
||||
@ -490,6 +500,24 @@ const buildTable = (baseId: BaseId, name: TableName) => {
|
||||
return tableResult._unsafeUnwrap();
|
||||
};
|
||||
|
||||
const buildHostTableReferencing = (hostBaseId: BaseId, foreignTable: Table, name: TableName) => {
|
||||
const hostNameField = FieldName.create('Host Title')._unsafeUnwrap();
|
||||
const linkFieldName = FieldName.create('Foreign Link')._unsafeUnwrap();
|
||||
const linkConfig = LinkFieldConfig.create({
|
||||
baseId: foreignTable.baseId().toString(),
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: foreignTable.id().toString(),
|
||||
lookupFieldId: foreignTable.primaryFieldId().toString(),
|
||||
isOneWay: true,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const builder = Table.builder().withBaseId(hostBaseId).withName(name);
|
||||
builder.field().singleLineText().withName(hostNameField).primary().done();
|
||||
builder.field().link().withName(linkFieldName).withConfig(linkConfig).done();
|
||||
builder.view().defaultGrid().done();
|
||||
return builder.build()._unsafeUnwrap();
|
||||
};
|
||||
|
||||
describe('Table specs', () => {
|
||||
it('evaluates base id spec', () => {
|
||||
const baseIdResult = BaseId.create(`bse${'a'.repeat(16)}`);
|
||||
@ -578,4 +606,25 @@ describe('Table specs', () => {
|
||||
spec.accept(visitor)._unsafeUnwrap();
|
||||
expect(visitor.calls).toContain('TableByNameLikeSpec');
|
||||
});
|
||||
|
||||
it('evaluates incoming-reference specs', () => {
|
||||
const foreignBaseId = BaseId.create(`bse${'f'.repeat(16)}`)._unsafeUnwrap();
|
||||
const hostBaseId = BaseId.create(`bse${'g'.repeat(16)}`)._unsafeUnwrap();
|
||||
const foreignName = TableName.create('Foreign')._unsafeUnwrap();
|
||||
const hostName = TableName.create('Host')._unsafeUnwrap();
|
||||
const unrelatedName = TableName.create('Other')._unsafeUnwrap();
|
||||
|
||||
const foreignTable = buildTable(foreignBaseId, foreignName);
|
||||
const hostTable = buildHostTableReferencing(hostBaseId, foreignTable, hostName);
|
||||
const unrelatedTable = buildTable(hostBaseId, unrelatedName);
|
||||
|
||||
const spec = TableByIncomingReferenceToTableSpec.create(foreignTable.id());
|
||||
expect(spec.isSatisfiedBy(foreignTable)).toBe(false);
|
||||
expect(spec.isSatisfiedBy(hostTable)).toBe(true);
|
||||
expect(spec.isSatisfiedBy(unrelatedTable)).toBe(false);
|
||||
|
||||
const visitor = new SpyVisitor();
|
||||
spec.accept(visitor)._unsafeUnwrap();
|
||||
expect(visitor.calls).toContain('TableByIncomingReferenceToTableSpec');
|
||||
});
|
||||
});
|
||||
|
||||
@ -62,6 +62,7 @@ import type { TableAddSelectOptionsSpec } from '../TableAddSelectOptionsSpec';
|
||||
import type { TableByBaseIdSpec } from '../TableByBaseIdSpec';
|
||||
import type { TableByIdSpec } from '../TableByIdSpec';
|
||||
import type { TableByIdsSpec } from '../TableByIdsSpec';
|
||||
import type { TableByIncomingReferenceToTableSpec } from '../TableByIncomingReferenceToTableSpec';
|
||||
import type { TableByNameLikeSpec } from '../TableByNameLikeSpec';
|
||||
import type { TableByNameSpec } from '../TableByNameSpec';
|
||||
import type { TableDuplicateFieldSpec } from '../TableDuplicateFieldSpec';
|
||||
@ -213,6 +214,12 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor<void>
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_spec: TableByIncomingReferenceToTableSpec
|
||||
): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@ -57,14 +57,15 @@ import type { TableAddSelectOptionsSpec } from '../TableAddSelectOptionsSpec';
|
||||
import type { TableByBaseIdSpec } from '../TableByBaseIdSpec';
|
||||
import type { TableByIdSpec } from '../TableByIdSpec';
|
||||
import type { TableByIdsSpec } from '../TableByIdsSpec';
|
||||
import type { TableByIncomingReferenceToTableSpec } from '../TableByIncomingReferenceToTableSpec';
|
||||
import type { TableByNameLikeSpec } from '../TableByNameLikeSpec';
|
||||
import type { TableByNameSpec } from '../TableByNameSpec';
|
||||
import type { TableDuplicateFieldSpec } from '../TableDuplicateFieldSpec';
|
||||
import type { TableRemoveFieldSpec } from '../TableRemoveFieldSpec';
|
||||
import type { TableRenameSpec } from '../TableRenameSpec';
|
||||
import type { TableUpdateFieldAiConfigSpec } from '../TableUpdateFieldAiConfigSpec';
|
||||
import type { TableUpdateFieldConstraintsSpec } from '../TableUpdateFieldConstraintsSpec';
|
||||
import type { TableUpdateFieldDbFieldNameSpec } from '../TableUpdateFieldDbFieldNameSpec';
|
||||
import type { TableUpdateFieldAiConfigSpec } from '../TableUpdateFieldAiConfigSpec';
|
||||
import type { TableUpdateFieldDescriptionSpec } from '../TableUpdateFieldDescriptionSpec';
|
||||
import type { TableUpdateFieldHasErrorSpec } from '../TableUpdateFieldHasErrorSpec';
|
||||
import type { TableUpdateFieldNameSpec } from '../TableUpdateFieldNameSpec';
|
||||
@ -246,6 +247,13 @@ export class TableSpecEventVisitor implements ITableSpecVisitor<void> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIncomingReferenceToTable(
|
||||
_spec: TableByIncomingReferenceToTableSpec<ITableSpecVisitor<void>>
|
||||
): Result<void, DomainError> {
|
||||
// Query-only spec, no events generated
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableByIds(_spec: TableByIdsSpec<ITableSpecVisitor<void>>): Result<void, DomainError> {
|
||||
// Query-only spec, no events generated
|
||||
return ok(undefined);
|
||||
|
||||
@ -1,28 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseId } from '../../../../base/BaseId';
|
||||
import { FieldId } from '../../../fields/FieldId';
|
||||
import { FieldName } from '../../../fields/FieldName';
|
||||
import { NumberFormatting, NumberFormattingType } from '../../../fields/types/NumberFormatting';
|
||||
import { Table } from '../../../Table';
|
||||
import { TableName } from '../../../TableName';
|
||||
import { FieldCreated } from '../../../events/FieldCreated';
|
||||
import { FieldDeleted } from '../../../events/FieldDeleted';
|
||||
import { FieldUpdated } from '../../../events/FieldUpdated';
|
||||
import { TableRenamed } from '../../../events/TableRenamed';
|
||||
import { FieldId } from '../../../fields/FieldId';
|
||||
import { FieldName } from '../../../fields/FieldName';
|
||||
import { NumberFormatting, NumberFormattingType } from '../../../fields/types/NumberFormatting';
|
||||
import { SelectOption } from '../../../fields/types/SelectOption';
|
||||
import { SingleLineTextField } from '../../../fields/types/SingleLineTextField';
|
||||
import { SingleSelectField } from '../../../fields/types/SingleSelectField';
|
||||
import { Table } from '../../../Table';
|
||||
import { TableName } from '../../../TableName';
|
||||
import { UpdateNumberFormattingSpec } from '../../field-updates/UpdateNumberFormattingSpec';
|
||||
import { TableAddFieldSpec } from '../../TableAddFieldSpec';
|
||||
import { TableByBaseIdSpec } from '../../TableByBaseIdSpec';
|
||||
import { TableByIdSpec } from '../../TableByIdSpec';
|
||||
import { TableByIncomingReferenceToTableSpec } from '../../TableByIncomingReferenceToTableSpec';
|
||||
import { TableRemoveFieldSpec } from '../../TableRemoveFieldSpec';
|
||||
import { TableRenameSpec } from '../../TableRenameSpec';
|
||||
import { TableByIdSpec } from '../../TableByIdSpec';
|
||||
import { TableByBaseIdSpec } from '../../TableByBaseIdSpec';
|
||||
import { TableUpdateFieldDescriptionSpec } from '../../TableUpdateFieldDescriptionSpec';
|
||||
import { TableUpdateFieldNameSpec } from '../../TableUpdateFieldNameSpec';
|
||||
import { TableUpdateFieldTypeSpec } from '../../TableUpdateFieldTypeSpec';
|
||||
import { UpdateNumberFormattingSpec } from '../../field-updates/UpdateNumberFormattingSpec';
|
||||
import { TableEventGeneratingSpecVisitor } from '../TableEventGeneratingSpecVisitor';
|
||||
import { SingleLineTextField } from '../../../fields/types/SingleLineTextField';
|
||||
import { SingleSelectField } from '../../../fields/types/SingleSelectField';
|
||||
import { SelectOption } from '../../../fields/types/SelectOption';
|
||||
|
||||
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
@ -196,6 +197,9 @@ describe('TableEventGeneratingSpecVisitor', () => {
|
||||
const byBaseIdSpec = TableByBaseIdSpec.create(table.baseId());
|
||||
byBaseIdSpec.accept(visitor)._unsafeUnwrap();
|
||||
|
||||
const byIncomingReferenceSpec = TableByIncomingReferenceToTableSpec.create(table.id());
|
||||
byIncomingReferenceSpec.accept(visitor)._unsafeUnwrap();
|
||||
|
||||
const events = visitor.getEvents();
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ import { TableAddSelectOptionsSpec } from '../../TableAddSelectOptionsSpec';
|
||||
import { TableByBaseIdSpec } from '../../TableByBaseIdSpec';
|
||||
import { TableByIdSpec } from '../../TableByIdSpec';
|
||||
import { TableByIdsSpec } from '../../TableByIdsSpec';
|
||||
import { TableByIncomingReferenceToTableSpec } from '../../TableByIncomingReferenceToTableSpec';
|
||||
import { TableByNameLikeSpec } from '../../TableByNameLikeSpec';
|
||||
import { TableByNameSpec } from '../../TableByNameSpec';
|
||||
import { TableDuplicateFieldSpec } from '../../TableDuplicateFieldSpec';
|
||||
@ -47,6 +48,18 @@ const protoInstance = <T extends object>(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): T => Object.assign(Object.create(ctor.prototype) as T, overrides as Partial<T>);
|
||||
|
||||
type UnsafeUnwrapResult = {
|
||||
_unsafeUnwrap(): void;
|
||||
};
|
||||
|
||||
type AcceptableSpec = {
|
||||
accept(visitor: TableSpecEventVisitor): UnsafeUnwrapResult;
|
||||
};
|
||||
|
||||
const acceptSpec = (visitor: TableSpecEventVisitor, spec: AcceptableSpec) => {
|
||||
spec.accept(visitor)._unsafeUnwrap();
|
||||
};
|
||||
|
||||
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
|
||||
|
||||
@ -405,7 +418,7 @@ describe('TableSpecEventVisitor', () => {
|
||||
const fieldId = table.getFields()[0].id();
|
||||
const spec = build(fieldId);
|
||||
|
||||
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
|
||||
acceptSpec(visitor, spec);
|
||||
|
||||
const events = visitor.collectedEvents();
|
||||
expect(events).toHaveLength(1);
|
||||
@ -430,7 +443,7 @@ describe('TableSpecEventVisitor', () => {
|
||||
nextUnique: () => comparable(true),
|
||||
});
|
||||
|
||||
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
|
||||
acceptSpec(visitor, spec);
|
||||
|
||||
expect(visitor.collectedEvents()).toHaveLength(0);
|
||||
});
|
||||
@ -443,7 +456,7 @@ describe('TableSpecEventVisitor', () => {
|
||||
fieldId: () => fieldId,
|
||||
});
|
||||
|
||||
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
|
||||
acceptSpec(visitor, spec);
|
||||
|
||||
const events = visitor.collectedEvents();
|
||||
expect(events).toHaveLength(1);
|
||||
@ -476,7 +489,7 @@ describe('TableSpecEventVisitor', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
((viewMetaSpec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
|
||||
acceptSpec(visitor, viewMetaSpec);
|
||||
|
||||
const events = visitor.collectedEvents();
|
||||
expect(events.some((event) => event instanceof FieldCreated)).toBe(true);
|
||||
@ -495,11 +508,12 @@ describe('TableSpecEventVisitor', () => {
|
||||
protoInstance(TableUpdateViewQueryDefaultsSpec),
|
||||
protoInstance(TableByBaseIdSpec),
|
||||
protoInstance(TableByIdSpec),
|
||||
protoInstance(TableByIncomingReferenceToTableSpec),
|
||||
protoInstance(TableByIdsSpec),
|
||||
protoInstance(TableByNameSpec),
|
||||
protoInstance(TableByNameLikeSpec),
|
||||
].forEach((spec) => {
|
||||
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
|
||||
acceptSpec(visitor, spec);
|
||||
});
|
||||
|
||||
expect(visitor.collectedEvents()).toHaveLength(0);
|
||||
|
||||
@ -31,6 +31,7 @@ export * from './application/services/FieldCrossTableUpdateSideEffectService';
|
||||
export * from './application/services/ForeignTableLoaderService';
|
||||
export * from './application/services/LinkFieldUpdateSideEffectService';
|
||||
export * from './application/services/LinkTitleResolverService';
|
||||
export * from './application/services/TableDeletionSideEffectService';
|
||||
export * from './application/services/AttachmentValueResolverService';
|
||||
export * from './application/services/RecordMutationSpecResolverService';
|
||||
export * from './application/services/RecordWriteSideEffectService';
|
||||
@ -227,6 +228,7 @@ export * from './domain/table/fields/visitors/FieldDefaultValueVisitor';
|
||||
export * from './domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor';
|
||||
export * from './domain/table/events/TableCreated';
|
||||
export * from './domain/table/events/TableDeleted';
|
||||
export * from './domain/table/events/TableTrashed';
|
||||
export * from './domain/table/events/TableRenamed';
|
||||
export * from './domain/table/events/FieldCreated';
|
||||
export * from './domain/table/events/FieldDeleted';
|
||||
@ -243,6 +245,7 @@ export * from './domain/table/events/RecordsBatchUpdated';
|
||||
export * from './domain/table/events/RecordsDeleted';
|
||||
export * from './domain/table/events/TableActionTriggerRequested';
|
||||
export * from './domain/table/specs/TableByIdSpec';
|
||||
export * from './domain/table/specs/TableByIncomingReferenceToTableSpec';
|
||||
export * from './domain/table/specs/TableRenameSpec';
|
||||
export * from './domain/table/specs/TableByIdsSpec';
|
||||
export * from './domain/table/specs/TableByNameLikeSpec';
|
||||
@ -278,6 +281,7 @@ export * from './domain/table/fields/visitors/FieldDeletionSideEffectVisitor';
|
||||
export * from './domain/table/fields/visitors/LinkFieldUpdateSideEffectVisitor';
|
||||
export * from './domain/table/fields/OnTeableFieldUpdated';
|
||||
export * from './domain/table/OnTeableFieldDeleted';
|
||||
export * from './domain/table/OnTeableTableDeleted';
|
||||
export * from './domain/table/fields/visitors/FieldValueTypeVisitor';
|
||||
export * from './domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
|
||||
export type { AttachmentField } from './domain/table/fields/types/AttachmentField';
|
||||
|
||||
@ -8,6 +8,12 @@ import type { TableSortKey } from '../domain/table/TableSortKey';
|
||||
import type { IExecutionContext } from './ExecutionContext';
|
||||
import type { IFindOptions } from './RepositoryQuery';
|
||||
|
||||
export type TableQueryState = 'active' | 'deleted' | 'all';
|
||||
|
||||
export type TableFindOptions = IFindOptions<TableSortKey> & {
|
||||
state?: TableQueryState;
|
||||
};
|
||||
|
||||
export type FieldVersionChange = {
|
||||
fieldId: string;
|
||||
oldVersion: number;
|
||||
@ -18,6 +24,12 @@ export type TableUpdatePersistResult = {
|
||||
fieldVersionChanges?: ReadonlyArray<FieldVersionChange>;
|
||||
};
|
||||
|
||||
export type TableDeleteMode = 'soft' | 'permanent';
|
||||
|
||||
export type TableDeleteOptions = {
|
||||
mode?: TableDeleteMode;
|
||||
};
|
||||
|
||||
export interface ITableRepository {
|
||||
insert(context: IExecutionContext, table: Table): Promise<Result<Table, DomainError>>;
|
||||
insertMany(
|
||||
@ -26,12 +38,13 @@ export interface ITableRepository {
|
||||
): Promise<Result<ReadonlyArray<Table>, DomainError>>;
|
||||
findOne(
|
||||
context: IExecutionContext,
|
||||
spec: ISpecification<Table, ITableSpecVisitor>
|
||||
spec: ISpecification<Table, ITableSpecVisitor>,
|
||||
options?: Pick<TableFindOptions, 'state'>
|
||||
): Promise<Result<Table, DomainError>>;
|
||||
find(
|
||||
context: IExecutionContext,
|
||||
spec: ISpecification<Table, ITableSpecVisitor>,
|
||||
options?: IFindOptions<TableSortKey>
|
||||
options?: TableFindOptions
|
||||
): Promise<Result<ReadonlyArray<Table>, DomainError>>;
|
||||
// table identifies the row, mutateSpec drives update values via visitors.
|
||||
updateOne(
|
||||
@ -39,5 +52,9 @@ export interface ITableRepository {
|
||||
table: Table,
|
||||
mutateSpec: ISpecification<Table, ITableSpecVisitor>
|
||||
): Promise<Result<TableUpdatePersistResult | void, DomainError>>;
|
||||
delete(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>>;
|
||||
delete(
|
||||
context: IExecutionContext,
|
||||
table: Table,
|
||||
options?: TableDeleteOptions
|
||||
): Promise<Result<void, DomainError>>;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ISpecification } from '../domain/shared/specification/ISpecificati
|
||||
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
|
||||
import type { Table } from '../domain/table/Table';
|
||||
import type { IExecutionContext } from './ExecutionContext';
|
||||
import type { TableDeleteOptions } from './TableRepository';
|
||||
|
||||
export interface ITableSchemaRepository {
|
||||
insert(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>>;
|
||||
@ -17,5 +18,9 @@ export interface ITableSchemaRepository {
|
||||
table: Table,
|
||||
mutateSpec: ISpecification<Table, ITableSpecVisitor>
|
||||
): Promise<Result<Table, DomainError>>;
|
||||
delete(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>>;
|
||||
delete(
|
||||
context: IExecutionContext,
|
||||
table: Table,
|
||||
options?: TableDeleteOptions
|
||||
): Promise<Result<void, DomainError>>;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ export const v2CoreTokens = {
|
||||
fieldCrossTableUpdateSideEffectService: Symbol('v2.core.fieldCrossTableUpdateSideEffectService'),
|
||||
linkFieldUpdateSideEffectService: Symbol('v2.core.linkFieldUpdateSideEffectService'),
|
||||
foreignTableLoaderService: Symbol('v2.core.foreignTableLoaderService'),
|
||||
tableDeletionSideEffectService: Symbol('v2.core.tableDeletionSideEffectService'),
|
||||
linkTitleResolverService: Symbol('v2.core.linkTitleResolverService'),
|
||||
recordMutationSpecResolverService: Symbol('v2.core.recordMutationSpecResolverService'),
|
||||
recordWriteSideEffectService: Symbol('v2.core.recordWriteSideEffectService'),
|
||||
|
||||
52
packages/v2/devtools/src/commands/explain/delete-table.ts
Normal file
52
packages/v2/devtools/src/commands/explain/delete-table.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Command, Options } from '@effect/cli';
|
||||
import { Effect, Option } from 'effect';
|
||||
import { CommandExplain } from '../../services/CommandExplain';
|
||||
import { Output } from '../../services/Output';
|
||||
import { analyzeOption, baseIdOptionalOption, connectionOption, tableIdOption } from '../shared';
|
||||
|
||||
const deleteModeOption = Options.choice('mode', ['soft', 'permanent']).pipe(
|
||||
Options.optional,
|
||||
Options.withDescription('Delete mode: soft or permanent')
|
||||
);
|
||||
|
||||
const handler = (args: {
|
||||
readonly connection: Option.Option<string>;
|
||||
readonly baseId: Option.Option<string>;
|
||||
readonly tableId: string;
|
||||
readonly mode: Option.Option<string>;
|
||||
readonly analyze: boolean;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const commandExplain = yield* CommandExplain;
|
||||
const output = yield* Output;
|
||||
|
||||
const input = {
|
||||
baseId: Option.getOrUndefined(args.baseId),
|
||||
tableId: args.tableId,
|
||||
mode: Option.getOrUndefined(args.mode) as 'soft' | 'permanent' | undefined,
|
||||
analyze: args.analyze,
|
||||
};
|
||||
|
||||
const result = yield* commandExplain.explainDeleteTable(input).pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.gen(function* () {
|
||||
yield* output.error('explain.delete-table', input, error);
|
||||
return yield* Effect.fail(error);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
yield* output.success('explain.delete-table', input, result);
|
||||
});
|
||||
|
||||
export const explainDeleteTable = Command.make(
|
||||
'delete-table',
|
||||
{
|
||||
connection: connectionOption,
|
||||
baseId: baseIdOptionalOption,
|
||||
tableId: tableIdOption,
|
||||
mode: deleteModeOption,
|
||||
analyze: analyzeOption,
|
||||
},
|
||||
handler
|
||||
).pipe(Command.withDescription('Explain DeleteTable command execution plan'));
|
||||
@ -4,4 +4,5 @@ export { explainDelete } from './delete';
|
||||
export { explainCreateField } from './create-field';
|
||||
export { explainUpdateField } from './update-field';
|
||||
export { explainDeleteField } from './delete-field';
|
||||
export { explainDeleteTable } from './delete-table';
|
||||
export { explainPaste } from './paste';
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
explainCreateField,
|
||||
explainUpdateField,
|
||||
explainDeleteField,
|
||||
explainDeleteTable,
|
||||
explainPaste,
|
||||
} from './explain';
|
||||
import { mockGenerate } from './mock';
|
||||
@ -33,6 +34,7 @@ export const explain = Command.make('explain').pipe(
|
||||
explainCreateField,
|
||||
explainUpdateField,
|
||||
explainDeleteField,
|
||||
explainDeleteTable,
|
||||
explainPaste,
|
||||
])
|
||||
);
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
UpdateFieldCommand,
|
||||
DeleteRecordsCommand,
|
||||
DeleteFieldCommand,
|
||||
DeleteTableCommand,
|
||||
PasteCommand,
|
||||
ActorId,
|
||||
TableByIdSpec,
|
||||
@ -26,6 +27,7 @@ import {
|
||||
type ExplainCreateFieldInput,
|
||||
type ExplainCreateInput,
|
||||
type ExplainDeleteFieldInput,
|
||||
type ExplainDeleteTableInput,
|
||||
type ExplainDeleteInput,
|
||||
type ExplainPasteInput,
|
||||
type ExplainUpdateFieldInput,
|
||||
@ -164,6 +166,37 @@ export const CommandExplainLive = Layer.effect(
|
||||
});
|
||||
}),
|
||||
|
||||
explainDeleteTable: (
|
||||
input: ExplainDeleteTableInput
|
||||
): Effect.Effect<ExplainResult, CliError> =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* createContext();
|
||||
const baseId = input.baseId ?? (yield* resolveBaseId(input.tableId));
|
||||
|
||||
const commandResult = DeleteTableCommand.create({
|
||||
baseId,
|
||||
tableId: input.tableId,
|
||||
mode: input.mode,
|
||||
});
|
||||
if (commandResult.isErr()) {
|
||||
return yield* Effect.fail(CliError.fromUnknown(commandResult.error));
|
||||
}
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const result = await explainService.explain(context, commandResult.value, {
|
||||
analyze: input.analyze,
|
||||
includeSql: true,
|
||||
includeGraph: false,
|
||||
includeLocks: true,
|
||||
});
|
||||
if (result.isErr()) throw result.error;
|
||||
return result.value;
|
||||
},
|
||||
catch: (e) => CliError.fromUnknown(e),
|
||||
});
|
||||
}),
|
||||
|
||||
explainCreate: (input: ExplainCreateInput): Effect.Effect<ExplainResult, CliError> =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* createContext();
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ExplainResult } from '@teable/v2-command-explain';
|
||||
import type {
|
||||
ICreateFieldCommandInput,
|
||||
IDeleteFieldCommandInput,
|
||||
IDeleteTableCommandInput,
|
||||
IFieldUpdateInput,
|
||||
IPasteCommandInput,
|
||||
} from '@teable/v2-core';
|
||||
@ -27,6 +28,13 @@ export interface ExplainDeleteFieldInput {
|
||||
readonly analyze: boolean;
|
||||
}
|
||||
|
||||
export interface ExplainDeleteTableInput {
|
||||
readonly baseId?: IDeleteTableCommandInput['baseId'];
|
||||
readonly tableId: IDeleteTableCommandInput['tableId'];
|
||||
readonly mode?: IDeleteTableCommandInput['mode'];
|
||||
readonly analyze: boolean;
|
||||
}
|
||||
|
||||
export interface ExplainCreateInput {
|
||||
readonly tableId: string;
|
||||
readonly fields: Record<string, unknown>;
|
||||
@ -62,6 +70,9 @@ export class CommandExplain extends Context.Tag('CommandExplain')<
|
||||
readonly explainDeleteField: (
|
||||
input: ExplainDeleteFieldInput
|
||||
) => Effect.Effect<ExplainResult, CliError>;
|
||||
readonly explainDeleteTable: (
|
||||
input: ExplainDeleteTableInput
|
||||
) => Effect.Effect<ExplainResult, CliError>;
|
||||
readonly explainCreate: (input: ExplainCreateInput) => Effect.Effect<ExplainResult, CliError>;
|
||||
readonly explainUpdate: (input: ExplainUpdateInput) => Effect.Effect<ExplainResult, CliError>;
|
||||
readonly explainDelete: (input: ExplainDeleteInput) => Effect.Effect<ExplainResult, CliError>;
|
||||
|
||||
@ -36,7 +36,7 @@ describe('create-field: conditionalLookup cross-base', () => {
|
||||
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseId, tableId }),
|
||||
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@ -36,7 +36,7 @@ describe('create-field: conditionalRollup cross-base', () => {
|
||||
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseId, tableId }),
|
||||
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@ -36,7 +36,7 @@ describe('create-field: lookup cross-base', () => {
|
||||
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseId, tableId }),
|
||||
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@ -36,7 +36,7 @@ describe('create-field: rollup cross-base', () => {
|
||||
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseId, tableId }),
|
||||
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -96,7 +96,7 @@ export interface SharedTestContext {
|
||||
payload: IUpdateFieldCommandInput
|
||||
) => Promise<ReturnType<typeof parseUpdateFieldResponse>>;
|
||||
deleteField: (payload: { tableId: string; fieldId: string }) => Promise<void>;
|
||||
deleteTable: (tableId: string) => Promise<void>;
|
||||
deleteTable: (tableId: string, options?: { mode?: 'soft' | 'permanent' }) => Promise<void>;
|
||||
renameTable: (
|
||||
tableId: string,
|
||||
name: string
|
||||
@ -441,11 +441,11 @@ const initSharedContext = async (): Promise<SharedTestContext> => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTable = async (tableId: string) => {
|
||||
const deleteTable = async (tableId: string, options?: { mode?: 'soft' | 'permanent' }) => {
|
||||
const response = await fetch(`${baseUrl}/tables/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseId, tableId }),
|
||||
body: JSON.stringify({ baseId, tableId, mode: options?.mode ?? 'permanent' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@ -32,7 +32,7 @@ describe('update-field: link conversion cross-base bulk', () => {
|
||||
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseId, tableId }),
|
||||
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user