[groupBy] groupBy relation fields (#15951)

Example query
Here person has 
- a N - 1 relationship with company
- a N - 1 morph relationship with pet or company

<img width="862" height="374" alt="image"
src="https://github.com/user-attachments/assets/59bc9b82-c943-43de-ad82-d3393b76904b"
/>
<img width="415" height="629" alt="image"
src="https://github.com/user-attachments/assets/9a3176bc-99cd-4983-8611-68ca3a2cf527"
/>

truncated response
<img width="299" height="447" alt="image"
src="https://github.com/user-attachments/assets/45af0322-9e66-4eae-8353-6c0dda487bbe"
/>

We don't allow grouping by relations of relations.

Left to do
- rest api
- tests on permissions
This commit is contained in:
Marie
2025-11-25 10:03:16 +01:00
committed by GitHub
parent 316aec3c40
commit 46d1ea6505
34 changed files with 1910 additions and 143 deletions
@@ -19,6 +19,12 @@ import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { getGroupByDefinitions } from 'src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util';
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
@@ -30,12 +36,11 @@ import {
} from 'src/engine/api/common/types/common-query-args.type';
import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types';
import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util';
import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util';
import { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
@@ -48,7 +53,6 @@ import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entit
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@Injectable()
export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerService<
@@ -84,6 +88,20 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
objectMetadataNameSingular,
);
const groupByFields = parseGroupByArgs(
args,
objectMetadataItemWithFieldMaps,
queryRunnerContext.objectMetadataMaps,
);
const objectAlias = getObjectAlias(objectMetadataItemWithFieldMaps);
this.addJoinForGroupByOnRelationFields({
queryBuilder,
groupByFields,
objectAlias,
});
let appliedFilters = args.filter ?? ({} as ObjectRecordFilter);
await this.addFiltersToQueryBuilder({
@@ -95,47 +113,17 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
commonQueryParser,
});
const queryBuilderWithFiltersAndWithoutGroupBy = queryBuilder.clone();
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: args.selectedFieldsResult.aggregate,
queryBuilder,
objectMetadataNameSingular,
});
const groupByFields = parseGroupByArgs(
args,
objectMetadataItemWithFieldMaps,
);
const groupByDefinitions = groupByFields.map((groupByField) => {
const columnName = isFieldMetadataRelationOrMorphRelation(
groupByField.fieldMetadata,
)
? formatColumnNameForRelationField(
groupByField.fieldMetadata.name,
groupByField.fieldMetadata.settings,
)
: formatColumnNamesFromCompositeFieldAndSubfields(
groupByField.fieldMetadata.name,
groupByField.subFieldName ? [groupByField.subFieldName] : undefined,
)[0];
const columnNameWithQuotes = `"${columnName}"`;
const alias =
removeQuotes(columnNameWithQuotes) +
(isGroupByDateField(groupByField)
? `_${groupByField.dateGranularity}`
: '');
return {
columnNameWithQuotes,
expression: getGroupByExpression({
groupByField,
columnNameWithQuotes,
}),
alias,
dateGranularity: isGroupByDateField(groupByField)
? groupByField.dateGranularity
: undefined,
};
const groupByDefinitions = getGroupByDefinitions({
groupByFields,
objectMetadataNameSingular,
});
groupByDefinitions.forEach((groupByColumn, index) => {
@@ -157,18 +145,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
const shouldIncludeRecords = args.includeRecords ?? false;
if (shouldIncludeRecords) {
const queryBuilderWithFiltersAndWithoutGroupBy =
repository.createQueryBuilder(objectMetadataNameSingular);
await this.addFiltersToQueryBuilder({
args,
objectMetadataItemWithFieldMaps,
workspaceId: authContext.workspace.id,
commonQueryParser,
appliedFilters,
queryBuilder: queryBuilderWithFiltersAndWithoutGroupBy,
});
return this.groupByWithRecordsService.resolveWithRecords({
queryBuilderWithFiltersAndWithoutGroupBy,
queryBuilderWithGroupBy: queryBuilder,
@@ -343,6 +319,48 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
});
}
private addJoinForGroupByOnRelationFields({
queryBuilder,
groupByFields,
objectAlias,
}: {
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByFields: GroupByField[];
objectAlias: string;
}): void {
const joinAliasSet = new Set<string>();
for (const groupByField of groupByFields) {
if (isGroupByRelationField(groupByField)) {
const joinAlias = groupByField.fieldMetadata.name;
if (
!groupByField.fieldMetadata.settings ||
!isFieldMetadataRelationOrMorphRelation(groupByField.fieldMetadata)
) {
throw new CommonQueryRunnerException(
`Field metadata settings are missing or invalid for field ${groupByField.fieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const joinColumnName = formatColumnNameForRelationField(
groupByField.fieldMetadata.name,
groupByField.fieldMetadata.settings,
);
if (!joinAliasSet.has(joinAlias)) {
queryBuilder.leftJoin(
`${objectAlias}.${joinAlias}`,
`${joinAlias}`,
`"${objectAlias}"."${joinColumnName}" = "${joinAlias}"."id"`,
);
joinAliasSet.add(joinAlias);
}
}
}
}
async validate(
_args: CommonInput<GroupByQueryArgs>,
_queryRunnerContext: CommonBaseQueryRunnerContext,
@@ -15,4 +15,5 @@ export enum CommonQueryRunnerExceptionCode {
INVALID_CURSOR = 'INVALID_CURSOR',
TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE',
BAD_REQUEST = 'BAD_REQUEST',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
}
@@ -30,6 +30,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
throw new InternalServerError(error);
default: {
return assertUnreachable(error.code);
@@ -31,6 +31,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new UnauthorizedException(error.message);
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
throw new InternalServerErrorException(error.message);
default: {
return assertUnreachable(error.code);
@@ -0,0 +1,60 @@
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { 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 { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { formatColumnNameAsAlias } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
export const getGroupByDefinitions = ({
groupByFields,
objectMetadataNameSingular,
}: {
groupByFields: GroupByField[];
objectMetadataNameSingular: string;
}): GroupByDefinition[] => {
return groupByFields.map((groupByField) => {
let columnName: string;
let columnNameWithQuotes: string;
if (isGroupByRelationField(groupByField)) {
const joinAlias = groupByField.fieldMetadata.name;
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
groupByField.nestedFieldMetadata.name,
groupByField.nestedSubFieldName
? [groupByField.nestedSubFieldName]
: undefined,
)[0];
columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
} else {
columnName = formatColumnNamesFromCompositeFieldAndSubfields(
groupByField.fieldMetadata.name,
groupByField.subFieldName ? [groupByField.subFieldName] : undefined,
)[0];
columnNameWithQuotes = `"${objectMetadataNameSingular}"."${columnName}"`;
}
const isGroupByDateFieldOrTargetField =
isGroupByDateField(groupByField) ||
(isGroupByRelationField(groupByField) && groupByField.dateGranularity);
const alias =
formatColumnNameAsAlias(columnNameWithQuotes) +
(isGroupByDateFieldOrTargetField
? `_${groupByField.dateGranularity}`
: '');
return {
columnNameWithQuotes,
expression: getGroupByExpression({
groupByField,
columnNameWithQuotes,
}),
alias,
dateGranularity: isGroupByDateFieldOrTargetField
? groupByField.dateGranularity
: undefined,
};
});
};
@@ -0,0 +1,7 @@
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const getObjectAlias = (
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): string => {
return objectMetadataItemWithFieldMaps.nameSingular;
};
@@ -4,6 +4,7 @@ import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
type ObjectRecordOrderByForCompositeField,
type ObjectRecordOrderByForRelationField,
type ObjectRecordOrderByForScalarField,
type ObjectRecordOrderByWithGroupByDateField,
OrderByDirection,
@@ -19,9 +20,11 @@ import {
} 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 { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util';
import {
type GroupByDateField,
type GroupByField,
type GroupByRegularField,
} 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';
@@ -34,6 +37,7 @@ import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-er
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 ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@@ -44,9 +48,14 @@ export type OrderByCondition = {
export class GraphqlQueryOrderFieldParser {
private objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
private objectMetadataMaps?: ObjectMetadataMaps;
constructor(objectMetadataMapItem: ObjectMetadataItemWithFieldMaps) {
constructor(
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps?: ObjectMetadataMaps,
) {
this.objectMetadataMapItem = objectMetadataMapItem;
this.objectMetadataMaps = objectMetadataMaps;
}
parse(
@@ -132,6 +141,12 @@ export class GraphqlQueryOrderFieldParser {
continue;
}
if (Object.keys(orderByArg).length > 1) {
throw new UserInputError(
'Please provide orderBy field criteria one by one in orderBy array',
);
}
const fieldName = Object.keys(orderByArg)[0];
const fieldMetadataId =
this.objectMetadataMapItem.fieldIdByName[fieldName];
@@ -180,6 +195,24 @@ export class GraphqlQueryOrderFieldParser {
continue;
}
if (
this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata)
) {
const parsedOrderByForRelationField =
this.parseObjectRecordOrderByForRelationField({
groupByFields,
orderByArg,
fieldMetadata,
});
if (!isDefined(parsedOrderByForRelationField)) {
continue;
}
parsedOrderBy.push(parsedOrderByForRelationField);
continue;
}
if (this.isObjectRecordOrderByForCompositeField(orderByArg)) {
const parsedOrderByForCompositeField =
this.parseObjectRecordOrderByForCompositeField({
@@ -221,7 +254,8 @@ export class GraphqlQueryOrderFieldParser {
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByWithGroupByDateField,
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForRelationField,
): orderByArg is AggregateOrderByWithGroupByField => {
return isDefined(orderByArg.aggregate);
};
@@ -230,7 +264,8 @@ export class GraphqlQueryOrderFieldParser {
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField,
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForRelationField,
): orderByArg is ObjectRecordOrderByForScalarField => {
if (Object.keys(orderByArg).length > 1) {
throw new UserInputError(
@@ -256,7 +291,8 @@ export class GraphqlQueryOrderFieldParser {
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField,
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForRelationField,
): orderByArg is ObjectRecordOrderByForCompositeField => {
const compositeFieldOrderByValue = Object.values(orderByArg)[0];
@@ -292,7 +328,8 @@ export class GraphqlQueryOrderFieldParser {
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByWithGroupByDateField,
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForRelationField,
fieldMetadataType: FieldMetadataType,
): orderByArg is ObjectRecordOrderByWithGroupByDateField => {
if (
@@ -443,7 +480,7 @@ export class GraphqlQueryOrderFieldParser {
!groupByFields.some(
(groupByField) =>
groupByField.fieldMetadata.id === fieldMetadata.id &&
groupByField.subFieldName === subFieldName,
(groupByField as GroupByRegularField).subFieldName === subFieldName,
)
) {
throw new UserInputError(
@@ -485,7 +522,7 @@ export class GraphqlQueryOrderFieldParser {
(groupByField) =>
groupByField.fieldMetadata.id === fieldMetadataId &&
(groupByField as GroupByDateField).dateGranularity === granularity,
);
) as GroupByDateField | undefined;
if (!isDefined(associatedGroupByField)) {
throw new UserInputError(
@@ -511,4 +548,179 @@ export class GraphqlQueryOrderFieldParser {
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
};
};
parseObjectRecordOrderByForRelationField = ({
groupByFields,
orderByArg,
fieldMetadata,
}: {
groupByFields: GroupByField[];
orderByArg: ObjectRecordOrderByForRelationField;
fieldMetadata: FieldMetadataEntity;
}): Record<string, OrderByCondition> | null => {
const {
associatedGroupByField,
nestedFieldMetadata,
nestedFieldOrderByValue,
} = prepareForOrderByRelationFieldParsing({
orderByArg,
fieldMetadata,
objectMetadataMaps: this.objectMetadataMaps,
groupByFields,
});
if (
!isDefined(associatedGroupByField) ||
!isDefined(nestedFieldMetadata) ||
!isDefined(nestedFieldOrderByValue)
) {
return null;
}
// Handle composite fields
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
if (!isObject(nestedFieldOrderByValue)) {
throw new UserInputError(
`Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`,
);
}
const compositeSubFields = Object.keys(nestedFieldOrderByValue);
if (compositeSubFields.length > 1) {
throw new UserInputError(
'Please provide composite subfield criteria one by one in orderBy array',
);
}
const nestedSubFieldName = compositeSubFields[0];
const orderByDirection = (
nestedFieldOrderByValue as Record<string, OrderByDirection>
)[nestedSubFieldName];
if (!isDefined(orderByDirection)) {
return null;
}
if (
!isDefined(associatedGroupByField.nestedSubFieldName) ||
associatedGroupByField.nestedSubFieldName !== nestedSubFieldName
) {
throw new UserInputError(
`Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`,
);
}
const joinAlias = fieldMetadata.name;
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
nestedFieldMetadata.name,
[nestedSubFieldName],
)[0];
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
return {
[columnNameWithQuotes]:
convertOrderByToFindOptionsOrder(orderByDirection),
};
}
const isGroupByDateField =
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
isObject(nestedFieldOrderByValue) &&
'orderBy' in nestedFieldOrderByValue &&
'granularity' in nestedFieldOrderByValue;
if (isGroupByDateField) {
const orderByDirection = (
nestedFieldOrderByValue as {
orderBy: OrderByDirection;
granularity: ObjectRecordGroupByDateGranularity;
}
).orderBy;
const granularity = (
nestedFieldOrderByValue as {
orderBy: OrderByDirection;
granularity: ObjectRecordGroupByDateGranularity;
}
).granularity;
if (
!isDefined(associatedGroupByField.dateGranularity) ||
associatedGroupByField.dateGranularity !== granularity
) {
throw new UserInputError(
`Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
);
}
const joinAlias = fieldMetadata.name;
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
nestedFieldMetadata.name,
associatedGroupByField.nestedSubFieldName
? [associatedGroupByField.nestedSubFieldName]
: undefined,
)[0];
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
const expression = getGroupByExpression({
groupByField: associatedGroupByField,
columnNameWithQuotes,
});
return {
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
};
}
// Handle regular nested fields
if (
typeof nestedFieldOrderByValue === 'string' &&
Object.values(OrderByDirection).includes(
nestedFieldOrderByValue as OrderByDirection,
)
) {
const orderByDirection = nestedFieldOrderByValue as OrderByDirection;
const joinAlias = fieldMetadata.name;
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
nestedFieldMetadata.name,
associatedGroupByField.nestedSubFieldName
? [associatedGroupByField.nestedSubFieldName]
: undefined,
)[0];
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
return {
[columnNameWithQuotes]:
convertOrderByToFindOptionsOrder(orderByDirection),
};
}
return null;
};
isObjectRecordOrderByForRelationField = (
orderByArg:
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForRelationField,
fieldMetadata: FieldMetadataEntity,
): orderByArg is ObjectRecordOrderByForRelationField => {
if (!isFieldMetadataRelationOrMorphRelation(fieldMetadata)) {
return false;
}
const relationFieldOrderByValue = Object.values(orderByArg)[0];
if (!isObject(relationFieldOrderByValue)) {
return false;
}
return Object.keys(relationFieldOrderByValue).length > 0;
};
}
@@ -0,0 +1,105 @@
import { UserInputError } from 'apollo-server-core';
import { isObject } from 'class-validator';
import { type ObjectRecordOrderByForRelationField } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
export const prepareForOrderByRelationFieldParsing = ({
orderByArg,
fieldMetadata,
objectMetadataMaps,
groupByFields,
}: {
orderByArg: ObjectRecordOrderByForRelationField;
fieldMetadata: FieldMetadataEntity;
objectMetadataMaps?: ObjectMetadataMaps;
groupByFields: GroupByField[];
}) => {
if (!objectMetadataMaps) {
throw new UserInputError(
`Object metadata maps not found for field ${fieldMetadata.name}`,
);
}
const relationFieldName = Object.keys(orderByArg)[0];
const nestedFieldOrderByObject = orderByArg[relationFieldName];
if (
!isDefined(nestedFieldOrderByObject) ||
!isObject(nestedFieldOrderByObject)
) {
return {};
}
if (Object.keys(nestedFieldOrderByObject).length > 1) {
throw new UserInputError(
'Please provide nested field criteria one by one in orderBy array',
);
}
const nestedFieldName = Object.keys(nestedFieldOrderByObject)[0];
const nestedFieldOrderByValue = nestedFieldOrderByObject[nestedFieldName];
if (!isDefined(nestedFieldOrderByValue)) {
return {};
}
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
throw new UserInputError(
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
);
}
const targetObjectMetadataItem =
objectMetadataMaps?.byId[fieldMetadata.relationTargetObjectMetadataId];
if (!isDefined(targetObjectMetadataItem)) {
throw new UserInputError(
`Target object metadata item not found for field ${fieldMetadata.name}`,
);
}
const nestedFieldMetadataId =
targetObjectMetadataItem?.fieldIdByName[nestedFieldName];
if (!isDefined(nestedFieldMetadataId)) {
throw new UserInputError(
`Nested field metadata id not found for field ${nestedFieldName}`,
);
}
const nestedFieldMetadata =
targetObjectMetadataItem?.fieldsById[nestedFieldMetadataId];
if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
throw new UserInputError(
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadataItem.nameSingular}"`,
);
}
const associatedGroupByField = groupByFields.find(
(groupByField) =>
isGroupByRelationField(groupByField) &&
groupByField.fieldMetadata.id === fieldMetadata.id &&
groupByField.nestedFieldMetadata.id === nestedFieldMetadataId,
) as GroupByRelationField | undefined;
if (!isDefined(associatedGroupByField)) {
throw new UserInputError(
`Cannot order by a relation field that is not in groupBy criteria: ${relationFieldName}.${nestedFieldName}`,
);
}
return {
associatedGroupByField,
nestedFieldMetadata,
nestedFieldOrderByValue,
};
};
@@ -40,6 +40,7 @@ export class GraphqlQueryParser {
);
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
this.objectMetadataMapItem,
this.objectMetadataMaps,
);
}
@@ -0,0 +1 @@
export type CompositeFieldGroupByDefinition = Record<string, boolean>;
@@ -0,0 +1,5 @@
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
export type DateFieldGroupByDefinition = {
granularity: ObjectRecordGroupByDateGranularity;
};
@@ -0,0 +1,8 @@
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
export type FieldGroupByDefinition =
| boolean
| CompositeFieldGroupByDefinition
| DateFieldGroupByDefinition
| undefined;
@@ -11,4 +11,13 @@ export type GroupByDateField = {
subFieldName?: string;
dateGranularity: ObjectRecordGroupByDateGranularity;
};
export type GroupByField = GroupByRegularField | GroupByDateField;
export type GroupByRelationField = {
fieldMetadata: FieldMetadataEntity;
nestedFieldMetadata: FieldMetadataEntity;
nestedSubFieldName?: string;
dateGranularity?: ObjectRecordGroupByDateGranularity;
};
export type GroupByField =
| GroupByRegularField
| GroupByDateField
| GroupByRelationField;
@@ -6,7 +6,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
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 { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@@ -1,8 +1,9 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
export const getGroupByExpression = ({
groupByField,
@@ -11,10 +12,19 @@ export const getGroupByExpression = ({
groupByField: GroupByField;
columnNameWithQuotes: string;
}) => {
if (!isGroupByDateField(groupByField)) {
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
return columnNameWithQuotes;
}
switch (groupByField.dateGranularity) {
const dateGranularity = groupByField.dateGranularity;
if (!isDefined(dateGranularity)) {
return columnNameWithQuotes;
}
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.NONE:
return columnNameWithQuotes;
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
@@ -27,8 +37,8 @@ export const getGroupByExpression = ({
case ObjectRecordGroupByDateGranularity.MONTH:
case ObjectRecordGroupByDateGranularity.QUARTER:
case ObjectRecordGroupByDateGranularity.YEAR:
return `DATE_TRUNC('${groupByField.dateGranularity}', ${columnNameWithQuotes})`;
return `DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes})`;
default:
assertUnreachable(groupByField.dateGranularity);
assertUnreachable(dateGranularity);
}
};
@@ -0,0 +1,30 @@
import { isDefined } from 'class-validator';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type';
export const isGroupByDateFieldDefinition = (
fieldGroupByDefinition: FieldGroupByDefinition,
): fieldGroupByDefinition is {
granularity: ObjectRecordGroupByDateGranularity;
} => {
if (
typeof fieldGroupByDefinition !== 'object' ||
!isDefined(fieldGroupByDefinition)
) {
return false;
}
if (!('granularity' in fieldGroupByDefinition)) {
return false;
}
const granularity = fieldGroupByDefinition.granularity;
return (
isDefined(granularity) &&
typeof granularity === 'string' &&
Object.values(ObjectRecordGroupByDateGranularity).includes(
granularity as ObjectRecordGroupByDateGranularity,
)
);
};
@@ -6,5 +6,8 @@ import {
export const isGroupByDateField = (
groupByField: GroupByField,
): groupByField is GroupByDateField => {
return 'dateGranularity' in groupByField;
return (
'dateGranularity' in groupByField &&
!('nestedFieldMetadata' in groupByField)
);
};
@@ -0,0 +1,10 @@
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
export const isGroupByRelationField = (
groupByField: GroupByField,
): groupByField is GroupByRelationField => {
return 'nestedFieldMetadata' in groupByField;
};
@@ -1,63 +1,32 @@
import { isDefined } from 'class-validator';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { FieldMetadataType } from 'twenty-shared/types';
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { parseGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
const isGroupByDateFieldDefinition = (
fieldGroupByDefinition:
| boolean
| Record<string, boolean>
| { granularity: ObjectRecordGroupByDateGranularity }
| undefined,
): fieldGroupByDefinition is {
granularity: ObjectRecordGroupByDateGranularity;
} => {
if (
typeof fieldGroupByDefinition !== 'object' ||
!isDefined(fieldGroupByDefinition)
) {
return false;
}
if (!('granularity' in fieldGroupByDefinition)) {
return false;
}
const granularity = fieldGroupByDefinition.granularity;
return (
isDefined(granularity) &&
typeof granularity === 'string' &&
Object.values(ObjectRecordGroupByDateGranularity).includes(
granularity as ObjectRecordGroupByDateGranularity,
)
);
};
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
export const parseGroupByArgs = (
args: GroupByResolverArgs,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): GroupByField[] => {
const groupByFieldNames = args.groupBy;
const groupByFields: GroupByField[] = [];
for (const fieldNames of groupByFieldNames) {
if (Object.keys(fieldNames).length > 1) {
throw new GraphqlQueryRunnerException(
validateSingleKeyForGroupByOrThrow({
groupByKeys: Object.keys(fieldNames),
errorMessage:
'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
});
for (const fieldName of Object.keys(fieldNames)) {
const fieldMetadataId =
objectMetadataItemWithFieldMaps.fieldIdByName[fieldName] ||
@@ -69,6 +38,26 @@ export const parseGroupByArgs = (
throw new Error(`Unidentified field in groupBy: ${fieldName}`);
}
const isGroupByRelationField =
isFieldMetadataRelationOrMorphRelation(fieldMetadata) &&
typeof fieldNames[fieldName] === 'object' &&
fieldNames[fieldName] !== null &&
!isGroupByDateFieldDefinition(fieldNames[fieldName]);
// Handle relation fields
if (isGroupByRelationField) {
parseGroupByRelationField({
fieldNames,
fieldName,
fieldMetadata,
objectMetadataMaps,
groupByFields,
});
continue;
}
// Handle date fields
if (
fieldMetadata.type === FieldMetadataType.DATE ||
fieldMetadata.type === FieldMetadataType.DATE_TIME
@@ -88,6 +77,7 @@ export const parseGroupByArgs = (
}
}
// Handle regular fields and composite fields
if (fieldNames[fieldName] === true) {
groupByFields.push({
fieldMetadata,
@@ -95,12 +85,12 @@ export const parseGroupByArgs = (
});
continue;
} else if (typeof fieldNames[fieldName] === 'object') {
if (Object.keys(fieldNames[fieldName]).length > 1) {
throw new GraphqlQueryRunnerException(
validateSingleKeyForGroupByOrThrow({
groupByKeys: Object.keys(fieldNames[fieldName]),
errorMessage:
'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
});
for (const subFieldName of Object.keys(fieldNames[fieldName])) {
if (
(fieldNames[fieldName] as Record<string, boolean>)[subFieldName] ===
@@ -0,0 +1,190 @@
import { UserInputError } from 'apollo-server-core';
import { isDefined } from 'class-validator';
import {
FieldMetadataType,
type ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.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 ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
const getNestedFieldMetadataDetails = ({
fieldNames,
fieldName,
fieldMetadata,
objectMetadataMaps,
}: {
fieldNames: Record<string, unknown>;
fieldName: string;
fieldMetadata: FieldMetadataEntity;
objectMetadataMaps: ObjectMetadataMaps;
}) => {
const nestedFieldGroupByDefinitions = fieldNames[fieldName] as
| Record<string, boolean>
| Record<string, CompositeFieldGroupByDefinition>
| Record<string, DateFieldGroupByDefinition>;
const targetObjectMetadata = getTargetObjectMetadataOrThrow(
fieldMetadata,
objectMetadataMaps,
);
const nestedFieldNames = Object.keys(nestedFieldGroupByDefinitions);
validateSingleKeyForGroupByOrThrow({
groupByKeys: nestedFieldNames,
errorMessage:
'You cannot provide multiple nested fields in one relation GroupByInput, split them into multiple GroupByInput',
});
const nestedFieldName = nestedFieldNames[0];
const nestedFieldMetadataId =
targetObjectMetadata.fieldIdByName[nestedFieldName];
const nestedFieldMetadata =
targetObjectMetadata.fieldsById[nestedFieldMetadataId];
if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
throw new GraphqlQueryRunnerException(
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
);
}
if (nestedFieldMetadata.type === FieldMetadataType.RELATION) {
throw new UserInputError(
`Cannot group by a relation field of the relation field: "${nestedFieldName}" is a relation field of "${targetObjectMetadata.nameSingular}"`,
);
}
const nestedFieldGroupByDefinition =
nestedFieldGroupByDefinitions[nestedFieldName];
return {
nestedFieldGroupByDefinition,
nestedFieldMetadata,
nestedFieldName,
};
};
const handleNestedCompositeField = ({
nestedFieldGroupByDefinition,
nestedFieldName,
fieldMetadata,
nestedFieldMetadata,
groupByFields,
}: {
nestedFieldGroupByDefinition: CompositeFieldGroupByDefinition;
nestedFieldName: string;
fieldMetadata: FieldMetadataEntity;
nestedFieldMetadata: FieldMetadataEntity;
groupByFields: GroupByField[];
}) => {
if (
typeof nestedFieldGroupByDefinition === 'object' &&
nestedFieldGroupByDefinition !== null
) {
const compositeSubFields = Object.keys(
nestedFieldGroupByDefinition as Record<string, unknown>,
);
validateSingleKeyForGroupByOrThrow({
groupByKeys: compositeSubFields,
errorMessage:
'You cannot provide multiple composite subfields in one GroupByInput, split them into multiple GroupByInput',
});
const nestedSubFieldName = compositeSubFields[0];
if (
(nestedFieldGroupByDefinition as Record<string, boolean>)[
nestedSubFieldName
] === true
) {
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
nestedSubFieldName,
});
return;
}
}
throw new GraphqlQueryRunnerException(
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
};
export const parseGroupByRelationField = ({
fieldNames,
fieldName,
fieldMetadata,
objectMetadataMaps,
groupByFields,
}: {
fieldNames: Record<string, unknown>;
fieldName: string;
fieldMetadata: FieldMetadataEntity;
objectMetadataMaps: ObjectMetadataMaps;
groupByFields: GroupByField[];
}) => {
const { nestedFieldGroupByDefinition, nestedFieldMetadata, nestedFieldName } =
getNestedFieldMetadataDetails({
fieldNames,
fieldName,
fieldMetadata,
objectMetadataMaps,
});
// Handle date fields in nested relations
if (
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
isGroupByDateFieldDefinition(nestedFieldGroupByDefinition)
) {
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
dateGranularity: (
nestedFieldGroupByDefinition as {
granularity: ObjectRecordGroupByDateGranularity;
}
).granularity,
});
return;
}
// Handle composite fields in nested relations
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
handleNestedCompositeField({
nestedFieldGroupByDefinition:
nestedFieldGroupByDefinition as CompositeFieldGroupByDefinition,
nestedFieldName,
fieldMetadata,
nestedFieldMetadata,
groupByFields,
});
}
// Handle regular nested fields
if (nestedFieldGroupByDefinition === true) {
groupByFields.push({
fieldMetadata,
nestedFieldMetadata,
});
return;
}
};
@@ -1,3 +1,9 @@
export const removeQuotes = (string: string): string => {
const removeQuotes = (string: string): string => {
return string.replace(/["']/g, '');
};
export const formatColumnNameAsAlias = (
columnNameWithQuotes: string,
): string => {
return removeQuotes(columnNameWithQuotes).replace(/\./g, '_');
};
@@ -0,0 +1,19 @@
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
export const validateSingleKeyForGroupByOrThrow = ({
groupByKeys,
errorMessage,
}: {
groupByKeys: string[];
errorMessage: string;
}): void => {
if (groupByKeys.length > 1) {
throw new GraphqlQueryRunnerException(
errorMessage,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
};
@@ -7,12 +7,13 @@ import { type ObjectLiteral } from 'typeorm';
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
@@ -90,7 +91,7 @@ export class GroupByWithRecordsService {
const recordsResult = await queryBuilderWithPartitionBy.getRawMany();
if (isDefined(selectedFieldsResult.relations)) {
if (!isEmpty(selectedFieldsResult.relations)) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
@@ -156,8 +157,10 @@ export class GroupByWithRecordsService {
queryBuilderForSubQuery,
);
const objectAlias = getObjectAlias(objectMetadataItemWithFieldMaps);
const recordSelectWithAlias = Object.keys(columnsToSelect)
.map((col) => `"${col}" as "${SUB_QUERY_PREFIX}${col}"`)
.map((col) => `"${objectAlias}"."${col}" as "${SUB_QUERY_PREFIX}${col}"`)
.join(', ');
const groupBySelectWithAlias = groupByDefinitions
@@ -188,6 +191,9 @@ export class GroupByWithRecordsService {
const mainQuery = mainQueryQueryBuilder
.from(`(${subQuery.getQuery()})`, 'ranked_records')
.setParameters(queryBuilderForSubQuery.expressionMap.parameters)
.where('rn <= :recordsPerGroupLimit', {
recordsPerGroupLimit: RECORDS_PER_GROUP_LIMIT,
})
.select(groupByAliases)
.addSelect(
`JSON_AGG(
@@ -92,7 +92,10 @@ export class GqlTypeGenerator {
objectMetadata,
);
this.relationConnectGqlInputTypeGenerator.buildAndStore(objectMetadata);
this.objectMetadataGqlInputTypeGenerator.buildAndStore(objectMetadata);
this.objectMetadataGqlInputTypeGenerator.buildAndStore(
objectMetadata,
dynamicObjectMetadataCollection,
);
if (
objectContainsRelationField(objectMetadata) ||
@@ -22,6 +22,8 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w
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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { pascalCase } from 'src/utils/pascal-case';
@Injectable()
@@ -29,13 +31,26 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
private readonly logger = new Logger(
ObjectMetadataGroupByGqlInputTypeGenerator.name,
);
private objectMetadataCollection: ObjectMetadataEntity[] = [];
private objectMetadataMaps: ObjectMetadataMaps | undefined;
constructor(
private readonly gqlTypesStorage: GqlTypesStorage,
private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator,
private readonly typeMapperService: TypeMapperService,
) {}
public buildAndStore(objectMetadata: ObjectMetadataEntity) {
public buildAndStore(
objectMetadata: ObjectMetadataEntity,
objectMetadataCollection?: ObjectMetadataEntity[],
) {
if (isDefined(objectMetadataCollection)) {
this.objectMetadataCollection = objectMetadataCollection;
this.objectMetadataMaps = generateObjectMetadataMaps(
objectMetadataCollection,
);
}
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.GroupBy.toString()}Input`,
description: objectMetadata.description,
@@ -61,6 +76,7 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
)
? this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldGroupByInputType(
fieldMetadata,
this.objectMetadataMaps,
)
: this.generateField(fieldMetadata);
@@ -19,7 +19,10 @@ export class ObjectMetadataGqlInputTypeGenerator {
private readonly objectMetadataGroupByGqlInputTypeGenerator: ObjectMetadataGroupByGqlInputTypeGenerator,
) {}
public buildAndStore(objectMetadata: ObjectMetadataEntity) {
public buildAndStore(
objectMetadata: ObjectMetadataEntity,
objectMetadataCollection?: ObjectMetadataEntity[],
) {
this.objectMetadataCreateGqlInputTypeGenerator.buildAndStore(
objectMetadata,
);
@@ -34,9 +37,11 @@ export class ObjectMetadataGqlInputTypeGenerator {
});
this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({
objectMetadata,
objectMetadataCollection,
});
this.objectMetadataGroupByGqlInputTypeGenerator.buildAndStore(
objectMetadata,
objectMetadataCollection,
);
}
}
@@ -18,6 +18,7 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w
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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class ObjectMetadataOrderByBaseGenerator {
@@ -31,10 +32,12 @@ export class ObjectMetadataOrderByBaseGenerator {
objectMetadata,
logger,
orderByDateGranularity,
objectMetadataMaps,
}: {
objectMetadata: ObjectMetadataEntity;
logger: Logger;
orderByDateGranularity?: boolean;
objectMetadataMaps?: ObjectMetadataMaps;
}): GraphQLInputFieldConfigMap {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
@@ -54,6 +57,7 @@ export class ObjectMetadataOrderByBaseGenerator {
{
fieldMetadata,
typeOptions,
objectMetadataMaps,
},
);
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
@@ -23,6 +23,8 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w
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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { pascalCase } from 'src/utils/pascal-case';
@Injectable()
@@ -37,11 +39,21 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
private readonly typeMapperService: TypeMapperService,
) {}
private objectMetadataMaps?: ObjectMetadataMaps;
public buildAndStore({
objectMetadata,
objectMetadataCollection,
}: {
objectMetadata: ObjectMetadataEntity;
objectMetadataCollection?: ObjectMetadataEntity[];
}) {
if (isDefined(objectMetadataCollection)) {
this.objectMetadataMaps = generateObjectMetadataMaps(
objectMetadataCollection,
);
}
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}Input`,
description: objectMetadata.description,
@@ -86,6 +98,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
{
fieldMetadata,
typeOptions,
objectMetadataMaps: this.objectMetadataMaps,
},
);
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
@@ -124,6 +137,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator {
objectMetadata,
logger: this.logger,
orderByDateGranularity: true,
objectMetadataMaps: this.objectMetadataMaps,
});
}
@@ -1,21 +1,24 @@
import { Injectable, Logger } from '@nestjs/common';
import {
GraphQLBoolean,
GraphQLInputFieldConfigMap,
isInputObjectType,
isObjectType,
} from 'graphql';
import { FieldMetadataType, RelationType } 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 {
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 { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
import { computeRelationConnectInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-relation-connect-input-type-key.util';
import { extractGraphQLRelationFieldNames } from 'src/engine/api/graphql/workspace-schema-builder/utils/extract-graphql-relation-field-names.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class RelationFieldMetadataGqlInputTypeGenerator {
@@ -115,16 +118,19 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
public generateSimpleRelationFieldOrderByInputType({
fieldMetadata,
typeOptions,
objectMetadataMaps,
}: {
fieldMetadata: FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>;
typeOptions: TypeOptions;
objectMetadataMaps?: ObjectMetadataMaps;
}) {
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY)
return {};
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
const { joinColumnName, fieldMetadataName } =
extractGraphQLRelationFieldNames(fieldMetadata);
const type = this.typeMapperService.mapToOrderByType(fieldMetadata.type);
@@ -143,32 +149,89 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
typeOptions,
);
return {
const fields: GraphQLInputFieldConfigMap = {
[joinColumnName]: {
type: modifiedType,
description: fieldMetadata.description,
},
};
if (
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
isDefined(objectMetadataMaps)
) {
const targetObjectMetadata =
objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId];
if (isDefined(targetObjectMetadata)) {
const targetOrderByInputTypeKey = computeObjectMetadataInputTypeKey(
targetObjectMetadata.nameSingular,
GqlInputTypeDefinitionKind.OrderBy,
);
const targetOrderByInputType = this.gqlTypesStorage.getGqlTypeByKey(
targetOrderByInputTypeKey,
);
if (
isDefined(targetOrderByInputType) &&
isInputObjectType(targetOrderByInputType)
) {
fields[fieldMetadataName] = {
type: targetOrderByInputType,
description: `Order by fields of the related ${targetObjectMetadata.nameSingular}`,
};
}
}
}
return fields;
}
public generateSimpleRelationFieldGroupByInputType(
fieldMetadata: FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
objectMetadataMaps?: ObjectMetadataMaps,
): GraphQLInputFieldConfigMap {
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY)
return {};
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
const { fieldMetadataName } =
extractGraphQLRelationFieldNames(fieldMetadata);
const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {});
const fields: GraphQLInputFieldConfigMap = {};
return {
[joinColumnName]: {
type,
description: fieldMetadata.description,
},
};
if (
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
isDefined(objectMetadataMaps)
) {
const targetObjectMetadata =
objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId];
if (isDefined(targetObjectMetadata)) {
const targetGroupByInputTypeKey = computeObjectMetadataInputTypeKey(
targetObjectMetadata.nameSingular,
GqlInputTypeDefinitionKind.GroupBy,
);
const targetGroupByInputType = this.gqlTypesStorage.getGqlTypeByKey(
targetGroupByInputTypeKey,
);
if (
isDefined(targetGroupByInputType) &&
isInputObjectType(targetGroupByInputType)
) {
fields[fieldMetadataName] = {
type: targetGroupByInputType,
description: `Group by fields of the related ${targetObjectMetadata.nameSingular}`,
};
}
}
}
return fields;
}
public generateConnectRelationFieldInputType({
@@ -215,7 +215,20 @@ export const validateQueryIsPermittedOrThrow = ({
return;
}
const allFieldsSelected = expressionMap.selects.some(
let expressionMapSelectsOnMainEntity = expressionMap.selects;
if (!isEmpty(expressionMap.joinAttributes)) {
const { selectsWithoutJoinedAliases } =
validatePermissionsForJoinsAndReturnSelectsWithoutJoins({
expressionMap,
objectsPermissions,
objectMetadataMaps,
});
expressionMapSelectsOnMainEntity = selectsWithoutJoinedAliases;
}
const allFieldsSelected = expressionMapSelectsOnMainEntity.some(
(select) => select.selection === mainEntity,
);
@@ -224,7 +237,8 @@ export const validateQueryIsPermittedOrThrow = ({
selectedColumns = getSelectedColumnsFromExpressionMap({
operationType,
expressionMap,
expressionMapReturning: expressionMap.returning,
expressionMapSelects: expressionMapSelectsOnMainEntity,
allFieldsSelected,
});
@@ -259,6 +273,63 @@ export const validateQueryIsPermittedOrThrow = ({
});
};
const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({
expressionMap,
objectsPermissions,
objectMetadataMaps,
}: {
expressionMap: QueryExpressionMap;
objectsPermissions: ObjectsPermissions;
objectMetadataMaps: ObjectMetadataMaps;
}) => {
const joinAttributesAliases = new Set(
expressionMap.joinAttributes.map((join) => join.alias.name),
);
const indexesOfSelectsForJoinedAlias: number[] = [];
for (const [_index, joinedAlias] of joinAttributesAliases.entries()) {
const entity = expressionMap.aliases.find(
(alias) => alias.type === 'join' && alias.name === joinedAlias,
)?.metadata;
if (isDefined(entity)) {
for (const [index, select] of expressionMap.selects.entries()) {
const regex = /"(\w+)"\."(\w+)"/;
const extractedAlias = select.selection.match(regex)?.[1]; // "person"."name" -> "person"
if (isDefined(extractedAlias) && extractedAlias === joinedAlias) {
indexesOfSelectsForJoinedAlias.push(index);
const selectedColumns = getSelectedColumnsFromExpressionMap({
operationType: 'select',
expressionMapSelects: expressionMap.selects.filter(
(_select, indexOfSelect) => indexOfSelect === index,
),
allFieldsSelected: false,
});
validateOperationIsPermittedOrThrow({
entityName: entity.name,
operationType: 'select' as OperationType,
objectsPermissions,
objectMetadataMaps,
selectedColumns,
allFieldsSelected: false,
updatedColumns: [],
});
}
}
}
}
const selectsWithoutJoinedAliases = expressionMap.selects.filter(
(_select, index) => !indexesOfSelectsForJoinedAlias.includes(index),
);
return { selectsWithoutJoinedAliases };
};
const validateReadFieldPermissionOrThrow = ({
restrictedFields,
selectedColumns,
@@ -336,12 +407,14 @@ const validateUpdateFieldPermissionOrThrow = ({
const getSelectedColumnsFromExpressionMap = ({
operationType,
expressionMap,
expressionMapReturning,
expressionMapSelects,
allFieldsSelected,
}: {
operationType: string;
expressionMap: QueryExpressionMap;
expressionMapSelects: { selection: string }[];
allFieldsSelected: boolean;
expressionMapReturning?: string | string[];
}) => {
let selectedColumns: string[] | '*' = [];
@@ -350,17 +423,16 @@ const getSelectedColumnsFromExpressionMap = ({
operationType,
)
) {
if (!isDefined(expressionMap.returning)) {
if (!isDefined(expressionMapReturning)) {
throw new InternalServerError(
'Returning columns are not set for update query',
);
}
selectedColumns =
expressionMap.returning === '*' ? '*' : [expressionMap.returning].flat();
expressionMapReturning === '*' ? '*' : [expressionMapReturning].flat();
} else if (!allFieldsSelected) {
selectedColumns = getSelectedColumnsFromExpressionMapSelects(
expressionMap.selects,
);
selectedColumns =
getSelectedColumnsFromExpressionMapSelects(expressionMapSelects);
}
return selectedColumns;
@@ -1,20 +1,43 @@
import { randomUUID } from 'crypto';
import { gql } from 'apollo-server-core';
import { default as request } from 'supertest';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util';
import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.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 { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createOneCoreViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-core-view-filter.util';
import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { OrderByDirection, ViewFilterOperand } from 'twenty-shared/types';
import {
FieldMetadataType,
OrderByDirection,
RelationType,
ViewFilterOperand,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
describe('group-by resolver (integration)', () => {
describe('standard case', () => {
@@ -660,4 +683,869 @@ describe('group-by resolver (integration)', () => {
);
});
});
describe('relation fields', () => {
describe('group by relation fields', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
const testCompanyId = randomUUID();
const testCompany2Id = randomUUID();
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
beforeAll(async () => {
// Create companies with different createdAt dates for grouping
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId,
name: 'Company A',
createdAt: '2025-01-02T12:00:00.000Z', // Thursday
address: {
addressCity: 'City A',
},
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompany2Id,
name: 'Company B',
createdAt: '2025-01-08T08:00:00.000Z', // Wednesday
address: {
addressCity: 'City B',
},
},
}),
);
// Create people linked to companies
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPersonId,
companyId: testCompanyId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson2Id,
companyId: testCompanyId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson3Id,
companyId: testCompany2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup people
for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup companies
for (const id of [testCompanyId, testCompany2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by one relation field - company createdAt with DAY_OF_THE_WEEK granularity', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
},
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const thursdayGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Thursday'),
);
const wednesdayGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday'),
);
expect(thursdayGroup).toBeDefined();
expect(wednesdayGroup).toBeDefined();
expect(thursdayGroup.totalCount).toBe(2);
expect(wednesdayGroup.totalCount).toBe(1);
});
it('groups by two relation fields from the same joined table', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
},
},
},
{
company: {
address: {
addressCity: true,
},
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const thursdayCityAGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Thursday') &&
g.groupByDimensionValues?.[1] === 'City A',
);
const wednesdayCityBGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday') &&
g.groupByDimensionValues?.[1] === 'City B',
);
expect(thursdayCityAGroup).toBeDefined();
expect(wednesdayCityBGroup).toBeDefined();
expect(thursdayCityAGroup.totalCount).toBe(2);
expect(wednesdayCityBGroup.totalCount).toBe(1);
});
});
describe('group by relation fields from different tables', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
const testCompanyId = randomUUID();
const testCompany2Id = randomUUID();
const testlistingId = randomUUID();
const testlisting2Id = randomUUID();
let listingObjectMetadataId: string;
let personlistingRelationFieldId: string;
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
beforeAll(async () => {
// Find person and company object metadata
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: 'id nameSingular',
expectToFail: false,
});
const personObject = objects.find((o) => o.nameSingular === 'person');
const companyObject = objects.find((o) => o.nameSingular === 'company');
if (!personObject?.id || !companyObject?.id) {
throw new Error('Person or Company object not found');
}
// Create listing object
const { data: createListingObjectData } = await createOneObjectMetadata(
{
input: {
nameSingular: 'listing',
namePlural: 'listings',
labelSingular: 'Listing',
labelPlural: 'Listings',
icon: 'IconPaw',
},
expectToFail: false,
},
);
listingObjectMetadataId = createListingObjectData.createOneObject.id;
// Create relation from person to listing
const personlistingRelation = await createRelationBetweenObjects({
objectMetadataId: personObject.id,
targetObjectMetadataId: listingObjectMetadataId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'assignedListing',
});
personlistingRelationFieldId = personlistingRelation.id;
// Create companies
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId,
name: 'Company A',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompany2Id,
name: 'Company B',
},
}),
);
// Create listings
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'listing',
gqlFields: 'id name',
data: {
id: testlistingId,
name: 'Flat in Paris',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'listing',
gqlFields: 'id name',
data: {
id: testlisting2Id,
name: 'House in Marseille',
},
}),
);
// Create people linked to companies and listings
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPersonId,
companyId: testCompanyId,
assignedListingId: testlistingId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson2Id,
companyId: testCompanyId,
assignedListingId: testlisting2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson3Id,
companyId: testCompanyId,
assignedListingId: testlisting2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup people
for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup listings
for (const id of [testlistingId, testlisting2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'listing',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup company
for (const id of [testCompanyId, testCompany2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup relation field
if (isDefined(personlistingRelationFieldId)) {
await updateOneFieldMetadata({
input: {
idToUpdate: personlistingRelationFieldId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: personlistingRelationFieldId },
expectToFail: false,
});
}
// Cleanup listing object
if (isDefined(listingObjectMetadataId)) {
await updateOneObjectMetadata({
input: {
idToUpdate: listingObjectMetadataId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneObjectMetadata({
input: { idToDelete: listingObjectMetadataId },
expectToFail: false,
});
}
});
it('groups by two relation fields from different tables', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
name: true,
},
},
{
assignedListing: {
name: true,
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const companyAFlatInParisGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0] === 'Company A' &&
g.groupByDimensionValues?.[1] === 'Flat in Paris',
);
const companyAHouseInMarseilleGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0] === 'Company A' &&
g.groupByDimensionValues?.[1] === 'House in Marseille',
);
expect(companyAFlatInParisGroup).toBeDefined();
expect(companyAHouseInMarseilleGroup).toBeDefined();
expect(companyAFlatInParisGroup.totalCount).toBe(1);
expect(companyAHouseInMarseilleGroup.totalCount).toBe(2);
});
});
describe('group by morph relation fields', () => {
const testPetId = randomUUID();
const testPet2Id = randomUUID();
const testPet3Id = randomUUID();
const testRocketId = randomUUID();
const testRocket2Id = randomUUID();
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
beforeAll(async () => {
// Create rockets with different names
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id name',
data: {
id: testRocketId,
name: 'Rocket A',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id name',
data: {
id: testRocket2Id,
name: 'Rocket B',
},
}),
);
// Create pets linked to rockets via morph relation
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPetId,
name: 'Pet 1',
ownerRocketId: testRocketId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPet2Id,
name: 'Pet 2',
ownerRocketId: testRocketId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPet3Id,
name: 'Pet 3',
ownerRocketId: testRocket2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup pets
for (const id of [testPetId, testPet2Id, testPet3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup rockets
for (const id of [testRocketId, testRocket2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by morph relation field - ownerRocket name', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'pet',
objectMetadataPluralName: 'pets',
groupBy: [
{
ownerRocket: {
name: true,
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.petsGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const rocketAGroup = groups.find(
(g: any) => g.groupByDimensionValues?.[0] === 'Rocket A',
);
const rocketBGroup = groups.find(
(g: any) => g.groupByDimensionValues?.[0] === 'Rocket B',
);
expect(rocketAGroup).toBeDefined();
expect(rocketBGroup).toBeDefined();
expect(rocketAGroup.totalCount).toBe(2);
expect(rocketBGroup.totalCount).toBe(1);
});
});
describe('should throw if group by a relation field user does not have reading rights on', () => {
const testPetId = randomUUID();
const testRocketId = randomUUID();
let customRoleId: string;
let petObjectId: string;
let rocketObjectId: string;
let originalMemberRoleId: string;
beforeAll(async () => {
// Get the original Member role ID for restoration later
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const rolesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
originalMemberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
).id;
// Get object metadata IDs for pet and rocket
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: 'id nameSingular',
expectToFail: false,
});
const petObject = objects.find((o) => o.nameSingular === 'pet');
const rocketObject = objects.find((o) => o.nameSingular === 'rocket');
if (!petObject?.id || !rocketObject?.id) {
throw new Error('Pet or Rocket object not found');
}
petObjectId = petObject.id;
rocketObjectId = rocketObject.id;
// Create a custom role with pet read permission but no rocket read permission
const createRoleOperation = {
query: gql`
mutation CreateOneRole {
createOneRole(
createRoleInput: {
label: "PetOnlyRole"
description: "Test role with pet read permission but no rocket read permission"
canUpdateAllSettings: false
canReadAllObjectRecords: false
canUpdateAllObjectRecords: false
canSoftDeleteAllObjectRecords: false
canDestroyAllObjectRecords: false
}
) {
id
label
}
}
`,
};
const createRoleResponse =
await makeGraphqlAPIRequest(createRoleOperation);
customRoleId = createRoleResponse.body.data.createOneRole.id;
// Set object permissions: allow reading pets but not rockets
const upsertObjectPermissionsOperation = {
query: gql`
mutation UpsertObjectPermissions(
$roleId: UUID!
$objectPermissions: [ObjectPermissionInput!]!
) {
upsertObjectPermissions(
upsertObjectPermissionsInput: {
roleId: $roleId
objectPermissions: $objectPermissions
}
) {
objectMetadataId
canReadObjectRecords
}
}
`,
variables: {
roleId: customRoleId,
objectPermissions: [
{
objectMetadataId: petObjectId,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectMetadataId: rocketObjectId,
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
},
};
await makeGraphqlAPIRequest(upsertObjectPermissionsOperation);
// Assign the custom role to a workspace member
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
// Create a rocket
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id name',
data: {
id: testRocketId,
name: 'Test Rocket',
},
}),
);
// Create a pet linked to the rocket
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPetId,
name: 'Test Pet',
ownerRocketId: testRocketId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup pet
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id',
recordId: testPetId,
}),
);
// Cleanup rocket
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id',
recordId: testRocketId,
}),
);
// // Restore original role
const restoreMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}"
roleId: "${originalMemberRoleId}"
) {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(restoreMemberRoleQuery);
// // Delete custom role
if (isDefined(customRoleId)) {
await deleteRole(client, customRoleId);
}
});
it('should throw a permission error when grouping by ownerRocket field without read permission', async () => {
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
const response = await makeGraphqlAPIRequestWithMemberRole(
groupByOperationFactory({
objectMetadataSingularName: 'pet',
objectMetadataPluralName: 'pets',
groupBy: [
{
ownerRocket: {
name: true,
},
},
],
filter: filter2025,
}),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
@@ -25,6 +25,7 @@ export type OrderByWithGroupBy = Array<
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForRelationField
| AggregateOrderByWithGroupByField
>;
@@ -35,3 +36,10 @@ export type ObjectRecordOrderByForScalarField = {
export type ObjectRecordOrderByForCompositeField = {
[Property in keyof ObjectRecord]?: Record<string, OrderByDirection>;
};
export type ObjectRecordOrderByForRelationField = {
[Property in keyof ObjectRecord]?:
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForScalarField;
};
@@ -124,6 +124,7 @@ export type {
OrderByWithGroupBy,
ObjectRecordOrderByForScalarField,
ObjectRecordOrderByForCompositeField,
ObjectRecordOrderByForRelationField,
} from './ObjectRecordGroupBy';
export { OrderByDirection } from './ObjectRecordGroupBy';
export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGranularity';