Add crud actions (#8500)

Adding update / delete / find actions

Update and delete are not really different than creation.

Find uses the same logique as for graphql filters.
Expected formats are:

Filter
```
{
  "and": [
    {
      "name": {
        "eq": "salut"
      }
    },
    {
      "employees": {
        "eq": "0"
      }
    }
  ]
}
```

Order
`[ { "name": 'AscNullsFirst' } ]`
This commit is contained in:
Thomas Trompette 2024-11-15 10:10:00 +01:00 committed by GitHub
parent 736635a94b
commit a2a272fed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 313 additions and 12 deletions

View File

@ -54,7 +54,7 @@ export class GraphqlQueryParser {
public applyDeletedAtToBuilder(
queryBuilder: SelectQueryBuilder<any>,
recordFilter: ObjectRecordFilter,
recordFilter: Partial<ObjectRecordFilter>,
): SelectQueryBuilder<any> {
if (this.checkForDeletedAtFilter(recordFilter)) {
queryBuilder.withDeleted();

View File

@ -10,10 +10,12 @@ import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event';
import { WorkflowSendEmailStepOutputSchema } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
import { WorkflowRecordCRUDType } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
import {
WorkflowAction,
WorkflowActionType,
@ -44,7 +46,7 @@ export class WorkflowBuilderWorkspaceService {
switch (stepType) {
case WorkflowTriggerType.DATABASE_EVENT: {
return await this.computeDatabaseEventTriggerOutputSchema({
return this.computeDatabaseEventTriggerOutputSchema({
eventName: step.settings.eventName,
workspaceId,
objectMetadataRepository: this.objectMetadataRepository,
@ -57,7 +59,7 @@ export class WorkflowBuilderWorkspaceService {
return {};
}
return await this.computeRecordOutputSchema({
return this.computeRecordOutputSchema({
objectType,
workspaceId,
objectMetadataRepository: this.objectMetadataRepository,
@ -70,7 +72,7 @@ export class WorkflowBuilderWorkspaceService {
const { serverlessFunctionId, serverlessFunctionVersion } =
step.settings.input;
return await this.computeCodeActionOutputSchema({
return this.computeCodeActionOutputSchema({
serverlessFunctionId,
serverlessFunctionVersion,
workspaceId,
@ -79,8 +81,9 @@ export class WorkflowBuilderWorkspaceService {
});
}
case WorkflowActionType.RECORD_CRUD:
return await this.computeRecordOutputSchema({
return this.computeRecordCrudOutputSchema({
objectType: step.settings.input.objectName,
operationType: step.settings.input.type,
workspaceId,
objectMetadataRepository: this.objectMetadataRepository,
});
@ -122,6 +125,34 @@ export class WorkflowBuilderWorkspaceService {
);
}
private async computeRecordCrudOutputSchema<Entity>({
objectType,
operationType,
workspaceId,
objectMetadataRepository,
}: {
objectType: string;
operationType: string;
workspaceId: string;
objectMetadataRepository: Repository<ObjectMetadataEntity>;
}) {
const recordOutputSchema = await this.computeRecordOutputSchema<Entity>({
objectType,
workspaceId,
objectMetadataRepository,
});
if (operationType === WorkflowRecordCRUDType.READ) {
return {
first: recordOutputSchema,
last: recordOutputSchema,
totalCount: generateFakeValue('number'),
};
}
return recordOutputSchema;
}
private async computeRecordOutputSchema<Entity>({
objectType,
workspaceId,

View File

@ -0,0 +1,13 @@
import { CustomException } from 'src/utils/custom-exception';
export class RecordCRUDActionException extends CustomException {
code: RecordCRUDActionExceptionCode;
constructor(message: string, code: RecordCRUDActionExceptionCode) {
super(message, code);
}
}
export enum RecordCRUDActionExceptionCode {
INVALID_REQUEST = 'INVALID_REQUEST',
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
}

View File

@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { RecordCRUDWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud.workflow-action';
@Module({
providers: [RecordCRUDWorkflowAction],
imports: [WorkspaceCacheStorageModule],
providers: [RecordCRUDWorkflowAction, ScopedWorkspaceContextFactory],
exports: [RecordCRUDWorkflowAction],
})
export class RecordCRUDActionModule {}

View File

@ -1,18 +1,45 @@
import { Injectable } from '@nestjs/common';
import { Entity } from '@microsoft/microsoft-graph-types';
import { ObjectLiteral } from 'typeorm';
import {
ObjectRecordFilter,
ObjectRecordOrderBy,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.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 { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import {
RecordCRUDActionException,
RecordCRUDActionExceptionCode,
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception';
import {
WorkflowCreateRecordActionInput,
WorkflowDeleteRecordActionInput,
WorkflowReadRecordActionInput,
WorkflowRecordCRUDActionInput,
WorkflowRecordCRUDType,
WorkflowUpdateRecordActionInput,
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-result.type';
@Injectable()
export class RecordCRUDWorkflowAction implements WorkflowAction {
constructor(private readonly twentyORMManager: TwentyORMManager) {}
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
) {}
async execute(
workflowActionInput: WorkflowRecordCRUDActionInput,
@ -20,9 +47,16 @@ export class RecordCRUDWorkflowAction implements WorkflowAction {
switch (workflowActionInput.type) {
case WorkflowRecordCRUDType.CREATE:
return this.createRecord(workflowActionInput);
case WorkflowRecordCRUDType.DELETE:
return this.deleteRecord(workflowActionInput);
case WorkflowRecordCRUDType.UPDATE:
return this.updateRecord(workflowActionInput);
case WorkflowRecordCRUDType.READ:
return this.findRecords(workflowActionInput);
default:
throw new Error(
`Unknown record operation type: ${workflowActionInput.type}`,
throw new RecordCRUDActionException(
`Unknown record operation type`,
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
}
@ -38,8 +72,213 @@ export class RecordCRUDWorkflowAction implements WorkflowAction {
workflowActionInput.objectRecord,
);
const createdObjectRecord = await repository.save(objectRecord);
await repository.save(objectRecord);
return { result: createdObjectRecord };
return {
result: objectRecord,
};
}
private async updateRecord(
workflowActionInput: WorkflowUpdateRecordActionInput,
): Promise<WorkflowActionResult> {
const repository = await this.twentyORMManager.getRepository(
workflowActionInput.objectName,
);
const objectRecord = await repository.findOne({
where: {
id: workflowActionInput.objectRecordId,
},
});
if (!objectRecord) {
throw new RecordCRUDActionException(
`Failed to update: Record ${workflowActionInput.objectName} with id ${workflowActionInput.objectRecordId} not found`,
RecordCRUDActionExceptionCode.RECORD_NOT_FOUND,
);
}
await repository.update(workflowActionInput.objectRecordId, {
...workflowActionInput.objectRecord,
});
return {
result: {
...objectRecord,
...workflowActionInput.objectRecord,
},
};
}
private async deleteRecord(
workflowActionInput: WorkflowDeleteRecordActionInput,
): Promise<WorkflowActionResult> {
const repository = await this.twentyORMManager.getRepository(
workflowActionInput.objectName,
);
const objectRecord = await repository.findOne({
where: {
id: workflowActionInput.objectRecordId,
},
});
if (!objectRecord) {
throw new RecordCRUDActionException(
`Failed to delete: Record ${workflowActionInput.objectName} with id ${workflowActionInput.objectRecordId} not found`,
RecordCRUDActionExceptionCode.RECORD_NOT_FOUND,
);
}
await repository.update(workflowActionInput.objectRecordId, {
deletedAt: new Date(),
});
return {
result: objectRecord,
};
}
private async findRecords(
workflowActionInput: WorkflowReadRecordActionInput,
): Promise<WorkflowActionResult> {
const repository = await this.twentyORMManager.getRepository(
workflowActionInput.objectName,
);
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) {
throw new RecordCRUDActionException(
'Failed to read: Workspace ID is required',
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspaceId);
if (currentCacheVersion === undefined) {
throw new RecordCRUDActionException(
'Failed to read: Metadata cache version not found',
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspaceId,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new RecordCRUDActionException(
'Failed to read: Object metadata collection not found',
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const objectMetadataItemWithFieldsMaps =
objectMetadataMaps.byNameSingular[workflowActionInput.objectName];
if (!objectMetadataItemWithFieldsMaps) {
throw new RecordCRUDActionException(
`Failed to read: Object ${workflowActionInput.objectName} not found`,
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldsMaps.fieldsByName,
objectMetadataMaps,
);
const objectRecords = await this.getObjectRecords(
workflowActionInput,
objectMetadataItemWithFieldsMaps,
objectMetadataMaps,
repository,
graphqlQueryParser,
);
const totalCount = await this.getTotalCount(
workflowActionInput,
repository,
graphqlQueryParser,
);
return {
result: {
first: objectRecords[0],
last: objectRecords[objectRecords.length - 1],
totalCount,
},
};
}
private async getObjectRecords<T extends ObjectLiteral>(
workflowActionInput: WorkflowReadRecordActionInput,
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
repository: WorkspaceRepository<T>,
graphqlQueryParser: GraphqlQueryParser,
) {
const queryBuilder = repository.createQueryBuilder(
workflowActionInput.objectName,
);
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
workflowActionInput.objectName,
workflowActionInput.filter ?? ({} as ObjectRecordFilter),
);
const orderByWithIdCondition = [
...(workflowActionInput.orderBy ?? []),
{ id: OrderByDirection.AscNullsFirst },
] as ObjectRecordOrderBy;
const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
withFilterQueryBuilder,
orderByWithIdCondition,
workflowActionInput.objectName,
false,
);
const nonFormattedObjectRecords = await withOrderByQueryBuilder
.take(workflowActionInput.limit ?? QUERY_MAX_RECORDS)
.getMany();
return formatResult(
nonFormattedObjectRecords,
objectMetadataItemWithFieldsMaps,
objectMetadataMaps,
);
}
private async getTotalCount(
workflowActionInput: WorkflowReadRecordActionInput,
repository: WorkspaceRepository<Entity>,
graphqlQueryParser: GraphqlQueryParser,
) {
const countQueryBuilder = repository.createQueryBuilder(
workflowActionInput.objectName,
);
const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
countQueryBuilder,
workflowActionInput.objectName,
workflowActionInput.filter ?? ({} as ObjectRecordFilter),
);
const withDeletedCountQueryBuilder =
graphqlQueryParser.applyDeletedAtToBuilder(
withFilterCountQueryBuilder,
workflowActionInput.filter
? workflowActionInput.filter
: ({} as ObjectRecordFilter),
);
return withDeletedCountQueryBuilder.getCount();
}
}

View File

@ -1,9 +1,15 @@
import {
ObjectRecordFilter,
ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
type ObjectRecord = Record<string, any>;
export enum WorkflowRecordCRUDType {
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
READ = 'read',
}
export type WorkflowCreateRecordActionInput = {
@ -25,7 +31,16 @@ export type WorkflowDeleteRecordActionInput = {
objectRecordId: string;
};
export type WorkflowReadRecordActionInput = {
type: WorkflowRecordCRUDType.READ;
objectName: string;
filter?: Partial<ObjectRecordFilter>;
orderBy?: Partial<ObjectRecordOrderBy>;
limit?: number;
};
export type WorkflowRecordCRUDActionInput =
| WorkflowCreateRecordActionInput
| WorkflowUpdateRecordActionInput
| WorkflowDeleteRecordActionInput;
| WorkflowDeleteRecordActionInput
| WorkflowReadRecordActionInput;