diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b61b65130..8629a0e2d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -42,6 +42,7 @@ import { AppLoggerMiddleware } from './middlewares/logging-middleware/app-logger import { DatabaseModule } from './shared/database/database.module.js'; import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js'; import { PersonalTableSettingsModule } from './entities/table-settings/personal-table-settings/personal-table-settings.module.js'; +import { SavedDbQueryModule } from './entities/visualizations/saved-db-query/saved-db-query.module.js'; @Module({ imports: [ @@ -88,6 +89,7 @@ import { PersonalTableSettingsModule } from './entities/table-settings/personal- SignInAuditModule, PersonalTableSettingsModule, S3WidgetModule, + SavedDbQueryModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/application/global-database-context.interface.ts b/backend/src/common/application/global-database-context.interface.ts index 820f3e32c..e7509f7c7 100644 --- a/backend/src/common/application/global-database-context.interface.ts +++ b/backend/src/common/application/global-database-context.interface.ts @@ -56,43 +56,46 @@ import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-aud import { ISignInAuditRepository } from '../../entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.js'; import { IPersonalTableSettingsRepository } from '../../entities/table-settings/personal-table-settings/repository/personal-table-settings.repository.interface.js'; import { PersonalTableSettingsEntity } from '../../entities/table-settings/personal-table-settings/personal-table-settings.entity.js'; +import { SavedDbQueryEntity } from '../../entities/visualizations/saved-db-query/saved-db-query.entity.js'; +import { ISavedDbQueryRepository } from '../../entities/visualizations/saved-db-query/repository/saved-db-query.repository.interface.js'; export interface IGlobalDatabaseContext extends IDatabaseContext { - userRepository: Repository & IUserRepository; - connectionRepository: Repository & IConnectionRepository; - groupRepository: IGroupRepository; - permissionRepository: IPermissionRepository; - tableSettingsRepository: Repository & ITableSettingsRepository; - userAccessRepository: IUserAccessRepository; - agentRepository: IAgentRepository; - emailVerificationRepository: IEmailVerificationRepository; - passwordResetRepository: IPasswordResetRepository; - emailChangeRepository: IEmailChangeRepository; - userInvitationRepository: IUserInvitationRepository; - connectionPropertiesRepository: Repository & IConnectionPropertiesRepository; - customFieldsRepository: ICustomFieldsRepository; - tableLogsRepository: ITableLogsRepository; - userActionRepository: IUserActionRepository; - logOutRepository: ILogOutRepository; - tableWidgetsRepository: Repository & ITableWidgetsRepository; - tableInfoRepository: Repository; - tableFieldInfoRepository: Repository; - tableActionRepository: Repository & ITableActionRepository; - userGitHubIdentifierRepository: IUserGitHubIdentifierRepository; - companyInfoRepository: Repository & ICompanyInfoRepository; - invitationInCompanyRepository: Repository & IInvitationInCompanyRepository; - userSessionSettingsRepository: Repository & IUserSessionSettings; - actionRulesRepository: Repository & IActionRulesRepository; - actionEventsRepository: Repository & IActionEventsRepository; - userApiKeysRepository: Repository & IUserApiKeyRepository; - companyLogoRepository: Repository; - companyFaviconRepository: Repository; - companyTabTitleRepository: Repository; - tableFiltersRepository: Repository & ITableFiltersCustomRepository; - aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; - tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; - userSecretRepository: Repository & IUserSecretRepository; - secretAccessLogRepository: Repository & ISecretAccessLogRepository; - signInAuditRepository: Repository & ISignInAuditRepository; - personalTableSettingsRepository: Repository & IPersonalTableSettingsRepository; + userRepository: Repository & IUserRepository; + connectionRepository: Repository & IConnectionRepository; + groupRepository: IGroupRepository; + permissionRepository: IPermissionRepository; + tableSettingsRepository: Repository & ITableSettingsRepository; + userAccessRepository: IUserAccessRepository; + agentRepository: IAgentRepository; + emailVerificationRepository: IEmailVerificationRepository; + passwordResetRepository: IPasswordResetRepository; + emailChangeRepository: IEmailChangeRepository; + userInvitationRepository: IUserInvitationRepository; + connectionPropertiesRepository: Repository & IConnectionPropertiesRepository; + customFieldsRepository: ICustomFieldsRepository; + tableLogsRepository: ITableLogsRepository; + userActionRepository: IUserActionRepository; + logOutRepository: ILogOutRepository; + tableWidgetsRepository: Repository & ITableWidgetsRepository; + tableInfoRepository: Repository; + tableFieldInfoRepository: Repository; + tableActionRepository: Repository & ITableActionRepository; + userGitHubIdentifierRepository: IUserGitHubIdentifierRepository; + companyInfoRepository: Repository & ICompanyInfoRepository; + invitationInCompanyRepository: Repository & IInvitationInCompanyRepository; + userSessionSettingsRepository: Repository & IUserSessionSettings; + actionRulesRepository: Repository & IActionRulesRepository; + actionEventsRepository: Repository & IActionEventsRepository; + userApiKeysRepository: Repository & IUserApiKeyRepository; + companyLogoRepository: Repository; + companyFaviconRepository: Repository; + companyTabTitleRepository: Repository; + tableFiltersRepository: Repository & ITableFiltersCustomRepository; + aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; + tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; + userSecretRepository: Repository & IUserSecretRepository; + secretAccessLogRepository: Repository & ISecretAccessLogRepository; + signInAuditRepository: Repository & ISignInAuditRepository; + personalTableSettingsRepository: Repository & IPersonalTableSettingsRepository; + savedDbQueryRepository: Repository & ISavedDbQueryRepository; } diff --git a/backend/src/common/application/global-database-context.ts b/backend/src/common/application/global-database-context.ts index 221ef343d..704da7df8 100644 --- a/backend/src/common/application/global-database-context.ts +++ b/backend/src/common/application/global-database-context.ts @@ -102,323 +102,334 @@ import { signInAuditCustomRepositoryExtension } from '../../entities/user-sign-i import { PersonalTableSettingsEntity } from '../../entities/table-settings/personal-table-settings/personal-table-settings.entity.js'; import { IPersonalTableSettingsRepository } from '../../entities/table-settings/personal-table-settings/repository/personal-table-settings.repository.interface.js'; import { personalTableSettingsCustomRepositoryExtension } from '../../entities/table-settings/personal-table-settings/repository/personal-table-settings-custom-repository-extension.js'; +import { SavedDbQueryEntity } from '../../entities/visualizations/saved-db-query/saved-db-query.entity.js'; +import { ISavedDbQueryRepository } from '../../entities/visualizations/saved-db-query/repository/saved-db-query.repository.interface.js'; +import { savedDbQueryCustomRepositoryExtension } from '../../entities/visualizations/saved-db-query/repository/saved-db-query-custom-repository-extension.js'; @Injectable({ scope: Scope.REQUEST }) export class GlobalDatabaseContext implements IGlobalDatabaseContext { - private _queryRunner: QueryRunner; - - private _userRepository: Repository & IUserRepository; - private _connectionRepository: Repository & IConnectionRepository; - private _groupRepository: IGroupRepository; - private _permissionRepository: IPermissionRepository; - private _tableSettingsRepository: Repository & ITableSettingsRepository; - private _userAccessRepository: IUserAccessRepository; - private _agentRepository: IAgentRepository; - private _emailVerificationRepository: IEmailVerificationRepository; - private _passwordResetRepository: IPasswordResetRepository; - private _emailChangeRepository: IEmailChangeRepository; - private _userInvitationRepository: IUserInvitationRepository; - private _connectionPropertiesRepository: Repository & IConnectionPropertiesRepository; - private _customFieldsRepository: ICustomFieldsRepository; - private _tableLogsRepository: ITableLogsRepository; - private _userActionRepository: IUserActionRepository; - private _logOutRepository: ILogOutRepository; - private _tableWidgetsRepository: Repository & ITableWidgetsRepository; - private _tableFieldInfoRepository: Repository; - private _tableInfoReposioty: Repository; - private _tableActionRepository: Repository & ITableActionRepository; - private _userGitHubIdentifierRepository: IUserGitHubIdentifierRepository; - private _companyInfoRepository: Repository & ICompanyInfoRepository; - private _invitationInCompanyRepository: Repository & IInvitationInCompanyRepository; - private _userSessionSettingsRepository: Repository & IUserSessionSettings; - private _actionRulesRepository: Repository & IActionRulesRepository; - private _actionEventsRepository: Repository & IActionEventsRepository; - private _userApiKeysRepository: Repository & IUserApiKeyRepository; - private _companyLogoRepository: Repository; - private _companyFaviconRepository: Repository; - private _companyTabTitleRepository: Repository; - private _tableFiltersRepository: Repository & ITableFiltersCustomRepository; - private _aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; - private _tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; - private _userSecretRepository: Repository & IUserSecretRepository; - private _secretAccessLogRepository: Repository & ISecretAccessLogRepository; - private _signInAuditRepository: Repository & ISignInAuditRepository; - private _personalTableSettingsRepository: Repository & IPersonalTableSettingsRepository; - - public constructor( - @Inject(BaseType.DATA_SOURCE) - public appDataSource: DataSource, - ) { - this.initRepositories(); - } - - private initRepositories(): void { - this._userRepository = this.appDataSource.getRepository(UserEntity).extend(userCustomRepositoryExtension); - this._connectionRepository = this.appDataSource - .getRepository(ConnectionEntity) - .extend(customConnectionRepositoryExtension); - this._groupRepository = this.appDataSource.getRepository(GroupEntity).extend(groupCustomRepositoryExtension); - this._permissionRepository = this.appDataSource - .getRepository(PermissionEntity) - .extend(permissionCustomRepositoryExtension); - this._tableSettingsRepository = this.appDataSource - .getRepository(TableSettingsEntity) - .extend(tableSettingsCustomRepositoryExtension); - this._userAccessRepository = this.appDataSource - .getRepository(PermissionEntity) - .extend(userAccessCustomReposiotoryExtension); - this._agentRepository = this.appDataSource.getRepository(AgentEntity).extend(customAgentRepositoryExtension); - this._emailVerificationRepository = this.appDataSource - .getRepository(EmailVerificationEntity) - .extend(emailVerificationRepositoryExtension); - this._passwordResetRepository = this.appDataSource - .getRepository(PasswordResetEntity) - .extend(userPasswordResetCustomRepositoryExtension); - this._emailChangeRepository = this.appDataSource - .getRepository(EmailChangeEntity) - .extend(emailChangeCustomRepositoryExtension); - this._userInvitationRepository = this.appDataSource - .getRepository(UserInvitationEntity) - .extend(userInvitationCustomRepositoryExtension); - this._connectionPropertiesRepository = this.appDataSource - .getRepository(ConnectionPropertiesEntity) - .extend(customConnectionPropertiesRepositoryExtension); - this._customFieldsRepository = this.appDataSource - .getRepository(CustomFieldsEntity) - .extend(cusomFieldsCustomRepositoryExtension); - this._tableLogsRepository = this.appDataSource - .getRepository(TableLogsEntity) - .extend(tableLogsCustomRepositoryExtension); - this._userActionRepository = this.appDataSource - .getRepository(UserActionEntity) - .extend(userActionCustomRepositoryExtension); - this._logOutRepository = this.appDataSource.getRepository(LogOutEntity).extend(logOutCustomRepositoryExtension); - this._tableWidgetsRepository = this.appDataSource - .getRepository(TableWidgetEntity) - .extend(tableWidgetsCustomRepositoryExtension); - this._tableInfoReposioty = this.appDataSource.getRepository(TableInfoEntity); - this._tableFieldInfoRepository = this.appDataSource.getRepository(TableFieldInfoEntity); - this._tableActionRepository = this.appDataSource - .getRepository(TableActionEntity) - .extend(tableActionsCustomRepositoryExtension); - this._userGitHubIdentifierRepository = this.appDataSource - .getRepository(GitHubUserIdentifierEntity) - .extend(userGitHubIdentifierCustomRepositoryExtension); - this._companyInfoRepository = this.appDataSource - .getRepository(CompanyInfoEntity) - .extend(companyInfoRepositoryExtension); - this._invitationInCompanyRepository = this.appDataSource - .getRepository(InvitationInCompanyEntity) - .extend(invitationInCompanyCustomRepositoryExtension); - this._userSessionSettingsRepository = this.appDataSource - .getRepository(UserSessionSettingsEntity) - .extend(userSessionSettingsRepositoryExtension); - this._actionRulesRepository = this.appDataSource - .getRepository(ActionRulesEntity) - .extend(actionRulesCustomRepositoryExtension); - this._actionEventsRepository = this.appDataSource - .getRepository(ActionEventsEntity) - .extend(actionEventsCustomRepositoryExtension); - this._userApiKeysRepository = this.appDataSource.getRepository(UserApiKeyEntity).extend(userApiRepositoryExtension); - this._companyLogoRepository = this.appDataSource.getRepository(CompanyLogoEntity); - this._companyFaviconRepository = this.appDataSource.getRepository(CompanyFaviconEntity); - this._companyTabTitleRepository = this.appDataSource.getRepository(CompanyTabTitleEntity); - this._tableFiltersRepository = this.appDataSource - .getRepository(TableFiltersEntity) - .extend(tableFiltersCustomRepositoryExtension); - this._aiResponsesToUserRepository = this.appDataSource - .getRepository(AiResponsesToUserEntity) - .extend(aiResponsesToUserRepositoryExtension); - this._tableCategoriesRepository = this.appDataSource - .getRepository(TableCategoriesEntity) - .extend(tableCategoriesCustomRepositoryExtension); - this._userSecretRepository = this.appDataSource - .getRepository(UserSecretEntity) - .extend(userSecretRepositoryExtension); - this._secretAccessLogRepository = this.appDataSource - .getRepository(SecretAccessLogEntity) - .extend(secretAccessLogRepositoryExtension); - this._signInAuditRepository = this.appDataSource - .getRepository(SignInAuditEntity) - .extend(signInAuditCustomRepositoryExtension); - this._personalTableSettingsRepository = this.appDataSource - .getRepository(PersonalTableSettingsEntity) - .extend(personalTableSettingsCustomRepositoryExtension); - } - - public get userRepository(): Repository & IUserRepository { - return this._userRepository; - } - - public get connectionRepository(): Repository & IConnectionRepository { - return this._connectionRepository; - } - - public get groupRepository(): IGroupRepository { - return this._groupRepository; - } - - public get permissionRepository(): IPermissionRepository { - return this._permissionRepository; - } - - public get tableSettingsRepository(): Repository & ITableSettingsRepository { - return this._tableSettingsRepository; - } - - public get userAccessRepository(): IUserAccessRepository { - return this._userAccessRepository; - } - - public get agentRepository(): IAgentRepository { - return this._agentRepository; - } - - public get emailVerificationRepository(): IEmailVerificationRepository { - return this._emailVerificationRepository; - } - - public get passwordResetRepository(): IPasswordResetRepository { - return this._passwordResetRepository; - } - - public get emailChangeRepository(): IEmailChangeRepository { - return this._emailChangeRepository; - } - - public get userInvitationRepository(): IUserInvitationRepository { - return this._userInvitationRepository; - } - - public get connectionPropertiesRepository(): Repository & - IConnectionPropertiesRepository { - return this._connectionPropertiesRepository; - } - - public get customFieldsRepository(): ICustomFieldsRepository { - return this._customFieldsRepository; - } - - public get tableLogsRepository(): ITableLogsRepository { - return this._tableLogsRepository; - } - - public get userActionRepository(): IUserActionRepository { - return this._userActionRepository; - } - - public get logOutRepository(): ILogOutRepository { - return this._logOutRepository; - } - - public get tableWidgetsRepository(): Repository & ITableWidgetsRepository { - return this._tableWidgetsRepository; - } - - public get tableInfoRepository(): Repository { - return this._tableInfoReposioty; - } - - public get tableFieldInfoRepository(): Repository { - return this._tableFieldInfoRepository; - } - - public get tableActionRepository(): Repository & ITableActionRepository { - return this._tableActionRepository; - } - - public get userGitHubIdentifierRepository(): IUserGitHubIdentifierRepository { - return this._userGitHubIdentifierRepository; - } - - public get companyInfoRepository(): Repository & ICompanyInfoRepository { - return this._companyInfoRepository; - } - - public get invitationInCompanyRepository(): Repository & IInvitationInCompanyRepository { - return this._invitationInCompanyRepository; - } - - public get userSessionSettingsRepository(): Repository & IUserSessionSettings { - return this._userSessionSettingsRepository; - } - - public get actionRulesRepository(): Repository & IActionRulesRepository { - return this._actionRulesRepository; - } - - public get actionEventsRepository(): Repository & IActionEventsRepository { - return this._actionEventsRepository; - } - - public get userApiKeysRepository(): Repository & IUserApiKeyRepository { - return this._userApiKeysRepository; - } - - public get companyLogoRepository(): Repository { - return this._companyLogoRepository; - } - - public get companyFaviconRepository(): Repository { - return this._companyFaviconRepository; - } - - public get companyTabTitleRepository(): Repository { - return this._companyTabTitleRepository; - } - - public get tableFiltersRepository(): Repository & ITableFiltersCustomRepository { - return this._tableFiltersRepository; - } - - public get aiResponsesToUserRepository(): Repository & IAiResponsesToUserRepository { - return this._aiResponsesToUserRepository; - } - - public get tableCategoriesRepository(): Repository & ITableCategoriesCustomRepository { - return this._tableCategoriesRepository; - } - - public get userSecretRepository(): Repository & IUserSecretRepository { - return this._userSecretRepository; - } - - public get secretAccessLogRepository(): Repository & ISecretAccessLogRepository { - return this._secretAccessLogRepository; - } - - public get signInAuditRepository(): Repository & ISignInAuditRepository { - return this._signInAuditRepository; - } - - public get personalTableSettingsRepository(): Repository & - IPersonalTableSettingsRepository { - return this._personalTableSettingsRepository; - } - - public startTransaction(): Promise { - this._queryRunner = this.appDataSource.createQueryRunner(); - this._queryRunner.startTransaction(); - return; - } - - public async commitTransaction(): Promise { - if (!this._queryRunner) return; - await this._queryRunner.commitTransaction(); - } - - public async rollbackTransaction(): Promise { - if (!this._queryRunner) return; - try { - await this._queryRunner.rollbackTransaction(); - } catch (e) { - console.error(e); - throw e; - } - } - - public async releaseQueryRunner(): Promise { - if (!this._queryRunner) return; - await this._queryRunner.release(); - } + private _queryRunner: QueryRunner; + + private _userRepository: Repository & IUserRepository; + private _connectionRepository: Repository & IConnectionRepository; + private _groupRepository: IGroupRepository; + private _permissionRepository: IPermissionRepository; + private _tableSettingsRepository: Repository & ITableSettingsRepository; + private _userAccessRepository: IUserAccessRepository; + private _agentRepository: IAgentRepository; + private _emailVerificationRepository: IEmailVerificationRepository; + private _passwordResetRepository: IPasswordResetRepository; + private _emailChangeRepository: IEmailChangeRepository; + private _userInvitationRepository: IUserInvitationRepository; + private _connectionPropertiesRepository: Repository & IConnectionPropertiesRepository; + private _customFieldsRepository: ICustomFieldsRepository; + private _tableLogsRepository: ITableLogsRepository; + private _userActionRepository: IUserActionRepository; + private _logOutRepository: ILogOutRepository; + private _tableWidgetsRepository: Repository & ITableWidgetsRepository; + private _tableFieldInfoRepository: Repository; + private _tableInfoReposioty: Repository; + private _tableActionRepository: Repository & ITableActionRepository; + private _userGitHubIdentifierRepository: IUserGitHubIdentifierRepository; + private _companyInfoRepository: Repository & ICompanyInfoRepository; + private _invitationInCompanyRepository: Repository & IInvitationInCompanyRepository; + private _userSessionSettingsRepository: Repository & IUserSessionSettings; + private _actionRulesRepository: Repository & IActionRulesRepository; + private _actionEventsRepository: Repository & IActionEventsRepository; + private _userApiKeysRepository: Repository & IUserApiKeyRepository; + private _companyLogoRepository: Repository; + private _companyFaviconRepository: Repository; + private _companyTabTitleRepository: Repository; + private _tableFiltersRepository: Repository & ITableFiltersCustomRepository; + private _aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; + private _tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; + private _userSecretRepository: Repository & IUserSecretRepository; + private _secretAccessLogRepository: Repository & ISecretAccessLogRepository; + private _signInAuditRepository: Repository & ISignInAuditRepository; + private _personalTableSettingsRepository: Repository & IPersonalTableSettingsRepository; + private _savedDbQueryRepository: Repository & ISavedDbQueryRepository; + + public constructor( + @Inject(BaseType.DATA_SOURCE) + public appDataSource: DataSource, + ) { + this.initRepositories(); + } + + private initRepositories(): void { + this._userRepository = this.appDataSource.getRepository(UserEntity).extend(userCustomRepositoryExtension); + this._connectionRepository = this.appDataSource + .getRepository(ConnectionEntity) + .extend(customConnectionRepositoryExtension); + this._groupRepository = this.appDataSource.getRepository(GroupEntity).extend(groupCustomRepositoryExtension); + this._permissionRepository = this.appDataSource + .getRepository(PermissionEntity) + .extend(permissionCustomRepositoryExtension); + this._tableSettingsRepository = this.appDataSource + .getRepository(TableSettingsEntity) + .extend(tableSettingsCustomRepositoryExtension); + this._userAccessRepository = this.appDataSource + .getRepository(PermissionEntity) + .extend(userAccessCustomReposiotoryExtension); + this._agentRepository = this.appDataSource.getRepository(AgentEntity).extend(customAgentRepositoryExtension); + this._emailVerificationRepository = this.appDataSource + .getRepository(EmailVerificationEntity) + .extend(emailVerificationRepositoryExtension); + this._passwordResetRepository = this.appDataSource + .getRepository(PasswordResetEntity) + .extend(userPasswordResetCustomRepositoryExtension); + this._emailChangeRepository = this.appDataSource + .getRepository(EmailChangeEntity) + .extend(emailChangeCustomRepositoryExtension); + this._userInvitationRepository = this.appDataSource + .getRepository(UserInvitationEntity) + .extend(userInvitationCustomRepositoryExtension); + this._connectionPropertiesRepository = this.appDataSource + .getRepository(ConnectionPropertiesEntity) + .extend(customConnectionPropertiesRepositoryExtension); + this._customFieldsRepository = this.appDataSource + .getRepository(CustomFieldsEntity) + .extend(cusomFieldsCustomRepositoryExtension); + this._tableLogsRepository = this.appDataSource + .getRepository(TableLogsEntity) + .extend(tableLogsCustomRepositoryExtension); + this._userActionRepository = this.appDataSource + .getRepository(UserActionEntity) + .extend(userActionCustomRepositoryExtension); + this._logOutRepository = this.appDataSource.getRepository(LogOutEntity).extend(logOutCustomRepositoryExtension); + this._tableWidgetsRepository = this.appDataSource + .getRepository(TableWidgetEntity) + .extend(tableWidgetsCustomRepositoryExtension); + this._tableInfoReposioty = this.appDataSource.getRepository(TableInfoEntity); + this._tableFieldInfoRepository = this.appDataSource.getRepository(TableFieldInfoEntity); + this._tableActionRepository = this.appDataSource + .getRepository(TableActionEntity) + .extend(tableActionsCustomRepositoryExtension); + this._userGitHubIdentifierRepository = this.appDataSource + .getRepository(GitHubUserIdentifierEntity) + .extend(userGitHubIdentifierCustomRepositoryExtension); + this._companyInfoRepository = this.appDataSource + .getRepository(CompanyInfoEntity) + .extend(companyInfoRepositoryExtension); + this._invitationInCompanyRepository = this.appDataSource + .getRepository(InvitationInCompanyEntity) + .extend(invitationInCompanyCustomRepositoryExtension); + this._userSessionSettingsRepository = this.appDataSource + .getRepository(UserSessionSettingsEntity) + .extend(userSessionSettingsRepositoryExtension); + this._actionRulesRepository = this.appDataSource + .getRepository(ActionRulesEntity) + .extend(actionRulesCustomRepositoryExtension); + this._actionEventsRepository = this.appDataSource + .getRepository(ActionEventsEntity) + .extend(actionEventsCustomRepositoryExtension); + this._userApiKeysRepository = this.appDataSource.getRepository(UserApiKeyEntity).extend(userApiRepositoryExtension); + this._companyLogoRepository = this.appDataSource.getRepository(CompanyLogoEntity); + this._companyFaviconRepository = this.appDataSource.getRepository(CompanyFaviconEntity); + this._companyTabTitleRepository = this.appDataSource.getRepository(CompanyTabTitleEntity); + this._tableFiltersRepository = this.appDataSource + .getRepository(TableFiltersEntity) + .extend(tableFiltersCustomRepositoryExtension); + this._aiResponsesToUserRepository = this.appDataSource + .getRepository(AiResponsesToUserEntity) + .extend(aiResponsesToUserRepositoryExtension); + this._tableCategoriesRepository = this.appDataSource + .getRepository(TableCategoriesEntity) + .extend(tableCategoriesCustomRepositoryExtension); + this._userSecretRepository = this.appDataSource + .getRepository(UserSecretEntity) + .extend(userSecretRepositoryExtension); + this._secretAccessLogRepository = this.appDataSource + .getRepository(SecretAccessLogEntity) + .extend(secretAccessLogRepositoryExtension); + this._signInAuditRepository = this.appDataSource + .getRepository(SignInAuditEntity) + .extend(signInAuditCustomRepositoryExtension); + this._personalTableSettingsRepository = this.appDataSource + .getRepository(PersonalTableSettingsEntity) + .extend(personalTableSettingsCustomRepositoryExtension); + this._savedDbQueryRepository = this.appDataSource + .getRepository(SavedDbQueryEntity) + .extend(savedDbQueryCustomRepositoryExtension); + } + + public get userRepository(): Repository & IUserRepository { + return this._userRepository; + } + + public get connectionRepository(): Repository & IConnectionRepository { + return this._connectionRepository; + } + + public get groupRepository(): IGroupRepository { + return this._groupRepository; + } + + public get permissionRepository(): IPermissionRepository { + return this._permissionRepository; + } + + public get tableSettingsRepository(): Repository & ITableSettingsRepository { + return this._tableSettingsRepository; + } + + public get userAccessRepository(): IUserAccessRepository { + return this._userAccessRepository; + } + + public get agentRepository(): IAgentRepository { + return this._agentRepository; + } + + public get emailVerificationRepository(): IEmailVerificationRepository { + return this._emailVerificationRepository; + } + + public get passwordResetRepository(): IPasswordResetRepository { + return this._passwordResetRepository; + } + + public get emailChangeRepository(): IEmailChangeRepository { + return this._emailChangeRepository; + } + + public get userInvitationRepository(): IUserInvitationRepository { + return this._userInvitationRepository; + } + + public get connectionPropertiesRepository(): Repository & + IConnectionPropertiesRepository { + return this._connectionPropertiesRepository; + } + + public get customFieldsRepository(): ICustomFieldsRepository { + return this._customFieldsRepository; + } + + public get tableLogsRepository(): ITableLogsRepository { + return this._tableLogsRepository; + } + + public get userActionRepository(): IUserActionRepository { + return this._userActionRepository; + } + + public get logOutRepository(): ILogOutRepository { + return this._logOutRepository; + } + + public get tableWidgetsRepository(): Repository & ITableWidgetsRepository { + return this._tableWidgetsRepository; + } + + public get tableInfoRepository(): Repository { + return this._tableInfoReposioty; + } + + public get tableFieldInfoRepository(): Repository { + return this._tableFieldInfoRepository; + } + + public get tableActionRepository(): Repository & ITableActionRepository { + return this._tableActionRepository; + } + + public get userGitHubIdentifierRepository(): IUserGitHubIdentifierRepository { + return this._userGitHubIdentifierRepository; + } + + public get companyInfoRepository(): Repository & ICompanyInfoRepository { + return this._companyInfoRepository; + } + + public get invitationInCompanyRepository(): Repository & IInvitationInCompanyRepository { + return this._invitationInCompanyRepository; + } + + public get userSessionSettingsRepository(): Repository & IUserSessionSettings { + return this._userSessionSettingsRepository; + } + + public get actionRulesRepository(): Repository & IActionRulesRepository { + return this._actionRulesRepository; + } + + public get actionEventsRepository(): Repository & IActionEventsRepository { + return this._actionEventsRepository; + } + + public get userApiKeysRepository(): Repository & IUserApiKeyRepository { + return this._userApiKeysRepository; + } + + public get companyLogoRepository(): Repository { + return this._companyLogoRepository; + } + + public get companyFaviconRepository(): Repository { + return this._companyFaviconRepository; + } + + public get companyTabTitleRepository(): Repository { + return this._companyTabTitleRepository; + } + + public get tableFiltersRepository(): Repository & ITableFiltersCustomRepository { + return this._tableFiltersRepository; + } + + public get aiResponsesToUserRepository(): Repository & IAiResponsesToUserRepository { + return this._aiResponsesToUserRepository; + } + + public get tableCategoriesRepository(): Repository & ITableCategoriesCustomRepository { + return this._tableCategoriesRepository; + } + + public get userSecretRepository(): Repository & IUserSecretRepository { + return this._userSecretRepository; + } + + public get secretAccessLogRepository(): Repository & ISecretAccessLogRepository { + return this._secretAccessLogRepository; + } + + public get signInAuditRepository(): Repository & ISignInAuditRepository { + return this._signInAuditRepository; + } + + public get personalTableSettingsRepository(): Repository & + IPersonalTableSettingsRepository { + return this._personalTableSettingsRepository; + } + + public get savedDbQueryRepository(): Repository & ISavedDbQueryRepository { + return this._savedDbQueryRepository; + } + + public startTransaction(): Promise { + this._queryRunner = this.appDataSource.createQueryRunner(); + this._queryRunner.startTransaction(); + return; + } + + public async commitTransaction(): Promise { + if (!this._queryRunner) return; + await this._queryRunner.commitTransaction(); + } + + public async rollbackTransaction(): Promise { + if (!this._queryRunner) return; + try { + await this._queryRunner.rollbackTransaction(); + } catch (e) { + console.error(e); + throw e; + } + } + + public async releaseQueryRunner(): Promise { + if (!this._queryRunner) return; + await this._queryRunner.release(); + } } diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 0f9c43065..03f595f00 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -180,4 +180,12 @@ export enum UseCaseType { GET_S3_FILE_URL = 'GET_S3_FILE_URL', GET_S3_UPLOAD_URL = 'GET_S3_UPLOAD_URL', + + CREATE_SAVED_DB_QUERY = 'CREATE_SAVED_DB_QUERY', + UPDATE_SAVED_DB_QUERY = 'UPDATE_SAVED_DB_QUERY', + FIND_SAVED_DB_QUERY = 'FIND_SAVED_DB_QUERY', + FIND_ALL_SAVED_DB_QUERIES = 'FIND_ALL_SAVED_DB_QUERIES', + DELETE_SAVED_DB_QUERY = 'DELETE_SAVED_DB_QUERY', + EXECUTE_SAVED_DB_QUERY = 'EXECUTE_SAVED_DB_QUERY', + TEST_DB_QUERY = 'TEST_DB_QUERY', } diff --git a/backend/src/entities/connection/connection.entity.ts b/backend/src/entities/connection/connection.entity.ts index b3cad30f0..751f462ab 100644 --- a/backend/src/entities/connection/connection.entity.ts +++ b/backend/src/entities/connection/connection.entity.ts @@ -27,6 +27,7 @@ import { nanoid } from 'nanoid'; import { Constants } from '../../helpers/constants/constants.js'; import { TableFiltersEntity } from '../table-filters/table-filters.entity.js'; import { PersonalTableSettingsEntity } from '../table-settings/personal-table-settings/personal-table-settings.entity.js'; +import { SavedDbQueryEntity } from '../visualizations/saved-db-query/saved-db-query.entity.js'; @Entity('connection') export class ConnectionEntity { @@ -245,4 +246,7 @@ export class ConnectionEntity { @OneToMany((_) => TableFiltersEntity, (table_filters) => table_filters.connection) table_filters: Relation[]; + + @OneToMany(() => SavedDbQueryEntity, (saved_db_query) => saved_db_query.connection) + saved_db_queries: Relation[]; } diff --git a/backend/src/entities/visualizations/dashboard/dashboards.controller.ts b/backend/src/entities/visualizations/dashboard/dashboards.controller.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/entities/visualizations/dashboard/dashboards.module.ts b/backend/src/entities/visualizations/dashboard/dashboards.module.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/entities/visualizations/saved-db-query/data-structures/create-saved-db-query.ds.ts b/backend/src/entities/visualizations/saved-db-query/data-structures/create-saved-db-query.ds.ts new file mode 100644 index 000000000..9de6c7c62 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/data-structures/create-saved-db-query.ds.ts @@ -0,0 +1,7 @@ +export class CreateSavedDbQueryDs { + connectionId: string; + masterPassword: string; + name: string; + description?: string; + query_text: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/data-structures/execute-saved-db-query.ds.ts b/backend/src/entities/visualizations/saved-db-query/data-structures/execute-saved-db-query.ds.ts new file mode 100644 index 000000000..5ea3e3596 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/data-structures/execute-saved-db-query.ds.ts @@ -0,0 +1,7 @@ +export class ExecuteSavedDbQueryDs { + queryId: string; + connectionId: string; + masterPassword: string; + tableName: string; + userId: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/data-structures/find-all-saved-db-queries.ds.ts b/backend/src/entities/visualizations/saved-db-query/data-structures/find-all-saved-db-queries.ds.ts new file mode 100644 index 000000000..a8f8b43e0 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/data-structures/find-all-saved-db-queries.ds.ts @@ -0,0 +1,4 @@ +export class FindAllSavedDbQueriesDs { + connectionId: string; + masterPassword: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/data-structures/find-saved-db-query.ds.ts b/backend/src/entities/visualizations/saved-db-query/data-structures/find-saved-db-query.ds.ts new file mode 100644 index 000000000..f574a6801 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/data-structures/find-saved-db-query.ds.ts @@ -0,0 +1,5 @@ +export class FindSavedDbQueryDs { + queryId: string; + connectionId: string; + masterPassword: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/data-structures/test-db-query.ds.ts b/backend/src/entities/visualizations/saved-db-query/data-structures/test-db-query.ds.ts new file mode 100644 index 000000000..1ec3b2efe --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/data-structures/test-db-query.ds.ts @@ -0,0 +1,7 @@ +export class TestDbQueryDs { + connectionId: string; + masterPassword: string; + query_text: string; + tableName?: string; + userId: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/data-structures/update-saved-db-query.ds.ts b/backend/src/entities/visualizations/saved-db-query/data-structures/update-saved-db-query.ds.ts new file mode 100644 index 000000000..dee72a4cf --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/data-structures/update-saved-db-query.ds.ts @@ -0,0 +1,8 @@ +export class UpdateSavedDbQueryDs { + queryId: string; + connectionId: string; + masterPassword: string; + name?: string; + description?: string; + query_text?: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/dto/create-saved-db-query.dto.ts b/backend/src/entities/visualizations/saved-db-query/dto/create-saved-db-query.dto.ts new file mode 100644 index 000000000..2eed96c7c --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/dto/create-saved-db-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateSavedDbQueryDto { + @ApiProperty({ type: String, description: 'The name of the saved query' }) + @IsNotEmpty() + @IsString() + @MaxLength(255) + name: string; + + @ApiProperty({ type: String, description: 'The description of the saved query', required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ type: String, description: 'The SQL query text' }) + @IsNotEmpty() + @IsString() + query_text: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/dto/execute-saved-db-query-result.dto.ts b/backend/src/entities/visualizations/saved-db-query/dto/execute-saved-db-query-result.dto.ts new file mode 100644 index 000000000..b7b66e58e --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/dto/execute-saved-db-query-result.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ExecuteSavedDbQueryResultDto { + @ApiProperty({ type: String, description: 'The saved query id' }) + query_id: string; + + @ApiProperty({ type: String, description: 'The saved query name' }) + query_name: string; + + @ApiProperty({ type: Array, description: 'The query execution result' }) + data: Array>; + + @ApiProperty({ type: Number, description: 'Execution time in milliseconds' }) + execution_time_ms: number; +} diff --git a/backend/src/entities/visualizations/saved-db-query/dto/found-saved-db-query.dto.ts b/backend/src/entities/visualizations/saved-db-query/dto/found-saved-db-query.dto.ts new file mode 100644 index 000000000..8f12f1f73 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/dto/found-saved-db-query.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class FoundSavedDbQueryDto { + @ApiProperty({ type: String, description: 'The entity id' }) + id: string; + + @ApiProperty({ type: String, description: 'The name of the saved query' }) + name: string; + + @ApiProperty({ type: String, description: 'The description of the saved query', nullable: true }) + description: string | null; + + @ApiProperty({ type: String, description: 'The SQL query text' }) + query_text: string; + + @ApiProperty({ type: String, description: 'The connection id' }) + connection_id: string; + + @ApiProperty({ type: Date, description: 'The creation date' }) + created_at: Date; + + @ApiProperty({ type: Date, description: 'The last update date' }) + updated_at: Date; +} diff --git a/backend/src/entities/visualizations/saved-db-query/dto/test-db-query-result.dto.ts b/backend/src/entities/visualizations/saved-db-query/dto/test-db-query-result.dto.ts new file mode 100644 index 000000000..551f03f62 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/dto/test-db-query-result.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TestDbQueryResultDto { + @ApiProperty({ description: 'The query result data' }) + data: Array>; + + @ApiProperty({ description: 'Query execution time in milliseconds' }) + execution_time_ms: number; +} diff --git a/backend/src/entities/visualizations/saved-db-query/dto/test-db-query.dto.ts b/backend/src/entities/visualizations/saved-db-query/dto/test-db-query.dto.ts new file mode 100644 index 000000000..8bca3d7ca --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/dto/test-db-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class TestDbQueryDto { + @ApiProperty({ description: 'The query text to test' }) + @IsString() + @IsNotEmpty() + query_text: string; + + @ApiProperty({ description: 'Optional table name for NoSQL databases', required: false }) + @IsString() + @IsOptional() + tableName?: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/dto/update-saved-db-query.dto.ts b/backend/src/entities/visualizations/saved-db-query/dto/update-saved-db-query.dto.ts new file mode 100644 index 000000000..1f6f01c47 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/dto/update-saved-db-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateSavedDbQueryDto { + @ApiProperty({ type: String, description: 'The name of the saved query', required: false }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiProperty({ type: String, description: 'The description of the saved query', required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ type: String, description: 'The SQL query text', required: false }) + @IsOptional() + @IsString() + query_text?: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/repository/saved-db-query-custom-repository-extension.ts b/backend/src/entities/visualizations/saved-db-query/repository/saved-db-query-custom-repository-extension.ts new file mode 100644 index 000000000..c41bb6de6 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/repository/saved-db-query-custom-repository-extension.ts @@ -0,0 +1,24 @@ +import { SavedDbQueryEntity } from '../saved-db-query.entity.js'; +import { ISavedDbQueryRepository } from './saved-db-query.repository.interface.js'; + +export const savedDbQueryCustomRepositoryExtension: ISavedDbQueryRepository = { + async findAllQueriesByConnectionId(connectionId: string): Promise { + const qb = this.createQueryBuilder('saved_db_query'); + qb.where('saved_db_query.connection_id = :connectionId', { connectionId }); + qb.orderBy('saved_db_query.created_at', 'DESC'); + return await qb.getMany(); + }, + + async findQueryById(queryId: string): Promise { + const qb = this.createQueryBuilder('saved_db_query'); + qb.where('saved_db_query.id = :queryId', { queryId }); + return await qb.getOne(); + }, + + async findQueryByIdAndConnectionId(queryId: string, connectionId: string): Promise { + const qb = this.createQueryBuilder('saved_db_query'); + qb.where('saved_db_query.id = :queryId', { queryId }); + qb.andWhere('saved_db_query.connection_id = :connectionId', { connectionId }); + return await qb.getOne(); + }, +}; diff --git a/backend/src/entities/visualizations/saved-db-query/repository/saved-db-query.repository.interface.ts b/backend/src/entities/visualizations/saved-db-query/repository/saved-db-query.repository.interface.ts new file mode 100644 index 000000000..94e466d55 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/repository/saved-db-query.repository.interface.ts @@ -0,0 +1,7 @@ +import { SavedDbQueryEntity } from '../saved-db-query.entity.js'; + +export interface ISavedDbQueryRepository { + findAllQueriesByConnectionId(connectionId: string): Promise; + findQueryById(queryId: string): Promise; + findQueryByIdAndConnectionId(queryId: string, connectionId: string): Promise; +} diff --git a/backend/src/entities/visualizations/saved-db-query/saved-db-query.controller.ts b/backend/src/entities/visualizations/saved-db-query/saved-db-query.controller.ts new file mode 100644 index 000000000..1faa84b35 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/saved-db-query.controller.ts @@ -0,0 +1,300 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Inject, + Injectable, + Param, + Post, + Put, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UseCaseType } from '../../../common/data-injection.tokens.js'; +import { MasterPassword } from '../../../decorators/master-password.decorator.js'; +import { SlugUuid } from '../../../decorators/slug-uuid.decorator.js'; +import { UserId } from '../../../decorators/user-id.decorator.js'; +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { ConnectionReadGuard } from '../../../guards/connection-read.guard.js'; +import { ConnectionEditGuard } from '../../../guards/connection-edit.guard.js'; +import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; +import { CreateSavedDbQueryDs } from './data-structures/create-saved-db-query.ds.js'; +import { ExecuteSavedDbQueryDs } from './data-structures/execute-saved-db-query.ds.js'; +import { FindAllSavedDbQueriesDs } from './data-structures/find-all-saved-db-queries.ds.js'; +import { FindSavedDbQueryDs } from './data-structures/find-saved-db-query.ds.js'; +import { UpdateSavedDbQueryDs } from './data-structures/update-saved-db-query.ds.js'; +import { CreateSavedDbQueryDto } from './dto/create-saved-db-query.dto.js'; +import { ExecuteSavedDbQueryResultDto } from './dto/execute-saved-db-query-result.dto.js'; +import { FoundSavedDbQueryDto } from './dto/found-saved-db-query.dto.js'; +import { TestDbQueryDto } from './dto/test-db-query.dto.js'; +import { TestDbQueryResultDto } from './dto/test-db-query-result.dto.js'; +import { UpdateSavedDbQueryDto } from './dto/update-saved-db-query.dto.js'; +import { + ICreateSavedDbQuery, + IDeleteSavedDbQuery, + IExecuteSavedDbQuery, + IFindAllSavedDbQueries, + IFindSavedDbQuery, + ITestDbQuery, + IUpdateSavedDbQuery, +} from './use-cases/saved-db-query-use-cases.interface.js'; +import { TestDbQueryDs } from './data-structures/test-db-query.ds.js'; + +@UseInterceptors(SentryInterceptor) +@Controller() +@ApiBearerAuth() +@ApiTags('Saved database queries') +@Injectable() +export class SavedDbQueryController { + constructor( + @Inject(UseCaseType.CREATE_SAVED_DB_QUERY) + private readonly createSavedDbQueryUseCase: ICreateSavedDbQuery, + @Inject(UseCaseType.UPDATE_SAVED_DB_QUERY) + private readonly updateSavedDbQueryUseCase: IUpdateSavedDbQuery, + @Inject(UseCaseType.FIND_SAVED_DB_QUERY) + private readonly findSavedDbQueryUseCase: IFindSavedDbQuery, + @Inject(UseCaseType.FIND_ALL_SAVED_DB_QUERIES) + private readonly findAllSavedDbQueriesUseCase: IFindAllSavedDbQueries, + @Inject(UseCaseType.DELETE_SAVED_DB_QUERY) + private readonly deleteSavedDbQueryUseCase: IDeleteSavedDbQuery, + @Inject(UseCaseType.EXECUTE_SAVED_DB_QUERY) + private readonly executeSavedDbQueryUseCase: IExecuteSavedDbQuery, + @Inject(UseCaseType.TEST_DB_QUERY) + private readonly testDbQueryUseCase: ITestDbQuery, + ) {} + + @ApiOperation({ summary: 'Get all saved queries for a connection' }) + @ApiResponse({ + status: 200, + description: 'Saved queries found.', + type: FoundSavedDbQueryDto, + isArray: true, + }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionReadGuard) + @Get('/connection/:connectionId/saved-queries') + async findAll( + @SlugUuid('connectionId') connectionId: string, + @MasterPassword() masterPwd: string, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: FindAllSavedDbQueriesDs = { + connectionId, + masterPassword: masterPwd, + }; + return await this.findAllSavedDbQueriesUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Get a saved query by id' }) + @ApiResponse({ + status: 200, + description: 'Saved query found.', + type: FoundSavedDbQueryDto, + }) + @ApiParam({ name: 'connectionId', required: true }) + @ApiParam({ name: 'queryId', required: true }) + @UseGuards(ConnectionReadGuard) + @Get('/connection/:connectionId/saved-query/:queryId') + async findOne( + @SlugUuid('connectionId') connectionId: string, + @Param('queryId') queryId: string, + @MasterPassword() masterPwd: string, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: FindSavedDbQueryDs = { + queryId, + connectionId, + masterPassword: masterPwd, + }; + return await this.findSavedDbQueryUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Create a new saved query' }) + @ApiResponse({ + status: 201, + description: 'Saved query created.', + type: FoundSavedDbQueryDto, + }) + @ApiBody({ type: CreateSavedDbQueryDto }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Post('/connection/:connectionId/saved-query') + async create( + @SlugUuid('connectionId') connectionId: string, + @MasterPassword() masterPwd: string, + @Body() createDto: CreateSavedDbQueryDto, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: CreateSavedDbQueryDs = { + connectionId, + masterPassword: masterPwd, + name: createDto.name, + description: createDto.description, + query_text: createDto.query_text, + }; + return await this.createSavedDbQueryUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Update a saved query' }) + @ApiResponse({ + status: 200, + description: 'Saved query updated.', + type: FoundSavedDbQueryDto, + }) + @ApiBody({ type: UpdateSavedDbQueryDto }) + @ApiParam({ name: 'connectionId', required: true }) + @ApiParam({ name: 'queryId', required: true }) + @UseGuards(ConnectionEditGuard) + @Put('/connection/:connectionId/saved-query/:queryId') + async update( + @SlugUuid('connectionId') connectionId: string, + @Param('queryId') queryId: string, + @MasterPassword() masterPwd: string, + @Body() updateDto: UpdateSavedDbQueryDto, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: UpdateSavedDbQueryDs = { + queryId, + connectionId, + masterPassword: masterPwd, + name: updateDto.name, + description: updateDto.description, + query_text: updateDto.query_text, + }; + return await this.updateSavedDbQueryUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Delete a saved query' }) + @ApiResponse({ + status: 200, + description: 'Saved query deleted.', + type: FoundSavedDbQueryDto, + }) + @ApiParam({ name: 'connectionId', required: true }) + @ApiParam({ name: 'queryId', required: true }) + @UseGuards(ConnectionEditGuard) + @Delete('/connection/:connectionId/saved-query/:queryId') + async delete( + @SlugUuid('connectionId') connectionId: string, + @Param('queryId') queryId: string, + @MasterPassword() masterPwd: string, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: FindSavedDbQueryDs = { + queryId, + connectionId, + masterPassword: masterPwd, + }; + return await this.deleteSavedDbQueryUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Execute a saved query' }) + @ApiResponse({ + status: 200, + description: 'Query executed successfully.', + type: ExecuteSavedDbQueryResultDto, + }) + @ApiParam({ name: 'connectionId', required: true }) + @ApiParam({ name: 'queryId', required: true }) + @UseGuards(ConnectionReadGuard) + @Post('/connection/:connectionId/saved-query/:queryId/execute') + async execute( + @SlugUuid('connectionId') connectionId: string, + @Param('queryId') queryId: string, + @Query('tableName') tableName: string, + @MasterPassword() masterPwd: string, + @UserId() userId: string, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: ExecuteSavedDbQueryDs = { + queryId, + connectionId, + masterPassword: masterPwd, + tableName, + userId, + }; + return await this.executeSavedDbQueryUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Test a query without saving it' }) + @ApiResponse({ + status: 201, + description: 'Query executed successfully.', + type: TestDbQueryResultDto, + }) + @ApiBody({ type: TestDbQueryDto }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionReadGuard) + @Post('/connection/:connectionId/query/test') + async testQuery( + @SlugUuid('connectionId') connectionId: string, + @MasterPassword() masterPwd: string, + @UserId() userId: string, + @Body() testDto: TestDbQueryDto, + ): Promise { + if (!connectionId) { + throw new HttpException( + { + message: Messages.CONNECTION_ID_MISSING, + }, + HttpStatus.BAD_REQUEST, + ); + } + const inputData: TestDbQueryDs = { + connectionId, + masterPassword: masterPwd, + query_text: testDto.query_text, + tableName: testDto.tableName, + userId, + }; + return await this.testDbQueryUseCase.execute(inputData, InTransactionEnum.OFF); + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/saved-db-query.entity.ts b/backend/src/entities/visualizations/saved-db-query/saved-db-query.entity.ts new file mode 100644 index 000000000..bf58b04c2 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/saved-db-query.entity.ts @@ -0,0 +1,67 @@ +import { + AfterLoad, + BeforeInsert, + BeforeUpdate, + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, +} from 'typeorm'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; + +@Entity('saved_db_query') +export class SavedDbQueryEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ type: 'text', default: null, nullable: true }) + description: string | null; + + @Column({ type: 'text' }) + query_text: string; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + created_at: Date; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + updated_at: Date; + + @BeforeInsert() + encryptQueryTextOnInsert(): void { + if (this.query_text) { + this.query_text = Encryptor.encryptData(this.query_text); + } + } + + @BeforeUpdate() + encryptQueryTextOnUpdate(): void { + this.updated_at = new Date(); + if (this.query_text) { + this.query_text = Encryptor.encryptData(this.query_text); + } + } + + @AfterLoad() + decryptQueryText(): void { + if (this.query_text) { + this.query_text = Encryptor.decryptData(this.query_text); + } + } + + @ManyToOne( + () => ConnectionEntity, + (connection) => connection.saved_db_queries, + { onDelete: 'CASCADE' }, + ) + @JoinColumn({ name: 'connection_id' }) + connection: Relation; + + @Column({ type: 'varchar', length: 38 }) + connection_id: string; +} diff --git a/backend/src/entities/visualizations/saved-db-query/saved-db-query.module.ts b/backend/src/entities/visualizations/saved-db-query/saved-db-query.module.ts new file mode 100644 index 000000000..a5a4f6682 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/saved-db-query.module.ts @@ -0,0 +1,70 @@ +import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GlobalDatabaseContext } from '../../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../../common/data-injection.tokens.js'; +import { AuthMiddleware } from '../../../authorization/auth.middleware.js'; +import { UserEntity } from '../../user/user.entity.js'; +import { LogOutEntity } from '../../log-out/log-out.entity.js'; +import { SavedDbQueryController } from './saved-db-query.controller.js'; +import { CreateSavedDbQueryUseCase } from './use-cases/create-saved-db-query.use.case.js'; +import { UpdateSavedDbQueryUseCase } from './use-cases/update-saved-db-query.use.case.js'; +import { FindSavedDbQueryUseCase } from './use-cases/find-saved-db-query.use.case.js'; +import { FindAllSavedDbQueriesUseCase } from './use-cases/find-all-saved-db-queries.use.case.js'; +import { DeleteSavedDbQueryUseCase } from './use-cases/delete-saved-db-query.use.case.js'; +import { ExecuteSavedDbQueryUseCase } from './use-cases/execute-saved-db-query.use.case.js'; +import { TestDbQueryUseCase } from './use-cases/test-db-query.use.case.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + { + provide: UseCaseType.CREATE_SAVED_DB_QUERY, + useClass: CreateSavedDbQueryUseCase, + }, + { + provide: UseCaseType.UPDATE_SAVED_DB_QUERY, + useClass: UpdateSavedDbQueryUseCase, + }, + { + provide: UseCaseType.FIND_SAVED_DB_QUERY, + useClass: FindSavedDbQueryUseCase, + }, + { + provide: UseCaseType.FIND_ALL_SAVED_DB_QUERIES, + useClass: FindAllSavedDbQueriesUseCase, + }, + { + provide: UseCaseType.DELETE_SAVED_DB_QUERY, + useClass: DeleteSavedDbQueryUseCase, + }, + { + provide: UseCaseType.EXECUTE_SAVED_DB_QUERY, + useClass: ExecuteSavedDbQueryUseCase, + }, + { + provide: UseCaseType.TEST_DB_QUERY, + useClass: TestDbQueryUseCase, + }, + ], + controllers: [SavedDbQueryController], + exports: [], +}) +export class SavedDbQueryModule { + public configure(consumer: MiddlewareConsumer): void { + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: '/connection/:connectionId/saved-queries', method: RequestMethod.GET }, + { path: '/connection/:connectionId/saved-query/:queryId', method: RequestMethod.GET }, + { path: '/connection/:connectionId/saved-query', method: RequestMethod.POST }, + { path: '/connection/:connectionId/saved-query/:queryId', method: RequestMethod.PUT }, + { path: '/connection/:connectionId/saved-query/:queryId', method: RequestMethod.DELETE }, + { path: '/connection/:connectionId/saved-query/:queryId/execute', method: RequestMethod.POST }, + { path: '/connection/:connectionId/query/test', method: RequestMethod.POST }, + ); + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/create-saved-db-query.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/create-saved-db-query.use.case.ts new file mode 100644 index 000000000..19c08e41a --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/create-saved-db-query.use.case.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +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 { Messages } from '../../../../exceptions/text/messages.js'; +import { CreateSavedDbQueryDs } from '../data-structures/create-saved-db-query.ds.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; +import { SavedDbQueryEntity } from '../saved-db-query.entity.js'; +import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js'; +import { validateQuerySafety } from '../utils/check-query-is-safe.util.js'; +import { ICreateSavedDbQuery } from './saved-db-query-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class CreateSavedDbQueryUseCase + extends AbstractUseCase + implements ICreateSavedDbQuery +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: CreateSavedDbQueryDs): Promise { + const { connectionId, masterPassword, name, description, query_text } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum); + + const newQuery = new SavedDbQueryEntity(); + newQuery.name = name; + newQuery.description = description || null; + newQuery.query_text = query_text; + newQuery.connection_id = foundConnection.id; + + const savedQuery = await this._dbContext.savedDbQueryRepository.save(newQuery); + const savedQueryCopy = { ...savedQuery }; + savedQueryCopy.query_text = query_text; + return buildFoundSavedDbQueryDto(savedQueryCopy as SavedDbQueryEntity); + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/delete-saved-db-query.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/delete-saved-db-query.use.case.ts new file mode 100644 index 000000000..260122cbe --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/delete-saved-db-query.use.case.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +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 { Messages } from '../../../../exceptions/text/messages.js'; +import { FindSavedDbQueryDs } from '../data-structures/find-saved-db-query.ds.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; +import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js'; +import { IDeleteSavedDbQuery } from './saved-db-query-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class DeleteSavedDbQueryUseCase + extends AbstractUseCase + implements IDeleteSavedDbQuery +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: FindSavedDbQueryDs): Promise { + const { queryId, connectionId, masterPassword } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + const foundQuery = await this._dbContext.savedDbQueryRepository.findQueryByIdAndConnectionId(queryId, connectionId); + + if (!foundQuery) { + throw new NotFoundException(Messages.SAVED_QUERY_NOT_FOUND); + } + + const deletedQueryDto = buildFoundSavedDbQueryDto(foundQuery); + await this._dbContext.savedDbQueryRepository.remove(foundQuery); + return deletedQueryDto; + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/execute-saved-db-query.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/execute-saved-db-query.use.case.ts new file mode 100644 index 000000000..2e0a38d07 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/execute-saved-db-query.use.case.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.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 { Messages } from '../../../../exceptions/text/messages.js'; +import { ExecuteSavedDbQueryDs } from '../data-structures/execute-saved-db-query.ds.js'; +import { ExecuteSavedDbQueryResultDto } from '../dto/execute-saved-db-query-result.dto.js'; +import { validateQuerySafety } from '../utils/check-query-is-safe.util.js'; +import { IExecuteSavedDbQuery } from './saved-db-query-use-cases.interface.js'; +import { isConnectionTypeAgent } from '../../../../helpers/is-connection-entity-agent.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class ExecuteSavedDbQueryUseCase + extends AbstractUseCase + implements IExecuteSavedDbQuery +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: ExecuteSavedDbQueryDs): Promise { + const { queryId, connectionId, masterPassword, tableName, userId } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + const foundQuery = await this._dbContext.savedDbQueryRepository.findQueryByIdAndConnectionId(queryId, connectionId); + + if (!foundQuery) { + throw new NotFoundException(Messages.SAVED_QUERY_NOT_FOUND); + } + + validateQuerySafety(foundQuery.query_text, foundConnection.type as ConnectionTypesEnum); + + let userEmail: string | null = null; + if (isConnectionTypeAgent(foundConnection.type)) { + userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId); + } + + const dao = getDataAccessObject(foundConnection); + const startTime = Date.now(); + + const executionResult = await dao.executeRawQuery(foundQuery.query_text, tableName, userEmail); + const processedResult = this.processQueryResult(executionResult, foundConnection.type as ConnectionTypesEnum); + + const executionTime = Date.now() - startTime; + + return { + query_id: foundQuery.id, + query_name: foundQuery.name, + data: processedResult, + execution_time_ms: executionTime, + }; + } + + private processQueryResult(result: unknown, connectionType: ConnectionTypesEnum): Array> { + if (!result) { + return []; + } + if (connectionType === ConnectionTypesEnum.postgres || connectionType === ConnectionTypesEnum.agent_postgres) { + if (result && typeof result === 'object' && 'rows' in result) { + return (result as { rows: Array> }).rows; + } + } + + if (connectionType === ConnectionTypesEnum.mysql || connectionType === ConnectionTypesEnum.agent_mysql) { + if (Array.isArray(result) && result.length >= 1 && Array.isArray(result[0])) { + return result[0]; + } + } + if (Array.isArray(result)) { + return result; + } + return []; + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/find-all-saved-db-queries.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/find-all-saved-db-queries.use.case.ts new file mode 100644 index 000000000..8239c195b --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/find-all-saved-db-queries.use.case.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +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 { Messages } from '../../../../exceptions/text/messages.js'; +import { FindAllSavedDbQueriesDs } from '../data-structures/find-all-saved-db-queries.ds.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; +import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js'; +import { IFindAllSavedDbQueries } from './saved-db-query-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class FindAllSavedDbQueriesUseCase + extends AbstractUseCase + implements IFindAllSavedDbQueries +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: FindAllSavedDbQueriesDs): Promise { + const { connectionId, masterPassword } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + const foundQueries = await this._dbContext.savedDbQueryRepository.findAllQueriesByConnectionId(connectionId); + + return foundQueries.map((query) => buildFoundSavedDbQueryDto(query)); + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/find-saved-db-query.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/find-saved-db-query.use.case.ts new file mode 100644 index 000000000..8b1a8327f --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/find-saved-db-query.use.case.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +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 { Messages } from '../../../../exceptions/text/messages.js'; +import { FindSavedDbQueryDs } from '../data-structures/find-saved-db-query.ds.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; +import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js'; +import { IFindSavedDbQuery } from './saved-db-query-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class FindSavedDbQueryUseCase + extends AbstractUseCase + implements IFindSavedDbQuery +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: FindSavedDbQueryDs): Promise { + const { queryId, connectionId, masterPassword } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + const foundQuery = await this._dbContext.savedDbQueryRepository.findQueryByIdAndConnectionId(queryId, connectionId); + + if (!foundQuery) { + throw new NotFoundException(Messages.SAVED_QUERY_NOT_FOUND); + } + + return buildFoundSavedDbQueryDto(foundQuery); + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/saved-db-query-use-cases.interface.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/saved-db-query-use-cases.interface.ts new file mode 100644 index 000000000..a17f57012 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/saved-db-query-use-cases.interface.ts @@ -0,0 +1,38 @@ +import { InTransactionEnum } from '../../../../enums/in-transaction.enum.js'; +import { CreateSavedDbQueryDs } from '../data-structures/create-saved-db-query.ds.js'; +import { ExecuteSavedDbQueryDs } from '../data-structures/execute-saved-db-query.ds.js'; +import { FindAllSavedDbQueriesDs } from '../data-structures/find-all-saved-db-queries.ds.js'; +import { FindSavedDbQueryDs } from '../data-structures/find-saved-db-query.ds.js'; +import { TestDbQueryDs } from '../data-structures/test-db-query.ds.js'; +import { UpdateSavedDbQueryDs } from '../data-structures/update-saved-db-query.ds.js'; +import { ExecuteSavedDbQueryResultDto } from '../dto/execute-saved-db-query-result.dto.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; +import { TestDbQueryResultDto } from '../dto/test-db-query-result.dto.js'; + +export interface ICreateSavedDbQuery { + execute(inputData: CreateSavedDbQueryDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IUpdateSavedDbQuery { + execute(inputData: UpdateSavedDbQueryDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IFindSavedDbQuery { + execute(inputData: FindSavedDbQueryDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IFindAllSavedDbQueries { + execute(inputData: FindAllSavedDbQueriesDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IDeleteSavedDbQuery { + execute(inputData: FindSavedDbQueryDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IExecuteSavedDbQuery { + execute(inputData: ExecuteSavedDbQueryDs, inTransaction: InTransactionEnum): Promise; +} + +export interface ITestDbQuery { + execute(inputData: TestDbQueryDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/test-db-query.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/test-db-query.use.case.ts new file mode 100644 index 000000000..1011ef850 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/test-db-query.use.case.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.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 { Messages } from '../../../../exceptions/text/messages.js'; +import { isConnectionTypeAgent } from '../../../../helpers/is-connection-entity-agent.js'; +import { TestDbQueryDs } from '../data-structures/test-db-query.ds.js'; +import { TestDbQueryResultDto } from '../dto/test-db-query-result.dto.js'; +import { validateQuerySafety } from '../utils/check-query-is-safe.util.js'; +import { ITestDbQuery } from './saved-db-query-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class TestDbQueryUseCase extends AbstractUseCase implements ITestDbQuery { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: TestDbQueryDs): Promise { + const { connectionId, masterPassword, query_text, tableName, userId } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum); + + let userEmail: string | null = null; + if (isConnectionTypeAgent(foundConnection.type)) { + userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId); + } + + const dao = getDataAccessObject(foundConnection); + const startTime = Date.now(); + + const executionResult = await dao.executeRawQuery(query_text, tableName, userEmail); + const processedResult = this.processQueryResult(executionResult, foundConnection.type as ConnectionTypesEnum); + + const executionTime = Date.now() - startTime; + + return { + data: processedResult, + execution_time_ms: executionTime, + }; + } + + private processQueryResult(result: unknown, connectionType: ConnectionTypesEnum): Array> { + if (!result) { + return []; + } + + if (connectionType === ConnectionTypesEnum.postgres || connectionType === ConnectionTypesEnum.agent_postgres) { + if (result && typeof result === 'object' && 'rows' in result) { + return (result as { rows: Array> }).rows; + } + } + + if (connectionType === ConnectionTypesEnum.mysql || connectionType === ConnectionTypesEnum.agent_mysql) { + if (Array.isArray(result) && result.length >= 1 && Array.isArray(result[0])) { + return result[0]; + } + } + + if (Array.isArray(result)) { + return result; + } + + return []; + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/use-cases/update-saved-db-query.use.case.ts b/backend/src/entities/visualizations/saved-db-query/use-cases/update-saved-db-query.use.case.ts new file mode 100644 index 000000000..489c24ec0 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/use-cases/update-saved-db-query.use.case.ts @@ -0,0 +1,60 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +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 { Messages } from '../../../../exceptions/text/messages.js'; +import { UpdateSavedDbQueryDs } from '../data-structures/update-saved-db-query.ds.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; +import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js'; +import { validateQuerySafety } from '../utils/check-query-is-safe.util.js'; +import { IUpdateSavedDbQuery } from './saved-db-query-use-cases.interface.js'; +import { SavedDbQueryEntity } from '../saved-db-query.entity.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class UpdateSavedDbQueryUseCase + extends AbstractUseCase + implements IUpdateSavedDbQuery +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + public async implementation(inputData: UpdateSavedDbQueryDs): Promise { + const { queryId, connectionId, masterPassword, name, description, query_text } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + const foundQuery = await this._dbContext.savedDbQueryRepository.findQueryByIdAndConnectionId(queryId, connectionId); + + if (!foundQuery) { + throw new NotFoundException(Messages.SAVED_QUERY_NOT_FOUND); + } + + if (name !== undefined) { + foundQuery.name = name; + } + if (description !== undefined) { + foundQuery.description = description; + } + if (query_text !== undefined) { + validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum); + foundQuery.query_text = query_text; + } + const resultQueryText = foundQuery.query_text; + const savedQuery = await this._dbContext.savedDbQueryRepository.save(foundQuery); + const savedQueryCopy = { ...savedQuery }; + savedQueryCopy.query_text = resultQueryText; + return buildFoundSavedDbQueryDto(savedQueryCopy as SavedDbQueryEntity); + } +} diff --git a/backend/src/entities/visualizations/saved-db-query/utils/build-found-saved-db-query-dto.util.ts b/backend/src/entities/visualizations/saved-db-query/utils/build-found-saved-db-query-dto.util.ts new file mode 100644 index 000000000..9f569e787 --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/utils/build-found-saved-db-query-dto.util.ts @@ -0,0 +1,14 @@ +import { SavedDbQueryEntity } from '../saved-db-query.entity.js'; +import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js'; + +export function buildFoundSavedDbQueryDto(entity: SavedDbQueryEntity): FoundSavedDbQueryDto { + return { + id: entity.id, + name: entity.name, + description: entity.description, + query_text: entity.query_text, + connection_id: entity.connection_id, + created_at: entity.created_at, + updated_at: entity.updated_at, + }; +} diff --git a/backend/src/entities/visualizations/saved-db-query/utils/check-query-is-safe.util.ts b/backend/src/entities/visualizations/saved-db-query/utils/check-query-is-safe.util.ts new file mode 100644 index 000000000..cf752072d --- /dev/null +++ b/backend/src/entities/visualizations/saved-db-query/utils/check-query-is-safe.util.ts @@ -0,0 +1,256 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; + +const FORBIDDEN_SQL_KEYWORDS = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'MERGE', + 'UPSERT', + 'REPLACE', + 'CREATE', + 'ALTER', + 'DROP', + 'TRUNCATE', + 'RENAME', + 'GRANT', + 'REVOKE', + 'COMMIT', + 'ROLLBACK', + 'SAVEPOINT', + 'VACUUM', + 'ANALYZE', + 'REINDEX', + 'CLUSTER', + 'EXECUTE', + 'EXEC', + 'CALL', + 'DO', + 'COPY', + 'LOAD', + 'IMPORT', + 'LOCK', + 'UNLOCK', +]; + +const FORBIDDEN_MONGODB_OPERATIONS = [ + '$merge', + '$out', + 'insertOne', + 'insertMany', + 'updateOne', + 'updateMany', + 'deleteOne', + 'deleteMany', + 'replaceOne', + 'findOneAndDelete', + 'findOneAndReplace', + 'findOneAndUpdate', + 'bulkWrite', + 'drop', + 'createIndex', + 'dropIndex', + 'createCollection', + 'renameCollection', +]; + +const FORBIDDEN_ELASTICSEARCH_OPERATIONS = [ + '_delete', + '_update', + '_bulk', + '_create', + '_doc', + '_index', + '_reindex', + '_delete_by_query', + '_update_by_query', +]; + +const FORBIDDEN_REDIS_COMMANDS = [ + 'SET', + 'DEL', + 'HSET', + 'HDEL', + 'LPUSH', + 'RPUSH', + 'LPOP', + 'RPOP', + 'SADD', + 'SREM', + 'ZADD', + 'ZREM', + 'FLUSHDB', + 'FLUSHALL', + 'RENAME', + 'EXPIRE', + 'PERSIST', + 'MOVE', + 'COPY', + 'RESTORE', + 'MIGRATE', + 'DUMP', +]; + +const SQL_CONNECTION_TYPES: ConnectionTypesEnum[] = [ + ConnectionTypesEnum.postgres, + ConnectionTypesEnum.agent_postgres, + ConnectionTypesEnum.mysql, + ConnectionTypesEnum.agent_mysql, + ConnectionTypesEnum.mssql, + ConnectionTypesEnum.agent_mssql, + ConnectionTypesEnum.oracledb, + ConnectionTypesEnum.agent_oracledb, + ConnectionTypesEnum.ibmdb2, + ConnectionTypesEnum.agent_ibmdb2, + ConnectionTypesEnum.clickhouse, + ConnectionTypesEnum.agent_clickhouse, + ConnectionTypesEnum.cassandra, + ConnectionTypesEnum.agent_cassandra, +]; + +const MONGODB_CONNECTION_TYPES: ConnectionTypesEnum[] = [ + ConnectionTypesEnum.mongodb, + ConnectionTypesEnum.agent_mongodb, +]; + +const ELASTICSEARCH_CONNECTION_TYPES: ConnectionTypesEnum[] = [ConnectionTypesEnum.elasticsearch]; + +const REDIS_CONNECTION_TYPES: ConnectionTypesEnum[] = [ConnectionTypesEnum.redis, ConnectionTypesEnum.agent_redis]; + +const DYNAMODB_CONNECTION_TYPES: ConnectionTypesEnum[] = [ConnectionTypesEnum.dynamodb]; + +export interface QuerySafetyResult { + isSafe: boolean; + reason?: string; + forbiddenKeyword?: string; +} + +export function checkSqlQueryIsSafe(query: string): QuerySafetyResult { + if (!query || typeof query !== 'string') { + return { isSafe: false, reason: 'Query is empty or invalid' }; + } + + const normalizedQuery = normalizeQuery(query); + + for (const keyword of FORBIDDEN_SQL_KEYWORDS) { + const regex = new RegExp(`\\b${keyword}\\b`, 'i'); + if (regex.test(normalizedQuery)) { + return { + isSafe: false, + reason: `Query contains forbidden keyword: ${keyword}`, + forbiddenKeyword: keyword, + }; + } + } + + const trimmedQuery = normalizedQuery.trim().toUpperCase(); + const allowedPrefixes = ['SELECT', 'WITH', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN']; + const startsWithAllowed = allowedPrefixes.some((prefix) => trimmedQuery.startsWith(prefix)); + + if (!startsWithAllowed) { + return { + isSafe: false, + reason: 'Query must start with SELECT, WITH, SHOW, DESCRIBE, or EXPLAIN', + }; + } + + return { isSafe: true }; +} + +export function checkMongoQueryIsSafe(query: string): QuerySafetyResult { + if (!query || typeof query !== 'string') { + return { isSafe: false, reason: 'Query is empty or invalid' }; + } + + const normalizedQuery = query.toLowerCase(); + + for (const operation of FORBIDDEN_MONGODB_OPERATIONS) { + if (normalizedQuery.includes(operation.toLowerCase())) { + return { + isSafe: false, + reason: `Query contains forbidden MongoDB operation: ${operation}`, + forbiddenKeyword: operation, + }; + } + } + + return { isSafe: true }; +} + +export function checkElasticsearchQueryIsSafe(query: string): QuerySafetyResult { + if (!query || typeof query !== 'string') { + return { isSafe: false, reason: 'Query is empty or invalid' }; + } + + const normalizedQuery = query.toLowerCase(); + + for (const operation of FORBIDDEN_ELASTICSEARCH_OPERATIONS) { + if (normalizedQuery.includes(operation.toLowerCase())) { + return { + isSafe: false, + reason: `Query contains forbidden Elasticsearch operation: ${operation}`, + forbiddenKeyword: operation, + }; + } + } + + return { isSafe: true }; +} + +export function checkRedisQueryIsSafe(query: string): QuerySafetyResult { + if (!query || typeof query !== 'string') { + return { isSafe: false, reason: 'Query is empty or invalid' }; + } + + const normalizedQuery = query.toUpperCase().trim(); + + for (const command of FORBIDDEN_REDIS_COMMANDS) { + const regex = new RegExp(`\\b${command}\\b`, 'i'); + if (regex.test(normalizedQuery)) { + return { + isSafe: false, + reason: `Query contains forbidden Redis command: ${command}`, + forbiddenKeyword: command, + }; + } + } + + return { isSafe: true }; +} + +export function checkDynamoDbQueryIsSafe(query: string): QuerySafetyResult { + return checkSqlQueryIsSafe(query); +} + +type QuerySafetyChecker = (query: string) => QuerySafetyResult; + +const CONNECTION_TYPE_CHECKERS: ReadonlyMap = new Map([ + ...SQL_CONNECTION_TYPES.map((type): [ConnectionTypesEnum, QuerySafetyChecker] => [type, checkSqlQueryIsSafe]), + ...MONGODB_CONNECTION_TYPES.map((type): [ConnectionTypesEnum, QuerySafetyChecker] => [type, checkMongoQueryIsSafe]), + ...ELASTICSEARCH_CONNECTION_TYPES.map((type): [ConnectionTypesEnum, QuerySafetyChecker] => [ + type, + checkElasticsearchQueryIsSafe, + ]), + ...REDIS_CONNECTION_TYPES.map((type): [ConnectionTypesEnum, QuerySafetyChecker] => [type, checkRedisQueryIsSafe]), + ...DYNAMODB_CONNECTION_TYPES.map((type): [ConnectionTypesEnum, QuerySafetyChecker] => [ + type, + checkDynamoDbQueryIsSafe, + ]), +]); + +export function validateQuerySafety(query: string, connectionType: ConnectionTypesEnum): void { + const checker = CONNECTION_TYPE_CHECKERS.get(connectionType) ?? checkSqlQueryIsSafe; + const result = checker(query); + + if (!result.isSafe) { + throw new BadRequestException(`Unsafe query: ${result.reason}. Only read-only queries are allowed.`); + } +} + +function normalizeQuery(query: string): string { + return query + .replace(/--.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 332086ab7..8e875608f 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -1,380 +1,381 @@ import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; import { UserRoleEnum } from '../../entities/user/enums/user-role.enum.js'; import { - EncryptionAlgorithmEnum, - LogOperationTypeEnum, - QueryOrderingEnum, - TableActionTypeEnum, - UserActionEnum, - WidgetTypeEnum, + EncryptionAlgorithmEnum, + LogOperationTypeEnum, + QueryOrderingEnum, + TableActionTypeEnum, + UserActionEnum, + WidgetTypeEnum, } from '../../enums/index.js'; import { TableActionEventEnum } from '../../enums/table-action-event-enum.js'; import { TableActionMethodEnum } from '../../enums/table-action-method-enum.js'; import { enumToString } from '../../helpers/enum-to-string.js'; import { toPrettyErrorsMsg } from '../../helpers/index.js'; export const Messages = { - AI_REQUESTS_NOT_ALLOWED: 'AI requests are not allowed for this connection', - AI_THREAD_NOT_FOUND: 'Thread with specified parameters not found', - ACCOUNT_SUSPENDED: - 'Your account has been suspended. Please reach out to your company administrator for assistance or contact our support team for further help', - ACCESS_LEVEL_INVALID: 'Access level is invalid', - AGENT_ID_MISSING: 'Agent id is missing', - AGENT_NOT_FOUND: 'Agent not found', - ALREADY_SUBSCRIBED_AT_THIS_LEVEL: `You already have a subscription of this level `, - API_KEY_NOT_FOUND: 'Api key not found', - AUTHORIZATION_REQUIRED: 'Authorization is required', - AUTHORIZATION_REJECTED: 'Authorization is rejected', - BULK_DELETE_FAILED_GET_ROWS: (errorReasonsArray: Array) => - `Failed to get rows for bulk delete: ${toPrettyErrorsMsg(errorReasonsArray)}`, - BULK_DELETE_FAILED_DELETE_ROWS: (errorReasonsArray: Array) => - `Failed to delete rows: ${toPrettyErrorsMsg(errorReasonsArray)}`, - LOGIN_DENIED: 'Incorrect email or password.', - LOGIN_DENIED_INVALID_OTP: 'Authenticator code entered incorrectly. Please try again.', - LOGIN_DENIED_SHOULD_CHOOSE_COMPANY: 'Login failed. You should choose company to login first', - CANNOT_ADD_AUTOGENERATED_VALUE: 'You cannot add value into autogenerated field', - CANNOT_CHANGE_ADMIN_GROUP: 'You can not change admin group permissions', - CANNOT_CREATE_CONNECTION_TO_THIS_HOST: 'You cannot create a connection to this host', - CANNOT_CREATE_CONNECTION_THIS_TYPE_IN_FREE_PLAN: (connectionType: ConnectionTypesEnum): string => - `You cannot create a connection of type ${connectionType} in free plan`, - CANNOT_SET_THIS_EMAIL: 'You cannot set this email', - CANT_CREATE_CONNECTION_USER_NON_COMPANY_ADMIN: `Only users with company administrator or database administrator roles can add new connections`, - CANT_CREATE_CONNECTION_USER_NOT_INVITED_AT_ANY_GROUP: `You cannot create a connection because you are not invited to any group. Please ask your administrator to add you to a group first.`, - CANT_CREATE_PERMISSION_TYPE_CONNECTION: - 'You can not create more than one permission of type "Connection" for the same group', - CANT_CREATE_PERMISSION_TYPE_GROUP: 'You can not create more than one permission of type "Group" for the same group', - CANT_DELETE_ADMIN_GROUP: 'You can not delete Admin group from connection', - CANT_DELETE_LAST_USER: 'You can not delete the last user from the Admin group', - CANT_REMOVE_LAST_USER_FROM_COMPANY: 'You can not remove the last user from the company.', - CANT_DELETE_PERMISSION_ADMIN_GROUP: `You can not delete editing permission for Connection from Admin group`, - CANT_CONNECT_AUTOADMIN_WS: `Connection to autoadmin websocket server failed.`, - CANT_INSERT_DUPLICATE_KEY: - 'It seems like the value you entered for the unique field already exists in database. Please check your input and try again with a different value', - CANT_LIST_AND_EXCLUDE: (fieldName: string) => - `You cannot select the same field ${fieldName ? fieldName : 'names'} to list and exclude`, - CANT_CATEGORIZE_HIDDEN_TABLE: (tableName: string) => ` + AI_REQUESTS_NOT_ALLOWED: 'AI requests are not allowed for this connection', + AI_THREAD_NOT_FOUND: 'Thread with specified parameters not found', + ACCOUNT_SUSPENDED: + 'Your account has been suspended. Please reach out to your company administrator for assistance or contact our support team for further help', + ACCESS_LEVEL_INVALID: 'Access level is invalid', + AGENT_ID_MISSING: 'Agent id is missing', + AGENT_NOT_FOUND: 'Agent not found', + ALREADY_SUBSCRIBED_AT_THIS_LEVEL: `You already have a subscription of this level `, + API_KEY_NOT_FOUND: 'Api key not found', + AUTHORIZATION_REQUIRED: 'Authorization is required', + AUTHORIZATION_REJECTED: 'Authorization is rejected', + BULK_DELETE_FAILED_GET_ROWS: (errorReasonsArray: Array) => + `Failed to get rows for bulk delete: ${toPrettyErrorsMsg(errorReasonsArray)}`, + BULK_DELETE_FAILED_DELETE_ROWS: (errorReasonsArray: Array) => + `Failed to delete rows: ${toPrettyErrorsMsg(errorReasonsArray)}`, + LOGIN_DENIED: 'Incorrect email or password.', + LOGIN_DENIED_INVALID_OTP: 'Authenticator code entered incorrectly. Please try again.', + LOGIN_DENIED_SHOULD_CHOOSE_COMPANY: 'Login failed. You should choose company to login first', + CANNOT_ADD_AUTOGENERATED_VALUE: 'You cannot add value into autogenerated field', + CANNOT_CHANGE_ADMIN_GROUP: 'You can not change admin group permissions', + CANNOT_CREATE_CONNECTION_TO_THIS_HOST: 'You cannot create a connection to this host', + CANNOT_CREATE_CONNECTION_THIS_TYPE_IN_FREE_PLAN: (connectionType: ConnectionTypesEnum): string => + `You cannot create a connection of type ${connectionType} in free plan`, + CANNOT_SET_THIS_EMAIL: 'You cannot set this email', + CANT_CREATE_CONNECTION_USER_NON_COMPANY_ADMIN: `Only users with company administrator or database administrator roles can add new connections`, + CANT_CREATE_CONNECTION_USER_NOT_INVITED_AT_ANY_GROUP: `You cannot create a connection because you are not invited to any group. Please ask your administrator to add you to a group first.`, + CANT_CREATE_PERMISSION_TYPE_CONNECTION: + 'You can not create more than one permission of type "Connection" for the same group', + CANT_CREATE_PERMISSION_TYPE_GROUP: 'You can not create more than one permission of type "Group" for the same group', + CANT_DELETE_ADMIN_GROUP: 'You can not delete Admin group from connection', + CANT_DELETE_LAST_USER: 'You can not delete the last user from the Admin group', + CANT_REMOVE_LAST_USER_FROM_COMPANY: 'You can not remove the last user from the company.', + CANT_DELETE_PERMISSION_ADMIN_GROUP: `You can not delete editing permission for Connection from Admin group`, + CANT_CONNECT_AUTOADMIN_WS: `Connection to autoadmin websocket server failed.`, + CANT_INSERT_DUPLICATE_KEY: + 'It seems like the value you entered for the unique field already exists in database. Please check your input and try again with a different value', + CANT_LIST_AND_EXCLUDE: (fieldName: string) => + `You cannot select the same field ${fieldName ? fieldName : 'names'} to list and exclude`, + CANT_CATEGORIZE_HIDDEN_TABLE: (tableName: string) => ` You cannot categorize the hidden table "${tableName}". Please remove it from hidden tables first.`, - CANT_SHOW_TABLE_AND_EXCLUDE: (tableName: string) => - `You cannot select the same table "${tableName}" to show by default and exclude`, - CANT_VIEW_AND_EXCLUDE: (fieldName: string) => - `You cannot select the same field ${fieldName ? fieldName : 'names'} to view and exclude`, - CANT_ORDER_AND_EXCLUDE: `You cannot select the same field names to order and exclude`, - CANT_READONLY_AND_EXCLUDE: (fieldName: string) => - `You cannot select the same field ${fieldName ? fieldName : 'names'} to be readonly and exclude`, - CANT_EXCLUDE_PRIMARY_KEY: (key: string) => `You cannot exclude primary key ${key}`, - CANT_DO_TABLE_OPERATION: `This type of operations is prohibited in the table settings`, - CANT_UPDATE_TABLE_VIEW: `You can't update table view`, - CANNOT_SUSPEND_LAST_USER: 'You cannot suspend the last user in the company', - COGNITO_USERNAME_MISSING: 'Cognito username missing', - COMPANY_ALREADY_EXISTS: 'Company already exists', - COMPANY_NOT_EXISTS_IN_CONNECTION: `Connection does not attached to company. Please contact our support team`, - COMPANY_NOT_FOUND: 'Company not found. Please contact our support team', - COMPANY_LOGO_NOT_FOUND: 'Company logo not found', - COMPANY_FAVICON_NOT_FOUND: 'Company favicon not found', - COMPANY_TAB_TITLE_NOT_FOUND: 'Company tab title not found', - COMPANY_NAME_UPDATE_FAILED_UNHANDLED_ERROR: `Failed to update company name. Please contact our support team.`, - COMPANY_ID_MISSING: `Company id is missing`, - COMPANIES_USER_EMAIL_NOT_FOUND: 'Email not found. Maybe you signed up through third-party authentication?', - CONNECTION_ID_MISSING: 'Connection id is missing', - CONNECTION_IS_FROZEN: `Connection is frozen. (This connection type is not available in free plan)`, - CONNECTION_NOT_CREATED: 'Connection was not successfully created.', - CONNECTION_NOT_FOUND: 'Connection with specified parameters not found', - CONNECTION_NOT_FOUND_OR_USER_NOT_ADDED_IN_ANY_CONNECTION_GROUP: - 'Connection not found or user not added in any group of this', - CONNECTION_NOT_ENCRYPTED: 'Connection is not encrypted', - CONNECTION_MASTER_PASSWORD_NOT_SET: - 'Connection master password is not set (or connection created before this feature)', - CONNECTION_TEST_FILED: 'Connection test failed. ', - CONNECTION_TYPE_INVALID: `Unsupported database type. Now we supports ${enumToString(ConnectionTypesEnum)}`, - CONNECTION_PROPERTIES_INVALID: 'Connection properties are invalid', - CONNECTION_PROPERTIES_CANT_BE_EMPTY: `Connection properties cannot be empty`, - CONNECTION_PROPERTIES_NOT_FOUND: `Connection properties not found`, - CONNECTION_TIMED_OUT: `Connection timed out no further information`, - CONFIRMATION_EMAIL_SENDING_FAILED: `Email sending timed out. Please try again later. If the problem persists, please contact our support team`, - CUSTOM_FIELD_ID_MISSING: 'Custom field id is missing', - CUSTOM_FIELD_NOT_FOUND: 'Custom table field with this parameters not found', - CUSTOM_FIELD_TEMPLATE_MISSING: 'Custom field template string is missing', - CUSTOM_FIELD_TEXT_MISSING: 'Custom field text is missing', - CUSTOM_FIELD_TYPE_INCORRECT: 'Unsupported custom field type', - CUSTOM_FIELD_TYPE_MISSING: 'Custom field type is missing', - CSV_EXPORT_FAILED: 'CSV export failed', - CSV_EXPORT_DISABLED: 'CSV export is disabled', - CSV_IMPORT_FAILED: 'CSV import failed', - CSV_IMPORT_DISABLED: 'CSV import is disabled', - CSV_IMPORT_DISABLED_FOR_TEST_CONNECTIONS: 'CSV import is disabled for test connections', - DATABASE_MISSING: 'Database is missing', - DELETE_ROW_FAILED: 'Row deletion failed', - DESCRIPTION_MISSING: 'Description is missing', - DONT_HAVE_PERMISSIONS: 'You do not have permission to perform this operation', - DONT_HAVE_NON_TEST_CONNECTIONS: - 'You only have test connections. To remove test connections please add your connection first', - ENCRYPTION_ALGORITHM_INCORRECT: (alg: string) => - `Unsupported algorithm type${alg ? ` ${alg}.` : '.'} We supports only ${enumToString( - EncryptionAlgorithmEnum, - )} algorithms.`, - EMAILS_NOT_IN_COMPANY: (emails: Array) => `Emails ${emails.join(', ')} are not in the company`, - EMAILS_REQUIRED_FOR_EMAIL_ACTION: `Emails are required for email action`, - ERROR_MESSAGE: 'Error message: ', - ERROR_MESSAGE_ORIGINAL: 'Error message from database: ', - EXCLUDED_OR_NOT_EXISTS: (fieldName: string) => - `The field "${fieldName}" does not exists in this table or is excluded.`, - FILE_MISSING: 'File is missing', - FAILED_ADD_GROUP_IN_CONNECTION: 'Connection failed to add group in connection.', - FAILED_ADD_PERMISSION_IN_GROUP: 'Failed to add permission in group.', - FAILED_TO_ADD_SETUP_INTENT_AND_SUBSCRIPTION: `Failed to add setup intent and create subscription`, - FAILED_ADD_ROW_IN_TABLE: 'Failed to add row in table.', - FAILED_ADD_USER_IN_GROUP: 'Failed to receive all user groups.', - FAILED_CONNECTION_DELETE: 'Connection failed to delete.', - FAILED_CONNECTION_UPDATE: 'Connection failed to update.', - FAILED_CREATE_GROUP_IN_CONNECTION: 'Failed to create group in connection.', - FAILED_TO_CHANGE_USER_NAME_WITH_THIS_PASSWORD: `Failed to change user name. Incorrect password or you registered with social netword`, - FAILED_DECRYPT_CONNECTION_CREDENTIALS: `Failed to decrypt connection parameters. Most likely the master password is incorrect.`, - FAILED_DELETE_GROUP: 'Failed to delete group.', - FAILED_DELETE_GROUP_FROM_CONNECTION: 'Failed to to delete group from connection.', - FAILED_DELETE_USER_ACCOUNT_IN_SAAS: `Failed to delete user account. Please contact our support team.`, - FAILED_UPDATE_USER_EMAIL_IN_SAAS: `Failed to update user email. Please contact our support team.`, - FAILED_ESTABLISH_SSH_CONNECTION: `Failed to establish ssh connection`, - FAILED_FIND_USERS_IN_GROUP: 'Failed to receive users in this group.', - FAILED_GET_ALL_GROUPS: 'Failed to receive all user groups.', - FAILED_GET_CONNECTION_ID: 'Failed to get connection ID.', - FAILED_GET_GROUP_PERMISSIONS: 'Failed to get permissions in group.', - FAILED_GET_GROUPS: 'Failed to receive groups of this connection.', - FAILED_GET_TABLE_ROWS: 'Failed to get tables rows.', - FAILED_GET_TABLE_STRUCTURE: 'Failed to get table structure.', - FAILED_GET_TABLES: 'Failed to get tables in connection.', - FAILED_REMOVE_PERMISSION_FROM_GROUP: 'Failed to remove permission from group.', - FAILED_REMOVE_USER_FROM_COMPANY: 'Failed to remove user from company.', - FAILED_REMOVE_USER_FROM_GROUP: 'Failed to remove user from group.', - FAILED_TABLE_SETTINGS_DELETE: 'Failed to delete table settings. ', - FAILED_LOGOUT: `Failed to log out`, - FAILED_UPDATE_MASTER_PASSWORD: `Failed update master password`, - FAILED_UPDATE_TABLE_SETTINGS: 'Failed to update table settings. ', - FIELD_MUST_BE_SORTABLE: (fieldName: string) => - `The field "${fieldName}" must be included in sortable fields in table settings`, - FAILED_REGISTER_COMPANY_AND_INVITE_USER_IN_GROUP_UNHANDLED_ERROR: `Failed to register company and invite user in group. Please contact our support team.`, - FAILED_INVITE_USER_IN_COMPANY_UNHANDLED_ERROR: `Failed to invite user in company. Please contact our support team.`, - FAILED_REMOVE_USER_FROM_COMPANY_UNHANDLED_ERROR: `Failed to remove user from company. Please contact our support team.`, - FAILED_REVOKE_USER_INVITATION_UNHANDLED_ERROR: `Failed to revoke user invitation. Please contact our support team.`, - FAILED_SEND_INVITATION_SAAS_UNHANDLED_ERROR: `Failed to send user invitation notification. Please contact our support team.`, - GROUP_NAME_UNIQUE: 'Group title must be unique', - GROUP_NOT_FOUND: 'Group with specified parameters not found', - GROUP_NOT_FROM_THIS_CONNECTION: 'This group does not belong to this connection', - GROUP_ID_MISSING: `Group id is missing`, - GROUP_TITLE_MISSING: `Group title missing`, - GOOGLE_LOGIN_FAILED: 'Google account login failed. If the problem persists, please contact our support team.', - GITHUB_AUTHENTICATION_FAILED: `GitHub authentication failed. If the problem persists, please contact our support team`, - GITHUB_REGISTRATION_FAILED: `GitHub registration failed. If the problem persists, please contact our support team`, - HOST_MISSING: 'Hostname is missing', - HOST_NAME_INVALID: 'Hostname is invalid', - ID_MISSING: 'Id is missing', - INCORRECT_DATE_FORMAT: `Date format is incorrect.`, - INCORRECT_TABLE_LOG_ACTION_TYPE: `Incorrect log operation type, supported types are ${enumToString( - LogOperationTypeEnum, - )}`, - INVALID_SIGN_IN_STATUS: `Invalid sign-in status. Supported values are: success, failed, blocked`, - INVALID_SIGN_IN_METHOD: `Invalid sign-in method. Supported values are: email, google, github, saml, otp`, - INVALID_DISPLAY_MODE: `Invalid display mode. Supported values are "on" and "off"`, - INVALID_USERNAME_OR_PASSWORD: `Username or password is invalid`, - INVALID_USER_COMPANY_ROLE: `Invalid user role in company. Only supported is ${enumToString(UserRoleEnum)}`, - INVALID_JWT_TOKEN: `JWT token syntax is invalid`, - LIST_PER_PAGE_INCORRECT: `You can't display less than one row per page`, - MASTED_NEW_PASSWORD_MISSING: `New master password is missing.`, - MASTED_OLD_PASSWORD_MISSING: `Old master password is missing.`, - MASTER_PASSWORD_MISSING: `Master password is missing.`, - MASTER_PASSWORD_REQUIRED: `A master password is required if you want to apply additional encryption.`, - MASTER_PASSWORD_INCORRECT: `Master password is incorrect`, - MUST_BE_ARRAY: (fieldName: string) => `The field "${fieldName}" must be an array`, - MUST_CONTAIN_ARRAY_OF_PRIMARY_KEYS: `Body must contain array of primary keys`, - NO_AUTH_KEYS_FOUND: 'No authorization keys found', - NO_CUSTOM_ACTIONS_FOUND_FOR_THIS_RULE: `No custom actions found for this rule`, - NO_SUCH_FIELDS_IN_TABLES: (fields: Array, tableName: string) => - `There are no such fields: ${fields.join(', ')} - in the table "${tableName}"`, - NO_SUCH_FIELD_IN_TABLE: (fieldName: string, tableName: string) => - `There is no such field: "${fieldName}" in the table "${tableName}"`, - NOT_ALLOWED_IN_THIS_MODE: 'This operation is not allowed in this mode', - ORDERING_FIELD_INCORRECT: `Value of sorting order is incorrect. You can choose from values ${enumToString( - QueryOrderingEnum, - )}`, - NO_USERS_TO_SUSPEND: 'No users available for suspension. Please verify the user emails.', - OTP_NOT_ENABLED: `Two factor auth is not enabled`, - OTP_VALIDATION_FAILED: `OTP validation failed`, - OTP_DISABLING_FAILED: `Two factor auth disabling failed`, - OTP_DISABLING_FAILED_INVALID_TOKEN: `Two factor auth disabling failed. Probably token is invalid`, - DISABLING_2FA_FORBIDDEN_BY_ADMIN: `Disabling 2fa is forbidden by company administrator`, - PAGE_AND_PERPAGE_INVALID: `Parameters "page" and "perPage" must be more than zero`, - PARAMETER_MISSING: 'Required parameter missing', - PARAMETER_NAME_MISSING: (parameterName: string) => `Required parameter "${parameterName}" missing`, - PASSWORD_MISSING: 'Password is missing', - PASSWORD_WEAK: 'Password is too weak', - PASSWORD_OLD_MISSING: 'Old password is missing', - PASSWORD_NEW_MISSING: 'New password is missing', - PASSWORD_OLD_INVALID: 'Old password is invalid', - PASSWORD_RESET_REQUESTED_SUCCESSFULLY: `Password reset requested successfully`, - PASSWORD_RESET_REQUESTED: `Password reset requested`, - PASSWORD_RESET_VERIFICATION_FAILED: `Password reset verification failed. Link is incorrect.`, - PERMISSION_NOT_FOUND: 'Permission not found', - PERMISSION_TYPE_INVALID: 'Permission type is invalid', - PERMISSIONS_MISSING: 'Permissions missing', - PORT_FORMAT_INCORRECT: 'Port value must be a number', - PORT_MISSING: 'Port value is invalid', - PRIMARY_KEY_INVALID: 'Primary key is incorrect. Please check all its parameters', - PRIMARY_KEY_MISSING: 'Primary key is missing', - PRIMARY_KEY_MISSING_PARAMETER_OR_INCORRECT: 'Primary key missing parameter or is incorrect', - PRIMARY_KEY_NAME_MISSING: 'Primary key name is missing', - PRIMARY_KEY_NOT_EXIST: 'This type of primary key does not exists in this table', - PRIMARY_KEY_VALUE_MISSING: 'Primary key value is missing', - RECEIVING_ROW_FAILED: 'Row receiving failed', - RECEIVING_USER_CONNECTIONS_FAILED: 'Receiving user connections failed.', - RECEIVING_USER_PERMISSIONS_FAILED: 'Receiving user permissions failed.', - REQUIRED_FIELD_CANT_BE_EMPTY: 'Required field can not be empty', - REQUIRED_PARAMETERS_MISSING: (paramsNames: Array): string => - `Required parameter${paramsNames.length > 1 ? 's' : ''} ${paramsNames.join(', ')} ${ - paramsNames.length > 1 ? 'are' : 'is' - } missing`, - ROW_PRIMARY_KEY_NOT_FOUND: 'Row with this primary key not found', - RULE_NOT_FOUND: 'Rule not found', - SAAS_COMPANY_NOT_REGISTERED_WITH_USER_INVITATION: `Failed to invite user in SaaS. Please contact our support team.`, - SAAS_UPDATE_USERS_ROLES_FAILED_UNHANDLED_ERROR: `Failed to update users roles in SaaS. Please contact our support team.`, - SAAS_DELETE_COMPANY_FAILED_UNHANDLED_ERROR: `Failed to delete company in SaaS. Please contact our support team.`, - SAAS_UPDATE_2FA_STATUS_FAILED_UNHANDLED_ERROR: `Failed to update 2fa status in SaaS. Please contact our support team.`, - SAAS_SUSPEND_USERS_FAILED_UNHANDLED_ERROR: `Failed to suspend users in SaaS. Please contact our support team.`, - SAAS_UNSUSPEND_USERS_FAILED_UNHANDLED_ERROR: `Failed to unsuspend users in SaaS. Please contact our support team.`, - SAAS_GET_COMPANY_ID_BY_CUSTOM_DOMAIN_FAILED_UNHANDLED_ERROR: `Failed to get company id by custom domain in. Please contact our support team.`, - SAAS_GET_COMPANY_CUSTOM_DOMAIN_BY_ID_FAILED_UNHANDLED_ERROR: `Failed to get company custom domain by id. Please contact our support team.`, - SAAS_RECOUNT_USERS_IN_COMPANY_FAILED_UNHANDLED_ERROR: `Failed to recount users in company. Please contact our support team.`, - SLACK_CREDENTIALS_MISSING: 'Slack credentials are missing', - SLACK_URL_MISSING: 'Slack url is missing', - SOMETHING_WENT_WRONG_ROW_ADD: 'Something went wrong on row insertion, check inserted parameters and try again', - SOMETHING_WENT_WRONG_AI_THREAD: 'Something went wrong on AI thread creation, check inserted parameters and try again', - SOMETHING_WENT_WRONG_AI_THREAD_MESSAGE: - 'Something went wrong on AI thread message creation, check inserted parameters and try again', - SSH_FORMAT_INCORRECT: 'Ssh value must be a boolean', - SSH_HOST_MISSING: 'Ssh host is missing', - SSH_PORT_MISSING: 'Ssh port is missing', - SSH_PORT_FORMAT_INCORRECT: 'Ssh port value must be a number', - SSH_USERNAME_MISSING: 'Ssh username is missing', - SSH_PASSWORD_MISSING: 'Ssh private key is missing', - TABLE_ACTION_TYPE_INCORRECT: `Incorrect table action. Now we supports types: ${enumToString(TableActionTypeEnum)}`, - TABLE_FILTERS_NOT_FOUND: 'Table filters not found', - TABLE_ID_MISSING: 'Table id is missing', - TABLE_LOGS_NOT_FOUND: `Unable to find logs for this table`, - TABLE_NAME_MISSING: 'Table name missing.', - TABLE_NAME_REQUIRED: 'Table name is required for permission type "Table"', - TABLE_NOT_EXISTS: 'A table with this name does not exist in the connection', - TABLE_WITH_NAME_NOT_EXISTS: (tableName: string) => `A table ${tableName} does not exist in the connection`, - TABLE_NOT_FOUND: 'Table not found', - TABLE_SETTINGS_NOT_FOUND: 'Table settings with this parameters not found', - TABLE_WIDGET_NOT_FOUND: 'Table widget with this parameters not found', - TABLE_ACTION_NOT_FOUND: 'Table action not found', - TABLE_ACTION_CONFIRMATION_REQUIRED: 'Table action confirmation required', - TABLE_TRIGGERS_NOT_FOUND: 'Table triggers with this parameters not found', - TABLE_TRIGGERS_NOT_FOUND_FOR_UPDATE: 'No triggers found for update', - TABLE_TRIGGERS_NOT_FOUND_FOR_DELETE: 'No triggers found for delete', - TABLE_TRIGGERS_EMAILS_REQUIRED: 'Emails are required for email trigger', - TEST_CONNECTIONS_UPDATE_NOT_ALLOWED: `You can't update test connection`, - TRY_AGAIN_LATER: 'Please try again later. If the problem persists, please contact our support team', - TRY_VERIFY_ADD_USER_WHEN_LOGGED_IN: `You can't join a group when you are logged in as another user. Please log out and try again.`, - TYPE_MISSING: 'Type is missing', - TOKEN_MISSING: 'Token is missing', - TWO_FA_REQUIRED: `Two factor authentication required in this company according to company settings. Please enable 2fa in your profile settings.`, - UNABLE_FIND_PORT: `Unable to find a free port. Please try again later. If the problem persists, please contact our support team`, - UPDATE_ROW_FAILED: 'Row updating failed', - USER_ALREADY_ADDED: 'User has already been added in this group', - USER_ALREADY_ADDED_IN_COMPANY: 'User has already been added in this company', - USER_ALREADY_ADDED_IN_COMPANY_BUT_NOT_ACTIVE: - 'User has already been added in this company, but email is not confirmed. We sent new invitation on this users email.', - USER_ALREADY_ADDED_BUT_NOT_ACTIVE: - 'User already added in this group, but email is not confirmed. We sent new invitation on this users email.', - USER_ALREADY_ADDED_BUT_NOT_ACTIVE_IN_COMPANY: - 'User already added in this company, but email is not confirmed. We sent new invitation on this users email.', - USER_CREATION_FAILED: 'Creating a new user failed.', - USER_DELETED_ACCOUNT: (email: string, reason: string, message: string) => - `User ${email ? email : 'unknowm'} deleted their account. Reason is: ${ - reason ? reason : 'unknown' - }. And message is: ${message ? message : 'no message'}.`, - USER_DELETED_CONNECTION: (email: string, reason: string, message: string) => - `User ${email ? email : 'unknowm'} deleted their own connection. Reason is: ${ - reason ? reason : 'unknown' - }. And message is: ${message ? message : 'no message'}.`, - USER_EMAIL_MISSING: `User email is missing`, - USER_MISSING_EMAIL_OR_SOCIAL_REGISTERED: `User with this email not found in our database. Please check your email. + CANT_SHOW_TABLE_AND_EXCLUDE: (tableName: string) => + `You cannot select the same table "${tableName}" to show by default and exclude`, + CANT_VIEW_AND_EXCLUDE: (fieldName: string) => + `You cannot select the same field ${fieldName ? fieldName : 'names'} to view and exclude`, + CANT_ORDER_AND_EXCLUDE: `You cannot select the same field names to order and exclude`, + CANT_READONLY_AND_EXCLUDE: (fieldName: string) => + `You cannot select the same field ${fieldName ? fieldName : 'names'} to be readonly and exclude`, + CANT_EXCLUDE_PRIMARY_KEY: (key: string) => `You cannot exclude primary key ${key}`, + CANT_DO_TABLE_OPERATION: `This type of operations is prohibited in the table settings`, + CANT_UPDATE_TABLE_VIEW: `You can't update table view`, + CANNOT_SUSPEND_LAST_USER: 'You cannot suspend the last user in the company', + COGNITO_USERNAME_MISSING: 'Cognito username missing', + COMPANY_ALREADY_EXISTS: 'Company already exists', + COMPANY_NOT_EXISTS_IN_CONNECTION: `Connection does not attached to company. Please contact our support team`, + COMPANY_NOT_FOUND: 'Company not found. Please contact our support team', + COMPANY_LOGO_NOT_FOUND: 'Company logo not found', + COMPANY_FAVICON_NOT_FOUND: 'Company favicon not found', + COMPANY_TAB_TITLE_NOT_FOUND: 'Company tab title not found', + COMPANY_NAME_UPDATE_FAILED_UNHANDLED_ERROR: `Failed to update company name. Please contact our support team.`, + COMPANY_ID_MISSING: `Company id is missing`, + COMPANIES_USER_EMAIL_NOT_FOUND: 'Email not found. Maybe you signed up through third-party authentication?', + CONNECTION_ID_MISSING: 'Connection id is missing', + CONNECTION_IS_FROZEN: `Connection is frozen. (This connection type is not available in free plan)`, + CONNECTION_NOT_CREATED: 'Connection was not successfully created.', + CONNECTION_NOT_FOUND: 'Connection with specified parameters not found', + CONNECTION_NOT_FOUND_OR_USER_NOT_ADDED_IN_ANY_CONNECTION_GROUP: + 'Connection not found or user not added in any group of this', + CONNECTION_NOT_ENCRYPTED: 'Connection is not encrypted', + CONNECTION_MASTER_PASSWORD_NOT_SET: + 'Connection master password is not set (or connection created before this feature)', + CONNECTION_TEST_FILED: 'Connection test failed. ', + CONNECTION_TYPE_INVALID: `Unsupported database type. Now we supports ${enumToString(ConnectionTypesEnum)}`, + CONNECTION_PROPERTIES_INVALID: 'Connection properties are invalid', + CONNECTION_PROPERTIES_CANT_BE_EMPTY: `Connection properties cannot be empty`, + CONNECTION_PROPERTIES_NOT_FOUND: `Connection properties not found`, + CONNECTION_TIMED_OUT: `Connection timed out no further information`, + CONFIRMATION_EMAIL_SENDING_FAILED: `Email sending timed out. Please try again later. If the problem persists, please contact our support team`, + CUSTOM_FIELD_ID_MISSING: 'Custom field id is missing', + CUSTOM_FIELD_NOT_FOUND: 'Custom table field with this parameters not found', + CUSTOM_FIELD_TEMPLATE_MISSING: 'Custom field template string is missing', + CUSTOM_FIELD_TEXT_MISSING: 'Custom field text is missing', + CUSTOM_FIELD_TYPE_INCORRECT: 'Unsupported custom field type', + CUSTOM_FIELD_TYPE_MISSING: 'Custom field type is missing', + CSV_EXPORT_FAILED: 'CSV export failed', + CSV_EXPORT_DISABLED: 'CSV export is disabled', + CSV_IMPORT_FAILED: 'CSV import failed', + CSV_IMPORT_DISABLED: 'CSV import is disabled', + CSV_IMPORT_DISABLED_FOR_TEST_CONNECTIONS: 'CSV import is disabled for test connections', + DATABASE_MISSING: 'Database is missing', + DELETE_ROW_FAILED: 'Row deletion failed', + DESCRIPTION_MISSING: 'Description is missing', + DONT_HAVE_PERMISSIONS: 'You do not have permission to perform this operation', + DONT_HAVE_NON_TEST_CONNECTIONS: + 'You only have test connections. To remove test connections please add your connection first', + SAVED_QUERY_NOT_FOUND: 'Saved query with specified parameters not found', + ENCRYPTION_ALGORITHM_INCORRECT: (alg: string) => + `Unsupported algorithm type${alg ? ` ${alg}.` : '.'} We supports only ${enumToString( + EncryptionAlgorithmEnum, + )} algorithms.`, + EMAILS_NOT_IN_COMPANY: (emails: Array) => `Emails ${emails.join(', ')} are not in the company`, + EMAILS_REQUIRED_FOR_EMAIL_ACTION: `Emails are required for email action`, + ERROR_MESSAGE: 'Error message: ', + ERROR_MESSAGE_ORIGINAL: 'Error message from database: ', + EXCLUDED_OR_NOT_EXISTS: (fieldName: string) => + `The field "${fieldName}" does not exists in this table or is excluded.`, + FILE_MISSING: 'File is missing', + FAILED_ADD_GROUP_IN_CONNECTION: 'Connection failed to add group in connection.', + FAILED_ADD_PERMISSION_IN_GROUP: 'Failed to add permission in group.', + FAILED_TO_ADD_SETUP_INTENT_AND_SUBSCRIPTION: `Failed to add setup intent and create subscription`, + FAILED_ADD_ROW_IN_TABLE: 'Failed to add row in table.', + FAILED_ADD_USER_IN_GROUP: 'Failed to receive all user groups.', + FAILED_CONNECTION_DELETE: 'Connection failed to delete.', + FAILED_CONNECTION_UPDATE: 'Connection failed to update.', + FAILED_CREATE_GROUP_IN_CONNECTION: 'Failed to create group in connection.', + FAILED_TO_CHANGE_USER_NAME_WITH_THIS_PASSWORD: `Failed to change user name. Incorrect password or you registered with social netword`, + FAILED_DECRYPT_CONNECTION_CREDENTIALS: `Failed to decrypt connection parameters. Most likely the master password is incorrect.`, + FAILED_DELETE_GROUP: 'Failed to delete group.', + FAILED_DELETE_GROUP_FROM_CONNECTION: 'Failed to to delete group from connection.', + FAILED_DELETE_USER_ACCOUNT_IN_SAAS: `Failed to delete user account. Please contact our support team.`, + FAILED_UPDATE_USER_EMAIL_IN_SAAS: `Failed to update user email. Please contact our support team.`, + FAILED_ESTABLISH_SSH_CONNECTION: `Failed to establish ssh connection`, + FAILED_FIND_USERS_IN_GROUP: 'Failed to receive users in this group.', + FAILED_GET_ALL_GROUPS: 'Failed to receive all user groups.', + FAILED_GET_CONNECTION_ID: 'Failed to get connection ID.', + FAILED_GET_GROUP_PERMISSIONS: 'Failed to get permissions in group.', + FAILED_GET_GROUPS: 'Failed to receive groups of this connection.', + FAILED_GET_TABLE_ROWS: 'Failed to get tables rows.', + FAILED_GET_TABLE_STRUCTURE: 'Failed to get table structure.', + FAILED_GET_TABLES: 'Failed to get tables in connection.', + FAILED_REMOVE_PERMISSION_FROM_GROUP: 'Failed to remove permission from group.', + FAILED_REMOVE_USER_FROM_COMPANY: 'Failed to remove user from company.', + FAILED_REMOVE_USER_FROM_GROUP: 'Failed to remove user from group.', + FAILED_TABLE_SETTINGS_DELETE: 'Failed to delete table settings. ', + FAILED_LOGOUT: `Failed to log out`, + FAILED_UPDATE_MASTER_PASSWORD: `Failed update master password`, + FAILED_UPDATE_TABLE_SETTINGS: 'Failed to update table settings. ', + FIELD_MUST_BE_SORTABLE: (fieldName: string) => + `The field "${fieldName}" must be included in sortable fields in table settings`, + FAILED_REGISTER_COMPANY_AND_INVITE_USER_IN_GROUP_UNHANDLED_ERROR: `Failed to register company and invite user in group. Please contact our support team.`, + FAILED_INVITE_USER_IN_COMPANY_UNHANDLED_ERROR: `Failed to invite user in company. Please contact our support team.`, + FAILED_REMOVE_USER_FROM_COMPANY_UNHANDLED_ERROR: `Failed to remove user from company. Please contact our support team.`, + FAILED_REVOKE_USER_INVITATION_UNHANDLED_ERROR: `Failed to revoke user invitation. Please contact our support team.`, + FAILED_SEND_INVITATION_SAAS_UNHANDLED_ERROR: `Failed to send user invitation notification. Please contact our support team.`, + GROUP_NAME_UNIQUE: 'Group title must be unique', + GROUP_NOT_FOUND: 'Group with specified parameters not found', + GROUP_NOT_FROM_THIS_CONNECTION: 'This group does not belong to this connection', + GROUP_ID_MISSING: `Group id is missing`, + GROUP_TITLE_MISSING: `Group title missing`, + GOOGLE_LOGIN_FAILED: 'Google account login failed. If the problem persists, please contact our support team.', + GITHUB_AUTHENTICATION_FAILED: `GitHub authentication failed. If the problem persists, please contact our support team`, + GITHUB_REGISTRATION_FAILED: `GitHub registration failed. If the problem persists, please contact our support team`, + HOST_MISSING: 'Hostname is missing', + HOST_NAME_INVALID: 'Hostname is invalid', + ID_MISSING: 'Id is missing', + INCORRECT_DATE_FORMAT: `Date format is incorrect.`, + INCORRECT_TABLE_LOG_ACTION_TYPE: `Incorrect log operation type, supported types are ${enumToString( + LogOperationTypeEnum, + )}`, + INVALID_SIGN_IN_STATUS: `Invalid sign-in status. Supported values are: success, failed, blocked`, + INVALID_SIGN_IN_METHOD: `Invalid sign-in method. Supported values are: email, google, github, saml, otp`, + INVALID_DISPLAY_MODE: `Invalid display mode. Supported values are "on" and "off"`, + INVALID_USERNAME_OR_PASSWORD: `Username or password is invalid`, + INVALID_USER_COMPANY_ROLE: `Invalid user role in company. Only supported is ${enumToString(UserRoleEnum)}`, + INVALID_JWT_TOKEN: `JWT token syntax is invalid`, + LIST_PER_PAGE_INCORRECT: `You can't display less than one row per page`, + MASTED_NEW_PASSWORD_MISSING: `New master password is missing.`, + MASTED_OLD_PASSWORD_MISSING: `Old master password is missing.`, + MASTER_PASSWORD_MISSING: `Master password is missing.`, + MASTER_PASSWORD_REQUIRED: `A master password is required if you want to apply additional encryption.`, + MASTER_PASSWORD_INCORRECT: `Master password is incorrect`, + MUST_BE_ARRAY: (fieldName: string) => `The field "${fieldName}" must be an array`, + MUST_CONTAIN_ARRAY_OF_PRIMARY_KEYS: `Body must contain array of primary keys`, + NO_AUTH_KEYS_FOUND: 'No authorization keys found', + NO_CUSTOM_ACTIONS_FOUND_FOR_THIS_RULE: `No custom actions found for this rule`, + NO_SUCH_FIELDS_IN_TABLES: (fields: Array, tableName: string) => + `There are no such fields: ${fields.join(', ')} - in the table "${tableName}"`, + NO_SUCH_FIELD_IN_TABLE: (fieldName: string, tableName: string) => + `There is no such field: "${fieldName}" in the table "${tableName}"`, + NOT_ALLOWED_IN_THIS_MODE: 'This operation is not allowed in this mode', + ORDERING_FIELD_INCORRECT: `Value of sorting order is incorrect. You can choose from values ${enumToString( + QueryOrderingEnum, + )}`, + NO_USERS_TO_SUSPEND: 'No users available for suspension. Please verify the user emails.', + OTP_NOT_ENABLED: `Two factor auth is not enabled`, + OTP_VALIDATION_FAILED: `OTP validation failed`, + OTP_DISABLING_FAILED: `Two factor auth disabling failed`, + OTP_DISABLING_FAILED_INVALID_TOKEN: `Two factor auth disabling failed. Probably token is invalid`, + DISABLING_2FA_FORBIDDEN_BY_ADMIN: `Disabling 2fa is forbidden by company administrator`, + PAGE_AND_PERPAGE_INVALID: `Parameters "page" and "perPage" must be more than zero`, + PARAMETER_MISSING: 'Required parameter missing', + PARAMETER_NAME_MISSING: (parameterName: string) => `Required parameter "${parameterName}" missing`, + PASSWORD_MISSING: 'Password is missing', + PASSWORD_WEAK: 'Password is too weak', + PASSWORD_OLD_MISSING: 'Old password is missing', + PASSWORD_NEW_MISSING: 'New password is missing', + PASSWORD_OLD_INVALID: 'Old password is invalid', + PASSWORD_RESET_REQUESTED_SUCCESSFULLY: `Password reset requested successfully`, + PASSWORD_RESET_REQUESTED: `Password reset requested`, + PASSWORD_RESET_VERIFICATION_FAILED: `Password reset verification failed. Link is incorrect.`, + PERMISSION_NOT_FOUND: 'Permission not found', + PERMISSION_TYPE_INVALID: 'Permission type is invalid', + PERMISSIONS_MISSING: 'Permissions missing', + PORT_FORMAT_INCORRECT: 'Port value must be a number', + PORT_MISSING: 'Port value is invalid', + PRIMARY_KEY_INVALID: 'Primary key is incorrect. Please check all its parameters', + PRIMARY_KEY_MISSING: 'Primary key is missing', + PRIMARY_KEY_MISSING_PARAMETER_OR_INCORRECT: 'Primary key missing parameter or is incorrect', + PRIMARY_KEY_NAME_MISSING: 'Primary key name is missing', + PRIMARY_KEY_NOT_EXIST: 'This type of primary key does not exists in this table', + PRIMARY_KEY_VALUE_MISSING: 'Primary key value is missing', + RECEIVING_ROW_FAILED: 'Row receiving failed', + RECEIVING_USER_CONNECTIONS_FAILED: 'Receiving user connections failed.', + RECEIVING_USER_PERMISSIONS_FAILED: 'Receiving user permissions failed.', + REQUIRED_FIELD_CANT_BE_EMPTY: 'Required field can not be empty', + REQUIRED_PARAMETERS_MISSING: (paramsNames: Array): string => + `Required parameter${paramsNames.length > 1 ? 's' : ''} ${paramsNames.join(', ')} ${ + paramsNames.length > 1 ? 'are' : 'is' + } missing`, + ROW_PRIMARY_KEY_NOT_FOUND: 'Row with this primary key not found', + RULE_NOT_FOUND: 'Rule not found', + SAAS_COMPANY_NOT_REGISTERED_WITH_USER_INVITATION: `Failed to invite user in SaaS. Please contact our support team.`, + SAAS_UPDATE_USERS_ROLES_FAILED_UNHANDLED_ERROR: `Failed to update users roles in SaaS. Please contact our support team.`, + SAAS_DELETE_COMPANY_FAILED_UNHANDLED_ERROR: `Failed to delete company in SaaS. Please contact our support team.`, + SAAS_UPDATE_2FA_STATUS_FAILED_UNHANDLED_ERROR: `Failed to update 2fa status in SaaS. Please contact our support team.`, + SAAS_SUSPEND_USERS_FAILED_UNHANDLED_ERROR: `Failed to suspend users in SaaS. Please contact our support team.`, + SAAS_UNSUSPEND_USERS_FAILED_UNHANDLED_ERROR: `Failed to unsuspend users in SaaS. Please contact our support team.`, + SAAS_GET_COMPANY_ID_BY_CUSTOM_DOMAIN_FAILED_UNHANDLED_ERROR: `Failed to get company id by custom domain in. Please contact our support team.`, + SAAS_GET_COMPANY_CUSTOM_DOMAIN_BY_ID_FAILED_UNHANDLED_ERROR: `Failed to get company custom domain by id. Please contact our support team.`, + SAAS_RECOUNT_USERS_IN_COMPANY_FAILED_UNHANDLED_ERROR: `Failed to recount users in company. Please contact our support team.`, + SLACK_CREDENTIALS_MISSING: 'Slack credentials are missing', + SLACK_URL_MISSING: 'Slack url is missing', + SOMETHING_WENT_WRONG_ROW_ADD: 'Something went wrong on row insertion, check inserted parameters and try again', + SOMETHING_WENT_WRONG_AI_THREAD: 'Something went wrong on AI thread creation, check inserted parameters and try again', + SOMETHING_WENT_WRONG_AI_THREAD_MESSAGE: + 'Something went wrong on AI thread message creation, check inserted parameters and try again', + SSH_FORMAT_INCORRECT: 'Ssh value must be a boolean', + SSH_HOST_MISSING: 'Ssh host is missing', + SSH_PORT_MISSING: 'Ssh port is missing', + SSH_PORT_FORMAT_INCORRECT: 'Ssh port value must be a number', + SSH_USERNAME_MISSING: 'Ssh username is missing', + SSH_PASSWORD_MISSING: 'Ssh private key is missing', + TABLE_ACTION_TYPE_INCORRECT: `Incorrect table action. Now we supports types: ${enumToString(TableActionTypeEnum)}`, + TABLE_FILTERS_NOT_FOUND: 'Table filters not found', + TABLE_ID_MISSING: 'Table id is missing', + TABLE_LOGS_NOT_FOUND: `Unable to find logs for this table`, + TABLE_NAME_MISSING: 'Table name missing.', + TABLE_NAME_REQUIRED: 'Table name is required for permission type "Table"', + TABLE_NOT_EXISTS: 'A table with this name does not exist in the connection', + TABLE_WITH_NAME_NOT_EXISTS: (tableName: string) => `A table ${tableName} does not exist in the connection`, + TABLE_NOT_FOUND: 'Table not found', + TABLE_SETTINGS_NOT_FOUND: 'Table settings with this parameters not found', + TABLE_WIDGET_NOT_FOUND: 'Table widget with this parameters not found', + TABLE_ACTION_NOT_FOUND: 'Table action not found', + TABLE_ACTION_CONFIRMATION_REQUIRED: 'Table action confirmation required', + TABLE_TRIGGERS_NOT_FOUND: 'Table triggers with this parameters not found', + TABLE_TRIGGERS_NOT_FOUND_FOR_UPDATE: 'No triggers found for update', + TABLE_TRIGGERS_NOT_FOUND_FOR_DELETE: 'No triggers found for delete', + TABLE_TRIGGERS_EMAILS_REQUIRED: 'Emails are required for email trigger', + TEST_CONNECTIONS_UPDATE_NOT_ALLOWED: `You can't update test connection`, + TRY_AGAIN_LATER: 'Please try again later. If the problem persists, please contact our support team', + TRY_VERIFY_ADD_USER_WHEN_LOGGED_IN: `You can't join a group when you are logged in as another user. Please log out and try again.`, + TYPE_MISSING: 'Type is missing', + TOKEN_MISSING: 'Token is missing', + TWO_FA_REQUIRED: `Two factor authentication required in this company according to company settings. Please enable 2fa in your profile settings.`, + UNABLE_FIND_PORT: `Unable to find a free port. Please try again later. If the problem persists, please contact our support team`, + UPDATE_ROW_FAILED: 'Row updating failed', + USER_ALREADY_ADDED: 'User has already been added in this group', + USER_ALREADY_ADDED_IN_COMPANY: 'User has already been added in this company', + USER_ALREADY_ADDED_IN_COMPANY_BUT_NOT_ACTIVE: + 'User has already been added in this company, but email is not confirmed. We sent new invitation on this users email.', + USER_ALREADY_ADDED_BUT_NOT_ACTIVE: + 'User already added in this group, but email is not confirmed. We sent new invitation on this users email.', + USER_ALREADY_ADDED_BUT_NOT_ACTIVE_IN_COMPANY: + 'User already added in this company, but email is not confirmed. We sent new invitation on this users email.', + USER_CREATION_FAILED: 'Creating a new user failed.', + USER_DELETED_ACCOUNT: (email: string, reason: string, message: string) => + `User ${email ? email : 'unknowm'} deleted their account. Reason is: ${ + reason ? reason : 'unknown' + }. And message is: ${message ? message : 'no message'}.`, + USER_DELETED_CONNECTION: (email: string, reason: string, message: string) => + `User ${email ? email : 'unknowm'} deleted their own connection. Reason is: ${ + reason ? reason : 'unknown' + }. And message is: ${message ? message : 'no message'}.`, + USER_EMAIL_MISSING: `User email is missing`, + USER_MISSING_EMAIL_OR_SOCIAL_REGISTERED: `User with this email not found in our database. Please check your email. Hint: if you registered through google or facebook, you need to change your password in these providers`, - UUID_INVALID: `Invalid id syntax`, - VERIFICATION_LINK_INCORRECT: 'Verification link is incorrect', - VERIFICATION_LINK_EXPIRED: 'Verification link expired', - VERIFICATION_STRING_INCORRECT: 'Verification string format is incorrect', - EMAIL_ALREADY_CONFIRMED: 'Email is already confirmed', - EMAIL_MISSING: 'Email is missing', - EMAIL_INVALID: 'Email is invalid', - EMAIL_SYNTAX_INVALID: 'Email syntax is invalid', - EMAIL_NOT_CONFIRMED: 'Email is not confirmed', - EMAIL_VERIFICATION_FAILED: 'Email verification failed', - EMAIL_VERIFIED_SUCCESSFULLY: 'Email verified successfully', - EMAIL_CHANGE_REQUESTED_SUCCESSFULLY: `Email change request was requested successfully`, - EMAIL_CHANGE_REQUESTED: `Email change request was requested`, - EMAIL_CHANGE_FAILED: `Email change request failed. Incorrect link`, - EMAIL_CHANGED: 'Email changed', - EMAIL_SEND_FAILED: (email: string) => `Email sending to ${email} failed`, - EMAIL_VERIFICATION_REQUESTED: 'Email verification requested', - FILTERS_MISSING: 'Filters are missing', - USER_ADDED_IN_GROUP: (email: string) => `User ${email} was added in group successfully`, - USER_ALREADY_REGISTERED: (email: string) => `User with email ${email} is already registered`, - USER_NOT_FOUND: 'User with specified parameters not found', - USER_NOT_FOUND_FOR_THIS_DOMAIN: 'User not found for this company domain. Please provide company id.', - USER_NOT_INVITED_IN_COMPANY: (email: string) => - `User ${email} is not invited in company. Invite user in company first`, - USER_ID_MISSING: 'User id is missing', - USER_TRY_CREATE_CONNECTION: (email: string, connectionType: ConnectionTypesEnum) => - `User "${email}" tried to create "${connectionType}" connection.`, - USER_CREATED_CONNECTION: (email: string, connectionType: ConnectionTypesEnum) => - `User "${email}" created "${connectionType}" connection.`, - USER_SUCCESSFULLY_TESTED_CONNECTION: (userEmail: string, connectionType: ConnectionTypesEnum) => - `User "${userEmail}" successfully tested the "${connectionType}" connection.`, - USERS_NOT_VERIFIED: (emails: Array) => `Users ${emails.join(', ')} are not verified`, - USERNAME_MISSING: 'Username is missing', - USER_ACTION_INCORRECT: `User action message if incorrect. Supported actions are ${enumToString(UserActionEnum)}`, - USER_NOT_ACTIVE: 'User is not active. Please confirm your email address.', - WIDGET_FIELD_NAME_MISSING: 'Missing property "Field name" for widget', - WIDGET_ID_MISSING: 'Widget id is missing', - WIDGET_NOT_FOUND: 'Widget with this parameters not found', - WIDGET_TYPE_INCORRECT: `Table widget type is incorrect. Now we supports types: ${enumToString(WidgetTypeEnum)}`, - WIDGETS_PROPERTY_MISSING: 'Widgets missing or are incorrect', - WIDGET_PARAMETER_UNSUPPORTED: (paramName: string, widgetType: WidgetTypeEnum) => - `Unsupported parameter "${paramName}" for widget type "${widgetType}"`, - WIDGET_REQUIRED_PARAMETER_MISSING: (param: string) => - `Required widget parameter${param ? ` "${param}" ` : ' '}missing`, - HIDDEN_TABLES_MUST_BE_ARRAY: `Hidden tables must be array`, - SUBSCRIPTION_SUCCESSFULLY_CREATED: `Subscription created successfully`, - SUBSCRIPTION_CANCELLED: `Subscription cancelled`, - MAXIMUM_INVITATIONS_COUNT_REACHED: 'Sorry, the maximum number of invitations has been reached. Try again later.', - MAXIMUM_FREE_INVITATION_REACHED: 'Sorry, reached maximum number of users for free plan', - MAXIMUM_FREE_INVITATION_REACHED_CANNOT_BE_INVITED: - 'Sorry you can not join this group because reached maximum number of users for free plan. Please ask you connection owner to upgrade plan or delete unnecessary user from group', - MAXIMUM_FREE_INVITATION_REACHED_CANNOT_BE_INVITED_IN_COMPANY: - 'Sorry you can not join this company because reached maximum number of users for free plan. Please ask you connection owner to upgrade plan or delete unused user accounts from company', - MAXIMUM_INVITATIONS_COUNT_REACHED_CANT_INVITE: ` Sorry, the maximum number of of users for free plan has been reached. You can't invite more users. Please ask you connection owner to upgrade plan or delete unused user accounts from company, or revoke unaccepted invitations.`, - CANT_UNSUSPEND_USERS_FREE_PLAN: `You can't unsuspend users because reached maximum number of users for free plan. Please ask you connection owner to upgrade plan or delete unused/suspended user accounts from company, or revoke unaccepted invitations.`, - FAILED_CREATE_SUBSCRIPTION_LOG: 'Failed to create subscription log. Please contact our support team.', - FAILED_CREATE_SUBSCRIPTION_LOG_YOUR_CUSTOMER_IS_DELETED: `Failed to create subscription log. Your customer is deleted. Please contact our support team.`, - URL_INVALID: `Url is invalid`, - FAILED_REMOVE_USER_SAAS_UNHANDLED_ERROR: `Failed to remove user from company. Please contact our support team.`, - FILED_REVOKE_USER_INVITATION_UNHANDLED_ERROR: `Failed to revoke user invitation. Please contact our support team.`, - FAILED_ACCEPT_INVITATION_SAAS_UNHANDLED_ERROR: `Failed to accept user invitation. Failed process webhook. Please contact our support team.`, - NOTHING_TO_REVOKE: `Nothing to revoke`, - NO_USERS_FOUND_TO_UPDATE_ROLES: `No users found to update roles`, - USER_ROLES_UPDATE_FAILED: `Failed to update user roles`, - INVALID_ACTION_METHOD: (method: string) => - `Invalid action method ${method}, supported methods are ${enumToString(TableActionMethodEnum)}`, - INVALID_EVENT_TYPE: (type: string) => - `Invalid event type ${type}, supported types are ${enumToString(TableActionEventEnum)}`, - INVALID_REQUEST_DOMAIN: `Invalid request domain`, - INVALID_REQUEST_DOMAIN_FORMAT: `Invalid request domain format`, - FEATURE_NON_AVAILABLE_IN_FREE_PLAN: `This feature is not available in free plan.`, - SECRET_NOT_FOUND: 'Secret not found', - SECRET_ALREADY_EXISTS: 'Secret with this slug already exists in your company', - SECRET_EXPIRED: 'Secret has expired', - SECRET_MASTER_PASSWORD_REQUIRED: 'Master password required', - SECRET_MASTER_PASSWORD_INVALID: 'Invalid master password', - SECRET_DELETED_SUCCESSFULLY: 'Secret deleted successfully', - USER_NOT_FOUND_OR_NOT_IN_COMPANY: 'User not found or not associated with a company', - PERSONAL_TABLE_SETTINGS_NOT_FOUND: 'Personal table settings with this parameters not found', + UUID_INVALID: `Invalid id syntax`, + VERIFICATION_LINK_INCORRECT: 'Verification link is incorrect', + VERIFICATION_LINK_EXPIRED: 'Verification link expired', + VERIFICATION_STRING_INCORRECT: 'Verification string format is incorrect', + EMAIL_ALREADY_CONFIRMED: 'Email is already confirmed', + EMAIL_MISSING: 'Email is missing', + EMAIL_INVALID: 'Email is invalid', + EMAIL_SYNTAX_INVALID: 'Email syntax is invalid', + EMAIL_NOT_CONFIRMED: 'Email is not confirmed', + EMAIL_VERIFICATION_FAILED: 'Email verification failed', + EMAIL_VERIFIED_SUCCESSFULLY: 'Email verified successfully', + EMAIL_CHANGE_REQUESTED_SUCCESSFULLY: `Email change request was requested successfully`, + EMAIL_CHANGE_REQUESTED: `Email change request was requested`, + EMAIL_CHANGE_FAILED: `Email change request failed. Incorrect link`, + EMAIL_CHANGED: 'Email changed', + EMAIL_SEND_FAILED: (email: string) => `Email sending to ${email} failed`, + EMAIL_VERIFICATION_REQUESTED: 'Email verification requested', + FILTERS_MISSING: 'Filters are missing', + USER_ADDED_IN_GROUP: (email: string) => `User ${email} was added in group successfully`, + USER_ALREADY_REGISTERED: (email: string) => `User with email ${email} is already registered`, + USER_NOT_FOUND: 'User with specified parameters not found', + USER_NOT_FOUND_FOR_THIS_DOMAIN: 'User not found for this company domain. Please provide company id.', + USER_NOT_INVITED_IN_COMPANY: (email: string) => + `User ${email} is not invited in company. Invite user in company first`, + USER_ID_MISSING: 'User id is missing', + USER_TRY_CREATE_CONNECTION: (email: string, connectionType: ConnectionTypesEnum) => + `User "${email}" tried to create "${connectionType}" connection.`, + USER_CREATED_CONNECTION: (email: string, connectionType: ConnectionTypesEnum) => + `User "${email}" created "${connectionType}" connection.`, + USER_SUCCESSFULLY_TESTED_CONNECTION: (userEmail: string, connectionType: ConnectionTypesEnum) => + `User "${userEmail}" successfully tested the "${connectionType}" connection.`, + USERS_NOT_VERIFIED: (emails: Array) => `Users ${emails.join(', ')} are not verified`, + USERNAME_MISSING: 'Username is missing', + USER_ACTION_INCORRECT: `User action message if incorrect. Supported actions are ${enumToString(UserActionEnum)}`, + USER_NOT_ACTIVE: 'User is not active. Please confirm your email address.', + WIDGET_FIELD_NAME_MISSING: 'Missing property "Field name" for widget', + WIDGET_ID_MISSING: 'Widget id is missing', + WIDGET_NOT_FOUND: 'Widget with this parameters not found', + WIDGET_TYPE_INCORRECT: `Table widget type is incorrect. Now we supports types: ${enumToString(WidgetTypeEnum)}`, + WIDGETS_PROPERTY_MISSING: 'Widgets missing or are incorrect', + WIDGET_PARAMETER_UNSUPPORTED: (paramName: string, widgetType: WidgetTypeEnum) => + `Unsupported parameter "${paramName}" for widget type "${widgetType}"`, + WIDGET_REQUIRED_PARAMETER_MISSING: (param: string) => + `Required widget parameter${param ? ` "${param}" ` : ' '}missing`, + HIDDEN_TABLES_MUST_BE_ARRAY: `Hidden tables must be array`, + SUBSCRIPTION_SUCCESSFULLY_CREATED: `Subscription created successfully`, + SUBSCRIPTION_CANCELLED: `Subscription cancelled`, + MAXIMUM_INVITATIONS_COUNT_REACHED: 'Sorry, the maximum number of invitations has been reached. Try again later.', + MAXIMUM_FREE_INVITATION_REACHED: 'Sorry, reached maximum number of users for free plan', + MAXIMUM_FREE_INVITATION_REACHED_CANNOT_BE_INVITED: + 'Sorry you can not join this group because reached maximum number of users for free plan. Please ask you connection owner to upgrade plan or delete unnecessary user from group', + MAXIMUM_FREE_INVITATION_REACHED_CANNOT_BE_INVITED_IN_COMPANY: + 'Sorry you can not join this company because reached maximum number of users for free plan. Please ask you connection owner to upgrade plan or delete unused user accounts from company', + MAXIMUM_INVITATIONS_COUNT_REACHED_CANT_INVITE: ` Sorry, the maximum number of of users for free plan has been reached. You can't invite more users. Please ask you connection owner to upgrade plan or delete unused user accounts from company, or revoke unaccepted invitations.`, + CANT_UNSUSPEND_USERS_FREE_PLAN: `You can't unsuspend users because reached maximum number of users for free plan. Please ask you connection owner to upgrade plan or delete unused/suspended user accounts from company, or revoke unaccepted invitations.`, + FAILED_CREATE_SUBSCRIPTION_LOG: 'Failed to create subscription log. Please contact our support team.', + FAILED_CREATE_SUBSCRIPTION_LOG_YOUR_CUSTOMER_IS_DELETED: `Failed to create subscription log. Your customer is deleted. Please contact our support team.`, + URL_INVALID: `Url is invalid`, + FAILED_REMOVE_USER_SAAS_UNHANDLED_ERROR: `Failed to remove user from company. Please contact our support team.`, + FILED_REVOKE_USER_INVITATION_UNHANDLED_ERROR: `Failed to revoke user invitation. Please contact our support team.`, + FAILED_ACCEPT_INVITATION_SAAS_UNHANDLED_ERROR: `Failed to accept user invitation. Failed process webhook. Please contact our support team.`, + NOTHING_TO_REVOKE: `Nothing to revoke`, + NO_USERS_FOUND_TO_UPDATE_ROLES: `No users found to update roles`, + USER_ROLES_UPDATE_FAILED: `Failed to update user roles`, + INVALID_ACTION_METHOD: (method: string) => + `Invalid action method ${method}, supported methods are ${enumToString(TableActionMethodEnum)}`, + INVALID_EVENT_TYPE: (type: string) => + `Invalid event type ${type}, supported types are ${enumToString(TableActionEventEnum)}`, + INVALID_REQUEST_DOMAIN: `Invalid request domain`, + INVALID_REQUEST_DOMAIN_FORMAT: `Invalid request domain format`, + FEATURE_NON_AVAILABLE_IN_FREE_PLAN: `This feature is not available in free plan.`, + SECRET_NOT_FOUND: 'Secret not found', + SECRET_ALREADY_EXISTS: 'Secret with this slug already exists in your company', + SECRET_EXPIRED: 'Secret has expired', + SECRET_MASTER_PASSWORD_REQUIRED: 'Master password required', + SECRET_MASTER_PASSWORD_INVALID: 'Invalid master password', + SECRET_DELETED_SUCCESSFULLY: 'Secret deleted successfully', + USER_NOT_FOUND_OR_NOT_IN_COMPANY: 'User not found or not associated with a company', + PERSONAL_TABLE_SETTINGS_NOT_FOUND: 'Personal table settings with this parameters not found', }; diff --git a/backend/src/migrations/1767976893755-AddSavedDBQueryEntity.ts b/backend/src/migrations/1767976893755-AddSavedDBQueryEntity.ts new file mode 100644 index 000000000..520350b1c --- /dev/null +++ b/backend/src/migrations/1767976893755-AddSavedDBQueryEntity.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSavedDBQueryEntity1767976893755 implements MigrationInterface { + name = 'AddSavedDBQueryEntity1767976893755'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "saved_db_query" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "description" text, "query_text" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "connection_id" character varying(38) NOT NULL, CONSTRAINT "PK_a4a1ff55f21c85f9ce11293a2b7" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "saved_db_query" ADD CONSTRAINT "FK_11269686996b3f9dadfc831428a" FOREIGN KEY ("connection_id") REFERENCES "connection"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "saved_db_query" DROP CONSTRAINT "FK_11269686996b3f9dadfc831428a"`); + await queryRunner.query(`DROP TABLE "saved_db_query"`); + } +} diff --git a/backend/test/ava-tests/saas-tests/saved-database-queries-e2e.test.ts b/backend/test/ava-tests/saas-tests/saved-database-queries-e2e.test.ts new file mode 100644 index 000000000..1fec85773 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saved-database-queries-e2e.test.ts @@ -0,0 +1,966 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { Messages } from '../../../src/exceptions/text/messages.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 { getTestData } from '../../utils/get-test-data.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { ValidationError } from 'class-validator'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { CreateSavedDbQueryDto } from '../../../src/entities/visualizations/saved-db-query/dto/create-saved-db-query.dto.js'; +import { FoundSavedDbQueryDto } from '../../../src/entities/visualizations/saved-db-query/dto/found-saved-db-query.dto.js'; +import { UpdateSavedDbQueryDto } from '../../../src/entities/visualizations/saved-db-query/dto/update-saved-db-query.dto.js'; +import { ExecuteSavedDbQueryResultDto } from '../../../src/entities/visualizations/saved-db-query/dto/execute-saved-db-query-result.dto.js'; +import { TestDbQueryDto } from '../../../src/entities/visualizations/saved-db-query/dto/test-db-query.dto.js'; +import { TestDbQueryResultDto } from '../../../src/entities/visualizations/saved-db-query/dto/test-db-query-result.dto.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let testUtils: TestUtils; +let currentTest: string; + +test.before(async () => { + 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(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'POST /connection/:connectionId/saved-query'; + +test.serial(`${currentTest} should create a new saved query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Test Query', + description: 'A test query for e2e testing', + query_text: `SELECT * FROM ${testTableName}`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + t.truthy(createSavedQueryRO.id); + t.is(createSavedQueryRO.name, createSavedQueryDTO.name); + t.is(createSavedQueryRO.description, createSavedQueryDTO.description); + t.is(createSavedQueryRO.query_text, createSavedQueryDTO.query_text); + t.is(createSavedQueryRO.connection_id, createConnectionRO.id); + t.truthy(createSavedQueryRO.created_at); + t.truthy(createSavedQueryRO.updated_at); +}); + +test.serial(`${currentTest} should fail to create a saved query without name`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO = { + description: 'A test query without name', + query_text: `SELECT * FROM ${testTableName}`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createSavedQueryResponse.status, 400); +}); + +test.serial(`${currentTest} should fail to create a saved query without query_text`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO = { + name: 'Test Query', + description: 'A test query without query_text', + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createSavedQueryResponse.status, 400); +}); + +currentTest = 'GET /connection/:connectionId/saved-queries'; + +test.serial(`${currentTest} should return all saved queries for a connection`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + // Create first saved query + const createSavedQueryDTO1: CreateSavedDbQueryDto = { + name: 'Test Query 1', + description: 'First test query', + query_text: `SELECT * FROM ${testTableName}`, + }; + + await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO1) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Create second saved query + const createSavedQueryDTO2: CreateSavedDbQueryDto = { + name: 'Test Query 2', + description: 'Second test query', + query_text: `SELECT id FROM ${testTableName}`, + }; + + await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO2) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get all saved queries + const getAllSavedQueriesResponse = await request(app.getHttpServer()) + .get(`/connection/${createConnectionRO.id}/saved-queries`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const getAllSavedQueriesRO: FoundSavedDbQueryDto[] = JSON.parse(getAllSavedQueriesResponse.text); + t.is(getAllSavedQueriesResponse.status, 200); + t.true(Array.isArray(getAllSavedQueriesRO)); + t.is(getAllSavedQueriesRO.length, 2); + t.truthy(getAllSavedQueriesRO.find((q) => q.name === 'Test Query 1')); + t.truthy(getAllSavedQueriesRO.find((q) => q.name === 'Test Query 2')); +}); + +test.serial(`${currentTest} should return empty array when no saved queries exist`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const getAllSavedQueriesResponse = await request(app.getHttpServer()) + .get(`/connection/${createConnectionRO.id}/saved-queries`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const getAllSavedQueriesRO: FoundSavedDbQueryDto[] = JSON.parse(getAllSavedQueriesResponse.text); + t.is(getAllSavedQueriesResponse.status, 200); + t.true(Array.isArray(getAllSavedQueriesRO)); + t.is(getAllSavedQueriesRO.length, 0); +}); + +currentTest = 'GET /connection/:connectionId/saved-query/:queryId'; + +test.serial(`${currentTest} should return a saved query by id`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Test Query', + description: 'A test query for e2e testing', + query_text: `SELECT * FROM ${testTableName}`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + + // Get saved query by id + const getSavedQueryResponse = await request(app.getHttpServer()) + .get(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const getSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(getSavedQueryResponse.text); + t.is(getSavedQueryResponse.status, 200); + t.is(getSavedQueryRO.id, createSavedQueryRO.id); + t.is(getSavedQueryRO.name, createSavedQueryDTO.name); + t.is(getSavedQueryRO.description, createSavedQueryDTO.description); + t.is(getSavedQueryRO.query_text, createSavedQueryDTO.query_text); +}); + +test.serial(`${currentTest} should return 404 when saved query not found`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const nonExistentQueryId = '00000000-0000-0000-0000-000000000000'; + const getSavedQueryResponse = await request(app.getHttpServer()) + .get(`/connection/${createConnectionRO.id}/saved-query/${nonExistentQueryId}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getSavedQueryResponse.status, 404); +}); + +currentTest = 'PUT /connection/:connectionId/saved-query/:queryId'; + +test.serial(`${currentTest} should update a saved query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Original Query Name', + description: 'Original description', + query_text: `SELECT * FROM ${testTableName}`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + + // Update saved query + const updateSavedQueryDTO: UpdateSavedDbQueryDto = { + name: 'Updated Query Name', + description: 'Updated description', + query_text: `SELECT id FROM ${testTableName}`, + }; + + const updateSavedQueryResponse = await request(app.getHttpServer()) + .put(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}`) + .send(updateSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const updateSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(updateSavedQueryResponse.text); + t.is(updateSavedQueryResponse.status, 200); + t.is(updateSavedQueryRO.id, createSavedQueryRO.id); + t.is(updateSavedQueryRO.name, updateSavedQueryDTO.name); + t.is(updateSavedQueryRO.description, updateSavedQueryDTO.description); + t.is(updateSavedQueryRO.query_text, updateSavedQueryDTO.query_text); +}); + +test.serial(`${currentTest} should update only provided fields`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Original Query Name', + description: 'Original description', + query_text: `SELECT * FROM ${testTableName}`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + + const updateSavedQueryDTO: UpdateSavedDbQueryDto = { + name: 'Updated Query Name Only', + }; + + const updateSavedQueryResponse = await request(app.getHttpServer()) + .put(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}`) + .send(updateSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const updateSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(updateSavedQueryResponse.text); + t.is(updateSavedQueryResponse.status, 200); + t.is(updateSavedQueryRO.name, updateSavedQueryDTO.name); + t.is(updateSavedQueryRO.description, createSavedQueryDTO.description); + t.is(updateSavedQueryRO.query_text, createSavedQueryDTO.query_text); +}); + +test.serial(`${currentTest} should return 404 when updating non-existent query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const nonExistentQueryId = '00000000-0000-0000-0000-000000000000'; + const updateSavedQueryDTO: UpdateSavedDbQueryDto = { + name: 'Updated Query Name', + }; + + const updateSavedQueryResponse = await request(app.getHttpServer()) + .put(`/connection/${createConnectionRO.id}/saved-query/${nonExistentQueryId}`) + .send(updateSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(updateSavedQueryResponse.status, 404); +}); + +currentTest = 'DELETE /connection/:connectionId/saved-query/:queryId'; + +test.serial(`${currentTest} should delete a saved query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Query to Delete', + description: 'This query will be deleted', + query_text: `SELECT * FROM ${testTableName}`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + + // Delete saved query + const deleteSavedQueryResponse = await request(app.getHttpServer()) + .delete(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const deleteSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(deleteSavedQueryResponse.text); + t.is(deleteSavedQueryResponse.status, 200); + t.is(deleteSavedQueryRO.id, createSavedQueryRO.id); + + // Verify query is deleted + const getSavedQueryResponse = await request(app.getHttpServer()) + .get(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getSavedQueryResponse.status, 404); +}); + +test.serial(`${currentTest} should return 404 when deleting non-existent query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const nonExistentQueryId = '00000000-0000-0000-0000-000000000000'; + const deleteSavedQueryResponse = await request(app.getHttpServer()) + .delete(`/connection/${createConnectionRO.id}/saved-query/${nonExistentQueryId}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteSavedQueryResponse.status, 404); +}); + +currentTest = 'POST /connection/:connectionId/saved-query/:queryId/execute'; + +test.serial(`${currentTest} should execute a saved query and return results`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable(connectionToTestDB); + console.log('🚀 ~ testTableName:', testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Query to Execute', + description: 'This query will be executed', + query_text: `SELECT * FROM "${testTableName}"`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + + // Execute saved query + const executeSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}/execute`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const executeSavedQueryRO: ExecuteSavedDbQueryResultDto = JSON.parse(executeSavedQueryResponse.text); + t.is(executeSavedQueryResponse.status, 201); + t.is(executeSavedQueryRO.query_id, createSavedQueryRO.id); + t.is(executeSavedQueryRO.query_name, createSavedQueryDTO.name); + t.true(Array.isArray(executeSavedQueryRO.data)); + t.truthy(executeSavedQueryRO.execution_time_ms >= 0); +}); + +test.serial(`${currentTest} should return 404 when executing non-existent query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const nonExistentQueryId = '00000000-0000-0000-0000-000000000000'; + const executeSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query/${nonExistentQueryId}/execute`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(executeSavedQueryResponse.status, 404); +}); + +currentTest = 'Saved queries isolation between connections'; + +test.serial(`${currentTest} should not return queries from other connections`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + // Create first connection + const createConnection1Response = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnection1RO = JSON.parse(createConnection1Response.text); + t.is(createConnection1Response.status, 201); + + // Create second connection + const createConnection2Response = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnection2RO = JSON.parse(createConnection2Response.text); + t.is(createConnection2Response.status, 201); + + // Create saved query in first connection + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Query in Connection 1', + description: 'This query belongs to connection 1', + query_text: `SELECT * FROM ${testTableName}`, + }; + + await request(app.getHttpServer()) + .post(`/connection/${createConnection1RO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get queries from second connection - should be empty + const getQueriesFromConnection2Response = await request(app.getHttpServer()) + .get(`/connection/${createConnection2RO.id}/saved-queries`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const getQueriesFromConnection2RO: FoundSavedDbQueryDto[] = JSON.parse(getQueriesFromConnection2Response.text); + t.is(getQueriesFromConnection2Response.status, 200); + t.is(getQueriesFromConnection2RO.length, 0); + + // Get queries from first connection - should have the query + const getQueriesFromConnection1Response = await request(app.getHttpServer()) + .get(`/connection/${createConnection1RO.id}/saved-queries`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const getQueriesFromConnection1RO: FoundSavedDbQueryDto[] = JSON.parse(getQueriesFromConnection1Response.text); + t.is(getQueriesFromConnection1Response.status, 200); + t.is(getQueriesFromConnection1RO.length, 1); + t.is(getQueriesFromConnection1RO[0].name, 'Query in Connection 1'); +}); + +currentTest = 'POST /connection/:connectionId/query/test'; + +test.serial(`${currentTest} should test a query and return results`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: `SELECT * FROM "${testTableName}"`, + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const testQueryRO: TestDbQueryResultDto = JSON.parse(testQueryResponse.text); + t.is(testQueryResponse.status, 201); + t.true(Array.isArray(testQueryRO.data)); + t.truthy(testQueryRO.execution_time_ms >= 0); +}); + +test.serial(`${currentTest} should fail to test a query without query_text`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO = {}; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 400); +}); + +test.serial(`${currentTest} should return error for invalid SQL query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: 'SELECT * FROM non_existent_table_12345', + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 500); +}); + +test.serial(`${currentTest} should test a query with specific columns`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: `SELECT id, "${testTableColumnName}" FROM "${testTableName}" LIMIT 5`, + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const testQueryRO: TestDbQueryResultDto = JSON.parse(testQueryResponse.text); + t.is(testQueryResponse.status, 201); + t.true(Array.isArray(testQueryRO.data)); + t.true(testQueryRO.data.length <= 5); + t.truthy(testQueryRO.execution_time_ms >= 0); +}); + +currentTest = 'Query safety validation'; + +test.serial(`${currentTest} should reject INSERT query in test endpoint`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: `INSERT INTO "${testTableName}" (id) VALUES (999)`, + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 400); + t.true(testQueryResponse.text.includes('Unsafe query')); +}); + +test.serial(`${currentTest} should reject DELETE query in test endpoint`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: `DELETE FROM "${testTableName}" WHERE id = 1`, + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 400); + t.true(testQueryResponse.text.includes('Unsafe query')); +}); + +test.serial(`${currentTest} should reject UPDATE query in test endpoint`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: `UPDATE "${testTableName}" SET id = 999 WHERE id = 1`, + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 400); + t.true(testQueryResponse.text.includes('Unsafe query')); +}); + +test.serial(`${currentTest} should reject DROP TABLE query in test endpoint`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const testQueryDTO: TestDbQueryDto = { + query_text: `DROP TABLE "${testTableName}"`, + }; + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/query/test`) + .send(testQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 400); + t.true(testQueryResponse.text.includes('Unsafe query')); +}); + +test.serial(`${currentTest} should reject unsafe query when creating saved query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Unsafe Query', + description: 'This should be rejected', + query_text: `DELETE FROM "${testTableName}"`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createSavedQueryResponse.status, 400); + t.true(createSavedQueryResponse.text.includes('Unsafe query')); +}); + +test.serial(`${currentTest} should reject unsafe query when updating saved query`, async (t) => { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const createSavedQueryDTO: CreateSavedDbQueryDto = { + name: 'Safe Query', + description: 'This is safe', + query_text: `SELECT * FROM "${testTableName}"`, + }; + + const createSavedQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${createConnectionRO.id}/saved-query`) + .send(createSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createSavedQueryRO: FoundSavedDbQueryDto = JSON.parse(createSavedQueryResponse.text); + t.is(createSavedQueryResponse.status, 201); + + const updateSavedQueryDTO: UpdateSavedDbQueryDto = { + query_text: `DROP TABLE "${testTableName}"`, + }; + + const updateSavedQueryResponse = await request(app.getHttpServer()) + .put(`/connection/${createConnectionRO.id}/saved-query/${createSavedQueryRO.id}`) + .send(updateSavedQueryDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(updateSavedQueryResponse.status, 400); + t.true(updateSavedQueryResponse.text.includes('Unsafe query')); +}); diff --git a/shared-code/src/data-access-layer/data-access-objects/data-access-object-mysql.ts b/shared-code/src/data-access-layer/data-access-objects/data-access-object-mysql.ts index c9fa9aad2..114dda571 100644 --- a/shared-code/src/data-access-layer/data-access-objects/data-access-object-mysql.ts +++ b/shared-code/src/data-access-layer/data-access-objects/data-access-object-mysql.ts @@ -30,332 +30,332 @@ import { isMySqlDateOrTimeType, isMySQLDateStringByRegexp } from '../../helpers/ import { nanoid } from 'nanoid'; export class DataAccessObjectMysql extends BasicDataAccessObject implements IDataAccessObject { - public async addRowInTable( - tableName: string, - row: Record, - ): Promise> { - const [tableStructure, primaryColumns] = await Promise.all([ - this.getTableStructure(tableName), - this.getTablePrimaryColumns(tableName), - ]); - - const jsonColumnNames = tableStructure.reduce((acc, structEl) => { - if (structEl.data_type.toLowerCase() === 'json') { - acc.add(structEl.column_name); - } - return acc; - }, new Set()); - - for (const key in row) { - if (jsonColumnNames.has(key)) { - setPropertyValue(row, key, JSON.stringify(getPropertyValueByDescriptor(row, key))); - } - } - - let autoIncrementPrimaryKey = null; - - for (const el of primaryColumns) { - const primaryKeyInStructure = tableStructure.find((structureEl) => structureEl.column_name === el.column_name); - - if ( - primaryKeyInStructure && - checkFieldAutoincrement(primaryKeyInStructure.column_default, primaryKeyInStructure.extra) - ) { - autoIncrementPrimaryKey = primaryKeyInStructure; - break; - } - } - - const knex = await this.configureKnex(); - await knex.raw('SET SQL_SAFE_UPDATES = 1;'); - - if (primaryColumns?.length > 0) { - const primaryKeys = primaryColumns.map((column) => column.column_name); - try { - await knex(tableName).insert(row); - if (!autoIncrementPrimaryKey) { - const resultsArray = primaryKeys.map((key) => [key, row[key]]); - return Object.fromEntries(resultsArray); - } else { - const lastInsertId = await knex(tableName).select(knex.raw(`LAST_INSERT_ID()`)); - const resultObj = primaryColumns.reduce((obj, el, index) => { - obj[el.column_name] = lastInsertId[index]['LAST_INSERT_ID()']; - return obj; - }, {}); - return resultObj; - } - } catch (e) { - throw new Error(e); - } - } else { - try { - await knex(tableName).insert(row).returning(Object.keys(row)); - } catch (error) { - throw new Error(error); - } - } - } - - public async deleteRowInTable( - tableName: string, - primaryKey: Record, - ): Promise> { - const knex = await this.configureKnex(); - await knex.raw('SET SQL_SAFE_UPDATES = 1;'); - return await knex(tableName).returning(Object.keys(primaryKey)).where(primaryKey).del(); - } - - public async getIdentityColumns( - tableName: string, - referencedFieldName: string, - identityColumnName: string, - fieldValues: (string | number)[], - ): Promise>> { - const knex = await this.configureKnex(); - const columnsToSelect = identityColumnName ? [referencedFieldName, identityColumnName] : [referencedFieldName]; - return await knex(tableName).select(columnsToSelect).whereIn(referencedFieldName, fieldValues); - } - - public async getRowByPrimaryKey( - tableName: string, - primaryKey: Record, - tableSettings: TableSettingsDS, - ): Promise> { - const knex: Knex = await this.configureKnex(); - let availableFields: string[] = []; - - if (tableSettings) { - const tableStructure = await this.getTableStructure(tableName); - availableFields = this.findAvailableFields(tableSettings, tableStructure); - } - - const result = await knex(tableName) - .select(availableFields.length ? availableFields : '*') - .where(primaryKey); - - return result[0] as unknown as Record; - } - - public async bulkGetRowsFromTableByPrimaryKeys( - tableName: string, - primaryKeys: Array>, - settings: TableSettingsDS, - ): Promise>> { - const knex = await this.configureKnex(); - let availableFields: string[] = []; - - if (settings) { - const tableStructure = await this.getTableStructure(tableName); - availableFields = this.findAvailableFields(settings, tableStructure); - } - - const query = knex(tableName).select(availableFields.length ? availableFields : '*'); - - primaryKeys.forEach((primaryKey) => { - query.orWhere((builder) => { - Object.entries(primaryKey).forEach(([column, value]) => { - builder.andWhere(column, value); - }); - }); - }); - - const results = await query; - return results as Array>; - } - - public async getRowsFromTable( - tableName: string, - settings: TableSettingsDS, - page: number, - perPage: number, - searchedFieldValue: string, - filteringFields: FilteringFieldsDS[], - autocompleteFields: AutocompleteFieldsDS, - tableStructure: TableStructureDS[] | null, - ): Promise { - page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; - perPage = - perPage > 0 - ? perPage - : settings.list_per_page > 0 - ? settings.list_per_page - : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; - - const knex = await this.configureKnex(); - if (!tableStructure) { - tableStructure = await this.getTableStructure(tableName); - } - const availableFields = this.findAvailableFields(settings, tableStructure); - - if (autocompleteFields?.value && autocompleteFields?.fields?.length > 0) { - const { fields, value } = autocompleteFields; - - const rows = await knex(tableName) - .select(fields) - .modify((builder) => { - if (value !== '*') { - fields.forEach((field) => { - builder.orWhere(field, 'like', `${value}%`); - }); - } else { - return builder; - } - }) - .limit(DAO_CONSTANTS.AUTOCOMPLETE_ROW_LIMIT); - - const { large_dataset } = await this.getRowsCount(knex, null, tableName, this.connection.database); - - const rowsRO = { - data: rows, - pagination: {} as any, - large_dataset, - }; - - return rowsRO; - } - const fastRowsCount = await this.getFastRowsCount(knex, tableName, this.connection.database); - const countRowsQB = knex(tableName) - .modify((builder) => { - let { search_fields } = settings; - if (!search_fields?.length && searchedFieldValue) { - search_fields = availableFields; - } - if (search_fields?.length && searchedFieldValue) { - for (const field of search_fields) { - if (Buffer.isBuffer(searchedFieldValue)) { - builder.orWhere(field, '=', searchedFieldValue); - } else { - if (fastRowsCount <= 1000) { - builder.orWhereRaw(` CAST(?? AS CHAR) LIKE ?`, [field, `%${searchedFieldValue.toLowerCase()}%`]); - } else { - builder.orWhereRaw(` CAST(?? AS CHAR) LIKE ?`, [field, `${searchedFieldValue.toLowerCase()}%`]); - } - } - } - } - return builder; - }) - .modify((builder) => { - if (filteringFields?.length) { - for (const filterObject of filteringFields) { - const { field, criteria, value } = filterObject; - const operators = { - [FilterCriteriaEnum.eq]: '=', - [FilterCriteriaEnum.startswith]: 'like', - [FilterCriteriaEnum.endswith]: 'like', - [FilterCriteriaEnum.gt]: '>', - [FilterCriteriaEnum.lt]: '<', - [FilterCriteriaEnum.lte]: '<=', - [FilterCriteriaEnum.gte]: '>=', - [FilterCriteriaEnum.contains]: 'like', - [FilterCriteriaEnum.icontains]: 'not like', - [FilterCriteriaEnum.empty]: 'is', - }; - const values = { - [FilterCriteriaEnum.startswith]: `${value}%`, - [FilterCriteriaEnum.endswith]: `%${value}`, - [FilterCriteriaEnum.contains]: `%${value}%`, - [FilterCriteriaEnum.icontains]: `%${value}%`, - [FilterCriteriaEnum.empty]: null, - }; - builder.where(field, operators[criteria], values[criteria] || value); - } - } - return builder; - }); - - const rowsResultQb = countRowsQB.clone(); - const offset = (page - 1) * perPage; - const rows = await rowsResultQb - .select(availableFields) - .limit(perPage) - .offset(offset) - .modify((builder) => { - if (settings.ordering_field && settings.ordering) { - builder.orderBy(settings.ordering_field, settings.ordering); - } - return builder; - }); - - const { large_dataset, rowsCount } = await this.getRowsCount( - knex, - countRowsQB, - tableName, - this.connection.database, - ); - - const pagination = { - total: rowsCount, - lastPage: Math.ceil(rowsCount / perPage), - perPage, - currentPage: page, - }; - const rowsRO = { - data: rows, - pagination, - large_dataset, - }; - - return rowsRO; - } - - public async getTableForeignKeys(tableName: string): Promise { - const cachedForeignKeys = LRUStorage.getTableForeignKeysCache(this.connection, tableName); - if (cachedForeignKeys) { - return cachedForeignKeys; - } - const { database } = this.connection; - const knex = await this.configureKnex(); - const foreignKeys = await knex(tableName) - .select( - knex.raw(`COLUMN_NAME,CONSTRAINT_NAME, + public async addRowInTable( + tableName: string, + row: Record, + ): Promise> { + const [tableStructure, primaryColumns] = await Promise.all([ + this.getTableStructure(tableName), + this.getTablePrimaryColumns(tableName), + ]); + + const jsonColumnNames = tableStructure.reduce((acc, structEl) => { + if (structEl.data_type.toLowerCase() === 'json') { + acc.add(structEl.column_name); + } + return acc; + }, new Set()); + + for (const key in row) { + if (jsonColumnNames.has(key)) { + setPropertyValue(row, key, JSON.stringify(getPropertyValueByDescriptor(row, key))); + } + } + + let autoIncrementPrimaryKey = null; + + for (const el of primaryColumns) { + const primaryKeyInStructure = tableStructure.find((structureEl) => structureEl.column_name === el.column_name); + + if ( + primaryKeyInStructure && + checkFieldAutoincrement(primaryKeyInStructure.column_default, primaryKeyInStructure.extra) + ) { + autoIncrementPrimaryKey = primaryKeyInStructure; + break; + } + } + + const knex = await this.configureKnex(); + await knex.raw('SET SQL_SAFE_UPDATES = 1;'); + + if (primaryColumns?.length > 0) { + const primaryKeys = primaryColumns.map((column) => column.column_name); + try { + await knex(tableName).insert(row); + if (!autoIncrementPrimaryKey) { + const resultsArray = primaryKeys.map((key) => [key, row[key]]); + return Object.fromEntries(resultsArray); + } else { + const lastInsertId = await knex(tableName).select(knex.raw(`LAST_INSERT_ID()`)); + const resultObj = primaryColumns.reduce((obj, el, index) => { + obj[el.column_name] = lastInsertId[index]['LAST_INSERT_ID()']; + return obj; + }, {}); + return resultObj; + } + } catch (e) { + throw new Error(e); + } + } else { + try { + await knex(tableName).insert(row).returning(Object.keys(row)); + } catch (error) { + throw new Error(error); + } + } + } + + public async deleteRowInTable( + tableName: string, + primaryKey: Record, + ): Promise> { + const knex = await this.configureKnex(); + await knex.raw('SET SQL_SAFE_UPDATES = 1;'); + return await knex(tableName).returning(Object.keys(primaryKey)).where(primaryKey).del(); + } + + public async getIdentityColumns( + tableName: string, + referencedFieldName: string, + identityColumnName: string, + fieldValues: (string | number)[], + ): Promise>> { + const knex = await this.configureKnex(); + const columnsToSelect = identityColumnName ? [referencedFieldName, identityColumnName] : [referencedFieldName]; + return await knex(tableName).select(columnsToSelect).whereIn(referencedFieldName, fieldValues); + } + + public async getRowByPrimaryKey( + tableName: string, + primaryKey: Record, + tableSettings: TableSettingsDS, + ): Promise> { + const knex: Knex = await this.configureKnex(); + let availableFields: string[] = []; + + if (tableSettings) { + const tableStructure = await this.getTableStructure(tableName); + availableFields = this.findAvailableFields(tableSettings, tableStructure); + } + + const result = await knex(tableName) + .select(availableFields.length ? availableFields : '*') + .where(primaryKey); + + return result[0] as unknown as Record; + } + + public async bulkGetRowsFromTableByPrimaryKeys( + tableName: string, + primaryKeys: Array>, + settings: TableSettingsDS, + ): Promise>> { + const knex = await this.configureKnex(); + let availableFields: string[] = []; + + if (settings) { + const tableStructure = await this.getTableStructure(tableName); + availableFields = this.findAvailableFields(settings, tableStructure); + } + + const query = knex(tableName).select(availableFields.length ? availableFields : '*'); + + primaryKeys.forEach((primaryKey) => { + query.orWhere((builder) => { + Object.entries(primaryKey).forEach(([column, value]) => { + builder.andWhere(column, value); + }); + }); + }); + + const results = await query; + return results as Array>; + } + + public async getRowsFromTable( + tableName: string, + settings: TableSettingsDS, + page: number, + perPage: number, + searchedFieldValue: string, + filteringFields: FilteringFieldsDS[], + autocompleteFields: AutocompleteFieldsDS, + tableStructure: TableStructureDS[] | null, + ): Promise { + page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; + perPage = + perPage > 0 + ? perPage + : settings.list_per_page > 0 + ? settings.list_per_page + : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; + + const knex = await this.configureKnex(); + if (!tableStructure) { + tableStructure = await this.getTableStructure(tableName); + } + const availableFields = this.findAvailableFields(settings, tableStructure); + + if (autocompleteFields?.value && autocompleteFields?.fields?.length > 0) { + const { fields, value } = autocompleteFields; + + const rows = await knex(tableName) + .select(fields) + .modify((builder) => { + if (value !== '*') { + fields.forEach((field) => { + builder.orWhere(field, 'like', `${value}%`); + }); + } else { + return builder; + } + }) + .limit(DAO_CONSTANTS.AUTOCOMPLETE_ROW_LIMIT); + + const { large_dataset } = await this.getRowsCount(knex, null, tableName, this.connection.database); + + const rowsRO = { + data: rows, + pagination: {} as any, + large_dataset, + }; + + return rowsRO; + } + const fastRowsCount = await this.getFastRowsCount(knex, tableName, this.connection.database); + const countRowsQB = knex(tableName) + .modify((builder) => { + let { search_fields } = settings; + if (!search_fields?.length && searchedFieldValue) { + search_fields = availableFields; + } + if (search_fields?.length && searchedFieldValue) { + for (const field of search_fields) { + if (Buffer.isBuffer(searchedFieldValue)) { + builder.orWhere(field, '=', searchedFieldValue); + } else { + if (fastRowsCount <= 1000) { + builder.orWhereRaw(` CAST(?? AS CHAR) LIKE ?`, [field, `%${searchedFieldValue.toLowerCase()}%`]); + } else { + builder.orWhereRaw(` CAST(?? AS CHAR) LIKE ?`, [field, `${searchedFieldValue.toLowerCase()}%`]); + } + } + } + } + return builder; + }) + .modify((builder) => { + if (filteringFields?.length) { + for (const filterObject of filteringFields) { + const { field, criteria, value } = filterObject; + const operators = { + [FilterCriteriaEnum.eq]: '=', + [FilterCriteriaEnum.startswith]: 'like', + [FilterCriteriaEnum.endswith]: 'like', + [FilterCriteriaEnum.gt]: '>', + [FilterCriteriaEnum.lt]: '<', + [FilterCriteriaEnum.lte]: '<=', + [FilterCriteriaEnum.gte]: '>=', + [FilterCriteriaEnum.contains]: 'like', + [FilterCriteriaEnum.icontains]: 'not like', + [FilterCriteriaEnum.empty]: 'is', + }; + const values = { + [FilterCriteriaEnum.startswith]: `${value}%`, + [FilterCriteriaEnum.endswith]: `%${value}`, + [FilterCriteriaEnum.contains]: `%${value}%`, + [FilterCriteriaEnum.icontains]: `%${value}%`, + [FilterCriteriaEnum.empty]: null, + }; + builder.where(field, operators[criteria], values[criteria] || value); + } + } + return builder; + }); + + const rowsResultQb = countRowsQB.clone(); + const offset = (page - 1) * perPage; + const rows = await rowsResultQb + .select(availableFields) + .limit(perPage) + .offset(offset) + .modify((builder) => { + if (settings.ordering_field && settings.ordering) { + builder.orderBy(settings.ordering_field, settings.ordering); + } + return builder; + }); + + const { large_dataset, rowsCount } = await this.getRowsCount( + knex, + countRowsQB, + tableName, + this.connection.database, + ); + + const pagination = { + total: rowsCount, + lastPage: Math.ceil(rowsCount / perPage), + perPage, + currentPage: page, + }; + const rowsRO = { + data: rows, + pagination, + large_dataset, + }; + + return rowsRO; + } + + public async getTableForeignKeys(tableName: string): Promise { + const cachedForeignKeys = LRUStorage.getTableForeignKeysCache(this.connection, tableName); + if (cachedForeignKeys) { + return cachedForeignKeys; + } + const { database } = this.connection; + const knex = await this.configureKnex(); + const foreignKeys = await knex(tableName) + .select( + knex.raw(`COLUMN_NAME,CONSTRAINT_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME`), - ) - .from( - knex.raw( - `INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE + ) + .from( + knex.raw( + `INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND REFERENCED_COLUMN_NAME IS NOT NULL;`, - [database, tableName], - ), - ); - - const foreignKeysInLowercase = foreignKeys.map(objectKeysToLowercase) as ForeignKeyDS[]; - - LRUStorage.setTableForeignKeysCache(this.connection, tableName, foreignKeysInLowercase); - return foreignKeysInLowercase; - } - - public async getTablePrimaryColumns(tableName: string): Promise { - const cachedPrimaryColumns = LRUStorage.getTablePrimaryKeysCache(this.connection, tableName); - if (cachedPrimaryColumns) { - return cachedPrimaryColumns; - } - const knex = await this.configureKnex(); - const { database } = this.connection; - - const primaryColumns = await knex(tableName) - .select('COLUMN_NAME', 'DATA_TYPE') - .from(knex.raw('information_schema.COLUMNS')) - .where( - knex.raw( - `TABLE_SCHEMA = ? AND + [database, tableName], + ), + ); + + const foreignKeysInLowercase = foreignKeys.map(objectKeysToLowercase) as ForeignKeyDS[]; + + LRUStorage.setTableForeignKeysCache(this.connection, tableName, foreignKeysInLowercase); + return foreignKeysInLowercase; + } + + public async getTablePrimaryColumns(tableName: string): Promise { + const cachedPrimaryColumns = LRUStorage.getTablePrimaryKeysCache(this.connection, tableName); + if (cachedPrimaryColumns) { + return cachedPrimaryColumns; + } + const knex = await this.configureKnex(); + const { database } = this.connection; + + const primaryColumns = await knex(tableName) + .select('COLUMN_NAME', 'DATA_TYPE') + .from(knex.raw('information_schema.COLUMNS')) + .where( + knex.raw( + `TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI'`, - [database, tableName], - ), - ); - - const primaryColumnsInLowercase = primaryColumns.map(objectKeysToLowercase) as PrimaryKeyDS[]; - LRUStorage.setTablePrimaryKeysCache(this.connection, tableName, primaryColumnsInLowercase); - return primaryColumnsInLowercase; - } - - public async getTablesFromDB(): Promise { - const knex = await this.configureKnex(); - const schema = this.connection.database; - const query = ` + [database, tableName], + ), + ); + + const primaryColumnsInLowercase = primaryColumns.map(objectKeysToLowercase) as PrimaryKeyDS[]; + LRUStorage.setTablePrimaryKeysCache(this.connection, tableName, primaryColumnsInLowercase); + return primaryColumnsInLowercase; + } + + public async getTablesFromDB(): Promise { + const knex = await this.configureKnex(); + const schema = this.connection.database; + const query = ` SELECT table_name, 'table' as type FROM information_schema.tables WHERE table_schema = ? @@ -364,176 +364,176 @@ export class DataAccessObjectMysql extends BasicDataAccessObject implements IDat FROM information_schema.views WHERE table_schema = ? `; - const [rows] = await knex.raw(query, [schema, schema]); - - return rows.map(({ TABLE_NAME, table_name, type }: any) => ({ - tableName: TABLE_NAME ?? table_name, - isView: type === 'view', - })); - } - - public async getTableStructure(tableName: string): Promise { - const cachedTableStructure = LRUStorage.getTableStructureCache(this.connection, tableName); - if (cachedTableStructure) { - return cachedTableStructure; - } - - const knex = await this.configureKnex(); - const { database } = this.connection; - - const structureColumns = await knex('information_schema.columns') - .select( - 'column_name', - 'column_default', - 'data_type', - 'column_type', - 'is_nullable', - 'character_maximum_length', - 'extra', - ) - .orderBy('ordinal_position') - .where({ - table_schema: database, - table_name: tableName, - }); - - const structureColumnsInLowercase = structureColumns.map(objectKeysToLowercase); - - structureColumnsInLowercase.forEach((element) => { - element.is_nullable = element.is_nullable === 'YES'; - renameObjectKeyName(element, 'is_nullable', 'allow_null'); - - switch (element.data_type) { - case 'enum': - element.data_type_params = element.column_type.slice(6, -2).split("','"); - break; - case 'set': - element.data_type_params = element.column_type.slice(5, -2).split("','"); - break; - } - - element.character_maximum_length = - element.character_maximum_length ?? getNumbersFromString(element.column_type) ?? null; - }); - - LRUStorage.setTableStructureCache(this.connection, tableName, structureColumnsInLowercase as TableStructureDS[]); - - return structureColumnsInLowercase as TableStructureDS[]; - } - - public async testConnect(): Promise { - if (!this.connection.id) { - this.connection.id = nanoid(6); - } - const knex = await this.configureKnex(); - try { - await knex.queryBuilder().select(1); - return { - result: true, - message: 'Successfully connected', - }; - } catch (e) { - return { - result: false, - message: e.message || 'Connection failed', - }; - } finally { - LRUStorage.delKnexCache(this.connection); - } - } - - public async updateRowInTable( - tableName: string, - row: Record, - primaryKey: Record, - ): Promise> { - const knex = await this.configureKnex(); - await knex.raw('SET SQL_SAFE_UPDATES = 1;'); - - const tableStructure = await this.getTableStructure(tableName); - - const jsonColumnNames = tableStructure - .filter(({ data_type }) => data_type.toLowerCase() === 'json') - .map(({ column_name }) => column_name); - - Object.entries(row).forEach(([key, value]) => { - if (jsonColumnNames.includes(key)) { - row[key] = JSON.stringify(value); - } - }); - - return await knex(tableName).returning(Object.keys(primaryKey)).where(primaryKey).update(row); - } - - public async bulkUpdateRowsInTable( - tableName: string, - newValues: Record, - primaryKeys: Array>, - ): Promise>> { - const knex = await this.configureKnex(); - await knex.raw('SET SQL_SAFE_UPDATES = 1;'); - - const tableStructure = await this.getTableStructure(tableName); - - const jsonColumnNames = tableStructure - .filter(({ data_type }) => data_type.toLowerCase() === 'json') - .map(({ column_name }) => column_name); - - Object.entries(newValues).forEach(([key, value]) => { - if (jsonColumnNames.includes(key)) { - newValues[key] = JSON.stringify(value); - } - }); - - return await knex.transaction(async (trx) => { - const results = []; - for (const primaryKey of primaryKeys) { - const result = await trx(tableName).returning(Object.keys(primaryKey)).where(primaryKey).update(newValues); - results.push(result[0]); - } - return results; - }); - } - - public async bulkDeleteRowsInTable(tableName: string, primaryKeys: Array>): Promise { - const knex = await this.configureKnex(); - - if (primaryKeys.length === 0) { - return 0; - } - - await knex.transaction(async (trx) => { - await trx(tableName) - .delete() - .modify((queryBuilder) => { - primaryKeys.forEach((key) => { - queryBuilder.orWhere((builder) => { - Object.entries(key).forEach(([column, value]) => { - builder.andWhere(column, value); - }); - }); - }); - }); - }); - - return primaryKeys.length; - } - - public async validateSettings(settings: ValidateTableSettingsDS, tableName: string): Promise { - const [tableStructure, primaryColumns] = await Promise.all([ - this.getTableStructure(tableName), - this.getTablePrimaryColumns(tableName), - ]); - return tableSettingsFieldValidator(tableStructure, primaryColumns, settings); - } - - public async getReferencedTableNamesAndColumns(tableName: string): Promise { - const primaryColumns = await this.getTablePrimaryColumns(tableName); - const knex = await this.configureKnex(); - const results: Array = []; - for (const primaryColumn of primaryColumns) { - const result = await knex.raw( - ` + const [rows] = await knex.raw(query, [schema, schema]); + + return rows.map(({ TABLE_NAME, table_name, type }: any) => ({ + tableName: TABLE_NAME ?? table_name, + isView: type === 'view', + })); + } + + public async getTableStructure(tableName: string): Promise { + const cachedTableStructure = LRUStorage.getTableStructureCache(this.connection, tableName); + if (cachedTableStructure) { + return cachedTableStructure; + } + + const knex = await this.configureKnex(); + const { database } = this.connection; + + const structureColumns = await knex('information_schema.columns') + .select( + 'column_name', + 'column_default', + 'data_type', + 'column_type', + 'is_nullable', + 'character_maximum_length', + 'extra', + ) + .orderBy('ordinal_position') + .where({ + table_schema: database, + table_name: tableName, + }); + + const structureColumnsInLowercase = structureColumns.map(objectKeysToLowercase); + + structureColumnsInLowercase.forEach((element) => { + element.is_nullable = element.is_nullable === 'YES'; + renameObjectKeyName(element, 'is_nullable', 'allow_null'); + + switch (element.data_type) { + case 'enum': + element.data_type_params = element.column_type.slice(6, -2).split("','"); + break; + case 'set': + element.data_type_params = element.column_type.slice(5, -2).split("','"); + break; + } + + element.character_maximum_length = + element.character_maximum_length ?? getNumbersFromString(element.column_type) ?? null; + }); + + LRUStorage.setTableStructureCache(this.connection, tableName, structureColumnsInLowercase as TableStructureDS[]); + + return structureColumnsInLowercase as TableStructureDS[]; + } + + public async testConnect(): Promise { + if (!this.connection.id) { + this.connection.id = nanoid(6); + } + const knex = await this.configureKnex(); + try { + await knex.queryBuilder().select(1); + return { + result: true, + message: 'Successfully connected', + }; + } catch (e) { + return { + result: false, + message: e.message || 'Connection failed', + }; + } finally { + LRUStorage.delKnexCache(this.connection); + } + } + + public async updateRowInTable( + tableName: string, + row: Record, + primaryKey: Record, + ): Promise> { + const knex = await this.configureKnex(); + await knex.raw('SET SQL_SAFE_UPDATES = 1;'); + + const tableStructure = await this.getTableStructure(tableName); + + const jsonColumnNames = tableStructure + .filter(({ data_type }) => data_type.toLowerCase() === 'json') + .map(({ column_name }) => column_name); + + Object.entries(row).forEach(([key, value]) => { + if (jsonColumnNames.includes(key)) { + row[key] = JSON.stringify(value); + } + }); + + return await knex(tableName).returning(Object.keys(primaryKey)).where(primaryKey).update(row); + } + + public async bulkUpdateRowsInTable( + tableName: string, + newValues: Record, + primaryKeys: Array>, + ): Promise>> { + const knex = await this.configureKnex(); + await knex.raw('SET SQL_SAFE_UPDATES = 1;'); + + const tableStructure = await this.getTableStructure(tableName); + + const jsonColumnNames = tableStructure + .filter(({ data_type }) => data_type.toLowerCase() === 'json') + .map(({ column_name }) => column_name); + + Object.entries(newValues).forEach(([key, value]) => { + if (jsonColumnNames.includes(key)) { + newValues[key] = JSON.stringify(value); + } + }); + + return await knex.transaction(async (trx) => { + const results = []; + for (const primaryKey of primaryKeys) { + const result = await trx(tableName).returning(Object.keys(primaryKey)).where(primaryKey).update(newValues); + results.push(result[0]); + } + return results; + }); + } + + public async bulkDeleteRowsInTable(tableName: string, primaryKeys: Array>): Promise { + const knex = await this.configureKnex(); + + if (primaryKeys.length === 0) { + return 0; + } + + await knex.transaction(async (trx) => { + await trx(tableName) + .delete() + .modify((queryBuilder) => { + primaryKeys.forEach((key) => { + queryBuilder.orWhere((builder) => { + Object.entries(key).forEach(([column, value]) => { + builder.andWhere(column, value); + }); + }); + }); + }); + }); + + return primaryKeys.length; + } + + public async validateSettings(settings: ValidateTableSettingsDS, tableName: string): Promise { + const [tableStructure, primaryColumns] = await Promise.all([ + this.getTableStructure(tableName), + this.getTablePrimaryColumns(tableName), + ]); + return tableSettingsFieldValidator(tableStructure, primaryColumns, settings); + } + + public async getReferencedTableNamesAndColumns(tableName: string): Promise { + const primaryColumns = await this.getTablePrimaryColumns(tableName); + const knex = await this.configureKnex(); + const results: Array = []; + for (const primaryColumn of primaryColumns) { + const result = await knex.raw( + ` SELECT TABLE_NAME as 'table_name', COLUMN_NAME as 'column_name' @@ -544,216 +544,217 @@ export class DataAccessObjectMysql extends BasicDataAccessObject implements IDat AND REFERENCED_COLUMN_NAME = ? AND TABLE_SCHEMA = ?; `, - [tableName, primaryColumn.column_name, this.connection.database], - ); - let resultValue = result[0] || []; - resultValue = Array.isArray(resultValue) ? resultValue : [resultValue]; - results.push({ - referenced_on_column_name: primaryColumn.column_name, - referenced_by: resultValue, - }); - } - return results; - } - - public async isView(tableName: string): Promise { - const knex = await this.configureKnex(); - const result = await knex('information_schema.tables').select('table_type').where({ - table_schema: this.connection.database, - table_name: tableName, - }); - if (result.length === 0) { - throw new Error(ERROR_MESSAGES.TABLE_NOT_FOUND(tableName)); - } - return result[0].table_type === 'VIEW'; - } - - public async getTableRowsStream( - tableName: string, - settings: TableSettingsDS, - page: number, - perPage: number, - searchedFieldValue: string, - filteringFields: Array, - ): Promise> { - page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; - perPage = - perPage > 0 - ? perPage - : settings.list_per_page > 0 - ? settings.list_per_page - : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; - - const offset = (page - 1) * perPage; - const knex = await this.configureKnex(); - - // const [{ large_dataset }, tableStructure] = await Promise.all([ - // this.getRowsCount(knex, null, tableName, this.connection.database), - // this.getTableStructure(tableName), - // ]); - - const tableStructure = await this.getTableStructure(tableName); - - // if (large_dataset) { - // throw new Error(ERROR_MESSAGES.DATA_IS_TO_LARGE); - // } - - const availableFields = this.findAvailableFields(settings, tableStructure); - - const rowsAsStream = knex(tableName) - .select(availableFields) - .modify((builder) => { - let { search_fields } = settings; - if ((!search_fields || search_fields?.length === 0) && searchedFieldValue) { - search_fields = availableFields; - } - if (search_fields && searchedFieldValue && search_fields.length > 0) { - for (const field of search_fields) { - if (Buffer.isBuffer(searchedFieldValue)) { - builder.orWhere(field, '=', searchedFieldValue); - } else { - builder.orWhereRaw(` CAST (?? AS CHAR (255))=?`, [field, searchedFieldValue]); - } - } - } - }) - .modify((builder) => { - if (filteringFields && filteringFields.length > 0) { - for (const filterObject of filteringFields) { - const { field, criteria, value } = filterObject; - const operators = { - [FilterCriteriaEnum.eq]: '=', - [FilterCriteriaEnum.startswith]: 'like', - [FilterCriteriaEnum.endswith]: 'like', - [FilterCriteriaEnum.gt]: '>', - [FilterCriteriaEnum.lt]: '<', - [FilterCriteriaEnum.lte]: '<=', - [FilterCriteriaEnum.gte]: '>=', - [FilterCriteriaEnum.contains]: 'like', - [FilterCriteriaEnum.icontains]: 'not like', - [FilterCriteriaEnum.empty]: 'is', - }; - const values = { - [FilterCriteriaEnum.startswith]: `${value}%`, - [FilterCriteriaEnum.endswith]: `%${value}`, - [FilterCriteriaEnum.contains]: `%${value}%`, - [FilterCriteriaEnum.icontains]: `%${value}%`, - [FilterCriteriaEnum.empty]: null, - }; - builder.where(field, operators[criteria], values[criteria] || value); - } - } - }) - .modify((builder) => { - if (settings.ordering_field && settings.ordering) { - builder.orderBy(settings.ordering_field, settings.ordering); - } - }) - .limit(perPage) - .offset(offset) - .stream(); - return rowsAsStream; - } - - public async importCSVInTable(file: Express.Multer.File, tableName: string): Promise { - const knex = await this.configureKnex(); - const structure = await this.getTableStructure(tableName); - const timestampColumnNames = structure - .filter(({ data_type }) => isMySqlDateOrTimeType(data_type)) - .map(({ column_name }) => column_name); - const stream = new Readable(); - stream.push(file.buffer); - stream.push(null); - - const parser = stream.pipe(csv.parse({ columns: true })); - - const results: any[] = []; - for await (const record of parser) { - results.push(record); - } - await knex.transaction(async (trx) => { - for (const row of results) { - for (const column of timestampColumnNames) { - if (row[column] && !isMySQLDateStringByRegexp(row[column])) { - const date = new Date(Number(row[column])); - const formattedDate = date.toISOString().slice(0, 19).replace('T', ' '); - row[column] = formattedDate; - } - } - - await trx(tableName).insert(row); - } - }); - } - - public async executeRawQuery(query: string): Promise>> { - const knex = await this.configureKnex(); - return await knex.raw(query); - } - - private async getRowsCount( - knex: Knex, - countRowsQB: Knex.QueryBuilder | null, - tableName: string, - database: string, - ): Promise<{ rowsCount: number; large_dataset: boolean }> { - if (countRowsQB) { - const slowRowsCount = await this.getRowsCountByQueryWithTimeOut(countRowsQB); - if (slowRowsCount) { - return { - rowsCount: slowRowsCount, - large_dataset: slowRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, - }; - } - const fastRowsCount = await this.getFastRowsCount(knex, tableName, database); - return { - rowsCount: fastRowsCount, - large_dataset: fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, - }; - } - - const fastRowsCount = await this.getFastRowsCount(knex, tableName, database); - if (fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT) { - return { - rowsCount: fastRowsCount, - large_dataset: true, - }; - } - const slowRowsCount = await this.getSlowRowsCount(knex, tableName); - return { - rowsCount: slowRowsCount, - large_dataset: false, - }; - } - - private async getRowsCountByQueryWithTimeOut(countRowsQB: Knex.QueryBuilder): Promise { - return new Promise(async (resolve) => { - setTimeout(() => { - resolve(null); - }, DAO_CONSTANTS.COUNT_QUERY_TIMEOUT_MS); - - try { - const slowCount = (await countRowsQB.count('*'))[0]['count(*)'] as number; - resolve(slowCount); - } catch (_error) { - resolve(null); - } - }); - } - - private async getSlowRowsCount(knex: Knex, tableName: string): Promise { - const slowCount = (await knex(tableName).count('*'))[0]['count(*)'] as number; - return slowCount; - } - - private async getFastRowsCount( - knex: Knex, - tableName: string, - databaseName: string, - ): Promise { - const fastCount = parseInt( - (await knex.raw(`SHOW TABLE STATUS IN ?? LIKE ?;`, [databaseName, tableName]))[0][0].Rows, 10 - ); - return fastCount; - } + [tableName, primaryColumn.column_name, this.connection.database], + ); + let resultValue = result[0] || []; + resultValue = Array.isArray(resultValue) ? resultValue : [resultValue]; + results.push({ + referenced_on_column_name: primaryColumn.column_name, + referenced_by: resultValue, + }); + } + return results; + } + + public async isView(tableName: string): Promise { + const knex = await this.configureKnex(); + const result = await knex('information_schema.tables').select('table_type').where({ + table_schema: this.connection.database, + table_name: tableName, + }); + if (result.length === 0) { + throw new Error(ERROR_MESSAGES.TABLE_NOT_FOUND(tableName)); + } + return result[0].table_type === 'VIEW'; + } + + public async getTableRowsStream( + tableName: string, + settings: TableSettingsDS, + page: number, + perPage: number, + searchedFieldValue: string, + filteringFields: Array, + ): Promise> { + page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; + perPage = + perPage > 0 + ? perPage + : settings.list_per_page > 0 + ? settings.list_per_page + : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; + + const offset = (page - 1) * perPage; + const knex = await this.configureKnex(); + + // const [{ large_dataset }, tableStructure] = await Promise.all([ + // this.getRowsCount(knex, null, tableName, this.connection.database), + // this.getTableStructure(tableName), + // ]); + + const tableStructure = await this.getTableStructure(tableName); + + // if (large_dataset) { + // throw new Error(ERROR_MESSAGES.DATA_IS_TO_LARGE); + // } + + const availableFields = this.findAvailableFields(settings, tableStructure); + + const rowsAsStream = knex(tableName) + .select(availableFields) + .modify((builder) => { + let { search_fields } = settings; + if ((!search_fields || search_fields?.length === 0) && searchedFieldValue) { + search_fields = availableFields; + } + if (search_fields && searchedFieldValue && search_fields.length > 0) { + for (const field of search_fields) { + if (Buffer.isBuffer(searchedFieldValue)) { + builder.orWhere(field, '=', searchedFieldValue); + } else { + builder.orWhereRaw(` CAST (?? AS CHAR (255))=?`, [field, searchedFieldValue]); + } + } + } + }) + .modify((builder) => { + if (filteringFields && filteringFields.length > 0) { + for (const filterObject of filteringFields) { + const { field, criteria, value } = filterObject; + const operators = { + [FilterCriteriaEnum.eq]: '=', + [FilterCriteriaEnum.startswith]: 'like', + [FilterCriteriaEnum.endswith]: 'like', + [FilterCriteriaEnum.gt]: '>', + [FilterCriteriaEnum.lt]: '<', + [FilterCriteriaEnum.lte]: '<=', + [FilterCriteriaEnum.gte]: '>=', + [FilterCriteriaEnum.contains]: 'like', + [FilterCriteriaEnum.icontains]: 'not like', + [FilterCriteriaEnum.empty]: 'is', + }; + const values = { + [FilterCriteriaEnum.startswith]: `${value}%`, + [FilterCriteriaEnum.endswith]: `%${value}`, + [FilterCriteriaEnum.contains]: `%${value}%`, + [FilterCriteriaEnum.icontains]: `%${value}%`, + [FilterCriteriaEnum.empty]: null, + }; + builder.where(field, operators[criteria], values[criteria] || value); + } + } + }) + .modify((builder) => { + if (settings.ordering_field && settings.ordering) { + builder.orderBy(settings.ordering_field, settings.ordering); + } + }) + .limit(perPage) + .offset(offset) + .stream(); + return rowsAsStream; + } + + public async importCSVInTable(file: Express.Multer.File, tableName: string): Promise { + const knex = await this.configureKnex(); + const structure = await this.getTableStructure(tableName); + const timestampColumnNames = structure + .filter(({ data_type }) => isMySqlDateOrTimeType(data_type)) + .map(({ column_name }) => column_name); + const stream = new Readable(); + stream.push(file.buffer); + stream.push(null); + + const parser = stream.pipe(csv.parse({ columns: true })); + + const results: any[] = []; + for await (const record of parser) { + results.push(record); + } + await knex.transaction(async (trx) => { + for (const row of results) { + for (const column of timestampColumnNames) { + if (row[column] && !isMySQLDateStringByRegexp(row[column])) { + const date = new Date(Number(row[column])); + const formattedDate = date.toISOString().slice(0, 19).replace('T', ' '); + row[column] = formattedDate; + } + } + + await trx(tableName).insert(row); + } + }); + } + + public async executeRawQuery(query: string): Promise>> { + const knex = await this.configureKnex(); + return await knex.raw(query); + } + + private async getRowsCount( + knex: Knex, + countRowsQB: Knex.QueryBuilder | null, + tableName: string, + database: string, + ): Promise<{ rowsCount: number; large_dataset: boolean }> { + if (countRowsQB) { + const slowRowsCount = await this.getRowsCountByQueryWithTimeOut(countRowsQB); + if (slowRowsCount) { + return { + rowsCount: slowRowsCount, + large_dataset: slowRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, + }; + } + const fastRowsCount = await this.getFastRowsCount(knex, tableName, database); + return { + rowsCount: fastRowsCount, + large_dataset: fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, + }; + } + + const fastRowsCount = await this.getFastRowsCount(knex, tableName, database); + if (fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT) { + return { + rowsCount: fastRowsCount, + large_dataset: true, + }; + } + const slowRowsCount = await this.getSlowRowsCount(knex, tableName); + return { + rowsCount: slowRowsCount, + large_dataset: false, + }; + } + + private async getRowsCountByQueryWithTimeOut(countRowsQB: Knex.QueryBuilder): Promise { + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(null); + }, DAO_CONSTANTS.COUNT_QUERY_TIMEOUT_MS); + + try { + const slowCount = (await countRowsQB.count('*'))[0]['count(*)'] as number; + resolve(slowCount); + } catch (_error) { + resolve(null); + } + }); + } + + private async getSlowRowsCount(knex: Knex, tableName: string): Promise { + const slowCount = (await knex(tableName).count('*'))[0]['count(*)'] as number; + return slowCount; + } + + private async getFastRowsCount( + knex: Knex, + tableName: string, + databaseName: string, + ): Promise { + const fastCount = parseInt( + (await knex.raw(`SHOW TABLE STATUS IN ?? LIKE ?;`, [databaseName, tableName]))[0][0].Rows, + 10, + ); + return fastCount; + } } diff --git a/shared-code/src/data-access-layer/data-access-objects/data-access-object-postgres.ts b/shared-code/src/data-access-layer/data-access-objects/data-access-object-postgres.ts index 94b78ed09..04ea86bca 100644 --- a/shared-code/src/data-access-layer/data-access-objects/data-access-object-postgres.ts +++ b/shared-code/src/data-access-layer/data-access-objects/data-access-object-postgres.ts @@ -24,260 +24,259 @@ import { BasicDataAccessObject } from './basic-data-access-object.js'; import { nanoid } from 'nanoid'; export class DataAccessObjectPostgres extends BasicDataAccessObject implements IDataAccessObject { - - public async addRowInTable( - tableName: string, - row: Record, - ): Promise> { - const knex: Knex = await this.configureKnex(); - const [tableStructure, primaryColumns] = await Promise.all([ - this.getTableStructure(tableName), - this.getTablePrimaryColumns(tableName), - ]); - - const jsonColumnNames = tableStructure - .filter(({ data_type }) => data_type.toLowerCase() === 'json' || data_type.toLowerCase() === 'jsonb') - .map(({ column_name }) => column_name); - - const processedRow = { ...row }; - jsonColumnNames.forEach((key) => { - if (key in processedRow) { - processedRow[key] = JSON.stringify(processedRow[key]); - } - }); - - const returningColumns = - primaryColumns?.length > 0 ? primaryColumns.map(({ column_name }) => column_name) : Object.keys(row); - - const result = await knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .returning(returningColumns) - .insert(processedRow); - - return result[0] as unknown as Record; - } - - public async deleteRowInTable( - tableName: string, - primaryKey: Record, - ): Promise> { - const knex = await this.configureKnex(); - return await knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .returning(Object.keys(primaryKey)) - .where(primaryKey) - .del(); - } - - public async getIdentityColumns( - tableName: string, - referencedFieldName: string, - identityColumnName: string, - fieldValues: (string | number)[], - ): Promise>> { - const knex: Knex = await this.configureKnex(); - const columnsToSelect = identityColumnName ? [referencedFieldName, identityColumnName] : [referencedFieldName]; - return knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .select(columnsToSelect) - .whereIn(referencedFieldName, fieldValues); - } - - public async getRowByPrimaryKey( - tableName: string, - primaryKey: Record, - settings: TableSettingsDS, - ): Promise> { - const knex: Knex = await this.configureKnex(); - let availableFields: string[] = []; - - if (settings) { - const tableStructure = await this.getTableStructure(tableName); - availableFields = this.findAvailableFields(settings, tableStructure); - } - - const result = await knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .select(availableFields.length ? availableFields : '*') - .where(primaryKey); - - return result[0] as unknown as Record; - } - - public async bulkGetRowsFromTableByPrimaryKeys( - tableName: string, - primaryKeys: Array>, - settings: TableSettingsDS, - ): Promise>> { - const knex: Knex = await this.configureKnex(); - let availableFields: string[] = []; - if (settings) { - const tableStructure = await this.getTableStructure(tableName); - availableFields = this.findAvailableFields(settings, tableStructure); - } - - const query = knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .select(availableFields.length ? availableFields : '*'); - - primaryKeys.forEach((primaryKey) => { - query.orWhere((builder) => { - Object.entries(primaryKey).forEach(([column, value]) => { - builder.andWhere(column, value); - }); - }); - }); - - const results = await query; - return results as Array>; - } - - public async getRowsFromTable( - tableName: string, - settings: TableSettingsDS, - page: number, - perPage: number, - searchedFieldValue: string, - filteringFields: FilteringFieldsDS[], - autocompleteFields: AutocompleteFieldsDS, - tableStructure: TableStructureDS[] | null, - ): Promise { - page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; - perPage = - perPage > 0 - ? perPage - : settings.list_per_page > 0 - ? settings.list_per_page - : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; - - const knex = await this.configureKnex(); - const tableSchema = this.connection.schema ?? 'public'; - if (!tableStructure) { - tableStructure = await this.getTableStructure(tableName); - } - const availableFields = this.findAvailableFields(settings, tableStructure); - - if (autocompleteFields?.value && autocompleteFields.fields?.length > 0) { - const { fields, value } = autocompleteFields; - const rows = await knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .select(fields) - .modify((builder) => { - if (value !== '*') { - fields.forEach((field) => { - builder.orWhereRaw(`CAST (?? AS TEXT) LIKE ?`, [field, `${value}%`]); - }); - } - }) - .limit(DAO_CONSTANTS.AUTOCOMPLETE_ROW_LIMIT); - - const { large_dataset } = await this.getRowsCount(knex, null, tableName, tableSchema); - return { - data: rows, - pagination: {} as any, - large_dataset: large_dataset, - }; - } - const fastRowsCount = await this.getFastRowsCount(knex, tableName, tableSchema); - const countRowsQB = knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .modify((builder) => { - let { search_fields } = settings; - if ((!search_fields || search_fields?.length === 0) && searchedFieldValue) { - search_fields = availableFields; - } - if (searchedFieldValue && search_fields.length > 0) { - for (const field of search_fields) { - if (Buffer.isBuffer(searchedFieldValue)) { - builder.orWhere(field, '=', searchedFieldValue); - } else { - if (fastRowsCount <= 1000) { - builder.orWhereRaw(`LOWER(CAST(?? AS TEXT)) LIKE ?`, [field, `%${searchedFieldValue.toLowerCase()}%`]); - } - builder.orWhereRaw(`LOWER(CAST(?? AS VARCHAR(255))) LIKE ?`, [ - field, - `${searchedFieldValue.toLowerCase()}%`, - ]); - } - } - } - }) - .modify((builder) => { - if (filteringFields?.length > 0) { - for (const filterObject of filteringFields) { - const { field, criteria, value } = filterObject; - const operators = { - [FilterCriteriaEnum.eq]: '=', - [FilterCriteriaEnum.startswith]: 'like', - [FilterCriteriaEnum.endswith]: 'like', - [FilterCriteriaEnum.gt]: '>', - [FilterCriteriaEnum.lt]: '<', - [FilterCriteriaEnum.lte]: '<=', - [FilterCriteriaEnum.gte]: '>=', - [FilterCriteriaEnum.contains]: 'like', - [FilterCriteriaEnum.icontains]: 'not like', - [FilterCriteriaEnum.empty]: 'is', - }; - const values = { - [FilterCriteriaEnum.startswith]: `${value}%`, - [FilterCriteriaEnum.endswith]: `%${value}`, - [FilterCriteriaEnum.contains]: `%${value}%`, - [FilterCriteriaEnum.icontains]: `%${value}%`, - [FilterCriteriaEnum.empty]: null, - }; - builder.where(field, operators[criteria], values[criteria] || value); - } - } - }); - - const rowsResultQb = countRowsQB.clone(); - const offset = (page - 1) * perPage; - const rows = await rowsResultQb - .select(availableFields) - .limit(perPage) - .offset(offset) - .modify((builder) => { - if (settings.ordering_field && settings.ordering) { - builder.orderBy(settings.ordering_field, settings.ordering); - } - }); - const { large_dataset, rowsCount } = await this.getRowsCount(knex, countRowsQB, tableName, tableSchema); - const pagination = { - total: rowsCount, - lastPage: Math.ceil(rowsCount / perPage), - perPage: perPage, - currentPage: page, - }; - return { - data: rows, - pagination, - large_dataset: large_dataset, - }; - } - - public async getTableForeignKeys(tableName: string): Promise { - const cachedForeignKeys = LRUStorage.getTableForeignKeysCache(this.connection, tableName); - if (cachedForeignKeys) { - return cachedForeignKeys; - } - const knex = await this.configureKnex(); - const tableSchema = this.connection.schema ?? 'public'; - const database = this.connection.database; - const foreignKeys: Array<{ - foreign_column_name: string; - foreign_table_name: string; - constraint_name: string; - column_name: string; - }> = await knex(tableName) - .select( - knex.raw(`kcu.constraint_name, + public async addRowInTable( + tableName: string, + row: Record, + ): Promise> { + const knex: Knex = await this.configureKnex(); + const [tableStructure, primaryColumns] = await Promise.all([ + this.getTableStructure(tableName), + this.getTablePrimaryColumns(tableName), + ]); + + const jsonColumnNames = tableStructure + .filter(({ data_type }) => data_type.toLowerCase() === 'json' || data_type.toLowerCase() === 'jsonb') + .map(({ column_name }) => column_name); + + const processedRow = { ...row }; + jsonColumnNames.forEach((key) => { + if (key in processedRow) { + processedRow[key] = JSON.stringify(processedRow[key]); + } + }); + + const returningColumns = + primaryColumns?.length > 0 ? primaryColumns.map(({ column_name }) => column_name) : Object.keys(row); + + const result = await knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .returning(returningColumns) + .insert(processedRow); + + return result[0] as unknown as Record; + } + + public async deleteRowInTable( + tableName: string, + primaryKey: Record, + ): Promise> { + const knex = await this.configureKnex(); + return await knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .returning(Object.keys(primaryKey)) + .where(primaryKey) + .del(); + } + + public async getIdentityColumns( + tableName: string, + referencedFieldName: string, + identityColumnName: string, + fieldValues: (string | number)[], + ): Promise>> { + const knex: Knex = await this.configureKnex(); + const columnsToSelect = identityColumnName ? [referencedFieldName, identityColumnName] : [referencedFieldName]; + return knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .select(columnsToSelect) + .whereIn(referencedFieldName, fieldValues); + } + + public async getRowByPrimaryKey( + tableName: string, + primaryKey: Record, + settings: TableSettingsDS, + ): Promise> { + const knex: Knex = await this.configureKnex(); + let availableFields: string[] = []; + + if (settings) { + const tableStructure = await this.getTableStructure(tableName); + availableFields = this.findAvailableFields(settings, tableStructure); + } + + const result = await knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .select(availableFields.length ? availableFields : '*') + .where(primaryKey); + + return result[0] as unknown as Record; + } + + public async bulkGetRowsFromTableByPrimaryKeys( + tableName: string, + primaryKeys: Array>, + settings: TableSettingsDS, + ): Promise>> { + const knex: Knex = await this.configureKnex(); + let availableFields: string[] = []; + if (settings) { + const tableStructure = await this.getTableStructure(tableName); + availableFields = this.findAvailableFields(settings, tableStructure); + } + + const query = knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .select(availableFields.length ? availableFields : '*'); + + primaryKeys.forEach((primaryKey) => { + query.orWhere((builder) => { + Object.entries(primaryKey).forEach(([column, value]) => { + builder.andWhere(column, value); + }); + }); + }); + + const results = await query; + return results as Array>; + } + + public async getRowsFromTable( + tableName: string, + settings: TableSettingsDS, + page: number, + perPage: number, + searchedFieldValue: string, + filteringFields: FilteringFieldsDS[], + autocompleteFields: AutocompleteFieldsDS, + tableStructure: TableStructureDS[] | null, + ): Promise { + page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; + perPage = + perPage > 0 + ? perPage + : settings.list_per_page > 0 + ? settings.list_per_page + : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; + + const knex = await this.configureKnex(); + const tableSchema = this.connection.schema ?? 'public'; + if (!tableStructure) { + tableStructure = await this.getTableStructure(tableName); + } + const availableFields = this.findAvailableFields(settings, tableStructure); + + if (autocompleteFields?.value && autocompleteFields.fields?.length > 0) { + const { fields, value } = autocompleteFields; + const rows = await knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .select(fields) + .modify((builder) => { + if (value !== '*') { + fields.forEach((field) => { + builder.orWhereRaw(`CAST (?? AS TEXT) LIKE ?`, [field, `${value}%`]); + }); + } + }) + .limit(DAO_CONSTANTS.AUTOCOMPLETE_ROW_LIMIT); + + const { large_dataset } = await this.getRowsCount(knex, null, tableName, tableSchema); + return { + data: rows, + pagination: {} as any, + large_dataset: large_dataset, + }; + } + const fastRowsCount = await this.getFastRowsCount(knex, tableName, tableSchema); + const countRowsQB = knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .modify((builder) => { + let { search_fields } = settings; + if ((!search_fields || search_fields?.length === 0) && searchedFieldValue) { + search_fields = availableFields; + } + if (searchedFieldValue && search_fields.length > 0) { + for (const field of search_fields) { + if (Buffer.isBuffer(searchedFieldValue)) { + builder.orWhere(field, '=', searchedFieldValue); + } else { + if (fastRowsCount <= 1000) { + builder.orWhereRaw(`LOWER(CAST(?? AS TEXT)) LIKE ?`, [field, `%${searchedFieldValue.toLowerCase()}%`]); + } + builder.orWhereRaw(`LOWER(CAST(?? AS VARCHAR(255))) LIKE ?`, [ + field, + `${searchedFieldValue.toLowerCase()}%`, + ]); + } + } + } + }) + .modify((builder) => { + if (filteringFields?.length > 0) { + for (const filterObject of filteringFields) { + const { field, criteria, value } = filterObject; + const operators = { + [FilterCriteriaEnum.eq]: '=', + [FilterCriteriaEnum.startswith]: 'like', + [FilterCriteriaEnum.endswith]: 'like', + [FilterCriteriaEnum.gt]: '>', + [FilterCriteriaEnum.lt]: '<', + [FilterCriteriaEnum.lte]: '<=', + [FilterCriteriaEnum.gte]: '>=', + [FilterCriteriaEnum.contains]: 'like', + [FilterCriteriaEnum.icontains]: 'not like', + [FilterCriteriaEnum.empty]: 'is', + }; + const values = { + [FilterCriteriaEnum.startswith]: `${value}%`, + [FilterCriteriaEnum.endswith]: `%${value}`, + [FilterCriteriaEnum.contains]: `%${value}%`, + [FilterCriteriaEnum.icontains]: `%${value}%`, + [FilterCriteriaEnum.empty]: null, + }; + builder.where(field, operators[criteria], values[criteria] || value); + } + } + }); + + const rowsResultQb = countRowsQB.clone(); + const offset = (page - 1) * perPage; + const rows = await rowsResultQb + .select(availableFields) + .limit(perPage) + .offset(offset) + .modify((builder) => { + if (settings.ordering_field && settings.ordering) { + builder.orderBy(settings.ordering_field, settings.ordering); + } + }); + const { large_dataset, rowsCount } = await this.getRowsCount(knex, countRowsQB, tableName, tableSchema); + const pagination = { + total: rowsCount, + lastPage: Math.ceil(rowsCount / perPage), + perPage: perPage, + currentPage: page, + }; + return { + data: rows, + pagination, + large_dataset: large_dataset, + }; + } + + public async getTableForeignKeys(tableName: string): Promise { + const cachedForeignKeys = LRUStorage.getTableForeignKeysCache(this.connection, tableName); + if (cachedForeignKeys) { + return cachedForeignKeys; + } + const knex = await this.configureKnex(); + const tableSchema = this.connection.schema ?? 'public'; + const database = this.connection.database; + const foreignKeys: Array<{ + foreign_column_name: string; + foreign_table_name: string; + constraint_name: string; + column_name: string; + }> = await knex(tableName) + .select( + knex.raw(`kcu.constraint_name, kcu.column_name, kcu2.table_name AS foreign_table_name, kcu2.column_name AS foreign_column_name`), - ) - .from( - knex.raw( - `??.information_schema.table_constraints AS tc + ) + .from( + knex.raw( + `??.information_schema.table_constraints AS tc JOIN ??.information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema @@ -289,261 +288,261 @@ export class DataAccessObjectPostgres extends BasicDataAccessObject implements I AND rc.unique_constraint_schema = kcu2.table_schema AND kcu.ordinal_position = kcu2.ordinal_position WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name=? AND tc.table_schema =?;`, - [database, database, database, database, tableName, tableSchema], - ), - ); - const resultKeys = foreignKeys.map((key) => { - return { - referenced_column_name: key.foreign_column_name, - referenced_table_name: key.foreign_table_name, - constraint_name: key.constraint_name, - column_name: key.column_name, - }; - }); - LRUStorage.setTableForeignKeysCache(this.connection, tableName, resultKeys); - return resultKeys; - } - - public async getTablePrimaryColumns(tableName: string): Promise { - const cachedPrimaryColumns = LRUStorage.getTablePrimaryKeysCache(this.connection, tableName); - if (cachedPrimaryColumns) { - return cachedPrimaryColumns; - } - const knex = await this.configureKnex(); - tableName = this.attachSchemaNameToTableName(tableName); - const primaryColumns: Array = await knex('pg_index i') - .select(knex.raw('a.attname, format_type(a.atttypid, a.atttypmod) AS data_type')) - .from(knex.raw('pg_index i')) - .join(knex.raw('pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)')) - .where(knex.raw(`i.indrelid = ?::regclass AND i.indisprimary;`, tableName)); - const resultKeys = primaryColumns.map((column) => { - return { - column_name: column.attname, - data_type: column.data_type, - }; - }); - LRUStorage.setTablePrimaryKeysCache(this.connection, tableName, resultKeys); - return resultKeys; - } - - public async getTablesFromDB(): Promise { - const knex = await this.configureKnex(); - const schema = this.connection.schema ?? 'public'; - const query = ` + [database, database, database, database, tableName, tableSchema], + ), + ); + const resultKeys = foreignKeys.map((key) => { + return { + referenced_column_name: key.foreign_column_name, + referenced_table_name: key.foreign_table_name, + constraint_name: key.constraint_name, + column_name: key.column_name, + }; + }); + LRUStorage.setTableForeignKeysCache(this.connection, tableName, resultKeys); + return resultKeys; + } + + public async getTablePrimaryColumns(tableName: string): Promise { + const cachedPrimaryColumns = LRUStorage.getTablePrimaryKeysCache(this.connection, tableName); + if (cachedPrimaryColumns) { + return cachedPrimaryColumns; + } + const knex = await this.configureKnex(); + tableName = this.attachSchemaNameToTableName(tableName); + const primaryColumns: Array = await knex('pg_index i') + .select(knex.raw('a.attname, format_type(a.atttypid, a.atttypmod) AS data_type')) + .from(knex.raw('pg_index i')) + .join(knex.raw('pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)')) + .where(knex.raw(`i.indrelid = ?::regclass AND i.indisprimary;`, tableName)); + const resultKeys = primaryColumns.map((column) => { + return { + column_name: column.attname, + data_type: column.data_type, + }; + }); + LRUStorage.setTablePrimaryKeysCache(this.connection, tableName, resultKeys); + return resultKeys; + } + + public async getTablesFromDB(): Promise { + const knex = await this.configureKnex(); + const schema = this.connection.schema ?? 'public'; + const query = ` SELECT table_name, table_type = 'VIEW' AS is_view FROM information_schema.tables WHERE table_schema = ? AND table_catalog = current_database() `; - const bindings = [schema]; - try { - const results = await knex.raw(query, bindings); - return results.rows.map((row: Record) => ({ tableName: row.table_name, isView: !!row.is_view })); - } catch (error) { - console.log({ tablesPg: error }); - throw error; - } - } - - public async getTableStructure(tableName: string): Promise { - const cachedTableStructure = LRUStorage.getTableStructureCache(this.connection, tableName); - if (cachedTableStructure) { - return cachedTableStructure; - } - const knex = await this.configureKnex(); - let result = await knex('information_schema.columns') - .select( - 'column_name', - 'column_default', - 'data_type', - 'udt_name', - 'is_nullable', - 'character_maximum_length', - 'is_identity', - 'identity_generation', - ) - .orderBy('dtd_identifier') - .where(`table_name`, tableName) - .andWhere('table_schema', this.connection.schema ? this.connection.schema : 'public'); - - const generatedIdentities: Array = ['BY DEFAULT', 'ALWAYS']; - const customTypeIndexes: Array = []; - result = result.map((element, i) => { - const { is_nullable, data_type, identity_generation } = element; - element.allow_null = is_nullable === 'YES'; - delete element.is_nullable; - if (data_type === 'USER-DEFINED') { - customTypeIndexes.push(i); - } - element.extra = generatedIdentities.includes(identity_generation) ? 'auto_increment' : undefined; - return element; - }); - - if (customTypeIndexes.length > 0) { - for (const index of customTypeIndexes) { - const customTypeInTableName = result[index].udt_name; - const customTypeAttrsQueryResult = await knex.raw( - `select attname, format_type(atttypid, atttypmod) + const bindings = [schema]; + try { + const results = await knex.raw(query, bindings); + return results.rows.map((row: Record) => ({ tableName: row.table_name, isView: !!row.is_view })); + } catch (error) { + console.log({ tablesPg: error }); + throw error; + } + } + + public async getTableStructure(tableName: string): Promise { + const cachedTableStructure = LRUStorage.getTableStructureCache(this.connection, tableName); + if (cachedTableStructure) { + return cachedTableStructure; + } + const knex = await this.configureKnex(); + let result = await knex('information_schema.columns') + .select( + 'column_name', + 'column_default', + 'data_type', + 'udt_name', + 'is_nullable', + 'character_maximum_length', + 'is_identity', + 'identity_generation', + ) + .orderBy('dtd_identifier') + .where(`table_name`, tableName) + .andWhere('table_schema', this.connection.schema ? this.connection.schema : 'public'); + + const generatedIdentities: Array = ['BY DEFAULT', 'ALWAYS']; + const customTypeIndexes: Array = []; + result = result.map((element, i) => { + const { is_nullable, data_type, identity_generation } = element; + element.allow_null = is_nullable === 'YES'; + delete element.is_nullable; + if (data_type === 'USER-DEFINED') { + customTypeIndexes.push(i); + } + element.extra = generatedIdentities.includes(identity_generation) ? 'auto_increment' : undefined; + return element; + }); + + if (customTypeIndexes.length > 0) { + for (const index of customTypeIndexes) { + const customTypeInTableName = result[index].udt_name; + const customTypeAttrsQueryResult = await knex.raw( + `select attname, format_type(atttypid, atttypmod) from pg_type join pg_class on pg_class.oid = pg_type.typrelid join pg_attribute on pg_attribute.attrelid = pg_class.oid where typname = ? order by attnum`, - customTypeInTableName, - ); - const customTypeAttrs = customTypeAttrsQueryResult.rows; - const enumLabelQueryResult = await knex.raw( - `SELECT e.enumlabel + customTypeInTableName, + ); + const customTypeAttrs = customTypeAttrsQueryResult.rows; + const enumLabelQueryResult = await knex.raw( + `SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = ?`, - customTypeInTableName, - ); - let enumLabelRows = []; - if (enumLabelQueryResult?.rows && enumLabelQueryResult.rows.length > 0) { - enumLabelRows = enumLabelQueryResult.rows.map((el) => el.enumlabel); - } - if (enumLabelRows.length > 0) { - result[index].data_type = 'enum'; - result[index].data_type_params = enumLabelRows; - } - - if (customTypeAttrs && customTypeAttrs.length > 0) { - const customDataTypeRo = customTypeAttrs.map((attr) => ({ - column_name: attr.attname, - data_type: attr.format_type, - })); - result[index].data_type = result[index].udt_name; - result[index].data_type_params = customDataTypeRo; - } - } - } - - LRUStorage.setTableStructureCache(this.connection, tableName, result); - return result; - } - - public async testConnect(): Promise { - if (!this.connection.id) { - this.connection.id = nanoid(6); - } - const knex = await this.configureKnex(); - try { - await knex.queryBuilder().select(1); - return { - result: true, - message: 'Successfully connected', - }; - } catch (e) { - return { - result: false, - message: e.message || 'Connection failed', - }; - } finally { - LRUStorage.delKnexCache(this.connection); - } - } - - public async updateRowInTable( - tableName: string, - row: Record, - primaryKey: Record, - ): Promise> { - const tableStructure = await this.getTableStructure(tableName); - const jsonColumnNames = tableStructure - .filter(({ data_type }) => data_type.toLowerCase() === 'json' || data_type.toLowerCase() === 'jsonb') - .map(({ column_name }) => column_name); - - const updatedRow = { ...row }; - jsonColumnNames.forEach((key) => { - if (key in updatedRow) { - updatedRow[key] = JSON.stringify(updatedRow[key]); - } - }); - - const knex = await this.configureKnex(); - return await knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .returning(Object.keys(primaryKey)) - .where(primaryKey) - .update(updatedRow); - } - - public async bulkUpdateRowsInTable( - tableName: string, - newValues: Record, - primaryKeys: Array>, - ): Promise[]> { - const tableStructure = await this.getTableStructure(tableName); - const jsonColumnNames = tableStructure - .filter(({ data_type }) => data_type.toLowerCase() === 'json' || data_type.toLowerCase() === 'jsonb') - .map(({ column_name }) => column_name); - - const updatedValues = { ...newValues }; - jsonColumnNames.forEach((key) => { - if (key in updatedValues) { - updatedValues[key] = JSON.stringify(updatedValues[key]); - } - }); - - const knex = await this.configureKnex(); - - return await knex.transaction(async (trx) => { - const results = []; - for (const primaryKey of primaryKeys) { - const result = await trx(tableName) - .withSchema(this.connection.schema ?? 'public') - .returning(Object.keys(primaryKey)) - .where(primaryKey) - .update(updatedValues); - results.push(result[0]); - } - return results; - }); - } - - public async bulkDeleteRowsInTable(tableName: string, primaryKeys: Array>): Promise { - const knex = await this.configureKnex(); - - if (primaryKeys.length === 0) { - return 0; - } - - await knex.transaction(async (trx) => { - await trx(tableName) - .withSchema(this.connection.schema ?? 'public') - .delete() - .modify((queryBuilder) => { - primaryKeys.forEach((key) => { - queryBuilder.orWhere((builder) => { - Object.entries(key).forEach(([column, value]) => { - builder.andWhere(column, value); - }); - }); - }); - }); - }); - - return primaryKeys.length; - } - - public async validateSettings(settings: ValidateTableSettingsDS, tableName: string): Promise { - const [tableStructure, primaryColumns] = await Promise.all([ - this.getTableStructure(tableName), - this.getTablePrimaryColumns(tableName), - ]); - return tableSettingsFieldValidator(tableStructure, primaryColumns, settings); - } - - public async getReferencedTableNamesAndColumns(tableName: string): Promise { - const primaryColumns = await this.getTablePrimaryColumns(tableName); - const schema = this.connection.schema ?? 'public'; - const knex = await this.configureKnex(); - - const results = await Promise.all( - primaryColumns.map(async (primaryColumn) => { - const result = await knex.raw( - ` + customTypeInTableName, + ); + let enumLabelRows = []; + if (enumLabelQueryResult?.rows && enumLabelQueryResult.rows.length > 0) { + enumLabelRows = enumLabelQueryResult.rows.map((el) => el.enumlabel); + } + if (enumLabelRows.length > 0) { + result[index].data_type = 'enum'; + result[index].data_type_params = enumLabelRows; + } + + if (customTypeAttrs && customTypeAttrs.length > 0) { + const customDataTypeRo = customTypeAttrs.map((attr) => ({ + column_name: attr.attname, + data_type: attr.format_type, + })); + result[index].data_type = result[index].udt_name; + result[index].data_type_params = customDataTypeRo; + } + } + } + + LRUStorage.setTableStructureCache(this.connection, tableName, result); + return result; + } + + public async testConnect(): Promise { + if (!this.connection.id) { + this.connection.id = nanoid(6); + } + const knex = await this.configureKnex(); + try { + await knex.queryBuilder().select(1); + return { + result: true, + message: 'Successfully connected', + }; + } catch (e) { + return { + result: false, + message: e.message || 'Connection failed', + }; + } finally { + LRUStorage.delKnexCache(this.connection); + } + } + + public async updateRowInTable( + tableName: string, + row: Record, + primaryKey: Record, + ): Promise> { + const tableStructure = await this.getTableStructure(tableName); + const jsonColumnNames = tableStructure + .filter(({ data_type }) => data_type.toLowerCase() === 'json' || data_type.toLowerCase() === 'jsonb') + .map(({ column_name }) => column_name); + + const updatedRow = { ...row }; + jsonColumnNames.forEach((key) => { + if (key in updatedRow) { + updatedRow[key] = JSON.stringify(updatedRow[key]); + } + }); + + const knex = await this.configureKnex(); + return await knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .returning(Object.keys(primaryKey)) + .where(primaryKey) + .update(updatedRow); + } + + public async bulkUpdateRowsInTable( + tableName: string, + newValues: Record, + primaryKeys: Array>, + ): Promise[]> { + const tableStructure = await this.getTableStructure(tableName); + const jsonColumnNames = tableStructure + .filter(({ data_type }) => data_type.toLowerCase() === 'json' || data_type.toLowerCase() === 'jsonb') + .map(({ column_name }) => column_name); + + const updatedValues = { ...newValues }; + jsonColumnNames.forEach((key) => { + if (key in updatedValues) { + updatedValues[key] = JSON.stringify(updatedValues[key]); + } + }); + + const knex = await this.configureKnex(); + + return await knex.transaction(async (trx) => { + const results = []; + for (const primaryKey of primaryKeys) { + const result = await trx(tableName) + .withSchema(this.connection.schema ?? 'public') + .returning(Object.keys(primaryKey)) + .where(primaryKey) + .update(updatedValues); + results.push(result[0]); + } + return results; + }); + } + + public async bulkDeleteRowsInTable(tableName: string, primaryKeys: Array>): Promise { + const knex = await this.configureKnex(); + + if (primaryKeys.length === 0) { + return 0; + } + + await knex.transaction(async (trx) => { + await trx(tableName) + .withSchema(this.connection.schema ?? 'public') + .delete() + .modify((queryBuilder) => { + primaryKeys.forEach((key) => { + queryBuilder.orWhere((builder) => { + Object.entries(key).forEach(([column, value]) => { + builder.andWhere(column, value); + }); + }); + }); + }); + }); + + return primaryKeys.length; + } + + public async validateSettings(settings: ValidateTableSettingsDS, tableName: string): Promise { + const [tableStructure, primaryColumns] = await Promise.all([ + this.getTableStructure(tableName), + this.getTablePrimaryColumns(tableName), + ]); + return tableSettingsFieldValidator(tableStructure, primaryColumns, settings); + } + + public async getReferencedTableNamesAndColumns(tableName: string): Promise { + const primaryColumns = await this.getTablePrimaryColumns(tableName); + const schema = this.connection.schema ?? 'public'; + const knex = await this.configureKnex(); + + const results = await Promise.all( + primaryColumns.map(async (primaryColumn) => { + const result = await knex.raw( + ` SELECT r.table_name, r.column_name FROM information_schema.constraint_column_usage u @@ -561,226 +560,226 @@ export class DataAccessObjectPostgres extends BasicDataAccessObject implements I u.table_schema = ? AND u.table_name = ? `, - [primaryColumn.column_name, schema, tableName], - ); - return { - referenced_on_column_name: primaryColumn.column_name, - referenced_by: result.rows, - }; - }), - ); - - return results; - } - - public async isView(tableName: string): Promise { - const schema = this.connection.schema ? this.connection.schema : 'public'; - const knex = await this.configureKnex(); - const entityType = await knex('information_schema.tables') - .select('table_type') - .where('table_schema', schema) - .andWhere('table_name', tableName); - if (entityType.length === 0) throw new Error(ERROR_MESSAGES.TABLE_NOT_FOUND(tableName)); - return entityType[0].table_type === 'VIEW'; - } - - public async getTableRowsStream( - tableName: string, - settings: TableSettingsDS, - page: number, - perPage: number, - searchedFieldValue: string, - filteringFields: Array, - ): Promise> { - page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; - perPage = - perPage > 0 - ? perPage - : settings.list_per_page > 0 - ? settings.list_per_page - : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; - - const offset = (page - 1) * perPage; - const knex = await this.configureKnex(); - - // const tableSchema = this.connection.schema ?? 'public'; - - // const [{ large_dataset }, tableStructure] = await Promise.all([ - // this.getRowsCount(knex, null, tableName, tableSchema), - // this.getTableStructure(tableName), - // ]); - - const tableStructure = await this.getTableStructure(tableName); - const availableFields = this.findAvailableFields(settings, tableStructure); - - // if (large_dataset) { - // throw new Error(ERROR_MESSAGES.DATA_IS_TO_LARGE); - // } - const rowsAsStream = knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .select(availableFields) - .modify((builder) => { - let { search_fields } = settings; - if ((!search_fields || search_fields?.length === 0) && searchedFieldValue) { - search_fields = availableFields; - } - if (searchedFieldValue && search_fields.length > 0) { - for (const field of search_fields) { - if (Buffer.isBuffer(searchedFieldValue)) { - builder.orWhere(field, '=', searchedFieldValue); - } else { - builder.orWhereRaw(`LOWER(CAST(?? AS VARCHAR(255))) LIKE ?`, [ - field, - `${searchedFieldValue.toLowerCase()}%`, - ]); - } - } - } - }) - .modify((builder) => { - if (filteringFields && filteringFields.length > 0) { - for (const filterObject of filteringFields) { - const { field, criteria, value } = filterObject; - const operators = { - [FilterCriteriaEnum.eq]: '=', - [FilterCriteriaEnum.startswith]: 'like', - [FilterCriteriaEnum.endswith]: 'like', - [FilterCriteriaEnum.gt]: '>', - [FilterCriteriaEnum.lt]: '<', - [FilterCriteriaEnum.lte]: '<=', - [FilterCriteriaEnum.gte]: '>=', - [FilterCriteriaEnum.contains]: 'like', - [FilterCriteriaEnum.icontains]: 'not like', - [FilterCriteriaEnum.empty]: 'is', - }; - const values = { - [FilterCriteriaEnum.startswith]: `${value}%`, - [FilterCriteriaEnum.endswith]: `%${value}`, - [FilterCriteriaEnum.contains]: `%${value}%`, - [FilterCriteriaEnum.icontains]: `%${value}%`, - [FilterCriteriaEnum.empty]: null, - }; - - builder.where(field, operators[criteria], values[criteria] || value); - } - } - }) - .modify((builder) => { - if (settings.ordering_field && settings.ordering) { - builder.orderBy(settings.ordering_field, settings.ordering); - } - }) - .limit(perPage) - .offset(offset) - .stream(); - return rowsAsStream; - } - - public async importCSVInTable(file: Express.Multer.File, tableName: string): Promise { - const knex = await this.configureKnex(); - const structure = await this.getTableStructure(tableName); - const timestampColumnNames = structure - .filter(({ data_type }) => isPostgresDateOrTimeType(data_type)) - .map(({ column_name }) => column_name); - const stream = new Readable(); - stream.push(file.buffer); - stream.push(null); - - const parser = stream.pipe(csv.parse({ columns: true })); - - const results: any[] = []; - for await (const record of parser) { - results.push(record); - } - await knex.transaction(async (trx) => { - for (const row of results) { - for (const column of timestampColumnNames) { - if (row[column] && !isPostgresDateStringByRegexp(row[column])) { - const date = new Date(Number(row[column])); - row[column] = date.toISOString(); - } - } - - await trx(tableName) - .withSchema(this.connection.schema ?? 'public') - .insert(row); - } - }); - } - - public async executeRawQuery(query: string): Promise>> { - const knex = await this.configureKnex(); - return knex.raw(query); - } - - private async getRowsCount( - knex: Knex, - countRowsQB: Knex.QueryBuilder | null, - tableName: string, - tableSchema: string, - ): Promise<{ rowsCount: number; large_dataset: boolean }> { - if (countRowsQB) { - const slowRowsCount = await this.getRowsCountByQueryWithTimeOut(countRowsQB); - if (slowRowsCount) { - return { - rowsCount: slowRowsCount, - large_dataset: slowRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, - }; - } - const fastRowsCount = await this.getFastRowsCount(knex, tableName, tableSchema); - return { - rowsCount: fastRowsCount, - large_dataset: fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, - }; - } - - const fastRowsCount = await this.getFastRowsCount(knex, tableName, tableSchema); - - if (fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT) { - return { - rowsCount: fastRowsCount, - large_dataset: true, - }; - } - - const slowRowsCount = await this.getSlowRowsCount(knex, tableName); - return { - rowsCount: slowRowsCount, - large_dataset: false, - }; - } - - private async getRowsCountByQueryWithTimeOut(countRowsQB: Knex.QueryBuilder): Promise { - return new Promise(async (resolve) => { - setTimeout(() => { - resolve(null); - }, DAO_CONSTANTS.COUNT_QUERY_TIMEOUT_MS); - - try { - const count = (await countRowsQB.count('*')) as any; - const slowCount = parseInt(count[0].count, 10); - resolve(slowCount); - } catch (_error) { - resolve(null); - } - }); - } - - private async getSlowRowsCount(knex: Knex, tableName: string): Promise { - const count = (await knex(tableName) - .withSchema(this.connection.schema ?? 'public') - .count('*')) as any; - const slowCount = parseInt(count[0].count, 10); - return slowCount; - } - - private async getFastRowsCount( - knex: Knex, - tableName: string, - tableSchema: string, - ): Promise { - const fastCount = await knex.raw( - ` + [primaryColumn.column_name, schema, tableName], + ); + return { + referenced_on_column_name: primaryColumn.column_name, + referenced_by: result.rows, + }; + }), + ); + + return results; + } + + public async isView(tableName: string): Promise { + const schema = this.connection.schema ? this.connection.schema : 'public'; + const knex = await this.configureKnex(); + const entityType = await knex('information_schema.tables') + .select('table_type') + .where('table_schema', schema) + .andWhere('table_name', tableName); + if (entityType.length === 0) throw new Error(ERROR_MESSAGES.TABLE_NOT_FOUND(tableName)); + return entityType[0].table_type === 'VIEW'; + } + + public async getTableRowsStream( + tableName: string, + settings: TableSettingsDS, + page: number, + perPage: number, + searchedFieldValue: string, + filteringFields: Array, + ): Promise> { + page = page > 0 ? page : DAO_CONSTANTS.DEFAULT_PAGINATION.page; + perPage = + perPage > 0 + ? perPage + : settings.list_per_page > 0 + ? settings.list_per_page + : DAO_CONSTANTS.DEFAULT_PAGINATION.perPage; + + const offset = (page - 1) * perPage; + const knex = await this.configureKnex(); + + // const tableSchema = this.connection.schema ?? 'public'; + + // const [{ large_dataset }, tableStructure] = await Promise.all([ + // this.getRowsCount(knex, null, tableName, tableSchema), + // this.getTableStructure(tableName), + // ]); + + const tableStructure = await this.getTableStructure(tableName); + const availableFields = this.findAvailableFields(settings, tableStructure); + + // if (large_dataset) { + // throw new Error(ERROR_MESSAGES.DATA_IS_TO_LARGE); + // } + const rowsAsStream = knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .select(availableFields) + .modify((builder) => { + let { search_fields } = settings; + if ((!search_fields || search_fields?.length === 0) && searchedFieldValue) { + search_fields = availableFields; + } + if (searchedFieldValue && search_fields.length > 0) { + for (const field of search_fields) { + if (Buffer.isBuffer(searchedFieldValue)) { + builder.orWhere(field, '=', searchedFieldValue); + } else { + builder.orWhereRaw(`LOWER(CAST(?? AS VARCHAR(255))) LIKE ?`, [ + field, + `${searchedFieldValue.toLowerCase()}%`, + ]); + } + } + } + }) + .modify((builder) => { + if (filteringFields && filteringFields.length > 0) { + for (const filterObject of filteringFields) { + const { field, criteria, value } = filterObject; + const operators = { + [FilterCriteriaEnum.eq]: '=', + [FilterCriteriaEnum.startswith]: 'like', + [FilterCriteriaEnum.endswith]: 'like', + [FilterCriteriaEnum.gt]: '>', + [FilterCriteriaEnum.lt]: '<', + [FilterCriteriaEnum.lte]: '<=', + [FilterCriteriaEnum.gte]: '>=', + [FilterCriteriaEnum.contains]: 'like', + [FilterCriteriaEnum.icontains]: 'not like', + [FilterCriteriaEnum.empty]: 'is', + }; + const values = { + [FilterCriteriaEnum.startswith]: `${value}%`, + [FilterCriteriaEnum.endswith]: `%${value}`, + [FilterCriteriaEnum.contains]: `%${value}%`, + [FilterCriteriaEnum.icontains]: `%${value}%`, + [FilterCriteriaEnum.empty]: null, + }; + + builder.where(field, operators[criteria], values[criteria] || value); + } + } + }) + .modify((builder) => { + if (settings.ordering_field && settings.ordering) { + builder.orderBy(settings.ordering_field, settings.ordering); + } + }) + .limit(perPage) + .offset(offset) + .stream(); + return rowsAsStream; + } + + public async importCSVInTable(file: Express.Multer.File, tableName: string): Promise { + const knex = await this.configureKnex(); + const structure = await this.getTableStructure(tableName); + const timestampColumnNames = structure + .filter(({ data_type }) => isPostgresDateOrTimeType(data_type)) + .map(({ column_name }) => column_name); + const stream = new Readable(); + stream.push(file.buffer); + stream.push(null); + + const parser = stream.pipe(csv.parse({ columns: true })); + + const results: any[] = []; + for await (const record of parser) { + results.push(record); + } + await knex.transaction(async (trx) => { + for (const row of results) { + for (const column of timestampColumnNames) { + if (row[column] && !isPostgresDateStringByRegexp(row[column])) { + const date = new Date(Number(row[column])); + row[column] = date.toISOString(); + } + } + + await trx(tableName) + .withSchema(this.connection.schema ?? 'public') + .insert(row); + } + }); + } + + public async executeRawQuery(query: string): Promise>> { + const knex = await this.configureKnex(); + return knex.raw(query); + } + + private async getRowsCount( + knex: Knex, + countRowsQB: Knex.QueryBuilder | null, + tableName: string, + tableSchema: string, + ): Promise<{ rowsCount: number; large_dataset: boolean }> { + if (countRowsQB) { + const slowRowsCount = await this.getRowsCountByQueryWithTimeOut(countRowsQB); + if (slowRowsCount) { + return { + rowsCount: slowRowsCount, + large_dataset: slowRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, + }; + } + const fastRowsCount = await this.getFastRowsCount(knex, tableName, tableSchema); + return { + rowsCount: fastRowsCount, + large_dataset: fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT, + }; + } + + const fastRowsCount = await this.getFastRowsCount(knex, tableName, tableSchema); + + if (fastRowsCount >= DAO_CONSTANTS.LARGE_DATASET_ROW_LIMIT) { + return { + rowsCount: fastRowsCount, + large_dataset: true, + }; + } + + const slowRowsCount = await this.getSlowRowsCount(knex, tableName); + return { + rowsCount: slowRowsCount, + large_dataset: false, + }; + } + + private async getRowsCountByQueryWithTimeOut(countRowsQB: Knex.QueryBuilder): Promise { + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(null); + }, DAO_CONSTANTS.COUNT_QUERY_TIMEOUT_MS); + + try { + const count = (await countRowsQB.count('*')) as any; + const slowCount = parseInt(count[0].count, 10); + resolve(slowCount); + } catch (_error) { + resolve(null); + } + }); + } + + private async getSlowRowsCount(knex: Knex, tableName: string): Promise { + const count = (await knex(tableName) + .withSchema(this.connection.schema ?? 'public') + .count('*')) as any; + const slowCount = parseInt(count[0].count, 10); + return slowCount; + } + + private async getFastRowsCount( + knex: Knex, + tableName: string, + tableSchema: string, + ): Promise { + const fastCount = await knex.raw( + ` SELECT CASE WHEN relpages = 0 THEN 0 ELSE ((reltuples / relpages) @@ -789,18 +788,18 @@ export class DataAccessObjectPostgres extends BasicDataAccessObject implements I END as count FROM pg_class WHERE oid = '??.??'::regclass;`, - [tableSchema, tableName, tableSchema, tableName], - ); - return parseInt(fastCount, 10); - } - - private attachSchemaNameToTableName(tableName: string): string { - let fullTableName: string; - if (this.connection.schema) { - fullTableName = `"${this.connection.schema}"."${tableName}"`; - } else { - fullTableName = `"public"."${tableName}"`; - } - return fullTableName; - } + [tableSchema, tableName, tableSchema, tableName], + ); + return parseInt(fastCount, 10); + } + + private attachSchemaNameToTableName(tableName: string): string { + let fullTableName: string; + if (this.connection.schema) { + fullTableName = `"${this.connection.schema}"."${tableName}"`; + } else { + fullTableName = `"public"."${tableName}"`; + } + return fullTableName; + } }