[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 14:15:18 +05:30
committed by GitHub
parent 43ce396152
commit f13e7e01fe
32 changed files with 1504 additions and 258 deletions
@@ -1 +0,0 @@
export type CompositeFieldGroupByDefinition = Record<string, boolean>;
@@ -1,10 +0,0 @@
import {
type ObjectRecordGroupByDateGranularity,
type FirstDayOfTheWeek,
} from 'twenty-shared/types';
export type DateFieldGroupByDefinition = {
granularity: ObjectRecordGroupByDateGranularity;
weekStartDay?: FirstDayOfTheWeek;
timeZone?: string;
};
@@ -1,8 +0,0 @@
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';
export type FieldGroupByDefinition =
| boolean
| CompositeFieldGroupByDefinition
| DateFieldGroupByDefinition
| undefined;
@@ -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;
};
@@ -1,210 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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';
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 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 { 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';
const getNestedFieldMetadataDetails = ({
fieldNames,
fieldName,
fieldMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: {
fieldNames: Record<string, unknown>;
fieldName: string;
fieldMetadata: FlatFieldMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}) => {
const nestedFieldGroupByDefinitions = fieldNames[fieldName] as
| Record<string, boolean>
| Record<string, CompositeFieldGroupByDefinition>
| Record<string, DateFieldGroupByDefinition>;
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
throw new UserInputError(
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
);
}
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: fieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const nestedFieldNames = Object.keys(nestedFieldGroupByDefinitions);
validateSingleKeyForGroupByOrThrow({
groupByKeys: nestedFieldNames,
errorMessage:
'You cannot provide multiple nested fields in one relation GroupByInput, split them into multiple GroupByInput',
});
const nestedFieldName = nestedFieldNames[0];
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
targetObjectMetadata,
);
const nestedFieldMetadataId = fieldIdByName[nestedFieldName];
const nestedFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: nestedFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
throw new GraphqlQueryRunnerException(
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if (nestedFieldMetadata.type === FieldMetadataType.RELATION) {
throw new UserInputError(
`Cannot group by a relation field of the relation field: "${nestedFieldName}" is a relation field of "${targetObjectMetadata.nameSingular}"`,
);
}
const nestedFieldGroupByDefinition =
nestedFieldGroupByDefinitions[nestedFieldName];
return {
nestedFieldGroupByDefinition,
nestedFieldMetadata,
nestedFieldName,
};
};
const handleNestedCompositeField = ({
nestedFieldGroupByDefinition,
nestedFieldName,
fieldMetadata,
nestedFieldMetadata,
groupByFields,
}: {
nestedFieldGroupByDefinition: CompositeFieldGroupByDefinition;
nestedFieldName: string;
fieldMetadata: FlatFieldMetadata;
nestedFieldMetadata: FlatFieldMetadata;
groupByFields: GroupByField[];
}) => {
if (
typeof nestedFieldGroupByDefinition === 'object' &&
nestedFieldGroupByDefinition !== null
) {
const compositeSubFields = Object.keys(
nestedFieldGroupByDefinition as Record<string, unknown>,
);
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 },
);
};
export const parseGroupByRelationField = ({
fieldNames,
fieldName,
fieldMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
groupByFields,
}: {
fieldNames: Record<string, unknown>;
fieldName: string;
fieldMetadata: FlatFieldMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
groupByFields: GroupByField[];
}) => {
const { nestedFieldGroupByDefinition, nestedFieldMetadata, nestedFieldName } =
getNestedFieldMetadataDetails({
fieldNames,
fieldName,
fieldMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
// Handle date fields in nested relations
if (
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
isGroupByDateFieldDefinition(nestedFieldGroupByDefinition)
) {
const dateFieldDefinition = nestedFieldGroupByDefinition;
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
dateGranularity: dateFieldDefinition.granularity,
weekStartDay: dateFieldDefinition.weekStartDay,
timeZone: dateFieldDefinition.timeZone,
});
return;
}
// Handle composite fields in nested relations
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
handleNestedCompositeField({
nestedFieldGroupByDefinition:
nestedFieldGroupByDefinition as CompositeFieldGroupByDefinition,
nestedFieldName,
fieldMetadata,
nestedFieldMetadata,
groupByFields,
});
}
// Handle regular nested fields
if (nestedFieldGroupByDefinition === true) {
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
});
return;
}
};
@@ -1,21 +0,0 @@
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';
export const validateSingleKeyForGroupByOrThrow = ({
groupByKeys,
errorMessage,
}: {
groupByKeys: string[];
errorMessage: string;
}): void => {
if (groupByKeys.length > 1) {
throw new GraphqlQueryRunnerException(
errorMessage,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
};
@@ -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,