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 @@ -45,6 +45,7 @@ export enum UseCaseType {
UNFREEZE_CONNECTION = 'UNFREEZE_CONNECTION',
UPDATE_CONNECTION_TITLE = 'UPDATE_CONNECTION_TITLE',
GET_CONNECTION_DIAGRAM = 'GET_CONNECTION_DIAGRAM',
PREVIEW_CONNECTION_DIAGRAM = 'PREVIEW_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,6 @@
export class PreviewConnectionDiagramDs {
connectionId: string;
masterPwd: string;
userId: string;
sqlCommands: Array<string>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, MaxLength } from 'class-validator';

export class ConnectionDiagramPreviewRequestDTO {
@ApiProperty({
type: [String],
description:
'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.',
example: ['ALTER TABLE users ADD COLUMN age INTEGER'],
})
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(20000, { each: true })
sqlCommands: Array<string>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ApiProperty } from '@nestjs/swagger';
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';

export class ConnectionDiagramPreviewStatementResultDTO {
@ApiProperty()
sql: string;

@ApiProperty({ enum: ['applied', 'skipped', 'error'] })
status: 'applied' | 'skipped' | 'error';

@ApiProperty({ required: false, nullable: true })
message?: string;
}

export class ConnectionDiagramPreviewDiffDTO {
@ApiProperty({ type: [String] })
addedTables: Array<string>;

@ApiProperty({ type: [String] })
droppedTables: Array<string>;

@ApiProperty({
description: 'Map of table name -> array of column names that would be added',
type: 'object',
additionalProperties: { type: 'array', items: { type: 'string' } },
})
addedColumns: Record<string, Array<string>>;

@ApiProperty({
description: 'Map of table name -> array of column names that would be removed',
type: 'object',
additionalProperties: { type: 'array', items: { type: 'string' } },
})
droppedColumns: Record<string, Array<string>>;

@ApiProperty({
description: 'Map of table name -> array of "column->refTable.refColumn" descriptors for new foreign keys',
type: 'object',
additionalProperties: { type: 'array', items: { type: 'string' } },
})
addedForeignKeys: Record<string, Array<string>>;

@ApiProperty({ type: [ConnectionDiagramPreviewStatementResultDTO] })
statementResults: Array<ConnectionDiagramPreviewStatementResultDTO>;
}

export class ConnectionDiagramPreviewResponseDTO {
@ApiProperty()
connectionId: string;

@ApiProperty({ enum: ConnectionTypesEnum })
databaseType: ConnectionTypesEnum;

@ApiProperty({
description:
'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.',
})
diagram: string;

@ApiProperty({ description: 'Human-readable description of the projected database structure (post-changes).' })
description: string;

@ApiProperty({ type: ConnectionDiagramPreviewDiffDTO })
diff: ConnectionDiagramPreviewDiffDTO;

@ApiProperty()
generatedAt: string;
}
36 changes: 36 additions & 0 deletions backend/src/entities/connection/connection.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,14 @@ import { FoundPermissionsInConnectionDs } from './application/data-structures/fo
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 { PreviewConnectionDiagramDs } from './application/data-structures/preview-connection-diagram.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 { ConnectionDiagramPreviewRequestDTO } from './application/dto/connection-diagram-preview-request.dto.js';
import { ConnectionDiagramPreviewResponseDTO } from './application/dto/connection-diagram-preview-response.dto.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';
Expand All @@ -79,6 +82,7 @@ import {
IGetConnectionDiagram,
IGetPermissionsForGroupInConnection,
IGetUserGroupsInConnection,
IPreviewConnectionDiagram,
IRefreshConnectionAgentToken,
IRestoreConnection,
ITestConnection,
Expand Down Expand Up @@ -140,6 +144,8 @@ export class ConnectionController {
private readonly updateConnectionTitleUseCase: IUpdateConnectionTitle,
@Inject(UseCaseType.GET_CONNECTION_DIAGRAM)
private readonly getConnectionDiagramUseCase: IGetConnectionDiagram,
@Inject(UseCaseType.PREVIEW_CONNECTION_DIAGRAM)
private readonly previewConnectionDiagramUseCase: IPreviewConnectionDiagram,
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly amplitudeService: AmplitudeService,
Expand Down Expand Up @@ -754,4 +760,34 @@ export class ConnectionController {
};
return await this.getConnectionDiagramUseCase.execute(inputData, InTransactionEnum.OFF);
}

@ApiOperation({
summary:
'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.',
})
@ApiBody({ type: ConnectionDiagramPreviewRequestDTO })
@ApiResponse({
status: 200,
type: ConnectionDiagramPreviewResponseDTO,
})
@UseGuards(ConnectionDiagramGuard)
@Timeout(90000)
@Post('/connection/diagram/:connectionId/preview')
async previewConnectionDiagram(
Comment on lines +769 to +776
@SlugUuid('connectionId') connectionId: string,
@MasterPassword() masterPwd: string,
@UserId() userId: string,
@Body() body: ConnectionDiagramPreviewRequestDTO,
): Promise<ConnectionDiagramPreviewResponseDTO> {
if (!connectionId) {
throw new BadRequestException(Messages.CONNECTION_ID_MISSING);
}
const inputData: PreviewConnectionDiagramDs = {
connectionId,
masterPwd,
userId,
sqlCommands: body.sqlCommands,
};
return await this.previewConnectionDiagramUseCase.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 @@ -28,6 +28,7 @@ import { GetConnectionDiagramUseCase } from './use-cases/get-connection-diagram.
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';
import { PreviewConnectionDiagramUseCase } from './use-cases/preview-connection-diagram.use.case.js';
import { RefreshConnectionAgentTokenUseCase } from './use-cases/refresh-connection-agent-token.use.case.js';
import { RestoreConnectionUseCase } from './use-cases/restore-connection-use.case.js';
import { TestConnectionUseCase } from './use-cases/test-connection.use.case.js';
Expand Down Expand Up @@ -140,6 +141,10 @@ import { ValidateConnectionTokenUseCase } from './use-cases/validate-connection-
provide: UseCaseType.GET_CONNECTION_DIAGRAM,
useClass: GetConnectionDiagramUseCase,
},
{
provide: UseCaseType.PREVIEW_CONNECTION_DIAGRAM,
useClass: PreviewConnectionDiagramUseCase,
},
],
controllers: [ConnectionController],
})
Expand Down Expand Up @@ -168,6 +173,7 @@ export class ConnectionModule implements NestModule {
{ path: '/connection/unfreeze/:connectionId', method: RequestMethod.PUT },
{ path: '/connection/title/:connectionId', method: RequestMethod.PUT },
{ path: '/connection/diagram/:connectionId', method: RequestMethod.GET },
{ path: '/connection/diagram/:connectionId/preview', method: RequestMethod.POST },
)
.apply(AuthWithApiMiddleware)
.forRoutes({ path: 'connections', method: RequestMethod.GET });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 { PreviewConnectionDiagramDs } from '../application/data-structures/preview-connection-diagram.ds.js';
import { ConnectionDiagramPreviewResponseDTO } from '../application/dto/connection-diagram-preview-response.dto.js';
import { applyProposedDdl, SchemaDiff } from '../utils/apply-proposed-ddl.util.js';
import { buildMermaidErDiagram, MermaidTableInput } from '../utils/build-mermaid-er-diagram.util.js';
import { isSqlConnectionType } from '../utils/is-sql-connection-type.util.js';
import { IPreviewConnectionDiagram } from './use-cases.interfaces.js';

