diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 972ecb321..2f485053d 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -44,6 +44,7 @@ export enum UseCaseType { VALIDATE_CONNECTION_MASTER_PASSWORD = 'VALIDATE_CONNECTION_MASTER_PASSWORD', UNFREEZE_CONNECTION = 'UNFREEZE_CONNECTION', UPDATE_CONNECTION_TITLE = 'UPDATE_CONNECTION_TITLE', + GET_CONNECTION_DIAGRAM = 'GET_CONNECTION_DIAGRAM', FIND_ALL_USER_GROUPS = 'FIND_ALL_USER_GROUPS', INVITE_USER_IN_GROUP = 'INVITE_USER_IN_GROUP', diff --git a/backend/src/entities/connection/application/data-structures/get-connection-diagram.ds.ts b/backend/src/entities/connection/application/data-structures/get-connection-diagram.ds.ts new file mode 100644 index 000000000..952c23222 --- /dev/null +++ b/backend/src/entities/connection/application/data-structures/get-connection-diagram.ds.ts @@ -0,0 +1,5 @@ +export class GetConnectionDiagramDs { + connectionId: string; + masterPwd: string; + userId: string; +} diff --git a/backend/src/entities/connection/application/dto/connection-diagram-response.dto.ts b/backend/src/entities/connection/application/dto/connection-diagram-response.dto.ts new file mode 100644 index 000000000..fce0698f2 --- /dev/null +++ b/backend/src/entities/connection/application/dto/connection-diagram-response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; + +export class ConnectionDiagramResponseDTO { + @ApiProperty() + connectionId: string; + + @ApiProperty({ enum: ConnectionTypesEnum }) + databaseType: ConnectionTypesEnum; + + @ApiProperty({ description: 'Mermaid erDiagram source string' }) + diagram: string; + + @ApiProperty({ description: 'Human-readable description of the database structure' }) + description: string; + + @ApiProperty() + generatedAt: string; +} diff --git a/backend/src/entities/connection/connection.controller.ts b/backend/src/entities/connection/connection.controller.ts index dd5b5597b..7356ea1d2 100644 --- a/backend/src/entities/connection/connection.controller.ts +++ b/backend/src/entities/connection/connection.controller.ts @@ -37,6 +37,7 @@ import { FindOneConnectionDs } from './application/data-structures/find-one-conn import { FoundConnectionsDs } from './application/data-structures/found-connections.ds.js'; import { FoundOneConnectionDs } from './application/data-structures/found-one-connection.ds.js'; import { FoundPermissionsInConnectionDs } from './application/data-structures/found-permissions-in-connection.ds.js'; +import { GetConnectionDiagramDs } from './application/data-structures/get-connection-diagram.ds.js'; import { GetGroupsInConnectionDs } from './application/data-structures/get-groups-in-connection.ds.js'; import { GetPermissionsInConnectionDs } from './application/data-structures/get-permissions-in-connection.ds.js'; import { RestoredConnectionDs } from './application/data-structures/restored-connection.ds.js'; @@ -44,6 +45,7 @@ import { UpdateConnectionDs } from './application/data-structures/update-connect import { UpdateConnectionTitleDs } from './application/data-structures/update-connection-title.ds.js'; import { UpdateMasterPasswordDs } from './application/data-structures/update-master-password.ds.js'; import { ValidateConnectionMasterPasswordDs } from './application/data-structures/validate-connection-master-password.ds.js'; +import { ConnectionDiagramResponseDTO } from './application/dto/connection-diagram-response.dto.js'; import { CreateConnectionDto } from './application/dto/create-connection.dto.js'; import { CreateGroupInConnectionDTO } from './application/dto/create-group-in-connection.dto.js'; import { CreatedConnectionDTO } from './application/dto/created-connection.dto.js'; @@ -64,6 +66,7 @@ import { IFindConnections, IFindOneConnection, IFindUsersInConnection, + IGetConnectionDiagram, IGetPermissionsForGroupInConnection, IGetUserGroupsInConnection, IRefreshConnectionAgentToken, @@ -125,6 +128,8 @@ export class ConnectionController { private readonly unfreezeConnectionUseCase: IUnfreezeConnection, @Inject(UseCaseType.UPDATE_CONNECTION_TITLE) private readonly updateConnectionTitleUseCase: IUpdateConnectionTitle, + @Inject(UseCaseType.GET_CONNECTION_DIAGRAM) + private readonly getConnectionDiagramUseCase: IGetConnectionDiagram, @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, private readonly amplitudeService: AmplitudeService, @@ -715,4 +720,27 @@ export class ConnectionController { }; return await this.updateConnectionTitleUseCase.execute(inputData, InTransactionEnum.ON); } + + @ApiOperation({ summary: 'Get Mermaid diagram of connection database structure (SQL only)' }) + @ApiResponse({ + status: 200, + type: ConnectionDiagramResponseDTO, + }) + @UseGuards(ConnectionReadGuard) + @Get('/connection/diagram/:connectionId') + async getConnectionDiagram( + @SlugUuid('connectionId') connectionId: string, + @MasterPassword() masterPwd: string, + @UserId() userId: string, + ): Promise { + if (!connectionId) { + throw new BadRequestException(Messages.CONNECTION_ID_MISSING); + } + const inputData: GetConnectionDiagramDs = { + connectionId, + masterPwd, + userId, + }; + return await this.getConnectionDiagramUseCase.execute(inputData, InTransactionEnum.OFF); + } } diff --git a/backend/src/entities/connection/connection.module.ts b/backend/src/entities/connection/connection.module.ts index 37c6abea6..df04c18fc 100644 --- a/backend/src/entities/connection/connection.module.ts +++ b/backend/src/entities/connection/connection.module.ts @@ -24,6 +24,7 @@ import { DeleteGroupFromConnectionUseCase } from './use-cases/delete-group-from- import { FindAllConnectionsUseCase } from './use-cases/find-all-connections.use.case.js'; import { FindAllUsersInConnectionUseCase } from './use-cases/find-all-users-in-connection.use.case.js'; import { FindOneConnectionUseCase } from './use-cases/find-one-connection.use.case.js'; +import { GetConnectionDiagramUseCase } from './use-cases/get-connection-diagram.use.case.js'; import { GetPermissionsForGroupInConnectionUseCase } from './use-cases/get-permissions-for-group-in-connection.use.case.js'; import { GetUserGroupsInConnectionUseCase } from './use-cases/get-user-groups-in-connection.use.case.js'; import { GetUserPermissionsForGroupInConnectionUseCase } from './use-cases/get-user-permissions-for-group-in-connection.use.case.js'; @@ -135,6 +136,10 @@ import { ValidateConnectionTokenUseCase } from './use-cases/validate-connection- provide: UseCaseType.UPDATE_CONNECTION_TITLE, useClass: UpdateConnectionTitleUseCase, }, + { + provide: UseCaseType.GET_CONNECTION_DIAGRAM, + useClass: GetConnectionDiagramUseCase, + }, ], controllers: [ConnectionController], }) @@ -162,6 +167,7 @@ export class ConnectionModule implements NestModule { { path: '/connection/masterpwd/verify/:connectionId', method: RequestMethod.GET }, { path: '/connection/unfreeze/:connectionId', method: RequestMethod.PUT }, { path: '/connection/title/:connectionId', method: RequestMethod.PUT }, + { path: '/connection/diagram/:connectionId', method: RequestMethod.GET }, ) .apply(AuthWithApiMiddleware) .forRoutes({ path: 'connections', method: RequestMethod.GET }); diff --git a/backend/src/entities/connection/use-cases/get-connection-diagram.use.case.ts b/backend/src/entities/connection/use-cases/get-connection-diagram.use.case.ts new file mode 100644 index 000000000..41b1c59f9 --- /dev/null +++ b/backend/src/entities/connection/use-cases/get-connection-diagram.use.case.ts @@ -0,0 +1,92 @@ +import { BadRequestException, HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common'; +import { validateSchemaCache } from '@rocketadmin/shared-code/dist/src/caching/schema-cache-validator.js'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { ForeignKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/foreign-key.ds.js'; +import { PrimaryKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/primary-key.ds.js'; +import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { ExceptionOperations } from '../../../exceptions/custom-exceptions/exception-operation.js'; +import { UnknownSQLException } from '../../../exceptions/custom-exceptions/unknown-sql-exception.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; +import { GetConnectionDiagramDs } from '../application/data-structures/get-connection-diagram.ds.js'; +import { ConnectionDiagramResponseDTO } from '../application/dto/connection-diagram-response.dto.js'; +import { buildMermaidErDiagram, MermaidTableInput } from '../utils/build-mermaid-er-diagram.util.js'; +import { isSqlConnectionType } from '../utils/is-sql-connection-type.util.js'; +import { IGetConnectionDiagram } from './use-cases.interfaces.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class GetConnectionDiagramUseCase + extends AbstractUseCase + implements IGetConnectionDiagram +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: GetConnectionDiagramDs): Promise { + const { connectionId, masterPwd, userId } = inputData; + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + if (!connection) { + throw new HttpException({ message: Messages.CONNECTION_NOT_FOUND }, HttpStatus.BAD_REQUEST); + } + if (!isSqlConnectionType(connection.type)) { + throw new BadRequestException(Messages.DIAGRAM_NOT_SUPPORTED_FOR_CONNECTION_TYPE); + } + + const dao = getDataAccessObject(connection); + const userEmail = isConnectionTypeAgent(connection.type) + ? await this._dbContext.userRepository.getUserEmailOrReturnNull(userId) + : undefined; + + await validateSchemaCache(dao, userEmail); + + let tables: Array<{ tableName: string; isView: boolean }>; + try { + tables = await dao.getTablesFromDB(userEmail); + } catch (e) { + throw new UnknownSQLException(e.message, ExceptionOperations.FAILED_TO_GET_TABLES); + } + + const realTables = tables.filter((t) => !t.isView); + const tableInputs: Array = await Promise.all( + realTables.map((t) => this.collectTableInfo(dao, t.tableName, userEmail)), + ); + + const { diagram, description } = buildMermaidErDiagram(connection.database || null, tableInputs); + return { + connectionId, + databaseType: connection.type as ConnectionTypesEnum, + diagram, + description, + generatedAt: new Date().toISOString(), + }; + } + + private async collectTableInfo( + dao: ReturnType, + tableName: string, + userEmail: string | undefined, + ): Promise { + const [structure, primaryColumns, foreignKeys] = await Promise.all([ + this.safe>(() => dao.getTableStructure(tableName, userEmail), []), + this.safe>(() => dao.getTablePrimaryColumns(tableName, userEmail), []), + this.safe>(() => dao.getTableForeignKeys(tableName, userEmail), []), + ]); + return { tableName, structure, primaryColumns, foreignKeys }; + } + + private async safe(fn: () => Promise, fallback: T): Promise { + try { + return await fn(); + } catch { + return fallback; + } + } +} diff --git a/backend/src/entities/connection/use-cases/use-cases.interfaces.ts b/backend/src/entities/connection/use-cases/use-cases.interfaces.ts index e50bd7dec..5c48f7973 100644 --- a/backend/src/entities/connection/use-cases/use-cases.interfaces.ts +++ b/backend/src/entities/connection/use-cases/use-cases.interfaces.ts @@ -11,6 +11,7 @@ import { FindOneConnectionDs } from '../application/data-structures/find-one-con import { FoundConnectionsDs } from '../application/data-structures/found-connections.ds.js'; import { FoundOneConnectionDs } from '../application/data-structures/found-one-connection.ds.js'; import { FoundPermissionsInConnectionDs } from '../application/data-structures/found-permissions-in-connection.ds.js'; +import { GetConnectionDiagramDs } from '../application/data-structures/get-connection-diagram.ds.js'; import { GetGroupsInConnectionDs } from '../application/data-structures/get-groups-in-connection.ds.js'; import { GetPermissionsInConnectionDs } from '../application/data-structures/get-permissions-in-connection.ds.js'; import { RestoredConnectionDs } from '../application/data-structures/restored-connection.ds.js'; @@ -21,6 +22,7 @@ import { UpdateConnectionDs } from '../application/data-structures/update-connec import { UpdateConnectionTitleDs } from '../application/data-structures/update-connection-title.ds.js'; import { UpdateMasterPasswordDs } from '../application/data-structures/update-master-password.ds.js'; import { ValidateConnectionMasterPasswordDs } from '../application/data-structures/validate-connection-master-password.ds.js'; +import { ConnectionDiagramResponseDTO } from '../application/dto/connection-diagram-response.dto.js'; import { CreatedConnectionDTO } from '../application/dto/created-connection.dto.js'; import { FoundUserGroupsInConnectionDTO } from '../application/dto/found-user-groups-in-connection.dto.js'; import { ValidationResultRo } from '../application/dto/validation-result.ro.js'; @@ -106,3 +108,7 @@ export interface IUnfreezeConnection { export interface IUpdateConnectionTitle { execute(inputData: UpdateConnectionTitleDs, inTransaction: InTransactionEnum): Promise; } + +export interface IGetConnectionDiagram { + execute(inputData: GetConnectionDiagramDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/entities/connection/utils/build-mermaid-er-diagram.util.ts b/backend/src/entities/connection/utils/build-mermaid-er-diagram.util.ts new file mode 100644 index 000000000..0a18b517e --- /dev/null +++ b/backend/src/entities/connection/utils/build-mermaid-er-diagram.util.ts @@ -0,0 +1,134 @@ +import { ForeignKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/foreign-key.ds.js'; +import { PrimaryKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/primary-key.ds.js'; +import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; + +export interface MermaidTableInput { + tableName: string; + structure: Array; + primaryColumns: Array; + foreignKeys: Array; +} + +export interface MermaidDiagramResult { + diagram: string; + description: string; +} + +export function buildMermaidErDiagram( + databaseName: string | null, + tables: Array, +): MermaidDiagramResult { + const aliasByTable = new Map(); + const usedAliases = new Set(); + for (const t of tables) { + aliasByTable.set(t.tableName, makeUniqueAlias(t.tableName, usedAliases)); + } + + const lines: Array = ['erDiagram']; + + for (const table of tables) { + const alias = aliasByTable.get(table.tableName)!; + const pkColumnNames = new Set(table.primaryColumns.map((p) => p.column_name)); + const fkColumnNames = new Set(table.foreignKeys.map((fk) => fk.column_name)); + + const aliasDiffersFromOriginal = alias !== table.tableName; + const header = aliasDiffersFromOriginal ? ` ${alias}["${escapeQuotes(table.tableName)}"] {` : ` ${alias} {`; + lines.push(header); + + if (table.structure.length === 0) { + lines.push(' string _empty_ "no columns"'); + } else { + for (const column of table.structure) { + const dataType = sanitizeIdentifier(column.data_type || column.udt_name || 'unknown'); + const colName = sanitizeIdentifier(column.column_name); + const markers: Array = []; + if (pkColumnNames.has(column.column_name)) markers.push('PK'); + if (fkColumnNames.has(column.column_name)) markers.push('FK'); + const comment = buildColumnComment(column); + const tail = [markers.join(','), comment].filter((p) => p && p.length > 0).join(' '); + lines.push(` ${dataType} ${colName}${tail ? ' ' + tail : ''}`); + } + } + lines.push(' }'); + } + + let relationshipCount = 0; + for (const table of tables) { + const sourceAlias = aliasByTable.get(table.tableName)!; + for (const fk of table.foreignKeys) { + const targetAlias = aliasByTable.get(fk.referenced_table_name); + if (!targetAlias) continue; + const label = `"${escapeQuotes(fk.column_name)} -> ${escapeQuotes(fk.referenced_column_name)}"`; + lines.push(` ${sourceAlias} }o--|| ${targetAlias} : ${label}`); + relationshipCount++; + } + } + + const diagram = lines.join('\n'); + const description = buildDescription(databaseName, tables, relationshipCount); + return { diagram, description }; +} + +function buildDescription( + databaseName: string | null, + tables: Array, + relationshipCount: number, +): string { + const dbLabel = databaseName ? `Database "${databaseName}"` : 'Database'; + const tablesPart = `${tables.length} ${pluralize(tables.length, 'table', 'tables')}`; + const relsPart = `${relationshipCount} ${pluralize(relationshipCount, 'foreign key relationship', 'foreign key relationships')}`; + const header = `${dbLabel} contains ${tablesPart} and ${relsPart}.`; + + if (tables.length === 0) { + return header; + } + + const tableSummaries = tables.map((t) => { + const pkNames = t.primaryColumns.map((p) => p.column_name); + const pkPart = pkNames.length > 0 ? `PK: ${pkNames.join(', ')}` : 'no primary key'; + const fkPart = + t.foreignKeys.length > 0 + ? `FKs: ${t.foreignKeys.map((fk) => `${fk.column_name}->${fk.referenced_table_name}.${fk.referenced_column_name}`).join(', ')}` + : 'no foreign keys'; + return `- ${t.tableName} (${t.structure.length} ${pluralize(t.structure.length, 'column', 'columns')}; ${pkPart}; ${fkPart})`; + }); + + return [header, 'Tables:', ...tableSummaries].join('\n'); +} + +function pluralize(n: number, singular: string, plural: string): string { + return n === 1 ? singular : plural; +} + +function buildColumnComment(column: TableStructureDS): string { + const parts: Array = []; + if (column.column_default !== null && column.column_default !== undefined && column.column_default !== '') { + parts.push(`default: ${String(column.column_default)}`); + } + parts.push(column.allow_null ? 'nullable' : 'not null'); + if (column.character_maximum_length) { + parts.push(`max length: ${column.character_maximum_length}`); + } + const text = parts.join('; '); + return text ? `"${escapeQuotes(text)}"` : ''; +} + +function makeUniqueAlias(name: string, used: Set): string { + let base = sanitizeIdentifier(name); + if (base.length === 0 || /^[0-9]/.test(base)) base = `t_${base}`; + let candidate = base; + let suffix = 1; + while (used.has(candidate)) { + candidate = `${base}_${suffix++}`; + } + used.add(candidate); + return candidate; +} + +function sanitizeIdentifier(value: string): string { + return value.replace(/[^A-Za-z0-9_]/g, '_'); +} + +function escapeQuotes(value: string): string { + return value.replace(/"/g, "'"); +} diff --git a/backend/src/entities/connection/utils/is-sql-connection-type.util.ts b/backend/src/entities/connection/utils/is-sql-connection-type.util.ts new file mode 100644 index 000000000..7f5b8082c --- /dev/null +++ b/backend/src/entities/connection/utils/is-sql-connection-type.util.ts @@ -0,0 +1,21 @@ +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; + +const SQL_CONNECTION_TYPES: ReadonlySet = new Set([ + ConnectionTypesEnum.postgres, + ConnectionTypesEnum.mysql, + ConnectionTypesEnum.mysql2, + ConnectionTypesEnum.oracledb, + ConnectionTypesEnum.mssql, + ConnectionTypesEnum.ibmdb2, + ConnectionTypesEnum.clickhouse, + ConnectionTypesEnum.agent_postgres, + ConnectionTypesEnum.agent_mysql, + ConnectionTypesEnum.agent_oracledb, + ConnectionTypesEnum.agent_mssql, + ConnectionTypesEnum.agent_ibmdb2, + ConnectionTypesEnum.agent_clickhouse, +]); + +export function isSqlConnectionType(type: ConnectionTypesEnum | string): boolean { + return SQL_CONNECTION_TYPES.has(type); +} diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index c28400c9e..3b98a7cf8 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -87,6 +87,8 @@ export const Messages = { 'Connection master password is not set (or connection created before this feature)', CONNECTION_TEST_FAILED: 'Connection test failed. ', CONNECTION_TYPE_INVALID: `Unsupported database type. Now we supports ${enumToString(ConnectionTypesEnum)}`, + DIAGRAM_NOT_SUPPORTED_FOR_CONNECTION_TYPE: + 'Database structure diagram is currently supported only for SQL connections', 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`, @@ -99,7 +101,8 @@ export const Messages = { CUSTOM_FIELD_TYPE_INCORRECT: 'Unsupported custom field type', CUSTOM_FIELD_TYPE_MISSING: 'Custom field type is missing', CEDAR_POLICY_REFERENCES_FOREIGN_GROUP: 'Cedar policy references a group that does not belong to this connection', - CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION: 'Cedar policy references a connection that does not match the target connection', + CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION: + 'Cedar policy references a connection that does not match the target connection', CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL: 'Cedar policy principal must reference the target group', CSV_EXPORT_FAILED: 'CSV export failed', CSV_EXPORT_DISABLED: 'CSV export is disabled', diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-connection-diagram-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-connection-diagram-e2e.test.ts new file mode 100644 index 000000000..49666e05a --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-connection-diagram-e2e.test.ts @@ -0,0 +1,158 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.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 { getRandomTestTableName } from '../../utils/get-random-test-table-name.js'; +import { getTestKnex } from '../../utils/get-test-knex.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let _testUtils: TestUtils; + +let parentTableName: string; +let childTableName: string; + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + _testUtils = moduleFixture.get(TestUtils); + + app = moduleFixture.createNestApplication() as any; + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); + + // Seed two related SQL tables in the postgres test DB once. + const postgresConnection = mockFactory.generateConnectionToTestPostgresDBInDocker(); + parentTableName = getRandomTestTableName(); + childTableName = getRandomTestTableName(); + const knex = getTestKnex(postgresConnection); + await knex.schema.dropTableIfExists(childTableName); + await knex.schema.dropTableIfExists(parentTableName); + await knex.schema.createTable(parentTableName, (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + }); + await knex.schema.createTable(childTableName, (table) => { + table.increments('id').primary(); + table.string('label', 100); + table.integer('parent_id').references('id').inTable(parentTableName); + }); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +test.serial( + 'GET /connection/diagram/:connectionId > returns Mermaid erDiagram and description for SQL connection', + async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionDto = mockFactory.generateConnectionToTestPostgresDBInDocker(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionDto) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const created = JSON.parse(createConnectionResponse.text); + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${created.id}`) + .set('Cookie', token) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 200); + const body = diagramResponse.body; + + t.is(body.connectionId, created.id); + t.is(body.databaseType, 'postgres'); + t.is(typeof body.diagram, 'string'); + t.is(typeof body.description, 'string'); + t.is(typeof body.generatedAt, 'string'); + t.notThrows(() => new Date(body.generatedAt)); + + t.true(body.diagram.startsWith('erDiagram')); + t.true(body.diagram.includes(parentTableName), 'diagram should mention parent table'); + t.true(body.diagram.includes(childTableName), 'diagram should mention child table'); + t.true(body.diagram.includes('PK'), 'diagram should mark primary key columns'); + t.true(body.diagram.includes('FK'), 'diagram should mark foreign key columns'); + t.true(body.diagram.includes('}o--||'), 'diagram should contain at least one many-to-one relationship arrow'); + + t.true(body.description.includes(parentTableName)); + t.true(body.description.includes(childTableName)); + t.regex(body.description, /\d+ tables?/); + t.regex(body.description, /\d+ foreign key relationships?/); + }, +); + +test.serial('GET /connection/diagram/:connectionId > rejects non-SQL connection types with 400', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const mongoDto = mockFactory.generateConnectionToTestMongoDBInDocker(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(mongoDto) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const created = JSON.parse(createConnectionResponse.text); + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${created.id}`) + .set('Cookie', token) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 400); + t.is(diagramResponse.body.message, Messages.DIAGRAM_NOT_SUPPORTED_FOR_CONNECTION_TYPE); +}); + +test.serial('GET /connection/diagram/:connectionId > rejects unauthenticated requests', async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const connectionDto = mockFactory.generateConnectionToTestPostgresDBInDocker(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionDto) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const created = JSON.parse(createConnectionResponse.text); + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${created.id}`) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 401); +});