fix(ai) - optimize crud tools (#21133)
- **Add delete many**, `delete_many_{object}` added alongside the
existing `delete_one_{object}`.
- **Uniformize naming**, crud module, type names, and MCP helper
constants renamed for consistency.
- **Optimize tool schema (learn phase)**
- `find_many(_companies)`: **7 158 → 2 700 tokens**
- `find_one(_company)`: **280 → 126 tokens**
- ....
- Main mechanism: `reused: 'ref'` (line 7 of
`to-tool-json-schema.util.ts`). Zod walks the schema tree, tracks which
Zod schema instances appear more than once, and emits each reused
instance exactly once in `$defs`, replacing all subsequent occurrences
with a `$ref`. Works because filter and value schemas are now extracted
as shared objects.
- **Optimize system prompt (tool catalog)**, DATABASE_CRUD section
restructured to list operation patterns (`find_many_{object}`, …) once +
objects once, instead of the full N×M cross-product of tool names.
- **Optimize execute_tool**, shared record-properties schema (same
`$defs` deduplication applies at call time); introduced `upsert_many`;
added `selectedFields` to `find_*` so the agent only fetches the fields
it needs.
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ A brief note on the overall tone of the conversation (collaborative, tense, expl
|
||||
- If the transcript is very short (under 200 words), provide a brief 2-3 sentence summary instead of the full structure.
|
||||
|
||||
## Saving the Summary
|
||||
After generating the summary, use \`update_callRecording\` to save it in the \`summary\` field with the format:
|
||||
After generating the summary, use \`update_one_call_recording\` to save it in the \`summary\` field with the format:
|
||||
\`\`\`json
|
||||
{ "summary": { "blocknote": null, "markdown": "<your summary>" } }
|
||||
\`\`\`
|
||||
|
||||
@@ -23,14 +23,14 @@ describe('resolveToolInput', () => {
|
||||
|
||||
it('should unwrap execute_tool input', () => {
|
||||
const input = {
|
||||
toolName: 'find_companies',
|
||||
toolName: 'find_many_companies',
|
||||
arguments: { filter: { name: 'Acme' } },
|
||||
};
|
||||
const result = resolveToolInput(input, 'execute_tool');
|
||||
|
||||
expect(result).toEqual({
|
||||
resolvedInput: { filter: { name: 'Acme' } },
|
||||
resolvedToolName: 'find_companies',
|
||||
resolvedToolName: 'find_many_companies',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,13 +108,13 @@ describe('getToolDisplayMessage', () => {
|
||||
describe('learn_tools', () => {
|
||||
it('should show tool names when provided', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ toolNames: ['find_companies', 'create_task'] },
|
||||
{ toolNames: ['find_many_companies', 'create_one_task'] },
|
||||
'learn_tools',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('Learned');
|
||||
expect(message).toContain('find_companies, create_task');
|
||||
expect(message).toContain('find_many_companies, create_one_task');
|
||||
});
|
||||
|
||||
it('should show generic message without tool names', () => {
|
||||
@@ -182,13 +182,13 @@ describe('getToolDisplayMessage', () => {
|
||||
describe('execute_tool wrapper', () => {
|
||||
it('should unwrap execute_tool and display inner tool name', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ toolName: 'find_companies', arguments: { limit: 10 } },
|
||||
{ toolName: 'find_many_companies', arguments: { limit: 10 } },
|
||||
'execute_tool',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('Ran');
|
||||
expect(message).toContain('find companies');
|
||||
expect(message).toContain('find many companies');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
const fileItemSchema = z
|
||||
.object({
|
||||
fileId: z.string().uuidv4(),
|
||||
fileId: z.uuidv4(),
|
||||
label: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ describe('McpProtocolService', () => {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'execute_tool',
|
||||
arguments: { toolName: 'find_companies', arguments: {} },
|
||||
arguments: { toolName: 'find_many_companies', arguments: {} },
|
||||
},
|
||||
id: '123',
|
||||
};
|
||||
|
||||
+4
-4
@@ -19,8 +19,8 @@ export const buildMcpServerInstructions = (
|
||||
``,
|
||||
...(skillNames ? [`Available skills: ${skillNames}.`, ``] : []),
|
||||
`CRUD tool name grammar — construct names directly without prior discovery:`,
|
||||
` Read: find_{objects} | find_one_{object} | group_by_{objects}`,
|
||||
` Write: create_{object} | create_many_{objects} | update_{object} | update_many_{objects} | delete_{object}`,
|
||||
` Read: find_many_{objects} | find_one_{object} | group_by_{objects}`,
|
||||
` Write: create_one_{object} | create_many_{objects} | update_one_{object} | update_many_{objects} | delete_one_{object} | delete_many_{objects} | upsert_many_{objects}. Use upsert_many_{objects} instead of update_many_{objects} when each record has its own individual data.`,
|
||||
``,
|
||||
`Non-CRUD tools — use learn_tools for schemas:`,
|
||||
` ACTION: http_request | send_email | draft_email | navigate_app | code_interpreter | search_help_center`,
|
||||
@@ -39,8 +39,8 @@ export const buildMcpServerInstructions = (
|
||||
` ⚠️ Never call workflow, dashboard, or metadata tools without loading their skill first.`,
|
||||
``,
|
||||
`Route by intent:`,
|
||||
` Named entity ("Acme company") → find_{objects} to resolve id first, then operate on id`,
|
||||
` Retrieve records → find_{objects} (default limit: 10, always report total count)`,
|
||||
` Named entity ("Acme company") → find_many_{objects} to resolve id first, then operate on id`,
|
||||
` Retrieve records → find_many_{objects} (default limit: 10, always report total count)`,
|
||||
` Single record by id → find_one_{object}`,
|
||||
` Analytics / grouped metrics → group_by_{objects} (COUNT, SUM, AVG, MIN, MAX)`,
|
||||
` Multiple metrics → run parallel group_by calls, merge results`,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
|
||||
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { DeleteManyRecordsService } from 'src/engine/core-modules/record-crud/services/delete-many-records.service';
|
||||
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { GroupByRecordsService } from 'src/engine/core-modules/record-crud/services/group-by-records.service';
|
||||
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
|
||||
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
|
||||
import { UpsertManyRecordsService } from 'src/engine/core-modules/record-crud/services/upsert-many-records.service';
|
||||
import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
@@ -30,9 +32,11 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
UpdateRecordService,
|
||||
UpdateManyRecordsService,
|
||||
DeleteRecordService,
|
||||
DeleteManyRecordsService,
|
||||
FindRecordsService,
|
||||
GroupByRecordsService,
|
||||
UpsertRecordService,
|
||||
UpsertManyRecordsService,
|
||||
],
|
||||
exports: [
|
||||
CreateRecordService,
|
||||
@@ -40,9 +44,11 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
UpdateRecordService,
|
||||
UpdateManyRecordsService,
|
||||
DeleteRecordService,
|
||||
DeleteManyRecordsService,
|
||||
FindRecordsService,
|
||||
GroupByRecordsService,
|
||||
UpsertRecordService,
|
||||
UpsertManyRecordsService,
|
||||
],
|
||||
})
|
||||
export class RecordCrudModule {}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { CommonDeleteManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-many-query-runner.service';
|
||||
import {
|
||||
RecordCrudException,
|
||||
RecordCrudExceptionCode,
|
||||
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
|
||||
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
|
||||
import { type DeleteManyRecordsParams } from 'src/engine/core-modules/record-crud/types/delete-many-records-params.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteManyRecordsService {
|
||||
private readonly logger = new Logger(DeleteManyRecordsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly commonDeleteManyRunner: CommonDeleteManyQueryRunnerService,
|
||||
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
|
||||
) {}
|
||||
|
||||
async execute(params: DeleteManyRecordsParams): Promise<ToolOutput> {
|
||||
const { objectName, filter, authContext } = params;
|
||||
|
||||
if (!isDefined(filter) || isEmptyObject(filter)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to delete records from ${objectName}`,
|
||||
error:
|
||||
'Filter must not be empty — deleting without a filter is not allowed',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { queryRunnerContext, selectedFields, flatObjectMetadata } =
|
||||
await this.commonApiContextBuilder.build({
|
||||
authContext,
|
||||
objectName,
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to delete: Object cannot be deleted by workflow',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const { results: deletedRecords } =
|
||||
await this.commonDeleteManyRunner.execute(
|
||||
{ filter, selectedFields },
|
||||
queryRunnerContext,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Soft deleted ${deletedRecords.length} records from ${objectName}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Soft deleted ${deletedRecords.length} records from ${objectName}`,
|
||||
result: deletedRecords.map((record) => ({ id: record.id })),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RecordCrudException) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to delete records from ${objectName}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to delete records: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to delete records from ${objectName}`,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to delete records',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -5,12 +5,15 @@ import { OrderByDirection, type ObjectRecord } from 'twenty-shared/types';
|
||||
|
||||
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
|
||||
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
|
||||
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
|
||||
import { type FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type';
|
||||
import { buildEffectiveSelectedFields } from 'src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util';
|
||||
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class FindRecordsService {
|
||||
@@ -31,12 +34,22 @@ export class FindRecordsService {
|
||||
limit,
|
||||
offset = 0,
|
||||
authContext,
|
||||
select,
|
||||
shouldBuildEffectiveSelectFields,
|
||||
} = params;
|
||||
|
||||
if (shouldBuildEffectiveSelectFields && !isNonEmptyArray(select)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Select at least one field in select parameter',
|
||||
error: 'Select is required',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
queryRunnerContext,
|
||||
selectedFields,
|
||||
selectedFields: allSelectableFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
} = await this.commonApiContextBuilder.build({
|
||||
@@ -44,6 +57,19 @@ export class FindRecordsService {
|
||||
objectName,
|
||||
});
|
||||
|
||||
const { effectiveSelectedFields, warnings } =
|
||||
shouldBuildEffectiveSelectFields && isDefined(select)
|
||||
? buildEffectiveSelectedFields({
|
||||
select,
|
||||
filter,
|
||||
orderBy,
|
||||
objectName,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
selectedFields: allSelectableFields,
|
||||
})
|
||||
: { effectiveSelectedFields: allSelectableFields, warnings: [] };
|
||||
|
||||
// Add id to orderBy for consistent pagination
|
||||
const orderByWithIdCondition: ObjectRecordOrderBy = [
|
||||
...(orderBy ?? []).filter((item) => item !== undefined),
|
||||
@@ -58,7 +84,7 @@ export class FindRecordsService {
|
||||
orderBy: orderByWithIdCondition,
|
||||
first: limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS,
|
||||
offset,
|
||||
selectedFields: { ...selectedFields, totalCount: true },
|
||||
selectedFields: { ...effectiveSelectedFields, totalCount: true },
|
||||
},
|
||||
queryRunnerContext,
|
||||
);
|
||||
@@ -82,6 +108,7 @@ export class FindRecordsService {
|
||||
records,
|
||||
count: totalCount,
|
||||
},
|
||||
...(isNonEmptyArray(warnings) ? { warnings: warnings } : {}),
|
||||
recordReferences,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
|
||||
import {
|
||||
RecordCrudException,
|
||||
RecordCrudExceptionCode,
|
||||
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
|
||||
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
|
||||
import { type UpsertManyRecordsParams } from 'src/engine/core-modules/record-crud/types/upsert-many-records-params.type';
|
||||
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
|
||||
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
|
||||
@Injectable()
|
||||
export class UpsertManyRecordsService {
|
||||
private readonly logger = new Logger(UpsertManyRecordsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly commonCreateManyRunner: CommonCreateManyQueryRunnerService,
|
||||
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
|
||||
) {}
|
||||
|
||||
async execute(params: UpsertManyRecordsParams): Promise<ToolOutput> {
|
||||
const { objectName, objectRecords, authContext } = params;
|
||||
|
||||
try {
|
||||
const {
|
||||
queryRunnerContext,
|
||||
selectedFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
} = await this.commonApiContextBuilder.build({
|
||||
authContext,
|
||||
objectName,
|
||||
});
|
||||
|
||||
if (
|
||||
!canObjectBeManagedByAutomation({
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
})
|
||||
) {
|
||||
throw new RecordCrudException(
|
||||
'Failed to upsert: Object cannot be upserted by workflow',
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const cleanedRecords = objectRecords.map((record) => ({
|
||||
...removeUndefinedFromRecord(record),
|
||||
...(isDefined(params.createdBy) && { createdBy: params.createdBy }),
|
||||
}));
|
||||
|
||||
const { results: upsertedRecords } =
|
||||
await this.commonCreateManyRunner.execute(
|
||||
{
|
||||
data: cleanedRecords,
|
||||
selectedFields,
|
||||
upsert: true,
|
||||
},
|
||||
queryRunnerContext,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Upserted ${upsertedRecords.length} records in ${objectName}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Upserted ${upsertedRecords.length} records in ${objectName}`,
|
||||
result: params.slimResponse
|
||||
? upsertedRecords.map((record) => ({ id: record.id }))
|
||||
: upsertedRecords,
|
||||
recordReferences: upsertedRecords.map((record) => ({
|
||||
objectNameSingular: objectName,
|
||||
recordId: record.id,
|
||||
displayName: getRecordDisplayName(
|
||||
record,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RecordCrudException) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to upsert records in ${objectName}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to upsert records: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to upsert records in ${objectName}`,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to upsert records',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export type DeleteManyRecordsParams = {
|
||||
objectName: string;
|
||||
filter: Record<string, unknown>;
|
||||
authContext: WorkspaceAuthContext;
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
};
|
||||
+2
@@ -15,4 +15,6 @@ export type FindRecordsParams = FindRecordsInput &
|
||||
| Partial<ObjectRecordFilter>[];
|
||||
orderBy?: Partial<ObjectRecordOrderBy>;
|
||||
offset?: number;
|
||||
select?: string[];
|
||||
shouldBuildEffectiveSelectFields: boolean;
|
||||
};
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export type UpsertManyRecordsParams = {
|
||||
objectName: string;
|
||||
objectRecords: ObjectRecordProperties[];
|
||||
authContext: WorkspaceAuthContext;
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
createdBy?: ActorMetadata;
|
||||
slimResponse?: boolean;
|
||||
};
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { buildEffectiveSelectedFields } from 'src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
const buildFlatFieldMetadataMaps = (
|
||||
fields: Array<{ id: string; name: string; type: FieldMetadataType }>,
|
||||
): FlatEntityMaps<FlatFieldMetadata> => {
|
||||
const universalIdentifierById: Partial<Record<string, string>> = {};
|
||||
const byUniversalIdentifier: Partial<
|
||||
Record<string, Partial<FlatFieldMetadata>>
|
||||
> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const uid = `uid-${field.id}`;
|
||||
|
||||
universalIdentifierById[field.id] = uid;
|
||||
byUniversalIdentifier[uid] = { name: field.name, type: field.type };
|
||||
}
|
||||
|
||||
return {
|
||||
universalIdentifierById,
|
||||
byUniversalIdentifier,
|
||||
universalIdentifiersByApplicationId: {},
|
||||
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
|
||||
};
|
||||
|
||||
const buildFlatObjectMetadata = (
|
||||
labelIdentifierFieldMetadataId: string | undefined,
|
||||
fieldIds: string[],
|
||||
): FlatObjectMetadata =>
|
||||
({
|
||||
labelIdentifierFieldMetadataId,
|
||||
fieldIds,
|
||||
}) as unknown as FlatObjectMetadata;
|
||||
|
||||
const FIELD_IDS = {
|
||||
name: 'field-id-name',
|
||||
email: 'field-id-email',
|
||||
searchVector: 'field-id-search-vector',
|
||||
richText: 'field-id-richText',
|
||||
};
|
||||
|
||||
const defaultFlatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
{ id: FIELD_IDS.name, name: 'name', type: FieldMetadataType.TEXT },
|
||||
{ id: FIELD_IDS.email, name: 'emails', type: FieldMetadataType.EMAILS },
|
||||
{
|
||||
id: FIELD_IDS.searchVector,
|
||||
name: 'searchVector',
|
||||
type: FieldMetadataType.TS_VECTOR,
|
||||
},
|
||||
{
|
||||
id: FIELD_IDS.richText,
|
||||
name: 'richText',
|
||||
type: FieldMetadataType.RICH_TEXT,
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultFlatObjectMetadata = buildFlatObjectMetadata(FIELD_IDS.name, [
|
||||
FIELD_IDS.name,
|
||||
FIELD_IDS.email,
|
||||
FIELD_IDS.searchVector,
|
||||
FIELD_IDS.richText,
|
||||
]);
|
||||
|
||||
const defaultSelectedFields: CommonSelectedFields = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
searchVector: true,
|
||||
body: { blocknote: true, markdown: true },
|
||||
};
|
||||
|
||||
describe('buildEffectiveSelectedFields', () => {
|
||||
describe('when select is ["*"] (wildcard case)', () => {
|
||||
it('should return all selectable fields excluding searchVector', () => {
|
||||
const { effectiveSelectedFields, warnings } =
|
||||
buildEffectiveSelectedFields({
|
||||
select: ['*'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
expect(effectiveSelectedFields).toHaveProperty('id');
|
||||
expect(effectiveSelectedFields).toHaveProperty('name');
|
||||
expect(effectiveSelectedFields).toHaveProperty('email');
|
||||
expect(effectiveSelectedFields).not.toHaveProperty('searchVector');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when select lists specific fields', () => {
|
||||
it('should return only the requested fields plus id', () => {
|
||||
const { effectiveSelectedFields, warnings } =
|
||||
buildEffectiveSelectedFields({
|
||||
select: ['name'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
expect(effectiveSelectedFields).toHaveProperty('id');
|
||||
expect(effectiveSelectedFields).toHaveProperty('name');
|
||||
expect(effectiveSelectedFields).not.toHaveProperty('email');
|
||||
expect(effectiveSelectedFields).not.toHaveProperty('searchVector');
|
||||
});
|
||||
|
||||
it('should always include id even if not listed in select', () => {
|
||||
const { effectiveSelectedFields } = buildEffectiveSelectedFields({
|
||||
select: ['email'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(effectiveSelectedFields).toHaveProperty('id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('warning case', () => {
|
||||
it('should emit a warning with a suggestion for a near-miss field name', () => {
|
||||
const { warnings } = buildEffectiveSelectedFields({
|
||||
select: ['nam'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("Field 'nam' not found on person");
|
||||
expect(warnings[0]).toContain('name');
|
||||
});
|
||||
|
||||
it('should emit a warning without a suggestion for a completely unknown field', () => {
|
||||
const { warnings } = buildEffectiveSelectedFields({
|
||||
select: ['zzz_totally_unknown'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain(
|
||||
"Field 'zzz_totally_unknown' not found on person",
|
||||
);
|
||||
expect(warnings[0]).not.toContain('Did you mean');
|
||||
});
|
||||
|
||||
it('should emit one warning per unknown field', () => {
|
||||
const { warnings } = buildEffectiveSelectedFields({
|
||||
select: ['unknownA', 'unknownB'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchVector field exclusion', () => {
|
||||
it('should exclude searchVector from wildcard results', () => {
|
||||
const { effectiveSelectedFields } = buildEffectiveSelectedFields({
|
||||
select: ['*'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(effectiveSelectedFields).not.toHaveProperty('searchVector');
|
||||
});
|
||||
|
||||
it('should emit a warning when searchVector is explicitly requested', () => {
|
||||
const { warnings, effectiveSelectedFields } =
|
||||
buildEffectiveSelectedFields({
|
||||
select: ['searchVector'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: defaultSelectedFields,
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('searchVector');
|
||||
expect(effectiveSelectedFields).not.toHaveProperty('searchVector');
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocknote sub-field exclusion for RICH_TEXT fields', () => {
|
||||
it('should strip blocknote from RICH_TEXT field sub-fields', () => {
|
||||
const { effectiveSelectedFields } = buildEffectiveSelectedFields({
|
||||
select: ['*'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: defaultFlatObjectMetadata,
|
||||
flatFieldMetadataMaps: defaultFlatFieldMetadataMaps,
|
||||
selectedFields: {
|
||||
id: true,
|
||||
richText: { blocknote: true, markdown: true },
|
||||
},
|
||||
});
|
||||
|
||||
const richTextFields =
|
||||
effectiveSelectedFields.richText as CommonSelectedFields;
|
||||
|
||||
expect(richTextFields).not.toHaveProperty('blocknote');
|
||||
expect(richTextFields).toHaveProperty('markdown');
|
||||
});
|
||||
|
||||
it('should keep blocknote when the field type is not RICH_TEXT', () => {
|
||||
const nonRichTextMaps = buildFlatFieldMetadataMaps([
|
||||
{ id: FIELD_IDS.name, name: 'name', type: FieldMetadataType.TEXT },
|
||||
{
|
||||
id: FIELD_IDS.richText,
|
||||
name: 'richText',
|
||||
type: FieldMetadataType.TEXT,
|
||||
},
|
||||
]);
|
||||
const nonRichTextObjectMetadata = buildFlatObjectMetadata(
|
||||
FIELD_IDS.name,
|
||||
[FIELD_IDS.name, FIELD_IDS.richText],
|
||||
);
|
||||
|
||||
const { effectiveSelectedFields } = buildEffectiveSelectedFields({
|
||||
select: ['*'],
|
||||
filter: undefined,
|
||||
orderBy: undefined,
|
||||
objectName: 'person',
|
||||
flatObjectMetadata: nonRichTextObjectMetadata,
|
||||
flatFieldMetadataMaps: nonRichTextMaps,
|
||||
selectedFields: {
|
||||
id: true,
|
||||
name: true,
|
||||
richText: { blocknote: true, markdown: true },
|
||||
},
|
||||
});
|
||||
|
||||
const richTextFields =
|
||||
effectiveSelectedFields.richText as CommonSelectedFields;
|
||||
|
||||
expect(richTextFields).toHaveProperty('blocknote');
|
||||
expect(richTextFields).toHaveProperty('markdown');
|
||||
});
|
||||
});
|
||||
});
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { isNull, isObject } from '@sniptt/guards';
|
||||
import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const SEARCH_VECTOR_FIELD = 'searchVector';
|
||||
const LOGICAL_OPERATORS = new Set(['and', 'or', 'not']);
|
||||
|
||||
const SUB_FIELDS_TO_EXCLUDE_BY_FIELD_TYPE: Partial<
|
||||
Record<FieldMetadataType, Set<string>>
|
||||
> = {
|
||||
[FieldMetadataType.RICH_TEXT]: new Set(['blocknote']),
|
||||
};
|
||||
|
||||
const buildSelectedField = (
|
||||
rawSelect: string[],
|
||||
filterFieldNames: string[],
|
||||
orderByFieldNames: string[],
|
||||
allSelectableFieldNames: string[],
|
||||
labelIdentifierFieldName: string,
|
||||
objectName: string,
|
||||
): { select: string[]; warnings: string[] } => {
|
||||
const cleanFieldNames = allSelectableFieldNames.filter(
|
||||
(name) => name !== SEARCH_VECTOR_FIELD,
|
||||
);
|
||||
|
||||
const implicitFields = [
|
||||
'id',
|
||||
labelIdentifierFieldName,
|
||||
...filterFieldNames,
|
||||
...orderByFieldNames,
|
||||
].filter((name) => cleanFieldNames.includes(name));
|
||||
|
||||
if (rawSelect.includes('*')) {
|
||||
return { select: cleanFieldNames, warnings: [] };
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const validFields: string[] = [...implicitFields];
|
||||
|
||||
for (const requestedName of rawSelect) {
|
||||
if (implicitFields.includes(requestedName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cleanFieldNames.includes(requestedName)) {
|
||||
validFields.push(requestedName);
|
||||
} else {
|
||||
const suggestions = findSimilarFieldNames(requestedName, cleanFieldNames);
|
||||
const hint =
|
||||
suggestions.length > 0
|
||||
? ` Did you mean: ${suggestions.map((s) => `'${s}'`).join(', ')}?`
|
||||
: '';
|
||||
|
||||
warnings.push(
|
||||
`Field '${requestedName}' not found on ${objectName}.${hint}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { select: [...new Set(validFields)], warnings };
|
||||
};
|
||||
|
||||
const extractFilterFieldNames = (
|
||||
filter:
|
||||
| Record<string, unknown>
|
||||
| Record<string, unknown>[]
|
||||
| Partial<ObjectRecordFilter>
|
||||
| Partial<ObjectRecordFilter>[]
|
||||
| undefined,
|
||||
): string[] => {
|
||||
if (Array.isArray(filter)) {
|
||||
return filter.flatMap((filterItem) => extractFilterFieldNames(filterItem));
|
||||
}
|
||||
|
||||
if (!isDefined(filter)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(filter).flatMap(([key, value]) => {
|
||||
if (LOGICAL_OPERATORS.has(key)) {
|
||||
return extractFilterFieldNames(
|
||||
value as Record<string, unknown> | Record<string, unknown>[],
|
||||
);
|
||||
}
|
||||
|
||||
return [key];
|
||||
});
|
||||
};
|
||||
|
||||
const extractOrderByFieldNames = (orderBy: unknown): string[] => {
|
||||
if (!Array.isArray(orderBy)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return orderBy.flatMap((item) => (isObject(item) ? Object.keys(item) : []));
|
||||
};
|
||||
|
||||
const buildSelectedFieldsOverride = (
|
||||
select: string[],
|
||||
allSelectableFields: CommonSelectedFields,
|
||||
fieldNameToType: Map<string, FieldMetadataType>,
|
||||
): CommonSelectedFields => {
|
||||
const fieldsToInclude = new Set([...select, 'id']);
|
||||
|
||||
const fieldsToProcess = Object.fromEntries(
|
||||
Object.entries(allSelectableFields).filter(([fieldName]) =>
|
||||
fieldsToInclude.has(fieldName),
|
||||
),
|
||||
);
|
||||
|
||||
return stripSubFieldsByType(fieldsToProcess, fieldNameToType);
|
||||
};
|
||||
|
||||
export const buildEffectiveSelectedFields = ({
|
||||
select,
|
||||
filter,
|
||||
orderBy,
|
||||
objectName,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
selectedFields,
|
||||
}: {
|
||||
select: string[];
|
||||
filter?:
|
||||
| Record<string, unknown>
|
||||
| Record<string, unknown>[]
|
||||
| Partial<ObjectRecordFilter>
|
||||
| Partial<ObjectRecordFilter>[];
|
||||
orderBy: unknown;
|
||||
objectName: string;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
selectedFields: CommonSelectedFields;
|
||||
}): { effectiveSelectedFields: CommonSelectedFields; warnings: string[] } => {
|
||||
const filterFieldNames = extractFilterFieldNames(filter);
|
||||
const orderByFieldNames = extractOrderByFieldNames(orderBy);
|
||||
|
||||
const labelIdentifierField = flatObjectMetadata.labelIdentifierFieldMetadataId
|
||||
? findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: flatObjectMetadata.labelIdentifierFieldMetadataId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const labelIdentifierFieldName = labelIdentifierField?.name ?? 'id';
|
||||
|
||||
const { select: cleanSelect, warnings } = buildSelectedField(
|
||||
select,
|
||||
filterFieldNames,
|
||||
orderByFieldNames,
|
||||
Object.keys(selectedFields),
|
||||
labelIdentifierFieldName,
|
||||
objectName,
|
||||
);
|
||||
|
||||
const fieldNameToType = buildFieldNameToTypeMap(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
return {
|
||||
effectiveSelectedFields: buildSelectedFieldsOverride(
|
||||
cleanSelect,
|
||||
selectedFields,
|
||||
fieldNameToType,
|
||||
),
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
|
||||
const buildFieldNameToTypeMap = (
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): Map<string, FieldMetadataType> => {
|
||||
const map = new Map<string, FieldMetadataType>();
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const field = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: fieldId,
|
||||
});
|
||||
|
||||
if (isDefined(field)) {
|
||||
map.set(field.name, field.type);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
const stripSubFieldsByType = (
|
||||
fields: CommonSelectedFields,
|
||||
fieldNameToType: Map<string, FieldMetadataType>,
|
||||
): CommonSelectedFields => {
|
||||
const result: CommonSelectedFields = {};
|
||||
|
||||
for (const [fieldName, fieldValue] of Object.entries(fields)) {
|
||||
result[fieldName] = stripFieldSubFieldsByType(
|
||||
fieldName,
|
||||
fieldValue,
|
||||
fieldNameToType,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const stripFieldSubFieldsByType = (
|
||||
fieldName: string,
|
||||
fieldValue: boolean | CommonSelectedFields,
|
||||
fieldNameToType: Map<string, FieldMetadataType>,
|
||||
): boolean | CommonSelectedFields => {
|
||||
if (!isObject(fieldValue) || isNull(fieldValue)) {
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
const fieldType = fieldNameToType.get(fieldName);
|
||||
const subFieldsToExclude = isDefined(fieldType)
|
||||
? SUB_FIELDS_TO_EXCLUDE_BY_FIELD_TYPE[fieldType]
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(subFieldsToExclude)) {
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
const stripped: CommonSelectedFields = {};
|
||||
|
||||
for (const [subFieldName, subFieldValue] of Object.entries(fieldValue)) {
|
||||
if (!subFieldsToExclude.has(subFieldName)) {
|
||||
stripped[subFieldName] = subFieldValue as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
return stripped;
|
||||
};
|
||||
|
||||
const findSimilarFieldNames = (
|
||||
name: string,
|
||||
fieldNames: string[],
|
||||
): string[] => {
|
||||
const fuse = new Fuse(fieldNames, {
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
});
|
||||
|
||||
return fuse
|
||||
.search(name)
|
||||
.slice(0, 3)
|
||||
.map((result) => result.item);
|
||||
};
|
||||
+3
-2
@@ -9,10 +9,11 @@ export const generateUpdateManyRecordInputSchema = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
) => {
|
||||
const { filterSchema } = generateRecordFilterSchema(
|
||||
const { filterSchema } = generateRecordFilterSchema({
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
);
|
||||
additionalExcludedFieldNames: ['createdAt', 'updatedAt'],
|
||||
});
|
||||
|
||||
const dataSchema = generateRecordPropertiesZodSchema(
|
||||
objectMetadata,
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Converts a Zod schema to a lean JSON Schema for LLM tool optimised consumption.
|
||||
export const toToolJsonSchema = (schema: z.ZodTypeAny): object => {
|
||||
const result = z.toJSONSchema(schema, {
|
||||
io: 'input',
|
||||
reused: 'ref',
|
||||
override(ctx) {
|
||||
if (!ctx.jsonSchema) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.jsonSchema.type === 'integer') {
|
||||
delete ctx.jsonSchema.minimum;
|
||||
delete ctx.jsonSchema.maximum;
|
||||
}
|
||||
|
||||
if (ctx.jsonSchema.format && ctx.jsonSchema.pattern) {
|
||||
delete ctx.jsonSchema.pattern;
|
||||
}
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
delete result['$schema'];
|
||||
|
||||
return result;
|
||||
};
|
||||
+22
-14
@@ -1,17 +1,25 @@
|
||||
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BulkDeleteToolInputSchema = z.object({
|
||||
filter: z
|
||||
.object({
|
||||
id: z
|
||||
.object({
|
||||
in: z
|
||||
.array(z.string().uuid())
|
||||
.describe('Array of record IDs to delete'),
|
||||
})
|
||||
.describe('Filter to select records to delete'),
|
||||
})
|
||||
.describe('Filter criteria to select records for bulk delete'),
|
||||
});
|
||||
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
|
||||
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
|
||||
|
||||
export type BulkDeleteToolInput = z.infer<typeof BulkDeleteToolInputSchema>;
|
||||
export const generateBulkDeleteToolInputSchema = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
) => {
|
||||
const { filterSchema } = generateRecordFilterSchema({
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
return z.object({
|
||||
filter: filterSchema.describe(
|
||||
'Filter to select which records to delete. Supports field-level filters and logical operators (or, and, not). WARNING: A broad filter may delete many records at once. Always verify the filter scope with a find query first.',
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export type BulkDeleteToolInput = {
|
||||
filter: Record<string, unknown>;
|
||||
};
|
||||
|
||||
+38
-427
@@ -6,127 +6,47 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
import {
|
||||
AddressFilterSchema,
|
||||
ArrayFieldFilterSchema,
|
||||
BooleanFilterSchema,
|
||||
CurrencyFilterSchema,
|
||||
DateFilterSchema,
|
||||
DefaultFilterSchema,
|
||||
EmailsFilterSchema,
|
||||
FullNameFilterSchema,
|
||||
LinksFilterSchema,
|
||||
NullCheckEnum,
|
||||
NumberFilterSchema,
|
||||
PhonesFilterSchema,
|
||||
TextFilterSchema,
|
||||
UuidFilterSchema,
|
||||
} from 'src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema';
|
||||
|
||||
const NullCheckEnum = z.enum(['NULL', 'NOT_NULL']);
|
||||
export { NullCheckEnum };
|
||||
|
||||
export const generateFieldFilterZodSchema = (
|
||||
field: FieldMetadataEntity | FlatFieldMetadata,
|
||||
): z.ZodTypeAny | null => {
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.UUID:
|
||||
return z
|
||||
.object({
|
||||
eq: z.string().uuid().optional().describe('Equals'),
|
||||
neq: z.string().uuid().optional().describe('Not equals'),
|
||||
in: z
|
||||
.array(z.string().uuid())
|
||||
.optional()
|
||||
.describe('In array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (UUID field)`);
|
||||
return UuidFilterSchema;
|
||||
|
||||
case FieldMetadataType.TEXT:
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
return z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
in: z.array(z.string()).optional().describe('In array of values'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Case-sensitive pattern match (use % for wildcards)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Case-insensitive pattern match (use % for wildcards)'),
|
||||
startsWith: z.string().optional().describe('Starts with'),
|
||||
endsWith: z.string().optional().describe('Ends with'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (text field)`);
|
||||
return TextFilterSchema;
|
||||
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.POSITION:
|
||||
return z
|
||||
.object({
|
||||
eq: z.number().optional().describe('Equals'),
|
||||
neq: z.number().optional().describe('Not equals'),
|
||||
gt: z.number().optional().describe('Greater than'),
|
||||
gte: z.number().optional().describe('Greater than or equal'),
|
||||
lt: z.number().optional().describe('Less than'),
|
||||
lte: z.number().optional().describe('Less than or equal'),
|
||||
in: z.array(z.number()).optional().describe('In array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (number field)`);
|
||||
return NumberFilterSchema;
|
||||
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
return z
|
||||
.object({
|
||||
eq: z.boolean().optional().describe('Equals'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (boolean field)`);
|
||||
return BooleanFilterSchema;
|
||||
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
case FieldMetadataType.DATE:
|
||||
return z
|
||||
.object({
|
||||
eq: z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.describe('Equals (ISO datetime string)'),
|
||||
neq: z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.describe('Not equals (ISO datetime string)'),
|
||||
gt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.describe('Greater than (ISO datetime string)'),
|
||||
gte: z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.describe('Greater than or equal (ISO datetime string)'),
|
||||
lt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.describe('Less than (ISO datetime string)'),
|
||||
lte: z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.describe('Less than or equal (ISO datetime string)'),
|
||||
in: z
|
||||
.array(z.string().datetime())
|
||||
.optional()
|
||||
.describe('In array of values (ISO datetime strings)'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (date field)`);
|
||||
return DateFilterSchema;
|
||||
|
||||
case FieldMetadataType.SELECT: {
|
||||
const enumValues =
|
||||
@@ -143,12 +63,9 @@ export const generateFieldFilterZodSchema = (
|
||||
eq: selectEnum.optional().describe('Equals'),
|
||||
neq: selectEnum.optional().describe('Not equals'),
|
||||
in: z.array(selectEnum).optional().describe('In array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (select field)`);
|
||||
.optional();
|
||||
}
|
||||
|
||||
case FieldMetadataType.MULTI_SELECT: {
|
||||
@@ -167,13 +84,10 @@ export const generateFieldFilterZodSchema = (
|
||||
.array(multiSelectEnum)
|
||||
.optional()
|
||||
.describe('Contains any of these values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
is: NullCheckEnum.optional(),
|
||||
isEmptyArray: z.boolean().optional().describe('Is empty array'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (multi-select field)`);
|
||||
.optional();
|
||||
}
|
||||
|
||||
case FieldMetadataType.RATING: {
|
||||
@@ -190,350 +104,47 @@ export const generateFieldFilterZodSchema = (
|
||||
.object({
|
||||
eq: ratingEnum.optional().describe('Equals'),
|
||||
in: z.array(ratingEnum).optional().describe('In array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (rating field)`);
|
||||
.optional();
|
||||
}
|
||||
|
||||
case FieldMetadataType.ARRAY:
|
||||
return z
|
||||
.object({
|
||||
containsIlike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Contains case-insensitive substring'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
isEmptyArray: z.boolean().optional().describe('Is empty array'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (array field)`);
|
||||
return ArrayFieldFilterSchema;
|
||||
|
||||
case FieldMetadataType.CURRENCY:
|
||||
return z
|
||||
.object({
|
||||
amountMicros: z
|
||||
.object({
|
||||
eq: z.number().optional().describe('Amount equals'),
|
||||
neq: z.number().optional().describe('Amount not equals'),
|
||||
gt: z.number().optional().describe('Amount greater than'),
|
||||
gte: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Amount greater than or equal'),
|
||||
lt: z.number().optional().describe('Amount less than'),
|
||||
lte: z.number().optional().describe('Amount less than or equal'),
|
||||
in: z
|
||||
.array(z.number())
|
||||
.optional()
|
||||
.describe('Amount in array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty amount. Use "NULL" to find records with no amount, "NOT_NULL" for records with an amount',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by amount'),
|
||||
currencyCode: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Currency code equals'),
|
||||
neq: z.string().optional().describe('Currency code not equals'),
|
||||
in: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Currency code in array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty currency code. Use "NULL" to find records with no currency code, "NOT_NULL" for records with a currency code',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by currency code'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (currency field)`);
|
||||
return CurrencyFilterSchema;
|
||||
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
return z
|
||||
.object({
|
||||
firstName: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('First name equals'),
|
||||
neq: z.string().optional().describe('First name not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('First name case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('First name case-insensitive pattern match'),
|
||||
startsWith: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('First name starts with'),
|
||||
endsWith: z.string().optional().describe('First name ends with'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty first name. Use "NULL" to find records with no first name, "NOT_NULL" for records with a first name',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by first name'),
|
||||
lastName: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Last name equals'),
|
||||
neq: z.string().optional().describe('Last name not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Last name case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Last name case-insensitive pattern match'),
|
||||
startsWith: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Last name starts with'),
|
||||
endsWith: z.string().optional().describe('Last name ends with'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty last name. Use "NULL" to find records with no last name, "NOT_NULL" for records with a last name',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by last name'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (full name field)`);
|
||||
return FullNameFilterSchema;
|
||||
|
||||
case FieldMetadataType.ADDRESS:
|
||||
return z
|
||||
.object({
|
||||
addressStreet1: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Street 1 equals'),
|
||||
neq: z.string().optional().describe('Street 1 not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Street 1 case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Street 1 case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty street 1. Use "NULL" to find records with no street 1, "NOT_NULL" for records with a street 1',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by street 1'),
|
||||
addressCity: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('City equals'),
|
||||
neq: z.string().optional().describe('City not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('City case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('City case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty city. Use "NULL" to find records with no city, "NOT_NULL" for records with a city',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by city'),
|
||||
addressCountry: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Country equals'),
|
||||
neq: z.string().optional().describe('Country not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Country case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Country case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty country. Use "NULL" to find records with no country, "NOT_NULL" for records with a country',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by country'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (address field)`);
|
||||
return AddressFilterSchema;
|
||||
|
||||
case FieldMetadataType.EMAILS:
|
||||
return z
|
||||
.object({
|
||||
primaryEmail: z
|
||||
.object({
|
||||
eq: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.describe('Primary email equals'),
|
||||
neq: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.describe('Primary email not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Primary email case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Primary email case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty primary email. Use "NULL" to find records with no primary email, "NOT_NULL" for records with a primary email',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by primary email'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (emails field)`);
|
||||
return EmailsFilterSchema;
|
||||
|
||||
case FieldMetadataType.PHONES:
|
||||
return z
|
||||
.object({
|
||||
primaryPhoneNumber: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Primary phone number equals'),
|
||||
neq: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Primary phone number not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Primary phone number case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Primary phone number case-insensitive pattern match',
|
||||
),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty primary phone number. Use "NULL" to find records with no primary phone number, "NOT_NULL" for records with a primary phone number',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by primary phone number'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (phones field)`);
|
||||
return PhonesFilterSchema;
|
||||
|
||||
case FieldMetadataType.LINKS:
|
||||
return z
|
||||
.object({
|
||||
primaryLinkUrl: z
|
||||
.object({
|
||||
eq: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe('Primary link URL equals'),
|
||||
neq: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe('Primary link URL not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Primary link URL case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Primary link URL case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty primary link URL. Use "NULL" to find records with no primary link URL, "NOT_NULL" for records with a primary link URL',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe('Filter by primary link URL'),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (links field)`);
|
||||
return LinksFilterSchema;
|
||||
|
||||
case FieldMetadataType.RELATION:
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
const fieldName = `${field.name}Id`;
|
||||
|
||||
return z
|
||||
.object({
|
||||
eq: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Related record ID equals'),
|
||||
neq: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Related record ID not equals'),
|
||||
in: z
|
||||
.array(z.string().uuid())
|
||||
.optional()
|
||||
.describe('Related record ID in array of values'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty related record. Use "NULL" to find records with no relation, "NOT_NULL" for records with a relation',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${fieldName} (relation field)`);
|
||||
return UuidFilterSchema;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
case FieldMetadataType.RAW_JSON:
|
||||
case FieldMetadataType.FILES:
|
||||
return z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Raw JSON equals'),
|
||||
neq: z.string().optional().describe('Raw JSON not equals'),
|
||||
like: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Raw JSON case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Raw JSON case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (raw JSON field)`);
|
||||
return DefaultFilterSchema;
|
||||
|
||||
default:
|
||||
return z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('Case-sensitive pattern match'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Case-insensitive pattern match'),
|
||||
is: NullCheckEnum.optional().describe(
|
||||
'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(`Filter by ${field.name} (string field)`);
|
||||
return DefaultFilterSchema;
|
||||
}
|
||||
};
|
||||
|
||||
+8
@@ -2,6 +2,14 @@ import { z } from 'zod';
|
||||
|
||||
export const FindOneToolInputSchema = z.object({
|
||||
id: z.string().uuid().describe('The unique UUID of the record to retrieve'),
|
||||
select: z
|
||||
.array(z.string())
|
||||
.nonempty()
|
||||
.describe(
|
||||
'Fields to include in the response. Required. ' +
|
||||
"Use '*' to return all fields. " +
|
||||
' MANY_TO_ONE relations are referenced by their FK column (e.g. companyId).',
|
||||
),
|
||||
});
|
||||
|
||||
export type FindOneToolInput = z.infer<typeof FindOneToolInputSchema>;
|
||||
|
||||
+15
-4
@@ -9,11 +9,10 @@ export const generateFindToolInputSchema = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
) => {
|
||||
const { filterShape, filterSchema } = generateRecordFilterSchema(
|
||||
const { filterShape, filterSchema } = generateRecordFilterSchema({
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
);
|
||||
|
||||
});
|
||||
return z.object({
|
||||
limit: z
|
||||
.number()
|
||||
@@ -31,8 +30,20 @@ export const generateFindToolInputSchema = (
|
||||
.default(0)
|
||||
.describe('Number of records to skip (default: 0)'),
|
||||
orderBy: ObjectRecordOrderBySchema.describe(
|
||||
'Sort records by field(s). CRITICAL for "top N", "largest", "smallest" queries. Each item is an object with exactly ONE property: field name as key, sort direction as value. Example: [{"employees": "DescNullsLast"}] sorts employees descending. Use "DescNullsLast" for top/largest, "AscNullsFirst" for bottom/smallest.',
|
||||
'Sort by field(s). ' +
|
||||
'Scalar fields: [{fieldName: "DescNullsLast"}]. ' +
|
||||
'Composite fields (name, address, currency, …): [{fieldName: {subFieldName: "AscNullsFirst"}}] — e.g. [{"name": {"firstName": "AscNullsFirst"}}]. ' +
|
||||
'Never use dot-notation keys like "name.firstName". ' +
|
||||
'Use DescNullsLast for top/largest, AscNullsFirst for bottom/smallest.',
|
||||
),
|
||||
select: z
|
||||
.array(z.string())
|
||||
.nonempty()
|
||||
.describe(
|
||||
`Fields to include in the response. Required. ` +
|
||||
`Use '*' to return all fields, or list specific field names. ` +
|
||||
`MANY_TO_ONE relations are referenced by their FK column (e.g. 'companyId'). `,
|
||||
),
|
||||
...filterShape,
|
||||
or: z
|
||||
.array(filterSchema)
|
||||
|
||||
+2
-2
@@ -147,10 +147,10 @@ export const generateGroupByToolInputSchema = (
|
||||
groupByEntries as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
|
||||
);
|
||||
|
||||
const { filterShape, filterSchema } = generateRecordFilterSchema(
|
||||
const { filterShape, filterSchema } = generateRecordFilterSchema({
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
);
|
||||
});
|
||||
|
||||
const availableAggregations = getAvailableAggregationsFromObjectFields(
|
||||
objectMetadata.fields.filter(
|
||||
|
||||
+14
-3
@@ -6,20 +6,31 @@ export const OrderByDirectionEnum = z.enum([
|
||||
'DescNullsFirst',
|
||||
'DescNullsLast',
|
||||
]);
|
||||
export const OrderByFieldValueSchema = z.union([
|
||||
OrderByDirectionEnum,
|
||||
z.record(z.string(), OrderByDirectionEnum),
|
||||
]);
|
||||
|
||||
export const ObjectRecordOrderByItemSchema = z
|
||||
.object({})
|
||||
.catchall(OrderByDirectionEnum)
|
||||
.catchall(OrderByFieldValueSchema)
|
||||
.refine((obj) => Object.keys(obj).length === 1, {
|
||||
message: 'Each orderBy item must specify exactly one field',
|
||||
})
|
||||
.describe(
|
||||
'Object with exactly ONE property: field name as key, OrderByDirection as value. Example: {"employees": "DescNullsLast"}',
|
||||
'Object with exactly ONE property. ' +
|
||||
'For scalar fields use a direction string: {"employees": "DescNullsLast"}. ' +
|
||||
'For composite fields (e.g. name, address, currency) use a nested object with the sub-field: {"name": {"firstName": "AscNullsFirst"}}. ' +
|
||||
'Never use dot-notation keys like "name.firstName".',
|
||||
);
|
||||
|
||||
export const ObjectRecordOrderBySchema = z
|
||||
.array(ObjectRecordOrderByItemSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'Array of sort criteria. Each item sorts by one field. Use "DescNullsLast" for descending (top/largest), "AscNullsFirst" for ascending (bottom/smallest). Example: [{"employees": "DescNullsLast"}]',
|
||||
'Array of sort criteria. Each item sorts by one field. ' +
|
||||
'Scalar fields: [{"employees": "DescNullsLast"}]. ' +
|
||||
'Composite fields (name, address, currency, …): [{"name": {"firstName": "AscNullsFirst"}}]. ' +
|
||||
'Use "DescNullsLast" for descending (top/largest), "AscNullsFirst" for ascending (bottom/smallest). ' +
|
||||
'Never use dot-notation keys like "name.firstName".',
|
||||
);
|
||||
|
||||
+17
-6
@@ -11,18 +11,29 @@ import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modul
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
// Builds the per-field filter shape and full recursive filter schema
|
||||
// for a given object metadata, reusable across find and updateMany tools
|
||||
export const generateRecordFilterSchema = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
): {
|
||||
// for a given object metadata, reusable across find, delete, and updateMany tools
|
||||
export const generateRecordFilterSchema = ({
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
additionalExcludedFieldNames = [],
|
||||
}: {
|
||||
objectMetadata: ObjectMetadataForToolSchema;
|
||||
restrictedFields?: RestrictedFieldsPermissions;
|
||||
additionalExcludedFieldNames?: string[];
|
||||
}): {
|
||||
filterShape: Record<string, z.ZodTypeAny>;
|
||||
filterSchema: z.ZodTypeAny;
|
||||
} => {
|
||||
const filterShape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
objectMetadata.fields.forEach((field) => {
|
||||
if (shouldExcludeFieldFromAgentToolSchema(field)) {
|
||||
if (
|
||||
shouldExcludeFieldFromAgentToolSchema(
|
||||
field,
|
||||
true,
|
||||
additionalExcludedFieldNames,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+91
-63
@@ -11,6 +11,24 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
|
||||
|
||||
import { filesFieldSchema } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
|
||||
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
|
||||
import {
|
||||
AddressValueOptionalSchema,
|
||||
AddressValueSchema,
|
||||
CurrencyValueOptionalSchema,
|
||||
CurrencyValueSchema,
|
||||
EmailsValueOptionalSchema,
|
||||
EmailsValueSchema,
|
||||
FullNameValueOptionalSchema,
|
||||
FullNameValueSchema,
|
||||
LinksValueOptionalSchema,
|
||||
LinksValueSchema,
|
||||
PhonesValueOptionalSchema,
|
||||
PhonesValueSchema,
|
||||
RichTextValueOptionalSchema,
|
||||
RichTextValueSchema,
|
||||
UuidValueOptionalSchema,
|
||||
UuidValueSchema,
|
||||
} from 'src/engine/core-modules/record-crud/zod-schemas/shared-value-defs.zod-schema';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
@@ -34,7 +52,7 @@ const isFieldAvailable = (field: FlatFieldMetadata, forResponse: boolean) => {
|
||||
const getFieldZodType = (field: FlatFieldMetadata): z.ZodTypeAny => {
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.UUID:
|
||||
return z.string().uuidv4();
|
||||
return UuidValueSchema;
|
||||
|
||||
case FieldMetadataType.TEXT:
|
||||
return z.string();
|
||||
@@ -101,11 +119,9 @@ export const generateRecordPropertiesZodSchema = (
|
||||
isRelationOrMorphRelation &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
const uuidSchema = z.uuidv4();
|
||||
|
||||
shape[`${field.name}Id`] = field.isNullable
|
||||
? uuidSchema.optional()
|
||||
: uuidSchema;
|
||||
? UuidValueOptionalSchema
|
||||
: UuidValueSchema;
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -160,47 +176,49 @@ export const generateRecordPropertiesZodSchema = (
|
||||
break;
|
||||
}
|
||||
|
||||
case FieldMetadataType.LINKS:
|
||||
fieldSchema = z.object({
|
||||
primaryLinkLabel: z.string().optional(),
|
||||
primaryLinkUrl: z.string().url().optional(),
|
||||
secondaryLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
label: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
break;
|
||||
case FieldMetadataType.LINKS: {
|
||||
const baseSchema = field.isNullable
|
||||
? LinksValueOptionalSchema
|
||||
: LinksValueSchema;
|
||||
|
||||
case FieldMetadataType.CURRENCY:
|
||||
fieldSchema = z.object({
|
||||
amountMicros: z.number().optional(),
|
||||
currencyCode: z.string().optional(),
|
||||
});
|
||||
break;
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
fieldSchema = z.object({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
});
|
||||
break;
|
||||
case FieldMetadataType.CURRENCY: {
|
||||
const baseSchema = field.isNullable
|
||||
? CurrencyValueOptionalSchema
|
||||
: CurrencyValueSchema;
|
||||
|
||||
case FieldMetadataType.ADDRESS:
|
||||
fieldSchema = z.object({
|
||||
addressStreet1: z.string().optional(),
|
||||
addressStreet2: z.string().optional(),
|
||||
addressCity: z.string().optional(),
|
||||
addressPostcode: z.string().optional(),
|
||||
addressState: z.string().optional(),
|
||||
addressCountry: z.string().optional(),
|
||||
addressLat: z.number().optional(),
|
||||
addressLng: z.number().optional(),
|
||||
});
|
||||
break;
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.FULL_NAME: {
|
||||
const baseSchema = field.isNullable
|
||||
? FullNameValueOptionalSchema
|
||||
: FullNameValueSchema;
|
||||
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.ADDRESS: {
|
||||
const baseSchema = field.isNullable
|
||||
? AddressValueOptionalSchema
|
||||
: AddressValueSchema;
|
||||
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.ACTOR:
|
||||
fieldSchema = z.object({
|
||||
@@ -226,28 +244,38 @@ export const generateRecordPropertiesZodSchema = (
|
||||
});
|
||||
break;
|
||||
|
||||
case FieldMetadataType.EMAILS:
|
||||
fieldSchema = z.object({
|
||||
primaryEmail: z.string().email().optional(),
|
||||
additionalEmails: z.array(z.string().email()).optional(),
|
||||
});
|
||||
break;
|
||||
case FieldMetadataType.EMAILS: {
|
||||
const baseSchema = field.isNullable
|
||||
? EmailsValueOptionalSchema
|
||||
: EmailsValueSchema;
|
||||
|
||||
case FieldMetadataType.PHONES:
|
||||
fieldSchema = z.object({
|
||||
primaryPhoneNumber: z.string().optional(),
|
||||
primaryPhoneCountryCode: z.string().optional(),
|
||||
primaryPhoneCallingCode: z.string().optional(),
|
||||
additionalPhones: z.array(z.string()).optional(),
|
||||
});
|
||||
break;
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
fieldSchema = z.object({
|
||||
markdown: z.string().optional(),
|
||||
blocknote: z.string().optional(),
|
||||
});
|
||||
break;
|
||||
case FieldMetadataType.PHONES: {
|
||||
const baseSchema = field.isNullable
|
||||
? PhonesValueOptionalSchema
|
||||
: PhonesValueSchema;
|
||||
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.RICH_TEXT: {
|
||||
const baseSchema = field.isNullable
|
||||
? RichTextValueOptionalSchema
|
||||
: RichTextValueSchema;
|
||||
|
||||
shape[field.name] = field.description
|
||||
? baseSchema.describe(field.description)
|
||||
: baseSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
case FieldMetadataType.FILES:
|
||||
fieldSchema = filesFieldSchema;
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NullCheckEnum = z
|
||||
.enum(['NULL', 'NOT_NULL'])
|
||||
.describe('Is null or not null');
|
||||
|
||||
export const TextFilterSchema = z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
in: z.array(z.string()).optional().describe('In array'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
startsWith: z.string().optional().describe('Starts with'),
|
||||
endsWith: z.string().optional().describe('Ends with'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const NumberFilterSchema = z
|
||||
.object({
|
||||
eq: z.number().optional().describe('Equals'),
|
||||
neq: z.number().optional().describe('Not equals'),
|
||||
gt: z.number().optional().describe('>'),
|
||||
gte: z.number().optional().describe('>='),
|
||||
lt: z.number().optional().describe('<'),
|
||||
lte: z.number().optional().describe('<='),
|
||||
in: z.array(z.number()).optional().describe('In array'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const DateFilterSchema = z
|
||||
.object({
|
||||
eq: z.string().datetime().optional().describe('Equals (ISO datetime)'),
|
||||
neq: z.string().datetime().optional().describe('Not equals (ISO datetime)'),
|
||||
gt: z.string().datetime().optional().describe('> ISO datetime'),
|
||||
gte: z.string().datetime().optional().describe('>= ISO datetime'),
|
||||
lt: z.string().datetime().optional().describe('< ISO datetime'),
|
||||
lte: z.string().datetime().optional().describe('<= ISO datetime'),
|
||||
in: z
|
||||
.array(z.string().datetime())
|
||||
.optional()
|
||||
.describe('In array (ISO datetimes)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const BooleanFilterSchema = z
|
||||
.object({
|
||||
eq: z.boolean().optional().describe('Equals'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const UuidFilterSchema = z
|
||||
.object({
|
||||
eq: z.string().uuid().optional().describe('Equals'),
|
||||
neq: z.string().uuid().optional().describe('Not equals'),
|
||||
in: z.array(z.string().uuid()).optional().describe('In array of values'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const DefaultFilterSchema = z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ArrayFieldFilterSchema = z
|
||||
.object({
|
||||
containsIlike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Contains case-insensitive substring'),
|
||||
is: NullCheckEnum.optional(),
|
||||
isEmptyArray: z.boolean().optional().describe('Is empty array'),
|
||||
})
|
||||
.optional();
|
||||
|
||||
// Composite filter schemas — A1.3
|
||||
// Each is a shared constant so all LINKS/ADDRESS/etc. fields share one $def.
|
||||
|
||||
export const LinksFilterSchema = z
|
||||
.object({
|
||||
primaryLinkUrl: z
|
||||
.object({
|
||||
eq: z.string().url().optional().describe('Equals'),
|
||||
neq: z.string().url().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const AddressFilterSchema = z
|
||||
.object({
|
||||
addressStreet1: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
addressCity: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
addressCountry: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const FullNameFilterSchema = z
|
||||
.object({
|
||||
firstName: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
startsWith: z.string().optional().describe('Starts with'),
|
||||
endsWith: z.string().optional().describe('Ends with'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
lastName: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
startsWith: z.string().optional().describe('Starts with'),
|
||||
endsWith: z.string().optional().describe('Ends with'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const EmailsFilterSchema = z
|
||||
.object({
|
||||
primaryEmail: z
|
||||
.object({
|
||||
eq: z.string().email().optional().describe('Equals'),
|
||||
neq: z.string().email().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const PhonesFilterSchema = z
|
||||
.object({
|
||||
primaryPhoneNumber: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CurrencyFilterSchema = z
|
||||
.object({
|
||||
amountMicros: z
|
||||
.object({
|
||||
eq: z.number().optional().describe('Equals'),
|
||||
neq: z.number().optional().describe('Not equals'),
|
||||
gt: z.number().optional().describe('>'),
|
||||
gte: z.number().optional().describe('>='),
|
||||
lt: z.number().optional().describe('<'),
|
||||
lte: z.number().optional().describe('<='),
|
||||
in: z.array(z.number()).optional().describe('In array'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
currencyCode: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
in: z.array(z.string()).optional().describe('In array'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UuidValueSchema = z.uuidv4();
|
||||
export const UuidValueOptionalSchema = UuidValueSchema.optional();
|
||||
|
||||
export const LinksValueSchema = z.object({
|
||||
primaryLinkLabel: z.string().optional(),
|
||||
primaryLinkUrl: z.string().url().optional(),
|
||||
secondaryLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
label: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export const LinksValueOptionalSchema = LinksValueSchema.optional();
|
||||
|
||||
export const CurrencyValueSchema = z.object({
|
||||
amountMicros: z.number().optional(),
|
||||
currencyCode: z.string().optional(),
|
||||
});
|
||||
export const CurrencyValueOptionalSchema = CurrencyValueSchema.optional();
|
||||
|
||||
export const FullNameValueSchema = z.object({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
});
|
||||
export const FullNameValueOptionalSchema = FullNameValueSchema.optional();
|
||||
|
||||
export const AddressValueSchema = z.object({
|
||||
addressStreet1: z.string().optional(),
|
||||
addressStreet2: z.string().optional(),
|
||||
addressCity: z.string().optional(),
|
||||
addressPostcode: z.string().optional(),
|
||||
addressState: z.string().optional(),
|
||||
addressCountry: z.string().optional(),
|
||||
addressLat: z.number().optional(),
|
||||
addressLng: z.number().optional(),
|
||||
});
|
||||
export const AddressValueOptionalSchema = AddressValueSchema.optional();
|
||||
|
||||
export const EmailsValueSchema = z.object({
|
||||
primaryEmail: z.string().email().optional(),
|
||||
additionalEmails: z.array(z.string().email()).optional(),
|
||||
});
|
||||
export const EmailsValueOptionalSchema = EmailsValueSchema.optional();
|
||||
|
||||
export const PhonesValueSchema = z.object({
|
||||
primaryPhoneNumber: z.string().optional(),
|
||||
primaryPhoneCountryCode: z.string().optional(),
|
||||
primaryPhoneCallingCode: z.string().optional(),
|
||||
additionalPhones: z.array(z.string()).optional(),
|
||||
});
|
||||
export const PhonesValueOptionalSchema = PhonesValueSchema.optional();
|
||||
|
||||
export const RichTextValueSchema = z.object({
|
||||
markdown: z.string().optional(),
|
||||
blocknote: z.string().optional(),
|
||||
});
|
||||
export const RichTextValueOptionalSchema = RichTextValueSchema.optional();
|
||||
+23
-23
@@ -109,15 +109,15 @@ describe('DatabaseToolProvider', () => {
|
||||
|
||||
expect(descriptorNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'create_note_target',
|
||||
'create_one_note_target',
|
||||
'create_many_note_targets',
|
||||
'update_note_target',
|
||||
'update_one_note_target',
|
||||
'update_many_note_targets',
|
||||
'delete_note_target',
|
||||
'create_task_target',
|
||||
'create_attachment',
|
||||
'create_timeline_activity',
|
||||
'create_person',
|
||||
'delete_one_note_target',
|
||||
'create_one_task_target',
|
||||
'create_one_attachment',
|
||||
'create_one_timeline_activity',
|
||||
'create_one_person',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -147,27 +147,27 @@ describe('DatabaseToolProvider', () => {
|
||||
|
||||
expect(descriptorNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'find_workspace_members',
|
||||
'find_messages',
|
||||
'find_calendar_events',
|
||||
'find_dashboards',
|
||||
'find_many_workspace_members',
|
||||
'find_many_messages',
|
||||
'find_many_calendar_events',
|
||||
'find_many_dashboards',
|
||||
]),
|
||||
);
|
||||
|
||||
expect(descriptorNames).toEqual(
|
||||
expect.not.arrayContaining([
|
||||
'create_workspace_member',
|
||||
'update_workspace_member',
|
||||
'delete_workspace_member',
|
||||
'create_message',
|
||||
'update_message',
|
||||
'delete_message',
|
||||
'create_calendar_event',
|
||||
'update_calendar_event',
|
||||
'delete_calendar_event',
|
||||
'create_dashboard',
|
||||
'update_dashboard',
|
||||
'delete_dashboard',
|
||||
'create_one_workspace_member',
|
||||
'update_one_workspace_member',
|
||||
'delete_one_workspace_member',
|
||||
'create_one_message',
|
||||
'update_one_message',
|
||||
'delete_one_message',
|
||||
'create_one_calendar_event',
|
||||
'update_one_calendar_event',
|
||||
'delete_one_calendar_event',
|
||||
'create_one_dashboard',
|
||||
'update_one_dashboard',
|
||||
'delete_one_dashboard',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
+77
-29
@@ -4,9 +4,8 @@ import {
|
||||
type ObjectsPermissions,
|
||||
type ObjectsPermissionsByRoleId,
|
||||
} from 'twenty-shared/types';
|
||||
import { camelToSnakeCase } from 'twenty-shared/utils';
|
||||
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
|
||||
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
@@ -17,6 +16,8 @@ import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/rec
|
||||
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
|
||||
import { generateUpdateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util';
|
||||
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
|
||||
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
|
||||
import { generateBulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema';
|
||||
import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema';
|
||||
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
|
||||
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
|
||||
@@ -32,6 +33,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
|
||||
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import z from 'zod';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseToolProvider implements ToolProvider {
|
||||
@@ -129,10 +131,10 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
|
||||
if (permission.canReadObjectRecords) {
|
||||
descriptors.push({
|
||||
name: `find_${snakePlural}`,
|
||||
name: `find_many_${snakePlural}`,
|
||||
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldIncludeSchema(`find_${snakePlural}`) && {
|
||||
...(shouldIncludeSchema(`find_many_${snakePlural}`) && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
generateFindToolInputSchema(objectMetadata, restrictedFields),
|
||||
),
|
||||
@@ -140,16 +142,16 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'find',
|
||||
operation: 'find_many',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'find',
|
||||
operation: 'find_many',
|
||||
});
|
||||
|
||||
descriptors.push({
|
||||
name: `find_one_${snakeSingular}`,
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} by ID.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldIncludeSchema(`find_one_${snakeSingular}`) && {
|
||||
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
|
||||
@@ -180,7 +182,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldGenerateGroupBy &&
|
||||
groupBySchema && {
|
||||
inputSchema: z.toJSONSchema(groupBySchema),
|
||||
inputSchema: toToolJsonSchema(groupBySchema),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
@@ -196,10 +198,10 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
|
||||
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
|
||||
descriptors.push({
|
||||
name: `create_${snakeSingular}`,
|
||||
name: `create_one_${snakeSingular}`,
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldIncludeSchema(`create_${snakeSingular}`) && {
|
||||
...(shouldIncludeSchema(`create_one_${snakeSingular}`) && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
|
||||
),
|
||||
@@ -207,11 +209,11 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'create',
|
||||
operation: 'create_one',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'create',
|
||||
operation: 'create_one',
|
||||
});
|
||||
|
||||
descriptors.push({
|
||||
@@ -237,10 +239,10 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
});
|
||||
|
||||
descriptors.push({
|
||||
name: `update_${snakeSingular}`,
|
||||
name: `update_one_${snakeSingular}`,
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldIncludeSchema(`update_${snakeSingular}`) && {
|
||||
...(shouldIncludeSchema(`update_one_${snakeSingular}`) && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
|
||||
),
|
||||
@@ -248,16 +250,16 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'update',
|
||||
operation: 'update_one',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'update',
|
||||
operation: 'update_one',
|
||||
});
|
||||
|
||||
descriptors.push({
|
||||
name: `update_many_${snakePlural}`,
|
||||
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
|
||||
description: `Apply the SAME field values to all ${objectMetadata.labelPlural} records matching a filter. Use when every matched record gets identical changes (e.g. bulk status change). For records that each have different data to update, use upsert_many_${snakePlural} instead. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldIncludeSchema(`update_many_${snakePlural}`) && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
@@ -276,24 +278,68 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'update_many',
|
||||
});
|
||||
}
|
||||
|
||||
if (permission.canSoftDeleteObjectRecords && canBeManagedByAutomation) {
|
||||
descriptors.push({
|
||||
name: `delete_${snakeSingular}`,
|
||||
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
|
||||
name: `upsert_many_${snakePlural}`,
|
||||
description: `Insert or update multiple ${objectMetadata.labelPlural} records in a single call, where each record has its own individual data. Use this instead of update_many_${snakePlural} when records need different field values. Existing records are matched by unique fields and updated; records with no match are created. Maximum 20 records per call. Returns the upserted records.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(shouldIncludeSchema(`delete_${snakeSingular}`) && {
|
||||
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
|
||||
...(shouldIncludeSchema(`upsert_many_${snakePlural}`) && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
generateCreateManyRecordInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'delete',
|
||||
operation: 'upsert_many',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'delete',
|
||||
operation: 'upsert_many',
|
||||
});
|
||||
}
|
||||
|
||||
if (permission.canSoftDeleteObjectRecords) {
|
||||
descriptors.push({
|
||||
name: `delete_one_${snakeSingular}`,
|
||||
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas && {
|
||||
inputSchema: toToolJsonSchema(DeleteToolInputSchema),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'delete_one',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'delete_one',
|
||||
});
|
||||
|
||||
descriptors.push({
|
||||
name: `delete_many_${snakePlural}`,
|
||||
description: `Soft-delete multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. Deleted records are hidden from normal queries and the operation is reversible. WARNING: Use specific filters to avoid unintended mass deletions.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas && {
|
||||
inputSchema: toToolJsonSchema(
|
||||
generateBulkDeleteToolInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'delete_many',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'delete_many',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -307,14 +353,16 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
snakePlural: string,
|
||||
): boolean {
|
||||
return (
|
||||
toolNames.has(`find_${snakePlural}`) ||
|
||||
toolNames.has(`find_many_${snakePlural}`) ||
|
||||
toolNames.has(`find_one_${snakeSingular}`) ||
|
||||
toolNames.has(`group_by_${snakePlural}`) ||
|
||||
toolNames.has(`create_${snakeSingular}`) ||
|
||||
toolNames.has(`create_one_${snakeSingular}`) ||
|
||||
toolNames.has(`create_many_${snakePlural}`) ||
|
||||
toolNames.has(`update_${snakeSingular}`) ||
|
||||
toolNames.has(`update_one_${snakeSingular}`) ||
|
||||
toolNames.has(`update_many_${snakePlural}`) ||
|
||||
toolNames.has(`delete_${snakeSingular}`)
|
||||
toolNames.has(`delete_one_${snakeSingular}`) ||
|
||||
toolNames.has(`delete_many_${snakePlural}`) ||
|
||||
toolNames.has(`upsert_many_${snakePlural}`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+36
-7
@@ -20,11 +20,13 @@ import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-u
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { DeleteManyRecordsService } from 'src/engine/core-modules/record-crud/services/delete-many-records.service';
|
||||
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { GroupByRecordsService } from 'src/engine/core-modules/record-crud/services/group-by-records.service';
|
||||
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
|
||||
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
|
||||
import { UpsertManyRecordsService } from 'src/engine/core-modules/record-crud/services/upsert-many-records.service';
|
||||
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
|
||||
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
@@ -48,7 +50,9 @@ export class ToolExecutorService {
|
||||
private readonly createManyRecordsService: CreateManyRecordsService,
|
||||
private readonly updateRecordService: UpdateRecordService,
|
||||
private readonly updateManyRecordsService: UpdateManyRecordsService,
|
||||
private readonly upsertManyRecordsService: UpsertManyRecordsService,
|
||||
private readonly deleteRecordService: DeleteRecordService,
|
||||
private readonly deleteManyRecordsService: DeleteManyRecordsService,
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
@InjectRepository(UserEntity)
|
||||
@@ -89,8 +93,8 @@ export class ToolExecutorService {
|
||||
context.authContext ?? (await this.buildAuthContext(context));
|
||||
|
||||
switch (ref.operation) {
|
||||
case 'find': {
|
||||
const { limit, offset, orderBy, ...filter } = args;
|
||||
case 'find_many': {
|
||||
const { limit, offset, orderBy, select, ...filter } = args;
|
||||
|
||||
return this.findRecordsService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
@@ -98,21 +102,28 @@ export class ToolExecutorService {
|
||||
orderBy: orderBy as FindRecordsParams['orderBy'],
|
||||
limit: limit as number | undefined,
|
||||
offset: offset as number | undefined,
|
||||
select: select as string[],
|
||||
shouldBuildEffectiveSelectFields: true,
|
||||
authContext,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
case 'find_one':
|
||||
case 'find_one': {
|
||||
const { select, id } = args;
|
||||
|
||||
return this.findRecordsService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
filter: { id: { eq: args.id } },
|
||||
filter: { id: { eq: id } },
|
||||
limit: 1,
|
||||
select: select as string[],
|
||||
shouldBuildEffectiveSelectFields: isDefined(select),
|
||||
authContext,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
case 'create':
|
||||
case 'create_one':
|
||||
return this.createRecordService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
objectRecord: args,
|
||||
@@ -132,7 +143,7 @@ export class ToolExecutorService {
|
||||
slimResponse: true,
|
||||
});
|
||||
|
||||
case 'update': {
|
||||
case 'update_one': {
|
||||
const { id, ...fields } = args;
|
||||
const objectRecord = Object.fromEntries(
|
||||
Object.entries(fields).filter(([, value]) => value !== undefined),
|
||||
@@ -158,7 +169,17 @@ export class ToolExecutorService {
|
||||
slimResponse: true,
|
||||
});
|
||||
|
||||
case 'delete':
|
||||
case 'upsert_many':
|
||||
return this.upsertManyRecordsService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
objectRecords: args.records as Record<string, unknown>[],
|
||||
authContext,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
createdBy: context.actorContext,
|
||||
slimResponse: true,
|
||||
});
|
||||
|
||||
case 'delete_one':
|
||||
return this.deleteRecordService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
objectRecordId: args.id as string,
|
||||
@@ -167,6 +188,14 @@ export class ToolExecutorService {
|
||||
soft: true,
|
||||
});
|
||||
|
||||
case 'delete_many':
|
||||
return this.deleteManyRecordsService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
filter: args.filter as Record<string, unknown>,
|
||||
authContext,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
});
|
||||
|
||||
case 'group_by': {
|
||||
const {
|
||||
groupBy,
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export const createExecuteToolTool = (
|
||||
return {
|
||||
success: false,
|
||||
message: `Tool "${toolName}" is not available`,
|
||||
error: `Tool "${toolName}" is not available in this context. Use learn_tools to discover available tools.`,
|
||||
error: `Tool "${toolName}" is not available in this context. Use get_tool_catalog to discover available tools.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -1,9 +1,11 @@
|
||||
export type DatabaseCrudOperation =
|
||||
| 'find'
|
||||
| 'find_many'
|
||||
| 'find_one'
|
||||
| 'create'
|
||||
| 'create_one'
|
||||
| 'create_many'
|
||||
| 'update'
|
||||
| 'update_one'
|
||||
| 'update_many'
|
||||
| 'delete'
|
||||
| 'upsert_many'
|
||||
| 'delete_one'
|
||||
| 'delete_many'
|
||||
| 'group_by';
|
||||
|
||||
+4
-4
@@ -18,7 +18,7 @@ class TwentyMCP:
|
||||
search_help_center. These are the 4 surfaces exposed directly.
|
||||
|
||||
- Workspace catalog: 250+ CRUD / view / workflow / dashboard tools
|
||||
like find_companies, create_person, update_opportunity. These are
|
||||
like find_many_companies, create_one_person, update_one_opportunity. These are
|
||||
reached through execute_tool as a dispatcher.
|
||||
|
||||
call_tool(name, args) accepts both — catalog tools are routed via
|
||||
@@ -47,7 +47,7 @@ class TwentyMCP:
|
||||
"""
|
||||
Call any Twenty tool by name.
|
||||
|
||||
Catalog tools (find_companies, create_person, …) are routed
|
||||
Catalog tools (find_many_companies, create_one_person, …) are routed
|
||||
through execute_tool. MCP-native tools are called directly.
|
||||
The execute_tool envelope { success, message, result } is
|
||||
unwrapped so you always get the inner tool's result back.
|
||||
@@ -60,7 +60,7 @@ class TwentyMCP:
|
||||
Tool result as parsed JSON
|
||||
|
||||
Example:
|
||||
companies = twenty.call_tool('find_companies', {'limit': 5})
|
||||
companies = twenty.call_tool('find_many_companies', {'limit': 5})
|
||||
# companies == {'records': [...], 'count': '5'}
|
||||
"""
|
||||
if not self._available:
|
||||
@@ -114,7 +114,7 @@ class TwentyMCP:
|
||||
# \`twenty\` is a pre-built instance of the TwentyMCP class above. It is
|
||||
# already bound in this module scope — DO NOT \`import twenty\`. There is
|
||||
# no Python package by that name. Just use it directly, e.g.:
|
||||
# companies = twenty.call_tool('find_companies', {'limit': 10})
|
||||
# companies = twenty.call_tool('find_many_companies', {'limit': 10})
|
||||
# --------------------------------------------------------------------------
|
||||
twenty = TwentyMCP()
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ToolOutput<T = object> = {
|
||||
message: string;
|
||||
error?: string;
|
||||
result?: T;
|
||||
warnings?: string[];
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
headers?: Record<string, string>;
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ export const WORKFLOW_SYSTEM_PROMPTS = {
|
||||
|
||||
Tool usage strategy:
|
||||
- Chain multiple tools to solve complex tasks
|
||||
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, etc.) over looping single-item calls
|
||||
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) over looping single-item calls
|
||||
- Use \`upsert_many_*\` instead of \`update_many_*\` when records have different data to set individually, or when some records may not exist yet
|
||||
- If a tool fails, try alternative approaches
|
||||
- Use results from one tool to inform the next
|
||||
- Don't give up after first failure - be persistent
|
||||
|
||||
+5
-4
@@ -29,11 +29,12 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need
|
||||
|
||||
## Database vs HTTP Tools
|
||||
|
||||
- Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations
|
||||
- Use database tools (find_many_*, find_one_*, create_one_*, create_many_*, update_one_*, update_many_*, upsert_many_*, delete_one_*, delete_many_*) for ALL Twenty CRM data operations
|
||||
- NEVER guess or construct API URLs — always use the appropriate database tool
|
||||
- The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data)
|
||||
- If you need to look up a record, learn and execute the corresponding find_one_* or find_many_* tool
|
||||
- For comparative/grouped analytics questions (by/per/top/most/least/average/total/ranking), use \`group_by_*\` instead of \`find_*\`; if multiple metrics are needed, run multiple \`group_by_*\` calls with the same dimensions and merge results.
|
||||
- If you need to look up a record by ID, use find_one_*; to search with filters, use find_many_*
|
||||
- For comparative/grouped analytics questions (by/per/top/most/least/average/total/ranking), use \`group_by_*\` instead of \`find_many_*\`; if multiple metrics are needed, run multiple \`group_by_*\` calls with the same dimensions and merge results.
|
||||
- **update_many_* vs upsert_many_***: use \`update_many_*\` when ALL matched records get the SAME data (e.g. mark all as closed). Use \`upsert_many_*\` when each record has different data to set, or when some records may not exist yet (insert-or-update per record).
|
||||
|
||||
## Data Efficiency
|
||||
|
||||
@@ -41,7 +42,7 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need
|
||||
- Always apply filters to narrow results — don't fetch all records of a type.
|
||||
- Fetch one type of data at a time and check if you have what you need before fetching more.
|
||||
- Every record returned consumes context. Fetching too many records at once will cause failures.
|
||||
- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, etc.) instead of looping single-item calls.
|
||||
- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) instead of looping single-item calls.
|
||||
|
||||
## Tool Strategy
|
||||
|
||||
|
||||
+74
-1
@@ -281,7 +281,16 @@ ${preloadedList}
|
||||
|
||||
const categoryLabel = this.getCategoryLabel(category);
|
||||
|
||||
sections.push(`
|
||||
if (category === ToolCategory.DATABASE_CRUD) {
|
||||
sections.push(
|
||||
this.buildDatabaseCrudCatalogSection(
|
||||
tools,
|
||||
preloadedSet,
|
||||
categoryLabel,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
sections.push(`
|
||||
#### ${categoryLabel} (${tools.length} tools)
|
||||
${tools
|
||||
.map((tool) => {
|
||||
@@ -290,6 +299,7 @@ ${tools
|
||||
return `- \`${tool.name}\`${status}`;
|
||||
})
|
||||
.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(`
|
||||
@@ -300,6 +310,69 @@ ${tools
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
private buildDatabaseCrudCatalogSection(
|
||||
tools: ToolIndexEntry[],
|
||||
preloadedSet: Set<string>,
|
||||
categoryLabel: string,
|
||||
): string {
|
||||
const operationOrder: string[] = [];
|
||||
const seenOps = new Set<string>();
|
||||
|
||||
const objectToolsMap = new Map<string, string[]>();
|
||||
const standaloneTools: ToolIndexEntry[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool.objectName && tool.operation) {
|
||||
const ops = objectToolsMap.get(tool.objectName) ?? [];
|
||||
|
||||
ops.push(tool.operation);
|
||||
objectToolsMap.set(tool.objectName, ops);
|
||||
|
||||
if (!seenOps.has(tool.operation)) {
|
||||
seenOps.add(tool.operation);
|
||||
operationOrder.push(tool.operation);
|
||||
}
|
||||
} else {
|
||||
standaloneTools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`];
|
||||
|
||||
if (objectToolsMap.size > 0) {
|
||||
const objectNames = [...objectToolsMap.keys()].sort();
|
||||
|
||||
lines.push(`Operations per object:`);
|
||||
lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``));
|
||||
|
||||
lines.push(`\nObjects (${objectNames.length}):`);
|
||||
lines.push(...objectNames.map((name) => `- \`${name}\``));
|
||||
|
||||
const findManyExample = tools.find((t) => t.operation === 'find_many');
|
||||
const findOneExample = tools.find(
|
||||
(t) =>
|
||||
t.operation === 'find_one' &&
|
||||
t.objectName === findManyExample?.objectName,
|
||||
);
|
||||
const examplePart =
|
||||
findManyExample && findOneExample
|
||||
? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\``
|
||||
: '';
|
||||
|
||||
lines.push(
|
||||
`\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const tool of standaloneTools) {
|
||||
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
|
||||
|
||||
lines.push(`- \`${tool.name}\`${status}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private getCategoryLabel(category: ToolCategory): string {
|
||||
switch (category) {
|
||||
case ToolCategory.DATABASE_CRUD:
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
|
||||
export const shouldExcludeFieldFromAgentToolSchema = (
|
||||
field: FieldMetadataEntity | FlatFieldMetadata,
|
||||
excludeId = true,
|
||||
additionalExcludedFieldNames: string[] = [],
|
||||
): boolean => {
|
||||
const excludedFieldNames = [
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
'searchVector',
|
||||
'createdBy',
|
||||
...additionalExcludedFieldNames,
|
||||
];
|
||||
|
||||
if (excludeId) {
|
||||
|
||||
+10
-9
@@ -183,7 +183,7 @@ For the fields you will create, make sure to create a good variety of field type
|
||||
|
||||
STEP 0: Present a plan to the user and wait for approval.
|
||||
- Use list_object_metadata_items to see all available objects in the workspace
|
||||
- Use find_people (limit: 5) and find_companies (limit: 5) and find_opportunities (limit: 5) to understand the existing seed data shape
|
||||
- Use find_many_people (limit: 5) and find_many_companies (limit: 5) and find_many_opportunities (limit: 5) to understand the existing seed data shape
|
||||
- Based on the user's business type, propose a plan that lists:
|
||||
- How People, Companies, and Opportunities map to the domain story (e.g. "People = Candidates", "Companies = Employers")
|
||||
- The 2–3 custom objects you will create, each with a one-line description of their role
|
||||
@@ -215,9 +215,9 @@ targetFieldIcon is like IconSomething, it's ok if it doesn't exist in the icon l
|
||||
STEP 6: Wait 3 seconds, for the backend side effects to be completed
|
||||
|
||||
STEP 7: Rename and enrich the first N records of People, Companies, and Opportunities.
|
||||
- Use find_people (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_companies (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_opportunities (limit: 50, orderBy: [{ position: "AscNullsFirst" }]) to get the IDs of the first records in each table
|
||||
- Use find_many_people (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_many_companies (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_many_opportunities (limit: 50, orderBy: [{ position: "AscNullsFirst" }]) to get the IDs of the first records in each table
|
||||
- Ordering by position ascending gives the earliest-inserted records, which are contiguous in the table — this keeps the demo data tightly grouped and makes the workspace feel coherent
|
||||
- For each standard object, call update_people / update_companies / update_opportunities **individually per record** (one call per record) to set domain-relevant names and field values:
|
||||
- For each standard object, call update_one_person / update_one_company / update_one_opportunity **individually per record** (one call per record) to set domain-relevant names and field values:
|
||||
- **People**: replace nameFirstName + nameLastName with realistic names that fit the domain role (e.g. for a law firm: "Sophie Martin", "James O'Brien"; for a clinic: "Dr. Clara Reyes", "Marco Bianchi"). Also set jobTitle to a domain-appropriate title.
|
||||
- **Companies**: replace name with realistic company names that fit the domain (e.g. for a law firm: "Ashford & Partners", "Nexus Legal Group"; for a clinic: "Meridian Health Clinic", "CarePoint Medical").
|
||||
- **Opportunities**: replace name with a domain-relevant deal name (e.g. "Q2 retainer — Ashford & Partners", "New patient intake — Meridian Health").
|
||||
@@ -636,9 +636,10 @@ print('Analysis complete!')
|
||||
instance of a class that has been pre-instantiated for you; just call methods
|
||||
on it directly.
|
||||
|
||||
Real catalog tools follow the pattern \`find_<object>\` / \`find_one_<object>\` /
|
||||
\`create_<object>\` / \`update_<object>\` / \`delete_<object>\` /
|
||||
\`group_by_<object>\` — e.g. \`find_companies\`, \`find_people\`, \`create_person\`.
|
||||
Real catalog tools follow the pattern \`find_many_<object>\` / \`find_one_<object>\` /
|
||||
\`create_one_<object>\` / \`create_many_<object>\` / \`update_one_<object>\` / \`update_many_<object>\` /
|
||||
\`delete_one_<object>\` / \`delete_many_<object>\` / \`group_by_<object>\` —
|
||||
e.g. \`find_many_companies\`, \`find_one_company\`, \`create_one_person\`.
|
||||
Call \`twenty.list_tools()\` to discover exact names. Catalog tools are routed
|
||||
through \`execute_tool\` automatically, and the helper raises an Exception on
|
||||
server-side failures with the error message.
|
||||
@@ -651,14 +652,14 @@ for tool in tools[:5]:
|
||||
print(f"- {tool['name']}")
|
||||
|
||||
# Find records — returns { 'records': [...], 'count': '5' }
|
||||
companies = twenty.call_tool('find_companies', {'limit': 5, 'offset': 0})
|
||||
companies = twenty.call_tool('find_many_companies', {'limit': 5, 'offset': 0})
|
||||
for c in companies['records']:
|
||||
print(c['name'], c.get('employees'))
|
||||
|
||||
# Create a record — arguments match the tool's inputSchema directly,
|
||||
# no nested 'data' wrapper. Use twenty.call_tool('learn_tools', ...) to
|
||||
# inspect a schema if unsure.
|
||||
result = twenty.call_tool('create_company', {
|
||||
result = twenty.call_tool('create_one_company', {
|
||||
'name': 'Acme Corp',
|
||||
'domainName': {'primaryLinkUrl': 'https://acme.com'},
|
||||
'position': 'first',
|
||||
@@ -666,7 +667,7 @@ result = twenty.call_tool('create_company', {
|
||||
print(f"Created company id={result['id']}")
|
||||
|
||||
# Update a record
|
||||
twenty.call_tool('update_person', {
|
||||
twenty.call_tool('update_one_person', {
|
||||
'id': 'person-uuid-here',
|
||||
'jobTitle': 'CEO',
|
||||
})
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
|
||||
limit: workflowActionInput.limit,
|
||||
authContext: executionContext.authContext,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
shouldBuildEffectiveSelectFields: false,
|
||||
});
|
||||
|
||||
if (!toolOutput.success) {
|
||||
|
||||
+29
-12
@@ -7,9 +7,9 @@ import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids
|
||||
const TEST_WORKSPACE_SCHEMA = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
|
||||
|
||||
const TOOL_NAMES = {
|
||||
createCompany: 'create_company',
|
||||
createNote: 'create_note',
|
||||
createNoteTarget: 'create_note_target',
|
||||
createCompany: 'create_one_company',
|
||||
createNote: 'create_one_note',
|
||||
createNoteTarget: 'create_one_note_target',
|
||||
groupByNoteTargets: 'group_by_note_targets',
|
||||
} as const;
|
||||
|
||||
@@ -135,15 +135,32 @@ describe('MCP tool execution (integration)', () => {
|
||||
it('should expose the morph-relation join columns as `${name}Id` UUID parameters', async () => {
|
||||
const inputSchema = await learnToolSchema(TOOL_NAMES.createNoteTarget);
|
||||
|
||||
const properties = (
|
||||
inputSchema as {
|
||||
properties?: Record<string, { type?: string; format?: string }>;
|
||||
}
|
||||
).properties;
|
||||
const schema = inputSchema as {
|
||||
properties?: Record<
|
||||
string,
|
||||
{ type?: string; format?: string; $ref?: string }
|
||||
>;
|
||||
$defs?: Record<string, { type?: string; format?: string }>;
|
||||
};
|
||||
|
||||
const properties = schema.properties;
|
||||
|
||||
expect(properties).toBeDefined();
|
||||
|
||||
expect(properties?.noteId).toMatchObject({
|
||||
const resolveProperty = (
|
||||
prop: { type?: string; format?: string; $ref?: string } | undefined,
|
||||
) => {
|
||||
if (!prop) return undefined;
|
||||
if (prop.$ref && schema.$defs) {
|
||||
const defKey = prop.$ref.replace('#/$defs/', '');
|
||||
|
||||
return schema.$defs[defKey];
|
||||
}
|
||||
|
||||
return prop;
|
||||
};
|
||||
|
||||
expect(resolveProperty(properties?.noteId)).toMatchObject({
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
});
|
||||
@@ -151,15 +168,15 @@ describe('MCP tool execution (integration)', () => {
|
||||
// Morph relations must be exposed as `${name}Id` UUIDs (the join column),
|
||||
// not as the relation name typed as string — the data-arg-processor only
|
||||
// accepts the join-column form for write operations.
|
||||
expect(properties?.targetCompanyId).toMatchObject({
|
||||
expect(resolveProperty(properties?.targetCompanyId)).toMatchObject({
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
});
|
||||
expect(properties?.targetPersonId).toMatchObject({
|
||||
expect(resolveProperty(properties?.targetPersonId)).toMatchObject({
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
});
|
||||
expect(properties?.targetOpportunityId).toMatchObject({
|
||||
expect(resolveProperty(properties?.targetOpportunityId)).toMatchObject({
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user