@Injectable({ scope: Scope.REQUEST })
export class PreviewConnectionDiagramUseCase
extends AbstractUseCase<PreviewConnectionDiagramDs, ConnectionDiagramPreviewResponseDTO>
implements IPreviewConnectionDiagram
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
) {
super();
}

protected async implementation(inputData: PreviewConnectionDiagramDs): Promise<ConnectionDiagramPreviewResponseDTO> {
const { connectionId, masterPwd, userId, sqlCommands } = 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);
dao.invalidateMetadataCache();

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)),
);

const { mutatedTables, diff } = applyProposedDdl(tableInputs, sqlCommands, connection.type as ConnectionTypesEnum);

const { diagram, description } = buildMermaidErDiagram(connection.database || null, mutatedTables, {
addedTables: diff.addedTables,
addedColumns: diff.addedColumns,
addedForeignKeys: diff.addedForeignKeys,
});

return {
connectionId,
databaseType: connection.type as ConnectionTypesEnum,
diagram,
description,
diff: serializeDiff(diff),
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;
}
}
}

function serializeDiff(diff: SchemaDiff): ConnectionDiagramPreviewResponseDTO['diff'] {
return {
addedTables: Array.from(diff.addedTables),
droppedTables: Array.from(diff.droppedTables),
addedColumns: mapSetToObject(diff.addedColumns),
droppedColumns: mapSetToObject(diff.droppedColumns),
addedForeignKeys: mapSetToObject(diff.addedForeignKeys),
statementResults: diff.statementResults,
};
}

function mapSetToObject(map: Map<string, Set<string>>): Record<string, Array<string>> {
const out: Record<string, Array<string>> = {};
for (const [key, set] of map.entries()) {
out[key] = Array.from(set);
}
return out;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FoundPermissionsInConnectionDs } from '../application/data-structures/f
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 { PreviewConnectionDiagramDs } from '../application/data-structures/preview-connection-diagram.ds.js';
import { RestoredConnectionDs } from '../application/data-structures/restored-connection.ds.js';
import { TestConnectionResultDs } from '../application/data-structures/test-connection-result.ds.js';
import { TokenDs } from '../application/data-structures/token.ds.js';
Expand All @@ -22,6 +23,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 { ConnectionDiagramPreviewResponseDTO } from '../application/dto/connection-diagram-preview-response.dto.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';
Expand Down Expand Up @@ -112,3 +114,10 @@ export interface IUpdateConnectionTitle {
export interface IGetConnectionDiagram {
execute(inputData: GetConnectionDiagramDs, inTransaction: InTransactionEnum): Promise<ConnectionDiagramResponseDTO>;
}

export interface IPreviewConnectionDiagram {
execute(
inputData: PreviewConnectionDiagramDs,
inTransaction: InTransactionEnum,
): Promise<ConnectionDiagramPreviewResponseDTO>;
}
Loading
Loading