From 292e60a7676ada0cc50277971ae99c6c683dc9a7 Mon Sep 17 00:00:00 2001 From: Benjin Dubishar Date: Tue, 11 Jan 2022 16:52:09 -0800 Subject: [PATCH] Apply changes from remote database to sqlproj - sql-database-projects changes (#17738) * update project from database * update project from database * Leftover merge update * Slight refactor to add vscode entrypoints * Re-adding leftover schemacompare bits that reference database project changes * Removing unnecessary function * Addiung GetDSP command to package.json * tests and a race condition fix * remove custom UUID generation code * swapping awaits for voids on promises * PR feedback * PR feedback * Hide update project command from vscode * Swapping cross-extension commands for bound extension contract * Re-adding schema compare radio buttons for sqlproj * Adding refresh after project update * Populating list of project scripts just before comparison to avoid missing script errors of project was separately edited * Adding missing await for okay button enable check * Correcting schema compare source when populated from a project * Rename UpdateDataModel to be more clear * Fix incorrectly changed type * Added new runComparison schema compare command, hooked up to sqlproj extension * Added progress indicator for "apply now" option * moved string literal to constant * Added missing await * Setting missing "saveScmpButton" state to fix test * Revert "Setting missing "saveScmpButton" state to fix test" This reverts commit 55612c9def24ac9e3398f5bbd153d21d9d3ca37f. * Removing preemptive resetWindow() call * general cleanup * PR feedback * property renames * Reverting rename; requires Tools Service change first * Adding header to updateProject * Adding missing header * PR feedback * adding missing await * Handing race condition for UI enable * Fixing broken okay enable case * Fixing enum comparison wonk Co-authored-by: Noureldine Yehia --- extensions/schema-compare/package.json | 8 + extensions/schema-compare/package.nls.json | 5 +- .../src/dialogs/schemaCompareDialog.ts | 52 +- extensions/schema-compare/src/extension.ts | 2 + .../schema-compare/src/localizedConstants.ts | 3 - .../src/schemaCompareMainWindow.ts | 77 ++- .../schema-compare/src/typings/ref.d.ts | 4 +- extensions/schema-compare/src/utils.ts | 6 + extensions/sql-database-projects/package.json | 40 +- .../sql-database-projects/package.nls.json | 1 + .../src/common/constants.ts | 22 + .../src/common/uiConstants.ts | 3 + .../src/controllers/mainController.ts | 3 + .../src/controllers/projectController.ts | 199 +++++- .../updateProjectFromDatabaseDialog.ts | 610 ++++++++++++++++++ .../src/models/api/updateProject.ts | 17 + .../src/projectProvider/projectProvider.ts | 15 + .../sql-database-projects/src/sqldbproj.d.ts | 11 + .../updateProjectFromDatabaseDialog.test.ts | 75 +++ .../src/test/testContext.ts | 3 +- 20 files changed, 1103 insertions(+), 53 deletions(-) create mode 100644 extensions/sql-database-projects/src/dialogs/updateProjectFromDatabaseDialog.ts create mode 100644 extensions/sql-database-projects/src/models/api/updateProject.ts create mode 100644 extensions/sql-database-projects/src/test/dialogs/updateProjectFromDatabaseDialog.test.ts diff --git a/extensions/schema-compare/package.json b/extensions/schema-compare/package.json index 603de3cd4e..f61925f59e 100644 --- a/extensions/schema-compare/package.json +++ b/extensions/schema-compare/package.json @@ -38,6 +38,10 @@ "light": "./images/light_icon.svg", "dark": "./images/dark_icon.svg" } + }, + { + "command": "schemaCompare.runComparison", + "title": "%schemaCompare.runComparison%" } ], "languages": [ @@ -83,6 +87,10 @@ { "command": "schemaCompare.start", "when": "mssql:engineedition != 11" + }, + { + "command": "schemaCompare.runComparison", + "when": "false" } ] } diff --git a/extensions/schema-compare/package.nls.json b/extensions/schema-compare/package.nls.json index 1415ebe994..ea32f47db0 100644 --- a/extensions/schema-compare/package.nls.json +++ b/extensions/schema-compare/package.nls.json @@ -1,5 +1,6 @@ { "displayName": "SQL Server Schema Compare", "description": "SQL Server Schema Compare for Azure Data Studio supports comparing the schemas of databases and dacpacs.", - "schemaCompare.start": "Schema Compare" -} \ No newline at end of file + "schemaCompare.start": "Schema Compare", + "schemaCompare.runComparison": "Run Schema Comparison" +} diff --git a/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts b/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts index 721f17a557..380b9851a8 100644 --- a/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts +++ b/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts @@ -5,12 +5,13 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; -import * as loc from '../localizedConstants'; import * as path from 'path'; +import * as sqldbproj from 'sqldbproj'; +import * as mssql from '../../../mssql'; +import * as loc from '../localizedConstants'; import { SchemaCompareMainWindow } from '../schemaCompareMainWindow'; import { TelemetryReporter, TelemetryViews } from '../telemetry'; import { getEndpointName, getRootPath, exists, getAzdataApi, getSchemaCompareEndpointString } from '../utils'; -import * as mssql from '../../../mssql'; const titleFontSize: number = 13; @@ -141,8 +142,8 @@ export class SchemaCompareDialog { this.schemaCompareMainWindow.sourceEndpointInfo = { endpointType: mssql.SchemaCompareEndpointType.Project, projectFilePath: this.sourceTextBox.value, - targetScripts: await this.getTargetScripts(true), - dataSchemaProvider: await this.getDsp(this.sourceTextBox.value), + targetScripts: await this.getProjectScriptFiles(this.sourceTextBox.value), + dataSchemaProvider: await this.getDatabaseSchemaProvider(this.sourceTextBox.value), folderStructure: '', serverDisplayName: '', serverName: '', @@ -190,8 +191,8 @@ export class SchemaCompareDialog { endpointType: mssql.SchemaCompareEndpointType.Project, projectFilePath: this.targetTextBox.value, folderStructure: this.targetStructureDropdown!.value as string, - targetScripts: await this.getTargetScripts(false), - dataSchemaProvider: await this.getDsp(this.targetTextBox.value), + targetScripts: await this.getProjectScriptFiles(this.targetTextBox.value), + dataSchemaProvider: await this.getDatabaseSchemaProvider(this.targetTextBox.value), serverDisplayName: '', serverName: '', databaseName: '', @@ -528,10 +529,9 @@ export class SchemaCompareDialog { let radioButtons = [this.sourceDatabaseRadioButton, this.sourceDacpacRadioButton]; - // TODO: re-add once database projects changes are checked in; chicken-and-egg problem (https://github.com/microsoft/azuredatastudio/pull/17738) - // if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) { - // radioButtons.push(this.sourceProjectRadioButton); - // } + if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) { + radioButtons.push(this.sourceProjectRadioButton); + } let flexRadioButtonsModel = this.view.modelBuilder.flexContainer() .withLayout({ flexFlow: 'column' }) @@ -617,10 +617,9 @@ export class SchemaCompareDialog { let radioButtons = [targetDatabaseRadioButton, targetDacpacRadioButton]; - // TODO: re-add once database projects changes are checked in; chicken-and-egg problem (https://github.com/microsoft/azuredatastudio/pull/17738) - // if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) { - // radioButtons.push(targetProjectRadioButton); - // } + if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) { + radioButtons.push(targetProjectRadioButton); + } let flexRadioButtonsModel = this.view.modelBuilder.flexContainer() .withLayout({ flexFlow: 'column' }) @@ -636,10 +635,10 @@ export class SchemaCompareDialog { private async shouldEnableOkayButton(): Promise { let sourcefilled = (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Dacpac && await this.existsDacpac(this.sourceTextBox.value)) - || (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Project && this.existsProjectFile(this.sourceTextBox.value)) + || (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Project && await this.existsProjectFile(this.sourceTextBox.value)) || (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Database && !isNullOrUndefined(this.sourceDatabaseDropdown.value) && this.sourceDatabaseDropdown.values.findIndex(x => this.matchesValue(x, this.sourceDbEditable)) !== -1); let targetfilled = (this.targetEndpointType === mssql.SchemaCompareEndpointType.Dacpac && await this.existsDacpac(this.targetTextBox.value)) - || (this.targetEndpointType === mssql.SchemaCompareEndpointType.Project && this.existsProjectFile(this.targetTextBox.value)) + || (this.targetEndpointType === mssql.SchemaCompareEndpointType.Project && await this.existsProjectFile(this.targetTextBox.value)) || (this.targetEndpointType === mssql.SchemaCompareEndpointType.Database && !isNullOrUndefined(this.targetDatabaseDropdown.value) && this.targetDatabaseDropdown.values.findIndex(x => this.matchesValue(x, this.targetDbEditable)) !== -1); return sourcefilled && targetfilled; @@ -670,7 +669,7 @@ export class SchemaCompareDialog { // check Database Schema Providers are set and valid if (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Project) { try { - await this.getDsp(this.sourceTextBox.value); + await this.getDatabaseSchemaProvider(this.sourceTextBox.value); } catch (err) { this.showErrorMessage(loc.dspErrorSource); } @@ -678,7 +677,7 @@ export class SchemaCompareDialog { if (this.targetEndpointType === mssql.SchemaCompareEndpointType.Project) { try { - await this.getDsp(this.targetTextBox.value); + await this.getDatabaseSchemaProvider(this.targetTextBox.value); } catch (err) { this.showErrorMessage(loc.dspErrorTarget); } @@ -703,13 +702,20 @@ export class SchemaCompareDialog { return !isNullOrUndefined(filename) && await exists(filename) && (filename.toLocaleLowerCase().endsWith('.sqlproj')); } - private async getTargetScripts(source: boolean): Promise { - const projectFilePath = source ? this.sourceTextBox.value : this.targetTextBox.value; - return await vscode.commands.executeCommand(loc.sqlDatabaseProjectsGetTargetScripts, projectFilePath); + private async getProjectScriptFiles(projectFilePath: string): Promise { + const databaseProjectsExtension = vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId); + + if (databaseProjectsExtension) { + return await (await databaseProjectsExtension.activate() as sqldbproj.IExtension).getProjectScriptFiles(projectFilePath); + } } - private async getDsp(projectFilePath: string): Promise { - return await vscode.commands.executeCommand(loc.sqlDatabaseProjectsGetDsp, projectFilePath); + private async getDatabaseSchemaProvider(projectFilePath: string): Promise { + const databaseProjectsExtension = vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId); + + if (databaseProjectsExtension) { + return await (await databaseProjectsExtension.activate() as sqldbproj.IExtension).getProjectDatabaseSchemaProvider(projectFilePath); + } } protected createSourceServerDropdown(): azdata.FormComponent { diff --git a/extensions/schema-compare/src/extension.ts b/extensions/schema-compare/src/extension.ts index 2f26ed4800..a31169d6de 100644 --- a/extensions/schema-compare/src/extension.ts +++ b/extensions/schema-compare/src/extension.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import * as mssql from '../../mssql/src/mssql'; import { SchemaCompareMainWindow } from './schemaCompareMainWindow'; export async function activate(extensionContext: vscode.ExtensionContext): Promise { vscode.commands.registerCommand('schemaCompare.start', async (sourceContext: any, targetContext: any = undefined, comparisonResult: any = undefined) => { await new SchemaCompareMainWindow(undefined, extensionContext, undefined).start(sourceContext, targetContext, comparisonResult); }); + vscode.commands.registerCommand('schemaCompare.runComparison', async (source: mssql.SchemaCompareEndpointInfo | undefined, target: mssql.SchemaCompareEndpointInfo | undefined, runComparison: boolean = false, comparisonResult: mssql.SchemaCompareResult | undefined) => { await new SchemaCompareMainWindow(undefined, extensionContext, undefined).launch(source, target, runComparison, comparisonResult); }); } export function deactivate(): void { diff --git a/extensions/schema-compare/src/localizedConstants.ts b/extensions/schema-compare/src/localizedConstants.ts index 2f483bd24a..5cf83be977 100644 --- a/extensions/schema-compare/src/localizedConstants.ts +++ b/extensions/schema-compare/src/localizedConstants.ts @@ -339,7 +339,4 @@ export const applySuccess: string = localize('schemaCompare.applySuccess', "Proj export const sqlDatabaseProjectExtensionId: string = 'microsoft.sql-database-projects'; // Commands -export const sqlDatabaseProjectsGetTargetScripts: string = 'sqlDatabaseProjects.schemaCompareGetTargetScripts'; -export const sqlDatabaseProjectsGetDsp: string = 'sqlDatabaseProjects.schemaCompareGetDsp'; export const sqlDatabaseProjectsPublishChanges: string = 'sqlDatabaseProjects.schemaComparePublishProjectChanges'; -export const sqlDatabaseProjectsShowProjectsView: string = 'sqlDatabaseProjects.schemaCompareShowProjectsView'; diff --git a/extensions/schema-compare/src/schemaCompareMainWindow.ts b/extensions/schema-compare/src/schemaCompareMainWindow.ts index 270732f692..018e3f7333 100644 --- a/extensions/schema-compare/src/schemaCompareMainWindow.ts +++ b/extensions/schema-compare/src/schemaCompareMainWindow.ts @@ -7,11 +7,12 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; +import * as sqldbproj from 'sqldbproj'; import * as mssql from '../../mssql'; import * as loc from './localizedConstants'; import { SchemaCompareOptionsDialog } from './dialogs/schemaCompareOptionsDialog'; import { TelemetryReporter, TelemetryViews } from './telemetry'; -import { getTelemetryErrorType, getEndpointName, verifyConnectionAndGetOwnerUri, getRootPath, getSchemaCompareEndpointString } from './utils'; +import { getTelemetryErrorType, getEndpointName, verifyConnectionAndGetOwnerUri, getRootPath, getSchemaCompareEndpointString, getDataWorkspaceExtensionApi } from './utils'; import { SchemaCompareDialog } from './dialogs/schemaCompareDialog'; import { isNullOrUndefined } from 'util'; @@ -87,6 +88,9 @@ export class SchemaCompareMainWindow { // 3. dacpac // 4. project public async start(sourceContext: any, targetContext: mssql.SchemaCompareEndpointInfo = undefined, comparisonResult: mssql.SchemaCompareResult = undefined): Promise { + let source: mssql.SchemaCompareEndpointInfo; + let target: mssql.SchemaCompareEndpointInfo; + const targetIsSetAsProject: boolean = targetContext && targetContext.endpointType === mssql.SchemaCompareEndpointType.Project; // if schema compare was launched from a db or a connection profile, set that as the source @@ -94,7 +98,7 @@ export class SchemaCompareMainWindow { if (targetIsSetAsProject) { profile = sourceContext; - this.targetEndpointInfo = targetContext; + target = targetContext; } else { profile = sourceContext ? sourceContext.connectionProfile : undefined; } @@ -115,7 +119,7 @@ export class SchemaCompareMainWindow { usr = loc.defaultText; } - this.sourceEndpointInfo = { + source = { endpointType: mssql.SchemaCompareEndpointType.Database, serverDisplayName: `${profile.serverName} (${usr})`, serverName: profile.serverName, @@ -130,7 +134,7 @@ export class SchemaCompareMainWindow { folderStructure: '' }; } else if (sourceDacpac) { - this.sourceEndpointInfo = { + source = { endpointType: mssql.SchemaCompareEndpointType.Dacpac, serverDisplayName: '', serverName: '', @@ -144,7 +148,7 @@ export class SchemaCompareMainWindow { folderStructure: '' }; } else if (sourceProject) { - this.sourceEndpointInfo = { + source = { endpointType: mssql.SchemaCompareEndpointType.Project, packageFilePath: '', serverDisplayName: '', @@ -159,14 +163,38 @@ export class SchemaCompareMainWindow { }; } + await this.launch(source, target, false, comparisonResult); + } + + /** + * Primary functional entrypoint for opening the schema comparison window, and optionally running it. + * @param source + * @param target + * @param runComparison whether to immediately run the schema comparison. Requires both source and target to be specified. Cannot be true when comparisonResult is set. + * @param comparisonResult a pre-computed schema comparison result to display. Cannot be set when runComparison is true. + */ + public async launch(source: mssql.SchemaCompareEndpointInfo | undefined, target: mssql.SchemaCompareEndpointInfo | undefined, runComparison: boolean = false, comparisonResult: mssql.SchemaCompareResult | undefined): Promise { + if (runComparison && comparisonResult) { + throw new Error('Cannot both pass a comparison result and request a new comparison be run.'); + } + + this.sourceEndpointInfo = source; + this.targetEndpointInfo = target; + await this.GetDefaultDeploymentOptions(); await Promise.all([ this.registerContent(), this.editor.openEditor() ]); - if (targetIsSetAsProject) { + if (comparisonResult) { await this.execute(comparisonResult); + } else if (runComparison) { + if (!source || !target) { + throw new Error('source and target must both be set when runComparison is true.'); + } + + await this.startCompare(); } } @@ -321,6 +349,18 @@ export class SchemaCompareMainWindow { this.deploymentOptions = deploymentOptions; } + private async populateProjectScripts(endpointInfo: mssql.SchemaCompareEndpointInfo): Promise { + if (endpointInfo.endpointType !== mssql.SchemaCompareEndpointType.Project) { + return; + } + + const databaseProjectsExtension = vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId); + + if (databaseProjectsExtension) { + endpointInfo.targetScripts = await (await databaseProjectsExtension.activate() as sqldbproj.IExtension).getProjectScriptFiles(endpointInfo.projectFilePath); + } + } + public async execute(comparisonResult: mssql.SchemaCompareCompletionResult = undefined) { const service = await this.getService(); @@ -336,6 +376,8 @@ export class SchemaCompareMainWindow { this.operationId = generateGuid(); } + await Promise.all([this.populateProjectScripts(this.sourceEndpointInfo), this.populateProjectScripts(this.targetEndpointInfo)]); + this.comparisonResult = await service.schemaCompare(this.operationId, this.sourceEndpointInfo, this.targetEndpointInfo, azdata.TaskExecutionMode.execute, this.deploymentOptions); if (!this.comparisonResult || !this.comparisonResult.success) { @@ -831,7 +873,8 @@ export class SchemaCompareMainWindow { TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyStarted') .withAdditionalProperties({ 'startTime': Date.now().toString(), - 'operationId': this.comparisonResult.operationId + 'operationId': this.comparisonResult.operationId, + 'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType) }).send(); // disable apply and generate script buttons because the results are no longer valid after applying the changes @@ -844,7 +887,12 @@ export class SchemaCompareMainWindow { case mssql.SchemaCompareEndpointType.Database: result = await service.schemaComparePublishDatabaseChanges(this.comparisonResult.operationId, this.targetEndpointInfo.serverName, this.targetEndpointInfo.databaseName, azdata.TaskExecutionMode.execute); break; - case mssql.SchemaCompareEndpointType.Project: // Project apply needs sql-database-projects updates in (circular dependency; coming next) // TODO: re-add this and show project logic below + case mssql.SchemaCompareEndpointType.Project: + result = await vscode.commands.executeCommand(loc.sqlDatabaseProjectsPublishChanges, this.comparisonResult.operationId, this.targetEndpointInfo.projectFilePath, this.targetEndpointInfo.folderStructure); + if (!result.success) { + void vscode.window.showErrorMessage(loc.applyError); + } + break; case mssql.SchemaCompareEndpointType.Dacpac: // Dacpac is an invalid publish target default: throw new Error(`Unsupported SchemaCompareEndpointType: ${getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType)}`); @@ -854,7 +902,8 @@ export class SchemaCompareMainWindow { TelemetryReporter.createErrorEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyFailed', undefined, getTelemetryErrorType(result?.errorMessage)) .withAdditionalProperties({ - 'operationId': this.comparisonResult.operationId + 'operationId': this.comparisonResult.operationId, + 'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType) }).send(); vscode.window.showErrorMessage(loc.applyErrorMessage(result?.errorMessage)); @@ -868,8 +917,16 @@ export class SchemaCompareMainWindow { TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyEnded') .withAdditionalProperties({ 'endTime': Date.now().toString(), - 'operationId': this.comparisonResult.operationId + 'operationId': this.comparisonResult.operationId, + 'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType) }).send(); + + if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Project) { + const workspaceApi = getDataWorkspaceExtensionApi(); + workspaceApi.showProjectsView(); + + void vscode.window.showInformationMessage(loc.applySuccess); + } } }); } diff --git a/extensions/schema-compare/src/typings/ref.d.ts b/extensions/schema-compare/src/typings/ref.d.ts index 4d46be908b..23c9548c44 100644 --- a/extensions/schema-compare/src/typings/ref.d.ts +++ b/extensions/schema-compare/src/typings/ref.d.ts @@ -6,4 +6,6 @@ /// /// /// -/// \ No newline at end of file +/// +/// +/// diff --git a/extensions/schema-compare/src/utils.ts b/extensions/schema-compare/src/utils.ts index d6be0a0e72..5870a520dd 100644 --- a/extensions/schema-compare/src/utils.ts +++ b/extensions/schema-compare/src/utils.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode'; import * as mssql from '../../mssql'; import * as os from 'os'; import * as loc from './localizedConstants'; +import * as dataworkspace from 'dataworkspace'; import { promises as fs } from 'fs'; export interface IPackageInfo { @@ -182,3 +183,8 @@ try { export function getAzdataApi(): typeof azdataType | undefined { return azdataApi; } + +export function getDataWorkspaceExtensionApi(): dataworkspace.IExtension { + const extension = vscode.extensions.getExtension(dataworkspace.extension.name)!; + return extension.exports; +} diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index ed48d41e9a..421258e588 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -16,6 +16,8 @@ "onCommand:sqlDatabaseProjects.new", "onCommand:sqlDatabaseProjects.open", "onCommand:sqlDatabaseProjects.createProjectFromDatabase", + "onCommand:sqlDatabaseProjects.updateProjectFromDatabase", + "onCommand:sqlDatabaseProjects.addSqlBinding", "onCommand:sqlDatabaseProjects.generateProjectFromOpenApiSpec", "onCommand:sqlDatabaseProjects.addSqlBinding", "workspaceContains:**/*.sqlproj", @@ -145,6 +147,12 @@ "category": "%sqlDatabaseProjects.displayName%", "icon": "images/databaseProjectToolbar.svg" }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "title": "%sqlDatabaseProjects.updateProjectFromDatabase%", + "category": "%sqlDatabaseProjects.displayName%", + "icon": "images/databaseProjectToolbar.svg" + }, { "command": "sqlDatabaseProjects.addDatabaseReference", "title": "%sqlDatabaseProjects.addDatabaseReference%", @@ -188,6 +196,11 @@ "when": "view == dataworkspace.views.main", "group": "1_currentWorkspace@1" }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "when": "view == dataworkspace.views.main", + "group": "1_currentWorkspace@2" + }, { "command": "sqlDatabaseProjects.generateProjectFromOpenApiSpec", "when": "view == dataworkspace.views.main", @@ -254,6 +267,10 @@ { "command": "sqlDatabaseProjects.createProjectFromDatabase" }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "when": "false" + }, { "command": "sqlDatabaseProjects.addDatabaseReference", "when": "false" @@ -299,6 +316,11 @@ "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project && azdataAvailable", "group": "1_dbProjectsFirst@3" }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project && azdataAvailable", + "group": "1_dbProjectsFirst@4" + }, { "command": "sqlDatabaseProjects.newItem", "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", @@ -389,20 +411,34 @@ { "command": "sqlDatabaseProjects.createProjectFromDatabase", "when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11", - "group": "export" + "group": "export@1" + }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11", + "group": "export@2" } ], "dataExplorer/context": [ { "command": "sqlDatabaseProjects.createProjectFromDatabase", "when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11", - "group": "export" + "group": "export@1" + }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11", + "group": "export@2" } ], "dashboard/toolbar": [ { "command": "sqlDatabaseProjects.createProjectFromDatabase", "when": "connectionProvider == 'MSSQL' && mssql:engineedition != 11" + }, + { + "command": "sqlDatabaseProjects.updateProjectFromDatabase", + "when": "connectionProvider == 'MSSQL' && mssql:engineedition != 11" } ] } diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 51f6a6ef83..52e45ce2f9 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -9,6 +9,7 @@ "sqlDatabaseProjects.build": "Build", "sqlDatabaseProjects.publish": "Publish", "sqlDatabaseProjects.createProjectFromDatabase": "Create Project From Database", + "sqlDatabaseProjects.updateProjectFromDatabase": "Update Project From Database", "sqlDatabaseProjects.properties": "Properties", "sqlDatabaseProjects.schemaCompare": "Schema Compare", "sqlDatabaseProjects.delete": "Delete", diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index acd733977f..36dcc60bfb 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -62,7 +62,9 @@ export const at = localize('at', "at"); // commands export const revealFileInOsCommand = 'revealFileInOS'; export const schemaCompareStartCommand = 'schemaCompare.start'; +export const schemaCompareRunComparisonCommand = 'schemaCompare.runComparison'; export const vscodeOpenCommand = 'vscode.open'; +export const refreshDataWorkspaceCommand = 'dataworkspace.refresh'; // UI Strings @@ -241,6 +243,7 @@ export const targetProject = localize('targetProject', "Target project"); export const createProjectSettings = localize('createProjectSettings', "Settings"); export const projectNameLabel = localize('projectNameLabel', "Name"); export const projectNamePlaceholderText = localize('projectNamePlaceholderText', "Enter project name"); +export const projectLocationLabel = localize('projectLocationLabel', "Location"); export const projectLocationPlaceholderText = localize('projectLocationPlaceholderText', "Select location to create project"); export const browseButtonText = localize('browseButtonText', "Browse folder"); export const selectFolderStructure = localize('selectFolderStructure', "Select folder structure"); @@ -251,9 +254,28 @@ export const selectProjectLocation = localize('selectProjectLocation', "Select p export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected project location '{0}' does not exist or is not a directory.", location); }; export const ProjectDirectoryAlreadyExistError = (projectName: string, location: string): string => { return localize('dataworkspace.projectDirectoryAlreadyExistError', "There is already a directory named '{0}' in the selected location: '{1}'.", projectName, location); }; +// Update Project From Database dialog strings + +export const updateProjectFromDatabaseDialogName = localize('updateProjectFromDatabaseDialogName', "Update project from database"); +export const updateText = localize('updateText', "Update"); +export const noSqlProjFile = localize('noSqlProjFile', "The selected project file does not exist"); +export const noSchemaCompareExtension = localize('noSchemaCompareExtension', "The Schema Compare extension must be installed to a update a project from a database."); +export const projectToUpdatePlaceholderText = localize('projectToUpdatePlaceholderText', "Select project file"); +export const updateAction = localize('updateAction', "Update action"); +export const compareActionRadioButtonLabel = localize('compareActionRadiButtonLabel', "View changes in Schema Compare"); +export const updateActionRadioButtonLabel = localize('updateActionRadiButtonLabel', "Apply all changes"); +export const actionLabel = localize('actionLabel', "Action"); + +// Update project from database + +export const applySuccess = localize('applySuccess', "Project was successfully updated."); +export const equalComparison = localize('equalComparison', "The project is already up to date with the database."); +export function applyError(errorMessage: string): string { return localize('applyError', "There was an error updating the project: {0}", errorMessage); } +export function updatingProjectFromDatabase(projectName: string, databaseName: string): string { return localize('updatingProjectFromDatabase', "Updating {0} from {1}...", projectName, databaseName); } // Error messages +export function compareErrorMessage(errorMessage: string): string { return localize('schemaCompare.compareErrorMessage', "Schema Compare failed: {0}", errorMessage ? errorMessage : 'Unknown'); } export const multipleSqlProjFiles = localize('multipleSqlProjFilesSelected', "Multiple .sqlproj files selected; please select only one."); export const noSqlProjFiles = localize('noSqlProjFilesSelected', "No .sqlproj file selected; please select one."); export const noDataSourcesFile = localize('noDataSourcesFile', "No {0} found", dataSourcesFileName); diff --git a/extensions/sql-database-projects/src/common/uiConstants.ts b/extensions/sql-database-projects/src/common/uiConstants.ts index 2dbafee936..f87e78942f 100644 --- a/extensions/sql-database-projects/src/common/uiConstants.ts +++ b/extensions/sql-database-projects/src/common/uiConstants.ts @@ -20,6 +20,9 @@ export namespace cssStyles { export const createProjectFromDatabaseLabelWidth = '110px'; export const createProjectFromDatabaseTextboxWidth = '310px'; + export const updateProjectFromDatabaseLabelWidth = '110px'; + export const updateProjectFromDatabaseTextboxWidth = '310px'; + // font-styles export namespace fontStyle { export const normal = 'normal'; diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 84d82199ba..bb2886649f 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -6,6 +6,7 @@ import type * as azdataType from 'azdata'; import * as vscode from 'vscode'; import * as vscodeMssql from 'vscode-mssql'; +import * as mssql from '../../../mssql'; import * as templates from '../templates/templates'; import * as path from 'path'; @@ -64,6 +65,8 @@ export default class MainController implements vscode.Disposable { vscode.commands.registerCommand('sqlDatabaseProjects.build', async (node: WorkspaceTreeItem) => { return this.projectsController.buildProject(node); }); vscode.commands.registerCommand('sqlDatabaseProjects.publish', async (node: WorkspaceTreeItem) => { return this.projectsController.publishProject(node); }); vscode.commands.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: WorkspaceTreeItem) => { return this.projectsController.schemaCompare(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.schemaComparePublishProjectChanges', async (operationId: string, projectFilePath: string, folderStructure: string): Promise => { return await this.projectsController.schemaComparePublishProjectChanges(operationId, projectFilePath, folderStructure); }); + vscode.commands.registerCommand('sqlDatabaseProjects.updateProjectFromDatabase', async (node: azdataType.IConnectionProfile | vscodeMssql.ITreeNodeInfo | WorkspaceTreeItem) => { await this.projectsController.updateProjectFromDatabase(node); }); vscode.commands.registerCommand('sqlDatabaseProjects.createProjectFromDatabase', async (context: azdataType.IConnectionProfile | vscodeMssql.ITreeNodeInfo | undefined) => { return this.projectsController.createProjectFromDatabase(context); }); vscode.commands.registerCommand('sqlDatabaseProjects.generateProjectFromOpenApiSpec', async (options?: GenerateProjectFromOpenApiSpecOptions) => { return this.projectsController.generateProjectFromOpenApiSpec(options); }); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 7c043cfe04..367b9d6286 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -32,6 +32,7 @@ import { AddDatabaseReferenceDialog } from '../dialogs/addDatabaseReferenceDialo import { ISystemDatabaseReferenceSettings, IDacpacReferenceSettings, IProjectReferenceSettings } from '../models/IDatabaseReferenceSettings'; import { DatabaseReferenceTreeItem } from '../models/tree/databaseReferencesTreeItem'; import { CreateProjectFromDatabaseDialog } from '../dialogs/createProjectFromDatabaseDialog'; +import { UpdateProjectFromDatabaseDialog } from '../dialogs/updateProjectFromDatabaseDialog'; import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/telemetry'; import { IconPathHelper } from '../common/iconHelper'; import { DashboardData, PublishData, Status } from '../models/dashboardData/dashboardData'; @@ -43,7 +44,8 @@ import { AutorestHelper } from '../tools/autorestHelper'; import { createNewProjectFromDatabaseWithQuickpick } from '../dialogs/createProjectFromDatabaseQuickpick'; import { addDatabaseReferenceQuickpick } from '../dialogs/addDatabaseReferenceQuickpick'; import { IDeployProfile } from '../models/deploy/deployProfile'; -import { FileProjectEntry, IDatabaseReferenceProjectEntry, SqlProjectReferenceProjectEntry } from '../models/projectEntry'; +import { EntryType, FileProjectEntry, IDatabaseReferenceProjectEntry, SqlProjectReferenceProjectEntry } from '../models/projectEntry'; +import { UpdateProjectAction, UpdateProjectDataModel } from '../models/api/updateProject'; const maxTableLength = 10; @@ -453,23 +455,28 @@ export class ProjectsController { return result; } - public async schemaCompare(treeNode: dataworkspace.WorkspaceTreeItem): Promise { + public async schemaCompare(source: dataworkspace.WorkspaceTreeItem | azdataType.IConnectionProfile, targetParam: any = undefined): Promise { try { // check if schema compare extension is installed if (vscode.extensions.getExtension(constants.schemaCompareExtensionId)) { - // build project - const dacpacPath = await this.buildProject(treeNode); + let sourceParam; - // check that dacpac exists - if (await utils.exists(dacpacPath)) { - TelemetryReporter.sendActionEvent(TelemetryViews.ProjectController, TelemetryActions.projectSchemaCompareCommandInvoked); - await vscode.commands.executeCommand(constants.schemaCompareStartCommand, dacpacPath); + if (source as dataworkspace.WorkspaceTreeItem) { + sourceParam = this.getProjectFromContext(source as dataworkspace.WorkspaceTreeItem).projectFilePath; } else { + sourceParam = source as azdataType.IConnectionProfile; + } + + try { + TelemetryReporter.sendActionEvent(TelemetryViews.ProjectController, TelemetryActions.projectSchemaCompareCommandInvoked); + await vscode.commands.executeCommand(constants.schemaCompareStartCommand, sourceParam, targetParam, undefined); + } catch (e) { throw new Error(constants.buildFailedCannotStartSchemaCompare); } } else { throw new Error(constants.schemaCompareNotInstalled); } + } catch (err) { const props: Record = {}; const message = utils.getErrorMessage(err); @@ -486,6 +493,67 @@ export class ProjectsController { } } + public async getProjectScriptFiles(projectFilePath: string): Promise { + const project = await Project.openProject(projectFilePath); + + return project.files + .filter(f => f.fsUri.fsPath.endsWith(constants.sqlFileExtension)) + .map(f => f.fsUri.fsPath); + } + + public async getProjectDatabaseSchemaProvider(projectFilePath: string): Promise { + const project = await Project.openProject(projectFilePath); + return project.getProjectTargetVersion(); + } + + public async schemaComparePublishProjectChanges(operationId: string, projectFilePath: string, folderStructure: string): Promise { + const ext = vscode.extensions.getExtension(mssql.extension.name)!; + const service = (await ext.activate() as mssql.IExtension).schemaCompare; + + const projectPath = path.dirname(projectFilePath); + + let fs: mssql.ExtractTarget; + + switch (folderStructure) { + case constants.file: + fs = mssql.ExtractTarget.file; + break; + case constants.flat: + fs = mssql.ExtractTarget.flat; + break; + case constants.objectType: + fs = mssql.ExtractTarget.objectType; + break; + case constants.schema: + fs = mssql.ExtractTarget.schema; + break; + case constants.schemaObjectType: + default: + fs = mssql.ExtractTarget.schemaObjectType; + break; + } + + const result: mssql.SchemaComparePublishProjectResult = await service.schemaComparePublishProjectChanges(operationId, projectPath, fs, utils.getAzdataApi()!.TaskExecutionMode.execute); + + const project = await Project.openProject(projectFilePath); + + let toAdd: vscode.Uri[] = []; + result.addedFiles.forEach((f: any) => toAdd.push(vscode.Uri.file(f))); + await project.addToProject(toAdd); + + let toRemove: vscode.Uri[] = []; + result.deletedFiles.forEach((f: any) => toRemove.push(vscode.Uri.file(f))); + + let toRemoveEntries: FileProjectEntry[] = []; + toRemove.forEach(f => toRemoveEntries.push(new FileProjectEntry(f, f.path.replace(projectPath + '\\', ''), EntryType.File))); + + toRemoveEntries.forEach(async f => await project.exclude(f)); + + await this.buildProject(project); + + return result; + } + public async addFolderPrompt(treeNode: dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(treeNode); const relativePathToParent = this.getRelativePath(treeNode.element); @@ -778,9 +846,6 @@ export class ProjectsController { } return undefined; } - - - } /** @@ -1246,6 +1311,118 @@ export class ProjectsController { // TODO: Check for success; throw error } + /** + * Display dialog for user to configure existing SQL Project with the changes/differences from a database + */ + public async updateProjectFromDatabase(context: azdataType.IConnectionProfile | mssqlVscode.ITreeNodeInfo | dataworkspace.WorkspaceTreeItem): Promise { + let connection: azdataType.IConnectionProfile | mssqlVscode.IConnectionInfo | undefined; + let project: Project | undefined; + + try { + if ('connectionProfile' in context) { + connection = this.getConnectionProfileFromContext(context as azdataType.IConnectionProfile | mssqlVscode.ITreeNodeInfo); + } + } catch { } + + try { + if ('treeDataProvider' in context) { + project = this.getProjectFromContext(context as dataworkspace.WorkspaceTreeItem); + } + } catch { } + + const updateProjectFromDatabaseDialog = this.getUpdateProjectFromDatabaseDialog(connection, project); + + updateProjectFromDatabaseDialog.updateProjectFromDatabaseCallback = async (model) => await this.updateProjectFromDatabaseCallback(model); + + await updateProjectFromDatabaseDialog.openDialog(); + + return updateProjectFromDatabaseDialog; + } + + public getUpdateProjectFromDatabaseDialog(connection: azdataType.IConnectionProfile | mssqlVscode.IConnectionInfo | undefined, project: Project | undefined): UpdateProjectFromDatabaseDialog { + return new UpdateProjectFromDatabaseDialog(connection, project); + } + + public async updateProjectFromDatabaseCallback(model: UpdateProjectDataModel) { + try { + await this.updateProjectFromDatabaseApiCall(model); + } catch (err) { + void vscode.window.showErrorMessage(utils.getErrorMessage(err)); + } + } + + /** + * Uses the DacFx service to update an existing SQL Project with the changes/differences from a database + */ + public async updateProjectFromDatabaseApiCall(model: UpdateProjectDataModel): Promise { + if (model.action === UpdateProjectAction.Compare) { + await vscode.commands.executeCommand(constants.schemaCompareRunComparisonCommand, model.sourceEndpointInfo, model.targetEndpointInfo, true, undefined); + } else if (model.action === UpdateProjectAction.Update) { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: constants.updatingProjectFromDatabase(path.basename(model.targetEndpointInfo.projectFilePath), model.sourceEndpointInfo.databaseName), + cancellable: false + }, async (_progress, _token) => { + return this.schemaCompareAndUpdateProject(model.sourceEndpointInfo, model.targetEndpointInfo); + }); + + void vscode.commands.executeCommand(constants.refreshDataWorkspaceCommand); + utils.getDataWorkspaceExtensionApi().showProjectsView(); + } else { + throw new Error(`Unknown UpdateProjectAction: ${model.action}`); + } + + return; + } + + private async schemaCompareAndUpdateProject(source: mssql.SchemaCompareEndpointInfo, target: mssql.SchemaCompareEndpointInfo): Promise { + // Run schema comparison + const ext = vscode.extensions.getExtension(mssql.extension.name)!; + const service = (await ext.activate() as mssql.IExtension).schemaCompare; + const deploymentOptions = await service.schemaCompareGetDefaultOptions(); + const operationId = UUID.generateUuid(); + + target.targetScripts = await this.getProjectScriptFiles(target.projectFilePath); + target.dataSchemaProvider = await this.getProjectDatabaseSchemaProvider(target.projectFilePath); + + TelemetryReporter.sendActionEvent(TelemetryViews.ProjectController, 'SchemaComparisonStarted'); + + // Perform schema comparison. Results are cached in SqlToolsService under the operationId + const comparisonResult: mssql.SchemaCompareResult = await service.schemaCompare( + operationId, source, target, utils.getAzdataApi()!.TaskExecutionMode.execute, deploymentOptions.defaultDeploymentOptions + ); + + if (!comparisonResult || !comparisonResult.success) { + TelemetryReporter.createErrorEvent(TelemetryViews.ProjectController, 'SchemaComparisonFailed') + .withAdditionalProperties({ + operationId: comparisonResult.operationId + }).send(); + await vscode.window.showErrorMessage(constants.compareErrorMessage(comparisonResult?.errorMessage)); + return; + } + + TelemetryReporter.createActionEvent(TelemetryViews.ProjectController, 'SchemaComparisonFinished') + .withAdditionalProperties({ + 'endTime': Date.now().toString(), + 'operationId': comparisonResult.operationId + }).send(); + + if (comparisonResult.areEqual) { + void vscode.window.showInformationMessage(constants.equalComparison); + return; + } + + // Publish the changes (retrieved from the cache by operationId) + const publishResult = await this.schemaComparePublishProjectChanges(operationId, target.projectFilePath, target.folderStructure); + + if (publishResult.success) { + void vscode.window.showInformationMessage(constants.applySuccess); + } else { + void vscode.window.showErrorMessage(constants.applyError(publishResult.errorMessage)); + } + } + /** * Generate a flat list of all files and folder under a folder. */ diff --git a/extensions/sql-database-projects/src/dialogs/updateProjectFromDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/updateProjectFromDatabaseDialog.ts new file mode 100644 index 0000000000..bc5cc2edb5 --- /dev/null +++ b/extensions/sql-database-projects/src/dialogs/updateProjectFromDatabaseDialog.ts @@ -0,0 +1,610 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as mssql from '../../../mssql'; +import * as azdata from 'azdata'; +import * as constants from '../common/constants'; +import * as newProjectTool from '../tools/newProjectTool'; +import type * as mssqlVscode from 'vscode-mssql'; + +import { Deferred } from '../common/promise'; +import { Project } from '../models/project'; +import { cssStyles } from '../common/uiConstants'; +import { IconPathHelper } from '../common/iconHelper'; +import { UpdateProjectDataModel, UpdateProjectAction } from '../models/api/updateProject'; +import { exists, getAzdataApi, getDataWorkspaceExtensionApi } from '../common/utils'; +import * as path from 'path'; + +export class UpdateProjectFromDatabaseDialog { + public dialog: azdata.window.Dialog; + public serverDropdown: azdata.DropDownComponent | undefined; + public databaseDropdown: azdata.DropDownComponent | undefined; + public projectFileTextBox: azdata.InputBoxComponent | undefined; + public compareActionRadioButton: azdata.RadioButtonComponent | undefined; + private updateProjectFromDatabaseTab: azdata.window.DialogTab; + private connectionButton: azdata.ButtonComponent | undefined; + private folderStructureDropDown: azdata.DropDownComponent | undefined; + private updateActionRadioButton: azdata.RadioButtonComponent | undefined; + private formBuilder: azdata.FormBuilder | undefined; + private connectionId: string | undefined; + private profile: azdata.IConnectionProfile | undefined; + public action: UpdateProjectAction | undefined; + private toDispose: vscode.Disposable[] = []; + private initDialogPromise: Deferred = new Deferred(); + public populatedInputsPromise: Deferred = new Deferred(); + + public updateProjectFromDatabaseCallback: ((model: UpdateProjectDataModel) => any) | undefined; + + constructor(connection: azdata.IConnectionProfile | mssqlVscode.IConnectionInfo | undefined, private project: Project | undefined) { + if (connection && 'connectionName' in connection) { + this.profile = connection; + } + + // need to set profile when database is updated as well as here + // see what schemaCompare is doing! + + this.dialog = getAzdataApi()!.window.createModelViewDialog(constants.updateProjectFromDatabaseDialogName, 'updateProjectFromDatabaseDialog'); + this.updateProjectFromDatabaseTab = getAzdataApi()!.window.createTab(constants.updateProjectFromDatabaseDialogName); + this.dialog.registerCloseValidator(async () => { + return this.validate(); + }); + + this.toDispose.push(this.dialog.onClosed(_ => this.initDialogPromise.resolve())); + } + + public async openDialog(): Promise { + let connection = await azdata.connection.getCurrentConnection(); + if (connection) { + this.connectionId = connection.connectionId; + } + + this.initializeDialog(); + + this.dialog.okButton.label = constants.updateText; + this.dialog.okButton.enabled = false; + this.toDispose.push(this.dialog.okButton.onClick(async () => await this.handleUpdateButtonClick())); + + this.dialog.cancelButton.label = constants.cancelButtonText; + + getAzdataApi()!.window.openDialog(this.dialog); + await this.initDialogPromise; + + this.tryEnableUpdateButton(); + } + + private dispose(): void { + this.toDispose.forEach(disposable => disposable.dispose()); + } + + private initializeDialog(): void { + this.initializeUpdateProjectFromDatabaseTab(); + this.dialog.content = [this.updateProjectFromDatabaseTab]; + } + + private initializeUpdateProjectFromDatabaseTab(): void { + this.updateProjectFromDatabaseTab.registerContent(async view => { + + const connectionRow = this.createServerRow(view); + const databaseRow = this.createDatabaseRow(view); + await this.populateServerDropdown(); + + const sourceDatabaseFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + sourceDatabaseFormSection.addItems([connectionRow, databaseRow]); + + const projectLocationRow = this.createProjectLocationRow(view); + const folderStructureRow = this.createFolderStructureRow(view); + const targetProjectFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + targetProjectFormSection.addItems([projectLocationRow, folderStructureRow]); + + const actionRow = await this.createActionRow(view); + const actionFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + actionFormSection.addItems([actionRow]); + + this.formBuilder = view.modelBuilder.formContainer() + .withFormItems([ + { + title: constants.sourceDatabase, + components: [ + { + component: sourceDatabaseFormSection, + } + ] + }, + { + title: constants.targetProject, + components: [ + { + component: targetProjectFormSection, + } + ] + }, + { + title: constants.updateAction, + components: [ + { + component: actionFormSection, + } + ] + } + ], { + horizontal: false, + titleFontSize: cssStyles.titleFontSize + }) + .withLayout({ + width: '100%', + padding: '10px 10px 0 20px' + }); + + let formModel = this.formBuilder.component(); + await view.initializeModel(formModel); + await this.connectionButton?.focus(); + this.initDialogPromise.resolve(); + }); + } + + private createServerRow(view: azdata.ModelView): azdata.FlexContainer { + this.createServerComponent(view); + + const serverLabel = view.modelBuilder.text().withProps({ + value: constants.server, + requiredIndicator: true, + width: cssStyles.updateProjectFromDatabaseLabelWidth + }).component(); + + const connectionRow = view.modelBuilder.flexContainer().withItems([serverLabel, this.serverDropdown!], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-5px', 'margin-top': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); + connectionRow.addItem(this.connectionButton!, { CSSStyles: { 'margin-right': '0px', 'margin-bottom': '-5px', 'margin-top': '-10px' } }); + + return connectionRow; + } + + private createDatabaseRow(view: azdata.ModelView): azdata.FlexContainer { + this.createDatabaseComponent(view); + + const databaseLabel = view.modelBuilder.text().withProps({ + value: constants.databaseNameLabel, + requiredIndicator: true, + width: cssStyles.updateProjectFromDatabaseLabelWidth + }).component(); + + const databaseRow = view.modelBuilder.flexContainer().withItems([databaseLabel, this.databaseDropdown!], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); + + return databaseRow; + } + + private createServerComponent(view: azdata.ModelView) { + this.serverDropdown = view.modelBuilder.dropDown().withProps({ + editable: true, + fireOnTextChange: true, + width: cssStyles.updateProjectFromDatabaseTextboxWidth + }).component(); + + this.createConnectionButton(view); + + this.serverDropdown.onValueChanged(() => { + this.tryEnableUpdateButton(); + }); + } + + private createDatabaseComponent(view: azdata.ModelView) { + this.databaseDropdown = view.modelBuilder.dropDown().withProps({ + editable: true, + fireOnTextChange: true, + width: cssStyles.updateProjectFromDatabaseTextboxWidth + }).component(); + + this.databaseDropdown.onValueChanged(() => { + this.tryEnableUpdateButton(); + }); + } + + private async populateServerDropdown() { + this.serverDropdown!.loading = true; + const values = await this.getServerValues(); + + if (values && values.length > 0) { + await this.serverDropdown!.updateProperties({ + values: values, + value: values[0] + }); + } + + this.serverDropdown!.loading = false; + + if (this.serverDropdown!.value) { + await this.populateDatabaseDropdown(); + } + + this.tryEnableUpdateButton(); + this.populatedInputsPromise.resolve(); + } + + protected async populateDatabaseDropdown() { + const connectionProfile = (this.serverDropdown!.value as ConnectionDropdownValue).connection; + + this.databaseDropdown!.loading = true; + + await this.databaseDropdown!.updateProperties({ + values: [], + value: undefined + }); + + let values = []; + try { + values = await this.getDatabaseValues(connectionProfile.connectionId); + } catch (e) { + // if the user doesn't have access to master, just set the database of the connection profile + values = [connectionProfile.databaseName]; + console.warn(e); + } + + if (values && values.length > 0) { + await this.databaseDropdown!.updateProperties({ + values: values, + value: values[0], + }); + } + + this.databaseDropdown!.loading = false; + } + + private async getServerValues() { + let cons = await azdata.connection.getConnections(/* activeConnectionsOnly */ true); + + // This user has no active connections + if (!cons || cons.length === 0) { + return undefined; + } + + // Update connection icon to "connected" state + this.connectionButton!.iconPath = IconPathHelper.connect; + + // reverse list so that most recent connections are first + cons.reverse(); + + let count = -1; + let idx = -1; + let values = cons.map(c => { + count++; + + let usr = c.options.user; + + if (!usr) { + usr = constants.defaultUser; + } + + let srv = c.options.server; + + let finalName = `${srv} (${usr})`; + + if (c.options.connectionName) { + finalName = c.options.connectionName; + } + + if (c.connectionId === this.connectionId) { + idx = count; + } + + return { + connection: c, + displayName: finalName, + name: srv, + }; + }); + + // move server of current connection to the top of the list so it is the default + if (idx >= 1) { + let tmp = values[0]; + values[0] = values[idx]; + values[idx] = tmp; + } + + values = values.reduce((uniqueValues: { connection: azdata.connection.ConnectionProfile, displayName: string, name: string }[], conn) => { + let exists = uniqueValues.find(x => x.displayName === conn.displayName); + if (!exists) { + uniqueValues.push(conn); + } + return uniqueValues; + }, []); + + return values; + } + + protected async getDatabaseValues(connectionId: string) { + let idx = -1; + let count = -1; + + let values = (await azdata.connection.listDatabases(connectionId)).sort((a, b) => a.localeCompare(b)).map(db => { + count++; + + // put currently selected db at the top of the dropdown if there is one + if (this.profile && this.profile.databaseName && this.profile.databaseName === db) { + idx = count; + } + + return db; + }); + + if (idx >= 0) { + let tmp = values[0]; + values[0] = values[idx]; + values[idx] = tmp; + } + return values; + } + + private createConnectionButton(view: azdata.ModelView) { + this.connectionButton = view.modelBuilder.button().withProps({ + ariaLabel: constants.selectConnection, + iconPath: IconPathHelper.selectConnection, + height: '20px', + width: '20px' + }).component(); + + this.connectionButton.onDidClick(async () => { + await this.connectionButtonClick(); + this.connectionButton!.iconPath = IconPathHelper.connect; + }); + } + + private async connectionButtonClick() { + let connection = await azdata.connection.openConnectionDialog(); + if (connection) { + this.connectionId = connection.connectionId; + await this.populateServerDropdown(); + } + } + + private createProjectLocationRow(view: azdata.ModelView): azdata.FlexContainer { + const browseFolderButton: azdata.Component = this.createBrowseFileButton(view); + + const value = this.project ? this.project.projectFilePath : ''; + + this.projectFileTextBox = view.modelBuilder.inputBox().withProps({ + value: value, + ariaLabel: constants.projectLocationLabel, + placeHolder: constants.projectToUpdatePlaceholderText, + width: cssStyles.updateProjectFromDatabaseTextboxWidth + }).component(); + + this.projectFileTextBox.onTextChanged(async () => { + await this.projectFileTextBox!.updateProperty('title', this.projectFileTextBox!.value); + this.tryEnableUpdateButton(); + }); + + const projectLocationLabel = view.modelBuilder.text().withProps({ + value: constants.projectLocationLabel, + requiredIndicator: true, + width: cssStyles.updateProjectFromDatabaseLabelWidth + }).component(); + + const projectLocationRow = view.modelBuilder.flexContainer().withItems([projectLocationLabel, this.projectFileTextBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-5px', 'margin-top': '-10px' } }).component(); + projectLocationRow.addItem(browseFolderButton, { CSSStyles: { 'margin-right': '0px', 'margin-bottom': '-5px', 'margin-top': '-10px' } }); + + return projectLocationRow; + } + + private createBrowseFileButton(view: azdata.ModelView): azdata.ButtonComponent { + const browseFolderButton = view.modelBuilder.button().withProps({ + ariaLabel: constants.browseButtonText, + iconPath: IconPathHelper.folder_blue, + height: '18px', + width: '18px' + }).component(); + + browseFolderButton.onDidClick(async () => { + let fileUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: constants.selectString, + defaultUri: newProjectTool.defaultProjectSaveLocation(), + filters: { + 'Files': ['sqlproj'] + } + }); + + if (!fileUris || fileUris.length === 0) { + return; + } + + this.projectFileTextBox!.value = fileUris[0].fsPath; + await this.projectFileTextBox!.updateProperty('title', fileUris[0].fsPath); + }); + + return browseFolderButton; + } + + private createFolderStructureRow(view: azdata.ModelView): azdata.FlexContainer { + this.folderStructureDropDown = view.modelBuilder.dropDown().withProps({ + values: [constants.file, constants.flat, constants.objectType, constants.schema, constants.schemaObjectType], + value: constants.schemaObjectType, + ariaLabel: constants.folderStructureLabel, + required: true, + width: cssStyles.updateProjectFromDatabaseTextboxWidth + }).component(); + + this.folderStructureDropDown.onValueChanged(() => { + this.tryEnableUpdateButton(); + }); + + const folderStructureLabel = view.modelBuilder.text().withProps({ + value: constants.folderStructureLabel, + requiredIndicator: true, + width: cssStyles.createProjectFromDatabaseLabelWidth + }).component(); + + const folderStructureRow = view.modelBuilder.flexContainer().withItems([folderStructureLabel, this.folderStructureDropDown], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); + + return folderStructureRow; + } + + private async createActionRow(view: azdata.ModelView): Promise { + this.compareActionRadioButton = view.modelBuilder.radioButton().withProps({ + name: 'action', + label: constants.compareActionRadioButtonLabel, + checked: true + }).component(); + + this.updateActionRadioButton = view.modelBuilder.radioButton().withProps({ + name: 'action', + label: constants.updateActionRadioButtonLabel + }).component(); + + await this.compareActionRadioButton.updateProperties({ checked: true }); + this.action = UpdateProjectAction.Compare; + + this.compareActionRadioButton.onDidClick(async () => { + this.action = UpdateProjectAction.Compare; + this.tryEnableUpdateButton(); + }); + + this.updateActionRadioButton.onDidClick(async () => { + this.action = UpdateProjectAction.Update; + this.tryEnableUpdateButton(); + }); + + let radioButtons = view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withItems([this.compareActionRadioButton, this.updateActionRadioButton]) + .withProps({ ariaRole: 'radiogroup' }) + .component(); + + const actionLabel = view.modelBuilder.text().withProps({ + value: constants.actionLabel, + requiredIndicator: true, + width: cssStyles.updateProjectFromDatabaseLabelWidth + }).component(); + + const actionRow = view.modelBuilder.flexContainer().withItems([actionLabel, radioButtons], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); + + return actionRow; + } + + // only enable Update button if all fields are filled + public tryEnableUpdateButton(): void { + if (this.serverDropdown?.value + && this.databaseDropdown?.value + && this.projectFileTextBox?.value + && this.folderStructureDropDown?.value + && this.action !== undefined) { + this.dialog.okButton.enabled = true; + } else { + this.dialog.okButton.enabled = false; + } + } + + public async handleUpdateButtonClick(): Promise { + const serverDropdownValue = this.serverDropdown!.value! as azdata.CategoryValue as ConnectionDropdownValue; + const ownerUri = await azdata.connection.getUriForConnection(serverDropdownValue.connection.connectionId); + + let connection = (await azdata.connection.getConnections(true)).filter(con => con.connectionId === serverDropdownValue.connection.connectionId)[0]; + connection.databaseName = this.databaseDropdown!.value! as string; + + const credentials = await azdata.connection.getCredentials(connection.connectionId); + if (credentials.hasOwnProperty('password')) { + connection.password = connection.options.password = credentials.password; + } + + const connectionDetails: azdata.IConnectionProfile = { + id: connection.connectionId, + userName: connection.userName, + password: connection.password, + serverName: connection.serverName, + databaseName: connection.databaseName, + connectionName: connection.connectionName, + providerName: connection.providerId, + groupId: connection.groupId, + groupFullName: connection.groupFullName, + authenticationType: connection.authenticationType, + savePassword: connection.savePassword, + saveProfile: connection.saveProfile, + options: connection.options, + }; + + const sourceEndpointInfo: mssql.SchemaCompareEndpointInfo = { + endpointType: mssql.SchemaCompareEndpointType.Database, + databaseName: this.databaseDropdown!.value! as string, + serverDisplayName: serverDropdownValue.displayName, + serverName: serverDropdownValue.name!, + connectionDetails: connectionDetails, + ownerUri: ownerUri, + projectFilePath: '', + folderStructure: '', + targetScripts: [], + dataSchemaProvider: '', + packageFilePath: '', + connectionName: serverDropdownValue.connection.options.connectionName + }; + + const targetEndpointInfo: mssql.SchemaCompareEndpointInfo = { + endpointType: mssql.SchemaCompareEndpointType.Project, + projectFilePath: this.projectFileTextBox!.value!, + folderStructure: this.folderStructureDropDown!.value as string, + targetScripts: [], + dataSchemaProvider: '', + connectionDetails: connectionDetails, + databaseName: '', + serverDisplayName: '', + serverName: '', + ownerUri: '', + packageFilePath: '', + }; + + const model: UpdateProjectDataModel = { + sourceEndpointInfo: sourceEndpointInfo, + targetEndpointInfo: targetEndpointInfo, + action: this.action! + }; + + getAzdataApi()!.window.closeDialog(this.dialog); + await this.updateProjectFromDatabaseCallback!(model); + + this.dispose(); + } + + async validate(): Promise { + try { + if (await getDataWorkspaceExtensionApi().validateWorkspace() === false) { + return false; + } + // the selected location should be an existing directory + const parentDirectoryExists = await exists(path.dirname(this.projectFileTextBox!.value!)); + if (!parentDirectoryExists) { + this.showErrorMessage(constants.ProjectParentDirectoryNotExistError(this.projectFileTextBox!.value!)); + return false; + } + + // the selected location must contain a .sqlproj file + const fileExists = await exists(this.projectFileTextBox!.value!); + if (!fileExists) { + this.showErrorMessage(constants.noSqlProjFile); + return false; + } + + // schema compare extension must be downloaded + if (!vscode.extensions.getExtension(constants.schemaCompareExtensionId)) { + this.showErrorMessage(constants.noSchemaCompareExtension); + return false; + } + + return true; + } catch (err) { + this.showErrorMessage(err?.message ? err.message : err); + return false; + } + } + + protected showErrorMessage(message: string): void { + this.dialog.message = { + text: message, + level: getAzdataApi()!.window.MessageLevel.Error + }; + } +} + +export interface ConnectionDropdownValue extends azdata.CategoryValue { + connection: azdata.connection.ConnectionProfile; +} diff --git a/extensions/sql-database-projects/src/models/api/updateProject.ts b/extensions/sql-database-projects/src/models/api/updateProject.ts new file mode 100644 index 0000000000..5ea3878359 --- /dev/null +++ b/extensions/sql-database-projects/src/models/api/updateProject.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as mssql from '../../../../mssql/src/mssql'; + +export interface UpdateProjectDataModel { + sourceEndpointInfo: mssql.SchemaCompareEndpointInfo; + targetEndpointInfo: mssql.SchemaCompareEndpointInfo; + action: UpdateProjectAction; +} + +export const enum UpdateProjectAction { + Compare = 0, + Update = 1 +} diff --git a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts index 3734f0c8bd..88d16a3a7e 100644 --- a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts +++ b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts @@ -168,4 +168,19 @@ export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvide const projectUri = getDataWorkspaceExtensionApi().openSpecificProjectNewProjectDialog(projectType); return projectUri; } + + /** + * Gets the list of .sql scripts contained in a project + * @param projectFilePath + */ + async getProjectScriptFiles(projectFilePath: string): Promise { + return await this.projectController.getProjectScriptFiles(projectFilePath); + } + + /** + * Gets the Database Schema Provider version for a SQL project + */ + async getProjectDatabaseSchemaProvider(projectFilePath: string): Promise { + return await this.projectController.getProjectDatabaseSchemaProvider(projectFilePath); + } } diff --git a/extensions/sql-database-projects/src/sqldbproj.d.ts b/extensions/sql-database-projects/src/sqldbproj.d.ts index a2502639b5..cd4a69b8cd 100644 --- a/extensions/sql-database-projects/src/sqldbproj.d.ts +++ b/extensions/sql-database-projects/src/sqldbproj.d.ts @@ -35,6 +35,17 @@ declare module 'sqldbproj' { * @returns uri of the created the project or undefined if no project was created */ openSqlNewProjectDialog(allowedTargetPlatforms?: SqlTargetPlatform[]): Promise; + + /** + * Gets the list of .sql scripts contained in a project + * @param projectFilePath + */ + getProjectScriptFiles(projectFilePath: string): Promise; + + /** + * Gets the Database Schema Provider version for a SQL project + */ + getProjectDatabaseSchemaProvider(projectFilePath: string): Promise; } /** diff --git a/extensions/sql-database-projects/src/test/dialogs/updateProjectFromDatabaseDialog.test.ts b/extensions/sql-database-projects/src/test/dialogs/updateProjectFromDatabaseDialog.test.ts new file mode 100644 index 0000000000..9ad12f4b1a --- /dev/null +++ b/extensions/sql-database-projects/src/test/dialogs/updateProjectFromDatabaseDialog.test.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as should from 'should'; +import * as sinon from 'sinon'; +import * as baselines from '../baselines/baselines'; +import * as testUtils from '../testUtils'; + +import { UpdateProjectFromDatabaseDialog } from '../../dialogs/updateProjectFromDatabaseDialog'; +import { mockConnectionProfile } from '../testContext'; + +describe('Update Project From Database Dialog', () => { + before(async function (): Promise { + await baselines.loadBaselines(); + }); + + afterEach(function (): void { + sinon.restore(); + }); + + it('Should populate endpoints correctly when no context passed', async function (): Promise { + const dialog = new UpdateProjectFromDatabaseDialog(undefined, undefined); + await dialog.openDialog(); + + should.equal(dialog.serverDropdown!.value, undefined, `Server dropdown should not be populated, but instead was "${dialog.serverDropdown!.value}".`); + should.equal(dialog.databaseDropdown!.value, undefined, `Database dropdown should not be populated, but instead was "${dialog.databaseDropdown!.value}".`); + should.equal(dialog.projectFileTextBox!.value, '', `Project file textbox should not be populated, but instead was "${dialog.projectFileTextBox!.value}".`); + should.equal(dialog.dialog.okButton.enabled, false, 'Okay button should be disabled.'); + }); + + it('Should populate endpoints correctly when Project context is passed', async function (): Promise { + const project = await testUtils.createTestProject(baselines.openProjectFileBaseline); + const dialog = new UpdateProjectFromDatabaseDialog(undefined, project); + await dialog.openDialog(); + + should.equal(dialog.serverDropdown!.value, undefined, `Server dropdown should not be populated, but instead was "${dialog.serverDropdown!.value}".`); + should.equal(dialog.databaseDropdown!.value, undefined, `Database dropdown should not be populated, but instead was "${dialog.databaseDropdown!.value}".`); + should.equal(dialog.projectFileTextBox!.value, project.projectFilePath, `Project file textbox should be the sqlproj path (${project.projectFilePath}), but instead was "${dialog.projectFileTextBox!.value}".`); + should.equal(dialog.dialog.okButton.enabled, false, 'Okay button should be disabled.'); + }); + + it('Should populate endpoints correctly when Connection context is passed', async function (): Promise { + sinon.stub(azdata.connection, 'getConnections').resolves([mockConnectionProfile]); + sinon.stub(azdata.connection, 'listDatabases').resolves([mockConnectionProfile.databaseName!]); + + const profile = mockConnectionProfile; + const dialog = new UpdateProjectFromDatabaseDialog(profile, undefined); + await dialog.openDialog(); + await dialog.populatedInputsPromise; + + should.equal((dialog.serverDropdown!.value).displayName, profile.options['connectionName'], `Server dropdown should be "${profile.options['connectionName']}", but instead was "${(dialog.serverDropdown!.value).displayName}".`); + should.equal(dialog.databaseDropdown!.value, profile.databaseName, `Database dropdown should be "${profile.databaseName}", but instead was "${dialog.databaseDropdown!.value}".`); + should.equal(dialog.projectFileTextBox!.value, '', `Project file textbox should not be populated, but instead was "${dialog.projectFileTextBox!.value}".`); + should.equal(dialog.dialog.okButton.enabled, false, 'Okay button should be disabled.'); + }); + + it('Should populate endpoints correctly when context is complete', async function (): Promise { + const project = await testUtils.createTestProject(baselines.openProjectFileBaseline); + sinon.stub(azdata.connection, 'getConnections').resolves([mockConnectionProfile]); + sinon.stub(azdata.connection, 'listDatabases').resolves([mockConnectionProfile.databaseName!]); + + const profile = mockConnectionProfile; + const dialog = new UpdateProjectFromDatabaseDialog(profile, project); + await dialog.openDialog(); + await dialog.populatedInputsPromise; + + should.equal((dialog.serverDropdown!.value).displayName, profile.options['connectionName'], `Server dropdown should be "${profile.options['connectionName']}", but instead was "${(dialog.serverDropdown!.value).displayName}".`); + should.equal(dialog.databaseDropdown!.value, profile.databaseName, `Database dropdown should as "${profile.databaseName}", but instead was "${dialog.databaseDropdown!.value}".`); + should.equal(dialog.projectFileTextBox!.value, project.projectFilePath, `Project file textbox should be the sqlproj path (${project.projectFilePath}), but instead was "${dialog.projectFileTextBox!.value}".`); + should.equal(dialog.dialog.okButton.enabled, true, 'Okay button should be enabled when dialog is complete.'); + }); +}); diff --git a/extensions/sql-database-projects/src/test/testContext.ts b/extensions/sql-database-projects/src/test/testContext.ts index 379f6b0bcb..4ed6449fa1 100644 --- a/extensions/sql-database-projects/src/test/testContext.ts +++ b/extensions/sql-database-projects/src/test/testContext.ts @@ -201,6 +201,7 @@ export const mockConnectionProfile: azdata.IConnectionProfile = { database: 'My Database', user: 'My User', password: 'My Pwd', - authenticationType: 'SqlLogin' + authenticationType: 'SqlLogin', + connectionName: 'My Connection Name' } };