diff --git a/README.md b/README.md index 0472b03a16..e952a8e637 100644 --- a/README.md +++ b/README.md @@ -27,24 +27,24 @@ The MSSQL extension provides a rich set of capabilities for SQL development. Eac Features that have moved from Public Preview to general availability. -| Capability | Description | -| --- | --- | -| [Connection Dialog](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#connection-dialog) | Connect using parameters, connection strings, or Azure/Fabric browse. Organize connections with color-coded groups | -| [Object Explorer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#object-explorer-filtering) | Browse and filter database objects with type-aware search | -| [Database Object Search](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-database-operations#database-object-search) | Search for tables, views, stored procedures, and other objects across a database | -| [Fabric integration](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-fabric-integration) | Browse Fabric workspaces and provision SQL databases | -| [Query Results](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#query-results-pane) | View, sort, copy, and export query results | -| [Query Plan Visualizer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#query-plan-visualizer) | Analyze execution plans with interactive node navigation | -| [Query Profiler](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-query-profiler) | Real-time database activity monitoring with Extended Events | -| [Table Designer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#table-designer) | Create and manage tables with a visual interface | -| [Schema Designer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-schema-designer) | Visual schema modeling with drag-and-drop, auto-layout, and T-SQL script generation | -| [Schema Compare](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-schema-compare) | Compare and synchronize schemas between databases or DACPACs | -| [View & Edit Data](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#view--edit-data) | Browse and modify table data inline without writing T-SQL | -| [Database Operations](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-database-operations) | Rename, back up, restore, import data from flat files, and drop databases from Object Explorer | -| [DACPAC/BACPAC Operations](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-data-tier-application) | Publish/extract database schema with `.dacpac` files and import/export schema + data with `.bacpac` files | -| [SQL Database Projects](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code) | Build, publish with the visual Publish Dialog, and analyze SQL projects with Code Analysis | -| [GitHub Copilot integration](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/github-copilot/overview) | AI-assisted SQL development with natural language chat and agent mode | -| [Local SQL Server container](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-local-container) | Create and manage SQL Server containers locally | +| Capability | Description | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| [Connection Dialog](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#connection-dialog) | Connect using parameters, connection strings, or Azure/Fabric browse. Organize connections with color-coded groups | +| [Object Explorer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#object-explorer-filtering) | Browse and filter database objects with type-aware search | +| [Database Object Search](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-database-operations#database-object-search) | Search for tables, views, stored procedures, and other objects across a database | +| [Fabric integration](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-fabric-integration) | Browse Fabric workspaces and provision SQL databases | +| [Query Results](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#query-results-pane) | View, sort, copy, and export query results | +| [Query Plan Visualizer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#query-plan-visualizer) | Analyze execution plans with interactive node navigation | +| [Query Profiler](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-query-profiler) | Real-time database activity monitoring with Extended Events | +| [Table Designer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#table-designer) | Create and manage tables with a visual interface | +| [Schema Designer](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-schema-designer) | Visual schema modeling with drag-and-drop, auto-layout, and T-SQL script generation | +| [Schema Compare](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-schema-compare) | Compare and synchronize schemas between databases or DACPACs | +| [View & Edit Data](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code#view--edit-data) | Browse and modify table data inline without writing T-SQL | +| [Database Operations](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-database-operations) | Rename, back up, restore, import data from flat files, and drop databases from Object Explorer | +| [DACPAC/BACPAC Operations](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-data-tier-application) | Publish/extract database schema with `.dacpac` files and import/export schema + data with `.bacpac` files | +| [SQL Database Projects](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-extension-visual-studio-code) | Build, publish with the visual Publish Dialog, and analyze SQL projects with Code Analysis | +| [GitHub Copilot integration](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/github-copilot/overview) | AI-assisted SQL development with natural language chat and agent mode | +| [Local SQL Server container](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-local-container) | Create and manage SQL Server containers locally | ### Public Preview @@ -55,7 +55,7 @@ Features available for public use while we gather feedback. APIs and UX may chan | [Schema Designer with GitHub Copilot](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-schema-designer-copilot) | Natural language schema design within the visual Schema Designer | | [Data API builder](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-data-api-builder) | Create REST, GraphQL, and MCP endpoints for SQL databases | | [GitHub Copilot in Data API builder](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-data-api-builder) | Generate Data API builder configs using natural language | -| [SQL Notebooks](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-sql-notebooks) | Jupyter-based SQL notebooks with rich results and multi-kernel support | +| [SQL Notebooks](https://learn.microsoft.com/sql/tools/visual-studio-code-extensions/mssql/mssql-sql-notebooks) | Jupyter-based SQL notebooks with rich results and multi-kernel support | ## Using the MSSQL Extension diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index fd25ced720..12fbb9953a 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -66,6 +66,8 @@ "Loading": "Loading", "Loading...": "Loading...", "General": "General", + "Database name is required": "Database name is required", + "Database name must be 128 characters or fewer": "Database name must be 128 characters or fewer", "Previous": "Previous", "OK": "OK", "Group by": "Group by", @@ -1144,8 +1146,6 @@ }, "Database Name": "Database Name", "Enter database name": "Enter database name", - "Database name is required": "Database name is required", - "Database name must be 128 characters or fewer": "Database name must be 128 characters or fewer", "Owner": "Owner", "Collation": "Collation", "Recovery Model": "Recovery Model", @@ -1168,6 +1168,17 @@ "I understand this action is permanent and irreversible": "I understand this action is permanent and irreversible", "Drop": "Drop", "Dropping database": "Dropping database", + "Rename Database": "Rename Database", + "Rename '{0}' on '{1}'./{0} is the current database name{1} is the server name": { + "message": "Rename '{0}' on '{1}'.", + "comment": ["{0} is the current database name", "{1} is the server name"] + }, + "Rename Options": "Rename Options", + "New Database Name": "New Database Name", + "Enter new database name": "Enter new database name", + "New database name must be different from the current name": "New database name must be different from the current name", + "Rename": "Rename", + "Renaming database": "Renaming database", "Data-tier Application": "Data-tier Application", "Deploy, extract, import, or export data-tier applications on the selected database": "Deploy, extract, import, or export data-tier applications on the selected database", "Operation": "Operation", @@ -1706,8 +1717,6 @@ "Public": "Public", "Private": "Private", "Remove": "Remove", - "Rename Database": "Rename Database", - "Enter the new database name": "Enter the new database name", "Please select a server node in Object Explorer to create a database.": "Please select a server node in Object Explorer to create a database.", "Please select a database node in Object Explorer to drop.": "Please select a database node in Object Explorer to drop.", "Please select a database node in Object Explorer to rename.": "Please select a database node in Object Explorer to rename.", diff --git a/extensions/mssql/media/renameDatabase_dark.svg b/extensions/mssql/media/renameDatabase_dark.svg new file mode 100644 index 0000000000..04a7768f04 --- /dev/null +++ b/extensions/mssql/media/renameDatabase_dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/extensions/mssql/media/renameDatabase_light.svg b/extensions/mssql/media/renameDatabase_light.svg new file mode 100644 index 0000000000..f100c280d0 --- /dev/null +++ b/extensions/mssql/media/renameDatabase_light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/extensions/mssql/scripts/bundle-webviews.js b/extensions/mssql/scripts/bundle-webviews.js index d3f29713db..b536bd8108 100644 --- a/extensions/mssql/scripts/bundle-webviews.js +++ b/extensions/mssql/scripts/bundle-webviews.js @@ -34,6 +34,7 @@ const config = { changePassword: "src/webviews/pages/ChangePassword/index.tsx", createDatabaseDialog: "src/webviews/pages/ObjectManagement/createDatabaseIndex.tsx", dropDatabaseDialog: "src/webviews/pages/ObjectManagement/dropDatabaseIndex.tsx", + renameDatabaseDialog: "src/webviews/pages/ObjectManagement/renameDatabaseIndex.tsx", publishProject: "src/webviews/pages/PublishProject/index.tsx", codeAnalysis: "src/webviews/pages/CodeAnalysis/index.tsx", tableExplorer: "src/webviews/pages/TableExplorer/index.tsx", diff --git a/extensions/mssql/src/configurations/config.ts b/extensions/mssql/src/configurations/config.ts index 80972e163f..5db291573d 100644 --- a/extensions/mssql/src/configurations/config.ts +++ b/extensions/mssql/src/configurations/config.ts @@ -8,7 +8,7 @@ export const config = { dotnetRuntimeVersion: "10.0", downloadUrl: "https://github.com/Microsoft/sqltoolsservice/releases/download/{#version#}/microsoft.sqltools.servicelayer-{#fileName#}", - version: "6.0.20260420.1", + version: "6.0.20260422.4", downloadFileNames: { Windows_64: "win-x64-net10.0.zip", Windows_ARM64: "win-arm64-net10.0.zip", diff --git a/extensions/mssql/src/constants/constants.ts b/extensions/mssql/src/constants/constants.ts index 0510524286..891fe14071 100644 --- a/extensions/mssql/src/constants/constants.ts +++ b/extensions/mssql/src/constants/constants.ts @@ -209,6 +209,8 @@ export const createDatabaseHelpLink = "https://learn.microsoft.com/sql/t-sql/statements/create-database-transact-sql"; export const dropDatabaseHelpLink = "https://learn.microsoft.com/sql/t-sql/statements/drop-database-transact-sql"; +export const renameDatabaseHelpLink = + "https://learn.microsoft.com/sql/t-sql/statements/alter-database-transact-sql"; export const backupDatabaseHelpLink = "https://learn.microsoft.com/sql/t-sql/statements/backup-transact-sql"; export const restoreDatabaseHelpLink = diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index 248bf59df0..539b25b385 100644 --- a/extensions/mssql/src/constants/locConstants.ts +++ b/extensions/mssql/src/constants/locConstants.ts @@ -32,8 +32,7 @@ export let dropDatabaseDialogTitle = l10n.t("Drop Database"); export let renameDatabaseDialogTitle = l10n.t("Rename Database"); export let createDatabaseWebviewTitle = l10n.t("Create Database"); export let dropDatabaseWebviewTitle = l10n.t("Drop Database"); -export let renameDatabaseInputPlaceholder = l10n.t("Enter the new database name"); -export let databaseNameRequired = l10n.t("Database name is required"); +export let renameDatabaseWebviewTitle = l10n.t("Rename Database"); export let msgSelectServerNodeToCreateDatabase = l10n.t( "Please select a server node in Object Explorer to create a database.", ); diff --git a/extensions/mssql/src/controllers/createDatabaseWebviewController.ts b/extensions/mssql/src/controllers/createDatabaseWebviewController.ts index 7bf2f1469a..ef26d288c3 100644 --- a/extensions/mssql/src/controllers/createDatabaseWebviewController.ts +++ b/extensions/mssql/src/controllers/createDatabaseWebviewController.ts @@ -33,7 +33,7 @@ interface CreateDatabaseViewInfo { } export class CreateDatabaseWebviewController extends ObjectManagementWebviewController { - private objectInfo: { [key: string]: unknown } | undefined; + private _objectInfo: { [key: string]: unknown } | undefined; public constructor( context: vscode.ExtensionContext, @@ -85,7 +85,7 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont ), ); - this.objectInfo = viewInfo.objectInfo as { [key: string]: unknown }; + this._objectInfo = viewInfo.objectInfo as { [key: string]: unknown }; const getDefault = (options?: { options?: string[]; defaultValueIndex?: number }) => { if (!options?.options?.length) { return undefined; @@ -96,28 +96,28 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont const viewModel: CreateDatabaseViewModel = { serverName: this.serverName, - databaseName: (this.objectInfo?.name as string | undefined) ?? "", + databaseName: (this._objectInfo?.name as string | undefined) ?? "", ownerOptions: viewInfo.loginNames?.options, owner: - (this.objectInfo?.owner as string | undefined) ?? + (this._objectInfo?.owner as string | undefined) ?? getDefault(viewInfo.loginNames), collationOptions: viewInfo.collationNames?.options, collationName: - (this.objectInfo?.collationName as string | undefined) ?? + (this._objectInfo?.collationName as string | undefined) ?? getDefault(viewInfo.collationNames), recoveryModelOptions: viewInfo.recoveryModels?.options, recoveryModel: - (this.objectInfo?.recoveryModel as string | undefined) ?? + (this._objectInfo?.recoveryModel as string | undefined) ?? getDefault(viewInfo.recoveryModels), compatibilityLevelOptions: viewInfo.compatibilityLevels?.options, compatibilityLevel: - (this.objectInfo?.compatibilityLevel as string | undefined) ?? + (this._objectInfo?.compatibilityLevel as string | undefined) ?? getDefault(viewInfo.compatibilityLevels), containmentTypeOptions: viewInfo.containmentTypes?.options, containmentType: - (this.objectInfo?.containmentType as string | undefined) ?? + (this._objectInfo?.containmentType as string | undefined) ?? getDefault(viewInfo.containmentTypes), - isLedgerDatabase: this.objectInfo?.isLedgerDatabase as boolean | undefined, + isLedgerDatabase: this._objectInfo?.isLedgerDatabase as boolean | undefined, }; this.updateWebviewState({ @@ -149,7 +149,7 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont ): Promise { const typedParams = params as CreateDatabaseParams; try { - if (!this.objectInfo) { + if (!this._objectInfo) { return { success: false, errorMessage: LocConstants.msgChooseDatabaseNotConnected, @@ -157,14 +157,29 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont } this.applyCreateParams(typedParams); - await this.objectManagementService.save( + const saveResponse = await this.objectManagementService.save( this.contextId, - this.objectInfo as { name: string; [key: string]: unknown }, + this._objectInfo as { name: string; [key: string]: unknown }, ); + if (saveResponse.errorMessage) { + return { + success: false, + errorMessage: saveResponse.errorMessage, + taskId: saveResponse.taskId, + }; + } + + if (!saveResponse.taskId) { + return { + success: false, + errorMessage: LocConstants.msgObjectManagementUnknownDialog, + }; + } + await this.disposeView(); this.closeDialog(typedParams.name); - return { success: true }; + return { success: true, taskId: saveResponse.taskId }; } catch (error) { return { success: false, errorMessage: getErrorMessage(error) }; } @@ -175,7 +190,7 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont ): Promise { const typedParams = params as CreateDatabaseParams; try { - if (!this.objectInfo) { + if (!this._objectInfo) { return { success: false, errorMessage: LocConstants.msgChooseDatabaseNotConnected, @@ -185,7 +200,7 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont this.applyCreateParams(typedParams); const script = await this.objectManagementService.script( this.contextId, - this.objectInfo as { name: string; [key: string]: unknown }, + this._objectInfo as { name: string; [key: string]: unknown }, ); if (!script) { @@ -209,28 +224,28 @@ export class CreateDatabaseWebviewController extends ObjectManagementWebviewCont } private applyCreateParams(params: CreateDatabaseParams): void { - if (!this.objectInfo) { + if (!this._objectInfo) { return; } - this.objectInfo.name = params.name; + this._objectInfo.name = params.name; if (params.owner !== undefined) { - this.objectInfo.owner = params.owner; + this._objectInfo.owner = params.owner; } if (params.collationName !== undefined) { - this.objectInfo.collationName = params.collationName; + this._objectInfo.collationName = params.collationName; } if (params.recoveryModel !== undefined) { - this.objectInfo.recoveryModel = params.recoveryModel; + this._objectInfo.recoveryModel = params.recoveryModel; } if (params.compatibilityLevel !== undefined) { - this.objectInfo.compatibilityLevel = params.compatibilityLevel; + this._objectInfo.compatibilityLevel = params.compatibilityLevel; } if (params.containmentType !== undefined) { - this.objectInfo.containmentType = params.containmentType; + this._objectInfo.containmentType = params.containmentType; } if (params.isLedgerDatabase !== undefined) { - this.objectInfo.isLedgerDatabase = params.isLedgerDatabase; + this._objectInfo.isLedgerDatabase = params.isLedgerDatabase; } } } diff --git a/extensions/mssql/src/controllers/dropDatabaseWebviewController.ts b/extensions/mssql/src/controllers/dropDatabaseWebviewController.ts index 4c1e4e08a9..164161aab2 100644 --- a/extensions/mssql/src/controllers/dropDatabaseWebviewController.ts +++ b/extensions/mssql/src/controllers/dropDatabaseWebviewController.ts @@ -22,8 +22,8 @@ interface DropDatabaseViewInfo { } export class DropDatabaseWebviewController extends ObjectManagementWebviewController { - private databaseNameForDrop = ""; - private objectInfo: { [key: string]: unknown } | undefined; + private _databaseNameForDrop = ""; + private _objectInfo: { [key: string]: unknown } | undefined; public constructor( context: vscode.ExtensionContext, @@ -56,7 +56,7 @@ export class DropDatabaseWebviewController extends ObjectManagementWebviewContro objectUrn, ); - this.databaseNameForDrop = databaseName ?? ""; + this._databaseNameForDrop = databaseName ?? ""; this.start(); } @@ -78,16 +78,16 @@ export class DropDatabaseWebviewController extends ObjectManagementWebviewContro ), ); - this.objectInfo = viewInfo.objectInfo as { [key: string]: unknown }; + this._objectInfo = viewInfo.objectInfo as { [key: string]: unknown }; const viewModel: DropDatabaseViewModel = { serverName: this.serverName, databaseName: - (this.objectInfo?.name as string | undefined) ?? this.databaseName ?? "", - owner: this.objectInfo?.owner as string | undefined, - status: this.objectInfo?.status as string | undefined, + (this._objectInfo?.name as string | undefined) ?? this.databaseName ?? "", + owner: this._objectInfo?.owner as string | undefined, + status: this._objectInfo?.status as string | undefined, }; - this.databaseNameForDrop = viewModel.databaseName; + this._databaseNameForDrop = viewModel.databaseName; this.updateWebviewState({ viewModel: { dialogType: ObjectManagementDialogType.DropDatabase, @@ -117,16 +117,32 @@ export class DropDatabaseWebviewController extends ObjectManagementWebviewContro ): Promise { const typedParams = params as { dropConnections: boolean; deleteBackupHistory: boolean }; try { - await this.objectManagementService.dropDatabase( + const dropResponse = await this.objectManagementService.dropDatabase( this.connectionUri, - this.databaseNameForDrop, + this._databaseNameForDrop, typedParams.dropConnections, typedParams.deleteBackupHistory, false, ); + + if (dropResponse.errorMessage) { + return { + success: false, + errorMessage: dropResponse.errorMessage, + taskId: dropResponse.taskId, + }; + } + + if (!dropResponse.taskId) { + return { + success: false, + errorMessage: LocConstants.msgObjectManagementUnknownDialog, + }; + } + await this.disposeView(); - this.closeDialog(this.databaseNameForDrop); - return { success: true }; + this.closeDialog(this._databaseNameForDrop); + return { success: true, taskId: dropResponse.taskId }; } catch (error) { return { success: false, errorMessage: getErrorMessage(error) }; } @@ -137,13 +153,14 @@ export class DropDatabaseWebviewController extends ObjectManagementWebviewContro ): Promise { const typedParams = params as { dropConnections: boolean; deleteBackupHistory: boolean }; try { - const script = await this.objectManagementService.dropDatabase( + const response = await this.objectManagementService.dropDatabase( this.connectionUri, - this.databaseNameForDrop, + this._databaseNameForDrop, typedParams.dropConnections, typedParams.deleteBackupHistory, true, ); + const script = response.script; if (!script) { void this.vscodeWrapper.showWarningMessage(LocConstants.msgNoScriptGenerated); diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index 270fde1692..4c726fb19a 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -54,6 +54,7 @@ import { ConnectionDialogWebviewController } from "../connectionconfig/connectio import { DacpacDialogWebviewController } from "./dacpacDialogWebviewController"; import { CreateDatabaseWebviewController } from "./createDatabaseWebviewController"; import { DropDatabaseWebviewController } from "./dropDatabaseWebviewController"; +import { RenameDatabaseWebviewController } from "./renameDatabaseWebviewController"; import { DacpacDialogWebviewState, DacPacDialogOperationType, @@ -1321,8 +1322,6 @@ export default class MainController implements vscode.Disposable { await this._objectExplorerProvider.refreshNode(node); }; - const escapeSingleQuotes = (value: string): string => value.replace(/'/g, "''"); - /** * Checks if the given node is a server node that is scoped to a database. */ @@ -1531,23 +1530,6 @@ export default class MainController implements vscode.Disposable { return; } - const databaseName = ObjectExplorerUtils.getDatabaseName(targetNode); - const newName = await vscode.window.showInputBox({ - title: LocalizedConstants.renameDatabaseDialogTitle, - value: databaseName, - placeHolder: LocalizedConstants.renameDatabaseInputPlaceholder, - validateInput: (value: string): string | undefined => { - if (!value.trim()) { - return LocalizedConstants.databaseNameRequired; - } - return undefined; - }, - }); - - if (!newName || newName === databaseName) { - return; - } - const connectionProfile = targetNode.connectionProfile; const connectionUri = connectionProfile && @@ -1559,34 +1541,25 @@ export default class MainController implements vscode.Disposable { return; } - const objectUrn = - targetNode.metadata?.urn ?? - `Server/Database[@Name='${escapeSingleQuotes(databaseName)}']`; - - try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: LocalizedConstants.renamingDatabase(databaseName, newName), - }, - async () => { - await this.objectManagementService.rename( - connectionUri, - Constants.databaseString, - objectUrn, - newName, - ); - }, - ); + const databaseName = ObjectExplorerUtils.getDatabaseName(targetNode); + const parentUrn = targetNode.parentNode?.metadata?.urn ?? "Server"; + const objectUrn = targetNode.metadata?.urn; + const controller = new RenameDatabaseWebviewController( + this._context, + this._vscodeWrapper, + this.objectManagementService, + connectionUri, + connectionProfile.server ?? "", + databaseName, + parentUrn, + objectUrn, + LocalizedConstants.renameDatabaseDialogTitle, + ); + await controller.whenWebviewReady(); + controller.revealToForeground(); + const renamedDatabase = await controller.dialogResult.promise; + if (renamedDatabase) { await refreshNodeChildren(targetNode.parentNode); - } catch (error) { - void this._vscodeWrapper.showErrorMessage( - LocalizedConstants.renameDatabaseError( - databaseName, - newName, - getErrorMessage(error), - ), - ); } }, ), diff --git a/extensions/mssql/src/controllers/renameDatabaseWebviewController.ts b/extensions/mssql/src/controllers/renameDatabaseWebviewController.ts new file mode 100644 index 0000000000..eb1e768159 --- /dev/null +++ b/extensions/mssql/src/controllers/renameDatabaseWebviewController.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from "vscode"; +import { + ObjectManagementActionParams, + ObjectManagementActionResult, + ObjectManagementDialogType, + RenameDatabaseParams, + RenameDatabaseViewModel, +} from "../sharedInterfaces/objectManagement"; +import * as Constants from "../constants/constants"; +import * as LocConstants from "../constants/locConstants"; +import { ObjectManagementService } from "../services/objectManagementService"; +import { getErrorMessage } from "../utils/utils"; +import VscodeWrapper from "./vscodeWrapper"; +import { ObjectManagementWebviewController } from "./objectManagementWebviewController"; + +interface RenameDatabaseViewInfo { + objectInfo: { [key: string]: unknown }; +} + +export class RenameDatabaseWebviewController extends ObjectManagementWebviewController { + private _databaseNameForRename = ""; + + public constructor( + context: vscode.ExtensionContext, + vscodeWrapper: VscodeWrapper, + objectManagementService: ObjectManagementService, + connectionUri: string, + serverName: string, + databaseName?: string, + parentUrn?: string, + objectUrn?: string, + dialogTitle?: string, + webviewTitle?: string, + ) { + super( + context, + vscodeWrapper, + objectManagementService, + ObjectManagementDialogType.RenameDatabase, + dialogTitle ?? LocConstants.renameDatabaseDialogTitle, + webviewTitle ?? LocConstants.renameDatabaseWebviewTitle, + { + light: "renameDatabase_light.svg", + dark: "renameDatabase_dark.svg", + }, + "renameDatabaseDialog", + connectionUri, + serverName, + databaseName, + parentUrn, + objectUrn, + ); + + this._databaseNameForRename = databaseName ?? ""; + this.start(); + } + + protected get helpLink(): string { + return Constants.renameDatabaseHelpLink; + } + + protected async initializeDialog(): Promise { + try { + const viewInfo = this.asViewInfo( + await this.objectManagementService.initializeView( + this.contextId, + Constants.databaseString, + this.connectionUri, + this.databaseName || Constants.defaultDatabase, + false, + this.parentUrn ?? "Server", + this.objectUrn, + ), + ); + + const objectInfo = viewInfo.objectInfo as { [key: string]: unknown }; + const databaseName = + (objectInfo?.name as string | undefined) ?? this.databaseName ?? ""; + const viewModel: RenameDatabaseViewModel = { + serverName: this.serverName, + databaseName, + newDatabaseName: databaseName, + owner: objectInfo?.owner as string | undefined, + status: objectInfo?.status as string | undefined, + }; + + this._databaseNameForRename = databaseName; + this.updateWebviewState({ + viewModel: { + dialogType: ObjectManagementDialogType.RenameDatabase, + model: viewModel, + }, + isLoading: false, + }); + } catch (error) { + const errorMessage = getErrorMessage(error); + this.logger.error(`Rename database initialization failed: ${errorMessage}`); + this.updateWebviewState({ + viewModel: { + dialogType: ObjectManagementDialogType.RenameDatabase, + model: { + serverName: this.serverName, + databaseName: this.databaseName ?? "", + newDatabaseName: this.databaseName ?? "", + }, + }, + errorMessage, + isLoading: false, + }); + } + } + + protected async handleSubmit( + params: ObjectManagementActionParams["params"], + ): Promise { + const typedParams = params as RenameDatabaseParams; + try { + const renameResponse = await this.objectManagementService.renameDatabase( + this.connectionUri, + this._databaseNameForRename, + typedParams.newName, + typedParams.dropConnections, + false, + ); + + if (renameResponse.errorMessage) { + return { + success: false, + errorMessage: renameResponse.errorMessage, + taskId: renameResponse.taskId, + }; + } + + if (!renameResponse.taskId) { + return { + success: false, + errorMessage: LocConstants.msgObjectManagementUnknownDialog, + }; + } + + await this.disposeView(); + this.closeDialog(typedParams.newName); + return { success: true, taskId: renameResponse.taskId }; + } catch (error) { + return { success: false, errorMessage: getErrorMessage(error) }; + } + } + + protected async handleScript( + params: ObjectManagementActionParams["params"], + ): Promise { + const typedParams = params as RenameDatabaseParams; + try { + const response = await this.objectManagementService.renameDatabase( + this.connectionUri, + this._databaseNameForRename, + typedParams.newName, + typedParams.dropConnections, + true, + ); + const script = response.script; + + if (!script) { + const errorMessage = response.errorMessage ?? LocConstants.msgNoScriptGenerated; + void this.vscodeWrapper.showWarningMessage(errorMessage); + return { + success: false, + errorMessage, + }; + } + + await this.openScriptInEditor(script); + return { success: true }; + } catch (error) { + this.logger.error(`Script generation failed: ${getErrorMessage(error)}`); + return { success: false, errorMessage: getErrorMessage(error) }; + } + } + + private asViewInfo(viewInfo: unknown): RenameDatabaseViewInfo { + return viewInfo as RenameDatabaseViewInfo; + } +} diff --git a/extensions/mssql/src/models/contracts/objectManagement.ts b/extensions/mssql/src/models/contracts/objectManagement.ts index c90583ebcb..39a8160753 100644 --- a/extensions/mssql/src/models/contracts/objectManagement.ts +++ b/extensions/mssql/src/models/contracts/objectManagement.ts @@ -50,10 +50,18 @@ export interface SaveObjectRequestParams { object: ObjectManagementSqlObject; } +export interface SaveObjectRequestResponse { + taskId?: string; + errorMessage?: string; +} + export namespace SaveObjectRequest { - export const type = new RequestType( - "objectManagement/save", - ); + export const type = new RequestType< + SaveObjectRequestParams, + SaveObjectRequestResponse, + void, + void + >("objectManagement/save"); } export interface ScriptObjectRequestParams { @@ -90,6 +98,29 @@ export namespace RenameObjectRequest { ); } +export interface RenameDatabaseRequestParams { + connectionUri: string; + database: string; + newName: string; + dropConnections: boolean; + generateScript: boolean; +} + +export interface RenameDatabaseResponse { + taskId?: string; + script?: string; + errorMessage?: string; +} + +export namespace RenameDatabaseRequest { + export const type = new RequestType< + RenameDatabaseRequestParams, + RenameDatabaseResponse, + void, + void + >("objectManagement/renameDatabase"); +} + export interface DropDatabaseRequestParams { connectionUri: string; database: string; @@ -98,10 +129,19 @@ export interface DropDatabaseRequestParams { generateScript: boolean; } +export interface DropDatabaseResponse { + taskId?: string; + script?: string; + errorMessage?: string; +} + export namespace DropDatabaseRequest { - export const type = new RequestType( - "objectManagement/dropDatabase", - ); + export const type = new RequestType< + DropDatabaseRequestParams, + DropDatabaseResponse, + void, + void + >("objectManagement/dropDatabase"); } //#region Backup Database; diff --git a/extensions/mssql/src/services/objectManagementService.ts b/extensions/mssql/src/services/objectManagementService.ts index b2f5acc766..091210a88d 100644 --- a/extensions/mssql/src/services/objectManagementService.ts +++ b/extensions/mssql/src/services/objectManagementService.ts @@ -6,12 +6,16 @@ import SqlToolsServiceClient from "../languageservice/serviceclient"; import { DropDatabaseRequest, + DropDatabaseResponse, InitializeViewRequest, InitializeViewRequestParams, ObjectManagementSqlObject, ObjectManagementViewInfo, + RenameDatabaseResponse, RenameObjectRequest, + RenameDatabaseRequest, SaveObjectRequest, + SaveObjectRequestResponse, ScriptObjectRequest, DisposeViewRequest, BackupConfigInfoRequest, @@ -46,7 +50,7 @@ export class ObjectManagementService { database: string, isNewObject: boolean, parentUrn: string, - objectUrn: string, + objectUrn?: string, ): Promise> { const params: InitializeViewRequestParams = { connectionUri, @@ -60,7 +64,10 @@ export class ObjectManagementService { return this._client.sendRequest(InitializeViewRequest.type, params); } - public async save(contextId: string, object: ObjectManagementSqlObject): Promise { + public async save( + contextId: string, + object: ObjectManagementSqlObject, + ): Promise { return this._client.sendRequest(SaveObjectRequest.type, { contextId, object }); } @@ -86,13 +93,29 @@ export class ObjectManagementService { }); } + public async renameDatabase( + connectionUri: string, + database: string, + newName: string, + dropConnections: boolean, + generateScript: boolean, + ): Promise { + return this._client.sendRequest(RenameDatabaseRequest.type, { + connectionUri, + database, + newName, + dropConnections, + generateScript, + }); + } + public async dropDatabase( connectionUri: string, database: string, dropConnections: boolean, deleteBackupHistory: boolean, generateScript: boolean, - ): Promise { + ): Promise { return this._client.sendRequest(DropDatabaseRequest.type, { connectionUri, database, diff --git a/extensions/mssql/src/sharedInterfaces/objectManagement.ts b/extensions/mssql/src/sharedInterfaces/objectManagement.ts index 20b7f2bc64..9f2729f52d 100644 --- a/extensions/mssql/src/sharedInterfaces/objectManagement.ts +++ b/extensions/mssql/src/sharedInterfaces/objectManagement.ts @@ -16,6 +16,7 @@ import { ApiStatus } from "./webview"; export enum ObjectManagementDialogType { CreateDatabase = "createDatabase", DropDatabase = "dropDatabase", + RenameDatabase = "renameDatabase", BackupDatabase = "backupDatabase", RestoreDatabase = "restoreDatabase", } @@ -58,6 +59,19 @@ export interface DropDatabaseParams { deleteBackupHistory: boolean; } +export interface RenameDatabaseViewModel { + serverName: string; + databaseName: string; + newDatabaseName?: string; + owner?: string; + status?: string; +} + +export interface RenameDatabaseParams { + newName: string; + dropConnections: boolean; +} + export type ObjectManagementViewModel = | { dialogType: ObjectManagementDialogType.CreateDatabase; @@ -67,6 +81,10 @@ export type ObjectManagementViewModel = dialogType: ObjectManagementDialogType.DropDatabase; model?: DropDatabaseViewModel; } + | { + dialogType: ObjectManagementDialogType.RenameDatabase; + model?: RenameDatabaseViewModel; + } | { dialogType: ObjectManagementDialogType.BackupDatabase; model?: BackupDatabaseViewModel; @@ -110,6 +128,10 @@ export type ObjectManagementActionParams = dialogType: ObjectManagementDialogType.DropDatabase; params: DropDatabaseParams; } + | { + dialogType: ObjectManagementDialogType.RenameDatabase; + params: RenameDatabaseParams; + } | { dialogType: ObjectManagementDialogType.BackupDatabase; params: BackupDatabaseParams; @@ -122,6 +144,7 @@ export type ObjectManagementActionParams = export interface ObjectManagementActionResult { success: boolean; errorMessage?: string; + taskId?: string; } export namespace ObjectManagementSubmitRequest { diff --git a/extensions/mssql/src/webviews/common/icons/renameDatabase.tsx b/extensions/mssql/src/webviews/common/icons/renameDatabase.tsx new file mode 100644 index 0000000000..47432da9b4 --- /dev/null +++ b/extensions/mssql/src/webviews/common/icons/renameDatabase.tsx @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useId } from "react"; + +export const RenameDatabaseIcon = Object.assign( + React.forwardRef>((props, ref) => { + const { fill = "currentColor", className, style, ...rest } = props; + const clipPathId = useId(); + + return ( + + + + + + + + + + + + ); + }), + { displayName: "RenameDatabaseIcon" }, +); diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index dc7bc02bcf..0c0c3c9764 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -71,6 +71,8 @@ export class LocConstants { loading: l10n.t("Loading"), loadingWithEllipsis: l10n.t("Loading..."), general: l10n.t("General"), + databaseNameRequired: l10n.t("Database name is required"), + databaseNameTooLong: l10n.t("Database name must be 128 characters or fewer"), previous: l10n.t("Previous"), ok: l10n.t("OK"), groupBy: l10n.t("Group by"), @@ -1816,8 +1818,8 @@ export class LocConstants { optionsSection: l10n.t("Advanced Options"), nameLabel: l10n.t("Database Name"), namePlaceholder: l10n.t("Enter database name"), - nameRequired: l10n.t("Database name is required"), - nameTooLong: l10n.t("Database name must be 128 characters or fewer"), + nameRequired: this.common.databaseNameRequired, + nameTooLong: this.common.databaseNameTooLong, ownerLabel: l10n.t("Owner"), collationLabel: l10n.t("Collation"), recoveryModelLabel: l10n.t("Recovery Model"), @@ -1860,6 +1862,36 @@ export class LocConstants { }; } + public get renameDatabase() { + return { + title: l10n.t("Rename Database"), + description: (databaseName: string, serverName: string) => + l10n.t({ + message: "Rename '{0}' on '{1}'.", + args: [databaseName, serverName], + comment: ["{0} is the current database name", "{1} is the server name"], + }), + loading: l10n.t("Loading..."), + detailsSection: l10n.t("Database Details"), + optionsSection: l10n.t("Rename Options"), + nameColumn: l10n.t("Name"), + ownerColumn: l10n.t("Owner"), + statusColumn: l10n.t("Status"), + valueUnknown: l10n.t("-"), + newNameLabel: l10n.t("New Database Name"), + newNamePlaceholder: l10n.t("Enter new database name"), + newNameRequired: this.common.databaseNameRequired, + newNameTooLong: this.common.databaseNameTooLong, + newNameUnchanged: l10n.t("New database name must be different from the current name"), + dropConnections: l10n.t("Drop active connections"), + helpButton: l10n.t("Help"), + scriptButton: l10n.t("Script"), + renameButton: l10n.t("Rename"), + cancelButton: l10n.t("Cancel"), + renamingDatabase: l10n.t("Renaming database"), + }; + } + public get dacpacDialog() { return { title: l10n.t("Data-tier Application"), @@ -1905,7 +1937,7 @@ export class LocConstants { execute: l10n.t("Execute"), filePathRequired: l10n.t("File path is required"), invalidFile: l10n.t("Invalid file"), - databaseNameRequired: l10n.t("Database name is required"), + databaseNameRequired: this.common.databaseNameRequired, invalidDatabase: l10n.t("Invalid database"), validationFailed: l10n.t("Validation failed"), deployingDacpac: l10n.t("Deploying DACPAC..."), diff --git a/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseDialogPage.tsx b/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseDialogPage.tsx new file mode 100644 index 0000000000..f862c1520c --- /dev/null +++ b/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseDialogPage.tsx @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useContext, useEffect, useRef, useState } from "react"; +import { makeStyles, Spinner } from "@fluentui/react-components"; +import { + ObjectManagementCancelNotification, + ObjectManagementDialogType, + ObjectManagementHelpNotification, + ObjectManagementScriptRequest, + ObjectManagementSubmitRequest, + RenameDatabaseParams, + RenameDatabaseViewModel, +} from "../../../sharedInterfaces/objectManagement"; +import { locConstants } from "../../common/locConstants"; +import { getErrorMessage } from "../../common/utils"; +import { ObjectManagementDialog } from "../../common/objectManagementDialog"; +import { RenameDatabaseIcon } from "../../common/icons/renameDatabase"; +import { ObjectManagementContext } from "./objectManagementStateProvider"; +import { RenameDatabaseForm, RenameDatabaseFormState } from "./renameDatabaseForm"; + +const maxDatabaseNameLength = 128; + +const useStyles = makeStyles({ + loadingPage: { + display: "flex", + justifyContent: "center", + alignItems: "center", + minHeight: "100vh", + width: "100%", + flexDirection: "column", + backgroundColor: "var(--vscode-editor-background)", + }, +}); + +export interface RenameDatabaseDialogPageProps { + model?: RenameDatabaseViewModel; + isLoading: boolean; + dialogTitle?: string; + initializationError?: string; +} + +export const RenameDatabaseDialogPage = ({ + model, + isLoading, + dialogTitle, + initializationError, +}: RenameDatabaseDialogPageProps) => { + const styles = useStyles(); + const context = useContext(ObjectManagementContext); + const extensionRpc = context!.extensionRpc; + const [resultApiError, setResultApiError] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hasEditedName, setHasEditedName] = useState(false); + const [renameForm, setRenameForm] = useState({ + newName: model?.newDatabaseName ?? model?.databaseName ?? "", + dropConnections: false, + }); + const renameFormInitialized = useRef(false); + + useEffect(() => { + if (model && !renameFormInitialized.current) { + setRenameForm({ + newName: model.newDatabaseName ?? model.databaseName, + dropConnections: false, + }); + setHasEditedName(false); + renameFormInitialized.current = true; + } + }, [model]); + + const trimmedName = renameForm.newName.trim(); + const currentDatabaseName = model?.databaseName.trim() ?? ""; + const isNameEmpty = trimmedName.length === 0; + const isNameTooLong = trimmedName.length > maxDatabaseNameLength; + const isNameUnchanged = trimmedName === currentDatabaseName; + const showNameRequired = renameForm.newName.length > 0 && isNameEmpty; + const showNameTooLong = !showNameRequired && isNameTooLong; + const showNameUnchanged = + hasEditedName && !showNameRequired && !showNameTooLong && isNameUnchanged; + const isSubmitDisabled = + isLoading || isSubmitting || isNameEmpty || isNameTooLong || isNameUnchanged; + + const newNameValidationMessage = showNameRequired + ? locConstants.renameDatabase.newNameRequired + : showNameTooLong + ? locConstants.renameDatabase.newNameTooLong + : showNameUnchanged + ? locConstants.renameDatabase.newNameUnchanged + : undefined; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + } + title={dialogTitle ?? locConstants.renameDatabase.title} + subtitle={ + model + ? locConstants.renameDatabase.description(model.databaseName, model.serverName) + : undefined + } + errorMessage={resultApiError ?? initializationError} + loadingMessage={isSubmitting ? locConstants.renameDatabase.renamingDatabase : undefined} + primaryLabel={locConstants.renameDatabase.renameButton} + cancelLabel={locConstants.renameDatabase.cancelButton} + helpLabel={locConstants.renameDatabase.helpButton} + scriptLabel={locConstants.renameDatabase.scriptButton} + primaryDisabled={isSubmitDisabled} + scriptDisabled={isSubmitDisabled} + onPrimary={async () => { + const params: RenameDatabaseParams = { + ...renameForm, + newName: trimmedName, + }; + setIsSubmitting(true); + setResultApiError(undefined); + + try { + const result = await extensionRpc.sendRequest( + ObjectManagementSubmitRequest.type, + { dialogType: ObjectManagementDialogType.RenameDatabase, params }, + ); + + if (!result?.success) { + setResultApiError(result?.errorMessage ?? initializationError); + setIsSubmitting(false); + } + } catch (error) { + setResultApiError(getErrorMessage(error)); + setIsSubmitting(false); + } + }} + onScript={async () => { + const params: RenameDatabaseParams = { + ...renameForm, + newName: trimmedName, + }; + setResultApiError(undefined); + + try { + const result = await extensionRpc.sendRequest( + ObjectManagementScriptRequest.type, + { dialogType: ObjectManagementDialogType.RenameDatabase, params }, + ); + + if (!result?.success) { + setResultApiError(result?.errorMessage ?? initializationError); + } + } catch (error) { + setResultApiError(getErrorMessage(error)); + } + }} + onHelp={() => { + void extensionRpc.sendNotification(ObjectManagementHelpNotification.type); + }} + onCancel={() => { + void extensionRpc.sendNotification(ObjectManagementCancelNotification.type); + }}> + {model && ( + { + setHasEditedName(true); + setRenameForm(next); + }} + /> + )} + + ); +}; diff --git a/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseForm.tsx b/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseForm.tsx new file mode 100644 index 0000000000..9d120c895d --- /dev/null +++ b/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseForm.tsx @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Checkbox, Field, Input, makeStyles, tokens } from "@fluentui/react-components"; +import { + RenameDatabaseParams, + RenameDatabaseViewModel, +} from "../../../sharedInterfaces/objectManagement"; +import { locConstants } from "../../common/locConstants"; + +const useStyles = makeStyles({ + root: { + width: "100%", + maxWidth: "560px", + display: "flex", + flexDirection: "column", + gap: "22px", + }, + section: { + display: "flex", + flexDirection: "column", + gap: "14px", + }, + sectionHeader: { + display: "flex", + flexDirection: "column", + gap: "6px", + paddingBottom: "10px", + borderBottom: "1px solid var(--vscode-editorGroup-border)", + }, + sectionTitle: { + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + fontWeight: tokens.fontWeightSemibold, + color: "var(--vscode-foreground)", + }, + tableContainer: { + overflow: "hidden", + borderRadius: "8px", + border: "1px solid var(--vscode-editorGroup-border)", + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-editor-background))", + }, + table: { + width: "100%", + borderCollapse: "collapse", + }, + tableHeaderCell: { + textAlign: "left", + fontSize: "12px", + fontWeight: "600", + padding: "8px 10px", + color: "var(--vscode-foreground)", + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-sideBar-background))", + borderBottom: "1px solid var(--vscode-editorGroup-border)", + }, + tableCell: { + fontSize: "13px", + padding: "8px 10px", + borderBottom: "1px solid var(--vscode-editorGroup-border)", + color: "var(--vscode-foreground)", + }, + fieldGroup: { + display: "flex", + flexDirection: "column", + gap: "12px", + }, +}); + +export interface RenameDatabaseFormState extends RenameDatabaseParams {} + +export interface RenameDatabaseFormProps { + value: RenameDatabaseFormState; + viewModel: RenameDatabaseViewModel; + newNameValidationMessage?: string; + newNameValidationState: "none" | "error" | "warning" | "success"; + onChange: (next: RenameDatabaseFormState) => void; +} + +export const RenameDatabaseForm = ({ + value, + viewModel, + newNameValidationMessage, + newNameValidationState, + onChange, +}: RenameDatabaseFormProps) => { + const styles = useStyles(); + + return ( +
+
+
+
+ {locConstants.renameDatabase.detailsSection} +
+
+
+ + + + + + + + + + + + + + + +
+ {locConstants.renameDatabase.nameColumn} + + {locConstants.renameDatabase.ownerColumn} + + {locConstants.renameDatabase.statusColumn} +
+ {viewModel.databaseName ?? + locConstants.renameDatabase.valueUnknown} + + {viewModel.owner ?? locConstants.renameDatabase.valueUnknown} + + {viewModel.status ?? locConstants.renameDatabase.valueUnknown} +
+
+
+
+
+
+ {locConstants.renameDatabase.optionsSection} +
+
+
+ + + onChange({ + ...value, + newName: data.value, + }) + } + /> + + + onChange({ + ...value, + dropConnections: !!data.checked, + }) + } + /> +
+
+
+ ); +}; diff --git a/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseIndex.tsx b/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseIndex.tsx new file mode 100644 index 0000000000..1e98bf3a3b --- /dev/null +++ b/extensions/mssql/src/webviews/pages/ObjectManagement/renameDatabaseIndex.tsx @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FluentProvider } from "@fluentui/react-components"; +import { ObjectManagementStateProvider } from "./objectManagementStateProvider"; +import { + ObjectManagementDialogType, + RenameDatabaseViewModel, +} from "../../../sharedInterfaces/objectManagement"; +import { RenameDatabaseDialogPage } from "./renameDatabaseDialogPage"; +import { VscodeWebviewProvider } from "../../common/vscodeWebviewProvider"; +import { useObjectManagementSelector } from "./objectManagementSelector"; +import "../../index.css"; +import { createRoot } from "react-dom/client"; + +const RenameDatabaseDialogRoot = () => { + const model = useObjectManagementSelector((state) => + state.viewModel.dialogType === ObjectManagementDialogType.RenameDatabase + ? (state.viewModel.model as RenameDatabaseViewModel | undefined) + : undefined, + ); + const isLoading = useObjectManagementSelector((state) => state.isLoading ?? false); + const dialogTitle = useObjectManagementSelector((state) => state.dialogTitle); + const errorMessage = useObjectManagementSelector((state) => state.errorMessage); + + return ( + + ); +}; + +const App = () => { + return ( + + + + + + + + ); +}; + +createRoot(document.getElementById("root")!).render(); diff --git a/extensions/mssql/test/unit/createDatabaseWebviewController.test.ts b/extensions/mssql/test/unit/createDatabaseWebviewController.test.ts index b56aafdb7f..91225fd99b 100644 --- a/extensions/mssql/test/unit/createDatabaseWebviewController.test.ts +++ b/extensions/mssql/test/unit/createDatabaseWebviewController.test.ts @@ -99,7 +99,7 @@ suite("CreateDatabaseWebviewController Tests", () => { test("handleSubmit should call save", async () => { createController(); await waitForInitialization(); - objectManagementServiceStub.save.resolves(); + objectManagementServiceStub.save.resolves({ taskId: "save-task-id" }); const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); expect(requestHandler, "Request handler was not registered").to.be.a("function"); @@ -114,10 +114,57 @@ suite("CreateDatabaseWebviewController Tests", () => { params, }); - expect(result.success).to.be.true; + expect(result).to.deep.equal({ + success: true, + taskId: "save-task-id", + }); expect(objectManagementServiceStub.save.calledOnce).to.be.true; }); + test("handleSubmit should return task id when save response reports failure", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.save.resolves({ + taskId: "save-task-id", + errorMessage: "Save failed", + }); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.CreateDatabase, + params: { + name: "test-db", + owner: "sa", + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Save failed", + taskId: "save-task-id", + }); + }); + + test("handleSubmit should fail when save response has no task id", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.save.resolves({}); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.CreateDatabase, + params: { + name: "test-db", + owner: "sa", + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Unknown object management dialog.", + }); + }); + test("handleSubmit should handle error", async () => { createController(); await waitForInitialization(); diff --git a/extensions/mssql/test/unit/dropDatabaseWebviewController.test.ts b/extensions/mssql/test/unit/dropDatabaseWebviewController.test.ts index d6c2ad2d37..6116242bd2 100644 --- a/extensions/mssql/test/unit/dropDatabaseWebviewController.test.ts +++ b/extensions/mssql/test/unit/dropDatabaseWebviewController.test.ts @@ -49,8 +49,9 @@ suite("DropDatabaseWebviewController Tests", () => { .stub(jsonRpc, "createMessageConnection") .returns(connection.connection as unknown as jsonRpc.MessageConnection); - const panelStub = stubWebviewPanel(sandbox); - sandbox.stub(vscode.window, "createWebviewPanel").callsFake(() => panelStub); + sandbox + .stub(vscode.window, "createWebviewPanel") + .callsFake(() => stubWebviewPanel(sandbox)); mockContext = { extensionUri: vscode.Uri.file("/tmp/ext"), @@ -101,7 +102,9 @@ suite("DropDatabaseWebviewController Tests", () => { test("handleSubmit should call dropDatabase", async () => { createController(); await waitForInitialization(); - objectManagementServiceStub.dropDatabase.resolves("Done"); + objectManagementServiceStub.dropDatabase.resolves({ + taskId: "drop-task-id", + }); const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); expect(requestHandler, "Request handler was not registered").to.be.a("function"); @@ -116,7 +119,10 @@ suite("DropDatabaseWebviewController Tests", () => { params, }); - expect(result.success).to.be.true; + expect(result).to.deep.equal({ + success: true, + taskId: "drop-task-id", + }); expect(objectManagementServiceStub.dropDatabase.calledOnce).to.be.true; const args = objectManagementServiceStub.dropDatabase.firstCall.args; expect(args[1]).to.equal(databaseName); @@ -125,6 +131,52 @@ suite("DropDatabaseWebviewController Tests", () => { expect(args[4]).to.be.false; // generateScript }); + test("handleSubmit should return task id when drop response reports failure", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.dropDatabase.resolves({ + taskId: "drop-task-id", + errorMessage: "Drop failed", + }); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.DropDatabase, + params: { + dropConnections: true, + deleteBackupHistory: false, + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Drop failed", + taskId: "drop-task-id", + }); + }); + + test("handleSubmit should not close the dialog when drop response has no task id", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.dropDatabase.resolves({}); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.DropDatabase, + params: { + dropConnections: true, + deleteBackupHistory: false, + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Unknown object management dialog.", + }); + }); + test("handleSubmit should handle error", async () => { createController(); await waitForInitialization(); @@ -149,8 +201,9 @@ suite("DropDatabaseWebviewController Tests", () => { test("handleScript should call dropDatabase with script flag", async () => { createController(); await waitForInitialization(); - const script = "DROP DATABASE [test-db]"; - objectManagementServiceStub.dropDatabase.resolves(script); + objectManagementServiceStub.dropDatabase.resolves({ + script: "DROP DATABASE [test-db]", + }); // Stub openTextDocument and showTextDocument const mockDoc = {} as vscode.TextDocument; diff --git a/extensions/mssql/test/unit/objectManagementService.test.ts b/extensions/mssql/test/unit/objectManagementService.test.ts index 58b65b6b4d..c1a0642e2d 100644 --- a/extensions/mssql/test/unit/objectManagementService.test.ts +++ b/extensions/mssql/test/unit/objectManagementService.test.ts @@ -13,6 +13,7 @@ import { ScriptObjectRequest, DisposeViewRequest, RenameObjectRequest, + RenameDatabaseRequest, DropDatabaseRequest, ObjectManagementSqlObject, BackupConfigInfoRequest, @@ -79,10 +80,11 @@ suite("ObjectManagementService Tests", () => { test("save should send correct request", async () => { const object: ObjectManagementSqlObject = { name: "test-object" }; - sqlToolsClientStub.sendRequest.resolves(); + sqlToolsClientStub.sendRequest.resolves({ taskId: "save-task-id" }); - await objectManagementService.save("context-id", object); + const result = await objectManagementService.save("context-id", object); + expect(result).to.deep.equal({ taskId: "save-task-id" }); expect(sqlToolsClientStub.sendRequest.calledOnce).to.be.true; const [type, params] = sqlToolsClientStub.sendRequest.firstCall.args; expect(type).to.equal(SaveObjectRequest.type); @@ -143,8 +145,37 @@ suite("ObjectManagementService Tests", () => { }); }); + test("renameDatabase should send correct request", async () => { + sqlToolsClientStub.sendRequest.resolves({ + script: "ALTER DATABASE [db] MODIFY NAME = [db2]", + }); + + const result = await objectManagementService.renameDatabase( + "connection-uri", + "database-name", + "new-database-name", + true, + true, + ); + + expect(result).to.deep.equal({ + script: "ALTER DATABASE [db] MODIFY NAME = [db2]", + }); + expect( + sqlToolsClientStub.sendRequest.calledWith(RenameDatabaseRequest.type, { + connectionUri: "connection-uri", + database: "database-name", + newName: "new-database-name", + dropConnections: true, + generateScript: true, + }), + ).to.be.true; + }); + test("dropDatabase should send correct request", async () => { - sqlToolsClientStub.sendRequest.resolves("script"); + sqlToolsClientStub.sendRequest.resolves({ + script: "script", + }); const result = await objectManagementService.dropDatabase( "connection-uri", @@ -154,7 +185,9 @@ suite("ObjectManagementService Tests", () => { true, ); - expect(result).to.equal("script"); + expect(result).to.deep.equal({ + script: "script", + }); expect(sqlToolsClientStub.sendRequest.calledOnce).to.be.true; const [type, params] = sqlToolsClientStub.sendRequest.firstCall.args; expect(type).to.equal(DropDatabaseRequest.type); diff --git a/extensions/mssql/test/unit/renameDatabaseWebviewController.test.ts b/extensions/mssql/test/unit/renameDatabaseWebviewController.test.ts new file mode 100644 index 0000000000..6979c19fd9 --- /dev/null +++ b/extensions/mssql/test/unit/renameDatabaseWebviewController.test.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from "vscode"; +import * as sinon from "sinon"; +import { expect } from "chai"; +import * as jsonRpc from "vscode-jsonrpc/node"; +import { RenameDatabaseWebviewController } from "../../src/controllers/renameDatabaseWebviewController"; +import { ObjectManagementService } from "../../src/services/objectManagementService"; +import VscodeWrapper from "../../src/controllers/vscodeWrapper"; +import { + stubTelemetry, + stubLogger, + stubVscodeWrapper, + stubWebviewConnectionRpc, + stubWebviewPanel, +} from "./utils"; +import { + ObjectManagementSubmitRequest, + ObjectManagementScriptRequest, + ObjectManagementDialogType, +} from "../../src/sharedInterfaces/objectManagement"; +import * as utils from "../../src/utils/utils"; + +suite("RenameDatabaseWebviewController Tests", () => { + let sandbox: sinon.SinonSandbox; + let mockContext: vscode.ExtensionContext; + let vscodeWrapperStub: sinon.SinonStubbedInstance; + let objectManagementServiceStub: sinon.SinonStubbedInstance; + let requestHandlers: Map Promise>; + let initializeViewCalled: Promise; + let resolveInitializeViewCalled: (() => void) | undefined; + + const connectionUri = "test-connection-uri"; + const serverName = "test-server"; + const databaseName = "test-db"; + + setup(() => { + sandbox = sinon.createSandbox(); + stubTelemetry(sandbox); + stubLogger(sandbox); + sandbox.stub(utils, "getNonce").returns("test-nonce"); + + const connection = stubWebviewConnectionRpc(sandbox); + requestHandlers = connection.requestHandlers as Map< + string, + (params: unknown) => Promise + >; + sandbox + .stub(jsonRpc, "createMessageConnection") + .returns(connection.connection as unknown as jsonRpc.MessageConnection); + + sandbox + .stub(vscode.window, "createWebviewPanel") + .callsFake(() => stubWebviewPanel(sandbox)); + + mockContext = { + extensionUri: vscode.Uri.file("/tmp/ext"), + extensionPath: "/tmp/ext", + subscriptions: [], + } as unknown as vscode.ExtensionContext; + + vscodeWrapperStub = stubVscodeWrapper(sandbox); + objectManagementServiceStub = sandbox.createStubInstance(ObjectManagementService); + initializeViewCalled = new Promise((resolve) => { + resolveInitializeViewCalled = resolve; + }); + objectManagementServiceStub.initializeView.callsFake(async () => { + resolveInitializeViewCalled?.(); + return { + objectInfo: { name: databaseName, owner: "sa", status: "Normal" }, + }; + }); + }); + + teardown(() => { + sandbox.restore(); + }); + + function createController(): RenameDatabaseWebviewController { + return new RenameDatabaseWebviewController( + mockContext, + vscodeWrapperStub, + objectManagementServiceStub, + connectionUri, + serverName, + databaseName, + ); + } + + async function waitForInitialization(): Promise { + await initializeViewCalled; + } + + test("initialization should call initializeView for the selected database", async () => { + createController(); + await waitForInitialization(); + + expect( + objectManagementServiceStub.initializeView.calledWithMatch( + sinon.match.string, + "Database", + connectionUri, + databaseName, + false, + ), + ).to.be.true; + }); + + test("handleSubmit should call renameDatabase", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.renameDatabase.resolves({ + taskId: "rename-task-id", + }); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + expect(requestHandler).to.be.a("function"); + + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.RenameDatabase, + params: { + newName: "renamed-db", + dropConnections: true, + }, + }); + + expect(result).to.deep.equal({ + success: true, + taskId: "rename-task-id", + }); + expect( + objectManagementServiceStub.renameDatabase.calledWith( + connectionUri, + databaseName, + "renamed-db", + true, + false, + ), + ).to.be.true; + }); + + test("handleSubmit should return task id when rename response reports failure", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.renameDatabase.resolves({ + taskId: "rename-task-id", + errorMessage: "Rename failed", + }); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.RenameDatabase, + params: { + newName: "renamed-db", + dropConnections: false, + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Rename failed", + taskId: "rename-task-id", + }); + }); + + test("handleSubmit should surface service errors", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.renameDatabase.rejects(new Error("Rename failed")); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.RenameDatabase, + params: { + newName: "renamed-db", + dropConnections: false, + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Rename failed", + }); + }); + + test("handleSubmit should not close the dialog when rename response has no task id", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.renameDatabase.resolves({}); + + const requestHandler = requestHandlers.get(ObjectManagementSubmitRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.RenameDatabase, + params: { + newName: "renamed-db", + dropConnections: false, + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Unknown object management dialog.", + }); + }); + + test("handleScript should request a rename script and open it", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.renameDatabase.resolves({ + script: "ALTER DATABASE [test-db] MODIFY NAME = [renamed-db]", + }); + + const mockDoc = {} as vscode.TextDocument; + sandbox.stub(vscode.workspace, "openTextDocument").resolves(mockDoc); + sandbox.stub(vscode.window, "showTextDocument").resolves({} as vscode.TextEditor); + + const requestHandler = requestHandlers.get(ObjectManagementScriptRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.RenameDatabase, + params: { + newName: "renamed-db", + dropConnections: true, + }, + }); + + expect(result).to.deep.equal({ success: true }); + expect( + objectManagementServiceStub.renameDatabase.calledWith( + connectionUri, + databaseName, + "renamed-db", + true, + true, + ), + ).to.be.true; + }); + + test("handleScript should surface the service error when script generation fails", async () => { + createController(); + await waitForInitialization(); + objectManagementServiceStub.renameDatabase.resolves({ + errorMessage: "Rename script failed", + }); + + const requestHandler = requestHandlers.get(ObjectManagementScriptRequest.type.method); + const result = await requestHandler!({ + dialogType: ObjectManagementDialogType.RenameDatabase, + params: { + newName: "renamed-db", + dropConnections: true, + }, + }); + + expect(result).to.deep.equal({ + success: false, + errorMessage: "Rename script failed", + }); + expect(vscodeWrapperStub.showWarningMessage.calledWith("Rename script failed")).to.be.true; + }); +}); diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 56011e8cab..5974d5f510 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2161,6 +2161,9 @@ Enter new column width + + Enter new database name + Enter new password @@ -2180,9 +2183,6 @@ Enter the SQL Login password for user '{0}'. {0} is the SQL Login username - - Enter the new database name - Enter the path for the output file @@ -3999,6 +3999,9 @@ New Database + + New Database Name + New Deployment @@ -4038,6 +4041,9 @@ New column mapping + + New database name must be different from the current name + New media set description @@ -5003,9 +5009,23 @@ Remove {0} {0} is the object type + + Rename + + + Rename '{0}' on '{1}'. + {0} is the current database name +{1} is the server name + Rename Database + + Rename Options + + + Renaming database + Renaming database '{0}' to '{1}'... {0} is the current database name