Common api - Group by query (#15108)

closes https://github.com/twentyhq/core-team-issues/issues/1626
This commit is contained in:
Etienne
2025-10-20 17:51:01 +02:00
committed by GitHub
parent 5186e73ce1
commit dea08a3a41
49 changed files with 1223 additions and 254 deletions
@@ -20,7 +20,9 @@ import {
CommonQueryNames, CommonQueryNames,
CreateManyQueryArgs, CreateManyQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type'; } from 'src/engine/api/common/types/common-query-args.type';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
@@ -37,7 +39,7 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService { export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService {
async run({ async run({
args, args,
authContext: toValidateAuthContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
}: { }: {
@@ -46,8 +48,6 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<ObjectRecord[]> { }): Promise<ObjectRecord[]> {
const authContext = toValidateAuthContext;
if (!isWorkspaceAuthContext(authContext)) { if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException( throw new CommonQueryRunnerException(
'Invalid auth context', 'Invalid auth context',
@@ -63,6 +63,18 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
} }
}); });
//TODO : Refacto-common - QueryParser should be common branded service
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
const selectedFieldsResult = commonQueryParser.parseSelectedFields(
objectMetadataItemWithFieldMaps,
args.selectedFields,
objectMetadataMaps,
);
const { workspaceDataSource, repository, rolePermissionConfig } = const { workspaceDataSource, repository, rolePermissionConfig } =
await this.prepareQueryRunnerContext({ await this.prepareQueryRunnerContext({
authContext, authContext,
@@ -80,24 +92,25 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
args: processedArgs, args: processedArgs,
selectedFieldsResult,
}); });
const upsertedRecords = await this.fetchUpsertedRecords({ const upsertedRecords = await this.fetchUpsertedRecords({
args: processedArgs,
objectRecords, objectRecords,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
repository, repository,
selectedFieldsResult,
}); });
await this.processNestedRelationsIfNeeded({ await this.processNestedRelationsIfNeeded({
args: processedArgs,
records: upsertedRecords, records: upsertedRecords,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
rolePermissionConfig, rolePermissionConfig,
authContext, authContext,
workspaceDataSource, workspaceDataSource,
selectedFieldsResult,
}); });
return upsertedRecords; return upsertedRecords;
@@ -136,16 +149,18 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
args, args,
selectedFieldsResult,
}: { }: {
repository: WorkspaceRepository<ObjectLiteral>; repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
args: CreateManyQueryArgs; args: CreateManyQueryArgs;
selectedFieldsResult: CommonSelectedFieldsResult;
}): Promise<InsertResult> { }): Promise<InsertResult> {
if (!args.upsert) { if (!args.upsert) {
const selectedColumns = buildColumnsToReturn({ const selectedColumns = buildColumnsToReturn({
select: args.selectedFieldsResult.select, select: selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations, relations: selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
}); });
@@ -158,6 +173,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
args, args,
selectedFieldsResult,
}); });
} }
@@ -166,11 +182,13 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
args, args,
selectedFieldsResult,
}: { }: {
repository: WorkspaceRepository<ObjectLiteral>; repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
args: CreateManyQueryArgs; args: CreateManyQueryArgs;
selectedFieldsResult: CommonSelectedFieldsResult;
}): Promise<InsertResult> { }): Promise<InsertResult> {
const conflictingFields = getConflictingFields( const conflictingFields = getConflictingFields(
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -195,8 +213,8 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
}; };
const columnsToReturn = buildColumnsToReturn({ const columnsToReturn = buildColumnsToReturn({
select: args.selectedFieldsResult.select, select: selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations, relations: selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
}); });
@@ -325,25 +343,25 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
} }
private async fetchUpsertedRecords({ private async fetchUpsertedRecords({
args,
objectRecords, objectRecords,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
repository, repository,
selectedFieldsResult,
}: { }: {
args: CreateManyQueryArgs;
objectRecords: InsertResult; objectRecords: InsertResult;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
repository: WorkspaceRepository<ObjectLiteral>; repository: WorkspaceRepository<ObjectLiteral>;
selectedFieldsResult: CommonSelectedFieldsResult;
}): Promise<ObjectRecord[]> { }): Promise<ObjectRecord[]> {
const queryBuilder = repository.createQueryBuilder( const queryBuilder = repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular, objectMetadataItemWithFieldMaps.nameSingular,
); );
const columnsToSelect = buildColumnsToSelect({ const columnsToSelect = buildColumnsToSelect({
select: args.selectedFieldsResult.select, select: selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations, relations: selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
}); });
@@ -363,23 +381,23 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
} }
private async processNestedRelationsIfNeeded({ private async processNestedRelationsIfNeeded({
args,
records, records,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
rolePermissionConfig, rolePermissionConfig,
authContext, authContext,
workspaceDataSource, workspaceDataSource,
selectedFieldsResult,
}: { }: {
args: CreateManyQueryArgs;
records: ObjectRecord[]; records: ObjectRecord[];
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
rolePermissionConfig?: RolePermissionConfig;
authContext: AuthContext; authContext: AuthContext;
workspaceDataSource: WorkspaceDataSource; workspaceDataSource: WorkspaceDataSource;
rolePermissionConfig?: RolePermissionConfig;
selectedFieldsResult: CommonSelectedFieldsResult;
}): Promise<void> { }): Promise<void> {
if (!args.selectedFieldsResult.relations) { if (!selectedFieldsResult.relations) {
return; return;
} }
@@ -388,7 +406,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
parentObjectMetadataItem: objectMetadataItemWithFieldMaps, parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: records, parentObjectRecords: records,
//TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common //TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common
relations: args.selectedFieldsResult.relations as Record< relations: selectedFieldsResult.relations as Record<
string, string,
FindOptionsRelations<ObjectLiteral> FindOptionsRelations<ObjectLiteral>
>, >,
@@ -396,7 +414,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext, authContext,
workspaceDataSource, workspaceDataSource,
rolePermissionConfig, rolePermissionConfig,
selectedFields: args.selectedFieldsResult.select, selectedFields: selectedFieldsResult.select,
}); });
} }
@@ -21,6 +21,7 @@ import {
CommonQueryNames, CommonQueryNames,
FindManyQueryArgs, FindManyQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type'; } from 'src/engine/api/common/types/common-query-args.type';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { getPageInfo } from 'src/engine/api/common/utils/get-page-info.util'; import { getPageInfo } from 'src/engine/api/common/utils/get-page-info.util';
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
@@ -36,7 +37,7 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met
export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerService { export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerService {
async run({ async run({
args, args,
authContext: toValidateAuthContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
}: { }: {
@@ -49,9 +50,9 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
aggregatedValues: Record<string, number>; aggregatedValues: Record<string, number>;
totalCount: number; totalCount: number;
pageInfo: CommonPageInfo; pageInfo: CommonPageInfo;
selectedFieldsResult: CommonSelectedFieldsResult;
}> { }> {
this.validate(args); this.validate(args);
const authContext = toValidateAuthContext;
if (!isWorkspaceAuthContext(authContext)) { if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException( throw new CommonQueryRunnerException(
@@ -66,6 +67,17 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
}); });
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
const selectedFieldsResult = commonQueryParser.parseSelectedFields(
objectMetadataItemWithFieldMaps,
args.selectedFields,
objectMetadataMaps,
);
const processedArgs = await this.processQueryArgs({ const processedArgs = await this.processQueryArgs({
authContext, authContext,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -80,11 +92,6 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
let appliedFilters = processedArgs.filter ?? ({} as ObjectRecordFilter); let appliedFilters = processedArgs.filter ?? ({} as ObjectRecordFilter);
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
commonQueryParser.applyFilterToBuilder( commonQueryParser.applyFilterToBuilder(
aggregateQueryBuilder, aggregateQueryBuilder,
objectMetadataItemWithFieldMaps.nameSingular, objectMetadataItemWithFieldMaps.nameSingular,
@@ -136,7 +143,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters); commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: processedArgs.selectedFieldsResult.aggregate, selectedAggregatedFields: selectedFieldsResult.aggregate,
queryBuilder: aggregateQueryBuilder, queryBuilder: aggregateQueryBuilder,
objectMetadataNameSingular: objectMetadataItemWithFieldMaps.nameSingular, objectMetadataNameSingular: objectMetadataItemWithFieldMaps.nameSingular,
}); });
@@ -145,8 +152,8 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
processedArgs.first ?? processedArgs.last ?? QUERY_MAX_RECORDS; processedArgs.first ?? processedArgs.last ?? QUERY_MAX_RECORDS;
const columnsToSelect = buildColumnsToSelect({ const columnsToSelect = buildColumnsToSelect({
select: processedArgs.selectedFieldsResult.select, select: selectedFieldsResult.select,
relations: processedArgs.selectedFieldsResult.relations, relations: selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
}); });
@@ -176,23 +183,23 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
const parentObjectRecordsAggregatedValues = const parentObjectRecordsAggregatedValues =
await aggregateQueryBuilder.getRawOne(); await aggregateQueryBuilder.getRawOne();
if (processedArgs.selectedFieldsResult.relations) { if (selectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({ await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps, objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps, parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: objectRecords, parentObjectRecords: objectRecords,
parentObjectRecordsAggregatedValues, parentObjectRecordsAggregatedValues,
//TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common //TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common
relations: processedArgs.selectedFieldsResult.relations as Record< relations: selectedFieldsResult.relations as Record<
string, string,
FindOptionsRelations<ObjectLiteral> FindOptionsRelations<ObjectLiteral>
>, >,
aggregate: processedArgs.selectedFieldsResult.aggregate, aggregate: selectedFieldsResult.aggregate,
limit: QUERY_MAX_RECORDS, limit: QUERY_MAX_RECORDS,
authContext, authContext,
workspaceDataSource, workspaceDataSource,
rolePermissionConfig, rolePermissionConfig,
selectedFields: processedArgs.selectedFieldsResult.select, selectedFields: selectedFieldsResult.select,
}); });
} }
@@ -209,6 +216,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
aggregatedValues: parentObjectRecordsAggregatedValues, aggregatedValues: parentObjectRecordsAggregatedValues,
totalCount: parentObjectRecordsAggregatedValues?.totalCount, totalCount: parentObjectRecordsAggregatedValues?.totalCount,
pageInfo, pageInfo,
selectedFieldsResult,
}; };
} }
@@ -28,7 +28,7 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met
export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService { export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService {
async run({ async run({
args, args,
authContext: toValidateAuthContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
}: { }: {
@@ -37,8 +37,6 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<ObjectRecord> { }): Promise<ObjectRecord> {
const authContext = toValidateAuthContext;
if (!isWorkspaceAuthContext(authContext)) { if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException( throw new CommonQueryRunnerException(
'Invalid auth context', 'Invalid auth context',
@@ -52,6 +50,18 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
}); });
//TODO : Refacto-common - QueryParser should be common branded service
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
const selectedFieldsResult = commonQueryParser.parseSelectedFields(
objectMetadataItemWithFieldMaps,
args.selectedFields,
objectMetadataMaps,
);
const processedArgs = await this.processQueryArgs({ const processedArgs = await this.processQueryArgs({
authContext, authContext,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -72,12 +82,6 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
objectMetadataItemWithFieldMaps.nameSingular, objectMetadataItemWithFieldMaps.nameSingular,
); );
//TODO : Refacto-common - QueryParser should be common branded service
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
commonQueryParser.applyFilterToBuilder( commonQueryParser.applyFilterToBuilder(
queryBuilder, queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular, objectMetadataItemWithFieldMaps.nameSingular,
@@ -90,8 +94,8 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
); );
const columnsToSelect = buildColumnsToSelect({ const columnsToSelect = buildColumnsToSelect({
select: args.selectedFieldsResult.select, select: selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations, relations: selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
}); });
@@ -111,13 +115,13 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
const objectRecords = [objectRecord] as ObjectRecord[]; const objectRecords = [objectRecord] as ObjectRecord[];
if (isDefined(args.selectedFieldsResult.relations)) { if (isDefined(selectedFieldsResult.relations)) {
await this.processNestedRelationsHelper.processNestedRelations({ await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps, objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps, parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: objectRecords, parentObjectRecords: objectRecords,
//TODO : Refacto-common - To fix when switching processNestedRelationsHelper to Common //TODO : Refacto-common - To fix when switching processNestedRelationsHelper to Common
relations: args.selectedFieldsResult.relations as Record< relations: selectedFieldsResult.relations as Record<
string, string,
FindOptionsRelations<ObjectLiteral> FindOptionsRelations<ObjectLiteral>
>, >,
@@ -125,7 +129,7 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
authContext, authContext,
workspaceDataSource, workspaceDataSource,
rolePermissionConfig, rolePermissionConfig,
selectedFields: args.selectedFieldsResult.select, selectedFields: selectedFieldsResult.select,
}); });
} }
@@ -0,0 +1,327 @@
import { Injectable } from '@nestjs/common';
import {
CompositeFieldSubFieldName,
PartialFieldMetadataItemOption,
RecordFilterGroupLogicalOperator,
} from 'twenty-shared/types';
import {
assertIsDefinedOrThrow,
combineFilters,
computeRecordGqlOperationFilter,
convertViewFilterValueToString,
getFilterTypeFromFieldType,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
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 { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import {
CommonQueryNames,
GroupByQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util';
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 { 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 { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@Injectable()
export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerService {
constructor(
private readonly viewFilterService: ViewFilterService,
private readonly viewFilterGroupService: ViewFilterGroupService,
private readonly viewService: ViewService,
) {
super();
}
async run({
args,
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
}: {
args: GroupByQueryArgs;
authContext: AuthContext;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<CommonGroupByOutputItem[]> {
if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
);
}
const { repository } = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
//TODO : Refacto-common - QueryParser should be common branded service
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
const selectedFieldsResult = commonQueryParser.parseSelectedFields(
objectMetadataItemWithFieldMaps,
args.selectedFields,
objectMetadataMaps,
);
const processedArgs = await this.processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
});
const objectMetadataNameSingular =
objectMetadataItemWithFieldMaps.nameSingular;
let queryBuilder = repository.createQueryBuilder(
objectMetadataNameSingular,
);
let appliedFilters = args.filter ?? ({} as ObjectRecordFilter);
if (args.viewId) {
appliedFilters = await this.addFiltersFromView({
args: processedArgs,
authContext,
objectMetadataItemWithFieldMaps,
appliedFilters,
});
}
commonQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataNameSingular,
appliedFilters,
);
commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: selectedFieldsResult.aggregate,
queryBuilder,
objectMetadataNameSingular,
});
const groupByFields = parseGroupByArgs(
processedArgs,
objectMetadataItemWithFieldMaps,
);
const groupByDefinitions = groupByFields.map((groupByField) => {
const columnNameWithQuotes = `"${
formatColumnNamesFromCompositeFieldAndSubfields(
groupByField.fieldMetadata.name,
groupByField.subFieldName ? [groupByField.subFieldName] : undefined,
)[0]
}"`;
const alias =
removeQuotes(columnNameWithQuotes) +
(isGroupByDateField(groupByField)
? `_${groupByField.dateGranularity}`
: '');
return {
columnNameWithQuotes,
expression: getGroupByExpression({
groupByField,
columnNameWithQuotes,
}),
alias,
dateGranularity: isGroupByDateField(groupByField)
? groupByField.dateGranularity
: undefined,
};
});
groupByDefinitions.forEach((groupByColumn, index) => {
queryBuilder.addSelect(groupByColumn.expression, groupByColumn.alias);
if (index === 0) {
queryBuilder.groupBy(groupByColumn.expression);
} else {
queryBuilder.addGroupBy(groupByColumn.expression);
}
});
if (processedArgs.omitNullValues) {
const aggregateFields = selectedFieldsResult.aggregate ?? {};
Object.values(aggregateFields).forEach((aggregationField) => {
const aggregateExpression =
ProcessAggregateHelper.getAggregateExpression(
aggregationField,
objectMetadataNameSingular,
);
if (aggregateExpression) {
queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`);
const isNumericReturningAggregate =
computeIsNumericReturningAggregate(
aggregationField.aggregateOperation,
aggregationField.fromFieldType,
);
if (isNumericReturningAggregate) {
queryBuilder.andHaving(`${aggregateExpression} != 0`);
}
}
});
}
commonQueryParser.applyGroupByOrderToBuilder(
queryBuilder,
processedArgs.orderBy ?? [],
groupByFields,
);
const result = await queryBuilder.getRawMany();
return formatResultWithGroupByDimensionValues(
result,
groupByDefinitions,
Object.keys(selectedFieldsResult.aggregate),
);
}
private async addFiltersFromView({
args,
objectMetadataItemWithFieldMaps,
appliedFilters,
authContext,
}: {
args: GroupByQueryArgs;
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
appliedFilters: ObjectRecordFilter;
}): Promise<ObjectRecordFilter> {
assertIsDefinedOrThrow(args.viewId);
const workspaceId = authContext.workspace.id;
const viewFilters = await this.viewFilterService.findByViewId(
workspaceId,
args.viewId,
);
const viewFilterGroups = await this.viewFilterGroupService.findByViewId(
workspaceId,
args.viewId,
);
const recordFilters = viewFilters.map((viewFilter) => {
const fieldMetadataItem =
objectMetadataItemWithFieldMaps.fieldsById[viewFilter.fieldMetadataId];
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
value: convertViewFilterValueToString(viewFilter.value),
type: getFilterTypeFromFieldType(fieldMetadataItem.type),
operand: viewFilter.operand,
recordFilterGroupId: viewFilter.viewFilterGroupId,
positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
};
});
const recordFilterGroups = viewFilterGroups.map((viewFilterGroup) => {
return {
id: viewFilterGroup.id,
logicalOperator:
viewFilterGroup.logicalOperator as unknown as RecordFilterGroupLogicalOperator, // TODO - https://github.com/twentyhq/twenty/issues/14746
parentRecordFilterGroupId: viewFilterGroup.parentViewFilterGroupId,
};
});
const fields = Object.values(
objectMetadataItemWithFieldMaps.fieldsById,
).map((field) => ({
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options as PartialFieldMetadataItemOption[],
}));
const filtersFromView = computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups: recordFilterGroups,
fields,
filterValueDependencies: {},
});
let view: ViewEntity | null = viewFilters[0]?.view;
if (!view) {
view = await this.viewService.findById(args.viewId, workspaceId);
}
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
fields,
filterValue: view?.anyFieldFilterValue ?? '',
});
appliedFilters = combineFilters([
appliedFilters,
filtersFromView,
anyFieldFilter,
]);
return appliedFilters;
}
async processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
}: {
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
args: GroupByQueryArgs;
}): Promise<GroupByQueryArgs> {
const hookedArgs =
(await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
CommonQueryNames.groupBy,
args,
//TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency
)) as GroupByQueryArgs;
return {
...hookedArgs,
filter: this.queryRunnerArgsFactory.overrideFilterByFieldMetadata(
hookedArgs.filter,
objectMetadataItemWithFieldMaps,
),
};
}
}
@@ -2,10 +2,12 @@ import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
export const CommonQueryRunners = [ export const CommonQueryRunners = [
CommonFindOneQueryRunnerService, CommonFindOneQueryRunnerService,
CommonFindManyQueryRunnerService, CommonFindManyQueryRunnerService,
CommonCreateOneQueryRunnerService, CommonCreateOneQueryRunnerService,
CommonCreateManyQueryRunnerService, CommonCreateManyQueryRunnerService,
CommonGroupByQueryRunnerService,
]; ];
@@ -14,6 +14,9 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewFilterGroupModule } from 'src/engine/metadata-modules/view-filter-group/view-filter-group.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@Module({ @Module({
@@ -26,6 +29,9 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
ApiKeyModule, ApiKeyModule,
WorkspacePermissionsCacheModule, WorkspacePermissionsCacheModule,
FileModule, FileModule,
ViewModule,
ViewFilterModule,
ViewFilterGroupModule,
], ],
providers: [ providers: [
ProcessNestedRelationsHelper, ProcessNestedRelationsHelper,
@@ -0,0 +1,9 @@
type AggregateValues = {
[key: string]: string;
};
type GroupByDimensionValues = {
groupByDimensionValues: string[];
};
export type CommonGroupByOutputItem = AggregateValues & GroupByDimensionValues;
@@ -1,25 +1,30 @@
import { type ObjectRecord } from 'twenty-shared/types'; import {
type ObjectRecord,
type OrderByWithGroupBy,
} from 'twenty-shared/types';
import { import {
type ObjectRecordFilter, type ObjectRecordFilter,
type ObjectRecordGroupBy,
type ObjectRecordOrderBy, type ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
export enum CommonQueryNames { export enum CommonQueryNames {
findOne = 'findOne', findOne = 'findOne',
findMany = 'findMany', findMany = 'findMany',
createMany = 'createMany', createMany = 'createMany',
groupBy = 'groupBy',
} }
export interface FindOneQueryArgs { export interface FindOneQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult; selectedFields: CommonSelectedFields;
filter?: ObjectRecordFilter; filter?: ObjectRecordFilter;
} }
export interface FindManyQueryArgs { export interface FindManyQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult; selectedFields: CommonSelectedFields;
filter?: ObjectRecordFilter; filter?: ObjectRecordFilter;
orderBy?: ObjectRecordOrderBy; orderBy?: ObjectRecordOrderBy;
first?: number; first?: number;
@@ -29,13 +34,26 @@ export interface FindManyQueryArgs {
} }
export interface CreateManyQueryArgs { export interface CreateManyQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult; selectedFields: CommonSelectedFields;
data: Partial<ObjectRecord>[]; data: Partial<ObjectRecord>[];
upsert?: boolean; upsert?: boolean;
} }
export interface CreateOneQueryArgs { export interface CreateOneQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult; selectedFields: CommonSelectedFields;
data: Partial<ObjectRecord>; data: Partial<ObjectRecord>;
upsert?: boolean; upsert?: boolean;
} }
export interface GroupByQueryArgs {
selectedFields: CommonSelectedFields;
filter?: ObjectRecordFilter;
orderBy?: OrderByWithGroupBy;
groupBy: ObjectRecordGroupBy;
omitNullValues?: boolean;
viewId?: string;
}
export type CommonQueryArgs =
| FindOneQueryArgs
| FindManyQueryArgs
| GroupByQueryArgs;
@@ -1,12 +1,12 @@
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
interface SelectedFields { export interface CommonSelectedFields {
[key: string]: boolean | SelectedFields; [key: string]: boolean | CommonSelectedFields;
} }
export type CommonSelectedFieldsResult = { export type CommonSelectedFieldsResult = {
select: SelectedFields; select: CommonSelectedFields;
relations: SelectedFields; relations: CommonSelectedFields;
//TODO = Refacto-common - to update when rest api will handle aggregates //TODO = Refacto-common - to update when rest api will handle aggregates
aggregate: Record<string, AggregationField>; aggregate: Record<string, AggregationField>;
}; };
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { import {
CompositeFieldSubFieldName, CompositeFieldSubFieldName,
FieldMetadataType,
ObjectRecord, ObjectRecord,
PartialFieldMetadataItemOption, PartialFieldMetadataItemOption,
RecordFilterGroupLogicalOperator, RecordFilterGroupLogicalOperator,
@@ -26,7 +25,7 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; 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 { 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 { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
@@ -152,10 +151,11 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
if (aggregateExpression) { if (aggregateExpression) {
queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`); queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`);
const isNumericReturningAggregate = this.isNumericReturningAggregate( const isNumericReturningAggregate =
aggregationField.aggregateOperation, computeIsNumericReturningAggregate(
aggregationField.fromFieldType, aggregationField.aggregateOperation,
); aggregationField.fromFieldType,
);
if (isNumericReturningAggregate) { if (isNumericReturningAggregate) {
queryBuilder.andHaving(`${aggregateExpression} != 0`); queryBuilder.andHaving(`${aggregateExpression} != 0`);
@@ -172,7 +172,11 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
const result = await queryBuilder.getRawMany(); const result = await queryBuilder.getRawMany();
return formatResultWithGroupByDimensionValues(result, groupByDefinitions); return formatResultWithGroupByDimensionValues(
result,
groupByDefinitions,
Object.keys(executionArgs.graphqlQuerySelectedFieldsResult.aggregate),
);
} }
private async addFiltersFromView({ private async addFiltersFromView({
@@ -266,39 +270,6 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
return appliedFilters; return appliedFilters;
} }
private isNumericReturningAggregate(
operation: AggregateOperations,
fromFieldType: FieldMetadataType,
): boolean {
if (
operation === AggregateOperations.COUNT ||
operation === AggregateOperations.COUNT_UNIQUE_VALUES ||
operation === AggregateOperations.COUNT_EMPTY ||
operation === AggregateOperations.COUNT_NOT_EMPTY ||
operation === AggregateOperations.COUNT_TRUE ||
operation === AggregateOperations.COUNT_FALSE ||
operation === AggregateOperations.PERCENTAGE_EMPTY ||
operation === AggregateOperations.PERCENTAGE_NOT_EMPTY
) {
return true;
}
if (
operation === AggregateOperations.MIN ||
operation === AggregateOperations.MAX ||
operation === AggregateOperations.AVG ||
operation === AggregateOperations.SUM
) {
return [
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.CURRENCY,
].includes(fromFieldType);
}
return false;
}
async validate( async validate(
_args: GroupByResolverArgs<ObjectRecordFilter>, _args: GroupByResolverArgs<ObjectRecordFilter>,
_options: WorkspaceQueryRunnerOptions, _options: WorkspaceQueryRunnerOptions,
@@ -0,0 +1,36 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
export const computeIsNumericReturningAggregate = (
operation: AggregateOperations,
fromFieldType: FieldMetadataType,
): boolean => {
if (
operation === AggregateOperations.COUNT ||
operation === AggregateOperations.COUNT_UNIQUE_VALUES ||
operation === AggregateOperations.COUNT_EMPTY ||
operation === AggregateOperations.COUNT_NOT_EMPTY ||
operation === AggregateOperations.COUNT_TRUE ||
operation === AggregateOperations.COUNT_FALSE ||
operation === AggregateOperations.PERCENTAGE_EMPTY ||
operation === AggregateOperations.PERCENTAGE_NOT_EMPTY
) {
return true;
}
if (
operation === AggregateOperations.MIN ||
operation === AggregateOperations.MAX ||
operation === AggregateOperations.AVG ||
operation === AggregateOperations.SUM
) {
return [
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.CURRENCY,
].includes(fromFieldType);
}
return false;
};
@@ -7,57 +7,60 @@ import {
import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface'; import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface';
import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util'; import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
export const formatResultWithGroupByDimensionValues = ( export const formatResultWithGroupByDimensionValues = <
// eslint-disable-next-line @typescript-eslint/no-explicit-any T extends
result: any[], | IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>
| CommonGroupByOutputItem,
>(
result: Record<string, string>[],
groupByColumnsWithQuotes: { groupByColumnsWithQuotes: {
columnNameWithQuotes: string; columnNameWithQuotes: string;
alias: string; alias: string;
dateGranularity?: ObjectRecordGroupByDateGranularity; dateGranularity?: ObjectRecordGroupByDateGranularity;
}[], }[],
): IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>[] => { aggregateFieldNames: string[],
let formattedResult: IGroupByConnection<ObjectRecord, IEdge<ObjectRecord>>[] = ): T[] => {
[]; let formattedResult: T[] = [];
result.forEach((group) => { result.forEach((group) => {
let dimensionValues = []; let dimensionValues: string[] = [];
for (const groupByColumn of groupByColumnsWithQuotes) { for (const groupByColumn of groupByColumnsWithQuotes) {
dimensionValues.push(group[groupByColumn.alias]); dimensionValues.push(
getTranslatedValueIfApplicable(
group[groupByColumn.alias],
groupByColumn.dateGranularity,
),
);
} }
const groupWithValueMappedToUnaliasedColumn = {
...group,
...groupByColumnsWithQuotes.reduce<Record<string, unknown>>(
(acc, groupByColumn) => {
const value = group[groupByColumn.alias];
acc[removeQuotes(groupByColumn.columnNameWithQuotes)] = const aggregateValues = aggregateFieldNames.reduce(
getTranslatedValueIfApplicable( (acc, fieldName) => {
value, if (fieldName in group) {
groupByColumn.dateGranularity, acc[fieldName] = group[fieldName];
); }
return acc; return acc;
}, },
{}, {} as Record<string, string>,
), );
};
formattedResult.push({ formattedResult.push({
groupByDimensionValues: dimensionValues, groupByDimensionValues: dimensionValues,
...groupWithValueMappedToUnaliasedColumn, ...aggregateValues,
}); //TODO: Refacto-common - remove generic type
} as T);
}); });
return formattedResult; return formattedResult;
}; };
const getTranslatedValueIfApplicable = ( const getTranslatedValueIfApplicable = (
value: unknown, value: string,
dateGranularity?: ObjectRecordGroupByDateGranularity, dateGranularity?: ObjectRecordGroupByDateGranularity,
) => { ): string => {
switch (dateGranularity) { switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK: case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
switch (value) { switch (value) {
@@ -11,6 +11,7 @@ import {
type FindDuplicatesResolverArgs, type FindDuplicatesResolverArgs,
type FindManyResolverArgs, type FindManyResolverArgs,
type FindOneResolverArgs, type FindOneResolverArgs,
GroupByResolverArgs,
type MergeManyResolverArgs, type MergeManyResolverArgs,
type ResolverArgs, type ResolverArgs,
ResolverArgsType, ResolverArgsType,
@@ -108,7 +109,6 @@ export class QueryRunnerArgsFactory {
options.objectMetadataItemWithFieldMaps, options.objectMetadataItemWithFieldMaps,
), ),
}; };
case ResolverArgsType.FindDuplicates: case ResolverArgsType.FindDuplicates:
return { return {
...args, ...args,
@@ -146,6 +146,14 @@ export class QueryRunnerArgsFactory {
.conflictPriorityIndex, .conflictPriorityIndex,
dryRun: (args as MergeManyResolverArgs).dryRun, dryRun: (args as MergeManyResolverArgs).dryRun,
} satisfies MergeManyResolverArgs; } satisfies MergeManyResolverArgs;
case ResolverArgsType.GroupBy:
return {
...args,
filter: this.overrideFilterByFieldMetadata(
(args as GroupByResolverArgs).filter,
options.objectMetadataItemWithFieldMaps,
),
};
default: default:
return args; return args;
} }
@@ -8,6 +8,7 @@ import {
type FindDuplicatesResolverArgs, type FindDuplicatesResolverArgs,
type FindManyResolverArgs, type FindManyResolverArgs,
type FindOneResolverArgs, type FindOneResolverArgs,
type GroupByResolverArgs,
type MergeManyResolverArgs, type MergeManyResolverArgs,
type RestoreManyResolverArgs, type RestoreManyResolverArgs,
type UpdateManyResolverArgs, type UpdateManyResolverArgs,
@@ -45,4 +46,6 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? DestroyOneResolverArgs ? DestroyOneResolverArgs
: T extends 'mergeMany' : T extends 'mergeMany'
? MergeManyResolverArgs ? MergeManyResolverArgs
: never; : T extends 'groupBy'
? GroupByResolverArgs
: never;
@@ -12,7 +12,6 @@ import {
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -46,20 +45,11 @@ export class CreateManyResolverFactory
const featureFlagsMap = workspaceDataSource.featureFlagMap; const featureFlagsMap = workspaceDataSource.featureFlagMap;
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) { if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
const graphqlQueryParser = new GraphqlQueryParser( const selectedFields = graphqlFields(info);
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
try { try {
const records = await this.commonCreateManyQueryRunnerService.run({ const records = await this.commonCreateManyQueryRunnerService.run({
args: { ...args, selectedFieldsResult }, args: { ...args, selectedFields },
authContext: internalContext.authContext, authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps, objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadataItemWithFieldMaps:
@@ -11,7 +11,6 @@ import {
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service'; import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -43,20 +42,11 @@ export class CreateOneResolverFactory
); );
if (isCommonApiEnabled) { if (isCommonApiEnabled) {
const graphqlQueryParser = new GraphqlQueryParser( const selectedFields = graphqlFields(info);
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
try { try {
const record = await this.commonCreateOneQueryRunnerService.run({ const record = await this.commonCreateOneQueryRunnerService.run({
args: { ...args, selectedFieldsResult }, args: { ...args, selectedFields },
authContext: internalContext.authContext, authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps, objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadataItemWithFieldMaps:
@@ -11,7 +11,6 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service'; import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -43,26 +42,22 @@ export class FindManyResolverFactory
); );
if (isCommonApiEnabled) { if (isCommonApiEnabled) {
const graphqlQueryParser = new GraphqlQueryParser( const selectedFields = graphqlFields(info);
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
try { try {
const { records, aggregatedValues, totalCount, pageInfo } = const {
await this.commonFindManyQueryRunnerService.run({ records,
args: { ...args, selectedFieldsResult }, aggregatedValues,
authContext: internalContext.authContext, totalCount,
objectMetadataMaps: internalContext.objectMetadataMaps, pageInfo,
objectMetadataItemWithFieldMaps: selectedFieldsResult,
internalContext.objectMetadataItemWithFieldMaps, } = await this.commonFindManyQueryRunnerService.run({
}); args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
const typeORMObjectRecordsParser = const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper( new ObjectRecordsToGraphqlConnectionHelper(
@@ -10,7 +10,6 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
@@ -43,19 +42,10 @@ export class FindOneResolverFactory
if (isCommonApiEnabled) { if (isCommonApiEnabled) {
try { try {
const graphqlQueryParser = new GraphqlQueryParser( const selectedFields = graphqlFields(info);
internalContext.objectMetadataItemWithFieldMaps,
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
const record = await this.commonFindOneQueryRunnerService.run({ const record = await this.commonFindOneQueryRunnerService.run({
args: { ...args, selectedFieldsResult }, args: { ...args, selectedFields },
authContext: internalContext.authContext, authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps, objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadataItemWithFieldMaps:
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { import {
@@ -8,8 +10,12 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service'; import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable() @Injectable()
export class GroupByResolverFactory export class GroupByResolverFactory
@@ -19,6 +25,8 @@ export class GroupByResolverFactory
constructor( constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryGroupByResolverService, private readonly graphqlQueryRunnerService: GraphqlQueryGroupByResolverService,
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
private readonly featureFlagService: FeatureFlagService,
) {} ) {}
create( create(
@@ -27,6 +35,27 @@ export class GroupByResolverFactory
const internalContext = context; const internalContext = context;
return async (_source, args, _context, info) => { return async (_source, args, _context, info) => {
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
if (isCommonApiEnabled) {
const selectedFields = graphqlFields(info);
try {
return await this.commonGroupByQueryRunnerService.run({
args: { ...args, selectedFields },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
} catch (error) {
return workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
const options: WorkspaceQueryRunnerOptions = { const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext, authContext: internalContext.authContext,
info, info,
@@ -29,6 +29,7 @@ export enum ResolverArgsType {
DestroyMany = 'DestroyMany', DestroyMany = 'DestroyMany',
DestroyOne = 'DestroyOne', DestroyOne = 'DestroyOne',
MergeMany = 'MergeMany', MergeMany = 'MergeMany',
GroupBy = 'GroupBy',
} }
export interface FindManyResolverArgs< export interface FindManyResolverArgs<
@@ -66,6 +66,20 @@ export class RestApiCoreController {
res.status(201).send(result); res.status(201).send(result);
} }
//TODO: Refacto-common - Document this endpoint
@Get('*/groupBy')
async handleApiGroupBy(
@Req() request: AuthenticatedRequest,
@Res() res: Response,
) {
this.logger.log(
`[REST API] Processing GROUP BY request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.groupBy(request);
res.status(200).send(result);
}
@Get('*') @Get('*')
async handleApiGet( async handleApiGet(
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,
@@ -16,7 +16,6 @@ import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parser
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util'; import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@Injectable() @Injectable()
export class RestApiCreateManyHandler extends RestApiBaseHandler { export class RestApiCreateManyHandler extends RestApiBaseHandler {
constructor( constructor(
@@ -35,7 +34,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
objectMetadataMaps, objectMetadataMaps,
} = await this.buildCommonOptions(request); } = await this.buildCommonOptions(request);
const selectedFieldsResult = await this.computeSelectedFields({ const selectedFields = await this.computeSelectedFields({
depth, depth,
objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMapItem: objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
@@ -43,7 +42,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
}); });
const records = await this.commonCreateManyQueryRunnerService.run({ const records = await this.commonCreateManyQueryRunnerService.run({
args: { data, selectedFieldsResult, upsert }, args: { data, selectedFields, upsert },
authContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -35,7 +35,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
objectMetadataMaps, objectMetadataMaps,
} = await this.buildCommonOptions(request); } = await this.buildCommonOptions(request);
const selectedFieldsResult = await this.computeSelectedFields({ const selectedFields = await this.computeSelectedFields({
depth, depth,
objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMapItem: objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
@@ -43,7 +43,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
}); });
const record = await this.commonCreateOneQueryRunnerService.run({ const record = await this.commonCreateOneQueryRunnerService.run({
args: { data, selectedFieldsResult, upsert }, args: { data, selectedFields, upsert },
authContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -102,7 +102,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
objectMetadataMaps, objectMetadataMaps,
} = await this.buildCommonOptions(request); } = await this.buildCommonOptions(request);
const selectedFieldsResult = await this.computeSelectedFields({ const selectedFields = await this.computeSelectedFields({
depth: parsedArgs.depth, depth: parsedArgs.depth,
objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMapItem: objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
@@ -111,7 +111,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
const { records, aggregatedValues, pageInfo } = const { records, aggregatedValues, pageInfo } =
await this.commonFindManyQueryRunnerService.run({ await this.commonFindManyQueryRunnerService.run({
args: { ...parsedArgs, selectedFieldsResult }, args: { ...parsedArgs, selectedFields },
authContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -66,7 +66,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
objectMetadataMaps, objectMetadataMaps,
} = await this.buildCommonOptions(request); } = await this.buildCommonOptions(request);
const selectedFieldsResult = await this.computeSelectedFields({ const selectedFields = await this.computeSelectedFields({
depth, depth,
objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMapItem: objectMetadataItemWithFieldMaps,
objectMetadataMaps, objectMetadataMaps,
@@ -74,7 +74,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
}); });
const record = await this.commonFindOneQueryRunnerService.run({ const record = await this.commonFindOneQueryRunnerService.run({
args: { filter, selectedFieldsResult }, args: { filter, selectedFields },
authContext, authContext,
objectMetadataMaps, objectMetadataMaps,
objectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps,
@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util';
import { parseOmitNullValuesRestRequest } from 'src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util';
import { parseOrderByWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util';
import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util';
@Injectable()
export class RestApiGroupByHandler extends RestApiBaseHandler {
constructor(
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
) {
super();
}
async handle(request: AuthenticatedRequest) {
try {
const {
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
} = await this.buildCommonOptions(request);
const {
filter,
orderBy,
viewId,
groupBy,
selectedFields,
omitNullValues,
} = this.parseRequestArgs(request);
return await this.commonGroupByQueryRunnerService.run({
args: {
filter,
orderBy,
viewId,
groupBy,
selectedFields,
omitNullValues,
},
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
});
} catch (error) {
throw workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
private parseRequestArgs(request: AuthenticatedRequest) {
const orderByWithGroupBy = parseOrderByWithGroupByRestRequest(request);
const filter = parseFilterRestRequest(request);
const viewId = parseViewIdRestRequest(request);
const groupBy = parseGroupByRestRequest(request);
const aggregateFields = parseAggregateFieldsRestRequest(request);
const omitNullValues = parseOmitNullValuesRestRequest(request);
const selectedFields = { ...aggregateFields, groupByDimensionValues: true };
return {
filter,
orderBy: orderByWithGroupBy,
viewId,
groupBy,
selectedFields,
omitNullValues,
};
}
}
@@ -13,6 +13,7 @@ import { In, type ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
@@ -104,7 +105,9 @@ export abstract class RestApiBaseHandler {
protected abstract handle( protected abstract handle(
request: AuthenticatedRequest, request: AuthenticatedRequest,
): Promise<FormatResult | { data: FormatResult[] }>; ): Promise<
FormatResult | { data: FormatResult[] } | CommonGroupByOutputItem[]
>;
public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) { public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) {
const { workspace, apiKey, userWorkspaceId } = request; const { workspace, apiKey, userWorkspaceId } = request;
@@ -32,7 +32,7 @@ export const parseCorePath = (
return { object: queryAction[1] }; return { object: queryAction[1] };
} }
if (queryAction[1] === 'duplicates') { if (queryAction[1] === 'duplicates' || queryAction[1] === 'group') {
return { object: queryAction[0] }; return { object: queryAction[0] };
} }
@@ -9,6 +9,7 @@ import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-
import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler'; import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler';
import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler'; import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler';
import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler'; import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler';
import { RestApiGroupByHandler } from 'src/engine/api/rest/core/handlers/rest-api-group-by.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module'; import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module';
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
@@ -35,6 +36,7 @@ const restApiCoreResolvers = [
RestApiFindOneHandler, RestApiFindOneHandler,
RestApiFindManyHandler, RestApiFindManyHandler,
RestApiFindDuplicatesHandler, RestApiFindDuplicatesHandler,
RestApiGroupByHandler,
]; ];
@Module({ @Module({
@@ -3,7 +3,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types'; import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant'; import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@@ -11,6 +11,10 @@ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/typ
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
type SelectFields = {
[key: string]: boolean | SelectFields;
};
@Injectable() @Injectable()
export class RestToCommonSelectedFieldsHandler { export class RestToCommonSelectedFieldsHandler {
computeFromDepth = ({ computeFromDepth = ({
@@ -23,17 +27,16 @@ export class RestToCommonSelectedFieldsHandler {
objectMetadataMaps: ObjectMetadataMaps; objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
depth: Depth | undefined; depth: Depth | undefined;
}): CommonSelectedFieldsResult => { }): CommonSelectedFields => {
const restrictedFields = const restrictedFields =
objectsPermissions[objectMetadataMapItem.id].restrictedFields; objectsPermissions[objectMetadataMapItem.id].restrictedFields;
const { relations, relationsSelectFields } = const relationsSelectFields = this.getRelationsAndRelationsSelectFields({
this.getRelationsAndRelationsSelectFields({ objectMetadataMaps,
objectMetadataMaps, objectMetadataMapItem,
objectMetadataMapItem, objectsPermissions,
objectsPermissions, depth,
depth, });
});
const selectableFields = getAllSelectableFields({ const selectableFields = getAllSelectableFields({
restrictedFields, restrictedFields,
@@ -43,12 +46,8 @@ export class RestToCommonSelectedFieldsHandler {
}); });
return { return {
select: { ...selectableFields,
...selectableFields, ...relationsSelectFields,
...relationsSelectFields,
},
relations,
aggregate: {},
}; };
}; };
@@ -63,20 +62,9 @@ export class RestToCommonSelectedFieldsHandler {
objectsPermissions: ObjectsPermissions; objectsPermissions: ObjectsPermissions;
depth: Depth | undefined; depth: Depth | undefined;
}) { }) {
if (!isDefined(depth) || depth === 0) { if (!isDefined(depth) || depth === 0) return {};
return {
relations: {},
relationsSelectFields: {},
};
}
let relations: { [key: string]: boolean | { [key: string]: boolean } } = {}; let relationsSelectFields: SelectFields = {};
let relationsSelectFields: {
[key: string]:
| boolean
| { [key: string]: boolean | { [key: string]: boolean } };
} = {};
for (const field of Object.values(objectMetadataMapItem.fieldsById)) { for (const field of Object.values(objectMetadataMapItem.fieldsById)) {
if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION))
@@ -104,35 +92,23 @@ export class RestToCommonSelectedFieldsHandler {
depth === MAX_DEPTH && depth === MAX_DEPTH &&
isDefined(field.relationTargetObjectMetadataId) isDefined(field.relationTargetObjectMetadataId)
) { ) {
const { const depth2RelationsSelectFields =
relations: depth2Relations, this.getRelationsAndRelationsSelectFields({
relationsSelectFields: depth2RelationsSelectFields, objectMetadataMaps,
} = this.getRelationsAndRelationsSelectFields({ objectMetadataMapItem: relationTargetObjectMetadata,
objectMetadataMaps, objectsPermissions,
objectMetadataMapItem: relationTargetObjectMetadata, depth: 1,
objectsPermissions, });
depth: 1,
}) as {
relations: { [key: string]: boolean };
relationsSelectFields: {
[key: string]: boolean;
};
};
relations[field.name] = depth2Relations as {
[key: string]: boolean;
};
relationsSelectFields[field.name] = { relationsSelectFields[field.name] = {
...relationFieldSelectFields, ...relationFieldSelectFields,
...depth2RelationsSelectFields, ...depth2RelationsSelectFields,
}; };
} else { } else {
relations[field.name] = true;
relationsSelectFields[field.name] = relationFieldSelectFields; relationsSelectFields[field.name] = relationFieldSelectFields;
} }
} }
return { relations, relationsSelectFields }; return relationsSelectFields;
} }
} }
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
@@ -8,6 +8,7 @@ import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-
import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler'; import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler';
import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler'; import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler';
import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler'; import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler';
import { RestApiGroupByHandler } from 'src/engine/api/rest/core/handlers/rest-api-group-by.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@@ -24,6 +25,7 @@ export class RestApiCoreService {
private readonly restApiFindOneHandler: RestApiFindOneHandler, private readonly restApiFindOneHandler: RestApiFindOneHandler,
private readonly restApiFindManyHandler: RestApiFindManyHandler, private readonly restApiFindManyHandler: RestApiFindManyHandler,
private readonly restApiFindDuplicatesHandler: RestApiFindDuplicatesHandler, private readonly restApiFindDuplicatesHandler: RestApiFindDuplicatesHandler,
private readonly restApiGroupByHandler: RestApiGroupByHandler,
private readonly featureFlagService: FeatureFlagService, private readonly featureFlagService: FeatureFlagService,
) {} ) {}
@@ -84,4 +86,16 @@ export class RestApiCoreService {
} }
} }
} }
async groupBy(request: AuthenticatedRequest) {
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isCommonApiEnabled) {
return await this.restApiGroupByHandler.handle(request);
} else {
throw new BadRequestException(
'Activate feature flag to use GroupBy in the REST API',
);
}
}
} }
@@ -0,0 +1,77 @@
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
describe('parseAggregateFieldsRestRequest', () => {
it('should parse single aggregate field', () => {
const request: any = {
query: { aggregate: '["countNotEmptyId"]' },
};
expect(parseAggregateFieldsRestRequest(request)).toEqual({
countNotEmptyId: true,
});
});
it('should parse multiple aggregate fields', () => {
const request: any = {
query: {
aggregate: '["countNotEmptyId", "countEmptyId"]',
},
};
expect(parseAggregateFieldsRestRequest(request)).toEqual({
countNotEmptyId: true,
countEmptyId: true,
});
});
it('should parse empty array', () => {
const request: any = {
query: { aggregate: '[]' },
};
expect(parseAggregateFieldsRestRequest(request)).toEqual({});
});
it('should throw if aggregate parameter is not a string', () => {
const request: any = {
query: { aggregate: ['countNotEmptyId'] },
};
expect(() => parseAggregateFieldsRestRequest(request)).toThrow(
new RestInputRequestParserException(
'Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]',
RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM,
),
);
});
it('should throw if aggregate parameter is not valid JSON', () => {
const request: any = {
query: { aggregate: 'not-valid-json' },
};
expect(() => parseAggregateFieldsRestRequest(request)).toThrow(
new RestInputRequestParserException(
'Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]',
RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM,
),
);
});
it('should throw if aggregate parameter is undefined', () => {
const request: any = {
query: {},
};
expect(() => parseAggregateFieldsRestRequest(request)).toThrow(
new RestInputRequestParserException(
'Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]',
RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM,
),
);
});
});
@@ -0,0 +1,37 @@
import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseAggregateFieldsRestRequest = (
request: AuthenticatedRequest,
): CommonSelectedFields => {
const aggregateFieldsQuery = request.query.aggregate;
if (typeof aggregateFieldsQuery !== 'string') {
throw new RestInputRequestParserException(
`Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]`,
RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM,
);
}
try {
const aggregateFields = JSON.parse(aggregateFieldsQuery);
return aggregateFields.reduce(
(acc: CommonSelectedFields, field: string) => {
acc[field] = true;
return acc;
},
{},
);
} catch {
throw new RestInputRequestParserException(
`Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]`,
RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM,
);
}
};
@@ -0,0 +1,46 @@
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
describe('parseDepthRestRequest', () => {
it('should return 0 when depth parameter is not provided', () => {
const request: any = {
query: {},
};
expect(parseDepthRestRequest(request)).toBe(0);
});
it('should parse depth=0', () => {
const request: any = {
query: { depth: '0' },
};
expect(parseDepthRestRequest(request)).toBe(0);
});
it('should throw if depth is not a number', () => {
const request: any = {
query: { depth: 'invalid' },
};
expect(() => parseDepthRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseDepthRestRequest(request)).toThrow(
"'depth=invalid' parameter invalid. Allowed values are 0, 1",
);
});
it('should throw if depth is not in allowed values (2)', () => {
const request: any = {
query: { depth: '2' },
};
expect(() => parseDepthRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseDepthRestRequest(request)).toThrow(
"'depth=2' parameter invalid. Allowed values are 0, 1",
);
});
});
@@ -1,5 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; import { type Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@@ -13,12 +15,13 @@ export const parseDepthRestRequest = (request: AuthenticatedRequest): Depth => {
const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1]; const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1];
if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) { if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) {
throw new BadRequestException( throw new RestInputRequestParserException(
`'depth=${ `'depth=${
request.query.depth request.query.depth
}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join( }' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
', ', ', ',
)}`, )}`,
RestInputRequestParserExceptionCode.INVALID_DEPTH_QUERY_PARAM,
); );
} }
@@ -1,4 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
export const checkFilterQuery = (filterQuery: string): void => { export const checkFilterQuery = (filterQuery: string): void => {
const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length; const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length;
@@ -13,8 +16,9 @@ export const checkFilterQuery = (filterQuery: string): void => {
Math.abs(diff) > 1 ? 's are' : ' is' Math.abs(diff) > 1 ? 's are' : ' is'
}`; }`;
throw new BadRequestException( throw new RestInputRequestParserException(
`'filter' invalid. ${hint} missing in the query`, `'filter' invalid. ${hint} missing in the query`,
RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM,
); );
} }
@@ -5,6 +5,10 @@ import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type
import { formatFieldValue } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/format-field-values.util'; import { formatFieldValue } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/format-field-values.util';
import { parseBaseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-base-filter.util'; import { parseBaseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-base-filter.util';
import { parseFilterContent } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-content.util'; import { parseFilterContent } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-content.util';
import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
//TODO : Refacto-common - Rename after deleting parseFilter //TODO : Refacto-common - Rename after deleting parseFilter
export const parseFilterWithoutMetadataValidation = ( export const parseFilterWithoutMetadataValidation = (
@@ -29,8 +33,9 @@ export const parseFilterWithoutMetadataValidation = (
if (conjunction === Conjunctions.not) { if (conjunction === Conjunctions.not) {
if (subResult.length > 1) { if (subResult.length > 1) {
throw new BadRequestException( throw new RestInputRequestParserException(
`'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`, `'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`,
RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM,
); );
} }
// @ts-expect-error legacy noImplicitAny // @ts-expect-error legacy noImplicitAny
@@ -0,0 +1,66 @@
import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util';
import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
describe('parseGroupByRestRequest', () => {
it('should parse mixed field types', () => {
const request: any = {
query: {
group_by:
'[{"firstField": true}, {"fieldCurrency": {"amountMicros": true}}, {"createdAt": {"granularity": "WEEK"}}]',
},
};
expect(parseGroupByRestRequest(request)).toEqual([
{ firstField: true },
{ fieldCurrency: { amountMicros: true } },
{ createdAt: { granularity: 'WEEK' } },
]);
});
it('should parse empty array', () => {
const request: any = {
query: { group_by: '[]' },
};
expect(parseGroupByRestRequest(request)).toEqual([]);
});
it('should throw if group_by parameter is not a string', () => {
const request: any = {
query: { group_by: [{ firstField: true }] },
};
expect(() => parseGroupByRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseGroupByRestRequest(request)).toThrow(
`Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`,
);
});
it('should throw if group_by parameter is not valid JSON', () => {
const request: any = {
query: { group_by: 'not-valid-json' },
};
expect(() => parseGroupByRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseGroupByRestRequest(request)).toThrow(
`Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`,
);
});
it('should throw if group_by parameter is undefined', () => {
const request: any = {
query: {},
};
expect(() => parseGroupByRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseGroupByRestRequest(request)).toThrow(
`Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`,
);
});
});
@@ -0,0 +1,29 @@
import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseGroupByRestRequest = (
request: AuthenticatedRequest,
): ObjectRecordGroupBy => {
const groupByQuery = request.query.group_by;
if (typeof groupByQuery !== 'string') {
throw new RestInputRequestParserException(
`Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`,
RestInputRequestParserExceptionCode.INVALID_GROUP_BY_QUERY_PARAM,
);
}
try {
return JSON.parse(groupByQuery);
} catch {
throw new RestInputRequestParserException(
`Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`,
RestInputRequestParserExceptionCode.INVALID_GROUP_BY_QUERY_PARAM,
);
}
};
@@ -1,10 +1,12 @@
import { BadRequestException } from '@nestjs/common';
import { import {
QUERY_DEFAULT_LIMIT_RECORDS, QUERY_DEFAULT_LIMIT_RECORDS,
QUERY_MAX_RECORDS, QUERY_MAX_RECORDS,
} from 'twenty-shared/constants'; } from 'twenty-shared/constants';
import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
@@ -18,8 +20,9 @@ export const parseLimitRestRequest = (
const limit = +request.query.limit; const limit = +request.query.limit;
if (isNaN(limit) || limit < 0) { if (isNaN(limit) || limit < 0) {
throw new BadRequestException( throw new RestInputRequestParserException(
`limit '${request.query.limit}' is invalid. Should be an integer`, `limit '${request.query.limit}' is invalid. Should be an integer`,
RestInputRequestParserExceptionCode.INVALID_LIMIT_QUERY_PARAM,
); );
} }
@@ -0,0 +1,13 @@
import { isDefined } from 'twenty-shared/utils';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseOmitNullValuesRestRequest = (
request: AuthenticatedRequest,
): boolean => {
if (!isDefined(request.query.omit_null_values)) {
return false;
}
return request.query.omit_null_values === 'true';
};
@@ -1,13 +1,15 @@
//TODO : Refacto-common - remove this comment - This parser is a copy of the OrderByInputFactory without objectMetadata dependency. Validation will be done in common layer //TODO : Refacto-common - remove this comment - This parser is a copy of the OrderByInputFactory without objectMetadata dependency. Validation will be done in common layer
import { BadRequestException } from '@nestjs/common';
import { OrderByDirection } from 'twenty-shared/types'; import { OrderByDirection } from 'twenty-shared/types';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/input-factories/order-by-input.factory'; import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { addDefaultOrderById } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/add-default-order-by-id.util'; import { addDefaultOrderById } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/add-default-order-by-id.util';
import {
RestInputRequestParserException,
RestInputRequestParserExceptionCode,
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseOrderByRestRequest = ( export const parseOrderByRestRequest = (
@@ -33,12 +35,13 @@ export const parseOrderByRestRequest = (
// fields -> [field_1] ; direction -> AscNullsFirst // fields -> [field_1] ; direction -> AscNullsFirst
if (!(direction in OrderByDirection)) { if (!(direction in OrderByDirection)) {
throw new BadRequestException( throw new RestInputRequestParserException(
`'order_by' direction '${direction}' invalid. Allowed values are '${Object.values( `'order_by' direction '${direction}' invalid. Allowed values are '${Object.values(
OrderByDirection, OrderByDirection,
).join( ).join(
"', '", "', '",
)}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`, )}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`,
RestInputRequestParserExceptionCode.INVALID_ORDER_BY_QUERY_PARAM,
); );
} }
@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
import { parseOrderByWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util';
describe('parseOrderByWithGroupByRestRequest', () => {
it('should parse mixed order by types', () => {
const request: any = {
query: {
order_by:
'[{"field_1": "AscNullsFirst"}, {"fieldCurrency": {"amountMicros": "DescNullsLast"}}, {"aggregate": {"countNotEmptyId": "AscNullsFirst"}}, {"createdAt": {"orderBy": "DescNullsLast", "granularity": "WEEK"}}]',
},
};
expect(parseOrderByWithGroupByRestRequest(request)).toEqual([
{ field_1: 'AscNullsFirst' },
{ fieldCurrency: { amountMicros: 'DescNullsLast' } },
{ aggregate: { countNotEmptyId: 'AscNullsFirst' } },
{ createdAt: { orderBy: 'DescNullsLast', granularity: 'WEEK' } },
]);
});
it('should parse empty array', () => {
const request: any = {
query: { order_by: '[]' },
};
expect(parseOrderByWithGroupByRestRequest(request)).toEqual([]);
});
it('should return undefined if order_by parameter is undefined', () => {
const request: any = {
query: {},
};
expect(parseOrderByWithGroupByRestRequest(request)).toBeUndefined();
});
it('should throw if order_by parameter is not valid JSON', () => {
const request: any = {
query: { order_by: 'not-valid-json' },
};
expect(() => parseOrderByWithGroupByRestRequest(request)).toThrow(
BadRequestException,
);
expect(() => parseOrderByWithGroupByRestRequest(request)).toThrow(
`Invalid order_by query parameter - should be a valid array of objects - ex: [{"firstField": "AscNullsFirst"}, {"secondField": {"subField": "DescNullsLast"}}, {"aggregate": {"aggregateField": "DescNullsLast"}}, {dateField: {"orderBy": "AscNullsFirst", "granularity": "DAY"}}]`,
);
});
});
@@ -0,0 +1,23 @@
import { BadRequestException } from '@nestjs/common';
import { type OrderByWithGroupBy } from 'twenty-shared/types';
import { RestInputRequestParserExceptionCode } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseOrderByWithGroupByRestRequest = (
request: AuthenticatedRequest,
): OrderByWithGroupBy | undefined => {
const orderByWithGroupByQuery = request.query.order_by;
if (typeof orderByWithGroupByQuery !== 'string') return undefined;
try {
return JSON.parse(orderByWithGroupByQuery);
} catch {
throw new BadRequestException(
`Invalid order_by query parameter - should be a valid array of objects - ex: [{"firstField": "AscNullsFirst"}, {"secondField": {"subField": "DescNullsLast"}}, {"aggregate": {"aggregateField": "DescNullsLast"}}, {dateField: {"orderBy": "AscNullsFirst", "granularity": "DAY"}}]`,
RestInputRequestParserExceptionCode.INVALID_ORDER_BY_WITH_GROUP_BY_QUERY_PARAM,
);
}
};
@@ -0,0 +1,13 @@
import { CustomException } from 'src/utils/custom-exception';
export class RestInputRequestParserException extends CustomException<RestInputRequestParserExceptionCode> {}
export enum RestInputRequestParserExceptionCode {
INVALID_AGGREGATE_FIELDS_QUERY_PARAM = 'INVALID_AGGREGATE_FIELDS_QUERY_PARAM',
INVALID_GROUP_BY_QUERY_PARAM = 'INVALID_GROUP_BY_QUERY_PARAM',
INVALID_ORDER_BY_WITH_GROUP_BY_QUERY_PARAM = 'INVALID_ORDER_BY_WITH_GROUP_BY_QUERY_PARAM',
INVALID_ORDER_BY_QUERY_PARAM = 'INVALID_ORDER_BY_QUERY_PARAM',
INVALID_DEPTH_QUERY_PARAM = 'INVALID_DEPTH_QUERY_PARAM',
INVALID_LIMIT_QUERY_PARAM = 'INVALID_LIMIT_QUERY_PARAM',
INVALID_FILTER_QUERY_PARAM = 'INVALID_FILTER_QUERY_PARAM',
}
@@ -0,0 +1,19 @@
import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util';
describe('parseViewIdRestRequest', () => {
it('should return undefined if viewId missing', () => {
const request: any = { query: {} };
expect(parseViewIdRestRequest(request)).toBeUndefined();
});
it('should return viewId when provided as string', () => {
const request: any = {
query: { viewId: '20202020-e29b-41d4-a716-446655440000' },
};
expect(parseViewIdRestRequest(request)).toEqual(
'20202020-e29b-41d4-a716-446655440000',
);
});
});
@@ -0,0 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseViewIdRestRequest = (
request: AuthenticatedRequest,
): string | undefined => {
if (
!isDefined(request.query.viewId) ||
typeof request.query.viewId !== 'string'
)
return undefined;
return request.query.viewId;
};
@@ -1,7 +1,10 @@
import { BadRequestException } from '@nestjs/common';
import { type QueryFailedError } from 'typeorm'; import { type QueryFailedError } from 'typeorm';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { commonQueryRunnerToRestApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util'; import { commonQueryRunnerToRestApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util';
import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
interface QueryFailedErrorWithCode extends QueryFailedError { interface QueryFailedErrorWithCode extends QueryFailedError {
code: string; code: string;
@@ -13,6 +16,8 @@ export const workspaceQueryRunnerRestApiExceptionHandler = (
switch (true) { switch (true) {
case error instanceof CommonQueryRunnerException: case error instanceof CommonQueryRunnerException:
return commonQueryRunnerToRestApiExceptionHandler(error); return commonQueryRunnerToRestApiExceptionHandler(error);
case error instanceof RestInputRequestParserException:
throw new BadRequestException(error.message);
default: default:
throw error; throw error;
} }
@@ -105,6 +105,72 @@ describe('group-by resolvers (integration)', () => {
expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]); expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]);
expect(groupWithCityB.totalCount).toEqual(2); expect(groupWithCityB.totalCount).toEqual(2);
}); });
it('computes aggregated metrics on date time field', async () => {
const cityA = 'City A';
const cityB = 'City B';
const person1 = (
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPersonId, city: cityA },
}),
)
).body.data.createPerson;
const person2 = (
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson2Id, city: cityB },
}),
)
).body.data.createPerson;
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson3Id, city: cityB },
}),
);
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
gqlFields: 'minCreatedAt',
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ groupByDimensionValues: [cityA] }),
expect.objectContaining({ groupByDimensionValues: [cityB] }),
]),
);
const groupWithCityA = groups.find(
(group: any) => group.groupByDimensionValues[0] === cityA,
);
expect(groupWithCityA.groupByDimensionValues).toEqual([cityA]);
expect(groupWithCityA.minCreatedAt).toEqual(person1.createdAt);
const groupWithCityB = groups.find(
(group: any) => group.groupByDimensionValues[0] === cityB,
);
expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]);
expect(groupWithCityB.minCreatedAt).toEqual(person2.createdAt);
});
}); });
describe('date range', () => { describe('date range', () => {