mirror of
https://github.com/teableio/teable.git
synced 2026-03-23 00:04:56 +08:00
[sync] fix: invite link item style (#1320)
Synced from teableio/teable-ee@8383bb6
This commit is contained in:
parent
7faec3133c
commit
da4c052f8a
@ -122,17 +122,17 @@
|
||||
"webpack": "5.91.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "4.0.41",
|
||||
"@ai-sdk/anthropic": "3.0.31",
|
||||
"@ai-sdk/azure": "3.0.24",
|
||||
"@ai-sdk/cohere": "3.0.16",
|
||||
"@ai-sdk/deepseek": "2.0.15",
|
||||
"@ai-sdk/google": "3.0.18",
|
||||
"@ai-sdk/mistral": "3.0.16",
|
||||
"@ai-sdk/openai": "3.0.23",
|
||||
"@ai-sdk/openai-compatible": "2.0.24",
|
||||
"@ai-sdk/togetherai": "2.0.27",
|
||||
"@ai-sdk/xai": "3.0.43",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.69",
|
||||
"@ai-sdk/anthropic": "3.0.50",
|
||||
"@ai-sdk/azure": "3.0.38",
|
||||
"@ai-sdk/cohere": "3.0.22",
|
||||
"@ai-sdk/deepseek": "2.0.21",
|
||||
"@ai-sdk/google": "3.0.34",
|
||||
"@ai-sdk/mistral": "3.0.21",
|
||||
"@ai-sdk/openai": "3.0.37",
|
||||
"@ai-sdk/openai-compatible": "2.0.31",
|
||||
"@ai-sdk/togetherai": "2.0.35",
|
||||
"@ai-sdk/xai": "3.0.60",
|
||||
"@an-epiphany/websocket-json-stream": "1.2.0",
|
||||
"@aws-sdk/client-s3": "3.609.0",
|
||||
"@aws-sdk/lib-storage": "3.609.0",
|
||||
@ -153,7 +153,7 @@
|
||||
"@nestjs/swagger": "7.3.0",
|
||||
"@nestjs/terminus": "10.2.3",
|
||||
"@nestjs/websockets": "10.3.5",
|
||||
"@openrouter/ai-sdk-provider": "2.1.1",
|
||||
"@openrouter/ai-sdk-provider": "2.2.3",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.5.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.201.1",
|
||||
@ -191,7 +191,7 @@
|
||||
"@teable/v2-di": "workspace:*",
|
||||
"@teable/v2-import": "workspace:*",
|
||||
"@valibot/to-json-schema": "1.3.0",
|
||||
"ai": "6.0.62",
|
||||
"ai": "6.0.105",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "7.0.1",
|
||||
"axios": "1.7.7",
|
||||
|
||||
@ -24,7 +24,6 @@ export const BASE_IMPORT_ATTACHMENTS_QUEUE = 'base-import-attachments-queue';
|
||||
@Processor(BASE_IMPORT_ATTACHMENTS_QUEUE)
|
||||
export class BaseImportAttachmentsQueueProcessor extends WorkerHost {
|
||||
private logger = new Logger(BaseImportAttachmentsQueueProcessor.name);
|
||||
private processedJobs = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
@ -36,19 +35,11 @@ export class BaseImportAttachmentsQueueProcessor extends WorkerHost {
|
||||
}
|
||||
|
||||
public async process(job: Job<IBaseImportJob>) {
|
||||
const jobId = String(job.id);
|
||||
if (this.processedJobs.has(jobId)) {
|
||||
this.logger.log(`Job ${jobId} already processed, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.processedJobs.add(jobId);
|
||||
|
||||
try {
|
||||
await this.handleBaseImportAttachments(job);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Process base import attachments failed: ${(error as Error)?.message}`,
|
||||
`[base import attachment] Process base import attachments failed: ${(error as Error)?.message}`,
|
||||
(error as Error)?.stack
|
||||
);
|
||||
}
|
||||
@ -109,126 +100,75 @@ export class BaseImportAttachmentsQueueProcessor extends WorkerHost {
|
||||
StorageAdapter.getBucket(UploadType.Import),
|
||||
path
|
||||
);
|
||||
const parser = unzipper.Parse();
|
||||
const parser = unzipper.Parse({ forceStream: true });
|
||||
zipStream.pipe(parser);
|
||||
const bucket = StorageAdapter.getBucket(UploadType.Table);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let processingFiles = 0;
|
||||
let hasError = false;
|
||||
try {
|
||||
for await (const entry of parser.pipe(new PassThrough({ objectMode: true }))) {
|
||||
await this.processAttachmentEntry(entry, bucket);
|
||||
}
|
||||
|
||||
parser.on('entry', (entry) => {
|
||||
const filePath = entry.path;
|
||||
const fileSuffix = filePath.split('.').pop();
|
||||
if (
|
||||
filePath.startsWith('attachments/') &&
|
||||
entry.type !== 'Directory' &&
|
||||
fileSuffix !== 'csv'
|
||||
) {
|
||||
processingFiles++;
|
||||
this.logger.log(`[base import attachment] all finished`);
|
||||
} finally {
|
||||
zipStream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const passThrough = new PassThrough();
|
||||
entry.pipe(passThrough);
|
||||
private async processAttachmentEntry(entry: unzipper.Entry, bucket: string) {
|
||||
const filePath = entry.path;
|
||||
const fileSuffix = filePath.split('.').pop() ?? '';
|
||||
|
||||
const token = filePath.replace('attachments/', '').split('.')[0];
|
||||
const isThumbnail = token.includes('thumbnail__');
|
||||
const fileSuffix = filePath.replace('attachments/', '').split('.').pop();
|
||||
const pathDir = StorageAdapter.getDir(UploadType.Table);
|
||||
const mimeTypeFromExtension = this.getFileMimeType(fileSuffix);
|
||||
if (
|
||||
!filePath.startsWith('attachments/') ||
|
||||
entry.type === 'Directory' ||
|
||||
fileSuffix === 'csv'
|
||||
) {
|
||||
entry.autodrain();
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPath = isThumbnail
|
||||
? `table/${token.split('__')[1].split('.')[0]}`
|
||||
: `${pathDir}/${token}`;
|
||||
let passThrough: PassThrough | undefined;
|
||||
try {
|
||||
const token = filePath.replace('attachments/', '').split('.')[0];
|
||||
const isThumbnail = token.includes('thumbnail__');
|
||||
const mimeType = this.getFileMimeType(fileSuffix);
|
||||
const pathDir = StorageAdapter.getDir(UploadType.Table);
|
||||
const finalPath = isThumbnail
|
||||
? `table/${token.split('__')[1].split('.')[0]}`
|
||||
: `${pathDir}/${token}`;
|
||||
const finalToken = isThumbnail ? token.split('__')[1].split('.')[0] : token;
|
||||
|
||||
const finalToken = isThumbnail ? token.split('__')[1].split('.')[0] : token;
|
||||
this.logger.log(`[base import attachment] start upload: ${token}`);
|
||||
|
||||
this.logger.log(`start upload attachment: ${token}`);
|
||||
|
||||
// this.storageAdapter
|
||||
// .uploadFile(bucket, finalPath, passThrough, {
|
||||
// // eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
// 'Content-Type': mimeTypeFromExtension,
|
||||
// })
|
||||
// .then(() => {
|
||||
// this.logger.log(`attachment finished: ${token}`);
|
||||
// processingFiles--;
|
||||
// checkComplete();
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// this.logger.error(`attachment upload error ${token}: ${err.message}`);
|
||||
// hasError = true;
|
||||
// processingFiles--;
|
||||
// checkComplete();
|
||||
// });
|
||||
|
||||
// if the token file is existed, skip the upload
|
||||
this.prismaService
|
||||
.txClient()
|
||||
.attachments.findUnique({
|
||||
where: {
|
||||
token: finalToken,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res) {
|
||||
this.logger.log(`attachment already exists: ${token}`);
|
||||
processingFiles--;
|
||||
checkComplete();
|
||||
return;
|
||||
}
|
||||
// update attachment
|
||||
await this.storageAdapter.uploadFileStream(bucket, finalPath, passThrough, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': mimeTypeFromExtension,
|
||||
});
|
||||
|
||||
this.logger.log(`attachment finished: ${token}`);
|
||||
processingFiles--;
|
||||
checkComplete();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error(`attachment upload error ${token}: ${err.message}`);
|
||||
hasError = true;
|
||||
processingFiles--;
|
||||
checkComplete();
|
||||
});
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
const existing = await this.prismaService.txClient().attachments.findUnique({
|
||||
where: { token: finalToken },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const checkComplete = () => {
|
||||
if (processingFiles === 0) {
|
||||
if (hasError) {
|
||||
reject(new Error('upload attachments error'));
|
||||
} else {
|
||||
parser.end();
|
||||
parser.destroy();
|
||||
zipStream.destroy();
|
||||
}
|
||||
if (existing) {
|
||||
this.logger.log(`[base import attachment] already exists: ${token}`);
|
||||
entry.autodrain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parser.closed) {
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
passThrough = new PassThrough();
|
||||
entry.pipe(passThrough);
|
||||
|
||||
parser.on('close', () => {
|
||||
this.logger.log(`import attachments success`);
|
||||
if (processingFiles === 0) {
|
||||
resolve(true);
|
||||
}
|
||||
await this.storageAdapter.uploadFileStream(bucket, finalPath, passThrough, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
|
||||
parser.on('error', (err) => {
|
||||
this.logger.error(`import attachments error: ${err.message}`);
|
||||
hasError = true;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
this.logger.log(`[base import attachment] ${token} finished: ${token}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`[base import attachment] upload error: ${(err as Error).message}`);
|
||||
if (passThrough) {
|
||||
passThrough.resume();
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExportMetricsService } from './export-metrics.service';
|
||||
import { ExportTracingService } from './export-tracing.service';
|
||||
|
||||
@Module({
|
||||
providers: [ExportMetricsService],
|
||||
exports: [ExportMetricsService],
|
||||
providers: [ExportMetricsService, ExportTracingService],
|
||||
exports: [ExportMetricsService, ExportTracingService],
|
||||
})
|
||||
export class ExportMetricsModule {}
|
||||
|
||||
@ -17,13 +17,6 @@ export class ExportMetricsService {
|
||||
],
|
||||
},
|
||||
});
|
||||
private readonly exportRows = this.meter.createHistogram('data.export.rows', {
|
||||
description: 'Number of rows per export task',
|
||||
unit: 'rows',
|
||||
advice: {
|
||||
explicitBucketBoundaries: [10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000],
|
||||
},
|
||||
});
|
||||
private readonly exportErrors = this.meter.createCounter('data.export.errors', {
|
||||
description: 'Total number of export errors',
|
||||
});
|
||||
@ -32,8 +25,7 @@ export class ExportMetricsService {
|
||||
this.exportTotal.add(1, { format });
|
||||
}
|
||||
|
||||
recordExportComplete(attrs: { format: string; rows: number; durationMs: number }): void {
|
||||
this.exportRows.record(attrs.rows, { format: attrs.format });
|
||||
recordExportComplete(attrs: { format: string; durationMs: number }): void {
|
||||
this.exportDuration.record(attrs.durationMs, { format: attrs.format });
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseTracingService } from '../../../tracing/base-tracing.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExportTracingService extends BaseTracingService {
|
||||
setExportAttributes(attrs: { rows: number }): void {
|
||||
this.withActiveSpan((span) => {
|
||||
span.setAttribute('data.export.rows', attrs.rows);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { FieldService } from '../../field/field.service';
|
||||
import { createFieldInstanceByVo } from '../../field/model/factory';
|
||||
import { RecordService } from '../../record/record.service';
|
||||
import { ExportMetricsService } from '../metrics/export-metrics.service';
|
||||
import { ExportTracingService } from '../metrics/export-tracing.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExportOpenApiService {
|
||||
@ -20,7 +21,8 @@ export class ExportOpenApiService {
|
||||
private readonly fieldService: FieldService,
|
||||
private readonly recordService: RecordService,
|
||||
private readonly prismaService: PrismaService,
|
||||
@Optional() private readonly exportMetrics?: ExportMetricsService
|
||||
@Optional() private readonly exportMetrics?: ExportMetricsService,
|
||||
@Optional() private readonly exportTracing?: ExportTracingService
|
||||
) {}
|
||||
async exportCsvFromTable(response: Response, tableId: string, query?: IExportCsvRo) {
|
||||
const exportStartTime = Date.now();
|
||||
@ -156,9 +158,9 @@ export class ExportOpenApiService {
|
||||
isOver = true;
|
||||
// end the stream
|
||||
csvStream.push(null);
|
||||
this.exportTracing?.setExportAttributes({ rows: count });
|
||||
this.exportMetrics?.recordExportComplete({
|
||||
format: 'csv',
|
||||
rows: count,
|
||||
durationMs: Date.now() - exportStartTime,
|
||||
});
|
||||
break;
|
||||
|
||||
54
apps/nestjs-backend/src/features/field/model/factory.spec.ts
Normal file
54
apps/nestjs-backend/src/features/field/model/factory.spec.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { IFieldVo } from '@teable/core';
|
||||
import { FieldType } from '@teable/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createFieldInstanceByVo } from './factory';
|
||||
|
||||
const baseField = {
|
||||
id: 'fldFactorySpec00001',
|
||||
name: 'Factory Field',
|
||||
dbFieldName: 'factory_field',
|
||||
unique: false,
|
||||
options: {},
|
||||
} as const;
|
||||
|
||||
describe('createFieldInstanceByVo', () => {
|
||||
it('normalizes v2 conditionalLookup using innerType and innerOptions', () => {
|
||||
const field = {
|
||||
...baseField,
|
||||
type: 'conditionalLookup',
|
||||
isLookup: true,
|
||||
isConditionalLookup: true,
|
||||
options: {
|
||||
innerType: FieldType.Number,
|
||||
innerOptions: {
|
||||
formatting: { type: 'decimal', precision: 1 },
|
||||
},
|
||||
},
|
||||
} as unknown as IFieldVo;
|
||||
|
||||
const instance = createFieldInstanceByVo(field);
|
||||
|
||||
expect(instance.type).toBe(FieldType.Number);
|
||||
expect(instance.isLookup).toBe(true);
|
||||
expect(instance.isConditionalLookup).toBe(true);
|
||||
expect(instance.options).toEqual({
|
||||
formatting: { type: 'decimal', precision: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to singleLineText when conditionalLookup innerType is missing', () => {
|
||||
const field = {
|
||||
...baseField,
|
||||
type: 'conditionalLookup',
|
||||
options: {},
|
||||
} as unknown as IFieldVo;
|
||||
|
||||
const instance = createFieldInstanceByVo(field);
|
||||
|
||||
expect(instance.type).toBe(FieldType.SingleLineText);
|
||||
expect(instance.isLookup).toBe(true);
|
||||
expect(instance.isConditionalLookup).toBe(true);
|
||||
expect(instance.options).toEqual({});
|
||||
});
|
||||
});
|
||||
@ -103,50 +103,78 @@ export function createFieldInstanceByRaw(fieldRaw: Field) {
|
||||
return createFieldInstanceByVo(rawField2FieldObj(fieldRaw));
|
||||
}
|
||||
|
||||
const normalizeConditionalLookupFieldVo = (field: IFieldVo): IFieldVo => {
|
||||
if (field.type !== ('conditionalLookup' as FieldType)) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const options =
|
||||
field.options && typeof field.options === 'object' && !Array.isArray(field.options)
|
||||
? (field.options as Record<string, unknown>)
|
||||
: {};
|
||||
const innerTypeRaw = options.innerType;
|
||||
const innerOptionsRaw = options.innerOptions;
|
||||
const innerType =
|
||||
typeof innerTypeRaw === 'string' ? (innerTypeRaw as FieldType) : FieldType.SingleLineText;
|
||||
const innerOptions =
|
||||
innerOptionsRaw && typeof innerOptionsRaw === 'object' && !Array.isArray(innerOptionsRaw)
|
||||
? (innerOptionsRaw as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
...field,
|
||||
type: innerType,
|
||||
options: innerOptions,
|
||||
isLookup: true,
|
||||
isConditionalLookup: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function createFieldInstanceByVo(field: IFieldVo) {
|
||||
switch (field.type) {
|
||||
const normalizedField = normalizeConditionalLookupFieldVo(field);
|
||||
switch (normalizedField.type) {
|
||||
case FieldType.SingleLineText:
|
||||
return plainToInstance(SingleLineTextFieldDto, field);
|
||||
return plainToInstance(SingleLineTextFieldDto, normalizedField);
|
||||
case FieldType.LongText:
|
||||
return plainToInstance(LongTextFieldDto, field);
|
||||
return plainToInstance(LongTextFieldDto, normalizedField);
|
||||
case FieldType.Number:
|
||||
return plainToInstance(NumberFieldDto, field);
|
||||
return plainToInstance(NumberFieldDto, normalizedField);
|
||||
case FieldType.SingleSelect:
|
||||
return plainToInstance(SingleSelectFieldDto, field);
|
||||
return plainToInstance(SingleSelectFieldDto, normalizedField);
|
||||
case FieldType.MultipleSelect:
|
||||
return plainToInstance(MultipleSelectFieldDto, field);
|
||||
return plainToInstance(MultipleSelectFieldDto, normalizedField);
|
||||
case FieldType.Link:
|
||||
return plainToInstance(LinkFieldDto, field);
|
||||
return plainToInstance(LinkFieldDto, normalizedField);
|
||||
case FieldType.Formula:
|
||||
return plainToInstance(FormulaFieldDto, field);
|
||||
return plainToInstance(FormulaFieldDto, normalizedField);
|
||||
case FieldType.Attachment:
|
||||
return plainToInstance(AttachmentFieldDto, field);
|
||||
return plainToInstance(AttachmentFieldDto, normalizedField);
|
||||
case FieldType.Date:
|
||||
return plainToInstance(DateFieldDto, field);
|
||||
return plainToInstance(DateFieldDto, normalizedField);
|
||||
case FieldType.Checkbox:
|
||||
return plainToInstance(CheckboxFieldDto, field);
|
||||
return plainToInstance(CheckboxFieldDto, normalizedField);
|
||||
case FieldType.Rollup:
|
||||
return plainToInstance(RollupFieldDto, field);
|
||||
return plainToInstance(RollupFieldDto, normalizedField);
|
||||
case FieldType.ConditionalRollup:
|
||||
return plainToInstance(ConditionalRollupFieldDto, field);
|
||||
return plainToInstance(ConditionalRollupFieldDto, normalizedField);
|
||||
case FieldType.Rating:
|
||||
return plainToInstance(RatingFieldDto, field);
|
||||
return plainToInstance(RatingFieldDto, normalizedField);
|
||||
case FieldType.AutoNumber:
|
||||
return plainToInstance(AutoNumberFieldDto, field);
|
||||
return plainToInstance(AutoNumberFieldDto, normalizedField);
|
||||
case FieldType.CreatedTime:
|
||||
return plainToInstance(CreatedTimeFieldDto, field);
|
||||
return plainToInstance(CreatedTimeFieldDto, normalizedField);
|
||||
case FieldType.LastModifiedTime:
|
||||
return plainToInstance(LastModifiedTimeFieldDto, field);
|
||||
return plainToInstance(LastModifiedTimeFieldDto, normalizedField);
|
||||
case FieldType.User:
|
||||
return plainToInstance(UserFieldDto, field);
|
||||
return plainToInstance(UserFieldDto, normalizedField);
|
||||
case FieldType.CreatedBy:
|
||||
return plainToInstance(CreatedByFieldDto, field);
|
||||
return plainToInstance(CreatedByFieldDto, normalizedField);
|
||||
case FieldType.LastModifiedBy:
|
||||
return plainToInstance(LastModifiedByFieldDto, field);
|
||||
return plainToInstance(LastModifiedByFieldDto, normalizedField);
|
||||
case FieldType.Button:
|
||||
return plainToInstance(ButtonFieldDto, field);
|
||||
return plainToInstance(ButtonFieldDto, normalizedField);
|
||||
default:
|
||||
assertNever(field.type);
|
||||
assertNever(normalizedField.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,29 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { CellValueType, DbFieldType, type IFieldVo } from '@teable/core';
|
||||
import { CellValueType, DbFieldType, getDefaultFormatting, type IFieldVo } from '@teable/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FieldOpenApiV2Service } from './field-open-api-v2.service';
|
||||
|
||||
type ITestFieldOpenApiV2Service = {
|
||||
mapLegacyCreateFieldToV2: (
|
||||
ro: Record<string, unknown>,
|
||||
table?: {
|
||||
getField: (
|
||||
predicate: (candidate: {
|
||||
id: () => { equals: (id: unknown) => boolean };
|
||||
relationship: () => { toString: () => string };
|
||||
}) => boolean
|
||||
) =>
|
||||
| {
|
||||
isErr: () => false;
|
||||
value: { relationship: () => { toString: () => string } };
|
||||
}
|
||||
| {
|
||||
isErr: () => true;
|
||||
};
|
||||
}
|
||||
) => Record<string, unknown>;
|
||||
mapConvertFieldToV2: (
|
||||
ro: Record<string, unknown>,
|
||||
currentField?: Record<string, unknown>
|
||||
@ -15,6 +33,31 @@ type ITestFieldOpenApiV2Service = {
|
||||
currentField?: Record<string, unknown>
|
||||
) => Record<string, unknown>;
|
||||
normalizeFieldVo: (field: unknown) => IFieldVo;
|
||||
hasDuplicatedDbFieldName: (
|
||||
table: { getFields: () => Array<unknown> },
|
||||
dbFieldName: string
|
||||
) => boolean;
|
||||
completeLegacyLinkDbConfigForCreate: (
|
||||
v2Field: Record<string, unknown>,
|
||||
currentTable: {
|
||||
dbTableName: () => {
|
||||
isErr: () => boolean;
|
||||
value: { value: () => { isErr: () => boolean; value: string } };
|
||||
};
|
||||
},
|
||||
tableQueryService: {
|
||||
getById: () => Promise<{
|
||||
isErr: () => boolean;
|
||||
value: {
|
||||
dbTableName: () => {
|
||||
isErr: () => boolean;
|
||||
value: { value: () => { isErr: () => boolean; value: string } };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
},
|
||||
context: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const createService = () =>
|
||||
@ -202,6 +245,40 @@ describe('FieldOpenApiV2Service mapConvertFieldToV2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('omits incomplete conditionalRollup result type in convert payload', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapConvertFieldToV2({
|
||||
type: 'conditionalRollup',
|
||||
options: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
expression: 'sum({values})',
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
cellValueType: 'number',
|
||||
});
|
||||
|
||||
expect(mapped).toEqual({
|
||||
type: 'conditionalRollup',
|
||||
options: {
|
||||
expression: 'sum({values})',
|
||||
},
|
||||
config: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps conditional lookup convert with carried result type from current field', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapConvertFieldToV2(
|
||||
@ -451,6 +528,391 @@ describe('FieldOpenApiV2Service mapConvertFieldToV2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FieldOpenApiV2Service mapLegacyCreateFieldToV2', () => {
|
||||
it('passes dbFieldName through create payload', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'singleLineText',
|
||||
name: 'TextField',
|
||||
dbFieldName: 'fldCustomCreateField001',
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'singleLineText',
|
||||
name: 'TextField',
|
||||
dbFieldName: 'fldCustomCreateField001',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes aiConfig through create payload', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'singleLineText',
|
||||
aiConfig: {
|
||||
type: 'summary',
|
||||
sourceFieldId: 'fldSource000000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'singleLineText',
|
||||
aiConfig: {
|
||||
type: 'summary',
|
||||
sourceFieldId: 'fldSource000000001',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not keep legacy false lookup multiplicity without link relationship context', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'singleLineText',
|
||||
isLookup: true,
|
||||
isMultipleCellValue: false,
|
||||
lookupOptions: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'lookup',
|
||||
options: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
expect(mapped).not.toHaveProperty('isMultipleCellValue');
|
||||
});
|
||||
|
||||
it('does not derive lookup multiplicity at openapi mapping layer', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'multipleSelect',
|
||||
isLookup: true,
|
||||
lookupOptions: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'lookup',
|
||||
options: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
expect(mapped).not.toHaveProperty('isMultipleCellValue');
|
||||
});
|
||||
|
||||
it('marks legacy lookup create payload to derive multiplicity in domain layer', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'singleLineText',
|
||||
isLookup: true,
|
||||
lookupOptions: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'lookup',
|
||||
legacyMultiplicityDerivation: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit true lookup multiplicity from legacy payload', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'date',
|
||||
isLookup: true,
|
||||
isMultipleCellValue: true,
|
||||
lookupOptions: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'lookup',
|
||||
isMultipleCellValue: true,
|
||||
options: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps conditional lookup create payload to v2 conditionalLookup input', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'number',
|
||||
isLookup: true,
|
||||
isConditionalLookup: true,
|
||||
options: {
|
||||
formatting: {
|
||||
type: 'currency',
|
||||
precision: 1,
|
||||
symbol: '¥',
|
||||
},
|
||||
},
|
||||
lookupOptions: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'conditionalLookup',
|
||||
options: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mapped.id).toEqual(expect.stringMatching(/^fld[\da-zA-Z]{16}$/));
|
||||
});
|
||||
|
||||
it('omits incomplete conditionalRollup result type in create payload', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'conditionalRollup',
|
||||
cellValueType: 'number',
|
||||
options: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
expression: 'sum({values})',
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toEqual({
|
||||
id: expect.any(String),
|
||||
type: 'conditionalRollup',
|
||||
options: {
|
||||
expression: 'sum({values})',
|
||||
},
|
||||
config: {
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps rollup create payload and splits config from options', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
id: 'fldCreate0000000001',
|
||||
type: 'rollup',
|
||||
options: {
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
expression: 'sum({values})',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toEqual({
|
||||
id: 'fldCreate0000000001',
|
||||
type: 'rollup',
|
||||
options: {
|
||||
expression: 'sum({values})',
|
||||
},
|
||||
config: {
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps link db config fields in create payload', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'link',
|
||||
options: {
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
symmetricFieldId: 'fldSymmetric0000001',
|
||||
fkHostTableName: 'bseTestBaseId.junction_custom',
|
||||
selfKeyName: '__fk_fldSymmetric0000001',
|
||||
foreignKeyName: '__fk_fldCreate0000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'link',
|
||||
options: {
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
symmetricFieldId: 'fldSymmetric0000001',
|
||||
fkHostTableName: 'bseTestBaseId.junction_custom',
|
||||
selfKeyName: '__fk_fldSymmetric0000001',
|
||||
foreignKeyName: '__fk_fldCreate0000001',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes UTC to utc in create payload options', () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
type: 'formula',
|
||||
options: {
|
||||
expression: 'NOW()',
|
||||
timeZone: 'UTC',
|
||||
formatting: {
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
type: 'formula',
|
||||
options: {
|
||||
expression: 'NOW()',
|
||||
timeZone: 'utc',
|
||||
formatting: {
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm',
|
||||
timeZone: 'utc',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fills link db config for manyOne when legacy payload misses it', async () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
id: 'fldCreate0000000001',
|
||||
type: 'link',
|
||||
options: {
|
||||
relationship: 'manyOne',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
},
|
||||
});
|
||||
|
||||
const currentTable = {
|
||||
dbTableName: () => ({
|
||||
isErr: () => false,
|
||||
value: {
|
||||
value: () => ({ isErr: () => false, value: 'bseTestBaseId.tblCurrentTable0001' }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const completed = await service.completeLegacyLinkDbConfigForCreate(
|
||||
mapped,
|
||||
currentTable,
|
||||
{
|
||||
getById: async () => ({
|
||||
isErr: () => true,
|
||||
value: currentTable,
|
||||
}),
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(completed).toMatchObject({
|
||||
type: 'link',
|
||||
options: {
|
||||
relationship: 'manyOne',
|
||||
fkHostTableName: 'bseTestBaseId.tblCurrentTable0001',
|
||||
selfKeyName: '__id',
|
||||
foreignKeyName: '__fk_fldCreate0000000001',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fills link db config for two-way oneMany from foreign table db name', async () => {
|
||||
const service = createService();
|
||||
const mapped = service.mapLegacyCreateFieldToV2({
|
||||
id: 'fldCreate0000000002',
|
||||
type: 'link',
|
||||
options: {
|
||||
relationship: 'oneMany',
|
||||
isOneWay: false,
|
||||
foreignTableId: 'tblAbCdEfGhIjKlMn01',
|
||||
lookupFieldId: 'fldLookup000000002',
|
||||
},
|
||||
});
|
||||
|
||||
const currentTable = {
|
||||
dbTableName: () => ({
|
||||
isErr: () => false,
|
||||
value: {
|
||||
value: () => ({ isErr: () => false, value: 'bseTestBaseId.tblCurrentTable0002' }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const completed = await service.completeLegacyLinkDbConfigForCreate(
|
||||
mapped,
|
||||
currentTable,
|
||||
{
|
||||
getById: async () => ({
|
||||
isErr: () => false,
|
||||
value: {
|
||||
dbTableName: () => ({
|
||||
isErr: () => false,
|
||||
value: {
|
||||
value: () => ({
|
||||
isErr: () => false,
|
||||
value: 'bseTestBaseId.tblForeignPhysical0002',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(completed).toMatchObject({
|
||||
type: 'link',
|
||||
options: {
|
||||
relationship: 'oneMany',
|
||||
isOneWay: false,
|
||||
fkHostTableName: 'bseTestBaseId.tblForeignPhysical0002',
|
||||
},
|
||||
});
|
||||
expect((completed.options as { selfKeyName: string }).selfKeyName).toMatch(/^__fk_/);
|
||||
expect((completed.options as { foreignKeyName: string }).foreignKeyName).toBe('__id');
|
||||
expect((completed.options as { symmetricFieldId?: string }).symmetricFieldId).toMatch(/^fld/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FieldOpenApiV2Service normalizeFieldVo', () => {
|
||||
const createNormalizeService = () =>
|
||||
new FieldOpenApiV2Service(
|
||||
@ -567,6 +1029,51 @@ describe('FieldOpenApiV2Service normalizeFieldVo', () => {
|
||||
expect(vo.dbFieldType).toBe(DbFieldType.Real);
|
||||
});
|
||||
|
||||
it('applies legacy number formatting fallback for numeric rollup expressions', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
id: 'fldRollupNormalize0002',
|
||||
name: 'Rollup Numeric Fallback',
|
||||
type: 'rollup',
|
||||
dbFieldName: 'rollup_numeric_fallback',
|
||||
cellValueType: 'string',
|
||||
options: { expression: 'sum({values})' },
|
||||
config: {
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect((vo.options as Record<string, unknown>).formatting).toEqual(
|
||||
getDefaultFormatting(CellValueType.Number)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not override existing rollup formatting when expression is numeric', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
id: 'fldRollupNormalize0003',
|
||||
name: 'Rollup Keep Formatting',
|
||||
type: 'rollup',
|
||||
dbFieldName: 'rollup_keep_formatting',
|
||||
options: {
|
||||
expression: 'sum({values})',
|
||||
formatting: { type: 'decimal', precision: 5 },
|
||||
},
|
||||
config: {
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect((vo.options as Record<string, unknown>).formatting).toEqual({
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives rating field as number type', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
@ -634,7 +1141,7 @@ describe('FieldOpenApiV2Service normalizeFieldVo', () => {
|
||||
expect((vo.options as Record<string, unknown>).symmetricFieldId).toBe('fldSymmetric000001');
|
||||
});
|
||||
|
||||
it('ensures unique defaults to false when missing', () => {
|
||||
it('keeps unique undefined when missing', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
id: 'fldTest0000000010',
|
||||
@ -643,7 +1150,63 @@ describe('FieldOpenApiV2Service normalizeFieldVo', () => {
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(vo.unique).toBe(false);
|
||||
expect(vo.unique).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits false isMultipleCellValue for v1 compatibility', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
id: 'fldButtonNormalize0001',
|
||||
name: 'Button',
|
||||
type: 'button',
|
||||
dbFieldName: 'button_field',
|
||||
isMultipleCellValue: false,
|
||||
options: {
|
||||
label: 'Run',
|
||||
color: 'red',
|
||||
},
|
||||
});
|
||||
|
||||
expect(vo.isMultipleCellValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips undefined keys from options payload', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
id: 'fldButtonNormalize0002',
|
||||
name: 'Button',
|
||||
type: 'button',
|
||||
dbFieldName: 'button_field_2',
|
||||
options: {
|
||||
label: 'Run',
|
||||
workflow: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(vo.options).toEqual({
|
||||
label: 'Run',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits false isMultipleCellValue for rollup field output compatibility', () => {
|
||||
const service = createNormalizeService();
|
||||
const vo = service.normalizeFieldVo({
|
||||
id: 'fldRollupNormalize0001',
|
||||
name: 'Rollup',
|
||||
type: 'rollup',
|
||||
dbFieldName: 'rollup_field',
|
||||
cellValueType: 'number',
|
||||
isMultipleCellValue: false,
|
||||
options: { expression: 'sum({values})' },
|
||||
config: {
|
||||
linkFieldId: 'fldLink000000000001',
|
||||
lookupFieldId: 'fldLookup000000001',
|
||||
foreignTableId: 'tblForeign00000001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(vo.isMultipleCellValue).toBeUndefined();
|
||||
expect(vo.cellValueType).toBe(CellValueType.Number);
|
||||
});
|
||||
|
||||
it('normalizes lookup options to empty object when source options are null', () => {
|
||||
@ -664,3 +1227,39 @@ describe('FieldOpenApiV2Service normalizeFieldVo', () => {
|
||||
expect(vo.options).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FieldOpenApiV2Service hasDuplicatedDbFieldName', () => {
|
||||
it('returns true when dbFieldName already exists in table', () => {
|
||||
const service = createService();
|
||||
const table = {
|
||||
getFields: () => [
|
||||
{
|
||||
dbFieldName: () => ({
|
||||
andThen: (
|
||||
fn: (name: { value: () => { isOk: () => boolean; value: string } }) => unknown
|
||||
) => fn({ value: () => ({ isOk: () => true, value: 'fld_existing_db_name' }) }),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(service.hasDuplicatedDbFieldName(table, 'fld_existing_db_name')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when dbFieldName does not exist in table', () => {
|
||||
const service = createService();
|
||||
const table = {
|
||||
getFields: () => [
|
||||
{
|
||||
dbFieldName: () => ({
|
||||
andThen: (
|
||||
fn: (name: { value: () => { isOk: () => boolean; value: string } }) => unknown
|
||||
) => fn({ value: () => ({ isOk: () => true, value: 'fld_other_db_name' }) }),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(service.hasDuplicatedDbFieldName(table, 'fld_missing_db_name')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,15 +1,34 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { IConvertFieldRo, IFieldVo, IUpdateFieldRo } from '@teable/core';
|
||||
import { CellValueType, DbFieldType, FieldKeyType, FieldType, getDbFieldType } from '@teable/core';
|
||||
import type { IConvertFieldRo, IFieldRo, IFieldVo, IUpdateFieldRo } from '@teable/core';
|
||||
import {
|
||||
CellValueType,
|
||||
DbFieldType,
|
||||
FieldKeyType,
|
||||
FieldType,
|
||||
generateFieldId,
|
||||
getDefaultFormatting,
|
||||
getDbFieldType,
|
||||
} from '@teable/core';
|
||||
import type { IDuplicateFieldRo } from '@teable/openapi';
|
||||
import {
|
||||
executeCreateFieldEndpoint,
|
||||
executeDuplicateFieldEndpoint,
|
||||
executeUpdateFieldEndpoint,
|
||||
executeUpdateRecordEndpoint,
|
||||
} from '@teable/v2-contract-http-implementation/handlers';
|
||||
import { TableId, v2CoreTokens } from '@teable/v2-core';
|
||||
import {
|
||||
DbTableName,
|
||||
FieldId,
|
||||
LinkFieldConfig,
|
||||
LinkRelationship,
|
||||
TableId,
|
||||
v2CoreTokens,
|
||||
} from '@teable/v2-core';
|
||||
import type {
|
||||
ICommandBus,
|
||||
IExecutionContext,
|
||||
Table,
|
||||
TableQueryService,
|
||||
ITableMapper,
|
||||
} from '@teable/v2-core';
|
||||
@ -44,6 +63,26 @@ export class FieldOpenApiV2Service {
|
||||
private readonly dataLoaderService: DataLoaderService
|
||||
) {}
|
||||
|
||||
private stripUndefinedDeep(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.stripUndefinedDeep(item));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (nested === undefined) {
|
||||
continue;
|
||||
}
|
||||
result[key] = this.stripUndefinedDeep(nested);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private invalidateFieldLoader(tableIds: ReadonlyArray<string>) {
|
||||
const ids = Array.from(
|
||||
new Set(tableIds.filter((id) => typeof id === 'string' && id.length > 0))
|
||||
@ -70,10 +109,6 @@ export class FieldOpenApiV2Service {
|
||||
|
||||
private normalizeFieldVo(field: unknown): IFieldVo {
|
||||
const vo = instanceToPlain(field, { excludePrefixes: ['_'] }) as IFieldVo;
|
||||
// Ensure unique is always a boolean (v2 persistence omits false, but v1 API expects it)
|
||||
if (vo.unique == null) {
|
||||
vo.unique = false;
|
||||
}
|
||||
const raw = vo as Record<string, unknown>;
|
||||
|
||||
// Translate v2 conditionalRollup DTO to v1 API format.
|
||||
@ -164,12 +199,6 @@ export class FieldOpenApiV2Service {
|
||||
const linkOpts = vo.options as Record<string, unknown>;
|
||||
if (linkOpts.isOneWay === true) {
|
||||
delete linkOpts.symmetricFieldId;
|
||||
} else if (
|
||||
linkOpts.isOneWay === false &&
|
||||
linkOpts.relationship !== 'oneOne' &&
|
||||
linkOpts.relationship !== 'one_one'
|
||||
) {
|
||||
delete linkOpts.isOneWay;
|
||||
}
|
||||
|
||||
if (raw.meta && typeof raw.meta === 'object') {
|
||||
@ -187,6 +216,26 @@ export class FieldOpenApiV2Service {
|
||||
}
|
||||
}
|
||||
|
||||
if (vo.isMultipleCellValue === false) {
|
||||
delete raw.isMultipleCellValue;
|
||||
}
|
||||
|
||||
if (vo.isComputed === true && raw.isPending == null) {
|
||||
raw.isPending = true;
|
||||
}
|
||||
|
||||
if (raw.options && typeof raw.options === 'object') {
|
||||
raw.options = this.denormalizeLegacyTimeZone(this.stripUndefinedDeep(raw.options));
|
||||
}
|
||||
|
||||
if (raw.lookupOptions && typeof raw.lookupOptions === 'object') {
|
||||
raw.lookupOptions = this.stripUndefinedDeep(raw.lookupOptions);
|
||||
}
|
||||
|
||||
if (raw.aiConfig && typeof raw.aiConfig === 'object') {
|
||||
raw.aiConfig = this.stripUndefinedDeep(raw.aiConfig);
|
||||
}
|
||||
|
||||
if (vo.type === FieldType.AutoNumber) {
|
||||
vo.cellValueType = CellValueType.Number;
|
||||
vo.dbFieldType = DbFieldType.Integer;
|
||||
@ -196,6 +245,20 @@ export class FieldOpenApiV2Service {
|
||||
vo.cellValueType = this.deriveCellValueType(vo);
|
||||
}
|
||||
|
||||
if (vo.type === FieldType.Rollup && vo.options && typeof vo.options === 'object') {
|
||||
const options = vo.options as Record<string, unknown>;
|
||||
if (options.formatting == null) {
|
||||
const fallbackCellValueType = this.shouldApplyLegacyRollupNumberFormatting(vo)
|
||||
? CellValueType.Number
|
||||
: vo.cellValueType;
|
||||
const defaultFormatting =
|
||||
fallbackCellValueType != null ? getDefaultFormatting(fallbackCellValueType) : undefined;
|
||||
if (defaultFormatting) {
|
||||
options.formatting = defaultFormatting;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derive isMultipleCellValue when not present for field types that are always multi-value.
|
||||
if (vo.isMultipleCellValue == null) {
|
||||
const isMultiple = this.deriveIsMultipleCellValue(vo);
|
||||
@ -269,6 +332,28 @@ export class FieldOpenApiV2Service {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldApplyLegacyRollupNumberFormatting(vo: IFieldVo): boolean {
|
||||
if (vo.type !== FieldType.Rollup) {
|
||||
return false;
|
||||
}
|
||||
const options =
|
||||
vo.options && typeof vo.options === 'object' && !Array.isArray(vo.options)
|
||||
? (vo.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
const expression =
|
||||
typeof options?.expression === 'string' ? options.expression.trim().toLowerCase() : '';
|
||||
if (!expression) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
expression.startsWith('sum(') ||
|
||||
expression.startsWith('average(') ||
|
||||
expression.startsWith('count(') ||
|
||||
expression.startsWith('counta(') ||
|
||||
expression.startsWith('countall(')
|
||||
);
|
||||
}
|
||||
|
||||
private async getFieldFromV2(
|
||||
tableId: string,
|
||||
fieldId: string,
|
||||
@ -328,6 +413,12 @@ export class FieldOpenApiV2Service {
|
||||
|
||||
if (vo.isLookup && vo.lookupOptions && typeof vo.lookupOptions === 'object') {
|
||||
const lookupOpts = vo.lookupOptions as Record<string, unknown>;
|
||||
if (lookupOpts.isOneWay === false) {
|
||||
delete lookupOpts.isOneWay;
|
||||
}
|
||||
if (lookupOpts.symmetricFieldId != null) {
|
||||
delete lookupOpts.symmetricFieldId;
|
||||
}
|
||||
const foreignTableId = lookupOpts.foreignTableId;
|
||||
const lookupFieldId = lookupOpts.lookupFieldId;
|
||||
if (typeof foreignTableId === 'string' && typeof lookupFieldId === 'string') {
|
||||
@ -353,6 +444,7 @@ export class FieldOpenApiV2Service {
|
||||
...(sourceOptions ?? {}),
|
||||
...(currentOptions ?? {}),
|
||||
} as IFieldVo['options'];
|
||||
vo.options = this.denormalizeLegacyTimeZone(vo.options) as IFieldVo['options'];
|
||||
}
|
||||
|
||||
if (sourceVo.cellValueType != null && vo.cellValueType == null) {
|
||||
@ -371,6 +463,11 @@ export class FieldOpenApiV2Service {
|
||||
return vo;
|
||||
}
|
||||
|
||||
async getField(tableId: string, fieldId: string): Promise<IFieldVo> {
|
||||
const context = await this.v2ContextFactory.createContext();
|
||||
return this.getFieldFromV2(tableId, fieldId, context);
|
||||
}
|
||||
|
||||
private mapLegacyUpdateFieldToV2(
|
||||
ro: IUpdateFieldRo,
|
||||
currentField?: Record<string, unknown>
|
||||
@ -412,6 +509,541 @@ export class FieldOpenApiV2Service {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private normalizeLegacyTimeZone(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.normalizeLegacyTimeZone(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (key === 'timeZone' && raw === 'UTC') {
|
||||
normalized[key] = 'utc';
|
||||
continue;
|
||||
}
|
||||
normalized[key] = this.normalizeLegacyTimeZone(raw);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private denormalizeLegacyTimeZone(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.denormalizeLegacyTimeZone(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (key === 'timeZone' && raw === 'utc') {
|
||||
normalized[key] = 'UTC';
|
||||
continue;
|
||||
}
|
||||
normalized[key] = this.denormalizeLegacyTimeZone(raw);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private getResultTypePair(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const cellValueType = raw.cellValueType;
|
||||
const isMultipleCellValue = raw.isMultipleCellValue;
|
||||
|
||||
if (typeof cellValueType === 'string' && typeof isMultipleCellValue === 'boolean') {
|
||||
return isMultipleCellValue ? { cellValueType, isMultipleCellValue } : { cellValueType };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private mapLegacyCreateFieldToV2(ro: IFieldRo): Record<string, unknown> {
|
||||
const field = ro as Record<string, unknown>;
|
||||
const base: Record<string, unknown> = {
|
||||
id: typeof field.id === 'string' ? field.id : generateFieldId(),
|
||||
};
|
||||
if (field.name != null) base.name = field.name;
|
||||
if (typeof field.dbFieldName === 'string') {
|
||||
base.dbFieldName = field.dbFieldName;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(field, 'description')) {
|
||||
base.description = field.description ?? null;
|
||||
}
|
||||
if (field.notNull != null) base.notNull = field.notNull;
|
||||
if (field.unique != null) base.unique = field.unique;
|
||||
if (Object.prototype.hasOwnProperty.call(field, 'aiConfig')) {
|
||||
base.aiConfig = field.aiConfig ?? null;
|
||||
}
|
||||
|
||||
if (field.isConditionalLookup) {
|
||||
const lookupOpts =
|
||||
ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)
|
||||
? (ro.lookupOptions as Record<string, unknown>)
|
||||
: undefined;
|
||||
const innerOptions =
|
||||
ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)
|
||||
? (ro.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
return this.normalizeLegacyTimeZone({
|
||||
...base,
|
||||
type: 'conditionalLookup',
|
||||
...(typeof field.isMultipleCellValue === 'boolean'
|
||||
? { isMultipleCellValue: field.isMultipleCellValue }
|
||||
: {}),
|
||||
options: {
|
||||
...(lookupOpts?.foreignTableId != null
|
||||
? { foreignTableId: lookupOpts.foreignTableId }
|
||||
: {}),
|
||||
...(lookupOpts?.lookupFieldId != null ? { lookupFieldId: lookupOpts.lookupFieldId } : {}),
|
||||
condition: {
|
||||
...(lookupOpts?.filter ? { filter: lookupOpts.filter } : {}),
|
||||
...(lookupOpts?.sort ? { sort: lookupOpts.sort } : {}),
|
||||
...(lookupOpts?.limit != null ? { limit: lookupOpts.limit } : {}),
|
||||
},
|
||||
},
|
||||
...(innerOptions && Object.keys(innerOptions).length > 0 ? { innerOptions } : {}),
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (field.isLookup) {
|
||||
const lookupOpts =
|
||||
ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)
|
||||
? (ro.lookupOptions as Record<string, unknown>)
|
||||
: undefined;
|
||||
const innerOptions =
|
||||
ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)
|
||||
? (ro.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
return this.normalizeLegacyTimeZone({
|
||||
...base,
|
||||
type: 'lookup',
|
||||
legacyMultiplicityDerivation: true,
|
||||
...(field.isMultipleCellValue === true ? { isMultipleCellValue: true } : {}),
|
||||
options: {
|
||||
...(lookupOpts?.linkFieldId != null ? { linkFieldId: lookupOpts.linkFieldId } : {}),
|
||||
...(lookupOpts?.lookupFieldId != null ? { lookupFieldId: lookupOpts.lookupFieldId } : {}),
|
||||
...(lookupOpts?.foreignTableId != null
|
||||
? { foreignTableId: lookupOpts.foreignTableId }
|
||||
: {}),
|
||||
...(lookupOpts?.filter ? { filter: lookupOpts.filter } : {}),
|
||||
...(lookupOpts?.sort ? { sort: lookupOpts.sort } : {}),
|
||||
...(lookupOpts?.limit != null ? { limit: lookupOpts.limit } : {}),
|
||||
},
|
||||
...(innerOptions && Object.keys(innerOptions).length > 0 ? { innerOptions } : {}),
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (ro.type === FieldType.Rollup) {
|
||||
const opts = (ro.options ?? {}) as Record<string, unknown>;
|
||||
const lookupOpts =
|
||||
ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)
|
||||
? (ro.lookupOptions as Record<string, unknown>)
|
||||
: undefined;
|
||||
const linkFieldId = opts.linkFieldId ?? lookupOpts?.linkFieldId;
|
||||
const lookupFieldId = opts.lookupFieldId ?? lookupOpts?.lookupFieldId;
|
||||
const foreignTableId = opts.foreignTableId ?? lookupOpts?.foreignTableId;
|
||||
const shouldIncludeConfig =
|
||||
linkFieldId != null && lookupFieldId != null && foreignTableId != null;
|
||||
return this.normalizeLegacyTimeZone({
|
||||
...base,
|
||||
type: FieldType.Rollup,
|
||||
...this.getResultTypePair(field),
|
||||
options: {
|
||||
...(opts.expression != null ? { expression: opts.expression } : {}),
|
||||
...(opts.formatting != null ? { formatting: opts.formatting } : {}),
|
||||
...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),
|
||||
...(opts.showAs != null ? { showAs: opts.showAs } : {}),
|
||||
},
|
||||
...(shouldIncludeConfig
|
||||
? {
|
||||
config: {
|
||||
linkFieldId,
|
||||
lookupFieldId,
|
||||
foreignTableId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (ro.type === FieldType.Link) {
|
||||
const opts =
|
||||
ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)
|
||||
? (ro.options as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return this.normalizeLegacyTimeZone({
|
||||
...base,
|
||||
type: FieldType.Link,
|
||||
options: {
|
||||
...(opts.baseId != null ? { baseId: opts.baseId } : {}),
|
||||
...(opts.relationship != null ? { relationship: opts.relationship } : {}),
|
||||
...(opts.foreignTableId != null ? { foreignTableId: opts.foreignTableId } : {}),
|
||||
...(opts.lookupFieldId != null ? { lookupFieldId: opts.lookupFieldId } : {}),
|
||||
...(opts.fkHostTableName != null ? { fkHostTableName: opts.fkHostTableName } : {}),
|
||||
...(opts.selfKeyName != null ? { selfKeyName: opts.selfKeyName } : {}),
|
||||
...(opts.foreignKeyName != null ? { foreignKeyName: opts.foreignKeyName } : {}),
|
||||
...(opts.isOneWay != null ? { isOneWay: opts.isOneWay } : {}),
|
||||
...(opts.symmetricFieldId != null ? { symmetricFieldId: opts.symmetricFieldId } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(opts, 'filterByViewId')
|
||||
? { filterByViewId: opts.filterByViewId }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(opts, 'visibleFieldIds')
|
||||
? { visibleFieldIds: opts.visibleFieldIds }
|
||||
: {}),
|
||||
...(opts.filter != null ? { filter: opts.filter } : {}),
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (ro.type === 'conditionalRollup') {
|
||||
const opts = (ro.options ?? {}) as Record<string, unknown>;
|
||||
const condition = {
|
||||
...(opts.filter ? { filter: opts.filter } : {}),
|
||||
...(opts.sort ? { sort: opts.sort } : {}),
|
||||
...(opts.limit != null ? { limit: opts.limit } : {}),
|
||||
};
|
||||
const shouldIncludeConfig =
|
||||
opts.foreignTableId != null &&
|
||||
opts.lookupFieldId != null &&
|
||||
Object.keys(condition).length > 0;
|
||||
return this.normalizeLegacyTimeZone({
|
||||
...base,
|
||||
type: 'conditionalRollup',
|
||||
...this.getResultTypePair(field),
|
||||
options: {
|
||||
...(opts.expression != null ? { expression: opts.expression } : {}),
|
||||
...(opts.formatting != null ? { formatting: opts.formatting } : {}),
|
||||
...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),
|
||||
...(opts.showAs != null ? { showAs: opts.showAs } : {}),
|
||||
},
|
||||
...(shouldIncludeConfig
|
||||
? {
|
||||
config: {
|
||||
foreignTableId: opts.foreignTableId,
|
||||
lookupFieldId: opts.lookupFieldId,
|
||||
condition,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return this.normalizeLegacyTimeZone({
|
||||
...base,
|
||||
type: ro.type,
|
||||
...(ro.options != null ? { options: ro.options } : {}),
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private getDbTableNameString(table: Table): string | undefined {
|
||||
const dbTableNameResult = table.dbTableName();
|
||||
if (dbTableNameResult.isErr()) {
|
||||
return undefined;
|
||||
}
|
||||
const valueResult = dbTableNameResult.value.value();
|
||||
if (valueResult.isErr()) {
|
||||
return undefined;
|
||||
}
|
||||
return valueResult.value;
|
||||
}
|
||||
|
||||
private hasDuplicatedDbFieldName(table: Table, dbFieldName: string): boolean {
|
||||
return table.getFields().some((field) => {
|
||||
const existingDbFieldNameResult = field.dbFieldName().andThen((name) => name.value());
|
||||
return existingDbFieldNameResult.isOk() && existingDbFieldNameResult.value === dbFieldName;
|
||||
});
|
||||
}
|
||||
|
||||
private async completeLegacyLinkDbConfigForCreate(
|
||||
v2Field: Record<string, unknown>,
|
||||
currentTable: Table,
|
||||
tableQueryService: TableQueryService,
|
||||
context: IExecutionContext
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (v2Field.type !== FieldType.Link) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const options =
|
||||
v2Field.options && typeof v2Field.options === 'object' && !Array.isArray(v2Field.options)
|
||||
? (v2Field.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!options) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const hasAnyDbConfig =
|
||||
options.fkHostTableName != null ||
|
||||
options.selfKeyName != null ||
|
||||
options.foreignKeyName != null;
|
||||
if (hasAnyDbConfig) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const relationshipRaw = options.relationship;
|
||||
const foreignTableIdRaw = options.foreignTableId;
|
||||
if (typeof relationshipRaw !== 'string' || typeof foreignTableIdRaw !== 'string') {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const relationshipResult = LinkRelationship.create(relationshipRaw);
|
||||
if (relationshipResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const relationship = relationshipResult.value.toString();
|
||||
const isOneWay = options.isOneWay === true;
|
||||
if (relationship === 'manyMany' || (relationship === 'oneMany' && isOneWay)) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const fieldIdRaw = v2Field.id;
|
||||
if (typeof fieldIdRaw !== 'string') {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
let fkHostTableNameValue: string | undefined;
|
||||
if (relationship === 'oneMany') {
|
||||
const foreignTableIdResult = TableId.create(foreignTableIdRaw);
|
||||
if (foreignTableIdResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
const foreignTableResult = await tableQueryService.getById(
|
||||
context,
|
||||
foreignTableIdResult.value
|
||||
);
|
||||
if (foreignTableResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
fkHostTableNameValue = this.getDbTableNameString(foreignTableResult.value);
|
||||
} else {
|
||||
fkHostTableNameValue = this.getDbTableNameString(currentTable);
|
||||
}
|
||||
|
||||
if (!fkHostTableNameValue) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const fieldIdResult = FieldId.create(fieldIdRaw);
|
||||
if (fieldIdResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
let symmetricFieldIdRaw =
|
||||
typeof options.symmetricFieldId === 'string' ? options.symmetricFieldId : undefined;
|
||||
if (relationship === 'oneMany' && !isOneWay && !symmetricFieldIdRaw) {
|
||||
symmetricFieldIdRaw = generateFieldId();
|
||||
}
|
||||
|
||||
let symmetricFieldId: FieldId | undefined;
|
||||
if (symmetricFieldIdRaw) {
|
||||
const symmetricFieldIdResult = FieldId.create(symmetricFieldIdRaw);
|
||||
if (symmetricFieldIdResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
symmetricFieldId = symmetricFieldIdResult.value;
|
||||
}
|
||||
|
||||
const dbTableNameResult = DbTableName.rehydrate(fkHostTableNameValue);
|
||||
if (dbTableNameResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const dbConfigResult = LinkFieldConfig.buildDbConfig({
|
||||
fkHostTableName: dbTableNameResult.value,
|
||||
relationship: relationshipResult.value,
|
||||
fieldId: fieldIdResult.value,
|
||||
symmetricFieldId,
|
||||
isOneWay,
|
||||
});
|
||||
if (dbConfigResult.isErr()) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
const fkHostTableNameResult = dbConfigResult.value.fkHostTableName.value();
|
||||
const selfKeyNameResult = dbConfigResult.value.selfKeyName.value();
|
||||
const foreignKeyNameResult = dbConfigResult.value.foreignKeyName.value();
|
||||
if (
|
||||
fkHostTableNameResult.isErr() ||
|
||||
selfKeyNameResult.isErr() ||
|
||||
foreignKeyNameResult.isErr()
|
||||
) {
|
||||
return v2Field;
|
||||
}
|
||||
|
||||
return {
|
||||
...v2Field,
|
||||
options: {
|
||||
...options,
|
||||
fkHostTableName: fkHostTableNameResult.value,
|
||||
selfKeyName: selfKeyNameResult.value,
|
||||
foreignKeyName: foreignKeyNameResult.value,
|
||||
...(symmetricFieldIdRaw != null ? { symmetricFieldId: symmetricFieldIdRaw } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createField(tableId: string, fieldRo: IFieldRo): Promise<IFieldVo> {
|
||||
const container = await this.v2ContainerService.getContainer();
|
||||
const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);
|
||||
const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);
|
||||
const context = await this.v2ContextFactory.createContext();
|
||||
const tableIdResult = TableId.create(tableId);
|
||||
if (tableIdResult.isErr()) {
|
||||
throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const tableResult = await tableQueryService.getById(context, tableIdResult.value);
|
||||
if (tableResult.isErr()) {
|
||||
const errMsg = tableResult.error.message ?? 'Table not found';
|
||||
const isNotFound =
|
||||
tableResult.error.code === 'table.not_found' || errMsg.includes('not found');
|
||||
throw new HttpException(
|
||||
errMsg,
|
||||
isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const rawFieldRo = fieldRo as Record<string, unknown>;
|
||||
const rawDbFieldName = rawFieldRo.dbFieldName;
|
||||
if (
|
||||
typeof rawDbFieldName === 'string' &&
|
||||
this.hasDuplicatedDbFieldName(tableResult.value, rawDbFieldName)
|
||||
) {
|
||||
throw new CustomHttpException(
|
||||
`Db Field name ${rawDbFieldName} already exists in this table`,
|
||||
getDefaultCodeByStatus(HttpStatus.BAD_REQUEST)
|
||||
);
|
||||
}
|
||||
|
||||
const hasAiConfig = Object.prototype.hasOwnProperty.call(rawFieldRo, 'aiConfig');
|
||||
const nextAiConfig = hasAiConfig ? rawFieldRo.aiConfig ?? null : undefined;
|
||||
const mappedField = this.mapLegacyCreateFieldToV2(fieldRo);
|
||||
const linkDbCompletedField = await this.completeLegacyLinkDbConfigForCreate(
|
||||
mappedField,
|
||||
tableResult.value,
|
||||
tableQueryService,
|
||||
context
|
||||
);
|
||||
const v2Field = linkDbCompletedField;
|
||||
const legacyOrder =
|
||||
fieldRo && typeof fieldRo === 'object' && 'order' in fieldRo
|
||||
? (fieldRo.order as
|
||||
| {
|
||||
viewId?: unknown;
|
||||
orderIndex?: unknown;
|
||||
}
|
||||
| undefined)
|
||||
: undefined;
|
||||
const normalizedOrder =
|
||||
typeof legacyOrder?.viewId === 'string' && typeof legacyOrder?.orderIndex === 'number'
|
||||
? {
|
||||
viewId: legacyOrder.viewId,
|
||||
orderIndex: legacyOrder.orderIndex,
|
||||
}
|
||||
: undefined;
|
||||
const result = await executeCreateFieldEndpoint(
|
||||
context,
|
||||
{
|
||||
baseId: tableResult.value.baseId().toString(),
|
||||
tableId,
|
||||
field: v2Field,
|
||||
...(normalizedOrder ? { order: normalizedOrder } : {}),
|
||||
},
|
||||
commandBus
|
||||
);
|
||||
|
||||
if (result.status === 200 && result.body.ok) {
|
||||
const tableIdsToInvalidate = [tableId];
|
||||
const mappedOptions =
|
||||
v2Field.options && typeof v2Field.options === 'object' && !Array.isArray(v2Field.options)
|
||||
? (v2Field.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
const mappedConfig =
|
||||
v2Field.config && typeof v2Field.config === 'object' && !Array.isArray(v2Field.config)
|
||||
? (v2Field.config as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (typeof mappedOptions?.foreignTableId === 'string') {
|
||||
tableIdsToInvalidate.push(mappedOptions.foreignTableId);
|
||||
}
|
||||
if (typeof mappedConfig?.foreignTableId === 'string') {
|
||||
tableIdsToInvalidate.push(mappedConfig.foreignTableId);
|
||||
}
|
||||
this.invalidateFieldLoader(tableIdsToInvalidate);
|
||||
|
||||
if (typeof v2Field.id === 'string') {
|
||||
const createdField = await this.getFieldFromV2(tableId, v2Field.id, context);
|
||||
|
||||
if (hasAiConfig) {
|
||||
createdField.aiConfig = nextAiConfig as IFieldVo['aiConfig'];
|
||||
}
|
||||
|
||||
return createdField;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.body.ok) {
|
||||
this.throwV2Error(result.body.error, result.status);
|
||||
}
|
||||
|
||||
throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
async duplicateField(
|
||||
tableId: string,
|
||||
fieldId: string,
|
||||
duplicateFieldRo: IDuplicateFieldRo,
|
||||
_windowId?: string
|
||||
): Promise<IFieldVo> {
|
||||
const container = await this.v2ContainerService.getContainer();
|
||||
const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);
|
||||
const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);
|
||||
const context = await this.v2ContextFactory.createContext();
|
||||
|
||||
const tableIdResult = TableId.create(tableId);
|
||||
if (tableIdResult.isErr()) {
|
||||
throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const tableResult = await tableQueryService.getById(context, tableIdResult.value);
|
||||
if (tableResult.isErr()) {
|
||||
const errMsg = tableResult.error.message ?? 'Table not found';
|
||||
const isNotFound =
|
||||
tableResult.error.code === 'table.not_found' || errMsg.includes('not found');
|
||||
throw new HttpException(
|
||||
errMsg,
|
||||
isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const duplicateResult = await executeDuplicateFieldEndpoint(
|
||||
context,
|
||||
{
|
||||
baseId: tableResult.value.baseId().toString(),
|
||||
tableId,
|
||||
fieldId,
|
||||
includeRecordValues: true,
|
||||
newFieldName: duplicateFieldRo.name,
|
||||
viewId: duplicateFieldRo.viewId,
|
||||
},
|
||||
commandBus
|
||||
);
|
||||
|
||||
if (!(duplicateResult.status === 200 && duplicateResult.body.ok)) {
|
||||
if (!duplicateResult.body.ok) {
|
||||
this.throwV2Error(duplicateResult.body.error, duplicateResult.status);
|
||||
}
|
||||
throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
const duplicatedFieldId = duplicateResult.body.data.newFieldId;
|
||||
|
||||
this.invalidateFieldLoader([tableId]);
|
||||
|
||||
return this.getFieldFromV2(tableId, duplicatedFieldId, context);
|
||||
}
|
||||
|
||||
async updateField(tableId: string, fieldId: string, updateFieldRo: IUpdateFieldRo) {
|
||||
const container = await this.v2ContainerService.getContainer();
|
||||
const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);
|
||||
@ -630,8 +1262,7 @@ export class FieldOpenApiV2Service {
|
||||
return {
|
||||
...base,
|
||||
type: 'conditionalRollup',
|
||||
cellValueType: (ro as Record<string, unknown>).cellValueType,
|
||||
isMultipleCellValue: (ro as Record<string, unknown>).isMultipleCellValue,
|
||||
...this.getResultTypePair(ro as Record<string, unknown>),
|
||||
options: {
|
||||
...(opts.expression != null ? { expression: opts.expression } : {}),
|
||||
...(opts.formatting != null ? { formatting: opts.formatting } : {}),
|
||||
|
||||
@ -90,6 +90,21 @@ export class FieldOpenApiController {
|
||||
@Param('tableId') tableId: string,
|
||||
@Param('fieldId') fieldId: string
|
||||
): Promise<IFieldVo> {
|
||||
const forceV2All = process.env.FORCE_V2_ALL?.toLowerCase() === 'true';
|
||||
if (this.cls.get('useV2') || forceV2All) {
|
||||
const field = await this.fieldOpenApiV2Service.getField(tableId, fieldId);
|
||||
if (field.hasError == null) {
|
||||
try {
|
||||
const legacyField = await this.fieldService.getField(tableId, fieldId);
|
||||
if (legacyField.hasError != null) {
|
||||
field.hasError = legacyField.hasError;
|
||||
}
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
}
|
||||
return field;
|
||||
}
|
||||
return await this.fieldService.getField(tableId, fieldId);
|
||||
}
|
||||
|
||||
@ -112,12 +127,16 @@ export class FieldOpenApiController {
|
||||
}
|
||||
|
||||
@Permissions('field|create')
|
||||
@UseV2Feature('createField')
|
||||
@Post()
|
||||
async createField(
|
||||
@Param('tableId') tableId: string,
|
||||
@Body(new ZodValidationPipe(createFieldRoSchema)) fieldRo: IFieldRo,
|
||||
@Headers('x-window-id') windowId: string
|
||||
): Promise<IFieldVo> {
|
||||
if (this.cls.get('useV2')) {
|
||||
return await this.fieldOpenApiV2Service.createField(tableId, fieldRo);
|
||||
}
|
||||
return await this.fieldOpenApiService.createField(tableId, fieldRo, windowId);
|
||||
}
|
||||
|
||||
@ -214,6 +233,7 @@ export class FieldOpenApiController {
|
||||
}
|
||||
|
||||
@Permissions('field|create')
|
||||
@UseV2Feature('duplicateField')
|
||||
@Post('/:fieldId/duplicate')
|
||||
async duplicateField(
|
||||
@Param('tableId') tableId: string,
|
||||
@ -221,6 +241,14 @@ export class FieldOpenApiController {
|
||||
@Body(new ZodValidationPipe(duplicateFieldRoSchema)) duplicateFieldRo: IDuplicateFieldRo,
|
||||
@Headers('x-window-id') windowId: string
|
||||
) {
|
||||
if (this.cls.get('useV2')) {
|
||||
return this.fieldOpenApiV2Service.duplicateField(
|
||||
tableId,
|
||||
fieldId,
|
||||
duplicateFieldRo,
|
||||
windowId
|
||||
);
|
||||
}
|
||||
return this.fieldOpenApiService.duplicateField(tableId, fieldId, duplicateFieldRo, windowId);
|
||||
}
|
||||
|
||||
|
||||
@ -536,10 +536,20 @@ export class FieldOpenApiService {
|
||||
}
|
||||
|
||||
async getFields(tableId: string, query: IGetFieldsQuery) {
|
||||
return await this.fieldService.getFieldsByQuery(tableId, {
|
||||
const fields = await this.fieldService.getFieldsByQuery(tableId, {
|
||||
...query,
|
||||
filterHidden: query.filterHidden == null ? true : query.filterHidden,
|
||||
});
|
||||
|
||||
return fields.map((field) => {
|
||||
if (field.isMultipleCellValue !== false) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const normalized = { ...field } as IFieldVo & Record<string, unknown>;
|
||||
delete normalized.isMultipleCellValue;
|
||||
return normalized as IFieldVo;
|
||||
});
|
||||
}
|
||||
|
||||
private async validateLookupField(field: IFieldInstance) {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImportMetricsService } from './import-metrics.service';
|
||||
import { ImportTracingService } from './import-tracing.service';
|
||||
|
||||
@Module({
|
||||
providers: [ImportMetricsService],
|
||||
exports: [ImportMetricsService],
|
||||
providers: [ImportMetricsService, ImportTracingService],
|
||||
exports: [ImportMetricsService, ImportTracingService],
|
||||
})
|
||||
export class ImportMetricsModule {}
|
||||
|
||||
@ -17,13 +17,6 @@ export class ImportMetricsService {
|
||||
],
|
||||
},
|
||||
});
|
||||
private readonly importRows = this.meter.createHistogram('data.import.rows', {
|
||||
description: 'Number of rows per import task',
|
||||
unit: 'rows',
|
||||
advice: {
|
||||
explicitBucketBoundaries: [10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000],
|
||||
},
|
||||
});
|
||||
private readonly importErrors = this.meter.createCounter('data.import.errors', {
|
||||
description: 'Total number of import errors',
|
||||
});
|
||||
@ -38,13 +31,8 @@ export class ImportMetricsService {
|
||||
recordImportComplete(attrs: {
|
||||
fileType: string;
|
||||
operationType: string;
|
||||
rows: number;
|
||||
durationMs: number;
|
||||
}): void {
|
||||
this.importRows.record(attrs.rows, {
|
||||
file_type: attrs.fileType,
|
||||
operation_type: attrs.operationType,
|
||||
});
|
||||
this.importDuration.record(attrs.durationMs, {
|
||||
file_type: attrs.fileType,
|
||||
operation_type: attrs.operationType,
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseTracingService } from '../../../tracing/base-tracing.service';
|
||||
|
||||
@Injectable()
|
||||
export class ImportTracingService extends BaseTracingService {
|
||||
setImportAttributes(attrs: { rows: number }): void {
|
||||
this.withActiveSpan((span) => {
|
||||
span.setAttribute('data.import.rows', attrs.rows);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import StorageAdapter from '../../attachments/plugins/adapter';
|
||||
import { InjectStorageAdapter } from '../../attachments/plugins/storage';
|
||||
import { NotificationService } from '../../notification/notification.service';
|
||||
import { ImportMetricsService } from '../metrics/import-metrics.service';
|
||||
import { ImportTracingService } from '../metrics/import-tracing.service';
|
||||
import { ImportTableCsvQueueProcessor, TABLE_IMPORT_CSV_QUEUE } from './import-csv.processor';
|
||||
import { DEFAULT_IMPORT_CPU_USAGE, getWorkerPath, importerFactory } from './import.class';
|
||||
|
||||
@ -86,7 +87,8 @@ export class ImportTableCsvChunkQueueProcessor extends WorkerHost {
|
||||
private readonly importTableCsvQueueProcessor: ImportTableCsvQueueProcessor,
|
||||
@InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,
|
||||
@InjectQueue(TABLE_IMPORT_CSV_CHUNK_QUEUE) public readonly queue: Queue<ITableImportChunkJob>,
|
||||
@Optional() private readonly importMetrics?: ImportMetricsService
|
||||
@Optional() private readonly importMetrics?: ImportMetricsService,
|
||||
@Optional() private readonly importTracing?: ImportTracingService
|
||||
) {
|
||||
super();
|
||||
// When BACKEND_CACHE_REDIS_URI is not set, queues are backed by the local
|
||||
@ -130,10 +132,10 @@ export class ImportTableCsvChunkQueueProcessor extends WorkerHost {
|
||||
);
|
||||
const rowCount = await this.resolveDataByWorker(job);
|
||||
this.logger.log(`import data to ${table.id} chunk data job completed`);
|
||||
this.importTracing?.setImportAttributes({ rows: rowCount });
|
||||
this.importMetrics?.recordImportComplete({
|
||||
fileType,
|
||||
operationType,
|
||||
rows: rowCount,
|
||||
durationMs: Date.now() - importStartTime,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -690,10 +690,7 @@ export class RecordOpenApiV2Service {
|
||||
|
||||
const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo);
|
||||
const normalizedFilter = this.mapV1FilterToV2(rangeQuery.filter);
|
||||
const sortWithGroupFallback = this.mergeGroupByIntoSort(
|
||||
rangeQuery.groupBy,
|
||||
rangeQuery.orderBy
|
||||
);
|
||||
const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy);
|
||||
const v2Input = {
|
||||
tableId,
|
||||
viewId: rangeQuery.viewId,
|
||||
@ -812,10 +809,7 @@ export class RecordOpenApiV2Service {
|
||||
const context = await this.v2ContextFactory.createContext();
|
||||
|
||||
const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo);
|
||||
const sortWithGroupFallback = this.mergeGroupByIntoSort(
|
||||
rangeQuery.groupBy,
|
||||
rangeQuery.orderBy
|
||||
);
|
||||
const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy);
|
||||
|
||||
// Build v2 deleteByRange input
|
||||
const v2Input = {
|
||||
|
||||
@ -62,6 +62,7 @@ export class RecordUpdateService {
|
||||
updateRecordsRo: IUpdateRecordsInternalRo,
|
||||
windowId?: string
|
||||
) {
|
||||
const effectiveWindowId = windowId ?? this.cls.get('windowId');
|
||||
const {
|
||||
records,
|
||||
order,
|
||||
@ -73,7 +74,7 @@ export class RecordUpdateService {
|
||||
const table = await this.tableDomainQueryService.getTableDomainById(tableId);
|
||||
const scopedRecords = this.filterRecordsByFieldKeys(records, fieldIds);
|
||||
const orderIndexesBefore =
|
||||
order != null && windowId
|
||||
order != null && effectiveWindowId
|
||||
? await this.recordService.getRecordIndexes(
|
||||
table,
|
||||
records.map((r) => r.id),
|
||||
@ -145,13 +146,13 @@ export class RecordUpdateService {
|
||||
});
|
||||
|
||||
const recordIds = records.map((r) => r.id);
|
||||
if (windowId) {
|
||||
if (effectiveWindowId) {
|
||||
const orderIndexesAfter =
|
||||
order && (await this.recordService.getRecordIndexes(table, recordIds, order.viewId));
|
||||
|
||||
this.eventEmitterService.emitAsync(Events.OPERATION_RECORDS_UPDATE, {
|
||||
tableId,
|
||||
windowId,
|
||||
windowId: effectiveWindowId,
|
||||
userId: this.cls.get('user.id'),
|
||||
recordIds,
|
||||
fieldIds: fieldIds?.length ? fieldIds : Object.keys(scopedRecords[0]?.fields || {}),
|
||||
|
||||
@ -757,6 +757,7 @@ export class SelectionService {
|
||||
windowId?: string;
|
||||
} = {}
|
||||
) {
|
||||
const effectiveWindowId = windowId ?? this.cls.get('windowId');
|
||||
const { content, header, ...rangesRo } = pasteRo;
|
||||
const { ranges, type, ...queryRo } = rangesRo;
|
||||
const { viewId } = queryRo;
|
||||
@ -911,9 +912,9 @@ export class SelectionService {
|
||||
};
|
||||
});
|
||||
|
||||
if (windowId) {
|
||||
if (effectiveWindowId) {
|
||||
this.eventEmitterService.emitAsync(Events.OPERATION_PASTE_SELECTION, {
|
||||
windowId,
|
||||
windowId: effectiveWindowId,
|
||||
userId: this.cls.get('user.id'),
|
||||
tableId,
|
||||
updateRecords,
|
||||
|
||||
@ -71,13 +71,6 @@ export class PasteSelectionOperation {
|
||||
});
|
||||
}
|
||||
|
||||
if (newRecords && newRecords.length > 0) {
|
||||
await this.recordOpenApiService.deleteRecords(
|
||||
tableId,
|
||||
newRecords.map((r) => r.id)
|
||||
);
|
||||
}
|
||||
|
||||
if (newFields && newFields.length > 0) {
|
||||
await this.fieldOpenApiService.deleteFields(
|
||||
tableId,
|
||||
@ -85,6 +78,13 @@ export class PasteSelectionOperation {
|
||||
);
|
||||
}
|
||||
|
||||
if (newRecords && newRecords.length > 0) {
|
||||
await this.recordOpenApiService.deleteRecords(
|
||||
tableId,
|
||||
newRecords.map((r) => r.id)
|
||||
);
|
||||
}
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { IFieldVo, IOtOperation, IRecord } from '@teable/core';
|
||||
import {
|
||||
CellValueType,
|
||||
FieldType,
|
||||
getDbFieldType,
|
||||
type IColumnMeta,
|
||||
type IFieldVo,
|
||||
type IOtOperation,
|
||||
type IRecord,
|
||||
} from '@teable/core';
|
||||
import {
|
||||
FieldCreated,
|
||||
FieldUpdated,
|
||||
ProjectionHandler,
|
||||
RecordReordered,
|
||||
@ -17,6 +26,7 @@ import {
|
||||
import type { DomainError, IEventHandler, IExecutionContext, Result } from '@teable/v2-core';
|
||||
import type { DependencyContainer } from '@teable/v2-di';
|
||||
import {
|
||||
type ICreateFieldsOperation,
|
||||
OperationName,
|
||||
type IConvertFieldV2Operation,
|
||||
type ICreateRecordsOperation,
|
||||
@ -157,6 +167,72 @@ const mergeOpsMap = (base: IOpsMap | undefined, patch: IOpsMap): IOpsMap => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const deriveCellValueType = (field: IFieldVo): CellValueType => {
|
||||
switch (field.type) {
|
||||
case FieldType.Number:
|
||||
case FieldType.Rating:
|
||||
case FieldType.AutoNumber:
|
||||
return CellValueType.Number;
|
||||
case FieldType.Checkbox:
|
||||
return CellValueType.Boolean;
|
||||
case FieldType.Date:
|
||||
case FieldType.CreatedTime:
|
||||
case FieldType.LastModifiedTime:
|
||||
return CellValueType.DateTime;
|
||||
default:
|
||||
return CellValueType.String;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveIsMultipleCellValue = (field: IFieldVo): boolean => {
|
||||
switch (field.type) {
|
||||
case FieldType.MultipleSelect:
|
||||
case FieldType.Attachment:
|
||||
return true;
|
||||
case FieldType.Link: {
|
||||
const options =
|
||||
field.options && typeof field.options === 'object' && !Array.isArray(field.options)
|
||||
? (field.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
const relationship = options?.relationship;
|
||||
return relationship === 'oneMany' || relationship === 'manyMany';
|
||||
}
|
||||
case FieldType.User: {
|
||||
const options =
|
||||
field.options && typeof field.options === 'object' && !Array.isArray(field.options)
|
||||
? (field.options as Record<string, unknown>)
|
||||
: undefined;
|
||||
return options?.isMultiple === true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeUndoField = (field: IFieldVo): IFieldVo => {
|
||||
const normalized: IFieldVo = {
|
||||
...field,
|
||||
};
|
||||
|
||||
if (normalized.cellValueType == null) {
|
||||
normalized.cellValueType = deriveCellValueType(normalized);
|
||||
}
|
||||
|
||||
if (normalized.isMultipleCellValue == null && deriveIsMultipleCellValue(normalized)) {
|
||||
normalized.isMultipleCellValue = true;
|
||||
}
|
||||
|
||||
if (normalized.dbFieldType == null && normalized.cellValueType != null) {
|
||||
normalized.dbFieldType = getDbFieldType(
|
||||
normalized.type as FieldType,
|
||||
normalized.cellValueType,
|
||||
normalized.isMultipleCellValue
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const buildModifiedOps = (
|
||||
tableId: string,
|
||||
fieldId: string,
|
||||
@ -419,6 +495,74 @@ class V2RecordsBatchUpdatedUndoRedoProjection implements IEventHandler<RecordsBa
|
||||
}
|
||||
}
|
||||
|
||||
@ProjectionHandler(FieldCreated)
|
||||
class V2FieldCreatedUndoRedoProjection implements IEventHandler<FieldCreated> {
|
||||
constructor(
|
||||
private readonly undoRedoStackService: UndoRedoStackService,
|
||||
private readonly tableQueryService: TableQueryService,
|
||||
private readonly tableMapper: ITableMapper
|
||||
) {}
|
||||
|
||||
async handle(
|
||||
context: IExecutionContext,
|
||||
event: FieldCreated
|
||||
): Promise<Result<void, DomainError>> {
|
||||
const { windowId, actorId } = context;
|
||||
|
||||
if (!windowId) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const tableId = event.tableId.toString();
|
||||
const userId = actorId.toString();
|
||||
const fieldId = event.fieldId.toString();
|
||||
|
||||
const tableResult = await this.tableQueryService.getById(context, event.tableId);
|
||||
if (tableResult.isErr()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const tableDtoResult = this.tableMapper.toDTO(tableResult.value);
|
||||
if (tableDtoResult.isErr()) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const createdField = tableDtoResult.value.fields.find((field) => field.id === fieldId);
|
||||
if (!createdField) {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const eventColumnMeta = Object.fromEntries(
|
||||
Object.entries(event.viewOrders ?? {}).map(([viewId, order]) => [viewId, { order }])
|
||||
) as IColumnMeta;
|
||||
const fallbackColumnMeta = Object.fromEntries(
|
||||
tableDtoResult.value.views.flatMap((view) => {
|
||||
const column = (view.columnMeta as Record<string, unknown> | undefined)?.[fieldId];
|
||||
return column == null ? [] : [[view.id, column]];
|
||||
})
|
||||
) as IColumnMeta;
|
||||
const normalizedColumnMeta =
|
||||
Object.keys(eventColumnMeta).length > 0 ? eventColumnMeta : fallbackColumnMeta;
|
||||
const createdFieldWithMeta: IFieldVo & { columnMeta?: IColumnMeta } = {
|
||||
...normalizeUndoField(createdField as unknown as IFieldVo),
|
||||
...(Object.keys(normalizedColumnMeta).length > 0 ? { columnMeta: normalizedColumnMeta } : {}),
|
||||
};
|
||||
|
||||
const operation: ICreateFieldsOperation = {
|
||||
name: OperationName.CreateFields,
|
||||
params: {
|
||||
tableId,
|
||||
},
|
||||
result: {
|
||||
fields: [createdFieldWithMeta],
|
||||
},
|
||||
};
|
||||
|
||||
await this.undoRedoStackService.push(userId, tableId, windowId, operation);
|
||||
return ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 projection handler that captures field type-conversion events and pushes
|
||||
* convert-field operations to undo/redo stack.
|
||||
@ -775,6 +919,11 @@ export class V2UndoRedoService {
|
||||
)
|
||||
);
|
||||
|
||||
container.registerInstance(
|
||||
V2FieldCreatedUndoRedoProjection,
|
||||
new V2FieldCreatedUndoRedoProjection(undoRedoStackService, tableQueryService, tableMapper)
|
||||
);
|
||||
|
||||
container.registerInstance(
|
||||
V2RecordReorderedUndoRedoProjection,
|
||||
new V2RecordReorderedUndoRedoProjection(undoRedoStackService)
|
||||
|
||||
@ -63,7 +63,7 @@ const nativeRequire: NodeRequire =
|
||||
typeof __non_webpack_require__ !== 'undefined' ? __non_webpack_require__ : require;
|
||||
|
||||
const { BatchLogRecordProcessor } = opentelemetry.logs;
|
||||
const { PeriodicExportingMetricReader } = opentelemetry.metrics;
|
||||
const { PeriodicExportingMetricReader, AggregationType } = opentelemetry.metrics;
|
||||
const { AlwaysOnSampler } = opentelemetry.node;
|
||||
|
||||
const otelLogger = new Logger('OpenTelemetry');
|
||||
@ -160,6 +160,32 @@ const metricsExporter = metricsEndpoint
|
||||
? new OTLPMetricExporter(createExporterOptions(metricsEndpoint))
|
||||
: undefined;
|
||||
|
||||
// Strip high-cardinality resource attributes from metrics only.
|
||||
// Traces and logs keep these for debugging; metrics drop them to prevent
|
||||
// cardinality explosion in ephemeral containers (each restart = new host.name + pid).
|
||||
if (metricsExporter) {
|
||||
const dropFromMetricResource = new Set([
|
||||
'host.name',
|
||||
'host.arch',
|
||||
'os.type',
|
||||
'os.description',
|
||||
'process.pid',
|
||||
'process.command',
|
||||
'process.command_args',
|
||||
'process.command_line',
|
||||
'process.executable.path',
|
||||
'process.owner',
|
||||
'service.instance.id',
|
||||
]);
|
||||
const origExport = metricsExporter.export.bind(metricsExporter);
|
||||
metricsExporter.export = (metrics, cb) => {
|
||||
const attrs = Object.fromEntries(
|
||||
Object.entries(metrics.resource.attributes).filter(([k]) => !dropFromMetricResource.has(k))
|
||||
);
|
||||
origExport({ ...metrics, resource: resourceFromAttributes(attrs) }, cb);
|
||||
};
|
||||
}
|
||||
|
||||
// Smart export: deterministic decision based on traceId hash
|
||||
// No cache needed - hash function is pure and fast
|
||||
const getTraceDecision = (traceId: string): boolean => {
|
||||
@ -263,12 +289,21 @@ const ignorePaths = [
|
||||
'/health',
|
||||
];
|
||||
|
||||
// Drop old semconv HTTP metrics — new semconv (http.*.request.duration) is used in all dashboards;
|
||||
// the old names (http.server.duration, http.client.duration) are pure duplicates with high cardinality.
|
||||
const dropAggregation = { type: AggregationType.DROP } as const;
|
||||
const metricViews = [
|
||||
{ instrumentName: 'http.server.duration', aggregation: dropAggregation },
|
||||
{ instrumentName: 'http.client.duration', aggregation: dropAggregation },
|
||||
];
|
||||
|
||||
const otelSDK = new opentelemetry.NodeSDK({
|
||||
spanProcessors,
|
||||
logRecordProcessors: logExporter ? [new BatchLogRecordProcessor(logExporter)] : [],
|
||||
sampler: new AlwaysOnSampler(),
|
||||
contextManager: SentryContextManager ? new SentryContextManager() : undefined,
|
||||
textMapPropagator: hasSentry ? new SentryPropagator() : undefined,
|
||||
views: metricViews,
|
||||
metricReader: metricsExporter
|
||||
? new PeriodicExportingMetricReader({
|
||||
exporter: metricsExporter,
|
||||
|
||||
17
apps/nestjs-backend/src/tracing/base-tracing.service.ts
Normal file
17
apps/nestjs-backend/src/tracing/base-tracing.service.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Span } from '@opentelemetry/api';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
|
||||
export abstract class BaseTracingService {
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
protected withActiveSpan(fn: (span: Span) => void): void {
|
||||
try {
|
||||
const span = trace.getActiveSpan();
|
||||
if (!span) return;
|
||||
fn(span);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Tracing failed: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -255,6 +255,7 @@ export type I18nTranslations = {
|
||||
"copySuccess": string;
|
||||
"retry": string;
|
||||
"copyToMySpace": string;
|
||||
"copyLink": string;
|
||||
"collapse": string;
|
||||
"viewDetails": string;
|
||||
};
|
||||
|
||||
@ -104,6 +104,148 @@ describe('OpenAPI FieldOpenApiController for duplicate field (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate field response compatibility under FORCE_V2', () => {
|
||||
let table: ITableFullVo;
|
||||
let foreignTable: ITableFullVo;
|
||||
let linkFieldId: string;
|
||||
let foreignPrimaryFieldId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
foreignTable = await createTable(baseId, {
|
||||
name: 'dup_force_v2_compat_foreign',
|
||||
fields: [
|
||||
{
|
||||
type: FieldType.SingleLineText,
|
||||
name: 'foreign_name',
|
||||
},
|
||||
],
|
||||
});
|
||||
foreignPrimaryFieldId = foreignTable.fields.find((f) => f.isPrimary)!.id;
|
||||
|
||||
table = await createTable(baseId, {
|
||||
name: 'dup_force_v2_compat_main',
|
||||
});
|
||||
|
||||
const linkField = (
|
||||
await createField(table.id, {
|
||||
type: FieldType.Link,
|
||||
name: 'to_foreign',
|
||||
options: {
|
||||
relationship: Relationship.ManyMany,
|
||||
foreignTableId: foreignTable.id,
|
||||
isOneWay: false,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
linkFieldId = linkField.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await permanentDeleteTable(baseId, table.id);
|
||||
await permanentDeleteTable(baseId, foreignTable.id);
|
||||
});
|
||||
|
||||
it('keeps description but omits false/linked compatibility keys in duplicated fields', async () => {
|
||||
const describedField = (
|
||||
await createField(table.id, {
|
||||
type: FieldType.Number,
|
||||
name: 'number_with_description',
|
||||
description: 'description_kept',
|
||||
})
|
||||
).data;
|
||||
const duplicatedDescribedField = (
|
||||
await duplicateField(table.id, describedField.id, {
|
||||
name: 'number_with_description_copy',
|
||||
})
|
||||
).data;
|
||||
expect(duplicatedDescribedField.description).toBe('description_kept');
|
||||
|
||||
const lookupField = (
|
||||
await createField(table.id, {
|
||||
type: FieldType.SingleLineText,
|
||||
name: 'lookup_force_v2_compat',
|
||||
isLookup: true,
|
||||
lookupOptions: {
|
||||
foreignTableId: foreignTable.id,
|
||||
linkFieldId,
|
||||
lookupFieldId: foreignPrimaryFieldId,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
const duplicatedLookupField = (
|
||||
await duplicateField(table.id, lookupField.id, {
|
||||
name: 'lookup_force_v2_compat_copy',
|
||||
})
|
||||
).data;
|
||||
const duplicatedLookupOptions = duplicatedLookupField.lookupOptions as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(duplicatedLookupOptions ?? {}, 'isOneWay')).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(duplicatedLookupOptions ?? {}, 'symmetricFieldId')
|
||||
).toBe(false);
|
||||
|
||||
const rollupField = (
|
||||
await createField(table.id, {
|
||||
type: FieldType.Rollup,
|
||||
name: 'rollup_force_v2_compat',
|
||||
lookupOptions: {
|
||||
foreignTableId: foreignTable.id,
|
||||
linkFieldId,
|
||||
lookupFieldId: foreignPrimaryFieldId,
|
||||
},
|
||||
options: {
|
||||
expression: 'countall({values})',
|
||||
},
|
||||
})
|
||||
).data;
|
||||
const duplicatedRollupField = (
|
||||
await duplicateField(table.id, rollupField.id, {
|
||||
name: 'rollup_force_v2_compat_copy',
|
||||
})
|
||||
).data;
|
||||
const duplicatedRollupLookupOptions = duplicatedRollupField.lookupOptions as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(duplicatedRollupLookupOptions ?? {}, 'isOneWay')
|
||||
).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
duplicatedRollupLookupOptions ?? {},
|
||||
'symmetricFieldId'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
const buttonField = (
|
||||
await createField(table.id, {
|
||||
type: FieldType.Button,
|
||||
name: 'button_force_v2_compat',
|
||||
options: {
|
||||
label: 'go',
|
||||
color: Colors.Blue,
|
||||
workflow: {
|
||||
id: generateWorkflowId(),
|
||||
name: 'wf_for_compat',
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
).data;
|
||||
const duplicatedButtonField = (
|
||||
await duplicateField(table.id, buttonField.id, {
|
||||
name: 'button_force_v2_compat_copy',
|
||||
})
|
||||
).data;
|
||||
|
||||
expect(duplicatedButtonField.isMultipleCellValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@ -3132,7 +3132,7 @@ describe('OpenAPI formula (e2e)', () => {
|
||||
}
|
||||
await permanentDeleteTable(baseId, foreign.id);
|
||||
}
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
it('applies timezone-aware formatting before slicing datetime values', async () => {
|
||||
const foreign = await createTable(baseId, {
|
||||
|
||||
@ -538,7 +538,6 @@ describe('OpenAPI integrity (e2e)', () => {
|
||||
|
||||
await executeKnex(
|
||||
knex.schema.alterTable(options.fkHostTableName, (table) => {
|
||||
table.dropForeign(options.foreignKeyName, `fk_${options.foreignKeyName}`);
|
||||
table.dropColumn(options.foreignKeyName);
|
||||
table.dropColumn(`${options.foreignKeyName}_order`);
|
||||
})
|
||||
@ -591,7 +590,6 @@ describe('OpenAPI integrity (e2e)', () => {
|
||||
|
||||
await executeKnex(
|
||||
knex.schema.alterTable(options.fkHostTableName, (table) => {
|
||||
table.dropForeign(options.foreignKeyName, `fk_${options.foreignKeyName}`);
|
||||
table.dropColumn(options.foreignKeyName);
|
||||
})
|
||||
);
|
||||
@ -638,7 +636,6 @@ describe('OpenAPI integrity (e2e)', () => {
|
||||
|
||||
await executeKnex(
|
||||
knex.schema.alterTable(options.fkHostTableName, (table) => {
|
||||
table.dropForeign(options.selfKeyName, `fk_${options.selfKeyName}`);
|
||||
table.dropColumn(options.selfKeyName);
|
||||
})
|
||||
);
|
||||
|
||||
@ -879,7 +879,6 @@ describe('OpenAPI Lookup field (e2e)', () => {
|
||||
);
|
||||
expect(sourceRecord).toBeTruthy();
|
||||
expect(hostRecord).toBeTruthy();
|
||||
|
||||
expect(hostRecord!.fields[HOST_LOOKUP_AUTO]).toEqual(sourceRecord!.fields[SOURCE_AUTO_FIELD]);
|
||||
expect(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_CREATED_TIME] as unknown)).toEqual(
|
||||
sourceRecord!.fields[SOURCE_CREATED_TIME_FIELD]
|
||||
|
||||
@ -16,9 +16,9 @@ export function createEventPromise(eventEmitterService: EventEmitterService, eve
|
||||
}
|
||||
|
||||
export function createAwaitWithEvent(eventEmitterService: EventEmitterService, event: Events) {
|
||||
return async function fn<T>(fn: () => Promise<T>) {
|
||||
return async function runWithEvent<T>(action: () => Promise<T>) {
|
||||
const promise = createEventPromise(eventEmitterService, event);
|
||||
const result = await fn();
|
||||
const result = await action();
|
||||
await promise;
|
||||
return result;
|
||||
};
|
||||
@ -28,9 +28,9 @@ export function createAwaitWithEventWithResult<R = unknown>(
|
||||
eventEmitterService: EventEmitterService,
|
||||
event: Events
|
||||
) {
|
||||
return async function fn<T>(fn: () => Promise<T>) {
|
||||
return async function runWithEventResult<T>(action: () => Promise<T>) {
|
||||
const promise = createEventPromise(eventEmitterService, event);
|
||||
await fn();
|
||||
await action();
|
||||
await promise;
|
||||
return (await promise) as R;
|
||||
};
|
||||
@ -62,9 +62,9 @@ export function createAwaitWithEventWithResultWithCount(
|
||||
event: Events,
|
||||
count: number = 1
|
||||
) {
|
||||
return async function fn<T>(fn: () => Promise<T>) {
|
||||
return async function runWithEventResultCount<T>(action: () => Promise<T>) {
|
||||
const promise = createEventPromiseWithCount(eventEmitterService, event, count);
|
||||
const result = await fn();
|
||||
const result = await action();
|
||||
const payloads = await promise;
|
||||
return {
|
||||
result,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HelpCircle, MoreHorizontal, UserPlus } from '@teable/icons';
|
||||
import { Copy, HelpCircle, MoreHorizontal, UserPlus } from '@teable/icons';
|
||||
import { BaseNodeResourceType } from '@teable/openapi';
|
||||
import {
|
||||
useBase,
|
||||
@ -21,6 +21,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTrigger,
|
||||
} from '@teable/ui-lib/shadcn';
|
||||
import { toast } from '@teable/ui-lib/shadcn/ui/sonner';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@ -30,6 +31,7 @@ import { ShareBasePopover } from '@/features/app/components/collaborator/share/S
|
||||
import { PublicOperateButton } from '@/features/app/components/PublicOperateButton';
|
||||
import type { IBaseResourceTable } from '@/features/app/hooks/useBaseResource';
|
||||
import { useBaseResource } from '@/features/app/hooks/useBaseResource';
|
||||
import { useIsInIframe } from '@/features/app/hooks/useIsInIframe';
|
||||
import { tableConfig } from '@/features/i18n/table.config';
|
||||
import { BaseNodeMore } from '../../base/base-side-bar/BaseNodeMore';
|
||||
import { ExpandViewList } from '../../view/list/ExpandViewList';
|
||||
@ -208,12 +210,14 @@ const RightActions = ({ setIsEditing }: { setIsEditing?: (isEditing: boolean) =>
|
||||
};
|
||||
|
||||
export const TableHeader: React.FC = () => {
|
||||
const { t } = useTranslation(tableConfig.i18nNamespaces);
|
||||
const view = useView();
|
||||
const { visible } = useLockedViewTipStore();
|
||||
const isReadOnlyPreview = useIsReadOnlyPreview();
|
||||
const template = useTemplate();
|
||||
const isInIframe = useIsInIframe();
|
||||
// Only show PublicOperateButton for real templates, not for share mode
|
||||
const isRealTemplate = !!template;
|
||||
const isRealTemplate = !!template && !isInIframe;
|
||||
const tipVisible = view?.isLocked && visible;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
return (
|
||||
@ -236,7 +240,20 @@ export const TableHeader: React.FC = () => {
|
||||
<div className="grow basis-0"></div>
|
||||
{!isReadOnlyPreview && <RightActions setIsEditing={setIsEditing} />}
|
||||
{isRealTemplate && (
|
||||
<div className="min-w-20">
|
||||
<div className="flex min-w-20 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-[13px] font-normal"
|
||||
onClick={() => {
|
||||
const link = `${window.location.origin}/t/${template!.id}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
toast.success(t('common:actions.copyLink'));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
{t('common:actions.copyLink')}
|
||||
</Button>
|
||||
<PublicOperateButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { Copy, X } from '@teable/icons';
|
||||
import { Copy, Trash2 } from '@teable/icons';
|
||||
import { useLanDayjs } from '@teable/sdk/hooks';
|
||||
import { syncCopy } from '@teable/sdk/utils';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@ -29,36 +28,46 @@ export const InviteLinkItem = (props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-3 pr-7">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Input className="h-8 flex-1" value={url} readOnly />
|
||||
<Copy
|
||||
onClick={copyInviteUrl}
|
||||
className="size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('invite.dialog.linkCreatedTime', { createdTime: dayjs(createdTime).fromNow() })}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{url}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{dayjs(createdTime).format('YYYY-MM-DD')}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="absolute right-0 h-auto p-0 hover:bg-inherit"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deleteDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<X className="size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('invite.dialog.linkRemove')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button className="size-8 p-0" size="sm" variant="ghost" onClick={copyInviteUrl}>
|
||||
<Copy className="size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('actions.copyLink')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="size-8 p-0"
|
||||
variant="ghost"
|
||||
disabled={deleteDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('invite.dialog.linkRemove')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Gibt die Differenz zwischen den Zeitpunkten in den angegebenen Einheiten zurück. Standardeinheiten sind Sekunden. (Siehe Liste der Einheitenspezifikationen hier.)\nDie Differenz zwischen Datumszeiten wird durch Subtraktion von [datum2] von [datum1] ermittelt. Das bedeutet, dass der resultierende Wert negativ ist, wenn [datum2] später als [datum1] liegt.",
|
||||
"summary": "Gibt die Differenz zwischen den Zeitpunkten in den angegebenen Einheiten zurück. Standardeinheit ist \"day\".\nUnterstützte Einheiten: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nDie Differenz zwischen Datumszeiten wird durch Subtraktion von [datum2] von [datum1] ermittelt. Das bedeutet, dass der resultierende Wert negativ ist, wenn [datum2] später als [datum1] liegt.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -63,6 +63,7 @@
|
||||
"login": "Login",
|
||||
"useTemplate": "Use template",
|
||||
"copyToMySpace": "Copy to my space",
|
||||
"copyLink": "Copy link",
|
||||
"backToSpace": "Back to space",
|
||||
"switchBase": "Switch base",
|
||||
"collapse": "Collapse",
|
||||
|
||||
@ -677,7 +677,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Returns the difference between datetimes in specified units. Default units are seconds. (See list of unit specifiers here.)\nThe difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative.",
|
||||
"summary": "Returns the difference between datetimes in specified units. Default unit is \"day\".\nSupported units: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nThe difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -664,7 +664,7 @@
|
||||
"example": "Tonow ({date}, \"día\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Devuelve la diferencia entre datos en unidades especificadas. ",
|
||||
"summary": "Devuelve la diferencia entre fechas en unidades especificadas. La unidad predeterminada es \"day\".\nUnidades soportadas: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nLa diferencia entre fechas se determina restando [date2] de [date1]. Esto significa que si [date2] es posterior a [date1], el valor resultante será negativo.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Renvoie la différence entre les datetimes dans les unités spécifiées. Les unités par défaut sont les secondes. (Voir la liste des spécificateurs d'unité ici.)\nLa différence entre les datetimes est déterminée en soustrayant [date2] de [date1]. Cela signifie que si [date2] est plus tard que [date1], la valeur résultante sera négative.",
|
||||
"summary": "Renvoie la différence entre les datetimes dans les unités spécifiées. L'unité par défaut est \"day\".\nUnités supportées : \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nLa différence entre les datetimes est déterminée en soustrayant [date2] de [date1]. Cela signifie que si [date2] est plus tard que [date1], la valeur résultante sera négative.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Restituisce la differenza tra le date in unità specificate. Le unità predefinite sono i secondi. (Vedi l'elenco dei specificatori di unità qui.)\nLa differenza tra le date è determinata sottraendo [date2] da [date1]. Ciò significa che se [date2] è successiva a [date1], il valore risultante sarà negativo.",
|
||||
"summary": "Restituisce la differenza tra le date in unità specificate. L'unità predefinita è \"day\".\nUnità supportate: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nLa differenza tra le date è determinata sottraendo [date2] da [date1]. Ciò significa che se [date2] è successiva a [date1], il valore risultante sarà negativo.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "指定された単位で日付時刻の差を返します。デフォルトの単位は秒です。(単位指定子のリストはここを参照してください。)\n日付時刻の差は、[date1] から [date2] を減算して決定されます。つまり、[date2] が [date1] より後の場合、結果の値は負になります。",
|
||||
"summary": "指定された単位で日付時刻の差を返します。デフォルトの単位は \"day\" です。\nサポートされている単位: \"millisecond\" (ms)、\"second\" (s)、\"minute\" (m)、\"hour\" (h)、\"day\" (d)、\"week\" (w)、\"month\" (M)、\"year\" (y)。\n日付時刻の差は、[date1] から [date2] を減算して決定されます。つまり、[date2] が [date1] より後の場合、結果の値は負になります。",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"день\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Возвращает разницу между датами в указанных единицах. По умолчанию — в секундах.",
|
||||
"summary": "Возвращает разницу между датами в указанных единицах. Единица по умолчанию — \"day\".\nПоддерживаемые единицы: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nРазница между датами определяется путём вычитания [date2] из [date1]. Это означает, что если [date2] позже [date1], результат будет отрицательным.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Tarih/saatler arasındaki farkı belirtilen birimde döndürür. Varsayılan birim saniyedir. (Birim belirteçlerinin listesi için buraya bakın.)\nTarih/saatler arasındaki fark [date2]'nin [date1]'den çıkarılmasıyla belirlenir. Bu, eğer [date2] [date1]'den sonraysa, sonuç değerinin negatif olacağı anlamına gelir.",
|
||||
"summary": "Tarih/saatler arasındaki farkı belirtilen birimde döndürür. Varsayılan birim \"day\"dir.\nDesteklenen birimler: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nTarih/saatler arasındaki fark [date2]'nin [date1]'den çıkarılmasıyla belirlenir. Bu, eğer [date2] [date1]'den sonraysa, sonuç değerinin negatif olacağı anlamına gelir.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -660,7 +660,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "Повертає різницю між датою та часом у вказаних одиницях. Одиницями за замовчуванням є секунди. (Див. список специфікаторів одиниць тут.)\nРізниця між датою та часом визначається шляхом віднімання [date2] від [date1]. Це означає, що якщо [date2] пізніше [date1], результуюче значення буде від’ємним.",
|
||||
"summary": "Повертає різницю між датою та часом у вказаних одиницях. Одиниця за замовчуванням — \"day\".\nПідтримувані одиниці: \"millisecond\" (ms), \"second\" (s), \"minute\" (m), \"hour\" (h), \"day\" (d), \"week\" (w), \"month\" (M), \"year\" (y).\nРізниця між датою та часом визначається шляхом віднімання [date2] від [date1]. Це означає, що якщо [date2] пізніше [date1], результуюче значення буде від'ємним.",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
@ -63,6 +63,7 @@
|
||||
"login": "登录",
|
||||
"useTemplate": "使用模版",
|
||||
"copyToMySpace": "复制到我的空间",
|
||||
"copyLink": "复制链接",
|
||||
"backToSpace": "返回空间",
|
||||
"switchBase": "切换数据库",
|
||||
"collapse": "收起",
|
||||
|
||||
@ -677,7 +677,7 @@
|
||||
"example": "TONOW({Date}, \"day\") => 25"
|
||||
},
|
||||
"DATETIME_DIFF": {
|
||||
"summary": "以指定的单位返回日期时间之间的差异。默认单位为秒。(单位说明符列表请点击此处。)\n日期时间之间的差异是通过从 [date1] 减去 [date2] 来确定的。这意味着如果 [date2] 晚于 [date1],结果值将为负数。",
|
||||
"summary": "以指定的单位返回日期时间之间的差异。默认单位为 \"day\"。\n支持的单位:\"millisecond\" (ms)、\"second\" (s)、\"minute\" (m)、\"hour\" (h)、\"day\" (d)、\"week\" (w)、\"month\" (M)、\"year\" (y)。\n日期时间之间的差异是通过从 [date1] 减去 [date2] 来确定的。这意味着如果 [date2] 晚于 [date1],结果值将为负数。",
|
||||
"example": "DATETIME_DIFF(\"2023-09-08\", \"2022-08-01\", \"day\") => 403"
|
||||
},
|
||||
"WORKDAY": {
|
||||
|
||||
58
packages/core/src/errors/extract-error-message.ts
Normal file
58
packages/core/src/errors/extract-error-message.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Safely extract a human-readable message from any thrown value.
|
||||
*
|
||||
* Handles all common shapes:
|
||||
* - `Error` instances → error.message
|
||||
* - plain strings → the string itself
|
||||
* - objects with message/error → the string field
|
||||
* - nested { error: { message } } → the nested message
|
||||
* - { responseBody: '{"error":{"message":"..."}}' } → parsed body message
|
||||
* - everything else → JSON.stringify (truncated) or fallback text
|
||||
*/
|
||||
const unknownError = 'Unknown error';
|
||||
|
||||
export function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || error.name || unknownError;
|
||||
if (typeof error === 'string') return error;
|
||||
if (typeof error !== 'object' || error === null) return unknownError;
|
||||
|
||||
const obj = error as Record<string, unknown>;
|
||||
|
||||
const direct = getStringField(obj, 'message') || getStringField(obj, 'error');
|
||||
if (direct) return direct;
|
||||
|
||||
const nested = getNestedErrorMessage(obj);
|
||||
if (nested) return nested;
|
||||
|
||||
const body = getResponseBodyMessage(obj);
|
||||
if (body) return body;
|
||||
|
||||
try {
|
||||
return JSON.stringify(error).slice(0, 500);
|
||||
} catch {
|
||||
return unknownError;
|
||||
}
|
||||
}
|
||||
|
||||
function getStringField(obj: Record<string, unknown>, field: string): string | null {
|
||||
const value = obj[field];
|
||||
return typeof value === 'string' && value ? value : null;
|
||||
}
|
||||
|
||||
function getNestedErrorMessage(obj: Record<string, unknown>): string | null {
|
||||
const nested = obj.error;
|
||||
if (typeof nested === 'object' && nested !== null) {
|
||||
return getStringField(nested as Record<string, unknown>, 'message');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getResponseBodyMessage(obj: Record<string, unknown>): string | null {
|
||||
if (typeof obj.responseBody !== 'string') return null;
|
||||
try {
|
||||
const body = JSON.parse(obj.responseBody);
|
||||
return body.error?.message || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './extract-error-message';
|
||||
export * from './http';
|
||||
export * from './types';
|
||||
|
||||
53
packages/openapi/src/admin/setting/ai-key-stats.ts
Normal file
53
packages/openapi/src/admin/setting/ai-key-stats.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
|
||||
import { z } from 'zod';
|
||||
import { axios } from '../../axios';
|
||||
import { registerRoute } from '../../utils';
|
||||
|
||||
export const aiKeyStatsVoSchema = z.object({
|
||||
groups: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
keys: z.array(
|
||||
z.object({
|
||||
index: z.number(),
|
||||
fingerprint: z.string(),
|
||||
totalRequests: z.number(),
|
||||
totalFailures: z.number(),
|
||||
activeRequests: z.number(),
|
||||
lastUsedAt: z.number().nullable(),
|
||||
isActive: z.boolean(),
|
||||
lastError: z.string().nullable(),
|
||||
})
|
||||
),
|
||||
totalSlots: z.number(),
|
||||
activeSlots: z.number(),
|
||||
waitingCount: z.number(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type IAiKeyStatsVo = z.infer<typeof aiKeyStatsVoSchema>;
|
||||
|
||||
export const AI_KEY_STATS = '/admin/setting/ai-key-stats';
|
||||
|
||||
export const AiKeyStatsRoute: RouteConfig = registerRoute({
|
||||
method: 'get',
|
||||
path: AI_KEY_STATS,
|
||||
description: 'Get per-key usage statistics for AI Gateway API keys',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Key statistics by group',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: aiKeyStatsVoSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['admin', 'setting'],
|
||||
});
|
||||
|
||||
export const getAiKeyStats = async (): Promise<IAiKeyStatsVo> => {
|
||||
const response = await axios.get<IAiKeyStatsVo>(AI_KEY_STATS);
|
||||
return response.data;
|
||||
};
|
||||
@ -8,3 +8,4 @@ export * from './batch-test-llm';
|
||||
export * from './test-api-key';
|
||||
export * from './test-public-access';
|
||||
export * from './set-transport-config';
|
||||
export * from './ai-key-stats';
|
||||
|
||||
@ -435,6 +435,43 @@ export const AttachmentTransferModeValues = ['url', 'base64'] as const;
|
||||
export type AttachmentTransferMode = (typeof AttachmentTransferModeValues)[number];
|
||||
export const attachmentTransferModeSchema = z.enum(AttachmentTransferModeValues);
|
||||
|
||||
// Task types for AI concurrency group routing
|
||||
export const TaskTypeValues = ['text', 'image'] as const;
|
||||
export type TaskType = (typeof TaskTypeValues)[number];
|
||||
export const taskTypeSchema = z.enum(TaskTypeValues);
|
||||
|
||||
// API key entry within a concurrency group (with verification status)
|
||||
export const concurrencyKeyEntrySchema = z.object({
|
||||
apiKey: z.string(),
|
||||
status: z.enum(['verified', 'untested', 'error']).default('untested'),
|
||||
});
|
||||
|
||||
export type IConcurrencyKeyEntry = z.infer<typeof concurrencyKeyEntrySchema>;
|
||||
|
||||
// Named group of API keys sharing a concurrency pool, scoped to specific task types
|
||||
export const concurrencyGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
taskTypes: z.array(taskTypeSchema).default([]),
|
||||
keys: z.array(concurrencyKeyEntrySchema).default([]),
|
||||
perKey: z.number().min(1).max(100).default(5).optional(),
|
||||
});
|
||||
|
||||
export type IConcurrencyGroup = z.infer<typeof concurrencyGroupSchema>;
|
||||
|
||||
// Vertex BYOK credential for free quota optimization via AI Gateway BYOK
|
||||
// @see https://vercel.com/docs/ai-gateway/authentication-and-byok/byok#credential-structure-by-provider
|
||||
export const vertexByokCredentialSchema = z.object({
|
||||
project: z.string(),
|
||||
location: z.string(),
|
||||
googleCredentials: z.object({
|
||||
privateKey: z.string(),
|
||||
clientEmail: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type IVertexByokCredential = z.infer<typeof vertexByokCredentialSchema>;
|
||||
|
||||
export const aiConfigSchema = z.object({
|
||||
llmProviders: z.array(llmProviderSchema).default([]),
|
||||
embeddingModel: z.string().optional(),
|
||||
@ -455,6 +492,14 @@ export const aiConfigSchema = z.object({
|
||||
attachmentTest: attachmentTestSchema.optional(),
|
||||
// Attachment transfer mode: 'url' (default) or 'base64'
|
||||
attachmentTransferMode: attachmentTransferModeSchema.default('url').optional(),
|
||||
// Multiple AI Gateway API keys for concurrency scaling via key rotation
|
||||
aiGatewayApiKeys: z.array(z.string()).optional(),
|
||||
// Vertex AI BYOK credential (free quota optimization for Google models)
|
||||
vertexByokCredential: vertexByokCredentialSchema.optional(),
|
||||
// Named concurrency groups: each group owns a set of API keys and task types
|
||||
concurrencyGroups: z.array(concurrencyGroupSchema).optional(),
|
||||
// Default concurrency slots per API key (applies when groups don't specify perKey)
|
||||
concurrencyPerKey: z.number().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export type IAIConfig = z.infer<typeof aiConfigSchema>;
|
||||
@ -493,6 +538,8 @@ export const v2FeatureSchema = z.enum([
|
||||
'paste',
|
||||
'clear',
|
||||
'importRecords',
|
||||
'createField',
|
||||
'duplicateField',
|
||||
'updateField',
|
||||
'convertField',
|
||||
]);
|
||||
|
||||
@ -826,6 +826,77 @@ describe('PostgresTableRepository (pg)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('persists and rehydrates aiConfig on create', 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 baseId = BaseId.create(`bse${'g'.repeat(16)}`)._unsafeUnwrap();
|
||||
const actorId = ActorId.create('system')._unsafeUnwrap();
|
||||
const context = { actorId };
|
||||
const spaceId = `spc${getRandomString(16)}`;
|
||||
|
||||
await db
|
||||
.insertInto('space')
|
||||
.values({ id: spaceId, name: 'AI Space', created_by: actorId.toString() })
|
||||
.execute();
|
||||
await db
|
||||
.insertInto('base')
|
||||
.values({
|
||||
id: baseId.toString(),
|
||||
space_id: spaceId,
|
||||
name: 'AI Base',
|
||||
order: 1,
|
||||
created_by: actorId.toString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const tableName = TableName.create('AI Table')._unsafeUnwrap();
|
||||
const titleName = FieldName.create('Title')._unsafeUnwrap();
|
||||
const aiName = FieldName.create('AI Summary')._unsafeUnwrap();
|
||||
|
||||
const builder = Table.builder().withBaseId(baseId).withName(tableName);
|
||||
builder.field().singleLineText().withName(titleName).primary().done();
|
||||
builder.field().singleLineText().withName(aiName).done();
|
||||
builder.view().defaultGrid().done();
|
||||
|
||||
const table = builder.build()._unsafeUnwrap();
|
||||
const aiField = table.getFields().find((field) => field.name().equals(aiName));
|
||||
expect(aiField).toBeDefined();
|
||||
if (!aiField) return;
|
||||
|
||||
const aiConfig = {
|
||||
type: 'summary',
|
||||
modelKey: 'openai@gpt-4o@gpt',
|
||||
sourceFieldId: table.primaryFieldId().toString(),
|
||||
};
|
||||
aiField.setAiConfig(aiConfig)._unsafeUnwrap();
|
||||
|
||||
(await repo.insert(context, table))._unsafeUnwrap();
|
||||
|
||||
const row = await db
|
||||
.selectFrom('field')
|
||||
.select(['id', 'ai_config'])
|
||||
.where('id', '=', aiField.id().toString())
|
||||
.where('deleted_time', 'is', null)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeDefined();
|
||||
expect(JSON.parse(row?.ai_config ?? 'null')).toEqual(aiConfig);
|
||||
|
||||
const spec = Table.specs(baseId).byId(table.id()).build()._unsafeUnwrap();
|
||||
const loaded = (await repo.findOne(context, spec))._unsafeUnwrap();
|
||||
const loadedAiField = loaded.getFields().find((field) => field.id().equals(aiField.id()));
|
||||
expect(loadedAiField?.aiConfig()).toEqual(aiConfig);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects duplicate db table names within a base', async () => {
|
||||
const c = container.createChildContainer();
|
||||
const db = await createPgDb(pgContainer.getConnectionUri());
|
||||
|
||||
@ -545,6 +545,7 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
'type',
|
||||
'options',
|
||||
'meta',
|
||||
'ai_config',
|
||||
'cell_value_type',
|
||||
'is_multiple_cell_value',
|
||||
'not_null',
|
||||
@ -640,6 +641,7 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
'type',
|
||||
'options',
|
||||
'meta',
|
||||
'ai_config',
|
||||
'cell_value_type',
|
||||
'is_multiple_cell_value',
|
||||
'not_null',
|
||||
@ -908,6 +910,7 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
type: string;
|
||||
options: string | null;
|
||||
meta: string | null;
|
||||
ai_config: string | null;
|
||||
cell_value_type: string | null;
|
||||
is_multiple_cell_value: boolean | null;
|
||||
not_null: boolean | null;
|
||||
@ -972,6 +975,7 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
type: string;
|
||||
options: string | null;
|
||||
meta: string | null;
|
||||
ai_config: string | null;
|
||||
cell_value_type: string | null;
|
||||
is_multiple_cell_value: boolean | null;
|
||||
not_null: boolean | null;
|
||||
@ -1060,10 +1064,12 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
const lookupOptions = resolveLookupOptions();
|
||||
const dbFieldName = row.db_field_name ?? undefined;
|
||||
const dbFieldType = row.db_field_type ?? undefined;
|
||||
const aiConfig = this.parseJsonValue(row.ai_config);
|
||||
const baseCommon = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
...(row.description !== null ? { description: row.description } : { description: null }),
|
||||
...(row.ai_config !== null ? { aiConfig } : {}),
|
||||
dbFieldName,
|
||||
dbFieldType,
|
||||
...(row.not_null ? { notNull: true } : {}),
|
||||
@ -1612,16 +1618,36 @@ export class PostgresTableRepository implements core.ITableRepository {
|
||||
defaultValue?: string | ReadonlyArray<string>;
|
||||
preventAutoNewOptions?: boolean;
|
||||
} {
|
||||
const normalizeColor = (color: unknown, index: number): string => {
|
||||
if (typeof color === 'string' && core.fieldColorValues.includes(color as never)) {
|
||||
return color;
|
||||
}
|
||||
return core.fieldColorValues[index % core.fieldColorValues.length];
|
||||
};
|
||||
|
||||
if (Array.isArray(raw.options)) {
|
||||
const choices = raw.options.map((name, index) => ({
|
||||
id: `cho${core.getRandomString(8)}`,
|
||||
name: String(name),
|
||||
color: core.fieldColorValues[index % core.fieldColorValues.length],
|
||||
color: normalizeColor(undefined, index),
|
||||
}));
|
||||
return { choices };
|
||||
}
|
||||
|
||||
const choices = Array.isArray(raw.choices) ? raw.choices : [];
|
||||
const choices = Array.isArray(raw.choices)
|
||||
? raw.choices.map((choice, index) => {
|
||||
const item =
|
||||
choice && typeof choice === 'object' ? (choice as Record<string, unknown>) : {};
|
||||
return {
|
||||
id:
|
||||
typeof item.id === 'string' && item.id.length > 0
|
||||
? item.id
|
||||
: `cho${core.getRandomString(8)}`,
|
||||
name: typeof item.name === 'string' ? item.name : String(item.name ?? ''),
|
||||
color: normalizeColor(item.color, index),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const defaultValue = raw.defaultValue;
|
||||
const preventAutoNewOptions =
|
||||
typeof raw.preventAutoNewOptions === 'boolean' ? raw.preventAutoNewOptions : undefined;
|
||||
|
||||
@ -30,7 +30,7 @@ export type TableFieldRow = {
|
||||
description: string | null;
|
||||
options: string | null;
|
||||
meta: string | null;
|
||||
ai_config: null;
|
||||
ai_config: string | null;
|
||||
type: string;
|
||||
cell_value_type: string;
|
||||
is_multiple_cell_value: boolean;
|
||||
@ -255,13 +255,18 @@ export class TableFieldPersistenceBuilder {
|
||||
: null;
|
||||
const persistedType = this.resolvePersistedFieldType(params.fieldDto);
|
||||
|
||||
const serializedAiConfig =
|
||||
params.fieldDto.aiConfig === undefined || params.fieldDto.aiConfig === null
|
||||
? null
|
||||
: JSON.stringify(params.fieldDto.aiConfig);
|
||||
|
||||
return {
|
||||
id: params.fieldDto.id,
|
||||
name: params.fieldDto.name,
|
||||
description: params.fieldDto.description ?? null,
|
||||
options: this.serializeFieldOptions(params.fieldDto),
|
||||
meta: this.serializeFieldMeta(params.fieldDto),
|
||||
ai_config: null,
|
||||
ai_config: serializedAiConfig,
|
||||
type: persistedType,
|
||||
cell_value_type: params.storageType.cellValueType,
|
||||
is_multiple_cell_value: params.storageType.isMultipleCellValue,
|
||||
@ -334,8 +339,9 @@ export class TableFieldPersistenceBuilder {
|
||||
if (field.isLookup && field.lookupOptions) {
|
||||
const linkOptions = this.resolveLinkFieldOptions(field.lookupOptions.linkFieldId);
|
||||
if (!linkOptions) return JSON.stringify(field.lookupOptions);
|
||||
const normalizedLinkOptions = this.normalizeLookupLinkedOptions(linkOptions);
|
||||
return JSON.stringify({
|
||||
...linkOptions,
|
||||
...normalizedLinkOptions,
|
||||
...field.lookupOptions,
|
||||
linkFieldId: field.lookupOptions.linkFieldId,
|
||||
});
|
||||
@ -346,7 +352,7 @@ export class TableFieldPersistenceBuilder {
|
||||
const linkOptions = this.resolveLinkFieldOptions(field.config.linkFieldId);
|
||||
if (!linkOptions) return JSON.stringify(field.config);
|
||||
return JSON.stringify({
|
||||
...linkOptions,
|
||||
...this.normalizeLookupLinkedOptions(linkOptions),
|
||||
...field.config,
|
||||
linkFieldId: field.config.linkFieldId,
|
||||
});
|
||||
@ -373,6 +379,19 @@ export class TableFieldPersistenceBuilder {
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeLookupLinkedOptions(
|
||||
linkOptions: ILinkFieldOptionsDTO
|
||||
): Partial<ILinkFieldOptionsDTO> {
|
||||
const options: Partial<ILinkFieldOptionsDTO> = { ...linkOptions };
|
||||
if (options.isOneWay === false) {
|
||||
delete options.isOneWay;
|
||||
}
|
||||
if ('symmetricFieldId' in options) {
|
||||
delete options.symmetricFieldId;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private resolveLinkFieldOptions(
|
||||
linkFieldId: string | undefined
|
||||
): ILinkFieldOptionsDTO | undefined {
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ok,
|
||||
RecordByIdsSpec,
|
||||
RecordConditionDateValue,
|
||||
RecordConditionFieldReferenceValue,
|
||||
RecordConditionLiteralListValue,
|
||||
RecordConditionLiteralValue,
|
||||
RecordId,
|
||||
@ -542,4 +543,21 @@ describe('TableRecordConditionWhereVisitor', () => {
|
||||
expect(compiled.sql).toContain('__id');
|
||||
expect(compiled.parameters).toEqual(recordIds.map((id) => id.toString()));
|
||||
});
|
||||
|
||||
test('supports date comparison with field reference values', () => {
|
||||
const field = fixture.fields.date;
|
||||
const value = RecordConditionFieldReferenceValue.create(field)._unsafeUnwrap();
|
||||
const spec = field.spec().create({ operator: 'isBefore', value });
|
||||
expect(spec.isOk()).toBe(true);
|
||||
if (spec.isErr()) return;
|
||||
|
||||
const visitor = new TableRecordConditionWhereVisitor({ tableAlias: 't' });
|
||||
const visitResult = spec.value.accept(visitor);
|
||||
expect(visitResult.isOk()).toBe(true);
|
||||
const where = visitor.where()._unsafeUnwrap();
|
||||
const compiled = compileCondition(db, where);
|
||||
|
||||
expect(compiled.sql).toContain('"t"."col_due_date" < "t"."col_due_date"');
|
||||
expect(compiled.parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -498,9 +498,19 @@ const buildDateComparisonCondition = (
|
||||
if (!value)
|
||||
return err(core.domainError.unexpected({ message: 'Record condition requires value' }));
|
||||
const column = yield* resolveColumn(field, tableAlias);
|
||||
const columnRef = sql.ref(column);
|
||||
|
||||
if (core.isRecordConditionFieldReferenceValue(value)) {
|
||||
const rightColumn = yield* resolveColumn(value.field(), tableAlias);
|
||||
const right = sql.ref(rightColumn);
|
||||
if (operator === '>') return ok(sql`${columnRef} > ${right}`);
|
||||
if (operator === '>=') return ok(sql`${columnRef} >= ${right}`);
|
||||
if (operator === '<') return ok(sql`${columnRef} < ${right}`);
|
||||
return ok(sql`${columnRef} <= ${right}`);
|
||||
}
|
||||
|
||||
const dateValue = yield* resolveDateValue(value);
|
||||
const range = yield* resolveDateRange(dateValue, resolveDateFormatting(field));
|
||||
const columnRef = sql.ref(column);
|
||||
const boundary = operator === '>' || operator === '<=' ? range.end : range.start;
|
||||
const right = sql`${boundary}`;
|
||||
if (operator === '>') return ok(sql`${columnRef} > ${right}`);
|
||||
|
||||
@ -294,6 +294,7 @@ export class ComputedFieldBackfillService {
|
||||
// This will select all records in the table
|
||||
const builder = new ComputedTableRecordQueryBuilder(db, {
|
||||
typeValidationStrategy: this.typeValidationStrategy,
|
||||
forceLookupArrayOutput: true,
|
||||
})
|
||||
.from(input.table)
|
||||
.select([fieldId]);
|
||||
@ -387,6 +388,7 @@ export class ComputedFieldBackfillService {
|
||||
// Build SELECT query for all computed fields
|
||||
const builder = new ComputedTableRecordQueryBuilder(db, {
|
||||
typeValidationStrategy: this.typeValidationStrategy,
|
||||
forceLookupArrayOutput: true,
|
||||
})
|
||||
.from(input.table)
|
||||
.select(fieldIds);
|
||||
|
||||
@ -832,6 +832,7 @@ export class ComputedFieldUpdater {
|
||||
if (!selectQuery) {
|
||||
const builder = new ComputedTableRecordQueryBuilder(db, {
|
||||
typeValidationStrategy: this.typeValidationStrategy,
|
||||
forceLookupArrayOutput: true,
|
||||
})
|
||||
.from(table)
|
||||
.select(fieldIds)
|
||||
|
||||
@ -595,7 +595,8 @@ export class ComputedUpdatePlanner {
|
||||
});
|
||||
|
||||
const cycleFieldIds = findCycleParticipantFieldIds(relevantEdges, computedFieldIds);
|
||||
const skippedFieldIdSet = cycleFieldIds.size > 0 ? cycleFieldIds : new Set(unsortedFieldIds);
|
||||
const skippedFieldIdSet =
|
||||
cycleFieldIds.size > 0 ? cycleFieldIds : new Set(unsortedFieldIds);
|
||||
const skippedFieldIds = [...skippedFieldIdSet];
|
||||
const message = `Computed field dependency cycle detected. Total unsorted: ${unsortedFieldIds.length}. Skipped cycle fields: ${skippedFieldIds.length}. Cycle: [${cycleInfoText}]. Sample fields: [${sampleFields.join(', ')}]`;
|
||||
const allowSkip = context.cyclePolicy === 'skip';
|
||||
@ -607,7 +608,9 @@ export class ComputedUpdatePlanner {
|
||||
);
|
||||
}
|
||||
|
||||
computedFieldIds = new Set([...computedFieldIds].filter((id) => !skippedFieldIdSet.has(id)));
|
||||
computedFieldIds = new Set(
|
||||
[...computedFieldIds].filter((id) => !skippedFieldIdSet.has(id))
|
||||
);
|
||||
relevantEdges = relevantEdges.filter(
|
||||
(edge) =>
|
||||
computedFieldIds.has(edge.fromFieldId.toString()) &&
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { domainError, Field, FieldType, FieldValueTypeVisitor } from '@teable/v2-core';
|
||||
import type {
|
||||
DomainError,
|
||||
ConditionalLookupField,
|
||||
FieldId,
|
||||
LookupField,
|
||||
Table,
|
||||
TableId,
|
||||
ConditionalLookupField,
|
||||
FieldValueType,
|
||||
LookupField,
|
||||
} from '@teable/v2-core';
|
||||
import type {
|
||||
CompiledQuery,
|
||||
@ -275,6 +275,7 @@ type FieldMapping = {
|
||||
fieldId: FieldId;
|
||||
isLookup: boolean;
|
||||
isLookupMultiValue: boolean;
|
||||
isLookupAutoNumber: boolean;
|
||||
dbFieldType: string;
|
||||
};
|
||||
|
||||
@ -285,19 +286,6 @@ const fieldIsJson = (field: Field): boolean => {
|
||||
return jsonSpecResult.value.isSatisfiedBy(field);
|
||||
};
|
||||
|
||||
const shouldSkipLookupAutoNumberUpdate = (field: Field): boolean => {
|
||||
if (
|
||||
!field.type().equals(FieldType.lookup()) &&
|
||||
!field.type().equals(FieldType.conditionalLookup())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const lookupField = field as LookupField | ConditionalLookupField;
|
||||
const innerTypeResult = lookupField.innerFieldType();
|
||||
if (innerTypeResult.isErr()) return false;
|
||||
return innerTypeResult.value.equals(FieldType.autoNumber());
|
||||
};
|
||||
|
||||
const resolveDbFieldType = (
|
||||
field: Field,
|
||||
cellValueType: string,
|
||||
@ -427,12 +415,13 @@ const buildLookupScalarCast = (expression: ReturnType<typeof sql>, columnType: s
|
||||
const buildLookupAssignmentFromRef = (
|
||||
sourceRef: unknown,
|
||||
lookupDbFieldType: string,
|
||||
isLookupMultiValue: boolean
|
||||
isLookupMultiValue: boolean,
|
||||
isLookupAutoNumber: boolean
|
||||
) => {
|
||||
const normalizedType = normalizeDbFieldType(lookupDbFieldType);
|
||||
if (normalizedType === 'jsonb') {
|
||||
const refJson = sql`to_jsonb(${sourceRef})`;
|
||||
if (isLookupMultiValue) {
|
||||
if (isLookupMultiValue && !isLookupAutoNumber) {
|
||||
return refJson;
|
||||
}
|
||||
return sql`(CASE WHEN jsonb_typeof(${refJson}) = 'array' THEN ${refJson} -> 0 ELSE ${refJson} END)`;
|
||||
@ -455,9 +444,6 @@ const buildFieldMappings = (
|
||||
if (field.hasError().isError()) {
|
||||
continue;
|
||||
}
|
||||
if (shouldSkipLookupAutoNumberUpdate(field)) {
|
||||
continue;
|
||||
}
|
||||
const dbFieldName = yield* field.dbFieldName();
|
||||
const columnName = yield* dbFieldName.value();
|
||||
// Determine if this is a lookup field
|
||||
@ -467,6 +453,21 @@ const buildFieldMappings = (
|
||||
const isLookup =
|
||||
field.type().equals(FieldType.lookup()) ||
|
||||
field.type().equals(FieldType.conditionalLookup());
|
||||
const isLookupAutoNumber = (() => {
|
||||
if (field.type().equals(FieldType.lookup())) {
|
||||
return (field as LookupField)
|
||||
.innerFieldType()
|
||||
.map((innerType) => innerType.equals(FieldType.autoNumber()))
|
||||
.unwrapOr(false);
|
||||
}
|
||||
if (field.type().equals(FieldType.conditionalLookup())) {
|
||||
return (field as ConditionalLookupField)
|
||||
.innerFieldType()
|
||||
.map((innerType) => innerType.equals(FieldType.autoNumber()))
|
||||
.unwrapOr(false);
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const valueType = yield* field.accept(valueTypeVisitor);
|
||||
const isLookupMultiValue = isLookup && valueType.isMultipleCellValue.toBoolean();
|
||||
@ -498,6 +499,13 @@ const buildFieldMappings = (
|
||||
dbFieldType = 'TEXT';
|
||||
}
|
||||
|
||||
// For multi-value lookups, always use JSON storage semantics. This protects against
|
||||
// stale scalar dbFieldType metadata that can otherwise produce jsonb=integer DISTINCT
|
||||
// comparisons during computed updates.
|
||||
if (isLookup && valueType.isMultipleCellValue.toBoolean()) {
|
||||
dbFieldType = 'JSON';
|
||||
}
|
||||
|
||||
// For single-value lookups, resolve the scalar dbFieldType for proper SQL generation.
|
||||
// The SELECT query (built by ComputedTableRecordQueryBuilder) returns JSONB arrays for all
|
||||
// lookup fields. For single-value lookups stored in scalar columns, we need to extract the
|
||||
@ -516,6 +524,7 @@ const buildFieldMappings = (
|
||||
fieldId,
|
||||
isLookup,
|
||||
isLookupMultiValue,
|
||||
isLookupAutoNumber,
|
||||
dbFieldType,
|
||||
});
|
||||
}
|
||||
@ -582,7 +591,8 @@ class UpdateAssignmentPlan {
|
||||
return buildLookupAssignmentFromRef(
|
||||
sourceRef,
|
||||
this.mapping.dbFieldType,
|
||||
this.mapping.isLookupMultiValue
|
||||
this.mapping.isLookupMultiValue,
|
||||
this.mapping.isLookupAutoNumber
|
||||
);
|
||||
case 'json':
|
||||
return sql`to_jsonb(${sourceRef})`;
|
||||
|
||||
@ -74,7 +74,7 @@ export class TableRecordQueryBuilderManager {
|
||||
: new ComputedTableRecordQueryBuilder(db, {
|
||||
typeValidationStrategy: this.typeValidationStrategy,
|
||||
preferStoredLastModifiedFormula: true,
|
||||
forceLookupArrayOutput: false,
|
||||
forceLookupArrayOutput: true,
|
||||
}).from(table);
|
||||
|
||||
const prepareSpan = context.tracer?.startSpan('teable.queryBuilder.prepare');
|
||||
|
||||
@ -487,10 +487,11 @@ export class ComputedFieldSelectExpressionVisitor
|
||||
}
|
||||
const orderByResult = this.getLinkOrderBy(linkField);
|
||||
if (orderByResult.isErr()) return err(orderByResult.error);
|
||||
const isMultiValueResult = field
|
||||
const lookupIsMultipleResult = field
|
||||
.isMultipleCellValue()
|
||||
.map((multiplicity) => multiplicity.isMultiple());
|
||||
if (isMultiValueResult.isErr()) return err(isMultiValueResult.error);
|
||||
if (lookupIsMultipleResult.isErr()) return err(lookupIsMultipleResult.error);
|
||||
const isMultiValue = lookupIsMultipleResult.value;
|
||||
const condition = field.lookupOptions().condition();
|
||||
const lateralAlias = this.lateral.addColumn(
|
||||
field.linkFieldId(),
|
||||
@ -499,7 +500,7 @@ export class ComputedFieldSelectExpressionVisitor
|
||||
{
|
||||
type: 'lookup',
|
||||
foreignFieldId: field.lookupFieldId(),
|
||||
isMultiValue: this.forceLookupArrayOutput ? true : isMultiValueResult.value,
|
||||
isMultiValue: this.forceLookupArrayOutput ? true : isMultiValue,
|
||||
orderBy: orderByResult.value,
|
||||
condition,
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
NumberFormatting,
|
||||
NumberFormattingType,
|
||||
TimeFormatting,
|
||||
createDateField,
|
||||
createSingleLineTextField,
|
||||
DbFieldName,
|
||||
FieldId,
|
||||
@ -1671,7 +1672,7 @@ describe('ComputedTableRecordQueryBuilder', () => {
|
||||
);
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"select "t"."__id" as "__id", "t"."__version" as "__version", "t"."col_category_ref" as "col_category_ref", "cond_fldcccccccccccccccc"."col_conditional_rollup" as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join lateral (select CAST(COALESCE(SUM("f"."col_number"), 0) AS DOUBLE PRECISION) as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" where "f"."col_category" = $1) as "cond_fldcccccccccccccccc" on true"`
|
||||
`"select "t"."__id" as "__id", "t"."__version" as "__version", "t"."col_category_ref" as "col_category_ref", "cond_fldcccccccccccccccc"."col_conditional_rollup" as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join lateral (select CAST(COALESCE(SUM("cond_fldcccccccccccccccc_src"."col_number"), 0) AS DOUBLE PRECISION) as "col_conditional_rollup" from (select * from "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" where "f"."col_category" = $1 order by "f"."__auto_number" asc limit $2) as "cond_fldcccccccccccccccc_src") as "cond_fldcccccccccccccccc" on true"`
|
||||
);
|
||||
});
|
||||
|
||||
@ -1700,7 +1701,7 @@ describe('ComputedTableRecordQueryBuilder', () => {
|
||||
);
|
||||
|
||||
expect(sql).toMatchInlineSnapshot(
|
||||
`"select "t"."__id" as "__id", "t"."__version" as "__version", "t"."col_category_ref" as "col_category_ref", "cond_fldcccccccccccccccc"."col_conditional_rollup" as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join lateral (select CAST(COALESCE(SUM("f"."col_number"), 0) AS DOUBLE PRECISION) as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" where "f"."col_category" = "t"."col_category_ref") as "cond_fldcccccccccccccccc" on true"`
|
||||
`"select "t"."__id" as "__id", "t"."__version" as "__version", "t"."col_category_ref" as "col_category_ref", "cond_fldcccccccccccccccc"."col_conditional_rollup" as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join lateral (select CAST(COALESCE(SUM("cond_fldcccccccccccccccc_src"."col_number"), 0) AS DOUBLE PRECISION) as "col_conditional_rollup" from (select * from "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" where "f"."col_category" = "t"."col_category_ref" order by "f"."__auto_number" asc limit $1) as "cond_fldcccccccccccccccc_src") as "cond_fldcccccccccccccccc" on true"`
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -2087,6 +2088,84 @@ describe('ComputedTableRecordQueryBuilder', () => {
|
||||
}
|
||||
);
|
||||
|
||||
test('uses UTC ISO datetime strings for multi-value lookup aggregation', () => {
|
||||
const baseId = BaseId.create(BASE_ID)._unsafeUnwrap();
|
||||
const tableId = TableId.create(MAIN_TABLE_ID)._unsafeUnwrap();
|
||||
const linkFieldId = FieldId.create(LINK_FIELD_ID)._unsafeUnwrap();
|
||||
const lookupTargetFieldId = FieldId.create(LOOKUP_TARGET_FIELD_ID)._unsafeUnwrap();
|
||||
|
||||
const linkConfig = LinkFieldConfig.create({
|
||||
relationship: 'oneMany',
|
||||
foreignTableId: tableId.toString(),
|
||||
lookupFieldId: lookupTargetFieldId.toString(),
|
||||
symmetricFieldId: SYMMETRIC_FIELD_ID,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const lookupOptions = LookupOptions.create({
|
||||
linkFieldId: linkFieldId.toString(),
|
||||
foreignTableId: tableId.toString(),
|
||||
lookupFieldId: lookupTargetFieldId.toString(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const innerField = createDateField({
|
||||
id: FieldId.create(`fld${'i'.repeat(16)}`)._unsafeUnwrap(),
|
||||
name: FieldName.create('InnerDate')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const builder = Table.builder()
|
||||
.withId(tableId)
|
||||
.withBaseId(baseId)
|
||||
.withName(TableName.create('SelfDateLookup')._unsafeUnwrap());
|
||||
builder
|
||||
.field()
|
||||
.date()
|
||||
.withId(lookupTargetFieldId)
|
||||
.withName(FieldName.create('Due')._unsafeUnwrap())
|
||||
.done();
|
||||
builder
|
||||
.field()
|
||||
.link()
|
||||
.withId(linkFieldId)
|
||||
.withName(FieldName.create('Reports')._unsafeUnwrap())
|
||||
.withConfig(linkConfig)
|
||||
.done();
|
||||
builder
|
||||
.field()
|
||||
.lookup()
|
||||
.withName(FieldName.create('ReportDueDates')._unsafeUnwrap())
|
||||
.withLookupOptions(lookupOptions)
|
||||
.withInnerField(innerField)
|
||||
.done();
|
||||
builder.view().defaultGrid().done();
|
||||
|
||||
const table = builder.build({ includeSelf: true })._unsafeUnwrap();
|
||||
table
|
||||
.getFields()[0]
|
||||
.setDbFieldName(DbFieldName.rehydrate('col_due')._unsafeUnwrap())
|
||||
._unsafeUnwrap();
|
||||
table
|
||||
.getFields()[1]
|
||||
.setDbFieldName(DbFieldName.rehydrate('col_link_reports')._unsafeUnwrap())
|
||||
._unsafeUnwrap();
|
||||
table
|
||||
.getFields()[2]
|
||||
.setDbFieldName(DbFieldName.rehydrate('col_lookup_due')._unsafeUnwrap())
|
||||
._unsafeUnwrap();
|
||||
|
||||
const db = createTestDb();
|
||||
const foreignTables = new Map([[tableId.toString(), table]]);
|
||||
const { sql } = compileQuery(
|
||||
db,
|
||||
new ComputedTableRecordQueryBuilder(db, { foreignTables, typeValidationStrategy }).from(
|
||||
table
|
||||
)
|
||||
);
|
||||
|
||||
expect(sql).toContain(
|
||||
`jsonb_agg(to_jsonb(to_char("f"."col_due" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) order by`
|
||||
);
|
||||
});
|
||||
|
||||
test.each(relationships)('self-ref %s with lookup and rollup snapshot', (relationship) => {
|
||||
const db = createTestDb();
|
||||
const { table, tableId } = createSelfRefWithLookupRollup(relationship);
|
||||
|
||||
@ -55,13 +55,27 @@ const T = COMPUTED_TABLE_ALIAS; // main table alias
|
||||
const F = 'f'; // foreign table alias in lateral
|
||||
const DEFAULT_CONDITIONAL_ORDER_BY = { column: '__auto_number', direction: 'asc' } as const;
|
||||
|
||||
const parsePositiveInt = (raw: string | undefined, fallback: number): number => {
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
};
|
||||
|
||||
const CONDITIONAL_QUERY_MAX_LIMIT = parsePositiveInt(process.env.CONDITIONAL_QUERY_MAX_LIMIT, 5000);
|
||||
const CONDITIONAL_QUERY_DEFAULT_LIMIT = Math.min(
|
||||
parsePositiveInt(process.env.CONDITIONAL_QUERY_DEFAULT_LIMIT, CONDITIONAL_QUERY_MAX_LIMIT),
|
||||
CONDITIONAL_QUERY_MAX_LIMIT
|
||||
);
|
||||
|
||||
type ResolvedOrderBy = {
|
||||
column: string;
|
||||
direction: 'asc' | 'desc';
|
||||
userLikeMode?: 'single' | 'multiple';
|
||||
userLikeSource?: 'field' | 'system';
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for dirty record filtering.
|
||||
* When provided, the query will INNER JOIN with the dirty table early
|
||||
@ -594,7 +608,8 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder
|
||||
const sortClause = condition
|
||||
? yield* this.resolveConditionalSort(foreignTable, condition)
|
||||
: null;
|
||||
const limitValue = condition?.limit();
|
||||
const configuredLimit = condition?.limit();
|
||||
const limitValue = configuredLimit ?? CONDITIONAL_QUERY_DEFAULT_LIMIT;
|
||||
const isConditionalDerived =
|
||||
firstColumnType?.type === 'conditionalLookup' ||
|
||||
firstColumnType?.type === 'conditionalRollup';
|
||||
@ -987,6 +1002,53 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder
|
||||
);
|
||||
}
|
||||
|
||||
private buildPerRowNestedJsonTextExpr(colRef: RawBuilder<unknown>): RawBuilder<unknown> {
|
||||
const colJson = sql`to_jsonb(${colRef})`;
|
||||
const normalized = sql`(CASE
|
||||
WHEN ${colRef} IS NULL THEN '[]'::jsonb
|
||||
WHEN jsonb_typeof(${colJson}) = 'array' THEN ${colJson}
|
||||
WHEN jsonb_typeof(${colJson}) = 'null' THEN '[]'::jsonb
|
||||
ELSE jsonb_build_array(${colJson})
|
||||
END)`;
|
||||
return sql`(
|
||||
SELECT string_agg(
|
||||
${sql.raw(extractJsonScalarText('leaf'))},
|
||||
', '
|
||||
ORDER BY outer_ord, inner_ord
|
||||
)
|
||||
FROM jsonb_array_elements(${normalized}) WITH ORDINALITY AS outer_elem(elem, outer_ord)
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE
|
||||
WHEN jsonb_typeof(outer_elem.elem) = 'array' THEN outer_elem.elem
|
||||
ELSE jsonb_build_array(outer_elem.elem)
|
||||
END
|
||||
) WITH ORDINALITY AS inner_elem(leaf, inner_ord)
|
||||
)`;
|
||||
}
|
||||
|
||||
private buildDistinctNestedJsonTextArrayExpr(
|
||||
baseAggregate: RawBuilder<unknown>
|
||||
): RawBuilder<unknown> {
|
||||
return sql`(
|
||||
SELECT jsonb_agg(to_jsonb(v.val))
|
||||
FROM (
|
||||
SELECT DISTINCT val
|
||||
FROM (
|
||||
SELECT ${sql.raw(extractJsonScalarText('leaf'))} AS val
|
||||
FROM jsonb_array_elements(COALESCE(${baseAggregate}, '[]'::jsonb)) AS row_elem(elem)
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE
|
||||
WHEN jsonb_typeof(row_elem.elem) = 'array' THEN row_elem.elem
|
||||
ELSE jsonb_build_array(row_elem.elem)
|
||||
END
|
||||
) AS leaf_elem(leaf)
|
||||
) AS flattened
|
||||
WHERE val IS NOT NULL AND val <> ''
|
||||
ORDER BY val
|
||||
) AS v
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build lookup aggregation expression.
|
||||
*
|
||||
@ -1069,8 +1131,16 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder
|
||||
);
|
||||
}
|
||||
|
||||
// For regular columns, use to_jsonb() to convert to JSONB
|
||||
const aggExpr = sql`jsonb_agg(to_jsonb(${colRef})${orderByRef}) FILTER (WHERE ${colRef} IS NOT NULL)`;
|
||||
const fieldValueTypeResult = foreignField.accept(new FieldValueTypeVisitor());
|
||||
const isDateTimeLookupTarget =
|
||||
fieldValueTypeResult.isOk() &&
|
||||
fieldValueTypeResult.value.cellValueType.equals(CellValueType.dateTime());
|
||||
|
||||
const lookupValueExpr =
|
||||
isMultiValue && isDateTimeLookupTarget
|
||||
? sql`to_jsonb(to_char(${colRef} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))`
|
||||
: sql`to_jsonb(${colRef})`;
|
||||
const aggExpr = sql`jsonb_agg(${lookupValueExpr}${orderByRef}) FILTER (WHERE ${colRef} IS NOT NULL)`;
|
||||
return ok(
|
||||
isMultiValue ? aggExpr.as(outputAlias) : sql`${aggExpr} -> 0`.as(outputAlias)
|
||||
);
|
||||
@ -1171,6 +1241,10 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder
|
||||
return ok(sql`(COUNT(CASE WHEN ${colRef}::boolean THEN 1 END) % 2 = 1)`);
|
||||
case 'array_join({values})':
|
||||
case 'concatenate({values})': {
|
||||
if (foreignField.type().equals(FieldType.link())) {
|
||||
const rowTextExpr = this.buildPerRowNestedJsonTextExpr(colRef);
|
||||
return ok(sql`STRING_AGG(${rowTextExpr}, ', '${orderBySql})`);
|
||||
}
|
||||
const columnName = yield* foreignField
|
||||
.dbFieldName()
|
||||
.andThen((dbFieldName) => dbFieldName.value());
|
||||
@ -1189,8 +1263,15 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder
|
||||
sql`STRING_AGG(${formattedSql ? sql.raw(formattedSql) : sql`${colRef}::text`}, ', '${orderBySql})`
|
||||
);
|
||||
}
|
||||
case 'array_unique({values})':
|
||||
case 'array_unique({values})': {
|
||||
if (foreignField.type().equals(FieldType.link())) {
|
||||
const baseAggregate = orderByExpr
|
||||
? sql`jsonb_agg(to_jsonb(${colRef}) ORDER BY ${orderByExpr}) FILTER (WHERE ${colRef} IS NOT NULL)`
|
||||
: sql`jsonb_agg(to_jsonb(${colRef})) FILTER (WHERE ${colRef} IS NOT NULL)`;
|
||||
return ok(this.buildDistinctNestedJsonTextArrayExpr(baseAggregate));
|
||||
}
|
||||
return ok(sql`json_agg(DISTINCT ${colRef})`);
|
||||
}
|
||||
case 'array_compact({values})': {
|
||||
const baseAggregate = orderByExpr
|
||||
? sql`jsonb_agg(${colRef} ORDER BY ${orderByExpr}) FILTER (WHERE (${colRef}) IS NOT NULL AND (${colRef})::text <> '')`
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
CheckboxConditionSpec,
|
||||
DbFieldName,
|
||||
FieldName,
|
||||
RecordConditionFieldReferenceValue,
|
||||
LongTextConditionSpec,
|
||||
NumberConditionSpec,
|
||||
RecordConditionLiteralListValue,
|
||||
@ -79,6 +80,8 @@ const createTestTable = () => {
|
||||
builder.field().singleSelect().withName(FieldName.create('Status')._unsafeUnwrap()).done();
|
||||
builder.field().checkbox().withName(FieldName.create('Done')._unsafeUnwrap()).done();
|
||||
builder.field().longText().withName(FieldName.create('Notes')._unsafeUnwrap()).done();
|
||||
builder.field().date().withName(FieldName.create('Due Date')._unsafeUnwrap()).done();
|
||||
builder.field().date().withName(FieldName.create('Cutoff Date')._unsafeUnwrap()).done();
|
||||
builder.view().defaultGrid().done();
|
||||
|
||||
const table = builder.build()._unsafeUnwrap();
|
||||
@ -88,6 +91,10 @@ const createTestTable = () => {
|
||||
fields[2].setDbFieldName(DbFieldName.rehydrate('col_status')._unsafeUnwrap())._unsafeUnwrap();
|
||||
fields[3].setDbFieldName(DbFieldName.rehydrate('col_done')._unsafeUnwrap())._unsafeUnwrap();
|
||||
fields[4].setDbFieldName(DbFieldName.rehydrate('col_notes')._unsafeUnwrap())._unsafeUnwrap();
|
||||
fields[5].setDbFieldName(DbFieldName.rehydrate('col_due_date')._unsafeUnwrap())._unsafeUnwrap();
|
||||
fields[6]
|
||||
.setDbFieldName(DbFieldName.rehydrate('col_cutoff_date')._unsafeUnwrap())
|
||||
._unsafeUnwrap();
|
||||
|
||||
return {
|
||||
table,
|
||||
@ -96,6 +103,8 @@ const createTestTable = () => {
|
||||
statusField: fields[2],
|
||||
doneField: fields[3],
|
||||
notesField: fields[4],
|
||||
dueDateField: fields[5],
|
||||
cutoffDateField: fields[6],
|
||||
};
|
||||
};
|
||||
|
||||
@ -121,7 +130,15 @@ const buildWhereFor = (
|
||||
|
||||
describe('TableRecordConditionWhereVisitor NULL handling', () => {
|
||||
const db = createTestDb();
|
||||
const { nameField, scoreField, statusField, doneField, notesField } = createTestTable();
|
||||
const {
|
||||
nameField,
|
||||
scoreField,
|
||||
statusField,
|
||||
doneField,
|
||||
notesField,
|
||||
dueDateField,
|
||||
cutoffDateField,
|
||||
} = createTestTable();
|
||||
|
||||
// ---- isNot ----
|
||||
|
||||
@ -251,6 +268,29 @@ describe('TableRecordConditionWhereVisitor NULL handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('date field reference comparisons', () => {
|
||||
test('date isBefore with field reference uses host table alias', () => {
|
||||
const value = RecordConditionFieldReferenceValue.create(cutoffDateField)._unsafeUnwrap();
|
||||
const spec = dueDateField.spec().create({ operator: 'isBefore', value });
|
||||
expect(spec.isOk()).toBe(true);
|
||||
if (spec.isErr()) return;
|
||||
|
||||
const visitor = new TableRecordConditionWhereVisitor({
|
||||
tableAlias: 'f',
|
||||
hostTableAlias: 't',
|
||||
});
|
||||
const visitResult = spec.value.accept(visitor);
|
||||
expect(visitResult.isOk()).toBe(true);
|
||||
const where = visitor.where();
|
||||
expect(where.isOk()).toBe(true);
|
||||
if (where.isErr()) return;
|
||||
|
||||
const { sql, parameters } = compileWhere(db, where.value);
|
||||
expect(sql).toContain('("f"."col_due_date")::date < ("t"."col_cutoff_date")::date');
|
||||
expect(parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- isEmpty / isNotEmpty ----
|
||||
|
||||
describe('isEmpty / isNotEmpty operators', () => {
|
||||
|
||||
@ -33,6 +33,8 @@ const fieldIsUserOrLink = (field: core.Field): boolean => {
|
||||
return type === 'user' || type === 'createdBy' || type === 'lastModifiedBy' || type === 'link';
|
||||
};
|
||||
|
||||
const fieldIsLink = (field: core.Field): boolean => field.type().toString() === 'link';
|
||||
|
||||
const buildUserLinkIdArray = (
|
||||
columnRef: RecordConditionWhere,
|
||||
isMultiple: boolean
|
||||
@ -46,6 +48,22 @@ const buildJsonbTextArray = (jsonArray: RecordConditionWhere): RecordConditionWh
|
||||
return sql`COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArray}) AS value), ARRAY[]::text[])`;
|
||||
};
|
||||
|
||||
const buildLinkTitleMatchCondition = (
|
||||
columnRef: RecordConditionWhere,
|
||||
rightColumnRef: RecordConditionWhere
|
||||
): RecordConditionWhere => {
|
||||
const normalizedLinks = sql`CASE
|
||||
WHEN jsonb_typeof(to_jsonb(${columnRef})) = 'array' THEN to_jsonb(${columnRef})
|
||||
WHEN to_jsonb(${columnRef}) IS NULL THEN '[]'::jsonb
|
||||
ELSE jsonb_build_array(to_jsonb(${columnRef}))
|
||||
END`;
|
||||
return sql`EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(${normalizedLinks}) AS __link
|
||||
WHERE __link->>'title' = (${rightColumnRef})::text
|
||||
)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for TableRecordConditionWhereVisitor.
|
||||
*/
|
||||
@ -204,6 +222,11 @@ const resolveDateFormatting = (field: core.Field): core.DateTimeFormatting | und
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const shouldCompareAsDateOnly = (field: core.Field): boolean => {
|
||||
const formatting = resolveDateFormatting(field);
|
||||
return formatting?.time() === core.TimeFormatting.None;
|
||||
};
|
||||
|
||||
const resolveDateRange = (
|
||||
value: core.RecordConditionDateValue,
|
||||
formatting?: core.DateTimeFormatting
|
||||
@ -463,12 +486,21 @@ const buildIsCondition = (
|
||||
|
||||
return ok(sql`jsonb_exists_any(${leftIds}, ${buildJsonbTextArray(rightIds)})`);
|
||||
}
|
||||
|
||||
if (fieldIsLink(field)) {
|
||||
const rightColumnRef = sql.ref(operand.column);
|
||||
return ok(buildLinkTitleMatchCondition(columnRef, rightColumnRef));
|
||||
}
|
||||
}
|
||||
|
||||
if (operand.kind === 'field' && core.isRecordConditionFieldReferenceValue(value)) {
|
||||
const referenceField = value.field();
|
||||
const rightColumnRef = sql.ref(operand.column);
|
||||
|
||||
if (shouldCompareAsDateOnly(field) || shouldCompareAsDateOnly(referenceField)) {
|
||||
return ok(sql`(${columnRef})::date = (${rightColumnRef})::date`);
|
||||
}
|
||||
|
||||
if (fieldIsJson(field) || fieldIsJson(referenceField)) {
|
||||
return ok(sql`to_jsonb(${columnRef}) = to_jsonb(${rightColumnRef})`);
|
||||
}
|
||||
@ -529,6 +561,10 @@ const buildIsNotCondition = (
|
||||
const referenceField = value.field();
|
||||
const rightColumnRef = sql.ref(operand.column);
|
||||
|
||||
if (shouldCompareAsDateOnly(field) || shouldCompareAsDateOnly(referenceField)) {
|
||||
return ok(sql`(${columnRef})::date is distinct from (${rightColumnRef})::date`);
|
||||
}
|
||||
|
||||
if (fieldIsJson(field) || fieldIsJson(referenceField)) {
|
||||
return ok(sql`to_jsonb(${columnRef}) is distinct from to_jsonb(${rightColumnRef})`);
|
||||
}
|
||||
@ -609,15 +645,35 @@ const buildDateComparisonCondition = (
|
||||
field: core.Field,
|
||||
value: core.RecordConditionValue | undefined,
|
||||
operator: ComparisonOperator,
|
||||
tableAlias?: string
|
||||
tableAlias?: string,
|
||||
hostTableAlias?: string
|
||||
): Result<RecordConditionWhere, DomainError> => {
|
||||
return safeTry<RecordConditionWhere, DomainError>(function* () {
|
||||
if (!value)
|
||||
return err(core.domainError.unexpected({ message: 'Record condition requires value' }));
|
||||
const column = yield* resolveColumn(field, tableAlias);
|
||||
const columnRef = sql.ref(column);
|
||||
|
||||
if (core.isRecordConditionFieldReferenceValue(value)) {
|
||||
const rightColumn = yield* resolveColumn(value.field(), hostTableAlias ?? tableAlias);
|
||||
const right = sql.ref(rightColumn);
|
||||
const referenceField = value.field();
|
||||
const leftExpr: RecordConditionWhere =
|
||||
shouldCompareAsDateOnly(field) || shouldCompareAsDateOnly(referenceField)
|
||||
? sql`(${columnRef})::date`
|
||||
: columnRef;
|
||||
const rightExpr: RecordConditionWhere =
|
||||
shouldCompareAsDateOnly(field) || shouldCompareAsDateOnly(referenceField)
|
||||
? sql`(${right})::date`
|
||||
: right;
|
||||
if (operator === '>') return ok(sql`${leftExpr} > ${rightExpr}`);
|
||||
if (operator === '>=') return ok(sql`${leftExpr} >= ${rightExpr}`);
|
||||
if (operator === '<') return ok(sql`${leftExpr} < ${rightExpr}`);
|
||||
return ok(sql`${leftExpr} <= ${rightExpr}`);
|
||||
}
|
||||
|
||||
const dateValue = yield* resolveDateValue(value);
|
||||
const range = yield* resolveDateRange(dateValue, resolveDateFormatting(field));
|
||||
const columnRef = sql.ref(column);
|
||||
const boundary = operator === '>' || operator === '<=' ? range.end : range.start;
|
||||
const right = sql`${boundary}`;
|
||||
if (operator === '>') return ok(sql`${columnRef} > ${right}`);
|
||||
@ -1645,7 +1701,7 @@ export class TableRecordConditionWhereVisitor
|
||||
operator: ComparisonOperator
|
||||
): Result<RecordConditionWhere, DomainError> {
|
||||
return this.addConditionResult(
|
||||
buildDateComparisonCondition(field, value, operator, this.tableAlias)
|
||||
buildDateComparisonCondition(field, value, operator, this.tableAlias, this.hostTableAlias)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -130,7 +130,10 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor<void>
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
visitTableDuplicateField(_spec: TableDuplicateFieldSpec): Result<void, DomainError> {
|
||||
visitTableDuplicateField(spec: TableDuplicateFieldSpec): Result<void, DomainError> {
|
||||
if (spec.newField().computed().toBoolean()) {
|
||||
this.addSelfBackfill(spec.newField().id());
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
|
||||
@ -175,20 +175,27 @@ export class TableSchemaUpdateVisitor
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueTypeResult = field.accept(new FieldValueTypeVisitor());
|
||||
if (valueTypeResult.isOk()) {
|
||||
const cellValueType = valueTypeResult.value.cellValueType.toString();
|
||||
if (cellValueType === 'boolean' || cellValueType === 'dateTime') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const { db, schema, tableName } = this.params;
|
||||
const fieldId = field.id().toString();
|
||||
const indexName = TableSchemaUpdateVisitor.getSearchIndexName(tableName, fieldId, dbFieldName);
|
||||
const pgSchema = schema ?? 'public';
|
||||
|
||||
// Build the index expression based on field characteristics.
|
||||
// This replicates v1 FieldFormatter.getIndexExpression logic.
|
||||
// Keep search index expressions text-compatible across field storage types
|
||||
// (e.g. numeric/jsonb), matching v1's text-oriented trigram indexing behavior.
|
||||
const isMultipleResult = field.isMultipleCellValue();
|
||||
const isMultiple = isMultipleResult.isOk() && isMultipleResult.value.toBoolean();
|
||||
let expression: string;
|
||||
if (isMultiple) {
|
||||
expression = `"${dbFieldName}"::text`;
|
||||
} else {
|
||||
expression = `"${dbFieldName}"`;
|
||||
let expression = `"${dbFieldName}"::text`;
|
||||
|
||||
if (!isMultiple && fieldType === 'longText') {
|
||||
expression = `REPLACE(REPLACE(REPLACE("${dbFieldName}"::text, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text)`;
|
||||
}
|
||||
|
||||
// Wrap in a DO block that only executes if search indexes are enabled for this table
|
||||
@ -410,10 +417,16 @@ export class TableSchemaUpdateVisitor
|
||||
visitTableAddField(
|
||||
spec: TableAddFieldSpec
|
||||
): Result<ReadonlyArray<TableSchemaStatementBuilder>, DomainError> {
|
||||
const visitor = this;
|
||||
const fieldVisitor = PostgresTableSchemaFieldCreateVisitor.forSchemaUpdate(this.params);
|
||||
const addCond = this.addCond.bind(this);
|
||||
return safeTry<ReadonlyArray<TableSchemaStatementBuilder>, DomainError>(function* () {
|
||||
const statements = yield* spec.field().accept(fieldVisitor);
|
||||
const statements = [...(yield* spec.field().accept(fieldVisitor))];
|
||||
const dbFieldName = yield* visitor.resolveDbFieldNameText(spec.field());
|
||||
const createSearchIdx = visitor.createSearchIndexStatement(spec.field(), dbFieldName);
|
||||
if (createSearchIdx) {
|
||||
statements.push(createSearchIdx);
|
||||
}
|
||||
yield* addCond(statements);
|
||||
return ok(statements);
|
||||
});
|
||||
|
||||
@ -4,7 +4,17 @@
|
||||
* These tests validate the SQL statements generated for various
|
||||
* field update operations using Kysely DummyDriver (no actual database connection).
|
||||
*/
|
||||
import { DbFieldName, FieldId, LinkFieldConfig, UpdateLinkRelationshipSpec } from '@teable/v2-core';
|
||||
import {
|
||||
BaseId,
|
||||
DbFieldName,
|
||||
FieldId,
|
||||
FieldName,
|
||||
LinkFieldConfig,
|
||||
Table,
|
||||
TableAddFieldSpec,
|
||||
TableName,
|
||||
UpdateLinkRelationshipSpec,
|
||||
} from '@teable/v2-core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TableSchemaUpdateVisitor } from '../TableSchemaUpdateVisitor';
|
||||
@ -838,10 +848,78 @@ describe('TableSchemaUpdateVisitor', () => {
|
||||
});
|
||||
|
||||
describe('visitTableAddField', () => {
|
||||
it.todo(
|
||||
'should delegate to PostgresTableSchemaFieldCreateVisitor'
|
||||
// Verify: create statements returned
|
||||
);
|
||||
const SCHEMA = 'bseTestBase00000001';
|
||||
const TABLE_NAME = 'tblTestTable0000001';
|
||||
const TABLE_ID = TABLE_NAME;
|
||||
const db = createTestDb();
|
||||
|
||||
const createTable = () => {
|
||||
const table = Table.builder()
|
||||
.withBaseId(BaseId.create(SCHEMA)._unsafeUnwrap())
|
||||
.withName(TableName.create('Test Table')._unsafeUnwrap());
|
||||
table.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done();
|
||||
table.view().defaultGrid().done();
|
||||
return table.build()._unsafeUnwrap();
|
||||
};
|
||||
|
||||
const createVisitor = () =>
|
||||
new TableSchemaUpdateVisitor({
|
||||
db,
|
||||
schema: SCHEMA,
|
||||
tableName: TABLE_NAME,
|
||||
tableId: TABLE_ID,
|
||||
table: createTable(),
|
||||
});
|
||||
|
||||
const createField = (params: { id: string; kind: 'singleLineText' | 'checkbox' }) => {
|
||||
const table = Table.builder()
|
||||
.withBaseId(BaseId.create(SCHEMA)._unsafeUnwrap())
|
||||
.withName(TableName.create('Field Source')._unsafeUnwrap());
|
||||
const fieldBuilder = table.field();
|
||||
const fieldName = FieldName.create(`Field ${params.id.slice(-4)}`)._unsafeUnwrap();
|
||||
const fieldId = FieldId.create(params.id)._unsafeUnwrap();
|
||||
|
||||
if (params.kind === 'singleLineText') {
|
||||
fieldBuilder.singleLineText().withId(fieldId).withName(fieldName).done();
|
||||
} else {
|
||||
fieldBuilder.checkbox().withId(fieldId).withName(fieldName).done();
|
||||
}
|
||||
|
||||
table.view().defaultGrid().done();
|
||||
const fieldSource = table.build()._unsafeUnwrap();
|
||||
const field = fieldSource
|
||||
.getField((candidate) => candidate.id().equals(fieldId))
|
||||
._unsafeUnwrap();
|
||||
const dbFieldName = DbFieldName.rehydrate(`fld_${params.id.slice(-8)}`)._unsafeUnwrap();
|
||||
field.setDbFieldName(dbFieldName)._unsafeUnwrap();
|
||||
return field;
|
||||
};
|
||||
|
||||
it('should append search index statement for searchable field types', () => {
|
||||
const field = createField({ id: 'fldSearchField00001', kind: 'singleLineText' });
|
||||
const spec = TableAddFieldSpec.create(field);
|
||||
const visitor = createVisitor();
|
||||
|
||||
const result = visitor.visitTableAddField(spec);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const sqls = result._unsafeUnwrap().map((statement) => statement.compile(db).sql);
|
||||
expect(sqls.some((text) => text.includes("indexname LIKE 'idx_trgm%'"))).toBe(true);
|
||||
expect(sqls.some((text) => text.includes('CREATE INDEX IF NOT EXISTS'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should not append search index statement for unsupported field types', () => {
|
||||
const field = createField({ id: 'fldCheckboxField001', kind: 'checkbox' });
|
||||
const spec = TableAddFieldSpec.create(field);
|
||||
const visitor = createVisitor();
|
||||
|
||||
const result = visitor.visitTableAddField(spec);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const sqls = result._unsafeUnwrap().map((statement) => statement.compile(db).sql);
|
||||
expect(sqls.some((text) => text.includes("indexname LIKE 'idx_trgm%'"))).toBe(false);
|
||||
expect(sqls.some((text) => text.includes('CREATE INDEX IF NOT EXISTS'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visitTableRemoveField', () => {
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
import type { IDuplicateFieldEndpointResult } from '@teable/v2-contract-http';
|
||||
import {
|
||||
mapDomainErrorToHttpError,
|
||||
mapDomainErrorToHttpStatus,
|
||||
mapDuplicateFieldResultToDto,
|
||||
} from '@teable/v2-contract-http';
|
||||
import { DuplicateFieldCommand } from '@teable/v2-core';
|
||||
import type { DuplicateFieldResult, ICommandBus, IExecutionContext } from '@teable/v2-core';
|
||||
|
||||
export const executeDuplicateFieldEndpoint = async (
|
||||
context: IExecutionContext,
|
||||
rawBody: unknown,
|
||||
commandBus: ICommandBus
|
||||
): Promise<IDuplicateFieldEndpointResult> => {
|
||||
const commandResult = DuplicateFieldCommand.create(rawBody);
|
||||
if (commandResult.isErr()) {
|
||||
const error = commandResult.error;
|
||||
return {
|
||||
status: mapDomainErrorToHttpStatus(error),
|
||||
body: { ok: false, error: mapDomainErrorToHttpError(error) },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await commandBus.execute<DuplicateFieldCommand, DuplicateFieldResult>(
|
||||
context,
|
||||
commandResult.value
|
||||
);
|
||||
if (result.isErr()) {
|
||||
const error = result.error;
|
||||
return {
|
||||
status: mapDomainErrorToHttpStatus(error),
|
||||
body: { ok: false, error: mapDomainErrorToHttpError(error) },
|
||||
};
|
||||
}
|
||||
|
||||
const mapped = mapDuplicateFieldResultToDto(result.value);
|
||||
if (mapped.isErr()) {
|
||||
const error = mapped.error;
|
||||
return {
|
||||
status: mapDomainErrorToHttpStatus(error),
|
||||
body: { ok: false, error: mapDomainErrorToHttpError(error) },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
data: mapped.value,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -22,3 +22,4 @@ export * from './updateRecord';
|
||||
export * from './updateField';
|
||||
export * from './reorderRecords';
|
||||
export * from './duplicateRecord';
|
||||
export * from './duplicateField';
|
||||
|
||||
@ -40,6 +40,7 @@ import { executeDeleteByRangeEndpoint } from './handlers/tables/deleteByRange';
|
||||
import { executeRenameTableEndpoint } from './handlers/tables/renameTable';
|
||||
import { executeUpdateRecordEndpoint } from './handlers/tables/updateRecord';
|
||||
import { executeReorderRecordsEndpoint } from './handlers/tables/reorderRecords';
|
||||
import { executeDuplicateFieldEndpoint } from './handlers/tables/duplicateField';
|
||||
import { executeDuplicateRecordEndpoint } from './handlers/tables/duplicateRecord';
|
||||
|
||||
export interface IV2OrpcRouterOptions {
|
||||
@ -441,6 +442,34 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => {
|
||||
throwDomainError('INTERNAL_SERVER_ERROR', result.body.error);
|
||||
});
|
||||
|
||||
const tablesDuplicateField = os.tables.duplicateField.handler(async ({ input }) => {
|
||||
const container = await resolveContainer();
|
||||
|
||||
let executionContext: IExecutionContext;
|
||||
try {
|
||||
executionContext = await createExecutionContext();
|
||||
} catch {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: executionContextErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);
|
||||
const result = await executeDuplicateFieldEndpoint(executionContext, input, commandBus);
|
||||
|
||||
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 tablesPaste = os.tables.paste.handler(async ({ input }) => {
|
||||
const container = await resolveContainer();
|
||||
|
||||
@ -918,6 +947,7 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => {
|
||||
createRecords: tablesCreateRecords,
|
||||
updateRecord: tablesUpdateRecord,
|
||||
reorderRecords: tablesReorderRecords,
|
||||
duplicateField: tablesDuplicateField,
|
||||
duplicateRecord: tablesDuplicateRecord,
|
||||
paste: tablesPaste,
|
||||
clear: tablesClear,
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
deleteFieldInputSchema,
|
||||
deleteRecordsInputSchema,
|
||||
deleteTableInputSchema,
|
||||
duplicateFieldInputSchema,
|
||||
duplicateRecordInputSchema,
|
||||
getRecordByIdInputSchema,
|
||||
getTableByIdInputSchema,
|
||||
@ -40,6 +41,7 @@ import { deleteByRangeOkResponseSchema } from './table/deleteByRange';
|
||||
import { deleteFieldOkResponseSchema } from './table/deleteField';
|
||||
import { deleteRecordsOkResponseSchema } from './table/deleteRecords';
|
||||
import { deleteTableErrorResponseSchema, deleteTableOkResponseSchema } from './table/deleteTable';
|
||||
import { duplicateFieldOkResponseSchema } from './table/duplicateField';
|
||||
import { duplicateRecordOkResponseSchema } from './table/duplicateRecord';
|
||||
import {
|
||||
explainCreateRecordInputSchema,
|
||||
@ -87,6 +89,7 @@ const TABLES_RENAME_PATH = '/tables/rename';
|
||||
const TABLES_UPDATE_FIELD_PATH = '/tables/updateField';
|
||||
const TABLES_UPDATE_RECORD_PATH = '/tables/updateRecord';
|
||||
const TABLES_REORDER_RECORDS_PATH = '/tables/reorderRecords';
|
||||
const TABLES_DUPLICATE_FIELD_PATH = '/tables/duplicateField';
|
||||
const TABLES_DUPLICATE_RECORD_PATH = '/tables/duplicateRecord';
|
||||
|
||||
export const v2Contract: AnyContractRouter = {
|
||||
@ -303,6 +306,16 @@ export const v2Contract: AnyContractRouter = {
|
||||
})
|
||||
.input(reorderRecordsInputSchema)
|
||||
.output(reorderRecordsOkResponseSchema),
|
||||
duplicateField: oc
|
||||
.route({
|
||||
method: 'POST',
|
||||
path: TABLES_DUPLICATE_FIELD_PATH,
|
||||
successStatus: 200,
|
||||
summary: 'Duplicate field',
|
||||
tags: ['tables'],
|
||||
})
|
||||
.input(duplicateFieldInputSchema)
|
||||
.output(duplicateFieldOkResponseSchema),
|
||||
duplicateRecord: oc
|
||||
.route({
|
||||
method: 'POST',
|
||||
|
||||
@ -26,6 +26,7 @@ export * from './table/updateField';
|
||||
export * from './table/updateRecord';
|
||||
export * from './table/reorderRecords';
|
||||
export * from './table/duplicateRecord';
|
||||
export * from './table/duplicateField';
|
||||
export * from './table/paste';
|
||||
export * from './table/clear';
|
||||
export * from './table/deleteByRange';
|
||||
|
||||
@ -103,6 +103,7 @@ const lookupOptionsSchema = z.object({
|
||||
linkFieldId: z.string(),
|
||||
foreignTableId: z.string(),
|
||||
lookupFieldId: z.string(),
|
||||
relationship: z.enum(['oneOne', 'manyMany', 'oneMany', 'manyOne']).optional(),
|
||||
filter: fieldConditionSchema.shape.filter,
|
||||
sort: fieldConditionSchema.shape.sort,
|
||||
limit: fieldConditionSchema.shape.limit,
|
||||
@ -461,7 +462,7 @@ class FieldToDtoVisitor implements IFieldVisitor<IFieldDto> {
|
||||
dbFieldName: this.optionalDbFieldName(field),
|
||||
isPrimary: field.id().equals(this.primaryFieldId),
|
||||
...(notNull ? { notNull } : {}),
|
||||
...(unique ? { unique } : {}),
|
||||
unique,
|
||||
...(isComputed ? { isComputed } : {}),
|
||||
...(hasError ? { hasError } : {}),
|
||||
};
|
||||
@ -814,8 +815,17 @@ class FieldToDtoVisitor implements IFieldVisitor<IFieldDto> {
|
||||
|
||||
visitLookupField(field: LookupField): Result<IFieldDto, DomainError> {
|
||||
const lookupOptions = field.lookupOptionsDto();
|
||||
|
||||
const baseField = this.baseField(field);
|
||||
const isMultipleCellValueResult = field.isMultipleCellValue();
|
||||
const isMultipleCellValue = isMultipleCellValueResult.isOk()
|
||||
? isMultipleCellValueResult.value.toBoolean()
|
||||
: undefined;
|
||||
const relationship: 'oneOne' | 'manyMany' | 'oneMany' | 'manyOne' | undefined =
|
||||
isMultipleCellValue == null ? undefined : isMultipleCellValue ? 'manyMany' : 'manyOne';
|
||||
const lookupOptionsWithRelationship = {
|
||||
...lookupOptions,
|
||||
...(baseField.hasError && relationship ? { relationship } : {}),
|
||||
};
|
||||
|
||||
// For pending lookup fields, return minimal DTO with singleLineText as default type
|
||||
if (field.isPending()) {
|
||||
@ -823,7 +833,7 @@ class FieldToDtoVisitor implements IFieldVisitor<IFieldDto> {
|
||||
...baseField,
|
||||
type: 'singleLineText',
|
||||
isLookup: true,
|
||||
lookupOptions,
|
||||
lookupOptions: lookupOptionsWithRelationship,
|
||||
});
|
||||
}
|
||||
|
||||
@ -836,7 +846,7 @@ class FieldToDtoVisitor implements IFieldVisitor<IFieldDto> {
|
||||
...innerDto,
|
||||
...baseField,
|
||||
isLookup: true,
|
||||
lookupOptions,
|
||||
lookupOptions: lookupOptionsWithRelationship,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
59
packages/v2/contract-http/src/table/duplicateField.ts
Normal file
59
packages/v2/contract-http/src/table/duplicateField.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type {
|
||||
DomainError,
|
||||
DuplicateFieldResult,
|
||||
IDuplicateFieldCommandInput,
|
||||
} from '@teable/v2-core';
|
||||
import type { Result } from 'neverthrow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { IDomainEventDto } from '../shared/domainEvent';
|
||||
import { domainEventDtoSchema, mapDomainEventToDto } from '../shared/domainEvent';
|
||||
import {
|
||||
apiErrorResponseDtoSchema,
|
||||
apiOkResponseDtoSchema,
|
||||
type HttpErrorStatus,
|
||||
type IApiErrorResponseDto,
|
||||
type IApiOkResponseDto,
|
||||
type IApiResponseDto,
|
||||
} from '../shared/http';
|
||||
import type { ITableDto } from './dto';
|
||||
import { mapTableToDto, tableDtoSchema } from './dto';
|
||||
|
||||
export type IDuplicateFieldRequestDto = IDuplicateFieldCommandInput;
|
||||
|
||||
export interface IDuplicateFieldResponseDataDto {
|
||||
table: ITableDto;
|
||||
newFieldId: string;
|
||||
events: Array<IDomainEventDto>;
|
||||
}
|
||||
|
||||
export type IDuplicateFieldResponseDto = IApiResponseDto<IDuplicateFieldResponseDataDto>;
|
||||
|
||||
export type IDuplicateFieldOkResponseDto = IApiOkResponseDto<IDuplicateFieldResponseDataDto>;
|
||||
export type IDuplicateFieldErrorResponseDto = IApiErrorResponseDto;
|
||||
|
||||
export type IDuplicateFieldEndpointResult =
|
||||
| { status: 200; body: IDuplicateFieldOkResponseDto }
|
||||
| { status: HttpErrorStatus; body: IDuplicateFieldErrorResponseDto };
|
||||
|
||||
export const duplicateFieldResponseDataSchema = z.object({
|
||||
table: tableDtoSchema,
|
||||
newFieldId: z.string(),
|
||||
events: z.array(domainEventDtoSchema),
|
||||
});
|
||||
|
||||
export const duplicateFieldOkResponseSchema = apiOkResponseDtoSchema(
|
||||
duplicateFieldResponseDataSchema
|
||||
);
|
||||
|
||||
export const duplicateFieldErrorResponseSchema = apiErrorResponseDtoSchema;
|
||||
|
||||
export const mapDuplicateFieldResultToDto = (
|
||||
result: DuplicateFieldResult
|
||||
): Result<IDuplicateFieldResponseDataDto, DomainError> => {
|
||||
return mapTableToDto(result.table).map((table) => ({
|
||||
table,
|
||||
newFieldId: result.newField.id().toString(),
|
||||
events: result.events.map(mapDomainEventToDto),
|
||||
}));
|
||||
};
|
||||
@ -82,6 +82,14 @@ import { sequenceResults } from '../shared/neverthrow';
|
||||
import type { IFieldDto, ITableDto, IViewDto } from './dto';
|
||||
|
||||
type FormulaFieldDto = Extract<IFieldDto, { type: 'formula' }>;
|
||||
type LookupRelationship = 'oneOne' | 'manyMany' | 'oneMany' | 'manyOne' | undefined;
|
||||
|
||||
const relationshipToLookupMultiplicity = (
|
||||
relationship: LookupRelationship
|
||||
): boolean | undefined => {
|
||||
if (relationship == null) return undefined;
|
||||
return relationship === 'manyOne' || relationship === 'oneOne' ? false : true;
|
||||
};
|
||||
|
||||
const optional = <T>(
|
||||
raw: unknown,
|
||||
@ -177,8 +185,11 @@ const mapFieldDtoToDomain = (dto: IFieldDto): Result<Field, DomainError> => {
|
||||
);
|
||||
}
|
||||
if (dto.isLookup && dto.lookupOptions) {
|
||||
const isMultipleCellValue = relationshipToLookupMultiplicity(
|
||||
dto.lookupOptions.relationship
|
||||
);
|
||||
return LookupOptions.create(dto.lookupOptions).andThen((lookupOptions) =>
|
||||
createLookupFieldPending({ id, name, lookupOptions })
|
||||
createLookupFieldPending({ id, name, lookupOptions, isMultipleCellValue })
|
||||
.andThen((field) => applyFieldValidation(field, dto.notNull, dto.unique))
|
||||
.andThen((field) => applyDbFieldName(field, dto.dbFieldName))
|
||||
);
|
||||
|
||||
@ -49,6 +49,76 @@ describe('CreateFieldCommand', () => {
|
||||
expect(command.field.type).toBe('singleLineText');
|
||||
});
|
||||
|
||||
it('accepts link input without lookupFieldId and keeps foreign table reference', () => {
|
||||
const foreignTableId = `tbl${'c'.repeat(16)}`;
|
||||
const commandResult = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId,
|
||||
field: {
|
||||
type: 'link',
|
||||
name: 'Linked Records',
|
||||
options: {
|
||||
relationship: 'manyMany',
|
||||
foreignTableId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
commandResult._unsafeUnwrap();
|
||||
const command = commandResult._unsafeUnwrap();
|
||||
expect(command.field.type).toBe('link');
|
||||
if (command.field.type !== 'link') return;
|
||||
expect(command.field.options.lookupFieldId).toBeUndefined();
|
||||
|
||||
const refs = command.foreignTableReferences()._unsafeUnwrap();
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]?.foreignTableId.toString()).toBe(foreignTableId);
|
||||
});
|
||||
|
||||
it('accepts lookup field multiplicity in create input', () => {
|
||||
const commandResult = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId,
|
||||
field: {
|
||||
type: 'lookup',
|
||||
name: 'Lookup C',
|
||||
isMultipleCellValue: false,
|
||||
options: {
|
||||
linkFieldId: `fld${'c'.repeat(16)}`,
|
||||
foreignTableId: `tbl${'d'.repeat(16)}`,
|
||||
lookupFieldId: `fld${'e'.repeat(16)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const command = commandResult._unsafeUnwrap();
|
||||
expect(command.field.type).toBe('lookup');
|
||||
if (command.field.type !== 'lookup') return;
|
||||
expect(command.field.isMultipleCellValue).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts aiConfig in create input for sidecar persistence flow', () => {
|
||||
const commandResult = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId,
|
||||
field: {
|
||||
type: 'singleLineText',
|
||||
name: 'AI Field',
|
||||
aiConfig: {
|
||||
type: 'summary',
|
||||
sourceFieldId: `fld${'f'.repeat(16)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const command = commandResult._unsafeUnwrap();
|
||||
expect(command.field.type).toBe('singleLineText');
|
||||
expect((command.field as { aiConfig?: unknown }).aiConfig).toEqual({
|
||||
type: 'summary',
|
||||
sourceFieldId: `fld${'f'.repeat(16)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts description on strict variants (formula)', () => {
|
||||
const commandResult = CreateFieldCommand.create({
|
||||
baseId,
|
||||
@ -436,6 +506,31 @@ describe('CreateFieldCommand', () => {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
type: 'link',
|
||||
name: 'Configured Link',
|
||||
options: {
|
||||
relationship: 'manyMany',
|
||||
foreignTableId: tableId,
|
||||
lookupFieldId: `fld${'c'.repeat(16)}`,
|
||||
symmetricFieldId: `fld${'d'.repeat(16)}`,
|
||||
fkHostTableName: `${baseId}.junction_custom_link`,
|
||||
selfKeyName: `__fk_fld${'d'.repeat(16)}`,
|
||||
foreignKeyName: `__fk_fld${'e'.repeat(16)}`,
|
||||
},
|
||||
},
|
||||
assert: (field: unknown) => {
|
||||
expect(field).toBeInstanceOf(LinkField);
|
||||
const typed = field as LinkField;
|
||||
expect(typed.relationship().toString()).toBe('manyMany');
|
||||
expect(typed.fkHostTableNameString()._unsafeUnwrap()).toBe(
|
||||
`${baseId}.junction_custom_link`
|
||||
);
|
||||
expect(typed.selfKeyNameString()._unsafeUnwrap()).toBe(`__fk_fld${'d'.repeat(16)}`);
|
||||
expect(typed.foreignKeyNameString()._unsafeUnwrap()).toBe(`__fk_fld${'e'.repeat(16)}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
type: 'link',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { err } from 'neverthrow';
|
||||
import { err, ok } from 'neverthrow';
|
||||
import type { Result } from 'neverthrow';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -14,6 +14,12 @@ export const createFieldInputSchema = z.object({
|
||||
baseId: z.string(),
|
||||
tableId: z.string(),
|
||||
field: tableFieldInputSchema,
|
||||
order: z
|
||||
.object({
|
||||
viewId: z.string(),
|
||||
orderIndex: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ICreateFieldCommandInput = z.input<typeof createFieldInputSchema>;
|
||||
@ -22,7 +28,11 @@ export class CreateFieldCommand extends TableUpdateCommand {
|
||||
private constructor(
|
||||
readonly baseId: BaseId,
|
||||
readonly tableId: TableId,
|
||||
readonly field: z.output<typeof tableFieldInputSchema>
|
||||
readonly field: z.output<typeof tableFieldInputSchema>,
|
||||
readonly order?: {
|
||||
viewId: string;
|
||||
orderIndex: number;
|
||||
}
|
||||
) {
|
||||
super(baseId, tableId);
|
||||
}
|
||||
@ -47,12 +57,21 @@ export class CreateFieldCommand extends TableUpdateCommand {
|
||||
|
||||
return BaseId.create(parsed.data.baseId).andThen((baseId) =>
|
||||
TableId.create(parsed.data.tableId).map(
|
||||
(tableId) => new CreateFieldCommand(baseId, tableId, parsed.data.field)
|
||||
(tableId) => new CreateFieldCommand(baseId, tableId, parsed.data.field, parsed.data.order)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreignTableReferences(): Result<ReadonlyArray<LinkForeignTableReference>, DomainError> {
|
||||
if (this.field.type === 'link') {
|
||||
const baseIdRaw = this.field.options.baseId;
|
||||
return TableId.create(this.field.options.foreignTableId).andThen((foreignTableId) =>
|
||||
baseIdRaw
|
||||
? BaseId.create(baseIdRaw).map((baseId) => [{ foreignTableId, baseId }])
|
||||
: ok([{ foreignTableId }])
|
||||
);
|
||||
}
|
||||
|
||||
return resolveTableFieldInputName(this.field, []).andThen((resolved) =>
|
||||
parseTableFieldSpec(resolved, { isPrimary: false }).andThen((spec) =>
|
||||
spec.foreignTableReferences()
|
||||
|
||||
@ -14,6 +14,7 @@ import { FieldId } from '../domain/table/fields/FieldId';
|
||||
import { FieldName } from '../domain/table/fields/FieldName';
|
||||
import type { FormulaField } from '../domain/table/fields/types/FormulaField';
|
||||
import type { LinkField } from '../domain/table/fields/types/LinkField';
|
||||
import type { LookupField } from '../domain/table/fields/types/LookupField';
|
||||
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
|
||||
import { Table } from '../domain/table/Table';
|
||||
import { TableId } from '../domain/table/TableId';
|
||||
@ -313,4 +314,275 @@ describe('CreateFieldHandler', () => {
|
||||
expect(cellValueTypeResult.isOk()).toBe(true);
|
||||
cellValueTypeResult._unsafeUnwrap();
|
||||
});
|
||||
|
||||
it('derives lookup multiplicity from oneMany link in domain layer', async () => {
|
||||
const baseId = `bse${'a'.repeat(16)}`;
|
||||
const hostTableId = `tbl${'b'.repeat(16)}`;
|
||||
const foreignTableId = `tbl${'c'.repeat(16)}`;
|
||||
const hostPrimaryId = `fld${'d'.repeat(16)}`;
|
||||
const foreignPrimaryId = `fld${'e'.repeat(16)}`;
|
||||
|
||||
const tableRepository = new InMemoryTableRepository();
|
||||
const schemaRepository = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const unitOfWork = new FakeUnitOfWork();
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
schemaRepository,
|
||||
eventBus,
|
||||
unitOfWork
|
||||
);
|
||||
const fieldCreationSideEffectService = new FieldCreationSideEffectService(tableUpdateFlow);
|
||||
const foreignTableLoaderService = new ForeignTableLoaderService(tableRepository);
|
||||
const handler = new CreateFieldHandler(
|
||||
tableUpdateFlow,
|
||||
fieldCreationSideEffectService,
|
||||
foreignTableLoaderService
|
||||
);
|
||||
|
||||
tableRepository.tables.push(
|
||||
buildTable({
|
||||
baseId,
|
||||
tableId: hostTableId,
|
||||
tableName: 'Host',
|
||||
primaryFieldId: hostPrimaryId,
|
||||
}),
|
||||
buildTable({
|
||||
baseId,
|
||||
tableId: foreignTableId,
|
||||
tableName: 'Foreign',
|
||||
primaryFieldId: foreignPrimaryId,
|
||||
})
|
||||
);
|
||||
|
||||
const createLink = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId: hostTableId,
|
||||
field: {
|
||||
type: 'link',
|
||||
name: 'Host Link',
|
||||
options: {
|
||||
relationship: 'oneMany',
|
||||
foreignTableId,
|
||||
lookupFieldId: foreignPrimaryId,
|
||||
},
|
||||
},
|
||||
})._unsafeUnwrap();
|
||||
const linkResult = await handler.handle(createContext(), createLink);
|
||||
linkResult._unsafeUnwrap();
|
||||
|
||||
const hostAfterLink = tableRepository.tables.find(
|
||||
(table) => table.id().toString() === hostTableId
|
||||
);
|
||||
expect(hostAfterLink).toBeDefined();
|
||||
if (!hostAfterLink) return;
|
||||
|
||||
const linkField = hostAfterLink
|
||||
.getFields()
|
||||
.find((field) => field.name().toString() === 'Host Link') as LinkField | undefined;
|
||||
expect(linkField).toBeDefined();
|
||||
if (!linkField) return;
|
||||
|
||||
const createLookup = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId: hostTableId,
|
||||
field: {
|
||||
type: 'lookup',
|
||||
name: 'Lookup Name',
|
||||
options: {
|
||||
foreignTableId,
|
||||
linkFieldId: linkField.id().toString(),
|
||||
lookupFieldId: foreignPrimaryId,
|
||||
},
|
||||
},
|
||||
})._unsafeUnwrap();
|
||||
const lookupResult = await handler.handle(createContext(), createLookup);
|
||||
lookupResult._unsafeUnwrap();
|
||||
|
||||
const hostAfterLookup = tableRepository.tables.find(
|
||||
(table) => table.id().toString() === hostTableId
|
||||
);
|
||||
expect(hostAfterLookup).toBeDefined();
|
||||
if (!hostAfterLookup) return;
|
||||
|
||||
const lookupField = hostAfterLookup
|
||||
.getFields()
|
||||
.find((field) => field.name().toString() === 'Lookup Name') as LookupField | undefined;
|
||||
expect(lookupField?.type().toString()).toBe('lookup');
|
||||
expect(lookupField?.isMultipleCellValue()._unsafeUnwrap().isMultiple()).toBe(true);
|
||||
});
|
||||
|
||||
it('derives lookup multiplicity as single for manyOne link in legacy mode', async () => {
|
||||
const baseId = `bse${'f'.repeat(16)}`;
|
||||
const hostTableId = `tbl${'g'.repeat(16)}`;
|
||||
const foreignTableId = `tbl${'h'.repeat(16)}`;
|
||||
const hostPrimaryId = `fld${'i'.repeat(16)}`;
|
||||
const foreignPrimaryId = `fld${'j'.repeat(16)}`;
|
||||
|
||||
const tableRepository = new InMemoryTableRepository();
|
||||
const schemaRepository = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const unitOfWork = new FakeUnitOfWork();
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
schemaRepository,
|
||||
eventBus,
|
||||
unitOfWork
|
||||
);
|
||||
const fieldCreationSideEffectService = new FieldCreationSideEffectService(tableUpdateFlow);
|
||||
const foreignTableLoaderService = new ForeignTableLoaderService(tableRepository);
|
||||
const handler = new CreateFieldHandler(
|
||||
tableUpdateFlow,
|
||||
fieldCreationSideEffectService,
|
||||
foreignTableLoaderService
|
||||
);
|
||||
|
||||
tableRepository.tables.push(
|
||||
buildTable({
|
||||
baseId,
|
||||
tableId: hostTableId,
|
||||
tableName: 'Host',
|
||||
primaryFieldId: hostPrimaryId,
|
||||
}),
|
||||
buildTable({
|
||||
baseId,
|
||||
tableId: foreignTableId,
|
||||
tableName: 'Foreign',
|
||||
primaryFieldId: foreignPrimaryId,
|
||||
})
|
||||
);
|
||||
|
||||
const createLink = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId: hostTableId,
|
||||
field: {
|
||||
type: 'link',
|
||||
name: 'Host Link',
|
||||
options: {
|
||||
relationship: 'manyOne',
|
||||
foreignTableId,
|
||||
lookupFieldId: foreignPrimaryId,
|
||||
},
|
||||
},
|
||||
})._unsafeUnwrap();
|
||||
const linkResult = await handler.handle(createContext(), createLink);
|
||||
linkResult._unsafeUnwrap();
|
||||
|
||||
const hostAfterLink = tableRepository.tables.find(
|
||||
(table) => table.id().toString() === hostTableId
|
||||
);
|
||||
expect(hostAfterLink).toBeDefined();
|
||||
if (!hostAfterLink) return;
|
||||
|
||||
const linkField = hostAfterLink
|
||||
.getFields()
|
||||
.find((field) => field.name().toString() === 'Host Link') as LinkField | undefined;
|
||||
expect(linkField).toBeDefined();
|
||||
if (!linkField) return;
|
||||
|
||||
const createLookup = CreateFieldCommand.create({
|
||||
baseId,
|
||||
tableId: hostTableId,
|
||||
field: {
|
||||
type: 'lookup',
|
||||
name: 'Lookup Name',
|
||||
legacyMultiplicityDerivation: true,
|
||||
options: {
|
||||
foreignTableId,
|
||||
linkFieldId: linkField.id().toString(),
|
||||
lookupFieldId: foreignPrimaryId,
|
||||
},
|
||||
},
|
||||
})._unsafeUnwrap();
|
||||
const lookupResult = await handler.handle(createContext(), createLookup);
|
||||
lookupResult._unsafeUnwrap();
|
||||
|
||||
const hostAfterLookup = tableRepository.tables.find(
|
||||
(table) => table.id().toString() === hostTableId
|
||||
);
|
||||
expect(hostAfterLookup).toBeDefined();
|
||||
if (!hostAfterLookup) return;
|
||||
|
||||
const lookupField = hostAfterLookup
|
||||
.getFields()
|
||||
.find((field) => field.name().toString() === 'Lookup Name') as LookupField | undefined;
|
||||
expect(lookupField?.type().toString()).toBe('lookup');
|
||||
expect(lookupField?.isMultipleCellValue()._unsafeUnwrap().isMultiple()).toBe(false);
|
||||
});
|
||||
|
||||
it('allows cross-base conditional lookup creation', async () => {
|
||||
const hostBaseId = `bse${'a'.repeat(16)}`;
|
||||
const foreignBaseId = `bse${'b'.repeat(16)}`;
|
||||
const hostTableId = `tbl${'c'.repeat(16)}`;
|
||||
const foreignTableId = `tbl${'d'.repeat(16)}`;
|
||||
const hostPrimaryId = `fld${'e'.repeat(16)}`;
|
||||
const foreignPrimaryId = `fld${'f'.repeat(16)}`;
|
||||
|
||||
const tableRepository = new InMemoryTableRepository();
|
||||
const schemaRepository = new FakeTableSchemaRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const unitOfWork = new FakeUnitOfWork();
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
schemaRepository,
|
||||
eventBus,
|
||||
unitOfWork
|
||||
);
|
||||
const fieldCreationSideEffectService = new FieldCreationSideEffectService(tableUpdateFlow);
|
||||
const foreignTableLoaderService = new ForeignTableLoaderService(tableRepository);
|
||||
const handler = new CreateFieldHandler(
|
||||
tableUpdateFlow,
|
||||
fieldCreationSideEffectService,
|
||||
foreignTableLoaderService
|
||||
);
|
||||
|
||||
tableRepository.tables.push(
|
||||
buildTable({
|
||||
baseId: hostBaseId,
|
||||
tableId: hostTableId,
|
||||
tableName: 'Host',
|
||||
primaryFieldId: hostPrimaryId,
|
||||
}),
|
||||
buildTable({
|
||||
baseId: foreignBaseId,
|
||||
tableId: foreignTableId,
|
||||
tableName: 'Foreign',
|
||||
primaryFieldId: foreignPrimaryId,
|
||||
})
|
||||
);
|
||||
|
||||
const commandResult = CreateFieldCommand.create({
|
||||
baseId: hostBaseId,
|
||||
tableId: hostTableId,
|
||||
field: {
|
||||
type: 'conditionalLookup',
|
||||
name: 'Cross Base Amounts',
|
||||
options: {
|
||||
foreignTableId,
|
||||
lookupFieldId: foreignPrimaryId,
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: foreignPrimaryId, operator: 'is', value: 'A' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
commandResult._unsafeUnwrap();
|
||||
|
||||
const result = await handler.handle(createContext(), commandResult._unsafeUnwrap());
|
||||
result._unsafeUnwrap();
|
||||
|
||||
const updatedTable = tableRepository.tables.find(
|
||||
(table) => table.id().toString() === hostTableId
|
||||
);
|
||||
expect(updatedTable).toBeDefined();
|
||||
if (!updatedTable) return;
|
||||
|
||||
const conditionalLookup = updatedTable
|
||||
.getFields()
|
||||
.find((field) => field.name().toString() === 'Cross Base Amounts');
|
||||
expect(conditionalLookup?.type().toString()).toBe('conditionalLookup');
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,11 +7,14 @@ import { ForeignTableLoaderService } from '../application/services/ForeignTableL
|
||||
import { TableUpdateFlow } from '../application/services/TableUpdateFlow';
|
||||
import { domainError, type DomainError } from '../domain/shared/DomainError';
|
||||
import type { IDomainEvent } from '../domain/shared/DomainEvent';
|
||||
import { DbFieldName } from '../domain/table/fields/DbFieldName';
|
||||
import type { Field } from '../domain/table/fields/Field';
|
||||
import type { Table } from '../domain/table/Table';
|
||||
import { ViewId } from '../domain/table/views/ViewId';
|
||||
import * as ExecutionContextPort from '../ports/ExecutionContext';
|
||||
import { v2CoreTokens } from '../ports/tokens';
|
||||
import { TraceSpan } from '../ports/TraceSpan';
|
||||
import type { ResolvedTableFieldInput } from '../schemas/field';
|
||||
import { CommandHandler, type ICommandHandler } from './CommandHandler';
|
||||
import { CreateFieldCommand } from './CreateFieldCommand';
|
||||
import { parseTableFieldSpec, resolveTableFieldInputName } from './TableFieldSpecs';
|
||||
@ -48,7 +51,7 @@ export class CreateFieldHandler implements ICommandHandler<CreateFieldCommand, C
|
||||
return safeTry<CreateFieldResult, DomainError>(async function* () {
|
||||
const foreignTableReferences = yield* command.foreignTableReferences();
|
||||
const foreignTables = yield* await handler.foreignTableLoaderService.load(context, {
|
||||
baseId: command.baseId,
|
||||
// Foreign references may point to tables in other bases (e.g. cross-base lookup).
|
||||
references: foreignTableReferences,
|
||||
});
|
||||
let createdField: Field | undefined;
|
||||
@ -61,16 +64,40 @@ export class CreateFieldHandler implements ICommandHandler<CreateFieldCommand, C
|
||||
t: context.$t,
|
||||
hostTable: table,
|
||||
foreignTables,
|
||||
}).andThen((resolved) =>
|
||||
parseTableFieldSpec(resolved, { isPrimary: false })
|
||||
}).andThen((resolved) => {
|
||||
const normalized = handler.populateLinkLookupFieldId(resolved, foreignTables);
|
||||
return parseTableFieldSpec(normalized, { isPrimary: false })
|
||||
.andThen((spec) =>
|
||||
spec.createField({ baseId: command.baseId, tableId: command.tableId })
|
||||
)
|
||||
.andThen((field) => {
|
||||
if (normalized.dbFieldName) {
|
||||
const dbFieldNameResult = DbFieldName.rehydrate(normalized.dbFieldName).andThen(
|
||||
(dbFieldName) => field.setDbFieldName(dbFieldName)
|
||||
);
|
||||
if (dbFieldNameResult.isErr()) {
|
||||
return err(dbFieldNameResult.error);
|
||||
}
|
||||
}
|
||||
createdField = field;
|
||||
return table.update((mutator) => mutator.addField(field, { foreignTables }));
|
||||
})
|
||||
);
|
||||
if (!command.order) {
|
||||
return table.update((mutator) => mutator.addField(field, { foreignTables }));
|
||||
}
|
||||
|
||||
const order = command.order;
|
||||
return ViewId.create(order.viewId).andThen((viewId) =>
|
||||
table.update((mutator) =>
|
||||
mutator.addField(field, {
|
||||
foreignTables,
|
||||
viewOrder: {
|
||||
viewId,
|
||||
order: order.orderIndex,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
hooks: {
|
||||
@ -93,4 +120,28 @@ export class CreateFieldHandler implements ICommandHandler<CreateFieldCommand, C
|
||||
return ok(CreateFieldResult.create(updateResult.table, updateResult.events));
|
||||
});
|
||||
}
|
||||
|
||||
private populateLinkLookupFieldId(
|
||||
field: ResolvedTableFieldInput,
|
||||
foreignTables: ReadonlyArray<Table>
|
||||
): ResolvedTableFieldInput {
|
||||
if (field.type !== 'link' || field.options.lookupFieldId != null) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const foreignTable = foreignTables.find(
|
||||
(table) => table.id().toString() === field.options.foreignTableId
|
||||
);
|
||||
if (!foreignTable) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
options: {
|
||||
...field.options,
|
||||
lookupFieldId: foreignTable.primaryFieldId().toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,17 +6,22 @@ import { FieldCreationSideEffectService } from '../application/services/FieldCre
|
||||
import { ForeignTableLoaderService } from '../application/services/ForeignTableLoaderService';
|
||||
import { TableCreationService } from '../application/services/TableCreationService';
|
||||
import { TableUpdateFlow } from '../application/services/TableUpdateFlow';
|
||||
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 type { RecordId } from '../domain/table/records/RecordId';
|
||||
import type { RecordUpdateResult } from '../domain/table/records/RecordUpdateResult';
|
||||
import type { ITableRecordConditionSpecVisitor } from '../domain/table/records/specs/ITableRecordConditionSpecVisitor';
|
||||
import type { ICellValueSpec } from '../domain/table/records/specs/values/ICellValueSpecVisitor';
|
||||
import type { TableRecord } from '../domain/table/records/TableRecord';
|
||||
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
|
||||
import type { Table } from '../domain/table/Table';
|
||||
import { Table } from '../domain/table/Table';
|
||||
import { TableId } from '../domain/table/TableId';
|
||||
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';
|
||||
@ -40,6 +45,29 @@ const createContext = (): IExecutionContext => {
|
||||
return { actorId: actorIdResult._unsafeUnwrap() };
|
||||
};
|
||||
|
||||
const buildTable = (params: {
|
||||
baseId: string;
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
primaryFieldId: string;
|
||||
}): Table => {
|
||||
return Table.builder()
|
||||
.withId(TableId.create(params.tableId)._unsafeUnwrap())
|
||||
.withBaseId(BaseId.create(params.baseId)._unsafeUnwrap())
|
||||
.withName(TableName.create(params.tableName)._unsafeUnwrap())
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(FieldId.create(params.primaryFieldId)._unsafeUnwrap())
|
||||
.withName(FieldName.create('Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done()
|
||||
.view()
|
||||
.defaultGrid()
|
||||
.done()
|
||||
.build()
|
||||
._unsafeUnwrap();
|
||||
};
|
||||
|
||||
class FakeTableRepository implements ITableRepository {
|
||||
inserted: Table[] = [];
|
||||
updated: Table[] = [];
|
||||
@ -435,4 +463,81 @@ describe('CreateTablesHandler', () => {
|
||||
expect(recordAValue).toBe('Alpha');
|
||||
expect(recordBValue).toBe('Beta');
|
||||
});
|
||||
|
||||
it('allows creating tables that link to external cross-base tables', async () => {
|
||||
const hostBaseId = `bse${'f'.repeat(16)}`;
|
||||
const foreignBaseId = `bse${'g'.repeat(16)}`;
|
||||
const foreignTableId = `tbl${'h'.repeat(16)}`;
|
||||
const foreignPrimaryId = `fld${'i'.repeat(16)}`;
|
||||
const hostTableId = `tbl${'j'.repeat(16)}`;
|
||||
const hostPrimaryId = `fld${'k'.repeat(16)}`;
|
||||
const hostLinkId = `fld${'l'.repeat(16)}`;
|
||||
|
||||
const commandResult = CreateTablesCommand.create({
|
||||
baseId: hostBaseId,
|
||||
tables: [
|
||||
{
|
||||
tableId: hostTableId,
|
||||
name: 'Host Table',
|
||||
fields: [
|
||||
{ type: 'singleLineText', id: hostPrimaryId, name: 'Name', isPrimary: true },
|
||||
{
|
||||
type: 'link',
|
||||
id: hostLinkId,
|
||||
name: 'Cross Base Link',
|
||||
options: {
|
||||
baseId: foreignBaseId,
|
||||
relationship: 'manyOne',
|
||||
foreignTableId,
|
||||
lookupFieldId: foreignPrimaryId,
|
||||
},
|
||||
},
|
||||
],
|
||||
views: [{ type: 'grid' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tableRepository = new FakeTableRepository();
|
||||
tableRepository.inserted.push(
|
||||
buildTable({
|
||||
baseId: foreignBaseId,
|
||||
tableId: foreignTableId,
|
||||
tableName: 'Foreign Table',
|
||||
primaryFieldId: foreignPrimaryId,
|
||||
})
|
||||
);
|
||||
const schemaRepository = new FakeTableSchemaRepository();
|
||||
const recordRepository = new FakeTableRecordRepository();
|
||||
const eventBus = new FakeEventBus();
|
||||
const unitOfWork = new FakeUnitOfWork();
|
||||
const tableUpdateFlow = new TableUpdateFlow(
|
||||
tableRepository,
|
||||
schemaRepository,
|
||||
eventBus,
|
||||
unitOfWork
|
||||
);
|
||||
const fieldCreationSideEffectService = new FieldCreationSideEffectService(tableUpdateFlow);
|
||||
const foreignTableLoaderService = new ForeignTableLoaderService(tableRepository);
|
||||
const tableCreationService = new TableCreationService(
|
||||
tableRepository,
|
||||
schemaRepository,
|
||||
fieldCreationSideEffectService
|
||||
);
|
||||
const handler = new CreateTablesHandler(
|
||||
tableRepository,
|
||||
recordRepository,
|
||||
foreignTableLoaderService,
|
||||
tableCreationService,
|
||||
eventBus,
|
||||
unitOfWork
|
||||
);
|
||||
|
||||
const result = await handler.handle(createContext(), commandResult._unsafeUnwrap());
|
||||
result._unsafeUnwrap();
|
||||
|
||||
expect(tableRepository.inserted.some((table) => table.id().toString() === hostTableId)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -282,7 +282,8 @@ export class CreateTablesHandler
|
||||
|
||||
// Load external/foreign tables
|
||||
const externalTables = yield* await handler.foreignTableLoaderService.load(context, {
|
||||
baseId: command.baseId,
|
||||
// Cross-base references are allowed for external tables.
|
||||
// Do not constrain by host baseId here.
|
||||
references: externalReferences,
|
||||
});
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ describe('DuplicateFieldCommand', () => {
|
||||
fieldId: 'fldcccccccccccccccc',
|
||||
includeRecordValues: false,
|
||||
newFieldName: 'Custom Name',
|
||||
viewId: 'viwaaaaaaaaaaaaaaaa',
|
||||
};
|
||||
|
||||
const result = DuplicateFieldCommand.create(input);
|
||||
@ -38,6 +39,7 @@ describe('DuplicateFieldCommand', () => {
|
||||
if (result.isOk()) {
|
||||
expect(result.value.includeRecordValues).toBe(false);
|
||||
expect(result.value.newFieldName).toBe('Custom Name');
|
||||
expect(result.value.viewId?.toString()).toBe(input.viewId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { domainError, type DomainError } from '../domain/shared/DomainError';
|
||||
import type { LinkForeignTableReference } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
|
||||
import { FieldId } from '../domain/table/fields/FieldId';
|
||||
import { TableId } from '../domain/table/TableId';
|
||||
import { ViewId } from '../domain/table/views/ViewId';
|
||||
import { TableUpdateCommand } from './TableUpdateCommand';
|
||||
|
||||
export const duplicateFieldInputSchema = z.object({
|
||||
@ -15,6 +16,7 @@ export const duplicateFieldInputSchema = z.object({
|
||||
fieldId: z.string(),
|
||||
includeRecordValues: z.boolean().default(true),
|
||||
newFieldName: z.string().optional(),
|
||||
viewId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type IDuplicateFieldCommandInput = z.input<typeof duplicateFieldInputSchema>;
|
||||
@ -25,7 +27,8 @@ export class DuplicateFieldCommand extends TableUpdateCommand {
|
||||
readonly tableId: TableId,
|
||||
readonly fieldId: FieldId,
|
||||
readonly includeRecordValues: boolean,
|
||||
readonly newFieldName: string | undefined
|
||||
readonly newFieldName: string | undefined,
|
||||
readonly viewId: ViewId | undefined
|
||||
) {
|
||||
super(baseId, tableId);
|
||||
}
|
||||
@ -42,15 +45,29 @@ export class DuplicateFieldCommand extends TableUpdateCommand {
|
||||
|
||||
return BaseId.create(parsed.data.baseId).andThen((baseId) =>
|
||||
TableId.create(parsed.data.tableId).andThen((tableId) =>
|
||||
FieldId.create(parsed.data.fieldId).map(
|
||||
(fieldId) =>
|
||||
new DuplicateFieldCommand(
|
||||
baseId,
|
||||
tableId,
|
||||
fieldId,
|
||||
parsed.data.includeRecordValues,
|
||||
parsed.data.newFieldName
|
||||
)
|
||||
FieldId.create(parsed.data.fieldId).andThen((fieldId) =>
|
||||
parsed.data.viewId
|
||||
? ViewId.create(parsed.data.viewId).map(
|
||||
(viewId) =>
|
||||
new DuplicateFieldCommand(
|
||||
baseId,
|
||||
tableId,
|
||||
fieldId,
|
||||
parsed.data.includeRecordValues,
|
||||
parsed.data.newFieldName,
|
||||
viewId
|
||||
)
|
||||
)
|
||||
: ok(
|
||||
new DuplicateFieldCommand(
|
||||
baseId,
|
||||
tableId,
|
||||
fieldId,
|
||||
parsed.data.includeRecordValues,
|
||||
parsed.data.newFieldName,
|
||||
undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@ -83,21 +83,25 @@ export class DuplicateFieldHandler
|
||||
const resolvedName = generateUniqueName(baseName, existingNames);
|
||||
const newFieldName = yield* FieldName.create(resolvedName);
|
||||
|
||||
// Clone the source field with new ID and name
|
||||
const clonedField = yield* field.duplicate({
|
||||
newId: newFieldId,
|
||||
newName: newFieldName,
|
||||
baseId: command.baseId,
|
||||
tableId: command.tableId,
|
||||
});
|
||||
newField = clonedField;
|
||||
|
||||
// Update table with duplicated field
|
||||
// Note: Value duplication happens in the repository visitor (TableSchemaUpdateVisitor)
|
||||
// when it visits the TableDuplicateFieldSpec
|
||||
const updated = yield* table.update((mutator) =>
|
||||
mutator.duplicateField(sourceField!, clonedField, command.includeRecordValues)
|
||||
mutator.duplicateField(
|
||||
sourceField!,
|
||||
newFieldId,
|
||||
newFieldName,
|
||||
command.includeRecordValues,
|
||||
{
|
||||
targetViewId: command.viewId,
|
||||
}
|
||||
)
|
||||
);
|
||||
const duplicatedFieldResult = updated.table.getField((f) => f.id().equals(newFieldId));
|
||||
if (duplicatedFieldResult.isErr()) {
|
||||
return err(duplicatedFieldResult.error);
|
||||
}
|
||||
newField = duplicatedFieldResult.value;
|
||||
return ok(updated);
|
||||
}),
|
||||
{
|
||||
|
||||
@ -31,7 +31,6 @@ const notNullValidationFieldTypes = new Set<FieldTypeValue>([
|
||||
'date',
|
||||
'rating',
|
||||
'attachment',
|
||||
'link',
|
||||
]);
|
||||
|
||||
export const isComputedFieldType = (fieldType: FieldTypeValue): boolean =>
|
||||
|
||||
@ -4,6 +4,7 @@ import { tableI18nKeys } from '@teable/i18n-keys';
|
||||
import { BaseId } from '../domain/base/BaseId';
|
||||
import { FieldId } from '../domain/table/fields/FieldId';
|
||||
import { FieldName } from '../domain/table/fields/FieldName';
|
||||
import { ConditionalLookupField } from '../domain/table/fields/types/ConditionalLookupField';
|
||||
import { Table } from '../domain/table/Table';
|
||||
import { TableName } from '../domain/table/TableName';
|
||||
import { TableId } from '../domain/table/TableId';
|
||||
@ -289,6 +290,46 @@ describe('TableFieldSpecs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves conditional lookup inner options patch in create spec', () => {
|
||||
const foreignTableId = `tbl${'b'.repeat(16)}`;
|
||||
const lookupFieldId = `fld${'a'.repeat(16)}`;
|
||||
|
||||
const specResult = parseSpec({
|
||||
type: 'conditionalLookup',
|
||||
name: 'Conditional Currency',
|
||||
options: {
|
||||
foreignTableId,
|
||||
lookupFieldId,
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: lookupFieldId, operator: 'is', value: 'active' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
innerOptions: {
|
||||
formatting: {
|
||||
type: 'currency',
|
||||
precision: 1,
|
||||
symbol: '¥',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(specResult.isOk()).toBe(true);
|
||||
const fieldResult = specResult._unsafeUnwrap().createField();
|
||||
expect(fieldResult.isOk()).toBe(true);
|
||||
const field = fieldResult._unsafeUnwrap();
|
||||
expect(field).toBeInstanceOf(ConditionalLookupField);
|
||||
expect((field as ConditionalLookupField).innerOptionsPatch()).toEqual({
|
||||
formatting: {
|
||||
type: 'currency',
|
||||
precision: 1,
|
||||
symbol: '¥',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('collects foreign table references from specs', () => {
|
||||
const linkSpec = parseSpec({
|
||||
type: 'link',
|
||||
@ -319,4 +360,27 @@ describe('TableFieldSpecs', () => {
|
||||
const field = spec.createField()._unsafeUnwrap();
|
||||
expect(field.id()).toBeInstanceOf(FieldId);
|
||||
});
|
||||
|
||||
it('applies aiConfig when creating field from spec', () => {
|
||||
const aiConfig = {
|
||||
type: 'summary',
|
||||
modelKey: 'openai@gpt-4o@gpt',
|
||||
sourceFieldId: `fld${'z'.repeat(16)}`,
|
||||
};
|
||||
|
||||
const spec = parseSpec({
|
||||
type: 'singleLineText',
|
||||
name: 'AI Text',
|
||||
aiConfig,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const field = spec
|
||||
.createField({
|
||||
baseId: BaseId.create(`bse${'e'.repeat(16)}`)._unsafeUnwrap(),
|
||||
tableId: TableId.create(`tbl${'f'.repeat(16)}`)._unsafeUnwrap(),
|
||||
})
|
||||
._unsafeUnwrap();
|
||||
|
||||
expect(field.aiConfig()).toEqual(aiConfig);
|
||||
});
|
||||
});
|
||||
|
||||
@ -143,8 +143,6 @@ const derivedFieldNameWithTranslation = (
|
||||
options: ResolveTableFieldNameOptions
|
||||
): string | undefined => {
|
||||
const t = options.t;
|
||||
if (!t) return;
|
||||
|
||||
const foreignTables = options.foreignTables;
|
||||
const hostTable = options.hostTable;
|
||||
|
||||
@ -163,9 +161,20 @@ const derivedFieldNameWithTranslation = (
|
||||
}
|
||||
).options;
|
||||
const foreignTable = findTableById(foreignTables, opts?.foreignTableId);
|
||||
const lookupFieldName = findFieldNameById(foreignTable, opts?.lookupFieldId);
|
||||
const lookupFieldName = findFieldNameById(foreignTable, opts?.lookupFieldId) ?? 'Name';
|
||||
const linkFieldName = findFieldNameById(hostTable, opts?.linkFieldId);
|
||||
if (!lookupFieldName || !linkFieldName) return;
|
||||
const tableName = foreignTable?.name().toString();
|
||||
if (!t) {
|
||||
return tableName
|
||||
? `${lookupFieldName} (from ${tableName})`
|
||||
: `${lookupFieldName} (from ${linkFieldName ?? 'Link'})`;
|
||||
}
|
||||
if (!linkFieldName) {
|
||||
if (tableName) {
|
||||
return `${lookupFieldName} (from ${tableName})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return translateOrFallback(t, tableI18nKeys.field.default.lookup.title, 'Lookup', {
|
||||
lookupFieldName,
|
||||
linkFieldName,
|
||||
@ -180,6 +189,13 @@ const derivedFieldNameWithTranslation = (
|
||||
const foreignTable = findTableById(foreignTables, cfg?.foreignTableId);
|
||||
const lookupFieldName = findFieldNameById(foreignTable, cfg?.lookupFieldId);
|
||||
const linkFieldName = findFieldNameById(hostTable, cfg?.linkFieldId);
|
||||
const tableName = foreignTable?.name().toString();
|
||||
if (!t) {
|
||||
if (lookupFieldName && tableName) {
|
||||
return `${lookupFieldName} Rollup (from ${tableName})`;
|
||||
}
|
||||
return 'Rollup';
|
||||
}
|
||||
if (lookupFieldName && linkFieldName) {
|
||||
return translateOrFallback(t, tableI18nKeys.field.default.rollup.title, 'Rollup', {
|
||||
lookupFieldName,
|
||||
@ -195,6 +211,9 @@ const derivedFieldNameWithTranslation = (
|
||||
const lookupFieldName = findFieldNameById(foreignTable, opts?.lookupFieldId);
|
||||
const tableName = foreignTable?.name().toString();
|
||||
if (!lookupFieldName || !tableName) return;
|
||||
if (!t) {
|
||||
return `${lookupFieldName} (from ${tableName})`;
|
||||
}
|
||||
return translateOrFallback(
|
||||
t,
|
||||
tableI18nKeys.field.default.conditionalLookup.title,
|
||||
@ -209,6 +228,9 @@ const derivedFieldNameWithTranslation = (
|
||||
const lookupFieldName = findFieldNameById(foreignTable, cfg?.lookupFieldId);
|
||||
const tableName = foreignTable?.name().toString();
|
||||
if (!lookupFieldName || !tableName) return;
|
||||
if (!t) {
|
||||
return `${lookupFieldName} Rollup (from ${tableName})`;
|
||||
}
|
||||
return translateOrFallback(
|
||||
t,
|
||||
tableI18nKeys.field.default.conditionalRollup.title,
|
||||
@ -226,14 +248,13 @@ const defaultFieldName = (
|
||||
options?: ResolveTableFieldNameOptions
|
||||
): string => {
|
||||
const t = options?.t;
|
||||
const derived = derivedFieldNameWithTranslation(field, options ?? {});
|
||||
if (derived) return derived;
|
||||
|
||||
// Keep legacy behavior when translation isn't provided.
|
||||
if (!t && field.isPrimary === true) return 'Name';
|
||||
|
||||
if (t) {
|
||||
const derived = derivedFieldNameWithTranslation(field, options ?? {});
|
||||
if (derived) return derived;
|
||||
|
||||
switch (field.type) {
|
||||
case 'singleLineText':
|
||||
return translateOrFallback(t, tableI18nKeys.field.default.singleLineText.title, 'Label');
|
||||
@ -321,7 +342,7 @@ const defaultFieldName = (
|
||||
case 'lastModifiedBy':
|
||||
return 'Last Modified By';
|
||||
case 'autoNumber':
|
||||
return 'Auto Number';
|
||||
return 'ID';
|
||||
case 'button':
|
||||
return 'Button';
|
||||
case 'formula':
|
||||
@ -418,6 +439,44 @@ class CreateTableFieldWithDescriptionSpec implements ICreateTableFieldSpec {
|
||||
}
|
||||
}
|
||||
|
||||
class CreateTableFieldWithAiConfigSpec implements ICreateTableFieldSpec {
|
||||
constructor(
|
||||
private readonly spec: ICreateTableFieldSpec,
|
||||
private readonly aiConfig: unknown | null | undefined,
|
||||
private readonly fieldName: string
|
||||
) {}
|
||||
|
||||
applyTo(builder: TableBuilder): void {
|
||||
this.spec.applyTo(builder);
|
||||
}
|
||||
|
||||
createField(params?: { baseId?: BaseId; tableId?: TableId }): Result<Field, DomainError> {
|
||||
return this.spec
|
||||
.createField(params)
|
||||
.andThen((field) =>
|
||||
this.aiConfig === undefined ? ok(field) : field.setAiConfig(this.aiConfig).map(() => field)
|
||||
);
|
||||
}
|
||||
|
||||
foreignTableReferences(): Result<ReadonlyArray<LinkForeignTableReference>, DomainError> {
|
||||
return this.spec.foreignTableReferences();
|
||||
}
|
||||
|
||||
applyPostBuild(table: Table): Result<void, DomainError> {
|
||||
const applyInner = this.spec.applyPostBuild ? this.spec.applyPostBuild(table) : ok(undefined);
|
||||
|
||||
if (this.aiConfig === undefined) {
|
||||
return applyInner;
|
||||
}
|
||||
|
||||
return applyInner.andThen(() =>
|
||||
table
|
||||
.getField((field) => field.name().toString() === this.fieldName)
|
||||
.andThen((field) => field.setAiConfig(this.aiConfig))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const withFieldDescription = (
|
||||
spec: ICreateTableFieldSpec,
|
||||
description: string | null | undefined,
|
||||
@ -426,6 +485,14 @@ const withFieldDescription = (
|
||||
return new CreateTableFieldWithDescriptionSpec(spec, description ?? null, fieldName);
|
||||
};
|
||||
|
||||
const withFieldAiConfig = (
|
||||
spec: ICreateTableFieldSpec,
|
||||
aiConfig: unknown | null | undefined,
|
||||
fieldName: string
|
||||
): ICreateTableFieldSpec => {
|
||||
return new CreateTableFieldWithAiConfigSpec(spec, aiConfig, fieldName);
|
||||
};
|
||||
|
||||
const uniqueForeignTableReferences = (
|
||||
refs: ReadonlyArray<LinkForeignTableReference>
|
||||
): ReadonlyArray<LinkForeignTableReference> => {
|
||||
@ -999,6 +1066,8 @@ class CreateLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
private readonly filter: unknown,
|
||||
private readonly sort: unknown,
|
||||
private readonly limit: number | undefined,
|
||||
private readonly innerOptionsPatch: Readonly<Record<string, unknown>> | undefined,
|
||||
private readonly legacyMultiplicityDerivation: boolean,
|
||||
private readonly isMultipleCellValue: boolean | undefined,
|
||||
private readonly notNull: FieldNotNull,
|
||||
private readonly unique: FieldUnique
|
||||
@ -1015,6 +1084,8 @@ class CreateLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
filter?: unknown;
|
||||
sort?: unknown;
|
||||
limit?: number;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
legacyMultiplicityDerivation?: boolean;
|
||||
isMultipleCellValue?: boolean;
|
||||
notNull: FieldNotNull;
|
||||
unique: FieldUnique;
|
||||
@ -1029,6 +1100,8 @@ class CreateLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
options.filter,
|
||||
options.sort,
|
||||
options.limit,
|
||||
options.innerOptionsPatch,
|
||||
options.legacyMultiplicityDerivation === true,
|
||||
options.isMultipleCellValue,
|
||||
options.notNull,
|
||||
options.unique
|
||||
@ -1059,6 +1132,8 @@ class CreateLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
id,
|
||||
name: this.name,
|
||||
lookupOptions,
|
||||
innerOptionsPatch: this.innerOptionsPatch,
|
||||
legacyMultiplicityDerivation: this.legacyMultiplicityDerivation,
|
||||
isMultipleCellValue: this.isMultipleCellValue,
|
||||
notNull: this.notNull,
|
||||
unique: this.unique,
|
||||
@ -1207,7 +1282,8 @@ class CreateConditionalLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
private constructor(
|
||||
private readonly id: FieldId | undefined,
|
||||
private readonly name: FieldName,
|
||||
private readonly conditionalLookupOptions: ConditionalLookupOptions
|
||||
private readonly conditionalLookupOptions: ConditionalLookupOptions,
|
||||
private readonly innerOptionsPatch: Readonly<Record<string, unknown>> | undefined
|
||||
) {}
|
||||
|
||||
static create(
|
||||
@ -1216,12 +1292,14 @@ class CreateConditionalLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
options: {
|
||||
isPrimary: boolean;
|
||||
conditionalLookupOptions: ConditionalLookupOptions;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
): CreateConditionalLookupFieldSpec {
|
||||
return new CreateConditionalLookupFieldSpec(
|
||||
id,
|
||||
name,
|
||||
options.conditionalLookupOptions
|
||||
options.conditionalLookupOptions,
|
||||
options.innerOptionsPatch
|
||||
).withPrimary(options.isPrimary);
|
||||
}
|
||||
|
||||
@ -1243,6 +1321,7 @@ class CreateConditionalLookupFieldSpec implements ICreateTableFieldSpec {
|
||||
id,
|
||||
name: this.name,
|
||||
conditionalLookupOptions: this.conditionalLookupOptions,
|
||||
innerOptionsPatch: this.innerOptionsPatch,
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -2269,7 +2348,7 @@ export const parseTableFieldSpec = (
|
||||
CreateFormulaFieldSpec.create(id, name, {
|
||||
isPrimary: options.isPrimary,
|
||||
expression,
|
||||
timeZone,
|
||||
timeZone: timeZone ?? TimeZone.default(),
|
||||
formatting,
|
||||
showAs,
|
||||
resultType,
|
||||
@ -2326,6 +2405,13 @@ export const parseTableFieldSpec = (
|
||||
filter: field.options.filter,
|
||||
sort: field.options.sort,
|
||||
limit: field.options.limit,
|
||||
innerOptionsPatch:
|
||||
field.innerOptions &&
|
||||
typeof field.innerOptions === 'object' &&
|
||||
!Array.isArray(field.innerOptions)
|
||||
? (field.innerOptions as Record<string, unknown>)
|
||||
: undefined,
|
||||
legacyMultiplicityDerivation: field.legacyMultiplicityDerivation === true,
|
||||
isMultipleCellValue:
|
||||
'isMultipleCellValue' in field && typeof field.isMultipleCellValue === 'boolean'
|
||||
? field.isMultipleCellValue
|
||||
@ -2504,11 +2590,23 @@ export const parseTableFieldSpec = (
|
||||
CreateConditionalLookupFieldSpec.create(id, name, {
|
||||
isPrimary: options.isPrimary,
|
||||
conditionalLookupOptions,
|
||||
innerOptionsPatch:
|
||||
field.innerOptions &&
|
||||
typeof field.innerOptions === 'object' &&
|
||||
!Array.isArray(field.innerOptions)
|
||||
? (field.innerOptions as Record<string, unknown>)
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
.exhaustive()
|
||||
.map((spec) => withFieldDescription(spec, field.description, field.name))
|
||||
.map((spec) =>
|
||||
withFieldAiConfig(
|
||||
withFieldDescription(spec, field.description, field.name),
|
||||
field.aiConfig,
|
||||
field.name
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@ -1387,6 +1387,7 @@ class UpdateConditionalLookupFieldSpec implements IUpdateTableFieldSpec {
|
||||
return err(nextInnerFieldResult.error);
|
||||
}
|
||||
const nextInnerField = nextInnerFieldResult.value;
|
||||
const nextInnerOptionsPatch = this.resolveNextInnerOptionsPatch(params.currentField);
|
||||
|
||||
if (nextInnerField) {
|
||||
return ConditionalLookupField.create({
|
||||
@ -1396,6 +1397,7 @@ class UpdateConditionalLookupFieldSpec implements IUpdateTableFieldSpec {
|
||||
conditionalLookupOptions: params.nextOptions,
|
||||
isMultipleCellValue: params.isMultipleCellValue,
|
||||
dependencies: params.currentField.dependencies(),
|
||||
innerOptionsPatch: nextInnerOptionsPatch,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1405,9 +1407,31 @@ class UpdateConditionalLookupFieldSpec implements IUpdateTableFieldSpec {
|
||||
conditionalLookupOptions: params.nextOptions,
|
||||
isMultipleCellValue: params.isMultipleCellValue,
|
||||
dependencies: params.currentField.dependencies(),
|
||||
innerOptionsPatch: nextInnerOptionsPatch,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveNextInnerOptionsPatch(
|
||||
currentField: ConditionalLookupField
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
const currentPatch = currentField.innerOptionsPatch();
|
||||
const hasInnerTypeUpdate = this.innerTypeValue !== undefined;
|
||||
const hasInnerOptionsUpdate = this.innerOptionsValue !== undefined;
|
||||
|
||||
if (hasInnerTypeUpdate) {
|
||||
return hasInnerOptionsUpdate ? this.innerOptionsValue : undefined;
|
||||
}
|
||||
|
||||
if (hasInnerOptionsUpdate) {
|
||||
return {
|
||||
...(currentPatch ?? {}),
|
||||
...this.innerOptionsValue,
|
||||
};
|
||||
}
|
||||
|
||||
return currentPatch;
|
||||
}
|
||||
|
||||
private buildUpdatedInnerField(
|
||||
currentField: ConditionalLookupField,
|
||||
nextOptions: ConditionalLookupOptions
|
||||
@ -2122,6 +2146,18 @@ class UpdateFormulaFieldSpec implements IUpdateTableFieldSpec {
|
||||
UpdateFormulaTimeZoneSpec.create(currentField.id(), currentTimeZone, this.timeZoneValue)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const currentTimeZone = currentField.timeZone();
|
||||
const touchedFormulaOptions =
|
||||
this.expressionValue !== undefined ||
|
||||
this.formattingValue !== undefined ||
|
||||
this.showAsValue !== undefined ||
|
||||
this.shouldClearShowAs;
|
||||
if (!currentTimeZone && touchedFormulaOptions) {
|
||||
specs.push(
|
||||
UpdateFormulaTimeZoneSpec.create(currentField.id(), currentTimeZone, TimeZone.default())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.formattingValue !== undefined) {
|
||||
|
||||
@ -89,14 +89,9 @@ export class FormulaTypeVisitor
|
||||
if (rightResult.isErr()) return err(rightResult.error);
|
||||
|
||||
const valueType = this.getBinaryOpValueType(ctx, leftResult.value, rightResult.value);
|
||||
// Comparison operators always return a single boolean value,
|
||||
// even when comparing arrays (the array is unwrapped to its first element in SQL).
|
||||
// Logical operators (||, &&) also return single boolean.
|
||||
const isComparisonOrLogical = this.isComparisonOrLogicalOp(ctx);
|
||||
const isMultiple = isComparisonOrLogical
|
||||
? false
|
||||
: Boolean(leftResult.value.isMultiple || rightResult.value.isMultiple);
|
||||
return ok(new TypedValue(null, valueType, isMultiple));
|
||||
// Binary operators unwrap arrays to scalar values in SQL translation.
|
||||
// Keep v1 parity by inferring scalar results for all binary operations.
|
||||
return ok(new TypedValue(null, valueType, false));
|
||||
}
|
||||
|
||||
visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): Result<TypedValue, DomainError> {
|
||||
@ -146,19 +141,6 @@ export class FormulaTypeVisitor
|
||||
return ok(new TypedValue(null, returnResult.value.type, returnResult.value.isMultiple));
|
||||
}
|
||||
|
||||
private isComparisonOrLogicalOp(ctx: BinaryOpContext): boolean {
|
||||
return Boolean(
|
||||
ctx.PIPE_PIPE() ||
|
||||
ctx.AMP_AMP() ||
|
||||
ctx.EQUAL() ||
|
||||
ctx.BANG_EQUAL() ||
|
||||
ctx.GT() ||
|
||||
ctx.GTE() ||
|
||||
ctx.LT() ||
|
||||
ctx.LTE()
|
||||
);
|
||||
}
|
||||
|
||||
private getBinaryOpValueType(
|
||||
ctx: BinaryOpContext,
|
||||
left: TypedValue,
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { ok } from 'neverthrow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BaseId } from '../base/BaseId';
|
||||
import { DbTableName } from './DbTableName';
|
||||
import { DbFieldName } from './fields/DbFieldName';
|
||||
import { FieldDeleted } from './events/FieldDeleted';
|
||||
import { FieldUpdated } from './events/FieldUpdated';
|
||||
import { TableCreated } from './events/TableCreated';
|
||||
@ -13,6 +14,8 @@ import { FieldId } from './fields/FieldId';
|
||||
import { FieldName } from './fields/FieldName';
|
||||
import { CheckboxDefaultValue } from './fields/types/CheckboxDefaultValue';
|
||||
import { CheckboxField } from './fields/types/CheckboxField';
|
||||
import { FieldNotNull } from './fields/types/FieldNotNull';
|
||||
import { FieldUnique } from './fields/types/FieldUnique';
|
||||
import { FormulaExpression } from './fields/types/FormulaExpression';
|
||||
import { LongTextField } from './fields/types/LongTextField';
|
||||
import { NumberDefaultValue } from './fields/types/NumberDefaultValue';
|
||||
@ -294,6 +297,204 @@ describe('Table', () => {
|
||||
expect(addedEntry.order).toBe(maxOrder + 1);
|
||||
});
|
||||
|
||||
it('rejects adding a field with duplicate dbFieldName', () => {
|
||||
const baseIdResult = createBaseId('d');
|
||||
const tableNameResult = TableName.create('Duplicate DbFieldName');
|
||||
const primaryFieldNameResult = FieldName.create('Title');
|
||||
const viewNameResult = ViewName.create('Grid');
|
||||
const secondFieldNameResult = FieldName.create('Text-1');
|
||||
const thirdFieldNameResult = FieldName.create('Text-2');
|
||||
const secondFieldIdResult = createFieldId('e');
|
||||
const thirdFieldIdResult = createFieldId('f');
|
||||
const duplicateDbFieldNameResult = DbFieldName.rehydrate('fld_duplicate_db_field');
|
||||
|
||||
[
|
||||
baseIdResult,
|
||||
tableNameResult,
|
||||
primaryFieldNameResult,
|
||||
viewNameResult,
|
||||
secondFieldNameResult,
|
||||
thirdFieldNameResult,
|
||||
secondFieldIdResult,
|
||||
thirdFieldIdResult,
|
||||
duplicateDbFieldNameResult,
|
||||
].forEach((result) => result._unsafeUnwrap());
|
||||
|
||||
const builder = Table.builder()
|
||||
.withBaseId(baseIdResult._unsafeUnwrap())
|
||||
.withName(tableNameResult._unsafeUnwrap());
|
||||
builder.field().singleLineText().withName(primaryFieldNameResult._unsafeUnwrap()).done();
|
||||
builder.view().grid().withName(viewNameResult._unsafeUnwrap()).done();
|
||||
const tableResult = builder.build();
|
||||
tableResult._unsafeUnwrap();
|
||||
const table = tableResult._unsafeUnwrap();
|
||||
|
||||
const secondFieldResult = SingleLineTextField.create({
|
||||
id: secondFieldIdResult._unsafeUnwrap(),
|
||||
name: secondFieldNameResult._unsafeUnwrap(),
|
||||
}).andThen((field) =>
|
||||
field.setDbFieldName(duplicateDbFieldNameResult._unsafeUnwrap()).map(() => field)
|
||||
);
|
||||
secondFieldResult._unsafeUnwrap();
|
||||
|
||||
const tableAfterSecondFieldResult = table.update((mutator) =>
|
||||
mutator.addField(secondFieldResult._unsafeUnwrap())
|
||||
);
|
||||
tableAfterSecondFieldResult._unsafeUnwrap();
|
||||
|
||||
const thirdFieldResult = SingleLineTextField.create({
|
||||
id: thirdFieldIdResult._unsafeUnwrap(),
|
||||
name: thirdFieldNameResult._unsafeUnwrap(),
|
||||
}).andThen((field) =>
|
||||
field.setDbFieldName(duplicateDbFieldNameResult._unsafeUnwrap()).map(() => field)
|
||||
);
|
||||
thirdFieldResult._unsafeUnwrap();
|
||||
|
||||
const duplicateResult = tableAfterSecondFieldResult
|
||||
._unsafeUnwrap()
|
||||
.table.update((mutator) => mutator.addField(thirdFieldResult._unsafeUnwrap()));
|
||||
|
||||
expect(duplicateResult.isErr()).toBe(true);
|
||||
if (duplicateResult.isErr()) {
|
||||
expect(duplicateResult.error.message).toContain('already exists in this table');
|
||||
}
|
||||
});
|
||||
|
||||
it('duplicates field and preserves common metadata in mutator flow', () => {
|
||||
const baseIdResult = createBaseId('m');
|
||||
const tableNameResult = TableName.create('Duplicate Metadata');
|
||||
const primaryFieldNameResult = FieldName.create('Title');
|
||||
const sourceFieldNameResult = FieldName.create('Source');
|
||||
const duplicatedFieldIdResult = createFieldId('n');
|
||||
const duplicatedFieldNameResult = FieldName.create('Source (copy)');
|
||||
|
||||
const builder = Table.builder()
|
||||
.withBaseId(baseIdResult._unsafeUnwrap())
|
||||
.withName(tableNameResult._unsafeUnwrap());
|
||||
builder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(primaryFieldNameResult._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
builder.field().singleLineText().withName(sourceFieldNameResult._unsafeUnwrap()).done();
|
||||
builder.view().defaultGrid().done();
|
||||
const buildResult = builder.build();
|
||||
buildResult._unsafeUnwrap();
|
||||
|
||||
const table = buildResult._unsafeUnwrap();
|
||||
const sourceSpecResult = Field.specs()
|
||||
.withFieldName(sourceFieldNameResult._unsafeUnwrap())
|
||||
.build();
|
||||
sourceSpecResult._unsafeUnwrap();
|
||||
const [sourceField] = table.getFields(sourceSpecResult._unsafeUnwrap());
|
||||
expect(sourceField).toBeDefined();
|
||||
if (!sourceField) return;
|
||||
|
||||
sourceField.setDescription('copy me')._unsafeUnwrap();
|
||||
sourceField.setAiConfig({ provider: 'openai', prompt: 'metadata copy' })._unsafeUnwrap();
|
||||
sourceField.setNotNull(FieldNotNull.required())._unsafeUnwrap();
|
||||
sourceField.setUnique(FieldUnique.enabled())._unsafeUnwrap();
|
||||
sourceField
|
||||
.setDbFieldName(DbFieldName.rehydrate('fld_source_duplicate_guard')._unsafeUnwrap())
|
||||
._unsafeUnwrap();
|
||||
|
||||
const updateResult = table.update((mutator) =>
|
||||
mutator.duplicateField(
|
||||
sourceField,
|
||||
duplicatedFieldIdResult._unsafeUnwrap(),
|
||||
duplicatedFieldNameResult._unsafeUnwrap(),
|
||||
true
|
||||
)
|
||||
);
|
||||
updateResult._unsafeUnwrap();
|
||||
|
||||
const updatedTable = updateResult._unsafeUnwrap().table;
|
||||
const duplicatedFieldResult = updatedTable.getField((field) =>
|
||||
field.id().equals(duplicatedFieldIdResult._unsafeUnwrap())
|
||||
);
|
||||
duplicatedFieldResult._unsafeUnwrap();
|
||||
const duplicatedField = duplicatedFieldResult._unsafeUnwrap();
|
||||
|
||||
expect(duplicatedField.description()).toBe('copy me');
|
||||
expect(duplicatedField.aiConfig()).toEqual({ provider: 'openai', prompt: 'metadata copy' });
|
||||
expect(duplicatedField.notNull().toBoolean()).toBe(true);
|
||||
expect(duplicatedField.unique().toBoolean()).toBe(true);
|
||||
expect(duplicatedField.dbFieldName().isErr()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duplicate field when duplicated field carries dbFieldName', () => {
|
||||
const baseIdResult = createBaseId('o');
|
||||
const tableNameResult = TableName.create('Duplicate Guard');
|
||||
const primaryFieldNameResult = FieldName.create('Title');
|
||||
const sourceFieldNameResult = FieldName.create('Source');
|
||||
const duplicatedFieldIdResult = createFieldId('p');
|
||||
const duplicatedFieldNameResult = FieldName.create('Source (copy)');
|
||||
const forcedDbFieldNameResult = DbFieldName.rehydrate('fld_forced_duplicate_db_name');
|
||||
|
||||
[
|
||||
baseIdResult,
|
||||
tableNameResult,
|
||||
primaryFieldNameResult,
|
||||
sourceFieldNameResult,
|
||||
duplicatedFieldIdResult,
|
||||
duplicatedFieldNameResult,
|
||||
forcedDbFieldNameResult,
|
||||
].forEach((result) => result._unsafeUnwrap());
|
||||
|
||||
const builder = Table.builder()
|
||||
.withBaseId(baseIdResult._unsafeUnwrap())
|
||||
.withName(tableNameResult._unsafeUnwrap());
|
||||
builder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withName(primaryFieldNameResult._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
builder.field().singleLineText().withName(sourceFieldNameResult._unsafeUnwrap()).done();
|
||||
builder.view().defaultGrid().done();
|
||||
|
||||
const buildResult = builder.build();
|
||||
buildResult._unsafeUnwrap();
|
||||
const table = buildResult._unsafeUnwrap();
|
||||
|
||||
const sourceSpecResult = Field.specs()
|
||||
.withFieldName(sourceFieldNameResult._unsafeUnwrap())
|
||||
.build();
|
||||
sourceSpecResult._unsafeUnwrap();
|
||||
const [sourceField] = table.getFields(sourceSpecResult._unsafeUnwrap());
|
||||
expect(sourceField).toBeDefined();
|
||||
if (!sourceField) return;
|
||||
|
||||
const originalDuplicate = sourceField.duplicate.bind(sourceField);
|
||||
const duplicateSpy = vi
|
||||
.spyOn(sourceField, 'duplicate')
|
||||
.mockImplementation((params) =>
|
||||
originalDuplicate(params).andThen((duplicatedField) =>
|
||||
duplicatedField
|
||||
.setDbFieldName(forcedDbFieldNameResult._unsafeUnwrap())
|
||||
.map(() => duplicatedField)
|
||||
)
|
||||
);
|
||||
|
||||
const updateResult = table.update((mutator) =>
|
||||
mutator.duplicateField(
|
||||
sourceField,
|
||||
duplicatedFieldIdResult._unsafeUnwrap(),
|
||||
duplicatedFieldNameResult._unsafeUnwrap(),
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
duplicateSpy.mockRestore();
|
||||
|
||||
expect(updateResult.isErr()).toBe(true);
|
||||
if (updateResult.isErr()) {
|
||||
expect(updateResult.error.code).toBe('invariant.violation');
|
||||
expect(updateResult.error.message).toBe('Duplicated field must not carry dbFieldName');
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a field and updates view column meta', () => {
|
||||
const baseIdResult = createBaseId('x');
|
||||
const tableNameResult = TableName.create('Remove Field');
|
||||
|
||||
@ -13,6 +13,7 @@ import { DbTableName } from './DbTableName';
|
||||
import type { RecordCreateSource } from './events/RecordFieldValuesDTO';
|
||||
import { TableCreated } from './events/TableCreated';
|
||||
import { TableDeleted } from './events/TableDeleted';
|
||||
import type { DbFieldName } from './fields/DbFieldName';
|
||||
import type { Field } from './fields/Field';
|
||||
import type { FieldId } from './fields/FieldId';
|
||||
import { FieldName } from './fields/FieldName';
|
||||
@ -666,6 +667,27 @@ export class Table extends AggregateRoot<TableId> {
|
||||
return err(domainError.conflict({ message: 'Field names must be unique' }));
|
||||
}
|
||||
|
||||
const nextDbFieldNameResult = field.dbFieldName().andThen((dbFieldName) => dbFieldName.value());
|
||||
if (nextDbFieldNameResult.isOk()) {
|
||||
const hasDuplicateDbFieldName = this.fieldsValue.some((existing) => {
|
||||
const existingDbFieldNameResult = existing
|
||||
.dbFieldName()
|
||||
.andThen((dbFieldName) => dbFieldName.value());
|
||||
return (
|
||||
existingDbFieldNameResult.isOk() &&
|
||||
existingDbFieldNameResult.value === nextDbFieldNameResult.value
|
||||
);
|
||||
});
|
||||
|
||||
if (hasDuplicateDbFieldName) {
|
||||
return err(
|
||||
domainError.conflict({
|
||||
message: `Db Field name ${nextDbFieldNameResult.value} already exists in this table`,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validationResult = this.validateForeignTables([field], options?.foreignTables);
|
||||
if (validationResult.isErr()) return err(validationResult.error);
|
||||
|
||||
@ -688,7 +710,10 @@ export class Table extends AggregateRoot<TableId> {
|
||||
|
||||
return Table.rehydrate(props).andThen((nextTable) => {
|
||||
const resolved = field.type().equals(FieldType.formula())
|
||||
? resolveFormulaFields(nextTable)
|
||||
? resolveFormulaFields(nextTable, {
|
||||
ignoreMissingReferenceOnExisting: true,
|
||||
strictFieldId: field.id(),
|
||||
})
|
||||
: ok(undefined);
|
||||
if (resolved.isErr()) return err(resolved.error);
|
||||
return ok(nextTable);
|
||||
@ -870,6 +895,17 @@ export class Table extends AggregateRoot<TableId> {
|
||||
return ok(this);
|
||||
}
|
||||
|
||||
updateFieldDbFieldName(fieldId: FieldId, dbFieldName: DbFieldName): Result<Table, DomainError> {
|
||||
const fieldResult = this.getField((field) => field.id().equals(fieldId));
|
||||
if (fieldResult.isErr()) return err(fieldResult.error);
|
||||
|
||||
const field = fieldResult.value;
|
||||
const renameResult = field.renameDbFieldName(dbFieldName);
|
||||
if (renameResult.isErr()) return err(renameResult.error);
|
||||
|
||||
return ok(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a field with a new field (for type conversion).
|
||||
* The new field must have the same ID as the old field.
|
||||
@ -937,7 +973,10 @@ export class Table extends AggregateRoot<TableId> {
|
||||
|
||||
return Table.rehydrate(props).andThen((nextTable) => {
|
||||
const resolved = newField.type().equals(FieldType.formula())
|
||||
? resolveFormulaFields(nextTable)
|
||||
? resolveFormulaFields(nextTable, {
|
||||
ignoreMissingReferenceOnExisting: true,
|
||||
strictFieldId: newField.id(),
|
||||
})
|
||||
: ok(undefined);
|
||||
if (resolved.isErr()) return err(resolved.error);
|
||||
return ok(nextTable);
|
||||
|
||||
@ -6,6 +6,7 @@ import type { ISpecification } from '../shared/specification/ISpecification';
|
||||
import { SpecBuilder, type SpecBuilderMode } from '../shared/specification/SpecBuilder';
|
||||
import { Field } from './fields/Field';
|
||||
import type { FieldId } from './fields/FieldId';
|
||||
import type { FieldName } from './fields/FieldName';
|
||||
import {
|
||||
isForeignTableRelatedField,
|
||||
validateForeignTablesForFields,
|
||||
@ -21,6 +22,8 @@ import { TableUpdateViewColumnMetaSpec } from './specs/TableUpdateViewColumnMeta
|
||||
import { TableEventGeneratingSpecVisitor } from './specs/visitors/TableEventGeneratingSpecVisitor';
|
||||
import type { Table } from './Table';
|
||||
import type { TableName } from './TableName';
|
||||
import { ViewColumnMeta } from './views/ViewColumnMeta';
|
||||
import type { ViewId } from './views/ViewId';
|
||||
|
||||
class TableMutateSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, TableMutateSpecBuilder> {
|
||||
private constructor(private currentTable: Table) {
|
||||
@ -46,7 +49,13 @@ class TableMutateSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, Table
|
||||
|
||||
addField(
|
||||
field: Field,
|
||||
options?: { foreignTables?: ReadonlyArray<Table> }
|
||||
options?: {
|
||||
foreignTables?: ReadonlyArray<Table>;
|
||||
viewOrder?: {
|
||||
viewId: ViewId;
|
||||
order: number;
|
||||
};
|
||||
}
|
||||
): TableMutateSpecBuilder {
|
||||
const nextTableResult = this.currentTable.addField(field, options);
|
||||
if (nextTableResult.isErr()) {
|
||||
@ -64,10 +73,48 @@ class TableMutateSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, Table
|
||||
}
|
||||
|
||||
this.addSpec(TableAddFieldSpec.create(resolvedFieldResult.value));
|
||||
const viewSpecResult = TableUpdateViewColumnMetaSpec.fromTableWithFieldId(
|
||||
nextTableResult.value,
|
||||
field.id()
|
||||
);
|
||||
const viewSpecResult = (() => {
|
||||
if (!options?.viewOrder) {
|
||||
return TableUpdateViewColumnMetaSpec.fromTableWithFieldId(
|
||||
nextTableResult.value,
|
||||
field.id()
|
||||
);
|
||||
}
|
||||
|
||||
const viewResult = nextTableResult.value.getView(options.viewOrder.viewId);
|
||||
if (viewResult.isErr()) {
|
||||
return err(viewResult.error);
|
||||
}
|
||||
|
||||
const columnMetaResult = viewResult.value.columnMeta();
|
||||
if (columnMetaResult.isErr()) {
|
||||
return err(columnMetaResult.error);
|
||||
}
|
||||
|
||||
const fieldId = field.id();
|
||||
const fieldIdStr = fieldId.toString();
|
||||
const currentMeta = columnMetaResult.value.toDto();
|
||||
const nextMetaResult = ViewColumnMeta.create({
|
||||
...currentMeta,
|
||||
[fieldIdStr]: {
|
||||
...(currentMeta[fieldIdStr] ?? {}),
|
||||
order: options.viewOrder.order,
|
||||
},
|
||||
});
|
||||
if (nextMetaResult.isErr()) {
|
||||
return err(nextMetaResult.error);
|
||||
}
|
||||
|
||||
return ok(
|
||||
TableUpdateViewColumnMetaSpec.create([
|
||||
{
|
||||
viewId: options.viewOrder.viewId,
|
||||
fieldId,
|
||||
columnMeta: nextMetaResult.value,
|
||||
},
|
||||
])
|
||||
);
|
||||
})();
|
||||
if (viewSpecResult.isErr()) {
|
||||
this.recordError(viewSpecResult.error);
|
||||
return this;
|
||||
@ -129,9 +176,52 @@ class TableMutateSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, Table
|
||||
|
||||
duplicateField(
|
||||
sourceField: Field,
|
||||
newField: Field,
|
||||
includeRecordValues: boolean
|
||||
newFieldId: FieldId,
|
||||
newFieldName: FieldName,
|
||||
includeRecordValues: boolean,
|
||||
options?: { targetViewId?: ViewId }
|
||||
): TableMutateSpecBuilder {
|
||||
const newFieldResult = sourceField.duplicate({
|
||||
newId: newFieldId,
|
||||
newName: newFieldName,
|
||||
baseId: this.currentTable.baseId(),
|
||||
tableId: this.currentTable.id(),
|
||||
});
|
||||
if (newFieldResult.isErr()) {
|
||||
this.recordError(newFieldResult.error);
|
||||
return this;
|
||||
}
|
||||
|
||||
const newField = newFieldResult.value;
|
||||
|
||||
if (newField.dbFieldName().isOk()) {
|
||||
this.recordError(
|
||||
domainError.invariant({ message: 'Duplicated field must not carry dbFieldName' })
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
const copyDescriptionResult = newField.setDescription(sourceField.description());
|
||||
if (copyDescriptionResult.isErr()) {
|
||||
this.recordError(copyDescriptionResult.error);
|
||||
return this;
|
||||
}
|
||||
const copyAiConfigResult = newField.setAiConfig(sourceField.aiConfig());
|
||||
if (copyAiConfigResult.isErr()) {
|
||||
this.recordError(copyAiConfigResult.error);
|
||||
return this;
|
||||
}
|
||||
const copyNotNullResult = newField.setNotNull(sourceField.notNull());
|
||||
if (copyNotNullResult.isErr()) {
|
||||
this.recordError(copyNotNullResult.error);
|
||||
return this;
|
||||
}
|
||||
const copyUniqueResult = newField.setUnique(sourceField.unique());
|
||||
if (copyUniqueResult.isErr()) {
|
||||
this.recordError(copyUniqueResult.error);
|
||||
return this;
|
||||
}
|
||||
|
||||
const nextTableResult = this.currentTable.addField(newField);
|
||||
if (nextTableResult.isErr()) {
|
||||
this.recordError(nextTableResult.error);
|
||||
@ -149,10 +239,14 @@ class TableMutateSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, Table
|
||||
this.addSpec(
|
||||
TableDuplicateFieldSpec.create(sourceField, resolvedFieldResult.value, includeRecordValues)
|
||||
);
|
||||
const viewSpecResult = TableUpdateViewColumnMetaSpec.fromTableWithFieldId(
|
||||
nextTableResult.value,
|
||||
newField.id()
|
||||
);
|
||||
const viewSpecResult = options?.targetViewId
|
||||
? TableUpdateViewColumnMetaSpec.forDuplicatePlacement({
|
||||
table: nextTableResult.value,
|
||||
sourceFieldId: sourceField.id(),
|
||||
newFieldId: newField.id(),
|
||||
targetViewId: options.targetViewId,
|
||||
})
|
||||
: TableUpdateViewColumnMetaSpec.fromTableWithFieldId(nextTableResult.value, newField.id());
|
||||
if (viewSpecResult.isErr()) {
|
||||
this.recordError(viewSpecResult.error);
|
||||
return this;
|
||||
@ -275,7 +369,16 @@ export class TableMutator {
|
||||
return this;
|
||||
}
|
||||
|
||||
addField(field: Field, options?: { foreignTables?: ReadonlyArray<Table> }): TableMutator {
|
||||
addField(
|
||||
field: Field,
|
||||
options?: {
|
||||
foreignTables?: ReadonlyArray<Table>;
|
||||
viewOrder?: {
|
||||
viewId: ViewId;
|
||||
order: number;
|
||||
};
|
||||
}
|
||||
): TableMutator {
|
||||
this.builder.addField(field, options);
|
||||
this.hasUpdates = true;
|
||||
return this;
|
||||
@ -296,8 +399,20 @@ export class TableMutator {
|
||||
return this;
|
||||
}
|
||||
|
||||
duplicateField(sourceField: Field, newField: Field, includeRecordValues: boolean): TableMutator {
|
||||
this.builder.duplicateField(sourceField, newField, includeRecordValues);
|
||||
duplicateField(
|
||||
sourceField: Field,
|
||||
newFieldId: FieldId,
|
||||
newFieldName: FieldName,
|
||||
includeRecordValues: boolean,
|
||||
options?: { targetViewId?: ViewId }
|
||||
): TableMutator {
|
||||
this.builder.duplicateField(
|
||||
sourceField,
|
||||
newFieldId,
|
||||
newFieldName,
|
||||
includeRecordValues,
|
||||
options
|
||||
);
|
||||
this.hasUpdates = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -12,12 +12,18 @@ export class FieldCreated extends AbstractTableUpdatedEvent {
|
||||
private constructor(
|
||||
tableId: TableId,
|
||||
baseId: BaseId,
|
||||
readonly fieldId: FieldId
|
||||
readonly fieldId: FieldId,
|
||||
readonly viewOrders?: Readonly<Record<string, number>>
|
||||
) {
|
||||
super(tableId, baseId);
|
||||
}
|
||||
|
||||
static create(params: { tableId: TableId; baseId: BaseId; fieldId: FieldId }): FieldCreated {
|
||||
return new FieldCreated(params.tableId, params.baseId, params.fieldId);
|
||||
static create(params: {
|
||||
tableId: TableId;
|
||||
baseId: BaseId;
|
||||
fieldId: FieldId;
|
||||
viewOrders?: Readonly<Record<string, number>>;
|
||||
}): FieldCreated {
|
||||
return new FieldCreated(params.tableId, params.baseId, params.fieldId, params.viewOrders);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,8 @@ export abstract class Field extends Entity<FieldId> {
|
||||
dbFieldName?: DbFieldName,
|
||||
dependencies: ReadonlyArray<FieldId> = [],
|
||||
computed?: FieldComputed,
|
||||
description: string | null = null
|
||||
description: string | null = null,
|
||||
aiConfig?: unknown | null
|
||||
) {
|
||||
super(id);
|
||||
this.dbFieldNameValue = dbFieldName ?? DbFieldName.empty();
|
||||
@ -43,6 +44,7 @@ export abstract class Field extends Entity<FieldId> {
|
||||
this.dependenciesValue = [...dependencies];
|
||||
this.computedValue = computed ?? FieldComputed.manual();
|
||||
this.descriptionValue = description;
|
||||
this.aiConfigValue = aiConfig;
|
||||
this.hasErrorValue = FieldHasError.ok();
|
||||
this.notNullValue = FieldNotNull.optional();
|
||||
this.uniqueValue = FieldUnique.disabled();
|
||||
@ -54,6 +56,7 @@ export abstract class Field extends Entity<FieldId> {
|
||||
private dependentsValue: ReadonlyArray<FieldId> | undefined;
|
||||
private readonly computedValue: FieldComputed;
|
||||
private descriptionValue: string | null;
|
||||
private aiConfigValue: unknown | null | undefined;
|
||||
private hasErrorValue: FieldHasError;
|
||||
private notNullValue: FieldNotNull;
|
||||
private uniqueValue: FieldUnique;
|
||||
@ -78,6 +81,15 @@ export abstract class Field extends Entity<FieldId> {
|
||||
return this.descriptionValue;
|
||||
}
|
||||
|
||||
aiConfig(): unknown | null | undefined {
|
||||
return this.aiConfigValue;
|
||||
}
|
||||
|
||||
setAiConfig(aiConfig: unknown | null | undefined): Result<void, DomainError> {
|
||||
this.aiConfigValue = aiConfig;
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
setDescription(description: string | null): Result<void, DomainError> {
|
||||
if (this.descriptionValue === description) return ok(undefined);
|
||||
this.descriptionValue = description;
|
||||
@ -141,6 +153,19 @@ export abstract class Field extends Entity<FieldId> {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
renameDbFieldName(dbFieldName: DbFieldName): Result<void, DomainError> {
|
||||
const nextValue = dbFieldName.value();
|
||||
if (nextValue.isErr()) return err(nextValue.error);
|
||||
|
||||
const currentValue = this.dbFieldNameValue.value();
|
||||
if (currentValue.isErr()) {
|
||||
return err(domainError.invariant({ message: 'DbFieldName not set' }));
|
||||
}
|
||||
|
||||
this.dbFieldNameValue = dbFieldName;
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
dbFieldType(): Result<DbFieldType, DomainError> {
|
||||
const valueResult = this.dbFieldTypeValue.value();
|
||||
if (valueResult.isErr()) return err(valueResult.error);
|
||||
|
||||
@ -175,6 +175,8 @@ export const createLookupFieldPending = (params: {
|
||||
name: FieldName;
|
||||
lookupOptions: LookupOptions;
|
||||
isMultipleCellValue?: boolean;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
legacyMultiplicityDerivation?: boolean;
|
||||
dependencies?: ReadonlyArray<FieldId>;
|
||||
notNull?: FieldNotNull;
|
||||
unique?: FieldUnique;
|
||||
@ -366,6 +368,7 @@ export const createConditionalLookupField = (params: {
|
||||
conditionalLookupOptions: ConditionalLookupOptions;
|
||||
dependencies?: ReadonlyArray<FieldId>;
|
||||
isMultipleCellValue?: boolean;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
notNull?: FieldNotNull;
|
||||
unique?: FieldUnique;
|
||||
}): Result<Field, DomainError> =>
|
||||
@ -376,6 +379,7 @@ export const createConditionalLookupFieldPending = (params: {
|
||||
name: FieldName;
|
||||
conditionalLookupOptions: ConditionalLookupOptions;
|
||||
dependencies?: ReadonlyArray<FieldId>;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
notNull?: FieldNotNull;
|
||||
unique?: FieldUnique;
|
||||
}): Result<Field, DomainError> =>
|
||||
|
||||
@ -77,7 +77,7 @@ export class ButtonField extends Field {
|
||||
color: this.color(),
|
||||
maxCount: this.maxCount(),
|
||||
resetCount: this.resetCount(),
|
||||
workflow: this.workflow(),
|
||||
workflow: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,50 @@ const createConditionalLookupField = (statusFieldId: FieldId) => {
|
||||
};
|
||||
|
||||
describe('ConditionalLookupField.onDependencyUpdated', () => {
|
||||
it('preserves inner options patch when duplicated', () => {
|
||||
const statusFieldId = createFieldId('z');
|
||||
const field = ConditionalLookupField.create({
|
||||
id: createFieldId('y'),
|
||||
name: FieldName.create('Conditional Lookup')._unsafeUnwrap(),
|
||||
innerField: SingleLineTextField.create({
|
||||
id: createFieldId('x'),
|
||||
name: FieldName.create('Title')._unsafeUnwrap(),
|
||||
})._unsafeUnwrap(),
|
||||
conditionalLookupOptions: ConditionalLookupOptions.create({
|
||||
foreignTableId: createTableId('w').toString(),
|
||||
lookupFieldId: createFieldId('v').toString(),
|
||||
condition: {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: statusFieldId.toString(), operator: 'is', value: 'Active' }],
|
||||
},
|
||||
},
|
||||
})._unsafeUnwrap(),
|
||||
innerOptionsPatch: {
|
||||
formatting: {
|
||||
type: 'currency',
|
||||
precision: 1,
|
||||
symbol: '¥',
|
||||
},
|
||||
},
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const duplicated = field
|
||||
.duplicate({
|
||||
newId: createFieldId('u'),
|
||||
newName: FieldName.create('Conditional Lookup Copy')._unsafeUnwrap(),
|
||||
})
|
||||
._unsafeUnwrap() as ConditionalLookupField;
|
||||
|
||||
expect(duplicated.innerOptionsPatch()).toEqual({
|
||||
formatting: {
|
||||
type: 'currency',
|
||||
precision: 1,
|
||||
symbol: '¥',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks hasError when referenced field is type-converted', () => {
|
||||
const statusFieldId = createFieldId('a');
|
||||
const conditionalLookup = createConditionalLookupField(statusFieldId);
|
||||
|
||||
@ -62,6 +62,7 @@ export class ConditionalLookupField
|
||||
implements ForeignTableRelatedField, OnTeableFieldUpdated
|
||||
{
|
||||
private innerFieldValue: Field | undefined;
|
||||
private readonly innerOptionsPatchValue: Readonly<Record<string, unknown>> | undefined;
|
||||
/**
|
||||
* Override for isMultipleCellValue. When set, this value is used instead of
|
||||
* defaulting to multiple. This is important for compatibility with v1.
|
||||
@ -75,7 +76,8 @@ export class ConditionalLookupField
|
||||
private readonly conditionalLookupOptionsValue: ConditionalLookupOptions,
|
||||
dbFieldName?: DbFieldName,
|
||||
dependencies?: ReadonlyArray<FieldId>,
|
||||
isMultipleCellValue?: boolean
|
||||
isMultipleCellValue?: boolean,
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@ -87,6 +89,7 @@ export class ConditionalLookupField
|
||||
);
|
||||
this.innerFieldValue = innerField;
|
||||
this.isMultipleCellValueOverride = isMultipleCellValue;
|
||||
this.innerOptionsPatchValue = innerOptionsPatch ? { ...innerOptionsPatch } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,6 +103,7 @@ export class ConditionalLookupField
|
||||
dbFieldName?: DbFieldName;
|
||||
dependencies?: ReadonlyArray<FieldId>;
|
||||
isMultipleCellValue?: boolean;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
}): Result<ConditionalLookupField, DomainError> {
|
||||
return ok(
|
||||
new ConditionalLookupField(
|
||||
@ -109,7 +113,8 @@ export class ConditionalLookupField
|
||||
params.conditionalLookupOptions,
|
||||
params.dbFieldName,
|
||||
params.dependencies,
|
||||
params.isMultipleCellValue
|
||||
params.isMultipleCellValue,
|
||||
params.innerOptionsPatch
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -125,6 +130,7 @@ export class ConditionalLookupField
|
||||
dbFieldName?: DbFieldName;
|
||||
dependencies?: ReadonlyArray<FieldId>;
|
||||
isMultipleCellValue?: boolean;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
}): Result<ConditionalLookupField, DomainError> {
|
||||
return ok(
|
||||
new ConditionalLookupField(
|
||||
@ -134,7 +140,8 @@ export class ConditionalLookupField
|
||||
params.conditionalLookupOptions,
|
||||
params.dbFieldName,
|
||||
params.dependencies,
|
||||
params.isMultipleCellValue
|
||||
params.isMultipleCellValue,
|
||||
params.innerOptionsPatch
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -150,6 +157,7 @@ export class ConditionalLookupField
|
||||
dbFieldName?: DbFieldName;
|
||||
dependencies?: ReadonlyArray<FieldId>;
|
||||
isMultipleCellValue?: boolean;
|
||||
innerOptionsPatch?: Readonly<Record<string, unknown>>;
|
||||
}): Result<ConditionalLookupField, DomainError> {
|
||||
return ConditionalLookupField.create(params);
|
||||
}
|
||||
@ -195,6 +203,10 @@ export class ConditionalLookupField
|
||||
return this.conditionalLookupOptionsValue.toDto();
|
||||
}
|
||||
|
||||
innerOptionsPatch(): Readonly<Record<string, unknown>> | undefined {
|
||||
return this.innerOptionsPatchValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the field being looked up in the foreign table.
|
||||
*/
|
||||
@ -340,6 +352,7 @@ export class ConditionalLookupField
|
||||
conditionalLookupOptions: this.conditionalLookupOptions(),
|
||||
isMultipleCellValue,
|
||||
dependencies: this.dependencies(),
|
||||
innerOptionsPatch: this.innerOptionsPatchValue,
|
||||
});
|
||||
}
|
||||
|
||||
@ -349,6 +362,7 @@ export class ConditionalLookupField
|
||||
conditionalLookupOptions: this.conditionalLookupOptions(),
|
||||
isMultipleCellValue,
|
||||
dependencies: this.dependencies(),
|
||||
innerOptionsPatch: this.innerOptionsPatchValue,
|
||||
});
|
||||
}
|
||||
|
||||
@ -436,6 +450,7 @@ export class ConditionalLookupField
|
||||
conditionalLookupOptions: nextOptionsResult.value,
|
||||
isMultipleCellValue: multiplicityResult.value.isMultiple(),
|
||||
dependencies: this.dependencies(),
|
||||
innerOptionsPatch: this.innerOptionsPatchValue,
|
||||
})
|
||||
)
|
||||
.orElse(() =>
|
||||
@ -445,6 +460,7 @@ export class ConditionalLookupField
|
||||
conditionalLookupOptions: nextOptionsResult.value,
|
||||
isMultipleCellValue: multiplicityResult.value.isMultiple(),
|
||||
dependencies: this.dependencies(),
|
||||
innerOptionsPatch: this.innerOptionsPatchValue,
|
||||
})
|
||||
);
|
||||
if (nextFieldResult.isErr()) {
|
||||
|
||||
@ -14,13 +14,6 @@ import type { FieldDuplicateParams } from '../Field';
|
||||
import type { FieldId } from '../FieldId';
|
||||
import type { FieldName } from '../FieldName';
|
||||
import { FieldType } from '../FieldType';
|
||||
import type {
|
||||
ForeignTableRelatedField,
|
||||
ForeignTableValidationContext,
|
||||
} from '../ForeignTableRelatedField';
|
||||
import type { FieldUpdateContext, OnTeableFieldUpdated } from '../OnTeableFieldUpdated';
|
||||
import { FieldValueTypeVisitor } from '../visitors/FieldValueTypeVisitor';
|
||||
import type { IFieldVisitor } from '../visitors/IFieldVisitor';
|
||||
import {
|
||||
buildFieldFilterSyncPlan,
|
||||
hasFieldReferenceInFilter,
|
||||
@ -28,7 +21,14 @@ import {
|
||||
isEquivalentFilter,
|
||||
syncFilterByFieldChanges,
|
||||
} from '../filter-sync';
|
||||
import type { CellValueMultiplicity } from './CellValueMultiplicity';
|
||||
import type {
|
||||
ForeignTableRelatedField,
|
||||
ForeignTableValidationContext,
|
||||
} from '../ForeignTableRelatedField';
|
||||
import type { FieldUpdateContext, OnTeableFieldUpdated } from '../OnTeableFieldUpdated';
|
||||
import { FieldValueTypeVisitor } from '../visitors/FieldValueTypeVisitor';
|
||||
import type { IFieldVisitor } from '../visitors/IFieldVisitor';
|
||||
import { CellValueMultiplicity } from './CellValueMultiplicity';
|
||||
import { CellValueType } from './CellValueType';
|
||||
import {
|
||||
ConditionalRollupConfig,
|
||||
@ -37,6 +37,7 @@ import {
|
||||
import type { DateTimeFormatting } from './DateTimeFormatting';
|
||||
import { DateTimeFormatting as DateTimeFormattingValue } from './DateTimeFormatting';
|
||||
import { FieldComputed } from './FieldComputed';
|
||||
import { FieldHasError } from './FieldHasError';
|
||||
import { NumberFormatting as NumberFormattingValue } from './NumberFormatting';
|
||||
import type { NumberFormatting } from './NumberFormatting';
|
||||
import { NumberShowAs as NumberShowAsValue } from './NumberShowAs';
|
||||
@ -319,16 +320,24 @@ export class ConditionalRollupField
|
||||
}
|
||||
|
||||
cellValueType(): Result<CellValueType, DomainError> {
|
||||
if (!this.cellValueTypeValue)
|
||||
if (!this.cellValueTypeValue) {
|
||||
if (this.hasError().isError()) {
|
||||
return ok(CellValueType.string());
|
||||
}
|
||||
return err(
|
||||
domainError.invariant({ message: 'ConditionalRollupField cell value type not set' })
|
||||
);
|
||||
}
|
||||
return ok(this.cellValueTypeValue);
|
||||
}
|
||||
|
||||
isMultipleCellValue(): Result<CellValueMultiplicity, DomainError> {
|
||||
if (!this.isMultipleCellValueValue)
|
||||
if (!this.isMultipleCellValueValue) {
|
||||
if (this.hasError().isError()) {
|
||||
return ok(CellValueMultiplicity.single());
|
||||
}
|
||||
return err(domainError.invariant({ message: 'ConditionalRollupField multiplicity not set' }));
|
||||
}
|
||||
return ok(this.isMultipleCellValueValue);
|
||||
}
|
||||
|
||||
@ -405,7 +414,9 @@ export class ConditionalRollupField
|
||||
cellValueType: valuesTypeResult.value.cellValueType,
|
||||
isMultipleCellValue: valuesTypeResult.value.isMultipleCellValue,
|
||||
});
|
||||
if (resolveResult.isErr()) return err(resolveResult.error);
|
||||
if (resolveResult.isErr()) {
|
||||
this.setHasError(FieldHasError.error());
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies include host fields referenced by condition value expressions.
|
||||
|
||||
@ -503,15 +503,28 @@ export class FieldCondition extends ValueObject {
|
||||
);
|
||||
}
|
||||
|
||||
const fieldIdResult = FieldId.create(filterItemEntry.fieldId);
|
||||
const isFieldRefObject =
|
||||
typeof filterItemEntry.value === 'object' &&
|
||||
filterItemEntry.value !== null &&
|
||||
'type' in filterItemEntry.value &&
|
||||
(filterItemEntry.value as { type?: string }).type === 'field' &&
|
||||
'fieldId' in filterItemEntry.value;
|
||||
const isSelfTableReference =
|
||||
isFieldRefObject && hostTable !== undefined && hostTable.id().equals(table.id());
|
||||
const effectiveFieldIdValue =
|
||||
isSelfTableReference && isFieldRefObject
|
||||
? (filterItemEntry.value as { fieldId: string }).fieldId
|
||||
: filterItemEntry.fieldId;
|
||||
|
||||
const fieldIdResult = FieldId.create(effectiveFieldIdValue);
|
||||
if (fieldIdResult.isErr()) return err(fieldIdResult.error);
|
||||
const field = fields.find((f) => f.id().equals(fieldIdResult.value));
|
||||
if (!field) {
|
||||
return err(
|
||||
domainError.notFound({
|
||||
code: 'field.condition.field_not_found',
|
||||
message: `Field not found: ${filterItemEntry.fieldId}`,
|
||||
details: { fieldId: filterItemEntry.fieldId },
|
||||
message: `Field not found: ${effectiveFieldIdValue}`,
|
||||
details: { fieldId: effectiveFieldIdValue },
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -520,18 +533,12 @@ export class FieldCondition extends ValueObject {
|
||||
// `value: null` is commonly used by v1-style filters for operators that don't require a value
|
||||
// (e.g. `isEmpty`, `isNotEmpty`). Treat null the same as "not provided".
|
||||
if (filterItemEntry.value !== undefined && filterItemEntry.value !== null) {
|
||||
// Check if value is a field reference object: { type: 'field', fieldId: ... }
|
||||
const isFieldRefObject =
|
||||
typeof filterItemEntry.value === 'object' &&
|
||||
filterItemEntry.value !== null &&
|
||||
'type' in filterItemEntry.value &&
|
||||
(filterItemEntry.value as { type?: string }).type === 'field' &&
|
||||
'fieldId' in filterItemEntry.value;
|
||||
|
||||
if (filterItemEntry.isSymbol || isFieldRefObject) {
|
||||
// Field reference - resolve from host table if provided, otherwise from main table
|
||||
const refFieldIdValue = isFieldRefObject
|
||||
? (filterItemEntry.value as { fieldId: string }).fieldId
|
||||
? isSelfTableReference
|
||||
? filterItemEntry.fieldId
|
||||
: (filterItemEntry.value as { fieldId: string }).fieldId
|
||||
: String(filterItemEntry.value);
|
||||
const refFieldId = yield* FieldId.create(refFieldIdValue);
|
||||
const refField = hostFields.find((f) => f.id().equals(refFieldId));
|
||||
|
||||
@ -4,15 +4,15 @@ import { describe, expect, it } from 'vitest';
|
||||
import { BaseId } from '../../../base/BaseId';
|
||||
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 { Table } from '../../Table';
|
||||
import { TableId } from '../../TableId';
|
||||
import { TableName } from '../../TableName';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import { ViewId } from '../../views/ViewId';
|
||||
import { DbFieldName } from '../DbFieldName';
|
||||
import { FieldId } from '../FieldId';
|
||||
import { FieldName } from '../FieldName';
|
||||
import { UpdateLinkConfigSpec } from '../../specs/field-updates/UpdateLinkConfigSpec';
|
||||
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
|
||||
import { LinkField } from './LinkField';
|
||||
import { LinkFieldConfig } from './LinkFieldConfig';
|
||||
import { LinkFieldMeta } from './LinkFieldMeta';
|
||||
@ -437,6 +437,71 @@ describe('LinkField', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ensures symmetricFieldId even when db config already exists', () => {
|
||||
const baseId = createBaseId('g')._unsafeUnwrap();
|
||||
const hostTableId = createTableId('h')._unsafeUnwrap();
|
||||
const foreignTableId = createTableId('i')._unsafeUnwrap();
|
||||
const lookupFieldId = createFieldId('j')._unsafeUnwrap();
|
||||
const linkFieldId = createFieldId('k')._unsafeUnwrap();
|
||||
const linkFieldName = FieldName.create('Link')._unsafeUnwrap();
|
||||
|
||||
const field = LinkFieldConfig.create({
|
||||
relationship: LinkRelationship.manyOne().toString(),
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
fkHostTableName: `${baseId.toString()}.${hostTableId.toString()}`,
|
||||
selfKeyName: '__id',
|
||||
foreignKeyName: `__fk_${linkFieldId.toString()}`,
|
||||
})
|
||||
.andThen((config) =>
|
||||
LinkField.create({
|
||||
id: linkFieldId,
|
||||
name: linkFieldName,
|
||||
config,
|
||||
})
|
||||
)
|
||||
._unsafeUnwrap();
|
||||
|
||||
field.ensureDbConfig({ baseId, hostTableId })._unsafeUnwrap();
|
||||
|
||||
const symmetricFieldId = field.symmetricFieldId();
|
||||
expect(symmetricFieldId).toBeDefined();
|
||||
expect(symmetricFieldId?.equals(linkFieldId)).toBe(false);
|
||||
|
||||
expect(field.fkHostTableNameString()._unsafeUnwrap()).toBe(
|
||||
`${baseId.toString()}.${hostTableId.toString()}`
|
||||
);
|
||||
expect(field.selfKeyNameString()._unsafeUnwrap()).toBe('__id');
|
||||
expect(field.foreignKeyNameString()._unsafeUnwrap()).toBe(`__fk_${linkFieldId.toString()}`);
|
||||
});
|
||||
|
||||
it('normalizes same-base baseId when creating a link field', () => {
|
||||
const baseId = createBaseId('w')._unsafeUnwrap();
|
||||
const hostTableId = createTableId('x')._unsafeUnwrap();
|
||||
const foreignTableId = createTableId('y')._unsafeUnwrap();
|
||||
const lookupFieldId = createFieldId('z')._unsafeUnwrap();
|
||||
const linkFieldId = createFieldId('a')._unsafeUnwrap();
|
||||
const linkFieldName = FieldName.create('Link')._unsafeUnwrap();
|
||||
|
||||
const config = LinkFieldConfig.create({
|
||||
baseId: baseId.toString(),
|
||||
relationship: LinkRelationship.manyOne().toString(),
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: lookupFieldId.toString(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const field = LinkField.createNew({
|
||||
id: linkFieldId,
|
||||
name: linkFieldName,
|
||||
config,
|
||||
baseId,
|
||||
hostTableId,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
expect(field.baseId()).toBeUndefined();
|
||||
expect(field.isCrossBase()).toBe(false);
|
||||
});
|
||||
|
||||
it('builds symmetric fields and swaps db config', () => {
|
||||
const baseIdResult = createBaseId('1');
|
||||
const hostTableIdResult = createTableId('2');
|
||||
@ -518,6 +583,7 @@ describe('LinkField', () => {
|
||||
expect(symmetric.foreignTableId().equals(hostTableId)).toBe(true);
|
||||
expect(symmetric.lookupFieldId().equals(hostPrimaryId)).toBe(true);
|
||||
expect(symmetric.symmetricFieldId()?.equals(linkFieldId)).toBe(true);
|
||||
expect(symmetric.baseId()).toBeUndefined();
|
||||
expect(symmetric.meta()?.hasOrderColumn()).toBe(true);
|
||||
expect(symmetric.name().toString()).toBe('Host');
|
||||
|
||||
@ -527,6 +593,68 @@ describe('LinkField', () => {
|
||||
expect(symmetricForeignKey._unsafeUnwrap()).toBe('__id');
|
||||
});
|
||||
|
||||
it('sets symmetric baseId to host base for cross-base links', () => {
|
||||
const hostBaseId = createBaseId('h')._unsafeUnwrap();
|
||||
const foreignBaseId = createBaseId('i')._unsafeUnwrap();
|
||||
const hostTableId = createTableId('j')._unsafeUnwrap();
|
||||
const foreignTableId = createTableId('k')._unsafeUnwrap();
|
||||
const hostPrimaryId = createFieldId('l')._unsafeUnwrap();
|
||||
const foreignPrimaryId = createFieldId('m')._unsafeUnwrap();
|
||||
const linkFieldId = createFieldId('n')._unsafeUnwrap();
|
||||
|
||||
const hostBuilder = Table.builder()
|
||||
.withId(hostTableId)
|
||||
.withBaseId(hostBaseId)
|
||||
.withName(TableName.create('Cross Host')._unsafeUnwrap());
|
||||
hostBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(hostPrimaryId)
|
||||
.withName(FieldName.create('Host Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
hostBuilder.view().defaultGrid().done();
|
||||
const hostTable = hostBuilder.build()._unsafeUnwrap();
|
||||
|
||||
const foreignBuilder = Table.builder()
|
||||
.withId(foreignTableId)
|
||||
.withBaseId(foreignBaseId)
|
||||
.withName(TableName.create('Cross Foreign')._unsafeUnwrap());
|
||||
foreignBuilder
|
||||
.field()
|
||||
.singleLineText()
|
||||
.withId(foreignPrimaryId)
|
||||
.withName(FieldName.create('Foreign Name')._unsafeUnwrap())
|
||||
.primary()
|
||||
.done();
|
||||
foreignBuilder.view().defaultGrid().done();
|
||||
const foreignTable = ForeignTable.from(foreignBuilder.build()._unsafeUnwrap());
|
||||
|
||||
const config = LinkFieldConfig.create({
|
||||
baseId: foreignBaseId.toString(),
|
||||
relationship: 'manyOne',
|
||||
foreignTableId: foreignTableId.toString(),
|
||||
lookupFieldId: foreignPrimaryId.toString(),
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const linkField = LinkField.createNew({
|
||||
id: linkFieldId,
|
||||
name: FieldName.create('Cross Link')._unsafeUnwrap(),
|
||||
config,
|
||||
baseId: hostBaseId,
|
||||
hostTableId,
|
||||
})._unsafeUnwrap();
|
||||
|
||||
const symmetric = linkField
|
||||
.buildSymmetricField({
|
||||
foreignTable,
|
||||
hostTable,
|
||||
})
|
||||
._unsafeUnwrap();
|
||||
|
||||
expect(symmetric.baseId()?.equals(hostBaseId)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects symmetric build for one-way links', () => {
|
||||
const baseIdResult = createBaseId('7');
|
||||
const foreignTableIdResult = createTableId('8');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user