diff --git a/extensions/dacpac/src/test/testDacFxService.ts b/extensions/dacpac/src/test/testDacFxService.ts index e10d604854..b08a727557 100644 --- a/extensions/dacpac/src/test/testDacFxService.ts +++ b/extensions/dacpac/src/test/testDacFxService.ts @@ -89,4 +89,8 @@ export class DacFxTestService implements mssql.IDacFxService { parseTSqlScript(filePath: string, databaseSchemaProvider: string): Thenable { return Promise.resolve({ containsCreateTableStatement: true }); } + + savePublishProfile(profilePath: string, databaseName: string, connectionString: string, sqlCommandVariableValues?: Record): Thenable { + return Promise.resolve(this.dacfxResult); + } } diff --git a/extensions/mssql/src/contracts.ts b/extensions/mssql/src/contracts.ts index 5b823e65ba..f31099f6c3 100644 --- a/extensions/mssql/src/contracts.ts +++ b/extensions/mssql/src/contracts.ts @@ -551,6 +551,14 @@ export interface ParseTSqlScriptParams { databaseSchemaProvider: string; } +export interface SavePublishProfileParams { + profilePath: string; + databaseName: string; + connectionString: string; + sqlCommandVariableValues?: Record; + deploymentOptions?: mssql.DeploymentOptions; +} + export namespace ExportRequest { export const type = new RequestType('dacfx/export'); } @@ -587,6 +595,10 @@ export namespace ParseTSqlScriptRequest { export const type = new RequestType('dacfx/parseTSqlScript'); } +export namespace SavePublishProfileRequest { + export const type = new RequestType('dacfx/savePublishProfile'); +} + // ------------------------------- ------------------------------------ // ------------------------------- ---------------------------------------- diff --git a/extensions/mssql/src/dacfx/dacFxService.ts b/extensions/mssql/src/dacfx/dacFxService.ts index e4ee892247..78d9a0d009 100644 --- a/extensions/mssql/src/dacfx/dacFxService.ts +++ b/extensions/mssql/src/dacfx/dacFxService.ts @@ -141,4 +141,15 @@ export class DacFxService implements mssql.IDacFxService { throw e; } } + + public async savePublishProfile(profilePath: string, databaseName: string, connectionString: string, sqlCommandVariableValues?: Record, deploymentOptions?: mssql.DeploymentOptions): Promise { + const params: contracts.SavePublishProfileParams = { profilePath, databaseName, connectionString, sqlCommandVariableValues, deploymentOptions }; + try { + const result = await this.client.sendRequest(contracts.SavePublishProfileRequest.type, params); + return result; + } catch (e) { + this.client.logFailedRequest(contracts.SavePublishProfileRequest.type, e); + throw e; + } + } } diff --git a/extensions/mssql/src/mssql.d.ts b/extensions/mssql/src/mssql.d.ts index 76c13d4856..c2be549dba 100644 --- a/extensions/mssql/src/mssql.d.ts +++ b/extensions/mssql/src/mssql.d.ts @@ -244,6 +244,7 @@ declare module 'mssql' { getOptionsFromProfile(profilePath: string): Thenable; validateStreamingJob(packageFilePath: string, createStreamingJobTsql: string): Thenable; parseTSqlScript(filePath: string, databaseSchemaProvider: string): Thenable; + savePublishProfile(profilePath: string, databaseName: string, connectionString: string, sqlCommandVariableValues?: Record, deploymentOptions?: DeploymentOptions): Thenable; } export interface DacFxResult extends azdata.ResultStatus { diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index fa71a509b8..ee72f3eb6e 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -135,6 +135,8 @@ export const server = localize('server', "Server"); export const defaultUser = localize('default', "default"); export const selectProfileToUse = localize('selectProfileToUse', "Select publish profile to load"); export const selectProfile = localize('selectProfile', "Select Profile"); +export const saveProfileAsButtonText = localize('saveProfileAsButtonText', "Save Profile As..."); +export const save = localize('save', "Save"); export const dontUseProfile = localize('dontUseProfile', "Don't use profile"); export const browseForProfileWithIcon = `$(folder) ${localize('browseForProfile', "Browse for profile")}`; export const chooseAction = localize('chooseAction', "Choose action"); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 24ad007cb9..f7bbabb24c 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -26,7 +26,7 @@ import { ImportDataModel } from '../models/api/import'; import { NetCoreTool, DotNetError } from '../tools/netcoreTool'; import { ShellCommandOptions } from '../tools/shellExecutionHelper'; import { BuildHelper } from '../tools/buildHelper'; -import { readPublishProfile } from '../models/publishProfile/publishProfile'; +import { readPublishProfile, savePublishProfile } from '../models/publishProfile/publishProfile'; import { AddDatabaseReferenceDialog } from '../dialogs/addDatabaseReferenceDialog'; import { ISystemDatabaseReferenceSettings, IDacpacReferenceSettings, IProjectReferenceSettings } from '../models/IDatabaseReferenceSettings'; import { DatabaseReferenceTreeItem } from '../models/tree/databaseReferencesTreeItem'; @@ -419,6 +419,7 @@ export class ProjectsController { publishDatabaseDialog.publishToContainer = async (proj, prof) => this.publishToDockerContainer(proj, prof); publishDatabaseDialog.generateScript = async (proj, prof) => this.publishOrScriptProject(proj, prof, false); publishDatabaseDialog.readPublishProfile = async (profileUri) => readPublishProfile(profileUri); + publishDatabaseDialog.savePublishProfile = async (profilePath, databaseName, connectionString, sqlCommandVariableValues, deploymentOptions) => savePublishProfile(profilePath, databaseName, connectionString, sqlCommandVariableValues, deploymentOptions); publishDatabaseDialog.openDialog(); diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts index 2a061c95e9..98e3572f6b 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import * as constants from '../common/constants'; import * as utils from '../common/utils'; import * as uiUtils from './utils'; +import * as path from 'path'; import { Project } from '../models/project'; import { SqlConnectionDataSource } from '../models/dataSources/sqlConnectionStringSource'; @@ -61,6 +62,7 @@ export class PublishDatabaseDialog { protected optionsButton: azdataType.ButtonComponent | undefined; private publishOptionsDialog: PublishOptionsDialog | undefined; public publishOptionsModified: boolean = false; + private publishProfileUri: vscode.Uri | undefined; private completionPromise: Deferred = new Deferred(); @@ -70,6 +72,7 @@ export class PublishDatabaseDialog { public publishToContainer: ((proj: Project, profile: IPublishToDockerSettings) => any) | undefined; public generateScript: ((proj: Project, profile: ISqlProjectPublishSettings) => any) | undefined; public readPublishProfile: ((profileUri: vscode.Uri) => any) | undefined; + public savePublishProfile: ((profilePath: string, databaseName: string, connectionString: string, sqlCommandVariableValues?: Record, deploymentOptions?: DeploymentOptions) => any) | undefined; constructor(private project: Project) { this.dialog = utils.getAzdataApi()!.window.createModelViewDialog(constants.publishDialogName, 'sqlProjectPublishDialog'); @@ -143,13 +146,14 @@ export class PublishDatabaseDialog { const options = await this.getDefaultDeploymentOptions(); this.setDeploymentOptions(options); - const profileRow = this.createProfileRow(view); + const profileRow = this.createProfileSection(view); + this.connectionRow = this.createConnectionRow(view); this.databaseRow = this.createDatabaseRow(view); const displayOptionsButton = this.createOptionsButton(view); const horizontalFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); - horizontalFormSection.addItems([profileRow, this.databaseRow]); + horizontalFormSection.addItems([this.databaseRow]); this.formBuilder = view.modelBuilder.formContainer() .withFormItems([ @@ -160,6 +164,10 @@ export class PublishDatabaseDialog { component: flexRadioButtonsModel, title: '' }, + { + component: profileRow, + title: constants.profile + }, { component: this.connectionRow, title: '' @@ -431,18 +439,20 @@ export class PublishDatabaseDialog { this.createDatabaseRow(view); this.tryEnableGenerateScriptAndOkButtons(); if (existingServer) { - if (this.connectionRow) { - this.formBuilder!.insertFormItem({ - title: '', - component: this.connectionRow - }, 2); - } if (this.localDbSection) { this.formBuilder!.removeFormItem({ title: '', component: this.localDbSection }); } + + if (this.connectionRow) { + this.formBuilder!.insertFormItem({ + title: '', + component: this.connectionRow + }, 3); + } + } else { if (this.connectionRow) { this.formBuilder!.removeFormItem({ @@ -450,6 +460,7 @@ export class PublishDatabaseDialog { component: this.connectionRow }); } + if (this.localDbSection) { this.formBuilder!.insertFormItem({ title: '', @@ -523,8 +534,10 @@ export class PublishDatabaseDialog { } } - private createProfileRow(view: azdataType.ModelView): azdataType.FlexContainer { - const loadProfileButton = this.createLoadProfileButton(view); + private createProfileSection(view: azdataType.ModelView): azdataType.FlexContainer { + const selectProfileButton = this.createSelectProfileButton(view); + const saveProfileAsButton = this.createSaveProfileAsButton(view); + this.loadProfileTextBox = view.modelBuilder.inputBox().withProps({ placeHolder: constants.loadProfilePlaceholderText, ariaLabel: constants.profile, @@ -532,13 +545,7 @@ export class PublishDatabaseDialog { enabled: false }).component(); - const profileLabel = view.modelBuilder.text().withProps({ - value: constants.profile, - width: cssStyles.publishDialogLabelWidth - }).component(); - - const profileRow = view.modelBuilder.flexContainer().withItems([profileLabel, this.loadProfileTextBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); - profileRow.insertItem(loadProfileButton, 2, { CSSStyles: { 'margin-right': '0px' } }); + const profileRow = view.modelBuilder.flexContainer().withItems([this.loadProfileTextBox, selectProfileButton, saveProfileAsButton], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); return profileRow; } @@ -842,12 +849,14 @@ export class PublishDatabaseDialog { } } - private createLoadProfileButton(view: azdataType.ModelView): azdataType.ButtonComponent { + private createSelectProfileButton(view: azdataType.ModelView): azdataType.ButtonComponent { let loadProfileButton: azdataType.ButtonComponent = view.modelBuilder.button().withProps({ - ariaLabel: constants.loadProfilePlaceholderText, - iconPath: IconPathHelper.folder_blue, - height: '18px', - width: '18px' + label: constants.selectProfile, + title: constants.selectProfile, + ariaLabel: constants.selectProfile, + width: cssStyles.PublishingOptionsButtonWidth, + height: '25px', + secondary: true, }).component(); loadProfileButton.onDidClick(async () => { @@ -899,12 +908,52 @@ export class PublishDatabaseDialog { await this.loadProfileTextBox!.updateProperty('title', fileUris[0].fsPath); this.profileUsed = true; + this.publishProfileUri = fileUris[0]; } }); return loadProfileButton; } + private createSaveProfileAsButton(view: azdataType.ModelView): azdataType.ButtonComponent { + let saveProfileAsButton: azdataType.ButtonComponent = view.modelBuilder.button().withProps({ + label: constants.saveProfileAsButtonText, + title: constants.saveProfileAsButtonText, + ariaLabel: constants.saveProfileAsButtonText, + width: cssStyles.PublishingOptionsButtonWidth, + height: '25px', + secondary: true + }).component(); + + saveProfileAsButton.onDidClick(async () => { + const filePath = await vscode.window.showSaveDialog( + { + defaultUri: this.publishProfileUri ?? vscode.Uri.file(path.join(this.project.projectFolderPath, `${this.project.projectFileName}_1`)), + saveLabel: constants.save, + filters: { + 'Publish Settings Files': ['publish.xml'], + } + } + ); + + if (!filePath) { + return; + } + + if (this.savePublishProfile) { + const targetConnectionString = this.connectionId ? await utils.getAzdataApi()!.connection.getConnectionString(this.connectionId, false) : ''; + const targetDatabaseName = this.targetDatabaseName ?? ''; + const deploymentOptions = await this.getDeploymentOptions(); + await this.savePublishProfile(filePath.fsPath, targetDatabaseName, targetConnectionString, this.getSqlCmdVariablesForPublish(), deploymentOptions); + } + + this.profileUsed = true; + this.publishProfileUri = filePath; + }); + + return saveProfileAsButton; + } + private convertSqlCmdVarsToTableFormat(sqlCmdVars: Record): azdataType.DeclarativeTableCellValue[][] { let data = []; for (let key in sqlCmdVars) { diff --git a/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts b/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts index 118264d4d7..d376c9b927 100644 --- a/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts +++ b/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts @@ -121,3 +121,11 @@ async function readConnectionString(xmlDoc: any): Promise<{ connectionId: string server: server }; } + +/** + * saves publish settings to the specified profile file + */ +export async function savePublishProfile(profilePath: string, databaseName: string, connectionString: string, sqlCommandVariableValues?: Record, deploymentOptions?: mssql.DeploymentOptions): Promise { + const dacFxService = await utils.getDacFxService(); + await dacFxService.savePublishProfile(profilePath, databaseName, connectionString, sqlCommandVariableValues, deploymentOptions); +} diff --git a/extensions/sql-database-projects/src/test/testContext.ts b/extensions/sql-database-projects/src/test/testContext.ts index 4fc7d8c8fc..8a5c211b84 100644 --- a/extensions/sql-database-projects/src/test/testContext.ts +++ b/extensions/sql-database-projects/src/test/testContext.ts @@ -22,6 +22,11 @@ export const mockDacFxResult = { report: '' }; +export const mockSavePublishResult = { + success: true, + errorMessage: '' +}; + /* Get the deployment options sample model */ export function getDeploymentOptions(): mssql.DeploymentOptions { const sampleDesc = 'Sample Description text'; @@ -57,6 +62,7 @@ export class MockDacFxService implements mssql.IDacFxService { public getOptionsFromProfile(_: string): Thenable { return Promise.resolve(mockDacFxOptionsResult); } public validateStreamingJob(_: string, __: string): Thenable { return Promise.resolve(mockDacFxResult); } public parseTSqlScript(_: string, __: string): Thenable { return Promise.resolve({ containsCreateTableStatement: true }); } + public savePublishProfile(_: string, __: string, ___: string, ______?: Record): Thenable { return Promise.resolve(mockSavePublishResult); } } export function createContext(): TestContext { diff --git a/extensions/types/vscode-mssql.d.ts b/extensions/types/vscode-mssql.d.ts index b346d06884..da5de8dd3d 100644 --- a/extensions/types/vscode-mssql.d.ts +++ b/extensions/types/vscode-mssql.d.ts @@ -426,6 +426,7 @@ declare module 'vscode-mssql' { generateDeployPlan(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: TaskExecutionMode): Thenable; getOptionsFromProfile(profilePath: string): Thenable; validateStreamingJob(packageFilePath: string, createStreamingJobTsql: string): Thenable; + savePublishProfile(profilePath: string, databaseName: string, connectionString: string, sqlCommandVariableValues?: Record, deploymentOptions?: DeploymentOptions): Thenable; } /** @@ -721,6 +722,14 @@ declare module 'vscode-mssql' { defaultDeploymentOptions: DeploymentOptions; } + export interface SavePublishProfileParams { + profilePath: string; + databaseName: string; + connectionString: string; + sqlCommandVariableValues?: Record; + deploymentOptions?: DeploymentOptions; + } + export interface ITreeNodeInfo extends vscode.TreeItem { readonly connectionInfo: IConnectionInfo; nodeType: string;