Common api - Group by query (#15108)
closes https://github.com/twentyhq/core-team-issues/issues/1626
This commit is contained in:
+14
@@ -66,6 +66,20 @@ export class RestApiCoreController {
|
||||
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('*')
|
||||
async handleApiGet(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
|
||||
+2
-3
@@ -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 { 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';
|
||||
|
||||
@Injectable()
|
||||
export class RestApiCreateManyHandler extends RestApiBaseHandler {
|
||||
constructor(
|
||||
@@ -35,7 +34,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const selectedFieldsResult = await this.computeSelectedFields({
|
||||
const selectedFields = await this.computeSelectedFields({
|
||||
depth,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
@@ -43,7 +42,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
|
||||
});
|
||||
|
||||
const records = await this.commonCreateManyQueryRunnerService.run({
|
||||
args: { data, selectedFieldsResult, upsert },
|
||||
args: { data, selectedFields, upsert },
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const selectedFieldsResult = await this.computeSelectedFields({
|
||||
const selectedFields = await this.computeSelectedFields({
|
||||
depth,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
@@ -43,7 +43,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
|
||||
});
|
||||
|
||||
const record = await this.commonCreateOneQueryRunnerService.run({
|
||||
args: { data, selectedFieldsResult, upsert },
|
||||
args: { data, selectedFields, upsert },
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
|
||||
+2
-2
@@ -102,7 +102,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const selectedFieldsResult = await this.computeSelectedFields({
|
||||
const selectedFields = await this.computeSelectedFields({
|
||||
depth: parsedArgs.depth,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
@@ -111,7 +111,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
|
||||
|
||||
const { records, aggregatedValues, pageInfo } =
|
||||
await this.commonFindManyQueryRunnerService.run({
|
||||
args: { ...parsedArgs, selectedFieldsResult },
|
||||
args: { ...parsedArgs, selectedFields },
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
|
||||
+2
-2
@@ -66,7 +66,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const selectedFieldsResult = await this.computeSelectedFields({
|
||||
const selectedFields = await this.computeSelectedFields({
|
||||
depth,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
@@ -74,7 +74,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
|
||||
});
|
||||
|
||||
const record = await this.commonFindOneQueryRunnerService.run({
|
||||
args: { filter, selectedFieldsResult },
|
||||
args: { filter, selectedFields },
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
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 { 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 { 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';
|
||||
@@ -104,7 +105,9 @@ export abstract class RestApiBaseHandler {
|
||||
|
||||
protected abstract handle(
|
||||
request: AuthenticatedRequest,
|
||||
): Promise<FormatResult | { data: FormatResult[] }>;
|
||||
): Promise<
|
||||
FormatResult | { data: FormatResult[] } | CommonGroupByOutputItem[]
|
||||
>;
|
||||
|
||||
public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) {
|
||||
const { workspace, apiKey, userWorkspaceId } = request;
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const parseCorePath = (
|
||||
return { object: queryAction[1] };
|
||||
}
|
||||
|
||||
if (queryAction[1] === 'duplicates') {
|
||||
if (queryAction[1] === 'duplicates' || queryAction[1] === 'group') {
|
||||
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 { 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 { 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 { 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';
|
||||
@@ -35,6 +36,7 @@ const restApiCoreResolvers = [
|
||||
RestApiFindOneHandler,
|
||||
RestApiFindManyHandler,
|
||||
RestApiFindDuplicatesHandler,
|
||||
RestApiGroupByHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
|
||||
+24
-48
@@ -3,7 +3,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
|
||||
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 { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
|
||||
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 { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
type SelectFields = {
|
||||
[key: string]: boolean | SelectFields;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RestToCommonSelectedFieldsHandler {
|
||||
computeFromDepth = ({
|
||||
@@ -23,17 +27,16 @@ export class RestToCommonSelectedFieldsHandler {
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
|
||||
depth: Depth | undefined;
|
||||
}): CommonSelectedFieldsResult => {
|
||||
}): CommonSelectedFields => {
|
||||
const restrictedFields =
|
||||
objectsPermissions[objectMetadataMapItem.id].restrictedFields;
|
||||
|
||||
const { relations, relationsSelectFields } =
|
||||
this.getRelationsAndRelationsSelectFields({
|
||||
objectMetadataMaps,
|
||||
objectMetadataMapItem,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
});
|
||||
const relationsSelectFields = this.getRelationsAndRelationsSelectFields({
|
||||
objectMetadataMaps,
|
||||
objectMetadataMapItem,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
});
|
||||
|
||||
const selectableFields = getAllSelectableFields({
|
||||
restrictedFields,
|
||||
@@ -43,12 +46,8 @@ export class RestToCommonSelectedFieldsHandler {
|
||||
});
|
||||
|
||||
return {
|
||||
select: {
|
||||
...selectableFields,
|
||||
...relationsSelectFields,
|
||||
},
|
||||
relations,
|
||||
aggregate: {},
|
||||
...selectableFields,
|
||||
...relationsSelectFields,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -63,20 +62,9 @@ export class RestToCommonSelectedFieldsHandler {
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
depth: Depth | undefined;
|
||||
}) {
|
||||
if (!isDefined(depth) || depth === 0) {
|
||||
return {
|
||||
relations: {},
|
||||
relationsSelectFields: {},
|
||||
};
|
||||
}
|
||||
if (!isDefined(depth) || depth === 0) return {};
|
||||
|
||||
let relations: { [key: string]: boolean | { [key: string]: boolean } } = {};
|
||||
|
||||
let relationsSelectFields: {
|
||||
[key: string]:
|
||||
| boolean
|
||||
| { [key: string]: boolean | { [key: string]: boolean } };
|
||||
} = {};
|
||||
let relationsSelectFields: SelectFields = {};
|
||||
|
||||
for (const field of Object.values(objectMetadataMapItem.fieldsById)) {
|
||||
if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION))
|
||||
@@ -104,35 +92,23 @@ export class RestToCommonSelectedFieldsHandler {
|
||||
depth === MAX_DEPTH &&
|
||||
isDefined(field.relationTargetObjectMetadataId)
|
||||
) {
|
||||
const {
|
||||
relations: depth2Relations,
|
||||
relationsSelectFields: depth2RelationsSelectFields,
|
||||
} = this.getRelationsAndRelationsSelectFields({
|
||||
objectMetadataMaps,
|
||||
objectMetadataMapItem: relationTargetObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth: 1,
|
||||
}) as {
|
||||
relations: { [key: string]: boolean };
|
||||
relationsSelectFields: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
relations[field.name] = depth2Relations as {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
const depth2RelationsSelectFields =
|
||||
this.getRelationsAndRelationsSelectFields({
|
||||
objectMetadataMaps,
|
||||
objectMetadataMapItem: relationTargetObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
relationsSelectFields[field.name] = {
|
||||
...relationFieldSelectFields,
|
||||
...depth2RelationsSelectFields,
|
||||
};
|
||||
} else {
|
||||
relations[field.name] = true;
|
||||
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';
|
||||
|
||||
@@ -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 { 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 { 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 { 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';
|
||||
@@ -24,6 +25,7 @@ export class RestApiCoreService {
|
||||
private readonly restApiFindOneHandler: RestApiFindOneHandler,
|
||||
private readonly restApiFindManyHandler: RestApiFindManyHandler,
|
||||
private readonly restApiFindDuplicatesHandler: RestApiFindDuplicatesHandler,
|
||||
private readonly restApiGroupByHandler: RestApiGroupByHandler,
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
+37
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
+46
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-3
@@ -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 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];
|
||||
|
||||
if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) {
|
||||
throw new BadRequestException(
|
||||
throw new RestInputRequestParserException(
|
||||
`'depth=${
|
||||
request.query.depth
|
||||
}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
|
||||
', ',
|
||||
)}`,
|
||||
RestInputRequestParserExceptionCode.INVALID_DEPTH_QUERY_PARAM,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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 => {
|
||||
const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length;
|
||||
@@ -13,8 +16,9 @@ export const checkFilterQuery = (filterQuery: string): void => {
|
||||
Math.abs(diff) > 1 ? 's are' : ' is'
|
||||
}`;
|
||||
|
||||
throw new BadRequestException(
|
||||
throw new RestInputRequestParserException(
|
||||
`'filter' invalid. ${hint} missing in the query`,
|
||||
RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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 { 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 {
|
||||
RestInputRequestParserException,
|
||||
RestInputRequestParserExceptionCode,
|
||||
} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
|
||||
|
||||
//TODO : Refacto-common - Rename after deleting parseFilter
|
||||
export const parseFilterWithoutMetadataValidation = (
|
||||
@@ -29,8 +33,9 @@ export const parseFilterWithoutMetadataValidation = (
|
||||
|
||||
if (conjunction === Conjunctions.not) {
|
||||
if (subResult.length > 1) {
|
||||
throw new BadRequestException(
|
||||
throw new RestInputRequestParserException(
|
||||
`'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`,
|
||||
RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM,
|
||||
);
|
||||
}
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
|
||||
+66
@@ -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'}}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+29
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
+6
-3
@@ -1,10 +1,12 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
QUERY_DEFAULT_LIMIT_RECORDS,
|
||||
QUERY_MAX_RECORDS,
|
||||
} 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 RequestContext } from 'src/engine/api/rest/types/RequestContext';
|
||||
|
||||
@@ -18,8 +20,9 @@ export const parseLimitRestRequest = (
|
||||
const limit = +request.query.limit;
|
||||
|
||||
if (isNaN(limit) || limit < 0) {
|
||||
throw new BadRequestException(
|
||||
throw new RestInputRequestParserException(
|
||||
`limit '${request.query.limit}' is invalid. Should be an integer`,
|
||||
RestInputRequestParserExceptionCode.INVALID_LIMIT_QUERY_PARAM,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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';
|
||||
};
|
||||
+6
-3
@@ -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
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { OrderByDirection } from 'twenty-shared/types';
|
||||
|
||||
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 { 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';
|
||||
|
||||
export const parseOrderByRestRequest = (
|
||||
@@ -33,12 +35,13 @@ export const parseOrderByRestRequest = (
|
||||
|
||||
// fields -> [field_1] ; direction -> AscNullsFirst
|
||||
if (!(direction in OrderByDirection)) {
|
||||
throw new BadRequestException(
|
||||
throw new RestInputRequestParserException(
|
||||
`'order_by' direction '${direction}' invalid. Allowed values are '${Object.values(
|
||||
OrderByDirection,
|
||||
).join(
|
||||
"', '",
|
||||
)}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`,
|
||||
RestInputRequestParserExceptionCode.INVALID_ORDER_BY_QUERY_PARAM,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+50
@@ -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"}}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+23
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
+13
@@ -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',
|
||||
}
|
||||
+19
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+15
@@ -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;
|
||||
};
|
||||
+5
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { type QueryFailedError } from 'typeorm';
|
||||
|
||||
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 { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
|
||||
|
||||
interface QueryFailedErrorWithCode extends QueryFailedError {
|
||||
code: string;
|
||||
@@ -13,6 +16,8 @@ export const workspaceQueryRunnerRestApiExceptionHandler = (
|
||||
switch (true) {
|
||||
case error instanceof CommonQueryRunnerException:
|
||||
return commonQueryRunnerToRestApiExceptionHandler(error);
|
||||
case error instanceof RestInputRequestParserException:
|
||||
throw new BadRequestException(error.message);
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user