[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
@@ -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({