Refactor graphql query runner and add mutation resolvers (#7418)

Fixes https://github.com/twentyhq/twenty/issues/6859

This PR adds all the remaining resolvers for
- updateOne/updateMany
- createOne/createMany
- deleteOne/deleteMany
- destroyOne
- restoreMany

Also
- refactored the graphql-query-runner to be able to add other resolvers
without too much boilerplate.
- add missing events that were not sent anymore as well as webhooks
- make resolver injectable so they can inject other services as well
- use objectMetadataMap from cache instead of computing it multiple time
- various fixes (mutation not correctly parsing JSON, relationHelper
fetching data with empty ids set, ...)

Next steps: 
- Wrapping query builder to handle DB events properly
- Move webhook emitters to db event listener
- Add pagination where it's missing (findDuplicates, nested relations,
etc...)
This commit is contained in:
Weiko
2024-10-04 11:58:33 +02:00
committed by GitHub
parent 8afa504b65
commit 511150a2d3
43 changed files with 1696 additions and 775 deletions

View File

@ -1,51 +1,53 @@
import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { In, InsertResult } from 'typeorm';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
export class GraphqlQueryCreateManyResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager;
@Injectable()
export class GraphqlQueryCreateManyResolverService
implements ResolverService<CreateManyResolverArgs, IRecord[]>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
this.twentyORMGlobalManager = twentyORMGlobalManager;
}
async createMany<ObjectRecord extends IRecord = IRecord>(
async resolve<ObjectRecord extends IRecord = IRecord>(
args: CreateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord[] | undefined> {
const { authContext, objectMetadataItem, objectMetadataCollection, info } =
): Promise<ObjectRecord[]> {
const { authContext, info, objectMetadataMap, objectMetadataMapItem } =
options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
objectMetadataItem.nameSingular,
);
const repository = dataSource.getRepository(
objectMetadataMapItem.nameSingular,
);
const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection,
);
const objectMetadata = getObjectMetadataOrThrow(
objectMetadataMap,
objectMetadataItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadata.fields,
objectMetadataMapItem.fields,
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { select, relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem,
const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataMapItem,
selectedFields,
);
@ -56,24 +58,59 @@ export class GraphqlQueryCreateManyResolverService {
skipUpdateIfNoValuesChanged: true,
});
const upsertedRecords = await repository.find({
where: {
const queryBuilder = repository.createQueryBuilder(
objectMetadataMapItem.nameSingular,
);
const nonFormattedUpsertedRecords = (await queryBuilder
.where({
id: In(objectRecords.generatedMaps.map((record) => record.id)),
},
select,
relations,
});
})
.take(QUERY_MAX_RECORDS)
.getMany()) as ObjectRecord[];
const upsertedRecords = formatResult(
nonFormattedUpsertedRecords,
objectMetadataMapItem,
objectMetadataMap,
);
const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
objectMetadataMapItem,
upsertedRecords,
relations,
QUERY_MAX_RECORDS,
authContext,
dataSource,
);
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return upsertedRecords.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord(
record,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
1,
1,
),
);
}
async validate<ObjectRecord extends IRecord>(
args: CreateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItem);
args.data.forEach((record) => {
if (record?.id) {
assertIsValidUuid(record.id);
}
});
}
}

View File

