Implement groupBy resolver (with orderBy, without viewId) (#14690)

Closes
https://github.com/twentyhq/core-team-issues/issues/248?issue=twentyhq%7Ccore-team-issues%7C1543

In this PR
- Implementation of groupBy resolver, without pagination, without viewId
parameter
- introduction of new GqlInputTypeDefinitionKind `OrderByWithGroupBy`
which differs from `OrderBy` as it should use aggregations as order by
conditions (e.g.: OrderByWithGroupBy should use avgEmployees and not
employees which does not make sense)
- Minor refacto of all orderBy expressions to use orderBy signature with
`OrderByCondition` which is usable for both groupBy queries and
non-groupBy queries. groupBy queries differ because their orderBy
conditions are based on aggregatedFields (such as avgEmployees) for
which we can't use the same orderBy signature
<img width="728" height="216" alt="Capture d’écran 2025-09-24 à 15 07
14"
src="https://github.com/user-attachments/assets/aa5c0875-cf38-4d4b-be70-f4d64da77a41"
/>

- Minor refacto to add table name in aggregates expressions
`(AVG("employees") -> AVG("company"."employees")` to keep having the
table names in the orderBy expression as we already had. Since we use
common utils for orderBy and aggregates on findMany this change also
impacts findMany queries with aggregates. It also impacts field
permission check where we extract the name of the selected fields to
check permission on, so I also updated that (see
`extractColumnNamesFromAggregateExpression`)
This commit is contained in:
Marie
2025-09-24 18:00:55 +02:00
committed by GitHub
parent d477db261a
commit 8d78032357
13 changed files with 530 additions and 345 deletions
@@ -1,3 +1,5 @@
import { InternalServerErrorException } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
@@ -10,12 +12,22 @@ import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import {
type AggregationField,
getAvailableAggregationsFromObjectFields,
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
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 CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
type OrderByCondition = {
order: 'ASC' | 'DESC';
nulls?: 'NULLS FIRST' | 'NULLS LAST';
};
export class GraphqlQueryOrderFieldParser {
private objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
@@ -27,7 +39,16 @@ export class GraphqlQueryOrderFieldParser {
orderBy: ObjectRecordOrderBy,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, string> {
isGroupBy = false,
): Record<string, OrderByCondition> {
if (isGroupBy) {
return this.parseForGroupBy(
orderBy,
objectNameSingular,
isForwardPagination,
);
}
return orderBy.reduce(
(acc, item) => {
Object.entries(item).forEach(([key, value]) => {
@@ -65,10 +86,54 @@ export class GraphqlQueryOrderFieldParser {
return acc;
},
{} as Record<string, string>,
{} as Record<string, OrderByCondition>,
);
}
private parseForGroupBy(
orderBy: ObjectRecordOrderBy,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, OrderByCondition> {
const availableAggregations: Record<string, AggregationField> =
getAvailableAggregationsFromObjectFields(
Object.values(this.objectMetadataMapItem.fieldsById),
);
let orderByExpressionsAndConditions: Record<string, OrderByCondition> = {};
for (const orderByCondition of orderBy) {
for (const [aggregatedOrderByCondition, direction] of Object.entries(
orderByCondition,
)) {
const selectedAggregation =
availableAggregations[aggregatedOrderByCondition];
if (!selectedAggregation) {
throw new InternalServerErrorException(
`Selected aggregation not found for ${aggregatedOrderByCondition}`,
);
}
const expression = ProcessAggregateHelper.getAggregateExpression(
selectedAggregation,
objectNameSingular,
);
if (!expression) {
throw new InternalServerErrorException(
`Aggregate expression not found for ${aggregatedOrderByCondition}`,
);
}
orderByExpressionsAndConditions[expression] =
this.convertOrderByToFindOptionsOrder(direction, isForwardPagination);
}
}
return orderByExpressionsAndConditions;
}
private getOptionalOrderByCasting(
fieldMetadata: Pick<FieldMetadataEntity, 'type'>,
): string {
@@ -88,7 +153,7 @@ export class GraphqlQueryOrderFieldParser {
value: any,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, string> {
): Record<string, OrderByCondition> {
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type as CompositeFieldMetadataType,
);
@@ -125,23 +190,35 @@ export class GraphqlQueryOrderFieldParser {
return acc;
},
{} as Record<string, string>,
{} as Record<string, OrderByCondition>,
);
}
private convertOrderByToFindOptionsOrder(
direction: OrderByDirection,
isForwardPagination = true,
): string {
): OrderByCondition {
switch (direction) {
case OrderByDirection.AscNullsFirst:
return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS FIRST`;
return {
order: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'NULLS FIRST',
};
case OrderByDirection.AscNullsLast:
return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS LAST`;
return {
order: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'NULLS LAST',
};
case OrderByDirection.DescNullsFirst:
return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS FIRST`;
return {
order: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'NULLS FIRST',
};
case OrderByDirection.DescNullsLast:
return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS LAST`;
return {
order: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'NULLS LAST',
};
default:
throw new GraphqlQueryRunnerException(
`Invalid direction: ${direction}`,
@@ -1,8 +1,4 @@
import {
type FindOptionsWhere,
type ObjectLiteral,
type OrderByCondition,
} from 'typeorm';
import { type FindOptionsWhere, type ObjectLiteral } from 'typeorm';
import {
type ObjectRecordFilter,
@@ -104,15 +100,35 @@ export class GraphqlQueryParser {
orderBy: ObjectRecordOrderBy,
objectNameSingular: string,
isForwardPagination = true,
isGroupBy = false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
const parsedOrderBys = this.orderFieldParser.parse(
orderBy,
objectNameSingular,
isForwardPagination,
isGroupBy,
);
return queryBuilder.orderBy(parsedOrderBys as OrderByCondition);
if (isGroupBy) {
Object.entries(parsedOrderBys).forEach(
([expression, direction], index) => {
if (index === 0) {
queryBuilder.orderBy(expression, direction.order, direction.nulls);
} else {
queryBuilder.addOrderBy(
expression,
direction.order,
direction.nulls,
);
}
},
);
return queryBuilder;
}
return queryBuilder.orderBy(parsedOrderBys);
}
public parseSelectedFields(
@@ -9,102 +9,28 @@ export class ProcessAggregateHelper {
public static addSelectedAggregatedFieldsQueriesToQueryBuilder = ({
selectedAggregatedFields,
queryBuilder,
objectMetadataNameSingular,
}: {
selectedAggregatedFields: Record<string, AggregationField>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryBuilder: WorkspaceSelectQueryBuilder<any>;
objectMetadataNameSingular: string;
}) => {
queryBuilder.select([]);
for (const [aggregatedFieldName, aggregatedField] of Object.entries(
selectedAggregatedFields,
)) {
if (
!isDefined(aggregatedField?.fromField) ||
!isDefined(aggregatedField?.aggregateOperation)
) {
continue;
}
const columnNames = formatColumnNamesFromCompositeFieldAndSubfields(
aggregatedField.fromField,
aggregatedField.fromSubFields,
const aggregateExpression = this.getAggregateExpression(
aggregatedField,
objectMetadataNameSingular,
);
const columnNameForNumericOperation = isDefined(
aggregatedField.subFieldForNumericOperation,
)
? formatColumnNamesFromCompositeFieldAndSubfields(
aggregatedField.fromField,
[aggregatedField.subFieldForNumericOperation],
)[0]
: columnNames[0];
if (
!Object.values(AggregateOperations).includes(
aggregatedField.aggregateOperation,
)
) {
if (!isDefined(aggregateExpression)) {
continue;
}
const concatenatedColumns = columnNames
.map((col) => `"${col}"`)
.join(',');
const columnExpression = `NULLIF(CONCAT(${concatenatedColumns}), '')`;
switch (aggregatedField.aggregateOperation) {
case AggregateOperations.COUNT_EMPTY:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnExpression}) END`,
`${aggregatedFieldName}`,
);
break;
case AggregateOperations.COUNT_NOT_EMPTY:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnExpression}) END`,
`${aggregatedFieldName}`,
);
break;
case AggregateOperations.COUNT_UNIQUE_VALUES:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT ${columnExpression}) END`,
`${aggregatedFieldName}`,
);
break;
case AggregateOperations.PERCENTAGE_EMPTY:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnExpression})::decimal) / COUNT(*)) AS DECIMAL) END`,
`${aggregatedFieldName}`,
);
break;
case AggregateOperations.PERCENTAGE_NOT_EMPTY:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnExpression})::decimal / COUNT(*)) AS DECIMAL) END`,
`${aggregatedFieldName}`,
);
break;
case AggregateOperations.COUNT_TRUE:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = TRUE THEN 1 ELSE NULL END) END`,
`${aggregatedFieldName}`,
);
break;
case AggregateOperations.COUNT_FALSE:
queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = FALSE THEN 1 ELSE NULL END) END`,
`${aggregatedFieldName}`,
);
break;
default: {
queryBuilder.addSelect(
`${aggregatedField.aggregateOperation}("${columnNameForNumericOperation}")`,
`${aggregatedFieldName}`,
);
}
}
queryBuilder.addSelect(aggregateExpression, aggregatedFieldName);
}
};
@@ -118,21 +44,104 @@ export class ProcessAggregateHelper {
if (concatMatches) {
// Extract all column names between quotes after CONCAT
const columnNames = selection
.match(/"([^"]+)"/g)
?.map((match) => match.slice(1, -1));
const columnNames = selection.match(/"([^"]+)"/g)?.map((match) => {
const fullColumn = match.slice(1, -1);
// If there's a dot, extract only the column name (part after the dot)
const parts = fullColumn.split('.');
return parts[parts.length - 1];
});
return columnNames || null;
}
// For non-CONCAT expressions, match content between double quotes
// Using positive lookbehind and lookahead to match content between quotes without including quotes
const columnMatch = selection.match(/(?<=")([^"]+)(?=")/);
// For non-CONCAT expressions, match table.column pattern within quotes
// Look for patterns like "table"."column" and extract only the column part
const tableColumnMatches = selection.match(/"[^"]+"\."([^"]+)"/g);
if (columnMatch) {
return [columnMatch[0]];
if (tableColumnMatches) {
const columnNames = tableColumnMatches
.map((match) => {
// Extract the column name from "table"."column" pattern
const columnMatch = match.match(/"[^"]+"\."([^"]+)"/);
return columnMatch ? columnMatch[1] : null;
})
.filter(Boolean);
return columnNames.length > 0
? columnNames.filter((c) => isDefined(c))
: null;
}
// Fallback: match single quoted content that doesn't contain dots
const singleColumnMatch = selection.match(/"([^".]+)"/);
if (singleColumnMatch) {
return [singleColumnMatch[1]];
}
return null;
};
public static getAggregateExpression = (
aggregatedField: AggregationField,
objectMetadataNameSingular: string,
): string | undefined => {
if (
!isDefined(aggregatedField?.fromField) ||
!isDefined(aggregatedField?.aggregateOperation)
) {
return;
}
const columnNames = formatColumnNamesFromCompositeFieldAndSubfields(
aggregatedField.fromField,
aggregatedField.fromSubFields,
);
const columnNameForNumericOperation = isDefined(
aggregatedField.subFieldForNumericOperation,
)
? formatColumnNamesFromCompositeFieldAndSubfields(
aggregatedField.fromField,
[aggregatedField.subFieldForNumericOperation],
)[0]
: columnNames[0];
if (
!Object.values(AggregateOperations).includes(
aggregatedField.aggregateOperation,
)
) {
return;
}
const concatenatedColumns = columnNames
.map((col) => `"${objectMetadataNameSingular}"."${col}"`)
.join(',');
const columnExpression = `NULLIF(CONCAT(${concatenatedColumns}), '')`;
switch (aggregatedField.aggregateOperation) {
case AggregateOperations.COUNT_EMPTY:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnExpression}) END`;
case AggregateOperations.COUNT_NOT_EMPTY:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnExpression}) END`;
case AggregateOperations.COUNT_UNIQUE_VALUES:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT ${columnExpression}) END`;
case AggregateOperations.PERCENTAGE_EMPTY:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnExpression})::decimal) / COUNT(*)) AS DECIMAL) END`;
case AggregateOperations.PERCENTAGE_NOT_EMPTY:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnExpression})::decimal / COUNT(*)) AS DECIMAL) END`;
case AggregateOperations.COUNT_TRUE:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = TRUE THEN 1 ELSE NULL END) END`;
case AggregateOperations.COUNT_FALSE:
return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = FALSE THEN 1 ELSE NULL END) END`;
default: {
return `${aggregatedField.aggregateOperation}("${objectMetadataNameSingular}"."${columnNameForNumericOperation}")`;
}
}
};
}
@@ -152,8 +152,10 @@ export class ProcessNestedRelationsV2Helper {
roleId,
);
const targetObjectNameSingular = targetObjectMetadata.nameSingular;
let targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder(
targetObjectMetadata.nameSingular,
targetObjectNameSingular,
);
const columnsToSelect = buildColumnsToSelect({
@@ -196,6 +198,7 @@ export class ProcessNestedRelationsV2Helper {
limit: limit * parentObjectRecords.length,
aggregate,
sourceFieldName,
targetObjectNameSingular,
});
this.assignRelationResults({
@@ -306,6 +309,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
aggregate,
sourceFieldName,
targetObjectNameSingular,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
referenceQueryBuilder: WorkspaceSelectQueryBuilder<any>;
@@ -316,7 +320,7 @@ export class ProcessNestedRelationsV2Helper {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aggregate: Record<string, any>;
sourceFieldName: string;
targetObjectNameSingular: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}): Promise<{ relationResults: any[]; relationAggregatedFieldsResult: any }> {
if (ids.length === 0) {
@@ -333,6 +337,7 @@ export class ProcessNestedRelationsV2Helper {
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: aggregateForRelation,
queryBuilder: aggregateQueryBuilder,
objectMetadataNameSingular: targetObjectNameSingular,
});
const aggregatedFieldsValues = await aggregateQueryBuilder
@@ -45,10 +45,13 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const objectMetadataNameSingular =
objectMetadataItemWithFieldMaps.nameSingular;
const { roleId } = executionArgs;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
objectMetadataNameSingular,
);
const aggregateQueryBuilder = queryBuilder.clone();
@@ -58,7 +61,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
executionArgs.graphqlQueryParser.applyFilterToBuilder(
aggregateQueryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
objectMetadataNameSingular,
appliedFilters,
);
@@ -93,14 +96,14 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
objectMetadataNameSingular,
appliedFilters,
);
executionArgs.graphqlQueryParser.applyOrderToBuilder(
queryBuilder,
orderByWithIdCondition,
objectMetadataItemWithFieldMaps.nameSingular,
objectMetadataNameSingular,
isForwardPagination,
);
@@ -113,6 +116,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
queryBuilder: aggregateQueryBuilder,
objectMetadataNameSingular,
});
const limit =
@@ -171,7 +175,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
objectRecordsAggregatedValues: parentObjectRecordsAggregatedValues,
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
objectName: objectMetadataNameSingular,
take: limit,
totalCount: parentObjectRecordsAggregatedValues?.totalCount,
order: orderByWithIdCondition,
@@ -1,8 +1,5 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
GraphqlQueryBaseResolverService,
GraphqlQueryResolverExecutionArgs,
@@ -20,9 +17,11 @@ import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@Injectable()
export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolverService<
@@ -35,33 +34,102 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
): Promise<IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>[]> {
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
const objectMetadataNameSingular =
objectMetadataItemWithFieldMaps.nameSingular;
let queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataNameSingular,
);
let appliedFilters =
executionArgs.args.filter ?? ({} as ObjectRecordFilter);
executionArgs.graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataNameSingular,
appliedFilters,
);
executionArgs.graphqlQueryParser.applyDeletedAtToBuilder(
queryBuilder,
appliedFilters,
);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
queryBuilder,
objectMetadataNameSingular,
});
const groupByFields = this.parseGroupByArgs(
executionArgs.args,
objectMetadataItemWithFieldMaps,
);
const aggregateMetrics = Object.keys(
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
const groupByColumnsWithQuotes = groupByFields.map((groupByField) => {
return `"${
formatColumnNamesFromCompositeFieldAndSubfields(
groupByField.fieldMetadata.name,
groupByField.subFieldName ? [groupByField.subFieldName] : undefined,
)[0]
}"`;
});
groupByColumnsWithQuotes.forEach((groupByColumn, index) => {
queryBuilder.addSelect(groupByColumn);
if (index === 0) {
queryBuilder.groupBy(groupByColumn);
} else {
queryBuilder.addGroupBy(groupByColumn);
}
});
let forwardPagination; // TODO
const isGroupBy = true;
executionArgs.graphqlQueryParser.applyOrderToBuilder(
queryBuilder,
executionArgs.args.orderBy ?? [],
objectMetadataNameSingular,
forwardPagination,
isGroupBy,
);
const mockedGroupByValues = Array.from({ length: 3 }, (_, index) => ({
groupByDimensionValues: groupByFields.map((groupByField) =>
this.getFakeDimensionValues(groupByField, index),
),
...aggregateMetrics.reduce(
(acc, aggregateMetric, subIndex) => {
acc[aggregateMetric] = this.getFakeAggregateValues(subIndex + index);
const result = await queryBuilder.getRawMany();
return acc;
},
{} as Record<string, number>,
),
}));
return this.formatResultWithGroupByDimensionValues(
result,
groupByColumnsWithQuotes,
);
}
return mockedGroupByValues as unknown as IGroupByConnection<
private formatResultWithGroupByDimensionValues(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result: any[],
groupByColumnsWithQuotes: string[],
): IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>[] {
let formattedResult: IGroupByConnection<
ObjectRecord,
IEdge<ObjectRecord>
>[];
>[] = [];
result.forEach((group) => {
let dimensionValues = [];
for (const groupByColumn of groupByColumnsWithQuotes) {
const groupByColumnWithoutQuotes = groupByColumn.replace(/["']/g, '');
dimensionValues.push(group[groupByColumnWithoutQuotes]);
}
formattedResult.push({
groupByDimensionValues: dimensionValues,
...group,
});
});
return formattedResult;
}
private parseGroupByArgs(
@@ -118,215 +186,4 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
_args: GroupByResolverArgs<ObjectRecordFilter>,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {}
//todo : remove these methods after the groupBy logic implementation
private getFakeDimensionValues(
groupByField: {
fieldMetadata: FieldMetadataEntity;
subFieldName?: string;
},
rank: number,
): string {
const fakeDimensionValuesByField = {
[FieldMetadataType.TEXT]: [
'Text 1',
'Long long long long and long text 2',
'Text 3',
],
[FieldMetadataType.NUMERIC]: [2000, 3000, 4000],
[FieldMetadataType.NUMBER]: [1, 2, 3],
[FieldMetadataType.BOOLEAN]: [true, false, true],
[FieldMetadataType.DATE_TIME]: [
new Date(2025, 5, 10, 12, 1, 0),
new Date(2025, 5, 10, 12, 1, 0),
new Date(2018, 6, 14, 12, 2, 0),
],
[FieldMetadataType.DATE]: [
new Date(2025, 1, 1),
new Date(2025, 1, 2),
new Date(2025, 1, 3),
],
[FieldMetadataType.UUID]: [
'00000000-0000-0000-0000-000000000000',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000002',
],
[FieldMetadataType.SELECT]: ['Option 1', 'Option 2', 'Option 3'],
[FieldMetadataType.MULTI_SELECT]: [
['Tag 1', 'Tag 2'],
['Tag 3'],
['Tag 4', 'Tag 5', 'Tag 6'],
],
[FieldMetadataType.RELATION]: [
{ id: 'relation-1', name: 'Relation 1' },
{ id: 'relation-2', name: 'Relation 2' },
{ id: 'relation-3', name: 'Relation 3' },
],
[FieldMetadataType.MORPH_RELATION]: [
{ id: 'morph-1', name: 'Morph Relation 1' },
{ id: 'morph-2', name: 'Morph Relation 2' },
{ id: 'morph-3', name: 'Morph Relation 3' },
],
[FieldMetadataType.RATING]: ['RATING_3', 'RATING_4', 'RATING_5'],
[FieldMetadataType.RAW_JSON]: [
{ key: 'value1' },
{ key: 'value2', key2: 'value2' },
{},
],
[FieldMetadataType.ARRAY]: [['value1', 'value2'], ['value3'], []],
[FieldMetadataType.POSITION]: [1, 2, 3],
[FieldMetadataType.TS_VECTOR]: ['vector1', 'vector2', 'vector3'],
[FieldMetadataType.EMAILS]: [
{
primaryEmail: 'tim@twenty.com',
additionalEmails: [
'tim@twenty.com',
'timapple@twenty.com',
'johnappletim@twenty.com',
],
},
{
primaryEmail: 'jane@twenty.com',
additionalEmails: ['jane@twenty.com', 'jane.doe@twenty.com'],
},
{
primaryEmail: 'john@twenty.com',
additionalEmails: ['john.doe@twenty.com'],
},
],
[FieldMetadataType.PHONES]: [
{
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '789012345',
additionalPhones: [
{ number: '617272323', callingCode: '+33', countryCode: 'FR' },
],
},
{
primaryPhoneCallingCode: '+1',
primaryPhoneCountryCode: 'US',
primaryPhoneNumber: '612345789',
additionalPhones: [
{ number: '123456789', callingCode: '+1', countryCode: 'US' },
{ number: '617272323', callingCode: '+1', countryCode: 'US' },
],
},
{
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '123456789',
additionalPhones: [],
},
],
[FieldMetadataType.CURRENCY]: [
{ amountMicros: 1000000, currencyCode: 'USD' },
{ amountMicros: 2000000, currencyCode: 'EUR' },
{ amountMicros: 3000000, currencyCode: 'GBP' },
],
[FieldMetadataType.LINKS]: [
{
primaryLinkUrl: 'twenty.com',
primaryLinkLabel: '',
secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }],
},
{
primaryLinkUrl: 'github.com/twentyhq/twenty',
primaryLinkLabel: 'Twenty Repo',
secondaryLinks: [{ url: 'twenty.com', label: '' }],
},
{
primaryLinkUrl: 'react.dev',
primaryLinkLabel: '',
secondaryLinks: [],
},
],
[FieldMetadataType.FULL_NAME]: [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
{ firstName: 'John', lastName: 'Smith' },
],
[FieldMetadataType.ADDRESS]: [
{
addressStreet1: '456 Oak Street',
addressStreet2: '',
addressCity: 'Springfield',
addressState: 'California',
addressCountry: 'United States',
addressPostcode: '90210',
addressLat: 34.0522,
addressLng: -118.2437,
},
{
addressStreet1: '123 Main Street',
addressStreet2: '',
addressCity: 'New York',
addressState: 'New York',
addressCountry: 'United States',
addressPostcode: '10001',
addressLat: 40.7128,
addressLng: -74.006,
},
{
addressStreet1: '8 rue Saint-Anne',
addressStreet2: '',
addressCity: 'Paris',
addressState: 'Ile-de-France',
addressCountry: 'France',
addressPostcode: '75001',
addressLat: 40.7128,
addressLng: -74.006,
},
],
[FieldMetadataType.ACTOR]: [
{
source: 'IMPORT',
name: 'name',
workspaceMemberId: 'id',
context: { provider: 'GOOGLE' },
},
{
source: 'MANUAL',
name: 'name',
workspaceMemberId: 'id',
context: { provider: 'MICROSOFT' },
},
{
source: 'WEBHOOK',
name: 'name',
workspaceMemberId: 'id',
context: {},
},
],
[FieldMetadataType.RICH_TEXT_V2]: [
{
blocknote: '[{"type":"heading","content":"Hello"}]',
markdown: '# Hello',
},
{
blocknote: '[{"type":"heading","content":"Hello World"}]',
markdown: '# Hello World',
},
{
blocknote: '[{"type":"heading","content":"Hello Again"}]',
markdown: '# Hello Again',
},
],
[FieldMetadataType.RICH_TEXT]: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as Record<FieldMetadataType, any>;
const fakeDimensionValues =
fakeDimensionValuesByField[groupByField.fieldMetadata.type][rank % 3];
return isDefined(groupByField.subFieldName)
? fakeDimensionValues[groupByField.subFieldName].toString()
: fakeDimensionValues.toString();
}
private getFakeAggregateValues(rank: number) {
const fakeAggregateValues = [39, 20, 56, 88, 2];
return fakeAggregateValues[rank % fakeAggregateValues.length];
}
}
@@ -4,4 +4,5 @@ export enum GqlInputTypeDefinitionKind {
Filter = 'Filter',
OrderBy = 'OrderBy',
GroupBy = 'GroupBy',
OrderByWithGroupBy = 'OrderByWithGroupBy',
}
@@ -4,6 +4,7 @@ import { ObjectMetadataCreateGqlInputTypeGenerator } from 'src/engine/api/graphq
import { ObjectMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator';
import { ObjectMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator';
import { ObjectMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator';
import { ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator } from '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';
import { ObjectMetadataUpdateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -14,6 +15,7 @@ export class ObjectMetadataGqlInputTypeGenerator {
private readonly objectMetadataUpdateGqlInputTypeGenerator: ObjectMetadataUpdateGqlInputTypeGenerator,
private readonly objectMetadataFilterGqlInputTypeGenerator: ObjectMetadataFilterGqlInputTypeGenerator,
private readonly objectMetadataOrderByGqlInputTypeGenerator: ObjectMetadataOrderByGqlInputTypeGenerator,
private readonly objectMetadataOrderByWithGroupByGqlInputTypeGenerator: ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator,
private readonly objectMetadataGroupByGqlInputTypeGenerator: ObjectMetadataGroupByGqlInputTypeGenerator,
) {}
@@ -27,9 +29,12 @@ export class ObjectMetadataGqlInputTypeGenerator {
this.objectMetadataFilterGqlInputTypeGenerator.buildAndStore(
objectMetadata,
);
this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore(
this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({
objectMetadata,
);
});
this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({
objectMetadata,
});
this.objectMetadataGroupByGqlInputTypeGenerator.buildAndStore(
objectMetadata,
);
@@ -34,7 +34,11 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator {
private readonly typeMapperService: TypeMapperService,
) {}
public buildAndStore(objectMetadata: ObjectMetadataEntity) {
public buildAndStore({
objectMetadata,
}: {
objectMetadata: ObjectMetadataEntity;
}) {
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderBy.toString()}Input`,
description: objectMetadata.description,
@@ -0,0 +1,183 @@
import { Injectable, Logger } from '@nestjs/common';
import {
GraphQLInputFieldConfigMap,
GraphQLInputObjectType,
isInputObjectType,
} from 'graphql';
import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator';
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 { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util';
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
import { getAvailableAggregationsFromObjectFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
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 { pascalCase } from 'src/utils/pascal-case';
@Injectable()
export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
private readonly logger = new Logger(
ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator.name,
);
constructor(
private readonly gqlTypesStorage: GqlTypesStorage,
private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator,
private readonly typeMapperService: TypeMapperService,
) {}
public buildAndStore({
objectMetadata,
}: {
objectMetadata: ObjectMetadataEntity;
}) {
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}Input`,
description: objectMetadata.description,
fields: () => this.generateFields(objectMetadata),
}) as GraphQLInputObjectType;
const key = computeObjectMetadataInputTypeKey(
objectMetadata.nameSingular,
GqlInputTypeDefinitionKind.OrderByWithGroupBy,
);
this.gqlTypesStorage.addGqlType(key, inputType);
}
private generateFields(
objectMetadata: ObjectMetadataEntity,
): GraphQLInputFieldConfigMap {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of objectMetadata.fields) {
fieldMetadata.isNullable = true;
const typeOptions = computeFieldInputTypeOptions(
fieldMetadata,
GqlInputTypeDefinitionKind.OrderBy,
);
let generatedFields;
if (isFieldMetadataRelationOrMorphRelation(fieldMetadata)) {
generatedFields =
this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldOrderByInputType(
{
fieldMetadata,
typeOptions,
},
);
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
generatedFields = this.generateCompositeFieldOrderByInputType(
fieldMetadata,
typeOptions,
);
} else {
generatedFields = this.generateAtomicFieldOrderByInputType(
fieldMetadata,
typeOptions,
);
}
Object.assign(allGeneratedFields, generatedFields);
}
return allGeneratedFields;
}
private generateCompositeFieldOrderByInputType(
fieldMetadata: FieldMetadataEntity,
typeOptions: TypeOptions,
) {
const key = computeCompositeFieldInputTypeKey(
fieldMetadata.type,
GqlInputTypeDefinitionKind.OrderBy,
);
const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key);
if (!isDefined(compositeType) || !isInputObjectType(compositeType)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
this.logger.error(message, {
fieldMetadata,
typeOptions,
});
throw new Error(message);
}
const aggregations =
this.generateAggregateFieldOrderByInputType(fieldMetadata);
return aggregations;
}
private generateAtomicFieldOrderByInputType(
fieldMetadata: FieldMetadataEntity,
typeOptions: TypeOptions,
) {
const orderByType = this.typeMapperService.mapToOrderByType(
fieldMetadata.type,
);
if (!isDefined(orderByType)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
this.logger.error(message, {
fieldMetadata,
typeOptions,
});
throw new Error(message);
}
const aggregations =
this.generateAggregateFieldOrderByInputType(fieldMetadata);
return aggregations;
}
private generateAggregateFieldOrderByInputType(
fieldMetadata: FieldMetadataEntity,
) {
const aggregations = getAvailableAggregationsFromObjectFields([
fieldMetadata,
]);
let result: GraphQLInputFieldConfigMap = {};
for (const [aggregationKey, aggregationDetails] of Object.entries(
aggregations,
)) {
const orderByWithGroupByType =
this.typeMapperService.mapToOrderByWithGroupByType(
aggregations[aggregationKey].aggregateOperation,
);
if (!isDefined(orderByWithGroupByType)) {
const message = `Could not find a GraphQL input type for ${aggregations.type} aggregation`;
this.logger.error(message, {
aggregations,
});
throw new Error(message);
}
result[aggregationKey] = {
type: orderByWithGroupByType,
description: aggregationDetails.description,
};
}
return result;
}
}
@@ -11,6 +11,7 @@ import { ObjectMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graph
import { ObjectMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator';
import { CompositeFieldMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator';
import { ObjectMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator';
import { ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator } from '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';
import { RelationConnectGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-connect-gql-input-type.generator';
import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator';
import { CompositeFieldMetadataUpdateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator';
@@ -45,6 +46,7 @@ export const workspaceSchemaBuilderTypeGenerators = [
ObjectMetadataFilterGqlInputTypeGenerator,
ObjectMetadataOrderByGqlInputTypeGenerator,
ObjectMetadataGroupByGqlInputTypeGenerator,
ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator,
CompositeFieldMetadataGqlObjectTypeGenerator,
ObjectMetadataGqlObjectTypeGenerator,
RelationConnectGqlInputTypeGenerator,
@@ -22,6 +22,7 @@ import {
NumberDataType,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum';
import {
ArrayFilterType,
@@ -168,6 +169,27 @@ export class TypeMapperService {
return typeOrderByMapping.get(fieldMetadataType);
}
mapToOrderByWithGroupByType(
aggregationType: AggregateOperations,
): GraphQLInputType | undefined {
const typeOrderByMapping = new Map<AggregateOperations, GraphQLInputType>([
[AggregateOperations.SUM, OrderByDirectionType],
[AggregateOperations.COUNT, OrderByDirectionType],
[AggregateOperations.COUNT_UNIQUE_VALUES, OrderByDirectionType],
[AggregateOperations.COUNT_EMPTY, OrderByDirectionType],
[AggregateOperations.COUNT_NOT_EMPTY, OrderByDirectionType],
[AggregateOperations.COUNT_TRUE, OrderByDirectionType],
[AggregateOperations.COUNT_FALSE, OrderByDirectionType],
[AggregateOperations.PERCENTAGE_EMPTY, OrderByDirectionType],
[AggregateOperations.PERCENTAGE_NOT_EMPTY, OrderByDirectionType],
[AggregateOperations.MIN, OrderByDirectionType],
[AggregateOperations.MAX, OrderByDirectionType],
[AggregateOperations.AVG, OrderByDirectionType],
]);
return typeOrderByMapping.get(aggregationType);
}
applyTypeOptions<T extends GraphQLType = GraphQLType>(
typeRef: T,
options: TypeOptions,
@@ -169,7 +169,7 @@ export const getResolverArgs = (
isNullable: true,
},
orderBy: {
kind: GqlInputTypeDefinitionKind.OrderBy,
kind: GqlInputTypeDefinitionKind.OrderByWithGroupBy,
isNullable: true,
isArray: true,
},