Skip to content

Commit 03ab135

Browse files
authored
Merge pull request #1750 from rocket-admin/backend_table_db_structure
feat: add endpoint to retrieve Mermaid diagram of SQL connection database structure
2 parents 5110e9b + f65c74b commit 03ab135

11 files changed

Lines changed: 474 additions & 1 deletion

File tree

backend/src/common/data-injection.tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export enum UseCaseType {
4444
VALIDATE_CONNECTION_MASTER_PASSWORD = 'VALIDATE_CONNECTION_MASTER_PASSWORD',
4545
UNFREEZE_CONNECTION = 'UNFREEZE_CONNECTION',
4646
UPDATE_CONNECTION_TITLE = 'UPDATE_CONNECTION_TITLE',
47+
GET_CONNECTION_DIAGRAM = 'GET_CONNECTION_DIAGRAM',
4748

4849
FIND_ALL_USER_GROUPS = 'FIND_ALL_USER_GROUPS',
4950
INVITE_USER_IN_GROUP = 'INVITE_USER_IN_GROUP',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class GetConnectionDiagramDs {
2+
connectionId: string;
3+
masterPwd: string;
4+
userId: string;
5+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
3+
4+
export class ConnectionDiagramResponseDTO {
5+
@ApiProperty()
6+
connectionId: string;
7+
8+
@ApiProperty({ enum: ConnectionTypesEnum })
9+
databaseType: ConnectionTypesEnum;
10+
11+
@ApiProperty({ description: 'Mermaid erDiagram source string' })
12+
diagram: string;
13+
14+
@ApiProperty({ description: 'Human-readable description of the database structure' })
15+
description: string;
16+
17+
@ApiProperty()
18+
generatedAt: string;
19+
}

backend/src/entities/connection/connection.controller.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ import { FindOneConnectionDs } from './application/data-structures/find-one-conn
3737
import { FoundConnectionsDs } from './application/data-structures/found-connections.ds.js';
3838
import { FoundOneConnectionDs } from './application/data-structures/found-one-connection.ds.js';
3939
import { FoundPermissionsInConnectionDs } from './application/data-structures/found-permissions-in-connection.ds.js';
40+
import { GetConnectionDiagramDs } from './application/data-structures/get-connection-diagram.ds.js';
4041
import { GetGroupsInConnectionDs } from './application/data-structures/get-groups-in-connection.ds.js';
4142
import { GetPermissionsInConnectionDs } from './application/data-structures/get-permissions-in-connection.ds.js';
4243
import { RestoredConnectionDs } from './application/data-structures/restored-connection.ds.js';
4344
import { UpdateConnectionDs } from './application/data-structures/update-connection.ds.js';
4445
import { UpdateConnectionTitleDs } from './application/data-structures/update-connection-title.ds.js';
4546
import { UpdateMasterPasswordDs } from './application/data-structures/update-master-password.ds.js';
4647
import { ValidateConnectionMasterPasswordDs } from './application/data-structures/validate-connection-master-password.ds.js';
48+
import { ConnectionDiagramResponseDTO } from './application/dto/connection-diagram-response.dto.js';
4749
import { CreateConnectionDto } from './application/dto/create-connection.dto.js';
4850
import { CreateGroupInConnectionDTO } from './application/dto/create-group-in-connection.dto.js';
4951
import { CreatedConnectionDTO } from './application/dto/created-connection.dto.js';
@@ -64,6 +66,7 @@ import {
6466
IFindConnections,
6567
IFindOneConnection,
6668
IFindUsersInConnection,
69+
IGetConnectionDiagram,
6770
IGetPermissionsForGroupInConnection,
6871
IGetUserGroupsInConnection,
6972
IRefreshConnectionAgentToken,
@@ -125,6 +128,8 @@ export class ConnectionController {
125128
private readonly unfreezeConnectionUseCase: IUnfreezeConnection,
126129
@Inject(UseCaseType.UPDATE_CONNECTION_TITLE)
127130
private readonly updateConnectionTitleUseCase: IUpdateConnectionTitle,
131+
@Inject(UseCaseType.GET_CONNECTION_DIAGRAM)
132+
private readonly getConnectionDiagramUseCase: IGetConnectionDiagram,
128133
@Inject(BaseType.GLOBAL_DB_CONTEXT)
129134
protected _dbContext: IGlobalDatabaseContext,
130135
private readonly amplitudeService: AmplitudeService,
@@ -715,4 +720,27 @@ export class ConnectionController {
715720
};
716721
return await this.updateConnectionTitleUseCase.execute(inputData, InTransactionEnum.ON);
717722
}
723+
724+
@ApiOperation({ summary: 'Get Mermaid diagram of connection database structure (SQL only)' })
725+
@ApiResponse({
726+
status: 200,
727+
type: ConnectionDiagramResponseDTO,
728+
})
729+
@UseGuards(ConnectionReadGuard)
730+
@Get('/connection/diagram/:connectionId')
731+
async getConnectionDiagram(
732+
@SlugUuid('connectionId') connectionId: string,
733+
@MasterPassword() masterPwd: string,
734+
@UserId() userId: string,
735+
): Promise<ConnectionDiagramResponseDTO> {
736+
if (!connectionId) {
737+
throw new BadRequestException(Messages.CONNECTION_ID_MISSING);
738+
}
739+
const inputData: GetConnectionDiagramDs = {
740+
connectionId,
741+
masterPwd,
742+
userId,
743+
};
744+
return await this.getConnectionDiagramUseCase.execute(inputData, InTransactionEnum.OFF);
745+
}
718746
}

backend/src/entities/connection/connection.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { DeleteGroupFromConnectionUseCase } from './use-cases/delete-group-from-
2424
import { FindAllConnectionsUseCase } from './use-cases/find-all-connections.use.case.js';
2525
import { FindAllUsersInConnectionUseCase } from './use-cases/find-all-users-in-connection.use.case.js';
2626
import { FindOneConnectionUseCase } from './use-cases/find-one-connection.use.case.js';
27+
import { GetConnectionDiagramUseCase } from './use-cases/get-connection-diagram.use.case.js';
2728
import { GetPermissionsForGroupInConnectionUseCase } from './use-cases/get-permissions-for-group-in-connection.use.case.js';
2829
import { GetUserGroupsInConnectionUseCase } from './use-cases/get-user-groups-in-connection.use.case.js';
2930
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-
135136
provide: UseCaseType.UPDATE_CONNECTION_TITLE,
136137
useClass: UpdateConnectionTitleUseCase,
137138
},
139+
{
140+
provide: UseCaseType.GET_CONNECTION_DIAGRAM,
141+
useClass: GetConnectionDiagramUseCase,
142+
},
138143
],
139144
controllers: [ConnectionController],
140145
})
@@ -162,6 +167,7 @@ export class ConnectionModule implements NestModule {
162167
{ path: '/connection/masterpwd/verify/:connectionId', method: RequestMethod.GET },
163168
{ path: '/connection/unfreeze/:connectionId', method: RequestMethod.PUT },
164169
{ path: '/connection/title/:connectionId', method: RequestMethod.PUT },
170+
{ path: '/connection/diagram/:connectionId', method: RequestMethod.GET },
165171
)
166172
.apply(AuthWithApiMiddleware)
167173
.forRoutes({ path: 'connections', method: RequestMethod.GET });
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { BadRequestException, HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common';
2+
import { validateSchemaCache } from '@rocketadmin/shared-code/dist/src/caching/schema-cache-validator.js';
3+
import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js';
4+
import { ForeignKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/foreign-key.ds.js';
5+
import { PrimaryKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/primary-key.ds.js';
6+
import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js';
7+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
8+
import AbstractUseCase from '../../../common/abstract-use.case.js';
9+
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
10+
import { BaseType } from '../../../common/data-injection.tokens.js';
11+
import { ExceptionOperations } from '../../../exceptions/custom-exceptions/exception-operation.js';
12+
import { UnknownSQLException } from '../../../exceptions/custom-exceptions/unknown-sql-exception.js';
13+
import { Messages } from '../../../exceptions/text/messages.js';
14+
import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js';
15+
import { GetConnectionDiagramDs } from '../application/data-structures/get-connection-diagram.ds.js';
16+
import { ConnectionDiagramResponseDTO } from '../application/dto/connection-diagram-response.dto.js';
17+
import { buildMermaidErDiagram, MermaidTableInput } from '../utils/build-mermaid-er-diagram.util.js';
18+
import { isSqlConnectionType } from '../utils/is-sql-connection-type.util.js';
19+
import { IGetConnectionDiagram } from './use-cases.interfaces.js';
20+
21+
@Injectable({ scope: Scope.REQUEST })
22+
export class GetConnectionDiagramUseCase
23+
extends AbstractUseCase<GetConnectionDiagramDs, ConnectionDiagramResponseDTO>
24+
implements IGetConnectionDiagram
25+
{
26+
constructor(
27+
@Inject(BaseType.GLOBAL_DB_CONTEXT)
28+
protected _dbContext: IGlobalDatabaseContext,
29+
) {
30+
super();
31+
}
32+
33+
protected async implementation(inputData: GetConnectionDiagramDs): Promise<ConnectionDiagramResponseDTO> {
34+
const { connectionId, masterPwd, userId } = inputData;
35+
const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd);
36+
if (!connection) {
37+
throw new HttpException({ message: Messages.CONNECTION_NOT_FOUND }, HttpStatus.BAD_REQUEST);
38+
}
39+
if (!isSqlConnectionType(connection.type)) {
40+
throw new BadRequestException(Messages.DIAGRAM_NOT_SUPPORTED_FOR_CONNECTION_TYPE);
41+
}
42+
43+
const dao = getDataAccessObject(connection);
44+
const userEmail = isConnectionTypeAgent(connection.type)
45+
? await this._dbContext.userRepository.getUserEmailOrReturnNull(userId)
46+
: undefined;
47+
48+
await validateSchemaCache(dao, userEmail);
49+
50+
let tables: Array<{ tableName: string; isView: boolean }>;
51+
try {
52+
tables = await dao.getTablesFromDB(userEmail);
53+
} catch (e) {
54+
throw new UnknownSQLException(e.message, ExceptionOperations.FAILED_TO_GET_TABLES);
55+
}
56+
57+
const realTables = tables.filter((t) => !t.isView);
58+
const tableInputs: Array<MermaidTableInput> = await Promise.all(
59+
realTables.map((t) => this.collectTableInfo(dao, t.tableName, userEmail)),
60+
);
61+
62+
const { diagram, description } = buildMermaidErDiagram(connection.database || null, tableInputs);
63+
return {
64+
connectionId,
65+
databaseType: connection.type as ConnectionTypesEnum,
66+
diagram,
67+
description,
68+
generatedAt: new Date().toISOString(),
69+
};
70+
}
71+
72+
private async collectTableInfo(
73+
dao: ReturnType<typeof getDataAccessObject>,
74+
tableName: string,
75+
userEmail: string | undefined,
76+
): Promise<MermaidTableInput> {
77+
const [structure, primaryColumns, foreignKeys] = await Promise.all([
78+
this.safe<Array<TableStructureDS>>(() => dao.getTableStructure(tableName, userEmail), []),
79+
this.safe<Array<PrimaryKeyDS>>(() => dao.getTablePrimaryColumns(tableName, userEmail), []),
80+
this.safe<Array<ForeignKeyDS>>(() => dao.getTableForeignKeys(tableName, userEmail), []),
81+
]);
82+
return { tableName, structure, primaryColumns, foreignKeys };
83+
}
84+
85+
private async safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
86+
try {
87+
return await fn();
88+
} catch {
89+
return fallback;
90+
}
91+
}
92+
}

backend/src/entities/connection/use-cases/use-cases.interfaces.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FindOneConnectionDs } from '../application/data-structures/find-one-con
1111
import { FoundConnectionsDs } from '../application/data-structures/found-connections.ds.js';
1212
import { FoundOneConnectionDs } from '../application/data-structures/found-one-connection.ds.js';
1313
import { FoundPermissionsInConnectionDs } from '../application/data-structures/found-permissions-in-connection.ds.js';
14+
import { GetConnectionDiagramDs } from '../application/data-structures/get-connection-diagram.ds.js';
1415
import { GetGroupsInConnectionDs } from '../application/data-structures/get-groups-in-connection.ds.js';
1516
import { GetPermissionsInConnectionDs } from '../application/data-structures/get-permissions-in-connection.ds.js';
1617
import { RestoredConnectionDs } from '../application/data-structures/restored-connection.ds.js';
@@ -21,6 +22,7 @@ import { UpdateConnectionDs } from '../application/data-structures/update-connec
2122
import { UpdateConnectionTitleDs } from '../application/data-structures/update-connection-title.ds.js';
2223
import { UpdateMasterPasswordDs } from '../application/data-structures/update-master-password.ds.js';
2324
import { ValidateConnectionMasterPasswordDs } from '../application/data-structures/validate-connection-master-password.ds.js';
25+
import { ConnectionDiagramResponseDTO } from '../application/dto/connection-diagram-response.dto.js';
2426
import { CreatedConnectionDTO } from '../application/dto/created-connection.dto.js';
2527
import { FoundUserGroupsInConnectionDTO } from '../application/dto/found-user-groups-in-connection.dto.js';
2628
import { ValidationResultRo } from '../application/dto/validation-result.ro.js';
@@ -106,3 +108,7 @@ export interface IUnfreezeConnection {
106108
export interface IUpdateConnectionTitle {
107109
execute(inputData: UpdateConnectionTitleDs, inTransaction: InTransactionEnum): Promise<SuccessResponse>;
108110
}
111+
112+
export interface IGetConnectionDiagram {
113+
execute(inputData: GetConnectionDiagramDs, inTransaction: InTransactionEnum): Promise<ConnectionDiagramResponseDTO>;
114+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { ForeignKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/foreign-key.ds.js';
2+
import { PrimaryKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/primary-key.ds.js';
3+
import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js';
4+
5+
export interface MermaidTableInput {
6+
tableName: string;
7+
structure: Array<TableStructureDS>;
8+
primaryColumns: Array<PrimaryKeyDS>;
9+
foreignKeys: Array<ForeignKeyDS>;
10+
}
11+
12+
export interface MermaidDiagramResult {
13+
diagram: string;
14+
description: string;
15+
}
16+
17+
export function buildMermaidErDiagram(
18+
databaseName: string | null,
19+
tables: Array<MermaidTableInput>,
20+
): MermaidDiagramResult {
21+
const aliasByTable = new Map<string, string>();
22+
const usedAliases = new Set<string>();
23+
for (const t of tables) {
24+
aliasByTable.set(t.tableName, makeUniqueAlias(t.tableName, usedAliases));
25+
}
26+
27+
const lines: Array<string> = ['erDiagram'];
28+
29+
for (const table of tables) {
30+
const alias = aliasByTable.get(table.tableName)!;
31+
const pkColumnNames = new Set(table.primaryColumns.map((p) => p.column_name));
32+
const fkColumnNames = new Set(table.foreignKeys.map((fk) => fk.column_name));
33+
34+
const aliasDiffersFromOriginal = alias !== table.tableName;
35+
const header = aliasDiffersFromOriginal ? ` ${alias}["${escapeQuotes(table.tableName)}"] {` : ` ${alias} {`;
36+
lines.push(header);
37+
38+
if (table.structure.length === 0) {
39+
lines.push(' string _empty_ "no columns"');
40+
} else {
41+
for (const column of table.structure) {
42+
const dataType = sanitizeIdentifier(column.data_type || column.udt_name || 'unknown');
43+
const colName = sanitizeIdentifier(column.column_name);
44+
const markers: Array<string> = [];
45+
if (pkColumnNames.has(column.column_name)) markers.push('PK');
46+
if (fkColumnNames.has(column.column_name)) markers.push('FK');
47+
const comment = buildColumnComment(column);
48+
const tail = [markers.join(','), comment].filter((p) => p && p.length > 0).join(' ');
49+
lines.push(` ${dataType} ${colName}${tail ? ' ' + tail : ''}`);
50+
}
51+
}
52+
lines.push(' }');
53+
}
54+
55+
let relationshipCount = 0;
56+
for (const table of tables) {
57+
const sourceAlias = aliasByTable.get(table.tableName)!;
58+
for (const fk of table.foreignKeys) {
59+
const targetAlias = aliasByTable.get(fk.referenced_table_name);
60+
if (!targetAlias) continue;
61+
const label = `"${escapeQuotes(fk.column_name)} -> ${escapeQuotes(fk.referenced_column_name)}"`;
62+
lines.push(` ${sourceAlias} }o--|| ${targetAlias} : ${label}`);
63+
relationshipCount++;
64+
}
65+
}
66+
67+
const diagram = lines.join('\n');
68+
const description = buildDescription(databaseName, tables, relationshipCount);
69+
return { diagram, description };
70+
}
71+
72+
function buildDescription(
73+
databaseName: string | null,
74+
tables: Array<MermaidTableInput>,
75+
relationshipCount: number,
76+
): string {
77+
const dbLabel = databaseName ? `Database "${databaseName}"` : 'Database';
78+
const tablesPart = `${tables.length} ${pluralize(tables.length, 'table', 'tables')}`;
79+
const relsPart = `${relationshipCount} ${pluralize(relationshipCount, 'foreign key relationship', 'foreign key relationships')}`;
80+
const header = `${dbLabel} contains ${tablesPart} and ${relsPart}.`;
81+
82+
if (tables.length === 0) {
83+
return header;
84+
}
85+
86+
const tableSummaries = tables.map((t) => {
87+
const pkNames = t.primaryColumns.map((p) => p.column_name);
88+
const pkPart = pkNames.length > 0 ? `PK: ${pkNames.join(', ')}` : 'no primary key';
89+
const fkPart =
90+
t.foreignKeys.length > 0
91+
? `FKs: ${t.foreignKeys.map((fk) => `${fk.column_name}->${fk.referenced_table_name}.${fk.referenced_column_name}`).join(', ')}`
92+
: 'no foreign keys';
93+
return `- ${t.tableName} (${t.structure.length} ${pluralize(t.structure.length, 'column', 'columns')}; ${pkPart}; ${fkPart})`;
94+
});
95+
96+
return [header, 'Tables:', ...tableSummaries].join('\n');
97+
}
98+
99+
function pluralize(n: number, singular: string, plural: string): string {
100+
return n === 1 ? singular : plural;
101+
}
102+
103+
function buildColumnComment(column: TableStructureDS): string {
104+
const parts: Array<string> = [];
105+
if (column.column_default !== null && column.column_default !== undefined && column.column_default !== '') {
106+
parts.push(`default: ${String(column.column_default)}`);
107+
}
108+
parts.push(column.allow_null ? 'nullable' : 'not null');
109+
if (column.character_maximum_length) {
110+
parts.push(`max length: ${column.character_maximum_length}`);
111+
}
112+
const text = parts.join('; ');
113+
return text ? `"${escapeQuotes(text)}"` : '';
114+
}
115+
116+
function makeUniqueAlias(name: string, used: Set<string>): string {
117+
let base = sanitizeIdentifier(name);
118+
if (base.length === 0 || /^[0-9]/.test(base)) base = `t_${base}`;
119+
let candidate = base;
120+
let suffix = 1;
121+
while (used.has(candidate)) {
122+
candidate = `${base}_${suffix++}`;
123+
}
124+
used.add(candidate);
125+
return candidate;
126+
}
127+
128+
function sanitizeIdentifier(value: string): string {
129+
return value.replace(/[^A-Za-z0-9_]/g, '_');
130+
}
131+
132+
function escapeQuotes(value: string): string {
133+
return value.replace(/"/g, "'");
134+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
2+
3+
const SQL_CONNECTION_TYPES: ReadonlySet<string> = new Set<string>([
4+
ConnectionTypesEnum.postgres,
5+
ConnectionTypesEnum.mysql,
6+
ConnectionTypesEnum.mysql2,
7+
ConnectionTypesEnum.oracledb,
8+
ConnectionTypesEnum.mssql,
9+
ConnectionTypesEnum.ibmdb2,
10+
ConnectionTypesEnum.clickhouse,
11+
ConnectionTypesEnum.agent_postgres,
12+
ConnectionTypesEnum.agent_mysql,
13+
ConnectionTypesEnum.agent_oracledb,
14+
ConnectionTypesEnum.agent_mssql,
15+
ConnectionTypesEnum.agent_ibmdb2,
16+
ConnectionTypesEnum.agent_clickhouse,
17+
]);
18+
19+
export function isSqlConnectionType(type: ConnectionTypesEnum | string): boolean {
20+
return SQL_CONNECTION_TYPES.has(type);
21+
}

0 commit comments

Comments
 (0)