Common api - findMany query (#15004)

Done in this PR : 
- simplify rest input parsing by removing metadata validation (for
filter and orderBy - to add in common) (1st commit)
- simplify result getter handlers signature (array of objectRecord only
for input)


closes https://github.com/twentyhq/core-team-issues/issues/1614
closes https://github.com/twentyhq/core-team-issues/issues/1615
closes https://github.com/twentyhq/core-team-issues/issues/1616
This commit is contained in:
Etienne
2025-10-13 12:48:50 +00:00
committed by GitHub
parent 4c7e8444cf
commit fc7304bc5a
67 changed files with 1291 additions and 349 deletions
@@ -4,10 +4,8 @@ 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 {
Depth,
MAX_DEPTH,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
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';
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';
@@ -3,11 +3,10 @@ import { Inject, Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonSelectedFieldsHandler } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler';
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
import { CommonQueryNames } from 'src/engine/api/common/types/common-query-args.type';
import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
@@ -30,13 +29,7 @@ import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/wo
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export abstract class CommonBaseQueryRunnerService<
Response extends
| ObjectRecord
| ObjectRecord[]
| IConnection<ObjectRecord, IEdge<ObjectRecord>>
| IConnection<ObjectRecord, IEdge<ObjectRecord>>[],
> {
export abstract class CommonBaseQueryRunnerService {
@Inject()
protected readonly workspaceQueryHookService: WorkspaceQueryHookService;
@Inject()
@@ -57,6 +50,8 @@ export abstract class CommonBaseQueryRunnerService<
protected readonly selectedFieldsHandler: CommonSelectedFieldsHandler;
@Inject()
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
@Inject()
protected readonly commonResultGettersService: CommonResultGettersService;
public async prepareQueryRunnerContext({
authContext,
@@ -107,18 +102,19 @@ export abstract class CommonBaseQueryRunnerService<
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
}: {
results: Response;
results: ObjectRecord[];
operationName: CommonQueryNames;
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}) {
const resultWithGetters = await this.queryResultGettersFactory.create(
results,
objectMetadataItemWithFieldMaps,
authContext.workspace.id,
objectMetadataMaps,
);
}): Promise<ObjectRecord[]> {
const resultWithGetters =
await this.commonResultGettersService.processQueryResult(
results,
objectMetadataItemWithFieldMaps.id,
objectMetadataMaps,
authContext.workspace.id,
);
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
@@ -0,0 +1,286 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import {
ObjectRecord,
ObjectRecordFilter,
ObjectRecordOrderBy,
OrderByDirection,
} 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 { CommonPageInfo } from 'src/engine/api/common/types/common-page-info.type';
import {
CommonQueryNames,
FindManyQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
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 { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { getCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
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';
@Injectable()
export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerService {
async run({
args,
authContext: toValidateAuthContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
}: {
args: FindManyQueryArgs;
authContext: AuthContext;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<{
records: ObjectRecord[];
aggregatedValues: Record<string, number>;
totalCount: number;
pageInfo: CommonPageInfo;
}> {
this.validate(args);
const authContext = toValidateAuthContext;
if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
);
}
const {
workspaceDataSource,
repository,
roleId,
shouldBypassPermissionChecks,
} = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const processedArgs = await this.processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
});
const queryBuilder = repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const aggregateQueryBuilder = queryBuilder.clone();
let appliedFilters = processedArgs.filter ?? ({} as ObjectRecordFilter);
const commonQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
commonQueryParser.applyFilterToBuilder(
aggregateQueryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
appliedFilters,
);
commonQueryParser.applyDeletedAtToBuilder(
aggregateQueryBuilder,
appliedFilters,
);
const orderByWithIdCondition = [
...(processedArgs.orderBy ?? []),
{ id: OrderByDirection.AscNullsFirst },
] as ObjectRecordOrderBy;
const isForwardPagination = !isDefined(processedArgs.before);
const cursor = getCursor(processedArgs);
if (cursor) {
const cursorArgFilter = computeCursorArgFilter(
cursor,
orderByWithIdCondition,
objectMetadataItemWithFieldMaps,
isForwardPagination,
);
appliedFilters = (processedArgs.filter
? {
and: [processedArgs.filter, { or: cursorArgFilter }],
}
: { or: cursorArgFilter }) as unknown as ObjectRecordFilter;
}
commonQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
appliedFilters,
);
commonQueryParser.applyOrderToBuilder(
queryBuilder,
orderByWithIdCondition,
objectMetadataItemWithFieldMaps.nameSingular,
isForwardPagination,
);
commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: processedArgs.selectedFieldsResult.aggregate,
queryBuilder: aggregateQueryBuilder,
objectMetadataNameSingular: objectMetadataItemWithFieldMaps.nameSingular,
});
const limit =
processedArgs.first ?? processedArgs.last ?? QUERY_MAX_RECORDS;
const columnsToSelect = buildColumnsToSelect({
select: processedArgs.selectedFieldsResult.select,
relations: processedArgs.selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const objectRecords = (await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.take(limit + 1)
.getMany()) as ObjectRecord[];
const pageInfo = getPageInfo(
objectRecords,
orderByWithIdCondition,
limit,
isForwardPagination,
);
if (objectRecords.length > limit) {
objectRecords.pop();
}
if (!isForwardPagination) {
objectRecords.reverse();
}
const parentObjectRecordsAggregatedValues =
await aggregateQueryBuilder.getRawOne();
if (processedArgs.selectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: objectRecords,
parentObjectRecordsAggregatedValues,
//TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common
relations: processedArgs.selectedFieldsResult.relations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
aggregate: processedArgs.selectedFieldsResult.aggregate,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
selectedFields: processedArgs.selectedFieldsResult.select,
});
}
const enrichedRecords = await this.enrichResultsWithGettersAndHooks({
results: objectRecords,
operationName: CommonQueryNames.findMany,
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
return {
records: enrichedRecords,
aggregatedValues: parentObjectRecordsAggregatedValues,
totalCount: parentObjectRecordsAggregatedValues?.totalCount,
pageInfo,
};
}
validate(args: FindManyQueryArgs) {
if (args.first && args.last) {
throw new CommonQueryRunnerException(
'Cannot provide both first and last',
CommonQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.before && args.after) {
throw new CommonQueryRunnerException(
'Cannot provide both before and after',
CommonQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.before && args.first) {
throw new CommonQueryRunnerException(
'Cannot provide both before and first',
CommonQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.after && args.last) {
throw new CommonQueryRunnerException(
'Cannot provide both after and last',
CommonQueryRunnerExceptionCode.ARGS_CONFLICT,
);
}
if (args.first !== undefined && args.first < 0) {
throw new CommonQueryRunnerException(
'First argument must be non-negative',
CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST,
);
}
if (args.last !== undefined && args.last < 0) {
throw new CommonQueryRunnerException(
'Last argument must be non-negative',
CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST,
);
}
}
async processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
}: {
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
args: FindManyQueryArgs;
}): Promise<FindManyQueryArgs> {
const hookedArgs =
(await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
CommonQueryNames.findMany,
args,
//TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency
)) as FindManyQueryArgs;
return {
...hookedArgs,
filter: this.queryRunnerArgsFactory.overrideFilterByFieldMetadata(
hookedArgs.filter,
objectMetadataItemWithFieldMaps,
),
};
}
}
@@ -21,14 +21,13 @@ import {
} 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 { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
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';
@Injectable()
export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService<ObjectRecord> {
export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService {
async run({
args,
authContext: toValidateAuthContext,
@@ -137,23 +136,15 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
});
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
const results = typeORMObjectRecordsParser.processRecord({
objectRecord: objectRecords[0],
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
}) as ObjectRecord;
return this.enrichResultsWithGettersAndHooks({
results,
const enrichedResults = await this.enrichResultsWithGettersAndHooks({
results: objectRecords,
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
operationName: CommonQueryNames.findOne,
});
return enrichedResults[0];
}
async processQueryArgs({
@@ -1,3 +1,7 @@
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';
export const CommonQueryRunners = [CommonFindOneQueryRunnerService];
export const CommonQueryRunners = [
CommonFindOneQueryRunnerService,
CommonFindManyQueryRunnerService,
];
@@ -6,4 +6,7 @@ export enum CommonQueryRunnerExceptionCode {
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',
INVALID_AUTH_CONTEXT = 'INVALID_AUTH_CONTEXT',
ARGS_CONFLICT = 'ARGS_CONFLICT',
INVALID_ARGS_FIRST = 'INVALID_ARGS_FIRST',
INVALID_ARGS_LAST = 'INVALID_ARGS_LAST',
}
@@ -16,6 +16,9 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
switch (error.code) {
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundError(error);
case CommonQueryRunnerExceptionCode.ARGS_CONFLICT:
case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST:
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new UserInputError(error);
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
@@ -15,6 +15,9 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
error: CommonQueryRunnerException,
): never => {
switch (error.code) {
case CommonQueryRunnerExceptionCode.ARGS_CONFLICT:
case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST:
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new BadRequestException(error.message);
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
@@ -0,0 +1,155 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-field-value';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { ActivityQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler';
import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler';
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
// TODO: find a way to prevent conflict between handlers executing logic on object relations
// And this factory that is also executing logic on object relations
// Right now the factory will override any change made on relations by the handlers
@Injectable()
export class CommonResultGettersService {
private readonly logger = new Logger(CommonResultGettersService.name);
private handlers: Map<string, QueryResultGetterHandlerInterface>;
constructor(private readonly fileService: FileService) {
this.initializeHandlers();
}
private initializeHandlers() {
this.handlers = new Map<string, QueryResultGetterHandlerInterface>([
['attachment', new AttachmentQueryResultGetterHandler(this.fileService)],
['person', new PersonQueryResultGetterHandler(this.fileService)],
[
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(this.fileService),
],
['note', new ActivityQueryResultGetterHandler(this.fileService)],
['task', new ActivityQueryResultGetterHandler(this.fileService)],
]);
}
private async processRecordArray(
recordArray: ObjectRecord[],
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
) {
return await Promise.all(
recordArray.map(
async (record: ObjectRecord) =>
await this.processRecord(
record,
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
),
),
);
}
private async processRecord(
record: ObjectRecord,
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
): Promise<ObjectRecord> {
const objectMetadataMapItem = objectMetadataMaps.byId[objectMetadataItemId];
if (!isDefined(objectMetadataMapItem)) {
throw new Error('Object metadata map item is not defined');
}
const handler = this.getHandler(objectMetadataMapItem.nameSingular);
const relationFields = Object.keys(record)
.map(
(recordFieldName) =>
objectMetadataMapItem.fieldsById[
objectMetadataMapItem.fieldIdByName[recordFieldName]
],
)
.filter(isDefined)
.filter((fieldMetadata) =>
isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION),
);
const relationFieldsProcessedMap = {} as Record<
string,
QueryResultFieldValue
>;
for (const relationField of relationFields) {
if (!isDefined(relationField.relationTargetObjectMetadataId)) {
throw new Error('Relation target object metadata id is not defined');
}
const recordFieldValue = record[relationField.name];
if (!isDefined(recordFieldValue)) {
continue;
}
relationFieldsProcessedMap[relationField.name] =
relationField.settings?.relationType === RelationType.ONE_TO_MANY
? await this.processRecordArray(
record[relationField.name],
relationField.relationTargetObjectMetadataId,
objectMetadataMaps,
workspaceId,
)
: await this.processRecord(
record[relationField.name],
relationField.relationTargetObjectMetadataId,
objectMetadataMaps,
workspaceId,
);
}
const objectRecordProcessedWithoutRelationFields = await handler.handle(
record,
workspaceId,
);
const processedRecord = {
...objectRecordProcessedWithoutRelationFields,
...relationFieldsProcessedMap,
};
return processedRecord;
}
async processQueryResult(
queryResultField: ObjectRecord[],
objectMetadataItemId: string,
objectMetadataMaps: ObjectMetadataMaps,
workspaceId: string,
): Promise<ObjectRecord[]> {
return await this.processRecordArray(
queryResultField,
objectMetadataItemId,
objectMetadataMaps,
workspaceId,
);
}
private getHandler(objectType: string): QueryResultGetterHandlerInterface {
return (
this.handlers.get(objectType) || {
handle: (result: ObjectRecord): Promise<ObjectRecord> =>
Promise.resolve(result),
}
);
}
}
@@ -3,12 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CommonArgsHandlers } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-arg-handlers';
import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners';
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -23,6 +25,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
UserRoleModule,
ApiKeyModule,
WorkspacePermissionsCacheModule,
FileModule,
],
providers: [
ProcessNestedRelationsHelper,
@@ -30,6 +33,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
...CommonArgsHandlers,
ProcessAggregateHelper,
...CommonQueryRunners,
CommonResultGettersService,
],
exports: [...CommonQueryRunners],
})
@@ -0,0 +1,8 @@
import { type PageInfo } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
export type CommonPageInfo = {
hasNextPage: NonNullable<PageInfo['hasNextPage']>;
hasPreviousPage: NonNullable<PageInfo['hasPreviousPage']>;
startCursor: string | null;
endCursor: string | null;
};
@@ -1,9 +1,13 @@
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
type ObjectRecordFilter,
type ObjectRecordOrderBy,
} 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';
export enum CommonQueryNames {
findOne = 'findOne',
findMany = 'findMany',
}
export interface FindOneQueryArgs {
@@ -11,4 +15,14 @@ export interface FindOneQueryArgs {
filter?: ObjectRecordFilter;
}
export type CommonQueryArgs = FindOneQueryArgs;
export interface FindManyQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult;
filter?: ObjectRecordFilter;
orderBy?: ObjectRecordOrderBy;
first?: number;
last?: number;
before?: string;
after?: string;
}
export type CommonQueryArgs = FindOneQueryArgs | FindManyQueryArgs;
@@ -1,3 +1,5 @@
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
interface SelectedFields {
[key: string]: boolean | SelectedFields;
}
@@ -5,5 +7,6 @@ interface SelectedFields {
export type CommonSelectedFieldsResult = {
select: SelectedFields;
relations: SelectedFields;
aggregate: SelectedFields;
//TODO = Refacto-common - to update when rest api will handle aggregates
aggregate: Record<string, AggregationField>;
};
@@ -0,0 +1,32 @@
import {
type ObjectRecord,
type ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type CommonPageInfo } from 'src/engine/api/common/types/common-page-info.type';
import {
encodeCursor,
getPaginationInfo,
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
export const getPageInfo = (
records: ObjectRecord[],
orderBy: ObjectRecordOrderBy,
limit: number,
isForwardPagination: boolean,
): CommonPageInfo => {
const { hasNextPage, hasPreviousPage } = getPaginationInfo(
records,
limit,
isForwardPagination,
);
const startCursor =
records.length > 0 ? encodeCursor(records[0], orderBy) : null;
const endCursor =
records.length > 0
? encodeCursor(records[records.length - 1], orderBy)
: null;
return { startCursor, endCursor, hasNextPage, hasPreviousPage };
};
@@ -25,6 +25,7 @@ import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modu
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { isPlainObject } from 'src/utils/is-plain-object';
// TODO: Refacto-common - Rename CommonRecordsToGraphqlConnectionHelper
export class ObjectRecordsToGraphqlConnectionHelper {
private objectMetadataMaps: ObjectMetadataMaps;
@@ -175,7 +175,9 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.createConnection({
objectRecords,
objectRecords: isForwardPagination
? objectRecords
: objectRecords.reverse(),
objectRecordsAggregatedValues: parentObjectRecordsAggregatedValues,
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
@@ -64,8 +64,7 @@ export const getCursor = (
};
export const getPaginationInfo = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
objectRecords: any[],
objectRecords: ObjectRecord[],
limit: number,
isForwardPagination: boolean,
) => {
@@ -1,15 +1,23 @@
import { Injectable } from '@nestjs/common';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import graphqlFields from 'graphql-fields';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
type FindManyResolverArgs,
type Resolver,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { type 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 { 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 { 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 { 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()
export class FindManyResolverFactory
@@ -18,7 +26,9 @@ export class FindManyResolverFactory
public static methodName = RESOLVER_METHOD_NAMES.FIND_MANY;
constructor(
private readonly commonFindManyQueryRunnerService: CommonFindManyQueryRunnerService,
private readonly graphqlQueryRunnerService: GraphqlQueryFindManyResolverService,
private readonly featureFlagService: FeatureFlagService,
) {}
create(
@@ -27,17 +37,64 @@ export class FindManyResolverFactory
const internalContext = context;
return async (_source, args, _context, info) => {
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
internalContext.authContext.workspace?.id as string,
);
if (isCommonApiEnabled) {
const graphqlQueryParser = new GraphqlQueryParser(
internalContext.objectMetadataItemWithFieldMaps,
};
internalContext.objectMetadataMaps,
);
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
internalContext.objectMetadataItemWithFieldMaps,
graphqlFields(info),
internalContext.objectMetadataMaps,
);
try {
const { records, aggregatedValues, totalCount, pageInfo } =
await this.commonFindManyQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.createConnection({
objectRecords: records,
objectRecordsAggregatedValues: aggregatedValues,
selectedAggregatedFields: selectedFieldsResult.aggregate,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: args.first ?? args.last ?? QUERY_MAX_RECORDS,
totalCount,
order: args.orderBy,
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
});
} catch (error) {
return workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
return await this.graphqlQueryRunnerService.execute(
args,
options,
{
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
},
FindManyResolverFactory.methodName,
);
};
@@ -11,6 +11,7 @@ import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-
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 { 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 { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@@ -53,15 +54,28 @@ export class FindOneResolverFactory
internalContext.objectMetadataMaps,
);
return await this.commonFindOneQueryRunnerService.run({
const record = await this.commonFindOneQueryRunnerService.run({
args: { ...args, selectedFieldsResult },
authContext: internalContext.authContext,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
});
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(
internalContext.objectMetadataMaps,
);
return typeORMObjectRecordsParser.processRecord({
objectRecord: record,
objectName:
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
return workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
}
@@ -9,6 +9,7 @@ import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@@ -85,7 +86,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
recordIds: createdRecordsIds,
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
depth: parseDepthRestRequest(request),
restrictedFields,
});
@@ -9,6 +9,7 @@ import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@@ -66,7 +67,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
recordIds: [createdRecord.id],
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
depth: parseDepthRestRequest(request),
restrictedFields,
});
@@ -1,11 +1,29 @@
import { Injectable } from '@nestjs/common';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
PageInfo,
RestApiBaseHandler,
} from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
import { parseOrderByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util';
import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-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 RestApiFindManyHandler extends RestApiBaseHandler {
constructor(
private readonly commonFindManyQueryRunnerService: CommonFindManyQueryRunnerService,
) {
super();
}
async handle(request: AuthenticatedRequest) {
const {
repository,
@@ -73,4 +91,73 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
totalCount,
});
}
async commonHandle(request: AuthenticatedRequest) {
try {
const parsedArgs = this.parseRequestArgs(request);
const {
authContext,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
} = await this.buildCommonOptions(request);
const selectedFieldsResult = await this.computeSelectedFields({
depth: parsedArgs.depth,
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
objectMetadataMaps,
authContext,
});
const { records, aggregatedValues, pageInfo } =
await this.commonFindManyQueryRunnerService.run({
args: { ...parsedArgs, selectedFieldsResult },
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
});
return this.formatRestResponse(
records,
aggregatedValues,
objectMetadataItemWithFieldMaps.namePlural,
pageInfo,
);
} catch (error) {
workspaceQueryRunnerRestApiExceptionHandler(error);
}
}
private formatRestResponse(
records: ObjectRecord[],
aggregatedValues: Record<string, number>,
objectNamePlural: string,
pageInfo: PageInfo,
) {
return {
data: {
[objectNamePlural]: records,
},
totalCount: aggregatedValues.totalCount,
pageInfo,
};
}
private parseRequestArgs(request: AuthenticatedRequest) {
const depth = parseDepthRestRequest(request);
const limit = parseLimitRestRequest(request);
const orderBy = parseOrderByRestRequest(request);
const filter = parseFilterRestRequest(request);
const endingBefore = parseEndingBeforeRestRequest(request);
const startingAfter = parseStartingAfterRestRequest(request);
return {
filter,
orderBy,
first: !endingBefore ? limit : undefined,
last: endingBefore ? limit : undefined,
before: endingBefore,
after: startingAfter,
depth,
};
}
}
@@ -7,6 +7,7 @@ import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-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';
@@ -95,29 +96,11 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
private async parseRequestArgs(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
const filter = { id: { eq: recordId } };
const depth = this.depthInputFactory.create(request);
const depth = parseDepthRestRequest(request);
return {
filter,
depth,
};
}
private async buildCommonOptions(request: AuthenticatedRequest) {
const { object: parsedObject } = parseCorePath(request);
const { objectMetadataMaps, objectMetadataMapItem } =
await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
const authContext = this.getAuthContextFromRequest(request);
return {
authContext: authContext,
objectMetadataItemWithFieldMaps: objectMetadataMapItem,
objectMetadataMaps: objectMetadataMaps,
};
}
}
@@ -10,6 +10,7 @@ import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@@ -62,7 +63,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
recordIds: [updatedRecordId],
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
depth: parseDepthRestRequest(request),
restrictedFields,
});
@@ -1,6 +1,5 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { type Request } from 'express';
import chunk from 'lodash.chunk';
import isEmpty from 'lodash.isempty';
import {
@@ -23,11 +22,9 @@ import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/fact
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
import {
DepthInputFactory,
MAX_DEPTH,
type Depth,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@@ -93,8 +90,6 @@ export abstract class RestApiBaseHandler {
@Inject()
protected readonly getVariablesFactory: GetVariablesFactory;
@Inject()
protected readonly depthInputFactory: DepthInputFactory;
@Inject()
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
@Inject()
protected readonly createdByFromAuthContextService: CreatedByFromAuthContextService;
@@ -392,7 +387,7 @@ export abstract class RestApiBaseHandler {
extraFilters,
restrictedFields,
}: {
request: Request;
request: AuthenticatedRequest;
recordId?: string;
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadata: {
@@ -463,7 +458,7 @@ export abstract class RestApiBaseHandler {
recordIds: recordIds.map((record) => record.id),
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
depth: parseDepthRestRequest(request),
restrictedFields,
});
@@ -610,4 +605,22 @@ export abstract class RestApiBaseHandler {
depth,
});
}
async buildCommonOptions(request: AuthenticatedRequest) {
const { object: parsedObject } = parseCorePath(request);
const { objectMetadataMaps, objectMetadataMapItem } =
await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
const authContext = this.getAuthContextFromRequest(request);
return {
authContext,
objectMetadataItemWithFieldMaps: objectMetadataMapItem,
objectMetadataMaps: objectMetadataMaps,
};
}
}
@@ -1,30 +1,27 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class GetVariablesFactory {
constructor(
private readonly startingAfterInputFactory: StartingAfterInputFactory,
private readonly endingBeforeInputFactory: EndingBeforeInputFactory,
private readonly limitInputFactory: LimitInputFactory,
private readonly orderByInputFactory: OrderByInputFactory,
private readonly filterInputFactory: FilterInputFactory,
) {}
create(
id: string | undefined,
request: Request,
request: AuthenticatedRequest,
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
@@ -35,10 +32,10 @@ export class GetVariablesFactory {
}
const filter = this.filterInputFactory.create(request, objectMetadata);
const limit = this.limitInputFactory.create(request);
const limit = parseLimitRestRequest(request);
const orderBy = this.orderByInputFactory.create(request, objectMetadata);
const endingBefore = this.endingBeforeInputFactory.create(request);
const startingAfter = this.startingAfterInputFactory.create(request);
const endingBefore = parseEndingBeforeRestRequest(request);
const startingAfter = parseStartingAfterRestRequest(request);
return {
filter,
@@ -127,7 +127,7 @@ describe('parseFilter', () => {
});
});
it('should handler not', () => {
it('should handle not', () => {
expect(
parseFilter(
'and(fieldNumber[eq]:1,not(fieldNumber[eq]:2))',
@@ -1,12 +1,12 @@
import { BadRequestException } from '@nestjs/common';
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
import { checkFilterEnumValues } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/check-filter-enum-values';
import { formatFieldValue } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/format-field-values.utils';
import { parseBaseFilter } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-base-filter.utils';
import { parseFilterContent } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter-content.utils';
import { getFieldType } from 'src/engine/api/rest/core/query-builder/utils/get-field-type.utils';
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { checkFilterEnumValues } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-enum-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 { parseFilterContent } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-content.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export enum Conjunctions {
@@ -19,7 +19,7 @@ export const parseFilter = (
filterQuery: string,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
): Record<string, FieldValue> => {
const result = {};
const result: Record<string, FieldValue> = {};
const match = filterQuery.match(
`^(${Object.values(Conjunctions).join('|')})\\((.+)\\)$`,
);
@@ -42,10 +42,8 @@ export const parseFilter = (
`'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`,
);
}
// @ts-expect-error legacy noImplicitAny
result[conjunction] = subResult[0];
} else {
// @ts-expect-error legacy noImplicitAny
result[conjunction] = subResult;
}
@@ -4,10 +4,8 @@ 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 {
Depth,
MAX_DEPTH,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
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';
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';
@@ -27,6 +27,13 @@ export class RestApiCoreService {
private readonly featureFlagService: FeatureFlagService,
) {}
private async isCommonApiEnabled(request: AuthenticatedRequest) {
return await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
request.workspace.id,
);
}
async delete(request: AuthenticatedRequest) {
return await this.restApiDeleteOneHandler.handle(request);
}
@@ -49,20 +56,20 @@ export class RestApiCoreService {
async get(request: AuthenticatedRequest) {
const { id: recordId } = parseCorePath(request);
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
if (isDefined(recordId)) {
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMON_API_ENABLED,
request.workspace.id,
);
if (isCommonApiEnabled) {
if (isCommonApiEnabled) {
if (isDefined(recordId)) {
return await this.restApiFindOneHandler.commonHandle(request);
} else {
return await this.restApiFindManyHandler.commonHandle(request);
}
return await this.restApiFindOneHandler.handle(request);
} else {
return await this.restApiFindManyHandler.handle(request);
if (isDefined(recordId)) {
return await this.restApiFindOneHandler.handle(request);
} else {
return await this.restApiFindManyHandler.handle(request);
}
}
}
}
@@ -1,33 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
describe('EndingBeforeInputFactory', () => {
let service: EndingBeforeInputFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EndingBeforeInputFactory],
}).compile();
service = module.get<EndingBeforeInputFactory>(EndingBeforeInputFactory);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return default if ending_before missing', () => {
const request: any = { query: {} };
expect(service.create(request)).toEqual(undefined);
});
it('should return ending_before', () => {
const request: any = { query: { ending_before: 'uuid' } };
expect(service.create(request)).toEqual('uuid');
});
});
});
@@ -1,49 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
describe('LimitInputFactory', () => {
let service: LimitInputFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LimitInputFactory],
}).compile();
service = module.get<LimitInputFactory>(LimitInputFactory);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return default if limit missing', () => {
const request: any = { query: {} };
expect(service.create(request)).toEqual(60);
});
it('should return limit', () => {
const request: any = { query: { limit: '10' } };
expect(service.create(request)).toEqual(10);
});
it('should throw if not integer', () => {
const request: any = { query: { limit: 'aaa' } };
expect(() => service.create(request)).toThrow(
"limit 'aaa' is invalid. Should be an integer",
);
});
it('should throw if limit negative', () => {
const request: any = { query: { limit: -1 } };
expect(() => service.create(request)).toThrow(
"limit '-1' is invalid. Should be an integer",
);
});
});
});
@@ -1,33 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
describe('StartingAfterInputFactory', () => {
let service: StartingAfterInputFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [StartingAfterInputFactory],
}).compile();
service = module.get<StartingAfterInputFactory>(StartingAfterInputFactory);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return default if starting_after missing', () => {
const request: any = { query: {} };
expect(service.create(request)).toEqual(undefined);
});
it('should return starting_after', () => {
const request: any = { query: { starting_after: 'uuid' } };
expect(service.create(request)).toEqual('uuid');
});
});
});
@@ -1,32 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { type Request } from 'express';
export const MAX_DEPTH = 2;
export type Depth = 0 | 1 | 2;
const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1];
@Injectable()
export class DepthInputFactory {
create(request: Request): Depth {
if (!request.query.depth) {
return 0;
}
const depth = +request.query.depth as Depth;
if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) {
throw new BadRequestException(
`'depth=${
request.query.depth
}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
', ',
)}`,
);
}
return depth;
}
}
@@ -1,15 +1,4 @@
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { DepthInputFactory } from 'src/engine/api/rest/input-factories/depth-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
export const inputFactories = [
DepthInputFactory,
EndingBeforeInputFactory,
FilterInputFactory,
LimitInputFactory,
OrderByInputFactory,
StartingAfterInputFactory,
];
export const inputFactories = [FilterInputFactory, OrderByInputFactory];
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/add-default-conjunction.utils';
import { checkFilterQuery } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/check-filter-query.utils';
import { parseFilter } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter.utils';
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/add-default-conjunction.util';
import { checkFilterQuery } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@@ -1,29 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
QUERY_DEFAULT_LIMIT_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class LimitInputFactory {
create(
request: RequestContext,
defaultLimit = QUERY_DEFAULT_LIMIT_RECORDS,
): number {
if (!request.query?.limit) {
return defaultLimit;
}
const limit = +request.query.limit;
if (isNaN(limit) || limit < 0) {
throw new BadRequestException(
`limit '${request.query.limit}' is invalid. Should be an integer`,
);
}
return Math.min(limit, QUERY_MAX_RECORDS);
}
}
@@ -1,16 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class StartingAfterInputFactory {
create(request: RequestContext): string | undefined {
const cursorQuery = request.query?.starting_after;
if (typeof cursorQuery !== 'string') {
return undefined;
}
return cursorQuery;
}
}
@@ -0,0 +1 @@
export const MAX_DEPTH = 2;
@@ -0,0 +1,26 @@
import { BadRequestException } from '@nestjs/common';
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';
export const parseDepthRestRequest = (request: AuthenticatedRequest): Depth => {
if (!request.query.depth) {
return 0;
}
const depth = +request.query.depth as Depth;
const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1];
if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) {
throw new BadRequestException(
`'depth=${
request.query.depth
}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
', ',
)}`,
);
}
return depth;
};
@@ -0,0 +1,15 @@
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
describe('parseEndingBeforeRestRequest', () => {
it('should return default if ending_before missing', () => {
const request: any = { query: {} };
expect(parseEndingBeforeRestRequest(request)).toEqual(undefined);
});
it('should return ending_before', () => {
const request: any = { query: { ending_before: 'uuid' } };
expect(parseEndingBeforeRestRequest(request)).toEqual('uuid');
});
});
@@ -0,0 +1,14 @@
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
export const parseEndingBeforeRestRequest = (
request: AuthenticatedRequest | RequestContext,
): string | undefined => {
const cursorQuery = request.query?.ending_before;
if (typeof cursorQuery !== 'string') {
return undefined;
}
return cursorQuery;
};
@@ -1,4 +1,4 @@
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/add-default-conjunction.utils';
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/add-default-conjunction.util';
describe('addDefaultConjunctionIfMissing', () => {
it('should add default conjunction if missing', () => {
@@ -4,7 +4,7 @@ import {
fieldSelectMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { checkFilterEnumValues } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/check-filter-enum-values';
import { checkFilterEnumValues } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-enum-values.util';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
@@ -1,4 +1,4 @@
import { checkFilterQuery } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/check-filter-query.utils';
import { checkFilterQuery } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util';
describe('checkFilterQuery', () => {
it('should check filter query', () => {
@@ -1,6 +1,6 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { formatFieldValue } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/format-field-values.utils';
import { formatFieldValue } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/format-field-values.util';
describe('formatFieldValue', () => {
it('should format fieldNumber value', () => {
@@ -1,4 +1,4 @@
import { parseBaseFilter } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-base-filter.utils';
import { parseBaseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-base-filter.util';
describe('parseBaseFilter', () => {
it('should parse simple filter string test 1', () => {
@@ -1,4 +1,4 @@
import { parseFilterContent } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter-content.utils';
import { parseFilterContent } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-content.util';
describe('parseFilterContent', () => {
it('should parse query filter test 1', () => {
@@ -0,0 +1,88 @@
import { parseFilterWithoutMetadataValidation } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util';
describe('parseFilterWithoutMetadataValidation', () => {
it('should parse string filter test 1', () => {
expect(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,fieldNumber[eq]:2)',
),
).toEqual({
and: [{ fieldNumber: { eq: '1' } }, { fieldNumber: { eq: '2' } }],
});
});
it('should parse string filter test 2', () => {
expect(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3))',
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
{ or: [{ fieldNumber: { eq: '2' } }, { fieldNumber: { eq: '3' } }] },
],
});
});
it('should parse string filter test 3', () => {
expect(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3,and(fieldNumber[eq]:6,fieldNumber[eq]:7)),or(fieldNumber[eq]:4,fieldNumber[eq]:5))',
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
{
or: [
{ fieldNumber: { eq: '2' } },
{ fieldNumber: { eq: '3' } },
{
and: [{ fieldNumber: { eq: '6' } }, { fieldNumber: { eq: '7' } }],
},
],
},
{ or: [{ fieldNumber: { eq: '4' } }, { fieldNumber: { eq: '5' } }] },
],
});
});
it('should parse string filter test 4', () => {
expect(
parseFilterWithoutMetadataValidation(
'and(fieldText[gt]:"val,ue",or(fieldNumber[is]:NOT_NULL,not(fieldText[startsWith]:"val"),and(fieldNumber[eq]:6,fieldText[ilike]:"%val%")),or(fieldNumber[eq]:4,fieldText[is]:NULL))',
),
).toEqual({
and: [
{ fieldText: { gt: 'val,ue' } },
{
or: [
{ fieldNumber: { is: 'NOT_NULL' } },
{ not: { fieldText: { startsWith: 'val' } } },
{
and: [
{ fieldNumber: { eq: '6' } },
{ fieldText: { ilike: '%val%' } },
],
},
],
},
{ or: [{ fieldNumber: { eq: '4' } }, { fieldText: { is: 'NULL' } }] },
],
});
});
it('should handle not', () => {
expect(
parseFilterWithoutMetadataValidation(
'and(fieldNumber[eq]:1,not(fieldNumber[eq]:2))',
),
).toEqual({
and: [
{ fieldNumber: { eq: '1' } },
{
not: { fieldNumber: { eq: '2' } },
},
],
});
});
});
@@ -0,0 +1,22 @@
//TODO : Refacto-common - remove this comment - This parser is a copy of the filter input factory without objectMetadata dependency. Validation will be done in common layer
import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { addDefaultConjunctionIfMissing } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/add-default-conjunction.util';
import { checkFilterQuery } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util';
import { parseFilterWithoutMetadataValidation } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseFilterRestRequest = (
request: AuthenticatedRequest,
): Record<string, FieldValue> => {
let filterQuery = request.query.filter;
if (typeof filterQuery !== 'string') {
return {};
}
checkFilterQuery(filterQuery);
filterQuery = addDefaultConjunctionIfMissing(filterQuery);
return parseFilterWithoutMetadataValidation(filterQuery);
};
@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { Conjunctions } from 'src/engine/api/rest/core/query-builder/utils/filter-utils/parse-filter.utils';
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';
//TODO : Refacto-common - Rename after deleting parseFilter
export const parseFilterWithoutMetadataValidation = (
filterQuery: string,
): Record<string, FieldValue> => {
const result = {};
const match = filterQuery.match(
`^(${Object.values(Conjunctions).join('|')})\\((.+)\\)$`,
);
if (match) {
const conjunction = match?.[1];
if (!conjunction) {
throw new BadRequestException(
'Error while matching filter query. Conjunction not found',
);
}
const subResult = parseFilterContent(filterQuery).map((elem) =>
parseFilterWithoutMetadataValidation(elem),
);
if (conjunction === Conjunctions.not) {
if (subResult.length > 1) {
throw new BadRequestException(
`'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`,
);
}
// @ts-expect-error legacy noImplicitAny
result[conjunction] = subResult[0];
} else {
// @ts-expect-error legacy noImplicitAny
result[conjunction] = subResult;
}
return result;
}
const { fields, comparator, value } = parseBaseFilter(filterQuery);
const formattedValue = formatFieldValue(value, undefined, comparator);
return fields.reverse().reduce(
(acc, currentValue) => {
return { [currentValue]: acc };
},
{ [comparator]: formattedValue },
);
};
@@ -0,0 +1,31 @@
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
describe('parseLimitRestRequest', () => {
it('should return default if limit missing', () => {
const request: any = { query: {} };
expect(parseLimitRestRequest(request)).toEqual(60);
});
it('should return limit', () => {
const request: any = { query: { limit: '10' } };
expect(parseLimitRestRequest(request)).toEqual(10);
});
it('should throw if not integer', () => {
const request: any = { query: { limit: 'aaa' } };
expect(() => parseLimitRestRequest(request)).toThrow(
"limit 'aaa' is invalid. Should be an integer",
);
});
it('should throw if limit negative', () => {
const request: any = { query: { limit: -1 } };
expect(() => parseLimitRestRequest(request)).toThrow(
"limit '-1' is invalid. Should be an integer",
);
});
});
@@ -0,0 +1,27 @@
import { BadRequestException } from '@nestjs/common';
import {
QUERY_DEFAULT_LIMIT_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
export const parseLimitRestRequest = (
request: AuthenticatedRequest | RequestContext,
defaultLimit = QUERY_DEFAULT_LIMIT_RECORDS,
): number => {
if (!request.query?.limit) {
return defaultLimit;
}
const limit = +request.query.limit;
if (isNaN(limit) || limit < 0) {
throw new BadRequestException(
`limit '${request.query.limit}' is invalid. Should be an integer`,
);
}
return Math.min(limit, QUERY_MAX_RECORDS);
};
@@ -0,0 +1,94 @@
import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { parseOrderByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util';
describe('parseOrderByRestRequest', () => {
it('should return default if order by missing', () => {
const request: any = { query: {} };
expect(parseOrderByRestRequest(request)).toEqual([
{},
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should create order by parser properly', () => {
const request: any = {
query: {
order_by: 'fieldNumber[AscNullsFirst],fieldText[DescNullsLast]',
},
};
expect(parseOrderByRestRequest(request)).toEqual([
{ fieldNumber: OrderByDirection.AscNullsFirst },
{ fieldText: OrderByDirection.DescNullsLast },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should choose default direction if missing', () => {
const request: any = {
query: {
order_by: 'fieldNumber',
},
};
expect(parseOrderByRestRequest(request)).toEqual([
{ fieldNumber: OrderByDirection.AscNullsFirst },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should handle complex fields', () => {
const request: any = {
query: {
order_by: 'fieldCurrency.amountMicros',
},
};
expect(parseOrderByRestRequest(request)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.AscNullsFirst } },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should handle complex fields with direction', () => {
const request: any = {
query: {
order_by: 'fieldCurrency.amountMicros[DescNullsLast]',
},
};
expect(parseOrderByRestRequest(request)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should handle multiple complex fields with direction', () => {
const request: any = {
query: {
order_by:
'fieldCurrency.amountMicros[DescNullsLast],fieldText.label[AscNullsLast]',
},
};
expect(parseOrderByRestRequest(request)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } },
{ fieldText: { label: OrderByDirection.AscNullsLast } },
{ id: OrderByDirection.AscNullsFirst },
]);
});
it('should throw if direction invalid', () => {
const request: any = {
query: {
order_by: 'fieldText[invalid]',
},
};
expect(() => parseOrderByRestRequest(request)).toThrow(
"'order_by' direction 'invalid' invalid. Allowed values are 'AscNullsFirst', 'AscNullsLast', 'DescNullsFirst', 'DescNullsLast'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3",
);
});
});
@@ -0,0 +1,12 @@
import {
type ObjectRecordOrderBy,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
export const addDefaultOrderById = (orderBy: ObjectRecordOrderBy) => {
const hasIdOrder = orderBy.some((o) => Object.keys(o).includes('id'));
return hasIdOrder
? orderBy
: [...orderBy, { id: OrderByDirection.AscNullsFirst }];
};
@@ -0,0 +1,77 @@
//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 {
type ObjectRecordOrderBy,
OrderByDirection,
} 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 { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseOrderByRestRequest = (
request: AuthenticatedRequest,
): ObjectRecordOrderBy => {
const orderByQuery = request.query.order_by;
if (typeof orderByQuery !== 'string') {
return addDefaultOrderById([{}]);
}
//orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3
const orderByItems = orderByQuery.split(',');
let result: Array<Record<string, OrderByDirection>> = [];
let itemDirection = '';
let itemFields = '';
for (const orderByItem of orderByItems) {
// orderByItem -> field_1[AscNullsFirst]
if (orderByItem.includes('[') && orderByItem.includes(']')) {
const [fieldName, directionWithRightBracket] = orderByItem.split('[');
const direction = directionWithRightBracket.replace(']', '');
// fields -> [field_1] ; direction -> AscNullsFirst
if (!(direction in OrderByDirection)) {
throw new BadRequestException(
`'order_by' direction '${direction}' invalid. Allowed values are '${Object.values(
OrderByDirection,
).join(
"', '",
)}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`,
);
}
itemDirection = direction;
itemFields = fieldName;
} else {
// orderByItem -> field_3
itemDirection = DEFAULT_ORDER_DIRECTION;
itemFields = orderByItem;
}
let fieldResult = {};
itemFields
.split('.')
.reverse()
.forEach((field) => {
if (Object.keys(fieldResult).length) {
fieldResult = { [field]: fieldResult };
} else {
// @ts-expect-error legacy noImplicitAny
fieldResult[field] = itemDirection;
}
}, itemDirection);
const resultFields = Object.keys(fieldResult).map((key) => ({
// @ts-expect-error legacy noImplicitAny
[key]: fieldResult[key],
}));
result = [...result, ...resultFields];
}
return addDefaultOrderById(result);
};
@@ -0,0 +1,15 @@
import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util';
describe('parseStartingAfterRestRequest', () => {
it('should return default if starting_after missing', () => {
const request: any = { query: {} };
expect(parseStartingAfterRestRequest(request)).toEqual(undefined);
});
it('should return starting_after', () => {
const request: any = { query: { starting_after: 'uuid' } };
expect(parseStartingAfterRestRequest(request)).toEqual('uuid');
});
});
@@ -0,0 +1,14 @@
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
export const parseStartingAfterRestRequest = (
request: AuthenticatedRequest | RequestContext,
): string | undefined => {
const cursorQuery = request.query?.starting_after;
if (typeof cursorQuery !== 'string') {
return undefined;
}
return cursorQuery;
};
@@ -0,0 +1 @@
export type Depth = 0 | 1 | 2;
@@ -1,19 +1,13 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { type MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util';
import { MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class GetMetadataVariablesFactory {
constructor(
private readonly startingAfterInputFactory: StartingAfterInputFactory,
private readonly endingBeforeInputFactory: EndingBeforeInputFactory,
private readonly limitInputFactory: LimitInputFactory,
) {}
create(
id: string | undefined,
requestContext: RequestContext,
@@ -22,9 +16,9 @@ export class GetMetadataVariablesFactory {
return { id };
}
const limit = this.limitInputFactory.create(requestContext, 1000);
const before = this.endingBeforeInputFactory.create(requestContext);
const after = this.startingAfterInputFactory.create(requestContext);
const limit = parseLimitRestRequest(requestContext, 1000);
const before = parseEndingBeforeRestRequest(requestContext);
const after = parseStartingAfterRestRequest(requestContext);
if (before && after) {
throw new BadRequestException(
@@ -257,7 +257,7 @@ describe('GraphQL People Pagination with Composite Field Sorting', () => {
expect(backwardPagePeople).toHaveLength(2);
expect(backwardPagePeople[0].id).toBe(allPeople.at(-2)?.id);
expect(backwardPagePeople[1].id).toBe(allPeople.at(-3)?.id);
expect(backwardPagePeople[0].id).toBe(allPeople.at(-3)?.id);
expect(backwardPagePeople[1].id).toBe(allPeople.at(-2)?.id);
});
});