Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { FieldKeyType, HttpErrorCode } from '@teable/core';
import { describe, expect, it, vi } from 'vitest';
import { CustomHttpException } from '../../../custom.exception';
import { RecordModifySharedService } from './record-modify.shared.service';

vi.mock('@teable/db-main-prisma', () => ({
PrismaService: class PrismaService {},
PrismaModule: class PrismaModule {},
}));

vi.mock('@prisma/client', () => ({
Prisma: {},
PrismaClient: class PrismaClient {},
}));

describe('RecordModifySharedService', () => {
const createService = () =>
new RecordModifySharedService(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);

it('includes available field keys when create-record input references missing field names', () => {
const service = createService();
const getEffectFieldInstances = (
service as unknown as {
getEffectFieldInstances: (
table: {
getFieldsMap: (fieldKeyType: FieldKeyType) => Map<string, { id: string; name: string }>;
},
recordsFields: Record<string, unknown>[],
fieldKeyType: FieldKeyType,
ignoreMissingFields?: boolean
) => unknown;
}
).getEffectFieldInstances.bind(service);

const table = {
getFieldsMap: (fieldKeyType: FieldKeyType) => {
expect(fieldKeyType).toBe(FieldKeyType.Name);
return new Map([
['Name', { id: 'fldName', name: 'Name' }],
['Status', { id: 'fldStatus', name: 'Status' }],
]);
},
};

try {
getEffectFieldInstances(
table,
[{ Name: 'Task A', 'Source ID 2': 'source-1' }],
FieldKeyType.Name
);
expect.unreachable('Expected getEffectFieldInstances to throw');
} catch (error) {
expect(error).toBeInstanceOf(CustomHttpException);

const httpError = error as CustomHttpException;
expect(httpError.code).toBe(HttpErrorCode.NOT_FOUND);
expect(httpError.message).toBe('Field "Source ID 2" does not exist in this table');
expect(httpError.data).toMatchObject({
fieldKeyType: FieldKeyType.Name,
missedFields: ['Source ID 2'],
availableFieldKeys: ['Name', 'Status'],
});
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export class RecordModifySharedService {
private readonly dataLoaderService: DataLoaderService
) {}

private buildMissingFieldsMessage(missedFields: string[]): string {
if (missedFields.length === 1) {
return `Field "${missedFields[0]}" does not exist in this table`;
}

return `Fields ${missedFields.map((field) => `"${field}"`).join(', ')} do not exist in this table`;
}

// Shared change compression and filtering utilities
compressAndFilterChanges(table: TableDomain, cellContexts: ICellContext[]): ICellChange[] {
if (!cellContexts.length) return [];
Expand Down Expand Up @@ -95,6 +103,7 @@ export class RecordModifySharedService {

const usedFieldIdsOrNames = Array.from(fieldIdsOrNamesSet);
const fieldsMap = table.getFieldsMap(fieldKeyType);
const availableFieldKeys = Array.from(fieldsMap.keys());

const usedFields = usedFieldIdsOrNames
.map((fieldIdOrName) => fieldsMap.get(fieldIdOrName))
Expand All @@ -106,9 +115,12 @@ export class RecordModifySharedService {
(fieldIdOrName) => !usedSet.has(fieldIdOrName)
);
throw new CustomHttpException(
`Field ${fieldKeyType}: ${missedFields.join(', ')} not found`,
this.buildMissingFieldsMessage(missedFields),
HttpErrorCode.NOT_FOUND,
{
fieldKeyType,
missedFields,
availableFieldKeys,
localization: {
i18nKey: 'httpErrors.field.fieldKeyTypeNotFound',
context: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';

import { BaseId } from '../../domain/base/BaseId';
import { FieldName } from '../../domain/table/fields/FieldName';
import { FieldId } from '../../domain/table/fields/FieldId';
import { FieldKeyType } from '../../domain/table/fields/FieldKeyType';
import { Table } from '../../domain/table/Table';
import { TableId } from '../../domain/table/TableId';
import { TableName } from '../../domain/table/TableName';
import { FieldKeyResolverService } from './FieldKeyResolverService';

const createTable = () => {
const builder = Table.builder()
.withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap())
.withBaseId(BaseId.create(`bse${'b'.repeat(16)}`)._unsafeUnwrap())
.withName(TableName.create('Resolver Test Table')._unsafeUnwrap());

builder
.field()
.singleLineText()
.withId(FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap())
.withName(FieldName.create('Name')._unsafeUnwrap())
.primary()
.done();

builder
.field()
.number()
.withId(FieldId.create(`fld${'d'.repeat(16)}`)._unsafeUnwrap())
.withName(FieldName.create('Amount')._unsafeUnwrap())
.done();

builder.view().defaultGrid().done();

return builder.build()._unsafeUnwrap();
};

describe('FieldKeyResolverService', () => {
it('returns a domain error with available field keys for missing field names', () => {
const table = createTable();
const result = FieldKeyResolverService.resolveFieldKeys(
table,
{ 'Source ID 2': 'src-1' },
FieldKeyType.Name
);

expect(result.isErr()).toBe(true);

if (result.isOk()) {
expect.unreachable('Expected resolveFieldKeys to fail');
}

expect(result.error.code).toBe('field.key_not_found');
expect(result.error.tags).toContain('not-found');
expect(result.error.message).toBe('Field "Source ID 2" does not exist in this table');
expect(result.error.details).toEqual({
fieldKeyType: FieldKeyType.Name,
fieldKey: 'Source ID 2',
availableFieldKeys: ['Name', 'Amount'],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ import type { Table } from '../../domain/table/Table';
* - After resolution, only field IDs are used in domain/repository layers
*/
export class FieldKeyResolverService {
private static buildFieldNotFoundError(
fieldKeyType: FieldKeyType,
fieldKey: string,
availableFieldKeys: ReadonlyArray<string>
): DomainError {
return domainError.notFound({
code: 'field.key_not_found',
message: `Field "${fieldKey}" does not exist in this table`,
details: { fieldKeyType, fieldKey, availableFieldKeys },
});
}

/**
* Resolve field keys in a record's fields object to field IDs
*
Expand All @@ -37,17 +49,13 @@ export class FieldKeyResolverService {

const resolvedFields: Record<string, unknown> = {};
const fieldMap = this.buildFieldMap(table, fieldKeyType);
const availableFieldKeys = Array.from(fieldMap.keys());

for (const [key, value] of Object.entries(normalizedFields)) {
const fieldId = fieldMap.get(key);

if (!fieldId) {
return err(
domainError.notFound({
message: `Field not found: ${key}`,
details: { fieldKeyType, fieldKey: key },
})
);
return err(this.buildFieldNotFoundError(fieldKeyType, key, availableFieldKeys));
}

resolvedFields[fieldId] = value;
Expand Down Expand Up @@ -113,15 +121,11 @@ export class FieldKeyResolverService {
}

const fieldMap = this.buildFieldMap(table, fieldKeyType);
const availableFieldKeys = Array.from(fieldMap.keys());
const fieldId = fieldMap.get(fieldKey);

if (!fieldId) {
return err(
domainError.notFound({
message: `Field not found: ${fieldKey}`,
details: { fieldKeyType, fieldKey },
})
);
return err(this.buildFieldNotFoundError(fieldKeyType, fieldKey, availableFieldKeys));
}

return ok(fieldId);
Expand Down
Loading