Inter-groups orderBy on groupByDimension values (#15024)

Closes https://github.com/twentyhq/core-team-issues/issues/1563.

We now have two inter-group orderBy criteria: one on the aggregate
values, the other on the dimension values.
Ex: I am grouping companies by city and querying the average number of
employees for each group. I can either order the groups by the city
name, or by the average number of employees, or by one then the other.
I could actually also order groups by an aggregated value that I did not
ask for (ex: average ARR), but I cannot order groups by a field value I
did not group records by (ex: country).
[See discord
discussion](https://discord.com/channels/1130383047699738754/1425438213555753050/1425438223097921659)

An example of query variables: 
```
{
  "groupBy": [
    {
      "createdAt": {
        "granularity": "QUARTER_OF_THE_YEAR"
      }
    },
    {"city": true}
  ],
  "orderBy": [
    { 
      "aggregate": {
        "avgEmployees": "DescNullsLast"
      }
    },
    { 
      "aggregate": {
        "percentyEmptyEmployees": "DescNullsLast"
      }
    },
    {
      "city": "AscNullsLast"
    },
    {
      "createdAt": {
        "orderBy": "AscNullsLast",
        "granularity": "QUARTER_OF_THE_YEAR",
      }
    }
  ]
}
```

The aggregate orderBy criteria had already been implemented, but I
updated the implementation to add an "aggregate" key to prefix them, in
order to avoid confusion between the two + for the schema generation not
to break (otherwise we would have an issue when generating
OrderByWithGroupByInput if a user has created a field that has the same
name as an aggregate field, such as avgEmployees).
This commit is contained in:
Marie
2025-10-13 11:51:42 +02:00
committed by GitHub
parent 168e7b16ec
commit 4c7e8444cf
15 changed files with 1386 additions and 292 deletions
@@ -1,29 +1,43 @@
import { InternalServerErrorException } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { isObject } from 'class-validator';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type ObjectRecordOrderBy,
OrderByDirection,
type AggregateOrderByWithGroupByField,
type ObjectRecordOrderBy,
type ObjectRecordOrderByForCompositeField,
type ObjectRecordOrderByForScalarField,
type ObjectRecordOrderByWithGroupByDateField,
type OrderByWithGroupBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
import {
type GroupByDateField,
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 { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import {
type AggregationField,
getAvailableAggregationsFromObjectFields,
type AggregationField,
} 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 { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
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';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
type OrderByCondition = {
export type OrderByCondition = {
order: 'ASC' | 'DESC';
nulls?: 'NULLS FIRST' | 'NULLS LAST';
};
@@ -39,16 +53,7 @@ export class GraphqlQueryOrderFieldParser {
orderBy: ObjectRecordOrderBy,
objectNameSingular: string,
isForwardPagination = true,
isGroupBy = false,
): Record<string, OrderByCondition> {
if (isGroupBy) {
return this.parseForGroupBy(
orderBy,
objectNameSingular,
isForwardPagination,
);
}
return orderBy.reduce(
(acc, item) => {
Object.entries(item).forEach(([key, value]) => {
@@ -64,7 +69,7 @@ export class GraphqlQueryOrderFieldParser {
}
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeOrder = this.parseCompositeFieldForOrder(
const compositeOrder = parseCompositeFieldForOrder(
fieldMetadata,
value,
objectNameSingular,
@@ -77,7 +82,7 @@ export class GraphqlQueryOrderFieldParser {
this.getOptionalOrderByCasting(fieldMetadata);
acc[`"${objectNameSingular}"."${key}"${orderByCasting}`] =
this.convertOrderByToFindOptionsOrder(
convertOrderByToFindOptionsOrder(
value as OrderByDirection,
isForwardPagination,
);
@@ -90,51 +95,104 @@ export class GraphqlQueryOrderFieldParser {
);
}
private parseForGroupBy(
orderBy: ObjectRecordOrderBy,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, OrderByCondition> {
parseForGroupBy({
orderBy,
groupByFields,
}: {
orderBy: OrderByWithGroupBy;
groupByFields: GroupByField[];
}): Record<string, OrderByCondition>[] {
let parsedOrderBy: 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,
for (const orderByArg of orderBy) {
if (this.isAggregateOrderByArg(orderByArg)) {
const parsedAggregateOrderBy = this.parseAggregateOrderByArg(
availableAggregations,
orderByArg,
this.objectMetadataMapItem,
);
if (!expression) {
throw new InternalServerErrorException(
`Aggregate expression not found for ${aggregatedOrderByCondition}`,
);
parsedOrderBy.push(parsedAggregateOrderBy);
continue;
}
const fieldName = Object.keys(orderByArg)[0];
const fieldMetadataId =
this.objectMetadataMapItem.fieldIdByName[fieldName];
const fieldMetadata =
this.objectMetadataMapItem.fieldsById[fieldMetadataId];
if (!isDefined(fieldMetadata)) {
throw new UserInputError(`Cannot orderBy unknown field: ${fieldName}.`);
}
if (this.isObjectRecordOrderByForScalarField(orderByArg)) {
const parsedOrderByForScalarField =
this.parseObjectRecordOrderByForScalarField({
groupByFields,
orderByArg,
objectMetadataItemWithFieldMaps: this.objectMetadataMapItem,
fieldMetadata,
});
if (!isDefined(parsedOrderByForScalarField)) {
continue;
}
orderByExpressionsAndConditions[expression] =
this.convertOrderByToFindOptionsOrder(direction, isForwardPagination);
parsedOrderBy.push(parsedOrderByForScalarField);
continue;
}
if (
this.isObjectRecordOrderByWithGroupByDateField(
orderByArg,
fieldMetadata.type,
)
) {
const parsedOrderByForGroupByDateField =
this.parseObjectRecordOrderByWithGroupByDateField({
groupByFields,
orderByArg,
fieldMetadataId,
});
if (!isDefined(parsedOrderByForGroupByDateField)) {
continue;
}
parsedOrderBy.push(parsedOrderByForGroupByDateField);
continue;
}
if (this.isObjectRecordOrderByForCompositeField(orderByArg)) {
const parsedOrderByForCompositeField =
this.parseObjectRecordOrderByForCompositeField({
groupByFields,
orderByArg,
objectMetadataItemWithFieldMaps: this.objectMetadataMapItem,
fieldMetadata,
});
if (!isDefined(parsedOrderByForCompositeField)) {
continue;
}
parsedOrderBy.push(parsedOrderByForCompositeField);
continue;
}
throw new UserInputError(`Unknown orderBy value: ${orderByArg}`);
}
return orderByExpressionsAndConditions;
return parsedOrderBy;
}
private getOptionalOrderByCasting(
getOptionalOrderByCasting(
fieldMetadata: Pick<FieldMetadataEntity, 'type'>,
): string {
if (
@@ -147,87 +205,299 @@ export class GraphqlQueryOrderFieldParser {
return '';
}
private parseCompositeFieldForOrder(
fieldMetadata: FieldMetadataEntity,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, OrderByCondition> {
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type as CompositeFieldMetadataType,
);
isAggregateOrderByArg = (
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByWithGroupByDateField,
): orderByArg is AggregateOrderByWithGroupByField => {
return isDefined(orderByArg.aggregate);
};
if (!compositeType) {
throw new Error(
`Composite type definition not found for type: ${fieldMetadata.type}`,
isObjectRecordOrderByForScalarField = (
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField,
): orderByArg is ObjectRecordOrderByForScalarField => {
if (Object.keys(orderByArg).length > 1) {
throw new UserInputError(
'Please provide orderBy field criteria one by one in orderBy array',
);
}
return Object.entries(value).reduce(
(acc, [subFieldKey, subFieldValue]) => {
const subFieldMetadata = compositeType.properties.find(
(property) => property.name === subFieldKey,
);
const scalarFieldOrCompositeFieldOrderByValue =
Object.values(orderByArg)[0];
if (!subFieldMetadata) {
throw new Error(
`Sub field metadata not found for composite type: ${fieldMetadata.type}`,
);
}
const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`;
if (!this.isOrderByDirection(subFieldValue)) {
throw new Error(
`Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`,
);
}
acc[fullFieldName] = this.convertOrderByToFindOptionsOrder(
subFieldValue,
isForwardPagination,
);
return acc;
},
{} as Record<string, OrderByCondition>,
);
}
private convertOrderByToFindOptionsOrder(
direction: OrderByDirection,
isForwardPagination = true,
): OrderByCondition {
switch (direction) {
case OrderByDirection.AscNullsFirst:
return {
order: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'NULLS FIRST',
};
case OrderByDirection.AscNullsLast:
return {
order: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'NULLS LAST',
};
case OrderByDirection.DescNullsFirst:
return {
order: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'NULLS FIRST',
};
case OrderByDirection.DescNullsLast:
return {
order: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'NULLS LAST',
};
default:
throw new GraphqlQueryRunnerException(
`Invalid direction: ${direction}`,
GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION,
);
if (
Object.values(OrderByDirection).includes(
scalarFieldOrCompositeFieldOrderByValue,
)
) {
return true;
}
}
private isOrderByDirection(value: unknown): value is OrderByDirection {
return Object.values(OrderByDirection).includes(value as OrderByDirection);
}
return false;
};
isObjectRecordOrderByForCompositeField = (
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField,
): orderByArg is ObjectRecordOrderByForCompositeField => {
const compositeFieldOrderByValue = Object.values(orderByArg)[0];
if (!isObject(compositeFieldOrderByValue)) {
throw new UserInputError(
`Unknown orderBy value: ${compositeFieldOrderByValue}`,
);
}
if (Object.values(compositeFieldOrderByValue).length > 1) {
throw new UserInputError(
'Please provide orderBy field criteria one by one in orderBy array',
);
}
const compositeFieldOrderByDirection = Object.values(
compositeFieldOrderByValue,
)[0];
if (
Object.values(OrderByDirection).includes(
compositeFieldOrderByDirection as OrderByDirection,
)
) {
return true;
}
return false;
};
isObjectRecordOrderByWithGroupByDateField = (
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByWithGroupByDateField,
fieldMetadataType: FieldMetadataType,
): orderByArg is ObjectRecordOrderByWithGroupByDateField => {
if (
fieldMetadataType !== FieldMetadataType.DATE &&
fieldMetadataType !== FieldMetadataType.DATE_TIME
) {
return false;
}
if (Object.keys(orderByArg).length > 1) {
throw new UserInputError(
'Please provide orderBy field criteria one by one in orderBy array',
);
}
const dateFieldOrderByValue = Object.values(orderByArg)[0];
if (!isDefined(dateFieldOrderByValue)) {
return false;
}
if (!isDefined(dateFieldOrderByValue.orderBy)) {
return false;
}
if (
!Object.values(OrderByDirection).includes(dateFieldOrderByValue.orderBy)
) {
return false;
}
if (!isDefined(dateFieldOrderByValue.granularity)) {
return false;
}
if (
!Object.values(ObjectRecordGroupByDateGranularity).includes(
dateFieldOrderByValue.granularity,
)
) {
return false;
}
return true;
};
parseAggregateOrderByArg = (
availableAggregations: Record<string, AggregationField>,
orderByArg: AggregateOrderByWithGroupByField,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): Record<string, OrderByCondition> => {
const aggregate = orderByArg.aggregate;
if (Object.keys(aggregate).length > 1) {
throw new UserInputError(
'Please provide aggregate criteria one by one in orderBy array',
);
}
const aggregateField = availableAggregations[Object.keys(aggregate)[0]];
if (!aggregateField) {
throw new UserInputError(
`Unknown aggregate field: ${Object.keys(aggregate)[0]}`,
);
}
const aggregateExpression = ProcessAggregateHelper.getAggregateExpression(
aggregateField,
objectMetadataItemWithFieldMaps.nameSingular,
);
if (!isDefined(aggregateExpression)) {
throw new UserInputError(
`Cannot find expression for aggregate field: ${Object.keys(aggregate)[0]}`,
);
}
const orderByDirection = Object.values(aggregate)[0];
const convertedDirection =
convertOrderByToFindOptionsOrder(orderByDirection);
return {
[aggregateExpression]: convertedDirection,
};
};
parseObjectRecordOrderByForScalarField = ({
groupByFields,
orderByArg,
objectMetadataItemWithFieldMaps,
fieldMetadata,
}: {
groupByFields: GroupByField[];
orderByArg: ObjectRecordOrderByForScalarField;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
fieldMetadata: FieldMetadataEntity;
}): Record<string, OrderByCondition> | null => {
const fieldIsInGroupBy = groupByFields.some(
(groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id,
);
if (!fieldIsInGroupBy) {
throw new UserInputError(
`Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`,
);
}
const orderByCasting = this.getOptionalOrderByCasting(fieldMetadata);
const orderByDirection = Object.values(orderByArg)[0];
if (!isDefined(orderByDirection)) {
return null;
}
return {
[`"${objectMetadataItemWithFieldMaps.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]:
convertOrderByToFindOptionsOrder(orderByDirection),
};
};
parseObjectRecordOrderByForCompositeField = ({
groupByFields,
orderByArg,
objectMetadataItemWithFieldMaps,
fieldMetadata,
}: {
groupByFields: GroupByField[];
orderByArg: ObjectRecordOrderByForCompositeField;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
fieldMetadata: FieldMetadataEntity;
}): Record<string, OrderByCondition> | null => {
const fieldName = Object.keys(orderByArg)[0];
const orderBySubField = orderByArg[fieldName];
if (!isDefined(orderBySubField)) {
return null;
}
if (Object.keys(orderBySubField).length > 1) {
throw new UserInputError(
`Subfields must be provided one by one in orderBy array.`,
);
}
const subFieldName = Object.keys(orderBySubField)[0];
if (
!groupByFields.some(
(groupByField) =>
groupByField.fieldMetadata.id === fieldMetadata.id &&
groupByField.subFieldName === subFieldName,
)
) {
throw new UserInputError(
`Cannot order by a field that is not in groupBy or that is not an aggregate field: ${subFieldName}`,
);
}
return parseCompositeFieldForOrder(
fieldMetadata,
orderBySubField,
objectMetadataItemWithFieldMaps.nameSingular,
);
};
parseObjectRecordOrderByWithGroupByDateField = ({
groupByFields,
orderByArg,
fieldMetadataId,
}: {
groupByFields: GroupByField[];
orderByArg: ObjectRecordOrderByWithGroupByDateField;
fieldMetadataId: string;
}): Record<string, OrderByCondition> | null => {
const orderByDirection = Object.values(orderByArg)[0]?.orderBy;
if (!isDefined(orderByDirection)) {
return null;
}
const granularity = Object.values(orderByArg)[0]?.granularity;
if (!isDefined(granularity)) {
throw new UserInputError(
`Missing date granularity for field ${Object.keys(orderByArg)[0]}`,
);
}
const associatedGroupByField = groupByFields.find(
(groupByField) =>
groupByField.fieldMetadata.id === fieldMetadataId &&
(groupByField as GroupByDateField).dateGranularity === granularity,
);
if (!isDefined(associatedGroupByField)) {
throw new UserInputError(
`Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
);
}
const columnNameWithQuotes = `"${
formatColumnNamesFromCompositeFieldAndSubfields(
associatedGroupByField.fieldMetadata.name,
associatedGroupByField.subFieldName
? [associatedGroupByField.subFieldName]
: undefined,
)[0]
}"`;
const expression = getGroupByExpression({
groupByField: associatedGroupByField,
columnNameWithQuotes,
});
return {
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
};
};
}
@@ -0,0 +1,40 @@
import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type OrderByCondition } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
export const convertOrderByToFindOptionsOrder = (
direction: OrderByDirection,
isForwardPagination = true,
): OrderByCondition => {
switch (direction) {
case OrderByDirection.AscNullsFirst:
return {
order: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'NULLS FIRST',
};
case OrderByDirection.AscNullsLast:
return {
order: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'NULLS LAST',
};
case OrderByDirection.DescNullsFirst:
return {
order: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'NULLS FIRST',
};
case OrderByDirection.DescNullsLast:
return {
order: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'NULLS LAST',
};
default:
throw new GraphqlQueryRunnerException(
`Invalid direction: ${direction}`,
GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION,
);
}
};
@@ -0,0 +1,60 @@
import { capitalize } from 'twenty-shared/utils';
import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type OrderByCondition } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
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 { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
export const parseCompositeFieldForOrder = (
fieldMetadata: FieldMetadataEntity,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, OrderByCondition> => {
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type as CompositeFieldMetadataType,
);
if (!compositeType) {
throw new Error(
`Composite type definition not found for type: ${fieldMetadata.type}`,
);
}
return Object.entries(value).reduce(
(acc, [subFieldKey, subFieldValue]) => {
const subFieldMetadata = compositeType.properties.find(
(property) => property.name === subFieldKey,
);
if (!subFieldMetadata) {
throw new Error(
`Sub field metadata not found for composite type: ${fieldMetadata.type}`,
);
}
const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`;
if (!isOrderByDirection(subFieldValue)) {
throw new Error(
`Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`,
);
}
acc[fullFieldName] = convertOrderByToFindOptionsOrder(
subFieldValue,
isForwardPagination,
);
return acc;
},
{} as Record<string, OrderByCondition>,
);
};
const isOrderByDirection = (value: unknown): value is OrderByDirection => {
return Object.values(OrderByDirection).includes(value as OrderByDirection);
};
@@ -3,6 +3,7 @@ import { type FindOptionsWhere, type ObjectLiteral } from 'typeorm';
import {
type ObjectRecordFilter,
type ObjectRecordOrderBy,
type OrderByWithGroupBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
@@ -15,6 +16,7 @@ import {
GraphqlQuerySelectedFieldsParser,
type GraphqlQuerySelectedFieldsResult,
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
@@ -97,40 +99,45 @@ export class GraphqlQueryParser {
public applyOrderToBuilder(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryBuilder: WorkspaceSelectQueryBuilder<any>,
orderBy: ObjectRecordOrderBy,
orderBy: ObjectRecordOrderBy | OrderByWithGroupBy,
objectNameSingular: string,
isForwardPagination = true,
isGroupBy = false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
const parsedOrderBys = this.orderFieldParser.parse(
orderBy,
orderBy as ObjectRecordOrderBy,
objectNameSingular,
isForwardPagination,
isGroupBy,
);
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 applyGroupByOrderToBuilder(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryBuilder: WorkspaceSelectQueryBuilder<any>,
orderBy: ObjectRecordOrderBy | OrderByWithGroupBy,
groupByFields: GroupByField[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
const parsedOrderBys = this.orderFieldParser.parseForGroupBy({
orderBy,
groupByFields,
});
parsedOrderBys.forEach((orderByField, index) => {
Object.entries(orderByField).forEach(([expression, direction]) => {
if (index === 0) {
queryBuilder.orderBy(expression, direction.order, direction.nulls);
} else {
queryBuilder.addOrderBy(expression, direction.order, direction.nulls);
}
});
});
return queryBuilder;
}
public parseSelectedFields(
parentObjectMetadata: ObjectMetadataItemWithFieldMaps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -137,15 +137,10 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
}
});
let forwardPagination; // TODO
const isGroupBy = true;
executionArgs.graphqlQueryParser.applyOrderToBuilder(
executionArgs.graphqlQueryParser.applyGroupByOrderToBuilder(
queryBuilder,
executionArgs.args.orderBy ?? [],
objectMetadataNameSingular,
forwardPagination,
isGroupBy,
groupByFields,
);
const result = await queryBuilder.getRawMany();
@@ -46,6 +46,26 @@ export type ObjectRecordOrderBy = Array<
ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField
>;
export type AggregateOrderByWithGroupByField = {
aggregate: {
[x: string]: OrderByDirection;
};
};
export type ObjectRecordOrderByWithGroupByDateField = {
[Property in keyof ObjectRecord]?: {
orderBy: OrderByDirection;
granularity: ObjectRecordGroupByDateGranularity;
};
};
export type OrderByWithGroupBy = Array<
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField
| AggregateOrderByWithGroupByField
>;
export type ObjectRecordOrderByForScalarField = {
[Property in keyof ObjectRecord]?: OrderByDirection;
};
@@ -5,6 +5,7 @@ import {
type ObjectRecordFilter,
type ObjectRecordGroupBy,
type ObjectRecordOrderBy,
type OrderByWithGroupBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
@@ -70,7 +71,7 @@ export interface GroupByResolverArgs<Filter = ObjectRecordFilter> {
filter?: Filter;
groupBy: ObjectRecordGroupBy;
viewId?: string;
orderBy?: ObjectRecordOrderBy;
orderBy?: OrderByWithGroupBy;
}
export interface UpdateOneResolverArgs<
@@ -2,40 +2,77 @@ import { Injectable } from '@nestjs/common';
import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum';
import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage';
export const GROUP_BY_DATE_GRANULARITY_INPUT_KEY = 'GroupByDateInput';
export const GROUP_BY_DATE_GRANULARITY_INPUT_KEY =
'GroupByDateGranularityInput';
export const ORDER_BY_DATE_GRANULARITY_INPUT_KEY =
'OrderByDateGranularityInput';
@Injectable()
export class GroupByDateGranularityInputTypeGenerator {
constructor(private readonly gqlTypesStorage: GqlTypesStorage) {}
public buildAndStore() {
const key = GROUP_BY_DATE_GRANULARITY_INPUT_KEY;
const type = new GraphQLInputObjectType({
this.gqlTypesStorage.addGqlType(
'DateGranularityEnum',
new GraphQLEnumType({
name: 'DateGranularityEnum',
values: Object.entries(ObjectRecordGroupByDateGranularity).reduce(
(acc, [key, value]) => {
acc[key] = { value };
return acc;
},
{} as Record<string, { value: string }>,
),
description:
'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)',
}),
);
const dateGranularityEnum = this.gqlTypesStorage.getGqlTypeByKey(
'DateGranularityEnum',
) as GraphQLEnumType;
if (!isDefined(dateGranularityEnum)) {
throw new Error('DateGranularityEnum not found');
}
const groupByDateField = new GraphQLInputObjectType({
name: GROUP_BY_DATE_GRANULARITY_INPUT_KEY,
fields: {
granularity: {
type: new GraphQLEnumType({
name: 'DateGranularityEnum',
values: Object.entries(ObjectRecordGroupByDateGranularity).reduce(
(acc, [key, value]) => {
acc[key] = { value };
return acc;
},
{} as Record<string, { value: string }>,
),
description:
'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)',
}),
type: dateGranularityEnum,
description:
'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)',
},
},
});
this.gqlTypesStorage.addGqlType(key, type);
this.gqlTypesStorage.addGqlType(
GROUP_BY_DATE_GRANULARITY_INPUT_KEY,
groupByDateField,
);
const orderByDateField = new GraphQLInputObjectType({
name: ORDER_BY_DATE_GRANULARITY_INPUT_KEY,
fields: {
orderBy: {
type: OrderByDirectionType,
},
granularity: {
type: dateGranularityEnum,
},
},
});
this.gqlTypesStorage.addGqlType(
ORDER_BY_DATE_GRANULARITY_INPUT_KEY,
orderByDateField,
);
}
}
@@ -0,0 +1,164 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLInputFieldConfigMap, isInputObjectType } from 'graphql';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { ORDER_BY_DATE_GRANULARITY_INPUT_KEY } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-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 {
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 { 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';
@Injectable()
export class ObjectMetadataOrderByBaseGenerator {
constructor(
private readonly gqlTypesStorage: GqlTypesStorage,
private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator,
private readonly typeMapperService: TypeMapperService,
) {}
public generateFields({
objectMetadata,
logger,
orderByDateGranularity,
}: {
objectMetadata: ObjectMetadataEntity;
logger: Logger;
orderByDateGranularity?: boolean;
}): 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,
logger,
);
} else {
generatedFields = this.generateAtomicFieldOrderByInputType({
fieldMetadata,
typeOptions,
orderByDateGranularity,
logger,
});
}
Object.assign(allGeneratedFields, generatedFields);
}
return allGeneratedFields;
}
private generateCompositeFieldOrderByInputType(
fieldMetadata: FieldMetadataEntity,
typeOptions: TypeOptions,
logger: Logger,
) {
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`;
logger.error(message, {
fieldMetadata,
typeOptions,
});
throw new Error(message);
}
return {
[fieldMetadata.name]: {
type: compositeType,
description: fieldMetadata.description,
},
};
}
private generateAtomicFieldOrderByInputType({
fieldMetadata,
typeOptions,
logger,
orderByDateGranularity,
}: {
fieldMetadata: FieldMetadataEntity;
typeOptions: TypeOptions;
logger: Logger;
orderByDateGranularity?: boolean;
}) {
if (
orderByDateGranularity === true &&
(fieldMetadata.type === FieldMetadataType.DATE ||
fieldMetadata.type === FieldMetadataType.DATE_TIME)
) {
const orderByDateGranularityInputType =
this.gqlTypesStorage.getGqlTypeByKey(
ORDER_BY_DATE_GRANULARITY_INPUT_KEY,
);
if (!isDefined(orderByDateGranularityInputType)) {
throw new Error('OrderByDateGranularityInputType not found');
}
return {
[fieldMetadata.name]: {
type: orderByDateGranularityInputType,
description: fieldMetadata.description,
},
};
}
const orderByType = this.typeMapperService.mapToOrderByType(
fieldMetadata.type,
);
if (!isDefined(orderByType)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
logger.error(message, {
fieldMetadata,
typeOptions,
});
throw new Error(message);
}
return {
[fieldMetadata.name]: {
type: orderByType,
description: fieldMetadata.description,
},
};
}
}
@@ -1,25 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import {
GraphQLInputFieldConfigMap,
GraphQLInputObjectType,
isInputObjectType,
} from 'graphql';
import { isDefined } from 'twenty-shared/utils';
import { GraphQLInputObjectType } from 'graphql';
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 { ObjectMetadataOrderByBaseGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator';
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 { 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';
@@ -30,8 +16,7 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator {
);
constructor(
private readonly gqlTypesStorage: GqlTypesStorage,
private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator,
private readonly typeMapperService: TypeMapperService,
private readonly objectMetadataOrderByBaseGenerator: ObjectMetadataOrderByBaseGenerator,
) {}
public buildAndStore({
@@ -42,7 +27,11 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator {
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderBy.toString()}Input`,
description: objectMetadata.description,
fields: () => this.generateFields(objectMetadata),
fields: () =>
this.objectMetadataOrderByBaseGenerator.generateFields({
objectMetadata,
logger: this.logger,
}),
}) as GraphQLInputObjectType;
const key = computeObjectMetadataInputTypeKey(
@@ -52,100 +41,4 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator {
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);
}
return {
[fieldMetadata.name]: {
type: compositeType,
description: fieldMetadata.description,
},
};
}
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);
}
return {
[fieldMetadata.name]: {
type: orderByType,
description: fieldMetadata.description,
},
};
}
}
@@ -8,6 +8,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { ObjectMetadataOrderByBaseGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator';
import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator';
import {
TypeMapperService,
@@ -32,6 +33,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
constructor(
private readonly gqlTypesStorage: GqlTypesStorage,
private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator,
private readonly objectMetadataOrderByBaseGenerator: ObjectMetadataOrderByBaseGenerator,
private readonly typeMapperService: TypeMapperService,
) {}
@@ -57,7 +59,16 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
private generateFields(
objectMetadata: ObjectMetadataEntity,
): GraphQLInputFieldConfigMap {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
return {
...this.generateOrderByOnAggregateFields(objectMetadata),
...this.generateOrderByOnDimensionValuesFields(objectMetadata),
};
}
private generateOrderByOnAggregateFields(
objectMetadata: ObjectMetadataEntity,
): GraphQLInputFieldConfigMap {
const allAggregatedFields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of objectMetadata.fields) {
fieldMetadata.isNullable = true;
@@ -89,10 +100,31 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
);
}
Object.assign(allGeneratedFields, generatedFields);
Object.assign(allAggregatedFields, generatedFields);
}
return allGeneratedFields;
const aggregateInputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}AggregateInput`,
description: 'Aggregate-based ordering',
fields: () => allAggregatedFields,
}) as GraphQLInputObjectType;
return {
aggregate: {
type: aggregateInputType,
description: 'Order by aggregate values',
},
};
}
private generateOrderByOnDimensionValuesFields(
objectMetadata: ObjectMetadataEntity,
) {
return this.objectMetadataOrderByBaseGenerator.generateFields({
objectMetadata,
logger: this.logger,
orderByDateGranularity: true,
});
}
private generateCompositeFieldOrderByInputType(
@@ -11,6 +11,7 @@ import { GroupByDateGranularityInputTypeGenerator } from 'src/engine/api/graphql
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 { 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 { ObjectMetadataOrderByBaseGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.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';
@@ -48,6 +49,7 @@ export const workspaceSchemaBuilderTypeGenerators = [
ObjectMetadataOrderByGqlInputTypeGenerator,
ObjectMetadataGroupByGqlInputTypeGenerator,
ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator,
ObjectMetadataOrderByBaseGenerator,
GroupByDateGranularityInputTypeGenerator,
CompositeFieldMetadataGqlObjectTypeGenerator,
ObjectMetadataGqlObjectTypeGenerator,
@@ -64,6 +64,7 @@ export const RESERVED_METADATA_NAME_KEYWORDS = [
'index',
'relation',
'relations',
'aggregate',
];
export const validateMetadataNameIsNotReservedKeywordOrThrow = (
@@ -0,0 +1,569 @@
import { randomUUID } from 'crypto';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
describe('group-by resolvers - order by', () => {
const testCompanyId1 = randomUUID();
const testCompanyId2 = randomUUID();
const testCompanyId3 = randomUUID();
const testCompanyId4 = randomUUID();
const testCompanyId5 = randomUUID();
const testCompanyId6 = randomUUID();
const testCompanyId7 = randomUUID();
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId1,
createdAt: '2025-03-03T09:30:00.000Z', // Monday
address: { addressCity: 'Cuzco' },
employees: 20,
annualRecurringRevenue: { amountMicros: 100 },
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId7,
createdAt: '2025-03-03T09:30:00.000Z', // Monday
address: { addressCity: 'Anvers' },
employees: 19,
annualRecurringRevenue: { amountMicros: 100 },
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId2,
createdAt: '2025-03-03T09:30:00.000Z', // Monday
address: { addressCity: 'Cuzco' },
employees: 19,
annualRecurringRevenue: { amountMicros: 105 },
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId3,
createdAt: '2025-03-03T09:30:00.000Z', // Monday
address: { addressCity: 'Dallas' },
employees: 2,
annualRecurringRevenue: { amountMicros: 100 },
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId4,
createdAt: '2025-01-02T12:00:00.000Z', // Thursday
address: { addressCity: 'Paris' },
employees: 10,
annualRecurringRevenue: { amountMicros: 100 },
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId5,
createdAt: '2025-01-08T08:00:00.000Z', // Wednesday
address: { addressCity: 'Barcelona' },
employees: 5,
annualRecurringRevenue: { amountMicros: 100 },
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId6,
createdAt: '2025-01-08T08:00:00.000Z', // Wednesday
address: { addressCity: 'Barcelona' },
employees: 1,
annualRecurringRevenue: { amountMicros: 100 },
},
}),
);
});
afterAll(async () => {
// cleanup created companies
for (const id of [
testCompanyId1,
testCompanyId2,
testCompanyId3,
testCompanyId4,
testCompanyId5,
testCompanyId6,
testCompanyId7,
]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
const groupByAddressCreatedAtAndARR = (orderBy: object[]) => {
return groupByOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
groupBy: [
{ address: { addressCity: true } },
{ createdAt: { granularity: 'DAY_OF_THE_WEEK' } },
{
annualRecurringRevenue: {
amountMicros: true,
},
},
],
orderBy,
filter: filter2025,
gqlFields: `
avgEmployees
`,
});
};
describe('valid cases', () => {
it('should order results in the right order - createdAt, avgEmployees, addressCity', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
orderBy: 'AscNullsFirst',
},
},
{
aggregate: {
avgEmployees: 'AscNullsFirst',
},
},
{
address: {
addressCity: 'AscNullsFirst',
},
},
]),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
// Extract group info for easier assertions
const groupInfos = groups.map((g: any) => ({
city: g.groupByDimensionValues?.[0],
dayOfWeek: g.groupByDimensionValues?.[1],
annualRecurringRevenue: g.groupByDimensionValues?.[2],
avgEmployees: g.avgEmployees,
totalCount: g.totalCount,
}));
// Order by dayOfWeek then avgEmployees then city
expect(groupInfos).toEqual([
{
city: 'Dallas',
dayOfWeek: 'Monday',
avgEmployees: 2,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Anvers',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '105',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 20,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Barcelona',
dayOfWeek: 'Wednesday',
avgEmployees: 3,
totalCount: 2,
annualRecurringRevenue: '100',
},
]);
});
it('should order results in the right order - createdAt, addressCity, avgEmployees', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
orderBy: 'AscNullsFirst',
},
},
{
address: {
addressCity: 'AscNullsFirst',
},
},
{
aggregate: {
avgEmployees: 'AscNullsFirst',
},
},
]),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const groupInfos = groups.map((g: any) => ({
city: g.groupByDimensionValues?.[0],
dayOfWeek: g.groupByDimensionValues?.[1],
annualRecurringRevenue: g.groupByDimensionValues?.[2],
avgEmployees: g.avgEmployees,
totalCount: g.totalCount,
}));
expect(groupInfos).toEqual([
{
city: 'Anvers',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '105',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 20,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Dallas',
dayOfWeek: 'Monday',
avgEmployees: 2,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Barcelona',
dayOfWeek: 'Wednesday',
avgEmployees: 3,
totalCount: 2,
annualRecurringRevenue: '100',
},
]);
});
it('should order results in the right order - addressCity, createdAt, avgEmployees', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{
address: {
addressCity: 'AscNullsFirst',
},
},
{
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
orderBy: 'AscNullsFirst',
},
},
{
aggregate: {
avgEmployees: 'AscNullsFirst',
},
},
]),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const groupInfos = groups.map((g: any) => ({
city: g.groupByDimensionValues?.[0],
dayOfWeek: g.groupByDimensionValues?.[1],
annualRecurringRevenue: g.groupByDimensionValues?.[2],
avgEmployees: g.avgEmployees,
totalCount: g.totalCount,
}));
expect(groupInfos).toEqual([
{
city: 'Anvers',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Barcelona',
dayOfWeek: 'Wednesday',
avgEmployees: 3,
totalCount: 2,
annualRecurringRevenue: '100',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '105',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 20,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Dallas',
dayOfWeek: 'Monday',
avgEmployees: 2,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
]);
});
it('should order results in the right order - avgEmployees, createdAt, addressCity', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{
aggregate: {
avgEmployees: 'AscNullsFirst',
},
},
{
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
orderBy: 'AscNullsFirst',
},
},
{
address: {
addressCity: 'AscNullsFirst',
},
},
]),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const groupInfos = groups.map((g: any) => ({
city: g.groupByDimensionValues?.[0],
dayOfWeek: g.groupByDimensionValues?.[1],
annualRecurringRevenue: g.groupByDimensionValues?.[2],
avgEmployees: g.avgEmployees,
totalCount: g.totalCount,
}));
expect(groupInfos).toEqual([
{
city: 'Dallas',
dayOfWeek: 'Monday',
avgEmployees: 2,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Barcelona',
dayOfWeek: 'Wednesday',
avgEmployees: 3,
totalCount: 2,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Anvers',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 19,
totalCount: 1,
annualRecurringRevenue: '105',
},
{
city: 'Cuzco',
dayOfWeek: 'Monday',
avgEmployees: 20,
totalCount: 1,
annualRecurringRevenue: '100',
},
]);
});
});
describe('invalid cases', () => {
it('should fail if attempt to order by a field that is not part of the groupBy', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([{ employees: 'AscNullsFirst' }]),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(1);
expect(response.body.errors[0].message).toBe(
'Cannot order by a field that is not an aggregate nor in groupBy criteria: employees.',
);
});
it('should fail if attempt to order by a date granularity that is not the same as in the groupBy', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{ createdAt: { granularity: 'MONTH', orderBy: 'AscNullsFirst' } },
]),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(1);
expect(response.body.errors[0].message).toBe(
'Cannot order by a date granularity that is not in groupBy criteria: MONTH',
);
});
it('should fail if attempt to order by a date without indicating the granularity', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{ createdAt: { orderBy: 'AscNullsFirst' } },
]),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(1);
expect(response.body.errors[0].message).toContain(
'Cannot order by a field that is not in groupBy or that is not an aggregate field',
);
});
it('should fail if attempt to indicate more than one orderBy field at the time (aggregate)', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{
aggregate: {
avgEmployees: 'AscNullsFirst',
avgAnnualRecurringRevenueAmountMicros: 'AscNullsFirst',
},
},
]),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(1);
expect(response.body.errors[0].message).toBe(
'Please provide aggregate criteria one by one in orderBy array',
);
});
it('should fail if attempt to indicate more than one orderBy field at the time', async () => {
const response = await makeGraphqlAPIRequest(
groupByAddressCreatedAtAndARR([
{
employees: 'AscNullsFirst',
name: 'AscNullsFirst',
},
]),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(1);
expect(response.body.errors[0].message).toBe(
'Please provide orderBy field criteria one by one in orderBy array',
);
});
});
});
@@ -8,6 +8,7 @@ type GroupByOperationFactoryParams = {
filter?: object;
orderBy?: object[];
viewId?: string;
gqlFields?: string;
};
export const groupByOperationFactory = ({
@@ -17,10 +18,12 @@ export const groupByOperationFactory = ({
filter = {},
orderBy = [],
viewId,
gqlFields,
}: GroupByOperationFactoryParams) => ({
query: gql`
query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID) {
${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId) {
${gqlFields ? gqlFields : ''}
groupByDimensionValues
totalCount
}