Fix graphql query createMany resolver with nested relations (#7061)

Looks like insert() does not return foreign keys. We could eventually
call findMany after but it seems that's what save() is doing so I'm
replacing insert with save.
```typescript
/**
 * Flag to determine whether the entity that is being persisted
 * should be reloaded during the persistence operation.
 *
 * It will work only on databases which does not support RETURNING / OUTPUT statement.
 * Enabled by default.
 */
reload?: boolean;
```

Note: save() also does an upsert by default with no way to configure
that so if we want to keep that behaviour we will need to add a check
before
```typescript
if (args.upsert) {
    const existingRecords = await repository.findBy({
      id: Any(args.data.map((record) => record.id)),
    });
    ...
```

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2024-09-18 18:43:45 +02:00
committed by Charles Bochet
parent 147eaaa5b0
commit 02618b3e6a
13 changed files with 347 additions and 121 deletions

View File

@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
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';
@Module({
imports: [WorkspaceQueryHookModule, WorkspaceQueryRunnerModule],
providers: [GraphqlQueryRunnerService],
exports: [GraphqlQueryRunnerService],
})

View File

@ -9,22 +9,49 @@ import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/inter
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DestroyOneResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
ResolverArgsType,
} 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 { 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 { 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';
@Injectable()
export class GraphqlQueryRunnerService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceQueryHookService: WorkspaceQueryHookService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@LogExecutionTime()
@ -38,7 +65,30 @@ export class GraphqlQueryRunnerService {
const graphqlQueryFindOneResolverService =
new GraphqlQueryFindOneResolverService(this.twentyORMGlobalManager);
return graphqlQueryFindOneResolverService.findOne(args, options);
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,
options,
ResolverArgsType.FindOne,
)) as FindOneResolverArgs<Filter>;
return graphqlQueryFindOneResolverService.findOne(computedArgs, options);
}
@LogExecutionTime()
@ -53,7 +103,78 @@ export class GraphqlQueryRunnerService {
const graphqlQueryFindManyResolverService =
new GraphqlQueryFindManyResolverService(this.twentyORMGlobalManager);
return graphqlQueryFindManyResolverService.findMany(args, options);
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);
}
@LogExecutionTime()
async createOne<ObjectRecord extends IRecord = IRecord>(
args: CreateOneResolverArgs<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;
}
@LogExecutionTime()
@ -64,7 +185,99 @@ export class GraphqlQueryRunnerService {
const graphqlQueryCreateManyResolverService =
new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager);
return graphqlQueryCreateManyResolverService.createMany(args, options);
const { authContext, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
args.data.forEach((record) => {
if (record?.id) {
assertIsValidUuid(record.id);
}
});
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'createMany',
args,
);
const computedArgs = (await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
ResolverArgsType.CreateMany,
)) as CreateManyResolverArgs<ObjectRecord>;
const results = (await graphqlQueryCreateManyResolverService.createMany(
computedArgs,
options,
)) as ObjectRecord[];
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'createMany',
results,
);
await this.triggerWebhooks<ObjectRecord>(
results,
CallWebhookJobsJobOperation.create,
options,
);
this.emitCreateEvents<ObjectRecord>(
results,
authContext,
objectMetadataItem,
);
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 async triggerWebhooks<Record>(
jobsData: Record[] | undefined,
operation: CallWebhookJobsJobOperation,
options: WorkspaceQueryRunnerOptions,
) {
if (!Array.isArray(jobsData)) {
return;
}
jobsData.forEach((jobData) => {
this.messageQueueService.add<CallWebhookJobsJobData>(
CallWebhookJobsJob.name,
{
record: jobData,
workspaceId: options.authContext.workspace.id,
operation,
objectMetadataItem: options.objectMetadataItem,
},
{ retryLimit: 3 },
);
});
}
@LogExecutionTime()

View File

@ -1,9 +1,14 @@
import { InsertResult } from 'typeorm';
import graphqlFields from 'graphql-fields';
import { In, InsertResult } from 'typeorm';
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 { 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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
export class GraphqlQueryCreateManyResolverService {
@ -17,17 +22,58 @@ export class GraphqlQueryCreateManyResolverService {
args: CreateManyResolverArgs<Partial<ObjectRecord>>,
options: WorkspaceQueryRunnerOptions,
): Promise<ObjectRecord[] | undefined> {
const { authContext, objectMetadataItem } = options;
const { authContext, objectMetadataItem, objectMetadataCollection, info } =
options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
authContext.workspace.id,
objectMetadataItem.nameSingular,
);
const insertResult: InsertResult = !args.upsert
? await repository.insert(args.data)
: await repository.upsert(args.data, ['id']);
const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection,
);
const objectMetadata = getObjectMetadataOrThrow(
objectMetadataMap,
objectMetadataItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadata.fields,
objectMetadataMap,
);
return insertResult.generatedMaps as ObjectRecord[];
const selectedFields = graphqlFields(info);
const { select, relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem,
selectedFields,
);
const objectRecords: InsertResult = !args.upsert
? await repository.insert(args.data)
: await repository.upsert(args.data, {
conflictPaths: ['id'],
skipUpdateIfNoValuesChanged: true,
});
const upsertedRecords = await repository.find({
where: {
id: In(objectRecords.generatedMaps.map((record) => record.id)),
},
select,
relations,
});
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
return upsertedRecords.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord(
record,
objectMetadataItem.nameSingular,
1,
1,
),
);
}
}

View File

@ -61,9 +61,11 @@ export class GraphqlQueryFindManyResolverService {
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { select, relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem,
graphqlFields(info),
selectedFields,
);
const isForwardPagination = !isDefined(args.before);
const order = graphqlQueryParser.parseOrder(
@ -84,7 +86,10 @@ export class GraphqlQueryFindManyResolverService {
relations,
take: limit + 1,
};
const totalCount = await repository.count({ where });
const totalCount = isDefined(selectedFields.totalCount)
? await repository.count({ where })
: 0;
if (cursor) {
applyRangeFilter(where, cursor, isForwardPagination);

View File

@ -51,9 +51,11 @@ export class GraphqlQueryFindOneResolverService {
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { select, relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem,
graphqlFields(info),
selectedFields,
);
const where = graphqlQueryParser.parseFilter(args.filter ?? ({} as Filter));