Refactor backend folder structure (#4505)

* Refactor backend folder structure

Co-authored-by: Charles Bochet <charles@twenty.com>

* fix tests

* fix

* move yoga hooks

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2024-03-15 18:37:09 +01:00
committed by GitHub
parent afb9b3e375
commit 2c09096edd
523 changed files with 1386 additions and 1856 deletions

View File

@ -0,0 +1,63 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
describe('QueryRunnerArgsFactory', () => {
const recordPositionFactory = {
create: jest.fn().mockResolvedValue(2),
};
const options = {
fieldMetadataCollection: [
{ name: 'position', type: FieldMetadataType.POSITION },
] as FieldMetadataInterface[],
objectMetadataItem: { isCustom: true, nameSingular: 'test' },
} as WorkspaceQueryRunnerOptions;
let factory: QueryRunnerArgsFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QueryRunnerArgsFactory,
{
provide: RecordPositionFactory,
useValue: {
create: recordPositionFactory.create,
},
},
],
}).compile();
factory = module.get<QueryRunnerArgsFactory>(QueryRunnerArgsFactory);
});
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('create', () => {
it('should simply return the args when data is an empty array', async () => {
const args = {
data: [],
};
const result = await factory.create(args, options);
expect(result).toEqual(args);
});
it('should override args when of type array', async () => {
const args = { data: [{ id: 1 }, { position: 'last' }] };
const result = await factory.create(args, options);
expect(result).toEqual({
data: [{ id: 1 }, { position: 2 }],
});
});
});
});

View File

@ -0,0 +1,64 @@
import { TestingModule, Test } from '@nestjs/testing';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { RecordPositionQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
describe('RecordPositionFactory', () => {
const recordPositionQueryFactory = {
create: jest.fn().mockResolvedValue('query'),
};
const workspaceDataSourceService = {
getSchemaName: jest.fn().mockReturnValue('schemaName'),
executeRawQuery: jest.fn().mockResolvedValue([{ position: 1 }]),
};
let factory: RecordPositionFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordPositionFactory,
{
provide: RecordPositionQueryFactory,
useValue: recordPositionQueryFactory,
},
{
provide: WorkspaceDataSourceService,
useValue: workspaceDataSourceService,
},
],
}).compile();
factory = module.get<RecordPositionFactory>(RecordPositionFactory);
});
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('create', () => {
const objectMetadata = { isCustom: false, nameSingular: 'company' };
const workspaceId = 'workspaceId';
it('should return the value when value is a number', async () => {
const value = 1;
const result = await factory.create(value, objectMetadata, workspaceId);
expect(result).toEqual(value);
});
it('should return the existing position / 2 when value is first', async () => {
const value = 'first';
const result = await factory.create(value, objectMetadata, workspaceId);
expect(result).toEqual(0.5);
});
it('should return the existing position + 1 when value is last', async () => {
const value = 'last';
const result = await factory.create(value, objectMetadata, workspaceId);
expect(result).toEqual(2);
});
});
});

View File

