Common api - Group by query (#15108)

closes https://github.com/twentyhq/core-team-issues/issues/1626
This commit is contained in:
Etienne
2025-10-20 17:51:01 +02:00
committed by GitHub
parent 5186e73ce1
commit dea08a3a41
49 changed files with 1223 additions and 254 deletions
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import {
CompositeFieldSubFieldName,
FieldMetadataType,
ObjectRecord,
PartialFieldMetadataItemOption,
RecordFilterGroupLogicalOperator,
@@ -26,7 +25,7 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util';
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';
@@ -152,10 +151,11 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
if (aggregateExpression) {
queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`);
const isNumericReturningAggregate = this.isNumericReturningAggregate(
aggregationField.aggregateOperation,
aggregationField.fromFieldType,
);
const isNumericReturningAggregate =
computeIsNumericReturningAggregate(
aggregationField.aggregateOperation,
aggregationField.fromFieldType,
);
if (isNumericReturningAggregate) {
queryBuilder.andHaving(`${aggregateExpression} != 0`);
@@ -172,7 +172,11 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
const result = await queryBuilder.getRawMany();
return formatResultWithGroupByDimensionValues(result, groupByDefinitions);
return formatResultWithGroupByDimensionValues(
result,
groupByDefinitions,
Object.keys(executionArgs.graphqlQuerySelectedFieldsResult.aggregate),
);
}
private async addFiltersFromView({
@@ -266,39 +270,6 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
return appliedFilters;
}
private isNumericReturningAggregate(
operation: AggregateOperations,
fromFieldType: FieldMetadataType,
): boolean {
if (
operation === AggregateOperations.COUNT ||
operation === AggregateOperations.COUNT_UNIQUE_VALUES ||
operation === AggregateOperations.COUNT_EMPTY ||
operation === AggregateOperations.COUNT_NOT_EMPTY ||
operation === AggregateOperations.COUNT_TRUE ||
operation === AggregateOperations.COUNT_FALSE ||
operation === AggregateOperations.PERCENTAGE_EMPTY ||
operation === AggregateOperations.PERCENTAGE_NOT_EMPTY
) {
return true;
}
if (
operation === AggregateOperations.MIN ||
operation === AggregateOperations.MAX ||
operation === AggregateOperations.AVG ||
operation === AggregateOperations.SUM
) {
return [
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.CURRENCY,
].includes(fromFieldType);
}
return false;
}
async validate(
_args: GroupByResolverArgs<ObjectRecordFilter>,
_options: WorkspaceQueryRunnerOptions,
@@ -0,0 +1,36 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
export const computeIsNumericReturningAggregate = (
operation: AggregateOperations,
fromFieldType: FieldMetadataType,
): boolean => {
if (
operation === AggregateOperations.COUNT ||
operation === AggregateOperations.COUNT_UNIQUE_VALUES ||
operation === AggregateOperations.COUNT_EMPTY ||
operation === AggregateOperations.COUNT_NOT_EMPTY ||
operation === AggregateOperations.COUNT_TRUE ||
operation === AggregateOperations.COUNT_FALSE ||
operation === AggregateOperations.PERCENTAGE_EMPTY ||
operation === AggregateOperations.PERCENTAGE_NOT_EMPTY
) {
return true;
}
if (
operation === AggregateOperations.MIN ||
operation === AggregateOperations.MAX ||
operation === AggregateOperations.AVG ||
operation === AggregateOperations.SUM
) {
return [
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.CURRENCY,
].includes(fromFieldType);
}
return false;
};
@@ -7,57 +7,60 @@ import {
import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface';
import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
export const formatResultWithGroupByDimensionValues = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result: any[],
export const formatResultWithGroupByDimensionValues = <
T extends
| IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>
| CommonGroupByOutputItem,
>(
result: Record<string, string>[],
groupByColumnsWithQuotes: {
columnNameWithQuotes: string;
alias: string;
dateGranularity?: ObjectRecordGroupByDateGranularity;
}[],
): IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>[] => {
let formattedResult: IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>[] =
[];
aggregateFieldNames: string[],
): T[] => {
let formattedResult: T[] = [];
result.forEach((group) => {
let dimensionValues = [];
let dimensionValues: string[] = [];
for (const groupByColumn of groupByColumnsWithQuotes) {
dimensionValues.push(group[groupByColumn.alias]);
dimensionValues.push(
getTranslatedValueIfApplicable(
group[groupByColumn.alias],
groupByColumn.dateGranularity,
),
);
}
const groupWithValueMappedToUnaliasedColumn = {
...group,
...groupByColumnsWithQuotes.reduce<Record<string, unknown>>(
(acc, groupByColumn) => {
const value = group[groupByColumn.alias];
acc[removeQuotes(groupByColumn.columnNameWithQuotes)] =
getTranslatedValueIfApplicable(
value,
groupByColumn.dateGranularity,
);
const aggregateValues = aggregateFieldNames.reduce(
(acc, fieldName) => {
if (fieldName in group) {
acc[fieldName] = group[fieldName];
}
return acc;
},
{},
),
};
return acc;
},
{} as Record<string, string>,
);
formattedResult.push({
groupByDimensionValues: dimensionValues,
...groupWithValueMappedToUnaliasedColumn,
});
...aggregateValues,
//TODO: Refacto-common - remove generic type
} as T);
});
return formattedResult;
};
const getTranslatedValueIfApplicable = (
value: unknown,
value: string,
dateGranularity?: ObjectRecordGroupByDateGranularity,
) => {
): string => {
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
switch (value) {
@@ -11,6 +11,7 @@ import {
type FindDuplicatesResolverArgs,
type FindManyResolverArgs,
type FindOneResolverArgs,
GroupByResolverArgs,
type MergeManyResolverArgs,
type ResolverArgs,
ResolverArgsType,
@@ -108,7 +109,6 @@ export class QueryRunnerArgsFactory {
options.objectMetadataItemWithFieldMaps,
),
};
case ResolverArgsType.FindDuplicates:
return {
...args,
@@ -146,6 +146,14 @@ export class QueryRunnerArgsFactory {
.conflictPriorityIndex,
dryRun: (args as MergeManyResolverArgs).dryRun,
} satisfies MergeManyResolverArgs;
case ResolverArgsType.GroupBy:
return {
...args,
filter: this.overrideFilterByFieldMetadata(
(args as GroupByResolverArgs).filter,
options.objectMetadataItemWithFieldMaps,
),
};
default:
return args;
}
@@ -8,6 +8,7 @@ import {
type FindDuplicatesResolverArgs,
type FindManyResolverArgs,
type FindOneResolverArgs,
type GroupByResolverArgs,
type MergeManyResolverArgs,
type RestoreManyResolverArgs,
type UpdateManyResolverArgs,
@@ -45,4 +46,6 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? DestroyOneResolverArgs
: T extends 'mergeMany'
? MergeManyResolverArgs
: never;
: T extends 'groupBy'
? GroupByResolverArgs
: never;
@@ -12,7 +12,6 @@ import {
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -46,20 +45,11 @@ export class CreateManyResolverFactory
const featureFlagsMap = workspaceDataSource.featureFlagMap;
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const graphqlQueryParser = new GraphqlQueryParser(
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
const selectedFields = graphqlFields(info);
try {
const records = await this.commonCreateManyQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
@@ -11,7 +11,6 @@ import {
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -43,20 +42,11 @@ export class CreateOneResolverFactory
);
if (isCommonApiEnabled) {
const graphqlQueryParser = new GraphqlQueryParser(
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
const selectedFields = graphqlFields(info);
try {
const record = await this.commonCreateOneQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
@@ -11,7 +11,6 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -43,26 +42,22 @@ export class FindManyResolverFactory
);
if (isCommonApiEnabled) {
const graphqlQueryParser = new GraphqlQueryParser(
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
const selectedFields = graphqlFields(info);
try {
const { records, aggregatedValues, totalCount, pageInfo } =
await this.commonFindManyQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
const {
records,
aggregatedValues,
totalCount,
pageInfo,
selectedFieldsResult,
} = await this.commonFindManyQueryRunnerService.run({
args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
@@ -10,7 +10,6 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -43,19 +42,10 @@ export class FindOneResolverFactory
if (isCommonApiEnabled) {
try {
const graphqlQueryParser = new GraphqlQueryParser(
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
const selectedFields = graphqlFields(info);
const record = await this.commonFindOneQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
@@ -8,8 +10,12 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class GroupByResolverFactory
@@ -19,6 +25,8 @@ export class GroupByResolverFactory
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryGroupByResolverService,
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
private readonly featureFlagService: FeatureFlagService,
) {}
create(
@@ -27,6 +35,27 @@ export class GroupByResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
if (isCommonApiEnabled) {
const selectedFields = graphqlFields(info);
try {
return await this.commonGroupByQueryRunnerService.run({
args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
} catch (error) {
return workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
@@ -29,6 +29,7 @@ export enum ResolverArgsType {
DestroyMany = 'DestroyMany',
DestroyOne = 'DestroyOne',
MergeMany = 'MergeMany',
GroupBy = 'GroupBy',
}
export interface FindManyResolverArgs<