[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:
+70
-52
@@ -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,
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+60
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user