@ -0,0 +1,7 @@
import { RecordPositionFactory } from './record-position.factory';
import { QueryRunnerArgsFactory } from './query-runner-args.factory';
export const workspaceQueryRunnerFactories = [
QueryRunnerArgsFactory,
RecordPositionFactory,
];

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RecordPositionFactory } from './record-position.factory';
@Injectable()
export class QueryRunnerArgsFactory {
constructor(private readonly recordPositionFactory: RecordPositionFactory) {}
async create(
args: Record<string, any>,
options: WorkspaceQueryRunnerOptions,
) {
const fieldMetadataCollection = options.fieldMetadataCollection;
const fieldMetadataMap = new Map(
fieldMetadataCollection.map((fieldMetadata) => [
fieldMetadata.name,
fieldMetadata,
]),
);
return {
data: await Promise.all(
args.data.map((arg) =>
this.overrideArgByFieldMetadata(arg, options, fieldMetadataMap),
),
),
};
}
private async overrideArgByFieldMetadata(
arg: Record<string, any>,
options: WorkspaceQueryRunnerOptions,
fieldMetadataMap: Map<string, FieldMetadataInterface>,
) {
const createArgPromiseByArgKey = Object.entries(arg).map(
async ([key, value]) => {
const fieldMetadata = fieldMetadataMap.get(key);
if (!fieldMetadata) {
return [key, await Promise.resolve(value)];
}
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
return [
key,
await this.recordPositionFactory.create(
value,
{
isCustom: options.objectMetadataItem.isCustom,
nameSingular: options.objectMetadataItem.nameSingular,
},
options.workspaceId,
),
];
default:
return [key, await Promise.resolve(value)];
}
},
);
const newArgEntries = await Promise.all(createArgPromiseByArgKey);
return Object.fromEntries(newArgEntries);
}
}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import {
RecordPositionQueryFactory,
RecordPositionQueryType,
} from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
@Injectable()
export class RecordPositionFactory {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly recordPositionQueryFactory: RecordPositionQueryFactory,
) {}
async create(
value: number | 'first' | 'last',
objectMetadata: { isCustom: boolean; nameSingular: string },
workspaceId: string,
): Promise<number> {
if (typeof value === 'number') {
return value;
}
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const query = await this.recordPositionQueryFactory.create(
RecordPositionQueryType.GET,
value,
objectMetadata,
dataSourceSchema,
);
const records = await this.workspaceDataSourceService.executeRawQuery(
query,
[],
workspaceId,
undefined,
);
return (
(value === 'first'
? records[0]?.position / 2
: records[0]?.position + 1) || 1
);
}
}

View File

@ -0,0 +1,8 @@
import { IPageInfo } from './page-info.interface';
import { IEdge } from './edge.interface';
export interface IConnection<T, CustomEdge extends IEdge<T> = IEdge<T>> {
edges: Array<CustomEdge>;
pageInfo: IPageInfo;
totalCount: number;
}

View File

@ -0,0 +1,4 @@
export interface IEdge<T> {
cursor: string;
node: T;
}

View File

@ -0,0 +1,6 @@
export interface IPageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: string;
endCursor?: string;
}

View File

@ -0,0 +1,15 @@
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
export interface PGGraphQLResponse<Data = any> {
resolve: {
data: Data;
errors: any[];
};
}
export type PGGraphQLResult<Data = any> = [PGGraphQLResponse<Data>];
export interface PGGraphQLMutation<Record = IRecord> {
affectedRows: number;
records: Record[];
}

View File

@ -0,0 +1,13 @@
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
export interface WorkspaceQueryRunnerOptions {
workspaceId: string;
userId: string | undefined;
info: GraphQLResolveInfo;
objectMetadataItem: ObjectMetadataInterface;
fieldMetadataCollection: FieldMetadataInterface[];
objectMetadataCollection: ObjectMetadataInterface[];
}

View File

@ -0,0 +1,93 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import {
CallWebhookJob,
CallWebhookJobData,
} from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook.job';
export enum CallWebhookJobsJobOperation {
create = 'create',
update = 'update',
delete = 'delete',
}
export type CallWebhookJobsJobData = {
workspaceId: string;
objectMetadataItem: ObjectMetadataInterface;
record: any;
operation: CallWebhookJobsJobOperation;
};
@Injectable()
export class CallWebhookJobsJob
implements MessageQueueJob<CallWebhookJobsJobData>
{
private readonly logger = new Logger(CallWebhookJobsJob.name);
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly dataSourceService: DataSourceService,
@Inject(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async handle(data: CallWebhookJobsJobData): Promise<void> {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
data.workspaceId,
);
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
data.workspaceId,
);
const nameSingular = data.objectMetadataItem.nameSingular;
const operation = data.operation;
const eventType = `${operation}.${nameSingular}`;
const webhooks: { id: string; targetUrl: string }[] =
await workspaceDataSource?.query(
`
SELECT * FROM ${dataSourceMetadata.schema}."webhook"
WHERE operation LIKE '%${eventType}%'
OR operation LIKE '%*.${nameSingular}%'
OR operation LIKE '%${operation}.*%'
OR operation LIKE '%*.*%'
`,
);
webhooks.forEach((webhook) => {
this.messageQueueService.add<CallWebhookJobData>(
CallWebhookJob.name,
{
targetUrl: webhook.targetUrl,
eventType,
objectMetadata: {
id: data.objectMetadataItem.id,
nameSingular: data.objectMetadataItem.nameSingular,
},
workspaceId: data.workspaceId,
webhookId: webhook.id,
eventDate: new Date(),
record: data.record,
},
{ retryLimit: 3 },
);
});
if (webhooks.length) {
this.logger.log(
`CallWebhookJobsJob on eventType '${eventType}' called on webhooks ids [\n"${webhooks
.map((webhook) => webhook.id)
.join('",\n"')}"\n]`,
);
}
}
}

