[groupBy] groupBy relation fields (#15951)

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

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

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

We don't allow grouping by relations of relations.

Left to do
- rest api
- tests on permissions
This commit is contained in:
Marie
2025-11-25 10:03:16 +01:00
committed by GitHub
parent 316aec3c40
commit 46d1ea6505
34 changed files with 1910 additions and 143 deletions
@@ -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<ObjectLiteral>;
groupByFields: GroupByField[];
objectAlias: string;
}): void {
const joinAliasSet = new Set<string>();
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<GroupByQueryArgs>,
_queryRunnerContext: CommonBaseQueryRunnerContext,
@@ -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',
}
@@ -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);
@@ -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);
@@ -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,
};
});
};
@@ -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;
};