Skip to content

Commit fe0b43a

Browse files
authored
Merge pull request #1786 from rocket-admin/bakcned_mermain_diagram_fixes
feat: implement applyProposedDdl utility for handling DDL statements …
2 parents 9d2bdc8 + 14a4893 commit fe0b43a

12 files changed

Lines changed: 1317 additions & 4 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export enum UseCaseType {
4545
UNFREEZE_CONNECTION = 'UNFREEZE_CONNECTION',
4646
UPDATE_CONNECTION_TITLE = 'UPDATE_CONNECTION_TITLE',
4747
GET_CONNECTION_DIAGRAM = 'GET_CONNECTION_DIAGRAM',
48+
PREVIEW_CONNECTION_DIAGRAM = 'PREVIEW_CONNECTION_DIAGRAM',
4849

4950
FIND_ALL_USER_GROUPS = 'FIND_ALL_USER_GROUPS',
5051
INVITE_USER_IN_GROUP = 'INVITE_USER_IN_GROUP',
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class PreviewConnectionDiagramDs {
2+
connectionId: string;
3+
masterPwd: string;
4+
userId: string;
5+
sqlCommands: Array<string>;
6+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, MaxLength } from 'class-validator';
3+
4+
export class ConnectionDiagramPreviewRequestDTO {
5+
@ApiProperty({
6+
type: [String],
7+
description:
8+
'Array of SQL DDL statements (CREATE TABLE, ALTER TABLE, DROP TABLE) to apply to the diagram preview. Statements are parsed and applied to an in-memory copy of the schema; nothing is executed against the real database.',
9+
example: ['ALTER TABLE users ADD COLUMN age INTEGER'],
10+
})
11+
@IsArray()
12+
@ArrayMinSize(1)
13+
@ArrayMaxSize(100)
14+
@IsString({ each: true })
15+
@MaxLength(20000, { each: true })
16+
sqlCommands: Array<string>;
17+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 ConnectionDiagramPreviewStatementResultDTO {
5+
@ApiProperty()
6+
sql: string;
7+
8+
@ApiProperty({ enum: ['applied', 'skipped', 'error'] })
9+
status: 'applied' | 'skipped' | 'error';
10+
11+
@ApiProperty({ required: false, nullable: true })
12+
message?: string;
13+
}
14+
15+
export class ConnectionDiagramPreviewDiffDTO {
16+
@ApiProperty({ type: [String] })
17+
addedTables: Array<string>;
18+
19+
@ApiProperty({ type: [String] })
20+
droppedTables: Array<string>;
21+
22+
@ApiProperty({
23+
description: 'Map of table name -> array of column names that would be added',
24+
type: 'object',
25+
additionalProperties: { type: 'array', items: { type: 'string' } },
26+
})
27+
addedColumns: Record<string, Array<string>>;
28+
29+
@ApiProperty({
30+
description: 'Map of table name -> array of column names that would be removed',
31+
type: 'object',
32+
additionalProperties: { type: 'array', items: { type: 'string' } },
33+
})
34+
droppedColumns: Record<string, Array<string>>;
35+
36+
@ApiProperty({
37+
description: 'Map of table name -> array of "column->refTable.refColumn" descriptors for new foreign keys',
38+
type: 'object',
39+
additionalProperties: { type: 'array', items: { type: 'string' } },
40+
})
41+
addedForeignKeys: Record<string, Array<string>>;
42+
43+
@ApiProperty({ type: [ConnectionDiagramPreviewStatementResultDTO] })
44+
statementResults: Array<ConnectionDiagramPreviewStatementResultDTO>;
45+
}
46+
47+
export class ConnectionDiagramPreviewResponseDTO {
48+
@ApiProperty()
49+
connectionId: string;
50+
51+
@ApiProperty({ enum: ConnectionTypesEnum })
52+
databaseType: ConnectionTypesEnum;
53+
54+
@ApiProperty({
55+
description:
56+
'Mermaid erDiagram source string representing the schema AFTER applying the provided SQL statements. Added entities are styled green via a classDef directive; new columns are marked with a "NEW" attribute key; new foreign keys are marked with "[NEW]" in the relationship label.',
57+
})
58+
diagram: string;
59+
60+
@ApiProperty({ description: 'Human-readable description of the projected database structure (post-changes).' })
61+
description: string;
62+
63+
@ApiProperty({ type: ConnectionDiagramPreviewDiffDTO })
64+
diff: ConnectionDiagramPreviewDiffDTO;
65+
66+
@ApiProperty()
67+
generatedAt: string;
68+
}

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,14 @@ import { FoundPermissionsInConnectionDs } from './application/data-structures/fo
5050
import { GetConnectionDiagramDs } from './application/data-structures/get-connection-diagram.ds.js';
5151
import { GetGroupsInConnectionDs } from './application/data-structures/get-groups-in-connection.ds.js';
5252
import { GetPermissionsInConnectionDs } from './application/data-structures/get-permissions-in-connection.ds.js';
53+
import { PreviewConnectionDiagramDs } from './application/data-structures/preview-connection-diagram.ds.js';
5354
import { RestoredConnectionDs } from './application/data-structures/restored-connection.ds.js';
5455
import { UpdateConnectionDs } from './application/data-structures/update-connection.ds.js';
5556
import { UpdateConnectionTitleDs } from './application/data-structures/update-connection-title.ds.js';
5657
import { UpdateMasterPasswordDs } from './application/data-structures/update-master-password.ds.js';
5758
import { ValidateConnectionMasterPasswordDs } from './application/data-structures/validate-connection-master-password.ds.js';
59+
import { ConnectionDiagramPreviewRequestDTO } from './application/dto/connection-diagram-preview-request.dto.js';
60+
import { ConnectionDiagramPreviewResponseDTO } from './application/dto/connection-diagram-preview-response.dto.js';
5861
import { ConnectionDiagramResponseDTO } from './application/dto/connection-diagram-response.dto.js';
5962
import { CreateConnectionDto } from './application/dto/create-connection.dto.js';
6063
import { CreateGroupInConnectionDTO } from './application/dto/create-group-in-connection.dto.js';
@@ -79,6 +82,7 @@ import {
7982
IGetConnectionDiagram,
8083
IGetPermissionsForGroupInConnection,
8184
IGetUserGroupsInConnection,
85+
IPreviewConnectionDiagram,
8286
IRefreshConnectionAgentToken,
8387
IRestoreConnection,
8488
ITestConnection,
@@ -140,6 +144,8 @@ export class ConnectionController {
140144
private readonly updateConnectionTitleUseCase: IUpdateConnectionTitle,
141145
@Inject(UseCaseType.GET_CONNECTION_DIAGRAM)
142146
private readonly getConnectionDiagramUseCase: IGetConnectionDiagram,
147+
@Inject(UseCaseType.PREVIEW_CONNECTION_DIAGRAM)
148+
private readonly previewConnectionDiagramUseCase: IPreviewConnectionDiagram,
143149
@Inject(BaseType.GLOBAL_DB_CONTEXT)
144150
protected _dbContext: IGlobalDatabaseContext,
145151
private readonly amplitudeService: AmplitudeService,
@@ -754,4 +760,34 @@ export class ConnectionController {
754760
};
755761
return await this.getConnectionDiagramUseCase.execute(inputData, InTransactionEnum.OFF);
756762
}
763+
764+
@ApiOperation({
765+
summary:
766+
'Preview Mermaid diagram with proposed DDL changes applied (SQL only). Nothing is executed against the real database — statements are parsed and applied to an in-memory copy of the schema.',
767+
})
768+
@ApiBody({ type: ConnectionDiagramPreviewRequestDTO })
769+
@ApiResponse({
770+
status: 200,
771+
type: ConnectionDiagramPreviewResponseDTO,
772+
})
773+
@UseGuards(ConnectionDiagramGuard)
774+
@Timeout(90000)
775+
@Post('/connection/diagram/:connectionId/preview')
776+
async previewConnectionDiagram(
777+
@SlugUuid('connectionId') connectionId: string,
778+
@MasterPassword() masterPwd: string,
779+
@UserId() userId: string,
780+
@Body() body: ConnectionDiagramPreviewRequestDTO,
781+
): Promise<ConnectionDiagramPreviewResponseDTO> {
782+
if (!connectionId) {
783+
throw new BadRequestException(Messages.CONNECTION_ID_MISSING);
784+
}
785+
const inputData: PreviewConnectionDiagramDs = {
786+
connectionId,
787+
masterPwd,
788+
userId,
789+
sqlCommands: body.sqlCommands,
790+
};
791+
return await this.previewConnectionDiagramUseCase.execute(inputData, InTransactionEnum.OFF);
792+
}
757793
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { GetConnectionDiagramUseCase } from './use-cases/get-connection-diagram.
2828
import { GetPermissionsForGroupInConnectionUseCase } from './use-cases/get-permissions-for-group-in-connection.use.case.js';
2929
import { GetUserGroupsInConnectionUseCase } from './use-cases/get-user-groups-in-connection.use.case.js';
3030
import { GetUserPermissionsForGroupInConnectionUseCase } from './use-cases/get-user-permissions-for-group-in-connection.use.case.js';
31+
import { PreviewConnectionDiagramUseCase } from './use-cases/preview-connection-diagram.use.case.js';
3132
import { RefreshConnectionAgentTokenUseCase } from './use-cases/refresh-connection-agent-token.use.case.js';
3233
import { RestoreConnectionUseCase } from './use-cases/restore-connection-use.case.js';
3334
import { TestConnectionUseCase } from './use-cases/test-connection.use.case.js';
@@ -140,6 +141,10 @@ import { ValidateConnectionTokenUseCase } from './use-cases/validate-connection-
140141
provide: UseCaseType.GET_CONNECTION_DIAGRAM,
141142
useClass: GetConnectionDiagramUseCase,
142143
},
144+
{
145+
provide: UseCaseType.PREVIEW_CONNECTION_DIAGRAM,
146+
useClass: PreviewConnectionDiagramUseCase,
147+
},
143148
],
144149
controllers: [ConnectionController],
145150
})
@@ -168,6 +173,7 @@ export class ConnectionModule implements NestModule {
168173
{ path: '/connection/unfreeze/:connectionId', method: RequestMethod.PUT },
169174
{ path: '/connection/title/:connectionId', method: RequestMethod.PUT },
170175
{ path: '/connection/diagram/:connectionId', method: RequestMethod.GET },
176+
{ path: '/connection/diagram/:connectionId/preview', method: RequestMethod.POST },
171177
)
172178
.apply(AuthWithApiMiddleware)
173179
.forRoutes({ path: 'connections', method: RequestMethod.GET });
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 { PreviewConnectionDiagramDs } from '../application/data-structures/preview-connection-diagram.ds.js';
16+
import { ConnectionDiagramPreviewResponseDTO } from '../application/dto/connection-diagram-preview-response.dto.js';
17+
import { applyProposedDdl, SchemaDiff } from '../utils/apply-proposed-ddl.util.js';
18+
import { buildMermaidErDiagram, MermaidTableInput } from '../utils/build-mermaid-er-diagram.util.js';
19+
import { isSqlConnectionType } from '../utils/is-sql-connection-type.util.js';
20+
import { IPreviewConnectionDiagram } from './use-cases.interfaces.js';
21+
22+
@Injectable({ scope: Scope.REQUEST })
23+
export class PreviewConnectionDiagramUseCase
24+
extends AbstractUseCase<PreviewConnectionDiagramDs, ConnectionDiagramPreviewResponseDTO>
25+
implements IPreviewConnectionDiagram
26+
{
27+
constructor(
28+
@Inject(BaseType.GLOBAL_DB_CONTEXT)
29+
protected _dbContext: IGlobalDatabaseContext,
30+
) {
31+
super();
32+
}
33+
34+
protected async implementation(inputData: PreviewConnectionDiagramDs): Promise<ConnectionDiagramPreviewResponseDTO> {
35+
const { connectionId, masterPwd, userId, sqlCommands } = inputData;
36+
const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd);
37+
if (!connection) {
38+
throw new HttpException({ message: Messages.CONNECTION_NOT_FOUND }, HttpStatus.BAD_REQUEST);
39+
}
40+
if (!isSqlConnectionType(connection.type)) {
41+
throw new BadRequestException(Messages.DIAGRAM_NOT_SUPPORTED_FOR_CONNECTION_TYPE);
42+
}
43+
44+
const dao = getDataAccessObject(connection);
45+
const userEmail = isConnectionTypeAgent(connection.type)
46+
? await this._dbContext.userRepository.getUserEmailOrReturnNull(userId)
47+
: undefined;
48+
49+
await validateSchemaCache(dao, userEmail);
50+
dao.invalidateMetadataCache();
51+
52+
let tables: Array<{ tableName: string; isView: boolean }>;
53+
try {
54+
tables = await dao.getTablesFromDB(userEmail);
55+
} catch (e) {
56+
throw new UnknownSQLException(e.message, ExceptionOperations.FAILED_TO_GET_TABLES);
57+
}
58+
59+
const realTables = tables.filter((t) => !t.isView);
60+
const tableInputs: Array<MermaidTableInput> = await Promise.all(
61+
realTables.map((t) => this.collectTableInfo(dao, t.tableName, userEmail)),
62+
);
63+
64+
const { mutatedTables, diff } = applyProposedDdl(tableInputs, sqlCommands, connection.type as ConnectionTypesEnum);
65+
66+
const { diagram, description } = buildMermaidErDiagram(connection.database || null, mutatedTables, {
67+
addedTables: diff.addedTables,
68+
addedColumns: diff.addedColumns,
69+
addedForeignKeys: diff.addedForeignKeys,
70+
});
71+
72+
return {
73+
connectionId,
74+
databaseType: connection.type as ConnectionTypesEnum,
75+
diagram,
76+
description,
77+
diff: serializeDiff(diff),
78+
generatedAt: new Date().toISOString(),
79+
};
80+
}
81+
82+
private async collectTableInfo(
83+
dao: ReturnType<typeof getDataAccessObject>,
84+
tableName: string,
85+
userEmail: string | undefined,
86+
): Promise<MermaidTableInput> {
87+
const [structure, primaryColumns, foreignKeys] = await Promise.all([
88+
this.safe<Array<TableStructureDS>>(() => dao.getTableStructure(tableName, userEmail), []),
89+
this.safe<Array<PrimaryKeyDS>>(() => dao.getTablePrimaryColumns(tableName, userEmail), []),
90+
this.safe<Array<ForeignKeyDS>>(() => dao.getTableForeignKeys(tableName, userEmail), []),
91+
]);
92+
return { tableName, structure, primaryColumns, foreignKeys };
93+
}
94+
95+
private async safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
96+
try {
97+
return await fn();
98+
} catch {
99+
return fallback;
100+
}
101+
}
102+
}
103+
104+
function serializeDiff(diff: SchemaDiff): ConnectionDiagramPreviewResponseDTO['diff'] {
105+
return {
106+
addedTables: Array.from(diff.addedTables),
107+
droppedTables: Array.from(diff.droppedTables),
108+
addedColumns: mapSetToObject(diff.addedColumns),
109+
droppedColumns: mapSetToObject(diff.droppedColumns),
110+
addedForeignKeys: mapSetToObject(diff.addedForeignKeys),
111+
statementResults: diff.statementResults,
112+
};
113+
}
114+
115+
function mapSetToObject(map: Map<string, Set<string>>): Record<string, Array<string>> {
116+
const out: Record<string, Array<string>> = {};
117+
for (const [key, set] of map.entries()) {
118+
out[key] = Array.from(set);
119+
}
120+
return out;
121+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FoundPermissionsInConnectionDs } from '../application/data-structures/f
1414
import { GetConnectionDiagramDs } from '../application/data-structures/get-connection-diagram.ds.js';
1515
import { GetGroupsInConnectionDs } from '../application/data-structures/get-groups-in-connection.ds.js';
1616
import { GetPermissionsInConnectionDs } from '../application/data-structures/get-permissions-in-connection.ds.js';
17+
import { PreviewConnectionDiagramDs } from '../application/data-structures/preview-connection-diagram.ds.js';
1718
import { RestoredConnectionDs } from '../application/data-structures/restored-connection.ds.js';
1819
import { TestConnectionResultDs } from '../application/data-structures/test-connection-result.ds.js';
1920
import { TokenDs } from '../application/data-structures/token.ds.js';
@@ -22,6 +23,7 @@ import { UpdateConnectionDs } from '../application/data-structures/update-connec
2223
import { UpdateConnectionTitleDs } from '../application/data-structures/update-connection-title.ds.js';
2324
import { UpdateMasterPasswordDs } from '../application/data-structures/update-master-password.ds.js';
2425
import { ValidateConnectionMasterPasswordDs } from '../application/data-structures/validate-connection-master-password.ds.js';
26+
import { ConnectionDiagramPreviewResponseDTO } from '../application/dto/connection-diagram-preview-response.dto.js';
2527
import { ConnectionDiagramResponseDTO } from '../application/dto/connection-diagram-response.dto.js';
2628
import { CreatedConnectionDTO } from '../application/dto/created-connection.dto.js';
2729
import { FoundUserGroupsInConnectionDTO } from '../application/dto/found-user-groups-in-connection.dto.js';
@@ -112,3 +114,10 @@ export interface IUpdateConnectionTitle {
112114
export interface IGetConnectionDiagram {
113115
execute(inputData: GetConnectionDiagramDs, inTransaction: InTransactionEnum): Promise<ConnectionDiagramResponseDTO>;
114116
}
117+
118+
export interface IPreviewConnectionDiagram {
119+
execute(
120+
inputData: PreviewConnectionDiagramDs,
121+
inTransaction: InTransactionEnum,
122+
): Promise<ConnectionDiagramPreviewResponseDTO>;
123+
}

0 commit comments

Comments
 (0)