View File

@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface';
export type CallWebhookJobData = {
targetUrl: string;
eventType: string;
objectMetadata: { id: string; nameSingular: string };
workspaceId: string;
webhookId: string;
eventDate: Date;
record: any;
};
@Injectable()
export class CallWebhookJob implements MessageQueueJob<CallWebhookJobData> {
private readonly logger = new Logger(CallWebhookJob.name);
constructor(private readonly httpService: HttpService) {}
async handle(data: CallWebhookJobData): Promise<void> {
try {
await this.httpService.axiosRef.post(data.targetUrl, data);
this.logger.log(
`CallWebhookJob successfully called on targetUrl '${data.targetUrl}'`,
);
} catch (err) {
this.logger.error(
`Error calling webhook on targetUrl '${data.targetUrl}': ${err}`,
);
}
}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface';
import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service';
export type RecordPositionBackfillJobData = {
workspaceId: string;
objectMetadata: { nameSingular: string; isCustom: boolean };
recordId: string;
};
@Injectable()
export class RecordPositionBackfillJob
implements MessageQueueJob<RecordPositionBackfillJobData>
{
constructor(
private readonly recordPositionBackfillService: RecordPositionBackfillService,
) {}
async handle(data: RecordPositionBackfillJobData): Promise<void> {
this.recordPositionBackfillService.backfill(
data.workspaceId,
data.objectMetadata,
data.recordId,
);
}
}

View File

@ -0,0 +1,56 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
CreatedObjectMetadata,
ObjectRecordCreateEvent,
} from 'src/engine/integrations/event-emitter/types/object-record-create.event';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import {
RecordPositionBackfillJob,
RecordPositionBackfillJobData,
} from 'src/engine/api/graphql/workspace-query-runner/jobs/record-position-backfill.job';
@Injectable()
export class RecordPositionListener {
constructor(
@Inject(MessageQueue.recordPositionBackfillQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('*.created')
async handleAllCreate(payload: ObjectRecordCreateEvent<any>) {
if (!hasPositionField(payload.createdObjectMetadata)) {
return;
}
if (hasPositionSet(payload.createdRecord)) {
return;
}
this.messageQueueService.add<RecordPositionBackfillJobData>(
RecordPositionBackfillJob.name,
{
workspaceId: payload.workspaceId,
recordId: payload.createdRecord.id,
objectMetadata: payload.createdObjectMetadata,
},
);
}
}
const hasPositionField = (
createdObjectMetadata: CreatedObjectMetadata,
): boolean => {
return (
createdObjectMetadata.isCustom ||
['opportunity', 'company', 'people'].includes(
createdObjectMetadata.nameSingular,
)
);
};
const hasPositionSet = (createdRecord: any): boolean => {
return !!createdRecord?.position;
};

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { RecordPositionQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service';
@Module({
imports: [WorkspaceDataSourceModule],
providers: [
RecordPositionFactory,
RecordPositionQueryFactory,
RecordPositionBackfillService,
],
exports: [RecordPositionBackfillService],
})
export class RecordPositionBackfillModule {}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import {
RecordPositionQueryFactory,
RecordPositionQueryType,
} from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
@Injectable()
export class RecordPositionBackfillService {
constructor(
private readonly recordPositionFactory: RecordPositionFactory,
private readonly recordPositionQueryFactory: RecordPositionQueryFactory,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
async backfill(
workspaceId: string,
objectMetadata: { nameSingular: string; isCustom: boolean },
recordId: string,
) {
const position = await this.recordPositionFactory.create(
'last',
objectMetadata as ObjectMetadataInterface,
workspaceId,
);
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const query = await this.recordPositionQueryFactory.create(
RecordPositionQueryType.UPDATE,
position,
objectMetadata as ObjectMetadataInterface,
dataSourceSchema,
);
this.workspaceDataSourceService.executeRawQuery(
query,
[position, recordId],
workspaceId,
undefined,
);
}
}

View File

@ -0,0 +1,105 @@
import {
isSpecialKey,
handleSpecialKey,
parseResult,
} from 'src/engine/api/graphql/workspace-query-runner/utils/parse-result.util';
describe('isSpecialKey', () => {
test('should return true if the key starts with "___"', () => {
expect(isSpecialKey('___specialKey')).toBe(true);
});
test('should return false if the key does not start with "___"', () => {
expect(isSpecialKey('normalKey')).toBe(false);
});
});
describe('handleSpecialKey', () => {
let result;
beforeEach(() => {
result = {};
});
test('should correctly process a special key and add it to the result object', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
expect(result).toEqual({
complexField: {
link: 'value1',
},
});
});
test('should add values under the same newKey if called multiple times', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
handleSpecialKey(result, '___complexField_text', 'value2');
expect(result).toEqual({
complexField: {
link: 'value1',
text: 'value2',
},
});
});
test('should not create a new field if the special key is not correctly formed', () => {
handleSpecialKey(result, '___complexField', 'value1');
expect(result).toEqual({});
});
});
describe('parseResult', () => {
test('should recursively parse an object and handle special keys', () => {
const obj = {
normalField: 'value1',
___specialField_part1: 'value2',
nested: {
___specialFieldNested_part2: 'value3',
},
};
const expectedResult = {
normalField: 'value1',
specialField: {
part1: 'value2',
},
nested: {
specialFieldNested: {
part2: 'value3',
},
},
};
expect(parseResult(obj)).toEqual(expectedResult);
});
test('should handle arrays and parse each element', () => {
const objArray = [
{
___specialField_part1: 'value1',
},
{
___specialField_part2: 'value2',
},
];
const expectedResult = [
{
specialField: {
part1: 'value1',
},
},
{
specialField: {
part2: 'value2',
},
},
];
expect(parseResult(objArray)).toEqual(expectedResult);
});
test('should return the original value if it is not an object or array', () => {
expect(parseResult('stringValue')).toBe('stringValue');
expect(parseResult(12345)).toBe(12345);
});
});

View File

@ -0,0 +1,34 @@
import {
BadRequestException,
HttpException,
InternalServerErrorException,
} from '@nestjs/common';
interface PgGraphQLErrorMapping {
[key: string]: (command: string, objectName: string) => HttpException;
}
const pgGraphQLErrorMapping: PgGraphQLErrorMapping = {
'delete impacts too many records': (command, objectName) =>
new BadRequestException(
`Cannot ${command} ${objectName} because it impacts too many records.`,
),
};
export const computePgGraphQLError = (
command: string,
objectName: string,
errors: any[],
) => {
const error = errors[0];
const errorMessage = error?.message;
const mappedError = pgGraphQLErrorMapping[errorMessage];
if (mappedError) {
return mappedError(command, objectName);
}
return new InternalServerErrorException(
`GraphQL errors on ${command}${objectName}: ${JSON.stringify(error)}`,
);
};

View File

@ -0,0 +1,53 @@
export const isSpecialKey = (key: string): boolean => {
return key.startsWith('___');
};
export const handleSpecialKey = (
result: any,
key: string,
value: any,
): void => {
const parts = key.split('_').filter((part) => part);
// If parts don't contain enough information, return without altering result
if (parts.length < 2) {
return;
}
const newKey = parts.slice(0, -1).join('');
const subKey = parts[parts.length - 1];
if (!result[newKey]) {
result[newKey] = {};
}
result[newKey][subKey] = value;
};
export const parseResult = (obj: any): any => {
if (obj === null || typeof obj !== 'object' || typeof obj === 'function') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => parseResult(item));
}
const result: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
result[key] = parseResult(obj[key]);
} else if (key === '__typename') {
result[key] = obj[key].replace(/^_*/, '');
} else if (isSpecialKey(key)) {
handleSpecialKey(result, key, obj[key]);
} else {
result[key] = obj[key];
}
}
}
return result;
};

