Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class GetConnectionDiagramDs {
connectionId: string;
masterPwd: string;
userId: string;
}
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions backend/src/entities/connection/connection.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ 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';
import { UpdateConnectionDs } from './application/data-structures/update-connection.ds.js';
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';
Expand All @@ -64,6 +66,7 @@ import {
IFindConnections,
IFindOneConnection,
IFindUsersInConnection,
IGetConnectionDiagram,
IGetPermissionsForGroupInConnection,
IGetUserGroupsInConnection,
IRefreshConnectionAgentToken,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ConnectionDiagramResponseDTO> {
if (!connectionId) {
throw new BadRequestException(Messages.CONNECTION_ID_MISSING);
}
const inputData: GetConnectionDiagramDs = {
connectionId,
masterPwd,
userId,
};
return await this.getConnectionDiagramUseCase.execute(inputData, InTransactionEnum.OFF);
}
}
6 changes: 6 additions & 0 deletions backend/src/entities/connection/connection.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
})
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GetConnectionDiagramDs, ConnectionDiagramResponseDTO>
implements IGetConnectionDiagram
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
) {
super();
}

protected async implementation(inputData: GetConnectionDiagramDs): Promise<ConnectionDiagramResponseDTO> {
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);
}
Comment on lines +35 to +41
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findAndDecryptConnection() can throw Messages.MASTER_PASSWORD_MISSING / Messages.MASTER_PASSWORD_INCORRECT (see custom-connection-repository-extension.ts), but this use case doesn't catch/translate those errors. That will surface as a 500 for a client-side error when the master password header is missing/incorrect. Catch these specific errors and rethrow an HttpException with HttpStatus.BAD_REQUEST (and the same type values used elsewhere, e.g. no_master_key / invalid_master_key).

Copilot uses AI. Check for mistakes.

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<MermaidTableInput> = await Promise.all(
realTables.map((t) => this.collectTableInfo(dao, t.tableName, userEmail)),
);
Comment on lines +57 to +60
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.all(realTables.map(...)) will kick off 3 queries per table concurrently (structure/PK/FK). On connections with many tables this can overwhelm the DB / exceed pool limits and cause avoidable timeouts. Consider adding a concurrency limit / batching (e.g. process tables in chunks) so the endpoint remains reliable for larger schemas.

Copilot uses AI. Check for mistakes.

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<typeof getDataAccessObject>,
tableName: string,
userEmail: string | undefined,
): Promise<MermaidTableInput> {
const [structure, primaryColumns, foreignKeys] = await Promise.all([
this.safe<Array<TableStructureDS>>(() => dao.getTableStructure(tableName, userEmail), []),
this.safe<Array<PrimaryKeyDS>>(() => dao.getTablePrimaryColumns(tableName, userEmail), []),
this.safe<Array<ForeignKeyDS>>(() => dao.getTableForeignKeys(tableName, userEmail), []),
]);
return { tableName, structure, primaryColumns, foreignKeys };
}

private async safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
try {
return await fn();
} catch {
return fallback;
}
Comment on lines +85 to +90
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safe() helper swallows all errors and returns a fallback without logging or surfacing partial-failure information. This can lead to silently incomplete diagrams and makes production failures hard to diagnose. At minimum, log (or capture to Sentry) the table name + operation when a DAO call fails, or propagate an error so the client can see the failure reason.

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -106,3 +108,7 @@ export interface IUnfreezeConnection {
export interface IUpdateConnectionTitle {
execute(inputData: UpdateConnectionTitleDs, inTransaction: InTransactionEnum): Promise<SuccessResponse>;
}

export interface IGetConnectionDiagram {
execute(inputData: GetConnectionDiagramDs, inTransaction: InTransactionEnum): Promise<ConnectionDiagramResponseDTO>;
}
134 changes: 134 additions & 0 deletions backend/src/entities/connection/utils/build-mermaid-er-diagram.util.ts
Original file line number Diff line number Diff line change
@@ -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<TableStructureDS>;
primaryColumns: Array<PrimaryKeyDS>;
foreignKeys: Array<ForeignKeyDS>;
}

export interface MermaidDiagramResult {
diagram: string;
description: string;
}

export function buildMermaidErDiagram(
databaseName: string | null,
tables: Array<MermaidTableInput>,
): MermaidDiagramResult {
const aliasByTable = new Map<string, string>();
const usedAliases = new Set<string>();
for (const t of tables) {
aliasByTable.set(t.tableName, makeUniqueAlias(t.tableName, usedAliases));
}

const lines: Array<string> = ['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<string> = [];
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<MermaidTableInput>,
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<string> = [];
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>): 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, "'");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';

const SQL_CONNECTION_TYPES: ReadonlySet<string> = new Set<string>([
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);
}
Loading
Loading