From 8e4da3c7273e870996852d81a5b15b2921d537dd Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 5 Jun 2026 14:07:27 +0000 Subject: [PATCH] feat: implement pure CRUD operations for table entities - Add PureCreateRowInTableUseCase for creating rows in tables. - Add PureDeleteRowFromTableUseCase for deleting rows from tables. - Add PureGetRowsFromTableUseCase for retrieving rows from tables. - Add PureReadRowFromTableUseCase for reading a single row by primary key. - Add PureUpdateRowInTableUseCase for updating rows in tables. - Define interfaces for CRUD use cases in table-pure-crud-use-cases.interface.ts. - Implement end-to-end tests for CRUD operations in non-saas-table-pure-crud-operations-e2e.test.ts. --- backend/src/app.module.ts | 2 + backend/src/common/data-injection.tokens.ts | 6 + .../data-structures/pure-create-row.ds.ts | 7 + .../pure-crud-row-response.ds.ts | 6 + .../data-structures/pure-delete-row.ds.ts | 7 + .../pure-found-rows-response.ds.ts | 10 + .../data-structures/pure-get-rows.ds.ts | 11 + .../data-structures/pure-read-row.ds.ts | 7 + .../data-structures/pure-update-row.ds.ts | 8 + .../table-pure-crud-operations.controller.ts | 283 +++++++++++++ .../table-pure-crud-operations.module.ts | 82 ++++ .../pure-create-row-in-table.use.case.ts | 94 +++++ .../pure-delete-row-from-table.use.case.ts | 94 +++++ .../pure-get-rows-from-table.use.case.ts | 113 +++++ .../pure-read-row-from-table.use.case.ts | 82 ++++ .../pure-update-row-in-table.use.case.ts | 109 +++++ .../table-pure-crud-use-cases.interface.ts | 28 ++ ...aas-table-pure-crud-operations-e2e.test.ts | 385 ++++++++++++++++++ 18 files changed, 1334 insertions(+) create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-create-row.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-crud-row-response.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-delete-row.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-found-rows-response.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-update-row.ds.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/use-cases/pure-create-row-in-table.use.case.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/use-cases/pure-delete-row-from-table.use.case.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/use-cases/pure-update-row-in-table.use.case.ts create mode 100644 backend/src/entities/table/table-pure-crud-operations/use-cases/table-pure-crud-use-cases.interface.ts create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 309849ac0..a76124fde 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -26,6 +26,7 @@ import { PermissionModule } from './entities/permission/permission.module.js'; import { S3WidgetModule } from './entities/s3-widget/s3-widget.module.js'; import { SharedJobsModule } from './entities/shared-jobs/shared-jobs.module.js'; import { TableModule } from './entities/table/table.module.js'; +import { TablePureCrudOperationsModule } from './entities/table/table-pure-crud-operations/table-pure-crud-operations.module.js'; import { TableTriggersModule } from './entities/table-actions/table-action-rules-module/action-rules.module.js'; import { TableActionModule } from './entities/table-actions/table-actions-module/table-action.module.js'; import { TableCategoriesModule } from './entities/table-categories/table-categories.module.js'; @@ -73,6 +74,7 @@ import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js'; PermissionModule, TableLogsModule, TableModule, + TablePureCrudOperationsModule, TableSettingsModule, TableWidgetModule, UserModule, diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index a819f1631..c8817a7e8 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -103,6 +103,12 @@ export enum UseCaseType { EXPORT_CSV_FROM_TABLE = 'EXPORT_CSV_FROM_TABLE', IMPORT_CSV_TO_TABLE = 'IMPORT_CSV_TO_TABLE', + PURE_CREATE_ROW_IN_TABLE = 'PURE_CREATE_ROW_IN_TABLE', + PURE_READ_ROW_FROM_TABLE = 'PURE_READ_ROW_FROM_TABLE', + PURE_UPDATE_ROW_IN_TABLE = 'PURE_UPDATE_ROW_IN_TABLE', + PURE_DELETE_ROW_FROM_TABLE = 'PURE_DELETE_ROW_FROM_TABLE', + PURE_GET_ROWS_FROM_TABLE = 'PURE_GET_ROWS_FROM_TABLE', + SAAS_COMPANY_REGISTRATION = 'SAAS_COMPANY_REGISTRATION', SAAS_GET_USER_INFO = 'SAAS_GET_USER_INFO', SAAS_USUAL_REGISTER_USER = 'SAAS_USUAL_REGISTER_USER', diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-create-row.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-create-row.ds.ts new file mode 100644 index 000000000..26737490b --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-create-row.ds.ts @@ -0,0 +1,7 @@ +export class PureCreateRowDs { + connectionId: string; + masterPwd: string; + row: Record; + tableName: string; + userId: string; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-crud-row-response.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-crud-row-response.ds.ts new file mode 100644 index 000000000..f0eadcfb8 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-crud-row-response.ds.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PureCrudRowResponseDs { + @ApiProperty({ type: Object }) + row: Record; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-delete-row.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-delete-row.ds.ts new file mode 100644 index 000000000..f3cfef9dd --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-delete-row.ds.ts @@ -0,0 +1,7 @@ +export class PureDeleteRowDs { + connectionId: string; + masterPwd: string; + primaryKey: Record; + tableName: string; + userId: string; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-found-rows-response.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-found-rows-response.ds.ts new file mode 100644 index 000000000..e0913ef2a --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-found-rows-response.ds.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RowsPaginationDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/rows-pagination.ds.js'; + +export class PureFoundRowsResponseDs { + @ApiProperty({ isArray: true, type: Object }) + rows: Array>; + + @ApiProperty({ type: RowsPaginationDS }) + pagination: RowsPaginationDS; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts new file mode 100644 index 000000000..aeb370042 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts @@ -0,0 +1,11 @@ +export class PureGetRowsDs { + connectionId: string; + masterPwd: string; + page: number; + perPage: number; + query: Record; + searchingFieldValue: string; + tableName: string; + userId: string; + filters?: Record; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts new file mode 100644 index 000000000..948f29f72 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts @@ -0,0 +1,7 @@ +export class PureReadRowDs { + connectionId: string; + masterPwd: string; + primaryKey: Record; + tableName: string; + userId: string; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-update-row.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-update-row.ds.ts new file mode 100644 index 000000000..8869d8712 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-update-row.ds.ts @@ -0,0 +1,8 @@ +export class PureUpdateRowDs { + connectionId: string; + masterPwd: string; + primaryKey: Record; + row: Record; + tableName: string; + userId: string; +} diff --git a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts new file mode 100644 index 000000000..0555e4769 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts @@ -0,0 +1,283 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpException, + HttpStatus, + Inject, + Injectable, + Post, + Put, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType, UseCaseType } from '../../../common/data-injection.tokens.js'; +import { MasterPassword } from '../../../decorators/master-password.decorator.js'; +import { QueryTableName } from '../../../decorators/query-table-name.decorator.js'; +import { SlugUuid } from '../../../decorators/slug-uuid.decorator.js'; +import { Timeout, TimeoutDefaults } from '../../../decorators/timeout.decorator.js'; +import { UserId } from '../../../decorators/user-id.decorator.js'; +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { ConnectionNotFoundException } from '../../../exceptions/custom-exceptions/connection-not-found-exception.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { TableAddGuard } from '../../../guards/table-add.guard.js'; +import { TableDeleteGuard } from '../../../guards/table-delete.guard.js'; +import { TableEditGuard } from '../../../guards/table-edit.guard.js'; +import { TableReadGuard } from '../../../guards/table-read.guard.js'; +import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; +import { isObjectEmpty } from '../../../helpers/is-object-empty.js'; +import { isObjectPropertyExists } from '../../../helpers/validators/is-object-property-exists-validator.js'; +import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; +import { FindAllRowsWithBodyFiltersDto } from '../dto/find-rows-with-body-filters.dto.js'; +import { PureCreateRowDs } from './application/data-structures/pure-create-row.ds.js'; +import { PureCrudRowResponseDs } from './application/data-structures/pure-crud-row-response.ds.js'; +import { PureDeleteRowDs } from './application/data-structures/pure-delete-row.ds.js'; +import { PureFoundRowsResponseDs } from './application/data-structures/pure-found-rows-response.ds.js'; +import { PureGetRowsDs } from './application/data-structures/pure-get-rows.ds.js'; +import { PureReadRowDs } from './application/data-structures/pure-read-row.ds.js'; +import { PureUpdateRowDs } from './application/data-structures/pure-update-row.ds.js'; +import { + IPureCreateRowInTable, + IPureDeleteRowFromTable, + IPureGetRowsFromTable, + IPureReadRowFromTable, + IPureUpdateRowInTable, +} from './use-cases/table-pure-crud-use-cases.interface.js'; + +@UseInterceptors(SentryInterceptor) +@Timeout() +@Controller() +@ApiBearerAuth() +@ApiTags('Table pure CRUD operations') +@Injectable() +export class TablePureCrudOperationsController { + constructor( + @Inject(UseCaseType.PURE_CREATE_ROW_IN_TABLE) + private readonly pureCreateRowInTableUseCase: IPureCreateRowInTable, + @Inject(UseCaseType.PURE_READ_ROW_FROM_TABLE) + private readonly pureReadRowFromTableUseCase: IPureReadRowFromTable, + @Inject(UseCaseType.PURE_UPDATE_ROW_IN_TABLE) + private readonly pureUpdateRowInTableUseCase: IPureUpdateRowInTable, + @Inject(UseCaseType.PURE_DELETE_ROW_FROM_TABLE) + private readonly pureDeleteRowFromTableUseCase: IPureDeleteRowFromTable, + @Inject(UseCaseType.PURE_GET_ROWS_FROM_TABLE) + private readonly pureGetRowsFromTableUseCase: IPureGetRowsFromTable, + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) {} + + @ApiOperation({ + summary: 'Create a single row in a table. API+', + description: 'Insert a new row and return only the created row. Support access with api key.', + }) + @ApiBody({ type: Object }) + @ApiResponse({ status: 201, description: 'Row created.', type: PureCrudRowResponseDs }) + @ApiQuery({ name: 'tableName', required: true }) + @UseGuards(TableAddGuard) + @Post('/table/crud/:connectionId') + async createRow( + @Body() body: Record, + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + @QueryTableName() tableName: string, + ): Promise { + if (!connectionId || isObjectEmpty(body)) { + throw new HttpException({ message: Messages.PARAMETER_MISSING }, HttpStatus.BAD_REQUEST); + } + const inputData: PureCreateRowDs = { + connectionId, + masterPwd, + row: body, + tableName, + userId, + }; + return await this.pureCreateRowInTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ + summary: 'Get table rows with filter parameters in body. API+', + description: + 'Return only rows and pagination. Support search, filtering (in body), ordering and pagination. Support access with api key.', + }) + @ApiResponse({ status: 200, description: 'Rows found.', type: PureFoundRowsResponseDs }) + @ApiBody({ type: FindAllRowsWithBodyFiltersDto }) + @ApiQuery({ name: 'tableName', required: true }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'perPage', required: false }) + @ApiQuery({ name: 'search', required: false }) + @UseGuards(TableReadGuard) + @Timeout(TimeoutDefaults.EXTENDED) + @Throttle({ default: { limit: 300, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @Post('/table/crud/rows/:connectionId') + async getRows( + @QueryTableName() tableName: string, + @Query('page') page: string, + @Query('perPage') perPage: string, + @Query('search') searchingFieldValue: string, + @Query() query: Record, + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + @Body() body: FindAllRowsWithBodyFiltersDto, + ): Promise { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + let parsedPage = 0; + let parsedPerPage = 0; + if (page && perPage) { + parsedPage = parseInt(page, 10); + parsedPerPage = parseInt(perPage, 10); + if ((parsedPage && parsedPage <= 0) || (parsedPerPage && parsedPerPage <= 0)) { + throw new HttpException({ message: Messages.PAGE_AND_PERPAGE_INVALID }, HttpStatus.BAD_REQUEST); + } + } + const inputData: PureGetRowsDs = { + connectionId, + masterPwd, + page: parsedPage, + perPage: parsedPerPage, + query, + searchingFieldValue, + tableName, + userId, + filters: body?.filters, + }; + return await this.pureGetRowsFromTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ + summary: 'Read a single row from a table by primary key. API+', + description: 'Return only the found row by primary key. Support access with api key.', + }) + @ApiResponse({ status: 200, description: 'Row found.', type: PureCrudRowResponseDs }) + @ApiQuery({ name: 'tableName', required: true }) + @UseGuards(TableReadGuard) + @Get('/table/crud/:connectionId') + async readRow( + @Query() query: Record, + @MasterPassword() masterPwd: string, + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @QueryTableName() tableName: string, + ): Promise { + const primaryKey = await this.extractPrimaryKeyFromQuery(userId, connectionId, tableName, query, masterPwd); + if (!connectionId || isObjectEmpty(primaryKey)) { + throw new HttpException({ message: Messages.PARAMETER_MISSING }, HttpStatus.BAD_REQUEST); + } + const inputData: PureReadRowDs = { + connectionId, + masterPwd, + primaryKey, + tableName, + userId, + }; + return await this.pureReadRowFromTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ + summary: 'Update a single row in a table by primary key. API+', + description: 'Update a row by primary key and return only the updated row. Support access with api key.', + }) + @ApiBody({ type: Object }) + @ApiResponse({ status: 200, description: 'Row updated.', type: PureCrudRowResponseDs }) + @ApiQuery({ name: 'tableName', required: true }) + @UseGuards(TableEditGuard) + @Put('/table/crud/:connectionId') + async updateRow( + @Body() body: Record, + @Query() query: Record, + @MasterPassword() masterPwd: string, + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @QueryTableName() tableName: string, + ): Promise { + if (!connectionId || isObjectEmpty(body)) { + throw new HttpException({ message: Messages.PARAMETER_MISSING }, HttpStatus.BAD_REQUEST); + } + const primaryKey = await this.extractPrimaryKeyFromQuery(userId, connectionId, tableName, query, masterPwd); + if (isObjectEmpty(primaryKey)) { + throw new HttpException({ message: Messages.PARAMETER_MISSING }, HttpStatus.BAD_REQUEST); + } + const inputData: PureUpdateRowDs = { + connectionId, + masterPwd, + primaryKey, + row: body, + tableName, + userId, + }; + return await this.pureUpdateRowInTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ + summary: 'Delete a single row from a table by primary key. API+', + description: 'Delete a row by primary key and return only the deleted row. Support access with api key.', + }) + @ApiResponse({ status: 200, description: 'Row deleted.', type: PureCrudRowResponseDs }) + @ApiQuery({ name: 'tableName', required: true }) + @UseGuards(TableDeleteGuard) + @Delete('/table/crud/:connectionId') + async deleteRow( + @Query() query: Record, + @MasterPassword() masterPwd: string, + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @QueryTableName() tableName: string, + ): Promise { + const primaryKey = await this.extractPrimaryKeyFromQuery(userId, connectionId, tableName, query, masterPwd); + if (!connectionId || isObjectEmpty(primaryKey)) { + throw new HttpException({ message: Messages.PARAMETER_MISSING }, HttpStatus.BAD_REQUEST); + } + const inputData: PureDeleteRowDs = { + connectionId, + masterPwd, + primaryKey, + tableName, + userId, + }; + return await this.pureDeleteRowFromTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + private async extractPrimaryKeyFromQuery( + userId: string, + connectionId: string, + tableName: string, + query: Record, + masterPwd: string, + ): Promise> { + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + if (!connection) { + throw new ConnectionNotFoundException(HttpStatus.BAD_REQUEST); + } + let userEmail = ''; + if (isConnectionTypeAgent(connection.type)) { + userEmail = (await this._dbContext.userRepository.getUserEmailOrReturnNull(userId)) ?? ''; + } + const dao = getDataAccessObject(connection); + + const tablesInConnection = await dao.getTablesFromDB(userEmail); + const tableNames = tablesInConnection.map((table) => table.tableName); + if (!tableNames.includes(tableName)) { + throw new HttpException({ message: Messages.TABLE_NOT_FOUND }, HttpStatus.BAD_REQUEST); + } + + const primaryColumns = await dao.getTablePrimaryColumns(tableName, userEmail); + const primaryKey: Record = {}; + for (const primaryColumn of primaryColumns) { + if (isObjectPropertyExists(primaryColumn, 'column_name')) { + primaryKey[primaryColumn.column_name] = query[primaryColumn.column_name]; + } + } + return primaryKey; + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts new file mode 100644 index 000000000..359df02ac --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts @@ -0,0 +1,82 @@ +import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthWithApiMiddleware } from '../../../authorization/auth-with-api.middleware.js'; +import { GlobalDatabaseContext } from '../../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../../common/data-injection.tokens.js'; +import { AgentModule } from '../../agent/agent.module.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { ConnectionModule } from '../../connection/connection.module.js'; +import { ConnectionPropertiesEntity } from '../../connection-properties/connection-properties.entity.js'; +import { CustomFieldsEntity } from '../../custom-field/custom-fields.entity.js'; +import { GroupEntity } from '../../group/group.entity.js'; +import { LogOutEntity } from '../../log-out/log-out.entity.js'; +import { TableLogsEntity } from '../../table-logs/table-logs.entity.js'; +import { TableSettingsEntity } from '../../table-settings/common-table-settings/table-settings.entity.js'; +import { UserEntity } from '../../user/user.entity.js'; +import { UserModule } from '../../user/user.module.js'; +import { TableWidgetEntity } from '../../widget/table-widget.entity.js'; +import { TablePureCrudOperationsController } from './table-pure-crud-operations.controller.js'; +import { PureCreateRowInTableUseCase } from './use-cases/pure-create-row-in-table.use.case.js'; +import { PureDeleteRowFromTableUseCase } from './use-cases/pure-delete-row-from-table.use.case.js'; +import { PureGetRowsFromTableUseCase } from './use-cases/pure-get-rows-from-table.use.case.js'; +import { PureReadRowFromTableUseCase } from './use-cases/pure-read-row-from-table.use.case.js'; +import { PureUpdateRowInTableUseCase } from './use-cases/pure-update-row-in-table.use.case.js'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ConnectionEntity, + CustomFieldsEntity, + GroupEntity, + TableLogsEntity, + TableSettingsEntity, + TableWidgetEntity, + UserEntity, + ConnectionPropertiesEntity, + LogOutEntity, + ]), + AgentModule, + ConnectionModule, + UserModule, + ], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + { + provide: UseCaseType.PURE_CREATE_ROW_IN_TABLE, + useClass: PureCreateRowInTableUseCase, + }, + { + provide: UseCaseType.PURE_READ_ROW_FROM_TABLE, + useClass: PureReadRowFromTableUseCase, + }, + { + provide: UseCaseType.PURE_UPDATE_ROW_IN_TABLE, + useClass: PureUpdateRowInTableUseCase, + }, + { + provide: UseCaseType.PURE_DELETE_ROW_FROM_TABLE, + useClass: PureDeleteRowFromTableUseCase, + }, + { + provide: UseCaseType.PURE_GET_ROWS_FROM_TABLE, + useClass: PureGetRowsFromTableUseCase, + }, + ], + controllers: [TablePureCrudOperationsController], +}) +export class TablePureCrudOperationsModule { + public configure(consumer: MiddlewareConsumer): void { + consumer + .apply(AuthWithApiMiddleware) + .forRoutes( + { path: '/table/crud/:connectionId', method: RequestMethod.POST }, + { path: '/table/crud/rows/:connectionId', method: RequestMethod.POST }, + { path: '/table/crud/:connectionId', method: RequestMethod.GET }, + { path: '/table/crud/:connectionId', method: RequestMethod.PUT }, + { path: '/table/crud/:connectionId', method: RequestMethod.DELETE }, + ); + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-create-row-in-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-create-row-in-table.use.case.ts new file mode 100644 index 000000000..1ce3e504e --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-create-row-in-table.use.case.ts @@ -0,0 +1,94 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { TableNotFoundException } from '../../../../exceptions/custom-exceptions/table-not-found-exception.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { getErrorMessage } from '../../../../helpers/get-error-message.js'; +import { isObjectEmpty } from '../../../../helpers/is-object-empty.js'; +import { toPrettyErrorsMsg } from '../../../../helpers/to-pretty-errors-msg.js'; +import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; +import { convertHexDataInRowUtil } from '../../utils/convert-hex-data-in-row.util.js'; +import { hashPasswordsInRowUtil } from '../../utils/hash-passwords-in-row.util.js'; +import { processUuidsInRowUtil } from '../../utils/process-uuids-in-row-util.js'; +import { removePasswordsFromRowsUtil } from '../../utils/remove-password-from-row.util.js'; +import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js'; +import { validateTableRowUtil } from '../../utils/validate-table-row.util.js'; +import { PureCreateRowDs } from '../application/data-structures/pure-create-row.ds.js'; +import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js'; +import { IPureCreateRowInTable } from './table-pure-crud-use-cases.interface.js'; + +@Injectable() +export class PureCreateRowInTableUseCase + extends AbstractUseCase + implements IPureCreateRowInTable +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: PureCreateRowDs): Promise { + const { connectionId, masterPwd, tableName, userId } = inputData; + let { row } = inputData; + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + validateConnection(connection); + + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, userId, this._dbContext.userRepository); + + const tablesInConnection = await dao.getTablesFromDB(userEmail); + const isTableInConnection = tablesInConnection.some((el) => el.tableName === tableName); + if (!isTableInConnection) { + throw new TableNotFoundException(); + } + + const isView = await dao.isView(tableName, userEmail); + if (isView) { + throw new HttpException({ message: Messages.CANT_UPDATE_TABLE_VIEW }, HttpStatus.BAD_REQUEST); + } + + const [tableStructure, tableWidgets, tableSettings] = await Promise.all([ + dao.getTableStructure(tableName, userEmail), + this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName), + this._dbContext.tableSettingsRepository.findTableSettings(connectionId, tableName), + ]); + + if (tableSettings && !tableSettings.can_add) { + throw new HttpException({ message: Messages.CANT_DO_TABLE_OPERATION }, HttpStatus.FORBIDDEN); + } + + const errors = validateTableRowUtil(row, tableStructure); + if (errors.length > 0) { + throw new HttpException({ message: toPrettyErrorsMsg(errors) }, HttpStatus.BAD_REQUEST); + } + + const builtTableSettings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null); + try { + row = await hashPasswordsInRowUtil(row, tableWidgets); + row = processUuidsInRowUtil(row, tableWidgets); + row = convertHexDataInRowUtil(row, tableStructure); + const addedRowPrimaryKey = (await dao.addRowInTable(tableName, row, userEmail)) as Record; + if (!addedRowPrimaryKey || isObjectEmpty(addedRowPrimaryKey)) { + return { row }; + } + let addedRow = await dao.getRowByPrimaryKey(tableName, addedRowPrimaryKey, builtTableSettings, userEmail); + addedRow = removePasswordsFromRowsUtil(addedRow, tableWidgets); + return { row: addedRow }; + } catch (e) { + throw new HttpException( + { + message: getErrorMessage(e).includes('duplicate key value') + ? Messages.CANT_INSERT_DUPLICATE_KEY + : `${Messages.FAILED_ADD_ROW_IN_TABLE} ${Messages.ERROR_MESSAGE} ${getErrorMessage(e)} ${Messages.TRY_AGAIN_LATER}`, + }, + HttpStatus.BAD_REQUEST, + ); + } + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-delete-row-from-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-delete-row-from-table.use.case.ts new file mode 100644 index 000000000..695039fac --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-delete-row-from-table.use.case.ts @@ -0,0 +1,94 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { DeleteRowException } from '../../../../exceptions/custom-exceptions/delete-row-exception.js'; +import { ExceptionOperations } from '../../../../exceptions/custom-exceptions/exception-operation.js'; +import { PrimaryKeyMissingException } from '../../../../exceptions/custom-exceptions/primary-key-missing-exception.js'; +import { UnknownSQLException } from '../../../../exceptions/custom-exceptions/unknown-sql-exception.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { compareArrayElements } from '../../../../helpers/compare-array-elements.js'; +import { getErrorMessage } from '../../../../helpers/get-error-message.js'; +import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; +import { convertHexDataInPrimaryKeyUtil } from '../../utils/convert-hex-data-in-primary-key.util.js'; +import { removePasswordsFromRowsUtil } from '../../utils/remove-password-from-row.util.js'; +import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js'; +import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js'; +import { PureDeleteRowDs } from '../application/data-structures/pure-delete-row.ds.js'; +import { IPureDeleteRowFromTable } from './table-pure-crud-use-cases.interface.js'; + +@Injectable() +export class PureDeleteRowFromTableUseCase + extends AbstractUseCase + implements IPureDeleteRowFromTable +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: PureDeleteRowDs): Promise { + const { connectionId, masterPwd, tableName, userId } = inputData; + let { primaryKey } = inputData; + if (!primaryKey) { + throw new PrimaryKeyMissingException(); + } + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + validateConnection(connection); + + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, userId, this._dbContext.userRepository); + + const isView = await dao.isView(tableName, userEmail); + if (isView) { + throw new HttpException({ message: Messages.CANT_UPDATE_TABLE_VIEW }, HttpStatus.BAD_REQUEST); + } + + const [tableStructure, tableWidgets, tableSettings, primaryColumns] = await Promise.all([ + dao.getTableStructure(tableName, userEmail), + this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName), + this._dbContext.tableSettingsRepository.findTableSettings(connectionId, tableName), + dao.getTablePrimaryColumns(tableName, userEmail), + ]); + + if (tableSettings && !tableSettings.can_delete) { + throw new HttpException({ message: Messages.CANT_DO_TABLE_OPERATION }, HttpStatus.FORBIDDEN); + } + + primaryKey = convertHexDataInPrimaryKeyUtil(primaryKey, tableStructure); + const availablePrimaryColumns = primaryColumns.map((column) => column.column_name); + for (const key in primaryKey) { + // eslint-disable-next-line security/detect-object-injection + if (!primaryKey[key] && primaryKey[key] !== '') delete primaryKey[key]; + } + const receivedPrimaryColumns = Object.keys(primaryKey); + if (!compareArrayElements(availablePrimaryColumns, receivedPrimaryColumns)) { + throw new HttpException({ message: Messages.PRIMARY_KEY_INVALID }, HttpStatus.BAD_REQUEST); + } + + const builtTableSettings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null); + let oldRowData: Record; + try { + oldRowData = await dao.getRowByPrimaryKey(tableName, primaryKey, builtTableSettings, userEmail); + } catch (e) { + throw new UnknownSQLException(getErrorMessage(e), ExceptionOperations.FAILED_TO_DELETE_ROW_FROM_TABLE); + } + + if (!oldRowData) { + throw new HttpException({ message: Messages.ROW_PRIMARY_KEY_NOT_FOUND }, HttpStatus.BAD_REQUEST); + } + + try { + await dao.deleteRowInTable(tableName, primaryKey, userEmail); + const deletedRow = removePasswordsFromRowsUtil(oldRowData, tableWidgets); + return { row: deletedRow }; + } catch (e) { + throw new DeleteRowException(getErrorMessage(e)); + } + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts new file mode 100644 index 000000000..6bdf86926 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts @@ -0,0 +1,113 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { validateSchemaCache } from '@rocketadmin/shared-code/dist/src/caching/schema-cache-validator.js'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { FoundRowsDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/found-rows.ds.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { ExceptionOperations } from '../../../../exceptions/custom-exceptions/exception-operation.js'; +import { TableNotFoundException } from '../../../../exceptions/custom-exceptions/table-not-found-exception.js'; +import { UnknownSQLException } from '../../../../exceptions/custom-exceptions/unknown-sql-exception.js'; +import { hexToBinary, isBinary } from '../../../../helpers/binary-to-hex.js'; +import { getErrorMessage } from '../../../../helpers/get-error-message.js'; +import { isObjectEmpty } from '../../../../helpers/is-object-empty.js'; +import { TableSettingsEntity } from '../../../table-settings/common-table-settings/table-settings.entity.js'; +import { FilteringFieldsDs } from '../../table-datastructures.js'; +import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; +import { findFilteringFieldsUtil, parseFilteringFieldsFromBodyData } from '../../utils/find-filtering-fields.util.js'; +import { findOrderingFieldUtil } from '../../utils/find-ordering-field.util.js'; +import { isHexString } from '../../utils/is-hex-string.js'; +import { processRowsUtil } from '../../utils/process-found-rows-util.js'; +import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js'; +import { PureFoundRowsResponseDs } from '../application/data-structures/pure-found-rows-response.ds.js'; +import { PureGetRowsDs } from '../application/data-structures/pure-get-rows.ds.js'; +import { IPureGetRowsFromTable } from './table-pure-crud-use-cases.interface.js'; + +@Injectable() +export class PureGetRowsFromTableUseCase + extends AbstractUseCase + implements IPureGetRowsFromTable +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: PureGetRowsDs): Promise { + const { connectionId, masterPwd, page, perPage, query, tableName, userId, filters } = inputData; + let { searchingFieldValue } = inputData; + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + validateConnection(connection); + + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, userId, this._dbContext.userRepository); + + const tablesInConnection = await dao.getTablesFromDB(userEmail); + const tableNames = tablesInConnection.map((table) => table.tableName); + if (!tableNames.includes(tableName)) { + throw new TableNotFoundException(); + } + + await validateSchemaCache(dao, userEmail); + + const { tableSettings, tableCustomFields, tableWidgets } = + await this._dbContext.tableSettingsRepository.findTableCustoms(connectionId, tableName); + + const tableStructure = await dao.getTableStructure(tableName, userEmail); + + const filteringFields: Array = isObjectEmpty(filters) + ? findFilteringFieldsUtil(query, tableStructure) + : parseFilteringFieldsFromBodyData(filters ?? {}, tableStructure); + const orderingField = findOrderingFieldUtil(query, tableStructure, tableSettings ?? ({} as TableSettingsEntity)); + + const builtTableSettings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null); + if (orderingField) { + builtTableSettings.ordering_field = orderingField.field; + builtTableSettings.ordering = orderingField.value; + } + + if ( + isHexString(searchingFieldValue) && + (tableStructure.some((field) => isBinary(field.data_type)) || + connection.type === ConnectionTypesEnum.mongodb || + connection.type === ConnectionTypesEnum.agent_mongodb) + ) { + searchingFieldValue = hexToBinary(searchingFieldValue) as unknown as string; + builtTableSettings.search_fields = tableStructure + .filter((field) => isBinary(field.data_type)) + .map((field) => field.column_name); + if (connection.type === ConnectionTypesEnum.mongodb || connection.type === ConnectionTypesEnum.agent_mongodb) { + builtTableSettings.search_fields.push('_id'); + } + } + + let rows: FoundRowsDS; + try { + rows = await dao.getRowsFromTable( + tableName, + builtTableSettings, + page, + perPage, + searchingFieldValue, + filteringFields, + { fields: [], value: '' }, + tableStructure, + userEmail, + ); + } catch (e) { + throw new UnknownSQLException(getErrorMessage(e), ExceptionOperations.FAILED_TO_GET_ROWS_FROM_TABLE); + } + + rows = processRowsUtil(rows, tableWidgets, tableCustomFields); + + return { + rows: rows.data, + pagination: rows.pagination, + }; + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts new file mode 100644 index 000000000..7f3838c51 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts @@ -0,0 +1,82 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { validateSchemaCache } from '@rocketadmin/shared-code/dist/src/caching/schema-cache-validator.js'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { ExceptionOperations } from '../../../../exceptions/custom-exceptions/exception-operation.js'; +import { PrimaryKeyMissingException } from '../../../../exceptions/custom-exceptions/primary-key-missing-exception.js'; +import { UnknownSQLException } from '../../../../exceptions/custom-exceptions/unknown-sql-exception.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { compareArrayElements } from '../../../../helpers/compare-array-elements.js'; +import { getErrorMessage } from '../../../../helpers/get-error-message.js'; +import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; +import { convertHexDataInPrimaryKeyUtil } from '../../utils/convert-hex-data-in-primary-key.util.js'; +import { removePasswordsFromRowsUtil } from '../../utils/remove-password-from-row.util.js'; +import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js'; +import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js'; +import { PureReadRowDs } from '../application/data-structures/pure-read-row.ds.js'; +import { IPureReadRowFromTable } from './table-pure-crud-use-cases.interface.js'; + +@Injectable() +export class PureReadRowFromTableUseCase + extends AbstractUseCase + implements IPureReadRowFromTable +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: PureReadRowDs): Promise { + const { connectionId, masterPwd, tableName, userId } = inputData; + let { primaryKey } = inputData; + if (!primaryKey) { + throw new PrimaryKeyMissingException(); + } + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + validateConnection(connection); + + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, userId, this._dbContext.userRepository); + + await validateSchemaCache(dao, userEmail); + + const [tableStructure, tableWidgets, tableSettings, primaryColumns] = await Promise.all([ + dao.getTableStructure(tableName, userEmail), + this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName), + this._dbContext.tableSettingsRepository.findTableSettings(connectionId, tableName), + dao.getTablePrimaryColumns(tableName, userEmail), + ]); + + primaryKey = convertHexDataInPrimaryKeyUtil(primaryKey, tableStructure); + const availablePrimaryColumns = primaryColumns.map((column) => column.column_name); + for (const key in primaryKey) { + // eslint-disable-next-line security/detect-object-injection + if (!primaryKey[key] && primaryKey[key] !== '') delete primaryKey[key]; + } + const receivedPrimaryColumns = Object.keys(primaryKey); + if (!compareArrayElements(availablePrimaryColumns, receivedPrimaryColumns)) { + throw new HttpException({ message: Messages.PRIMARY_KEY_INVALID }, HttpStatus.BAD_REQUEST); + } + + const builtTableSettings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null); + let rowData: Record; + try { + rowData = await dao.getRowByPrimaryKey(tableName, primaryKey, builtTableSettings, userEmail); + } catch (e) { + throw new UnknownSQLException(getErrorMessage(e), ExceptionOperations.FAILED_TO_GET_ROW_BY_PRIMARY_KEY); + } + + if (!rowData) { + throw new HttpException({ message: Messages.ROW_PRIMARY_KEY_NOT_FOUND }, HttpStatus.BAD_REQUEST); + } + + rowData = removePasswordsFromRowsUtil(rowData, tableWidgets); + return { row: rowData }; + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-update-row-in-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-update-row-in-table.use.case.ts new file mode 100644 index 000000000..00a0ec500 --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-update-row-in-table.use.case.ts @@ -0,0 +1,109 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { ExceptionOperations } from '../../../../exceptions/custom-exceptions/exception-operation.js'; +import { PrimaryKeyMissingException } from '../../../../exceptions/custom-exceptions/primary-key-missing-exception.js'; +import { UnknownSQLException } from '../../../../exceptions/custom-exceptions/unknown-sql-exception.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { compareArrayElements } from '../../../../helpers/compare-array-elements.js'; +import { getErrorMessage } from '../../../../helpers/get-error-message.js'; +import { isObjectEmpty } from '../../../../helpers/is-object-empty.js'; +import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; +import { convertHexDataInPrimaryKeyUtil } from '../../utils/convert-hex-data-in-primary-key.util.js'; +import { convertHexDataInRowUtil } from '../../utils/convert-hex-data-in-row.util.js'; +import { hashPasswordsInRowUtil } from '../../utils/hash-passwords-in-row.util.js'; +import { processUuidsInRowUtil } from '../../utils/process-uuids-in-row-util.js'; +import { removePasswordsFromRowsUtil } from '../../utils/remove-password-from-row.util.js'; +import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js'; +import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js'; +import { PureUpdateRowDs } from '../application/data-structures/pure-update-row.ds.js'; +import { IPureUpdateRowInTable } from './table-pure-crud-use-cases.interface.js'; + +@Injectable() +export class PureUpdateRowInTableUseCase + extends AbstractUseCase + implements IPureUpdateRowInTable +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: PureUpdateRowDs): Promise { + const { connectionId, masterPwd, tableName, userId } = inputData; + let { primaryKey, row } = inputData; + if (!primaryKey) { + throw new PrimaryKeyMissingException(); + } + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + validateConnection(connection); + + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, userId, this._dbContext.userRepository); + + const isView = await dao.isView(tableName, userEmail); + if (isView) { + throw new HttpException({ message: Messages.CANT_UPDATE_TABLE_VIEW }, HttpStatus.BAD_REQUEST); + } + + const [tableStructure, tableWidgets, tableSettings, tablePrimaryKeys] = await Promise.all([ + dao.getTableStructure(tableName, userEmail), + this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName), + this._dbContext.tableSettingsRepository.findTableSettings(connectionId, tableName), + dao.getTablePrimaryColumns(tableName, userEmail), + ]); + + if (tableSettings && !tableSettings.can_update) { + throw new HttpException({ message: Messages.CANT_DO_TABLE_OPERATION }, HttpStatus.FORBIDDEN); + } + + primaryKey = convertHexDataInPrimaryKeyUtil(primaryKey, tableStructure); + const availablePrimaryColumns = tablePrimaryKeys.map((key) => key.column_name); + for (const key in primaryKey) { + // eslint-disable-next-line security/detect-object-injection + if (!primaryKey[key] && primaryKey[key] !== '') delete primaryKey[key]; + } + const receivedPrimaryColumns = Object.keys(primaryKey); + if (!compareArrayElements(availablePrimaryColumns, receivedPrimaryColumns)) { + throw new HttpException({ message: Messages.PRIMARY_KEY_INVALID }, HttpStatus.BAD_REQUEST); + } + + const builtTableSettings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null); + let oldRowData: Record; + try { + oldRowData = await dao.getRowByPrimaryKey(tableName, primaryKey, builtTableSettings, userEmail); + } catch (e) { + throw new UnknownSQLException(getErrorMessage(e), ExceptionOperations.FAILED_TO_UPDATE_ROW_IN_TABLE); + } + if (!oldRowData) { + throw new HttpException({ message: Messages.ROW_PRIMARY_KEY_NOT_FOUND }, HttpStatus.BAD_REQUEST); + } + + const futureRowData = Object.assign({ ...oldRowData }, row); + let futurePrimaryKey: Record = {}; + for (const primaryColumn of tablePrimaryKeys) { + futurePrimaryKey[primaryColumn.column_name] = futureRowData[primaryColumn.column_name]; + } + if (isObjectEmpty(futurePrimaryKey)) { + futurePrimaryKey = primaryKey; + } + + try { + row = await hashPasswordsInRowUtil(row, tableWidgets); + row = processUuidsInRowUtil(row, tableWidgets); + row = convertHexDataInRowUtil(row, tableStructure); + await dao.updateRowInTable(tableName, row, primaryKey, userEmail); + let updatedRow = await dao.getRowByPrimaryKey(tableName, futurePrimaryKey, builtTableSettings, userEmail); + updatedRow = removePasswordsFromRowsUtil(updatedRow, tableWidgets); + return { row: updatedRow }; + } catch (e) { + throw new UnknownSQLException(getErrorMessage(e), ExceptionOperations.FAILED_TO_UPDATE_ROW_IN_TABLE); + } + } +} diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/table-pure-crud-use-cases.interface.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/table-pure-crud-use-cases.interface.ts new file mode 100644 index 000000000..1463d9b3c --- /dev/null +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/table-pure-crud-use-cases.interface.ts @@ -0,0 +1,28 @@ +import { InTransactionEnum } from '../../../../enums/in-transaction.enum.js'; +import { PureCreateRowDs } from '../application/data-structures/pure-create-row.ds.js'; +import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js'; +import { PureDeleteRowDs } from '../application/data-structures/pure-delete-row.ds.js'; +import { PureFoundRowsResponseDs } from '../application/data-structures/pure-found-rows-response.ds.js'; +import { PureGetRowsDs } from '../application/data-structures/pure-get-rows.ds.js'; +import { PureReadRowDs } from '../application/data-structures/pure-read-row.ds.js'; +import { PureUpdateRowDs } from '../application/data-structures/pure-update-row.ds.js'; + +export interface IPureCreateRowInTable { + execute(inputData: PureCreateRowDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IPureReadRowFromTable { + execute(inputData: PureReadRowDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IPureUpdateRowInTable { + execute(inputData: PureUpdateRowDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IPureDeleteRowFromTable { + execute(inputData: PureDeleteRowDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IPureGetRowsFromTable { + execute(inputData: PureGetRowsDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts new file mode 100644 index 000000000..23c545808 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts @@ -0,0 +1,385 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable security/detect-object-injection */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { dropTestTables } from '../../utils/drop-test-tables.js'; +import { getTestData } from '../../utils/get-test-data.js'; +import { + createInitialTestUser, + registerUserAndReturnUserInfo, +} from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let _testUtils: TestUtils; +const testTables: Array = []; +let currentTest; + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + await createInitialTestUser(app); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToMySQL; + await dropTestTables(testTables, connectionToTestDB); + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +async function createConnectionAndTable(): Promise<{ + token: string; + connectionId: string; + testTableName: string; + testTableColumnName: string; + testTableSecondColumnName: string; +}> { + const connectionToTestDB = getTestData(mockFactory).connectionToMySQL; + const token = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + return { + token, + connectionId: createConnectionRO.id, + testTableName, + testTableColumnName, + testTableSecondColumnName, + }; +} + +currentTest = 'POST /table/crud/:connectionId'; + +test.serial(`${currentTest} should create a row and return only the created row`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + const fakeName = faker.person.firstName(); + const fakeMail = faker.internet.email(); + const row = { + [testTableColumnName]: fakeName, + [testTableSecondColumnName]: fakeMail, + }; + + const createResponse = await request(app.getHttpServer()) + .post(`/table/crud/${connectionId}?tableName=${testTableName}`) + .send(JSON.stringify(row)) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createResponse.status, 201); + const createRO = JSON.parse(createResponse.text); + + // only the row is returned, no permissions / settings / structure / foreignKeys + t.is(Object.hasOwn(createRO, 'row'), true); + t.is(Object.keys(createRO).length, 1); + t.is(Object.hasOwn(createRO, 'structure'), false); + t.is(Object.hasOwn(createRO, 'foreignKeys'), false); + t.is(Object.hasOwn(createRO, 'primaryColumns'), false); + t.is(Object.hasOwn(createRO, 'table_settings'), false); + t.is(createRO.row[testTableColumnName], fakeName); + t.is(createRO.row[testTableSecondColumnName], fakeMail); + t.is(createRO.row.id, 43); +}); + +test.serial(`${currentTest} should throw an exception when connection id is not passed`, async (t) => { + const { token, testTableName, testTableColumnName, testTableSecondColumnName } = await createConnectionAndTable(); + + const row = { + [testTableColumnName]: faker.person.firstName(), + [testTableSecondColumnName]: faker.internet.email(), + }; + + const createResponse = await request(app.getHttpServer()) + .post(`/table/crud/?tableName=${testTableName}`) + .send(JSON.stringify(row)) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createResponse.status, 404); +}); + +test.serial(`${currentTest} should throw an exception when body is empty`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const createResponse = await request(app.getHttpServer()) + .post(`/table/crud/${connectionId}?tableName=${testTableName}`) + .send(JSON.stringify({})) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createResponse.status, 400); +}); + +currentTest = 'GET /table/crud/:connectionId'; + +test.serial(`${currentTest} should read a row by primary key and return only the row`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + const readResponse = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(readResponse.status, 200); + const readRO = JSON.parse(readResponse.text); + + t.is(Object.hasOwn(readRO, 'row'), true); + t.is(Object.keys(readRO).length, 1); + t.is(Object.hasOwn(readRO, 'structure'), false); + t.is(Object.hasOwn(readRO, 'foreignKeys'), false); + t.is(readRO.row.id, 1); + t.is(typeof readRO.row[testTableColumnName], 'string'); + t.is(typeof readRO.row[testTableSecondColumnName], 'string'); +}); + +test.serial(`${currentTest} should return an error when row not found by primary key`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const readResponse = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=999999`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(readResponse.status, 400); +}); + +currentTest = 'PUT /table/crud/:connectionId'; + +test.serial(`${currentTest} should update a row by primary key and return only the updated row`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + const fakeName = faker.person.firstName(); + const fakeMail = faker.internet.email(); + const row = { + [testTableColumnName]: fakeName, + [testTableSecondColumnName]: fakeMail, + }; + + const updateResponse = await request(app.getHttpServer()) + .put(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .send(JSON.stringify(row)) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(updateResponse.status, 200); + const updateRO = JSON.parse(updateResponse.text); + + t.is(Object.hasOwn(updateRO, 'row'), true); + t.is(Object.keys(updateRO).length, 1); + t.is(Object.hasOwn(updateRO, 'structure'), false); + t.is(updateRO.row.id, 1); + t.is(updateRO.row[testTableColumnName], fakeName); + t.is(updateRO.row[testTableSecondColumnName], fakeMail); + + // verify the row was actually updated + const readResponse = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const readRO = JSON.parse(readResponse.text); + t.is(readRO.row[testTableColumnName], fakeName); +}); + +test.serial(`${currentTest} should throw an exception when body is empty`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const updateResponse = await request(app.getHttpServer()) + .put(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .send(JSON.stringify({})) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(updateResponse.status, 400); +}); + +currentTest = 'DELETE /table/crud/:connectionId'; + +test.serial(`${currentTest} should delete a row by primary key and return the deleted row`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const idForDeletion = 1; + const deleteResponse = await request(app.getHttpServer()) + .delete(`/table/crud/${connectionId}?tableName=${testTableName}&id=${idForDeletion}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteResponse.status, 200); + const deleteRO = JSON.parse(deleteResponse.text); + + t.is(Object.hasOwn(deleteRO, 'row'), true); + t.is(Object.keys(deleteRO).length, 1); + t.is(deleteRO.row.id, idForDeletion); + + // verify the row was actually deleted + const readResponse = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=${idForDeletion}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(readResponse.status, 400); +}); + +test.serial(`${currentTest} should return an error when row not found by primary key`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const deleteResponse = await request(app.getHttpServer()) + .delete(`/table/crud/${connectionId}?tableName=${testTableName}&id=999999`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteResponse.status, 400); +}); + +currentTest = 'POST /table/crud/rows/:connectionId'; + +test.serial(`${currentTest} should return only rows and pagination`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const getRowsResponse = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=100`) + .send({}) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getRowsResponse.status, 200); + const getRowsRO = JSON.parse(getRowsResponse.text); + + // only rows + pagination are returned, no structure / foreignKeys / widgets / permissions / settings + t.is(Object.hasOwn(getRowsRO, 'rows'), true); + t.is(Object.hasOwn(getRowsRO, 'pagination'), true); + t.is(Object.keys(getRowsRO).length, 2); + t.is(Object.hasOwn(getRowsRO, 'structure'), false); + t.is(Object.hasOwn(getRowsRO, 'foreignKeys'), false); + t.is(Object.hasOwn(getRowsRO, 'widgets'), false); + t.is(Object.hasOwn(getRowsRO, 'primaryColumns'), false); + t.is(Object.hasOwn(getRowsRO, 'table_permissions'), false); + t.is(Object.hasOwn(getRowsRO, 'table_settings'), false); + + t.is(Array.isArray(getRowsRO.rows), true); + t.is(getRowsRO.rows.length, 42); + t.is(typeof getRowsRO.rows[0][testTableColumnName], 'string'); +}); + +test.serial(`${currentTest} should support pagination`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const getRowsResponse = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`) + .send({}) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getRowsResponse.status, 200); + const getRowsRO = JSON.parse(getRowsResponse.text); + t.is(getRowsRO.rows.length, 10); + t.is(getRowsRO.pagination.currentPage, 1); + t.is(getRowsRO.pagination.perPage, 10); +}); + +test.serial(`${currentTest} should support search`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const getRowsResponse = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&search=Vasia`) + .send({}) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getRowsResponse.status, 200); + const getRowsRO = JSON.parse(getRowsResponse.text); + // 'Vasia' is seeded into 3 rows by createTestTable + t.is(getRowsRO.rows.length, 3); + getRowsRO.rows.forEach((row) => t.is(row[testTableColumnName], 'Vasia')); +}); + +test.serial(`${currentTest} should support filtering with filters in body`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + const getRowsResponse = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}`) + .send({ filters: { id: { eq: 1 } } }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getRowsResponse.status, 200); + const getRowsRO = JSON.parse(getRowsResponse.text); + t.is(getRowsRO.rows.length, 1); + t.is(getRowsRO.rows[0].id, 1); +}); + +test.serial(`${currentTest} should reject when connection does not exist`, async (t) => { + const { token, testTableName } = await createConnectionAndTable(); + + const fakeConnectionId = '00000000-0000-0000-0000-000000000000'; + const getRowsResponse = await request(app.getHttpServer()) + .post(`/table/crud/rows/${fakeConnectionId}?tableName=${testTableName}`) + .send({}) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.not(getRowsResponse.status, 200); +});