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:
+397
-127
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+40
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
+60
@@ -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);
|
||||
};
|
||||
+29
-22
@@ -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
|
||||
|
||||
+2
-7
@@ -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();
|
||||
|
||||
+20
@@ -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;
|
||||
};
|
||||
|
||||
+2
-1
@@ -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<
|
||||
|
||||
+54
-17
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
-115
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+35
-3
@@ -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(
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -64,6 +64,7 @@ export const RESERVED_METADATA_NAME_KEYWORDS = [
|
||||
'index',
|
||||
'relation',
|
||||
'relations',
|
||||
'aggregate',
|
||||
];
|
||||
|
||||
export const validateMetadataNameIsNotReservedKeywordOrThrow = (
|
||||
|
||||
+569
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user