[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:
+70
-52
@@ -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,
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+60
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
+219
-7
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
+105
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
@@ -40,6 +40,7 @@ export class GraphqlQueryParser {
|
||||
);
|
||||
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
|
||||
this.objectMetadataMapItem,
|
||||
this.objectMetadataMaps,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type CompositeFieldGroupByDefinition = Record<string, boolean>;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export type DateFieldGroupByDefinition = {
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
};
|
||||
+8
@@ -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;
|
||||
+10
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+15
-5
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+30
@@ -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,
|
||||
)
|
||||
);
|
||||
};
|
||||
+4
-1
@@ -6,5 +6,8 @@ import {
|
||||
export const isGroupByDateField = (
|
||||
groupByField: GroupByField,
|
||||
): groupByField is GroupByDateField => {
|
||||
return 'dateGranularity' in groupByField;
|
||||
return (
|
||||
'dateGranularity' in groupByField &&
|
||||
!('nestedFieldMetadata' in groupByField)
|
||||
);
|
||||
};
|
||||
|
||||
+10
@@ -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;
|
||||
};
|
||||
+38
-48
@@ -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] ===
|
||||
|
||||
+190
@@ -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;
|
||||
}
|
||||
};
|
||||
+7
-1
@@ -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, '_');
|
||||
};
|
||||
|
||||
+19
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
+9
-3
@@ -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(
|
||||
|
||||
+4
-1
@@ -92,7 +92,10 @@ export class GqlTypeGenerator {
|
||||
objectMetadata,
|
||||
);
|
||||
this.relationConnectGqlInputTypeGenerator.buildAndStore(objectMetadata);
|
||||
this.objectMetadataGqlInputTypeGenerator.buildAndStore(objectMetadata);
|
||||
this.objectMetadataGqlInputTypeGenerator.buildAndStore(
|
||||
objectMetadata,
|
||||
dynamicObjectMetadataCollection,
|
||||
);
|
||||
|
||||
if (
|
||||
objectContainsRelationField(objectMetadata) ||
|
||||
|
||||
+17
-1
@@ -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);
|
||||
|
||||
|
||||
+6
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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)) {
|
||||
|
||||
+14
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+74
-11
@@ -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;
|
||||
|
||||
+889
-1
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user