From 46d1ea650585052fb14381521d23b08d0396d706 Mon Sep 17 00:00:00 2001
From: Marie <51697796+ijreilly@users.noreply.github.com>
Date: Tue, 25 Nov 2025 10:03:16 +0100
Subject: [PATCH] [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
truncated response
We don't allow grouping by relations of relations.
Left to do
- rest api
- tests on permissions
---
.../common-group-by-query-runner.service.ts | 122 ++-
.../errors/common-query-runner.exception.ts | 1 +
...r-to-graphql-api-exception-handler.util.ts | 1 +
...nner-to-rest-api-exception-handler.util.ts | 1 +
.../utils/get-group-by-definitions.util.ts | 60 ++
.../get-object-alias-for-group-by.util.ts | 7 +
.../graphql-query-order.parser.ts | 226 ++++-
...or-order-by-relation-field-parsing.util.ts | 105 +++
.../graphql-query.parser.ts | 1 +
...omposite-field-group-by-definition.type.ts | 1 +
.../date-field-group-by-definition.type.ts | 5 +
.../types/field-group-by-definition.type.ts | 8 +
...n.types.ts => group-by-definition.type.ts} | 0
.../resolvers/types/group-by-field.types.ts | 11 +-
...ult-with-group-by-dimension-values.util.ts | 2 +-
.../utils/get-group-by-expression.util.ts | 20 +-
.../is-group-by-date-field-definition.util.ts | 30 +
.../utils/is-group-by-date-field.util.ts | 5 +-
.../utils/is-group-by-relation-field.util.ts | 10 +
.../utils/parse-group-by-args.util.ts | 86 +-
.../parse-group-by-relation-field.util.ts | 190 ++++
.../resolvers/utils/remove-quote.util.ts | 8 +-
...e-single-key-for-group-by-or-throw.util.ts | 19 +
.../services/group-by-with-records.service.ts | 12 +-
.../gql-type.generator.ts | 5 +-
...adata-group-by-gql-input-type.generator.ts | 18 +-
...bject-metadata-gql-input-type.generator.ts | 7 +-
...object-metadata-order-by-base.generator.ts | 4 +
...-with-group-by-gql-input-type.generator.ts | 14 +
...ation-field-metadata-gql-type.generator.ts | 85 +-
.../repository/permissions.utils.ts | 90 +-
.../group-by-resolver.integration-spec.ts | 890 +++++++++++++++++-
.../src/types/ObjectRecordGroupBy.ts | 8 +
packages/twenty-shared/src/types/index.ts | 1 +
34 files changed, 1910 insertions(+), 143 deletions(-)
create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts
create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts
rename packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/{group-by-definition.types.ts => group-by-definition.type.ts} (100%)
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
index 85e3d18816..6f709a3b1d 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
@@ -19,6 +19,12 @@ import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
+import {
+ CommonQueryRunnerException,
+ CommonQueryRunnerExceptionCode,
+} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
+import { getGroupByDefinitions } from 'src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util';
+import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
@@ -30,12 +36,11 @@ import {
} from 'src/engine/api/common/types/common-query-args.type';
import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
-import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types';
+import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
+import { GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
-import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
-import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
+import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util';
-import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.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';
@@ -48,7 +53,6 @@ import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entit
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util';
-import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@Injectable()
export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerService<
@@ -84,6 +88,20 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
objectMetadataNameSingular,
);
+ const groupByFields = parseGroupByArgs(
+ args,
+ objectMetadataItemWithFieldMaps,
+ queryRunnerContext.objectMetadataMaps,
+ );
+
+ const objectAlias = getObjectAlias(objectMetadataItemWithFieldMaps);
+
+ this.addJoinForGroupByOnRelationFields({
+ queryBuilder,
+ groupByFields,
+ objectAlias,
+ });
+
let appliedFilters = args.filter ?? ({} as ObjectRecordFilter);
await this.addFiltersToQueryBuilder({
@@ -95,47 +113,17 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
commonQueryParser,
});
+ const queryBuilderWithFiltersAndWithoutGroupBy = queryBuilder.clone();
+
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: args.selectedFieldsResult.aggregate,
queryBuilder,
objectMetadataNameSingular,
});
- const groupByFields = parseGroupByArgs(
- args,
- objectMetadataItemWithFieldMaps,
- );
-
- const groupByDefinitions = groupByFields.map((groupByField) => {
- const columnName = isFieldMetadataRelationOrMorphRelation(
- groupByField.fieldMetadata,
- )
- ? formatColumnNameForRelationField(
- groupByField.fieldMetadata.name,
- groupByField.fieldMetadata.settings,
- )
- : formatColumnNamesFromCompositeFieldAndSubfields(
- groupByField.fieldMetadata.name,
- groupByField.subFieldName ? [groupByField.subFieldName] : undefined,
- )[0];
- const columnNameWithQuotes = `"${columnName}"`;
- const alias =
- removeQuotes(columnNameWithQuotes) +
- (isGroupByDateField(groupByField)
- ? `_${groupByField.dateGranularity}`
- : '');
-
- return {
- columnNameWithQuotes,
- expression: getGroupByExpression({
- groupByField,
- columnNameWithQuotes,
- }),
- alias,
- dateGranularity: isGroupByDateField(groupByField)
- ? groupByField.dateGranularity
- : undefined,
- };
+ const groupByDefinitions = getGroupByDefinitions({
+ groupByFields,
+ objectMetadataNameSingular,
});
groupByDefinitions.forEach((groupByColumn, index) => {
@@ -157,18 +145,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
const shouldIncludeRecords = args.includeRecords ?? false;
if (shouldIncludeRecords) {
- const queryBuilderWithFiltersAndWithoutGroupBy =
- repository.createQueryBuilder(objectMetadataNameSingular);
-
- await this.addFiltersToQueryBuilder({
- args,
- objectMetadataItemWithFieldMaps,
- workspaceId: authContext.workspace.id,
- commonQueryParser,
- appliedFilters,
- queryBuilder: queryBuilderWithFiltersAndWithoutGroupBy,
- });
-
return this.groupByWithRecordsService.resolveWithRecords({
queryBuilderWithFiltersAndWithoutGroupBy,
queryBuilderWithGroupBy: queryBuilder,
@@ -343,6 +319,48 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
});
}
+ private addJoinForGroupByOnRelationFields({
+ queryBuilder,
+ groupByFields,
+ objectAlias,
+ }: {
+ queryBuilder: WorkspaceSelectQueryBuilder;
+ groupByFields: GroupByField[];
+ objectAlias: string;
+ }): void {
+ const joinAliasSet = new Set();
+
+ for (const groupByField of groupByFields) {
+ if (isGroupByRelationField(groupByField)) {
+ const joinAlias = groupByField.fieldMetadata.name;
+
+ if (
+ !groupByField.fieldMetadata.settings ||
+ !isFieldMetadataRelationOrMorphRelation(groupByField.fieldMetadata)
+ ) {
+ throw new CommonQueryRunnerException(
+ `Field metadata settings are missing or invalid for field ${groupByField.fieldMetadata.name}`,
+ CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
+ );
+ }
+
+ const joinColumnName = formatColumnNameForRelationField(
+ groupByField.fieldMetadata.name,
+ groupByField.fieldMetadata.settings,
+ );
+
+ if (!joinAliasSet.has(joinAlias)) {
+ queryBuilder.leftJoin(
+ `${objectAlias}.${joinAlias}`,
+ `${joinAlias}`,
+ `"${objectAlias}"."${joinColumnName}" = "${joinAlias}"."id"`,
+ );
+ joinAliasSet.add(joinAlias);
+ }
+ }
+ }
+ }
+
async validate(
_args: CommonInput,
_queryRunnerContext: CommonBaseQueryRunnerContext,
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts
index 58fa42d93e..7ffc54a2f9 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts
@@ -15,4 +15,5 @@ export enum CommonQueryRunnerExceptionCode {
INVALID_CURSOR = 'INVALID_CURSOR',
TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE',
BAD_REQUEST = 'BAD_REQUEST',
+ INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
}
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts
index 72fd0a0085..78fb36e512 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts
@@ -30,6 +30,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
+ case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
throw new InternalServerError(error);
default: {
return assertUnreachable(error.code);
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts
index ac2aa6bc35..1419428005 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts
@@ -31,6 +31,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new UnauthorizedException(error.message);
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
+ case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
throw new InternalServerErrorException(error.message);
default: {
return assertUnreachable(error.code);
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts
new file mode 100644
index 0000000000..f6ca155b59
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts
@@ -0,0 +1,60 @@
+import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
+import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
+import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
+import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
+import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
+import { formatColumnNameAsAlias } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util';
+import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
+
+export const getGroupByDefinitions = ({
+ groupByFields,
+ objectMetadataNameSingular,
+}: {
+ groupByFields: GroupByField[];
+ objectMetadataNameSingular: string;
+}): GroupByDefinition[] => {
+ return groupByFields.map((groupByField) => {
+ let columnName: string;
+ let columnNameWithQuotes: string;
+
+ if (isGroupByRelationField(groupByField)) {
+ const joinAlias = groupByField.fieldMetadata.name;
+ const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
+ groupByField.nestedFieldMetadata.name,
+ groupByField.nestedSubFieldName
+ ? [groupByField.nestedSubFieldName]
+ : undefined,
+ )[0];
+
+ columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
+ } else {
+ columnName = formatColumnNamesFromCompositeFieldAndSubfields(
+ groupByField.fieldMetadata.name,
+ groupByField.subFieldName ? [groupByField.subFieldName] : undefined,
+ )[0];
+ columnNameWithQuotes = `"${objectMetadataNameSingular}"."${columnName}"`;
+ }
+
+ const isGroupByDateFieldOrTargetField =
+ isGroupByDateField(groupByField) ||
+ (isGroupByRelationField(groupByField) && groupByField.dateGranularity);
+
+ const alias =
+ formatColumnNameAsAlias(columnNameWithQuotes) +
+ (isGroupByDateFieldOrTargetField
+ ? `_${groupByField.dateGranularity}`
+ : '');
+
+ return {
+ columnNameWithQuotes,
+ expression: getGroupByExpression({
+ groupByField,
+ columnNameWithQuotes,
+ }),
+ alias,
+ dateGranularity: isGroupByDateFieldOrTargetField
+ ? groupByField.dateGranularity
+ : undefined,
+ };
+ });
+};
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts
new file mode 100644
index 0000000000..36fa83fdd3
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts
@@ -0,0 +1,7 @@
+import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
+
+export const getObjectAlias = (
+ objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
+): string => {
+ return objectMetadataItemWithFieldMaps.nameSingular;
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts
index 2b9886e184..683bddff33 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts
@@ -4,6 +4,7 @@ import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
type ObjectRecordOrderByForCompositeField,
+ type ObjectRecordOrderByForRelationField,
type ObjectRecordOrderByForScalarField,
type ObjectRecordOrderByWithGroupByDateField,
OrderByDirection,
@@ -19,9 +20,11 @@ import {
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
+import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util';
import {
type GroupByDateField,
type GroupByField,
+ type GroupByRegularField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
@@ -34,6 +37,7 @@ import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-er
import { type 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 { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
+import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@@ -44,9 +48,14 @@ export type OrderByCondition = {
export class GraphqlQueryOrderFieldParser {
private objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
+ private objectMetadataMaps?: ObjectMetadataMaps;
- constructor(objectMetadataMapItem: ObjectMetadataItemWithFieldMaps) {
+ constructor(
+ objectMetadataMapItem: ObjectMetadataItemWithFieldMaps,
+ objectMetadataMaps?: ObjectMetadataMaps,
+ ) {
this.objectMetadataMapItem = objectMetadataMapItem;
+ this.objectMetadataMaps = objectMetadataMaps;
}
parse(
@@ -132,6 +141,12 @@ export class GraphqlQueryOrderFieldParser {
continue;
}
+ if (Object.keys(orderByArg).length > 1) {
+ throw new UserInputError(
+ 'Please provide orderBy field criteria one by one in orderBy array',
+ );
+ }
+
const fieldName = Object.keys(orderByArg)[0];
const fieldMetadataId =
this.objectMetadataMapItem.fieldIdByName[fieldName];
@@ -180,6 +195,24 @@ export class GraphqlQueryOrderFieldParser {
continue;
}
+ if (
+ this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata)
+ ) {
+ const parsedOrderByForRelationField =
+ this.parseObjectRecordOrderByForRelationField({
+ groupByFields,
+ orderByArg,
+ fieldMetadata,
+ });
+
+ if (!isDefined(parsedOrderByForRelationField)) {
+ continue;
+ }
+
+ parsedOrderBy.push(parsedOrderByForRelationField);
+ continue;
+ }
+
if (this.isObjectRecordOrderByForCompositeField(orderByArg)) {
const parsedOrderByForCompositeField =
this.parseObjectRecordOrderByForCompositeField({
@@ -221,7 +254,8 @@ export class GraphqlQueryOrderFieldParser {
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| AggregateOrderByWithGroupByField
- | ObjectRecordOrderByWithGroupByDateField,
+ | ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForRelationField,
): orderByArg is AggregateOrderByWithGroupByField => {
return isDefined(orderByArg.aggregate);
};
@@ -230,7 +264,8 @@ export class GraphqlQueryOrderFieldParser {
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
- | ObjectRecordOrderByWithGroupByDateField,
+ | ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForRelationField,
): orderByArg is ObjectRecordOrderByForScalarField => {
if (Object.keys(orderByArg).length > 1) {
throw new UserInputError(
@@ -256,7 +291,8 @@ export class GraphqlQueryOrderFieldParser {
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
- | ObjectRecordOrderByWithGroupByDateField,
+ | ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForRelationField,
): orderByArg is ObjectRecordOrderByForCompositeField => {
const compositeFieldOrderByValue = Object.values(orderByArg)[0];
@@ -292,7 +328,8 @@ export class GraphqlQueryOrderFieldParser {
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| AggregateOrderByWithGroupByField
- | ObjectRecordOrderByWithGroupByDateField,
+ | ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForRelationField,
fieldMetadataType: FieldMetadataType,
): orderByArg is ObjectRecordOrderByWithGroupByDateField => {
if (
@@ -443,7 +480,7 @@ export class GraphqlQueryOrderFieldParser {
!groupByFields.some(
(groupByField) =>
groupByField.fieldMetadata.id === fieldMetadata.id &&
- groupByField.subFieldName === subFieldName,
+ (groupByField as GroupByRegularField).subFieldName === subFieldName,
)
) {
throw new UserInputError(
@@ -485,7 +522,7 @@ export class GraphqlQueryOrderFieldParser {
(groupByField) =>
groupByField.fieldMetadata.id === fieldMetadataId &&
(groupByField as GroupByDateField).dateGranularity === granularity,
- );
+ ) as GroupByDateField | undefined;
if (!isDefined(associatedGroupByField)) {
throw new UserInputError(
@@ -511,4 +548,179 @@ export class GraphqlQueryOrderFieldParser {
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
};
};
+
+ parseObjectRecordOrderByForRelationField = ({
+ groupByFields,
+ orderByArg,
+ fieldMetadata,
+ }: {
+ groupByFields: GroupByField[];
+ orderByArg: ObjectRecordOrderByForRelationField;
+ fieldMetadata: FieldMetadataEntity;
+ }): Record | null => {
+ const {
+ associatedGroupByField,
+ nestedFieldMetadata,
+ nestedFieldOrderByValue,
+ } = prepareForOrderByRelationFieldParsing({
+ orderByArg,
+ fieldMetadata,
+ objectMetadataMaps: this.objectMetadataMaps,
+ groupByFields,
+ });
+
+ if (
+ !isDefined(associatedGroupByField) ||
+ !isDefined(nestedFieldMetadata) ||
+ !isDefined(nestedFieldOrderByValue)
+ ) {
+ return null;
+ }
+
+ // Handle composite fields
+ if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
+ if (!isObject(nestedFieldOrderByValue)) {
+ throw new UserInputError(
+ `Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`,
+ );
+ }
+
+ const compositeSubFields = Object.keys(nestedFieldOrderByValue);
+
+ if (compositeSubFields.length > 1) {
+ throw new UserInputError(
+ 'Please provide composite subfield criteria one by one in orderBy array',
+ );
+ }
+
+ const nestedSubFieldName = compositeSubFields[0];
+ const orderByDirection = (
+ nestedFieldOrderByValue as Record
+ )[nestedSubFieldName];
+
+ if (!isDefined(orderByDirection)) {
+ return null;
+ }
+
+ if (
+ !isDefined(associatedGroupByField.nestedSubFieldName) ||
+ associatedGroupByField.nestedSubFieldName !== nestedSubFieldName
+ ) {
+ throw new UserInputError(
+ `Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`,
+ );
+ }
+
+ const joinAlias = fieldMetadata.name;
+ const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
+ nestedFieldMetadata.name,
+ [nestedSubFieldName],
+ )[0];
+
+ const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
+
+ return {
+ [columnNameWithQuotes]:
+ convertOrderByToFindOptionsOrder(orderByDirection),
+ };
+ }
+
+ const isGroupByDateField =
+ (nestedFieldMetadata.type === FieldMetadataType.DATE ||
+ nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
+ isObject(nestedFieldOrderByValue) &&
+ 'orderBy' in nestedFieldOrderByValue &&
+ 'granularity' in nestedFieldOrderByValue;
+
+ if (isGroupByDateField) {
+ const orderByDirection = (
+ nestedFieldOrderByValue as {
+ orderBy: OrderByDirection;
+ granularity: ObjectRecordGroupByDateGranularity;
+ }
+ ).orderBy;
+ const granularity = (
+ nestedFieldOrderByValue as {
+ orderBy: OrderByDirection;
+ granularity: ObjectRecordGroupByDateGranularity;
+ }
+ ).granularity;
+
+ if (
+ !isDefined(associatedGroupByField.dateGranularity) ||
+ associatedGroupByField.dateGranularity !== granularity
+ ) {
+ throw new UserInputError(
+ `Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
+ );
+ }
+
+ const joinAlias = fieldMetadata.name;
+ const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
+ nestedFieldMetadata.name,
+ associatedGroupByField.nestedSubFieldName
+ ? [associatedGroupByField.nestedSubFieldName]
+ : undefined,
+ )[0];
+
+ const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
+
+ const expression = getGroupByExpression({
+ groupByField: associatedGroupByField,
+ columnNameWithQuotes,
+ });
+
+ return {
+ [expression]: convertOrderByToFindOptionsOrder(orderByDirection),
+ };
+ }
+
+ // Handle regular nested fields
+ if (
+ typeof nestedFieldOrderByValue === 'string' &&
+ Object.values(OrderByDirection).includes(
+ nestedFieldOrderByValue as OrderByDirection,
+ )
+ ) {
+ const orderByDirection = nestedFieldOrderByValue as OrderByDirection;
+
+ const joinAlias = fieldMetadata.name;
+ const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
+ nestedFieldMetadata.name,
+ associatedGroupByField.nestedSubFieldName
+ ? [associatedGroupByField.nestedSubFieldName]
+ : undefined,
+ )[0];
+
+ const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
+
+ return {
+ [columnNameWithQuotes]:
+ convertOrderByToFindOptionsOrder(orderByDirection),
+ };
+ }
+
+ return null;
+ };
+
+ isObjectRecordOrderByForRelationField = (
+ orderByArg:
+ | ObjectRecordOrderByForScalarField
+ | ObjectRecordOrderByForCompositeField
+ | ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForRelationField,
+ fieldMetadata: FieldMetadataEntity,
+ ): orderByArg is ObjectRecordOrderByForRelationField => {
+ if (!isFieldMetadataRelationOrMorphRelation(fieldMetadata)) {
+ return false;
+ }
+
+ const relationFieldOrderByValue = Object.values(orderByArg)[0];
+
+ if (!isObject(relationFieldOrderByValue)) {
+ return false;
+ }
+
+ return Object.keys(relationFieldOrderByValue).length > 0;
+ };
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts
new file mode 100644
index 0000000000..75eb0d3c13
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts
@@ -0,0 +1,105 @@
+import { UserInputError } from 'apollo-server-core';
+import { isObject } from 'class-validator';
+import { type ObjectRecordOrderByForRelationField } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+import {
+ type GroupByField,
+ type GroupByRelationField,
+} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
+import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
+import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
+import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
+
+export const prepareForOrderByRelationFieldParsing = ({
+ orderByArg,
+ fieldMetadata,
+ objectMetadataMaps,
+ groupByFields,
+}: {
+ orderByArg: ObjectRecordOrderByForRelationField;
+ fieldMetadata: FieldMetadataEntity;
+ objectMetadataMaps?: ObjectMetadataMaps;
+ groupByFields: GroupByField[];
+}) => {
+ if (!objectMetadataMaps) {
+ throw new UserInputError(
+ `Object metadata maps not found for field ${fieldMetadata.name}`,
+ );
+ }
+
+ const relationFieldName = Object.keys(orderByArg)[0];
+ const nestedFieldOrderByObject = orderByArg[relationFieldName];
+
+ if (
+ !isDefined(nestedFieldOrderByObject) ||
+ !isObject(nestedFieldOrderByObject)
+ ) {
+ return {};
+ }
+
+ if (Object.keys(nestedFieldOrderByObject).length > 1) {
+ throw new UserInputError(
+ 'Please provide nested field criteria one by one in orderBy array',
+ );
+ }
+
+ const nestedFieldName = Object.keys(nestedFieldOrderByObject)[0];
+ const nestedFieldOrderByValue = nestedFieldOrderByObject[nestedFieldName];
+
+ if (!isDefined(nestedFieldOrderByValue)) {
+ return {};
+ }
+
+ if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
+ throw new UserInputError(
+ `Relation target object metadata id not found for field ${fieldMetadata.name}`,
+ );
+ }
+
+ const targetObjectMetadataItem =
+ objectMetadataMaps?.byId[fieldMetadata.relationTargetObjectMetadataId];
+
+ if (!isDefined(targetObjectMetadataItem)) {
+ throw new UserInputError(
+ `Target object metadata item not found for field ${fieldMetadata.name}`,
+ );
+ }
+
+ const nestedFieldMetadataId =
+ targetObjectMetadataItem?.fieldIdByName[nestedFieldName];
+
+ if (!isDefined(nestedFieldMetadataId)) {
+ throw new UserInputError(
+ `Nested field metadata id not found for field ${nestedFieldName}`,
+ );
+ }
+
+ const nestedFieldMetadata =
+ targetObjectMetadataItem?.fieldsById[nestedFieldMetadataId];
+
+ if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
+ throw new UserInputError(
+ `Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadataItem.nameSingular}"`,
+ );
+ }
+
+ const associatedGroupByField = groupByFields.find(
+ (groupByField) =>
+ isGroupByRelationField(groupByField) &&
+ groupByField.fieldMetadata.id === fieldMetadata.id &&
+ groupByField.nestedFieldMetadata.id === nestedFieldMetadataId,
+ ) as GroupByRelationField | undefined;
+
+ if (!isDefined(associatedGroupByField)) {
+ throw new UserInputError(
+ `Cannot order by a relation field that is not in groupBy criteria: ${relationFieldName}.${nestedFieldName}`,
+ );
+ }
+
+ return {
+ associatedGroupByField,
+ nestedFieldMetadata,
+ nestedFieldOrderByValue,
+ };
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts
index beb434714f..804f95ec6e 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts
@@ -40,6 +40,7 @@ export class GraphqlQueryParser {
);
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
this.objectMetadataMapItem,
+ this.objectMetadataMaps,
);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts
new file mode 100644
index 0000000000..ba03d75161
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts
@@ -0,0 +1 @@
+export type CompositeFieldGroupByDefinition = Record;
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts
new file mode 100644
index 0000000000..664feefb77
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts
@@ -0,0 +1,5 @@
+import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
+
+export type DateFieldGroupByDefinition = {
+ granularity: ObjectRecordGroupByDateGranularity;
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts
new file mode 100644
index 0000000000..ab01d4739e
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts
@@ -0,0 +1,8 @@
+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;
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type.ts
similarity index 100%
rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types.ts
rename to packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type.ts
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts
index 41d5bda095..34c642eba1 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts
@@ -11,4 +11,13 @@ export type GroupByDateField = {
subFieldName?: string;
dateGranularity: ObjectRecordGroupByDateGranularity;
};
-export type GroupByField = GroupByRegularField | GroupByDateField;
+export type GroupByRelationField = {
+ fieldMetadata: FieldMetadataEntity;
+ nestedFieldMetadata: FieldMetadataEntity;
+ nestedSubFieldName?: string;
+ dateGranularity?: ObjectRecordGroupByDateGranularity;
+};
+export type GroupByField =
+ | GroupByRegularField
+ | GroupByDateField
+ | GroupByRelationField;
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts
index 0c76692176..c5259f5eda 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts
@@ -6,7 +6,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
-import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types';
+import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts
index c993ac2e23..683822e88b 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts
@@ -1,8 +1,9 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
-import { assertUnreachable } from 'twenty-shared/utils';
+import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
+import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
export const getGroupByExpression = ({
groupByField,
@@ -11,10 +12,19 @@ export const getGroupByExpression = ({
groupByField: GroupByField;
columnNameWithQuotes: string;
}) => {
- if (!isGroupByDateField(groupByField)) {
+ if (
+ !(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
+ ) {
return columnNameWithQuotes;
}
- switch (groupByField.dateGranularity) {
+
+ const dateGranularity = groupByField.dateGranularity;
+
+ if (!isDefined(dateGranularity)) {
+ return columnNameWithQuotes;
+ }
+
+ switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.NONE:
return columnNameWithQuotes;
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
@@ -27,8 +37,8 @@ export const getGroupByExpression = ({
case ObjectRecordGroupByDateGranularity.MONTH:
case ObjectRecordGroupByDateGranularity.QUARTER:
case ObjectRecordGroupByDateGranularity.YEAR:
- return `DATE_TRUNC('${groupByField.dateGranularity}', ${columnNameWithQuotes})`;
+ return `DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes})`;
default:
- assertUnreachable(groupByField.dateGranularity);
+ assertUnreachable(dateGranularity);
}
};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts
new file mode 100644
index 0000000000..447012d9df
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts
@@ -0,0 +1,30 @@
+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,
+ )
+ );
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts
index 8afd9260f7..2343edda8c 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts
@@ -6,5 +6,8 @@ import {
export const isGroupByDateField = (
groupByField: GroupByField,
): groupByField is GroupByDateField => {
- return 'dateGranularity' in groupByField;
+ return (
+ 'dateGranularity' in groupByField &&
+ !('nestedFieldMetadata' in groupByField)
+ );
};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts
new file mode 100644
index 0000000000..2c23cac48d
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts
@@ -0,0 +1,10 @@
+import {
+ type GroupByField,
+ type GroupByRelationField,
+} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
+
+export const isGroupByRelationField = (
+ groupByField: GroupByField,
+): groupByField is GroupByRelationField => {
+ return 'nestedFieldMetadata' in groupByField;
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts
index cdcbc6c2ec..623ed015d7 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts
@@ -1,63 +1,32 @@
import { isDefined } from 'class-validator';
-import {
- FieldMetadataType,
- ObjectRecordGroupByDateGranularity,
-} from 'twenty-shared/types';
+import { FieldMetadataType } from 'twenty-shared/types';
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
-import {
- GraphqlQueryRunnerException,
- GraphqlQueryRunnerExceptionCode,
-} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/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 { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
-
-const isGroupByDateFieldDefinition = (
- fieldGroupByDefinition:
- | boolean
- | Record
- | { granularity: ObjectRecordGroupByDateGranularity }
- | undefined,
-): 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,
- )
- );
-};
+import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
export const parseGroupByArgs = (
args: GroupByResolverArgs,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
+ objectMetadataMaps: ObjectMetadataMaps,
): GroupByField[] => {
const groupByFieldNames = args.groupBy;
const groupByFields: GroupByField[] = [];
for (const fieldNames of groupByFieldNames) {
- if (Object.keys(fieldNames).length > 1) {
- throw new GraphqlQueryRunnerException(
+ validateSingleKeyForGroupByOrThrow({
+ groupByKeys: Object.keys(fieldNames),
+ errorMessage:
'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput',
- GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
- );
- }
+ });
+
for (const fieldName of Object.keys(fieldNames)) {
const fieldMetadataId =
objectMetadataItemWithFieldMaps.fieldIdByName[fieldName] ||
@@ -69,6 +38,26 @@ export const parseGroupByArgs = (
throw new Error(`Unidentified field in groupBy: ${fieldName}`);
}
+ const isGroupByRelationField =
+ isFieldMetadataRelationOrMorphRelation(fieldMetadata) &&
+ typeof fieldNames[fieldName] === 'object' &&
+ fieldNames[fieldName] !== null &&
+ !isGroupByDateFieldDefinition(fieldNames[fieldName]);
+
+ // Handle relation fields
+ if (isGroupByRelationField) {
+ parseGroupByRelationField({
+ fieldNames,
+ fieldName,
+ fieldMetadata,
+ objectMetadataMaps,
+ groupByFields,
+ });
+
+ continue;
+ }
+
+ // Handle date fields
if (
fieldMetadata.type === FieldMetadataType.DATE ||
fieldMetadata.type === FieldMetadataType.DATE_TIME
@@ -88,6 +77,7 @@ export const parseGroupByArgs = (
}
}
+ // Handle regular fields and composite fields
if (fieldNames[fieldName] === true) {
groupByFields.push({
fieldMetadata,
@@ -95,12 +85,12 @@ export const parseGroupByArgs = (
});
continue;
} else if (typeof fieldNames[fieldName] === 'object') {
- if (Object.keys(fieldNames[fieldName]).length > 1) {
- throw new GraphqlQueryRunnerException(
+ validateSingleKeyForGroupByOrThrow({
+ groupByKeys: Object.keys(fieldNames[fieldName]),
+ errorMessage:
'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput',
- GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
- );
- }
+ });
+
for (const subFieldName of Object.keys(fieldNames[fieldName])) {
if (
(fieldNames[fieldName] as Record)[subFieldName] ===
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts
new file mode 100644
index 0000000000..724bf6fdee
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts
@@ -0,0 +1,190 @@
+import { UserInputError } from 'apollo-server-core';
+import { isDefined } from 'class-validator';
+import {
+ FieldMetadataType,
+ type ObjectRecordGroupByDateGranularity,
+} from 'twenty-shared/types';
+
+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/graphql/graphql-query-runner/group-by/resolvers/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 { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util';
+import { type 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 { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
+
+const getNestedFieldMetadataDetails = ({
+ fieldNames,
+ fieldName,
+ fieldMetadata,
+ objectMetadataMaps,
+}: {
+ fieldNames: Record;
+ fieldName: string;
+ fieldMetadata: FieldMetadataEntity;
+ objectMetadataMaps: ObjectMetadataMaps;
+}) => {
+ const nestedFieldGroupByDefinitions = fieldNames[fieldName] as
+ | Record
+ | Record
+ | Record;
+
+ const targetObjectMetadata = getTargetObjectMetadataOrThrow(
+ fieldMetadata,
+ objectMetadataMaps,
+ );
+
+ 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 nestedFieldMetadataId =
+ targetObjectMetadata.fieldIdByName[nestedFieldName];
+ const nestedFieldMetadata =
+ targetObjectMetadata.fieldsById[nestedFieldMetadataId];
+
+ if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
+ throw new GraphqlQueryRunnerException(
+ `Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
+ GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
+ );
+ }
+
+ 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: FieldMetadataEntity;
+ nestedFieldMetadata: FieldMetadataEntity;
+ groupByFields: GroupByField[];
+}) => {
+ if (
+ typeof nestedFieldGroupByDefinition === 'object' &&
+ nestedFieldGroupByDefinition !== null
+ ) {
+ const compositeSubFields = Object.keys(
+ nestedFieldGroupByDefinition as Record,
+ );
+
+ 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)[
+ nestedSubFieldName
+ ] === true
+ ) {
+ groupByFields.push({
+ fieldMetadata,
+ nestedFieldMetadata,
+ nestedSubFieldName,
+ });
+
+ return;
+ }
+ }
+ throw new GraphqlQueryRunnerException(
+ `Composite field "${nestedFieldName}" requires a subfield to be specified`,
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+};
+
+export const parseGroupByRelationField = ({
+ fieldNames,
+ fieldName,
+ fieldMetadata,
+ objectMetadataMaps,
+ groupByFields,
+}: {
+ fieldNames: Record;
+ fieldName: string;
+ fieldMetadata: FieldMetadataEntity;
+ objectMetadataMaps: ObjectMetadataMaps;
+ groupByFields: GroupByField[];
+}) => {
+ const { nestedFieldGroupByDefinition, nestedFieldMetadata, nestedFieldName } =
+ getNestedFieldMetadataDetails({
+ fieldNames,
+ fieldName,
+ fieldMetadata,
+ objectMetadataMaps,
+ });
+
+ // Handle date fields in nested relations
+ if (
+ (nestedFieldMetadata.type === FieldMetadataType.DATE ||
+ nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
+ isGroupByDateFieldDefinition(nestedFieldGroupByDefinition)
+ ) {
+ groupByFields.push({
+ fieldMetadata,
+ nestedFieldMetadata,
+ dateGranularity: (
+ nestedFieldGroupByDefinition as {
+ granularity: ObjectRecordGroupByDateGranularity;
+ }
+ ).granularity,
+ });
+
+ 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;
+ }
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts
index df6f3361ba..9f9d953fe9 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts
@@ -1,3 +1,9 @@
-export const removeQuotes = (string: string): string => {
+const removeQuotes = (string: string): string => {
return string.replace(/["']/g, '');
};
+
+export const formatColumnNameAsAlias = (
+ columnNameWithQuotes: string,
+): string => {
+ return removeQuotes(columnNameWithQuotes).replace(/\./g, '_');
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts
new file mode 100644
index 0000000000..25295ea401
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts
@@ -0,0 +1,19 @@
+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,
+ );
+ }
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts
index 990c562fda..312f914aeb 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts
@@ -7,12 +7,13 @@ import { type ObjectLiteral } from 'typeorm';
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
+import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
-import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types';
+import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
@@ -90,7 +91,7 @@ export class GroupByWithRecordsService {
const recordsResult = await queryBuilderWithPartitionBy.getRawMany();
- if (isDefined(selectedFieldsResult.relations)) {
+ if (!isEmpty(selectedFieldsResult.relations)) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
@@ -156,8 +157,10 @@ export class GroupByWithRecordsService {
queryBuilderForSubQuery,
);
+ const objectAlias = getObjectAlias(objectMetadataItemWithFieldMaps);
+
const recordSelectWithAlias = Object.keys(columnsToSelect)
- .map((col) => `"${col}" as "${SUB_QUERY_PREFIX}${col}"`)
+ .map((col) => `"${objectAlias}"."${col}" as "${SUB_QUERY_PREFIX}${col}"`)
.join(', ');
const groupBySelectWithAlias = groupByDefinitions
@@ -188,6 +191,9 @@ export class GroupByWithRecordsService {
const mainQuery = mainQueryQueryBuilder
.from(`(${subQuery.getQuery()})`, 'ranked_records')
.setParameters(queryBuilderForSubQuery.expressionMap.parameters)
+ .where('rn <= :recordsPerGroupLimit', {
+ recordsPerGroupLimit: RECORDS_PER_GROUP_LIMIT,
+ })
.select(groupByAliases)
.addSelect(
`JSON_AGG(
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts
index deafd7867b..e03557963e 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts
@@ -92,7 +92,10 @@ export class GqlTypeGenerator {
objectMetadata,
);
this.relationConnectGqlInputTypeGenerator.buildAndStore(objectMetadata);
- this.objectMetadataGqlInputTypeGenerator.buildAndStore(objectMetadata);
+ this.objectMetadataGqlInputTypeGenerator.buildAndStore(
+ objectMetadata,
+ dynamicObjectMetadataCollection,
+ );
if (
objectContainsRelationField(objectMetadata) ||
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts
index 4398f8fac8..1529ce0c76 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts
@@ -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);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts
index 936c114116..4b2c5f6df4 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts
@@ -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,
);
}
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts
index dcbd4a6043..23d8bd6433 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts
@@ -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)) {
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts
index a17d4b0fe6..c477428f1c 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts
@@ -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,
});
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts
index 1fe6b35033..57b17b0ac3 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts
@@ -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({
diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
index 0503fc3874..f0390284a5 100644
--- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
@@ -215,7 +215,20 @@ export const validateQueryIsPermittedOrThrow = ({
return;
}
- const allFieldsSelected = expressionMap.selects.some(
+ let expressionMapSelectsOnMainEntity = expressionMap.selects;
+
+ if (!isEmpty(expressionMap.joinAttributes)) {
+ const { selectsWithoutJoinedAliases } =
+ validatePermissionsForJoinsAndReturnSelectsWithoutJoins({
+ expressionMap,
+ objectsPermissions,
+ objectMetadataMaps,
+ });
+
+ expressionMapSelectsOnMainEntity = selectsWithoutJoinedAliases;
+ }
+
+ const allFieldsSelected = expressionMapSelectsOnMainEntity.some(
(select) => select.selection === mainEntity,
);
@@ -224,7 +237,8 @@ export const validateQueryIsPermittedOrThrow = ({
selectedColumns = getSelectedColumnsFromExpressionMap({
operationType,
- expressionMap,
+ expressionMapReturning: expressionMap.returning,
+ expressionMapSelects: expressionMapSelectsOnMainEntity,
allFieldsSelected,
});
@@ -259,6 +273,63 @@ export const validateQueryIsPermittedOrThrow = ({
});
};
+const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({
+ expressionMap,
+ objectsPermissions,
+ objectMetadataMaps,
+}: {
+ expressionMap: QueryExpressionMap;
+ objectsPermissions: ObjectsPermissions;
+ objectMetadataMaps: ObjectMetadataMaps;
+}) => {
+ const joinAttributesAliases = new Set(
+ expressionMap.joinAttributes.map((join) => join.alias.name),
+ );
+
+ const indexesOfSelectsForJoinedAlias: number[] = [];
+
+ for (const [_index, joinedAlias] of joinAttributesAliases.entries()) {
+ const entity = expressionMap.aliases.find(
+ (alias) => alias.type === 'join' && alias.name === joinedAlias,
+ )?.metadata;
+
+ if (isDefined(entity)) {
+ for (const [index, select] of expressionMap.selects.entries()) {
+ const regex = /"(\w+)"\."(\w+)"/;
+ const extractedAlias = select.selection.match(regex)?.[1]; // "person"."name" -> "person"
+
+ if (isDefined(extractedAlias) && extractedAlias === joinedAlias) {
+ indexesOfSelectsForJoinedAlias.push(index);
+
+ const selectedColumns = getSelectedColumnsFromExpressionMap({
+ operationType: 'select',
+ expressionMapSelects: expressionMap.selects.filter(
+ (_select, indexOfSelect) => indexOfSelect === index,
+ ),
+ allFieldsSelected: false,
+ });
+
+ validateOperationIsPermittedOrThrow({
+ entityName: entity.name,
+ operationType: 'select' as OperationType,
+ objectsPermissions,
+ objectMetadataMaps,
+ selectedColumns,
+ allFieldsSelected: false,
+ updatedColumns: [],
+ });
+ }
+ }
+ }
+ }
+
+ const selectsWithoutJoinedAliases = expressionMap.selects.filter(
+ (_select, index) => !indexesOfSelectsForJoinedAlias.includes(index),
+ );
+
+ return { selectsWithoutJoinedAliases };
+};
+
const validateReadFieldPermissionOrThrow = ({
restrictedFields,
selectedColumns,
@@ -336,12 +407,14 @@ const validateUpdateFieldPermissionOrThrow = ({
const getSelectedColumnsFromExpressionMap = ({
operationType,
- expressionMap,
+ expressionMapReturning,
+ expressionMapSelects,
allFieldsSelected,
}: {
operationType: string;
- expressionMap: QueryExpressionMap;
+ expressionMapSelects: { selection: string }[];
allFieldsSelected: boolean;
+ expressionMapReturning?: string | string[];
}) => {
let selectedColumns: string[] | '*' = [];
@@ -350,17 +423,16 @@ const getSelectedColumnsFromExpressionMap = ({
operationType,
)
) {
- if (!isDefined(expressionMap.returning)) {
+ if (!isDefined(expressionMapReturning)) {
throw new InternalServerError(
'Returning columns are not set for update query',
);
}
selectedColumns =
- expressionMap.returning === '*' ? '*' : [expressionMap.returning].flat();
+ expressionMapReturning === '*' ? '*' : [expressionMapReturning].flat();
} else if (!allFieldsSelected) {
- selectedColumns = getSelectedColumnsFromExpressionMapSelects(
- expressionMap.selects,
- );
+ selectedColumns =
+ getSelectedColumnsFromExpressionMapSelects(expressionMapSelects);
}
return selectedColumns;
diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
index 4c4bde8c3f..2cae998635 100644
--- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
@@ -1,20 +1,43 @@
import { randomUUID } from 'crypto';
+import { gql } from 'apollo-server-core';
+import { default as request } from 'supertest';
+import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util';
+import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util';
+import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
+import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
+import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
+import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
+import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
+import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
+import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
+import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createOneCoreViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-core-view-filter.util';
import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
-import { OrderByDirection, ViewFilterOperand } from 'twenty-shared/types';
+import {
+ FieldMetadataType,
+ OrderByDirection,
+ RelationType,
+ ViewFilterOperand,
+} from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
+import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
+import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
+
+const client = request(`http://localhost:${APP_PORT}`);
describe('group-by resolver (integration)', () => {
describe('standard case', () => {
@@ -660,4 +683,869 @@ describe('group-by resolver (integration)', () => {
);
});
});
+
+ describe('relation fields', () => {
+ describe('group by relation fields', () => {
+ const testPersonId = randomUUID();
+ const testPerson2Id = randomUUID();
+ const testPerson3Id = randomUUID();
+ const testCompanyId = randomUUID();
+ const testCompany2Id = randomUUID();
+
+ const filter2025 = {
+ and: [
+ {
+ createdAt: {
+ gte: '2025-01-01T00:00:00.000Z',
+ },
+ },
+ {
+ createdAt: {
+ lte: '2025-03-03T23:59:59.999Z',
+ },
+ },
+ ],
+ };
+
+ beforeAll(async () => {
+ // Create companies with different createdAt dates for grouping
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: COMPANY_GQL_FIELDS,
+ data: {
+ id: testCompanyId,
+ name: 'Company A',
+ createdAt: '2025-01-02T12:00:00.000Z', // Thursday
+ address: {
+ addressCity: 'City A',
+ },
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: COMPANY_GQL_FIELDS,
+ data: {
+ id: testCompany2Id,
+ name: 'Company B',
+ createdAt: '2025-01-08T08:00:00.000Z', // Wednesday
+ address: {
+ addressCity: 'City B',
+ },
+ },
+ }),
+ );
+
+ // Create people linked to companies
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_GQL_FIELDS,
+ data: {
+ id: testPersonId,
+ companyId: testCompanyId,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_GQL_FIELDS,
+ data: {
+ id: testPerson2Id,
+ companyId: testCompanyId,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_GQL_FIELDS,
+ data: {
+ id: testPerson3Id,
+ companyId: testCompany2Id,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+ });
+
+ afterAll(async () => {
+ // Cleanup people
+ for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+
+ // Cleanup companies
+ for (const id of [testCompanyId, testCompany2Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+ });
+
+ it('groups by one relation field - company createdAt with DAY_OF_THE_WEEK granularity', async () => {
+ const response = await makeGraphqlAPIRequest(
+ groupByOperationFactory({
+ objectMetadataSingularName: 'person',
+ objectMetadataPluralName: 'people',
+ groupBy: [
+ {
+ company: {
+ createdAt: {
+ granularity: 'DAY_OF_THE_WEEK',
+ },
+ },
+ },
+ ],
+ filter: filter2025,
+ }),
+ );
+
+ const groups = response.body.data.peopleGroupBy;
+
+ expect(groups).toBeDefined();
+ expect(Array.isArray(groups)).toBe(true);
+
+ const thursdayGroup = groups.find((g: any) =>
+ g.groupByDimensionValues?.[0]?.startsWith?.('Thursday'),
+ );
+ const wednesdayGroup = groups.find((g: any) =>
+ g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday'),
+ );
+
+ expect(thursdayGroup).toBeDefined();
+ expect(wednesdayGroup).toBeDefined();
+
+ expect(thursdayGroup.totalCount).toBe(2);
+ expect(wednesdayGroup.totalCount).toBe(1);
+ });
+
+ it('groups by two relation fields from the same joined table', async () => {
+ const response = await makeGraphqlAPIRequest(
+ groupByOperationFactory({
+ objectMetadataSingularName: 'person',
+ objectMetadataPluralName: 'people',
+ groupBy: [
+ {
+ company: {
+ createdAt: {
+ granularity: 'DAY_OF_THE_WEEK',
+ },
+ },
+ },
+ {
+ company: {
+ address: {
+ addressCity: true,
+ },
+ },
+ },
+ ],
+ filter: filter2025,
+ }),
+ );
+
+ const groups = response.body.data.peopleGroupBy;
+
+ expect(groups).toBeDefined();
+ expect(Array.isArray(groups)).toBe(true);
+
+ const thursdayCityAGroup = groups.find(
+ (g: any) =>
+ g.groupByDimensionValues?.[0]?.startsWith?.('Thursday') &&
+ g.groupByDimensionValues?.[1] === 'City A',
+ );
+ const wednesdayCityBGroup = groups.find(
+ (g: any) =>
+ g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday') &&
+ g.groupByDimensionValues?.[1] === 'City B',
+ );
+
+ expect(thursdayCityAGroup).toBeDefined();
+ expect(wednesdayCityBGroup).toBeDefined();
+
+ expect(thursdayCityAGroup.totalCount).toBe(2);
+ expect(wednesdayCityBGroup.totalCount).toBe(1);
+ });
+ });
+
+ describe('group by relation fields from different tables', () => {
+ const testPersonId = randomUUID();
+ const testPerson2Id = randomUUID();
+ const testPerson3Id = randomUUID();
+ const testCompanyId = randomUUID();
+ const testCompany2Id = randomUUID();
+ const testlistingId = randomUUID();
+ const testlisting2Id = randomUUID();
+ let listingObjectMetadataId: string;
+ let personlistingRelationFieldId: string;
+ const filter2025 = {
+ and: [
+ {
+ createdAt: {
+ gte: '2025-01-01T00:00:00.000Z',
+ },
+ },
+ {
+ createdAt: {
+ lte: '2025-03-03T23:59:59.999Z',
+ },
+ },
+ ],
+ };
+
+ beforeAll(async () => {
+ // Find person and company object metadata
+ const { objects } = await findManyObjectMetadata({
+ input: {
+ filter: {},
+ paging: {
+ first: 100,
+ },
+ },
+ gqlFields: 'id nameSingular',
+ expectToFail: false,
+ });
+
+ const personObject = objects.find((o) => o.nameSingular === 'person');
+ const companyObject = objects.find((o) => o.nameSingular === 'company');
+
+ if (!personObject?.id || !companyObject?.id) {
+ throw new Error('Person or Company object not found');
+ }
+
+ // Create listing object
+ const { data: createListingObjectData } = await createOneObjectMetadata(
+ {
+ input: {
+ nameSingular: 'listing',
+ namePlural: 'listings',
+ labelSingular: 'Listing',
+ labelPlural: 'Listings',
+ icon: 'IconPaw',
+ },
+ expectToFail: false,
+ },
+ );
+
+ listingObjectMetadataId = createListingObjectData.createOneObject.id;
+
+ // Create relation from person to listing
+ const personlistingRelation = await createRelationBetweenObjects({
+ objectMetadataId: personObject.id,
+ targetObjectMetadataId: listingObjectMetadataId,
+ relationType: RelationType.MANY_TO_ONE,
+ type: FieldMetadataType.RELATION,
+ name: 'assignedListing',
+ });
+
+ personlistingRelationFieldId = personlistingRelation.id;
+
+ // Create companies
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: COMPANY_GQL_FIELDS,
+ data: {
+ id: testCompanyId,
+ name: 'Company A',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: COMPANY_GQL_FIELDS,
+ data: {
+ id: testCompany2Id,
+ name: 'Company B',
+ },
+ }),
+ );
+
+ // Create listings
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'listing',
+ gqlFields: 'id name',
+ data: {
+ id: testlistingId,
+ name: 'Flat in Paris',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'listing',
+ gqlFields: 'id name',
+ data: {
+ id: testlisting2Id,
+ name: 'House in Marseille',
+ },
+ }),
+ );
+
+ // Create people linked to companies and listings
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_GQL_FIELDS,
+ data: {
+ id: testPersonId,
+ companyId: testCompanyId,
+ assignedListingId: testlistingId,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_GQL_FIELDS,
+ data: {
+ id: testPerson2Id,
+ companyId: testCompanyId,
+ assignedListingId: testlisting2Id,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_GQL_FIELDS,
+ data: {
+ id: testPerson3Id,
+ companyId: testCompanyId,
+ assignedListingId: testlisting2Id,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+ });
+
+ afterAll(async () => {
+ // Cleanup people
+ for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+
+ // Cleanup listings
+ for (const id of [testlistingId, testlisting2Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'listing',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+
+ // Cleanup company
+ for (const id of [testCompanyId, testCompany2Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+
+ // Cleanup relation field
+ if (isDefined(personlistingRelationFieldId)) {
+ await updateOneFieldMetadata({
+ input: {
+ idToUpdate: personlistingRelationFieldId,
+ updatePayload: {
+ isActive: false,
+ },
+ },
+ expectToFail: false,
+ });
+
+ await deleteOneFieldMetadata({
+ input: { idToDelete: personlistingRelationFieldId },
+ expectToFail: false,
+ });
+ }
+
+ // Cleanup listing object
+ if (isDefined(listingObjectMetadataId)) {
+ await updateOneObjectMetadata({
+ input: {
+ idToUpdate: listingObjectMetadataId,
+ updatePayload: {
+ isActive: false,
+ },
+ },
+ expectToFail: false,
+ });
+
+ await deleteOneObjectMetadata({
+ input: { idToDelete: listingObjectMetadataId },
+ expectToFail: false,
+ });
+ }
+ });
+
+ it('groups by two relation fields from different tables', async () => {
+ const response = await makeGraphqlAPIRequest(
+ groupByOperationFactory({
+ objectMetadataSingularName: 'person',
+ objectMetadataPluralName: 'people',
+ groupBy: [
+ {
+ company: {
+ name: true,
+ },
+ },
+ {
+ assignedListing: {
+ name: true,
+ },
+ },
+ ],
+ filter: filter2025,
+ }),
+ );
+
+ const groups = response.body.data.peopleGroupBy;
+
+ expect(groups).toBeDefined();
+ expect(Array.isArray(groups)).toBe(true);
+
+ const companyAFlatInParisGroup = groups.find(
+ (g: any) =>
+ g.groupByDimensionValues?.[0] === 'Company A' &&
+ g.groupByDimensionValues?.[1] === 'Flat in Paris',
+ );
+ const companyAHouseInMarseilleGroup = groups.find(
+ (g: any) =>
+ g.groupByDimensionValues?.[0] === 'Company A' &&
+ g.groupByDimensionValues?.[1] === 'House in Marseille',
+ );
+
+ expect(companyAFlatInParisGroup).toBeDefined();
+ expect(companyAHouseInMarseilleGroup).toBeDefined();
+
+ expect(companyAFlatInParisGroup.totalCount).toBe(1);
+ expect(companyAHouseInMarseilleGroup.totalCount).toBe(2);
+ });
+ });
+
+ describe('group by morph relation fields', () => {
+ const testPetId = randomUUID();
+ const testPet2Id = randomUUID();
+ const testPet3Id = randomUUID();
+ const testRocketId = randomUUID();
+ const testRocket2Id = randomUUID();
+
+ const filter2025 = {
+ and: [
+ {
+ createdAt: {
+ gte: '2025-01-01T00:00:00.000Z',
+ },
+ },
+ {
+ createdAt: {
+ lte: '2025-03-03T23:59:59.999Z',
+ },
+ },
+ ],
+ };
+
+ beforeAll(async () => {
+ // Create rockets with different names
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'rocket',
+ gqlFields: 'id name',
+ data: {
+ id: testRocketId,
+ name: 'Rocket A',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'rocket',
+ gqlFields: 'id name',
+ data: {
+ id: testRocket2Id,
+ name: 'Rocket B',
+ },
+ }),
+ );
+
+ // Create pets linked to rockets via morph relation
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'pet',
+ gqlFields: 'id name ownerRocket { id name }',
+ data: {
+ id: testPetId,
+ name: 'Pet 1',
+ ownerRocketId: testRocketId,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'pet',
+ gqlFields: 'id name ownerRocket { id name }',
+ data: {
+ id: testPet2Id,
+ name: 'Pet 2',
+ ownerRocketId: testRocketId,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'pet',
+ gqlFields: 'id name ownerRocket { id name }',
+ data: {
+ id: testPet3Id,
+ name: 'Pet 3',
+ ownerRocketId: testRocket2Id,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+ });
+
+ afterAll(async () => {
+ // Cleanup pets
+ for (const id of [testPetId, testPet2Id, testPet3Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'pet',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+
+ // Cleanup rockets
+ for (const id of [testRocketId, testRocket2Id]) {
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'rocket',
+ gqlFields: 'id',
+ recordId: id,
+ }),
+ );
+ }
+ });
+
+ it('groups by morph relation field - ownerRocket name', async () => {
+ const response = await makeGraphqlAPIRequest(
+ groupByOperationFactory({
+ objectMetadataSingularName: 'pet',
+ objectMetadataPluralName: 'pets',
+ groupBy: [
+ {
+ ownerRocket: {
+ name: true,
+ },
+ },
+ ],
+ filter: filter2025,
+ }),
+ );
+
+ const groups = response.body.data.petsGroupBy;
+
+ expect(groups).toBeDefined();
+ expect(Array.isArray(groups)).toBe(true);
+
+ const rocketAGroup = groups.find(
+ (g: any) => g.groupByDimensionValues?.[0] === 'Rocket A',
+ );
+ const rocketBGroup = groups.find(
+ (g: any) => g.groupByDimensionValues?.[0] === 'Rocket B',
+ );
+
+ expect(rocketAGroup).toBeDefined();
+ expect(rocketBGroup).toBeDefined();
+
+ expect(rocketAGroup.totalCount).toBe(2);
+ expect(rocketBGroup.totalCount).toBe(1);
+ });
+ });
+
+ describe('should throw if group by a relation field user does not have reading rights on', () => {
+ const testPetId = randomUUID();
+ const testRocketId = randomUUID();
+ let customRoleId: string;
+ let petObjectId: string;
+ let rocketObjectId: string;
+ let originalMemberRoleId: string;
+
+ beforeAll(async () => {
+ // Get the original Member role ID for restoration later
+ const getRolesQuery = {
+ query: `
+ query GetRoles {
+ getRoles {
+ id
+ label
+ }
+ }
+ `,
+ };
+
+ const rolesResponse = await client
+ .post('/graphql')
+ .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
+ .send(getRolesQuery);
+
+ originalMemberRoleId = rolesResponse.body.data.getRoles.find(
+ (role: any) => role.label === 'Member',
+ ).id;
+
+ // Get object metadata IDs for pet and rocket
+ const { objects } = await findManyObjectMetadata({
+ input: {
+ filter: {},
+ paging: {
+ first: 100,
+ },
+ },
+ gqlFields: 'id nameSingular',
+ expectToFail: false,
+ });
+
+ const petObject = objects.find((o) => o.nameSingular === 'pet');
+ const rocketObject = objects.find((o) => o.nameSingular === 'rocket');
+
+ if (!petObject?.id || !rocketObject?.id) {
+ throw new Error('Pet or Rocket object not found');
+ }
+
+ petObjectId = petObject.id;
+ rocketObjectId = rocketObject.id;
+
+ // Create a custom role with pet read permission but no rocket read permission
+ const createRoleOperation = {
+ query: gql`
+ mutation CreateOneRole {
+ createOneRole(
+ createRoleInput: {
+ label: "PetOnlyRole"
+ description: "Test role with pet read permission but no rocket read permission"
+ canUpdateAllSettings: false
+ canReadAllObjectRecords: false
+ canUpdateAllObjectRecords: false
+ canSoftDeleteAllObjectRecords: false
+ canDestroyAllObjectRecords: false
+ }
+ ) {
+ id
+ label
+ }
+ }
+ `,
+ };
+
+ const createRoleResponse =
+ await makeGraphqlAPIRequest(createRoleOperation);
+
+ customRoleId = createRoleResponse.body.data.createOneRole.id;
+
+ // Set object permissions: allow reading pets but not rockets
+ const upsertObjectPermissionsOperation = {
+ query: gql`
+ mutation UpsertObjectPermissions(
+ $roleId: UUID!
+ $objectPermissions: [ObjectPermissionInput!]!
+ ) {
+ upsertObjectPermissions(
+ upsertObjectPermissionsInput: {
+ roleId: $roleId
+ objectPermissions: $objectPermissions
+ }
+ ) {
+ objectMetadataId
+ canReadObjectRecords
+ }
+ }
+ `,
+ variables: {
+ roleId: customRoleId,
+ objectPermissions: [
+ {
+ objectMetadataId: petObjectId,
+ canReadObjectRecords: true,
+ canUpdateObjectRecords: false,
+ canSoftDeleteObjectRecords: false,
+ canDestroyObjectRecords: false,
+ },
+ {
+ objectMetadataId: rocketObjectId,
+ canReadObjectRecords: false,
+ canUpdateObjectRecords: false,
+ canSoftDeleteObjectRecords: false,
+ canDestroyObjectRecords: false,
+ },
+ ],
+ },
+ };
+
+ await makeGraphqlAPIRequest(upsertObjectPermissionsOperation);
+
+ // Assign the custom role to a workspace member
+ await updateWorkspaceMemberRole({
+ client,
+ roleId: customRoleId,
+ workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
+ });
+
+ // Create a rocket
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'rocket',
+ gqlFields: 'id name',
+ data: {
+ id: testRocketId,
+ name: 'Test Rocket',
+ },
+ }),
+ );
+
+ // Create a pet linked to the rocket
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'pet',
+ gqlFields: 'id name ownerRocket { id name }',
+ data: {
+ id: testPetId,
+ name: 'Test Pet',
+ ownerRocketId: testRocketId,
+ createdAt: '2025-03-03T09:30:00.000Z',
+ },
+ }),
+ );
+ });
+
+ afterAll(async () => {
+ // Cleanup pet
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'pet',
+ gqlFields: 'id',
+ recordId: testPetId,
+ }),
+ );
+
+ // Cleanup rocket
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'rocket',
+ gqlFields: 'id',
+ recordId: testRocketId,
+ }),
+ );
+
+ // // Restore original role
+ const restoreMemberRoleQuery = {
+ query: `
+ mutation UpdateWorkspaceMemberRole {
+ updateWorkspaceMemberRole(
+ workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}"
+ roleId: "${originalMemberRoleId}"
+ ) {
+ id
+ }
+ }
+ `,
+ };
+
+ await client
+ .post('/graphql')
+ .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
+ .send(restoreMemberRoleQuery);
+
+ // // Delete custom role
+ if (isDefined(customRoleId)) {
+ await deleteRole(client, customRoleId);
+ }
+ });
+
+ it('should throw a permission error when grouping by ownerRocket field without read permission', async () => {
+ const filter2025 = {
+ and: [
+ {
+ createdAt: {
+ gte: '2025-01-01T00:00:00.000Z',
+ },
+ },
+ {
+ createdAt: {
+ lte: '2025-03-03T23:59:59.999Z',
+ },
+ },
+ ],
+ };
+
+ const response = await makeGraphqlAPIRequestWithMemberRole(
+ groupByOperationFactory({
+ objectMetadataSingularName: 'pet',
+ objectMetadataPluralName: 'pets',
+ groupBy: [
+ {
+ ownerRocket: {
+ name: true,
+ },
+ },
+ ],
+ filter: filter2025,
+ }),
+ );
+
+ expect(response.body.errors).toBeDefined();
+ expect(response.body.errors[0].message).toBe(
+ PermissionsExceptionMessage.PERMISSION_DENIED,
+ );
+ expect(response.body.errors[0].extensions.code).toBe(
+ ErrorCode.FORBIDDEN,
+ );
+ });
+ });
+ });
});
diff --git a/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts b/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts
index 134ae25730..fe9f3d2227 100644
--- a/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts
+++ b/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts
@@ -25,6 +25,7 @@ export type OrderByWithGroupBy = Array<
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForRelationField
| AggregateOrderByWithGroupByField
>;
@@ -35,3 +36,10 @@ export type ObjectRecordOrderByForScalarField = {
export type ObjectRecordOrderByForCompositeField = {
[Property in keyof ObjectRecord]?: Record;
};
+
+export type ObjectRecordOrderByForRelationField = {
+ [Property in keyof ObjectRecord]?:
+ | ObjectRecordOrderByForCompositeField
+ | ObjectRecordOrderByWithGroupByDateField
+ | ObjectRecordOrderByForScalarField;
+};
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 997c799943..053120c409 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -124,6 +124,7 @@ export type {
OrderByWithGroupBy,
ObjectRecordOrderByForScalarField,
ObjectRecordOrderByForCompositeField,
+ ObjectRecordOrderByForRelationField,
} from './ObjectRecordGroupBy';
export { OrderByDirection } from './ObjectRecordGroupBy';
export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGranularity';