-
-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add endpoint to retrieve Mermaid diagram of SQL connection database structure #1750
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| 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); | ||
| } | ||
|
|
||
| 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
|
||
|
|
||
| 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
|
||
| } | ||
| } | ||
| 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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
findAndDecryptConnection()can throwMessages.MASTER_PASSWORD_MISSING/Messages.MASTER_PASSWORD_INCORRECT(seecustom-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 anHttpExceptionwithHttpStatus.BAD_REQUEST(and the sametypevalues used elsewhere, e.g.no_master_key/invalid_master_key).