View File

@ -0,0 +1,9 @@
import { ResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export interface WorkspacePreQueryHook {
execute(
userId: string | undefined,
workspaceId: string,
payload: ResolverArgs,
): Promise<void>;
}

View File

@ -0,0 +1,52 @@
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export type ExecutePreHookMethod =
| 'createMany'
| 'createOne'
| 'deleteMany'
| 'deleteOne'
| 'findMany'
| 'findOne'
| 'findDuplicates'
| 'updateMany'
| 'updateOne';
export type ObjectName = string;
export type HookName = string;
export type WorkspaceQueryHook = {
[key in ObjectName]: {
[key in ExecutePreHookMethod]?: HookName[];
};
};
export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? CreateManyResolverArgs
: T extends 'createOne'
? CreateOneResolverArgs
: T extends 'deleteMany'
? DeleteManyResolverArgs
: T extends 'deleteOne'
? DeleteOneResolverArgs
: T extends 'findMany'
? FindManyResolverArgs
: T extends 'findOne'
? FindOneResolverArgs
: T extends 'updateMany'
? UpdateManyResolverArgs
: T extends 'updateOne'
? UpdateOneResolverArgs
: T extends 'findDuplicates'
? FindDuplicatesResolverArgs
: never;

View File

@ -0,0 +1,11 @@
import { MessageFindManyPreQueryHook } from 'src/modules/messaging/query-hooks/message/message-find-many.pre-query.hook';
import { MessageFindOnePreQueryHook } from 'src/modules/messaging/query-hooks/message/message-find-one.pre-query-hook';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type';
// TODO: move to a decorator
export const workspacePreQueryHooks: WorkspaceQueryHook = {
message: {
findOne: [MessageFindOnePreQueryHook.name],
findMany: [MessageFindManyPreQueryHook.name],
},
};

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MessagingQueryHookModule } from 'src/modules/messaging/query-hooks/messaging-query-hook.module';
import { WorkspacePreQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
@Module({
imports: [MessagingQueryHookModule],
providers: [WorkspacePreQueryHookService],
exports: [WorkspacePreQueryHookService],
})
export class WorkspacePreQueryHookModule {}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { WorkspacePreQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface';
import {
ExecutePreHookMethod,
WorkspacePreQueryHookPayload,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type';
import { workspacePreQueryHooks } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.config';
@Injectable()
export class WorkspacePreQueryHookService {
constructor(private readonly workspaceQueryHookModuleRef: ModuleRef) {}
public async executePreHooks<T extends ExecutePreHookMethod>(
userId: string | undefined,
workspaceId: string,
objectName: string,
method: T,
payload: WorkspacePreQueryHookPayload<T>,
): Promise<void> {
const hooks = workspacePreQueryHooks[objectName] || [];
for (const hookName of Object.values(hooks[method] ?? [])) {
const hook: WorkspacePreQueryHook =
await this.workspaceQueryHookModuleRef.get(hookName, {
strict: false,
});
await hook.execute(userId, workspaceId, payload);
}
}
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { WorkspaceQueryBuilderModule } from 'src/engine/api/graphql/workspace-query-builder/workspace-query-builder.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspacePreQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.module';
import { workspaceQueryRunnerFactories } from 'src/engine/api/graphql/workspace-query-runner/factories';
import { RecordPositionListener } from 'src/engine/api/graphql/workspace-query-runner/listeners/record-position.listener';
import { WorkspaceQueryRunnerService } from './workspace-query-runner.service';
@Module({
imports: [
WorkspaceQueryBuilderModule,
WorkspaceDataSourceModule,
WorkspacePreQueryHookModule,
],
providers: [
WorkspaceQueryRunnerService,
...workspaceQueryRunnerFactories,
RecordPositionListener,
],
exports: [WorkspaceQueryRunnerService],
})
export class WorkspaceQueryRunnerModule {}

View File

@ -0,0 +1,519 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import isEmpty from 'lodash.isempty';
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import {
Record as IRecord,
RecordFilter,
RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceQueryBuilderFactory } from 'src/engine/api/graphql/workspace-query-builder/workspace-query-builder.factory';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import {
CallWebhookJobsJob,
CallWebhookJobsJobData,
CallWebhookJobsJobOperation,
} from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job';
import { parseResult } from 'src/engine/api/graphql/workspace-query-runner/utils/parse-result.util';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event';
import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event';
import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event';
import { WorkspacePreQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { NotFoundError } from 'src/engine/filters/utils/graphql-errors.util';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
import {
PGGraphQLMutation,
PGGraphQLResult,
} from './interfaces/pg-graphql.interface';
import { computePgGraphQLError } from './utils/compute-pg-graphql-error.util';
@Injectable()
export class WorkspaceQueryRunnerService {
private readonly logger = new Logger(WorkspaceQueryRunnerService.name);
constructor(
private readonly workspaceQueryBuilderFactory: WorkspaceQueryBuilderFactory,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
@Inject(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
private readonly eventEmitter: EventEmitter2,
private readonly workspacePreQueryHookService: WorkspacePreQueryHookService,
private readonly environmentService: EnvironmentService,
) {}
async findMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
>(
args: FindManyResolverArgs<Filter, OrderBy>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<Record> | undefined> {
const { workspaceId, userId, objectMetadataItem } = options;
const start = performance.now();
const query = await this.workspaceQueryBuilderFactory.findMany(
args,
options,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findMany',
args,
);
const result = await this.execute(query, workspaceId);
const end = performance.now();
this.logger.log(
`query time: ${end - start} ms on query ${
options.objectMetadataItem.nameSingular
}`,
);
return this.parseResult<IConnection<Record>>(
result,
objectMetadataItem,
'',
);
}
async findOne<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: FindOneResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new BadRequestException('Missing filter argument');
}
const { workspaceId, userId, objectMetadataItem } = options;
const query = await this.workspaceQueryBuilderFactory.findOne(
args,
options,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findOne',
args,
);
const result = await this.execute(query, workspaceId);
const parsedResult = this.parseResult<IConnection<Record>>(
result,
objectMetadataItem,
'',
);
return parsedResult?.edges?.[0]?.node;
}
async findDuplicates<TRecord extends IRecord = IRecord>(
args: FindDuplicatesResolverArgs<TRecord>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<TRecord> | undefined> {
if (!args.data && !args.id) {
throw new BadRequestException(
'You have to provide either "data" or "id" argument',
);
}
if (!args.id && isEmpty(args.data)) {
throw new BadRequestException(
'The "data" condition can not be empty when ID input not provided',
);
}
const { workspaceId, userId, objectMetadataItem } = options;
let existingRecord: Record<string, unknown> | undefined;
if (args.id) {
const existingRecordQuery =
this.workspaceQueryBuilderFactory.findDuplicatesExistingRecord(
args.id,
options,
);
const existingRecordResult = await this.execute(
existingRecordQuery,
workspaceId,
);
const parsedResult = this.parseResult<Record<string, unknown>>(
existingRecordResult,
objectMetadataItem,
'',
);
existingRecord = parsedResult?.edges?.[0]?.node;
if (!existingRecord) {
throw new NotFoundError(`Object with id ${args.id} not found`);
}
}
const query = await this.workspaceQueryBuilderFactory.findDuplicates(
args,
options,
existingRecord,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findDuplicates',
args,
);
const result = await this.execute(query, workspaceId);
return this.parseResult<IConnection<TRecord>>(
result,
objectMetadataItem,
'',
);
}
async createMany<Record extends IRecord = IRecord>(
args: CreateManyResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const computedArgs = await this.queryRunnerArgsFactory.create(
args,
options,
);
const query = await this.workspaceQueryBuilderFactory.createMany(
computedArgs,
options,
);
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'insertInto',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.create,
options,
);
parsedResults.forEach((record) => {
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.created`, {
workspaceId,
createdRecord: this.removeNestedProperties(record),
createdObjectMetadata: {
nameSingular: objectMetadataItem.nameSingular,
isCustom: objectMetadataItem.isCustom,
},
} satisfies ObjectRecordCreateEvent<any>);
});
return parsedResults;
}
async createOne<Record extends IRecord = IRecord>(
args: CreateOneResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const results = await this.createMany({ data: [args.data] }, options);
return results?.[0];
}
async updateOne<Record extends IRecord = IRecord>(
args: UpdateOneResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const { workspaceId, objectMetadataItem } = options;
const existingRecord = await this.findOne(
{ filter: { id: { eq: args.id } } } as FindOneResolverArgs,
options,
);
const query = await this.workspaceQueryBuilderFactory.updateOne(
args,
options,
);
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'update',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.update,
options,
);
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.updated`, {
workspaceId,
previousRecord: this.removeNestedProperties(existingRecord as Record),
updatedRecord: this.removeNestedProperties(parsedResults?.[0]),
} satisfies ObjectRecordUpdateEvent<any>);
return parsedResults?.[0];
}
async updateMany<Record extends IRecord = IRecord>(
args: UpdateManyResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',
);
const query = await this.workspaceQueryBuilderFactory.updateMany(args, {
...options,
atMost: maximumRecordAffected,
});
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'update',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.update,
options,
);
return parsedResults;
}
async deleteMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: DeleteManyResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',
);
const query = await this.workspaceQueryBuilderFactory.deleteMany(args, {
...options,
atMost: maximumRecordAffected,
});
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.delete,
options,
);
parsedResults.forEach((record) => {
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.deleted`, {
workspaceId,
deletedRecord: [this.removeNestedProperties(record)],
} satisfies ObjectRecordDeleteEvent<any>);
});
return parsedResults;
}
async deleteOne<Record extends IRecord = IRecord>(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const { workspaceId, objectMetadataItem } = options;
const query = await this.workspaceQueryBuilderFactory.deleteOne(
args,
options,
);
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.delete,
options,
);
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.deleted`, {
workspaceId,
deletedRecord: this.removeNestedProperties(parsedResults?.[0]),
} satisfies ObjectRecordDeleteEvent<any>);
return parsedResults?.[0];
}
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;
}
sanitizedRecord[key] = value;
}
return sanitizedRecord;
}
async execute(
query: string,
workspaceId: string,
): Promise<PGGraphQLResult | undefined> {
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
workspaceId,
);
await workspaceDataSource?.query(`
SET search_path TO ${this.workspaceDataSourceService.getSchemaName(
workspaceId,
)};
`);
const results = await workspaceDataSource?.query<PGGraphQLResult>(`
SELECT graphql.resolve($$
${query}
$$);
`);
return results;
}
private parseResult<Result>(
graphqlResult: PGGraphQLResult | undefined,
objectMetadataItem: ObjectMetadataInterface,
command: string,
): Result {
const entityKey = `${command}${computeObjectTargetTable(
objectMetadataItem,
)}Collection`;
const result = graphqlResult?.[0]?.resolve?.data?.[entityKey];
const errors = graphqlResult?.[0]?.resolve?.errors;
if (!result) {
this.logger.log(
`No result found for ${entityKey}, graphqlResult: ` +
JSON.stringify(graphqlResult, null, 3),
);
}
if (
result &&
['update', 'deleteFrom'].includes(command) &&
!result.affectedCount
) {
throw new BadRequestException('No rows were affected.');
}
if (errors && errors.length > 0) {
const error = computePgGraphQLError(
command,
objectMetadataItem.nameSingular,
errors,
);
throw error;
}
return parseResult(result);
}
async executeAndParse<Result>(
query: string,
objectMetadataItem: ObjectMetadataInterface,
command: string,
workspaceId: string,
): Promise<Result> {
const result = await this.execute(query, workspaceId);
return this.parseResult(result, objectMetadataItem, command);
}
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.workspaceId,
operation,
objectMetadataItem: options.objectMetadataItem,
},
{ retryLimit: 3 },
);
});
}
}