@ -1,33 +1,68 @@
import { Injectable } from '@nestjs/common';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
export class GraphqlQueryDestroyOneResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager;
@Injectable()
export class GraphqlQueryDestroyOneResolverService
implements ResolverService<DestroyOneResolverArgs, IRecord>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
this.twentyORMGlobalManager = twentyORMGlobalManager;
}
async destroyOne<ObjectRecord extends IRecord = IRecord>(
async resolve<ObjectRecord extends IRecord = IRecord>(
args: DestroyOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItem } = options;
const { authContext, objectMetadataMapItem, objectMetadataMap } = options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
authContext.workspace.id,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
);
const record = await repository.findOne({
const nonFormattedRecordBeforeDeletion = await repository.findOne({
where: { id: args.id },
withDeleted: true,
});
if (!nonFormattedRecordBeforeDeletion) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const recordBeforeDeletion = formatResult(
[nonFormattedRecordBeforeDeletion],
objectMetadataMapItem,
objectMetadataMap,
)[0];
await repository.delete(args.id);
return record as ObjectRecord;
return recordBeforeDeletion as ObjectRecord;
}
async validate(
args: DestroyOneResolverArgs,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.id) {
throw new GraphqlQueryRunnerException(
'Missing id',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}

View File

@ -0,0 +1,214 @@
import { Injectable } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { In } from 'typeorm';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
OrderByDirection,
RecordFilter,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { FindDuplicatesResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { 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 { settings } from 'src/engine/constants/settings';
import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants';
import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@Injectable()
export class GraphqlQueryFindDuplicatesResolverService
implements
ResolverService<FindDuplicatesResolverArgs, IConnection<IRecord>[]>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async resolve<ObjectRecord extends IRecord = IRecord>(
args: FindDuplicatesResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>[]> {
const { authContext, objectMetadataMapItem, objectMetadataMap } = options;
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
);
const repository = dataSource.getRepository(
objectMetadataMapItem.nameSingular,
);
const existingRecordsQueryBuilder = repository.createQueryBuilder(
objectMetadataMapItem.nameSingular,
);
const duplicateRecordsQueryBuilder = repository.createQueryBuilder(
objectMetadataMapItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataMap[objectMetadataMapItem.nameSingular].fields,
objectMetadataMap,
);
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
let objectRecords: Partial<ObjectRecord>[] = [];
if (args.ids) {
const nonFormattedObjectRecords = (await existingRecordsQueryBuilder
.where({ id: In(args.ids) })
.getMany()) as ObjectRecord[];
objectRecords = formatResult(
nonFormattedObjectRecords,
objectMetadataMapItem,
objectMetadataMap,
);
} else if (args.data && !isEmpty(args.data)) {
objectRecords = formatData(args.data, objectMetadataMapItem);
}
const duplicateConnections: IConnection<ObjectRecord>[] = await Promise.all(
objectRecords.map(async (record) => {
const duplicateConditions = this.buildDuplicateConditions(
objectMetadataMapItem,
[record],
record.id,
);
if (isEmpty(duplicateConditions)) {
return typeORMObjectRecordsParser.createConnection(
[],
objectMetadataMapItem.nameSingular,
0,
0,
[{ id: OrderByDirection.AscNullsFirst }],
false,
false,
);
}
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
duplicateRecordsQueryBuilder,
objectMetadataMapItem.nameSingular,
duplicateConditions,
);
const nonFormattedDuplicates =
(await withFilterQueryBuilder.getMany()) as ObjectRecord[];
const duplicates = formatResult(
nonFormattedDuplicates,
objectMetadataMapItem,
objectMetadataMap,
);
return typeORMObjectRecordsParser.createConnection(
duplicates,
objectMetadataMapItem.nameSingular,
duplicates.length,
duplicates.length,
[{ id: OrderByDirection.AscNullsFirst }],
false,
false,
);
}),
);
return duplicateConnections;
}
private buildDuplicateConditions(
objectMetadataMapItem: ObjectMetadataMapItem,
records?: Partial<IRecord>[] | undefined,
filteringByExistingRecordId?: string,
): Partial<RecordFilter> {
if (!records || records.length === 0) {
return {};
}
const criteriaCollection = this.getApplicableDuplicateCriteriaCollection(
objectMetadataMapItem,
);
const conditions = records.flatMap((record) => {
const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) =>
criteria.columnNames.every((columnName) => {
const value = record[columnName] as string | undefined;
return (
value && value.length >= settings.minLengthOfStringForDuplicateCheck
);
}),
);
return criteriaWithMatchingArgs.map((criteria) => {
const condition = {};
criteria.columnNames.forEach((columnName) => {
condition[columnName] = { eq: record[columnName] };
});
return condition;
});
});
const filter: Partial<RecordFilter> = {};
if (conditions && !isEmpty(conditions)) {
filter.or = conditions;
if (filteringByExistingRecordId) {
filter.id = { neq: filteringByExistingRecordId };
}
}
return filter;
}
private getApplicableDuplicateCriteriaCollection(
objectMetadataMapItem: ObjectMetadataMapItem,
) {
return DUPLICATE_CRITERIA_COLLECTION.filter(
(duplicateCriteria) =>
duplicateCriteria.objectName === objectMetadataMapItem.nameSingular,
);
}
async validate(
args: FindDuplicatesResolverArgs,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.data && !args.ids) {
throw new GraphqlQueryRunnerException(
'You have to provide either "data" or "ids" argument',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (args.data && args.ids) {
throw new GraphqlQueryRunnerException(
'You cannot provide both "data" and "ids" arguments',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (!args.ids && isEmpty(args.data)) {
throw new GraphqlQueryRunnerException(
'The "data" condition can not be empty when "ids" input not provided',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}

View File

@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import graphqlFields from 'graphql-fields';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
OrderByDirection,
@ -17,26 +20,25 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
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 { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter';
import { decodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
import {
ObjectMetadataMapItem,
generateObjectMetadataMap,
} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
getCursor,
getPaginationInfo,
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
export class GraphqlQueryFindManyResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager;
@Injectable()
export class GraphqlQueryFindManyResolverService
implements ResolverService<FindManyResolverArgs, IConnection<IRecord>>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
this.twentyORMGlobalManager = twentyORMGlobalManager;
}
async findMany<
async resolve<
ObjectRecord extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
@ -44,51 +46,41 @@ export class GraphqlQueryFindManyResolverService {
args: FindManyResolverArgs<Filter, OrderBy>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>> {
const { authContext, objectMetadataItem, info, objectMetadataCollection } =
const { authContext, objectMetadataMapItem, info, objectMetadataMap } =
options;
this.validateArgsOrThrow(args);
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
);
const repository = dataSource.getRepository(
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
);
const queryBuilder = repository.createQueryBuilder(
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
);
const countQueryBuilder = repository.createQueryBuilder(
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
);
const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection,
);
const objectMetadata = getObjectMetadataOrThrow(
objectMetadataMap,
objectMetadataItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadata.fields,
objectMetadataMapItem.fields,
objectMetadataMap,
);
const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
countQueryBuilder,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
args.filter ?? ({} as Filter),
);
const selectedFields = graphqlFields(info);
const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem,
objectMetadataMapItem,
selectedFields,
);
const isForwardPagination = !isDefined(args.before);
@ -105,7 +97,7 @@ export class GraphqlQueryFindManyResolverService {
? await withDeletedCountQueryBuilder.getCount()
: 0;
const cursor = this.getCursor(args);
const cursor = getCursor(args);
let appliedFilters = args.filter ?? ({} as Filter);
@ -118,7 +110,7 @@ export class GraphqlQueryFindManyResolverService {
const cursorArgFilter = computeCursorArgFilter(
cursor,
orderByWithIdCondition,
objectMetadata.fields,
objectMetadataMapItem.fields,
isForwardPagination,
);
@ -131,14 +123,14 @@ export class GraphqlQueryFindManyResolverService {
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
appliedFilters,
);
const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
withFilterQueryBuilder,
orderByWithIdCondition,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
isForwardPagination,
);
@ -153,11 +145,11 @@ export class GraphqlQueryFindManyResolverService {
const objectRecords = formatResult(
nonFormattedObjectRecords,
objectMetadata,
objectMetadataMapItem,
objectMetadataMap,
);
const { hasNextPage, hasPreviousPage } = this.getPaginationInfo(
const { hasNextPage, hasPreviousPage } = getPaginationInfo(
objectRecords,
limit,
isForwardPagination,
@ -167,14 +159,12 @@ export class GraphqlQueryFindManyResolverService {
objectRecords.pop();
}
const processNestedRelationsHelper = new ProcessNestedRelationsHelper(
this.twentyORMGlobalManager,
);
const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
objectMetadata,
objectMetadataMapItem,
objectRecords,
relations,
limit,
@ -184,20 +174,25 @@ export class GraphqlQueryFindManyResolverService {
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return typeORMObjectRecordsParser.createConnection(
const result = typeORMObjectRecordsParser.createConnection(
objectRecords,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
limit,
totalCount,
orderByWithIdCondition,
hasNextPage,
hasPreviousPage,
);
return result;
}
private validateArgsOrThrow(args: FindManyResolverArgs<any, any>) {
async validate<Filter extends RecordFilter>(
args: FindManyResolverArgs<Filter>,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (args.first && args.last) {
throw new GraphqlQueryRunnerException(
'Cannot provide both first and last',
@ -235,49 +230,4 @@ export class GraphqlQueryFindManyResolverService {
);
}
}
private getCursor(
args: FindManyResolverArgs<any, any>,
): Record<string, any> | undefined {
if (args.after) return decodeCursor(args.after);
if (args.before) return decodeCursor(args.before);
return undefined;
}
private addOrderByColumnsToSelect(
order: Record<string, any>,
select: Record<string, boolean>,
) {
for (const column of Object.keys(order || {})) {
if (!select[column]) {
select[column] = true;
}
}
}
private addForeingKeyColumnsToSelect(
relations: Record<string, any>,
select: Record<string, boolean>,
objectMetadata: ObjectMetadataMapItem,
) {
for (const column of Object.keys(relations || {})) {
if (!select[`${column}Id`] && objectMetadata.fields[`${column}Id`]) {
select[`${column}Id`] = true;
}
}
}
private getPaginationInfo(
objectRecords: any[],
limit: number,
isForwardPagination: boolean,
) {
const hasMoreRecords = objectRecords.length > limit;
return {
hasNextPage: isForwardPagination && hasMoreRecords,
hasPreviousPage: !isForwardPagination && hasMoreRecords,
};
}
}

View File

@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
RecordFilter,
@ -13,28 +16,31 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
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 { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
export class GraphqlQueryFindOneResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager;
@Injectable()
export class GraphqlQueryFindOneResolverService
implements ResolverService<FindOneResolverArgs, IRecord>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
this.twentyORMGlobalManager = twentyORMGlobalManager;
}
async findOne<
async resolve<
ObjectRecord extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: FindOneResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord | undefined> {
const { authContext, objectMetadataItem, info, objectMetadataCollection } =
): Promise<ObjectRecord> {
const { authContext, objectMetadataMapItem, info, objectMetadataMap } =
options;
const dataSource =
@ -43,37 +49,28 @@ export class GraphqlQueryFindOneResolverService {
);
const repository = dataSource.getRepository(
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
);
const queryBuilder = repository.createQueryBuilder(
objectMetadataItem.nameSingular,
);
const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection,
);
const objectMetadata = getObjectMetadataOrThrow(
objectMetadataMap,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadata.fields,
objectMetadataMapItem.fields,
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem,
objectMetadataMapItem,
selectedFields,
);
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
args.filter ?? ({} as Filter),
);
@ -86,12 +83,10 @@ export class GraphqlQueryFindOneResolverService {
const objectRecord = formatResult(
nonFormattedObjectRecord,
objectMetadata,
objectMetadataMapItem,
objectMetadataMap,
);
const limit = QUERY_MAX_RECORDS;
if (!objectRecord) {
throw new GraphqlQueryRunnerException(
'Record not found',
@ -99,32 +94,42 @@ export class GraphqlQueryFindOneResolverService {
);
}
const processNestedRelationsHelper = new ProcessNestedRelationsHelper(
this.twentyORMGlobalManager,
);
const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
const objectRecords = [objectRecord];
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
objectMetadata,
objectMetadataMapItem,
objectRecords,
relations,
limit,
QUERY_MAX_RECORDS,
authContext,
dataSource,
);
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return typeORMObjectRecordsParser.processRecord(
objectRecords[0],
objectMetadataItem.nameSingular,
objectMetadataMapItem.nameSingular,
1,
1,
) as ObjectRecord;
}
async validate<Filter extends RecordFilter>(
args: FindOneResolverArgs<Filter>,
_options: WorkspaceQueryRunnerOptions,
): Promise<void> {
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new WorkspaceQueryRunnerException(
'Missing filter argument',
WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}

View File

@ -1,3 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
OrderByDirection,
@ -11,47 +14,25 @@ import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
export class GraphqlQuerySearchResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager;
private featureFlagService: FeatureFlagService;
@Injectable()
export class GraphqlQuerySearchResolverService
implements ResolverService<SearchResolverArgs, IConnection<IRecord>>
{
constructor(
twentyORMGlobalManager: TwentyORMGlobalManager,
featureFlagService: FeatureFlagService,
) {
this.twentyORMGlobalManager = twentyORMGlobalManager;
this.featureFlagService = featureFlagService;
}
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly featureFlagService: FeatureFlagService,
) {}
async search<ObjectRecord extends IRecord = IRecord>(
async resolve<ObjectRecord extends IRecord = IRecord>(
args: SearchResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>> {
const { authContext, objectMetadataItem, objectMetadataCollection } =
options;
const featureFlagsForWorkspace =
await this.featureFlagService.getWorkspaceFeatureFlags(
authContext.workspace.id,
);
const isQueryRunnerTwentyORMEnabled =
featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED;
const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED;
if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) {
throw new GraphqlQueryRunnerException(
'This endpoint is not available yet, please use findMany instead.',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const { authContext, objectMetadataItem, objectMetadataMap } = options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
@ -59,21 +40,8 @@ export class GraphqlQuerySearchResolverService {
objectMetadataItem.nameSingular,
);
const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection,
);
const objectMetadata = objectMetadataMap[objectMetadataItem.nameSingular];
if (!objectMetadata) {
throw new GraphqlQueryRunnerException(
`Object metadata not found for ${objectMetadataItem.nameSingular}`,
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
if (!args.searchInput) {
return typeORMObjectRecordsParser.createConnection(
@ -100,7 +68,7 @@ export class GraphqlQuerySearchResolverService {
'DESC',
)
.setParameter('searchTerms', searchTerms)
.limit(limit)
.take(limit)
.getMany()) as ObjectRecord[];
const objectRecords = await repository.formatResult(resultsWithTsVector);
@ -129,4 +97,26 @@ export class GraphqlQuerySearchResolverService {
return formattedWords.join(' | ');
}
async validate(
_args: SearchResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
const featureFlagsForWorkspace =
await this.featureFlagService.getWorkspaceFeatureFlags(
options.authContext.workspace.id,
);
const isQueryRunnerTwentyORMEnabled =
featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED;
const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED;
if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) {
throw new GraphqlQueryRunnerException(
'This endpoint is not available yet, please use findMany instead.',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}

View File

@ -0,0 +1,116 @@
import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
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 { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@Injectable()
export class GraphqlQueryUpdateManyResolverService
implements ResolverService<UpdateManyResolverArgs, IRecord[]>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async resolve<ObjectRecord extends IRecord = IRecord>(
args: UpdateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord[]> {
const { authContext, objectMetadataMapItem, objectMetadataMap, info } =
options;
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
);
const repository = dataSource.getRepository(
objectMetadataMapItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataMapItem.fields,
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataMapItem,
selectedFields,
);
const queryBuilder = repository.createQueryBuilder(
objectMetadataMapItem.nameSingular,
);
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataMapItem.nameSingular,
args.filter,
);
const data = formatData(args.data, objectMetadataMapItem);
const result = await withFilterQueryBuilder
.update()
.set(data)
.returning('*')
.execute();
const nonFormattedUpdatedObjectRecords = result.raw;
const updatedRecords = formatResult(
nonFormattedUpdatedObjectRecords,
objectMetadataMapItem,
objectMetadataMap,
);
const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
objectMetadataMapItem,
updatedRecords,
relations,
QUERY_MAX_RECORDS,
authContext,
dataSource,
);
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return updatedRecords.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord(
record,
objectMetadataMapItem.nameSingular,
1,
1,
),
);
}
async validate<ObjectRecord extends IRecord = IRecord>(
args: UpdateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataMapItem);
args.filter?.id?.in?.forEach((id: string) => assertIsValidUuid(id));
}
}

View File

@ -0,0 +1,123 @@
import { Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
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 { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@Injectable()
export class GraphqlQueryUpdateOneResolverService
implements ResolverService<UpdateOneResolverArgs, IRecord>
{
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async resolve<ObjectRecord extends IRecord = IRecord>(
args: UpdateOneResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const { authContext, objectMetadataMapItem, objectMetadataMap, info } =
options;
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
);
const repository = dataSource.getRepository(
objectMetadataMapItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataMapItem.fields,
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataMapItem,
selectedFields,
);
const queryBuilder = repository.createQueryBuilder(
objectMetadataMapItem.nameSingular,
);
const withFilterQueryBuilder = queryBuilder.where({ id: args.id });
const data = formatData(args.data, objectMetadataMapItem);
const result = await withFilterQueryBuilder
.update()
.set(data)
.returning('*')
.execute();
const nonFormattedUpdatedObjectRecords = result.raw;
const updatedRecords = formatResult(
nonFormattedUpdatedObjectRecords,
objectMetadataMapItem,
objectMetadataMap,
);
if (updatedRecords.length === 0) {
throw new GraphqlQueryRunnerException(
'Record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const updatedRecord = updatedRecords[0] as ObjectRecord;
const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
objectMetadataMapItem,
[updatedRecord],
relations,
QUERY_MAX_RECORDS,
authContext,
dataSource,
);
}
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return typeORMObjectRecordsParser.processRecord<ObjectRecord>(
updatedRecord,
objectMetadataMapItem.nameSingular,
1,
1,
);
}
async validate<ObjectRecord extends IRecord = IRecord>(
args: UpdateOneResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataMapItem);
assertIsValidUuid(args.id);
}
}