[groupBy] groupBy relation fields (#15951)

Example query
Here person has 
- a N - 1 relationship with company
- a N - 1 morph relationship with pet or company

<img width="862" height="374" alt="image"
src="https://github.com/user-attachments/assets/59bc9b82-c943-43de-ad82-d3393b76904b"
/>
<img width="415" height="629" alt="image"
src="https://github.com/user-attachments/assets/9a3176bc-99cd-4983-8611-68ca3a2cf527"
/>

truncated response
<img width="299" height="447" alt="image"
src="https://github.com/user-attachments/assets/45af0322-9e66-4eae-8353-6c0dda487bbe"
/>

We don't allow grouping by relations of relations.

Left to do
- rest api
- tests on permissions
This commit is contained in:
Marie
2025-11-25 10:03:16 +01:00
committed by GitHub
parent 316aec3c40
commit 46d1ea6505
34 changed files with 1910 additions and 143 deletions
@@ -92,7 +92,10 @@ export class GqlTypeGenerator {
objectMetadata,
);
this.relationConnectGqlInputTypeGenerator.buildAndStore(objectMetadata);
this.objectMetadataGqlInputTypeGenerator.buildAndStore(objectMetadata);
this.objectMetadataGqlInputTypeGenerator.buildAndStore(
objectMetadata,
dynamicObjectMetadataCollection,
);
if (
objectContainsRelationField(objectMetadata) ||
@@ -22,6 +22,8 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { pascalCase } from 'src/utils/pascal-case';
@Injectable()
@@ -29,13 +31,26 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
private readonly logger = new Logger(
ObjectMetadataGroupByGqlInputTypeGenerator.name,
);
private objectMetadataCollection: ObjectMetadataEntity[] = [];
private objectMetadataMaps: ObjectMetadataMaps | undefined;
constructor(
private readonly gqlTypesStorage: GqlTypesStorage,
private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator,
private readonly typeMapperService: TypeMapperService,
) {}
public buildAndStore(objectMetadata: ObjectMetadataEntity) {
public buildAndStore(
objectMetadata: ObjectMetadataEntity,
objectMetadataCollection?: ObjectMetadataEntity[],
) {
if (isDefined(objectMetadataCollection)) {
this.objectMetadataCollection = objectMetadataCollection;
this.objectMetadataMaps = generateObjectMetadataMaps(
objectMetadataCollection,
);
}
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.GroupBy.toString()}Input`,
description: objectMetadata.description,
@@ -61,6 +76,7 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
)
? this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldGroupByInputType(
fieldMetadata,
this.objectMetadataMaps,
)
: this.generateField(fieldMetadata);
@@ -19,7 +19,10 @@ export class ObjectMetadataGqlInputTypeGenerator {
private readonly objectMetadataGroupByGqlInputTypeGenerator: ObjectMetadataGroupByGqlInputTypeGenerator,
) {}
public buildAndStore(objectMetadata: ObjectMetadataEntity) {
public buildAndStore(
objectMetadata: ObjectMetadataEntity,
objectMetadataCollection?: ObjectMetadataEntity[],
) {
this.objectMetadataCreateGqlInputTypeGenerator.buildAndStore(
objectMetadata,
);
@@ -34,9 +37,11 @@ export class ObjectMetadataGqlInputTypeGenerator {
});
this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({
objectMetadata,
objectMetadataCollection,
});
this.objectMetadataGroupByGqlInputTypeGenerator.buildAndStore(
objectMetadata,
objectMetadataCollection,
);
}
}
@@ -18,6 +18,7 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class ObjectMetadataOrderByBaseGenerator {
@@ -31,10 +32,12 @@ export class ObjectMetadataOrderByBaseGenerator {
objectMetadata,
logger,
orderByDateGranularity,
objectMetadataMaps,
}: {
objectMetadata: ObjectMetadataEntity;
logger: Logger;
orderByDateGranularity?: boolean;
objectMetadataMaps?: ObjectMetadataMaps;
}): GraphQLInputFieldConfigMap {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
@@ -54,6 +57,7 @@ export class ObjectMetadataOrderByBaseGenerator {
{
fieldMetadata,
typeOptions,
objectMetadataMaps,
},
);
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
@@ -23,6 +23,8 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { pascalCase } from 'src/utils/pascal-case';
@Injectable()
@@ -37,11 +39,21 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
private readonly typeMapperService: TypeMapperService,
) {}
private objectMetadataMaps?: ObjectMetadataMaps;
public buildAndStore({
objectMetadata,
objectMetadataCollection,
}: {
objectMetadata: ObjectMetadataEntity;
objectMetadataCollection?: ObjectMetadataEntity[];
}) {
if (isDefined(objectMetadataCollection)) {
this.objectMetadataMaps = generateObjectMetadataMaps(
objectMetadataCollection,
);
}
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}Input`,
description: objectMetadata.description,
@@ -86,6 +98,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
{
fieldMetadata,
typeOptions,
objectMetadataMaps: this.objectMetadataMaps,
},
);
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
@@ -124,6 +137,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
objectMetadata,
logger: this.logger,
orderByDateGranularity: true,
objectMetadataMaps: this.objectMetadataMaps,
});
}
@@ -1,21 +1,24 @@
import { Injectable, Logger } from '@nestjs/common';
import {
GraphQLBoolean,
GraphQLInputFieldConfigMap,
isInputObjectType,
isObjectType,
} from 'graphql';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import {
TypeMapperService,
TypeOptions,
} 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 { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
import { computeRelationConnectInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-relation-connect-input-type-key.util';
import { extractGraphQLRelationFieldNames } from 'src/engine/api/graphql/workspace-schema-builder/utils/extract-graphql-relation-field-names.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class RelationFieldMetadataGqlInputTypeGenerator {
@@ -115,16 +118,19 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
public generateSimpleRelationFieldOrderByInputType({
fieldMetadata,
typeOptions,
objectMetadataMaps,
}: {
fieldMetadata: FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>;
typeOptions: TypeOptions;
objectMetadataMaps?: ObjectMetadataMaps;
}) {
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY)
return {};
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
const { joinColumnName, fieldMetadataName } =
extractGraphQLRelationFieldNames(fieldMetadata);
const type = this.typeMapperService.mapToOrderByType(fieldMetadata.type);
@@ -143,32 +149,89 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
typeOptions,
);
return {
const fields: GraphQLInputFieldConfigMap = {
[joinColumnName]: {
type: modifiedType,
description: fieldMetadata.description,
},
};
if (
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
isDefined(objectMetadataMaps)
) {
const targetObjectMetadata =
objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId];
if (isDefined(targetObjectMetadata)) {
const targetOrderByInputTypeKey = computeObjectMetadataInputTypeKey(
targetObjectMetadata.nameSingular,
GqlInputTypeDefinitionKind.OrderBy,
);
const targetOrderByInputType = this.gqlTypesStorage.getGqlTypeByKey(
targetOrderByInputTypeKey,
);
if (
isDefined(targetOrderByInputType) &&
isInputObjectType(targetOrderByInputType)
) {
fields[fieldMetadataName] = {
type: targetOrderByInputType,
description: `Order by fields of the related ${targetObjectMetadata.nameSingular}`,
};
}
}
}
return fields;
}
public generateSimpleRelationFieldGroupByInputType(
fieldMetadata: FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
objectMetadataMaps?: ObjectMetadataMaps,
): GraphQLInputFieldConfigMap {
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY)
return {};
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
const { fieldMetadataName } =
extractGraphQLRelationFieldNames(fieldMetadata);
const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {});
const fields: GraphQLInputFieldConfigMap = {};
return {
[joinColumnName]: {
type,
description: fieldMetadata.description,
},
};
if (
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
isDefined(objectMetadataMaps)
) {
const targetObjectMetadata =
objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId];
if (isDefined(targetObjectMetadata)) {
const targetGroupByInputTypeKey = computeObjectMetadataInputTypeKey(
targetObjectMetadata.nameSingular,
GqlInputTypeDefinitionKind.GroupBy,
);
const targetGroupByInputType = this.gqlTypesStorage.getGqlTypeByKey(
targetGroupByInputTypeKey,
);
if (
isDefined(targetGroupByInputType) &&
isInputObjectType(targetGroupByInputType)
) {
fields[fieldMetadataName] = {
type: targetGroupByInputType,
description: `Group by fields of the related ${targetObjectMetadata.nameSingular}`,
};
}
}
}
return fields;
}
public generateConnectRelationFieldInputType({