[AI] Add group_by_* database tools and centralize groupBy validation (#19406)
closes https://discord.com/channels/1130383047699738754/1488990242873806868 https://github.com/user-attachments/assets/2b2bbfba-3fa6-4114-9a26-96a61599d748 <img width="729" height="1283" alt="CleanShot 2026-04-07 at 20 43 06" src="https://github.com/user-attachments/assets/815efb97-81a0-44ea-8d79-b3ce7d5b00b6" /> <img width="708" height="1266" alt="CleanShot 2026-04-07 at 20 40 13" src="https://github.com/user-attachments/assets/692366bc-b629-4d9f-b6b8-ab670d5ad046" /> <img width="665" height="3524" alt="CleanShot 2026-04-07 at 20 42 00" src="https://github.com/user-attachments/assets/5e844e0f-7835-47a8-9d20-a5baddc0992d" />
This commit is contained in:
@@ -7,6 +7,7 @@ import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/se
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.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 { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service';
|
||||
@@ -30,6 +31,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
UpdateManyRecordsService,
|
||||
DeleteRecordService,
|
||||
FindRecordsService,
|
||||
GroupByRecordsService,
|
||||
UpsertRecordService,
|
||||
],
|
||||
exports: [
|
||||
@@ -39,6 +41,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
UpdateManyRecordsService,
|
||||
DeleteRecordService,
|
||||
FindRecordsService,
|
||||
GroupByRecordsService,
|
||||
UpsertRecordService,
|
||||
],
|
||||
})
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import {
|
||||
AggregateOperations,
|
||||
OrderByDirection,
|
||||
type OrderByWithGroupBy,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service';
|
||||
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
|
||||
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
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 GroupByRecordsParams } from 'src/engine/core-modules/record-crud/types/group-by-records-params.type';
|
||||
import { type GroupByRecordsResult } from 'src/engine/core-modules/record-crud/types/group-by-records-result.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
|
||||
@Injectable()
|
||||
export class GroupByRecordsService {
|
||||
private readonly logger = new Logger(GroupByRecordsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly commonGroupByRunner: CommonGroupByQueryRunnerService,
|
||||
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
|
||||
private readonly groupByArgProcessor: GroupByArgProcessorService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
params: GroupByRecordsParams,
|
||||
): Promise<ToolOutput<GroupByRecordsResult>> {
|
||||
const {
|
||||
objectName,
|
||||
groupBy,
|
||||
aggregateOperation = AggregateOperations.COUNT,
|
||||
aggregateFieldName,
|
||||
limit,
|
||||
orderBy = 'DESC',
|
||||
filter,
|
||||
authContext,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const {
|
||||
queryRunnerContext,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
objectsPermissions,
|
||||
} = await this.commonApiContextBuilder.build({
|
||||
authContext,
|
||||
objectName,
|
||||
});
|
||||
|
||||
const availableAggregations =
|
||||
this.groupByArgProcessor.getAvailableAggregations({
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
restrictedFields:
|
||||
objectsPermissions[flatObjectMetadata.id]?.restrictedFields,
|
||||
});
|
||||
|
||||
let aggregateFieldKey: string;
|
||||
|
||||
try {
|
||||
aggregateFieldKey =
|
||||
this.groupByArgProcessor.resolveToolAggregateFieldKeyOrThrow({
|
||||
aggregateOperation,
|
||||
aggregateFieldName,
|
||||
availableAggregations,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CommonQueryRunnerException) {
|
||||
throw new RecordCrudException(
|
||||
error.message,
|
||||
RecordCrudExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const selectedFields = {
|
||||
[aggregateFieldKey]: true,
|
||||
groupByDimensionValues: true,
|
||||
};
|
||||
|
||||
const mappedOrderBy: OrderByWithGroupBy = [
|
||||
{
|
||||
aggregate: {
|
||||
[aggregateFieldKey]:
|
||||
orderBy === 'ASC'
|
||||
? OrderByDirection.AscNullsLast
|
||||
: OrderByDirection.DescNullsLast,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const clampedLimit = limit
|
||||
? Math.min(limit, QUERY_MAX_RECORDS)
|
||||
: QUERY_MAX_RECORDS;
|
||||
|
||||
const { results } = await this.commonGroupByRunner.execute(
|
||||
{
|
||||
filter: filter ?? {},
|
||||
groupBy,
|
||||
orderBy: mappedOrderBy,
|
||||
selectedFields,
|
||||
limit: clampedLimit,
|
||||
},
|
||||
queryRunnerContext,
|
||||
);
|
||||
|
||||
const dimensionLabels = groupBy.map((entry) =>
|
||||
this.getDimensionLabelFromGroupByEntry(entry),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Grouped ${objectName} by ${dimensionLabels.join(', ')}: ${results.length} groups`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Grouped ${objectName} by ${dimensionLabels.join(', ')}: ${results.length} groups`,
|
||||
result: {
|
||||
groups: results.map((item) => ({
|
||||
dimensions: item.groupByDimensionValues,
|
||||
value: item[aggregateFieldKey],
|
||||
})),
|
||||
dimensionLabels,
|
||||
aggregation: aggregateOperation,
|
||||
groupCount: results.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RecordCrudException) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to group ${objectName} records`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to group records: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to group ${objectName} records`,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to group records',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getDimensionLabelFromGroupByEntry(
|
||||
entry: GroupByRecordsParams['groupBy'][number],
|
||||
): string {
|
||||
const fieldEntries = Object.entries(entry);
|
||||
|
||||
if (fieldEntries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [fieldName, fieldDefinition] = fieldEntries[0];
|
||||
|
||||
if (fieldDefinition === true) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
if (typeof fieldDefinition !== 'object' || fieldDefinition === null) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
const nestedEntries = Object.entries(fieldDefinition);
|
||||
|
||||
if (nestedEntries.length !== 1) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
const [nestedFieldName, nestedFieldDefinition] = nestedEntries[0];
|
||||
|
||||
if (nestedFieldDefinition !== true) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
if (nestedFieldName === 'id' && fieldName.endsWith('Id')) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
return `${fieldName}.${nestedFieldName}`;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type AggregateOperations } from 'twenty-shared/types';
|
||||
|
||||
import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type RecordCrudExecutionContext } from './record-crud-execution-context.type';
|
||||
|
||||
export type GroupByRecordsParams = RecordCrudExecutionContext & {
|
||||
objectName: string;
|
||||
groupBy: ObjectRecordGroupBy;
|
||||
aggregateOperation?: keyof typeof AggregateOperations;
|
||||
aggregateFieldName?: string;
|
||||
limit?: number;
|
||||
orderBy?: 'ASC' | 'DESC';
|
||||
filter?: Record<string, unknown>;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type AggregateOperations } from 'twenty-shared/types';
|
||||
|
||||
export type GroupByRecordsResult = {
|
||||
groups: Array<{ dimensions: string[]; value: string | number | null }>;
|
||||
dimensionLabels: string[];
|
||||
aggregation: keyof typeof AggregateOperations;
|
||||
groupCount: number;
|
||||
};
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { GraphQLFloat, GraphQLInt } from 'graphql';
|
||||
import { AggregateOperations, FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/utils/resolve-aggregate-field-key.util';
|
||||
|
||||
const availableAggregations: Record<string, AggregationField> = {
|
||||
totalCount: {
|
||||
type: GraphQLInt,
|
||||
description: 'Total count',
|
||||
fromField: '*',
|
||||
fromFieldType: FieldMetadataType.UUID,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
},
|
||||
sumEmployees: {
|
||||
type: GraphQLFloat,
|
||||
description: 'Sum of employees',
|
||||
fromField: 'employees',
|
||||
fromFieldType: FieldMetadataType.NUMBER,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
},
|
||||
avgEmployees: {
|
||||
type: GraphQLFloat,
|
||||
description: 'Average of employees',
|
||||
fromField: 'employees',
|
||||
fromFieldType: FieldMetadataType.NUMBER,
|
||||
aggregateOperation: AggregateOperations.AVG,
|
||||
},
|
||||
sumAmountAmountMicros: {
|
||||
type: GraphQLFloat,
|
||||
description: 'Sum of amount',
|
||||
fromField: 'amount',
|
||||
fromFieldType: FieldMetadataType.CURRENCY,
|
||||
fromSubFields: ['amountMicros', 'currencyCode'],
|
||||
subFieldForNumericOperation: 'amountMicros',
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
},
|
||||
avgAmountAmountMicros: {
|
||||
type: GraphQLFloat,
|
||||
description: 'Average of amount',
|
||||
fromField: 'amount',
|
||||
fromFieldType: FieldMetadataType.CURRENCY,
|
||||
fromSubFields: ['amountMicros', 'currencyCode'],
|
||||
subFieldForNumericOperation: 'amountMicros',
|
||||
aggregateOperation: AggregateOperations.AVG,
|
||||
},
|
||||
};
|
||||
|
||||
describe('resolveAggregateFieldKey', () => {
|
||||
it('resolves a simple NUMBER field', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey('SUM', 'employees', availableAggregations),
|
||||
).toBe('sumEmployees');
|
||||
});
|
||||
|
||||
it('resolves a CURRENCY field with dot notation', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey(
|
||||
'SUM',
|
||||
'amount.amountMicros',
|
||||
availableAggregations,
|
||||
),
|
||||
).toBe('sumAmountAmountMicros');
|
||||
});
|
||||
|
||||
it('resolves a CURRENCY field with just the parent name', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey('SUM', 'amount', availableAggregations),
|
||||
).toBe('sumAmountAmountMicros');
|
||||
});
|
||||
|
||||
it('rejects an invalid sub-field for a composite type', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey(
|
||||
'SUM',
|
||||
'amount.currencyCode',
|
||||
availableAggregations,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid multi-level dot notation', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey(
|
||||
'SUM',
|
||||
'amount.amountMicros.extra',
|
||||
availableAggregations,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-existent field', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey('SUM', 'nonExistent', availableAggregations),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('matches the correct operation when multiple exist for the same field', () => {
|
||||
expect(
|
||||
resolveAggregateFieldKey('AVG', 'employees', availableAggregations),
|
||||
).toBe('avgEmployees');
|
||||
|
||||
expect(
|
||||
resolveAggregateFieldKey(
|
||||
'AVG',
|
||||
'amount.amountMicros',
|
||||
availableAggregations,
|
||||
),
|
||||
).toBe('avgAmountAmountMicros');
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { AggregateOperations } from 'twenty-shared/types';
|
||||
|
||||
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
|
||||
export const resolveAggregateFieldKey = (
|
||||
aggregateOperation: keyof typeof AggregateOperations,
|
||||
aggregateFieldName: string,
|
||||
availableAggregations: Record<string, AggregationField>,
|
||||
): string | null => {
|
||||
// Tool inputs use (aggregateOperation, aggregateFieldName), while GraphQL/REST
|
||||
// already pass concrete aggregate keys (e.g. "sumEmployees"), so this helper
|
||||
// intentionally adapts only the tool-surface contract.
|
||||
const fieldPathParts = aggregateFieldName.split('.');
|
||||
|
||||
if (
|
||||
fieldPathParts.length > 2 ||
|
||||
fieldPathParts.some((fieldPathPart) => fieldPathPart.length === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [parentField, subField] = fieldPathParts;
|
||||
|
||||
const targetOperation = AggregateOperations[aggregateOperation];
|
||||
|
||||
const matchingEntry = Object.entries(availableAggregations).find(
|
||||
([, aggregation]) => {
|
||||
if (aggregation.aggregateOperation !== targetOperation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (aggregation.fromField !== parentField) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subField) {
|
||||
return aggregation.subFieldForNumericOperation === subField;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
return matchingEntry?.[0] ?? null;
|
||||
};
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
AggregateOperations,
|
||||
FirstDayOfTheWeek,
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getAvailableAggregationsFromObjectFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
|
||||
import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/utils/resolve-aggregate-field-key.util';
|
||||
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
const dateGranularityValues = Object.values(
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
).filter((v) => v !== ObjectRecordGroupByDateGranularity.NONE) as [
|
||||
string,
|
||||
...string[],
|
||||
];
|
||||
|
||||
const dateGroupBySchema = z
|
||||
.object({
|
||||
granularity: z
|
||||
.enum(dateGranularityValues)
|
||||
.default(ObjectRecordGroupByDateGranularity.MONTH)
|
||||
.describe('Date grouping granularity. Default: MONTH.'),
|
||||
weekStartDay: z
|
||||
.nativeEnum(FirstDayOfTheWeek)
|
||||
.optional()
|
||||
.describe(
|
||||
'First day of week (MONDAY, SUNDAY, SATURDAY). Only used when granularity is WEEK.',
|
||||
),
|
||||
timeZone: z
|
||||
.string()
|
||||
.default('UTC')
|
||||
.describe(
|
||||
'IANA timezone for date groupings (e.g. "America/New_York"). Default: UTC.',
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.describe('Date field grouping configuration');
|
||||
|
||||
const buildGroupByEntriesAndDescriptions = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
): {
|
||||
groupByEntries: z.ZodTypeAny[];
|
||||
fieldNameDescriptions: string[];
|
||||
} => {
|
||||
const groupByEntries: z.ZodTypeAny[] = [];
|
||||
const fieldNameDescriptions: string[] = [];
|
||||
|
||||
for (const field of objectMetadata.fields) {
|
||||
if (restrictedFields?.[field.id]?.canRead === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) {
|
||||
if (field.settings?.relationType === RelationType.MANY_TO_ONE) {
|
||||
const relationFieldName = `${field.name}Id`;
|
||||
|
||||
groupByEntries.push(
|
||||
z.object({ [relationFieldName]: z.literal(true) }).strict(),
|
||||
);
|
||||
fieldNameDescriptions.push(relationFieldName);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFieldMetadataDateKind(field.type)) {
|
||||
groupByEntries.push(
|
||||
z.object({ [field.name]: dateGroupBySchema }).strict(),
|
||||
);
|
||||
fieldNameDescriptions.push(`${field.name} (date)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(field.type)) {
|
||||
const subFields = getGroupableSubFieldsForCompositeType(field.type);
|
||||
|
||||
if (subFields) {
|
||||
for (const subField of subFields) {
|
||||
groupByEntries.push(
|
||||
z
|
||||
.object({
|
||||
[field.name]: z
|
||||
.object({ [subField]: z.literal(true) })
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
);
|
||||
fieldNameDescriptions.push(`${field.name}.${subField}`);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
groupByEntries.push(z.object({ [field.name]: z.literal(true) }).strict());
|
||||
fieldNameDescriptions.push(field.name);
|
||||
}
|
||||
|
||||
return { groupByEntries, fieldNameDescriptions };
|
||||
};
|
||||
|
||||
export const hasGroupByToolInputSchema = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
): boolean => {
|
||||
return (
|
||||
buildGroupByEntriesAndDescriptions(objectMetadata, restrictedFields)
|
||||
.groupByEntries.length > 0
|
||||
);
|
||||
};
|
||||
|
||||
export const generateGroupByToolInputSchema = (
|
||||
objectMetadata: ObjectMetadataForToolSchema,
|
||||
restrictedFields?: RestrictedFieldsPermissions,
|
||||
): z.ZodTypeAny | null => {
|
||||
const { groupByEntries, fieldNameDescriptions } =
|
||||
buildGroupByEntriesAndDescriptions(objectMetadata, restrictedFields);
|
||||
|
||||
if (groupByEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupByEntrySchema =
|
||||
groupByEntries.length === 1
|
||||
? groupByEntries[0]
|
||||
: z.union(
|
||||
groupByEntries as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
|
||||
);
|
||||
|
||||
const { filterShape, filterSchema } = generateRecordFilterSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
);
|
||||
|
||||
const availableAggregations = getAvailableAggregationsFromObjectFields(
|
||||
objectMetadata.fields.filter(
|
||||
(field) => restrictedFields?.[field.id]?.canRead !== false,
|
||||
),
|
||||
);
|
||||
const availableAggregateFieldNames = Array.from(
|
||||
new Set(
|
||||
Object.values(availableAggregations)
|
||||
.filter(
|
||||
(aggregation) =>
|
||||
aggregation.aggregateOperation !== AggregateOperations.COUNT,
|
||||
)
|
||||
.map((aggregation) =>
|
||||
aggregation.subFieldForNumericOperation
|
||||
? `${aggregation.fromField}.${aggregation.subFieldForNumericOperation}`
|
||||
: aggregation.fromField,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return z
|
||||
.object({
|
||||
groupBy: z
|
||||
.array(groupByEntrySchema)
|
||||
.min(1)
|
||||
.max(2)
|
||||
.describe(
|
||||
`Fields to group by (max 2). Each entry must be an object with exactly one field key. Examples: {"status": true}, {"companyId": true}, {"createdAt": {"granularity": "MONTH", "timeZone": "UTC"}}. Available: ${fieldNameDescriptions.join(', ')}.`,
|
||||
),
|
||||
aggregateOperation: z
|
||||
.enum(Object.keys(AggregateOperations) as [string, ...string[]])
|
||||
.default(AggregateOperations.COUNT)
|
||||
.describe(
|
||||
'Aggregate operation to apply per group. Default: COUNT. Any operation other than COUNT requires aggregateFieldName.',
|
||||
),
|
||||
aggregateFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
`Field to aggregate. Required for any operation other than COUNT. Available fields: ${availableAggregateFieldNames.join(', ')}.`,
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100)
|
||||
.default(50)
|
||||
.describe(
|
||||
'Maximum number of groups to return (default: 50, max: 100).',
|
||||
),
|
||||
orderBy: z
|
||||
.enum(['ASC', 'DESC'])
|
||||
.default('DESC')
|
||||
.describe(
|
||||
'Order groups by aggregate value. DESC (default) gives "top N" behavior.',
|
||||
),
|
||||
...filterShape,
|
||||
or: z
|
||||
.array(filterSchema)
|
||||
.optional()
|
||||
.describe('OR condition - matches if ANY of the filters match'),
|
||||
and: z
|
||||
.array(filterSchema)
|
||||
.optional()
|
||||
.describe('AND condition - matches if ALL filters match'),
|
||||
not: filterSchema
|
||||
.optional()
|
||||
.describe('NOT condition - matches if the filter does NOT match'),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((input, context) => {
|
||||
const aggregateOperation =
|
||||
input.aggregateOperation as keyof typeof AggregateOperations;
|
||||
|
||||
if (aggregateOperation === AggregateOperations.COUNT) {
|
||||
if (input.aggregateFieldName) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'aggregateFieldName is not supported for COUNT operation.',
|
||||
path: ['aggregateFieldName'],
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.aggregateFieldName) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `aggregateFieldName is required for ${aggregateOperation} operation.`,
|
||||
path: ['aggregateFieldName'],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAggregateFieldKey = resolveAggregateFieldKey(
|
||||
aggregateOperation,
|
||||
input.aggregateFieldName,
|
||||
availableAggregations,
|
||||
);
|
||||
|
||||
if (!resolvedAggregateFieldKey) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `No aggregation available for ${aggregateOperation} on field "${input.aggregateFieldName}".`,
|
||||
path: ['aggregateFieldName'],
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
+31
@@ -19,6 +19,10 @@ import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-
|
||||
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';
|
||||
import {
|
||||
generateGroupByToolInputSchema,
|
||||
hasGroupByToolInputSchema,
|
||||
} from 'src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
@@ -138,6 +142,33 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'find_one',
|
||||
});
|
||||
|
||||
const groupBySchema = includeSchemas
|
||||
? generateGroupByToolInputSchema(objectMetadata, restrictedFields)
|
||||
: null;
|
||||
const hasGroupBySchema =
|
||||
groupBySchema !== null ||
|
||||
hasGroupByToolInputSchema(objectMetadata, restrictedFields);
|
||||
|
||||
if (hasGroupBySchema) {
|
||||
descriptors.push({
|
||||
name: `group_by_${snakePlural}`,
|
||||
description: `Group ${objectMetadata.labelPlural} records by one or two fields and compute an aggregate (COUNT, SUM, AVG, MIN, MAX, etc.). Use for questions like "how many deals per stage?" or "total revenue by company". Returns groups with dimension values and aggregate results, ordered by the aggregate value.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas &&
|
||||
groupBySchema && {
|
||||
inputSchema: z.toJSONSchema(groupBySchema),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'group_by',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'group_by',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (permission.canUpdateObjectRecords) {
|
||||
|
||||
+30
@@ -1,9 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type AggregateOperations } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
@@ -19,6 +22,7 @@ import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/se
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.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 { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
|
||||
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';
|
||||
@@ -48,6 +52,7 @@ export class ToolExecutorService {
|
||||
|
||||
constructor(
|
||||
private readonly findRecordsService: FindRecordsService,
|
||||
private readonly groupByRecordsService: GroupByRecordsService,
|
||||
private readonly createRecordService: CreateRecordService,
|
||||
private readonly createManyRecordsService: CreateManyRecordsService,
|
||||
private readonly updateRecordService: UpdateRecordService,
|
||||
@@ -181,6 +186,31 @@ export class ToolExecutorService {
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
soft: true,
|
||||
});
|
||||
|
||||
case 'group_by': {
|
||||
const {
|
||||
groupBy,
|
||||
aggregateOperation,
|
||||
aggregateFieldName,
|
||||
limit: groupByLimit,
|
||||
orderBy: groupByOrderBy,
|
||||
...groupByFilter
|
||||
} = args;
|
||||
|
||||
return this.groupByRecordsService.execute({
|
||||
objectName: ref.objectNameSingular,
|
||||
groupBy: groupBy as ObjectRecordGroupBy,
|
||||
aggregateOperation: aggregateOperation as
|
||||
| keyof typeof AggregateOperations
|
||||
| undefined,
|
||||
aggregateFieldName: aggregateFieldName as string | undefined,
|
||||
limit: groupByLimit as number | undefined,
|
||||
orderBy: groupByOrderBy as 'ASC' | 'DESC' | undefined,
|
||||
filter: groupByFilter,
|
||||
authContext,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -5,4 +5,5 @@ export type DatabaseCrudOperation =
|
||||
| 'create_many'
|
||||
| 'update'
|
||||
| 'update_many'
|
||||
| 'delete';
|
||||
| 'delete'
|
||||
| 'group_by';
|
||||
|
||||
Reference in New Issue
Block a user