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) {