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

@ -6,180 +6,90 @@ import {
RecordOrderBy,
} 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 { IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
DestroyOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
ResolverArgs,
ResolverArgsType,
RestoreManyResolverArgs,
SearchResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
WorkspaceResolverBuilderMethodNames,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service';
import { GraphqlQueryResolverFactory } from 'src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import {
CallWebhookJobsJob,
CallWebhookJobsJobData,
CallWebhookJobsJobOperation,
} from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { LogExecutionTime } from 'src/engine/decorators/observability/log-execution-time.decorator';
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { capitalize } from 'src/utils/capitalize';
@Injectable()
export class GraphqlQueryRunnerService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceQueryHookService: WorkspaceQueryHookService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
private readonly graphqlQueryResolverFactory: GraphqlQueryResolverFactory,
private readonly apiEventEmitterService: ApiEventEmitterService,
) {}
/** QUERIES */
@LogExecutionTime()
async findOne<
ObjectRecord extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
async findOne<ObjectRecord extends IRecord, Filter extends RecordFilter>(
args: FindOneResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord | undefined> {
const graphqlQueryFindOneResolverService =
new GraphqlQueryFindOneResolverService(this.twentyORMGlobalManager);
const { authContext, objectMetadataItem } = options;
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new WorkspaceQueryRunnerException(
'Missing filter argument',
WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'findOne',
args,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
hookedArgs,
): Promise<ObjectRecord> {
return this.executeQuery<FindOneResolverArgs<Filter>, ObjectRecord>(
'findOne',
args,
options,
ResolverArgsType.FindOne,
)) as FindOneResolverArgs<Filter>;
return graphqlQueryFindOneResolverService.findOne(computedArgs, options);
);
}
@LogExecutionTime()
async findMany<
ObjectRecord extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
ObjectRecord extends IRecord,
Filter extends RecordFilter,
OrderBy extends RecordOrderBy,
>(
args: FindManyResolverArgs<Filter, OrderBy>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>> {
const graphqlQueryFindManyResolverService =
new GraphqlQueryFindManyResolverService(this.twentyORMGlobalManager);
const { authContext, objectMetadataItem } = options;
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'findMany',
args,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
ResolverArgsType.FindMany,
)) as FindManyResolverArgs<Filter, OrderBy>;
return graphqlQueryFindManyResolverService.findMany(computedArgs, options);
): Promise<IConnection<ObjectRecord, IEdge<ObjectRecord>>> {
return this.executeQuery<
FindManyResolverArgs<Filter, OrderBy>,
IConnection<ObjectRecord, IEdge<ObjectRecord>>
>('findMany', args, options);
}
@LogExecutionTime()
async createOne<ObjectRecord extends IRecord = IRecord>(
args: CreateOneResolverArgs<Partial<ObjectRecord>>,
async findDuplicates<ObjectRecord extends IRecord>(
args: FindDuplicatesResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord | undefined> {
const graphqlQueryCreateManyResolverService =
new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager);
const { authContext, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
if (args.data.id) {
assertIsValidUuid(args.data.id);
}
const createManyArgs = {
data: [args.data],
upsert: args.upsert,
} as CreateManyResolverArgs<ObjectRecord>;
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'createMany',
createManyArgs,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
ResolverArgsType.CreateMany,
)) as CreateManyResolverArgs<ObjectRecord>;
const results = (await graphqlQueryCreateManyResolverService.createMany(
computedArgs,
options,
)) as ObjectRecord[];
await this.triggerWebhooks<ObjectRecord>(
results,
CallWebhookJobsJobOperation.create,
options,
);
this.emitCreateEvents<ObjectRecord>(
results,
authContext,
objectMetadataItem,
);
return results?.[0] as ObjectRecord;
): Promise<IConnection<ObjectRecord>[]> {
return this.executeQuery<
FindDuplicatesResolverArgs<Partial<ObjectRecord>>,
IConnection<ObjectRecord>[]
>('findDuplicates', args, options);
}
@LogExecutionTime()
@ -187,104 +97,286 @@ export class GraphqlQueryRunnerService {
args: SearchResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<ObjectRecord>> {
const graphqlQuerySearchResolverService =
new GraphqlQuerySearchResolverService(
this.twentyORMGlobalManager,
this.featureFlagService,
);
return this.executeQuery<SearchResolverArgs, IConnection<ObjectRecord>>(
'search',
args,
options,
);
}
return graphqlQuerySearchResolverService.search(args, options);
/** MUTATIONS */
@LogExecutionTime()
async createOne<ObjectRecord extends IRecord>(
args: CreateOneResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const results = await this.executeQuery<
CreateManyResolverArgs<Partial<ObjectRecord>>,
ObjectRecord[]
>('createMany', { data: [args.data], upsert: args.upsert }, options);
// TODO: emitCreateEvents should be moved to the ORM layer
if (results) {
this.apiEventEmitterService.emitCreateEvents(
results,
options.authContext,
options.objectMetadataItem,
);
}
return results[0];
}
@LogExecutionTime()
async createMany<ObjectRecord extends IRecord = IRecord>(
async createMany<ObjectRecord extends IRecord>(
args: CreateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord[] | undefined> {
const graphqlQueryCreateManyResolverService =
new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager);
): Promise<ObjectRecord[]> {
const results = await this.executeQuery<
CreateManyResolverArgs<Partial<ObjectRecord>>,
ObjectRecord[]
>('createMany', args, options);
if (results) {
this.apiEventEmitterService.emitCreateEvents(
results,
options.authContext,
options.objectMetadataItem,
);
}
return results;
}
@LogExecutionTime()
public async updateOne<ObjectRecord extends IRecord>(
args: UpdateOneResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const existingRecord = await this.executeQuery<
FindOneResolverArgs,
ObjectRecord
>(
'findOne',
{
filter: { id: { eq: args.id } },
},
options,
);
const result = await this.executeQuery<
UpdateOneResolverArgs<Partial<ObjectRecord>>,
ObjectRecord
>('updateOne', args, options);
this.apiEventEmitterService.emitUpdateEvents(
[existingRecord],
[result],
Object.keys(args.data),
options.authContext,
options.objectMetadataItem,
);
return result;
}
@LogExecutionTime()
public async updateMany<ObjectRecord extends IRecord>(
args: UpdateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord[]> {
const existingRecords = await this.executeQuery<
FindManyResolverArgs,
IConnection<ObjectRecord, IEdge<ObjectRecord>>
>(
'findMany',
{
filter: args.filter,
},
options,
);
const result = await this.executeQuery<
UpdateManyResolverArgs<Partial<ObjectRecord>>,
ObjectRecord[]
>('updateMany', args, options);
this.apiEventEmitterService.emitUpdateEvents(
existingRecords.edges.map((edge) => edge.node),
result,
Object.keys(args.data),
options.authContext,
options.objectMetadataItem,
);
return result;
}
@LogExecutionTime()
public async deleteOne<ObjectRecord extends IRecord & { deletedAt?: Date }>(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const result = await this.executeQuery<
UpdateOneResolverArgs<Partial<ObjectRecord>>,
ObjectRecord
>(
'deleteOne',
{
id: args.id,
data: { deletedAt: new Date() } as Partial<ObjectRecord>,
},
options,
);
this.apiEventEmitterService.emitDeletedEvents(
[result],
options.authContext,
options.objectMetadataItem,
);
return result;
}
@LogExecutionTime()
public async deleteMany<ObjectRecord extends IRecord & { deletedAt?: Date }>(
args: DeleteManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord[]> {
const result = await this.executeQuery<
UpdateManyResolverArgs<Partial<ObjectRecord>>,
ObjectRecord[]
>(
'deleteMany',
{
filter: args.filter,
data: { deletedAt: new Date() } as Partial<ObjectRecord>,
},
options,
);
this.apiEventEmitterService.emitDeletedEvents(
result,
options.authContext,
options.objectMetadataItem,
);
return result;
}
@LogExecutionTime()
async destroyOne<ObjectRecord extends IRecord>(
args: DestroyOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const result = await this.executeQuery<
DestroyOneResolverArgs,
ObjectRecord
>('destroyOne', args, options);
this.apiEventEmitterService.emitDestroyEvents(
[result],
options.authContext,
options.objectMetadataItem,
);
return result;
}
@LogExecutionTime()
public async restoreMany<ObjectRecord extends IRecord>(
args: RestoreManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord> {
const result = await this.executeQuery<
UpdateManyResolverArgs<Partial<ObjectRecord>>,
ObjectRecord
>(
'restoreMany',
{
filter: args.filter,
data: { deletedAt: null } as Partial<ObjectRecord>,
},
options,
);
return result;
}
private async executeQuery<Input extends ResolverArgs, Response>(
operationName: WorkspaceResolverBuilderMethodNames,
args: Input,
options: WorkspaceQueryRunnerOptions,
): Promise<Response> {
const { authContext, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
const resolver =
this.graphqlQueryResolverFactory.getResolver(operationName);
args.data.forEach((record) => {
if (record?.id) {
assertIsValidUuid(record.id);
}
});
await resolver.validate(args, options);
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'createMany',
operationName,
args,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
const computedArgs = await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
ResolverArgsType.CreateMany,
)) as CreateManyResolverArgs<ObjectRecord>;
ResolverArgsType[capitalize(operationName)],
);
const results = (await graphqlQueryCreateManyResolverService.createMany(
computedArgs,
options,
)) as ObjectRecord[];
const results = await resolver.resolve(computedArgs as Input, options);
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'createMany',
results,
operationName,
Array.isArray(results) ? results : [results],
);
await this.triggerWebhooks<ObjectRecord>(
results,
CallWebhookJobsJobOperation.create,
options,
);
const jobOperation = this.operationNameToJobOperation(operationName);
this.emitCreateEvents<ObjectRecord>(
results,
authContext,
objectMetadataItem,
);
if (jobOperation) {
await this.triggerWebhooks(results, jobOperation, options);
}
return results;
}
private emitCreateEvents<BaseRecord extends IRecord = IRecord>(
records: BaseRecord[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
) {
this.workspaceEventEmitter.emit(
`${objectMetadataItem.nameSingular}.created`,
records.map(
(record) =>
({
userId: authContext.user?.id,
recordId: record.id,
objectMetadata: objectMetadataItem,
properties: {
after: record,
},
}) satisfies ObjectRecordCreateEvent<any>,
),
authContext.workspace.id,
);
private operationNameToJobOperation(
operationName: WorkspaceResolverBuilderMethodNames,
): CallWebhookJobsJobOperation | undefined {
switch (operationName) {
case 'createOne':
case 'createMany':
return CallWebhookJobsJobOperation.create;
case 'updateOne':
case 'updateMany':
case 'restoreMany':
return CallWebhookJobsJobOperation.update;
case 'deleteOne':
case 'deleteMany':
return CallWebhookJobsJobOperation.delete;
case 'destroyOne':
return CallWebhookJobsJobOperation.destroy;
default:
return undefined;
}
}
private async triggerWebhooks<Record>(
jobsData: Record[] | undefined,
private async triggerWebhooks<T>(
jobsData: T[] | undefined,
operation: CallWebhookJobsJobOperation,
options: WorkspaceQueryRunnerOptions,
) {
if (!Array.isArray(jobsData)) {
return;
}
): Promise<void> {
if (!jobsData || !Array.isArray(jobsData)) return;
jobsData.forEach((jobData) => {
this.messageQueueService.add<CallWebhookJobsJobData>(
CallWebhookJobsJob.name,
@ -298,99 +390,4 @@ export class GraphqlQueryRunnerService {
);
});
}
@LogExecutionTime()
async destroyOne<ObjectRecord extends IRecord = IRecord>(
args: DestroyOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord | undefined> {
const graphqlQueryDestroyOneResolverService =
new GraphqlQueryDestroyOneResolverService(this.twentyORMGlobalManager);
const { authContext, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
assertIsValidUuid(args.id);
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'destroyOne',
args,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
ResolverArgsType.DestroyOne,
)) as DestroyOneResolverArgs;
const result = (await graphqlQueryDestroyOneResolverService.destroyOne(
computedArgs,
options,
)) as ObjectRecord;
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'destroyOne',
[result],
);
await this.triggerWebhooks<IRecord>(
[result],
CallWebhookJobsJobOperation.destroy,
options,
);
this.emitDestroyEvents<IRecord>([result], authContext, objectMetadataItem);
return result;
}
private emitDestroyEvents<BaseRecord extends IRecord = IRecord>(
records: BaseRecord[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
) {
this.workspaceEventEmitter.emit(
`${objectMetadataItem.nameSingular}.destroyed`,
records.map((record) => {
return {
userId: authContext.user?.id,
recordId: record.id,
objectMetadata: objectMetadataItem,
properties: {
before: this.removeNestedProperties(record),
},
} satisfies ObjectRecordDeleteEvent<any>;
}),
authContext.workspace.id,
);
}
private removeNestedProperties<Record extends IRecord = IRecord>(
record: Record,
) {
if (!record) {
return;
}
const sanitizedRecord = {};
for (const [key, value] of Object.entries(record)) {
if (value && typeof value === 'object' && value['edges']) {
continue;
}
if (key === '__typename') {
continue;
}
sanitizedRecord[key] = value;
}
return sanitizedRecord;
}
}