[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:
nitin
2026-04-10 08:45:18 +00:00
committed by GitHub
parent 43ce396152
commit f13e7e01fe
32 changed files with 1504 additions and 258 deletions
@@ -1,9 +1,31 @@
import { Injectable } from '@nestjs/common';
import {
AggregateOperations,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { validateAndTransformGroupByFieldsOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-group-by-fields-or-throw.util';
import {
type AggregationField,
getAvailableAggregationsFromObjectFields,
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import {
ObjectRecordGroupByForAtomicField,
ObjectRecordGroupByForCompositeField,
ObjectRecordGroupByForDateField,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.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';
import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/utils/resolve-aggregate-field-key.util';
@Injectable()
export class GroupByArgProcessorService {
@@ -30,4 +52,113 @@ export class GroupByArgProcessorService {
return [groupBy];
}
validateAndTransformGroupByFieldsOrThrow({
groupBy,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: {
groupBy: Array<
| ObjectRecordGroupByForAtomicField
| ObjectRecordGroupByForCompositeField
| ObjectRecordGroupByForDateField
>;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): GroupByField[] {
return validateAndTransformGroupByFieldsOrThrow({
groupBy,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
}
getAvailableAggregations({
flatObjectMetadata,
flatFieldMetadataMaps,
restrictedFields,
}: {
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
restrictedFields?: RestrictedFieldsPermissions;
}): Record<string, AggregationField> {
const objectFields = findManyFlatEntityByIdInFlatEntityMaps({
flatEntityIds: flatObjectMetadata.fieldIds,
flatEntityMaps: flatFieldMetadataMaps,
}).filter((field) => restrictedFields?.[field.id]?.canRead !== false);
return getAvailableAggregationsFromObjectFields(objectFields);
}
validateAggregateFieldKeysOrThrow({
aggregateFieldKeys,
availableAggregations,
}: {
aggregateFieldKeys: string[];
availableAggregations: Record<string, AggregationField>;
}): void {
const invalidAggregateFieldKeys = aggregateFieldKeys.filter(
(aggregateFieldKey) =>
!isDefined(availableAggregations[aggregateFieldKey]),
);
if (invalidAggregateFieldKeys.length === 0) {
return;
}
throw new CommonQueryRunnerException(
`Unknown aggregate field(s): ${invalidAggregateFieldKeys.join(', ')}`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
resolveToolAggregateFieldKeyOrThrow({
aggregateOperation,
aggregateFieldName,
availableAggregations,
}: {
aggregateOperation: keyof typeof AggregateOperations;
aggregateFieldName?: string;
availableAggregations: Record<string, AggregationField>;
}): string {
if (aggregateOperation === AggregateOperations.COUNT) {
if (aggregateFieldName) {
throw new CommonQueryRunnerException(
'aggregateFieldName is not supported for COUNT operation',
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
return 'totalCount';
}
if (!aggregateFieldName) {
throw new CommonQueryRunnerException(
`aggregateFieldName is required for ${aggregateOperation} operation`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const aggregateFieldKey = resolveAggregateFieldKey(
aggregateOperation,
aggregateFieldName,
availableAggregations,
);
if (!aggregateFieldKey) {
throw new CommonQueryRunnerException(
`No aggregation available for ${aggregateOperation} on field "${aggregateFieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
return aggregateFieldKey;
}
}
@@ -1,6 +1,6 @@
import {
type ObjectRecordGroupByDateGranularity,
type FirstDayOfTheWeek,
type ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
export type DateFieldGroupByDefinition = {
@@ -1,5 +1,5 @@
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/date-field-group-by-definition.type';
export type FieldGroupByDefinition =
| boolean
@@ -0,0 +1,27 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isPlainObject } from 'twenty-shared/utils';
import { type DateFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/date-field-group-by-definition.type';
const GROUP_BY_DATE_GRANULARITIES = new Set<string>(
Object.values(ObjectRecordGroupByDateGranularity),
);
export const isGroupByDateFieldDefinition = (
fieldGroupByDefinition: unknown,
): fieldGroupByDefinition is DateFieldGroupByDefinition => {
if (!isPlainObject(fieldGroupByDefinition)) {
return false;
}
if (!('granularity' in fieldGroupByDefinition)) {
return false;
}
const granularity = fieldGroupByDefinition.granularity;
return (
typeof granularity === 'string' &&
GROUP_BY_DATE_GRANULARITIES.has(granularity)
);
};
@@ -0,0 +1,16 @@
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
export const isRelationNestedFieldSupportedInGroupBy = ({
nestedFieldName,
nestedFieldMetadata,
}: {
nestedFieldName: string;
nestedFieldMetadata: FlatFieldMetadata;
}): boolean => {
if (nestedFieldName === 'id') {
return true;
}
return isFlatFieldMetadataSupportedInGroupBy(nestedFieldMetadata);
};
@@ -0,0 +1,271 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
import {
ObjectRecordGroupByForAtomicField,
ObjectRecordGroupByForCompositeField,
ObjectRecordGroupByForDateField,
} 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 { 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 { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const getFieldMetadataForGroupByOrThrow = ({
fieldName,
fieldIdByName,
fieldIdByJoinColumnName,
flatFieldMetadataMaps,
}: {
fieldName: string;
fieldIdByName: Record<string, string>;
fieldIdByJoinColumnName: Record<string, string>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): FlatFieldMetadata => {
const fieldMetadataId =
fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName];
const fieldMetadata = fieldMetadataId
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
})
: undefined;
if (!isDefined(fieldMetadata) || !isDefined(fieldMetadataId)) {
throw new CommonQueryRunnerException(
`Unidentified field in groupBy: ${fieldName}`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
return fieldMetadata;
};
const validateAndTransformCompositeGroupByDefinitionOrThrow = ({
fieldName,
fieldMetadata,
fieldGroupByDefinition,
groupByFields,
}: {
fieldName: string;
fieldMetadata: FlatFieldMetadata;
fieldGroupByDefinition: Record<string, unknown>;
groupByFields: GroupByField[];
}) => {
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
throw new CommonQueryRunnerException(
`Field "${fieldName}" does not support nested subfields in groupBy`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const supportedCompositeSubFields = getGroupableSubFieldsForCompositeType(
fieldMetadata.type,
);
validateSingleKeyForGroupByOrThrow({
groupByKeys: Object.keys(fieldGroupByDefinition),
errorMessage:
'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput',
});
for (const subFieldName of Object.keys(fieldGroupByDefinition)) {
if (
isCompositeFieldMetadataType(fieldMetadata.type) &&
!supportedCompositeSubFields?.includes(subFieldName)
) {
throw new CommonQueryRunnerException(
`Composite subfield "${subFieldName}" is not supported in groupBy for "${fieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if (fieldGroupByDefinition[subFieldName] !== true) {
throw new CommonQueryRunnerException(
`Composite subfield "${subFieldName}" must be set to true in groupBy for "${fieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
groupByFields.push({
fieldMetadata,
subFieldName,
});
}
};
const validateAndTransformSingleGroupByFieldOrThrow = ({
fieldNames,
fieldName,
fieldIdByName,
fieldIdByJoinColumnName,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
groupByFields,
}: {
fieldNames: Record<string, unknown>;
fieldName: string;
fieldIdByName: Record<string, string>;
fieldIdByJoinColumnName: Record<string, string>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
groupByFields: GroupByField[];
}) => {
const fieldMetadata = getFieldMetadataForGroupByOrThrow({
fieldName,
fieldIdByName,
fieldIdByJoinColumnName,
flatFieldMetadataMaps,
});
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
throw new CommonQueryRunnerException(
`Field "${fieldName}" is not supported in groupBy`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const fieldGroupByDefinition = fieldNames[fieldName];
const isObjectFieldGroupByDefinition = isPlainObject(fieldGroupByDefinition);
const isGroupByRelationField =
isMorphOrRelationFlatFieldMetadata(fieldMetadata) &&
isObjectFieldGroupByDefinition &&
!isGroupByDateFieldDefinition(fieldGroupByDefinition);
const isGroupByRelationJoinColumnField =
isMorphOrRelationFlatFieldMetadata(fieldMetadata) &&
fieldGroupByDefinition === true &&
isDefined(fieldIdByJoinColumnName[fieldName]);
if (isGroupByRelationField || isGroupByRelationJoinColumnField) {
const normalizedFieldNames = isGroupByRelationJoinColumnField
? { ...fieldNames, [fieldName]: { id: true } }
: fieldNames;
validateAndTransformRelationGroupByFieldOrThrow({
fieldNames: normalizedFieldNames,
fieldName,
fieldMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
groupByFields,
});
return;
}
if (
(fieldMetadata.type === FieldMetadataType.DATE ||
fieldMetadata.type === FieldMetadataType.DATE_TIME) &&
isGroupByDateFieldDefinition(fieldGroupByDefinition)
) {
groupByFields.push({
fieldMetadata,
dateGranularity: fieldGroupByDefinition.granularity,
weekStartDay: fieldGroupByDefinition.weekStartDay,
timeZone: fieldGroupByDefinition.timeZone,
});
return;
}
if (isObjectFieldGroupByDefinition && 'unnest' in fieldGroupByDefinition) {
groupByFields.push({
fieldMetadata,
subFieldName: undefined,
shouldUnnest: true,
});
return;
}
if (fieldGroupByDefinition === true) {
groupByFields.push({
fieldMetadata,
subFieldName: undefined,
});
return;
}
if (isObjectFieldGroupByDefinition) {
validateAndTransformCompositeGroupByDefinitionOrThrow({
fieldName,
fieldMetadata,
fieldGroupByDefinition,
groupByFields,
});
return;
}
throw new CommonQueryRunnerException(
`Invalid groupBy definition for field "${fieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
};
export const validateAndTransformGroupByFieldsOrThrow = ({
groupBy,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: {
groupBy: Array<
| ObjectRecordGroupByForAtomicField
| ObjectRecordGroupByForCompositeField
| ObjectRecordGroupByForDateField
>;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): GroupByField[] => {
const groupByFields: GroupByField[] = [];
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
for (const fieldNames of groupBy) {
validateSingleKeyForGroupByOrThrow({
groupByKeys: Object.keys(fieldNames),
errorMessage:
'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput',
});
for (const fieldName of Object.keys(fieldNames)) {
validateAndTransformSingleGroupByFieldOrThrow({
fieldNames,
fieldName,
fieldIdByName,
fieldIdByJoinColumnName,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
groupByFields,
});
}
}
return groupByFields;
};
@@ -1,21 +1,20 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/composite-field-group-by-definition.type';
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
import { isRelationNestedFieldSupportedInGroupBy } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-relation-nested-field-supported-in-group-by.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
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 { 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 { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -33,22 +32,37 @@ const getNestedFieldMetadataDetails = ({
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}) => {
const nestedFieldGroupByDefinitions = fieldNames[fieldName] as
| Record<string, boolean>
| Record<string, CompositeFieldGroupByDefinition>
| Record<string, DateFieldGroupByDefinition>;
const nestedFieldGroupByDefinitions = fieldNames[fieldName];
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
throw new UserInputError(
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
if (!isPlainObject(nestedFieldGroupByDefinitions)) {
throw new CommonQueryRunnerException(
`Invalid groupBy definition for relation field "${fieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
throw new CommonQueryRunnerException(
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(targetObjectMetadata)) {
throw new CommonQueryRunnerException(
`Target object metadata not found for relation field ${fieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const nestedFieldNames = Object.keys(nestedFieldGroupByDefinitions);
validateSingleKeyForGroupByOrThrow({
@@ -70,16 +84,31 @@ const getNestedFieldMetadataDetails = ({
});
if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
throw new GraphqlQueryRunnerException(
throw new CommonQueryRunnerException(
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if (
!isRelationNestedFieldSupportedInGroupBy({
nestedFieldName,
nestedFieldMetadata,
})
) {
throw new CommonQueryRunnerException(
`Nested field "${nestedFieldName}" is not supported in groupBy`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if (nestedFieldMetadata.type === FieldMetadataType.RELATION) {
throw new UserInputError(
throw new CommonQueryRunnerException(
`Cannot group by a relation field of the relation field: "${nestedFieldName}" is a relation field of "${targetObjectMetadata.nameSingular}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
@@ -93,57 +122,59 @@ const getNestedFieldMetadataDetails = ({
};
};
const handleNestedCompositeField = ({
const validateAndTransformNestedCompositeFieldOrThrow = ({
nestedFieldGroupByDefinition,
nestedFieldName,
fieldMetadata,
nestedFieldMetadata,
groupByFields,
}: {
nestedFieldGroupByDefinition: CompositeFieldGroupByDefinition;
nestedFieldGroupByDefinition: unknown;
nestedFieldName: string;
fieldMetadata: FlatFieldMetadata;
nestedFieldMetadata: FlatFieldMetadata;
groupByFields: GroupByField[];
}) => {
if (
typeof nestedFieldGroupByDefinition === 'object' &&
nestedFieldGroupByDefinition !== null
) {
const compositeSubFields = Object.keys(
nestedFieldGroupByDefinition as Record<string, unknown>,
if (!isPlainObject(nestedFieldGroupByDefinition)) {
throw new CommonQueryRunnerException(
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
validateSingleKeyForGroupByOrThrow({
groupByKeys: compositeSubFields,
errorMessage:
'You cannot provide multiple composite subfields in one GroupByInput, split them into multiple GroupByInput',
});
const nestedSubFieldName = compositeSubFields[0];
if (
(nestedFieldGroupByDefinition as Record<string, boolean>)[
nestedSubFieldName
] === true
) {
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
nestedSubFieldName,
});
return;
}
}
throw new GraphqlQueryRunnerException(
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
const compositeSubFields = Object.keys(nestedFieldGroupByDefinition);
validateSingleKeyForGroupByOrThrow({
groupByKeys: compositeSubFields,
errorMessage:
'You cannot provide multiple composite subfields in one GroupByInput, split them into multiple GroupByInput',
});
const nestedSubFieldName = compositeSubFields[0];
const supportedCompositeSubFields = getGroupableSubFieldsForCompositeType(
nestedFieldMetadata.type,
);
if (
!supportedCompositeSubFields?.includes(nestedSubFieldName) ||
nestedFieldGroupByDefinition[nestedSubFieldName] !== true
) {
throw new CommonQueryRunnerException(
`Composite subfield "${nestedSubFieldName}" is not supported in groupBy for "${nestedFieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
nestedSubFieldName,
});
};
export const parseGroupByRelationField = ({
export const validateAndTransformRelationGroupByFieldOrThrow = ({
fieldNames,
fieldName,
fieldMetadata,
@@ -167,7 +198,6 @@ export const parseGroupByRelationField = ({
flatFieldMetadataMaps,
});
// Handle date fields in nested relations
if (
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
@@ -186,19 +216,18 @@ export const parseGroupByRelationField = ({
return;
}
// Handle composite fields in nested relations
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
handleNestedCompositeField({
nestedFieldGroupByDefinition:
nestedFieldGroupByDefinition as CompositeFieldGroupByDefinition,
validateAndTransformNestedCompositeFieldOrThrow({
nestedFieldGroupByDefinition,
nestedFieldName,
fieldMetadata,
nestedFieldMetadata,
groupByFields,
});
return;
}
// Handle regular nested fields
if (nestedFieldGroupByDefinition === true) {
groupByFields.push({
fieldMetadata,
@@ -207,4 +236,10 @@ export const parseGroupByRelationField = ({
return;
}
throw new CommonQueryRunnerException(
`Invalid groupBy definition for nested field "${fieldName}.${nestedFieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
};
@@ -1,8 +1,8 @@
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
export const validateSingleKeyForGroupByOrThrow = ({
groupByKeys,
@@ -12,9 +12,9 @@ export const validateSingleKeyForGroupByOrThrow = ({
errorMessage: string;
}): void => {
if (groupByKeys.length > 1) {
throw new GraphqlQueryRunnerException(
throw new CommonQueryRunnerException(
errorMessage,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
@@ -41,7 +41,6 @@ import {
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util';
import { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
@@ -94,12 +93,13 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
objectMetadataNameSingular,
);
const groupByFields = parseGroupByArgs(
args,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
const groupByFields =
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
groupBy: args.groupBy,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
const objectAlias = getObjectAlias(flatObjectMetadata);
@@ -393,9 +393,20 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
}
async validate(
_args: CommonInput<GroupByQueryArgs>,
_queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<void> {}
args: CommonInput<GroupByQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<void> {
const normalizedGroupBy = this.groupByArgProcessor.process({
groupBy: args.groupBy,
});
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
groupBy: normalizedGroupBy,
flatObjectMetadata: queryRunnerContext.flatObjectMetadata,
flatObjectMetadataMaps: queryRunnerContext.flatObjectMetadataMaps,
flatFieldMetadataMaps: queryRunnerContext.flatFieldMetadataMaps,
});
}
async computeArgs(
args: CommonInput<GroupByQueryArgs>,
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommonArgsProcessors } from 'src/engine/api/common/common-args-processors/common-args-processors';
import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners';
@@ -53,6 +54,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
CommonResultGettersService,
GroupByWithRecordsService,
],
exports: [...CommonQueryRunners],
exports: [...CommonQueryRunners, GroupByArgProcessorService],
})
export class CoreCommonApiModule {}
@@ -1,30 +0,0 @@
import { isDefined } from 'class-validator';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type';
export const isGroupByDateFieldDefinition = (
fieldGroupByDefinition: FieldGroupByDefinition,
): fieldGroupByDefinition is {
granularity: ObjectRecordGroupByDateGranularity;
} => {
if (
typeof fieldGroupByDefinition !== 'object' ||
!isDefined(fieldGroupByDefinition)
) {
return false;
}
if (!('granularity' in fieldGroupByDefinition)) {
return false;
}
const granularity = fieldGroupByDefinition.granularity;
return (
isDefined(granularity) &&
typeof granularity === 'string' &&
Object.values(ObjectRecordGroupByDateGranularity).includes(
granularity as ObjectRecordGroupByDateGranularity,
)
);
};
@@ -1,141 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { parseGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
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 { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const parseGroupByArgs = (
args: GroupByResolverArgs,
flatObjectMetadata: FlatObjectMetadata,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): GroupByField[] => {
const groupByFieldNames = args.groupBy;
const groupByFields: GroupByField[] = [];
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
for (const fieldNames of groupByFieldNames) {
validateSingleKeyForGroupByOrThrow({
groupByKeys: Object.keys(fieldNames),
errorMessage:
'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput',
});
for (const fieldName of Object.keys(fieldNames)) {
const fieldMetadataId =
fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName];
const fieldMetadata = fieldMetadataId
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
})
: undefined;
if (!isDefined(fieldMetadata) || !isDefined(fieldMetadataId)) {
throw new Error(`Unidentified field in groupBy: ${fieldName}`);
}
const isGroupByRelationField =
isMorphOrRelationFlatFieldMetadata(fieldMetadata) &&
typeof fieldNames[fieldName] === 'object' &&
fieldNames[fieldName] !== null &&
!isGroupByDateFieldDefinition(fieldNames[fieldName]);
// Handle relation fields
if (isGroupByRelationField) {
parseGroupByRelationField({
fieldNames,
fieldName,
fieldMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
groupByFields,
});
continue;
}
// Handle date fields
if (
fieldMetadata.type === FieldMetadataType.DATE ||
fieldMetadata.type === FieldMetadataType.DATE_TIME
) {
const fieldGroupByDefinition = fieldNames[fieldName];
const shouldGroupByDateGranularity = isGroupByDateFieldDefinition(
fieldGroupByDefinition,
);
if (shouldGroupByDateGranularity) {
groupByFields.push({
fieldMetadata,
dateGranularity: fieldGroupByDefinition.granularity,
weekStartDay: fieldGroupByDefinition.weekStartDay,
timeZone: fieldGroupByDefinition.timeZone,
});
continue;
}
}
// Handle array unnest fields
if (
typeof fieldNames[fieldName] === 'object' &&
fieldNames[fieldName] !== null &&
'unnest' in fieldNames[fieldName]
) {
groupByFields.push({
fieldMetadata,
subFieldName: undefined,
shouldUnnest: true,
});
continue;
}
// Handle regular fields and composite fields
if (fieldNames[fieldName] === true) {
groupByFields.push({
fieldMetadata,
subFieldName: undefined,
});
continue;
} else if (typeof fieldNames[fieldName] === 'object') {
validateSingleKeyForGroupByOrThrow({
groupByKeys: Object.keys(fieldNames[fieldName]),
errorMessage:
'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput',
});
for (const subFieldName of Object.keys(fieldNames[fieldName])) {
if (
(fieldNames[fieldName] as Record<string, boolean>)[subFieldName] ===
true
) {
groupByFields.push({
fieldMetadata,
subFieldName,
});
continue;
}
}
}
}
}
return groupByFields;
};
@@ -12,6 +12,7 @@ import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-sch
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage';
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
import { isCompositePropertySupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-property-supported-in-group-by.util';
import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util';
@Injectable()
@@ -54,8 +55,7 @@ export class CompositeFieldMetadataGroupByGqlInputTypeGenerator {
throw new Error('Relation fields are not supported in composite types');
}
// Skip hidden fields
if (property.hidden === true) {
if (!isCompositePropertySupportedInGroupBy(property)) {
continue;
}
@@ -20,6 +20,7 @@ import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-s
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -62,6 +63,10 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of fields) {
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
continue;
}
const generatedField = isMorphOrRelationFlatFieldMetadata(fieldMetadata)
? this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldGroupByInputType(
fieldMetadata,
@@ -1,2 +1,2 @@
export const MCP_SERVER_INSTRUCTIONS =
'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building.';
'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building. For comparative/grouped analytics (by/per/top/most/least/average/total/ranking), use group_by tools, and use find tools for record retrieval; for multiple metrics, run multiple group_by calls and merge results.';
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from 'twenty-shared/constants';
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 { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
@@ -19,6 +20,7 @@ import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest
export class RestApiGroupByHandler extends RestApiBaseHandler {
constructor(
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
private readonly groupByArgProcessor: GroupByArgProcessorService,
) {
super();
}
@@ -84,6 +86,17 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
const groupBy = parseGroupByRestRequest(request);
const includeRecords = parseIncludeRecordsSampleRestRequest(request);
const aggregateFields = parseAggregateFieldsRestRequest(request);
const availableAggregations =
this.groupByArgProcessor.getAvailableAggregations({
flatObjectMetadata,
flatFieldMetadataMaps,
});
this.groupByArgProcessor.validateAggregateFieldKeysOrThrow({
aggregateFieldKeys: Object.keys(aggregateFields),
availableAggregations,
});
const limit = parseLimitRestRequest(
request,
DEFAULT_NUMBER_OF_GROUPS_LIMIT,