fix(ai): MCPCore findRecord tool (#14033)
Fix #13545 --------- Co-authored-by: lhdu <lhdu@bigquant.ai> Co-authored-by: huxianc <47603705+huxianc@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-ada
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
@@ -28,6 +29,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
TypeOrmModule.forFeature([RoleEntity], 'core'),
|
||||
TokenModule,
|
||||
FeatureFlagModule,
|
||||
RecordTransformerModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
|
||||
+2
-3
@@ -3,9 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
import { AIBillingService } from './ai-billing.service';
|
||||
import { AiModelRegistryService } from './ai-model-registry.service';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
|
||||
describe('AIBillingService', () => {
|
||||
let service: AIBillingService;
|
||||
+1
-2
@@ -11,8 +11,7 @@ import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { McpService } from './mcp.service';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
|
||||
describe('McpService', () => {
|
||||
let service: McpService;
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
|
||||
const createMockToolRegistry = () => ({
|
||||
getAllToolTypes: jest.fn(),
|
||||
getTool: jest.fn(),
|
||||
});
|
||||
|
||||
const createMockPermissions = () => ({
|
||||
hasToolPermission: jest.fn<
|
||||
Promise<boolean>,
|
||||
[string, string, PermissionFlagType]
|
||||
>(),
|
||||
});
|
||||
|
||||
describe('ToolAdapterService', () => {
|
||||
let mockRegistry: ReturnType<typeof createMockToolRegistry>;
|
||||
let mockPermissions: ReturnType<typeof createMockPermissions>;
|
||||
let service: ToolAdapterService;
|
||||
|
||||
// Shared tools
|
||||
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
result: { echoed: input },
|
||||
}));
|
||||
const unflaggedTool: Tool = {
|
||||
description: 'HTTP Request tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: unflaggedToolExecute,
|
||||
};
|
||||
|
||||
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
result: { sent: input },
|
||||
}));
|
||||
const flaggedTool: Tool = {
|
||||
description: 'Send Email tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: flaggedToolExecute,
|
||||
flag: PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockRegistry = createMockToolRegistry();
|
||||
mockPermissions = createMockPermissions();
|
||||
|
||||
// Setup mock tool responses
|
||||
mockRegistry.getAllToolTypes.mockReturnValue([
|
||||
ToolType.HTTP_REQUEST,
|
||||
ToolType.SEND_EMAIL,
|
||||
]);
|
||||
mockRegistry.getTool.mockImplementation((type: ToolType) => {
|
||||
if (type === ToolType.HTTP_REQUEST) return unflaggedTool;
|
||||
if (type === ToolType.SEND_EMAIL) return flaggedTool;
|
||||
throw new Error('Tool not found in mock');
|
||||
});
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ToolAdapterService,
|
||||
{
|
||||
provide: ToolRegistryService,
|
||||
useValue: mockRegistry,
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: mockPermissions,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ToolAdapterService);
|
||||
});
|
||||
|
||||
it('should include unflagged tools regardless of role/workspace', async () => {
|
||||
const toolsNoContext = await service.getTools();
|
||||
|
||||
expect(Object.keys(toolsNoContext)).toContain('http_request');
|
||||
|
||||
const toolsWithPartialContext = await service.getTools('role-1');
|
||||
|
||||
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
|
||||
});
|
||||
|
||||
it('should not include flagged tools when role/workspace are missing', async () => {
|
||||
const toolsNoContext = await service.getTools();
|
||||
|
||||
expect(Object.keys(toolsNoContext)).not.toContain('send_email');
|
||||
|
||||
const toolsRoleOnly = await service.getTools('role-1');
|
||||
|
||||
expect(Object.keys(toolsRoleOnly)).not.toContain('send_email');
|
||||
|
||||
const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1');
|
||||
|
||||
expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email');
|
||||
});
|
||||
|
||||
it('should include flagged tools when permission is granted', async () => {
|
||||
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
|
||||
|
||||
const tools = await service.getTools('role-1', 'ws-1');
|
||||
|
||||
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
|
||||
'role-1',
|
||||
'ws-1',
|
||||
PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
);
|
||||
|
||||
expect(Object.keys(tools)).toContain('send_email');
|
||||
});
|
||||
|
||||
it('should exclude flagged tools when permission is denied', async () => {
|
||||
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
|
||||
|
||||
const tools = await service.getTools('role-1', 'ws-1');
|
||||
|
||||
expect(Object.keys(tools)).not.toContain('send_email');
|
||||
});
|
||||
|
||||
it('should lowercase tool type keys in the returned ToolSet', async () => {
|
||||
const tools = await service.getTools();
|
||||
|
||||
const keys = Object.keys(tools);
|
||||
|
||||
expect(keys).toContain('http_request');
|
||||
expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is
|
||||
});
|
||||
|
||||
it('should forward execute input correctly and return underlying result', async () => {
|
||||
const tools = await service.getTools();
|
||||
|
||||
const input = { url: 'https://example.com', method: 'GET' } as ToolInput;
|
||||
const result = await tools['http_request'].execute?.(
|
||||
{ input },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'content',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure wrapper forwards only parameters.input
|
||||
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
|
||||
expect(result).toEqual({ result: { echoed: input } });
|
||||
});
|
||||
});
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
|
||||
|
||||
// Minimal mock repository type
|
||||
const createMockRepository = () => ({
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
});
|
||||
|
||||
describe('ToolService', () => {
|
||||
const workspaceId = 'ws_1';
|
||||
const roleId = 'role_1';
|
||||
|
||||
let service: ToolService;
|
||||
let ormManager: TwentyORMGlobalManager;
|
||||
let permissionsCacheService: WorkspacePermissionsCacheService;
|
||||
let transformer: RecordInputTransformerService;
|
||||
let workspaceCache: WorkspaceCacheStorageService;
|
||||
|
||||
const testObject = getMockObjectMetadataEntity({
|
||||
workspaceId: '',
|
||||
id: 'obj_1',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const mockRepo = createMockRepository();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ToolService,
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ObjectMetadataService,
|
||||
useValue: {
|
||||
findManyWithinWorkspace: jest.fn().mockResolvedValue([testObject]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspacePermissionsCacheService,
|
||||
useValue: {
|
||||
getRolesPermissionsFromCache: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
[roleId]: {
|
||||
[testObject.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RecordInputTransformerService,
|
||||
useValue: {
|
||||
process: jest.fn(async ({ recordInput }) => recordInput),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: {
|
||||
getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({
|
||||
byId: {
|
||||
[testObject.id]: {
|
||||
...testObject,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
indexMetadatas: [],
|
||||
},
|
||||
},
|
||||
idByNameSingular: { [testObject.nameSingular]: testObject.id },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ToolService);
|
||||
ormManager = moduleRef.get(TwentyORMGlobalManager);
|
||||
permissionsCacheService = moduleRef.get(WorkspacePermissionsCacheService);
|
||||
transformer = moduleRef.get(RecordInputTransformerService);
|
||||
workspaceCache = moduleRef.get(WorkspaceCacheStorageService);
|
||||
});
|
||||
|
||||
describe('listTools', () => {
|
||||
it('should return tools based on role permissions', async () => {
|
||||
const tools = await service.listTools(roleId, workspaceId);
|
||||
|
||||
expect(
|
||||
permissionsCacheService.getRolesPermissionsFromCache,
|
||||
).toHaveBeenCalledWith({ workspaceId });
|
||||
|
||||
// Verify tool keys
|
||||
expect(tools['create_testObject']).toBeDefined();
|
||||
expect(tools['update_testObject']).toBeDefined();
|
||||
expect(tools['find_testObject']).toBeDefined();
|
||||
expect(tools['find_one_testObject']).toBeDefined();
|
||||
expect(tools['soft_delete_testObject']).toBeDefined();
|
||||
expect(tools['soft_delete_many_testObject']).toBeDefined();
|
||||
|
||||
// Ensure the execute functions are wired
|
||||
expect(typeof tools['create_testObject'].execute).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRecord', () => {
|
||||
it('should create a record successfully', async () => {
|
||||
const record = { id: 'r1', name: 'Test' };
|
||||
|
||||
mockRepo.save.mockResolvedValue(record);
|
||||
|
||||
const result = await service.createRecord(
|
||||
'testObject',
|
||||
{ name: 'Test' },
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.record).toEqual(record);
|
||||
expect(ormManager.getRepositoryForWorkspace).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
'testObject',
|
||||
{ roleId },
|
||||
);
|
||||
expect(workspaceCache.getObjectMetadataMapsOrThrow).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
);
|
||||
expect(transformer.process).toHaveBeenCalled();
|
||||
expect(mockRepo.save).toHaveBeenCalledWith({ name: 'Test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecord', () => {
|
||||
it('should return error when id is missing', async () => {
|
||||
const result = await (service as any).updateRecord(
|
||||
'testObject',
|
||||
{ name: 'No ID' },
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Record ID is required for update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRecords', () => {
|
||||
it('should return records and count', async () => {
|
||||
const records = [{ id: 'a' }, { id: 'b' }];
|
||||
|
||||
mockRepo.find.mockResolvedValue(records);
|
||||
|
||||
const result = await (service as any).findRecords(
|
||||
'testObject',
|
||||
{},
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.records).toEqual(records);
|
||||
expect(result.count).toBe(2);
|
||||
expect(mockRepo.find).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDeleteManyRecords', () => {
|
||||
it('should error when filter is invalid', async () => {
|
||||
const result = await (service as any).softDeleteManyRecords(
|
||||
'testObject',
|
||||
{},
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
'Filter with record IDs is required for bulk soft delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import {
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
|
||||
import { buildWhereConditions } from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import {
|
||||
generateBulkDeleteToolSchema,
|
||||
generateFindOneToolSchema,
|
||||
@@ -22,8 +13,10 @@ import {
|
||||
} from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class ToolService {
|
||||
@@ -31,6 +24,8 @@ export class ToolService {
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
private readonly recordInputTransformerService: RecordInputTransformerService,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
) {}
|
||||
|
||||
async listTools(roleId: string, workspaceId: string): Promise<ToolSet> {
|
||||
@@ -167,7 +162,7 @@ export class ToolService {
|
||||
|
||||
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
|
||||
|
||||
const whereConditions = this.buildWhereConditions(searchCriteria);
|
||||
const whereConditions = buildWhereConditions(searchCriteria);
|
||||
|
||||
const records = await repository.find({
|
||||
where: whereConditions,
|
||||
@@ -191,124 +186,6 @@ export class ToolService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildWhereConditions(
|
||||
searchCriteria: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const whereConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(searchCriteria).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const nestedConditions = this.buildNestedWhereConditions(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
whereConditions[key] = nestedConditions;
|
||||
} else {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
whereConditions[key] = filterCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
whereConditions[key] = value;
|
||||
});
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
private buildNestedWhereConditions(
|
||||
nestedValue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nestedConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
|
||||
if (
|
||||
nestedFieldValue === undefined ||
|
||||
nestedFieldValue === null ||
|
||||
nestedFieldValue === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nestedFieldValue === 'object' &&
|
||||
!Array.isArray(nestedFieldValue)
|
||||
) {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
nestedFieldValue as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
nestedConditions[nestedKey] = filterCondition;
|
||||
}
|
||||
} else {
|
||||
nestedConditions[nestedKey] = nestedFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return nestedConditions;
|
||||
}
|
||||
|
||||
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(filterValue.gte);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(filterValue.lte);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
return In(filterValue.in as string[]);
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return ILike(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${filterValue.startsWith}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
if (filterValue.is === 'NULL') {
|
||||
return IsNull();
|
||||
}
|
||||
if (filterValue.is === 'NOT_NULL') {
|
||||
return Not(IsNull());
|
||||
}
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${filterValue.containsIlike}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOneRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
@@ -373,7 +250,29 @@ export class ToolService {
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const createdRecord = await repository.save(parameters);
|
||||
const objectMetadataMaps =
|
||||
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const objectMetadataItemWithFieldsMaps =
|
||||
getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName);
|
||||
|
||||
if (!objectMetadataItemWithFieldsMaps) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Object metadata not found',
|
||||
message: `Failed to create ${objectName}: Object metadata not found`,
|
||||
};
|
||||
}
|
||||
|
||||
const transformedCreateData =
|
||||
await this.recordInputTransformerService.process({
|
||||
recordInput: parameters,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
|
||||
});
|
||||
|
||||
const createdRecord = await repository.save(transformedCreateData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -425,7 +324,29 @@ export class ToolService {
|
||||
};
|
||||
}
|
||||
|
||||
await repository.update(id as string, updateData);
|
||||
const objectMetadataMaps =
|
||||
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const objectMetadataItemWithFieldsMaps =
|
||||
getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName);
|
||||
|
||||
if (!objectMetadataItemWithFieldsMaps) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Object metadata not found',
|
||||
message: `Failed to update ${objectName}: Object metadata not found`,
|
||||
};
|
||||
}
|
||||
|
||||
const transformedUpdateData =
|
||||
await this.recordInputTransformerService.process({
|
||||
recordInput: updateData,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
|
||||
});
|
||||
|
||||
await repository.update(id as string, transformedUpdateData);
|
||||
|
||||
const updatedRecord = await repository.findOne({
|
||||
where: { id: id as string },
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
|
||||
import {
|
||||
buildWhereConditions,
|
||||
parseFilterCondition,
|
||||
} from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
|
||||
|
||||
describe('find-records-filters.utils', () => {
|
||||
describe('parseFilterCondition', () => {
|
||||
it('should handle eq', () => {
|
||||
expect(parseFilterCondition({ eq: 10 })).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle neq', () => {
|
||||
expect(parseFilterCondition({ neq: 5 })).toEqual(Not(5));
|
||||
});
|
||||
|
||||
it('should handle gt/gte/lt/lte', () => {
|
||||
expect(parseFilterCondition({ gt: 1 })).toEqual(MoreThan(1));
|
||||
expect(parseFilterCondition({ gte: 2 })).toEqual(MoreThanOrEqual(2));
|
||||
expect(parseFilterCondition({ lt: 3 })).toEqual(LessThan(3));
|
||||
expect(parseFilterCondition({ lte: 4 })).toEqual(LessThanOrEqual(4));
|
||||
});
|
||||
|
||||
it('should handle in', () => {
|
||||
expect(parseFilterCondition({ in: ['a', 'b'] })).toEqual(In(['a', 'b']));
|
||||
});
|
||||
|
||||
it('should handle like/ilike', () => {
|
||||
expect(parseFilterCondition({ like: '%foo%' })).toEqual(Like('%foo%'));
|
||||
expect(parseFilterCondition({ ilike: '%bar%' })).toEqual(ILike('%bar%'));
|
||||
});
|
||||
|
||||
it('should handle startsWith', () => {
|
||||
expect(parseFilterCondition({ startsWith: 'pre' })).toEqual(Like('pre%'));
|
||||
});
|
||||
|
||||
it('should handle is NULL and NOT_NULL', () => {
|
||||
expect(parseFilterCondition({ is: 'NULL' })).toEqual(IsNull());
|
||||
expect(parseFilterCondition({ is: 'NOT_NULL' })).toEqual(Not(IsNull()));
|
||||
});
|
||||
|
||||
it('should handle isEmptyArray', () => {
|
||||
expect(parseFilterCondition({ isEmptyArray: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle containsIlike (uses Like with wildcards)', () => {
|
||||
const result = parseFilterCondition({ containsIlike: 'mid' });
|
||||
|
||||
expect(result).toEqual(Like('%mid%'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhereConditions', () => {
|
||||
it('should build where conditions from mixed criteria', () => {
|
||||
const where = buildWhereConditions({
|
||||
// primitive values
|
||||
id: '123',
|
||||
active: true,
|
||||
count: 7,
|
||||
// skip falsy-empty values
|
||||
skipUndefined: undefined,
|
||||
skipNull: null,
|
||||
skipEmptyString: '',
|
||||
// operator objects
|
||||
name: { ilike: '%alpha%' },
|
||||
createdAt: { gte: '2024-01-01' },
|
||||
score: { lte: 99 },
|
||||
tags: { in: ['a', 'b'] },
|
||||
prefix: { startsWith: 'PRE' },
|
||||
nothing: { is: 'NULL' },
|
||||
notNothing: { is: 'NOT_NULL' },
|
||||
contains: { containsIlike: 'mid' },
|
||||
// nested relation-like object
|
||||
account: {
|
||||
name: { startsWith: 'ACME' },
|
||||
size: { gte: 100 },
|
||||
country: '', // should be omitted
|
||||
},
|
||||
// arrays should pass through when not an operator object
|
||||
list: [1, 2, 3],
|
||||
});
|
||||
|
||||
expect(where.id).toBe('123');
|
||||
expect(where.active).toBe(true);
|
||||
expect(where.count).toBe(7);
|
||||
|
||||
expect(where.name).toEqual(ILike('%alpha%'));
|
||||
expect(where.createdAt).toEqual(MoreThanOrEqual('2024-01-01'));
|
||||
expect(where.score).toEqual(LessThanOrEqual(99));
|
||||
expect(where.tags).toEqual(In(['a', 'b']));
|
||||
expect(where.prefix).toEqual(Like('PRE%'));
|
||||
expect(where.nothing).toEqual(IsNull());
|
||||
expect(where.notNothing).toEqual(Not(IsNull()));
|
||||
expect(where.contains).toEqual(Like('%mid%'));
|
||||
|
||||
expect(where.account).toEqual({
|
||||
name: Like('ACME%'),
|
||||
size: MoreThanOrEqual(100),
|
||||
});
|
||||
|
||||
expect(where.list).toEqual([1, 2, 3]);
|
||||
|
||||
// Ensure skipped values are not present
|
||||
expect('skipUndefined' in where).toBe(false);
|
||||
expect('skipNull' in where).toBe(false);
|
||||
expect('skipEmptyString' in where).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type FilterObject = Record<string, unknown>;
|
||||
|
||||
type WhereRecord = Record<string, unknown>;
|
||||
|
||||
const isSkippableValue = (value: unknown): boolean =>
|
||||
!isDefined(value) || value === '';
|
||||
|
||||
const isPlainObject = (value: unknown): value is FilterObject =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
export const parseFilterCondition = (filterValue: FilterObject): unknown => {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq as unknown);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt as unknown as number | string | Date);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(
|
||||
filterValue.gte as unknown as number | string | Date,
|
||||
);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt as unknown as number | string | Date);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(
|
||||
filterValue.lte as unknown as number | string | Date,
|
||||
);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
const values = (filterValue as { in: unknown }).in;
|
||||
|
||||
return Array.isArray(values) ? In(values as unknown[]) : null;
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return ILike(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${String(filterValue.startsWith)}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
const v = (filterValue as { is: unknown }).is;
|
||||
|
||||
if (v === 'NULL') return IsNull();
|
||||
if (v === 'NOT_NULL') return Not(IsNull());
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${String(filterValue.containsIlike)}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildWhereConditions = (
|
||||
searchCriteria: FilterObject,
|
||||
): WhereRecord => {
|
||||
return Object.entries(searchCriteria).reduce<WhereRecord>(
|
||||
(acc, [key, value]) => {
|
||||
if (isSkippableValue(value)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
// Direct operator-based condition (eq, ilike, etc.)
|
||||
const filterCondition = parseFilterCondition(value as FilterObject);
|
||||
|
||||
if (isDefined(filterCondition)) {
|
||||
acc[key] = filterCondition;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Otherwise, try to build nested conditions
|
||||
const nestedConditions = buildWhereConditions(value);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
acc[key] = nestedConditions;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[key] = value as unknown;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
+37
@@ -21,6 +21,8 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
export interface AgentToolTestContext {
|
||||
module: TestingModule;
|
||||
@@ -82,6 +84,18 @@ export const createAgentToolTestModule =
|
||||
provide: ToolService,
|
||||
useClass: ToolService,
|
||||
},
|
||||
{
|
||||
provide: RecordInputTransformerService,
|
||||
useValue: {
|
||||
process: jest.fn(async ({ recordInput }) => recordInput),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: {
|
||||
getObjectMetadataMapsOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToolAdapterService,
|
||||
useClass: ToolAdapterService,
|
||||
@@ -212,6 +226,29 @@ export const createAgentToolTestModule =
|
||||
fieldPermissions: [],
|
||||
});
|
||||
|
||||
// Ensure ToolService input transformation has access to minimal metadata maps
|
||||
const workspaceCacheStorageService =
|
||||
module.get<WorkspaceCacheStorageService>(WorkspaceCacheStorageService);
|
||||
|
||||
// Return a barebones object metadata map where fields are unknown (so transformer is a no-op)
|
||||
const getMapsMock =
|
||||
workspaceCacheStorageService.getObjectMetadataMapsOrThrow as jest.Mock;
|
||||
|
||||
getMapsMock.mockResolvedValue({
|
||||
byId: {
|
||||
[testObjectMetadata.id]: {
|
||||
...testObjectMetadata,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
indexMetadatas: [],
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[testObjectMetadata.nameSingular]: testObjectMetadata.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
module,
|
||||
agentToolService,
|
||||
|
||||
Reference in New Issue
Block a user